Files
cairnobs/web/src/lib/charts/theme.ts
T
jcoffey-dev 5e8b3d8edd 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.
2026-08-16 12:35:40 -07:00

122 lines
4.0 KiB
TypeScript

// 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 <html> 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 } }
}
};
}