modular meteograms
This commit is contained in:
@@ -46,6 +46,18 @@
|
||||
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 {
|
||||
@@ -95,6 +107,8 @@
|
||||
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 }[];
|
||||
/** 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) */
|
||||
@@ -109,6 +123,8 @@
|
||||
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) */
|
||||
@@ -134,6 +150,7 @@
|
||||
timezone,
|
||||
series,
|
||||
bands = [],
|
||||
pictograms = [],
|
||||
highlight,
|
||||
unit = '',
|
||||
unitRight,
|
||||
@@ -141,6 +158,7 @@
|
||||
group,
|
||||
yMin,
|
||||
yMax,
|
||||
zeroBaseLeft = true,
|
||||
yMinRight,
|
||||
yMaxRight,
|
||||
invertRight = false,
|
||||
@@ -154,10 +172,11 @@
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const PAD_LEFT = 60;
|
||||
const PAD_BOTTOM = 34;
|
||||
const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours
|
||||
const HOUR = 3600;
|
||||
// Puffy cloud band: 100% cover hangs 40px from the top of the plot
|
||||
const CLOUD_BAND_MAX = 40;
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -203,11 +222,18 @@
|
||||
let zoomed = $derived(viewRange !== null && viewEnd - viewStart < tMax - tMin);
|
||||
|
||||
let visibleSeries = $derived(series.filter((s) => !s.hidden && !legendHidden.has(s.name)));
|
||||
let hasRightAxis = $derived(series.some((s) => s.axis === 'right'));
|
||||
// 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);
|
||||
let padRight = $derived(hasRightAxis ? 56 : 20);
|
||||
let padTop = $derived(title ? (subtitle ? 66 : 46) : 28);
|
||||
let plotW = $derived(Math.max(1, width - PAD_LEFT - padRight));
|
||||
// Reserve a slim row at the very top for weather pictograms when present
|
||||
let padTop = $derived((title ? (subtitle ? 66 : 46) : 28) + (pictograms.length > 0 ? 22 : 0));
|
||||
let plotW = $derived(Math.max(1, width - padLeft - padRight));
|
||||
let plotH = $derived(Math.max(1, height - padTop - PAD_BOTTOM));
|
||||
|
||||
interface Scale {
|
||||
@@ -228,10 +254,10 @@
|
||||
return nice * 10 ** exp;
|
||||
}
|
||||
|
||||
function dataExtent(axis: 'left' | 'right'): [number, number] {
|
||||
function dataExtent(axis: 'left' | 'right', includeZero = true): [number, number] {
|
||||
let lo = Infinity;
|
||||
let hi = -Infinity;
|
||||
for (const s of visibleSeries) {
|
||||
for (const s of plottedSeries) {
|
||||
if ((s.axis ?? 'left') !== axis) continue;
|
||||
for (const v of s.data) {
|
||||
if (v === null || !isFinite(v)) continue;
|
||||
@@ -241,9 +267,12 @@
|
||||
}
|
||||
if (!isFinite(lo)) return [0, 1];
|
||||
// Match the previous ECharts behavior (value axis without `scale`): always
|
||||
// include zero in the axis extent.
|
||||
lo = Math.min(lo, 0);
|
||||
hi = Math.max(hi, 0);
|
||||
// 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];
|
||||
}
|
||||
@@ -256,22 +285,26 @@
|
||||
}
|
||||
|
||||
let leftScale = $derived.by((): Scale => {
|
||||
const [dLo, dHi] = dataExtent('left');
|
||||
const [dLo, dHi] = dataExtent('left', zeroBaseLeft);
|
||||
return buildScale(yMin ?? dLo, yMax ?? dHi, yMin !== undefined, yMax !== undefined);
|
||||
});
|
||||
|
||||
let rightScale = $derived.by((): Scale => {
|
||||
const lo = yMinRight ?? 0;
|
||||
const hi = yMaxRight ?? 100;
|
||||
return buildScale(lo, hi, true, true);
|
||||
// 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 PAD_LEFT + ((t - viewStart) / (viewEnd - viewStart)) * plotW;
|
||||
return padLeft + ((t - viewStart) / (viewEnd - viewStart)) * plotW;
|
||||
}
|
||||
|
||||
function pixToTime(x: number): number {
|
||||
return viewStart + ((x - PAD_LEFT) / plotW) * (viewEnd - viewStart);
|
||||
return viewStart + ((x - padLeft) / plotW) * (viewEnd - viewStart);
|
||||
}
|
||||
|
||||
function yPix(v: number, axis: 'left' | 'right'): number {
|
||||
@@ -364,7 +397,7 @@
|
||||
const value = s.format
|
||||
? s.format(v, hoverIdx)
|
||||
: `${v.toFixed(1)}${axisUnit ? ' ' + axisUnit : ''}`;
|
||||
rows.push({ name: s.name, color: s.color, value });
|
||||
rows.push({ name: s.shortName ?? s.name, color: s.color, value });
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
@@ -373,6 +406,58 @@
|
||||
let tooltipX = $derived(hoverIdx >= 0 ? xPix(timestamps[hoverIdx]) : 0);
|
||||
let tooltipFlip = $derived(tooltipX > width * 0.55);
|
||||
|
||||
// ─── Pictograms (DOM overlay across the top) ─────────────────────────────────
|
||||
|
||||
// Thin the icons so they never crowd: keep ≥ 34px apart within the view.
|
||||
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 = xPix(p.t);
|
||||
if (x - lastX < 34) continue;
|
||||
out.push({ x, icon: p.icon });
|
||||
lastX = x;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// ─── 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 {
|
||||
@@ -435,8 +520,11 @@
|
||||
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 = PAD_LEFT + plotW;
|
||||
const plotRight = padLeft + plotW;
|
||||
const plotBottom = padTop + plotH;
|
||||
const font = '11px system-ui, sans-serif';
|
||||
|
||||
@@ -444,7 +532,7 @@
|
||||
ctx.fillStyle = CHART_COLORS.daylight;
|
||||
for (const band of bands) {
|
||||
if (band.end < viewStart || band.start > viewEnd) continue;
|
||||
const x1 = Math.max(PAD_LEFT, xPix(band.start));
|
||||
const 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);
|
||||
}
|
||||
@@ -452,7 +540,7 @@
|
||||
// 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(PAD_LEFT, xPix(highlight.start));
|
||||
const x1 = Math.max(padLeft, xPix(highlight.start));
|
||||
const x2 = Math.min(plotRight, xPix(highlight.end));
|
||||
if (x2 > x1) {
|
||||
ctx.save();
|
||||
@@ -466,7 +554,7 @@
|
||||
ctx.beginPath();
|
||||
for (const edge of [highlight.start, highlight.end]) {
|
||||
const x = xPix(edge);
|
||||
if (x >= PAD_LEFT && x <= plotRight) {
|
||||
if (x >= padLeft && x <= plotRight) {
|
||||
ctx.moveTo(x, padTop);
|
||||
ctx.lineTo(x, plotBottom);
|
||||
}
|
||||
@@ -485,11 +573,11 @@
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(PAD_LEFT, y);
|
||||
ctx.moveTo(padLeft, y);
|
||||
ctx.lineTo(plotRight, y);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), PAD_LEFT - 8, y);
|
||||
ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), padLeft - 8, y);
|
||||
}
|
||||
|
||||
// Right axis labels (only when a unit is provided)
|
||||
@@ -534,15 +622,37 @@
|
||||
// Series (clipped to the plot area)
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(PAD_LEFT, padTop, plotW, plotH);
|
||||
ctx.rect(padLeft, padTop, plotW, plotH);
|
||||
ctx.clip();
|
||||
|
||||
const barSeries = visibleSeries.filter((s) => s.type === 'bar');
|
||||
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 visibleSeries) {
|
||||
for (const s of plottedSeries) {
|
||||
const axis = s.axis ?? 'left';
|
||||
const baseline = Math.min(plotBottom, Math.max(padTop, yPix(0, axis)));
|
||||
|
||||
@@ -564,9 +674,9 @@
|
||||
|
||||
// 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] — yBand only used when s.bandTo is set.
|
||||
const runs: Array<Array<[number, number, number]>> = [];
|
||||
let run: Array<[number, number, number]> = [];
|
||||
// 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];
|
||||
@@ -576,7 +686,7 @@
|
||||
run = [];
|
||||
continue;
|
||||
}
|
||||
run.push([xPix(timestamps[i]), yPix(v, axis), s.bandTo ? yPix(b as number, axis) : 0]);
|
||||
run.push([xPix(timestamps[i]), yPix(v, axis), s.bandTo ? yPix(b as number, axis) : 0, i]);
|
||||
}
|
||||
if (run.length > 0) runs.push(run);
|
||||
|
||||
@@ -603,18 +713,65 @@
|
||||
|
||||
const lineWidth = s.width ?? 2;
|
||||
if (lineWidth > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0][0], points[0][1]);
|
||||
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
|
||||
ctx.strokeStyle = s.color;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.setLineDash(s.dashed ? [6, 4] : []);
|
||||
ctx.stroke();
|
||||
|
||||
// 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;
|
||||
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 - 8 : y + 15;
|
||||
ctx.strokeText(label, x, ly);
|
||||
ctx.fillText(label, x, ly);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Current time marker
|
||||
@@ -655,7 +812,7 @@
|
||||
if (unit) {
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.fillText(unit, PAD_LEFT - 4, padTop - 8);
|
||||
ctx.fillText(unit, padLeft - 4, padTop - 8);
|
||||
}
|
||||
if (hasRightAxis && unitRight) {
|
||||
ctx.textAlign = 'left';
|
||||
@@ -730,6 +887,11 @@
|
||||
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' = 'none';
|
||||
let touchStart: { x: number; y: number; start: number; end: number } | null = null;
|
||||
|
||||
const localX = (e: { clientX: number }): number => e.clientX - el.getBoundingClientRect().left;
|
||||
|
||||
@@ -739,16 +901,27 @@
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent): void => {
|
||||
el.setPointerCapture(e.pointerId);
|
||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pointers.size === 1) {
|
||||
panStart = { x: e.clientX, start: viewStart, end: viewEnd };
|
||||
pinchStart = null;
|
||||
} else if (pointers.size === 2) {
|
||||
|
||||
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') {
|
||||
el.setPointerCapture(e.pointerId);
|
||||
panStart = { x: e.clientX, start: viewStart, end: viewEnd };
|
||||
pinchStart = null;
|
||||
gesture = zoomed ? 'pan' : 'inspect';
|
||||
} 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';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -757,30 +930,53 @@
|
||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
}
|
||||
|
||||
if (pinchStart && pointers.size === 2) {
|
||||
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 scale = pinchStart!.dist / dist;
|
||||
const span = pinchStart!.end - pinchStart!.start;
|
||||
const center = (pinchStart!.start + pinchStart!.end) / 2;
|
||||
const newSpan = span * scale;
|
||||
applyRange(center - newSpan / 2, center + newSpan / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
if (panStart && pointers.size === 1 && zoomed) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
updateHover(e);
|
||||
if (pointers.size <= 1) updateHover(e);
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent): void => {
|
||||
pointers.delete(e.pointerId);
|
||||
if (pointers.size < 2) pinchStart = null;
|
||||
if (pointers.size < 1) panStart = null;
|
||||
if (pointers.size < 1) {
|
||||
panStart = null;
|
||||
touchStart = null;
|
||||
gesture = 'none';
|
||||
}
|
||||
if (e.pointerType !== 'mouse') setHover(null);
|
||||
};
|
||||
|
||||
@@ -838,6 +1034,23 @@
|
||||
style:touch-action="pan-y"
|
||||
></canvas>
|
||||
|
||||
<!-- Weather pictograms across the top of the plot -->
|
||||
{#if visiblePictograms.length > 0}
|
||||
<div class="pointer-events-none absolute inset-0 z-10" style:top="{padTop - 24}px">
|
||||
{#each visiblePictograms as p (p.x)}
|
||||
<svg
|
||||
class="absolute fill-foreground"
|
||||
width="20"
|
||||
height="20"
|
||||
style:left="{p.x - 10}px"
|
||||
style:top="0"
|
||||
>
|
||||
<use xlink:href="/images/weather-icons/{p.icon}.svg#Layer_1"></use>
|
||||
</svg>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if zoomHintVisible}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 z-30 flex items-center justify-center rounded-md bg-black/25 transition-opacity"
|
||||
@@ -893,7 +1106,9 @@
|
||||
class="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style:background-color={s.color}
|
||||
></span>
|
||||
<span class="text-muted-foreground">{s.name}</span>
|
||||
<span class="text-muted-foreground"
|
||||
>{width > 0 && width < 520 ? (s.shortName ?? s.name) : s.name}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
+56
-11
@@ -165,6 +165,8 @@ export interface WeekForecastParams extends WeatherLocation, WeatherUnitParams {
|
||||
model?: string;
|
||||
forecast_days?: number;
|
||||
past_days?: number;
|
||||
/** Hourly API variables to request; defaults to the full core set */
|
||||
hourlyVariables?: string[];
|
||||
}
|
||||
|
||||
export interface WeekHourlyData {
|
||||
@@ -178,6 +180,19 @@ export interface WeekHourlyData {
|
||||
relative_humidity_2m: number[];
|
||||
apparent_temperature: number[];
|
||||
dew_point_2m: number[];
|
||||
// Additional popular variables available for the customizable meteograms
|
||||
wind_gusts_10m: number[];
|
||||
pressure_msl: number[];
|
||||
surface_pressure: number[];
|
||||
rain: number[];
|
||||
showers: number[];
|
||||
snowfall: number[];
|
||||
cloud_cover_low: number[];
|
||||
cloud_cover_mid: number[];
|
||||
cloud_cover_high: number[];
|
||||
uv_index: number[];
|
||||
visibility: number[];
|
||||
cape: number[];
|
||||
}
|
||||
|
||||
export interface WeekDailyData {
|
||||
@@ -259,6 +274,9 @@ export interface EnsembleForecastResult {
|
||||
|
||||
// ─── Week Forecast Fetch ────────────────────────────────────────────────────────
|
||||
|
||||
// Fallback set when the caller does not specify which hourly variables it
|
||||
// needs. Callers normally pass an explicit list so only shown variables are
|
||||
// requested.
|
||||
const WEEK_HOURLY_VARS = [
|
||||
'temperature_2m',
|
||||
'precipitation',
|
||||
@@ -294,10 +312,16 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
||||
const pastDays = params.past_days ?? 0;
|
||||
const modelParam = params.model && params.model !== 'best_match' ? params.model : undefined;
|
||||
|
||||
// Request only the variables the caller needs; fall back to the core set.
|
||||
const hourlyVars =
|
||||
params.hourlyVariables && params.hourlyVariables.length > 0
|
||||
? [...new Set(params.hourlyVariables)]
|
||||
: [...WEEK_HOURLY_VARS];
|
||||
|
||||
const apiParams: Record<string, string | number | undefined> = {
|
||||
latitude: params.latitude,
|
||||
longitude: params.longitude,
|
||||
hourly: WEEK_HOURLY_VARS.join(','),
|
||||
hourly: hourlyVars.join(','),
|
||||
daily: WEEK_DAILY_VARS.join(','),
|
||||
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
||||
@@ -328,17 +352,38 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
||||
const hourlyTimestamps = getTimestamps(hourlyBlock);
|
||||
const hourlyDates = hourlyTimestamps.map((t) => new Date(t));
|
||||
|
||||
// Values come back in the requested order; index them by API name so
|
||||
// variables that were not requested resolve to empty arrays.
|
||||
const byName: Record<string, number[]> = {};
|
||||
hourlyVars.forEach((name, i) => {
|
||||
const variable = hourlyBlock.variables(i);
|
||||
byName[name] = variable ? getValues(variable) : [];
|
||||
});
|
||||
const g = (name: string): number[] => byName[name] ?? [];
|
||||
|
||||
const hourly: WeekHourlyData = {
|
||||
temperature_2m: getValues(hourlyBlock.variables(0)!),
|
||||
precipitation: getValues(hourlyBlock.variables(1)!),
|
||||
precipitation_probability: getValues(hourlyBlock.variables(2)!),
|
||||
weather_code: getValues(hourlyBlock.variables(3)!),
|
||||
windspeed_10m: getValues(hourlyBlock.variables(4)!),
|
||||
winddirection_10m: getValues(hourlyBlock.variables(5)!),
|
||||
cloud_cover: getValues(hourlyBlock.variables(6)!),
|
||||
relative_humidity_2m: getValues(hourlyBlock.variables(7)!),
|
||||
apparent_temperature: getValues(hourlyBlock.variables(8)!),
|
||||
dew_point_2m: getValues(hourlyBlock.variables(9)!)
|
||||
temperature_2m: g('temperature_2m'),
|
||||
precipitation: g('precipitation'),
|
||||
precipitation_probability: g('precipitation_probability'),
|
||||
weather_code: g('weather_code'),
|
||||
windspeed_10m: g('wind_speed_10m'),
|
||||
winddirection_10m: g('wind_direction_10m'),
|
||||
cloud_cover: g('cloud_cover'),
|
||||
relative_humidity_2m: g('relative_humidity_2m'),
|
||||
apparent_temperature: g('apparent_temperature'),
|
||||
dew_point_2m: g('dew_point_2m'),
|
||||
wind_gusts_10m: g('wind_gusts_10m'),
|
||||
pressure_msl: g('pressure_msl'),
|
||||
surface_pressure: g('surface_pressure'),
|
||||
rain: g('rain'),
|
||||
showers: g('showers'),
|
||||
snowfall: g('snowfall'),
|
||||
cloud_cover_low: g('cloud_cover_low'),
|
||||
cloud_cover_mid: g('cloud_cover_mid'),
|
||||
cloud_cover_high: g('cloud_cover_high'),
|
||||
uv_index: g('uv_index'),
|
||||
visibility: g('visibility'),
|
||||
cape: g('cape')
|
||||
};
|
||||
|
||||
// Daily: variables are in the same order as WEEK_DAILY_VARS
|
||||
|
||||
@@ -79,5 +79,23 @@ export const defaultVariablePrefs: VariablePrefs = {
|
||||
|
||||
export const storedVariablePrefs = persisted<VariablePrefs>('variable_prefs', defaultVariablePrefs);
|
||||
|
||||
/**
|
||||
* Meteogram layout: an ordered list of chart panels, each holding an ordered
|
||||
* list of variable keys (see the chart variable registry). Users drag
|
||||
* variables between panels to fully customise the meteograms.
|
||||
*/
|
||||
export interface ChartPanel {
|
||||
id: string;
|
||||
variables: string[];
|
||||
}
|
||||
|
||||
export const defaultChartLayout: ChartPanel[] = [
|
||||
{ id: 'panel-1', variables: ['temperature', 'cloud_cover'] },
|
||||
{ id: 'panel-2', variables: ['precipitation', 'precipitation_probability'] },
|
||||
{ id: 'panel-3', variables: ['wind', 'humidity'] }
|
||||
];
|
||||
|
||||
export const storedChartLayout = persisted<ChartPanel[]>('chart_layout_v1', defaultChartLayout);
|
||||
|
||||
/** Selected ensemble model for the 14-day spread forecast. */
|
||||
export const storedEnsembleModel = persisted<string>('ensemble_model', 'ncep_gefs_seamless');
|
||||
|
||||
Reference in New Issue
Block a user