This repository has been archived on 2026-08-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
drizzli/src/lib/charts/CanvasChart.svelte
T

1228 lines
42 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
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, Ctrl+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;
/** With `fill`, fill the area between this line and another data array
* instead of the baseline (e.g. an ensemble min-max band) */
bandTo?: (number | null)[];
/** 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;
/** Short name used in the tooltip / legend when the full name is long */
shortName?: string;
/** Colour each line segment by value (e.g. a temperature colour scale) */
segmentColor?: (value: number, index: number) => string;
/** Draw a contrasting halo (black in light mode, white in dark) under the line */
outline?: boolean;
/** Annotate local minima / maxima with their value */
labelExtrema?: boolean;
/** Formatter for extrema labels (defaults to the tooltip format) */
labelFormat?: (value: number) => string;
/** Render as a puffy cloud band hanging from the top instead of a line */
cloudBand?: boolean;
}
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];
}
}
/**
* Drive the shared crosshair of a chart group from the outside (e.g. hovering
* the hourly table). `time` is epoch seconds, or null to clear. No-op if no
* chart in that group is currently mounted.
*/
export function setGroupHover(name: string, time: number | null): void {
const state = groups[name];
if (state) state.hover = time;
}
/** Current shared zoom range of a group (null = full range), reactive. */
export function groupRange(name: string): { start: number; end: number } | null {
return groups[name]?.range ?? null;
}
</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 }[];
/** Weather pictograms drawn across the top (t in epoch seconds) */
pictograms?: { t: number; icon: string }[];
/** Wind-direction arrows drawn across the top (t in epoch seconds, deg from N) */
windArrows?: { t: number; deg: number }[];
/** Highlighted time range (epoch seconds), e.g. the selected day */
highlight?: { 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;
/** Force the derived left axis to include zero (default true) */
zeroBaseLeft?: boolean;
/** 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;
/** Reserve the right-axis gutter even without a right axis (keeps a row of
* stacked charts identically sized) */
reserveRightAxis?: boolean;
/** Reserve this many top icon-rows even if this chart has fewer, so a row
* of stacked charts share the same plot rectangle */
reserveTopRows?: number;
/** 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 = [],
pictograms = [],
windArrows = [],
highlight,
unit = '',
unitRight,
height = 300,
group,
yMin,
yMax,
zeroBaseLeft = true,
yMinRight,
yMaxRight,
invertRight = false,
reserveRightAxis = false,
reserveTopRows = 0,
title,
subtitle,
showLegend = false,
showNow = true,
showCredit = false,
class: className = ''
}: Props = $props();
// ─── Constants ──────────────────────────────────────────────────────────────
const PAD_BOTTOM = 34;
const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours
const HOUR = 3600;
// Top icon rows (weather pictograms / wind arrows)
const ICON_ROW_H = 30; // reserved height per icon row
const ICON_BAND_H = 28; // visible band height
const ICON_PX = 26; // pictogram size
// Puffy cloud band: 100% cover hangs 40px from the top of the plot
const CLOUD_BAND_MAX = 40;
// ─── State ──────────────────────────────────────────────────────────────────
let containerEl: HTMLDivElement | undefined = $state();
let canvasEl: HTMLCanvasElement | undefined = $state();
let width = $state(0);
let themeVersion = $state(0);
const legendHidden = new SvelteSet<string>();
// Drag-to-zoom selection rectangle (plot-local pixel x), null when inactive.
let dragSelect = $state<{ x0: number; x1: number } | null>(null);
// 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)));
// Cloud-band series draw a decorative top band and are excluded from the
// axis scale and from the normal line/bar drawing.
let plottedSeries = $derived(visibleSeries.filter((s) => !s.cloudBand));
let cloudBandSeries = $derived(visibleSeries.filter((s) => s.cloudBand));
let hasRightAxis = $derived(plottedSeries.some((s) => s.axis === 'right'));
// Tighter left gutter on narrow screens so axis labels sit near the edge
let padLeft = $derived(width > 0 && width < 520 ? 38 : 60);
// Reserve the right gutter when this chart (or a sibling, via reserveRightAxis)
// has a right axis, so a stacked row of charts share the same plot width.
let padRight = $derived(hasRightAxis || reserveRightAxis ? 56 : 20);
// Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps
// a stacked row of charts the same height even if some have fewer icon rows.
let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0));
let iconRows = $derived(Math.max(ownIconRows, reserveTopRows));
let padTop = $derived((title ? (subtitle ? 66 : 46) : 28) + iconRows * ICON_ROW_H);
let plotW = $derived(Math.max(1, width - padLeft - 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', includeZero = true): [number, number] {
let lo = Infinity;
let hi = -Infinity;
for (const s of plottedSeries) {
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. Skipped for derived secondary axes
// (e.g. pressure) where zero would flatten the series.
if (includeZero) {
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', zeroBaseLeft);
return buildScale(yMin ?? dLo, yMax ?? dHi, yMin !== undefined, yMax !== undefined);
});
let rightScale = $derived.by((): Scale => {
// Use explicit bounds when given; otherwise derive from the right-axis
// data so arbitrary variables (pressure, wind, …) can share a panel.
const loFixed = yMinRight !== undefined;
const hiFixed = yMaxRight !== undefined;
if (loFixed && hiFixed) return buildScale(yMinRight!, yMaxRight!, true, true);
const [dLo, dHi] = dataExtent('right', false);
return buildScale(yMinRight ?? dLo, yMaxRight ?? dHi, loFixed, hiFixed);
});
function xPix(t: number): number {
return padLeft + ((t - viewStart) / (viewEnd - viewStart)) * plotW;
}
function pixToTime(x: number): number {
return viewStart + ((x - padLeft) / 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.shortName ?? 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);
// ─── Pictograms (DOM overlay across the top) ─────────────────────────────────
// The icon band spans exactly the plot area so the icons line up with the
// data (and axis) below and never overhang the plot's cut-off edge.
let iconBandLeft = $derived(padLeft);
let iconBandWidth = $derived(plotW);
function iconBandX(t: number): number {
return xPix(t) - padLeft;
}
// Thin the icons so they never crowd: keep ≥ 40px apart within the band.
let visiblePictograms = $derived.by((): { x: number; icon: string }[] => {
if (pictograms.length === 0 || width <= 0) return [];
const out: { x: number; icon: string }[] = [];
let lastX = -Infinity;
for (const p of pictograms) {
if (p.t < viewStart || p.t > viewEnd) continue;
const x = iconBandX(p.t);
if (x - lastX < 40) continue;
out.push({ x, icon: p.icon });
lastX = x;
}
return out;
});
// Same thinning for the wind-direction arrow row.
let visibleWindArrows = $derived.by((): { x: number; deg: number }[] => {
if (windArrows.length === 0 || width <= 0) return [];
const out: { x: number; deg: number }[] = [];
let lastX = -Infinity;
for (const a of windArrows) {
if (a.t < viewStart || a.t > viewEnd) continue;
const x = iconBandX(a.t);
if (x - lastX < 40) continue;
out.push({ x, deg: a.deg });
lastX = x;
}
return out;
});
// Vertical offset (px from container top) of each icon row's top edge, anchored
// just above the plot. When both rows are present, pictograms sit above the
// wind arrows (which stay closest to the plot).
let pictoRowTop = $derived(padTop - (windArrows.length > 0 ? 2 : 1) * ICON_ROW_H + 2);
let windRowTop = $derived(padTop - ICON_ROW_H + 2);
// ─── Local minima / maxima (for value labels) ────────────────────────────────
function findExtrema(data: (number | null)[]): { i: number; type: 'min' | 'max' }[] {
const res: { i: number; type: 'min' | 'max' }[] = [];
const W = 3;
let lastLabeled = -Infinity;
for (let i = 0; i < data.length; i++) {
const v = data[i];
if (v === null || !isFinite(v)) continue;
let noGreater = true;
let noLess = true;
let someLess = false;
let someGreater = false;
for (let j = Math.max(0, i - W); j <= Math.min(data.length - 1, i + W); j++) {
if (j === i) continue;
const u = data[j];
if (u === null || !isFinite(u)) continue;
if (u > v) {
noGreater = false;
someGreater = true;
} else if (u < v) {
noLess = false;
someLess = true;
}
}
const isMax = noGreater && someLess;
const isMin = noLess && someGreater;
if ((isMax || isMin) && i - lastLabeled >= W) {
res.push({ i, type: isMax ? 'max' : 'min' });
lastLabeled = i;
}
}
return res;
}
// ─── 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 bgColor = cssColor('--card', '#ffffff');
const dark = document.documentElement.classList.contains('dark');
const outlineColor = dark ? '#ffffff' : '#000000';
const plotRight = padLeft + 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(padLeft, xPix(band.start));
const x2 = Math.min(plotRight, xPix(band.end));
if (x2 > x1) ctx.fillRect(x1, padTop, x2 - x1, plotH);
}
// Selected-day highlight: soft tint + dashed edge lines
if (highlight && highlight.end > viewStart && highlight.start < viewEnd) {
const accent = cssColor('--primary', '#e08a3c');
const x1 = Math.max(padLeft, xPix(highlight.start));
const x2 = Math.min(plotRight, xPix(highlight.end));
if (x2 > x1) {
ctx.save();
ctx.globalAlpha = 0.08;
ctx.fillStyle = accent;
ctx.fillRect(x1, padTop, x2 - x1, plotH);
ctx.globalAlpha = 0.55;
ctx.strokeStyle = accent;
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 4]);
ctx.beginPath();
for (const edge of [highlight.start, highlight.end]) {
const x = xPix(edge);
if (x >= padLeft && x <= plotRight) {
ctx.moveTo(x, padTop);
ctx.lineTo(x, plotBottom);
}
}
ctx.stroke();
ctx.restore();
}
}
// 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(padLeft, y);
ctx.lineTo(plotRight, y);
ctx.stroke();
ctx.fillStyle = textColor;
ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), padLeft - 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(padLeft, padTop, plotW, plotH);
ctx.clip();
const interval = timestamps.length > 1 ? timestamps[1] - timestamps[0] : HOUR;
// Puffy cloud band: overlapping circles hang from the top of the plot,
// each reaching down by (cover/100) × CLOUD_BAND_MAX. Neighbouring puffs
// merge into a soft, rounded silhouette.
for (const s of cloudBandSeries) {
ctx.save();
ctx.fillStyle = s.color;
ctx.globalAlpha = 0.5;
for (let i = 0; i < timestamps.length; i++) {
const v = s.data[i];
if (v === null || v === undefined || !isFinite(v) || v <= 0) continue;
const t = timestamps[i];
if (t < viewStart - interval || t > viewEnd + interval) continue;
const r = (Math.min(100, v) / 100) * CLOUD_BAND_MAX;
if (r < 1) continue;
ctx.beginPath();
ctx.arc(xPix(t), padTop, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
const barSeries = plottedSeries.filter((s) => s.type === 'bar');
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 plottedSeries) {
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). Each point
// is [x, y, yBand, sourceIndex] — yBand only used when s.bandTo is set.
const runs: Array<Array<[number, number, number, number]>> = [];
let run: Array<[number, number, number, number]> = [];
for (let i = 0; i < timestamps.length; i++) {
const v = s.data[i];
const b = s.bandTo?.[i];
const bandInvalid = s.bandTo != null && (b === null || b === undefined || !isFinite(b));
if (v === null || v === undefined || !isFinite(v) || bandInvalid) {
if (run.length > 0) runs.push(run);
run = [];
continue;
}
run.push([xPix(timestamps[i]), yPix(v, axis), s.bandTo ? yPix(b as number, axis) : 0, i]);
}
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]);
if (s.bandTo) {
// close the polygon along the second line, walked backwards
for (let i = points.length - 1; i >= 0; i--) ctx.lineTo(points[i][0], points[i][2]);
} else {
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.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.setLineDash(s.dashed ? [6, 4] : []);
// Contrasting halo drawn under the line so a multi-colour line
// stays legible over any background.
if (s.outline && points.length > 1) {
ctx.strokeStyle = outlineColor;
ctx.lineWidth = lineWidth + 2.5;
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.stroke();
}
ctx.lineWidth = lineWidth;
if (s.segmentColor) {
// Colour each segment by its value (temperature colour scale).
// `idx[i]` maps a run point back to its source data index.
for (let i = 1; i < points.length; i++) {
const v = s.data[points[i][3]];
ctx.strokeStyle = s.segmentColor(v as number, points[i][3]);
ctx.beginPath();
ctx.moveTo(points[i - 1][0], points[i - 1][1]);
ctx.lineTo(points[i][0], points[i][1]);
ctx.stroke();
}
} else {
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.stroke();
}
ctx.setLineDash([]);
}
}
// Local minima / maxima value labels
if (s.labelExtrema) {
const fmt = s.labelFormat ?? ((v: number) => v.toFixed(0));
ctx.font = 'bold 11px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.lineWidth = 3;
ctx.strokeStyle = bgColor;
ctx.fillStyle = strongColor;
// clear the (possibly thick) line + its outline before the text sits
const off = (s.width ?? 2) / 2 + 7;
for (const ext of findExtrema(s.data)) {
const t = timestamps[ext.i];
if (t < viewStart || t > viewEnd) continue;
const v = s.data[ext.i] as number;
const x = xPix(t);
const y = yPix(v, axis);
const label = fmt(v);
const ly = ext.type === 'max' ? y - off : y + off + 8;
// keep the centred label fully inside the plot so it never clips
const halfW = ctx.measureText(label).width / 2 + 2;
const lx = Math.max(padLeft + halfW, Math.min(plotRight - halfW, x));
ctx.strokeText(label, lx, ly);
ctx.fillText(label, lx, ly);
}
}
}
// 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, padLeft - 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;
// Touch gesture intent. On touch we defer pointer capture until we know
// the finger is moving horizontally; a vertical drag is left to the page
// so the meteograms don't hijack scrolling (and don't flash the tooltip).
let gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' | 'select' = 'none';
let touchStart: { x: number; y: number; start: number; end: number } | null = null;
let selStartX = 0; // drag-to-zoom anchor (canvas-local px)
const localX = (e: { clientX: number }): number => e.clientX - el.getBoundingClientRect().left;
const clampPlotX = (x: number): number => Math.max(padLeft, Math.min(width - padRight, x));
const updateHover = (e: PointerEvent): void => {
const t = pixToTime(localX(e));
setHover(t >= viewStart && t <= viewEnd ? t : null);
};
const onPointerDown = (e: PointerEvent): void => {
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 2) {
el.setPointerCapture(e.pointerId);
const [a, b] = [...pointers.values()];
pinchStart = { dist: Math.max(10, Math.abs(a.x - b.x)), start: viewStart, end: viewEnd };
panStart = null;
gesture = 'pinch';
setHover(null);
return;
}
if (e.pointerType === 'mouse') {
// Mouse: click-drag selects a range to zoom into.
el.setPointerCapture(e.pointerId);
selStartX = clampPlotX(localX(e));
dragSelect = null;
panStart = null;
pinchStart = null;
gesture = 'select';
} else {
// Touch: wait for the first move to reveal scroll vs inspect intent.
touchStart = { x: e.clientX, y: e.clientY, start: viewStart, end: viewEnd };
gesture = 'none';
}
};
const onPointerMove = (e: PointerEvent): void => {
if (pointers.has(e.pointerId)) {
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
}
if (gesture === 'pinch' && 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;
}
// Mouse drag-to-zoom: track the selection rectangle
if (gesture === 'select' && e.pointerType === 'mouse' && pointers.size === 1) {
const x = clampPlotX(localX(e));
if (dragSelect || Math.abs(x - selStartX) >= 3) {
dragSelect = { x0: selStartX, x1: x };
setHover(null);
}
return;
}
// Resolve touch intent from the initial drag direction
if (e.pointerType !== 'mouse' && gesture === 'none' && touchStart && pointers.size === 1) {
const dx = Math.abs(e.clientX - touchStart.x);
const dy = Math.abs(e.clientY - touchStart.y);
if (dx < 6 && dy < 6) return;
if (dy > dx) {
// Vertical: let the page scroll, never capture or hover
gesture = 'scroll';
return;
}
gesture = zoomed ? 'pan' : 'inspect';
el.setPointerCapture(e.pointerId);
if (gesture === 'pan') {
panStart = { x: e.clientX, start: touchStart.start, end: touchStart.end };
}
}
if (gesture === 'scroll') return;
if (gesture === 'pan' && panStart && pointers.size === 1 && zoomed) {
const dt = ((panStart.x - e.clientX) / plotW) * (panStart.end - panStart.start);
applyRange(panStart.start + dt, panStart.end + dt);
return;
}
if (pointers.size <= 1) updateHover(e);
};
const onPointerUp = (e: PointerEvent): void => {
// Commit a mouse drag-to-zoom selection (if it spans a real range).
if (gesture === 'select' && e.pointerType === 'mouse' && dragSelect) {
const a = pixToTime(dragSelect.x0);
const b = pixToTime(dragSelect.x1);
if (Math.abs(a - b) > 0) applyRange(Math.min(a, b), Math.max(a, b));
}
dragSelect = null;
pointers.delete(e.pointerId);
if (pointers.size < 2) pinchStart = null;
if (pointers.size < 1) {
panStart = null;
touchStart = null;
gesture = 'none';
}
if (e.pointerType !== 'mouse') setHover(null);
};
const onPointerLeave = (): void => {
if (pointers.size === 0) setHover(null);
};
const onWheel = (e: WheelEvent): void => {
// Zoom only while Ctrl (or ⌘) is held — a plain scroll should keep
// scrolling the page. Trackpad pinch also arrives as ctrlKey wheel.
if (!e.ctrlKey && !e.metaKey) {
return;
}
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}">
<div class="relative">
<canvas
bind:this={canvasEl}
class="block w-full"
style:height="{height}px"
style:touch-action="pan-y"
></canvas>
<!-- Drag-to-zoom selection rectangle -->
{#if dragSelect}
<div
class="pointer-events-none absolute z-20 border-x-2 border-primary/70 bg-primary/15"
style:left="{Math.min(dragSelect.x0, dragSelect.x1)}px"
style:top="{padTop}px"
style:width="{Math.abs(dragSelect.x1 - dragSelect.x0)}px"
style:height="{plotH}px"
></div>
{/if}
<!-- Weather pictograms: a bordered band across the top of the plot -->
{#if visiblePictograms.length > 0}
<div
class="pointer-events-none absolute z-10 overflow-hidden rounded-lg border border-border/60 bg-muted/30"
style:left="{iconBandLeft}px"
style:top="{pictoRowTop}px"
style:width="{iconBandWidth}px"
style:height="{ICON_BAND_H}px"
>
{#each visiblePictograms as p (p.x)}
<svg
class="absolute top-px fill-foreground"
width={ICON_PX}
height={ICON_PX}
style:left="{Math.max(2, Math.min(iconBandWidth - ICON_PX - 2, p.x - ICON_PX / 2))}px"
>
<use xlink:href="/images/weather-icons/{p.icon}.svg#Layer_1"></use>
</svg>
{/each}
</div>
{/if}
<!-- Wind-direction arrows: a matching band -->
{#if visibleWindArrows.length > 0}
<div
class="pointer-events-none absolute z-10 overflow-hidden rounded-lg border border-border/60 bg-muted/30"
style:left="{iconBandLeft}px"
style:top="{windRowTop}px"
style:width="{iconBandWidth}px"
style:height="{ICON_BAND_H}px"
>
{#each visibleWindArrows as a (a.x)}
<span
class="absolute top-px inline-flex items-center justify-center"
style:width="{ICON_PX}px"
style:height="{ICON_PX}px"
style:left="{Math.max(2, Math.min(iconBandWidth - ICON_PX - 2, a.x - ICON_PX / 2))}px"
style:transform="rotate({a.deg}deg)"
>
<svg class="fill-foreground/80" width="22" height="22">
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
</svg>
</span>
{/each}
</div>
{/if}
{#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>
<!-- Legend sits below the graph -->
{#if showLegend && series.length > 0}
<div class="mt-1.5 flex flex-wrap items-center justify-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"
>{width > 0 && width < 520 ? (s.shortName ?? s.name) : s.name}</span
>
</button>
{/each}
</div>
{/if}
</div>