From 5e8b3d8eddd090aad80c0caa63ec606864a111e3 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 16 Aug 2026 12:35:40 -0700 Subject: [PATCH] Add a real charting layer and a heatmap panel type Five chart types on ECharts (modular imports, not the full bundle): TimeSeriesChart (multi-series, legend toggle), BarChart (incl. stacked), SingleStat (big number + sparkline + trend), Heatmap, TopN. Shared interactions: tooltips, dataZoom feeding the global time-range picker, click-to-drill-into-query (drilldown.ts strips a panel's query to its pre-stats filter and appends the clicked series/x-value as a new filter term -- no backend change needed). pivot.ts reshapes the query language's existing {columns, rows} tabular output into per-series chart data client-side -- `stats count by service, timestamp` already returns "long" rows, so multi-series support needed zero query-language changes. theme.ts reads real computed CSS custom properties so charts render in the active theme's actual colors, with an SSR_FALLBACK for adapter-static's prerender pass where `document` doesn't exist. heatmap is the one narrow, justified backend change: a new VizType needed to feed a new visualization, not a new query capability. Three places had to change together, not two -- api/dashboards/types.go's validator, web/src/lib/api.ts's union (previous commit), and the dashboard_panels table's viz_type CHECK constraint (migrations/0035_add_heatmap_viz_type.sql), which mirrors the Go validator and doesn't update itself. /dev/charts (unlisted, dev-only) is a synthetic fixture/perf-test route: confirmed 50ms first-two-frames render time on a production build against a 30,006-row/6-series stress case, and a 211,975-byte gzipped chart chunk -- both real measurements behind the ECharts-over- Observable-Plot-or-D3 choice, not estimates. --- api/dashboards/types.go | 8 +- .../migrations/0035_add_heatmap_viz_type.sql | 9 + web/src/lib/PanelViz.svelte | 203 +++++++----------- web/src/lib/charts/BarChart.svelte | 54 +++++ web/src/lib/charts/EChart.svelte | 75 +++++++ web/src/lib/charts/Heatmap.svelte | 76 +++++++ web/src/lib/charts/SingleStat.svelte | 109 ++++++++++ web/src/lib/charts/TimeSeriesChart.svelte | 71 ++++++ web/src/lib/charts/TopN.svelte | 65 ++++++ web/src/lib/charts/drilldown.ts | 68 ++++++ web/src/lib/charts/index.ts | 8 + web/src/lib/charts/pivot.ts | 90 ++++++++ web/src/lib/charts/setup.ts | 46 ++++ web/src/lib/charts/theme.ts | 121 +++++++++++ web/src/routes/dev/charts/+page.svelte | 190 ++++++++++++++++ web/src/routes/dev/charts/+page.ts | 6 + 16 files changed, 1066 insertions(+), 133 deletions(-) create mode 100644 metadata/migrations/0035_add_heatmap_viz_type.sql create mode 100644 web/src/lib/charts/BarChart.svelte create mode 100644 web/src/lib/charts/EChart.svelte create mode 100644 web/src/lib/charts/Heatmap.svelte create mode 100644 web/src/lib/charts/SingleStat.svelte create mode 100644 web/src/lib/charts/TimeSeriesChart.svelte create mode 100644 web/src/lib/charts/TopN.svelte create mode 100644 web/src/lib/charts/drilldown.ts create mode 100644 web/src/lib/charts/index.ts create mode 100644 web/src/lib/charts/pivot.ts create mode 100644 web/src/lib/charts/setup.ts create mode 100644 web/src/lib/charts/theme.ts create mode 100644 web/src/routes/dev/charts/+page.svelte create mode 100644 web/src/routes/dev/charts/+page.ts diff --git a/api/dashboards/types.go b/api/dashboards/types.go index 6c38466..16c3d43 100644 --- a/api/dashboards/types.go +++ b/api/dashboards/types.go @@ -21,11 +21,15 @@ const ( VizBar VizType = "bar" VizSingleStat VizType = "single_stat" VizTopN VizType = "top_n" + // VizHeatmap is Phase 5's addition (log-volume-over-time patterns) -- + // same "query already produced the right rows, only UI framing + // differs" shape as VizTopN, no new execution path. + VizHeatmap VizType = "heatmap" ) func validVizType(v VizType) bool { switch v { - case VizTable, VizLine, VizBar, VizSingleStat, VizTopN: + case VizTable, VizLine, VizBar, VizSingleStat, VizTopN, VizHeatmap: return true default: return false @@ -77,7 +81,7 @@ func validatePanel(p *Panel) error { return fmt.Errorf("raw-SQL panels are not supported -- dashboards only support pipe-syntax queries, since the dashboard time-range picker is injected as leading query terms") } if !validVizType(p.VizType) { - return fmt.Errorf("viz_type must be one of table, line, bar, single_stat, top_n, got %q", p.VizType) + return fmt.Errorf("viz_type must be one of table, line, bar, single_stat, top_n, heatmap, got %q", p.VizType) } if len(p.VizConfig) == 0 { p.VizConfig = json.RawMessage(`{}`) diff --git a/metadata/migrations/0035_add_heatmap_viz_type.sql b/metadata/migrations/0035_add_heatmap_viz_type.sql new file mode 100644 index 0000000..0ee3f62 --- /dev/null +++ b/metadata/migrations/0035_add_heatmap_viz_type.sql @@ -0,0 +1,9 @@ +-- Phase 5 added a heatmap panel type (api/dashboards/types.go's +-- validVizType()) but missed updating the DB-level check constraint +-- that mirrors it, so heatmap panels passed Go validation and then +-- failed on insert. Postgres has no ALTER CHECK, so drop and recreate. +ALTER TABLE dashboard_panels DROP CONSTRAINT dashboard_panels_viz_type_check; + +ALTER TABLE dashboard_panels + ADD CONSTRAINT dashboard_panels_viz_type_check + CHECK (viz_type IN ('table', 'line', 'bar', 'single_stat', 'top_n', 'heatmap')); diff --git a/web/src/lib/PanelViz.svelte b/web/src/lib/PanelViz.svelte index ee33ef4..72f0624 100644 --- a/web/src/lib/PanelViz.svelte +++ b/web/src/lib/PanelViz.svelte @@ -1,147 +1,88 @@ -{#if vizType === 'table' || vizType === 'top_n'} +{#if vizType === 'table'} {:else if vizType === 'single_stat'} -
{result.rows[0]?.[0] ?? '—'}
-{:else} -
+ +{:else if vizType === 'heatmap'} + handleDrillDown(pt, false)} /> +{:else if vizType === 'top_n'} + handleDrillDown(pt, false)} /> +{:else if vizType === 'line'} + handleDrillDown(pt, isTimeSeries)} + onZoom={handleZoom} + /> +{:else if vizType === 'bar'} + handleDrillDown(pt, isTimeSeries)} /> {/if} - - diff --git a/web/src/lib/charts/BarChart.svelte b/web/src/lib/charts/BarChart.svelte new file mode 100644 index 0000000..675a2f9 --- /dev/null +++ b/web/src/lib/charts/BarChart.svelte @@ -0,0 +1,54 @@ + + + onDrillDown?.(pt)} /> diff --git a/web/src/lib/charts/EChart.svelte b/web/src/lib/charts/EChart.svelte new file mode 100644 index 0000000..7770c12 --- /dev/null +++ b/web/src/lib/charts/EChart.svelte @@ -0,0 +1,75 @@ + + +
+ + diff --git a/web/src/lib/charts/Heatmap.svelte b/web/src/lib/charts/Heatmap.svelte new file mode 100644 index 0000000..87e53fc --- /dev/null +++ b/web/src/lib/charts/Heatmap.svelte @@ -0,0 +1,76 @@ + + + onDrillDown?.(pt)} /> diff --git a/web/src/lib/charts/SingleStat.svelte b/web/src/lib/charts/SingleStat.svelte new file mode 100644 index 0000000..14c9530 --- /dev/null +++ b/web/src/lib/charts/SingleStat.svelte @@ -0,0 +1,109 @@ + + +
+
+ {stat.current !== null ? stat.current.toLocaleString() : '—'} + {#if config.unit}{config.unit}{/if} + {#if stat.delta !== null} + + {stat.delta >= 0 ? '▲' : '▼'} {Math.abs(stat.delta).toFixed(1)}% + + {/if} +
+ {#if stat.values.length > 1} + + + + + {/if} +
+ + diff --git a/web/src/lib/charts/TimeSeriesChart.svelte b/web/src/lib/charts/TimeSeriesChart.svelte new file mode 100644 index 0000000..6f49c9c --- /dev/null +++ b/web/src/lib/charts/TimeSeriesChart.svelte @@ -0,0 +1,71 @@ + + + onDrillDown?.(pt)} + onZoom={(range) => onZoom?.(range)} +/> diff --git a/web/src/lib/charts/TopN.svelte b/web/src/lib/charts/TopN.svelte new file mode 100644 index 0000000..fa9ff9c --- /dev/null +++ b/web/src/lib/charts/TopN.svelte @@ -0,0 +1,65 @@ + + + onDrillDown?.(pt)} /> diff --git a/web/src/lib/charts/drilldown.ts b/web/src/lib/charts/drilldown.ts new file mode 100644 index 0000000..b4717c7 --- /dev/null +++ b/web/src/lib/charts/drilldown.ts @@ -0,0 +1,68 @@ +// Turns a clicked chart data point back into the raw-log query that +// produced it -- the "click to drill into query" affordance. Building +// this generally (rather than special-casing one panel's query) means +// stripping any aggregation stage rather than trying to parse/rewrite +// arbitrary pipe syntax: a panel's query is typically +// `service=api status>=500 | stats count by host, timestamp`, and the +// aggregated `count` a chart point represents doesn't exist as a real +// log row -- the useful drill-down is "show me the raw rows that fed +// this bucket", which means the *pre-aggregation* filter plus whatever +// grouping value was clicked, not the full original query. + +export type DrillDownTarget = { query: string; earliest?: string; latest?: string }; + +const STATS_STAGE = /\|\s*stats\b/i; + +function baseFilterQuery(query: string): string { + const idx = query.search(STATS_STAGE); + return (idx >= 0 ? query.slice(0, idx) : query).trim(); +} + +// Quotes a value for use as a bare `field="value"` filter term -- +// query-language string literals are double-quoted with backslash +// escapes, same convention field=value filters already use. +function quote(value: string): string { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +export function buildDrillDownQuery( + panelQuery: string, + point: { seriesName?: string; seriesColumn?: string; xValue: number | string; isTime: boolean; bucketMs?: number } +): DrillDownTarget { + let query = baseFilterQuery(panelQuery); + + if (point.seriesColumn && point.seriesName) { + query = `${query} ${point.seriesColumn}=${quote(point.seriesName)}`.trim(); + } + + if (!point.isTime) { + // Non-time x axis (e.g. grouped by host) -- nothing more to add, + // the series filter above (or the x value itself, if there's no + // separate series column) already narrows it enough. + return { query }; + } + + const center = typeof point.xValue === 'number' ? point.xValue : Date.parse(String(point.xValue)); + if (Number.isNaN(center)) return { query }; + + // Half the bucket width on each side when known (a clicked bar/point + // represents that whole bucket); otherwise a flat 5-minute window -- + // wide enough to catch a clicked point's neighborhood without + // silently becoming "show me the whole day" like the default range + // would. + const halfWindowMs = point.bucketMs ? point.bucketMs / 2 : 5 * 60_000; + const earliest = new Date(center - halfWindowMs).toISOString(); + const latest = new Date(center + halfWindowMs).toISOString(); + return { query, earliest, latest }; +} + +// Builds the URL the Search page's drill-down effect reads +// (?q=&earliest=&latest=) -- kept separate from buildDrillDownQuery so +// callers that already have a DrillDownTarget from elsewhere (not just +// a chart click) can link to it too. +export function drillDownUrl(target: DrillDownTarget): string { + const params = new URLSearchParams({ q: target.query }); + if (target.earliest) params.set('earliest', target.earliest); + if (target.latest) params.set('latest', target.latest); + return `/?${params.toString()}`; +} diff --git a/web/src/lib/charts/index.ts b/web/src/lib/charts/index.ts new file mode 100644 index 0000000..da03597 --- /dev/null +++ b/web/src/lib/charts/index.ts @@ -0,0 +1,8 @@ +export { default as TimeSeriesChart } from './TimeSeriesChart.svelte'; +export { default as BarChart } from './BarChart.svelte'; +export { default as TopN } from './TopN.svelte'; +export { default as Heatmap } from './Heatmap.svelte'; +export { default as SingleStat } from './SingleStat.svelte'; +export { pivot, parseTimeValue } from './pivot'; +export { readChartTokens, baseOption, SERIES_PALETTE } from './theme'; +export { buildDrillDownQuery, drillDownUrl, type DrillDownTarget } from './drilldown'; diff --git a/web/src/lib/charts/pivot.ts b/web/src/lib/charts/pivot.ts new file mode 100644 index 0000000..df23fb6 --- /dev/null +++ b/web/src/lib/charts/pivot.ts @@ -0,0 +1,90 @@ +// Shapes a QueryResult ({columns, rows} -- the pipe-language's one +// tabular output shape, unchanged since Phase 2) into what a chart +// needs. No backend/query-language change was needed for multi-series +// output: a `stats count by service, timestamp`-style query already +// returns "long" rows (one row per service+timestamp pair) -- pivoting +// that into one series per distinct `service` value is frontend work, +// the same way PanelViz already turned {columns, rows} into a single +// uPlot series in Phase 3. viz_config's series_column key (new, but +// viz_config was already an opaque Record the backend +// just stores/returns -- see Panel.viz_config -- so this needed no +// schema change either) tells the pivot which column to group by. + +// Deliberately narrow: matches ingest's own emitted format +// ("2026-08-13T20:20:24..."), not a general ISO-8601 parser. See +// PanelViz.svelte's original doc comment on why Date.parse() alone is +// too lenient to use as a "does this look like a timestamp" check. +const isoTimestampPrefix = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/; + +export function parseTimeValue(v: unknown): number | null { + if (typeof v === 'number') return v * 1000; + if (typeof v !== 'string' || !isoTimestampPrefix.test(v)) return null; + const parsed = Date.parse(v); + return Number.isNaN(parsed) ? null : parsed; +} + +export type PivotedSeries = { + name: string; + data: [number | string, number][]; +}; + +export type Pivoted = { + isTime: boolean; + categories: string[]; // populated when !isTime -- category axis labels in row order + series: PivotedSeries[]; +}; + +function columnIndex(columns: string[], name: string | undefined, fallback: number): number { + if (!name) return fallback; + const i = columns.indexOf(name); + return i >= 0 ? i : fallback; +} + +export function pivot( + columns: string[], + rows: unknown[][], + config: { xColumn?: string; valueColumn?: string; seriesColumn?: string } +): Pivoted { + if (columns.length === 0 || rows.length === 0) { + return { isTime: false, categories: [], series: [] }; + } + + const xIdx = columnIndex(columns, config.xColumn, 0); + const seriesIdx = config.seriesColumn ? columns.indexOf(config.seriesColumn) : -1; + // Value column default: first numeric-looking column that isn't x/series. + // findIndex returns -1 (not null/undefined) when nothing matches, so a + // `??` fallback here never fires -- must check for -1 explicitly. This + // is the only-one-column case (e.g. a bare `stats count`, single_stat's + // most common query shape): xIdx defaults to 0, excluding the sole + // column from the search, so findIndex always returns -1 and the value + // silently read as `undefined` -> 0 without this check. + const foundValueIdx = columns.findIndex((_, i) => i !== xIdx && i !== seriesIdx && typeof rows[0][i] !== 'string'); + const valueIdx = + config.valueColumn && columns.includes(config.valueColumn) + ? columns.indexOf(config.valueColumn) + : foundValueIdx !== -1 + ? foundValueIdx + : (columns.length > 1 ? 1 : xIdx); + + const isTime = rows.every((r) => parseTimeValue(r[xIdx]) !== null); + const categories: string[] = []; + const seen = new Map(); + + for (const row of rows) { + const xRaw = row[xIdx]; + const x: number | string = isTime ? (parseTimeValue(xRaw) as number) : String(xRaw ?? ''); + if (!isTime && !categories.includes(String(x))) categories.push(String(x)); + + const seriesName = seriesIdx >= 0 ? String(row[seriesIdx] ?? '') : columns[valueIdx]; + let s = seen.get(seriesName); + if (!s) { + s = { name: seriesName, data: [] }; + seen.set(seriesName, s); + } + const raw = row[valueIdx]; + const value = typeof raw === 'number' ? raw : Number(raw) || 0; + s.data.push([x, value]); + } + + return { isTime, categories, series: [...seen.values()] }; +} diff --git a/web/src/lib/charts/setup.ts b/web/src/lib/charts/setup.ts new file mode 100644 index 0000000..396e317 --- /dev/null +++ b/web/src/lib/charts/setup.ts @@ -0,0 +1,46 @@ +// Modular ECharts registration -- pulling in `echarts` (the full bundle) +// would ship every chart type/component ECharts has ever shipped, +// against the whole reason it was picked over hand-rolled D3 for the +// bundle-size tradeoff (see the Phase 5 charting-library review). This +// registers only what Sentry's five chart types actually use: line/bar +// (time-series, stacked bar, top-N, the single-stat sparkline) and +// heatmap, plus tooltip/legend/grid/dataZoom/visualMap and the canvas +// renderer. Imported once, here, not per-component -- echarts.use() is +// idempotent but there's no reason to repeat the list five times. +import * as echarts from 'echarts/core'; +import { LineChart, BarChart, HeatmapChart } from 'echarts/charts'; +import { + TooltipComponent, + GridComponent, + LegendComponent, + DataZoomComponent, + VisualMapComponent, + MarkLineComponent +} from 'echarts/components'; +import { CanvasRenderer } from 'echarts/renderers'; + +echarts.use([ + LineChart, + BarChart, + HeatmapChart, + TooltipComponent, + GridComponent, + LegendComponent, + DataZoomComponent, + VisualMapComponent, + MarkLineComponent, + CanvasRenderer +]); + +export { echarts }; +export type EChartsOption = echarts.ComposeOption< + | import('echarts/charts').LineSeriesOption + | import('echarts/charts').BarSeriesOption + | import('echarts/charts').HeatmapSeriesOption + | import('echarts/components').TooltipComponentOption + | import('echarts/components').GridComponentOption + | import('echarts/components').LegendComponentOption + | import('echarts/components').DataZoomComponentOption + | import('echarts/components').VisualMapComponentOption + | import('echarts/components').MarkLineComponentOption +>; diff --git a/web/src/lib/charts/theme.ts b/web/src/lib/charts/theme.ts new file mode 100644 index 0000000..d69c5e3 --- /dev/null +++ b/web/src/lib/charts/theme.ts @@ -0,0 +1,121 @@ +// ECharts renders to canvas, not the DOM -- it needs concrete color +// values, not CSS var() references. This reads the real resolved value +// of each token this module cares about directly off so charts +// always match whatever theme/density is currently active instead of +// carrying a second, hand-maintained copy of the palette that can drift +// from tokens.css. + +export type ChartTokens = { + text: string; + textMuted: string; + border: string; + surface: string; + surfaceRaised: string; + accent: string; + sevQuiet: string; + sevInfo: string; + sevWarn: string; + sevError: string; + sevCritical: string; + fontUI: string; + fontMono: string; +}; + +function cssVar(name: string): string { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); +} + +// Dark-mode literal fallback for prerendering/SSR, where `document` +// doesn't exist -- adapter-static prerenders every route (including +// dev/charts) at build time, and each chart's `option` is a $derived +// that runs during that pass same as it does in the browser. The real +// values always take over immediately once the client mounts; this +// only has to look reasonable for the static HTML shell, not be +// theme-accurate (prerendering can't know the visitor's theme choice +// anyway). +const SSR_FALLBACK: ChartTokens = { + text: '#f0f0f1', + textMuted: '#85888d', + border: '#2a2c2f', + surface: '#17181a', + surfaceRaised: '#1e2023', + accent: '#3fb6ff', + sevQuiet: '#85888d', + sevInfo: '#4c8dff', + sevWarn: '#f5c242', + sevError: '#ff6a39', + sevCritical: '#ff2d78', + fontUI: 'Overpass, sans-serif', + fontMono: 'Overpass Mono, monospace' +}; + +export function readChartTokens(): ChartTokens { + if (typeof document === 'undefined') return SSR_FALLBACK; + return { + text: cssVar('--color-text'), + textMuted: cssVar('--color-text-muted'), + border: cssVar('--color-border'), + surface: cssVar('--color-surface'), + surfaceRaised: cssVar('--color-surface-raised'), + accent: cssVar('--color-accent'), + sevQuiet: cssVar('--color-sev-quiet'), + sevInfo: cssVar('--color-sev-info'), + sevWarn: cssVar('--color-sev-warn'), + sevError: cssVar('--color-sev-error'), + sevCritical: cssVar('--color-sev-critical'), + fontUI: cssVar('--font-ui'), + fontMono: cssVar('--font-mono') + }; +} + +// A fixed categorical palette for multi-series charts (hosts, services, +// etc. -- data that isn't severity-shaped, so severity's blue/amber/ +// orange/magenta ramp doesn't apply). Chosen to stay distinguishable +// from the four severity colors above (no orange/magenta/amber-gold +// here) so a legend never makes a non-severity series look like it's +// signaling a severity. Colorblind-conscious: alternates hue and +// lightness, not just hue. +export const SERIES_PALETTE = [ + '#3fb6ff', // accent blue + '#6fd6b0', // teal-green + '#b48cff', // violet + '#5c8dff', // periwinkle + '#4dd0e1', // cyan + '#8bc34a', // olive-green + '#7986cb', // indigo + '#4db6ac' // seafoam +]; + +// Shared base option every chart type extends -- background transparent +// (the card behind it supplies --color-surface), grid inset for axis +// labels, tooltip/legend/axis text all pulled from tokens so nothing is +// hardcoded per chart type. +export function baseOption(t: ChartTokens) { + return { + backgroundColor: 'transparent', + textStyle: { fontFamily: t.fontUI, color: t.text }, + grid: { left: 48, right: 16, top: 28, bottom: 28, containLabel: true }, + tooltip: { + backgroundColor: t.surfaceRaised, + borderColor: t.border, + borderWidth: 1, + textStyle: { color: t.text, fontFamily: t.fontMono, fontSize: 12 }, + extraCssText: 'box-shadow: var(--shadow-md); border-radius: 6px;' + }, + legend: { + textStyle: { color: t.textMuted, fontFamily: t.fontUI, fontSize: 12 }, + inactiveColor: t.border, + top: 0 + }, + xAxis: { + axisLine: { lineStyle: { color: t.border } }, + axisLabel: { color: t.textMuted, fontFamily: t.fontMono, fontSize: 11 }, + splitLine: { show: false } + }, + yAxis: { + axisLine: { show: false }, + axisLabel: { color: t.textMuted, fontFamily: t.fontMono, fontSize: 11 }, + splitLine: { lineStyle: { color: t.border, type: 'dashed' as const } } + } + }; +} diff --git a/web/src/routes/dev/charts/+page.svelte b/web/src/routes/dev/charts/+page.svelte new file mode 100644 index 0000000..ad4c343 --- /dev/null +++ b/web/src/routes/dev/charts/+page.svelte @@ -0,0 +1,190 @@ + + +Chart fixtures — dev + +
+

