+
+
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;