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%
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