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

1621 lines
57 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;
/** Area fill coloured by the value scale (segmentColor), fading to
* transparent ~20px below the series minimum. */
gradientFill?: 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 the line itself in the theme foreground (black/white), ignoring
* segmentColor for the stroke (segmentColor still colours any fill) */
foregroundLine?: boolean;
/** 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;
}
/**
* Current shared crosshair time of a group (epoch seconds, or null), reactive.
* Lets outside UI (e.g. the hourly table) mirror the chart's hovered timestep.
*/
export function groupHover(name: string): number | null {
return groups[name]?.hover ?? 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;
/** Minimum breathing room (axis units) above the left-axis data range. */
yPadTop?: number;
/** Minimum breathing room (axis units) below the left-axis data range. */
yPadBottom?: 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,
yPadTop,
yPadBottom,
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 = 40; // reserved height per icon row
const ICON_BAND_H = 38; // visible band height
const ICON_PX = 25; // pictogram size (a touch smaller than the arrows)
const ARROW_PX = 36; // wind-direction arrow size
// edge inset used for BOTH rows so pictograms and arrows clamp to the same
// centre and stay aligned with each other
const ICON_EDGE = ARROW_PX / 2;
// Puffy cloud band: 100% cover hangs 40px from the top of the plot
const CLOUD_BAND_MAX = 40;
/** Return a colour string with the given alpha (handles rgb/rgba/#hex). */
function withAlpha(color: string, alpha: number): string {
const m = color.match(/rgba?\(([^)]+)\)/);
if (m) {
const [r, g, b] = m[1].split(',').map((p) => parseFloat(p));
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
if (color[0] === '#') {
const h = color.slice(1);
const n =
h.length === 3
? h
.split('')
.map((c) => c + c)
.join('')
: h;
const r = parseInt(n.slice(0, 2), 16);
const g = parseInt(n.slice(2, 4), 16);
const b = parseInt(n.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
return color;
}
// ─── 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'));
// Minimal gutters on narrow screens so the plot uses nearly the full width
// (just enough to keep the axis tick labels legible).
let isNarrow = $derived(width > 0 && width < 520);
let padLeft = $derived(isNarrow ? 26 : 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. On
// narrow (mobile) screens we don't reserve it — the graph uses the full width
// and the right-axis labels are drawn overlaid on top instead (see below).
let padRight = $derived(isNarrow ? 6 : 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 —
// but only on wide screens: on mobile that uniform band wastes precious
// vertical space, so each chart reserves just its own rows there.
let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0));
let iconRows = $derived(isNarrow ? ownIconRows : Math.max(ownIconRows, reserveTopRows));
// tighter top/bottom gutters on mobile so charts don't waste vertical space
const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H);
let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 14 : 28) + iconRows * iconRowH);
let padBottom = $derived(isNarrow ? 24 : PAD_BOTTOM);
let plotW = $derived(Math.max(1, width - padLeft - padRight));
let plotH = $derived(Math.max(1, height - padTop - padBottom));
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,
halfStepBounds = false
): Scale {
const step = niceNum(niceNum(Math.max(hi - lo, 1e-9), false) / 4, true);
// Padded axes (e.g. temperature) may end on HALF steps — 5° when ticks
// are every 10° — so the requested margin isn't inflated to a whole step.
// Tick drawing starts at the first full-step multiple, so a half-step
// bound gets no label or gridline of its own.
const snap = halfStepBounds ? step / 2 : step;
const min = loFixed ? lo : Math.floor(lo / snap) * snap;
const max = hiFixed ? hi : Math.ceil(hi / snap) * snap;
return { min, max: max > min ? max : min + step, step };
}
/** First tick at or above the scale minimum (bounds may sit on half steps). */
function firstTick(scale: Scale): number {
return Math.ceil((scale.min - 1e-9) / scale.step) * scale.step;
}
let leftScale = $derived.by((): Scale => {
let [dLo, dHi] = dataExtent('left', zeroBaseLeft);
// requested breathing room around the data (e.g. temperature): at least
// the given units, growing with wide ranges so it stays proportionate
const span = dHi - dLo;
const padded = yPadTop != null || yPadBottom != null;
if (yMax === undefined && yPadTop) dHi += Math.max(yPadTop, span * 0.08);
if (yMin === undefined && yPadBottom) dLo -= Math.max(yPadBottom, span * 0.12);
return buildScale(yMin ?? dLo, yMax ?? dHi, yMin !== undefined, yMax !== undefined, padded);
});
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;
}
// ─── Full export (title + icon bands + legend composited onto one canvas) ────
// The canvas alone omits the DOM overlays (weather/wind icons), the panel
// title (rendered by the parent), and the legend (an HTML row). This builds a
// standalone canvas that includes all of them so downloads look like the page.
const iconImageCache = new Map<string, Promise<HTMLImageElement>>();
function loadColoredIcon(name: string, color: string): Promise<HTMLImageElement> {
const key = `${name}|${color}`;
let p = iconImageCache.get(key);
if (!p) {
p = fetch(`/images/weather-icons/${name}.svg`)
.then((r) => r.text())
.then(
(svg) =>
new Promise<HTMLImageElement>((resolve, reject) => {
// the paths carry no fill, so a root fill tints the whole glyph
const colored = svg.replace(/<svg\b/, `<svg fill="${color}"`);
const url = URL.createObjectURL(new Blob([colored], { type: 'image/svg+xml' }));
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = (e) => {
URL.revokeObjectURL(url);
reject(e);
};
img.src = url;
})
);
iconImageCache.set(key, p);
}
return p;
}
/**
* Composite the chart, its icon bands, an optional title, and the legend onto
* a fresh canvas for export. Async because the icon SVGs are rasterized.
*/
export async function getExportImage(opts?: {
title?: string;
}): Promise<HTMLCanvasElement | null> {
if (!canvasEl || !containerEl || width <= 0) return null;
const dpr = window.devicePixelRatio || 1;
const styles = getComputedStyle(containerEl);
const cssVar = (n: string, f: string) => styles.getPropertyValue(n).trim() || f;
const strong = cssVar('--foreground', '#374151');
const muted = cssVar('--muted-foreground', '#6b7280');
const bg = cssVar('--card', '#ffffff');
const title = opts?.title;
const titleH = title ? 30 : 0;
const legendItems = showLegend ? series.filter((s) => s.showInLegend !== false) : [];
const legendH = legendItems.length > 0 ? 28 : 0;
const totalH = titleH + height + legendH;
const out = document.createElement('canvas');
out.width = Math.round(width * dpr);
out.height = Math.round(totalH * dpr);
const ctx = out.getContext('2d');
if (!ctx) return null;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = bg;
ctx.fillRect(0, 0, width, totalH);
if (title) {
ctx.fillStyle = strong;
ctx.font = '600 14px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(title, 4, titleH / 2 + 2);
}
// the plot itself (canvasEl is a dpr-scaled bitmap of the css-sized chart)
ctx.drawImage(canvasEl, 0, titleH, width, height);
// weather pictograms
for (const p of visiblePictograms) {
try {
const img = await loadColoredIcon(p.icon, strong);
const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, p.x));
ctx.drawImage(
img,
iconBandLeft + cx - ICON_PX / 2,
titleH + pictoRowTop + (ICON_BAND_H - ICON_PX) / 2,
ICON_PX,
ICON_PX
);
} catch {
/* skip an icon that failed to rasterize */
}
}
// wind-direction arrows, rotated about their centre
for (const a of visibleWindArrows) {
try {
const img = await loadColoredIcon('wi-direction-down', strong);
const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, a.x));
ctx.save();
ctx.translate(iconBandLeft + cx, titleH + windRowTop + ICON_BAND_H / 2);
ctx.rotate((a.deg * Math.PI) / 180);
ctx.globalAlpha = 0.8;
ctx.drawImage(img, -ARROW_PX / 2, -ARROW_PX / 2, ARROW_PX, ARROW_PX);
ctx.restore();
} catch {
/* skip */
}
}
// legend row, centred like the on-screen one
if (legendItems.length > 0) {
ctx.font = '12px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const gap = 12;
const dot = 9;
const dotGap = 6;
const widths = legendItems.map((s) => dot + dotGap + ctx.measureText(s.name).width);
const totalW = widths.reduce((a, b) => a + b, 0) + gap * (legendItems.length - 1);
let x = Math.max(4, (width - totalW) / 2);
const y = titleH + height + legendH / 2;
for (let i = 0; i < legendItems.length; i++) {
const s = legendItems[i];
ctx.fillStyle = s.color;
ctx.beginPath();
ctx.arc(x + dot / 2, y, dot / 2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = muted;
ctx.fillText(s.name, x + dot + dotGap, y);
x += widths[i] + gap;
}
}
// credit hugging the bottom-right corner of the export
if (showCredit) {
ctx.font = '10px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = muted;
ctx.globalAlpha = 0.8;
ctx.fillText('Weather data by Open-Meteo · visualisation by Drizz.li', width - 6, totalH - 5);
ctx.globalAlpha = 1;
}
return out;
}
/** 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);
// keep clear of the band edges so the first/last never bunch or clip
if (x < ICON_EDGE || x > iconBandWidth - ICON_EDGE) continue;
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 < ICON_EDGE || x > iconBandWidth - ICON_EDGE) continue;
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) * iconRowH + 2);
let windRowTop = $derived(padTop - iconRowH + 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;
}
}
// A "EEE d" day label needs ~46px of room; when days are packed tighter
// (long ranges on a narrow screen) keep every day's gridline but only
// label every Nth one so the dates never overlap.
const pxPerDay = 24 * pxPerHour;
const dayStride = pxPerDay >= 46 ? 1 : Math.max(1, Math.ceil(46 / pxPerDay));
const ticks: XTick[] = [];
const first = Math.ceil(viewStart / HOUR) * HOUR;
let dayCount = 0;
for (let t = first; t <= viewEnd; t += HOUR) {
const date = new Date(t * 1000);
const hour = getZonedHour(date, timezone);
if (hour === 0) {
const showLabel = dayCount % dayStride === 0;
dayCount++;
ticks.push({
t,
label: showLabel ? 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');
// halo matches the background (white in light mode, dark in dark mode) so a
// line reads clearly where it crosses others
const outlineColor = bgColor;
const plotRight = padLeft + plotW;
const plotBottom = padTop + plotH;
const font = '11px system-ui, sans-serif';
// Daylight bands (kept subtle so they don't compete with the data)
ctx.fillStyle = CHART_COLORS.daylight;
ctx.globalAlpha = 0.65;
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);
}
ctx.globalAlpha = 1;
// 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 = firstTick(leftScale);
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 in the gutter (desktop only). On mobile there is no
// gutter — the labels are drawn on top of the graph with halos after the
// series (see drawOverlaidAxisLabels below), so the data can't cover them.
if (hasRightAxis && unitRight !== undefined && !isNarrow) {
ctx.textAlign = 'left';
ctx.fillStyle = textColor;
for (
let v = firstTick(rightScale);
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.gradientFill && points.length > 1) {
// Coloured fill anchored at the curve, fading out towards zero: down
// for positive values, up for all-negative values. Full opacity near
// the far-from-zero extreme, fading ~30px past the near-zero extreme.
let minV = Infinity;
let maxV = -Infinity;
for (const p of points) {
const val = s.data[p[3]] as number;
if (val < minV) minV = val;
if (val > maxV) maxV = val;
}
const goUp = maxV <= 0; // all non-positive → fill toward zero (upward)
const maxYp = yPix(maxV, axis);
const minYp = yPix(minV, axis);
// anchor = opaque end (far-from-zero extreme); fade = transparent end
const anchorY = goUp ? minYp : maxYp;
const nearY = goUp ? maxYp : minYp;
// fade runs a good stretch past the near extreme, flowing most of the
// way to the plot edge (~78% of the way there)
const fadeY = goUp
? Math.max(padTop, maxYp - (maxYp - padTop) * 0.78)
: Math.min(plotBottom, minYp + (plotBottom - minYp) * 0.78);
const anchorV = goUp ? minV : maxV;
const nearV = goUp ? maxV : minV;
const span = fadeY - anchorY;
// begin the fade a touch (~10px) BEFORE the near-zero extreme
const fadeStartY = nearY - Math.sign(nearY - anchorY) * 10;
const fadeStart =
span !== 0 ? Math.max(0, Math.min(0.95, (fadeStartY - anchorY) / span)) : 0.6;
const colorFn = s.segmentColor ?? (() => s.color);
const FULL = 0.8;
const grad = ctx.createLinearGradient(0, anchorY, 0, fadeY);
// full colour from the curve down to just before the near extreme…
const STOPS = 6;
for (let k = 0; k <= STOPS; k++) {
const t = k / STOPS;
const val = anchorV + t * (nearV - anchorV);
grad.addColorStop(t * fadeStart, withAlpha(colorFn(val, 0), FULL));
}
// …then a long, gentle fade out to the plot edge
grad.addColorStop(fadeStart, withAlpha(colorFn(nearV, 0), FULL));
grad.addColorStop(1, withAlpha(colorFn(nearV, 0), 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.lineTo(points[points.length - 1][0], fadeY);
ctx.lineTo(points[0][0], fadeY);
ctx.closePath();
ctx.fillStyle = grad;
// the bright temperature colours glow over a dark background, so
// knock the whole fill back to 75% in dark mode
ctx.globalAlpha = dark ? 0.75 : 1;
ctx.fill();
ctx.globalAlpha = 1;
}
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 ? [8, 8] : []);
// 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 && !s.foregroundLine) {
// 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 if (s.foregroundLine) {
// Foreground line drawn on top of its own fill. Stroke the whole
// path once with a horizontal gradient sampled from the value
// colour scale, so the colour flows smoothly along the line
// instead of stepping at each data point.
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.segmentColor && points.length > 1) {
const x0 = points[0][0];
const x1 = points[points.length - 1][0];
const span = x1 - x0 || 1;
const grad = ctx.createLinearGradient(x0, 0, x1, 0);
let prevT = -1;
for (let i = 0; i < points.length; i++) {
let t = (points[i][0] - x0) / span;
t = t < 0 ? 0 : t > 1 ? 1 : t;
if (t <= prevT) t = prevT + 1e-6; // keep stops strictly increasing
if (t > 1) t = 1;
prevT = t;
// exact same colour as the fill so the line and gradient match
grad.addColorStop(t, s.segmentColor(s.data[points[i][3]] as number, points[i][3]));
}
ctx.strokeStyle = grad;
} else {
ctx.strokeStyle = strongColor;
}
// keep the line crisp (full opacity) as a clean edge; only the
// large fill area is dimmed in dark mode
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);
// keep the label inside the plot vertically (never under the icon
// band above or clipped at the bottom)
const ly = Math.max(
padTop + 11,
Math.min(plotBottom - 3, 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);
}
}
}
// Mobile: right-axis labels overlaid on top of the data with a halo (no
// gutter is reserved on narrow screens, so the graph runs full width).
if (isNarrow && hasRightAxis && unitRight !== undefined) {
ctx.font = font;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
for (
let v = firstTick(rightScale);
v <= rightScale.max + rightScale.step / 2;
v += rightScale.step
) {
const y = yPix(v, 'right');
const label = v.toFixed(tickDecimals(rightScale.step));
ctx.strokeStyle = bgColor;
ctx.strokeText(label, plotRight - 2, y);
ctx.fillStyle = textColor;
ctx.fillText(label, plotRight - 2, y);
}
}
// 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) {
if (isNarrow) {
// overlaid inside the plot's top-right corner (no right gutter) with a halo
ctx.textAlign = 'right';
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
ctx.strokeStyle = bgColor;
ctx.strokeText(unitRight, plotRight - 2, padTop - 8);
ctx.fillStyle = textColor;
ctx.fillText(unitRight, plotRight - 2, padTop - 8);
} else {
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 is a DOM overlay (below) so its two sources can be links; the
// export path redraws it onto the exported canvas in getExportImage().
}
$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-t-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)}
{@const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, p.x))}
<svg
class="absolute top-1/2 -translate-y-1/2 fill-foreground"
width={ICON_PX}
height={ICON_PX}
style:left="{cx - 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-t-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)}
{@const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, a.x))}
<span
class="absolute top-1/2 inline-flex items-center justify-center"
style:width="{ARROW_PX}px"
style:height="{ARROW_PX}px"
style:left="{cx - ARROW_PX / 2}px"
style:transform="translateY(-50%) rotate({a.deg}deg)"
>
<svg class="fill-foreground/80" width={ARROW_PX} height={ARROW_PX}>
<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-0.5 flex flex-wrap items-center justify-center gap-x-3 gap-y-0.5 px-1 md:mt-1.5 md:gap-y-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}
<!-- Credit: below the legend, in the bottom-right corner, with linked sources -->
{#if showCredit}
<div
class="px-2 pt-2 pb-1 text-center md:text-right text-[10px] leading-none text-muted-foreground/70"
>
Weather data by <a
class="font-medium underline-offset-2 hover:text-foreground hover:underline"
href="https://open-meteo.com"
target="_blank"
rel="noopener noreferrer">Open-Meteo</a
>, visualisation by
<a
class="font-medium underline-offset-2 hover:text-foreground hover:underline"
href="https://drizz.li"
target="_blank"
rel="noopener noreferrer">Drizz.li</a
>
</div>
{/if}
</div>