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.
89 lines
3.0 KiB
Svelte
89 lines
3.0 KiB
Svelte
<script lang="ts">
|
|
// Dispatches a query result to the right rendering for a panel's
|
|
// viz_type. "table" still reuses ResultsTable.svelte; every chart
|
|
// type is Phase 5's ECharts layer ($lib/charts), replacing Phase 3's
|
|
// direct uPlot usage. top_n's *execution* is unchanged (see
|
|
// api/dashboards/types.go's VizType doc comment -- the query itself
|
|
// already did `stats ... | sort | head`), but now actually renders as
|
|
// the ranked horizontal bar chart the name always implied, not a
|
|
// plain table.
|
|
import { goto } from '$app/navigation';
|
|
import ResultsTable from '$lib/ResultsTable.svelte';
|
|
import {
|
|
TimeSeriesChart,
|
|
BarChart,
|
|
TopN,
|
|
Heatmap,
|
|
SingleStat,
|
|
pivot,
|
|
buildDrillDownQuery,
|
|
drillDownUrl
|
|
} from '$lib/charts';
|
|
import type { QueryResult, VizType } from '$lib/api';
|
|
|
|
let {
|
|
result,
|
|
vizType,
|
|
vizConfig = {},
|
|
query,
|
|
onZoom
|
|
}: {
|
|
result: QueryResult;
|
|
vizType: VizType;
|
|
vizConfig?: Record<string, string>;
|
|
// Needed to build a drill-down query (see $lib/charts/drilldown.ts);
|
|
// undefined call sites just lose drill-down, not error.
|
|
query?: string;
|
|
// Bubbles a time-series chart's zoom range up to the dashboard so
|
|
// it can become the new global time range -- see
|
|
// dashboards/[id]/+page.svelte's onZoom handler.
|
|
onZoom?: (range: { earliest: string; latest: string }) => void;
|
|
} = $props();
|
|
|
|
let isTimeSeries = $derived.by(() => {
|
|
if (vizType !== 'line' && vizType !== 'bar') return false;
|
|
return pivot(result.columns, result.rows, { xColumn: vizConfig.x_column }).isTime;
|
|
});
|
|
|
|
function handleDrillDown(point: { seriesName?: string; name: string; value: unknown }, isTime: boolean) {
|
|
if (!query) return;
|
|
// value is [x, y] for line/bar (ECharts passes the raw data tuple
|
|
// back), or just the category label for TopN/Heatmap.
|
|
const xValue = Array.isArray(point.value) ? point.value[0] : point.name;
|
|
const target = buildDrillDownQuery(query, {
|
|
seriesColumn: vizConfig.series_column,
|
|
seriesName: point.seriesName,
|
|
xValue,
|
|
isTime
|
|
});
|
|
goto(drillDownUrl(target));
|
|
}
|
|
|
|
function handleZoom(range: { startValue?: number; endValue?: number }) {
|
|
if (!onZoom || range.startValue == null || range.endValue == null) return;
|
|
onZoom({
|
|
earliest: new Date(range.startValue).toISOString(),
|
|
latest: new Date(range.endValue).toISOString()
|
|
});
|
|
}
|
|
</script>
|
|
|
|
{#if vizType === 'table'}
|
|
<ResultsTable columns={result.columns} rows={result.rows} hasRun={true} />
|
|
{:else if vizType === 'single_stat'}
|
|
<SingleStat {result} config={vizConfig} />
|
|
{:else if vizType === 'heatmap'}
|
|
<Heatmap {result} config={vizConfig} onDrillDown={(pt) => handleDrillDown(pt, false)} />
|
|
{:else if vizType === 'top_n'}
|
|
<TopN {result} config={vizConfig} onDrillDown={(pt) => handleDrillDown(pt, false)} />
|
|
{:else if vizType === 'line'}
|
|
<TimeSeriesChart
|
|
{result}
|
|
config={vizConfig}
|
|
onDrillDown={(pt) => handleDrillDown(pt, isTimeSeries)}
|
|
onZoom={handleZoom}
|
|
/>
|
|
{:else if vizType === 'bar'}
|
|
<BarChart {result} config={vizConfig} onDrillDown={(pt) => handleDrillDown(pt, isTimeSeries)} />
|
|
{/if}
|