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%
8.3 KB · 235 lines tsx
Raw Blame History
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