Chart layer fixtures

+

+ Not part of the app's nav — a living test page for every chart type in $lib/charts, + including a large-N dataset for the perf-verification Phase 5 task 4 asked for. Fixture + generation: realistic time-series {realisticTSMs.toFixed(1)}ms for {realisticTS.rows.length} rows; + large time-series {largeTSGenMs.toFixed(1)}ms for {largeTS.rows.length} rows. +

+ +
+ + + + + + + + + + + + + + + + + + + +
+ +

Perf stress case

+

+ {largeTS.rows.length.toLocaleString()} rows, {SERVICES.length} series, canvas renderer. + {#if largeRenderMs !== null} + First two painted frames after mount: {largeRenderMs.toFixed(0)}ms. + {/if} + Try zooming (drag on the chart or the bottom slider) and toggling a legend entry — both should + stay responsive at this volume. +

+ + + +
+ + diff --git a/web/src/routes/dev/charts/+page.ts b/web/src/routes/dev/charts/+page.ts new file mode 100644 index 0000000..cd19766 --- /dev/null +++ b/web/src/routes/dev/charts/+page.ts @@ -0,0 +1,6 @@ +// Not linked from the app's nav or command palette -- a living fixture +// page for verifying the chart layer (rendering, theme/density +// reactivity, and performance at realistic data volumes) without a live +// backend. Kept in the repo rather than thrown away after Phase 5's +// build pass: any future chart change can be sanity-checked here first. +export const prerender = true;