SPB Git

spb/airiskindex Public

The most methodologically rigorous, fully transparent AI job-exposure index.

TypeScript 88% Python 6.1% SQL 2.7% CSS 1.2% JavaScript 0.9% Shell 0.8%
5.0 KB · 125 lines tsx
Raw Blame History
1//  File:    insights-charts.tsx2//  Path:    apps/web/components/insights-charts.tsx3//  Project: AI Risk Index — airiskindex.io4//  Author:  Simon-Pierre Boucher5//  Contact: contact@spboucher.ai6//  Copyright © 2026 Simon-Pierre Boucher. All rights reserved.7//8//  Description: Server-rendered SVG charts for the insights page: scatter (substitution × augmentation), wage-decile columns.910export interface ScatterPoint {11  x: number; // substitution 0–10012  y: number; // augmentation 0–10013  size?: number | null; // employment14  label: string;15}1617/**18 * Single-series scatter on two 0–100 axes with quadrant annotations.19 * One hue (identity is not encoded), dot area ~ employment, native <title>20 * tooltips, hairline gridlines, text in ink tokens.21 */22export function ScatterPlot({ points }: { points: ScatterPoint[] }): JSX.Element {23  const W = 640;24  const H = 440;25  const m = { t: 20, r: 18, b: 46, l: 46 };26  const sx = (v: number): number => m.l + (v / 100) * (W - m.l - m.r);27  const sy = (v: number): number => H - m.b - (v / 100) * (H - m.t - m.b);28  const maxSize = Math.max(1, ...points.map((p) => p.size ?? 0));29  const radius = (size?: number | null): number => 2.2 + 7 * Math.sqrt((size ?? 0) / maxSize);3031  return (32    <svg viewBox={`0 0 ${W} ${H}`} className="h-auto w-full" role="img" aria-label="Scatter plot of substitution versus augmentation for every scored occupation; dot size reflects employment.">33      {[0, 25, 50, 75, 100].map((tick) => (34        <g key={tick}>35          <line x1={sx(tick)} y1={sy(0)} x2={sx(tick)} y2={sy(100)} stroke="var(--grid)" strokeWidth="1" />36          <line x1={sx(0)} y1={sy(tick)} x2={sx(100)} y2={sy(tick)} stroke="var(--grid)" strokeWidth="1" />37          <text x={sx(tick)} y={H - m.b + 16} textAnchor="middle" fontSize="10" fill="var(--muted)">38            {tick}39          </text>40          <text x={m.l - 8} y={sy(tick) + 3} textAnchor="end" fontSize="10" fill="var(--muted)">41            {tick}42          </text>43        </g>44      ))}45      {/* midlines */}46      <line x1={sx(50)} y1={sy(0)} x2={sx(50)} y2={sy(100)} stroke="var(--baseline)" strokeWidth="1" />47      <line x1={sx(0)} y1={sy(50)} x2={sx(100)} y2={sy(50)} stroke="var(--baseline)" strokeWidth="1" />48      {/* quadrant annotations */}49      <text x={sx(25)} y={sy(94)} textAnchor="middle" fontSize="11" fontWeight="600" fill="var(--muted)">50        AI assists, rarely replaces51      </text>52      <text x={sx(75)} y={sy(94)} textAnchor="middle" fontSize="11" fontWeight="600" fill="var(--muted)">53        deep task transformation54      </text>55      <text x={sx(25)} y={sy(4)} textAnchor="middle" fontSize="11" fontWeight="600" fill="var(--muted)">56        little AI contact today57      </text>58      <text x={sx(75)} y={sy(4)} textAnchor="middle" fontSize="11" fontWeight="600" fill="var(--muted)">59        replacement pressure60      </text>61      {points.map((point, index) => (62        <circle63          key={index}64          cx={sx(point.x)}65          cy={sy(point.y)}66          r={radius(point.size)}67          fill="var(--seq)"68          fillOpacity="0.42"69          stroke="var(--surface-1)"70          strokeWidth="0.6"71        >72          <title>73            {point.label} — substitution {point.x.toFixed(0)}, augmentation {point.y.toFixed(0)}74            {point.size ? `, ${Math.round(point.size).toLocaleString("en-US")} employed` : ""}75          </title>76        </circle>77      ))}78      <text x={sx(50)} y={H - 6} textAnchor="middle" fontSize="11" fill="var(--ink-2)">79        Substitution →80      </text>81      <text x={12} y={sy(50)} textAnchor="middle" fontSize="11" fill="var(--ink-2)" transform={`rotate(-90 12 ${sy(50)})`}>82        Augmentation →83      </text>84    </svg>85  );86}8788export interface DecileColumn {89  label: string; // wage range90  value: number; // employment-weighted mean substitution91}9293/** Column chart: one value per wage decile, value labels on caps. */94export function DecileColumns({ columns }: { columns: DecileColumn[] }): JSX.Element {95  const max = Math.max(1, ...columns.map((c) => c.value));96  return (97    <div>98      <div className="flex items-end gap-[6px]" style={{ height: 150 }} aria-hidden="true">99        {columns.map((column) => (100          <div key={column.label} className="flex h-full min-w-0 flex-1 flex-col items-center justify-end">101            <span className="mb-1 text-[10px] font-semibold tabular-nums text-[var(--ink-2)]">102              {column.value.toFixed(0)}103            </span>104            <div105              className="w-full max-w-[38px] rounded-t-[3px] bg-[var(--seq)]"106              style={{ height: `${(column.value / max) * 100}%`, minHeight: 3 }}107            />108          </div>109        ))}110      </div>111      <div className="mt-1 flex gap-[6px] border-t border-[var(--baseline)] pt-1">112        {columns.map((column) => (113          <span114            key={column.label}115            className="min-w-0 flex-1 truncate text-center text-[9px] text-[var(--muted)]"116            title={column.label}117          >118            {column.label}119          </span>120        ))}121      </div>122    </div>123  );124}125