# Files changed · PR \#131 · flambo/atlantis

[View on GitCafe](https://git.cafe/flambo/atlantis/pull/131/diffs)

Repository: [flambo/atlantis](https://git.cafe/flambo/atlantis)

Visibility: public

[feat\(web\): migrate charts to TanStack Charts](https://git.cafe/flambo/atlantis/pull/131.md)

Version: 0

Head commit: ab5316e0786e50a468fd0cefd926f8cbf9c4f6f8

Base commit: 62e2a6dfdf66e82e298b9d919010683c3ad198cf

Comparison base: 62e2a6dfdf66e82e298b9d919010683c3ad198cf

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/package.json

Status: modified; +3; −2

```diff
@@ -32,6 +32,7 @@
 		"@sveltejs/vite-plugin-svelte": "^7.3.1",
 		"@tailwindcss/vite": "^4.0.0",
 		"@types/better-sqlite3": "^7.6.13",
+		"@types/d3-scale": "4.0.9",
 		"@types/node": "^22.15.17",
 		"bits-ui": "^2.16.3",
 		"clsx": "^2.1.1",
@@ -56,9 +57,9 @@
 	},
 	"dependencies": {
 		"@lucide/svelte": "^1.33.0",
+		"@tanstack/charts": "1.0.0",
 		"better-sqlite3": "^12.6.2",
+		"d3-scale": "4.0.2",
-		"chart.js": "^4.4.9",
-		"chartjs-plugin-annotation": "^3.1.0",
 		"drizzle-orm": "1.0.0-rc.5-ab785fc",
 		"zod": "^4.3.6"
 	},
```

## apps/web/src/lib/components/charts/BreakdownChart.svelte

Status: modified; +155; −1051

```diff
@@ -1,18 +1,6 @@
 <script lang="ts" generics="Kind extends BreakdownChartKind">
-	import { onDestroy, untrack } from 'svelte';
-	import { createSubscriber } from 'svelte/reactivity';
-	import { finiteSpectrumPoints, paddedSpectrumBounds } from './spectrum-points';
 	import { goto } from '$app/navigation';
-	import { Chart } from './chart-registry';
-	import { buildCoveragePointStyle } from './coverage-line-style';
-	import { getRelativePosition } from 'chart.js/helpers';
-	import type { ActiveElement, ChartEvent } from 'chart.js';
 	import type { GroupByOption, RouterConfig } from '#lib/components/netflow/types.ts';
-	import ChartCard from './ChartCard.svelte';
-	import { Checkbox } from '#lib/components/ui/checkbox/index.ts';
-	import { Skeleton } from '#lib/components/ui/skeleton/index.ts';
-	import SegmentedControl from '#lib/components/common/SegmentedControl.svelte';
-	import { navigateToNetflowFile } from '#lib/utils/netflow-file-navigation.ts';
 	import {
 		DEFAULT_MAAD_IP_VERSION,
 		MAAD_IP_VERSION_OPTIONS,
@@ -28,6 +16,16 @@
 	} from '#lib/types/types.ts';
 	import type { SpectrumStatsPayload } from '#lib/types/spectrum-stats.ts';
 	import type { DimensionMetricKey } from '#lib/types/dimension-stats.ts';
+	import { navigateToNetflowFile } from '#lib/utils/netflow-file-navigation.ts';
+	import { dateStringToEpochPST, formatDateAsPSTDateString } from '#lib/utils/timezone.ts';
+	import { ensureCachedWindow, readCachedWindow, type TimeRange } from '#lib/utils/window-cache.ts';
+	import { Checkbox } from '#lib/components/ui/checkbox/index.ts';
+	import { Skeleton } from '#lib/components/ui/skeleton/index.ts';
+	import SegmentedControl from '#lib/components/common/SegmentedControl.svelte';
+	import ChartCard from './ChartCard.svelte';
+	import ChartPlot from './ChartPlot.svelte';
+	import { plotBounds, finitePoint, type PlotSeries, type PlotOptions } from './chart-registry';
+	import { finiteSpectrumPoints, paddedSpectrumBounds } from './spectrum-points';
 	import {
 		BREAKDOWN_CHART_CONFIGS,
 		DIMENSION_ORDER_OPTIONS,
@@ -42,39 +40,9 @@
 		type DimensionSide,
 		type LineBucketData
 	} from './breakdown-chart-config';
-	import {
-		generateSlugFromLabel,
-		parseClickedLabel,
-		formatNumber,
-		Y_AXIS_WIDTH,
-		MIN_DRAG_PIXELS,
-		groupByBucketDurationMs,
-		chooseAdaptiveGranularity,
-		createRangeDragState,
-		getSelectionLabels,
-		indexFromPixelX,
-		beginRangeDrag,
-		updateRangeDrag,
-		endRangeDrag,
-		buildMirroredSelectionStyle,
-		findTemporalDataBounds,
-		getChartBucketCoverage,
-		isCoverageSegmentDashed,
-		type ChartCoverage
-	} from './chart-utils';
-	import {
+	import { formatNumber, groupByBucketDurationMs, getChartBucketCoverage } from './chart-utils';
+	import { formatTemporalBucketLabel, formatIpGranularityTick } from './ip-time-axis';
-		formatIpGranularityTick,
-		formatTemporalBucketLabel,
-		shouldHighlightIpGranularityGrid,
-		placeTicksOnBucketStarts
-	} from './ip-time-axis';
-	import { dateStringToEpochPST, formatDateAsPSTDateString } from '#lib/utils/timezone.ts';
-	import { crosshairStore } from '#lib/stores/crosshair.ts';
-	import { rangeSelection } from '#lib/stores/rangeSelection.svelte.ts';
-	import { theme } from '#lib/stores/theme.svelte.ts';
-	import { cancelDrawFrame, requestDrawFrame } from '#lib/utils/animation-frame.ts';
-	import { ensureCachedWindow, readCachedWindow, type TimeRange } from '#lib/utils/window-cache.ts';
-
+	import { openTemporalPoint, openTemporalRange } from './temporal-navigation';
 	const IP_TO_GROUP_BY: Record<IpGranularity, GroupByOption> = {
 		'1d': 'date',
 		'1h': 'hour',
@@ -82,15 +50,6 @@
 		'10m': '10min',
 		'5m': '5min'
 	};
-
-	const GROUP_BY_TRANSITIONS: Record<GroupByOption, GroupByOption | null> = {
-		date: 'hour',
-		hour: '10min',
-		'30min': '5min',
-		'10min': '5min',
-		'5min': null
-	};
-
 	type MetricsForKind<ChartKind extends BreakdownChartKind> = ChartKind extends 'ip'
 		? IpMetricKey[]
 		: ChartKind extends 'protocol'
@@ -125,7 +84,6 @@
 	}
 
 	const config = $derived(getConfig(props.kind));
-	const CHART_ID = $derived(config.chartId);
 
 	const today = new Date();
 	const formatDate = (date: Date): string => formatDateAsPSTDateString(date);
@@ -225,973 +183,154 @@
 					.join(' · ')
 			: null
 	);
-	let bucketStarts: number[] = [];
 
+	function deriveSelectedRouters(routers: RouterConfig | undefined): string[] {
+		return Object.entries(routers ?? {})
+			.filter(([, enabled]) => enabled)
+			.map(([name]) => name.trim())
+			.filter(Boolean)
+			.sort();
+	}
-	let chartCanvas: HTMLCanvasElement | null = null;
-	let chart: Chart | null = null;
-	let rangeDrag = $state(createRangeDragState());
-	let selectionLeft = $derived(Math.min(rangeDrag.dragStartX, rangeDrag.dragCurrentX));
-	let selectionWidth = $derived(Math.abs(rangeDrag.dragStartX - rangeDrag.dragCurrentX));
-	let mirroredRange = $derived(rangeSelection.selection);
-	let pointerMoveFrame: number | null = null;
-	let pendingPointerMoveEvent: MouseEvent | null = null;
-	let localHoverLabel: string | null = null;
-	let externalHoverLabel: string | null = null;
-	let localHoverX: number | null = null;
-	let externalHoverX: number | null = null;
-	let notifyHover = () => {};
-	let showLocalTooltip = false;
-	let tooltipTimeout: ReturnType<typeof setTimeout> | null = null;
-
 	function pointsForBucket(bucket: BreakdownChartBucket): SpectrumPoint[] {
 		if (!bucket.data || !('spectrumSa' in bucket.data)) return [];
 		return finiteSpectrumPoints(
 			addressType === 'sa' ? bucket.data.spectrumSa : bucket.data.spectrumDa
 		);
 	}
-
-	function nearestBucketIndex(value: number): number | null {
-		if (!Number.isFinite(value) || bucketStarts.length === 0) return null;
-		let closestIndex = 0;
-		let closestDistance = Math.abs(bucketStarts[0] - value);
-		for (let index = 1; index < bucketStarts.length; index += 1) {
-			const distance = Math.abs((bucketStarts[index] ?? 0) - value);
-			if (distance < closestDistance) {
-				closestIndex = index;
-				closestDistance = distance;
-			}
-		}
-		return closestIndex;
-	}
-
-	const hasSelectedSpectrumData = $derived(
-		buckets.some((bucket) => {
-			const points = pointsForBucket(bucket);
-			return points.length > 0;
-		})
-	);
-
-	const subscribeHover = createSubscriber((update) => {
-		notifyHover = update;
-		const unsubscribe = crosshairStore.subscribe(({ label, sourceChartId }) => {
-			externalHoverLabel = sourceChartId === CHART_ID ? null : label;
-			externalHoverX = getPixelForLabel(externalHoverLabel);
-			update();
-		});
-		return () => {
-			unsubscribe();
-			notifyHover = () => {};
-		};
-	});
-	const hover = $derived.by(() => {
-		subscribeHover();
-		return {
-			localHoverLabel,
-			localHoverX,
-			showLocalTooltip,
-			activeCrosshairX: localHoverX ?? externalHoverX
-		};
-	});
-
-	function toEpochSeconds(dateString: string, isEnd = false): number {
-		return dateStringToEpochPST(dateString, isEnd);
-	}
-
-	function getBucketStartForTickValue(value: unknown): number | null {
-		if (typeof value !== 'number' || !Number.isFinite(value)) {
-			return null;
-		}
-		const index = nearestBucketIndex(value);
-		return index === null ? null : (bucketStarts[index] ?? null);
-	}
-
-	function getChartColors() {
-		const style = getComputedStyle(document.documentElement);
-		return {
-			textColor: style.getPropertyValue('--chart-text-color').trim(),
-			gridColor: style.getPropertyValue('--chart-grid-color').trim(),
-			gridHighlightColor: style.getPropertyValue('--chart-grid-highlight-color').trim(),
-			tooltipBackgroundColor: style.getPropertyValue('--chart-tooltip-bg').trim(),
-			tooltipTextColor: style.getPropertyValue('--chart-tooltip-text-color').trim(),
-			tooltipBorderColor: style.getPropertyValue('--chart-tooltip-border-color').trim()
-		};
-	}
-
-	function getPixelForLabel(label: string | null): number | null {
-		if (!chart || !label || !chart.data.labels) {
-			return null;
-		}
-		const labels = chart.data.labels as string[];
-		const index = labels.indexOf(label);
-		if (index === -1) {
-			return null;
-		}
-		const bucketStart = bucketStarts[index];
-		return bucketStart === undefined ? null : chart.scales.x.getPixelForValue(bucketStart);
-	}
-
-	function syncCrosshairPositions() {
-		localHoverX = getPixelForLabel(localHoverLabel);
-		externalHoverX = getPixelForLabel(externalHoverLabel);
-		notifyHover();
-	}
-
-	function clearTooltipDelay() {
-		if (tooltipTimeout !== null) {
-			clearTimeout(tooltipTimeout);
-			tooltipTimeout = null;
-		}
-	}
-
-	function scheduleTooltip() {
-		clearTooltipDelay();
-		showLocalTooltip = false;
-		if (!localHoverLabel) {
-			return;
-		}
-		tooltipTimeout = setTimeout(() => {
-			showLocalTooltip = true;
-			notifyHover();
-		}, 500);
-	}
-
-	function clearLocalHover() {
-		const hadLocalHover = localHoverLabel !== null || localHoverX !== null;
-		localHoverLabel = null;
-		localHoverX = null;
-		showLocalTooltip = false;
-		clearTooltipDelay();
-		notifyHover();
-		if (hadLocalHover && crosshairStore.sourceChartId === CHART_ID) {
-			crosshairStore.clearHover();
-		}
-	}
-
-	function hideCrosshairOverlay() {
-		clearLocalHover();
-		externalHoverX = getPixelForLabel(externalHoverLabel);
-		notifyHover();
-	}
-
-	function updateLocalCrosshair(event: MouseEvent) {
-		if (!chart || !chartCanvas || rangeDrag.isDraggingRange) {
-			return;
-		}
-
-		const rect = chartCanvas.getBoundingClientRect();
-		const x = event.clientX - rect.left;
-		const y = event.clientY - rect.top;
-		const area = chart.chartArea;
-		const isInChartArea = x >= area.left && x <= area.right && y >= area.top && y <= area.bottom;
-
-		if (!isInChartArea) {
-			clearLocalHover();
-			return;
-		}
-
-		const rawIndex = chart.scales.x.getValueForPixel(x);
-		if (typeof rawIndex !== 'number' || !Number.isFinite(rawIndex) || bucketStarts.length === 0) {
-			clearLocalHover();
-			return;
-		}
-
-		const nearestIndex = nearestBucketIndex(rawIndex);
-		if (nearestIndex === null) {
-			clearLocalHover();
-			return;
-		}
-		const nextIndex = nearestIndex;
-		const nextLabel = getLabelFromIndex(nextIndex);
-		if (!nextLabel) {
-			clearLocalHover();
-			return;
-		}
-
-		const nextX = chart.scales.x.getPixelForValue(bucketStarts[nextIndex] ?? 0);
-		const labelChanged = nextLabel !== localHoverLabel;
-		localHoverLabel = nextLabel;
-		localHoverX = nextX;
-		if (labelChanged) {
-			scheduleTooltip();
-			crosshairStore.setHover(nextLabel, CHART_ID);
-		}
-		notifyHover();
-	}
-
-	function getCrosshairLineStyle(x: number | null): string | null {
-		if (x === null || !chart) {
-			return null;
-		}
-		const area = chart.chartArea;
-		const snappedX = Math.round(x) + 0.5;
-		return `left:${snappedX}px; top:${area.top}px; width:1px; height:${area.bottom - area.top}px; background-image:repeating-linear-gradient(to bottom, rgba(100,100,100,0.8) 0 3px, transparent 3px 6px);`;
-	}
-
-	function getCrosshairTooltipStyle(x: number | null): string | null {
-		if (x === null || !chart) {
-			return null;
-		}
-
-		const area = chart.chartArea;
-		const snappedX = Math.round(x) + 0.5;
-		const tooltipWidth = 190;
-		const left = Math.min(
-			Math.max(snappedX - tooltipWidth / 2, area.left + 5),
-			area.right - tooltipWidth - 5
-		);
-		const top = Math.max(6, area.top - 34);
-		return `left:${left}px; top:${top}px; width:${tooltipWidth}px;`;
-	}
-
-	// Color gradient function based on f value
-	// Purple (low f) -> Blue -> Cyan -> Green -> Yellow (high f)
-	function getColorForF(f: number, minF: number, maxF: number): string {
-		if (maxF === minF) return 'hsl(180, 70%, 50%)';
-		const normalized = (f - minF) / (maxF - minF);
-		// HSL: hue 270=purple, 180=cyan, 120=green, 60=yellow
-		const hue = 270 - normalized * 210; // 270 (purple) to 60 (yellow)
-		return `hsl(${hue}, 70%, 50%)`;
-	}
-
-	function destroyChart() {
-		if (chart) {
-			if (props.kind !== 'spectrum') {
-				crosshairStore.unregister(CHART_ID);
-			}
-			chart.destroy();
-			chart = null;
-		}
-		if (crosshairStore.sourceChartId === CHART_ID) {
-			crosshairStore.clearHover();
-		}
-		clearTooltipDelay();
-		localHoverLabel = null;
-		localHoverX = null;
-		showLocalTooltip = false;
-		externalHoverX = null;
-		notifyHover();
-	}
-
-	function deriveSelectedRouters(routerConfig: RouterConfig | undefined): string[] {
-		if (!routerConfig) {
-			return [];
-		}
-		return Object.entries(routerConfig)
-			.filter(([, enabled]) => enabled)
-			.map(([name]) => name.trim())
-			.filter((name) => name.length > 0)
-			.sort();
-	}
-
-	function buildColors(metricIndex: number, routerIndex: number) {
-		const metric = config.metrics[metricIndex];
-		if (!metric?.color) {
-			return { stroke: 'transparent', fill: 'transparent' };
-		}
-		const hue = (metric.color.hue + routerIndex * config.routerHueStep) % 360;
-		const stroke = `hsl(${hue}, ${metric.color.saturation}%, ${metric.color.lightness}%)`;
-		const fill = `hsla(${hue}, ${metric.color.saturation}%, ${metric.color.lightness}%, ${config.fillAlpha})`;
-		return { stroke, fill };
-	}
-
-	function buildRouterColors(router: string) {
-		const index = Object.keys(props.routers ?? {})
-			.map((name) => name.trim())
-			.sort()
-			.indexOf(router);
-		const slot = index >= 0 && index < 8 ? `${index + 1}` : 'other';
-		const color = getComputedStyle(document.documentElement)
-			.getPropertyValue(`--chart-series-${slot}`)
-			.trim();
-		return { stroke: color, fill: color };
-	}
-
 	function handleMetricToggle(metric: BreakdownMetricKey) {
+		const next = activeMetrics.includes(metric)
-		const nextMetrics = activeMetrics.includes(metric)
+			? activeMetrics.filter((value) => value !== metric)
-			? activeMetrics.filter((item) => item !== metric)
 			: [...activeMetrics, metric];
-		activeMetrics = nextMetrics;
-		props.onMetricsChange?.({
-			metrics: nextMetrics as MetricsForKind<Kind>
-		});
-	}
-
-	function emitDrilldown(nextGroupBy: GroupByOption, start: Date, end: Date) {
-		props.onDrillDown?.({
-			groupBy: nextGroupBy,
-			startDate: formatDate(start),
-			endDate: formatDate(end)
+		activeMetrics = next;
+		props.onMetricsChange?.({ metrics: next as MetricsForKind<Kind> });
-		});
 	}
-
 	function handleRouterChange(router: string) {
-		if (router === (props.router ?? '')) {
-			return;
-		}
 		props.onRouterChange?.({ router });
 	}
+	function handleAddressTypeChange(side: 'sa' | 'da') {
+		addressType = side;
+		props.onAddressTypeChange?.({ addressType: side });
-
-	function handleAddressTypeChange(nextAddressType: 'sa' | 'da') {
-		if (nextAddressType === addressType) {
-			return;
-		}
-		addressType = nextAddressType;
-		props.onAddressTypeChange?.({ addressType: nextAddressType });
 	}
-
 	function handleDimensionChange(side: DimensionSide, order: DimensionOrder) {
-		const nextMetrics = [dimensionMetricKey(side, order)];
-		activeMetrics = nextMetrics;
-		props.onMetricsChange?.({ metrics: nextMetrics as MetricsForKind<Kind> });
-	}
-
-	function publishRangeSelection(startIndex: number, endIndex: number) {
-		const labels = getSelectionLabels(chart, startIndex, endIndex);
-		if (!labels) return;
-		rangeSelection.set({ sourceChartId: CHART_ID, ...labels });
-	}
-
-	function applyRangeDrilldown(startIndex: number, endIndex: number) {
-		if (!chart?.data.labels) return;
-		const labels = chart.data.labels as string[];
-		const from = Math.max(0, Math.min(labels.length - 1, Math.min(startIndex, endIndex)));
-		const to = Math.max(0, Math.min(labels.length - 1, Math.max(startIndex, endIndex)));
-		const startLabel = labels[from];
-		const endLabel = labels[to];
-		if (!startLabel || !endLabel) return;
-
-		const groupBy = IP_TO_GROUP_BY[currentGranularity];
-		if (!groupBy) return;
-
-		const startDate = parseClickedLabel(startLabel, groupBy);
-		const endBucketStart = parseClickedLabel(endLabel, groupBy);
-		if (Number.isNaN(startDate.getTime()) || Number.isNaN(endBucketStart.getTime())) return;
-		const endExclusive = new Date(endBucketStart.getTime() + groupByBucketDurationMs(groupBy));
-		const selectedRangeMs = endExclusive.getTime() - startDate.getTime();
-		const nextGroupBy = chooseAdaptiveGranularity(selectedRangeMs);
-		emitDrilldown(nextGroupBy, startDate, endExclusive);
-	}
-
-	function handleRangeMouseDown(event: MouseEvent) {
-		cancelPendingPointerMove();
-		if (props.kind === 'spectrum') {
-			clearLocalHover();
-		}
-		beginRangeDrag(rangeDrag, event, chartCanvas, chart, publishRangeSelection, bucketStarts);
-	}
-
-	function applyPendingPointerMove() {
-		pointerMoveFrame = null;
-		const event = pendingPointerMoveEvent;
-		pendingPointerMoveEvent = null;
-		if (!event) {
-			return;
-		}
-		updateRangeDrag(rangeDrag, event, chartCanvas, chart, publishRangeSelection, bucketStarts);
-		if (props.kind === 'spectrum') {
-			updateLocalCrosshair(event);
-		}
-	}
-
-	function cancelPendingPointerMove() {
-		if (pointerMoveFrame !== null) {
-			cancelDrawFrame(pointerMoveFrame);
-			pointerMoveFrame = null;
-		}
-		pendingPointerMoveEvent = null;
-	}
-
-	function flushPendingPointerMove() {
-		if (pointerMoveFrame === null) {
-			return;
-		}
-		cancelDrawFrame(pointerMoveFrame);
-		applyPendingPointerMove();
-	}
-
-	function handleRangeMouseMove(event: MouseEvent) {
-		if (!rangeDrag.isDraggingRange && props.kind !== 'spectrum') {
-			return;
-		}
-		pendingPointerMoveEvent = event;
-		if (pointerMoveFrame === null) {
-			pointerMoveFrame = requestDrawFrame(applyPendingPointerMove);
-		}
-	}
-
-	function finishRangeSelection() {
-		flushPendingPointerMove();
-		endRangeDrag(rangeDrag, chart, applyRangeDrilldown, bucketStarts);
-		rangeSelection.clear();
-	}
-
-	function handlePointerLeave() {
-		finishRangeSelection();
-		hideCrosshairOverlay();
-	}
-
-	const mirroredSelectionStyle = $derived.by(() => {
-		subscribeHover();
-		return buildMirroredSelectionStyle(chart, mirroredRange, CHART_ID, bucketStarts);
-	});
-
-	function getLabelFromIndex(index: number): string | null {
-		if (!chart || !chart.data.labels) {
-			return null;
-		}
-		const labels = chart.data.labels as string[];
-		if (index < 0 || index >= labels.length) {
-			return null;
-		}
-		return labels[index] ?? null;
-	}
-
-	function handleChartClick(event: ChartEvent, activeElements: ActiveElement[]) {
-		if (rangeDrag.suppressNextClick) {
-			rangeDrag.suppressNextClick = false;
-			return;
-		}
-		if (!chart || !chart.data.labels) {
-			return;
-		}
-
-		const groupBy = IP_TO_GROUP_BY[currentGranularity];
-		if (!groupBy) {
-			return;
-		}
-
-		const labels = chart.data.labels as string[];
-		if (labels.length === 0 || bucketStarts.length === 0) {
-			return;
-		}
-
-		const canvasPosition = getRelativePosition(event, chart);
-		const dataX = chart.scales.x.getValueForPixel(canvasPosition.x);
-		const labelIndex =
-			props.kind === 'spectrum'
-				? typeof dataX === 'number' && Number.isFinite(dataX)
-					? nearestBucketIndex(dataX)
-					: null
-				: indexFromPixelX(chart, canvasPosition.x);
-		const fallbackIndex =
-			activeElements.length === 0
-				? null
-				: props.kind === 'spectrum'
-					? labelIndex
-					: activeElements[0].index;
-		const targetIndex = labelIndex ?? fallbackIndex;
-		const label = targetIndex !== null ? getLabelFromIndex(targetIndex) : labels[0];
-
-		if (!label) {
-			return;
-		}
-
-		const clickedDate = parseClickedLabel(label, groupBy);
-		if (!(clickedDate instanceof Date) || Number.isNaN(clickedDate.getTime())) {
-			if (props.kind !== 'protocol') {
-				console.warn('Unable to parse clicked label for drilldown', { label, groupBy });
-			}
-			return;
-		}
-		const activeLabel = fallbackIndex !== null ? getLabelFromIndex(fallbackIndex) : null;
-
-		if (groupBy === '5min') {
-			const labelForSlug = activeLabel ?? label;
-			const slug = generateSlugFromLabel(labelForSlug, '5min');
-			if (slug) {
-				void navigateToNetflowFile(
-					goto,
-					slug,
-					props.dataset,
-					props.direction ?? 'all',
-					props.ipVersion,
-					props.measure
-				);
-			}
-			return;
-		}
-
-		const nextGroupBy = GROUP_BY_TRANSITIONS[groupBy];
-		if (!nextGroupBy) {
-			return;
-		}
-
-		if (groupBy === 'date') {
-			const rangeStart = new Date(clickedDate.getTime() - 15 * 24 * 60 * 60 * 1000);
-			const rangeEnd = new Date(clickedDate.getTime() + 16 * 24 * 60 * 60 * 1000);
-			emitDrilldown(nextGroupBy, rangeStart, rangeEnd);
-		} else if (groupBy === 'hour') {
-			const rangeStart = new Date(clickedDate.getTime() - 3 * 24 * 60 * 60 * 1000);
-			const rangeEnd = new Date(clickedDate.getTime() + 4 * 24 * 60 * 60 * 1000);
-			emitDrilldown(nextGroupBy, rangeStart, rangeEnd);
-		} else if (groupBy === '30min' || groupBy === '10min') {
-			const rangeEnd = new Date(clickedDate.getTime() + 24 * 60 * 60 * 1000);
-			emitDrilldown(nextGroupBy, clickedDate, rangeEnd);
-		}
-	}
-
-	interface DataPoint {
-		x: number;
-		y: number;
-		f: number;
-		timeLabel: string;
-	}
-
-	function buildDatasets(
-		selectedBuckets: BreakdownChartBucket[],
-		bucketStarts: number[]
-	): {
-		data: DataPoint[];
-		minF: number;
-		maxF: number;
-		minAlpha: number;
-		maxAlpha: number;
-	} {
-		const pointsByBucketStart: Record<number, SpectrumPoint[]> = {};
-		selectedBuckets.forEach((bucket) => {
-			const points = pointsForBucket(bucket);
-			if (points.length > 0) {
-				pointsByBucketStart[bucket.bucketStart] = points;
-			}
-		});
-
-		// Find global min/max for f and alpha
-		let minF = Infinity;
-		let maxF = -Infinity;
-		let minAlpha = Infinity;
-		let maxAlpha = -Infinity;
-
-		Object.values(pointsByBucketStart).forEach((points) => {
-			points.forEach((point) => {
-				minF = Math.min(minF, point.f);
-				maxF = Math.max(maxF, point.f);
-				minAlpha = Math.min(minAlpha, point.alpha);
-				maxAlpha = Math.max(maxAlpha, point.alpha);
-			});
-		});
-
-		if (minF === Infinity) {
-			return { data: [], minF: 0, maxF: 0, minAlpha: 0, maxAlpha: 0 };
-		}
-
-		// Build data points: each (time, alpha) has an f value for coloring
-		const data: DataPoint[] = [];
-
-		bucketStarts.forEach((bucketStart) => {
-			const points = pointsByBucketStart[bucketStart];
-			if (!points || points.length === 0) return;
-
-			const timeLabel = formatTemporalBucketLabel(bucketStart, currentGranularity);
-
-			points.forEach((point) => {
-				data.push({
+		activeMetrics = [dimensionMetricKey(side, order)];
+		props.onMetricsChange?.({ metrics: activeMetrics as MetricsForKind<Kind> });
-					x: bucketStart,
-					y: point.alpha,
-					f: point.f,
-					timeLabel
-				});
-			});
-		});
-
-		return { data, minF, maxF, minAlpha, maxAlpha };
 	}
-
-	function buildLineScales(
-		textColor: string,
-		gridColor: string,
-		gridHighlightColor: string,
-		dataBounds: { min: number; max: number },
-		tickBucketStarts: number[],
-		yAxisTitle: string
-	) {
-		return {
-			x: {
-				type: 'linear' as const,
-				min: dataBounds.min,
-				max: dataBounds.max,
-				afterBuildTicks: placeTicksOnBucketStarts(tickBucketStarts, 1),
-				title: { display: true, text: `Time (${currentGranularity})`, color: textColor },
-				ticks: {
-					color: textColor,
-					autoSkip: false,
-					maxRotation: 45,
-					minRotation: 45,
-					sampleSize: 12,
-					callback: (value: string | number) =>
-						formatIpGranularityTick(Number(value), currentGranularity, 0)
-				},
-				grid: {
-					color: (context: { tick?: { value?: number } }) =>
+	function toEpochSeconds(date: string, end = false) {
+		return dateStringToEpochPST(date, end);
-						shouldHighlightIpGranularityGrid(
-							Number(context.tick?.value ?? 0),
-							currentGranularity,
-							0
-						)
-							? gridColor
-							: gridHighlightColor
-				}
-			},
-			y: {
-				beginAtZero: !config.fitYAxisToData,
-				...(config.fitYAxisToData ? { grace: '10%' } : {}),
-				afterFit(axis: { width: number }) {
-					axis.width = Y_AXIS_WIDTH;
-				},
-				title: { display: true, text: yAxisTitle, color: textColor },
-				ticks: config.formatYAxisTicks
-					? {
-							color: textColor,
-							callback: (value: string | number) => formatNumber(Number(value))
-						}
-					: config.fitYAxisToData
-						? {
-								color: textColor,
-								callback: (value: string | number) => Number(value).toFixed(2)
-							}
-						: { color: textColor },
-				grid: { color: gridColor }
-			}
-		};
 	}
-
+	const bucketStarts = $derived(
+		[...new Set(buckets.map((bucket) => bucket.bucketStart))].sort((a, b) => a - b)
+	);
+	const series = $derived.by((): PlotSeries[] => {
+		if (props.kind === 'spectrum')
+			return [
+				{
+					label: 'Spectrum',
+					color: '#36A2EB',
+					data: buckets.flatMap((bucket) =>
+						pointsForBucket(bucket).map((point) => ({
+							x: bucket.bucketStart,
+							y: point.alpha,
+							f: point.f,
+							label: formatTemporalBucketLabel(bucket.bucketStart, currentGranularity),
-	function buildLinePlugins(
-		textColor: string,
-		tooltipBackgroundColor: string,
+							coverage: bucket.coverage
-		tooltipTextColor: string,
-		tooltipBorderColor: string
-	) {
-		return {
-			legend: { position: 'top', labels: { color: textColor } },
-			...(config.fitYAxisToData
-				? {
-						tooltip: {
-							callbacks: {
-								label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) =>
-									`${context.dataset.label ?? ''}: ${context.parsed.y === null ? 'No data' : context.parsed.y.toFixed(3)}`
-							}
-						}
-					}
-				: {}),
-			verticalCrosshair: {
-				enabled: true,
-				line: {
-					color: 'rgba(100, 100, 100, 0.8)',
-					width: 1,
-					dash: [3, 3]
-				},
-				tooltip: {
-					enabled: true,
-					delay: 500,
-					backgroundColor: tooltipBackgroundColor,
-					textColor: tooltipTextColor,
-					borderColor: tooltipBorderColor,
-					borderWidth: 1,
-					borderRadius: 4,
-					padding: 8,
-					fontSize: 12,
-					fontFamily: 'system-ui, sans-serif'
-				},
-				sync: {
-					onHover: (label: string | null) => crosshairStore.setHover(label, CHART_ID),
-					getExternalLabel: () => crosshairStore.getExternalLabel(CHART_ID)
+						}))
+					)
 				}
-			}
-		} as Record<string, unknown>;
-	}
-
-	function renderLineChart() {
-		const {
-			textColor,
-			gridColor,
-			gridHighlightColor,
-			tooltipBackgroundColor,
-			tooltipTextColor,
-			tooltipBorderColor
-		} = getChartColors();
-		const selectedRouters = new Set(deriveSelectedRouters(props.routers));
-		const selectedBuckets = cachedBuckets.filter((record) => selectedRouters.has(record.router));
-
-		if (activeMetrics.length === 0 || selectedBuckets.length === 0) {
-			destroyChart();
-			return;
-		}
-
-		const canvas = chartCanvas;
-		if (!canvas) return;
-
-		bucketStarts = Array.from(
-			new Set(selectedBuckets.map((record) => record.bucket.bucketStart))
-		).sort((left, right) => left - right);
-		const routers = Array.from(new Set(selectedBuckets.map((record) => record.router))).sort();
-		const dataBounds = findTemporalDataBounds(
-			selectedBuckets,
-			(record) => record.bucket.bucketStart,
-			(record) => record.bucket.data !== null,
-			groupByBucketDurationMs(IP_TO_GROUP_BY[currentGranularity]) / 1000
-		);
-		if (!dataBounds) {
-			destroyChart();
+			];
+		const routers = [...new Set(cachedBuckets.map((record) => record.router))].sort();
-			return;
-		}
-		const labels = bucketStarts.map((bucketStart) =>
+		const records = new Map(
+			cachedBuckets.map((record) => [
+				`${record.router}-${record.bucket.bucketStart}`,
+				record.bucket
+			])
-			formatTemporalBucketLabel(bucketStart, currentGranularity)
 		);
-		const bucketByRouterAndStart = new Map(
-			selectedBuckets.map((record) => [`${record.router}-${record.bucket.bucketStart}`, record])
-		);
-
-		const activeMetricConfigs = config.metrics.filter((metric) =>
-			activeMetrics.includes(metric.key)
-		);
-		const datasets = routers.flatMap((router, routerIndex) =>
+		return routers.flatMap((router, routerIndex) =>
 			config.metrics
 				.filter((metric) => activeMetrics.includes(metric.key))
 				.map((metric) => {
-					const configIndex = config.metrics.findIndex((candidate) => candidate.key === metric.key);
-					const { stroke, fill } = config.seriesByRouter
-						? buildRouterColors(router)
-						: buildColors(configIndex, routerIndex);
+					const color = metric.color;
+					const slot =
+						Object.keys(props.routers ?? {})
+							.sort()
+							.indexOf(router) + 1;
-					const data = bucketStarts.map((bucketStart) => {
-						const record = bucketByRouterAndStart.get(`${router}-${bucketStart}`);
-						const bucket = record?.bucket;
-						const coverage = getChartBucketCoverage(bucket) ?? {
-							state: 'unknown',
-							observedUnits: 0,
-							expectedUnits: 0
-						};
-						return {
-							x: bucketStart,
-							y: readLineMetric((bucket?.data as LineBucketData | null) ?? null, metric.key),
-							bucketStart,
-							bucketEnd: bucket?.bucketEnd ?? bucketStart,
-							coverage
-						};
-					});
-					const hasPartialCoverage = data.some((point) => point.coverage.state === 'partial');
-					const pointStyle = hasPartialCoverage
-						? buildCoveragePointStyle(
-								data,
-								(point) => point.y,
-								(point) => point.coverage,
-								stroke
-							)
-						: null;
 					return {
 						label: config.seriesByRouter ? router : `${router} · ${metric.seriesLabel}`,
-						data,
-						borderColor: stroke,
-						backgroundColor: fill,
+						color: config.seriesByRouter
+							? `var(--chart-series-${slot > 0 && slot <= 8 ? slot : 'other'})`
+							: `hsl(${((color?.hue ?? 0) + routerIndex * config.routerHueStep) % 360}, ${color?.saturation ?? 70}%, ${color?.lightness ?? 50}%)`,
+						data: bucketStarts.map((start) => {
+							const bucket = records.get(`${router}-${start}`);
+							return {
+								x: start,
+								y: readLineMetric((bucket?.data as LineBucketData | null) ?? null, metric.key),
+								label: formatTemporalBucketLabel(start, currentGranularity),
+								coverage: getChartBucketCoverage(bucket) ?? {
+									state: 'unknown',
+									observedUnits: 0,
+									expectedUnits: 0
-						tension: 0.3,
-						fill: false,
-						pointRadius: 0,
-						...(pointStyle ?? {}),
-						pointHoverRadius: 4,
-						spanGaps: false,
-						...(hasPartialCoverage
-							? {
-									segment: {
-										borderDash: (context: { p0: { raw: unknown }; p1: { raw: unknown } }) =>
-											isCoverageSegmentDashed(
-												context.p0.raw as { coverage?: ChartCoverage },
-												context.p1.raw as { coverage?: ChartCoverage }
-											)
-												? [6, 4]
-												: []
-									}
 								}
+							};
+						})
-							: {}),
-						parsing: false
 					};
 				})
 		);
+	});
+	const hasData = $derived(series.some((item) => item.data.some(finitePoint)));
+	const bounds = $derived(
+		plotBounds(series, groupByBucketDurationMs(IP_TO_GROUP_BY[currentGranularity]) / 1000)
+	);
+	const chartOptions = $derived.by((): PlotOptions => {
+		const points = series.flatMap((item) => item.data).filter(finitePoint);
-
-		if (datasets.length === 0 || labels.length === 0) {
-			if (chart) {
-				chart.data.labels = [];
-				chart.data.datasets = [];
-				chart.update();
+		let minAlpha = Infinity;
+		let maxAlpha = -Infinity;
+		let minF = Infinity;
+		let maxF = -Infinity;
+		for (const point of points) {
+			minAlpha = Math.min(minAlpha, point.y ?? Infinity);
+			maxAlpha = Math.max(maxAlpha, point.y ?? -Infinity);
+			minF = Math.min(minF, point.f ?? Infinity);
+			maxF = Math.max(maxF, point.f ?? -Infinity);
-			}
-			return;
 		}
-
-		const scales = buildLineScales(
-			textColor,
-			gridColor,
-			gridHighlightColor,
-			dataBounds,
-			bucketStarts,
-			config.seriesByRouter
-				? (activeMetricConfigs[0]?.label ?? config.yAxisTitle)
-				: config.yAxisTitle
-		);
-		const plugins = buildLinePlugins(
-			textColor,
-			tooltipBackgroundColor,
-			tooltipTextColor,
-			tooltipBorderColor
-		);
-		chart = new Chart(canvas, {
-			type: 'line',
-			data: { labels, datasets },
-			options: {
-				onClick: handleChartClick,
+		const alpha = paddedSpectrumBounds(minAlpha, maxAlpha);
+		return {
+			kind: props.kind === 'spectrum' ? 'scatter' : 'line',
-				responsive: true,
+			xTitle: `Time (${currentGranularity})`,
-				maintainAspectRatio: false,
-				animation: false,
-				normalized: true,
-				interaction: { mode: 'index', intersect: false },
-				plugins,
-				scales
-			}
-		} as never);
-		crosshairStore.register(CHART_ID, chart);
+			yTitle: config.seriesByRouter
+				? (config.metrics.find((metric) => activeMetrics.includes(metric.key))?.label ??
+					config.yAxisTitle)
+				: config.yAxisTitle,
+			xDomain: bounds,
+			xTicks: bucketStarts.filter((start) => start >= bounds[0] && start <= bounds[1]),
+			xFormat: (start) => formatIpGranularityTick(start, currentGranularity, 0),
+			yFormat: config.formatYAxisTicks
+				? formatNumber
+				: config.fitYAxisToData
+					? (value) => value.toFixed(2)
+					: undefined,
+			zero: !config.fitYAxisToData,
+			legend: props.kind !== 'spectrum',
+			...(props.kind === 'spectrum' && points.length
+				? {
+						yDomain: [alpha.min, alpha.max],
+						colorDomain: minF === maxF ? [minF - 0.5, maxF + 0.5] : [minF, maxF]
+					}
+				: {})
+		};
+	});
+	function drilldown(groupBy: GroupByOption, startDate: string, endDate: string) {
+		props.onDrillDown?.({ groupBy, startDate, endDate });
 	}
+	function file(slug: string) {
+		void navigateToNetflowFile(
+			goto,
+			slug,
+			props.dataset,
+			props.direction ?? 'all',
+			props.ipVersion,
+			props.measure
-
-	function renderSpectrumChart() {
-		const { textColor, gridColor, gridHighlightColor } = getChartColors();
-		const canvas = chartCanvas;
-		if (!canvas) {
-			return;
-		}
-
-		const selectedBuckets = currentRouter ? buckets : [];
-
-		// Get unique time buckets, sorted
-		bucketStarts = Array.from(new Set(selectedBuckets.map((b) => b.bucketStart))).sort(
-			(a, b) => a - b
 		);
-
-		if (bucketStarts.length === 0) {
-			return;
-		}
-
-		const { data, minF, maxF, minAlpha, maxAlpha } = buildDatasets(selectedBuckets, bucketStarts);
-
-		if (data.length === 0) {
-			return;
-		}
-		const dataBounds = findTemporalDataBounds(
-			data,
-			(point) => point.x,
-			() => true,
-			groupByBucketDurationMs(IP_TO_GROUP_BY[currentGranularity]) / 1000
-		);
-		if (!dataBounds) return;
-
-		const labels = bucketStarts.map((bucketStart) =>
-			formatTemporalBucketLabel(bucketStart, currentGranularity)
-		);
-
-		// Create scatter dataset with individual point colors based on f
-		const pointColors = data.map((d) => getColorForF(d.f, minF, maxF));
-		const chartPoints = data.map((d) => ({
-			x: d.x,
-			y: d.y
-		}));
-
-		const granularity = currentGranularity;
-		const alphaBounds = paddedSpectrumBounds(minAlpha, maxAlpha);
-
-		chart = new Chart(canvas, {
-			type: 'scatter',
-			data: {
-				labels,
-				datasets: [
-					{
-						data: chartPoints,
-						backgroundColor: pointColors,
-						borderColor: pointColors,
-						pointRadius: 1,
-						pointHoverRadius: 2
-					}
-				]
-			},
-			options: {
-				onClick: handleChartClick,
-				animation: false as const,
-				responsive: true,
-				maintainAspectRatio: false,
-				events: ['click'],
-				interaction: {
-					mode: 'nearest',
-					intersect: true
-				},
-				plugins: {
-					verticalCrosshair: false,
-					legend: {
-						display: false
-					},
-					tooltip: {
-						enabled: false
-					}
-				} as Record<string, unknown>,
-				scales: {
-					x: {
-						type: 'linear',
-						min: dataBounds.min,
-						max: dataBounds.max,
-						afterBuildTicks: placeTicksOnBucketStarts(bucketStarts, 1),
-						title: {
-							display: true,
-							text: `Time (${granularity})`,
-							color: textColor
-						},
-						ticks: {
-							color: textColor,
-							autoSkip: false,
-							maxRotation: 45,
-							minRotation: 45,
-							sampleSize: 12,
-							callback: (value: unknown) => {
-								const bucketStart = getBucketStartForTickValue(value);
-								if (bucketStart === null) return '';
-								const index = typeof value === 'number' ? (nearestBucketIndex(value) ?? 0) : 0;
-								return formatIpGranularityTick(bucketStart, granularity, index);
-							}
-						},
-						grid: {
-							color: (ctx: { tick?: { value?: number } }) => {
-								const tickValue = ctx.tick?.value;
-								const bucketStart = getBucketStartForTickValue(tickValue);
-								if (bucketStart === null || typeof tickValue !== 'number') {
-									return gridHighlightColor;
-								}
-								const index = nearestBucketIndex(tickValue) ?? 0;
-								return shouldHighlightIpGranularityGrid(bucketStart, granularity, index)
-									? gridColor
-									: gridHighlightColor;
-							}
-						}
-					},
-					y: {
-						type: 'linear',
-						min: alphaBounds.min,
-						max: alphaBounds.max,
-						afterFit(axis: { width: number }) {
-							axis.width = Y_AXIS_WIDTH;
-						},
-						title: {
-							display: true,
-							text: 'alpha',
-							color: textColor
-						},
-						ticks: { color: textColor },
-						grid: { color: gridColor }
-					}
-				}
-			}
-		});
-		syncCrosshairPositions();
 	}
-
-	function renderChart() {
-		if (props.kind === 'spectrum') {
-			renderSpectrumChart();
-		} else {
-			renderLineChart();
-		}
-	}
-
 	async function loadData(filters: FilterInputs, dataset: string, signal: AbortSignal) {
 		const requestedRange: TimeRange = {
 			start: toEpochSeconds(filters.startDate),
@@ -1259,33 +398,6 @@
 		);
 		return () => controller.abort();
 	});
-
-	function attachChart(canvas: HTMLCanvasElement) {
-		void cachedBuckets;
-		void addressType;
-		void activeMetrics;
-		void theme.dark;
-		untrack(() => {
-			chartCanvas = canvas;
-			renderChart();
-			syncCrosshairPositions();
-		});
-		const observer = new ResizeObserver(() => {
-			chart?.resize();
-			syncCrosshairPositions();
-		});
-		observer.observe(canvas);
-		return () => {
-			observer.disconnect();
-			cancelPendingPointerMove();
-			destroyChart();
-			chartCanvas = null;
-		};
-	}
-
-	onDestroy(() => {
-		if (mirroredRange?.sourceChartId === CHART_ID) rangeSelection.clear();
-	});
 </script>
 
 <ChartCard
@@ -1297,25 +409,14 @@
 	{loading}
 	{error}
 	noMetrics={props.kind === 'spectrum'
-		? buckets.length > 0 && !hasSelectedSpectrumData
+		? buckets.length > 0 && !hasData
 		: activeMetrics.length === 0}
-	empty={buckets.length === 0}
+	empty={buckets.length === 0 || !hasData}
 	loadingCopy={config.loadingCopy}
 	noMetricsCopy={props.kind === 'spectrum'
 		? `No ${addressType === 'sa' ? 'source' : 'destination'} spectrum data for the selected source.`
 		: config.noMetricsCopy}
 	emptyCopy={config.emptyCopy}
-	isDraggingRange={rangeDrag.isDraggingRange}
-	{selectionLeft}
-	{selectionWidth}
-	selectionTop={rangeDrag.selectionTop}
-	selectionHeight={rangeDrag.selectionHeight}
-	{mirroredSelectionStyle}
-	minDragPixels={MIN_DRAG_PIXELS}
-	onmousedown={handleRangeMouseDown}
-	onmousemove={handleRangeMouseMove}
-	onmouseup={finishRangeSelection}
-	onmouseleave={props.kind === 'spectrum' ? handlePointerLeave : finishRangeSelection}
 >
 	{#snippet controls()}
 		{#if props.kind === 'spectrum'}
@@ -1381,25 +482,28 @@
 		{/if}
 	{/snippet}
 
+	<ChartPlot
+		name={config.chartLabel}
+		chartId={config.chartId}
+		{series}
+		options={chartOptions}
+		formatTooltip={(points) =>
+			props.kind === 'spectrum'
+				? [
+						points[0]?.datum.label ?? '',
+						`alpha: ${points[0]?.datum.y?.toFixed(6)}`,
+						`f(alpha): ${points[0]?.datum.f?.toFixed(6)}`
+					].join('\n')
+				: [
+						points[0]?.datum.label ?? '',
+						...points.map(
+							(point) =>
+								`${point.groupLabel}: ${config.fitYAxisToData ? point.datum.y?.toFixed(3) : point.datum.y?.toLocaleString()}`
+						)
+					].join('\n')}
+		onSelect={(point) =>
+			openTemporalPoint(point, IP_TO_GROUP_BY[currentGranularity], drilldown, file)}
+		onRange={(start, end) =>
+			openTemporalRange(start, end, IP_TO_GROUP_BY[currentGranularity], drilldown)}
-	<canvas {@attach attachChart} aria-label={config.canvasLabel}></canvas>
-
-	{#snippet overlay()}
+	/>
-		{#if props.kind === 'spectrum'}
-			{#if !rangeDrag.isDraggingRange && hover.activeCrosshairX !== null}
-				<div
-					class="pointer-events-none absolute z-20"
-					style={getCrosshairLineStyle(hover.activeCrosshairX)}
-				></div>
-			{/if}
-			{#if !rangeDrag.isDraggingRange && hover.localHoverX !== null && hover.showLocalTooltip && hover.localHoverLabel}
-				<div
-					role="tooltip"
-					class="pointer-events-none absolute z-20 rounded border px-2 py-1 text-xs whitespace-nowrap shadow-sm"
-					style={`${getCrosshairTooltipStyle(hover.localHoverX)} background:${getChartColors().tooltipBackgroundColor}; color:${getChartColors().tooltipTextColor}; border-color:${getChartColors().tooltipBorderColor};`}
-				>
-					<div>{hover.localHoverLabel}</div>
-				</div>
-			{/if}
-		{/if}
-	{/snippet}
 </ChartCard>
```

## apps/web/src/lib/components/charts/ChartCard.svelte

Status: modified; +1; −42

```diff
@@ -16,20 +16,8 @@
 		loadingCopy,
 		noMetricsCopy,
 		emptyCopy,
-		isDraggingRange = false,
-		selectionLeft = 0,
-		selectionWidth = 0,
-		selectionTop = 0,
-		selectionHeight = 0,
-		mirroredSelectionStyle = null,
-		minDragPixels = 0,
 		controls,
-		children,
-		overlay,
-		onmousedown,
-		onmousemove,
-		onmouseup,
+		children
-		onmouseleave
 	}: {
 		title: string;
 		subtitle?: string | null;
@@ -43,20 +31,8 @@
 		loadingCopy: string;
 		noMetricsCopy: string;
 		emptyCopy: string;
-		isDraggingRange?: boolean;
-		selectionLeft?: number;
-		selectionWidth?: number;
-		selectionTop?: number;
-		selectionHeight?: number;
-		mirroredSelectionStyle?: string | null;
-		minDragPixels?: number;
 		controls?: Snippet;
 		children: Snippet;
-		overlay?: Snippet;
-		onmousedown?: (event: MouseEvent) => void;
-		onmousemove?: (event: MouseEvent) => void;
-		onmouseup?: () => void;
-		onmouseleave?: () => void;
 	} = $props();
 </script>
 
@@ -108,10 +84,6 @@
 						? 'border-border bg-background/60 relative h-[640px] min-h-[520px] resize-y overflow-hidden rounded-md border xl:h-[320px] xl:min-h-[240px]'
 						: 'border-border bg-background/60 relative h-[320px] min-h-[240px] resize-y overflow-hidden rounded-md border'}
 				role="presentation"
-				{onmousedown}
-				{onmousemove}
-				{onmouseup}
-				{onmouseleave}
 			>
 				{#if selectionUnavailableCopy}
 					<div
@@ -137,19 +109,6 @@
 				{:else}
 					<div class="relative h-full">
 						{@render children()}
-						{@render overlay?.()}
-						{#if isDraggingRange && selectionWidth >= minDragPixels}
-							<div
-								class="border-muted-foreground/70 bg-muted-foreground/20 pointer-events-none absolute border"
-								style={`left:${selectionLeft}px; width:${selectionWidth}px; top:${selectionTop}px; height:${selectionHeight}px;`}
-							></div>
-						{/if}
-						{#if !isDraggingRange && mirroredSelectionStyle !== null}
-							<div
-								class="border-muted-foreground/70 bg-muted-foreground/20 pointer-events-none absolute border"
-								style={mirroredSelectionStyle}
-							></div>
-						{/if}
 					</div>
 				{/if}
 			</div>
```

## apps/web/src/lib/components/charts/ChartContainer.svelte

Status: modified; +112; −762

```diff
@@ -1,792 +1,142 @@
 <script lang="ts">
-	import { onDestroy, onMount } from 'svelte';
-	import type { TooltipItem } from 'chart.js';
-	import { getRelativePosition } from 'chart.js/helpers';
 	import { goto } from '$app/navigation';
 	import { resolve } from '$app/paths';
-	import { Chart } from './chart-registry';
-	import { buildCoveragePointStyle } from './coverage-line-style';
-	import { crosshairStore } from '#lib/stores/crosshair.ts';
-	import { rangeSelection } from '#lib/stores/rangeSelection.svelte.ts';
-	import { theme } from '#lib/stores/theme.svelte.ts';
+	import type { ChartPoint } from '@tanstack/charts';
+	import ChartPlot from './ChartPlot.svelte';
+	import { plotBounds, type PlotPoint, type PlotSeries } from './chart-registry';
-	import { cancelDrawFrame, requestDrawFrame } from '#lib/utils/animation-frame.ts';
-	import { SvelteSet } from 'svelte/reactivity';
-	import { NETFLOW_DATA_OPTION_FIELDS } from '#lib/components/netflow/constants.ts';
 	import {
 		formatLabels,
+		formatNetflowTick,
+		formatNumber,
 		getXAxisTitle,
-		parseClickedLabel,
-		generateSlugFromLabel,
-		Y_AXIS_WIDTH,
-		MIN_DRAG_PIXELS,
-		groupByBucketDurationMs,
-		chooseAdaptiveGranularity,
-		createRangeDragState,
-		getSelectionLabels,
+		groupByBucketDurationMs
-		beginRangeDrag,
-		updateRangeDrag,
-		endRangeDrag,
-		buildMirroredSelectionStyle,
-		findTemporalDataBounds,
-		isCoverageSegmentDashed
 	} from './chart-utils';
-	import {
-		parseLabelToPSTComponents,
-		formatDateAsPSTDateString,
-		epochToPSTComponents,
-		getWeekdayName,
-		type PSTDateComponents
+	import { openTemporalPoint, openTemporalRange } from './temporal-navigation';
+	import { NETFLOW_DATA_OPTION_FIELDS } from '#lib/components/netflow/constants.ts';
-	} from '#lib/utils/timezone.ts';
 	import type {
 		NetflowDataPoint,
 		GroupByOption,
 		ChartTypeOption,
+		DataOption
-		DataOption,
-		ClickedElement,
-		ChartConfig,
-		ChartDataset
 	} from '#lib/components/netflow/types.ts';
 
+	let {
+		results,
+		groupBy,
+		chartType,
+		dataOptions,
+		onDrillDown,
-	interface Props {
+		onNavigateToFile
+	}: {
 		results: NetflowDataPoint[];
 		groupBy: GroupByOption;
 		chartType: ChartTypeOption;
 		dataOptions: DataOption[];
-		onDrillDown?: (newGroupBy: GroupByOption, newStartDate: string, newEndDate: string) => void;
+		onDrillDown?: (groupBy: GroupByOption, startDate: string, endDate: string) => void;
 		onNavigateToFile?: (slug: string) => void;
+	} = $props();
+	const colors = [
+		'rgb(75, 192, 192)',
+		'rgb(255, 99, 132)',
+		'rgb(54, 162, 235)',
+		'rgb(255, 206, 86)',
+		'rgb(153, 102, 255)',
+		'rgb(255, 159, 64)',
+		'rgb(255, 99, 71)',
+		'rgb(0, 206, 209)',
+		'rgb(60, 179, 113)',
+		'rgb(218, 112, 214)',
+		'rgb(255, 215, 0)',
+		'rgb(128, 0, 128)'
+	];
+	const labels = $derived(formatLabels(results, groupBy));
+	const series = $derived<PlotSeries[]>(
+		dataOptions
+			.filter((option) => option.checked)
+			.map((option, index) => ({
+				label: option.label,
+				color: colors[index % colors.length],
+				data: results.map((result, index) => ({
+					x: result.bucketStart,
+					y: result.data?.[NETFLOW_DATA_OPTION_FIELDS[option.index]] ?? null,
+					coverage: result.coverage,
+					label: labels[index]
+				}))
+			}))
+	);
+	const bounds = $derived(plotBounds(series, groupByBucketDurationMs(groupBy) / 1000));
-	}
-
-	let { results, groupBy, chartType, dataOptions, onDrillDown, onNavigateToFile }: Props = $props();
+	const visibleStarts = $derived(
+		results
+			.map((result) => result.bucketStart)
+			.filter((start) => start >= bounds[0] && start <= bounds[1])
+	);
+	const allBytes = $derived(
+		dataOptions.filter((option) => option.checked).every((option) => option.label.includes('Bytes'))
-
-	const CHART_ID = 'netflow';
-	const COMPACT_CHART_WIDTH = 560;
-
-	let chartCanvas: HTMLCanvasElement;
-	let chart: Chart | null = null;
-	let resizeObserver: ResizeObserver | null = null;
-	let compactChartMode: boolean | null = null;
-	let rangeDrag = $state(createRangeDragState());
-	let selectionLeft = $derived(Math.min(rangeDrag.dragStartX, rangeDrag.dragCurrentX));
-	let selectionWidth = $derived(Math.abs(rangeDrag.dragCurrentX - rangeDrag.dragStartX));
-	let mirroredRange = $derived(rangeSelection.selection);
-	let rangeMoveFrame: number | null = null;
-	let pendingRangeMoveEvent: MouseEvent | null = null;
-
-	type MetricFamily = 'flows' | 'packets' | 'bytes';
-
-	function publishRangeSelection(startIndex: number, endIndex: number) {
-		const labels = getSelectionLabels(chart, startIndex, endIndex);
-		if (!labels) return;
-		rangeSelection.set({ sourceChartId: CHART_ID, ...labels });
-	}
-
-	function applyRangeDrilldown(startIndex: number, endIndex: number) {
-		if (!chart?.data.labels || !onDrillDown) return;
-		const labels = chart.data.labels as string[];
-		if (labels.length === 0) return;
-		const safeStart = Math.max(0, Math.min(labels.length - 1, startIndex));
-		const safeEnd = Math.max(0, Math.min(labels.length - 1, endIndex));
-		const from = Math.min(safeStart, safeEnd);
-		const to = Math.max(safeStart, safeEnd);
-		const startLabel = labels[from];
-		const endLabel = labels[to];
-		if (!startLabel || !endLabel) return;
-
-		const startDate = parseClickedLabel(startLabel, groupBy);
-		const endBucketStart = parseClickedLabel(endLabel, groupBy);
-		if (Number.isNaN(startDate.getTime()) || Number.isNaN(endBucketStart.getTime())) return;
-
-		const endExclusive = new Date(endBucketStart.getTime() + groupByBucketDurationMs(groupBy));
-		const selectedRangeMs = endExclusive.getTime() - startDate.getTime();
-		const nextGroupBy = chooseAdaptiveGranularity(selectedRangeMs);
-		onDrillDown(
-			nextGroupBy,
-			formatDateAsPSTDateString(startDate),
-			formatDateAsPSTDateString(endExclusive)
-		);
-	}
-
-	function handleRangeMouseDown(event: MouseEvent) {
-		cancelPendingRangeMove();
-		beginRangeDrag(rangeDrag, event, chartCanvas, chart, publishRangeSelection);
-	}
-
-	function applyPendingRangeMove() {
-		rangeMoveFrame = null;
-		const event = pendingRangeMoveEvent;
-		pendingRangeMoveEvent = null;
-		if (event) {
-			updateRangeDrag(rangeDrag, event, chartCanvas, chart, publishRangeSelection);
-		}
-	}
-
-	function cancelPendingRangeMove() {
-		if (rangeMoveFrame !== null) {
-			cancelDrawFrame(rangeMoveFrame);
-			rangeMoveFrame = null;
-		}
-		pendingRangeMoveEvent = null;
-	}
-
-	function flushPendingRangeMove() {
-		if (rangeMoveFrame === null) {
-			return;
-		}
-		cancelDrawFrame(rangeMoveFrame);
-		applyPendingRangeMove();
-	}
-
-	function handleRangeMouseMove(event: MouseEvent) {
-		if (!rangeDrag.isDraggingRange) {
-			return;
-		}
-		pendingRangeMoveEvent = event;
-		if (rangeMoveFrame === null) {
-			rangeMoveFrame = requestDrawFrame(applyPendingRangeMove);
-		}
-	}
-
-	function finishRangeSelection() {
-		flushPendingRangeMove();
-		endRangeDrag(rangeDrag, chart, applyRangeDrilldown);
-		rangeSelection.clear();
-	}
-
-	let mirroredSelectionStyle = $derived(
-		buildMirroredSelectionStyle(chart, mirroredRange, CHART_ID)
 	);
 
+	function formatMetric(value: number, label: string) {
+		if (label.includes('Bytes')) {
-	function formatTickLabel(
+			for (const [power, unit] of [
-		pst: PSTDateComponents | null,
+				[5, 'PB'],
+				[4, 'TB'],
+				[3, 'GB'],
+				[2, 'MB'],
+				[1, 'KB']
+			] as const) {
+				if (value >= 1024 ** power) return `${(value / 1024 ** power).toFixed(1)} ${unit}`;
-		currentGroupBy: GroupByOption,
-		index: number
-	): string {
-		if (!pst) {
-			return '';
-		}
-		const weekday = getWeekdayName(pst.dayOfWeek);
-		const month = pst.month;
-		const day = pst.day;
-		const hours = pst.hours;
-		const minutes = pst.minutes;
-
-		if (currentGroupBy === 'date') {
-			return pst.dayOfWeek === 1 ? `${weekday} ${month}/${day}` : '';
-		}
-
-		if (currentGroupBy === 'hour') {
-			return hours === 0 ? `${weekday} ${month}/${day}` : '';
-		}
-
-		if (currentGroupBy === '30min' || currentGroupBy === '10min') {
-			if (minutes === 0 && (hours === 0 || hours === 12)) {
-				return `${weekday} ${month}/${day} ${hours.toString().padStart(2, '0')}:00`;
 			}
-			return '';
-		}
-
-		if (currentGroupBy === '5min') {
-			if (minutes === 0) {
-				return `${weekday} ${month}/${day} ${hours.toString().padStart(2, '0')}:00`;
-			}
-			return '';
-		}
-
-		return index === 0 ? `${weekday} ${month}/${day}` : '';
-	}
-
-	function shouldHighlightTick(
-		pst: PSTDateComponents | null,
-		currentGroupBy: GroupByOption,
-		index: number
-	): boolean {
-		if (!pst) {
-			return index === 0;
-		}
-		const hours = pst.hours;
-		const minutes = pst.minutes;
-
-		if (currentGroupBy === 'date') {
-			return pst.dayOfWeek === 1;
-		}
-		if (currentGroupBy === 'hour') {
-			return hours === 0;
-		}
-		if (currentGroupBy === '30min' || currentGroupBy === '10min') {
-			return minutes === 0 && (hours === 0 || hours === 12);
-		}
-		if (currentGroupBy === '5min') {
-			return minutes === 0;
-		}
-		return index === 0;
-	}
-
-	function getClickedElement(
-		activeElements: { datasetIndex: number; index: number }[]
-	): ClickedElement | null {
-		if (!chart) {
-			return null;
-		}
-		if (activeElements.length > 0) {
-			const element = activeElements[0];
-			const datasetIndex = element.datasetIndex;
-			const index = element.index;
-			const dataset = chart.data.datasets[datasetIndex] as ChartDataset;
-			const label = chart.data.labels?.[index] as string;
-			const value = dataset.data[index];
-			if (typeof value !== 'number') {
-				return null;
-			}
-			return {
-				dataset: {
-					label: dataset.label,
-					data: dataset.data,
-					backgroundColor: dataset.backgroundColor as string,
-					borderColor: dataset.borderColor as string
-				},
-				label,
-				value,
-				datasetIndex,
-				index
-			};
-		}
-		return null;
-	}
-
-	function getMetricFamily(label: string): MetricFamily | null {
-		const normalized = label.toLowerCase();
-		if (normalized.includes('flow')) return 'flows';
-		if (normalized.includes('packet')) return 'packets';
-		if (normalized.includes('byte')) return 'bytes';
-		return null;
-	}
-
-	function formatMetricValue(value: number, family: MetricFamily): string {
-		if (family === 'bytes') {
-			if (value >= 1024 ** 5) return `${(value / 1024 ** 5).toFixed(1)} PB`;
-			if (value >= 1024 ** 4) return `${(value / 1024 ** 4).toFixed(1)} TB`;
-			if (value >= 1024 ** 3) return `${(value / 1024 ** 3).toFixed(1)} GB`;
-			if (value >= 1024 ** 2) return `${(value / 1024 ** 2).toFixed(1)} MB`;
-			if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`;
 			return `${value.toLocaleString()} bytes`;
 		}
-
 		return value.toLocaleString();
 	}
-
-	function getTooltipTotalLines(items: TooltipItem<'line'>[]): string[] {
-		const dataIndex = items[0]?.dataIndex;
-		if (dataIndex == null) {
-			return [];
-		}
-
-		const bucket = results[dataIndex];
-		if (!bucket) {
-			return [];
-		}
-		const payload = bucket.data;
-		if (!payload) {
-			return [];
-		}
-
-		const visibleFamilies = new SvelteSet<MetricFamily>();
-		for (const item of items) {
-			const family = getMetricFamily(item.dataset.label ?? '');
-			if (family) {
-				visibleFamilies.add(family);
-			}
-		}
-
-		const lines: string[] = [];
-		if (visibleFamilies.has('flows')) {
-			lines.push(`Total Flows: ${formatMetricValue(payload.flows, 'flows')}`);
-		}
-		if (visibleFamilies.has('packets')) {
-			lines.push(`Total Packets: ${formatMetricValue(payload.packets, 'packets')}`);
-		}
-		if (visibleFamilies.has('bytes')) {
-			lines.push(`Total Bytes: ${formatMetricValue(payload.bytes, 'bytes')}`);
-		}
-
-		return lines;
-	}
-
-	function handleChartClick(
-		e: MouseEvent,
-		activeElements: { datasetIndex: number; index: number }[]
-	) {
-		if (rangeDrag.suppressNextClick) {
-			rangeDrag.suppressNextClick = false;
-			return;
-		}
-		if (!chart) {
-			return;
-		}
-		// Always get the canvas position and convert to data values
-		const canvasPosition = getRelativePosition(e, chart);
-		const dataX = chart.scales.x.getValueForPixel(canvasPosition.x);
-
-		// Convert the x-axis data value to the appropriate date based on current groupBy
-		let clickedDate: Date;
-
-		if (typeof dataX === 'number') {
-			// dataX is the index in the labels array
-			const labelIndex = Math.round(dataX);
-			const labels = chart.data.labels;
-
-			if (labels && labelIndex >= 0 && labelIndex < labels.length) {
-				const clickedLabel = labels[labelIndex] as string;
-				clickedDate = parseClickedLabel(clickedLabel, groupBy);
-			} else {
-				// Handle clicks outside the data range
-				if (labels && labels.length > 0) {
-					let targetLabel: string;
-					if (labelIndex < 0) {
-						targetLabel = labels[0] as string;
-					} else {
-						targetLabel = labels[labels.length - 1] as string;
-					}
-					clickedDate = parseClickedLabel(targetLabel, groupBy);
-				} else {
-					return;
-				}
-			}
-		} else {
-			return;
-		}
-
-		const clickedElement = getClickedElement(activeElements);
-
-		if (groupBy === 'date') {
-			const startOfMonth = new Date(clickedDate.getTime() - 15 * 24 * 60 * 60 * 1000);
-			const endOfMonth = new Date(clickedDate.getTime() + 16 * 24 * 60 * 60 * 1000);
-			const startDateStr = formatDateAsPSTDateString(startOfMonth);
-			const endDateStr = formatDateAsPSTDateString(endOfMonth);
-			onDrillDown?.('hour', startDateStr, endDateStr);
-		} else if (groupBy === 'hour') {
-			const startOfWeek = new Date(clickedDate.getTime() - 3 * 24 * 60 * 60 * 1000);
-			const endOfWeek = new Date(clickedDate.getTime() + 4 * 24 * 60 * 60 * 1000);
-			const startDateStr = formatDateAsPSTDateString(startOfWeek);
-			const endDateStr = formatDateAsPSTDateString(endOfWeek);
-			onDrillDown?.('10min', startDateStr, endDateStr);
-		} else if (groupBy === '30min' || groupBy === '10min') {
-			const endDate = new Date(clickedDate.getTime() + 24 * 60 * 60 * 1000);
-			const startDateStr = formatDateAsPSTDateString(clickedDate);
-			const endDateStr = formatDateAsPSTDateString(endDate);
-			onDrillDown?.('5min', startDateStr, endDateStr);
-		} else if (groupBy === '5min') {
-			// For 5min level, we need to create a slug from the clicked date
-			// Convert the date back to a label format that generateSlugFromLabel expects
-			let labelForSlug: string;
-			if (clickedElement) {
-				labelForSlug = clickedElement.label;
-			} else {
-				// Format the date to match the label format for 5min grouping using PST
-				const pst = epochToPSTComponents(Math.floor(clickedDate.getTime() / 1000));
-				const year = pst.year;
-				const month = String(pst.month).padStart(2, '0');
-				const day = String(pst.day).padStart(2, '0');
-				const hour = String(pst.hours).padStart(2, '0');
-				const minute = String(pst.minutes).padStart(2, '0');
-				labelForSlug = `${year}-${month}-${day} ${hour}:${minute}`;
-			}
-
-			const slug = generateSlugFromLabel(labelForSlug, groupBy);
-			if (onNavigateToFile) {
-				onNavigateToFile(slug);
+	function tooltip(points: readonly ChartPoint<PlotPoint, number, number>[]) {
+		const bucket = results.find((result) => result.bucketStart === points[0]?.datum.x);
+		const lines = [
+			points[0]?.datum.label ?? '',
-			} else {
-				goto(resolve('/netflow/files/[slug]', { slug }));
-			}
+			...points.map(
+				(point) => `${point.groupLabel}: ${formatMetric(point.datum.y ?? 0, point.groupLabel)}`
+			)
+		];
+		for (const [family, label] of [
+			['flows', 'Flows'],
+			['packets', 'Packets'],
+			['bytes', 'Bytes']
+		] as const) {
+			if (bucket?.data && points.some((point) => point.groupLabel.includes(label)))
+				lines.push(`Total ${label}: ${formatMetric(bucket.data[family], label)}`);
 		}
+		return lines.join('\n');
 	}
-
-	function parseLabelToPST(label: string | undefined): PSTDateComponents | null {
-		if (!label) return null;
+	function navigate(slug: string) {
+		if (onNavigateToFile) onNavigateToFile(slug);
+		else void goto(resolve('/netflow/files/[slug]', { slug }));
-		return parseLabelToPSTComponents(label);
 	}
-
-	function getChartColors() {
-		const style = getComputedStyle(document.documentElement);
-		return {
-			textColor: style.getPropertyValue('--chart-text-color').trim(),
-			gridColor: style.getPropertyValue('--chart-grid-color').trim(),
-			gridHighlightColor: style.getPropertyValue('--chart-grid-highlight-color').trim(),
-			tooltipBackgroundColor: style.getPropertyValue('--chart-tooltip-bg').trim(),
-			tooltipTextColor: style.getPropertyValue('--chart-tooltip-text-color').trim(),
-			tooltipBorderColor: style.getPropertyValue('--chart-tooltip-border-color').trim()
-		};
-	}
-
-	function isCompactChart(): boolean {
-		const width = chartCanvas.parentElement?.clientWidth ?? chartCanvas.clientWidth;
-		return width < COMPACT_CHART_WIDTH;
-	}
-
-	function getLabelPSTFromLabels(
-		labels: (string | number | null | undefined)[],
-		idx: number
-	): PSTDateComponents | null {
-		const label = labels[idx];
-		return typeof label === 'string' ? parseLabelToPST(label) : null;
-	}
-
-	function createChartConfig(): ChartConfig {
-		const {
-			textColor,
-			gridColor,
-			gridHighlightColor,
-			tooltipBackgroundColor,
-			tooltipTextColor,
-			tooltipBorderColor
-		} = getChartColors();
-		const dataBounds = findTemporalDataBounds(
-			results,
-			(result) => result.bucketStart,
-			(result) => result.data !== null
-		);
-		const visibleResults = dataBounds
-			? results.filter(
-					(result) => result.bucketStart >= dataBounds.min && result.bucketStart <= dataBounds.max
-				)
-			: [];
-		const labels = formatLabels(visibleResults, groupBy);
-		const getLabelPST = (idx: number): PSTDateComponents | null =>
-			getLabelPSTFromLabels(labels, idx);
-		const xAxisTitle = getXAxisTitle(groupBy);
-		const compactChart = isCompactChart();
-
-		// Use manual chart type selection - matches original logic
-		const isStackedChart = chartType === 'stacked';
-
-		// Original predefined colors
-		const predefinedColors = [
-			'rgb(75, 192, 192)',
-			'rgb(255, 99, 132)',
-			'rgb(54, 162, 235)',
-			'rgb(255, 206, 86)',
-			'rgb(153, 102, 255)',
-			'rgb(255, 159, 64)',
-			'rgb(255, 99, 71)',
-			'rgb(0, 206, 209)',
-			'rgb(60, 179, 113)',
-			'rgb(218, 112, 214)',
-			'rgb(255, 215, 0)',
-			'rgb(128, 0, 128)',
-			'rgb(0, 128, 128)',
-			'rgb(255, 0, 255)',
-			'rgb(0, 255, 0)',
-			'rgb(128, 128, 0)',
-			'rgb(0, 0, 128)',
-			'rgb(255, 140, 0)',
-			'rgb(34, 139, 34)',
-			'rgb(139, 0, 0)'
-		];
-
-		// Parse data from results - matches original parsing logic
-		const datasets: ChartDataset[] = [];
-		let colorIndex = 0;
-		const hasPartialCoverage = visibleResults.some((result) => result.coverage.state === 'partial');
-
-		for (const option of dataOptions) {
-			if (option.checked) {
-				const field = NETFLOW_DATA_OPTION_FIELDS[option.index];
-				if (!field) {
-					continue;
-				}
-				const data = visibleResults.map((item) => item.data?.[field] ?? null);
-				const color = predefinedColors[colorIndex % predefinedColors.length];
-				const pointStyle = hasPartialCoverage
-					? buildCoveragePointStyle(
-							visibleResults,
-							(_result, index) => data[index] ?? null,
-							(result) => result.coverage,
-							color
-						)
-					: null;
-				colorIndex++;
-
-				datasets.push({
-					label: option.label,
-					data: data,
-					borderColor: color,
-					backgroundColor: isStackedChart
-						? color.replace('rgb', 'rgba').replace(')', ', 0.6)')
-						: color,
-					fill: isStackedChart ? 'origin' : false,
-					tension: 0.1,
-					pointRadius: 0,
-					...(pointStyle ?? {}),
-					spanGaps: false,
-					...(hasPartialCoverage
-						? {
-								segment: {
-									borderDash: (context: { p0DataIndex: number; p1DataIndex: number }) =>
-										isCoverageSegmentDashed(
-											visibleResults[context.p0DataIndex],
-											visibleResults[context.p1DataIndex]
-										)
-											? [6, 4]
-											: []
-								}
-							}
-						: {}),
-					hitRadius: 2,
-					hoverRadius: 5
-				});
-			}
-		}
-
-		// Check if all selected metrics are bytes
-		const selectedOptions = dataOptions.filter((o) => o.checked);
-		const allAreBytesMetrics = selectedOptions.every((o) => o.label.includes('Bytes'));
-		const formatAxisValue = (value: string | number) => {
-			const num = Number(value);
-
-			if (allAreBytesMetrics) {
-				if (num >= Math.pow(1024, 5)) return (num / Math.pow(1024, 5)).toFixed(1) + 'PB';
-				if (num >= Math.pow(1024, 4)) return (num / Math.pow(1024, 4)).toFixed(1) + 'TB';
-				if (num >= Math.pow(1024, 3)) return (num / Math.pow(1024, 3)).toFixed(1) + 'GB';
-				if (num >= Math.pow(1024, 2)) return (num / Math.pow(1024, 2)).toFixed(1) + 'MB';
-				if (num >= 1024) return (num / 1024).toFixed(1) + 'KB';
-				return num.toString() + ' bytes';
-			}
-
-			if (num >= 1e15) return (num / 1e15).toFixed(1) + 'Q';
-			if (num >= 1e12) return (num / 1e12).toFixed(1) + 'T';
-			if (num >= 1e9) return (num / 1e9).toFixed(1) + 'B';
-			if (num >= 1e6) return (num / 1e6).toFixed(1) + 'M';
-			if (num >= 1e3) return (num / 1e3).toFixed(1) + 'K';
-			return num.toString();
-		};
-
-		// Original scales configuration
-		const scales: Record<string, object> = {
-			x: {
-				title: {
-					display: !compactChart,
-					text: xAxisTitle,
-					color: textColor
-				},
-				ticks: {
-					color: textColor,
-					autoSkip: compactChart,
-					maxRotation: compactChart ? 0 : 45,
-					maxTicksLimit: compactChart ? 4 : undefined,
-					minRotation: compactChart ? 0 : 45,
-					sampleSize: 12,
-					callback: (_val: string | number, idx: number) =>
-						formatTickLabel(getLabelPST(Number(idx)), groupBy, Number(idx))
-				},
-				grid: {
-					color: (ctx: { index?: number; tick?: { index?: number } }) => {
-						const tickIndex = ctx.index ?? ctx.tick?.index ?? 0;
-						const safeIndex = Number.isFinite(Number(tickIndex)) ? Number(tickIndex) : 0;
-						return shouldHighlightTick(getLabelPST(safeIndex), groupBy, safeIndex)
-							? gridColor
-							: gridHighlightColor;
-					}
-				}
-			},
-			y: isStackedChart
-				? {
-						display: true,
-						type: 'linear',
-						stacked: true,
-						beginAtZero: true,
-						afterFit(axis: { width: number }) {
-							axis.width = compactChart ? 56 : Y_AXIS_WIDTH;
-						},
-						title: {
-							display: !compactChart,
-							text: 'Value',
-							color: textColor
-						},
-						ticks: {
-							color: textColor,
-							callback: formatAxisValue
-						},
-						grid: {
-							color: gridColor
-						}
-					}
-				: {
-						display: true,
-						type: 'linear',
-						beginAtZero: true,
-						afterFit(axis: { width: number }) {
-							axis.width = compactChart ? 56 : Y_AXIS_WIDTH;
-						},
-						title: {
-							display: !compactChart,
-							text: 'Value',
-							color: textColor
-						},
-						ticks: {
-							color: textColor,
-							callback: formatAxisValue
-						},
-						grid: {
-							color: gridColor
-						}
-					}
-		};
-
-		return {
-			type: 'line',
-			data: {
-				labels,
-				datasets
-			},
-			options: {
-				onClick: handleChartClick,
-				responsive: true,
-				maintainAspectRatio: false,
-				animation: false,
-				normalized: true,
-				interaction: {
-					mode: 'index',
-					intersect: false
-				},
-				scales: scales,
-				plugins: {
-					legend: {
-						display: !compactChart,
-						position: 'top' as const,
-						labels: { color: textColor }
-					},
-					tooltip: {
-						mode: 'index',
-						intersect: false,
-						backgroundColor: tooltipBackgroundColor,
-						titleColor: tooltipTextColor,
-						bodyColor: tooltipTextColor,
-						footerColor: tooltipTextColor,
-						borderColor: tooltipBorderColor,
-						borderWidth: 1,
-						callbacks: {
-							label: (context: TooltipItem<'line'>) => {
-								if (context.parsed.y === null) {
-									return `${context.dataset.label}: No data`;
-								}
-								const family = getMetricFamily(context.dataset.label ?? '') ?? 'flows';
-								const value = context.parsed.y;
-								return `${context.dataset.label}: ${formatMetricValue(value, family)}`;
-							},
-							footer: (items: TooltipItem<'line'>[]) => getTooltipTotalLines(items)
-						}
-					},
-					verticalCrosshair: {
-						enabled: true,
-						line: {
-							color: 'rgba(100, 100, 100, 0.8)',
-							width: 1,
-							dash: [3, 3]
-						},
-						tooltip: {
-							enabled: true,
-							delay: 500,
-							backgroundColor: tooltipBackgroundColor,
-							textColor: tooltipTextColor,
-							borderColor: tooltipBorderColor,
-							borderWidth: 1,
-							borderRadius: 4,
-							padding: 8,
-							fontSize: 12,
-							fontFamily: 'system-ui, sans-serif'
-						},
-						sync: {
-							onHover: (label: string | null) => crosshairStore.setHover(label, CHART_ID),
-							getExternalLabel: () => crosshairStore.getExternalLabel(CHART_ID)
-						}
-					}
-				} as Record<string, object>
-			}
-		};
-	}
-
-	onMount(() => {
-		const container = chartCanvas.parentElement;
-		if (container) {
-			resizeObserver = new ResizeObserver(() => {
-				const nextCompactChartMode = isCompactChart();
-				if (chart && results.length > 0 && nextCompactChartMode !== compactChartMode) {
-					const config = createChartConfig();
-					chart.data = config.data;
-					// eslint-disable-next-line @typescript-eslint/no-explicit-any
-					chart.options = config.options as any;
-					compactChartMode = nextCompactChartMode;
-					chart.update('none');
-					return;
-				}
-				compactChartMode = nextCompactChartMode;
-				chart?.resize();
-			});
-			resizeObserver.observe(container);
-		}
-	});
-
-	onDestroy(() => {
-		cancelPendingRangeMove();
-		crosshairStore.unregister(CHART_ID);
-		if (mirroredRange?.sourceChartId === CHART_ID) {
-			rangeSelection.clear();
-		}
-		chart?.destroy();
-		chart = null;
-		resizeObserver?.disconnect();
-		resizeObserver = null;
-	});
-
-	$effect(() => {
-		void theme.dark;
-		if (!chartCanvas || results.length === 0) {
-			return;
-		}
-
-		const config = createChartConfig();
-		compactChartMode = isCompactChart();
-		if (!chart) {
-			// eslint-disable-next-line @typescript-eslint/no-explicit-any
-			chart = new Chart(chartCanvas, config as any);
-			crosshairStore.register(CHART_ID, chart);
-			return;
-		}
-
-		chart.data = config.data;
-		// eslint-disable-next-line @typescript-eslint/no-explicit-any
-		chart.options = config.options as any;
-		chart.update('none');
-	});
 </script>
 
+<ChartPlot
+	retainEmptySurface
+	name="Traffic overview chart"
+	chartId="netflow"
-<div
-	class="chart-container"
-	role="presentation"
-	onmousedown={handleRangeMouseDown}
-	onmousemove={handleRangeMouseMove}
-	onmouseup={finishRangeSelection}
-	onmouseleave={finishRangeSelection}
->
-	<canvas bind:this={chartCanvas} class="h-full w-full" aria-label="Traffic overview chart"
-	></canvas>
-	{#if rangeDrag.isDraggingRange && selectionWidth >= MIN_DRAG_PIXELS}
-		<div
-			class="border-muted-foreground/70 bg-muted/20 pointer-events-none absolute border"
-			style={`left:${selectionLeft}px; width:${selectionWidth}px; top:${rangeDrag.selectionTop}px; height:${rangeDrag.selectionHeight}px;`}
-		></div>
-	{/if}
-	{#if !rangeDrag.isDraggingRange && mirroredSelectionStyle !== null}
-		<div
-			class="border-muted-foreground/70 bg-muted/20 pointer-events-none absolute border"
-			style={mirroredSelectionStyle}
-		></div>
-	{/if}
-</div>
-
-<style>
-	.chart-container {
-		position: relative;
+	{series}
+	options={{
+		kind: chartType,
+		xTitle: getXAxisTitle(groupBy),
+		yTitle: 'Value',
+		xDomain: bounds,
+		xTicks: visibleStarts,
+		xFormat: (start) =>
+			formatNetflowTick(
+				groupBy,
+				labels[results.findIndex((result) => result.bucketStart === start)],
+				0
+			),
+		yFormat: (value) => (allBytes ? formatMetric(value, 'Bytes') : formatNumber(value)),
+		compact: true
+	}}
+	formatTooltip={tooltip}
+	emptyCopy={series.length
+		? 'No traffic observations for the selected window.'
+		: 'Select at least one metric to display.'}
+	onSelect={(point) => openTemporalPoint(point, groupBy, onDrillDown, navigate)}
-		height: 100%;
-		width: 100%;
-	}
+	onRange={(start, end) => openTemporalRange(start, end, groupBy, onDrillDown)}
+/>
-</style>
```

## apps/web/src/lib/components/charts/ChartPlot.svelte

Status: added; +479; −0

```diff
@@ -1,0 +1,479 @@
+<script lang="ts">
+	import { SvelteSet, SvelteMap } from 'svelte/reactivity';
+	import type { ChartPoint, ChartRenderContext } from '@tanstack/charts';
+	import {
+		Chart,
+		buildChartDefinition,
+		finitePoint,
+		plotTooltip,
+		type PlotOptions,
+		type PlotPoint,
+		type PlotSeries
+	} from './chart-registry';
+	import { paintLegendSwatch } from './legend-swatch';
+	import { createChartContract } from './chart-contract';
+	import { MIN_DRAG_PIXELS, findNearestValueIndex } from './chart-utils';
+	import { crosshairStore } from '#lib/stores/crosshair.ts';
+	import { rangeSelection } from '#lib/stores/rangeSelection.svelte.ts';
+
+	let {
+		name,
+		chartId,
+		series,
+		options,
+		formatTooltip,
+		onSelect,
+		onRange,
+		emptyCopy = 'No finite data for this selection.',
+		retainEmptySurface = false
+	}: {
+		name: string;
+		chartId?: string;
+		series: PlotSeries[];
+		options: PlotOptions;
+		formatTooltip?: (points: readonly ChartPoint<PlotPoint, number, number>[]) => string;
+		onSelect?: (point: PlotPoint) => void;
+		onRange?: (start: PlotPoint, end: PlotPoint) => void;
+		emptyCopy?: string;
+		retainEmptySurface?: boolean;
+	} = $props();
+	const hidden = new SvelteSet<string>();
+	let size = $state({ width: 800, height: 320 });
+	let context: ChartRenderContext<PlotPoint, number, number> | null = null;
+	let contract: ReturnType<typeof createChartContract> | null = null;
+	let contractSurface: HTMLElement | SVGElement | null = null;
+	let cleanupRender = () => {};
+	let rangeStart: number | null = null;
+	let rangeEnd: number | null = null;
+	let keyboardStart: PlotPoint | null = null;
+	let focused: PlotPoint | null = null;
+	let suppressSelect = false;
+	let selection: HTMLDivElement | null = null;
+	let externalCrosshair: HTMLDivElement | null = null;
+	const hasData = $derived(series.some((item) => item.data.some(finitePoint)));
+	const timeline = $derived.by(() => {
+		const unique = new SvelteMap<number, PlotPoint>();
+		for (const item of series) {
+			if (hidden.has(item.label)) continue;
+			for (const point of item.data) if (finitePoint(point)) unique.set(point.x, point);
+		}
+		return [...unique.values()].sort((left, right) => left.x - right.x);
+	});
+	const timestamps = $derived(timeline.map((point) => point.x));
+	const labeledPoints = $derived(new SvelteMap(timeline.map((point) => [point.label, point])));
+	const definition = $derived(
+		buildChartDefinition(
+			series,
+			{ ...options, compact: options.compact && size.width < 560 },
+			hidden,
+			toggle,
+			formatTooltip
+		)
+	);
+
+	function toggle(visible: readonly string[]) {
+		for (const item of series) {
+			if (visible.includes(item.label)) hidden.delete(item.label);
+			else hidden.add(item.label);
+		}
+	}
+
+	function attachSize(node: HTMLDivElement) {
+		const observer = new ResizeObserver(() => {
+			const width = node.clientWidth;
+			const height = node.clientHeight;
+			if (width > 0 && height > 0 && (width !== size.width || height !== size.height))
+				size = { width, height };
+		});
+		observer.observe(node);
+		return () => {
+			observer.disconnect();
+			cleanupRender();
+			contract?.destroy();
+			contract = null;
+			context = null;
+			if (chartId && crosshairStore.sourceChartId === chartId) crosshairStore.clearHover();
+			if (chartId && rangeSelection.selection?.sourceChartId === chartId) rangeSelection.clear();
+		};
+	}
+
+	function attachLifetime() {
+		return () => {
+			cleanupRender();
+			contract?.destroy();
+			contract = null;
+			contractSurface = null;
+			context = null;
+		};
+	}
+
+	function publish(next: ChartRenderContext<PlotPoint, number, number>) {
+		cleanupRender();
+		context = next;
+		const surface = next.surface.element;
+		if (!(surface instanceof HTMLElement || surface instanceof SVGElement))
+			throw new TypeError('Expected an HTML or SVG chart surface');
+		if (contractSurface !== surface) {
+			contract?.destroy();
+			contract = createChartContract(surface);
+			contractSurface = surface;
+		}
+		const seen = new SvelteSet<PlotPoint>();
+		const positioned = new SvelteMap<string, ChartPoint<PlotPoint, number, number>[]>();
+		for (const point of next.scene.points) {
+			if (
+				seen.has(point.datum) ||
+				!finitePoint(point.datum) ||
+				!Number.isFinite(point.x) ||
+				!Number.isFinite(point.y)
+			)
+				continue;
+			if (
+				point.x < next.scene.chart.x - 0.01 ||
+				point.x > next.scene.chart.x + next.scene.chart.width + 0.01
+			)
+				continue;
+			seen.add(point.datum);
+			const markId = point.markId.replace(/-(complete|partial|run-\d+)$/, '');
+			const points = positioned.get(markId) ?? [];
+			points.push(point);
+			positioned.set(markId, points);
+		}
+		const buttons = Array.from(
+			next.container.querySelectorAll<HTMLButtonElement>('[data-chart-legend-value]')
+		);
+		for (const button of buttons) {
+			const item = series.find((item) => item.label === button.dataset.chartLegendValue);
+			if (item) paintLegendSwatch(button, item, options.kind);
+		}
+
+		contract?.render({
+			name,
+			kind: options.kind === 'coverage' ? 'line' : (options.kind ?? 'line'),
+			axes:
+				options.compact && size.width < 560 ? [] : [options.xTitle, options.yTitle].filter(Boolean),
+			series: series.map((item, index) => {
+				const visible = !hidden.has(item.label);
+				const points = positioned.get(`series-${index}`) ?? [];
+				const values = visible
+					? points.map((point) =>
+							point.datum.value === undefined ? point.datum.y : point.datum.value
+						)
+					: item.data
+							.filter((point) => {
+								const x = next.scene.scales.x.map(point.x);
+								return (
+									finitePoint(point) &&
+									Number.isFinite(x) &&
+									x >= next.scene.chart.x &&
+									x <= next.scene.chart.x + next.scene.chart.width
+								);
+							})
+							.map((point) => (point.value === undefined ? point.y : point.value));
+				const finite = values.filter(
+					(value): value is number => typeof value === 'number' && Number.isFinite(value)
+				);
+				const button = Array.from(
+					next.container.querySelectorAll<HTMLButtonElement>('[data-chart-legend-value]')
+				).find((button) => button.dataset.chartLegendValue === item.label);
+				const box = button?.getBoundingClientRect();
+				const bounds = surface.getBoundingClientRect();
+				const target = points[0];
+				return {
+					label: item.label,
+					visible,
+					count: finite.length,
+					min: finite.length ? finite.reduce((min, value) => Math.min(min, value), Infinity) : null,
+					max: finite.length
+						? finite.reduce((max, value) => Math.max(max, value), -Infinity)
+						: null,
+					total: finite.reduce((sum, value) => sum + value, 0),
+					point:
+						visible && target
+							? { x: target.x / next.scene.width, y: target.y / next.scene.height }
+							: undefined,
+					legend:
+						box && bounds.width && bounds.height
+							? {
+									x: (box.x + box.width / 2 - bounds.x) / bounds.width,
+									y: (box.y + box.height / 2 - bounds.y) / bounds.height
+								}
+							: undefined
+				};
+			})
+		});
+		const legend = next.container.querySelector('.ts-chart__interactive-legend');
+		const syncLegendTargets = () => {
+			const bounds = surface.getBoundingClientRect();
+			for (const button of next.container.querySelectorAll<HTMLButtonElement>(
+				'[data-chart-legend-value]'
+			)) {
+				const box = button.getBoundingClientRect();
+				if (bounds.width && bounds.height)
+					contract?.legend(button.dataset.chartLegendValue ?? '', {
+						x: (box.x + box.width / 2 - bounds.x) / bounds.width,
+						y: (box.y + box.height / 2 - bounds.y) / bounds.height
+					});
+			}
+		};
+		legend?.addEventListener('scroll', syncLegendTargets);
+		selection = document.createElement('div');
+		selection.className =
+			'pointer-events-none absolute border border-muted-foreground/70 bg-muted-foreground/20';
+		externalCrosshair = document.createElement('div');
+		externalCrosshair.className =
+			'pointer-events-none absolute border-l border-dashed border-current opacity-60';
+		next.container.append(selection, externalCrosshair);
+		const unsubscribeHover = crosshairStore.subscribe(() => paintExternalHover());
+		const unsubscribeRange = rangeSelection.subscribe(() => paintSelection());
+		next.container.addEventListener('mousedown', beginDrag, true);
+		next.container.addEventListener('mousemove', moveDrag, true);
+		window.addEventListener('mouseup', finishDrag, true);
+		next.container.addEventListener('keydown', handleKey, true);
+		next.container.addEventListener('click', selectAtPointer, true);
+		cleanupRender = () => {
+			legend?.removeEventListener('scroll', syncLegendTargets);
+			unsubscribeHover();
+			unsubscribeRange();
+			next.container.removeEventListener('mousedown', beginDrag, true);
+			next.container.removeEventListener('mousemove', moveDrag, true);
+			window.removeEventListener('mouseup', finishDrag, true);
+			next.container.removeEventListener('keydown', handleKey, true);
+			next.container.removeEventListener('click', selectAtPointer, true);
+			selection?.remove();
+			externalCrosshair?.remove();
+		};
+	}
+
+	function atPixel(x: number): PlotPoint | null {
+		if (!context) return null;
+		const value = context.scene.scales.x.invert?.(x);
+		const index = typeof value === 'number' ? findNearestValueIndex(timestamps, value) : null;
+		return index === null ? null : timeline[index];
+	}
+
+	function pointerPosition(event: MouseEvent) {
+		return context?.interaction.clientToScene(event.clientX, event.clientY) ?? null;
+	}
+
+	function selectAtPointer(event: MouseEvent) {
+		if (
+			!onSelect ||
+			!context ||
+			(event.target instanceof Element && event.target.closest('button'))
+		)
+			return;
+		const position = pointerPosition(event);
+		const area = context.scene.chart;
+		if (
+			!position ||
+			position.x < area.x ||
+			position.x > area.x + area.width ||
+			position.y < area.y ||
+			position.y > area.y + area.height
+		)
+			return;
+		event.stopImmediatePropagation();
+		if (suppressSelect) {
+			suppressSelect = false;
+			return;
+		}
+		const point = atPixel(position.x);
+		if (point) onSelect(point);
+	}
+
+	function beginDrag(event: MouseEvent) {
+		if (
+			!onRange ||
+			event.button !== 0 ||
+			!context ||
+			(event.target instanceof Element && event.target.closest('button'))
+		)
+			return;
+		const position = pointerPosition(event);
+		const area = context.scene.chart;
+		if (
+			!position ||
+			position.x < area.x ||
+			position.x > area.x + area.width ||
+			position.y < area.y ||
+			position.y > area.y + area.height
+		)
+			return;
+		rangeStart = position.x;
+		rangeEnd = position.x;
+		suppressSelect = false;
+	}
+
+	function moveDrag(event: MouseEvent) {
+		if (rangeStart === null || !context) return;
+		const position = pointerPosition(event);
+		if (!position) return;
+		rangeEnd = Math.max(
+			context.scene.chart.x,
+			Math.min(context.scene.chart.x + context.scene.chart.width, position.x)
+		);
+		const first = atPixel(rangeStart);
+		const last = atPixel(rangeEnd);
+		if (first && last && chartId)
+			rangeSelection.set({
+				sourceChartId: chartId,
+				startLabel: first.label ?? '',
+				endLabel: last.label ?? ''
+			});
+		paintSelection();
+	}
+
+	function finishDrag() {
+		if (rangeStart === null || rangeEnd === null) return;
+		if (Math.abs(rangeEnd - rangeStart) >= MIN_DRAG_PIXELS) {
+			suppressSelect = true;
+			const first = atPixel(Math.min(rangeStart, rangeEnd));
+			const last = atPixel(Math.max(rangeStart, rangeEnd));
+			if (first && last) onRange?.(first, last);
+		}
+		rangeStart = null;
+		rangeEnd = null;
+		rangeSelection.clear();
+		paintSelection();
+	}
+
+	function paintSelection() {
+		if (!selection || !context) return;
+		const mirrored = rangeSelection.selection;
+		const from = labeledPoints.get(mirrored?.startLabel);
+		const to = labeledPoints.get(mirrored?.endLabel);
+		const left = rangeStart ?? (from ? context.scene.scales.x.map(from.x) : null);
+		const right = rangeEnd ?? (to ? context.scene.scales.x.map(to.x) : null);
+		selection.hidden = left === null || right === null || Math.abs(right - left) < MIN_DRAG_PIXELS;
+		if (left !== null && right !== null)
+			selection.style.cssText = `left:${Math.min(left, right)}px;top:${context.scene.chart.y}px;width:${Math.abs(right - left)}px;height:${context.scene.chart.height}px`;
+	}
+
+	function paintExternalHover() {
+		if (!externalCrosshair || !context) return;
+		const label = chartId ? crosshairStore.getExternalLabel(chartId) : null;
+		const point = label ? labeledPoints.get(label) : null;
+		externalCrosshair.hidden = !point;
+		if (point)
+			externalCrosshair.style.cssText = `left:${context.scene.scales.x.map(point.x)}px;top:${context.scene.chart.y}px;height:${context.scene.chart.height}px`;
+	}
+
+	function focusGroup(points: readonly ChartPoint<PlotPoint, number, number>[]) {
+		focused = points[0]?.datum ?? null;
+		contract?.tooltip(plotTooltip(points, formatTooltip));
+		if (chartId) {
+			if (focused?.label) crosshairStore.setHover(focused.label, chartId);
+			else if (crosshairStore.sourceChartId === chartId) crosshairStore.clearHover();
+		}
+	}
+
+	function select(point: ChartPoint<PlotPoint, number, number> | null) {
+		if (suppressSelect) {
+			suppressSelect = false;
+			return;
+		}
+		if (point) onSelect?.(point.datum);
+	}
+
+	function handleKey(event: KeyboardEvent) {
+		if (!onRange || !context || (event.target instanceof Element && event.target.closest('button')))
+			return;
+		if (event.key === 'Escape') {
+			keyboardStart = null;
+			rangeSelection.clear();
+			paintSelection();
+			return;
+		}
+		if (keyboardStart && event.key === 'Enter' && focused) {
+			event.preventDefault();
+			event.stopImmediatePropagation();
+			const start = keyboardStart;
+			keyboardStart = null;
+			onRange(start.x <= focused.x ? start : focused, start.x <= focused.x ? focused : start);
+			rangeSelection.clear();
+			return;
+		}
+		if (!event.shiftKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
+		event.preventDefault();
+		event.stopImmediatePropagation();
+		const points = timeline;
+		const index = Math.max(
+			0,
+			points.findIndex((point) => point.x === focused?.x)
+		);
+		keyboardStart ??= focused ?? points[index];
+		const next =
+			points[
+				Math.max(0, Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1)))
+			];
+		const target = context.scene.points.find((point) => point.datum.x === next?.x);
+		if (!target || !keyboardStart) return;
+		context.interaction.setControlledFocus(target);
+		focused = target.datum;
+		if (chartId)
+			rangeSelection.set({
+				sourceChartId: chartId,
+				startLabel: keyboardStart.label ?? '',
+				endLabel: focused.label ?? ''
+			});
+		paintSelection();
+	}
+</script>
+
+<div class="relative h-full w-full min-w-0" {@attach attachSize}>
+	{#if hasData || retainEmptySurface}
+		<div class="h-full" {@attach attachLifetime}>
+			<Chart
+				{definition}
+				ariaLabel={name}
+				height={size.height}
+				onRender={publish}
+				onFocusGroupChange={focusGroup}
+				onSelect={select}
+			>
+				{#snippet tooltipBody({ points, defaultBody })}
+					{#if options.kind === 'scatter'}
+						<div role="tooltip" class="whitespace-pre-line">
+							{plotTooltip(points, formatTooltip)}
+						</div>
+					{:else}
+						{@render defaultBody()}
+					{/if}
+				{/snippet}
+			</Chart>
+		</div>
+		{#if !hasData}<p
+				class="text-muted-foreground pointer-events-none absolute inset-0 flex items-center justify-center px-4 text-center text-sm"
+			>
+				{emptyCopy}
+			</p>{/if}
+		<p class="sr-only">
+			Use arrow keys to explore values.
+			{#if onSelect}Press Enter to open a bucket.{/if}
+			{#if onRange}Shift and arrow keys select a time range; Enter applies it and Escape cancels.{/if}
+		</p>
+	{:else}
+		<p
+			class="text-muted-foreground flex h-full items-center justify-center px-4 text-center text-sm"
+		>
+			{emptyCopy}
+		</p>
+	{/if}
+</div>
+
+<style>
+	:global(.ts-chart__interactive-legend) {
+		overflow: auto;
+		align-content: start;
+		grid-auto-rows: 44px;
+	}
+
+	:global(.ts-chart-host) {
+		--ts-chart-foreground: var(--chart-text-color);
+		--ts-chart-grid: var(--chart-grid-color);
+		--ts-chart-tooltip-background: var(--chart-tooltip-bg);
+		--ts-chart-tooltip-color: var(--chart-tooltip-text-color);
+		--ts-chart-tooltip-border: 1px solid var(--chart-tooltip-border-color);
+	}
+</style>
```

## apps/web/src/lib/components/charts/CoverageStrip.svelte

Status: modified; +115; −330

```diff
@@ -33,21 +33,11 @@
 </script>
 
 <script lang="ts">
-	import { onDestroy } from 'svelte';
-	import type { Plugin, TooltipItem } from 'chart.js';
 	import DragGrip from '#lib/components/common/DragGrip.svelte';
 	import type { BucketCoverage, CoverageState } from '#lib/types/types.ts';
 	import type { GroupByOption, RouterConfig } from '#lib/components/netflow/types.ts';
 	import { dateStringToEpochPST } from '#lib/utils/timezone.ts';
-	import {
-		ensureCachedWindow,
-		getMissingWindowRanges,
-		readCachedWindow,
-		type TimeRange
-	} from '#lib/utils/window-cache.ts';
-	import { crosshairStore } from '#lib/stores/crosshair.ts';
-	import { theme } from '#lib/stores/theme.svelte.ts';
+	import { ensureCachedWindow, readCachedWindow, type TimeRange } from '#lib/utils/window-cache.ts';
-	import { Chart } from './chart-registry';
 	import { findTemporalDataBounds } from './chart-utils';
 	import {
 		formatCoverageState,
@@ -55,9 +45,9 @@
 		type CoverageStripBucket,
 		type CoverageStripTimeline
 	} from './coverage-strip';
+	import ChartPlot from './ChartPlot.svelte';
-
+	import type { PlotSeries } from './chart-registry';
 	const CHART_ID = 'coverage-strip';
-
 	const props = $props<{
 		dataset: string;
 		startDate: string;
@@ -66,34 +56,63 @@
 		routers: RouterConfig;
 		routersLoaded: boolean;
 	}>();
-
-	type ChartPalette = {
-		textColor: string;
-		trackColor: string;
-		tooltipBackgroundColor: string;
-		tooltipTextColor: string;
-		tooltipBorderColor: string;
-		completeColor: string;
-		partialColor: string;
-	};
-
-	let timelines = $state.raw<CoverageStripTimeline[]>([]);
-	let loading = $state(false);
-	let error = $state<string | null>(null);
-	let canvas = $state<HTMLCanvasElement | null>(null);
-	let chart: Chart<'line', number[], string> | null = null;
-	let requestKey = '';
-	let requestToken = 0;
-	let requestController: AbortController | null = null;
-	let renderedPalette: ChartPalette | null = null;
-	let renderedTimelines: CoverageStripTimeline[] = [];
-
 	const startEpoch = $derived(dateStringToEpochPST(props.startDate));
 	const endEpoch = $derived(dateStringToEpochPST(props.endDate, true));
 	const selectedRouters = $derived(deriveSelectedRouters(props.routers));
+	const requestKey = $derived(
+		JSON.stringify({
+			dataset: props.dataset,
+			startEpoch,
+			endEpoch,
+			groupBy: props.groupBy,
+			routers: selectedRouters
+		})
+	);
+	let settled = $state.raw<{
+		key: string;
+		timelines: CoverageStripTimeline[];
+		error: string | null;
+	} | null>(null);
+	const loading = $derived(
+		!props.routersLoaded || (selectedRouters.length > 0 && settled?.key !== requestKey)
+	);
+	const error = $derived(
+		selectedRouters.length === 0 && props.routersLoaded
+			? 'Select at least one source to view coverage'
+			: settled?.key === requestKey
+				? settled.error
+				: null
+	);
+	const timelines = $derived(settled?.key === requestKey ? settled.timelines : []);
 	const visibleTimelines = $derived(trimCoverageTimelines(timelines));
 	const chartHeight = $derived(Math.max(48, visibleTimelines.length * 18 + 30));
+	const series = $derived<PlotSeries[]>(
+		visibleTimelines.map((timeline, index) => ({
+			label: timeline.sourceId,
+			color: 'rgb(16,185,129)',
+			data: timeline.buckets.map((bucket) => ({
+				x: bucket.bucketStart,
-
+				x2: bucket.bucketEnd,
+				y: index,
+				label: formatCoverageStripLabel(bucket.bucketStart, props.groupBy),
+				coverage: bucket.coverage,
+				value:
+					bucket.coverage.state === 'unknown' || bucket.coverage.expectedUnits === 0
+						? null
+						: bucket.coverage.observedUnits / bucket.coverage.expectedUnits
+			}))
+		}))
+	);
+	const domain = $derived.by((): readonly [number, number] => {
+		let min = Infinity;
+		let max = -Infinity;
+		for (const item of series)
+			for (const point of item.data) {
+				min = Math.min(min, point.x);
+				max = Math.max(max, point.x2 ?? point.x);
+			}
+		return Number.isFinite(min) ? [min, max] : [0, 1];
+	});
 	function deriveSelectedRouters(routers: RouterConfig): string[] {
 		return Object.entries(routers)
 			.filter(([, enabled]) => enabled)
@@ -194,308 +213,52 @@
 		return rebuildCoverageTimelines(records, routers);
 	}
 
-	async function loadCoverage(routers: string[], token: number) {
-		requestController?.abort();
+	async function loadCoverage(routers: string[], signal: AbortSignal) {
+		const range = { start: startEpoch, end: endEpoch };
-		const controller = new AbortController();
-		requestController = controller;
-		const requestedRange = { start: startEpoch, end: endEpoch };
 		const cacheKey = getCacheKey(routers);
-		loading = getMissingWindowRanges(cacheKey, requestedRange).length > 0;
-		error = null;
-		const baseParams = new URLSearchParams({
+		const params = new URLSearchParams({
 			dataset: props.dataset,
 			groupBy: props.groupBy,
 			routers: routers.join(',')
 		});
+		await ensureCachedWindow<CachedCoverageRecord>({
+			key: cacheKey,
+			requestedRange: range,
+			signal,
+			fetchRange: async (range, signal) => {
+				const response = await fetch(
+					`/api/netflow/coverage?${new URLSearchParams({ ...Object.fromEntries(params), startDate: String(range.start), endDate: String(range.end) })}`,
+					{ signal }
+				);
+				if (!response.ok) throw new Error((await response.text()) || 'Failed to load coverage');
-
-		try {
-			await ensureCachedWindow<CachedCoverageRecord>({
-				key: cacheKey,
-				requestedRange,
-				signal: controller.signal,
-				fetchRange: async (range, signal) => {
-					const response = await fetch(
-						`/api/netflow/coverage?${new URLSearchParams({
-							...Object.fromEntries(baseParams.entries()),
-							startDate: range.start.toString(),
-							endDate: range.end.toString()
-						}).toString()}`,
-						{ signal }
-					);
-					if (!response.ok) {
-						const message = await response.text();
-						throw new Error(message || `Failed to load coverage: ${response.statusText}`);
-					}
-					const payload: unknown = await response.json();
-					return flattenCoverageTimelines(parseCoverageTimelines(payload));
-				},
-				getRecordKey: (record) => `${record.sourceId}:${record.bucket.bucketStart}`,
-				compareRecords: (left, right) =>
-					left.bucket.bucketStart - right.bucket.bucketStart ||
-					left.sourceId.localeCompare(right.sourceId)
-			});
-			if (token !== requestToken) return;
-			timelines = readCachedCoverage(cacheKey, requestedRange, routers);
-		} catch (err) {
-			if (token !== requestToken) return;
-			if (err instanceof DOMException && err.name === 'AbortError') return;
-			timelines = [];
-			error = err instanceof Error ? err.message : 'Failed to load coverage';
-		} finally {
-			if (token === requestToken) {
-				loading = false;
+				return flattenCoverageTimelines(parseCoverageTimelines(await response.json()));
+			},
-				if (requestController === controller) requestController = null;
-			}
-		}
+			getRecordKey: (record) => `${record.sourceId}:${record.bucket.bucketStart}`,
+			compareRecords: (left, right) =>
+				left.bucket.bucketStart - right.bucket.bucketStart ||
+				left.sourceId.localeCompare(right.sourceId)
+		});
+		return readCachedCoverage(cacheKey, range, routers);
 	}
+	$effect(() => {
+		const key = requestKey;
+		if (!props.routersLoaded || selectedRouters.length === 0) return;
+		const controller = new AbortController();
+		loadCoverage(selectedRouters, controller.signal).then(
+			(timelines) => {
+				if (!controller.signal.aborted) settled = { key, timelines, error: null };
+			},
-
-	function getChartColors(): ChartPalette {
-		const style = getComputedStyle(document.documentElement);
-		return {
-			textColor: style.getPropertyValue('--chart-text-color').trim(),
-			trackColor: style.getPropertyValue('--chart-grid-color').trim(),
-			tooltipBackgroundColor: style.getPropertyValue('--chart-tooltip-bg').trim(),
-			tooltipTextColor: style.getPropertyValue('--chart-tooltip-text-color').trim(),
-			tooltipBorderColor: style.getPropertyValue('--chart-tooltip-border-color').trim(),
-			completeColor: 'rgb(16, 185, 129)',
-			partialColor: 'rgb(245, 158, 11)'
-		};
-	}
-
-	const coverageLanesPlugin: Plugin<'line'> = {
-		id: 'coverageLanes',
-		beforeDatasetsDraw(chartInstance) {
-			const palette = renderedPalette;
-			if (!palette) return;
-			const xScale = chartInstance.scales.x;
-			const yScale = chartInstance.scales.y;
+			(reason: unknown) => {
+				if (!controller.signal.aborted)
+					settled = {
+						key,
+						timelines: [],
+						error: reason instanceof Error ? reason.message : 'Failed to load coverage'
+					};
-			const { ctx, chartArea } = chartInstance;
-			const bucketCount = renderedTimelines[0]?.buckets.length ?? 0;
-			if (!xScale || !yScale || bucketCount === 0) return;
-
-			const centers = Array.from({ length: bucketCount }, (_, index) =>
-				xScale.getPixelForValue(index)
-			);
-
-			ctx.save();
-			ctx.lineCap = 'butt';
-			for (const [sourceIndex, timeline] of renderedTimelines.entries()) {
-				const y = Math.round(yScale.getPixelForValue(sourceIndex)) + 0.5;
-				ctx.strokeStyle = palette.trackColor;
-				ctx.lineWidth = 1;
-				ctx.setLineDash([]);
-				ctx.beginPath();
-				ctx.moveTo(chartArea.left, y);
-				ctx.lineTo(chartArea.right, y);
-				ctx.stroke();
-
-				for (const [bucketIndex, bucket] of timeline.buckets.entries()) {
-					if (bucket.coverage.state === 'unknown') continue;
-					const center = centers[bucketIndex];
-					if (center === undefined) continue;
-					const previousCenter = centers[bucketIndex - 1];
-					const nextCenter = centers[bucketIndex + 1];
-					const left =
-						previousCenter === undefined ? chartArea.left : (previousCenter + center) / 2;
-					const right = nextCenter === undefined ? chartArea.right : (center + nextCenter) / 2;
-
-					ctx.strokeStyle =
-						bucket.coverage.state === 'complete' ? palette.completeColor : palette.partialColor;
-					ctx.lineWidth = 2;
-					ctx.setLineDash(bucket.coverage.state === 'partial' ? [5, 4] : []);
-					ctx.beginPath();
-					ctx.moveTo(left, y);
-					ctx.lineTo(right, y);
-					ctx.stroke();
-				}
 			}
-			ctx.restore();
-		}
-	};
-
-	function destroyChart() {
-		if (!chart) return;
-		crosshairStore.unregister(CHART_ID);
-		chart.destroy();
-		chart = null;
-	}
-
-	function renderChart() {
-		if (!canvas || visibleTimelines.length === 0 || visibleTimelines[0]?.buckets.length === 0) {
-			destroyChart();
-			return;
-		}
-
-		const palette = getChartColors();
-		const labels = visibleTimelines[0].buckets.map((bucket) =>
-			formatCoverageStripLabel(bucket.bucketStart, props.groupBy)
 		);
-		const datasets = visibleTimelines.map((timeline, sourceIndex) => ({
-			label: timeline.sourceId,
-			data: timeline.buckets.map(() => sourceIndex),
-			chartContractValues: timeline.buckets.map((bucket) =>
-				bucket.coverage.state === 'unknown'
-					? null
-					: bucket.coverage.observedUnits / bucket.coverage.expectedUnits
-			),
-			borderColor: 'transparent',
-			backgroundColor: 'transparent',
-			borderWidth: 0,
-			pointRadius: 0,
-			pointHoverRadius: 0,
-			pointHitRadius: 10,
-			showLine: false
-		}));
-
-		renderedPalette = palette;
-		renderedTimelines = visibleTimelines;
-		const config = {
-			type: 'line',
-			data: { labels, datasets },
-			plugins: [coverageLanesPlugin],
-			options: {
-				responsive: true,
-				maintainAspectRatio: false,
-				animation: false,
-				normalized: true,
-				interaction: { mode: 'index', axis: 'x', intersect: false },
-				layout: { padding: { top: 4, right: 4, bottom: 4 } },
-				plugins: {
-					legend: { display: false },
-					tooltip: {
-						mode: 'index',
-						intersect: false,
-						displayColors: false,
-						backgroundColor: palette.tooltipBackgroundColor,
-						titleColor: palette.tooltipTextColor,
-						bodyColor: palette.tooltipTextColor,
-						borderColor: palette.tooltipBorderColor,
-						borderWidth: 1,
-						callbacks: {
-							label: (context: TooltipItem<'line'>) => {
-								const timeline = visibleTimelines[context.datasetIndex];
-								const bucket = timeline?.buckets[context.dataIndex];
-								return bucket
-									? `${timeline.sourceId}: ${formatCoverageState(bucket.coverage)}`
-									: '';
-							}
-						}
-					},
-					verticalCrosshair: {
-						enabled: true,
-						line: {
-							color: 'rgba(100, 100, 100, 0.8)',
-							width: 1,
-							dash: [3, 3]
-						},
-						tooltip: {
-							enabled: false,
-							delay: 500,
-							backgroundColor: palette.tooltipBackgroundColor,
-							textColor: palette.tooltipTextColor,
-							borderColor: palette.tooltipBorderColor,
-							borderWidth: 1,
-							borderRadius: 4,
-							padding: 8,
-							fontSize: 12,
-							fontFamily: 'system-ui, sans-serif'
-						},
-						sync: {
-							onHover: (label: string | null) => crosshairStore.setHover(label, CHART_ID),
-							getExternalLabel: () => crosshairStore.getExternalLabel(CHART_ID)
-						}
-					}
-				} as Record<string, unknown>,
-				scales: {
-					x: {
-						type: 'category',
-						offset: true,
-						ticks: { display: false },
-						grid: { display: false },
-						border: { display: false }
-					},
-					y: {
-						type: 'linear',
-						min: -0.5,
-						max: visibleTimelines.length - 0.5,
-						reverse: true,
-						afterBuildTicks: (axis: { ticks: Array<{ value: number }> }) => {
-							axis.ticks = visibleTimelines.map((_, index) => ({ value: index }));
-						},
-						ticks: {
-							color: palette.textColor,
-							font: { size: 10 },
-							padding: 6,
-							callback: (value: string | number) => visibleTimelines[Number(value)]?.sourceId ?? ''
-						},
-						grid: { display: false },
-						border: { display: false }
-					}
-				}
-			}
-		};
-		if (chart && chart.canvas === canvas) {
-			chart.data = config.data;
-			chart.options = config.options as never;
-			chart.update('none');
-			return;
-		}
-
-		destroyChart();
-		chart = new Chart(canvas, config as never);
-		crosshairStore.register(CHART_ID, chart);
-	}
-
-	$effect(() => {
-		if (!props.routersLoaded) {
-			requestToken += 1;
-			requestController?.abort();
-			requestController = null;
-			requestKey = '';
-			loading = true;
-			error = null;
-			timelines = [];
-			return;
-		}
-
-		if (selectedRouters.length === 0) {
-			requestToken += 1;
-			requestController?.abort();
+		return () => controller.abort();
-			requestController = null;
-			requestKey = '';
-			loading = false;
-			error = 'Select at least one source to view coverage';
-			timelines = [];
-			return;
-		}
-
-		const nextKey = JSON.stringify({
-			dataset: props.dataset,
-			startDate: props.startDate,
-			endDate: props.endDate,
-			groupBy: props.groupBy,
-			routers: selectedRouters
-		});
-		if (nextKey === requestKey) return;
-		requestKey = nextKey;
-		const token = ++requestToken;
-		void loadCoverage(selectedRouters, token);
 	});
-
-	$effect(() => {
-		void theme.dark;
-		void timelines;
-		renderChart();
-	});
-
-	onDestroy(() => {
-		requestToken += 1;
-		requestController?.abort();
-		if (crosshairStore.sourceChartId === CHART_ID) {
-			crosshairStore.clearHover();
-		}
-		destroyChart();
-	});
 </script>
 
 <div
@@ -533,7 +296,29 @@
 				role="region"
 				aria-label="Coverage timeline"
 			>
+				<ChartPlot
+					name="Coverage time series"
+					chartId={CHART_ID}
+					{series}
+					options={{
+						kind: 'coverage',
+						xTitle: '',
+						yTitle: '',
+						xDomain: domain,
+						yDomain: [visibleTimelines.length - 0.5, -0.5],
+						yTicks: visibleTimelines.map((_, index) => index),
+						yFormat: (value) => visibleTimelines[value]?.sourceId ?? '',
+						legend: false
+					}}
+					formatTooltip={(points) =>
+						[
+							points[0]?.datum.label ?? '',
+							...points.map(
+								(point) =>
+									`${point.groupLabel}: ${point.datum.coverage ? formatCoverageState(point.datum.coverage) : ''}`
-				<canvas bind:this={canvas} aria-label="Coverage time series"></canvas>
+							)
+						].join('\n')}
+				/>
 			</div>
 			<p class="sr-only">
 				{visibleTimelines.length} source coverage lanes across {visibleTimelines[0]?.buckets
```

## apps/web/src/lib/components/charts/MetricLinePanel.svelte

Status: modified; +51; −429

```diff
@@ -1,43 +1,12 @@
 <script lang="ts">
-	import { onDestroy } from 'svelte';
-	import type {
+	import ChartPlot from './ChartPlot.svelte';
+	import { plotBounds, type PlotSeries } from './chart-registry';
+	import { openTemporalPoint, openTemporalRange } from './temporal-navigation';
+	import type { ChartCoverage } from './chart-utils';
+	import { groupByBucketDurationMs } from './chart-utils';
+	import { formatIpGranularityTick, formatTemporalBucketLabel } from './ip-time-axis';
-		ActiveElement,
-		ChartConfiguration,
-		ChartEvent,
-		ScriptableLineSegmentContext
-	} from 'chart.js';
-	import { Chart } from './chart-registry';
-	import { buildCoveragePointStyle } from './coverage-line-style';
 	import type { GroupByOption } from '#lib/components/netflow/types.ts';
 	import type { IpGranularity } from '#lib/types/types.ts';
-	import {
-		formatIpGranularityTick,
-		formatTemporalBucketLabel,
-		shouldHighlightIpGranularityGrid
-	} from './ip-time-axis';
-	import { theme } from '#lib/stores/theme.svelte.ts';
-	import { crosshairStore } from '#lib/stores/crosshair.ts';
-	import { rangeSelection } from '#lib/stores/rangeSelection.svelte.ts';
-	import { cancelDrawFrame, requestDrawFrame } from '#lib/utils/animation-frame.ts';
-	import { formatDateAsPSTDateString } from '#lib/utils/timezone.ts';
-	import {
-		MIN_DRAG_PIXELS,
-		Y_AXIS_WIDTH,
-		beginRangeDrag,
-		buildMirroredSelectionStyle,
-		chooseAdaptiveGranularity,
-		createRangeDragState,
-		endRangeDrag,
-		findTemporalDataBounds,
-		generateSlugFromLabel,
-		getSelectionLabels,
-		groupByBucketDurationMs,
-		indexFromPixelX,
-		isCoverageSegmentDashed,
-		parseClickedLabel,
-		updateRangeDrag,
-		type ChartCoverage
-	} from './chart-utils';
 
 	export type MetricLineSeries = {
 		label: string;
@@ -46,15 +15,7 @@
 		dash?: number[];
 		coverage: ChartCoverage[];
 	};
-
-	type MetricLinePoint = {
-		x: number;
-		y: number | null;
-		bucketStart: number;
-		coverage: ChartCoverage;
-	};
-
-	type Props = {
+	const props: {
 		chartId: string;
 		title: string;
 		hideTitle?: boolean;
@@ -66,394 +27,55 @@
 		valueFormat?: 'duration' | 'decimal' | 'integer';
 		onDrillDown?: (groupBy: GroupByOption, startDate: string, endDate: string) => void;
 		onNavigateToFile?: (slug: string) => void;
-	};
-
-	const props: Props = $props();
-
-	let canvas = $state<HTMLCanvasElement | null>(null);
-	let chart: Chart<'line', MetricLinePoint[], string> | null = null;
-	let rangeDrag = $state(createRangeDragState());
+	} = $props();
+	const series = $derived<PlotSeries[]>(
+		props.series.map((item) => ({
+			label: item.label,
+			color: item.color,
+			dash: item.dash?.join(' '),
+			data: props.bucketStarts.map((start, index) => ({
+				x: start,
-	let selectionLeft = $derived(Math.min(rangeDrag.dragStartX, rangeDrag.dragCurrentX));
-	let selectionWidth = $derived(Math.abs(rangeDrag.dragStartX - rangeDrag.dragCurrentX));
-	let mirroredRange = $derived(rangeSelection.selection);
-	let pointerMoveFrame: number | null = null;
-	let pendingPointerMoveEvent: MouseEvent | null = null;
-	const hasData = $derived(
-		props.series.some((series) => series.values.some((value) => typeof value === 'number'))
+				y: item.values[index] ?? null,
+				coverage: item.coverage[index],
+				label: formatTemporalBucketLabel(start, props.granularity)
+			}))
+		}))
 	);
+	const bounds = $derived(plotBounds(series, groupByBucketDurationMs(props.groupBy) / 1000));
+	function formatValue(value: number) {
+		if (props.valueFormat === 'duration')
-
-	function formatValue(value: number): string {
-		if (props.valueFormat === 'duration') {
 			return value >= 1000 ? `${(value / 1000).toFixed(2)} s` : `${value.toFixed(1)} ms`;
-		}
-		if (props.valueFormat === 'integer') {
-			return Math.round(value).toLocaleString();
-		}
+		if (props.valueFormat === 'integer') return Math.round(value).toLocaleString();
 		return value.toFixed(2);
 	}
-
-	function colors() {
-		const style = getComputedStyle(document.documentElement);
-		return {
-			text: style.getPropertyValue('--chart-text-color').trim(),
-			grid: style.getPropertyValue('--chart-grid-color').trim(),
-			gridHighlight: style.getPropertyValue('--chart-grid-highlight-color').trim(),
-			tooltip: style.getPropertyValue('--chart-tooltip-bg').trim(),
-			tooltipText: style.getPropertyValue('--chart-tooltip-text-color').trim(),
-			tooltipBorder: style.getPropertyValue('--chart-tooltip-border-color').trim()
-		};
-	}
-
-	function publishRangeSelection(startIndex: number, endIndex: number) {
-		const labels = getSelectionLabels(chart, startIndex, endIndex);
-		if (!labels) return;
-		rangeSelection.set({ sourceChartId: props.chartId, ...labels });
-	}
-
-	function applyRangeDrilldown(startIndex: number, endIndex: number) {
-		if (!chart?.data.labels || !props.onDrillDown) return;
-		const labels = chart.data.labels as string[];
-		const from = Math.max(0, Math.min(labels.length - 1, Math.min(startIndex, endIndex)));
-		const to = Math.max(0, Math.min(labels.length - 1, Math.max(startIndex, endIndex)));
-		const startLabel = labels[from];
-		const endLabel = labels[to];
-		if (!startLabel || !endLabel) return;
-
-		const startDate = parseClickedLabel(startLabel, props.groupBy);
-		const endBucketStart = parseClickedLabel(endLabel, props.groupBy);
-		if (Number.isNaN(startDate.getTime()) || Number.isNaN(endBucketStart.getTime())) return;
-
-		const endExclusive = new Date(
-			endBucketStart.getTime() + groupByBucketDurationMs(props.groupBy)
-		);
-		const selectedRangeMs = endExclusive.getTime() - startDate.getTime();
-		props.onDrillDown(
-			chooseAdaptiveGranularity(selectedRangeMs),
-			formatDateAsPSTDateString(startDate),
-			formatDateAsPSTDateString(endExclusive)
-		);
-	}
-
-	function handleRangeMouseDown(event: MouseEvent) {
-		cancelPendingPointerMove();
-		beginRangeDrag(rangeDrag, event, canvas, chart, publishRangeSelection);
-	}
-
-	function applyPendingPointerMove() {
-		pointerMoveFrame = null;
-		const event = pendingPointerMoveEvent;
-		pendingPointerMoveEvent = null;
-		if (event) updateRangeDrag(rangeDrag, event, canvas, chart, publishRangeSelection);
-	}
-
-	function cancelPendingPointerMove() {
-		if (pointerMoveFrame !== null) {
-			cancelDrawFrame(pointerMoveFrame);
-			pointerMoveFrame = null;
-		}
-		pendingPointerMoveEvent = null;
-	}
-
-	function flushPendingPointerMove() {
-		if (pointerMoveFrame === null) return;
-		cancelDrawFrame(pointerMoveFrame);
-		applyPendingPointerMove();
-	}
-
-	function handleRangeMouseMove(event: MouseEvent) {
-		if (!rangeDrag.isDraggingRange) return;
-		pendingPointerMoveEvent = event;
-		if (pointerMoveFrame === null) {
-			pointerMoveFrame = requestDrawFrame(applyPendingPointerMove);
-		}
-	}
-
-	function finishRangeSelection() {
-		flushPendingPointerMove();
-		endRangeDrag(rangeDrag, chart, applyRangeDrilldown);
-		rangeSelection.clear();
-	}
-
-	let mirroredSelectionStyle = $derived(
-		buildMirroredSelectionStyle(chart, mirroredRange, props.chartId)
-	);
-
-	function handleChartClick(event: ChartEvent, activeElements: ActiveElement[]) {
-		if (rangeDrag.suppressNextClick) {
-			rangeDrag.suppressNextClick = false;
-			return;
-		}
-		if (!chart?.data.labels) return;
-
-		const index =
-			typeof event.x === 'number'
-				? indexFromPixelX(chart, event.x)
-				: (activeElements[0]?.index ?? null);
-		if (index === null) return;
-		const label = chart.data.labels[index] as string | undefined;
-		if (!label) return;
-
-		if (props.groupBy === '5min') {
-			const slug = generateSlugFromLabel(label, props.groupBy);
-			if (slug) props.onNavigateToFile?.(slug);
-			return;
-		}
-
-		const clickedDate = parseClickedLabel(label, props.groupBy);
-		if (Number.isNaN(clickedDate.getTime()) || !props.onDrillDown) return;
-
-		if (props.groupBy === 'date') {
-			props.onDrillDown(
-				'hour',
-				formatDateAsPSTDateString(new Date(clickedDate.getTime() - 15 * 24 * 60 * 60 * 1000)),
-				formatDateAsPSTDateString(new Date(clickedDate.getTime() + 16 * 24 * 60 * 60 * 1000))
-			);
-		} else if (props.groupBy === 'hour') {
-			props.onDrillDown(
-				'10min',
-				formatDateAsPSTDateString(new Date(clickedDate.getTime() - 3 * 24 * 60 * 60 * 1000)),
-				formatDateAsPSTDateString(new Date(clickedDate.getTime() + 4 * 24 * 60 * 60 * 1000))
-			);
-		} else {
-			props.onDrillDown(
-				'5min',
-				formatDateAsPSTDateString(clickedDate),
-				formatDateAsPSTDateString(new Date(clickedDate.getTime() + 24 * 60 * 60 * 1000))
-			);
-		}
-	}
-
-	function destroyChart() {
-		crosshairStore.unregister(props.chartId);
-		if (crosshairStore.sourceChartId === props.chartId) crosshairStore.clearHover();
-		chart?.destroy();
-		chart = null;
-	}
-
-	function renderChart() {
-		if (!canvas || props.bucketStarts.length === 0 || props.series.length === 0 || !hasData) {
-			destroyChart();
-			return;
-		}
-		const indexedBuckets: Array<{ bucketStart: number; index: number }> = props.bucketStarts.map(
-			(bucketStart, index) => ({ bucketStart, index })
-		);
-		const dataBounds = findTemporalDataBounds(
-			indexedBuckets,
-			(item) => item.bucketStart,
-			(item) => props.series.some((series) => typeof series.values[item.index] === 'number')
-		);
-		if (!dataBounds) {
-			destroyChart();
-			return;
-		}
-
-		const palette = colors();
-		const datasets = props.series.map((series) => {
-			const data: MetricLinePoint[] = props.bucketStarts.map((bucketStart, index) => {
-				const coverage = series.coverage[index] ?? {
-					state: 'unknown' as const,
-					observedUnits: 0,
-					expectedUnits: 0
-				};
-				return {
-					x: bucketStart,
-					y: series.values[index] ?? null,
-					bucketStart,
-					coverage
-				};
-			});
-			const hasPartialCoverage = data.some((point) => point.coverage.state === 'partial');
-			const pointStyle = hasPartialCoverage
-				? buildCoveragePointStyle(
-						data,
-						(point) => point.y,
-						(point) => point.coverage,
-						series.color
-					)
-				: null;
-			return {
-				label: series.label,
-				data,
-				borderColor: series.color,
-				backgroundColor: series.color,
-				borderDash: series.dash ?? [],
-				pointRadius: 0,
-				...(pointStyle ?? {}),
-				pointHoverRadius: 4,
-				spanGaps: false,
-				...(hasPartialCoverage
-					? {
-							segment: {
-								borderDash: (context: ScriptableLineSegmentContext) => {
-									const left = context.p0 as unknown as {
-										raw?: { coverage?: ChartCoverage };
-									};
-									const right = context.p1 as unknown as {
-										raw?: { coverage?: ChartCoverage };
-									};
-									return isCoverageSegmentDashed(left.raw, right.raw)
-										? [6, 4]
-										: (series.dash ?? []);
-								}
-							}
-						}
-					: {}),
-				parsing: false as const,
-				tension: 0.25
-			};
-		});
-		const labels = props.bucketStarts.map((bucketStart) =>
-			formatTemporalBucketLabel(bucketStart, props.granularity)
-		);
-
-		const config: ChartConfiguration<'line', MetricLinePoint[], string> = {
-			type: 'line',
-			data: { labels, datasets },
-			options: {
-				responsive: true,
-				maintainAspectRatio: false,
-				animation: false,
-				normalized: true,
-				onClick: handleChartClick,
-				interaction: { mode: 'index', intersect: false },
-				plugins: {
-					legend: { position: 'top', labels: { color: palette.text } },
-					tooltip: {
-						backgroundColor: palette.tooltip,
-						titleColor: palette.tooltipText,
-						bodyColor: palette.tooltipText,
-						borderColor: palette.tooltipBorder,
-						borderWidth: 1,
-						callbacks: {
-							label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) =>
-								`${context.dataset.label ?? ''}: ${context.parsed.y === null ? 'No data' : formatValue(context.parsed.y)}`
-						}
-					},
-					verticalCrosshair: {
-						enabled: true,
-						line: { color: 'rgba(100, 100, 100, 0.8)', width: 1, dash: [3, 3] },
-						tooltip: {
-							enabled: true,
-							delay: 500,
-							backgroundColor: palette.tooltip,
-							textColor: palette.tooltipText,
-							borderColor: palette.tooltipBorder,
-							borderWidth: 1,
-							borderRadius: 4,
-							padding: 8,
-							fontSize: 12,
-							fontFamily: 'system-ui, sans-serif'
-						},
-						sync: {
-							onHover: (label: string | null) => crosshairStore.setHover(label, props.chartId),
-							getExternalLabel: () => crosshairStore.getExternalLabel(props.chartId)
-						}
-					}
-				} as never,
-				scales: {
-					x: {
-						type: 'linear',
-						min: dataBounds.min,
-						max: dataBounds.max,
-						title: {
-							display: true,
-							text: `Time (${props.granularity})`,
-							color: palette.text
-						},
-						ticks: {
-							color: palette.text,
-							autoSkip: false,
-							maxRotation: 45,
-							minRotation: 45,
-							sampleSize: 12,
-							callback: (value: string | number) =>
-								formatIpGranularityTick(Number(value), props.granularity, 0)
-						},
-						grid: {
-							color: (context: { tick?: { value?: number } }) =>
-								shouldHighlightIpGranularityGrid(
-									Number(context.tick?.value ?? 0),
-									props.granularity,
-									0
-								)
-									? palette.grid
-									: palette.gridHighlight
-						}
-					},
-					y: {
-						beginAtZero: true,
-						afterFit(axis) {
-							axis.width = Y_AXIS_WIDTH;
-						},
-						title: { display: true, text: props.yAxisTitle, color: palette.text },
-						ticks: {
-							color: palette.text,
-							callback: (value: string | number) => formatValue(Number(value))
-						},
-						grid: { color: palette.grid }
-					}
-				}
-			}
-		};
-		if (chart && chart.canvas === canvas) {
-			chart.data = config.data;
-			chart.options = config.options as never;
-			chart.update('none');
-			return;
-		}
-
-		destroyChart();
-		chart = new Chart(canvas, config);
-		crosshairStore.register(props.chartId, chart);
-	}
-
-	$effect(() => {
-		void theme.dark;
-		renderChart();
-	});
-
-	onDestroy(() => {
-		cancelPendingPointerMove();
-		if (mirroredRange?.sourceChartId === props.chartId) rangeSelection.clear();
-		destroyChart();
-	});
 </script>
 
 <section class="flex h-full min-h-0 min-w-0 flex-col" aria-label={props.title}>
-	{#if !props.hideTitle}
-		<h3 class="text-foreground px-3 pt-3 text-sm font-semibold">{props.title}</h3>
-	{/if}
-	{#if !hasData}
-		<div class="text-muted-foreground flex flex-1 items-center justify-center text-sm">
-			No data for this metric
-		</div>
-	{:else}
-		<div
-			class="relative min-h-0 flex-1"
-			role="presentation"
-			onmousedown={handleRangeMouseDown}
-			onmousemove={handleRangeMouseMove}
-			onmouseup={finishRangeSelection}
-			onmouseleave={finishRangeSelection}
-		>
-			<canvas bind:this={canvas} class="h-full w-full" aria-label={`${props.title} time series`}
-			></canvas>
-			{#if rangeDrag.isDraggingRange && selectionWidth >= MIN_DRAG_PIXELS}
-				<div
-					class="border-muted-foreground/70 bg-muted-foreground/20 pointer-events-none absolute border"
-					style={`left:${selectionLeft}px; width:${selectionWidth}px; top:${rangeDrag.selectionTop}px; height:${rangeDrag.selectionHeight}px;`}
-				></div>
-			{/if}
-			{#if !rangeDrag.isDraggingRange && mirroredSelectionStyle !== null}
-				<div
-					class="border-muted-foreground/70 bg-muted-foreground/20 pointer-events-none absolute border"
-					style={mirroredSelectionStyle}
-				></div>
-			{/if}
-		</div>
-		<p class="sr-only">
+	{#if !props.hideTitle}<h3 class="text-foreground px-3 pt-3 text-sm font-semibold">
+			{props.title}
+		</h3>{/if}
+	<div class="relative min-h-0 flex-1">
+		<ChartPlot
+			name={`${props.title} time series`}
+			chartId={props.chartId}
+			{series}
+			emptyCopy="No data for this metric"
+			options={{
+				xTitle: `Time (${props.granularity})`,
+				yTitle: props.yAxisTitle,
-			{props.series.length} series across {props.bucketStarts.length} time buckets.
-		</p>
-	{/if}
+				xDomain: bounds,
+				xTicks: props.bucketStarts.filter((start) => start >= bounds[0] && start <= bounds[1]),
+				xFormat: (start) => formatIpGranularityTick(start, props.granularity, 0),
+				yFormat: formatValue
+			}}
+			formatTooltip={(points) =>
+				[
+					points[0]?.datum.label ?? '',
+					...points.map((point) => `${point.groupLabel}: ${formatValue(point.datum.y ?? 0)}`)
+				].join('\n')}
+			onSelect={(point) =>
+				openTemporalPoint(point, props.groupBy, props.onDrillDown, props.onNavigateToFile)}
+			onRange={(start, end) => openTemporalRange(start, end, props.groupBy, props.onDrillDown)}
+		/>
+	</div>
 </section>
```

## apps/web/src/lib/components/charts/SpectrumChart.svelte

Status: modified; +50; −137

```diff
@@ -1,135 +1,30 @@
 <script lang="ts">
-	import type { ChartConfiguration } from 'chart.js';
+	import ChartPlot from './ChartPlot.svelte';
 	import { finiteSpectrumPoints, paddedSpectrumBounds } from './spectrum-points';
-	import { Chart } from './annotation-chart-registry';
 	import type { SpectrumData } from '#lib/types/types.ts';
-	import { theme } from '#lib/stores/theme.svelte.ts';
-
 	let { data }: { data: SpectrumData } = $props();
 	const points = $derived(finiteSpectrumPoints(data.spectrum));
-
-	function attachChart(canvas: HTMLCanvasElement) {
-		void theme.dark;
-		const chart = new Chart(canvas, buildConfig());
-		return () => chart.destroy();
-	}
-
-	function getChartColors() {
-		const style = getComputedStyle(document.documentElement);
-		return {
-			textColor: style.getPropertyValue('--chart-text-color').trim(),
-			gridColor: style.getPropertyValue('--chart-grid-color').trim(),
-			tooltipBackgroundColor: style.getPropertyValue('--chart-tooltip-bg').trim(),
-			tooltipTextColor: style.getPropertyValue('--chart-tooltip-text-color').trim(),
-			tooltipBorderColor: style.getPropertyValue('--chart-tooltip-border-color').trim()
-		};
-	}
-
-	function buildConfig(): ChartConfiguration<'line'> {
-		const { textColor, gridColor, tooltipBackgroundColor, tooltipTextColor, tooltipBorderColor } =
-			getChartColors();
-
-		const alphaValues = points.map((p) => p.alpha);
-		const minAlpha = Math.min(...alphaValues);
-		const maxAlpha = Math.max(...alphaValues);
-		const bounds = paddedSpectrumBounds(minAlpha, maxAlpha);
-
-		const chartData = {
-			datasets: [
-				{
-					label: 'f(alpha)',
-					data: points.map((p) => ({ x: p.alpha, y: p.f })),
-					borderColor: 'rgb(147, 51, 234)',
-					backgroundColor: 'rgba(147, 51, 234, 0.1)',
-					borderWidth: 2,
-					pointRadius: 3,
-					pointHoverRadius: 5,
-					pointBackgroundColor: 'rgb(147, 51, 234)',
-					pointBorderColor: 'white',
-					pointBorderWidth: 1,
-					fill: false,
-					tension: 0.3
-				}
-			]
-		};
+	const bounds = $derived.by(() => {
+		let min = Infinity;
+		let max = -Infinity;
+		for (const point of points) {
+			min = Math.min(min, point.alpha);
+			max = Math.max(max, point.alpha);
+		}
+		return paddedSpectrumBounds(min, max);
+	});
+	const series = $derived([
+		{
+			label: 'f(alpha)',
+			color: 'rgb(147, 51, 234)',
+			strokeWidth: 2,
+			pointFill: 'rgb(147, 51, 234)',
+			pointStroke: 'var(--card)',
+			pointStrokeWidth: 1,
+			radius: 3,
+			data: points.map((point) => ({ x: point.alpha, y: point.f }))
+		}
+	]);
-
-		return {
-			type: 'line' as const,
-			data: chartData,
-			options: {
-				responsive: true,
-				maintainAspectRatio: false,
-				animation: false as const,
-				scales: {
-					x: {
-						type: 'linear' as const,
-						min: bounds.min,
-						max: bounds.max,
-						title: {
-							display: true,
-							text: 'alpha',
-							color: textColor
-						},
-						ticks: { color: textColor },
-						grid: { color: gridColor }
-					},
-					y: {
-						type: 'linear' as const,
-						title: {
-							display: true,
-							text: 'f(alpha)',
-							color: textColor
-						},
-						position: 'left' as const,
-						ticks: { color: textColor },
-						grid: { color: gridColor }
-					}
-				},
-				plugins: {
-					legend: {
-						display: true,
-						position: 'top' as const,
-						labels: { color: textColor }
-					},
-					tooltip: {
-						mode: 'nearest' as const,
-						intersect: false,
-						backgroundColor: tooltipBackgroundColor,
-						titleColor: tooltipTextColor,
-						bodyColor: tooltipTextColor,
-						borderColor: tooltipBorderColor,
-						borderWidth: 1,
-						callbacks: {
-							title: (items) => `alpha = ${items[0]?.parsed?.x?.toFixed(6)}`,
-							label: (item) => {
-								const value = item.parsed.y?.toFixed(6);
-								return `${item.dataset.label}: ${value}`;
-							}
-						}
-					},
-					annotation: {
-						annotations: {
-							reference: {
-								type: 'line',
-								xMin: bounds.min,
-								xMax: bounds.max,
-								yMin: bounds.min,
-								yMax: bounds.max,
-								borderColor: 'rgba(128, 128, 128, 0.5)',
-								borderWidth: 1,
-								borderDash: [5, 5]
-							}
-						}
-					},
-					verticalCrosshair: false
-				},
-				interaction: {
-					mode: 'nearest' as const,
-					intersect: false
-				}
-			}
-		};
-	}
 </script>
 
 <div class="w-full">
@@ -144,15 +39,33 @@
 		{/if}
 	</div>
 	<div class="relative h-72 w-full min-w-0 sm:h-96">
+		<ChartPlot
+			name="Multifractal spectrum chart"
+			{series}
+			emptyCopy="No finite spectrum data for this selection."
-		{#if points.length > 0}
+			options={{
-			<canvas {@attach attachChart} aria-label="Multifractal spectrum chart"></canvas>
+				xTitle: 'alpha',
+				yTitle: 'f(alpha)',
+				xDomain: [bounds.min, bounds.max],
-		{:else}
-			<p class="text-muted-foreground flex h-full items-center justify-center">
-				No finite spectrum data for this selection.
-			</p>
-		{/if}
+				zero: false,
+				annotations: [
+					{
+						color: 'rgb(128,128,128)',
+						opacity: 0.5,
+						strokeWidth: 1,
+						dash: '5 5',
+						data: [
+							{ x: bounds.min, y: bounds.min },
+							{ x: bounds.max, y: bounds.max }
+						]
+					}
+				]
+			}}
+			formatTooltip={(points) =>
+				`alpha = ${points[0]?.datum.x.toFixed(6)}\nf(alpha): ${points[0]?.datum.y?.toFixed(6)}`}
+		/>
 	</div>
+	{#if points.length > 0}<p class="text-muted-foreground mt-1 text-xs">
+			Dashed line: f(alpha) = alpha.
+		</p>{/if}
-	{#if points.length > 0}
-		<p class="text-muted-foreground mt-1 text-xs">Dashed line: f(alpha) = alpha.</p>
-	{/if}
 </div>
```

## apps/web/src/lib/components/charts/StructureFunctionChart.svelte

Status: modified; +52; −219

```diff
@@ -1,229 +1,49 @@
 <script lang="ts">
-	import { onMount } from 'svelte';
+	import ChartPlot from './ChartPlot.svelte';
-	import { Chart } from './annotation-chart-registry';
 	import type { StructureFunctionData } from '#lib/types/types.ts';
-	import { theme } from '#lib/stores/theme.svelte.ts';
+	import type { PlotAnnotation } from './chart-registry';
-
 	let { data }: { data: StructureFunctionData } = $props();
-	let chartCanvas: HTMLCanvasElement;
-	let chart: Chart | null = null;
-
-	onMount(() => {
-		return () => {
-			destroyChart();
-		};
-	});
-
-	$effect(() => {
-		if (!chartCanvas?.parentElement) {
-			return;
-		}
-
-		const observer = new ResizeObserver(() => {
-			chart?.resize();
-		});
-		observer.observe(chartCanvas.parentElement);
-
-		return () => {
-			observer.disconnect();
-		};
-	});
-
-	$effect(() => {
-		void theme.dark;
-		if (!chartCanvas) {
-			return;
-		}
-
-		if (data?.structureFunction?.length > 0) {
-			updateChart();
-			return;
-		}
-
-		destroyChart();
-	});
-
-	function getChartColors() {
-		const style = getComputedStyle(document.documentElement);
-		return {
-			textColor: style.getPropertyValue('--chart-text-color').trim(),
-			gridColor: style.getPropertyValue('--chart-grid-color').trim(),
-			tooltipBackgroundColor: style.getPropertyValue('--chart-tooltip-bg').trim(),
-			tooltipTextColor: style.getPropertyValue('--chart-tooltip-text-color').trim(),
+	const points = $derived(
+		data.structureFunction.filter((point) => Number.isFinite(point.q) && Number.isFinite(point.tau))
+	);
+	const series = $derived([
+		{
+			label: 'tau(q)',
+			color: 'rgb(59,130,246)',
+			strokeWidth: 2,
+			data: points.map((point) => ({ x: point.q, y: point.tau, sd: point.sd }))
-			tooltipBorderColor: style.getPropertyValue('--chart-tooltip-border-color').trim()
-		};
-	}
-
-	function destroyChart() {
-		if (chart) {
-			chart.destroy();
-			chart = null;
 		}
-	}
-
-	function updateChart() {
-		const points = data.structureFunction;
-		const { textColor, gridColor, tooltipBackgroundColor, tooltipTextColor, tooltipBorderColor } =
-			getChartColors();
-
-		const errorBarAnnotations: Record<
-			string,
-			{
-				type: 'line';
-				xMin: number;
-				xMax: number;
-				yMin: number;
-				yMax: number;
-				borderColor: string;
-				borderWidth: number;
-			}
-		> = {};
-
-		points.forEach((point, index) => {
-			const capWidth = 0.02;
-
-			errorBarAnnotations[`errorBar_${index}`] = {
-				type: 'line',
-				xMin: point.q,
-				xMax: point.q,
-				yMin: point.tau - point.sd,
-				yMax: point.tau + point.sd,
-				borderColor: 'rgba(128, 128, 128, 0.7)',
-				borderWidth: 1.5
-			};
-
-			errorBarAnnotations[`errorBarTop_${index}`] = {
-				type: 'line',
-				xMin: point.q - capWidth,
-				xMax: point.q + capWidth,
-				yMin: point.tau + point.sd,
-				yMax: point.tau + point.sd,
-				borderColor: 'rgba(128, 128, 128, 0.7)',
-				borderWidth: 1.5
-			};
-
-			errorBarAnnotations[`errorBarBottom_${index}`] = {
-				type: 'line',
-				xMin: point.q - capWidth,
-				xMax: point.q + capWidth,
-				yMin: point.tau - point.sd,
-				yMax: point.tau - point.sd,
-				borderColor: 'rgba(128, 128, 128, 0.7)',
-				borderWidth: 1.5
+	]);
+	const annotations = $derived<PlotAnnotation[]>(
+		points
+			.filter((point) => Number.isFinite(point.sd))
+			.flatMap((point) => [
-			};
-		});
-
-		const chartData = {
-			datasets: [
 				{
-					label: 'tau(q)',
+					color: 'rgb(128,128,128)',
+					opacity: 0.7,
+					data: [
+						{ x: point.q, y: point.tau - point.sd },
+						{ x: point.q, y: point.tau + point.sd }
+					]
-					data: points.map((p) => ({ x: p.q, y: p.tau })),
-					borderColor: 'rgb(59, 130, 246)',
-					backgroundColor: 'rgba(59, 130, 246, 0.1)',
-					borderWidth: 2,
-					pointRadius: 0,
-					pointHoverRadius: 0,
-					pointBackgroundColor: 'rgb(59, 130, 246)',
-					pointBorderColor: 'white',
-					pointBorderWidth: 1,
-					fill: false,
-					tension: 0.1
-				}
-			]
-		};
-
-		const config = {
-			type: 'line' as const,
-			data: chartData,
-			options: {
-				responsive: true,
-				maintainAspectRatio: false,
-				animation: false as const,
-				scales: {
-					x: {
-						type: 'linear' as const,
-						min: -2.1,
-						max: 4.1,
-						title: {
-							display: true,
-							text: 'q',
-							color: textColor
-						},
-						ticks: { color: textColor },
-						grid: { color: gridColor }
-					},
-					y: {
-						type: 'linear' as const,
-						title: {
-							display: true,
-							text: 'tau(q)',
-							color: textColor
-						},
-						position: 'left' as const,
-						ticks: { color: textColor },
-						grid: { color: gridColor }
-					}
 				},
-				plugins: {
-					legend: {
-						display: true,
-						position: 'top' as const,
-						labels: { color: textColor }
+				{
+					color: 'rgb(128,128,128)',
+					opacity: 0.7,
+					data: [
+						{ x: point.q - 0.02, y: point.tau - point.sd },
-					},
-					tooltip: {
+						{ x: point.q + 0.02, y: point.tau - point.sd }
+					]
-						mode: 'index' as const,
-						intersect: false,
-						backgroundColor: tooltipBackgroundColor,
-						titleColor: tooltipTextColor,
-						bodyColor: tooltipTextColor,
-						borderColor: tooltipBorderColor,
-						borderWidth: 1,
-						callbacks: {
-							title: (items: { parsed: { x: number } }[]) =>
-								`q = ${items[0]?.parsed?.x?.toFixed(3)}`,
-							label: (item: {
-								dataset: { label: string };
-								parsed: { y: number };
-								dataIndex: number;
-							}) => {
-								const value = item.parsed.y.toFixed(6);
-								const pointIndex = item.dataIndex;
-								const point = points[pointIndex];
-								return [
-									`${item.dataset.label}: ${value}`,
-									`Standard Deviation: ±${point.sd.toFixed(6)}`
-								];
-							}
-						}
-					},
-					annotation: {
-						annotations: errorBarAnnotations
-					},
-					verticalCrosshair: {
-						enabled: true,
-						tooltip: {
-							enabled: false
-						}
-					}
 				},
-				interaction: {
+				{
+					color: 'rgb(128,128,128)',
+					opacity: 0.7,
+					data: [
+						{ x: point.q - 0.02, y: point.tau + point.sd },
+						{ x: point.q + 0.02, y: point.tau + point.sd }
+					]
-					mode: 'index' as const,
-					intersect: false
 				}
+			])
-			}
+	);
-		};
-
-		if (!chart) {
-			// eslint-disable-next-line @typescript-eslint/no-explicit-any
-			chart = new Chart(chartCanvas, config as any);
-			return;
-		}
-
-		chart.data = chartData;
-		// eslint-disable-next-line @typescript-eslint/no-explicit-any
-		chart.options = config.options as any;
-		chart.update('none');
-	}
 </script>
 
 <div class="w-full">
@@ -238,6 +58,19 @@
 		{/if}
 	</div>
 	<div class="relative h-72 w-full min-w-0 sm:h-96">
-		<canvas bind:this={chartCanvas} aria-label="Structure function chart"></canvas>
+		<ChartPlot
+			name="Structure function chart"
+			{series}
+			options={{
+				xTitle: 'q',
+				yTitle: 'tau(q)',
+				xDomain: [-2.1, 4.1],
+				xTicks: [-2.1, -1, 0, 1, 2, 3, 4.1],
+				zero: false,
+				annotations
+			}}
+			formatTooltip={(points) =>
+				`q = ${points[0]?.datum.x.toFixed(3)}\ntau(q): ${points[0]?.datum.y?.toFixed(6)}\nStandard Deviation: ±${points[0]?.datum.sd?.toFixed(6)}`}
+		/>
 	</div>
 </div>
```

## apps/web/src/lib/components/charts/annotation-chart-registry.ts

Status: deleted; +0; −17

```diff
@@ -1,17 +1,0 @@
-import annotationPlugin from 'chartjs-plugin-annotation';
-import { Chart } from './chart-registry';
-
-let annotationRegistered = false;
-
-export function ensureAnnotationChartRegistry(): void {
-	if (annotationRegistered) {
-		return;
-	}
-
-	Chart.register(annotationPlugin);
-	annotationRegistered = true;
-}
-
-ensureAnnotationChartRegistry();
-
-export { Chart };
```

## apps/web/src/lib/components/charts/breakdown-chart-config.ts

Status: modified; +5; −5

```diff
@@ -44,7 +44,7 @@
 	noSourceCopy: string;
 	fetchErrorCopy: string;
 	unexpectedErrorCopy: string;
-	canvasLabel: string;
+	chartLabel: string;
 }
 
 const ipMetricLabels: Record<IpMetricKey, string> = {
@@ -87,7 +87,7 @@
 	noSourceCopy: 'Select at least one source to view IP statistics',
 	fetchErrorCopy: 'Failed to load IP statistics',
 	unexpectedErrorCopy: 'Unexpected error loading IP statistics',
+	chartLabel: 'IP chart'
-	canvasLabel: 'IP chart'
 };
 
 const PROTOCOL_CONFIG: BreakdownChartConfig = {
@@ -124,7 +124,7 @@
 	noSourceCopy: 'Select at least one source to view protocol statistics',
 	fetchErrorCopy: 'Failed to load protocol statistics',
 	unexpectedErrorCopy: 'Unexpected error loading protocol statistics',
-	canvasLabel: 'Protocol chart'
+	chartLabel: 'Protocol chart'
 };
 
 export type DimensionSide = 'sa' | 'da';
@@ -186,7 +186,7 @@
 	noSourceCopy: 'Select at least one source to view MAAD dimensions',
 	fetchErrorCopy: 'Failed to load MAAD dimensions',
 	unexpectedErrorCopy: 'Unexpected error loading MAAD dimensions',
+	chartLabel: 'MAAD dimensions chart'
-	canvasLabel: 'MAAD dimensions chart'
 };
 
 const SPECTRUM_CONFIG: BreakdownChartConfig = {
@@ -210,7 +210,7 @@
 	noSourceCopy: 'Select at least one source to view spectrum statistics',
 	fetchErrorCopy: 'Failed to load spectrum statistics',
 	unexpectedErrorCopy: 'Unexpected error loading spectrum statistics',
-	canvasLabel: 'Spectrum chart'
+	chartLabel: 'Spectrum chart'
 };
 
 export const BREAKDOWN_CHART_CONFIGS: Record<BreakdownChartKind, BreakdownChartConfig> = {
```

## apps/web/src/lib/components/charts/chart-contract.ts

Status: modified; +31; −5

```diff
@@ -24,8 +24,23 @@
 	const tooltip = document.createElement('span');
 	tooltip.dataset.testid = 'chart-tooltip';
 	tooltip.hidden = true;
+	let rendered = false;
+	let description = '';
+	const seriesEntries = new Map<string, HTMLElement>();
+	function syncAttributes() {
+		if (surface.dataset.testid !== 'chart-surface') surface.dataset.testid = 'chart-surface';
-	surface.dataset.testid = 'chart-surface';
-	surface.setAttribute('role', 'img');
+		if (surface.getAttribute('role') !== 'img') surface.setAttribute('role', 'img');
+		if (rendered && surface.dataset.chartRendered !== 'true')
+			surface.dataset.chartRendered = 'true';
+		if (description && surface.getAttribute('aria-description') !== description)
+			surface.setAttribute('aria-description', description);
+	}
+	syncAttributes();
+	const observer = new MutationObserver(syncAttributes);
+	observer.observe(surface, {
+		attributes: true,
+		attributeFilter: ['data-testid', 'data-chart-rendered', 'role', 'aria-description']
+	});
 	surface.after(summary);
 
 	return {
@@ -33,13 +48,14 @@
 			const visible = snapshot.series.filter((series) => series.visible);
 			const marks = visible.reduce((count, series) => count + series.count, 0);
 			summary.dataset.state = marks > 0 ? 'ready' : 'empty';
-			surface.dataset.chartRendered = 'true';
 			summary.dataset.kind = snapshot.kind;
 			summary.dataset.seriesCount = String(visible.length);
 			summary.dataset.markCount = String(marks);
+			description = `${snapshot.name}: ${visible.length} series, ${marks} data points. ${snapshot.axes.join(', ')}.`;
+			rendered = true;
+			syncAttributes();
-			const label = `${snapshot.name}: ${visible.length} series, ${marks} data points. ${snapshot.axes.join(', ')}.`;
 			summary.setAttribute('aria-label', 'Rendered chart summary');
-			surface.setAttribute('aria-description', label);
+			seriesEntries.clear();
 			const entries = snapshot.series.map((series) => {
 				const entry = document.createElement('span');
 				entry.dataset.testid = 'chart-series';
@@ -57,6 +73,7 @@
 					entry.dataset.legendY = String(series.legend.y);
 				}
 				entry.textContent = series.label;
+				seriesEntries.set(series.label, entry);
 				return entry;
 			});
 			const axes = snapshot.axes.map((label) => {
@@ -67,11 +84,20 @@
 			});
 			summary.replaceChildren(...axes, ...entries, tooltip);
 		},
+		legend(label: string, position: { x: number; y: number }) {
+			const entry = seriesEntries.get(label);
+			if (entry) {
+				entry.dataset.legendX = String(position.x);
+				entry.dataset.legendY = String(position.y);
+			}
+		},
 		tooltip(text: string) {
 			if (tooltip.textContent !== text) tooltip.textContent = text;
 			tooltip.hidden = text.length === 0;
 		},
 		destroy() {
+			observer.disconnect();
+			seriesEntries.clear();
 			summary.remove();
 			delete surface.dataset.chartRendered;
 			surface.removeAttribute('aria-description');
```

## apps/web/src/lib/components/charts/chart-registry.ts

Status: modified; +374; −34

```diff
@@ -1,41 +1,381 @@
+import { defineChart } from '@tanstack/charts';
+import { lineY } from '@tanstack/charts/line';
+import { areaY } from '@tanstack/charts/area';
+import { rect } from '@tanstack/charts/rect';
-import {
+import { dot } from '@tanstack/charts/dot';
-	CategoryScale,
-	Chart as ChartJS,
-	Filler,
-	Legend,
-	LineController,
-	LineElement,
-	LinearScale,
+import { decorative } from '@tanstack/charts/mark/decorative';
+import { crosshair } from '@tanstack/charts/crosshair';
+import { focusGroupX } from '@tanstack/charts/focus';
+import { tooltip } from '@tanstack/charts/tooltip';
+import { interactiveColorLegend } from '@tanstack/charts/legend';
+import { controlledSignal } from '@tanstack/charts/interaction/signal';
+import { scaleLinear } from '@tanstack/charts/scales/linear';
+import { scaleSequential } from 'd3-scale';
+import type { ChartMark, ChartPoint } from '@tanstack/charts';
+import { temporalTicks } from './temporal-ticks';
+import { temporalGrid } from './temporal-grid';
+import { coverageLineRuns } from './coverage-line-style';
+import type { ChartCoverage } from './chart-utils';
-	PointElement,
-	ScatterController,
-	Tooltip
-} from 'chart.js';
-import { verticalCrosshairPlugin } from './crosshair-plugin';
-import { chartContractPlugin } from './chartjs-contract';
 
+export { Chart } from '@tanstack/charts/svelte';
-let registered = false;
 
+export type PlotPoint = {
+	x: number;
+	y: number | null;
+	label?: string;
+	coverage?: ChartCoverage;
-export function ensureChartRegistry(): void {
-	if (registered) {
-		return;
+	f?: number;
+	sd?: number;
-	}
+	x2?: number;
+	value?: number | null;
+	y0?: number;
+	y2?: number | null;
+};
 
-	ChartJS.register(
+export type PlotSeries = {
+	label: string;
+	color: string;
+	data: PlotPoint[];
+	dash?: string;
+	radius?: number;
+	strokeWidth?: number;
+	pointFill?: string;
+	pointStroke?: string;
+	pointStrokeWidth?: number;
+};
+
+export type PlotAnnotation = {
+	opacity?: number;
+	strokeWidth?: number;
+	data: PlotPoint[];
+	color: string;
+	dash?: string;
+};
+
+export type PlotOptions = {
+	kind?: 'line' | 'stacked' | 'scatter' | 'coverage';
+	xTitle: string;
+	yTitle: string;
+	xDomain?: readonly [number, number];
+	yDomain?: readonly [number, number];
+	xTicks?: readonly number[];
+	yTicks?: readonly number[];
+	xFormat?: (value: number) => string;
+	yFormat?: (value: number) => string;
+	zero?: boolean;
+	legend?: boolean;
+	compact?: boolean;
+	annotations?: PlotAnnotation[];
+	colorDomain?: readonly [number, number];
+};
+
+export function finitePoint(point: PlotPoint): boolean {
+	return (
-		CategoryScale,
-		Filler,
-		Legend,
-		LineController,
-		LineElement,
-		LinearScale,
-		PointElement,
-		ScatterController,
-		Tooltip,
-		verticalCrosshairPlugin,
+		Number.isFinite(point.x) &&
-		chartContractPlugin
+		typeof point.y === 'number' &&
+		Number.isFinite(point.y) &&
+		point.coverage?.state !== 'unknown'
 	);
-	registered = true;
 }
 
+export function plotBounds(series: PlotSeries[], singletonPadding = 1): readonly [number, number] {
+	let min = Infinity;
+	let max = -Infinity;
+	for (const item of series)
+		for (const point of item.data) {
+			if (!finitePoint(point)) continue;
+			min = Math.min(min, point.x);
+			max = Math.max(max, point.x2 ?? point.x);
+		}
+	if (!Number.isFinite(min)) return [0, 1];
+	return min === max ? [min - singletonPadding / 2, max + singletonPadding / 2] : [min, max];
+}
+
+export function plotTooltip(
+	points: readonly ChartPoint<PlotPoint, number, number>[],
+	format?: (points: readonly ChartPoint<PlotPoint, number, number>[]) => string
+): string {
+	if (!points.length) return '';
+	if (format) return format(points);
+	return [
+		points[0].datum.label ?? String(points[0].xValue),
+		...points.map((point) => `${point.groupLabel}: ${point.datum.y?.toLocaleString()}`)
-ensureChartRegistry();
+	].join('\n');
+}
 
+export function buildChartDefinition(
+	series: PlotSeries[],
+	options: PlotOptions,
+	hidden: ReadonlySet<string>,
+	onToggle: (labels: readonly string[]) => void,
+	formatTooltip?: (points: readonly ChartPoint<PlotPoint, number, number>[]) => string
+) {
+	const marks: ChartMark<PlotPoint, number, number>[] = [];
+	const cumulative = new Map<number, number>();
+	for (const [index, item] of series.entries()) {
+		const id = `series-${index}`;
+		const channels = {
-export { ChartJS as Chart };
+			id,
+			x: 'x' as const,
+			y: 'y' as const,
+			color: () => item.label,
+			z: () => item.label
+		};
+		let displayedData = item.data;
+		if (options.kind === 'scatter') {
+			marks.push(
+				dot(item.data, {
+					...channels,
+					color: options.colorDomain ? (point) => point.f ?? 0 : channels.color,
+					r: item.radius ?? 1
+				})
+			);
+		} else if (options.kind === 'coverage') {
+			const track = options.xDomain ?? plotBounds(series);
+			marks.push(
+				decorative(
+					lineY(
+						[
+							{ x: track[0], y: index },
+							{ x: track[1], y: index }
+						],
+						{ id: `${id}-track`, x: 'x', y: 'y', stroke: 'var(--chart-grid-color)', strokeWidth: 1 }
+					)
+				)
+			);
+			for (const state of ['complete', 'partial', 'unknown'] as const) {
+				const data = item.data.filter(
+					(point) =>
+						Number.isFinite(point.x) && Number.isFinite(point.y) && point.coverage?.state === state
+				);
+				marks.push(
+					rect(data, {
+						...channels,
+						id: `${id}-${state}`,
+						x: 'x',
+						x1: 'x',
+						x2: (point) => point.x2 ?? point.x,
+						y: 'y',
+						y1: (point) => (point.y ?? 0) - 0.055,
+						y2: (point) => (point.y ?? 0) + 0.055,
+						inset: 0,
+						fill: state === 'complete' ? 'rgb(16,185,129)' : 'transparent'
+					})
+				);
+				if (state === 'partial')
+					for (const [position, point] of data.entries()) {
+						marks.push(
+							decorative(
+								lineY([point, { ...point, x: point.x2 ?? point.x }], {
+									id: `${id}-coverage-dash-${position}`,
+									x: 'x',
+									y: 'y',
+									stroke: 'rgb(245,158,11)',
+									strokeWidth: 2,
+									strokeDasharray: '5 4'
+								})
+							)
+						);
+					}
+			}
+		} else if (options.kind === 'stacked') {
+			const data = item.data.map((point) => {
+				const y0 = cumulative.get(point.x) ?? 0;
+				const y2 = point.y === null ? null : y0 + (hidden.has(item.label) ? 0 : point.y);
+				if (y2 !== null) cumulative.set(point.x, y2);
+				return { ...point, y0, y2 };
+			});
+			displayedData = data;
+			marks.push(areaY(data, { ...channels, y1: 'y0', y2: 'y2', fillOpacity: 0.6 }));
+			for (const [runIndex, run] of coverageLineRuns(
+				data,
+				(point) => point.y2,
+				(point) => point.coverage?.state === 'partial'
+			).entries()) {
+				marks.push(
+					decorative(
+						lineY(run.points, {
+							...channels,
+							id: `${id}-outline-${runIndex}`,
+							y: 'y2',
+							strokeDasharray: run.partial ? '6 4' : item.dash,
+							strokeWidth: item.strokeWidth ?? 3
+						})
+					)
+				);
+			}
+		} else {
+			for (const [runIndex, run] of coverageLineRuns(
+				item.data,
+				(point) => (finitePoint(point) ? point.y : null),
+				(point) => point.coverage?.state === 'partial'
+			).entries()) {
+				marks.push(
+					lineY(run.points, {
+						...channels,
+						id: `${id}-run-${runIndex}`,
+						strokeDasharray: run.partial ? '6 4' : item.dash,
+						strokeWidth: item.strokeWidth ?? 3
+					})
+				);
+			}
+		}
+		if (options.kind !== 'scatter' && options.kind !== 'coverage') {
+			const isolated = displayedData.filter(
+				(point, position, data) =>
+					finitePoint(point) &&
+					(point.coverage?.state === 'partial' ||
+						item.radius ||
+						(!finitePoint(data[position - 1] ?? { x: NaN, y: null }) &&
+							!finitePoint(data[position + 1] ?? { x: NaN, y: null })))
+			);
+			marks.push(
+				decorative(
+					dot(isolated, {
+						...channels,
+						y: (point) => (options.kind === 'stacked' ? (point.y2 ?? null) : point.y),
+						id: `${id}-dots`,
+						r: item.radius ?? 3,
+						fill: item.pointFill ?? 'var(--card)',
+						stroke: item.pointStroke ?? item.color,
+						strokeWidth: item.pointStrokeWidth ?? 2
+					})
+				)
+			);
+		}
+	}
+	for (const [index, annotation] of (options.annotations ?? []).entries()) {
+		marks.push(
+			decorative(
+				lineY(annotation.data, {
+					id: `annotation-${index}`,
+					x: 'x',
+					y: 'y',
+					stroke: annotation.color,
+					strokeDasharray: annotation.dash,
+					strokeWidth: annotation.strokeWidth ?? 1.5,
+					strokeOpacity: annotation.opacity ?? 1
+				})
+			)
+		);
+	}
+	marks.push(
+		crosshair({ y: false, stroke: 'currentColor', strokeOpacity: 0.65, strokeDasharray: '3 3' })
+	);
+	const xDomain = options.xDomain ?? plotBounds(series);
+	let maxY = 0;
+	let minY = 0;
+	for (const item of series) {
+		if (hidden.has(item.label)) continue;
+		for (const point of item.data) {
+			if (!finitePoint(point)) continue;
+			minY = Math.min(minY, point.y ?? 0);
+			maxY = Math.max(
+				maxY,
+				options.kind === 'stacked' ? (cumulative.get(point.x) ?? 0) : (point.y ?? 0)
+			);
+		}
+	}
+	const yScale = options.yDomain
+		? scaleLinear().domain(options.yDomain)
+		: options.zero === false
+			? scaleLinear
+			: scaleLinear().domain([minY, maxY === minY ? maxY + 1 : maxY]);
+	const labels = series.map((item) => item.label);
+	const formatX = options.xFormat;
+	const timeAxis = Boolean(options.xTicks && formatX);
+	const xTicks =
+		options.xTicks && formatX
+			? temporalTicks(options.xTicks, formatX, options.compact)
+			: options.xTicks;
+	if (timeAxis && options.kind !== 'coverage')
+		marks.unshift(
+			temporalGrid(
+				options.xTicks ?? [],
+				new Set(temporalTicks(options.xTicks ?? [], formatX ?? String))
+			)
+		);
+	const legend = interactiveColorLegend({
+		placement: 'top',
+		itemWidth: 130,
+		visible: controlledSignal<readonly string[]>(
+			labels.filter((label) => !hidden.has(label)),
+			onToggle
+		)
+	});
+	return defineChart({
+		marks,
+		margin:
+			options.kind === 'coverage'
+				? { left: 120, right: 8, top: 4, bottom: 4 }
+				: {
+						left: options.compact ? 56 : 80,
+						right: 18,
+						bottom: options.compact ? 35 : timeAxis ? 110 : 62
+					},
+		clip: true,
+		theme: {
+			foreground: 'var(--chart-text-color)',
+			muted: 'var(--chart-text-color)',
+			grid: 'var(--chart-grid-color)'
+		},
+		scales: {
+			x: {
+				scale: scaleLinear().domain(xDomain),
+				grid: options.kind === 'coverage' || timeAxis ? false : { strokeOpacity: 1 },
+				axis:
+					options.kind === 'coverage'
+						? false
+						: {
+								label: options.compact ? undefined : options.xTitle,
+								ticks: {
+									...(xTicks ? { values: xTicks } : { count: options.compact ? 4 : 8 }),
+									format: formatX
+								},
+								tickLabels: {
+									rotate: timeAxis && !options.compact ? -45 : 0,
+									thin: timeAxis ? false : { minGap: 8 }
+								}
+							}
+			},
+			y: {
+				scale: yScale,
+				nice: !options.yDomain,
+				grid: options.kind === 'coverage' ? false : { strokeOpacity: 1 },
+				axis: {
+					label: options.compact ? undefined : options.yTitle,
+					ticks: {
+						...(options.yTicks ? { values: options.yTicks } : { count: options.compact ? 5 : 8 }),
+						format: options.yFormat
+					}
+				}
+			}
+		},
+		color: options.colorDomain
+			? {
+					scale: scaleSequential((value: number) => `hsl(${270 - value * 210}, 70%, 50%)`).domain(
+						options.colorDomain
+					)
+				}
+			: {
+					domain: labels,
+					range: series.map((item) => item.color),
+					legend:
+						options.legend === false || options.compact
+							? undefined
+							: {
+									...legend,
+									height: (count, context) => Math.min(108, legend.height(count, context))
+								}
+				},
+		focus: options.kind === 'scatter' ? undefined : focusGroupX,
+		tooltip: {
+			use: tooltip,
+			formatGroup: (points) => plotTooltip(points, formatTooltip),
+			motion: false
+		},
+		motion: false,
+		svgAnimation: false,
+		keyboard: true
+	});
+}
```

## apps/web/src/lib/components/charts/chart-utils.ts

Status: modified; +2; −210

```diff
@@ -1,6 +1,4 @@
-import type { Chart } from 'chart.js';
 import type { GroupByOption, NetflowDataPoint } from '#lib/components/netflow/types.ts';
-import type { RangeSelectionState } from '#lib/stores/rangeSelection.svelte.ts';
 import {
 	parseLabelToPSTComponents,
 	parseLabelToDateForDrilldown,
@@ -33,212 +31,6 @@
 	return nearestIndex;
 }
 
-export interface RangeDragState {
-	isDraggingRange: boolean;
-	dragStartX: number;
-	dragCurrentX: number;
-	selectionTop: number;
-	selectionHeight: number;
-	suppressNextClick: boolean;
-}
-
-export function createRangeDragState(): RangeDragState {
-	return {
-		isDraggingRange: false,
-		dragStartX: 0,
-		dragCurrentX: 0,
-		selectionTop: 0,
-		selectionHeight: 0,
-		suppressNextClick: false
-	};
-}
-
-export function getChartArea(chart: Chart | null) {
-	if (!chart) return null;
-	return chart.chartArea;
-}
-
-export function clampToChartX(chart: Chart | null, x: number): number {
-	const area = getChartArea(chart);
-	if (!area) return x;
-	return Math.max(area.left, Math.min(area.right, x));
-}
-
-export function indexFromPixelX(
-	chart: Chart | null,
-	pixelX: number,
-	bucketStarts?: readonly number[]
-): number | null {
-	if (!chart?.data.labels) return null;
-	const labels = chart.data.labels;
-	if (!labels || labels.length === 0) return null;
-	const value = chart.scales.x.getValueForPixel(pixelX);
-	if (typeof value !== 'number' || Number.isNaN(value)) return null;
-	if ((chart.scales.x.options as { type?: string }).type === 'linear') {
-		if (bucketStarts) return findNearestValueIndex(bucketStarts, value);
-		const points = chart.data.datasets[0]?.data ?? [];
-		let closestIndex: number | null = null;
-		let closestDistance = Infinity;
-		const seenX = new Set<number>();
-		points.forEach((point, index) => {
-			if (typeof point !== 'object' || point === null || Array.isArray(point) || !('x' in point))
-				return;
-			const x = point.x;
-			if (typeof x !== 'number' || !Number.isFinite(x)) return;
-			if (seenX.has(x)) return;
-			seenX.add(x);
-			const distance = Math.abs(x - value);
-			if (distance < closestDistance) {
-				closestDistance = distance;
-				closestIndex = index;
-			}
-		});
-		return closestIndex;
-	}
-	const rounded = Math.round(value);
-	return Math.max(0, Math.min(labels.length - 1, rounded));
-}
-
-export function getSelectionLabels(
-	chart: Chart | null,
-	startIndex: number,
-	endIndex: number
-): { startLabel: string; endLabel: string } | null {
-	if (!chart?.data.labels) return null;
-	const labels = chart.data.labels as string[];
-	if (labels.length === 0) return null;
-	const from = Math.max(0, Math.min(labels.length - 1, Math.min(startIndex, endIndex)));
-	const to = Math.max(0, Math.min(labels.length - 1, Math.max(startIndex, endIndex)));
-	const startLabel = labels[from];
-	const endLabel = labels[to];
-	if (!startLabel || !endLabel) return null;
-	return { startLabel, endLabel };
-}
-
-export function beginRangeDrag(
-	state: RangeDragState,
-	event: MouseEvent,
-	chartCanvas: HTMLCanvasElement | null,
-	chart: Chart | null,
-	onPreviewRange?: (startIndex: number, endIndex: number) => void,
-	bucketStarts?: readonly number[]
-): void {
-	if (event.button !== 0 || !chartCanvas) return;
-	const rect = chartCanvas.getBoundingClientRect();
-	const x = event.clientX - rect.left;
-	const y = event.clientY - rect.top;
-	const area = getChartArea(chart);
-	if (!area) return;
-	if (x < area.left || x > area.right || y < area.top || y > area.bottom) return;
-	state.isDraggingRange = true;
-	state.dragStartX = clampToChartX(chart, x);
-	state.dragCurrentX = state.dragStartX;
-	state.selectionTop = area.top;
-	state.selectionHeight = area.bottom - area.top;
-	const startIndex = indexFromPixelX(chart, state.dragStartX, bucketStarts);
-	if (startIndex !== null) {
-		onPreviewRange?.(startIndex, startIndex);
-	}
-}
-
-export function updateRangeDrag(
-	state: RangeDragState,
-	event: MouseEvent,
-	chartCanvas: HTMLCanvasElement | null,
-	chart: Chart | null,
-	onPreviewRange?: (startIndex: number, endIndex: number) => void,
-	bucketStarts?: readonly number[]
-): void {
-	if (!state.isDraggingRange || !chartCanvas) return;
-	const rect = chartCanvas.getBoundingClientRect();
-	const x = event.clientX - rect.left;
-	state.dragCurrentX = clampToChartX(chart, x);
-	const startIndex = indexFromPixelX(chart, state.dragStartX, bucketStarts);
-	const endIndex = indexFromPixelX(chart, state.dragCurrentX, bucketStarts);
-	if (startIndex !== null && endIndex !== null) {
-		onPreviewRange?.(startIndex, endIndex);
-	}
-}
-
-export function endRangeDrag(
-	state: RangeDragState,
-	chart: Chart | null,
-	onCommitRange: (startIndex: number, endIndex: number) => void,
-	bucketStarts?: readonly number[]
-): void {
-	if (!state.isDraggingRange) return;
-	const wasDrag = Math.abs(state.dragCurrentX - state.dragStartX) >= MIN_DRAG_PIXELS;
-	if (wasDrag) {
-		const startIndex = indexFromPixelX(chart, state.dragStartX, bucketStarts);
-		const endIndex = indexFromPixelX(chart, state.dragCurrentX, bucketStarts);
-		if (startIndex !== null && endIndex !== null) {
-			onCommitRange(startIndex, endIndex);
-			state.suppressNextClick = true;
-		}
-	}
-	state.isDraggingRange = false;
-}
-
-export function buildMirroredSelectionStyle(
-	chart: Chart | null,
-	mirroredRange: RangeSelectionState | null,
-	sourceChartId: string,
-	bucketStarts?: readonly number[]
-): string | null {
-	if (!mirroredRange || mirroredRange.sourceChartId === sourceChartId || !chart?.data.labels)
-		return null;
-	const labels = chart.data.labels as string[];
-	const startIndex = labels.indexOf(mirroredRange.startLabel);
-	const endIndex = labels.indexOf(mirroredRange.endLabel);
-	if (startIndex === -1 || endIndex === -1) return null;
-	const area = getChartArea(chart);
-	if (!area) return null;
-	const from = Math.min(startIndex, endIndex);
-	const to = Math.max(startIndex, endIndex);
-	const firstDataset = chart.data.datasets[0]?.data ?? [];
-	const xValues =
-		bucketStarts ??
-		firstDataset.reduce<number[]>((values, point) => {
-			if (
-				typeof point === 'object' &&
-				point !== null &&
-				!Array.isArray(point) &&
-				'x' in point &&
-				typeof point.x === 'number' &&
-				Number.isFinite(point.x) &&
-				!values.includes(point.x)
-			) {
-				values.push(point.x);
-			}
-			return values;
-		}, []);
-	const xValueAt = (index: number): number => {
-		if ((chart.scales.x.options as { type?: string }).type === 'linear') {
-			const uniqueX = xValues[index];
-			if (uniqueX !== undefined) return uniqueX;
-		}
-		const point = firstDataset[index];
-		if (
-			(chart.scales.x.options as { type?: string }).type === 'linear' &&
-			typeof point === 'object' &&
-			point !== null &&
-			!Array.isArray(point) &&
-			'x' in point
-		) {
-			const x = point.x;
-			if (typeof x === 'number' && Number.isFinite(x)) return x;
-		}
-		return index;
-	};
-	const rawLeft = chart.scales.x.getPixelForValue(xValueAt(from));
-	const rawRight = chart.scales.x.getPixelForValue(xValueAt(to));
-	const left = Math.max(area.left, Math.min(rawLeft, rawRight));
-	const right = Math.min(area.right, Math.max(rawLeft, rawRight));
-	const width = right - left;
-	if (!Number.isFinite(width) || width < MIN_DRAG_PIXELS) return null;
-	return `left:${left}px; width:${width}px; top:${area.top}px; height:${area.bottom - area.top}px;`;
-}
-
 /**
  * Format labels from NetFlow data points using PST timezone.
  * item.bucketStart is epoch seconds from the API.
@@ -578,7 +370,7 @@
 	return typeof value === 'object' && value !== null;
 }
 
-/** Read coverage metadata from an untyped Chart.js point. */
+/** Read coverage metadata from an untyped point. */
 export function getChartBucketCoverage(bucket: unknown): ChartCoverage | null {
 	if (!isRecord(bucket) || !isRecord(bucket.coverage)) {
 		return null;
@@ -598,7 +390,7 @@
 	return null;
 }
 
+/** Build temporal points without compressing gaps into adjacent indexes. */
-/** Build Chart.js points without compressing gaps into adjacent indexes. */
 export function buildTemporalChartPoints<T>(
 	buckets: readonly ChartTimeBucket<T>[],
 	getValue: (data: T) => number | null
```

## apps/web/src/lib/components/charts/chartjs-contract.ts

Status: deleted; +0; −118

```diff
@@ -1,118 +1,0 @@
-import type { Chart, Plugin } from 'chart.js';
-import { createChartContract, type RenderedChart } from './chart-contract';
-
-function isHitBox(
-	value: unknown
-): value is { left: number; top: number; width: number; height: number } {
-	return (
-		typeof value === 'object' &&
-		value !== null &&
-		['left', 'top', 'width', 'height'].every(
-			(key) => key in value && typeof Reflect.get(value, key) === 'number'
-		)
-	);
-}
-
-const contracts = new WeakMap<Chart, ReturnType<typeof createChartContract>>();
-
-export const chartContractPlugin: Plugin = {
-	id: 'chartContract',
-	afterRender(chart) {
-		const series = chart.data.datasets.map((dataset, index) => {
-			const meta = chart.getDatasetMeta(index);
-			let count = 0;
-			let min = Infinity;
-			let max = -Infinity;
-			let total = 0;
-			let target: { x: number; y: number } | undefined;
-			for (let point = 0; point < dataset.data.length; point += 1) {
-				const parsed = meta._parsed[point];
-				if (
-					!parsed ||
-					typeof parsed !== 'object' ||
-					!('x' in parsed) ||
-					!('y' in parsed) ||
-					typeof parsed.x !== 'number' ||
-					typeof parsed.y !== 'number' ||
-					!Number.isFinite(parsed.y)
-				)
-					continue;
-				if (meta.xScale && (parsed.x < meta.xScale.min || parsed.x > meta.xScale.max)) continue;
-				const element = meta.data[point];
-				if (!element || !Number.isFinite(element.x) || !Number.isFinite(element.y)) continue;
-				const values = 'chartContractValues' in dataset ? dataset.chartContractValues : null;
-				const value = Array.isArray(values) ? values[point] : parsed.y;
-				if (typeof value !== 'number' || !Number.isFinite(value)) continue;
-				if (!target) target = { x: element.x / chart.width, y: element.y / chart.height };
-				count += 1;
-				min = Math.min(min, value);
-				max = Math.max(max, value);
-				total += value;
-			}
-			const legendIndex =
-				chart.legend?.legendItems?.findIndex((item) => item.datasetIndex === index) ?? -1;
-			const hitBoxes =
-				chart.legend && 'legendHitBoxes' in chart.legend ? chart.legend.legendHitBoxes : null;
-			const candidate =
-				chart.options.plugins?.legend?.display && Array.isArray(hitBoxes)
-					? hitBoxes[legendIndex]
-					: null;
-			const box = isHitBox(candidate) ? candidate : null;
-			return {
-				point: chart.isDatasetVisible(index) ? target : undefined,
-				legend: box
-					? {
-							x: (box.left + box.width / 2) / chart.width,
-							y: (box.top + box.height / 2) / chart.height
-						}
-					: undefined,
-				label: dataset.label ?? 'Spectrum',
-				visible: chart.isDatasetVisible(index),
-				count,
-				min: count ? min : null,
-				max: count ? max : null,
-				total
-			};
-		});
-		const snapshot: RenderedChart = {
-			name: chart.canvas.getAttribute('aria-label') ?? 'Chart',
-			kind: chart.data.datasets.some((dataset) => 'fill' in dataset && dataset.fill)
-				? 'stacked'
-				: (chart.getDatasetMeta(0)?.type ?? 'line'),
-			axes: Object.values(chart.scales).flatMap((scale) => {
-				const title = 'title' in scale.options ? scale.options.title : null;
-				return title &&
-					typeof title === 'object' &&
-					'display' in title &&
-					title.display &&
-					'text' in title
-					? [String(title.text)]
-					: [];
-			}),
-			series
-		};
-		let contract = contracts.get(chart);
-		if (!contract) {
-			contract = createChartContract(chart.canvas);
-			contracts.set(chart, contract);
-		}
-		contract.render(snapshot);
-	},
-	afterDraw(chart) {
-		const tooltip = chart.tooltip;
-		const text = tooltip?.opacity
-			? [
-					...tooltip.title,
-					...tooltip.beforeBody,
-					...tooltip.body.flatMap((body) => [...body.before, ...body.lines, ...body.after]),
-					...tooltip.afterBody,
-					...tooltip.footer
-				].join('\n')
-			: '';
-		contracts.get(chart)?.tooltip(text);
-	},
-	afterDestroy(chart) {
-		contracts.get(chart)?.destroy();
-		contracts.delete(chart);
-	}
-};
```

## apps/web/src/lib/components/charts/coverage-line-style.ts

Status: modified; +28; −50

```diff
@@ -1,54 +1,32 @@
-type CoverageState = {
-	state: 'complete' | 'partial' | 'unknown';
-};
-
-export type CoveragePointStyle = {
-	pointRadius: number[];
-	pointBackgroundColor: string[];
-	pointBorderColor: string;
-	pointBorderWidth: number[];
-};
-
-const PARTIAL_POINT_RADIUS = 3;
-const PARTIAL_POINT_BORDER_WIDTH = 2;
-const TRANSPARENT_POINT_FILL = 'rgba(0, 0, 0, 0)';
-
-/** Build array styles only when a series has a numeric partial observation to mark. */
+export function coverageLineRuns<Point>(
-export function buildCoveragePointStyle<Point>(
 	points: readonly Point[],
+	getValue: (point: Point) => number | null,
+	isPartial: (point: Point) => boolean
+): Array<{ points: Point[]; partial: boolean }> {
+	const runs: Array<{ points: Point[]; partial: boolean }> = [];
-	getValue: (point: Point, index: number) => number | null,
-	getCoverage: (point: Point, index: number) => CoverageState,
-	color: string
+	let previous: Point | undefined;
+	let current: { points: Point[]; partial: boolean } | undefined;
+	for (const point of points) {
+		const value = getValue(point);
+		if (value === null || !Number.isFinite(value)) {
-): CoveragePointStyle | null {
-	let firstPartialPoint = -1;
-	for (let index = 0; index < points.length; index += 1) {
-		const point = points[index];
-		if (point === undefined) continue;
-		const value = getValue(point, index);
-		if (getCoverage(point, index).state === 'partial' && Number.isFinite(value)) {
-			firstPartialPoint = index;
-			break;
+			previous = undefined;
+			current = undefined;
+			continue;
 		}
+		if (!previous) {
+			current = { points: [point], partial: isPartial(point) };
+			runs.push(current);
+		} else {
+			const partial = isPartial(previous) || isPartial(point);
+			if (current && (current.partial === partial || current.points.length === 1)) {
+				current.partial = partial;
+				current.points.push(point);
+			} else {
+				current = { points: [previous, point], partial };
+				runs.push(current);
+			}
+		}
+		previous = point;
 	}
+	return runs;
-	if (firstPartialPoint === -1) return null;
-
-	const pointRadius = new Array<number>(points.length).fill(0);
-	const pointBackgroundColor = new Array<string>(points.length).fill(color);
-	const pointBorderWidth = new Array<number>(points.length).fill(0);
-	pointRadius[firstPartialPoint] = PARTIAL_POINT_RADIUS;
-	pointBackgroundColor[firstPartialPoint] = TRANSPARENT_POINT_FILL;
-	pointBorderWidth[firstPartialPoint] = PARTIAL_POINT_BORDER_WIDTH;
-
-	for (let index = firstPartialPoint + 1; index < points.length; index += 1) {
-		const point = points[index];
-		if (point === undefined) continue;
-		const value = getValue(point, index);
-		if (getCoverage(point, index).state !== 'partial' || !Number.isFinite(value)) continue;
-
-		pointRadius[index] = PARTIAL_POINT_RADIUS;
-		pointBackgroundColor[index] = TRANSPARENT_POINT_FILL;
-		pointBorderWidth[index] = PARTIAL_POINT_BORDER_WIDTH;
-	}
-
-	return { pointRadius, pointBackgroundColor, pointBorderColor: color, pointBorderWidth };
 }
```

## apps/web/src/lib/components/charts/crosshair-plugin.ts

Status: deleted; +0; −395

```diff
@@ -1,395 +1,0 @@
-import type { Chart, ChartArea, Plugin, ChartEvent, ChartType } from 'chart.js';
-import { cancelDrawFrame, requestDrawFrame } from '#lib/utils/animation-frame.ts';
-import { findNearestValueIndex } from './chart-utils';
-
-interface CrosshairState {
-	mouseX: number | null;
-	isMouseOver: boolean;
-	showTooltip: boolean;
-	tooltipTimeout: NodeJS.Timeout | null;
-	hoveredDate: string | null;
-	drawFrame: number | null;
-}
-
-interface CrosshairOptions {
-	enabled: boolean;
-	line: {
-		color: string;
-		width: number;
-		dash: number[];
-	};
-	tooltip: {
-		enabled: boolean;
-		delay: number;
-		backgroundColor: string;
-		textColor: string;
-		borderColor: string;
-		borderWidth: number;
-		borderRadius: number;
-		padding: number;
-		fontSize: number;
-		fontFamily: string;
-	};
-	// Sync options for coordinating crosshairs across multiple charts
-	sync?: {
-		/** Called when hover state changes (label when hovering, null when leaving) */
-		onHover?: (label: string | null) => void;
-		/** Returns external hover label if another chart is being hovered */
-		getExternalLabel?: () => string | null;
-	};
-}
-
-declare module 'chart.js' {
-	interface PluginOptionsByType<TType extends ChartType> {
-		verticalCrosshair?: TType extends 'line' | 'bar' | 'scatter' ? CrosshairOptions | false : never;
-	}
-}
-
-// Store mouse state for each chart instance
-const chartStates = new WeakMap<Chart, CrosshairState>();
-
-function getPointX(point: unknown): number | null {
-	if (
-		typeof point === 'object' &&
-		point !== null &&
-		!Array.isArray(point) &&
-		'x' in point &&
-		typeof point.x === 'number' &&
-		Number.isFinite(point.x)
-	) {
-		return point.x;
-	}
-	return null;
-}
-
-function findNearestLinearPointIndex(chart: Chart, value: number): number | null {
-	const points = chart.data.datasets.find((dataset) =>
-		dataset.data.some((point) => getPointX(point) !== null)
-	)?.data;
-	if (!points) return null;
-	return findNearestValueIndex(points.map(getPointX), value);
-}
-
-function getPixelForLabel(chart: Chart, labelIndex: number): number {
-	if (chart.scales.x.type === 'linear') {
-		for (const dataset of chart.data.datasets) {
-			const x = getPointX(dataset.data[labelIndex]);
-			if (x !== null) return chart.scales.x.getPixelForValue(x);
-		}
-	}
-	return chart.scales.x.getPixelForValue(labelIndex);
-}
-
-// Helper function to calculate the hovered date from mouse position
-function calculateHoveredDate(chart: Chart, mouseX: number): string | null {
-	try {
-		// Get the x-axis data value from pixel position
-		const dataX = chart.scales.x.getValueForPixel(mouseX);
-		const labels = chart.data.labels;
-
-		if (typeof dataX === 'number' && labels && labels.length > 0) {
-			const labelIndex =
-				chart.scales.x.type === 'linear'
-					? findNearestLinearPointIndex(chart, dataX)
-					: Math.round(dataX);
-
-			if (labelIndex !== null && labelIndex >= 0 && labelIndex < labels.length) {
-				return labels[labelIndex] as string;
-			} else if (labelIndex !== null) {
-				// Handle clicks outside data range
-				if (labelIndex < 0) {
-					return labels[0] as string;
-				} else {
-					return labels[labels.length - 1] as string;
-				}
-			}
-		}
-	} catch (error) {
-		console.warn('Error calculating hovered date:', error);
-	}
-
-	return null;
-}
-
-// Helper function to draw the tooltip
-function drawTooltip(
-	ctx: CanvasRenderingContext2D,
-	mouseX: number,
-	date: string,
-	chartArea: ChartArea,
-	options: CrosshairOptions['tooltip']
-) {
-	// Set font for text measurement
-	ctx.font = `${options.fontSize}px ${options.fontFamily}`;
-
-	// Measure text dimensions
-	const textMetrics = ctx.measureText(date);
-	const textWidth = textMetrics.width;
-	const textHeight = options.fontSize;
-
-	// Calculate tooltip dimensions
-	const tooltipWidth = textWidth + options.padding * 2;
-	const tooltipHeight = textHeight + options.padding * 2;
-
-	// Calculate tooltip position (center horizontally on the crosshair)
-	let tooltipX = mouseX - tooltipWidth / 2;
-	let tooltipY = chartArea.top - tooltipHeight - 10; // 10px above the chart
-
-	// Adjust position if tooltip would go outside chart bounds
-	if (tooltipX < chartArea.left) {
-		tooltipX = chartArea.left + 5;
-	} else if (tooltipX + tooltipWidth > chartArea.right) {
-		tooltipX = chartArea.right - tooltipWidth - 5;
-	}
-
-	// If tooltip would go above chart area, show below crosshair instead
-	if (tooltipY < 0) {
-		tooltipY = chartArea.top + 10;
-	}
-
-	// Save canvas state
-	ctx.save();
-
-	// Draw tooltip background
-	ctx.fillStyle = options.backgroundColor;
-	ctx.strokeStyle = options.borderColor;
-	ctx.lineWidth = options.borderWidth;
-
-	// Draw rounded rectangle
-	ctx.beginPath();
-	ctx.roundRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight, options.borderRadius);
-	ctx.fill();
-	if (options.borderWidth > 0) {
-		ctx.stroke();
-	}
-
-	// Draw text
-	ctx.fillStyle = options.textColor;
-	ctx.textAlign = 'left';
-	ctx.textBaseline = 'top';
-	ctx.fillText(date, tooltipX + options.padding, tooltipY + options.padding);
-
-	// Restore canvas state
-	ctx.restore();
-}
-
-function snapCrosshairX(x: number): number {
-	return Math.round(x) + 0.5;
-}
-
-function scheduleChartDraw(chart: Chart, state: CrosshairState): void {
-	if (state.drawFrame !== null) {
-		return;
-	}
-
-	state.drawFrame = requestDrawFrame(() => {
-		state.drawFrame = null;
-		chart.draw();
-	});
-}
-
-export const verticalCrosshairPlugin: Plugin<'line' | 'bar' | 'scatter'> = {
-	id: 'verticalCrosshair',
-
-	defaults: {
-		enabled: true,
-		line: {
-			color: 'rgba(128, 128, 128, 0.8)',
-			width: 1,
-			dash: [5, 5]
-		},
-		tooltip: {
-			enabled: true,
-			delay: 500,
-			backgroundColor: 'rgba(0, 0, 0, 0.8)',
-			textColor: 'white',
-			borderColor: 'rgba(100, 100, 100, 0.8)',
-			borderWidth: 1,
-			borderRadius: 4,
-			padding: 8,
-			fontSize: 12,
-			fontFamily: 'system-ui, sans-serif'
-		}
-	} as CrosshairOptions,
-
-	afterInit(chart: Chart) {
-		// Initialize state for this chart instance
-		chartStates.set(chart, {
-			mouseX: null,
-			isMouseOver: false,
-			showTooltip: false,
-			tooltipTimeout: null,
-			hoveredDate: null,
-			drawFrame: null
-		});
-	},
-
-	afterEvent(chart: Chart, args: { event: ChartEvent }, options: CrosshairOptions) {
-		const state = chartStates.get(chart);
-		if (!state || !options.enabled) return;
-
-		const event = args.event;
-
-		if (
-			event.type === 'mousemove' &&
-			event.x !== null &&
-			event.x !== undefined &&
-			event.y !== null &&
-			event.y !== undefined
-		) {
-			// Check if mouse is within chart area
-			const chartArea = chart.chartArea;
-			const isInChartArea =
-				event.x >= chartArea.left &&
-				event.x <= chartArea.right &&
-				event.y >= chartArea.top &&
-				event.y <= chartArea.bottom;
-
-			if (isInChartArea) {
-				state.mouseX = event.x;
-				state.isMouseOver = true;
-
-				// Clear existing tooltip timeout
-				if (state.tooltipTimeout !== null) {
-					clearTimeout(state.tooltipTimeout);
-				}
-
-				// Hide tooltip while moving
-				if (state.showTooltip) {
-					state.showTooltip = false;
-					scheduleChartDraw(chart, state);
-				}
-
-				// Calculate hovered date
-				state.hoveredDate = calculateHoveredDate(chart, event.x);
-
-				// Notify sync callback of hover state change
-				options.sync?.onHover?.(state.hoveredDate);
-
-				// Set new timeout to show tooltip if tooltip is enabled
-				if (options.tooltip.enabled && state.hoveredDate) {
-					state.tooltipTimeout = setTimeout(() => {
-						state.showTooltip = true;
-						state.tooltipTimeout = null;
-						scheduleChartDraw(chart, state);
-					}, options.tooltip.delay);
-				}
-
-				scheduleChartDraw(chart, state);
-			} else if (state.isMouseOver) {
-				// Mouse left chart area
-				if (state.tooltipTimeout !== null) {
-					clearTimeout(state.tooltipTimeout);
-					state.tooltipTimeout = null;
-				}
-				state.isMouseOver = false;
-				state.mouseX = null;
-				state.showTooltip = false;
-				state.hoveredDate = null;
-
-				// Notify sync callback that hover ended
-				options.sync?.onHover?.(null);
-
-				scheduleChartDraw(chart, state);
-			}
-		} else if (event.type === 'mouseout') {
-			// Mouse completely left the canvas
-			if (state.tooltipTimeout !== null) {
-				clearTimeout(state.tooltipTimeout);
-				state.tooltipTimeout = null;
-			}
-			state.isMouseOver = false;
-			state.mouseX = null;
-			state.showTooltip = false;
-			state.hoveredDate = null;
-
-			// Notify sync callback that hover ended
-			options.sync?.onHover?.(null);
-
-			scheduleChartDraw(chart, state);
-		}
-	},
-
-	afterDraw(chart: Chart, args: unknown, options: CrosshairOptions) {
-		const state = chartStates.get(chart);
-		if (!state || !options.enabled) {
-			return;
-		}
-
-		const ctx = chart.ctx;
-		const chartArea = chart.chartArea;
-
-		// Determine crosshair position: local hover takes precedence over external
-		let crosshairX: number | null = null;
-		let crosshairLabel: string | null = null;
-		let isLocalHover = false;
-
-		if (state.isMouseOver && state.mouseX !== null) {
-			// Local hover - use mouse position directly
-			crosshairX = state.mouseX;
-			crosshairLabel = state.hoveredDate;
-			isLocalHover = true;
-		} else {
-			// Check for external hover from another chart
-			const externalLabel = options.sync?.getExternalLabel?.();
-			if (externalLabel) {
-				// Find the label index and convert to pixel position
-				const labels = chart.data.labels;
-				if (labels && labels.length > 0) {
-					const labelIndex = labels.indexOf(externalLabel);
-					if (labelIndex >= 0) {
-						crosshairX = getPixelForLabel(chart, labelIndex);
-						crosshairLabel = externalLabel;
-					}
-				}
-			}
-		}
-
-		// Don't draw if no crosshair position
-		if (crosshairX === null) {
-			return;
-		}
-
-		const snappedCrosshairX = snapCrosshairX(crosshairX);
-
-		// Save the current canvas state
-		ctx.save();
-
-		// Set line style
-		ctx.strokeStyle = options.line.color;
-		ctx.lineWidth = options.line.width;
-
-		// Set line dash pattern if specified
-		if (options.line.dash && options.line.dash.length > 0) {
-			ctx.setLineDash(options.line.dash);
-		} else {
-			ctx.setLineDash([]);
-		}
-
-		// Draw vertical line
-		ctx.beginPath();
-		ctx.moveTo(snappedCrosshairX, chartArea.top);
-		ctx.lineTo(snappedCrosshairX, chartArea.bottom);
-		ctx.stroke();
-
-		// Restore the canvas state
-		ctx.restore();
-
-		// Draw tooltip only for local hover (not external)
-		if (isLocalHover && options.tooltip.enabled && state.showTooltip && crosshairLabel) {
-			drawTooltip(ctx, snappedCrosshairX, crosshairLabel, chartArea, options.tooltip);
-		}
-	},
-
-	beforeDestroy(chart: Chart) {
-		// Clean up state when chart is destroyed
-		const state = chartStates.get(chart);
-		if (state && state.tooltipTimeout !== null) {
-			clearTimeout(state.tooltipTimeout);
-		}
-		if (state && state.drawFrame !== null) {
-			cancelDrawFrame(state.drawFrame);
-		}
-		chartStates.delete(chart);
-	}
-};
```

## apps/web/src/lib/components/charts/legend-swatch.ts

Status: added; +49; −0

```diff
@@ -1,0 +1,49 @@
+import type { PlotOptions, PlotSeries } from './chart-registry';
+
+export function paintLegendSwatch(
+	button: HTMLButtonElement,
+	series: PlotSeries,
+	kind: PlotOptions['kind']
+) {
+	const swatch = button.querySelector<HTMLElement>('[data-chart-legend-swatch]');
+	if (!swatch) return;
+	const document = button.ownerDocument;
+	const ns = 'http://www.w3.org/2000/svg';
+	const svg = document.createElementNS(ns, 'svg');
+	svg.setAttribute('width', '28');
+	svg.setAttribute('height', '12');
+	svg.setAttribute('aria-hidden', 'true');
+	const mark = document.createElementNS(ns, kind === 'stacked' ? 'rect' : 'path');
+	if (kind === 'stacked') {
+		mark.setAttribute('x', '1');
+		mark.setAttribute('y', '1');
+		mark.setAttribute('width', '26');
+		mark.setAttribute('height', '10');
+		mark.setAttribute('fill', series.color);
+		mark.setAttribute('fill-opacity', '0.6');
+	} else {
+		mark.setAttribute('d', 'M0,6H28');
+		mark.setAttribute('fill', 'none');
+	}
+	mark.setAttribute('stroke', series.color);
+	mark.setAttribute('stroke-width', String(series.strokeWidth ?? 3));
+	if (series.dash) mark.setAttribute('stroke-dasharray', series.dash);
+	svg.append(mark);
+	if (series.radius) {
+		const point = document.createElementNS(ns, 'circle');
+		point.setAttribute('cx', '14');
+		point.setAttribute('cy', '6');
+		point.setAttribute('r', String(series.radius));
+		point.setAttribute('fill', series.pointFill ?? 'var(--card)');
+		point.setAttribute('stroke', series.pointStroke ?? series.color);
+		point.setAttribute('stroke-width', String(series.pointStrokeWidth ?? 2));
+		svg.append(point);
+	}
+	swatch.style.width = '28px';
+	swatch.style.height = '12px';
+	swatch.style.border = '0';
+	swatch.style.borderRadius = '0';
+	swatch.style.background = 'transparent';
+	swatch.style.opacity = button.getAttribute('aria-pressed') === 'false' ? '0.4' : '1';
+	swatch.replaceChildren(svg);
+}
```

## apps/web/src/lib/components/charts/temporal-grid.ts

Status: added; +39; −0

```diff
@@ -1,0 +1,39 @@
+import type { ChartMark, SceneNode } from '@tanstack/charts';
+
+export function temporalGrid(
+	values: readonly number[],
+	boundaries: ReadonlySet<number>
+): ChartMark<never, number, number> {
+	return {
+		initialize: () => ({
+			id: 'temporal-grid',
+			channels: {},
+			render: ({ scales, chart }) => {
+				const columns = new Map<number, boolean>();
+				for (const value of values) {
+					const position = scales.x.map(value);
+					if (position < chart.x || position > chart.x + chart.width || !Number.isFinite(position))
+						continue;
+					const x = Math.round(position) + 0.5;
+					columns.set(x, (columns.get(x) ?? false) || boundaries.has(value));
+				}
+				const nodes: SceneNode[] = [false, true].map((major) => ({
+					kind: 'polyline',
+					key: `temporal-grid-${major ? 'major' : 'minor'}`,
+					points: [],
+					path: [...columns]
+						.filter(([, boundary]) => boundary === major)
+						.map(([x]) => `M${x},${chart.y}V${chart.y + chart.height}`)
+						.join(''),
+					ariaHidden: true,
+					style: {
+						fill: 'none',
+						stroke: major ? 'var(--chart-grid-color)' : 'var(--chart-grid-highlight-color)',
+						strokeWidth: 1
+					}
+				}));
+				return { nodes };
+			}
+		})
+	};
+}
```

## apps/web/src/lib/components/charts/temporal-navigation.ts

Status: added; +61; −0

```diff
@@ -1,0 +1,61 @@
+import type { GroupByOption } from '#lib/components/netflow/types.ts';
+import { formatDateAsPSTDateString } from '#lib/utils/timezone.ts';
+import {
+	chooseAdaptiveGranularity,
+	generateSlugFromLabel,
+	groupByBucketDurationMs,
+	parseClickedLabel
+} from './chart-utils';
+import type { PlotPoint } from './chart-registry';
+
+export type Drilldown = (groupBy: GroupByOption, startDate: string, endDate: string) => void;
+
+export function openTemporalPoint(
+	point: PlotPoint,
+	groupBy: GroupByOption,
+	onDrillDown?: Drilldown,
+	onFile?: (slug: string) => void
+) {
+	if (!point.label) return;
+	if (groupBy === '5min') {
+		const slug = generateSlugFromLabel(point.label, groupBy);
+		if (slug) onFile?.(slug);
+		return;
+	}
+	const clicked = parseClickedLabel(point.label, groupBy);
+	if (!Number.isFinite(clicked.getTime())) return;
+	const day = 86400000;
+	const start =
+		groupBy === 'date'
+			? new Date(clicked.getTime() - 15 * day)
+			: groupBy === 'hour'
+				? new Date(clicked.getTime() - 3 * day)
+				: clicked;
+	const end = new Date(
+		clicked.getTime() + (groupBy === 'date' ? 16 : groupBy === 'hour' ? 4 : 1) * day
+	);
+	onDrillDown?.(
+		groupBy === 'date' ? 'hour' : groupBy === 'hour' ? '10min' : '5min',
+		formatDateAsPSTDateString(start),
+		formatDateAsPSTDateString(end)
+	);
+}
+
+export function openTemporalRange(
+	start: PlotPoint,
+	end: PlotPoint,
+	groupBy: GroupByOption,
+	onDrillDown?: Drilldown
+) {
+	if (!start.label || !end.label) return;
+	const from = parseClickedLabel(start.label, groupBy);
+	const to = new Date(
+		parseClickedLabel(end.label, groupBy).getTime() + groupByBucketDurationMs(groupBy)
+	);
+	if (!Number.isFinite(from.getTime()) || !Number.isFinite(to.getTime())) return;
+	onDrillDown?.(
+		chooseAdaptiveGranularity(to.getTime() - from.getTime()),
+		formatDateAsPSTDateString(from),
+		formatDateAsPSTDateString(to)
+	);
+}
```

[Next page](https://git.cafe/flambo/atlantis/pull/131/diffs?after=ImFwcHMvd2ViL3NyYy9saWIvY29tcG9uZW50cy9jaGFydHMvdGVtcG9yYWwtbmF2aWdhdGlvbi50cyI&expectedVersion=0&format=markdown)
