SPB Git

spb/airiskindex

Public

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

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 (Aug 5, 2026) parent 0618a43 Browse files

2 changed files +315 −3

modified apps/web/app/insights/page.tsx +191 −3
@@ -10,6 +10,9 @@
10 import Link from "next/link"; 10 import Link from "next/link";
11 import { prisma } from "@airiskindex/db"; 11 import { prisma } from "@airiskindex/db";
12 import { HIGH_EXPOSURE_THRESHOLD } from "@airiskindex/scoring"; 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 export const dynamic = "force-dynamic"; 17 export const dynamic = "force-dynamic";
15 18
@@ -66,7 +69,46 @@ async function loadInsights() {
66 FROM "TaskScore" WHERE "runId" = ${run.id} 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 prisma.$queryRaw<Array<{ code: string; title: string; employment: number; sub: number }>>` 112 prisma.$queryRaw<Array<{ code: string; title: string; employment: number; sub: number }>>`
71 SELECT o.code, o.title, o.employment::float8 AS employment, s.substitution::float8 AS sub 113 SELECT o.code, o.title, o.employment::float8 AS employment, s.substitution::float8 AS sub
72 FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode" 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 function Tile({ 140 function Tile({
89 label, 141 label,
90 value, 142 value,
@@ -113,7 +165,7 @@ export default async function InsightsPage(): Promise<JSX.Element> {
113 </main> 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 const atRiskShare = (agg.wage_at_risk / agg.wage_bill) * 100; 169 const atRiskShare = (agg.wage_at_risk / agg.wage_bill) * 100;
118 170
119 return ( 171 return (
@@ -179,6 +231,142 @@ export default async function InsightsPage(): Promise<JSX.Element> {
179 /> 231 />
180 </div> 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 <section className="mt-12 grid gap-6 lg:grid-cols-2"> 370 <section className="mt-12 grid gap-6 lg:grid-cols-2">
183 <div className="min-w-0"> 371 <div className="min-w-0">
184 <h2 className="text-lg font-semibold tracking-tight"> 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
Menu

Theme