| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { useMemo } from "react"; |
| import { chartData } from "./chartData"; |
| import type { Bucket, ChartSpec } from "./chartData"; |
| |
| |
| |
| import { asFields, inServerOrder } from "./seriesData"; |
| import type { WireField } from "./seriesData"; |
| import type { Row } from "./types"; |
|
|
| |
| |
| |
| |
| export const SERIES_SLOTS = 4; |
|
|
| const W = 720; |
| const H = 230; |
| const PAD_L = 52; |
| const PAD_R = 10; |
| const PAD_T = 12; |
| const PAD_B = 34; |
|
|
| export interface SeriesDef { |
| y: string; |
| label: string; |
| } |
|
|
| function niceNumber(v: number): string { |
| if (!Number.isFinite(v)) return "—"; |
| const abs = Math.abs(v); |
| if (abs >= 1e9) return (v / 1e9).toFixed(1) + "B"; |
| if (abs >= 1e6) return (v / 1e6).toFixed(1) + "M"; |
| if (abs >= 1e3) return (v / 1e3).toFixed(1) + "k"; |
| return Number.isInteger(v) ? v.toLocaleString() : v.toFixed(2); |
| } |
|
|
| |
| |
| |
| |
| function deltaLabel(v: number): string { |
| const sign = v > 0 ? "+" : v < 0 ? "−" : ""; |
| const abs = Math.abs(v); |
| if (abs >= 999) return `${sign}${Math.round(abs / 100)}×`; |
| return `${sign}${abs.toFixed(abs < 10 ? 1 : 0)}%`; |
| } |
|
|
| export type { WireField } from "./seriesData"; |
|
|
| export interface SeriesChartProps { |
| spec: ChartSpec; |
| series: SeriesDef[]; |
| fields: WireField[]; |
| rows: Row[]; |
| xOrder?: string[]; |
| |
| |
| |
| deltaByKey?: Map<string, number>; |
| |
| |
| onPick?: (xKey: string) => void; |
| |
| |
| pickable?: boolean; |
| } |
|
|
| export function SeriesChart({ |
| spec, |
| series, |
| fields, |
| rows, |
| xOrder, |
| deltaByKey, |
| onPick, |
| pickable, |
| }: SeriesChartProps) { |
| const vizFields = useMemo(() => asFields(fields), [fields]); |
| const fieldByKey = useMemo( |
| () => new Map(vizFields.map((f) => [f.key, f])), |
| [vizFields] |
| ); |
|
|
| const computed = useMemo( |
| () => |
| series.map((s) => ({ |
| def: s, |
| data: chartData({ ...spec, y: s.y }, rows, fieldByKey), |
| })), |
| [series, spec, rows, fieldByKey] |
| ); |
|
|
| |
| |
| |
| const axis = useMemo(() => { |
| const seen = new Map<string, string>(); |
| for (const c of computed) |
| for (const b of c.data.buckets) if (!seen.has(b.key)) seen.set(b.key, b.label); |
| const merged: Bucket[] = [...seen].map(([key, label]) => ({ key, label, value: 0, n: 0 })); |
| return inServerOrder(merged, xOrder); |
| }, [computed, xOrder]); |
|
|
| const problem = computed.find((c) => c.data.problem)?.data.problem; |
| if (problem) return <p className="pg-empty">{problem}</p>; |
| if (!axis.length) |
| return <p className="pg-empty">No periods in this scope yet, so there is nothing to plot.</p>; |
|
|
| const byKey = computed.map((c) => ({ |
| def: c.def, |
| data: c.data, |
| lookup: new Map(c.data.buckets.map((b) => [b.key, b])), |
| })); |
|
|
| const max = Math.max( |
| 0, |
| ...byKey.flatMap((s) => s.data.buckets.map((b) => b.value)) |
| ); |
| const scale = max > 0 ? (H - PAD_T - PAD_B) / max : 0; |
| const plotW = W - PAD_L - PAD_R; |
| const slotW = plotW / axis.length; |
| const barW = Math.max(2, (slotW * 0.72) / Math.max(1, byKey.length)); |
|
|
| |
| const labelEvery = Math.max(1, Math.ceil(axis.length / 12)); |
|
|
| const base = H - PAD_B; |
| const rowsSeen = byKey[0]?.data.rows ?? 0; |
| const missing = byKey.reduce((a, s) => a + s.data.missingY, 0); |
| const omitted = byKey[0]?.data.omitted ?? 0; |
| const omittedValue = byKey[0]?.data.omittedValue ?? 0; |
|
|
| return ( |
| <div className="pg-charts"> |
| <div className="pg-legend"> |
| {byKey.map((s, i) => ( |
| <span key={s.def.y} className="pg-legend-key"> |
| <span className={`pg-legend-swatch pg-ser-${i % SERIES_SLOTS}`} /> |
| {s.def.label} |
| </span> |
| ))} |
| </div> |
| <svg |
| className="pg-chart-svg" |
| viewBox={`0 0 ${W} ${H}`} |
| role="img" |
| aria-label={spec.title ?? "Chart"} |
| > |
| {/* The baseline and the top gridline — two rules, not a grid. A chart |
| that needs five gridlines to be read is a table. */} |
| <line className="pg-ax" x1={PAD_L} y1={base} x2={W - PAD_R} y2={base} /> |
| <line className="pg-ax pg-ax-soft" x1={PAD_L} y1={PAD_T} x2={W - PAD_R} y2={PAD_T} /> |
| <text className="pg-ax-lab" x={PAD_L - 6} y={PAD_T + 4} textAnchor="end"> |
| {niceNumber(max)} |
| </text> |
| <text className="pg-ax-lab" x={PAD_L - 6} y={base} textAnchor="end"> |
| 0 |
| </text> |
| |
| {axis.map((slot, xi) => { |
| const x0 = PAD_L + xi * slotW; |
| const groupW = barW * byKey.length; |
| const left = x0 + (slotW - groupW) / 2; |
| return ( |
| <g key={slot.key || `(blank)-${xi}`}> |
| {pickable && onPick ? ( |
| // The whole slot is the target, not the 6px bar. A click target |
| // narrower than a fingertip is a control that only works for |
| // people who already know it is there. |
| <rect |
| className="pg-slot" |
| x={x0} |
| y={PAD_T} |
| width={slotW} |
| height={base - PAD_T} |
| onClick={() => onPick(slot.key)} |
| > |
| <title>{`Open ${slot.label}`}</title> |
| </rect> |
| ) : null} |
| {byKey.map((s, si) => { |
| const b = s.lookup.get(slot.key); |
| const v = b?.value ?? 0; |
| const h = Math.max(v > 0 ? 1 : 0, v * scale); |
| return ( |
| <rect |
| key={s.def.y} |
| className={`pg-bar pg-ser-${si % SERIES_SLOTS}`} |
| x={left + si * barW} |
| y={base - h} |
| width={Math.max(1, barW - 1.5)} |
| height={h} |
| > |
| <title>{`${slot.label} · ${s.def.label}: ${niceNumber(v)}`}</title> |
| </rect> |
| ); |
| })} |
| {(() => { |
| // The server-sent delta over the group ("YoY % on the chart"). |
| // Sits above the group's tallest bar; a slot the server sent |
| // no value for (the partial period) stays honestly unlabelled. |
| const d = deltaByKey?.get(slot.key); |
| if (d == null) return null; |
| const tallest = Math.max( |
| 0, |
| ...byKey.map((s) => { |
| const v = s.lookup.get(slot.key)?.value ?? 0; |
| return Math.max(v > 0 ? 1 : 0, v * scale); |
| }) |
| ); |
| return ( |
| <text |
| className={`pg-bar-delta ${d > 0 ? "is-up" : d < 0 ? "is-down" : ""}`} |
| x={x0 + slotW / 2} |
| y={Math.max(8, base - tallest - 4)} |
| textAnchor="middle" |
| > |
| {deltaLabel(d)} |
| </text> |
| ); |
| })()} |
| {xi % labelEvery === 0 ? ( |
| <text className="pg-ax-lab" x={x0 + slotW / 2} y={base + 14} textAnchor="middle"> |
| {slot.label} |
| </text> |
| ) : null} |
| </g> |
| ); |
| })} |
| </svg> |
| |
| {/* The honesty footer — rule 8b, rendered rather than merely computed. |
| Every claim here comes from `chartData`'s return value, so it cannot |
| disagree with the picture above it. */} |
| <p className="pg-chart-note"> |
| {`${rowsSeen.toLocaleString()} period${rowsSeen === 1 ? "" : "s"} plotted`} |
| {omitted > 0 |
| ? ` · ${omitted} more not drawn, worth ${niceNumber(omittedValue)}` |
| : ""} |
| {missing > 0 ? ` · ${missing} value${missing === 1 ? "" : "s"} the measure could not use` : ""} |
| {pickable ? " · click a period to decompose it" : ""} |
| </p> |
| </div> |
| ); |
| } |
|
|