1228 lines
42 KiB
Svelte
1228 lines
42 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">
|
||
export interface ChartSeries {
|
||
/** Series display name (used in legend and tooltip) */
|
||
name: string;
|
||
/** Render style */
|
||
type: 'line' | 'bar';
|
||
/** Any CSS color string */
|
||
color: string;
|
||
/** One value per timestamp; null values break lines */
|
||
data: (number | null)[];
|
||
/** Line width in px (default 2); 0 draws only the area fill */
|
||
width?: number;
|
||
/** Draw a low-alpha area fill below (or above, on inverted axes) the line */
|
||
fill?: boolean;
|
||
/** With `fill`, fill the area between this line and another data array
|
||
* instead of the baseline (e.g. an ensemble min-max band) */
|
||
bandTo?: (number | null)[];
|
||
/** Opacity of the area fill (default 0.15) */
|
||
fillOpacity?: number;
|
||
/** Draw the line dashed */
|
||
dashed?: boolean;
|
||
/** Hide the series entirely (not rendered, not listed in the tooltip) */
|
||
hidden?: boolean;
|
||
/** Which y axis the series is scaled against (default 'left') */
|
||
axis?: 'left' | 'right';
|
||
/** Include the series in the legend row (default true) */
|
||
showInLegend?: boolean;
|
||
/** Custom tooltip value formatter */
|
||
format?: (value: number, index: number) => string;
|
||
/** Short name used in the tooltip / legend when the full name is long */
|
||
shortName?: string;
|
||
/** Colour each line segment by value (e.g. a temperature colour scale) */
|
||
segmentColor?: (value: number, index: number) => string;
|
||
/** Draw a contrasting halo (black in light mode, white in dark) under the line */
|
||
outline?: boolean;
|
||
/** Annotate local minima / maxima with their value */
|
||
labelExtrema?: boolean;
|
||
/** Formatter for extrema labels (defaults to the tooltip format) */
|
||
labelFormat?: (value: number) => string;
|
||
/** Render as a puffy cloud band hanging from the top instead of a line */
|
||
cloudBand?: boolean;
|
||
}
|
||
|
||
interface GroupState {
|
||
range: { start: number; end: number } | null;
|
||
hover: number | null;
|
||
count: number;
|
||
}
|
||
|
||
// Module-level registry: charts sharing a `group` name share zoom range and
|
||
// crosshair position through one reactive state object.
|
||
const groups: Record<string, GroupState> = $state({});
|
||
|
||
function acquireGroup(name: string): GroupState {
|
||
if (!groups[name]) {
|
||
groups[name] = { range: null, hover: null, count: 0 };
|
||
}
|
||
groups[name].count++;
|
||
return groups[name];
|
||
}
|
||
|
||
function releaseGroup(name: string): void {
|
||
const state = groups[name];
|
||
if (!state) return;
|
||
state.count--;
|
||
if (state.count <= 0) {
|
||
delete groups[name];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Drive the shared crosshair of a chart group from the outside (e.g. hovering
|
||
* the hourly table). `time` is epoch seconds, or null to clear. No-op if no
|
||
* chart in that group is currently mounted.
|
||
*/
|
||
export function setGroupHover(name: string, time: number | null): void {
|
||
const state = groups[name];
|
||
if (state) state.hover = time;
|
||
}
|
||
|
||
/** Current shared zoom range of a group (null = full range), reactive. */
|
||
export function groupRange(name: string): { start: number; end: number } | null {
|
||
return groups[name]?.range ?? null;
|
||
}
|
||
</script>
|
||
|
||
<script lang="ts">
|
||
import { onDestroy, onMount, untrack } from 'svelte';
|
||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||
|
||
import { formatZoned, getZonedHour } from '$lib/utils/date';
|
||
|
||
import { CHART_COLORS } from './data';
|
||
|
||
// ─── Props ──────────────────────────────────────────────────────────────────
|
||
|
||
interface Props {
|
||
/** Time axis values in epoch seconds (shared by all series) */
|
||
timestamps: number[];
|
||
/** IANA timezone used for all time labels */
|
||
timezone: string;
|
||
/** Series to render */
|
||
series: ChartSeries[];
|
||
/** Background bands (epoch seconds), e.g. daylight */
|
||
bands?: { start: number; end: number }[];
|
||
/** Weather pictograms drawn across the top (t in epoch seconds) */
|
||
pictograms?: { t: number; icon: string }[];
|
||
/** Wind-direction arrows drawn across the top (t in epoch seconds, deg from N) */
|
||
windArrows?: { t: number; deg: number }[];
|
||
/** Highlighted time range (epoch seconds), e.g. the selected day */
|
||
highlight?: { start: number; end: number };
|
||
/** Unit label for the left y axis (also used in tooltip values) */
|
||
unit?: string;
|
||
/** Unit label for the right y axis; when set, right-axis labels are drawn */
|
||
unitRight?: string;
|
||
/** Canvas height in px */
|
||
height?: number;
|
||
/** Charts sharing a group share x-zoom range and crosshair */
|
||
group?: string;
|
||
/** Fixed left-axis minimum (otherwise derived from data, including 0) */
|
||
yMin?: number;
|
||
/** Fixed left-axis maximum */
|
||
yMax?: number;
|
||
/** Force the derived left axis to include zero (default true) */
|
||
zeroBaseLeft?: boolean;
|
||
/** Fixed right-axis minimum (default 0) */
|
||
yMinRight?: number;
|
||
/** Fixed right-axis maximum (default 100) */
|
||
yMaxRight?: number;
|
||
/** Invert the right axis (min at the top) */
|
||
invertRight?: boolean;
|
||
/** Reserve the right-axis gutter even without a right axis (keeps a row of
|
||
* stacked charts identically sized) */
|
||
reserveRightAxis?: boolean;
|
||
/** Reserve this many top icon-rows even if this chart has fewer, so a row
|
||
* of stacked charts share the same plot rectangle */
|
||
reserveTopRows?: number;
|
||
/** Chart title drawn top-left on the canvas */
|
||
title?: string;
|
||
/** Smaller subtitle drawn under the title */
|
||
subtitle?: string;
|
||
/** Show the DOM legend row above the canvas */
|
||
showLegend?: boolean;
|
||
/** Draw a red vertical line at the current time */
|
||
showNow?: boolean;
|
||
/** Draw the Open-Meteo.com credit bottom-right */
|
||
showCredit?: boolean;
|
||
/** Optional CSS class for the outer container */
|
||
class?: string;
|
||
}
|
||
|
||
let {
|
||
timestamps,
|
||
timezone,
|
||
series,
|
||
bands = [],
|
||
pictograms = [],
|
||
windArrows = [],
|
||
highlight,
|
||
unit = '',
|
||
unitRight,
|
||
height = 300,
|
||
group,
|
||
yMin,
|
||
yMax,
|
||
zeroBaseLeft = true,
|
||
yMinRight,
|
||
yMaxRight,
|
||
invertRight = false,
|
||
reserveRightAxis = false,
|
||
reserveTopRows = 0,
|
||
title,
|
||
subtitle,
|
||
showLegend = false,
|
||
showNow = true,
|
||
showCredit = false,
|
||
class: className = ''
|
||
}: Props = $props();
|
||
|
||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||
|
||
const PAD_BOTTOM = 34;
|
||
const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours
|
||
const HOUR = 3600;
|
||
// Top icon rows (weather pictograms / wind arrows)
|
||
const ICON_ROW_H = 30; // reserved height per icon row
|
||
const ICON_BAND_H = 28; // visible band height
|
||
const ICON_PX = 26; // pictogram size
|
||
// Puffy cloud band: 100% cover hangs 40px from the top of the plot
|
||
const CLOUD_BAND_MAX = 40;
|
||
|
||
// ─── State ──────────────────────────────────────────────────────────────────
|
||
|
||
let containerEl: HTMLDivElement | undefined = $state();
|
||
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||
let width = $state(0);
|
||
let themeVersion = $state(0);
|
||
const legendHidden = new SvelteSet<string>();
|
||
|
||
// Drag-to-zoom selection rectangle (plot-local pixel x), null when inactive.
|
||
let dragSelect = $state<{ x0: number; x1: number } | null>(null);
|
||
|
||
// Zoom range and crosshair: either group-shared or local to this chart.
|
||
// The group is acquired once at component init (the prop is treated as fixed).
|
||
const groupName = untrack(() => group);
|
||
const groupState: GroupState | null = groupName ? acquireGroup(groupName) : null;
|
||
let localRange = $state<{ start: number; end: number } | null>(null);
|
||
let localHover = $state<number | null>(null);
|
||
|
||
onDestroy(() => {
|
||
if (groupName) releaseGroup(groupName);
|
||
});
|
||
|
||
// ─── Derived: view window & scales ──────────────────────────────────────────
|
||
|
||
let viewRange = $derived(groupState ? groupState.range : localRange);
|
||
let hoverTime = $derived(groupState ? groupState.hover : localHover);
|
||
|
||
let tMin = $derived(timestamps.length > 0 ? timestamps[0] : 0);
|
||
let tMax = $derived(timestamps.length > 1 ? timestamps[timestamps.length - 1] : tMin + HOUR);
|
||
let viewStart = $derived(viewRange ? Math.max(tMin, viewRange.start) : tMin);
|
||
let viewEnd = $derived(
|
||
viewRange ? Math.max(viewStart + MIN_SPAN / 2, Math.min(tMax, viewRange.end)) : tMax
|
||
);
|
||
let zoomed = $derived(viewRange !== null && viewEnd - viewStart < tMax - tMin);
|
||
|
||
let visibleSeries = $derived(series.filter((s) => !s.hidden && !legendHidden.has(s.name)));
|
||
// Cloud-band series draw a decorative top band and are excluded from the
|
||
// axis scale and from the normal line/bar drawing.
|
||
let plottedSeries = $derived(visibleSeries.filter((s) => !s.cloudBand));
|
||
let cloudBandSeries = $derived(visibleSeries.filter((s) => s.cloudBand));
|
||
let hasRightAxis = $derived(plottedSeries.some((s) => s.axis === 'right'));
|
||
|
||
// Tighter left gutter on narrow screens so axis labels sit near the edge
|
||
let padLeft = $derived(width > 0 && width < 520 ? 38 : 60);
|
||
// Reserve the right gutter when this chart (or a sibling, via reserveRightAxis)
|
||
// has a right axis, so a stacked row of charts share the same plot width.
|
||
let padRight = $derived(hasRightAxis || reserveRightAxis ? 56 : 20);
|
||
// Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps
|
||
// a stacked row of charts the same height even if some have fewer icon rows.
|
||
let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0));
|
||
let iconRows = $derived(Math.max(ownIconRows, reserveTopRows));
|
||
let padTop = $derived((title ? (subtitle ? 66 : 46) : 28) + iconRows * ICON_ROW_H);
|
||
let plotW = $derived(Math.max(1, width - padLeft - padRight));
|
||
let plotH = $derived(Math.max(1, height - padTop - PAD_BOTTOM));
|
||
|
||
interface Scale {
|
||
min: number;
|
||
max: number;
|
||
step: number;
|
||
}
|
||
|
||
function niceNum(range: number, round: boolean): number {
|
||
const exp = Math.floor(Math.log10(range));
|
||
const frac = range / 10 ** exp;
|
||
let nice: number;
|
||
if (round) {
|
||
nice = frac < 1.5 ? 1 : frac < 3 ? 2 : frac < 7 ? 5 : 10;
|
||
} else {
|
||
nice = frac <= 1 ? 1 : frac <= 2 ? 2 : frac <= 5 ? 5 : 10;
|
||
}
|
||
return nice * 10 ** exp;
|
||
}
|
||
|
||
function dataExtent(axis: 'left' | 'right', includeZero = true): [number, number] {
|
||
let lo = Infinity;
|
||
let hi = -Infinity;
|
||
for (const s of plottedSeries) {
|
||
if ((s.axis ?? 'left') !== axis) continue;
|
||
for (const v of s.data) {
|
||
if (v === null || !isFinite(v)) continue;
|
||
if (v < lo) lo = v;
|
||
if (v > hi) hi = v;
|
||
}
|
||
}
|
||
if (!isFinite(lo)) return [0, 1];
|
||
// Match the previous ECharts behavior (value axis without `scale`): always
|
||
// include zero in the axis extent. Skipped for derived secondary axes
|
||
// (e.g. pressure) where zero would flatten the series.
|
||
if (includeZero) {
|
||
lo = Math.min(lo, 0);
|
||
hi = Math.max(hi, 0);
|
||
}
|
||
if (lo === hi) hi = lo + 1;
|
||
return [lo, hi];
|
||
}
|
||
|
||
function buildScale(lo: number, hi: number, loFixed: boolean, hiFixed: boolean): Scale {
|
||
const step = niceNum(niceNum(Math.max(hi - lo, 1e-9), false) / 4, true);
|
||
const min = loFixed ? lo : Math.floor(lo / step) * step;
|
||
const max = hiFixed ? hi : Math.ceil(hi / step) * step;
|
||
return { min, max: max > min ? max : min + step, step };
|
||
}
|
||
|
||
let leftScale = $derived.by((): Scale => {
|
||
const [dLo, dHi] = dataExtent('left', zeroBaseLeft);
|
||
return buildScale(yMin ?? dLo, yMax ?? dHi, yMin !== undefined, yMax !== undefined);
|
||
});
|
||
|
||
let rightScale = $derived.by((): Scale => {
|
||
// Use explicit bounds when given; otherwise derive from the right-axis
|
||
// data so arbitrary variables (pressure, wind, …) can share a panel.
|
||
const loFixed = yMinRight !== undefined;
|
||
const hiFixed = yMaxRight !== undefined;
|
||
if (loFixed && hiFixed) return buildScale(yMinRight!, yMaxRight!, true, true);
|
||
const [dLo, dHi] = dataExtent('right', false);
|
||
return buildScale(yMinRight ?? dLo, yMaxRight ?? dHi, loFixed, hiFixed);
|
||
});
|
||
|
||
function xPix(t: number): number {
|
||
return padLeft + ((t - viewStart) / (viewEnd - viewStart)) * plotW;
|
||
}
|
||
|
||
function pixToTime(x: number): number {
|
||
return viewStart + ((x - padLeft) / plotW) * (viewEnd - viewStart);
|
||
}
|
||
|
||
function yPix(v: number, axis: 'left' | 'right'): number {
|
||
if (axis === 'right') {
|
||
const frac = (v - rightScale.min) / (rightScale.max - rightScale.min);
|
||
return invertRight ? padTop + frac * plotH : padTop + (1 - frac) * plotH;
|
||
}
|
||
const frac = (v - leftScale.min) / (leftScale.max - leftScale.min);
|
||
return padTop + (1 - frac) * plotH;
|
||
}
|
||
|
||
// ─── Zoom / pan helpers ─────────────────────────────────────────────────────
|
||
|
||
function setViewRange(range: { start: number; end: number } | null): void {
|
||
if (groupState) groupState.range = range;
|
||
else localRange = range;
|
||
}
|
||
|
||
function setHover(time: number | null): void {
|
||
if (groupState) groupState.hover = time;
|
||
else localHover = time;
|
||
}
|
||
|
||
function applyRange(start: number, end: number): void {
|
||
const full = tMax - tMin;
|
||
const span = Math.min(Math.max(end - start, MIN_SPAN), full);
|
||
if (span >= full) {
|
||
setViewRange(null);
|
||
return;
|
||
}
|
||
const s = Math.max(tMin, Math.min(start, tMax - span));
|
||
setViewRange({ start: s, end: s + span });
|
||
}
|
||
|
||
function zoomAt(centerTime: number, factor: number): void {
|
||
const span = viewEnd - viewStart;
|
||
const newSpan = span * factor;
|
||
const frac = (centerTime - viewStart) / span;
|
||
applyRange(centerTime - frac * newSpan, centerTime + (1 - frac) * newSpan);
|
||
}
|
||
|
||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||
|
||
/** Returns the chart as a PNG data URL, or null before mount. */
|
||
export function getPngDataUrl(): string | null {
|
||
return canvasEl ? canvasEl.toDataURL('image/png') : null;
|
||
}
|
||
|
||
/** Zooms the x axis to the given epoch-second range (clamped to the data). */
|
||
export function setRange(startEpoch: number, endEpoch: number): void {
|
||
applyRange(startEpoch, endEpoch);
|
||
}
|
||
|
||
/** Resets the x axis to the full data range. */
|
||
export function resetRange(): void {
|
||
setViewRange(null);
|
||
}
|
||
|
||
// ─── Tooltip data ───────────────────────────────────────────────────────────
|
||
|
||
function nearestIndex(arr: number[], t: number): number {
|
||
let lo = 0;
|
||
let hi = arr.length - 1;
|
||
while (lo < hi) {
|
||
const mid = (lo + hi) >> 1;
|
||
if (arr[mid] < t) lo = mid + 1;
|
||
else hi = mid;
|
||
}
|
||
if (lo > 0 && Math.abs(arr[lo - 1] - t) <= Math.abs(arr[lo] - t)) return lo - 1;
|
||
return lo;
|
||
}
|
||
|
||
let hoverIdx = $derived(
|
||
hoverTime === null || timestamps.length === 0 ? -1 : nearestIndex(timestamps, hoverTime)
|
||
);
|
||
|
||
interface TooltipRow {
|
||
name: string;
|
||
color: string;
|
||
value: string;
|
||
}
|
||
|
||
let tooltipRows = $derived.by((): TooltipRow[] => {
|
||
if (hoverIdx < 0) return [];
|
||
const rows: TooltipRow[] = [];
|
||
for (const s of visibleSeries) {
|
||
const v = s.data[hoverIdx];
|
||
if (v === null || v === undefined || !isFinite(v)) continue;
|
||
const axisUnit = (s.axis ?? 'left') === 'right' ? (unitRight ?? '') : unit;
|
||
const value = s.format
|
||
? s.format(v, hoverIdx)
|
||
: `${v.toFixed(1)}${axisUnit ? ' ' + axisUnit : ''}`;
|
||
rows.push({ name: s.shortName ?? s.name, color: s.color, value });
|
||
}
|
||
return rows;
|
||
});
|
||
|
||
let tooltipVisible = $derived(hoverIdx >= 0 && tooltipRows.length > 0 && width > 0);
|
||
let tooltipX = $derived(hoverIdx >= 0 ? xPix(timestamps[hoverIdx]) : 0);
|
||
let tooltipFlip = $derived(tooltipX > width * 0.55);
|
||
|
||
// ─── Pictograms (DOM overlay across the top) ─────────────────────────────────
|
||
|
||
// The icon band spans exactly the plot area so the icons line up with the
|
||
// data (and axis) below and never overhang the plot's cut-off edge.
|
||
let iconBandLeft = $derived(padLeft);
|
||
let iconBandWidth = $derived(plotW);
|
||
function iconBandX(t: number): number {
|
||
return xPix(t) - padLeft;
|
||
}
|
||
|
||
// Thin the icons so they never crowd: keep ≥ 40px apart within the band.
|
||
let visiblePictograms = $derived.by((): { x: number; icon: string }[] => {
|
||
if (pictograms.length === 0 || width <= 0) return [];
|
||
const out: { x: number; icon: string }[] = [];
|
||
let lastX = -Infinity;
|
||
for (const p of pictograms) {
|
||
if (p.t < viewStart || p.t > viewEnd) continue;
|
||
const x = iconBandX(p.t);
|
||
if (x - lastX < 40) continue;
|
||
out.push({ x, icon: p.icon });
|
||
lastX = x;
|
||
}
|
||
return out;
|
||
});
|
||
|
||
// Same thinning for the wind-direction arrow row.
|
||
let visibleWindArrows = $derived.by((): { x: number; deg: number }[] => {
|
||
if (windArrows.length === 0 || width <= 0) return [];
|
||
const out: { x: number; deg: number }[] = [];
|
||
let lastX = -Infinity;
|
||
for (const a of windArrows) {
|
||
if (a.t < viewStart || a.t > viewEnd) continue;
|
||
const x = iconBandX(a.t);
|
||
if (x - lastX < 40) continue;
|
||
out.push({ x, deg: a.deg });
|
||
lastX = x;
|
||
}
|
||
return out;
|
||
});
|
||
|
||
// Vertical offset (px from container top) of each icon row's top edge, anchored
|
||
// just above the plot. When both rows are present, pictograms sit above the
|
||
// wind arrows (which stay closest to the plot).
|
||
let pictoRowTop = $derived(padTop - (windArrows.length > 0 ? 2 : 1) * ICON_ROW_H + 2);
|
||
let windRowTop = $derived(padTop - ICON_ROW_H + 2);
|
||
|
||
// ─── Local minima / maxima (for value labels) ────────────────────────────────
|
||
|
||
function findExtrema(data: (number | null)[]): { i: number; type: 'min' | 'max' }[] {
|
||
const res: { i: number; type: 'min' | 'max' }[] = [];
|
||
const W = 3;
|
||
let lastLabeled = -Infinity;
|
||
for (let i = 0; i < data.length; i++) {
|
||
const v = data[i];
|
||
if (v === null || !isFinite(v)) continue;
|
||
let noGreater = true;
|
||
let noLess = true;
|
||
let someLess = false;
|
||
let someGreater = false;
|
||
for (let j = Math.max(0, i - W); j <= Math.min(data.length - 1, i + W); j++) {
|
||
if (j === i) continue;
|
||
const u = data[j];
|
||
if (u === null || !isFinite(u)) continue;
|
||
if (u > v) {
|
||
noGreater = false;
|
||
someGreater = true;
|
||
} else if (u < v) {
|
||
noLess = false;
|
||
someLess = true;
|
||
}
|
||
}
|
||
const isMax = noGreater && someLess;
|
||
const isMin = noLess && someGreater;
|
||
if ((isMax || isMin) && i - lastLabeled >= W) {
|
||
res.push({ i, type: isMax ? 'max' : 'min' });
|
||
lastLabeled = i;
|
||
}
|
||
}
|
||
return res;
|
||
}
|
||
|
||
// ─── X axis ticks ───────────────────────────────────────────────────────────
|
||
|
||
interface XTick {
|
||
t: number;
|
||
label: string;
|
||
isDay: boolean;
|
||
}
|
||
|
||
function computeXTicks(): XTick[] {
|
||
const spanHours = (viewEnd - viewStart) / HOUR;
|
||
const pxPerHour = plotW / spanHours;
|
||
const steps = [1, 2, 3, 6, 12, 24];
|
||
let step = 24;
|
||
for (const s of steps) {
|
||
if (s * pxPerHour >= 48) {
|
||
step = s;
|
||
break;
|
||
}
|
||
}
|
||
const ticks: XTick[] = [];
|
||
const first = Math.ceil(viewStart / HOUR) * HOUR;
|
||
for (let t = first; t <= viewEnd; t += HOUR) {
|
||
const date = new Date(t * 1000);
|
||
const hour = getZonedHour(date, timezone);
|
||
if (hour === 0) {
|
||
ticks.push({ t, label: formatZoned(date, timezone, 'EEE d'), isDay: true });
|
||
} else if (step < 24 && hour % step === 0) {
|
||
ticks.push({ t, label: formatZoned(date, timezone, 'HH:mm'), isDay: false });
|
||
}
|
||
}
|
||
return ticks;
|
||
}
|
||
|
||
// ─── Rendering ──────────────────────────────────────────────────────────────
|
||
|
||
function tickDecimals(step: number): number {
|
||
if (step >= 1) return 0;
|
||
return Math.min(3, Math.max(0, Math.ceil(-Math.log10(step))));
|
||
}
|
||
|
||
function draw(): void {
|
||
if (!canvasEl || !containerEl || width <= 0) return;
|
||
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const w = Math.round(width * dpr);
|
||
const h = Math.round(height * dpr);
|
||
if (canvasEl.width !== w) canvasEl.width = w;
|
||
if (canvasEl.height !== h) canvasEl.height = h;
|
||
|
||
const ctx = canvasEl.getContext('2d');
|
||
if (!ctx) return;
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
ctx.clearRect(0, 0, width, height);
|
||
|
||
if (timestamps.length === 0) return;
|
||
|
||
const styles = getComputedStyle(containerEl);
|
||
const cssColor = (name: string, fallback: string): string =>
|
||
styles.getPropertyValue(name).trim() || fallback;
|
||
const textColor = cssColor('--muted-foreground', '#6b7280');
|
||
const strongColor = cssColor('--foreground', '#374151');
|
||
const gridColor = cssColor('--border', 'rgba(0, 0, 0, 0.1)');
|
||
const bgColor = cssColor('--card', '#ffffff');
|
||
const dark = document.documentElement.classList.contains('dark');
|
||
const outlineColor = dark ? '#ffffff' : '#000000';
|
||
|
||
const plotRight = padLeft + plotW;
|
||
const plotBottom = padTop + plotH;
|
||
const font = '11px system-ui, sans-serif';
|
||
|
||
// Daylight bands
|
||
ctx.fillStyle = CHART_COLORS.daylight;
|
||
for (const band of bands) {
|
||
if (band.end < viewStart || band.start > viewEnd) continue;
|
||
const x1 = Math.max(padLeft, xPix(band.start));
|
||
const x2 = Math.min(plotRight, xPix(band.end));
|
||
if (x2 > x1) ctx.fillRect(x1, padTop, x2 - x1, plotH);
|
||
}
|
||
|
||
// Selected-day highlight: soft tint + dashed edge lines
|
||
if (highlight && highlight.end > viewStart && highlight.start < viewEnd) {
|
||
const accent = cssColor('--primary', '#e08a3c');
|
||
const x1 = Math.max(padLeft, xPix(highlight.start));
|
||
const x2 = Math.min(plotRight, xPix(highlight.end));
|
||
if (x2 > x1) {
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.08;
|
||
ctx.fillStyle = accent;
|
||
ctx.fillRect(x1, padTop, x2 - x1, plotH);
|
||
ctx.globalAlpha = 0.55;
|
||
ctx.strokeStyle = accent;
|
||
ctx.lineWidth = 1.5;
|
||
ctx.setLineDash([5, 4]);
|
||
ctx.beginPath();
|
||
for (const edge of [highlight.start, highlight.end]) {
|
||
const x = xPix(edge);
|
||
if (x >= padLeft && x <= plotRight) {
|
||
ctx.moveTo(x, padTop);
|
||
ctx.lineTo(x, plotBottom);
|
||
}
|
||
}
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
// Horizontal grid lines + left axis labels
|
||
ctx.font = font;
|
||
ctx.textAlign = 'right';
|
||
ctx.textBaseline = 'middle';
|
||
for (let v = leftScale.min; v <= leftScale.max + leftScale.step / 2; v += leftScale.step) {
|
||
const y = yPix(v, 'left');
|
||
ctx.strokeStyle = gridColor;
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(padLeft, y);
|
||
ctx.lineTo(plotRight, y);
|
||
ctx.stroke();
|
||
ctx.fillStyle = textColor;
|
||
ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), padLeft - 8, y);
|
||
}
|
||
|
||
// Right axis labels (only when a unit is provided)
|
||
if (hasRightAxis && unitRight !== undefined) {
|
||
ctx.textAlign = 'left';
|
||
ctx.fillStyle = textColor;
|
||
for (
|
||
let v = rightScale.min;
|
||
v <= rightScale.max + rightScale.step / 2;
|
||
v += rightScale.step
|
||
) {
|
||
ctx.fillText(v.toFixed(tickDecimals(rightScale.step)), plotRight + 8, yPix(v, 'right'));
|
||
}
|
||
}
|
||
|
||
// X axis ticks: midnight gridlines + time labels
|
||
ctx.textBaseline = 'top';
|
||
for (const tick of computeXTicks()) {
|
||
const x = xPix(tick.t);
|
||
if (tick.isDay) {
|
||
ctx.strokeStyle = gridColor;
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, padTop);
|
||
ctx.lineTo(x, plotBottom);
|
||
ctx.stroke();
|
||
ctx.font = 'bold 11px system-ui, sans-serif';
|
||
ctx.fillStyle = strongColor;
|
||
} else {
|
||
ctx.strokeStyle = gridColor;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, plotBottom);
|
||
ctx.lineTo(x, plotBottom + 4);
|
||
ctx.stroke();
|
||
ctx.font = font;
|
||
ctx.fillStyle = textColor;
|
||
}
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(tick.label, x, plotBottom + 7);
|
||
}
|
||
|
||
// Series (clipped to the plot area)
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.rect(padLeft, padTop, plotW, plotH);
|
||
ctx.clip();
|
||
|
||
const interval = timestamps.length > 1 ? timestamps[1] - timestamps[0] : HOUR;
|
||
|
||
// Puffy cloud band: overlapping circles hang from the top of the plot,
|
||
// each reaching down by (cover/100) × CLOUD_BAND_MAX. Neighbouring puffs
|
||
// merge into a soft, rounded silhouette.
|
||
for (const s of cloudBandSeries) {
|
||
ctx.save();
|
||
ctx.fillStyle = s.color;
|
||
ctx.globalAlpha = 0.5;
|
||
for (let i = 0; i < timestamps.length; i++) {
|
||
const v = s.data[i];
|
||
if (v === null || v === undefined || !isFinite(v) || v <= 0) continue;
|
||
const t = timestamps[i];
|
||
if (t < viewStart - interval || t > viewEnd + interval) continue;
|
||
const r = (Math.min(100, v) / 100) * CLOUD_BAND_MAX;
|
||
if (r < 1) continue;
|
||
ctx.beginPath();
|
||
ctx.arc(xPix(t), padTop, r, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
const barSeries = plottedSeries.filter((s) => s.type === 'bar');
|
||
const slot = plotW / ((viewEnd - viewStart) / interval);
|
||
const barWidth = Math.min(8, Math.max(1, (slot * 0.7) / Math.max(1, barSeries.length)));
|
||
|
||
for (const s of plottedSeries) {
|
||
const axis = s.axis ?? 'left';
|
||
const baseline = Math.min(plotBottom, Math.max(padTop, yPix(0, axis)));
|
||
|
||
if (s.type === 'bar') {
|
||
const bi = barSeries.indexOf(s);
|
||
const groupOffset = (barSeries.length * barWidth) / 2 - bi * barWidth;
|
||
ctx.fillStyle = s.color;
|
||
for (let i = 0; i < timestamps.length; i++) {
|
||
const v = s.data[i];
|
||
if (v === null || v === undefined || !isFinite(v)) continue;
|
||
const t = timestamps[i];
|
||
if (t < viewStart - interval || t > viewEnd + interval) continue;
|
||
const x = xPix(t) - groupOffset;
|
||
const y = yPix(v, axis);
|
||
ctx.fillRect(x, Math.min(y, baseline), barWidth, Math.abs(baseline - y) || 1);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Line series: draw fill and stroke per contiguous non-null run
|
||
// (points outside the view are handled by the clip rect). Each point
|
||
// is [x, y, yBand, sourceIndex] — yBand only used when s.bandTo is set.
|
||
const runs: Array<Array<[number, number, number, number]>> = [];
|
||
let run: Array<[number, number, number, number]> = [];
|
||
for (let i = 0; i < timestamps.length; i++) {
|
||
const v = s.data[i];
|
||
const b = s.bandTo?.[i];
|
||
const bandInvalid = s.bandTo != null && (b === null || b === undefined || !isFinite(b));
|
||
if (v === null || v === undefined || !isFinite(v) || bandInvalid) {
|
||
if (run.length > 0) runs.push(run);
|
||
run = [];
|
||
continue;
|
||
}
|
||
run.push([xPix(timestamps[i]), yPix(v, axis), s.bandTo ? yPix(b as number, axis) : 0, i]);
|
||
}
|
||
if (run.length > 0) runs.push(run);
|
||
|
||
for (const points of runs) {
|
||
if (points.length === 0) continue;
|
||
|
||
if (s.fill && points.length > 1) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(points[0][0], points[0][1]);
|
||
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
|
||
if (s.bandTo) {
|
||
// close the polygon along the second line, walked backwards
|
||
for (let i = points.length - 1; i >= 0; i--) ctx.lineTo(points[i][0], points[i][2]);
|
||
} else {
|
||
ctx.lineTo(points[points.length - 1][0], baseline);
|
||
ctx.lineTo(points[0][0], baseline);
|
||
}
|
||
ctx.closePath();
|
||
ctx.globalAlpha = s.fillOpacity ?? 0.15;
|
||
ctx.fillStyle = s.color;
|
||
ctx.fill();
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
const lineWidth = s.width ?? 2;
|
||
if (lineWidth > 0) {
|
||
ctx.lineJoin = 'round';
|
||
ctx.lineCap = 'round';
|
||
ctx.setLineDash(s.dashed ? [6, 4] : []);
|
||
|
||
// Contrasting halo drawn under the line so a multi-colour line
|
||
// stays legible over any background.
|
||
if (s.outline && points.length > 1) {
|
||
ctx.strokeStyle = outlineColor;
|
||
ctx.lineWidth = lineWidth + 2.5;
|
||
ctx.beginPath();
|
||
ctx.moveTo(points[0][0], points[0][1]);
|
||
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
|
||
ctx.stroke();
|
||
}
|
||
|
||
ctx.lineWidth = lineWidth;
|
||
if (s.segmentColor) {
|
||
// Colour each segment by its value (temperature colour scale).
|
||
// `idx[i]` maps a run point back to its source data index.
|
||
for (let i = 1; i < points.length; i++) {
|
||
const v = s.data[points[i][3]];
|
||
ctx.strokeStyle = s.segmentColor(v as number, points[i][3]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(points[i - 1][0], points[i - 1][1]);
|
||
ctx.lineTo(points[i][0], points[i][1]);
|
||
ctx.stroke();
|
||
}
|
||
} else {
|
||
ctx.beginPath();
|
||
ctx.moveTo(points[0][0], points[0][1]);
|
||
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
|
||
ctx.strokeStyle = s.color;
|
||
ctx.stroke();
|
||
}
|
||
ctx.setLineDash([]);
|
||
}
|
||
}
|
||
|
||
// Local minima / maxima value labels
|
||
if (s.labelExtrema) {
|
||
const fmt = s.labelFormat ?? ((v: number) => v.toFixed(0));
|
||
ctx.font = 'bold 11px system-ui, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'alphabetic';
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeStyle = bgColor;
|
||
ctx.fillStyle = strongColor;
|
||
// clear the (possibly thick) line + its outline before the text sits
|
||
const off = (s.width ?? 2) / 2 + 7;
|
||
for (const ext of findExtrema(s.data)) {
|
||
const t = timestamps[ext.i];
|
||
if (t < viewStart || t > viewEnd) continue;
|
||
const v = s.data[ext.i] as number;
|
||
const x = xPix(t);
|
||
const y = yPix(v, axis);
|
||
const label = fmt(v);
|
||
const ly = ext.type === 'max' ? y - off : y + off + 8;
|
||
// keep the centred label fully inside the plot so it never clips
|
||
const halfW = ctx.measureText(label).width / 2 + 2;
|
||
const lx = Math.max(padLeft + halfW, Math.min(plotRight - halfW, x));
|
||
ctx.strokeText(label, lx, ly);
|
||
ctx.fillText(label, lx, ly);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Current time marker
|
||
if (showNow) {
|
||
const now = Date.now() / 1000;
|
||
if (now >= viewStart && now <= viewEnd) {
|
||
const x = xPix(now);
|
||
ctx.strokeStyle = CHART_COLORS.currentTimeLine;
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, padTop);
|
||
ctx.lineTo(x, plotBottom);
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// Crosshair (snapped to the nearest timestamp)
|
||
if (hoverIdx >= 0) {
|
||
const t = timestamps[hoverIdx];
|
||
if (t >= viewStart && t <= viewEnd) {
|
||
const x = xPix(t);
|
||
ctx.strokeStyle = textColor;
|
||
ctx.lineWidth = 1;
|
||
ctx.setLineDash([4, 4]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, padTop);
|
||
ctx.lineTo(x, plotBottom);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
}
|
||
|
||
ctx.restore();
|
||
|
||
// Axis unit labels
|
||
ctx.font = font;
|
||
ctx.textBaseline = 'alphabetic';
|
||
if (unit) {
|
||
ctx.textAlign = 'right';
|
||
ctx.fillStyle = textColor;
|
||
ctx.fillText(unit, padLeft - 4, padTop - 8);
|
||
}
|
||
if (hasRightAxis && unitRight) {
|
||
ctx.textAlign = 'left';
|
||
ctx.fillStyle = textColor;
|
||
ctx.fillText(unitRight, plotRight + 4, padTop - 8);
|
||
}
|
||
|
||
// Title / subtitle
|
||
if (title) {
|
||
ctx.textAlign = 'left';
|
||
ctx.font = '16px system-ui, sans-serif';
|
||
ctx.fillStyle = strongColor;
|
||
ctx.fillText(title, 4, 20);
|
||
if (subtitle) {
|
||
ctx.font = '12px system-ui, sans-serif';
|
||
ctx.fillStyle = textColor;
|
||
ctx.fillText(subtitle, 4, 38);
|
||
}
|
||
}
|
||
|
||
// Credit watermark
|
||
if (showCredit) {
|
||
ctx.textAlign = 'right';
|
||
ctx.font = '10px system-ui, sans-serif';
|
||
ctx.globalAlpha = 0.4;
|
||
ctx.fillStyle = strongColor;
|
||
ctx.fillText('Open-Meteo.com', width - 10, height - 6);
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
}
|
||
|
||
$effect(() => {
|
||
// themeVersion is a manual dependency: it bumps when the document theme
|
||
// class changes so colors are re-read from CSS custom properties.
|
||
void themeVersion;
|
||
draw();
|
||
});
|
||
|
||
// ─── Lifecycle: resize + theme observers ────────────────────────────────────
|
||
|
||
onMount(() => {
|
||
const resizeObserver = new ResizeObserver((entries) => {
|
||
for (const entry of entries) {
|
||
width = entry.contentRect.width;
|
||
}
|
||
});
|
||
if (containerEl) resizeObserver.observe(containerEl);
|
||
|
||
const themeObserver = new MutationObserver(() => {
|
||
themeVersion++;
|
||
});
|
||
themeObserver.observe(document.documentElement, {
|
||
attributes: true,
|
||
attributeFilter: ['class', 'data-theme']
|
||
});
|
||
|
||
return () => {
|
||
resizeObserver.disconnect();
|
||
themeObserver.disconnect();
|
||
};
|
||
});
|
||
|
||
// ─── Interaction ────────────────────────────────────────────────────────────
|
||
|
||
// Listeners are attached programmatically (not via template attributes) so the
|
||
// wheel handler can be registered as non-passive.
|
||
$effect(() => {
|
||
const el = canvasEl;
|
||
if (!el) return;
|
||
|
||
// Plain interaction bookkeeping (not reactive state)
|
||
const pointers = new SvelteMap<number, { x: number; y: number }>();
|
||
let panStart: { x: number; start: number; end: number } | null = null;
|
||
let pinchStart: { dist: number; start: number; end: number } | null = null;
|
||
// Touch gesture intent. On touch we defer pointer capture until we know
|
||
// the finger is moving horizontally; a vertical drag is left to the page
|
||
// so the meteograms don't hijack scrolling (and don't flash the tooltip).
|
||
let gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' | 'select' = 'none';
|
||
let touchStart: { x: number; y: number; start: number; end: number } | null = null;
|
||
let selStartX = 0; // drag-to-zoom anchor (canvas-local px)
|
||
|
||
const localX = (e: { clientX: number }): number => e.clientX - el.getBoundingClientRect().left;
|
||
const clampPlotX = (x: number): number => Math.max(padLeft, Math.min(width - padRight, x));
|
||
|
||
const updateHover = (e: PointerEvent): void => {
|
||
const t = pixToTime(localX(e));
|
||
setHover(t >= viewStart && t <= viewEnd ? t : null);
|
||
};
|
||
|
||
const onPointerDown = (e: PointerEvent): void => {
|
||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||
|
||
if (pointers.size === 2) {
|
||
el.setPointerCapture(e.pointerId);
|
||
const [a, b] = [...pointers.values()];
|
||
pinchStart = { dist: Math.max(10, Math.abs(a.x - b.x)), start: viewStart, end: viewEnd };
|
||
panStart = null;
|
||
gesture = 'pinch';
|
||
setHover(null);
|
||
return;
|
||
}
|
||
|
||
if (e.pointerType === 'mouse') {
|
||
// Mouse: click-drag selects a range to zoom into.
|
||
el.setPointerCapture(e.pointerId);
|
||
selStartX = clampPlotX(localX(e));
|
||
dragSelect = null;
|
||
panStart = null;
|
||
pinchStart = null;
|
||
gesture = 'select';
|
||
} else {
|
||
// Touch: wait for the first move to reveal scroll vs inspect intent.
|
||
touchStart = { x: e.clientX, y: e.clientY, start: viewStart, end: viewEnd };
|
||
gesture = 'none';
|
||
}
|
||
};
|
||
|
||
const onPointerMove = (e: PointerEvent): void => {
|
||
if (pointers.has(e.pointerId)) {
|
||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||
}
|
||
|
||
if (gesture === 'pinch' && pointers.size === 2) {
|
||
const [a, b] = [...pointers.values()];
|
||
const dist = Math.max(10, Math.abs(a.x - b.x));
|
||
const scale = pinchStart!.dist / dist;
|
||
const span = pinchStart!.end - pinchStart!.start;
|
||
const center = (pinchStart!.start + pinchStart!.end) / 2;
|
||
const newSpan = span * scale;
|
||
applyRange(center - newSpan / 2, center + newSpan / 2);
|
||
return;
|
||
}
|
||
|
||
// Mouse drag-to-zoom: track the selection rectangle
|
||
if (gesture === 'select' && e.pointerType === 'mouse' && pointers.size === 1) {
|
||
const x = clampPlotX(localX(e));
|
||
if (dragSelect || Math.abs(x - selStartX) >= 3) {
|
||
dragSelect = { x0: selStartX, x1: x };
|
||
setHover(null);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Resolve touch intent from the initial drag direction
|
||
if (e.pointerType !== 'mouse' && gesture === 'none' && touchStart && pointers.size === 1) {
|
||
const dx = Math.abs(e.clientX - touchStart.x);
|
||
const dy = Math.abs(e.clientY - touchStart.y);
|
||
if (dx < 6 && dy < 6) return;
|
||
if (dy > dx) {
|
||
// Vertical: let the page scroll, never capture or hover
|
||
gesture = 'scroll';
|
||
return;
|
||
}
|
||
gesture = zoomed ? 'pan' : 'inspect';
|
||
el.setPointerCapture(e.pointerId);
|
||
if (gesture === 'pan') {
|
||
panStart = { x: e.clientX, start: touchStart.start, end: touchStart.end };
|
||
}
|
||
}
|
||
|
||
if (gesture === 'scroll') return;
|
||
|
||
if (gesture === 'pan' && panStart && pointers.size === 1 && zoomed) {
|
||
const dt = ((panStart.x - e.clientX) / plotW) * (panStart.end - panStart.start);
|
||
applyRange(panStart.start + dt, panStart.end + dt);
|
||
return;
|
||
}
|
||
|
||
if (pointers.size <= 1) updateHover(e);
|
||
};
|
||
|
||
const onPointerUp = (e: PointerEvent): void => {
|
||
// Commit a mouse drag-to-zoom selection (if it spans a real range).
|
||
if (gesture === 'select' && e.pointerType === 'mouse' && dragSelect) {
|
||
const a = pixToTime(dragSelect.x0);
|
||
const b = pixToTime(dragSelect.x1);
|
||
if (Math.abs(a - b) > 0) applyRange(Math.min(a, b), Math.max(a, b));
|
||
}
|
||
dragSelect = null;
|
||
|
||
pointers.delete(e.pointerId);
|
||
if (pointers.size < 2) pinchStart = null;
|
||
if (pointers.size < 1) {
|
||
panStart = null;
|
||
touchStart = null;
|
||
gesture = 'none';
|
||
}
|
||
if (e.pointerType !== 'mouse') setHover(null);
|
||
};
|
||
|
||
const onPointerLeave = (): void => {
|
||
if (pointers.size === 0) setHover(null);
|
||
};
|
||
|
||
const onWheel = (e: WheelEvent): void => {
|
||
// Zoom only while Ctrl (or ⌘) is held — a plain scroll should keep
|
||
// scrolling the page. Trackpad pinch also arrives as ctrlKey wheel.
|
||
if (!e.ctrlKey && !e.metaKey) {
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
const t = pixToTime(localX(e));
|
||
zoomAt(t, e.deltaY < 0 ? 1 / 1.3 : 1.3);
|
||
};
|
||
|
||
const onDblClick = (): void => {
|
||
setViewRange(null);
|
||
};
|
||
|
||
el.addEventListener('pointerdown', onPointerDown);
|
||
el.addEventListener('pointermove', onPointerMove);
|
||
el.addEventListener('pointerup', onPointerUp);
|
||
el.addEventListener('pointercancel', onPointerUp);
|
||
el.addEventListener('pointerleave', onPointerLeave);
|
||
el.addEventListener('wheel', onWheel, { passive: false });
|
||
el.addEventListener('dblclick', onDblClick);
|
||
|
||
return () => {
|
||
el.removeEventListener('pointerdown', onPointerDown);
|
||
el.removeEventListener('pointermove', onPointerMove);
|
||
el.removeEventListener('pointerup', onPointerUp);
|
||
el.removeEventListener('pointercancel', onPointerUp);
|
||
el.removeEventListener('pointerleave', onPointerLeave);
|
||
el.removeEventListener('wheel', onWheel);
|
||
el.removeEventListener('dblclick', onDblClick);
|
||
};
|
||
});
|
||
|
||
function toggleSeries(name: string): void {
|
||
if (legendHidden.has(name)) legendHidden.delete(name);
|
||
else legendHidden.add(name);
|
||
}
|
||
</script>
|
||
|
||
<div bind:this={containerEl} class="relative w-full select-none {className}">
|
||
<div class="relative">
|
||
<canvas
|
||
bind:this={canvasEl}
|
||
class="block w-full"
|
||
style:height="{height}px"
|
||
style:touch-action="pan-y"
|
||
></canvas>
|
||
|
||
<!-- Drag-to-zoom selection rectangle -->
|
||
{#if dragSelect}
|
||
<div
|
||
class="pointer-events-none absolute z-20 border-x-2 border-primary/70 bg-primary/15"
|
||
style:left="{Math.min(dragSelect.x0, dragSelect.x1)}px"
|
||
style:top="{padTop}px"
|
||
style:width="{Math.abs(dragSelect.x1 - dragSelect.x0)}px"
|
||
style:height="{plotH}px"
|
||
></div>
|
||
{/if}
|
||
|
||
<!-- Weather pictograms: a bordered band across the top of the plot -->
|
||
{#if visiblePictograms.length > 0}
|
||
<div
|
||
class="pointer-events-none absolute z-10 overflow-hidden rounded-lg border border-border/60 bg-muted/30"
|
||
style:left="{iconBandLeft}px"
|
||
style:top="{pictoRowTop}px"
|
||
style:width="{iconBandWidth}px"
|
||
style:height="{ICON_BAND_H}px"
|
||
>
|
||
{#each visiblePictograms as p (p.x)}
|
||
<svg
|
||
class="absolute top-px fill-foreground"
|
||
width={ICON_PX}
|
||
height={ICON_PX}
|
||
style:left="{Math.max(2, Math.min(iconBandWidth - ICON_PX - 2, p.x - ICON_PX / 2))}px"
|
||
>
|
||
<use xlink:href="/images/weather-icons/{p.icon}.svg#Layer_1"></use>
|
||
</svg>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Wind-direction arrows: a matching band -->
|
||
{#if visibleWindArrows.length > 0}
|
||
<div
|
||
class="pointer-events-none absolute z-10 overflow-hidden rounded-lg border border-border/60 bg-muted/30"
|
||
style:left="{iconBandLeft}px"
|
||
style:top="{windRowTop}px"
|
||
style:width="{iconBandWidth}px"
|
||
style:height="{ICON_BAND_H}px"
|
||
>
|
||
{#each visibleWindArrows as a (a.x)}
|
||
<span
|
||
class="absolute top-px inline-flex items-center justify-center"
|
||
style:width="{ICON_PX}px"
|
||
style:height="{ICON_PX}px"
|
||
style:left="{Math.max(2, Math.min(iconBandWidth - ICON_PX - 2, a.x - ICON_PX / 2))}px"
|
||
style:transform="rotate({a.deg}deg)"
|
||
>
|
||
<svg class="fill-foreground/80" width="22" height="22">
|
||
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
|
||
</svg>
|
||
</span>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
{#if tooltipVisible}
|
||
<div
|
||
class="pointer-events-none absolute z-20 rounded-md border border-border bg-popover px-3 py-2 text-xs whitespace-nowrap text-popover-foreground shadow-md"
|
||
style:top="{padTop + 8}px"
|
||
style:left="{tooltipFlip ? tooltipX - 12 : tooltipX + 12}px"
|
||
style:transform={tooltipFlip ? 'translateX(-100%)' : ''}
|
||
>
|
||
<div class="mb-1 font-semibold">
|
||
{formatZoned(new Date(timestamps[hoverIdx] * 1000), timezone, 'EEE d MMM HH:mm')}
|
||
</div>
|
||
{#each tooltipRows as row (row.name)}
|
||
<div class="flex items-center gap-1.5">
|
||
<span
|
||
class="inline-block h-2 w-2 shrink-0 rounded-full"
|
||
style:background-color={row.color}
|
||
></span>
|
||
<span>{row.name}:</span>
|
||
<span class="ml-auto pl-2 font-semibold">{row.value}</span>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Legend sits below the graph -->
|
||
{#if showLegend && series.length > 0}
|
||
<div class="mt-1.5 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 px-1">
|
||
{#each series.filter((s) => s.showInLegend !== false) as s (s.name)}
|
||
<button
|
||
type="button"
|
||
class="flex cursor-pointer items-center gap-1.5 text-xs transition-opacity {legendHidden.has(
|
||
s.name
|
||
)
|
||
? 'opacity-40'
|
||
: ''}"
|
||
onclick={() => toggleSeries(s.name)}
|
||
title="Toggle {s.name}"
|
||
>
|
||
<span
|
||
class="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
||
style:background-color={s.color}
|
||
></span>
|
||
<span class="text-muted-foreground"
|
||
>{width > 0 && width < 520 ? (s.shortName ?? s.name) : s.name}</span
|
||
>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|