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: score-marks.tsx2// Path: apps/web/components/score-marks.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: Chart marks: score bars with CI whiskers, stat tiles, interval strips, meter.910/**11 * Chart marks for 0–100 scores with confidence bounds.12 * Specs per the dataviz skill: thin bars (≤24px) with a 4px rounded data-end13 * and a square baseline, track = lighter step of the same ramp, CI whisker in14 * muted ink, values in text tokens (never the series color).15 */1617export interface Band {18 low: number;19 score: number;20 high: number;21}2223const pct = (value: number): string => `${Math.max(0, Math.min(100, value))}%`;2425/** Horizontal score bar with CI whisker on a 0–100 track. */26export function ScoreBar({ band, thick = 10 }: { band: Band; thick?: number }): JSX.Element {27 return (28 <div29 aria-hidden="true"30 className="relative w-full rounded-r-[4px] bg-[var(--seq-track)]"31 style={{ height: thick }}32 >33 <div34 className="absolute inset-y-0 left-0 rounded-r-[4px] bg-[var(--seq)]"35 style={{ width: pct(band.score) }}36 />37 {/* CI whisker: hairline + end ticks, muted ink over the track */}38 <div39 className="absolute top-1/2 h-px -translate-y-1/2 bg-[var(--muted)]"40 style={{ left: pct(band.low), width: pct(band.high - band.low) }}41 />42 <div43 className="absolute top-1/2 h-[7px] w-px -translate-y-1/2 bg-[var(--muted)]"44 style={{ left: pct(band.low) }}45 />46 <div47 className="absolute top-1/2 h-[7px] w-px -translate-y-1/2 bg-[var(--muted)]"48 style={{ left: pct(band.high) }}49 />50 </div>51 );52}5354/** Compact interval strip for stat tiles: band wash + point marker. */55export function IntervalStrip({ band }: { band: Band }): JSX.Element {56 return (57 <div aria-hidden="true" className="relative h-[6px] w-full rounded-[3px] bg-[var(--seq-track)]">58 <div59 className="absolute inset-y-0 rounded-[3px] bg-[var(--seq)] opacity-40"60 style={{ left: pct(band.low), width: pct(band.high - band.low) }}61 />62 {/* point marker ≥8px with a 2px surface ring */}63 <div64 className="absolute top-1/2 h-[10px] w-[10px] -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[var(--surface-1)] bg-[var(--seq)]"65 style={{ left: pct(band.score) }}66 />67 </div>68 );69}7071/** Stat tile: label · value · CI, with the interval strip underneath. */72export function ScoreTile({73 label,74 band,75 hint,76}: {77 label: string;78 band: Band;79 hint: string;80}): JSX.Element {81 return (82 <div className="card p-5">83 <p className="text-sm font-medium text-[var(--ink-2)]">{label}</p>84 <p className="mt-1 text-4xl font-semibold">85 {band.score.toFixed(0)}86 <span className="ml-2 align-middle text-sm font-normal text-[var(--muted)]">87 CI {band.low.toFixed(0)}–{band.high.toFixed(0)}88 </span>89 </p>90 <div className="mt-3">91 <IntervalStrip band={band} />92 </div>93 <p className="mt-3 text-xs leading-relaxed text-[var(--muted)]">{hint}</p>94 </div>95 );96}9798/**99 * Dot plot of labeled scores on a shared 0–100 axis: hairline gridlines,100 * CI band wash, ≥10px point marker with a 2px surface ring. Rows are101 * direct-labeled, single hue — no legend needed.102 */103export function SubScoreDotPlot({104 rows,105}: {106 rows: Array<{ label: string; band: Band }>;107}): JSX.Element {108 return (109 <div>110 {rows.map((row) => (111 <div key={row.label} className="flex items-center gap-3 py-2.5">112 <span className="w-24 shrink-0 text-sm font-medium text-[var(--ink-2)] sm:w-28">113 {row.label}114 </span>115 <div aria-hidden="true" className="relative h-[26px] min-w-0 flex-1">116 {[0, 25, 50, 75, 100].map((tick) => (117 <div118 key={tick}119 className="absolute inset-y-0 w-px bg-[var(--grid)]"120 style={{ left: pct(tick) }}121 />122 ))}123 <div124 className="absolute top-1/2 h-[10px] -translate-y-1/2 rounded-full bg-[var(--seq-track)]"125 style={{ left: pct(row.band.low), width: pct(row.band.high - row.band.low) }}126 />127 <div128 className="absolute top-1/2 h-[13px] w-[13px] -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[var(--surface-1)] bg-[var(--seq)]"129 style={{ left: pct(row.band.score) }}130 />131 </div>132 <span className="w-9 shrink-0 text-right text-lg font-semibold tabular-nums">133 {row.band.score.toFixed(0)}134 </span>135 </div>136 ))}137 <div className="flex items-center gap-3" aria-hidden="true">138 <span className="w-24 shrink-0 sm:w-28" />139 <div className="relative h-4 min-w-0 flex-1 text-[10px] text-[var(--muted)]">140 <span className="absolute left-0">0</span>141 <span className="absolute left-1/4 -translate-x-1/2">25</span>142 <span className="absolute left-1/2 -translate-x-1/2">50</span>143 <span className="absolute left-3/4 -translate-x-1/2">75</span>144 <span className="absolute right-0">100</span>145 </div>146 <span className="w-9 shrink-0" />147 </div>148 </div>149 );150}151152/**153 * Compact histogram over the 0–100 scale (bins of equal width). Emphasis154 * form: recessive context columns with an optional accent marker line155 * ("this occupation"). Columns are thin with rounded caps, square baseline.156 */157export function DistributionChart({158 bins,159 marker,160 markerLabel,161 height = 96,162 accent = false,163}: {164 bins: number[];165 marker?: number;166 markerLabel?: string;167 height?: number;168 accent?: boolean;169}): JSX.Element {170 const max = Math.max(1, ...bins);171 const binWidth = 100 / bins.length;172 return (173 <div>174 <div aria-hidden="true" className="relative" style={{ height }}>175 {/* baseline */}176 <div className="absolute inset-x-0 bottom-0 h-px bg-[var(--baseline)]" />177 {bins.map((count, index) => (178 <div179 key={index}180 className={`absolute bottom-0 rounded-t-[3px] ${accent && marker === undefined ? "bg-[var(--seq)]" : marker !== undefined ? "bg-[var(--seq-track)]" : "bg-[var(--seq)]"}`}181 style={{182 left: `calc(${index * binWidth}% + 1px)`,183 width: `calc(${binWidth}% - 2px)`,184 height: `${Math.max(count > 0 ? 3 : 0, (count / max) * 100)}%`,185 }}186 />187 ))}188 {marker !== undefined && (189 <>190 <div191 className="absolute inset-y-0 w-[2px] rounded bg-[var(--seq)]"192 style={{ left: pct(marker) }}193 />194 {markerLabel && (195 <span196 className="absolute -top-1 -translate-x-1/2 whitespace-nowrap rounded-full bg-[var(--seq)] px-2 py-0.5 text-[10px] font-semibold text-white"197 style={{ left: `clamp(3.5rem, ${pct(marker)}, calc(100% - 3.5rem))` }}198 >199 {markerLabel}200 </span>201 )}202 </>203 )}204 </div>205 <div aria-hidden="true" className="relative mt-1 h-4 text-[10px] text-[var(--muted)]">206 <span className="absolute left-0">0</span>207 <span className="absolute left-1/4 -translate-x-1/2">25</span>208 <span className="absolute left-1/2 -translate-x-1/2">50</span>209 <span className="absolute left-3/4 -translate-x-1/2">75</span>210 <span className="absolute right-0">100</span>211 </div>212 </div>213 );214}215216/** Meter: fill + same-ramp track (marks-and-anatomy §Figures). */217export function ShareMeter({ share, label }: { share: number; label: string }): JSX.Element {218 const value = Math.round(share * 100);219 return (220 <div className="card p-5">221 <div className="flex items-baseline justify-between">222 <p className="text-sm font-medium text-[var(--ink-2)]">{label}</p>223 <p className="text-2xl font-semibold">{value}%</p>224 </div>225 <div className="mt-3 h-[10px] w-full rounded-[4px] bg-[var(--seq-track)]">226 <div227 className="h-full rounded-[4px] bg-[var(--seq)]"228 style={{ width: `${value}%` }}229 aria-hidden="true"230 />231 </div>232 </div>233 );234}235