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%

feat(web): insights dashboard — scatter (substitution × augmentation), distribution, wage deciles, group bars, top-20 table

- ScatterPlot: 923 occupations, dot area ~ employment, quadrant
  annotations, native tooltips, hairline grid (server-rendered SVG)
- DistributionChart reuse with employment-weighted-mean marker
- DecileColumns: employment-weighted substitution per wage decile
- employment-weighted SOC group bars + top-20 table with CI/wage/employment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 5 days ago (Aug 5, 2026) parent 0618a43

Showing 2 changed files with +315 and −3

modified apps/web/app/insights/page.tsx +191 −3
@@ -10,6 +10,9 @@
10 10 import Link from "next/link";
11 11 import { prisma } from "@airiskindex/db";
12 12 import { HIGH_EXPOSURE_THRESHOLD } from "@airiskindex/scoring";
13 +import { DecileColumns, ScatterPlot } from "@/components/insights-charts";
14 +import { DistributionChart } from "@/components/score-marks";
15 +import { SOC_MAJOR_GROUPS } from "@/lib/soc-groups";
13 16
14 17 export const dynamic = "force-dynamic";
15 18
@@ -66,7 +69,46 @@ async function loadInsights() {
66 69 FROM "TaskScore" WHERE "runId" = ${run.id}
67 70 `;
68 71
69 const [mostWorkersAtHigh, widestCI] = await Promise.all([
72 + const [scatter, groups, deciles, top20, allScores, mostWorkersAtHigh, widestCI] = await Promise.all([
73 + prisma.$queryRaw<Array<{ x: number; y: number; size: number | null; label: string }>>`
74 + SELECT s.substitution::float8 AS x, s.augmentation::float8 AS y,
75 + o.employment::float8 AS size, o.title AS label
76 + FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode"
77 + WHERE s."runId" = ${run.id}
78 + `,
79 + prisma.$queryRaw<Array<{ prefix: string; sub: number; n: number; emp: number }>>`
80 + SELECT left(s."occupationCode", 2) AS prefix,
81 + (sum(s.substitution * COALESCE(o.employment, 0)) / NULLIF(sum(COALESCE(o.employment, 0)), 0))::float8 AS sub,
82 + count(*)::int AS n,
83 + sum(COALESCE(o.employment, 0))::float8 AS emp
84 + FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode"
85 + WHERE s."runId" = ${run.id}
86 + GROUP BY 1 HAVING sum(COALESCE(o.employment, 0)) > 0
87 + ORDER BY 2 DESC
88 + `,
89 + prisma.$queryRaw<Array<{ decile: number; sub: number; lo: number; hi: number }>>`
90 + WITH d AS (
91 + SELECT s.substitution, o.employment::float8 AS emp, o."medianWageCents",
92 + ntile(10) OVER (ORDER BY o."medianWageCents") AS decile
93 + FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode"
94 + WHERE s."runId" = ${run.id} AND o."medianWageCents" IS NOT NULL AND o.employment IS NOT NULL
95 + )
96 + SELECT decile::int, (sum(substitution * emp) / sum(emp))::float8 AS sub,
97 + min("medianWageCents")::float8 AS lo, max("medianWageCents")::float8 AS hi
98 + FROM d GROUP BY decile ORDER BY decile
99 + `,
100 + prisma.occupationScore.findMany({
101 + where: { runId: run.id },
102 + orderBy: { substitution: "desc" },
103 + take: 20,
104 + include: {
105 + occupation: { select: { code: true, title: true, medianWageCents: true, employment: true } },
106 + },
107 + }),
108 + prisma.occupationScore.findMany({
109 + where: { runId: run.id },
110 + select: { substitution: true },
111 + }),
70 112 prisma.$queryRaw<Array<{ code: string; title: string; employment: number; sub: number }>>`
71 113 SELECT o.code, o.title, o.employment::float8 AS employment, s.substitution::float8 AS sub
72 114 FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode"
@@ -82,9 +124,19 @@ async function loadInsights() {
82 124 `,
83 125 ]);
84 126
85 return { run, agg, taskShare, mostWorkersAtHigh, widestCI };
127 + return { run, agg, taskShare, scatter, groups, deciles, top20, allScores, mostWorkersAtHigh, widestCI };
128 +}
129 +
130 +function toBins(values: number[], binCount = 20): number[] {
131 + const bins = Array.from({ length: binCount }, () => 0);
132 + for (const value of values) {
133 + bins[Math.min(binCount - 1, Math.floor((value / 100) * binCount))] += 1;
134 + }
135 + return bins;
86 136 }
87 137
138 +const wageShort = (cents: number): string => `$${Math.round(cents / 100_000)}k`;
139 +
88 140 function Tile({
89 141 label,
90 142 value,
@@ -113,7 +165,7 @@ export default async function InsightsPage(): Promise<JSX.Element> {
113 165 </main>
114 166 );
115 167 }
116 const { run, agg, taskShare, mostWorkersAtHigh, widestCI } = data;
168 + const { run, agg, taskShare, scatter, groups, deciles, top20, allScores, mostWorkersAtHigh, widestCI } = data;
117 169 const atRiskShare = (agg.wage_at_risk / agg.wage_bill) * 100;
118 170
119 171 return (
@@ -179,6 +231,142 @@ export default async function InsightsPage(): Promise<JSX.Element> {
179 231 />
180 232 </div>
181 233
234 + {/* The signature chart: substitution × augmentation */}
235 + <section className="mt-12">
236 + <h2 className="text-xl font-semibold tracking-tight">
237 + Substitution × augmentation — every occupation
238 + </h2>
239 + <p className="mt-1 max-w-2xl text-sm text-[var(--ink-2)]">
240 + The core claim of the methodology in one picture: replacement and assistance are
241 + different axes. Each dot is one of {scatter.length.toLocaleString("en-US")} occupations;
242 + dot area reflects U.S. employment. Hover a dot for its name.
243 + </p>
244 + <div className="card mt-4 p-4 sm:p-6">
245 + <ScatterPlot points={scatter} />
246 + </div>
247 + </section>
248 +
249 + <section className="mt-10 grid gap-4 lg:grid-cols-2">
250 + <div className="card min-w-0 p-5 sm:p-6">
251 + <h2 className="text-lg font-semibold tracking-tight">The shape of the index</h2>
252 + <p className="mt-1 text-sm text-[var(--ink-2)]">
253 + Distribution of all {allScores.length.toLocaleString("en-US")} occupation substitution
254 + scores; the marker is the employment-weighted mean.
255 + </p>
256 + <div className="mt-4">
257 + <DistributionChart
258 + bins={toBins(allScores.map((s) => s.substitution))}
259 + marker={agg.emp_weighted_sub}
260 + markerLabel={`workers' mean · ${agg.emp_weighted_sub.toFixed(0)}`}
261 + />
262 + </div>
263 + </div>
264 + <div className="card min-w-0 p-5 sm:p-6">
265 + <h2 className="text-lg font-semibold tracking-tight">Pressure by wage decile</h2>
266 + <p className="mt-1 text-sm text-[var(--ink-2)]">
267 + Employment-weighted mean substitution per median-wage decile — where on the pay scale
268 + the pressure sits.
269 + </p>
270 + <div className="mt-4">
271 + <DecileColumns
272 + columns={deciles.map((d) => ({
273 + label: `${wageShort(d.lo)}–${wageShort(d.hi)}`,
274 + value: d.sub,
275 + }))}
276 + />
277 + </div>
278 + </div>
279 + </section>
280 +
281 + <section className="mt-10">
282 + <h2 className="text-xl font-semibold tracking-tight">By occupation group</h2>
283 + <p className="mt-1 text-sm text-[var(--ink-2)]">
284 + Employment-weighted mean substitution per SOC major group.
285 + </p>
286 + <div className="card mt-4 overflow-hidden">
287 + {groups.map((group) => (
288 + <Link
289 + key={group.prefix}
290 + href={`/occupations#g${group.prefix}`}
291 + className="block border-b border-[var(--grid)] px-4 py-3 last:border-b-0 hover:bg-[var(--wash)] sm:px-5"
292 + >
293 + <div className="flex items-baseline gap-3">
294 + <span className="min-w-0 truncate text-sm">
295 + {SOC_MAJOR_GROUPS[group.prefix] ?? group.prefix}
296 + </span>
297 + <span className="ml-auto shrink-0 text-xs text-[var(--muted)]">
298 + {group.n} occ. · {millions(group.emp)} workers
299 + </span>
300 + <span className="w-8 shrink-0 text-right text-sm font-semibold tabular-nums">
301 + {group.sub.toFixed(0)}
302 + </span>
303 + </div>
304 + <div aria-hidden="true" className="mt-1.5 h-[6px] rounded-r-[3px] bg-[var(--seq-track)]">
305 + <div
306 + className="h-full rounded-r-[3px] bg-[var(--seq)]"
307 + style={{ width: `${Math.min(100, group.sub)}%` }}
308 + />
309 + </div>
310 + </Link>
311 + ))}
312 + </div>
313 + </section>
314 +
315 + <section className="mt-10">
316 + <div className="flex flex-wrap items-baseline justify-between gap-2">
317 + <h2 className="text-xl font-semibold tracking-tight">Top 20 by substitution</h2>
318 + <Link href="/ranking" className="text-xs text-[var(--muted)] underline hover:text-[var(--ink)]">
319 + full index →
320 + </Link>
321 + </div>
322 + <div className="card mt-4 overflow-x-auto">
323 + <table className="w-full min-w-[640px] text-left text-sm">
324 + <caption className="sr-only">Top 20 occupations by substitution score</caption>
325 + <thead className="border-b border-[var(--grid)] text-xs uppercase tracking-wide text-[var(--muted)]">
326 + <tr>
327 + <th className="px-4 py-3 font-medium">#</th>
328 + <th className="px-4 py-3 font-medium">Occupation</th>
329 + <th className="px-4 py-3 font-medium">Substitution (CI)</th>
330 + <th className="px-4 py-3 font-medium">Exposure</th>
331 + <th className="px-4 py-3 font-medium">Augment.</th>
332 + <th className="px-4 py-3 font-medium">Median wage</th>
333 + <th className="px-4 py-3 font-medium">Employed</th>
334 + </tr>
335 + </thead>
336 + <tbody className="tabular-nums">
337 + {top20.map((row, index) => (
338 + <tr key={row.occupationCode} className="border-b border-[var(--grid)] last:border-b-0 hover:bg-[var(--wash)]">
339 + <td className="px-4 py-2.5 text-[var(--muted)]">{index + 1}</td>
340 + <td className="max-w-xs px-4 py-2.5">
341 + <Link href={`/occupations/${row.occupationCode}`} className="hover:underline">
342 + {row.occupation.title}
343 + </Link>
344 + </td>
345 + <td className="px-4 py-2.5 font-semibold">
346 + {row.substitution.toFixed(1)}
347 + <span className="ml-1 font-normal text-[var(--muted)]">
348 + ({row.substitutionLow.toFixed(0)}–{row.substitutionHigh.toFixed(0)})
349 + </span>
350 + </td>
351 + <td className="px-4 py-2.5">{row.exposure.toFixed(0)}</td>
352 + <td className="px-4 py-2.5">{row.augmentation.toFixed(0)}</td>
353 + <td className="px-4 py-2.5">
354 + {row.occupation.medianWageCents != null
355 + ? `$${Math.round(row.occupation.medianWageCents / 100).toLocaleString("en-US")}`
356 + : "—"}
357 + </td>
358 + <td className="px-4 py-2.5">
359 + {row.occupation.employment != null
360 + ? row.occupation.employment.toLocaleString("en-US")
361 + : "—"}
362 + </td>
363 + </tr>
364 + ))}
365 + </tbody>
366 + </table>
367 + </div>
368 + </section>
369 +
182 370 <section className="mt-12 grid gap-6 lg:grid-cols-2">
183 371 <div className="min-w-0">
184 372 <h2 className="text-lg font-semibold tracking-tight">
added apps/web/components/insights-charts.tsx +124 −0
@@ -0,0 +1,124 @@
1 +// File: insights-charts.tsx
2 +// Path: apps/web/components/insights-charts.tsx
3 +// Project: AI Risk Index — airiskindex.io
4 +// Author: Simon-Pierre Boucher
5 +// Contact: contact@spboucher.ai
6 +// 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.
9 +
10 +export interface ScatterPoint {
11 + x: number; // substitution 0–100
12 + y: number; // augmentation 0–100
13 + size?: number | null; // employment
14 + label: string;
15 +}
16 +
17 +/**
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 + */
22 +export 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);
30 +
31 + 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 replaces
51 + </text>
52 + <text x={sx(75)} y={sy(94)} textAnchor="middle" fontSize="11" fontWeight="600" fill="var(--muted)">
53 + deep task transformation
54 + </text>
55 + <text x={sx(25)} y={sy(4)} textAnchor="middle" fontSize="11" fontWeight="600" fill="var(--muted)">
56 + little AI contact today
57 + </text>
58 + <text x={sx(75)} y={sy(4)} textAnchor="middle" fontSize="11" fontWeight="600" fill="var(--muted)">
59 + replacement pressure
60 + </text>
61 + {points.map((point, index) => (
62 + <circle
63 + 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 +}
87 +
88 +export interface DecileColumn {
89 + label: string; // wage range
90 + value: number; // employment-weighted mean substitution
91 +}
92 +
93 +/** Column chart: one value per wage decile, value labels on caps. */
94 +export 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 + <div
105 + 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 + <span
114 + 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