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
2026-08-02 09:03:35 +02:00

1814 lines
64 KiB
Svelte

<!--
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">
import * as m from '$lib/paraglide/messages';
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 soft cloud band instead of a line (see `cloudLayer`) */
cloudBand?: boolean;
/**
* Which slot a cloud band occupies. Omitted (total cover) hangs from the
* top of the plot; the named layers stack in their real vertical order,
* each in its own slot, so a layered chart reads like the sky itself.
*/
cloudLayer?: 'high' | 'mid' | 'low';
}
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 { useNow } from '$lib/utils/now.svelte';
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();
// Minute-resolution clock shared with the rest of the app; read in draw() so
// the current-time marker stays put as time passes.
const clock = useNow();
// ─── 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;
// Total cloud cover: 100% reaches this far down from the top of the plot.
const CLOUD_BAND_MAX = 48;
// Layered cover (high / mid / low): each layer owns a slot of this height and
// grows symmetrically out of the centre line of that slot.
const CLOUD_LAYER_H = 42;
const CLOUD_LAYER_GAP = 6;
const CLOUD_LAYER_ORDER = ['high', 'mid', 'low'] as const;
/** 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;
}
/**
* Light [1 2 1] smoothing pass. Cloud cover is noisy hour to hour; smoothing
* the values before interpolating gives the band a slow, rolling silhouette
* instead of one that tracks every single sample.
*/
function smoothCover(values: number[], passes = 2): number[] {
let out = values;
for (let p = 0; p < passes; p++) {
const next = new Array<number>(out.length);
for (let i = 0; i < out.length; i++) {
const prev = out[i - 1] ?? out[i];
const nxt = out[i + 1] ?? out[i];
next[i] = (prev + 2 * out[i] + nxt) / 4;
}
out = next;
}
return out;
}
/** Vertical offset of a cloud series' slot from the top of the plot. */
function cloudSlotOffset(s: ChartSeries): number {
if (!s.cloudLayer) return 0;
return CLOUD_LAYER_ORDER.indexOf(s.cloudLayer) * (CLOUD_LAYER_H + CLOUD_LAYER_GAP);
}
/**
* Traces a monotone cubic (Fritsch-Carlson) curve through the points. Unlike a
* plain Catmull-Rom spline it never overshoots the data, so a cloud band can't
* bulge past 0% or 100% between two samples.
*/
function traceMonotone(
ctx: CanvasRenderingContext2D,
pts: Array<[number, number]>,
continuePath = false
): void {
const n = pts.length;
if (!continuePath) ctx.moveTo(pts[0][0], pts[0][1]);
if (n === 2) {
ctx.lineTo(pts[1][0], pts[1][1]);
return;
}
// secant slopes, then tangents averaged from the neighbouring secants
const slope: number[] = [];
for (let i = 0; i < n - 1; i++) {
const dx = pts[i + 1][0] - pts[i][0];
slope.push(dx === 0 ? 0 : (pts[i + 1][1] - pts[i][1]) / dx);
}
const m: number[] = [slope[0]];
for (let i = 1; i < n - 1; i++) m.push((slope[i - 1] + slope[i]) / 2);
m.push(slope[n - 2]);
// clamp the tangents back onto the monotone circle of radius 3
for (let i = 0; i < n - 1; i++) {
if (slope[i] === 0) {
m[i] = 0;
m[i + 1] = 0;
continue;
}
const a = m[i] / slope[i];
const b = m[i + 1] / slope[i];
const h = a * a + b * b;
if (h > 9) {
const t = 3 / Math.sqrt(h);
m[i] = t * a * slope[i];
m[i + 1] = t * b * slope[i];
}
}
for (let i = 0; i < n - 1; i++) {
const [x0, y0] = pts[i];
const [x1, y1] = pts[i + 1];
const dx = (x1 - x0) / 3;
ctx.bezierCurveTo(x0 + dx, y0 + m[i] * dx, x1 - dx, y1 - m[i + 1] * dx, x1, y1);
}
}
// ─── 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).
// Compact gutters (small axis padding, short icon rows) apply to phones AND
// tablets: below `lg` the page is edge-to-edge, so wide desktop-style axis
// margins would waste most of the width. Desktop keeps its roomier metrics.
let belowDesktop = $state(false);
onMount(() => {
const mq = window.matchMedia('(max-width: 1023px)');
const apply = () => (belowDesktop = mq.matches);
apply();
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
});
let isNarrow = $derived(belowDesktop || (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(
`${m.footer_data_by()} Open-Meteo · ${m.chart_credit_viz()} 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;
// Cloud bands: cover drives how far the band reaches into its slot, traced
// as a monotone-interpolated curve so the silhouette flows instead of
// stepping hour to hour. Both gradients run from near-transparent at the
// anchor to solid at full cover, so a 10% sky barely registers while an
// overcast one is unmistakable.
//
// * total cover hangs from the top of the plot
// * high / mid / low each grow symmetrically out of their slot's centre
// line, stacked in the order the layers actually sit in the sky
for (const s of cloudBandSeries) {
const slotTop = padTop + cloudSlotOffset(s);
const layered = s.cloudLayer != null;
const slotH = layered ? CLOUD_LAYER_H : CLOUD_BAND_MAX;
const centre = slotTop + slotH / 2;
// Samples just outside the viewport are kept so the curve enters and
// leaves the plot at the right slope.
const xs: number[] = [];
const covers: number[] = [];
for (let i = 0; i < timestamps.length; i++) {
const t = timestamps[i];
if (t < viewStart - interval * 2 || t > viewEnd + interval * 2) continue;
const v = s.data[i];
xs.push(xPix(t));
covers.push(
v === null || v === undefined || !isFinite(v) ? 0 : Math.min(100, Math.max(0, v))
);
}
if (xs.length < 2) continue;
const smoothed = smoothCover(covers);
const top: Array<[number, number]> = [];
const bottom: Array<[number, number]> = [];
for (let i = 0; i < xs.length; i++) {
const frac = smoothed[i] / 100;
if (layered) {
const half = frac * (slotH / 2);
top.push([xs[i], centre - half]);
bottom.push([xs[i], centre + half]);
} else {
top.push([xs[i], slotTop]);
bottom.push([xs[i], slotTop + frac * slotH]);
}
}
// The gradient runs ALONG the series rather than top-to-bottom: every
// sample contributes a stop at its own x, so the band's density tracks
// the cover itself - a clear spell dissolves, an overcast one goes
// solid - and the fade always lines up with the silhouette above it.
const first = xs[0];
const span = xs[xs.length - 1] - first || 1;
const base = layered ? 0.08 : 0.05;
const peak = layered ? 0.92 : 0.68;
// Total cover ramps up late: a broken sky stays nearly clear on the
// plot and the density only really builds as it closes over.
const curve = layered ? 1 : 2.2;
const fill = ctx.createLinearGradient(first, 0, xs[xs.length - 1], 0);
// The edge follows the same values but starts later still: below a
// fifth of the sky there is no silhouette to draw at all.
const EDGE_FLOOR = 20;
const edge = layered ? null : ctx.createLinearGradient(first, 0, xs[xs.length - 1], 0);
for (let i = 0; i < xs.length; i++) {
const pos = Math.max(0, Math.min(1, (xs[i] - first) / span));
const cover = smoothed[i];
fill.addColorStop(
pos,
withAlpha(s.color, base + (peak - base) * Math.pow(cover / 100, curve))
);
if (edge) {
const above = Math.max(0, (cover - EDGE_FLOOR) / (100 - EDGE_FLOOR));
edge.addColorStop(pos, withAlpha(s.color, 0.85 * Math.pow(above, 1.6)));
}
}
ctx.save();
ctx.beginPath();
traceMonotone(ctx, bottom);
if (layered) {
// back along the mirrored upper edge
ctx.lineTo(top[top.length - 1][0], top[top.length - 1][1]);
traceMonotone(ctx, [...top].reverse(), true);
} else {
ctx.lineTo(bottom[bottom.length - 1][0], slotTop);
ctx.lineTo(bottom[0][0], slotTop);
}
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill();
// The layers are gradient only - an outline would fight the soft mass
// they are meant to look like.
if (edge) {
ctx.beginPath();
traceMonotone(ctx, bottom);
ctx.strokeStyle = edge;
ctx.lineWidth = 1.25;
ctx.lineJoin = 'round';
ctx.stroke();
}
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. Reading the shared clock (rather than Date.now())
// makes the enclosing draw effect re-run every minute, so the line moves.
if (showNow) {
const now = clock.current.getTime() / 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={m.chart_toggle_series({ series: 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"
>
{m.footer_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
>, {m.chart_credit_viz()}
<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>