remove echarts, use canvas

This commit is contained in:
Vincent van der Wal
2026-07-19 15:25:04 +02:00
parent 6d81af8df5
commit e031716ce6
37 changed files with 1483 additions and 2510 deletions
+829
View File
@@ -0,0 +1,829 @@
<!--
CanvasChart.svelte — Self-contained canvas time-series chart
Renders line and bar series over a shared hourly time axis on a
devicePixelRatio-aware canvas. Supports daylight background bands,
timezone-aware axis labels with emphasized day boundaries, a DOM tooltip
with crosshair, wheel/pinch zoom, drag panning, and cross-chart
synchronization via the `group` prop.
Usage:
<CanvasChart
timestamps={epochSeconds}
timezone="Europe/Berlin"
series={[{ name: 'Temperature', type: 'line', color: '#ef6c00', data: temps }]}
bands={daylightBands}
unit="°C"
group="meteogram"
/>
-->
<script module lang="ts">
export interface ChartSeries {
/** Series display name (used in legend and tooltip) */
name: string;
/** Render style */
type: 'line' | 'bar';
/** Any CSS color string */
color: string;
/** One value per timestamp; null values break lines */
data: (number | null)[];
/** Line width in px (default 2); 0 draws only the area fill */
width?: number;
/** Draw a low-alpha area fill below (or above, on inverted axes) the line */
fill?: boolean;
/** Opacity of the area fill (default 0.15) */
fillOpacity?: number;
/** Draw the line dashed */
dashed?: boolean;
/** Hide the series entirely (not rendered, not listed in the tooltip) */
hidden?: boolean;
/** Which y axis the series is scaled against (default 'left') */
axis?: 'left' | 'right';
/** Include the series in the legend row (default true) */
showInLegend?: boolean;
/** Custom tooltip value formatter */
format?: (value: number, index: number) => string;
}
interface GroupState {
range: { start: number; end: number } | null;
hover: number | null;
count: number;
}
// Module-level registry: charts sharing a `group` name share zoom range and
// crosshair position through one reactive state object.
const groups: Record<string, GroupState> = $state({});
function acquireGroup(name: string): GroupState {
if (!groups[name]) {
groups[name] = { range: null, hover: null, count: 0 };
}
groups[name].count++;
return groups[name];
}
function releaseGroup(name: string): void {
const state = groups[name];
if (!state) return;
state.count--;
if (state.count <= 0) {
delete groups[name];
}
}
</script>
<script lang="ts">
import { onDestroy, onMount, untrack } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { formatZoned, getZonedHour } from '$lib/utils/date';
import { CHART_COLORS } from './data';
// ─── Props ──────────────────────────────────────────────────────────────────
interface Props {
/** Time axis values in epoch seconds (shared by all series) */
timestamps: number[];
/** IANA timezone used for all time labels */
timezone: string;
/** Series to render */
series: ChartSeries[];
/** Background bands (epoch seconds), e.g. daylight */
bands?: { start: number; end: number }[];
/** Unit label for the left y axis (also used in tooltip values) */
unit?: string;
/** Unit label for the right y axis; when set, right-axis labels are drawn */
unitRight?: string;
/** Canvas height in px */
height?: number;
/** Charts sharing a group share x-zoom range and crosshair */
group?: string;
/** Fixed left-axis minimum (otherwise derived from data, including 0) */
yMin?: number;
/** Fixed left-axis maximum */
yMax?: number;
/** Fixed right-axis minimum (default 0) */
yMinRight?: number;
/** Fixed right-axis maximum (default 100) */
yMaxRight?: number;
/** Invert the right axis (min at the top) */
invertRight?: boolean;
/** Chart title drawn top-left on the canvas */
title?: string;
/** Smaller subtitle drawn under the title */
subtitle?: string;
/** Show the DOM legend row above the canvas */
showLegend?: boolean;
/** Draw a red vertical line at the current time */
showNow?: boolean;
/** Draw the Open-Meteo.com credit bottom-right */
showCredit?: boolean;
/** Optional CSS class for the outer container */
class?: string;
}
let {
timestamps,
timezone,
series,
bands = [],
unit = '',
unitRight,
height = 300,
group,
yMin,
yMax,
yMinRight,
yMaxRight,
invertRight = false,
title,
subtitle,
showLegend = false,
showNow = true,
showCredit = false,
class: className = ''
}: Props = $props();
// ─── Constants ──────────────────────────────────────────────────────────────
const PAD_LEFT = 60;
const PAD_BOTTOM = 34;
const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours
const HOUR = 3600;
// ─── State ──────────────────────────────────────────────────────────────────
let containerEl: HTMLDivElement | undefined = $state();
let canvasEl: HTMLCanvasElement | undefined = $state();
let width = $state(0);
let themeVersion = $state(0);
const legendHidden = new SvelteSet<string>();
// Zoom range and crosshair: either group-shared or local to this chart.
// The group is acquired once at component init (the prop is treated as fixed).
const groupName = untrack(() => group);
const groupState: GroupState | null = groupName ? acquireGroup(groupName) : null;
let localRange = $state<{ start: number; end: number } | null>(null);
let localHover = $state<number | null>(null);
onDestroy(() => {
if (groupName) releaseGroup(groupName);
});
// ─── Derived: view window & scales ──────────────────────────────────────────
let viewRange = $derived(groupState ? groupState.range : localRange);
let hoverTime = $derived(groupState ? groupState.hover : localHover);
let tMin = $derived(timestamps.length > 0 ? timestamps[0] : 0);
let tMax = $derived(timestamps.length > 1 ? timestamps[timestamps.length - 1] : tMin + HOUR);
let viewStart = $derived(viewRange ? Math.max(tMin, viewRange.start) : tMin);
let viewEnd = $derived(
viewRange ? Math.max(viewStart + MIN_SPAN / 2, Math.min(tMax, viewRange.end)) : tMax
);
let zoomed = $derived(viewRange !== null && viewEnd - viewStart < tMax - tMin);
let visibleSeries = $derived(series.filter((s) => !s.hidden && !legendHidden.has(s.name)));
let hasRightAxis = $derived(series.some((s) => s.axis === 'right'));
let padRight = $derived(hasRightAxis ? 56 : 20);
let padTop = $derived(title ? (subtitle ? 66 : 46) : 28);
let plotW = $derived(Math.max(1, width - PAD_LEFT - padRight));
let plotH = $derived(Math.max(1, height - padTop - PAD_BOTTOM));
interface Scale {
min: number;
max: number;
step: number;
}
function niceNum(range: number, round: boolean): number {
const exp = Math.floor(Math.log10(range));
const frac = range / 10 ** exp;
let nice: number;
if (round) {
nice = frac < 1.5 ? 1 : frac < 3 ? 2 : frac < 7 ? 5 : 10;
} else {
nice = frac <= 1 ? 1 : frac <= 2 ? 2 : frac <= 5 ? 5 : 10;
}
return nice * 10 ** exp;
}
function dataExtent(axis: 'left' | 'right'): [number, number] {
let lo = Infinity;
let hi = -Infinity;
for (const s of visibleSeries) {
if ((s.axis ?? 'left') !== axis) continue;
for (const v of s.data) {
if (v === null || !isFinite(v)) continue;
if (v < lo) lo = v;
if (v > hi) hi = v;
}
}
if (!isFinite(lo)) return [0, 1];
// Match the previous ECharts behavior (value axis without `scale`): always
// include zero in the axis extent.
lo = Math.min(lo, 0);
hi = Math.max(hi, 0);
if (lo === hi) hi = lo + 1;
return [lo, hi];
}
function buildScale(lo: number, hi: number, loFixed: boolean, hiFixed: boolean): Scale {
const step = niceNum(niceNum(Math.max(hi - lo, 1e-9), false) / 4, true);
const min = loFixed ? lo : Math.floor(lo / step) * step;
const max = hiFixed ? hi : Math.ceil(hi / step) * step;
return { min, max: max > min ? max : min + step, step };
}
let leftScale = $derived.by((): Scale => {
const [dLo, dHi] = dataExtent('left');
return buildScale(yMin ?? dLo, yMax ?? dHi, yMin !== undefined, yMax !== undefined);
});
let rightScale = $derived.by((): Scale => {
const lo = yMinRight ?? 0;
const hi = yMaxRight ?? 100;
return buildScale(lo, hi, true, true);
});
function xPix(t: number): number {
return PAD_LEFT + ((t - viewStart) / (viewEnd - viewStart)) * plotW;
}
function pixToTime(x: number): number {
return viewStart + ((x - PAD_LEFT) / plotW) * (viewEnd - viewStart);
}
function yPix(v: number, axis: 'left' | 'right'): number {
if (axis === 'right') {
const frac = (v - rightScale.min) / (rightScale.max - rightScale.min);
return invertRight ? padTop + frac * plotH : padTop + (1 - frac) * plotH;
}
const frac = (v - leftScale.min) / (leftScale.max - leftScale.min);
return padTop + (1 - frac) * plotH;
}
// ─── Zoom / pan helpers ─────────────────────────────────────────────────────
function setViewRange(range: { start: number; end: number } | null): void {
if (groupState) groupState.range = range;
else localRange = range;
}
function setHover(time: number | null): void {
if (groupState) groupState.hover = time;
else localHover = time;
}
function applyRange(start: number, end: number): void {
const full = tMax - tMin;
const span = Math.min(Math.max(end - start, MIN_SPAN), full);
if (span >= full) {
setViewRange(null);
return;
}
const s = Math.max(tMin, Math.min(start, tMax - span));
setViewRange({ start: s, end: s + span });
}
function zoomAt(centerTime: number, factor: number): void {
const span = viewEnd - viewStart;
const newSpan = span * factor;
const frac = (centerTime - viewStart) / span;
applyRange(centerTime - frac * newSpan, centerTime + (1 - frac) * newSpan);
}
// ─── Public API ─────────────────────────────────────────────────────────────
/** Returns the chart as a PNG data URL, or null before mount. */
export function getPngDataUrl(): string | null {
return canvasEl ? canvasEl.toDataURL('image/png') : null;
}
/** Zooms the x axis to the given epoch-second range (clamped to the data). */
export function setRange(startEpoch: number, endEpoch: number): void {
applyRange(startEpoch, endEpoch);
}
/** Resets the x axis to the full data range. */
export function resetRange(): void {
setViewRange(null);
}
// ─── Tooltip data ───────────────────────────────────────────────────────────
function nearestIndex(arr: number[], t: number): number {
let lo = 0;
let hi = arr.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (arr[mid] < t) lo = mid + 1;
else hi = mid;
}
if (lo > 0 && Math.abs(arr[lo - 1] - t) <= Math.abs(arr[lo] - t)) return lo - 1;
return lo;
}
let hoverIdx = $derived(
hoverTime === null || timestamps.length === 0 ? -1 : nearestIndex(timestamps, hoverTime)
);
interface TooltipRow {
name: string;
color: string;
value: string;
}
let tooltipRows = $derived.by((): TooltipRow[] => {
if (hoverIdx < 0) return [];
const rows: TooltipRow[] = [];
for (const s of visibleSeries) {
const v = s.data[hoverIdx];
if (v === null || v === undefined || !isFinite(v)) continue;
const axisUnit = (s.axis ?? 'left') === 'right' ? (unitRight ?? '') : unit;
const value = s.format
? s.format(v, hoverIdx)
: `${v.toFixed(1)}${axisUnit ? ' ' + axisUnit : ''}`;
rows.push({ name: s.name, color: s.color, value });
}
return rows;
});
let tooltipVisible = $derived(hoverIdx >= 0 && tooltipRows.length > 0 && width > 0);
let tooltipX = $derived(hoverIdx >= 0 ? xPix(timestamps[hoverIdx]) : 0);
let tooltipFlip = $derived(tooltipX > width * 0.55);
// ─── X axis ticks ───────────────────────────────────────────────────────────
interface XTick {
t: number;
label: string;
isDay: boolean;
}
function computeXTicks(): XTick[] {
const spanHours = (viewEnd - viewStart) / HOUR;
const pxPerHour = plotW / spanHours;
const steps = [1, 2, 3, 6, 12, 24];
let step = 24;
for (const s of steps) {
if (s * pxPerHour >= 48) {
step = s;
break;
}
}
const ticks: XTick[] = [];
const first = Math.ceil(viewStart / HOUR) * HOUR;
for (let t = first; t <= viewEnd; t += HOUR) {
const date = new Date(t * 1000);
const hour = getZonedHour(date, timezone);
if (hour === 0) {
ticks.push({ t, label: formatZoned(date, timezone, 'EEE d'), isDay: true });
} else if (step < 24 && hour % step === 0) {
ticks.push({ t, label: formatZoned(date, timezone, 'HH:mm'), isDay: false });
}
}
return ticks;
}
// ─── Rendering ──────────────────────────────────────────────────────────────
function tickDecimals(step: number): number {
if (step >= 1) return 0;
return Math.min(3, Math.max(0, Math.ceil(-Math.log10(step))));
}
function draw(): void {
if (!canvasEl || !containerEl || width <= 0) return;
const dpr = window.devicePixelRatio || 1;
const w = Math.round(width * dpr);
const h = Math.round(height * dpr);
if (canvasEl.width !== w) canvasEl.width = w;
if (canvasEl.height !== h) canvasEl.height = h;
const ctx = canvasEl.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
if (timestamps.length === 0) return;
const styles = getComputedStyle(containerEl);
const cssColor = (name: string, fallback: string): string =>
styles.getPropertyValue(name).trim() || fallback;
const textColor = cssColor('--muted-foreground', '#6b7280');
const strongColor = cssColor('--foreground', '#374151');
const gridColor = cssColor('--border', 'rgba(0, 0, 0, 0.1)');
const plotRight = PAD_LEFT + plotW;
const plotBottom = padTop + plotH;
const font = '11px system-ui, sans-serif';
// Daylight bands
ctx.fillStyle = CHART_COLORS.daylight;
for (const band of bands) {
if (band.end < viewStart || band.start > viewEnd) continue;
const x1 = Math.max(PAD_LEFT, xPix(band.start));
const x2 = Math.min(plotRight, xPix(band.end));
if (x2 > x1) ctx.fillRect(x1, padTop, x2 - x1, plotH);
}
// Horizontal grid lines + left axis labels
ctx.font = font;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (let v = leftScale.min; v <= leftScale.max + leftScale.step / 2; v += leftScale.step) {
const y = yPix(v, 'left');
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD_LEFT, y);
ctx.lineTo(plotRight, y);
ctx.stroke();
ctx.fillStyle = textColor;
ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), PAD_LEFT - 8, y);
}
// Right axis labels (only when a unit is provided)
if (hasRightAxis && unitRight !== undefined) {
ctx.textAlign = 'left';
ctx.fillStyle = textColor;
for (
let v = rightScale.min;
v <= rightScale.max + rightScale.step / 2;
v += rightScale.step
) {
ctx.fillText(v.toFixed(tickDecimals(rightScale.step)), plotRight + 8, yPix(v, 'right'));
}
}
// X axis ticks: midnight gridlines + time labels
ctx.textBaseline = 'top';
for (const tick of computeXTicks()) {
const x = xPix(tick.t);
if (tick.isDay) {
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, padTop);
ctx.lineTo(x, plotBottom);
ctx.stroke();
ctx.font = 'bold 11px system-ui, sans-serif';
ctx.fillStyle = strongColor;
} else {
ctx.strokeStyle = gridColor;
ctx.beginPath();
ctx.moveTo(x, plotBottom);
ctx.lineTo(x, plotBottom + 4);
ctx.stroke();
ctx.font = font;
ctx.fillStyle = textColor;
}
ctx.textAlign = 'center';
ctx.fillText(tick.label, x, plotBottom + 7);
}
// Series (clipped to the plot area)
ctx.save();
ctx.beginPath();
ctx.rect(PAD_LEFT, padTop, plotW, plotH);
ctx.clip();
const barSeries = visibleSeries.filter((s) => s.type === 'bar');
const interval = timestamps.length > 1 ? timestamps[1] - timestamps[0] : HOUR;
const slot = plotW / ((viewEnd - viewStart) / interval);
const barWidth = Math.min(8, Math.max(1, (slot * 0.7) / Math.max(1, barSeries.length)));
for (const s of visibleSeries) {
const axis = s.axis ?? 'left';
const baseline = Math.min(plotBottom, Math.max(padTop, yPix(0, axis)));
if (s.type === 'bar') {
const bi = barSeries.indexOf(s);
const groupOffset = (barSeries.length * barWidth) / 2 - bi * barWidth;
ctx.fillStyle = s.color;
for (let i = 0; i < timestamps.length; i++) {
const v = s.data[i];
if (v === null || v === undefined || !isFinite(v)) continue;
const t = timestamps[i];
if (t < viewStart - interval || t > viewEnd + interval) continue;
const x = xPix(t) - groupOffset;
const y = yPix(v, axis);
ctx.fillRect(x, Math.min(y, baseline), barWidth, Math.abs(baseline - y) || 1);
}
continue;
}
// Line series: draw fill and stroke per contiguous non-null run
// (points outside the view are handled by the clip rect)
const runs: Array<Array<[number, number]>> = [];
let run: Array<[number, number]> = [];
for (let i = 0; i < timestamps.length; i++) {
const v = s.data[i];
if (v === null || v === undefined || !isFinite(v)) {
if (run.length > 0) runs.push(run);
run = [];
continue;
}
run.push([xPix(timestamps[i]), yPix(v, axis)]);
}
if (run.length > 0) runs.push(run);
for (const points of runs) {
if (points.length === 0) continue;
if (s.fill && points.length > 1) {
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
ctx.lineTo(points[points.length - 1][0], baseline);
ctx.lineTo(points[0][0], baseline);
ctx.closePath();
ctx.globalAlpha = s.fillOpacity ?? 0.15;
ctx.fillStyle = s.color;
ctx.fill();
ctx.globalAlpha = 1;
}
const lineWidth = s.width ?? 2;
if (lineWidth > 0) {
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
ctx.strokeStyle = s.color;
ctx.lineWidth = lineWidth;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.setLineDash(s.dashed ? [6, 4] : []);
ctx.stroke();
ctx.setLineDash([]);
}
}
}
// Current time marker
if (showNow) {
const now = Date.now() / 1000;
if (now >= viewStart && now <= viewEnd) {
const x = xPix(now);
ctx.strokeStyle = CHART_COLORS.currentTimeLine;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, padTop);
ctx.lineTo(x, plotBottom);
ctx.stroke();
}
}
// Crosshair (snapped to the nearest timestamp)
if (hoverIdx >= 0) {
const t = timestamps[hoverIdx];
if (t >= viewStart && t <= viewEnd) {
const x = xPix(t);
ctx.strokeStyle = textColor;
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(x, padTop);
ctx.lineTo(x, plotBottom);
ctx.stroke();
ctx.setLineDash([]);
}
}
ctx.restore();
// Axis unit labels
ctx.font = font;
ctx.textBaseline = 'alphabetic';
if (unit) {
ctx.textAlign = 'right';
ctx.fillStyle = textColor;
ctx.fillText(unit, PAD_LEFT - 4, padTop - 8);
}
if (hasRightAxis && unitRight) {
ctx.textAlign = 'left';
ctx.fillStyle = textColor;
ctx.fillText(unitRight, plotRight + 4, padTop - 8);
}
// Title / subtitle
if (title) {
ctx.textAlign = 'left';
ctx.font = '16px system-ui, sans-serif';
ctx.fillStyle = strongColor;
ctx.fillText(title, 4, 20);
if (subtitle) {
ctx.font = '12px system-ui, sans-serif';
ctx.fillStyle = textColor;
ctx.fillText(subtitle, 4, 38);
}
}
// Credit watermark
if (showCredit) {
ctx.textAlign = 'right';
ctx.font = '10px system-ui, sans-serif';
ctx.globalAlpha = 0.4;
ctx.fillStyle = strongColor;
ctx.fillText('Open-Meteo.com', width - 10, height - 6);
ctx.globalAlpha = 1;
}
}
$effect(() => {
// themeVersion is a manual dependency: it bumps when the document theme
// class changes so colors are re-read from CSS custom properties.
void themeVersion;
draw();
});
// ─── Lifecycle: resize + theme observers ────────────────────────────────────
onMount(() => {
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
width = entry.contentRect.width;
}
});
if (containerEl) resizeObserver.observe(containerEl);
const themeObserver = new MutationObserver(() => {
themeVersion++;
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme']
});
return () => {
resizeObserver.disconnect();
themeObserver.disconnect();
};
});
// ─── Interaction ────────────────────────────────────────────────────────────
// Listeners are attached programmatically (not via template attributes) so the
// wheel handler can be registered as non-passive.
$effect(() => {
const el = canvasEl;
if (!el) return;
// Plain interaction bookkeeping (not reactive state)
const pointers = new SvelteMap<number, { x: number; y: number }>();
let panStart: { x: number; start: number; end: number } | null = null;
let pinchStart: { dist: number; start: number; end: number } | null = null;
const localX = (e: { clientX: number }): number => e.clientX - el.getBoundingClientRect().left;
const updateHover = (e: PointerEvent): void => {
const t = pixToTime(localX(e));
setHover(t >= viewStart && t <= viewEnd ? t : null);
};
const onPointerDown = (e: PointerEvent): void => {
el.setPointerCapture(e.pointerId);
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 1) {
panStart = { x: e.clientX, start: viewStart, end: viewEnd };
pinchStart = null;
} else if (pointers.size === 2) {
const [a, b] = [...pointers.values()];
pinchStart = { dist: Math.max(10, Math.abs(a.x - b.x)), start: viewStart, end: viewEnd };
panStart = null;
setHover(null);
}
};
const onPointerMove = (e: PointerEvent): void => {
if (pointers.has(e.pointerId)) {
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
}
if (pinchStart && pointers.size === 2) {
const [a, b] = [...pointers.values()];
const dist = Math.max(10, Math.abs(a.x - b.x));
const scale = pinchStart.dist / dist;
const span = pinchStart.end - pinchStart.start;
const center = (pinchStart.start + pinchStart.end) / 2;
const newSpan = span * scale;
applyRange(center - newSpan / 2, center + newSpan / 2);
return;
}
if (panStart && pointers.size === 1 && zoomed) {
const dt = ((panStart.x - e.clientX) / plotW) * (panStart.end - panStart.start);
applyRange(panStart.start + dt, panStart.end + dt);
return;
}
updateHover(e);
};
const onPointerUp = (e: PointerEvent): void => {
pointers.delete(e.pointerId);
if (pointers.size < 2) pinchStart = null;
if (pointers.size < 1) panStart = null;
if (e.pointerType !== 'mouse') setHover(null);
};
const onPointerLeave = (): void => {
if (pointers.size === 0) setHover(null);
};
const onWheel = (e: WheelEvent): void => {
e.preventDefault();
const t = pixToTime(localX(e));
zoomAt(t, e.deltaY < 0 ? 1 / 1.3 : 1.3);
};
const onDblClick = (): void => {
setViewRange(null);
};
el.addEventListener('pointerdown', onPointerDown);
el.addEventListener('pointermove', onPointerMove);
el.addEventListener('pointerup', onPointerUp);
el.addEventListener('pointercancel', onPointerUp);
el.addEventListener('pointerleave', onPointerLeave);
el.addEventListener('wheel', onWheel, { passive: false });
el.addEventListener('dblclick', onDblClick);
return () => {
el.removeEventListener('pointerdown', onPointerDown);
el.removeEventListener('pointermove', onPointerMove);
el.removeEventListener('pointerup', onPointerUp);
el.removeEventListener('pointercancel', onPointerUp);
el.removeEventListener('pointerleave', onPointerLeave);
el.removeEventListener('wheel', onWheel);
el.removeEventListener('dblclick', onDblClick);
};
});
function toggleSeries(name: string): void {
if (legendHidden.has(name)) legendHidden.delete(name);
else legendHidden.add(name);
}
</script>
<div bind:this={containerEl} class="relative w-full select-none {className}">
{#if showLegend && series.length > 0}
<div class="mb-1 flex flex-wrap items-center gap-x-3 gap-y-1 px-1">
{#each series.filter((s) => s.showInLegend !== false) as s (s.name)}
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 text-xs transition-opacity {legendHidden.has(
s.name
)
? 'opacity-40'
: ''}"
onclick={() => toggleSeries(s.name)}
title="Toggle {s.name}"
>
<span
class="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
style:background-color={s.color}
></span>
<span class="text-muted-foreground">{s.name}</span>
</button>
{/each}
</div>
{/if}
<div class="relative">
<canvas
bind:this={canvasEl}
class="block w-full"
style:height="{height}px"
style:touch-action="pan-y"
></canvas>
{#if tooltipVisible}
<div
class="pointer-events-none absolute z-20 rounded-md border border-border bg-popover px-3 py-2 text-xs whitespace-nowrap text-popover-foreground shadow-md"
style:top="{padTop + 8}px"
style:left="{tooltipFlip ? tooltipX - 12 : tooltipX + 12}px"
style:transform={tooltipFlip ? 'translateX(-100%)' : ''}
>
<div class="mb-1 font-semibold">
{formatZoned(new Date(timestamps[hoverIdx] * 1000), timezone, 'EEE d MMM HH:mm')}
</div>
{#each tooltipRows as row (row.name)}
<div class="flex items-center gap-1.5">
<span
class="inline-block h-2 w-2 shrink-0 rounded-full"
style:background-color={row.color}
></span>
<span>{row.name}:</span>
<span class="ml-auto pl-2 font-semibold">{row.value}</span>
</div>
{/each}
</div>
{/if}
</div>
</div>