import { useState, useEffect, useCallback, useRef } from "react"; const OCCUPATIONS = [ { key: "cm", label: "Computer & Mathematical", short: "Computer & Math" }, { key: "oa", label: "Office & Administrative Support", short: "Office & Admin" }, { key: "bf", label: "Business & Financial Operations", short: "Business & Finance" }, { key: "ad", label: "Arts, Design, Entertainment, Sports & Media", short: "Arts & Design" }, { key: "le", label: "Legal", short: "Legal" }, { key: "et", label: "Education, Training & Library", short: "Education & Library" }, { key: "mg", label: "Management", short: "Management" }, { key: "sc", label: "Life, Physical & Social Science", short: "Science" }, { key: "cs", label: "Community & Social Service", short: "Community & Social" }, { key: "hp", label: "Healthcare Practitioners & Technical", short: "Healthcare Practitioners" }, { key: "sr", label: "Sales & Related", short: "Sales" }, { key: "ae", label: "Architecture & Engineering", short: "Architecture & Eng." }, { key: "pc", label: "Personal Care & Service", short: "Personal Care" }, { key: "ps", label: "Protective Service", short: "Protective Service" }, { key: "hs", label: "Healthcare Support", short: "Healthcare Support" }, { key: "fp", label: "Food Preparation & Serving", short: "Food Prep & Serving" }, { key: "bg", label: "Building & Grounds Cleaning & Maintenance", short: "Building & Grounds" }, { key: "pr", label: "Production", short: "Production" }, { key: "ce", label: "Construction & Extraction", short: "Construction" }, { key: "tm", label: "Transportation & Material Moving", short: "Transportation" }, { key: "im", label: "Installation, Maintenance & Repair", short: "Install. & Repair" }, { key: "ff", label: "Farming, Fishing & Forestry", short: "Farming & Forestry" }, { key: "ts", label: "Technical SEO", short: "Technical SEO" }, { key: "fd", label: "Frontend Development", short: "Frontend Dev" }, { key: "om", label: "Online Marketing", short: "Online Marketing" }, { key: "lb", label: "Linkbuilding", short: "Linkbuilding" }, ]; const N = OCCUPATIONS.length; function generateRandom() { const vals = {}; OCCUPATIONS.forEach((o) => { vals[o.key] = Math.round(Math.random() * 10) * 10; }); return vals; } function encodeState(vals) { return OCCUPATIONS.map((o) => Math.round((vals[o.key] ?? 0) / 10)).join(""); } function decodeState(str) { const vals = {}; OCCUPATIONS.forEach((o, i) => { const ch = str[i]; vals[o.key] = ch !== undefined ? parseInt(ch, 10) * 10 : 0; if (isNaN(vals[o.key])) vals[o.key] = 0; vals[o.key] = Math.min(100, Math.max(0, vals[o.key])); }); return vals; } function getInitialValues() { try { const hash = window.location.hash.slice(1); if (hash && hash.length >= N && /^\d+$/.test(hash.slice(0, N))) return decodeState(hash); } catch (e) {} return generateRandom(); } const BG_DARK = "#111318"; const BG_CARD = "#1a1e26"; const BG_SLIDER = "#252a35"; const TEXT_PRIMARY = "#e4e7ec"; const TEXT_SECONDARY = "#8b93a5"; const TEXT_DIM = "#555d6e"; const ACCENT = "#5b8def"; const FILL_COLOR = "rgba(91, 141, 239, 0.28)"; const STROKE_COLOR = "rgba(91, 141, 239, 0.85)"; const GRID_COLOR = "rgba(255,255,255,0.06)"; const SPOKE_COLOR = "rgba(255,255,255,0.08)"; const DOT_COLOR = "#5b8def"; const CHART_TITLE = "Theoretical AI capability by occupational category"; const WATERMARK_TEXT = "johnmu.com"; const TITLE_H = 52; const WATERMARK_H = 36; function polarToXY(cx, cy, radius, angleDeg) { const rad = ((angleDeg - 90) * Math.PI) / 180; return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) }; } function labelAnchor(angle) { const n = ((angle % 360) + 360) % 360; if (n > 10 && n < 170) return "start"; if (n > 190 && n < 350) return "end"; return "middle"; } function labelDy(angle) { const n = ((angle % 360) + 360) % 360; if (n > 100 && n < 260) return "0.8em"; if (n < 10 || n > 350) return "-0.4em"; return "0.35em"; } function RadarChartSVG({ values, hovered, onHover, size }) { const totalH = size + TITLE_H + WATERMARK_H; const cx = size / 2; const cy = size / 2 + TITLE_H; const maxR = size * 0.34; const labelR = maxR + 28; const angleStep = 360 / N; const rings = [20, 40, 60, 80, 100]; const points = OCCUPATIONS.map((occ, i) => { const val = values[occ.key] || 0; return polarToXY(cx, cy, (val / 100) * maxR, i * angleStep); }); const pathD = points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ") + " Z"; return ( {CHART_TITLE} {rings.map((pct) => { const r = (pct / 100) * maxR; return ( {pct}% ); })} {OCCUPATIONS.map((occ, i) => { const angle = i * angleStep; const outerPt = polarToXY(cx, cy, maxR, angle); const labelPt = polarToXY(cx, cy, labelR, angle); const isHov = hovered === occ.key; return ( onHover(occ.key)} onMouseLeave={() => onHover(null)}> {occ.short} ); })} {points.map((p, i) => { const occ = OCCUPATIONS[i]; const val = values[occ.key] || 0; if (val === 0) return null; const isHov = hovered === occ.key; return ( onHover(occ.key)} onMouseLeave={() => onHover(null)}> {isHov && ( {val}% )} ); })} {WATERMARK_TEXT} ); } function buildExportSVGString(values) { const S = 900; const totalH = S + TITLE_H + WATERMARK_H; const cx = S / 2; const cy = S / 2 + TITLE_H; const maxR = S * 0.34; const labelR = maxR + 28; const angleStep = 360 / N; const rings = [20, 40, 60, 80, 100]; const esc = (s) => s.replace(/&/g, "&").replace(//g, ">"); const p = []; p.push(``); p.push(``); p.push(`${esc(CHART_TITLE)}`); rings.forEach(pct => { const r = (pct / 100) * maxR; p.push(``); p.push(`${pct}%`); }); OCCUPATIONS.forEach((occ, i) => { const angle = i * angleStep; const outerPt = polarToXY(cx, cy, maxR, angle); const labelPt = polarToXY(cx, cy, labelR, angle); p.push(``); p.push(`${esc(occ.short)}`); }); const pts = OCCUPATIONS.map((occ, i) => { const val = values[occ.key] || 0; return polarToXY(cx, cy, (val / 100) * maxR, i * angleStep); }); const pathD = pts.map((pt, i) => `${i === 0 ? "M" : "L"} ${pt.x} ${pt.y}`).join(" ") + " Z"; p.push(``); pts.forEach((pt, i) => { const val = values[OCCUPATIONS[i].key] || 0; if (val > 0) { p.push(``); p.push(`${val}%`); } }); p.push(`${esc(WATERMARK_TEXT)}`); p.push(``); return { svg: p.join("\n"), width: S * 2, height: totalH * 2 }; } function SliderRow({ occ, value, onChange, isHovered, onHover }) { return (
onHover(occ.key)} onMouseLeave={() => onHover(null)}>
{occ.short} 0 ? ACCENT : TEXT_DIM, minWidth: "36px", textAlign: "right", }}>{value}%
onChange(occ.key, parseInt(e.target.value, 10))} style={{ width: "100%", height: "4px", appearance: "none", WebkitAppearance: "none", background: `linear-gradient(to right, ${ACCENT} 0%, ${ACCENT} ${value}%, ${BG_SLIDER} ${value}%, ${BG_SLIDER} 100%)`, borderRadius: "2px", outline: "none", cursor: "pointer", }} />
); } const btnStyle = { padding: "7px 14px", fontSize: "12px", fontWeight: 500, border: "none", borderRadius: "6px", cursor: "pointer", background: BG_SLIDER, color: TEXT_SECONDARY, fontFamily: "'IBM Plex Sans', system-ui, sans-serif", transition: "background 0.15s, color 0.15s", whiteSpace: "nowrap", }; export default function OccupationExposureRadar() { const [values, setValues] = useState(getInitialValues); const [hovered, setHovered] = useState(null); const [showSliders, setShowSliders] = useState(true); const [downloading, setDownloading] = useState(false); const containerRef = useRef(null); const [chartSize, setChartSize] = useState(680); useEffect(() => { window.location.hash = encodeState(values); }, [values]); useEffect(() => { const onHash = () => { const hash = window.location.hash.slice(1); if (hash && hash.length >= N && /^\d+$/.test(hash.slice(0, N))) setValues(decodeState(hash)); }; window.addEventListener("hashchange", onHash); return () => window.removeEventListener("hashchange", onHash); }, []); useEffect(() => { const measure = () => { if (containerRef.current) { const w = containerRef.current.clientWidth; const h = containerRef.current.clientHeight; setChartSize(Math.min(w - 20, h - 20, 780)); } }; measure(); window.addEventListener("resize", measure); return () => window.removeEventListener("resize", measure); }, [showSliders]); const handleChange = useCallback((key, val) => { setValues((prev) => ({ ...prev, [key]: val })); }, []); const handleDownload = async () => { setDownloading(true); try { const { svg, width, height } = buildExportSVGString(values); // Use data URI instead of blob URL to avoid CSP img-src restrictions const dataUrl = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg); const img = new Image(); img.onload = () => { const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0, width, height); // Use data URI for the PNG as well const pngDataUrl = canvas.toDataURL("image/png"); const a = document.createElement("a"); a.href = pngDataUrl; a.download = "ai-capability-by-occupation.png"; document.body.appendChild(a); a.click(); document.body.removeChild(a); setDownloading(false); }; img.onerror = () => setDownloading(false); img.src = dataUrl; } catch (e) { console.error("Download error:", e); setDownloading(false); } }; return (
{/* Header */}

Theoretical AI Capability by Occupation

Share of job tasks that LLMs could theoretically perform. Adjust sliders, then share the URL or download as PNG.

{/* Main */}
{showSliders && (
Occupation Controls (10% steps)
{OCCUPATIONS.map((occ) => ( ))}
)}
); }