import { createMemo, createSignal, For, Show, JSXElement } from "solid-js"; import { US_STATES, US_VIEWBOX } from "./usStates.ts"; // A choropleth of the 50 states + DC, plus optional lat/lng markers. Like uikit/Chart it // draws SVG as an innerHTML string (the Go Solid compiler will not namespace control-flow // SVG — see Chart.tsx). The state boundaries are pre-projected with d3's albersUsa into a // 960×600 box (usStates.ts); the SAME projection is reimplemented below so that lat/lng // points land exactly on top of the states. It is a faithful, dependency-free port — // validated to 0px against d3-geo, Alaska and Hawaii insets included. // // The map scales by viewBox rather than by measurement (there is no axis text to keep // crisp), so no ResizeObserver: viewBox 960×600 + a 960/600 aspect-ratio box fills the // column. Hover reads e.target's data-attributes — the specific state path or point marker // under the pointer — so no coordinate maths is needed to know what is being pointed at. // ── the albersUsa projection (ported from d3-geo, scale 1280, translate [480,300]) ────── const RAD = Math.PI / 180, TAU = 2 * Math.PI; function conicEqualAreaRaw(y0: number, y1: number) { const sy0 = Math.sin(y0), n = (sy0 + Math.sin(y1)) / 2; const c = 1 + sy0 * (2 * n - sy0), r0 = Math.sqrt(c) / n; return (lambda: number, phi: number): [number, number] => { const r = Math.sqrt(c - 2 * n * Math.sin(phi)) / n; return [r * Math.sin(lambda * n), r0 - r * Math.cos(lambda * n)]; }; } // One conic-equal-area lobe. `center` is given in the rotated frame (near 0° lon), so it is // not re-rotated; the input point is rotated by `rotateLon` before projecting. function albersLobe(rotateLon: number, centerLon: number, centerLat: number, p0: number, p1: number, scale: number, tx: number, ty: number) { const raw = conicEqualAreaRaw(p0 * RAD, p1 * RAD); const rot = (lon: number) => { const l = (lon + rotateLon) * RAD; return ((l + Math.PI) % TAU + TAU) % TAU - Math.PI; }; const [cx, cy] = raw(centerLon * RAD, centerLat * RAD); return (lon: number, lat: number): [number, number] => { const [x, y] = raw(rot(lon), lat * RAD); return [tx + scale * (x - cx), ty - scale * (y - cy)]; }; } const K = 1280, TX = 480, TY = 300, EPS = 1e-6; const _lower48 = albersLobe(96, -0.6, 38.7, 29.5, 45.5, K, TX, TY); const _alaska = albersLobe(154, -2, 58.5, 55, 65, K * 0.35, TX - 0.307 * K, TY + 0.201 * K); const _hawaii = albersLobe(157, -3, 19.9, 8, 18, K, TX - 0.205 * K, TY + 0.212 * K); const inBox = (p: [number, number], x0: number, y0: number, x1: number, y1: number) => p[0] >= x0 && p[0] <= x1 && p[1] >= y0 && p[1] <= y1; // Project [lng, lat] to the 960×600 map, choosing the lower-48 / Alaska / Hawaii lobe the // way albersUsa does — by which one's clip box the point falls in. null if off-map. export function projectUS(lng: number, lat: number): [number, number] | null { let p = _lower48(lng, lat); if (inBox(p, TX - 0.455 * K, TY - 0.238 * K, TX + 0.455 * K, TY + 0.238 * K)) return p; p = _alaska(lng, lat); if (inBox(p, TX - 0.425 * K + EPS, TY + 0.120 * K + EPS, TX - 0.214 * K - EPS, TY + 0.234 * K - EPS)) return p; p = _hawaii(lng, lat); if (inBox(p, TX - 0.214 * K + EPS, TY + 0.166 * K + EPS, TX - 0.115 * K - EPS, TY + 0.234 * K - EPS)) return p; return null; } // ── the component ──────────────────────────────────────────────────────────────── export interface USHeatmapPoint { lat: number; lng: number; value?: number; label?: string; } export interface USHeatmapProps { // State value map: USPS code (e.g. "CA", "TX", "DC") → number. States present are // shaded on the sequential ramp; states absent are drawn in the no-data neutral. data?: Record; // lat/lng markers, projected onto the map. Points outside the US are dropped. points?: USHeatmapPoint[]; height?: number; // px; omit to size from the container width (960:600 aspect). class?: string; steps?: number; // choropleth buckets, 1–6 (default 6, the token count). tooltip?: boolean; valueFormat?: (v: number) => string; pointColor?: string; // default var(--color-chart-1) (blue). pointRadius?: number; // fixed dot radius (default 5); the MAXIMUM radius when proportional. // Scale each dot's AREA by its value (radius ∝ √value) so a bigger dot means "more" — // area, not radius, because the eye reads a circle by its area. proportional?: boolean; // Override a state's tooltip name (default the built-in full name). stateName?: (code: string) => string; } const CHOROPLETH_STEPS = 6; // must match --color-choropleth-1..N in theme.css const POINT_OPACITY = 0.85; // dots are slightly see-through so the state beneath still reads const _intl = () => new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }); let _fmt: Intl.NumberFormat | null = null; const defaultFormat = (v: number) => (Number.isFinite(v) ? (_fmt ??= _intl()).format(v) : String(v)); const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); const esc = (s: unknown) => String(s).replace(/[&<>"]/g, (c) => (c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """)); type Hover = | { kind: "state"; code: string } | { kind: "point"; idx: number } | null; export function USHeatmap(props: USHeatmapProps): JSXElement { let wrap: HTMLDivElement | undefined; const [hover, setHover] = createSignal(null); const [pointer, setPointer] = createSignal<[number, number]>([0, 0]); const fmt = (v: number) => (props.valueFormat ?? defaultFormat)(v); const steps = () => clamp(props.steps ?? CHOROPLETH_STEPS, 1, CHOROPLETH_STEPS); const stateName = (code: string) => (props.stateName ? props.stateName(code) : US_STATES[code]?.name ?? code); // The value range across the states that have data, for quantising into ramp buckets. const range = createMemo(() => { const vals = Object.values(props.data ?? {}).filter((v) => Number.isFinite(v)); return vals.length ? { min: Math.min(...vals), max: Math.max(...vals), has: true } : { min: 0, max: 0, has: false }; }); const bucket = (v: number) => { const r = range(); const t = r.max > r.min ? (v - r.min) / (r.max - r.min) : 1; return clamp(Math.floor(t * steps()), 0, steps() - 1) + 1; }; // projected markers (drop anything off-map), kept with their original index for hover. const points = createMemo(() => (props.points ?? []).map((pt, idx) => ({ pt, idx, xy: projectUS(pt.lng, pt.lat) })) .filter((m): m is { pt: USHeatmapPoint; idx: number; xy: [number, number] } => m.xy !== null)); const body = createMemo(() => { const data = props.data ?? {}; const hv = hover(); const proportional = !!props.proportional; const maxR = props.pointRadius ?? (proportional ? 16 : 5); const minR = Math.min(3, maxR * 0.35); const maxV = proportional ? Math.max(1, ...points().map((m) => Math.max(0, m.pt.value ?? 0))) : 1; const radiusOf = (v: number | undefined) => proportional ? minR + (maxR - minR) * Math.sqrt(clamp((v ?? 0) / maxV, 0, 1)) : maxR; const pc = esc(props.pointColor ?? "var(--color-chart-1)"); const out: string[] = []; for (const code in US_STATES) { const st = US_STATES[code]; const has = Object.prototype.hasOwnProperty.call(data, code) && Number.isFinite(data[code]); const fill = has ? `var(--color-choropleth-${bucket(data[code])})` : "var(--color-surface-strong)"; const isHover = hv?.kind === "state" && hv.code === code; out.push(``); } for (const m of points()) { const isHover = hv?.kind === "point" && hv.idx === m.idx; const r = radiusOf(m.pt.value); out.push(``); } return out.join(""); }); const onMove = (e: PointerEvent) => { if (props.tooltip === false) return; const t = e.target as Element; const pIdx = t.getAttribute?.("data-pt"); const code = t.getAttribute?.("data-state"); if (pIdx != null) setHover({ kind: "point", idx: +pIdx }); else if (code != null) setHover({ kind: "state", code }); else setHover(null); if (wrap) { const r = wrap.getBoundingClientRect(); setPointer([e.clientX - r.left, e.clientY - r.top]); } }; const tip = createMemo(() => { const hv = hover(); if (!hv) return null; if (hv.kind === "point") { const pt = (props.points ?? [])[hv.idx]; if (!pt) return null; return { title: pt.label ?? `${pt.lat.toFixed(2)}, ${pt.lng.toFixed(2)}`, value: pt.value != null ? fmt(pt.value) : "", swatch: props.pointColor ?? "var(--color-chart-1)", }; } const v = (props.data ?? {})[hv.code]; const has = v != null && Number.isFinite(v); return { title: stateName(hv.code), value: has ? fmt(v) : "no data", swatch: has ? `var(--color-choropleth-${bucket(v)})` : "var(--color-surface-strong)", }; }); return (
setHover(null)} /> {(t) => (
{t().title}
{t().value}
)}
); } function ChoroplethLegend(p: { min: number; max: number; steps: number; fmt: (v: number) => string }): JSXElement { const swatches = () => Array.from({ length: p.steps }, (_, i) => i + 1); return (
{p.fmt(p.min)}
{(k) => ( )}
{p.fmt(p.max)}
); }