modular meteograms
This commit is contained in:
@@ -46,6 +46,18 @@
|
|||||||
showInLegend?: boolean;
|
showInLegend?: boolean;
|
||||||
/** Custom tooltip value formatter */
|
/** Custom tooltip value formatter */
|
||||||
format?: (value: number, index: number) => string;
|
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 {
|
interface GroupState {
|
||||||
@@ -95,6 +107,8 @@
|
|||||||
series: ChartSeries[];
|
series: ChartSeries[];
|
||||||
/** Background bands (epoch seconds), e.g. daylight */
|
/** Background bands (epoch seconds), e.g. daylight */
|
||||||
bands?: { start: number; end: number }[];
|
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 */
|
/** Highlighted time range (epoch seconds), e.g. the selected day */
|
||||||
highlight?: { start: number; end: number };
|
highlight?: { start: number; end: number };
|
||||||
/** Unit label for the left y axis (also used in tooltip values) */
|
/** Unit label for the left y axis (also used in tooltip values) */
|
||||||
@@ -109,6 +123,8 @@
|
|||||||
yMin?: number;
|
yMin?: number;
|
||||||
/** Fixed left-axis maximum */
|
/** Fixed left-axis maximum */
|
||||||
yMax?: number;
|
yMax?: number;
|
||||||
|
/** Force the derived left axis to include zero (default true) */
|
||||||
|
zeroBaseLeft?: boolean;
|
||||||
/** Fixed right-axis minimum (default 0) */
|
/** Fixed right-axis minimum (default 0) */
|
||||||
yMinRight?: number;
|
yMinRight?: number;
|
||||||
/** Fixed right-axis maximum (default 100) */
|
/** Fixed right-axis maximum (default 100) */
|
||||||
@@ -134,6 +150,7 @@
|
|||||||
timezone,
|
timezone,
|
||||||
series,
|
series,
|
||||||
bands = [],
|
bands = [],
|
||||||
|
pictograms = [],
|
||||||
highlight,
|
highlight,
|
||||||
unit = '',
|
unit = '',
|
||||||
unitRight,
|
unitRight,
|
||||||
@@ -141,6 +158,7 @@
|
|||||||
group,
|
group,
|
||||||
yMin,
|
yMin,
|
||||||
yMax,
|
yMax,
|
||||||
|
zeroBaseLeft = true,
|
||||||
yMinRight,
|
yMinRight,
|
||||||
yMaxRight,
|
yMaxRight,
|
||||||
invertRight = false,
|
invertRight = false,
|
||||||
@@ -154,10 +172,11 @@
|
|||||||
|
|
||||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PAD_LEFT = 60;
|
|
||||||
const PAD_BOTTOM = 34;
|
const PAD_BOTTOM = 34;
|
||||||
const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours
|
const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours
|
||||||
const HOUR = 3600;
|
const HOUR = 3600;
|
||||||
|
// Puffy cloud band: 100% cover hangs 40px from the top of the plot
|
||||||
|
const CLOUD_BAND_MAX = 40;
|
||||||
|
|
||||||
// ─── State ──────────────────────────────────────────────────────────────────
|
// ─── State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -203,11 +222,18 @@
|
|||||||
let zoomed = $derived(viewRange !== null && viewEnd - viewStart < tMax - tMin);
|
let zoomed = $derived(viewRange !== null && viewEnd - viewStart < tMax - tMin);
|
||||||
|
|
||||||
let visibleSeries = $derived(series.filter((s) => !s.hidden && !legendHidden.has(s.name)));
|
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 padRight = $derived(hasRightAxis ? 56 : 20);
|
||||||
let padTop = $derived(title ? (subtitle ? 66 : 46) : 28);
|
// Reserve a slim row at the very top for weather pictograms when present
|
||||||
let plotW = $derived(Math.max(1, width - PAD_LEFT - padRight));
|
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));
|
let plotH = $derived(Math.max(1, height - padTop - PAD_BOTTOM));
|
||||||
|
|
||||||
interface Scale {
|
interface Scale {
|
||||||
@@ -228,10 +254,10 @@
|
|||||||
return nice * 10 ** exp;
|
return nice * 10 ** exp;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dataExtent(axis: 'left' | 'right'): [number, number] {
|
function dataExtent(axis: 'left' | 'right', includeZero = true): [number, number] {
|
||||||
let lo = Infinity;
|
let lo = Infinity;
|
||||||
let hi = -Infinity;
|
let hi = -Infinity;
|
||||||
for (const s of visibleSeries) {
|
for (const s of plottedSeries) {
|
||||||
if ((s.axis ?? 'left') !== axis) continue;
|
if ((s.axis ?? 'left') !== axis) continue;
|
||||||
for (const v of s.data) {
|
for (const v of s.data) {
|
||||||
if (v === null || !isFinite(v)) continue;
|
if (v === null || !isFinite(v)) continue;
|
||||||
@@ -241,9 +267,12 @@
|
|||||||
}
|
}
|
||||||
if (!isFinite(lo)) return [0, 1];
|
if (!isFinite(lo)) return [0, 1];
|
||||||
// Match the previous ECharts behavior (value axis without `scale`): always
|
// Match the previous ECharts behavior (value axis without `scale`): always
|
||||||
// include zero in the axis extent.
|
// include zero in the axis extent. Skipped for derived secondary axes
|
||||||
lo = Math.min(lo, 0);
|
// (e.g. pressure) where zero would flatten the series.
|
||||||
hi = Math.max(hi, 0);
|
if (includeZero) {
|
||||||
|
lo = Math.min(lo, 0);
|
||||||
|
hi = Math.max(hi, 0);
|
||||||
|
}
|
||||||
if (lo === hi) hi = lo + 1;
|
if (lo === hi) hi = lo + 1;
|
||||||
return [lo, hi];
|
return [lo, hi];
|
||||||
}
|
}
|
||||||
@@ -256,22 +285,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let leftScale = $derived.by((): Scale => {
|
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);
|
return buildScale(yMin ?? dLo, yMax ?? dHi, yMin !== undefined, yMax !== undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
let rightScale = $derived.by((): Scale => {
|
let rightScale = $derived.by((): Scale => {
|
||||||
const lo = yMinRight ?? 0;
|
// Use explicit bounds when given; otherwise derive from the right-axis
|
||||||
const hi = yMaxRight ?? 100;
|
// data so arbitrary variables (pressure, wind, …) can share a panel.
|
||||||
return buildScale(lo, hi, true, true);
|
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 {
|
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 {
|
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 {
|
function yPix(v: number, axis: 'left' | 'right'): number {
|
||||||
@@ -364,7 +397,7 @@
|
|||||||
const value = s.format
|
const value = s.format
|
||||||
? s.format(v, hoverIdx)
|
? s.format(v, hoverIdx)
|
||||||
: `${v.toFixed(1)}${axisUnit ? ' ' + axisUnit : ''}`;
|
: `${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;
|
return rows;
|
||||||
});
|
});
|
||||||
@@ -373,6 +406,58 @@
|
|||||||
let tooltipX = $derived(hoverIdx >= 0 ? xPix(timestamps[hoverIdx]) : 0);
|
let tooltipX = $derived(hoverIdx >= 0 ? xPix(timestamps[hoverIdx]) : 0);
|
||||||
let tooltipFlip = $derived(tooltipX > width * 0.55);
|
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 ───────────────────────────────────────────────────────────
|
// ─── X axis ticks ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface XTick {
|
interface XTick {
|
||||||
@@ -435,8 +520,11 @@
|
|||||||
const textColor = cssColor('--muted-foreground', '#6b7280');
|
const textColor = cssColor('--muted-foreground', '#6b7280');
|
||||||
const strongColor = cssColor('--foreground', '#374151');
|
const strongColor = cssColor('--foreground', '#374151');
|
||||||
const gridColor = cssColor('--border', 'rgba(0, 0, 0, 0.1)');
|
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 plotBottom = padTop + plotH;
|
||||||
const font = '11px system-ui, sans-serif';
|
const font = '11px system-ui, sans-serif';
|
||||||
|
|
||||||
@@ -444,7 +532,7 @@
|
|||||||
ctx.fillStyle = CHART_COLORS.daylight;
|
ctx.fillStyle = CHART_COLORS.daylight;
|
||||||
for (const band of bands) {
|
for (const band of bands) {
|
||||||
if (band.end < viewStart || band.start > viewEnd) continue;
|
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));
|
const x2 = Math.min(plotRight, xPix(band.end));
|
||||||
if (x2 > x1) ctx.fillRect(x1, padTop, x2 - x1, plotH);
|
if (x2 > x1) ctx.fillRect(x1, padTop, x2 - x1, plotH);
|
||||||
}
|
}
|
||||||
@@ -452,7 +540,7 @@
|
|||||||
// Selected-day highlight: soft tint + dashed edge lines
|
// Selected-day highlight: soft tint + dashed edge lines
|
||||||
if (highlight && highlight.end > viewStart && highlight.start < viewEnd) {
|
if (highlight && highlight.end > viewStart && highlight.start < viewEnd) {
|
||||||
const accent = cssColor('--primary', '#e08a3c');
|
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));
|
const x2 = Math.min(plotRight, xPix(highlight.end));
|
||||||
if (x2 > x1) {
|
if (x2 > x1) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
@@ -466,7 +554,7 @@
|
|||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (const edge of [highlight.start, highlight.end]) {
|
for (const edge of [highlight.start, highlight.end]) {
|
||||||
const x = xPix(edge);
|
const x = xPix(edge);
|
||||||
if (x >= PAD_LEFT && x <= plotRight) {
|
if (x >= padLeft && x <= plotRight) {
|
||||||
ctx.moveTo(x, padTop);
|
ctx.moveTo(x, padTop);
|
||||||
ctx.lineTo(x, plotBottom);
|
ctx.lineTo(x, plotBottom);
|
||||||
}
|
}
|
||||||
@@ -485,11 +573,11 @@
|
|||||||
ctx.strokeStyle = gridColor;
|
ctx.strokeStyle = gridColor;
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(PAD_LEFT, y);
|
ctx.moveTo(padLeft, y);
|
||||||
ctx.lineTo(plotRight, y);
|
ctx.lineTo(plotRight, y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.fillStyle = textColor;
|
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)
|
// Right axis labels (only when a unit is provided)
|
||||||
@@ -534,15 +622,37 @@
|
|||||||
// Series (clipped to the plot area)
|
// Series (clipped to the plot area)
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.rect(PAD_LEFT, padTop, plotW, plotH);
|
ctx.rect(padLeft, padTop, plotW, plotH);
|
||||||
ctx.clip();
|
ctx.clip();
|
||||||
|
|
||||||
const barSeries = visibleSeries.filter((s) => s.type === 'bar');
|
|
||||||
const interval = timestamps.length > 1 ? timestamps[1] - timestamps[0] : HOUR;
|
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 slot = plotW / ((viewEnd - viewStart) / interval);
|
||||||
const barWidth = Math.min(8, Math.max(1, (slot * 0.7) / Math.max(1, barSeries.length)));
|
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 axis = s.axis ?? 'left';
|
||||||
const baseline = Math.min(plotBottom, Math.max(padTop, yPix(0, axis)));
|
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
|
// Line series: draw fill and stroke per contiguous non-null run
|
||||||
// (points outside the view are handled by the clip rect). Each point
|
// (points outside the view are handled by the clip rect). Each point
|
||||||
// is [x, y, yBand] — yBand only used when s.bandTo is set.
|
// is [x, y, yBand, sourceIndex] — yBand only used when s.bandTo is set.
|
||||||
const runs: Array<Array<[number, number, number]>> = [];
|
const runs: Array<Array<[number, number, number, number]>> = [];
|
||||||
let run: Array<[number, number, number]> = [];
|
let run: Array<[number, number, number, number]> = [];
|
||||||
for (let i = 0; i < timestamps.length; i++) {
|
for (let i = 0; i < timestamps.length; i++) {
|
||||||
const v = s.data[i];
|
const v = s.data[i];
|
||||||
const b = s.bandTo?.[i];
|
const b = s.bandTo?.[i];
|
||||||
@@ -576,7 +686,7 @@
|
|||||||
run = [];
|
run = [];
|
||||||
continue;
|
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);
|
if (run.length > 0) runs.push(run);
|
||||||
|
|
||||||
@@ -603,18 +713,65 @@
|
|||||||
|
|
||||||
const lineWidth = s.width ?? 2;
|
const lineWidth = s.width ?? 2;
|
||||||
if (lineWidth > 0) {
|
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.lineJoin = 'round';
|
||||||
ctx.lineCap = 'round';
|
ctx.lineCap = 'round';
|
||||||
ctx.setLineDash(s.dashed ? [6, 4] : []);
|
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([]);
|
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
|
// Current time marker
|
||||||
@@ -655,7 +812,7 @@
|
|||||||
if (unit) {
|
if (unit) {
|
||||||
ctx.textAlign = 'right';
|
ctx.textAlign = 'right';
|
||||||
ctx.fillStyle = textColor;
|
ctx.fillStyle = textColor;
|
||||||
ctx.fillText(unit, PAD_LEFT - 4, padTop - 8);
|
ctx.fillText(unit, padLeft - 4, padTop - 8);
|
||||||
}
|
}
|
||||||
if (hasRightAxis && unitRight) {
|
if (hasRightAxis && unitRight) {
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
@@ -730,6 +887,11 @@
|
|||||||
const pointers = new SvelteMap<number, { x: number; y: number }>();
|
const pointers = new SvelteMap<number, { x: number; y: number }>();
|
||||||
let panStart: { x: number; start: number; end: number } | null = null;
|
let panStart: { x: number; start: number; end: number } | null = null;
|
||||||
let pinchStart: { dist: 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;
|
const localX = (e: { clientX: number }): number => e.clientX - el.getBoundingClientRect().left;
|
||||||
|
|
||||||
@@ -739,16 +901,27 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onPointerDown = (e: PointerEvent): void => {
|
const onPointerDown = (e: PointerEvent): void => {
|
||||||
el.setPointerCapture(e.pointerId);
|
|
||||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
if (pointers.size === 1) {
|
|
||||||
panStart = { x: e.clientX, start: viewStart, end: viewEnd };
|
if (pointers.size === 2) {
|
||||||
pinchStart = null;
|
el.setPointerCapture(e.pointerId);
|
||||||
} else if (pointers.size === 2) {
|
|
||||||
const [a, b] = [...pointers.values()];
|
const [a, b] = [...pointers.values()];
|
||||||
pinchStart = { dist: Math.max(10, Math.abs(a.x - b.x)), start: viewStart, end: viewEnd };
|
pinchStart = { dist: Math.max(10, Math.abs(a.x - b.x)), start: viewStart, end: viewEnd };
|
||||||
panStart = null;
|
panStart = null;
|
||||||
|
gesture = 'pinch';
|
||||||
setHover(null);
|
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 });
|
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 [a, b] = [...pointers.values()];
|
||||||
const dist = Math.max(10, Math.abs(a.x - b.x));
|
const dist = Math.max(10, Math.abs(a.x - b.x));
|
||||||
const scale = pinchStart.dist / dist;
|
const scale = pinchStart!.dist / dist;
|
||||||
const span = pinchStart.end - pinchStart.start;
|
const span = pinchStart!.end - pinchStart!.start;
|
||||||
const center = (pinchStart.start + pinchStart.end) / 2;
|
const center = (pinchStart!.start + pinchStart!.end) / 2;
|
||||||
const newSpan = span * scale;
|
const newSpan = span * scale;
|
||||||
applyRange(center - newSpan / 2, center + newSpan / 2);
|
applyRange(center - newSpan / 2, center + newSpan / 2);
|
||||||
return;
|
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);
|
const dt = ((panStart.x - e.clientX) / plotW) * (panStart.end - panStart.start);
|
||||||
applyRange(panStart.start + dt, panStart.end + dt);
|
applyRange(panStart.start + dt, panStart.end + dt);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateHover(e);
|
if (pointers.size <= 1) updateHover(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPointerUp = (e: PointerEvent): void => {
|
const onPointerUp = (e: PointerEvent): void => {
|
||||||
pointers.delete(e.pointerId);
|
pointers.delete(e.pointerId);
|
||||||
if (pointers.size < 2) pinchStart = null;
|
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);
|
if (e.pointerType !== 'mouse') setHover(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -838,6 +1034,23 @@
|
|||||||
style:touch-action="pan-y"
|
style:touch-action="pan-y"
|
||||||
></canvas>
|
></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}
|
{#if zoomHintVisible}
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-0 z-30 flex items-center justify-center rounded-md bg-black/25 transition-opacity"
|
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"
|
class="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
||||||
style:background-color={s.color}
|
style:background-color={s.color}
|
||||||
></span>
|
></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>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+56
-11
@@ -165,6 +165,8 @@ export interface WeekForecastParams extends WeatherLocation, WeatherUnitParams {
|
|||||||
model?: string;
|
model?: string;
|
||||||
forecast_days?: number;
|
forecast_days?: number;
|
||||||
past_days?: number;
|
past_days?: number;
|
||||||
|
/** Hourly API variables to request; defaults to the full core set */
|
||||||
|
hourlyVariables?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WeekHourlyData {
|
export interface WeekHourlyData {
|
||||||
@@ -178,6 +180,19 @@ export interface WeekHourlyData {
|
|||||||
relative_humidity_2m: number[];
|
relative_humidity_2m: number[];
|
||||||
apparent_temperature: number[];
|
apparent_temperature: number[];
|
||||||
dew_point_2m: 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 {
|
export interface WeekDailyData {
|
||||||
@@ -259,6 +274,9 @@ export interface EnsembleForecastResult {
|
|||||||
|
|
||||||
// ─── Week Forecast Fetch ────────────────────────────────────────────────────────
|
// ─── 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 = [
|
const WEEK_HOURLY_VARS = [
|
||||||
'temperature_2m',
|
'temperature_2m',
|
||||||
'precipitation',
|
'precipitation',
|
||||||
@@ -294,10 +312,16 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
|||||||
const pastDays = params.past_days ?? 0;
|
const pastDays = params.past_days ?? 0;
|
||||||
const modelParam = params.model && params.model !== 'best_match' ? params.model : undefined;
|
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> = {
|
const apiParams: Record<string, string | number | undefined> = {
|
||||||
latitude: params.latitude,
|
latitude: params.latitude,
|
||||||
longitude: params.longitude,
|
longitude: params.longitude,
|
||||||
hourly: WEEK_HOURLY_VARS.join(','),
|
hourly: hourlyVars.join(','),
|
||||||
daily: WEEK_DAILY_VARS.join(','),
|
daily: WEEK_DAILY_VARS.join(','),
|
||||||
temperature_unit: params.temperature_unit ?? 'celsius',
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||||
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
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 hourlyTimestamps = getTimestamps(hourlyBlock);
|
||||||
const hourlyDates = hourlyTimestamps.map((t) => new Date(t));
|
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 = {
|
const hourly: WeekHourlyData = {
|
||||||
temperature_2m: getValues(hourlyBlock.variables(0)!),
|
temperature_2m: g('temperature_2m'),
|
||||||
precipitation: getValues(hourlyBlock.variables(1)!),
|
precipitation: g('precipitation'),
|
||||||
precipitation_probability: getValues(hourlyBlock.variables(2)!),
|
precipitation_probability: g('precipitation_probability'),
|
||||||
weather_code: getValues(hourlyBlock.variables(3)!),
|
weather_code: g('weather_code'),
|
||||||
windspeed_10m: getValues(hourlyBlock.variables(4)!),
|
windspeed_10m: g('wind_speed_10m'),
|
||||||
winddirection_10m: getValues(hourlyBlock.variables(5)!),
|
winddirection_10m: g('wind_direction_10m'),
|
||||||
cloud_cover: getValues(hourlyBlock.variables(6)!),
|
cloud_cover: g('cloud_cover'),
|
||||||
relative_humidity_2m: getValues(hourlyBlock.variables(7)!),
|
relative_humidity_2m: g('relative_humidity_2m'),
|
||||||
apparent_temperature: getValues(hourlyBlock.variables(8)!),
|
apparent_temperature: g('apparent_temperature'),
|
||||||
dew_point_2m: getValues(hourlyBlock.variables(9)!)
|
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
|
// 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);
|
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. */
|
/** Selected ensemble model for the 14-day spread forecast. */
|
||||||
export const storedEnsembleModel = persisted<string>('ensemble_model', 'ncep_gefs_seamless');
|
export const storedEnsembleModel = persisted<string>('ensemble_model', 'ncep_gefs_seamless');
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
fetchModelComparison
|
fetchModelComparison
|
||||||
} from '$lib/services/weather';
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
import { hourly, modelGroups } from '../../options';
|
import { findModel, hourly, modelGroups } from '../../options';
|
||||||
import { defaultParameters } from '../../options';
|
import { defaultParameters } from '../../options';
|
||||||
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
|
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
|
||||||
|
|
||||||
@@ -174,8 +174,11 @@
|
|||||||
if (model === 'time') continue;
|
if (model === 'time') continue;
|
||||||
if (!model.startsWith(variable)) continue;
|
if (!model.startsWith(variable)) continue;
|
||||||
|
|
||||||
|
// strip the variable prefix and use the concise model label so the
|
||||||
|
// tooltip/legend stay readable (model ids are very long)
|
||||||
|
const modelId = model.slice(variable.length + 1);
|
||||||
series.push({
|
series.push({
|
||||||
name: model,
|
name: findModel(modelId)?.label ?? modelId,
|
||||||
type: isColumn ? 'bar' : 'line',
|
type: isColumn ? 'bar' : 'line',
|
||||||
color: SERIES_COLORS[modelIndex % SERIES_COLORS.length],
|
color: SERIES_COLORS[modelIndex % SERIES_COLORS.length],
|
||||||
data: values as (number | null)[],
|
data: values as (number | null)[],
|
||||||
@@ -186,7 +189,7 @@
|
|||||||
|
|
||||||
const { average } = calculateAverage(hourlyData, variable, timeLength);
|
const { average } = calculateAverage(hourlyData, variable, timeLength);
|
||||||
series.push({
|
series.push({
|
||||||
name: variable + '_average',
|
name: 'Average',
|
||||||
type: isColumn ? 'bar' : 'line',
|
type: isColumn ? 'bar' : 'line',
|
||||||
color: CHART_COLORS.average,
|
color: CHART_COLORS.average,
|
||||||
data: average,
|
data: average,
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
|
|
||||||
// Local maps dev server (open-meteo/maps); production: https://maps.open-meteo.com
|
// Local maps dev server (open-meteo/maps); production: https://maps.open-meteo.com
|
||||||
// Run drizzli on a different port so the map keeps 5173 to itself.
|
// Run drizzli on a different port so the map keeps 5173 to itself.
|
||||||
const MAPS_ORIGIN = 'http://localhost:5173';
|
// const MAPS_ORIGIN = 'http://localhost:5173';
|
||||||
|
const MAPS_ORIGIN = 'https://maps.open-meteo.com';
|
||||||
|
|
||||||
const MAP_HASH_RE = /^#\d+(\.\d+)?\/-?\d+(\.\d+)?\/-?\d+(\.\d+)?/;
|
const MAP_HASH_RE = /^#\d+(\.\d+)?\/-?\d+(\.\d+)?\/-?\d+(\.\d+)?/;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
import { storedLocation, storedModel, storedVariablePrefs } from '$lib/stores/settings';
|
import {
|
||||||
|
storedChartLayout,
|
||||||
|
storedLocation,
|
||||||
|
storedModel,
|
||||||
|
storedVariablePrefs
|
||||||
|
} from '$lib/stores/settings';
|
||||||
|
|
||||||
import { ChartContainer } from '$lib/components/charts';
|
import { ChartContainer } from '$lib/components/charts';
|
||||||
|
|
||||||
@@ -15,6 +20,7 @@
|
|||||||
import MeteogramCharts from './MeteogramCharts.svelte';
|
import MeteogramCharts from './MeteogramCharts.svelte';
|
||||||
import ModelSelector from './ModelSelector.svelte';
|
import ModelSelector from './ModelSelector.svelte';
|
||||||
import VariableSidebar from './VariableSidebar.svelte';
|
import VariableSidebar from './VariableSidebar.svelte';
|
||||||
|
import { neededHourlyApiVars } from './variables';
|
||||||
|
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import type { FetchedDaily, FetchedHourly } from './types';
|
import type { FetchedDaily, FetchedHourly } from './types';
|
||||||
@@ -28,16 +34,18 @@
|
|||||||
|
|
||||||
let variableSidebarOpen = $state(false);
|
let variableSidebarOpen = $state(false);
|
||||||
|
|
||||||
// Number of meteogram chart panels currently enabled: used to reserve the
|
// Number of meteogram panels: reserves the chart area height before data
|
||||||
// exact chart area height before data arrives (no layout shift)
|
// arrives (no layout shift)
|
||||||
let enabledChartCount = $derived.by(() => {
|
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
|
||||||
const on = (key: string) => $storedVariablePrefs.charts?.[key] ?? true;
|
|
||||||
return (
|
// Request only the hourly variables the table rows and meteograms actually
|
||||||
(on('temperature') || on('cloud_cover') ? 1 : 0) +
|
// show, so unused variables are never fetched.
|
||||||
(on('precipitation') || on('precipitation_probability') ? 1 : 0) +
|
let hourlyVars = $derived(
|
||||||
(on('wind') || on('humidity') ? 1 : 0)
|
neededHourlyApiVars(
|
||||||
);
|
$storedVariablePrefs.table,
|
||||||
});
|
$storedChartLayout.flatMap((p) => p.variables)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// the URL is the source of truth: location comes from the load function,
|
// the URL is the source of truth: location comes from the load function,
|
||||||
// which is also correct on hydrated prerendered pages. The persisted store
|
// which is also correct on hydrated prerendered pages. The persisted store
|
||||||
@@ -72,6 +80,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const loc = location;
|
const loc = location;
|
||||||
const modelList = params.models;
|
const modelList = params.models;
|
||||||
|
const requestVars = hourlyVars;
|
||||||
|
|
||||||
if (!mounted || !loc || !modelList?.length) return;
|
if (!mounted || !loc || !modelList?.length) return;
|
||||||
|
|
||||||
@@ -84,6 +93,7 @@
|
|||||||
latitude: loc.latitude!,
|
latitude: loc.latitude!,
|
||||||
longitude: loc.longitude!,
|
longitude: loc.longitude!,
|
||||||
model: modelList[0],
|
model: modelList[0],
|
||||||
|
hourlyVariables: requestVars,
|
||||||
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||||
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade, fly } from 'svelte/transition';
|
||||||
|
|
||||||
|
import { type ChartPanel, defaultChartLayout, storedChartLayout } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { CHART_VARIABLES, VARIABLE_BY_KEY } from './variables';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open, onClose }: Props = $props();
|
||||||
|
|
||||||
|
// Variables not placed in any panel form the "available" pool.
|
||||||
|
let usedKeys = $derived(new Set($storedChartLayout.flatMap((p) => p.variables)));
|
||||||
|
let availableVars = $derived(CHART_VARIABLES.filter((v) => !usedKeys.has(v.key)));
|
||||||
|
|
||||||
|
// ─── Drag state ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let dragKey = $state<string | null>(null);
|
||||||
|
let dragLabel = $state('');
|
||||||
|
let dragColor = $state('');
|
||||||
|
let dragPos = $state({ x: 0, y: 0 });
|
||||||
|
let dropZone = $state<string | null>(null); // panel id or 'pool'
|
||||||
|
let dropIndex = $state(0);
|
||||||
|
let pointerStart: { x: number; y: number } | null = null;
|
||||||
|
let started = $state(false);
|
||||||
|
|
||||||
|
function beginDrag(e: PointerEvent, key: string): void {
|
||||||
|
const def = VARIABLE_BY_KEY.get(key);
|
||||||
|
if (!def) return;
|
||||||
|
pointerStart = { x: e.clientX, y: e.clientY };
|
||||||
|
started = false;
|
||||||
|
dragKey = key;
|
||||||
|
dragLabel = def.label;
|
||||||
|
dragColor = def.color;
|
||||||
|
dragPos = { x: e.clientX, y: e.clientY };
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragMove(e: PointerEvent): void {
|
||||||
|
if (dragKey === null || !pointerStart) return;
|
||||||
|
if (!started) {
|
||||||
|
const dx = Math.abs(e.clientX - pointerStart.x);
|
||||||
|
const dy = Math.abs(e.clientY - pointerStart.y);
|
||||||
|
if (dx < 5 && dy < 5) return;
|
||||||
|
started = true;
|
||||||
|
}
|
||||||
|
dragPos = { x: e.clientX, y: e.clientY };
|
||||||
|
|
||||||
|
const under = document.elementFromPoint(e.clientX, e.clientY);
|
||||||
|
const zoneEl = under?.closest('[data-zone]') as HTMLElement | null;
|
||||||
|
if (!zoneEl) {
|
||||||
|
dropZone = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dropZone = zoneEl.dataset.zone ?? null;
|
||||||
|
|
||||||
|
// Insert before the chip whose centre is to the right of the pointer.
|
||||||
|
const chips = [...zoneEl.querySelectorAll('[data-chip]')] as HTMLElement[];
|
||||||
|
let idx = chips.length;
|
||||||
|
for (let i = 0; i < chips.length; i++) {
|
||||||
|
const r = chips[i].getBoundingClientRect();
|
||||||
|
if (e.clientY < r.top || (e.clientY <= r.bottom && e.clientX < r.left + r.width / 2)) {
|
||||||
|
idx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dropIndex = idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag(e: PointerEvent): void {
|
||||||
|
if (dragKey === null) return;
|
||||||
|
const key = dragKey;
|
||||||
|
const zone = started ? dropZone : null;
|
||||||
|
(e.currentTarget as HTMLElement)?.releasePointerCapture?.(e.pointerId);
|
||||||
|
dragKey = null;
|
||||||
|
pointerStart = null;
|
||||||
|
if (zone) moveVar(key, zone, dropIndex);
|
||||||
|
dropZone = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveVar(key: string, toZone: string, toIndex: number): void {
|
||||||
|
const layout: ChartPanel[] = $storedChartLayout.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
variables: p.variables.filter((k) => k !== key)
|
||||||
|
}));
|
||||||
|
if (toZone !== 'pool') {
|
||||||
|
const panel = layout.find((p) => p.id === toZone);
|
||||||
|
if (panel) panel.variables.splice(Math.min(toIndex, panel.variables.length), 0, key);
|
||||||
|
}
|
||||||
|
storedChartLayout.set(layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeVar(key: string): void {
|
||||||
|
moveVar(key, 'pool', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPanel(): void {
|
||||||
|
const maxN = $storedChartLayout.reduce((m, p) => {
|
||||||
|
const n = parseInt(p.id.replace(/\D/g, ''), 10);
|
||||||
|
return Number.isFinite(n) ? Math.max(m, n) : m;
|
||||||
|
}, 0);
|
||||||
|
storedChartLayout.set([...$storedChartLayout, { id: `panel-${maxN + 1}`, variables: [] }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePanel(id: string): void {
|
||||||
|
storedChartLayout.set($storedChartLayout.filter((p) => p.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLayout(): void {
|
||||||
|
storedChartLayout.set(structuredClone(defaultChartLayout));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape' && open && dragKey === null) onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-stretch justify-center md:items-center md:p-6">
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/40"
|
||||||
|
transition:fade={{ duration: 150 }}
|
||||||
|
onclick={() => dragKey === null && onClose()}
|
||||||
|
onkeydown={onClose}
|
||||||
|
role="presentation"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative flex w-full max-w-3xl flex-col overflow-hidden bg-card shadow-2xl md:rounded-2xl md:border md:border-border"
|
||||||
|
transition:fly={{ y: 20, duration: 200 }}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between border-b border-border px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-bold">Customize meteograms</h2>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Drag variables between charts to build your own layout.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
onclick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4.5 w-4.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 space-y-4 overflow-y-auto px-5 py-4">
|
||||||
|
{#each $storedChartLayout as panel, i (panel.id)}
|
||||||
|
<div
|
||||||
|
data-zone={panel.id}
|
||||||
|
class="rounded-xl border-2 border-dashed p-3 transition-colors {dropZone === panel.id
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border'}"
|
||||||
|
>
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="text-[11px] font-bold tracking-wider text-primary uppercase"
|
||||||
|
>Chart {i + 1}</span
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
|
||||||
|
onclick={() => deletePanel(panel.id)}
|
||||||
|
aria-label="Delete chart {i + 1}"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" d="M6 7h12M9 7V5h6v2m-1 0v12H10V7M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-h-9 flex-wrap gap-2">
|
||||||
|
{#each panel.variables as key (key)}
|
||||||
|
{@const def = VARIABLE_BY_KEY.get(key)}
|
||||||
|
{#if def}
|
||||||
|
<div
|
||||||
|
data-chip
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Drag {def.label}"
|
||||||
|
class="flex cursor-grab touch-none items-center gap-1.5 rounded-lg border border-border bg-background py-1.5 pr-1 pl-2.5 text-sm shadow-sm select-none active:cursor-grabbing {dragKey ===
|
||||||
|
key
|
||||||
|
? 'opacity-30'
|
||||||
|
: ''}"
|
||||||
|
onpointerdown={(e) => beginDrag(e, key)}
|
||||||
|
onpointermove={onDragMove}
|
||||||
|
onpointerup={endDrag}
|
||||||
|
onpointercancel={endDrag}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||||
|
style:background-color={def.color}
|
||||||
|
></span>
|
||||||
|
<span class="font-medium">{def.label}</span>
|
||||||
|
<button
|
||||||
|
class="ml-0.5 flex h-5 w-5 cursor-pointer items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
onpointerdown={(e) => e.stopPropagation()}
|
||||||
|
onclick={() => removeVar(key)}
|
||||||
|
aria-label="Remove {def.label}"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2.5"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#if panel.variables.length === 0}
|
||||||
|
<span class="self-center text-xs text-muted-foreground italic"
|
||||||
|
>Drop variables here</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full cursor-pointer rounded-xl border-2 border-dashed border-border py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
|
||||||
|
onclick={addPanel}
|
||||||
|
>
|
||||||
|
+ Add chart
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-2 text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
|
||||||
|
Available variables
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
data-zone="pool"
|
||||||
|
class="flex min-h-12 flex-wrap gap-2 rounded-xl border-2 border-dashed p-3 transition-colors {dropZone ===
|
||||||
|
'pool'
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border'}"
|
||||||
|
>
|
||||||
|
{#each availableVars as def (def.key)}
|
||||||
|
<div
|
||||||
|
data-chip
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Drag {def.label}"
|
||||||
|
class="flex cursor-grab touch-none items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 py-1.5 text-sm text-muted-foreground shadow-sm select-none active:cursor-grabbing {dragKey ===
|
||||||
|
def.key
|
||||||
|
? 'opacity-30'
|
||||||
|
: ''}"
|
||||||
|
onpointerdown={(e) => beginDrag(e, def.key)}
|
||||||
|
onpointermove={onDragMove}
|
||||||
|
onpointerup={endDrag}
|
||||||
|
onpointercancel={endDrag}
|
||||||
|
>
|
||||||
|
<span class="h-2.5 w-2.5 shrink-0 rounded-full" style:background-color={def.color}
|
||||||
|
></span>
|
||||||
|
<span class="font-medium">{def.label}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if availableVars.length === 0}
|
||||||
|
<span class="self-center text-xs text-muted-foreground italic"
|
||||||
|
>All variables are in use</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between border-t border-border px-5 py-3">
|
||||||
|
<button
|
||||||
|
class="cursor-pointer text-xs font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
|
||||||
|
onclick={resetLayout}
|
||||||
|
>
|
||||||
|
Reset to defaults
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded-lg bg-primary px-4 py-1.5 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90"
|
||||||
|
onclick={onClose}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating drag preview -->
|
||||||
|
{#if dragKey !== null && started}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none fixed z-60 flex items-center gap-1.5 rounded-lg border border-primary bg-card px-2.5 py-1.5 text-sm font-medium shadow-xl"
|
||||||
|
style:left="{dragPos.x + 8}px"
|
||||||
|
style:top="{dragPos.y + 8}px"
|
||||||
|
>
|
||||||
|
<span class="h-2.5 w-2.5 shrink-0 rounded-full" style:background-color={dragColor}></span>
|
||||||
|
{dragLabel}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -1,22 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import { storedVariablePrefs } from '$lib/stores/settings';
|
import { type ChartPanel, storedChartLayout } from '$lib/stores/settings';
|
||||||
|
|
||||||
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
|
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
|
||||||
|
|
||||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||||
|
|
||||||
import { CanvasChart, type ChartSeries } from '$lib/charts';
|
import { CanvasChart } from '$lib/charts';
|
||||||
|
|
||||||
import {
|
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||||
type FetchedHourly,
|
import ChartCustomizer from './ChartCustomizer.svelte';
|
||||||
type WeatherUnits,
|
import { type FetchedHourly, type WeatherUnits } from './types';
|
||||||
getPrecipUnit,
|
import { VARIABLE_BY_KEY, buildPanelDef } from './variables';
|
||||||
getTempUnit,
|
|
||||||
getWindDirectionLabel,
|
|
||||||
getWindUnit
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: FetchedHourly;
|
data: FetchedHourly;
|
||||||
@@ -30,14 +26,22 @@
|
|||||||
|
|
||||||
const CHART_GROUP = 'week-meteogram';
|
const CHART_GROUP = 'week-meteogram';
|
||||||
const SECONDS_PER_DAY = 24 * 3600;
|
const SECONDS_PER_DAY = 24 * 3600;
|
||||||
|
const CHART_HEIGHT = 300;
|
||||||
|
|
||||||
let chartComponents: CanvasChart[] = $state([]);
|
let customizerOpen = $state(false);
|
||||||
|
|
||||||
|
// Charts persist across data refetches; entries are null while unmounted.
|
||||||
|
let chartComponents: (CanvasChart | null)[] = $state([]);
|
||||||
|
let liveCharts = $derived(chartComponents.filter((chart): chart is CanvasChart => chart != null));
|
||||||
|
|
||||||
|
// Only render panels that hold at least one known variable.
|
||||||
|
let panels = $derived(
|
||||||
|
$storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k)))
|
||||||
|
);
|
||||||
|
|
||||||
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
|
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
|
||||||
let timestampsSec = $derived(data.timestamps.map((t) => t / 1000));
|
let timestampsSec = $derived(data.timestamps.map((t) => t / 1000));
|
||||||
|
|
||||||
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
|
|
||||||
|
|
||||||
function dayStartSec(day: Date): number | null {
|
function dayStartSec(day: Date): number | null {
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
const tz = data.timezone;
|
const tz = data.timezone;
|
||||||
@@ -60,8 +64,6 @@
|
|||||||
onResetZoom?.();
|
onResetZoom?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Range presets: charts show the full week by default, these (or
|
|
||||||
// Ctrl+scroll) narrow the window.
|
|
||||||
const rangePresets = [
|
const rangePresets = [
|
||||||
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
|
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
|
||||||
{ label: 'Selected day', apply: () => setRangeDays(selectedDay, 1) },
|
{ label: 'Selected day', apply: () => setRangeDays(selectedDay, 1) },
|
||||||
@@ -76,165 +78,46 @@
|
|||||||
return start == null ? undefined : { start, end: start + SECONDS_PER_DAY };
|
return start == null ? undefined : { start, end: start + SECONDS_PER_DAY };
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Series Building ────────────────────────────────────────────────────────
|
// ─── Pictograms (weather icons across the top) ──────────────────────────────
|
||||||
|
|
||||||
let tempUnit = $derived(getTempUnit(units));
|
function isDaytime(tSec: number): boolean {
|
||||||
let precipUnit = $derived(getPrecipUnit(units));
|
return data.daylightBands.some((b) => tSec >= b.start && tSec < b.end);
|
||||||
let windUnit = $derived(getWindUnit(units));
|
|
||||||
|
|
||||||
interface ChartDef {
|
|
||||||
title: string;
|
|
||||||
unit: string;
|
|
||||||
unitRight?: string;
|
|
||||||
yMin?: number;
|
|
||||||
yMinRight?: number;
|
|
||||||
yMaxRight?: number;
|
|
||||||
invertRight?: boolean;
|
|
||||||
showCredit?: boolean;
|
|
||||||
series: ChartSeries[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let chartDefs = $derived.by((): ChartDef[] => {
|
let pictograms = $derived.by((): { t: number; icon: string }[] => {
|
||||||
if (!data) return [];
|
const codes = data.hourly.weather_code ?? [];
|
||||||
|
const out: { t: number; icon: string }[] = [];
|
||||||
const { hourly } = data;
|
for (let i = 0; i < timestampsSec.length; i++) {
|
||||||
// Variables the user disabled in the sidebar are left out entirely
|
const code = codes[i];
|
||||||
const on = (key: string): boolean => $storedVariablePrefs.charts?.[key] ?? true;
|
if (code == null || !isFinite(code)) continue;
|
||||||
const defs: ChartDef[] = [];
|
const t = timestampsSec[i];
|
||||||
|
out.push({ t, icon: getWeatherIconName(code, isDaytime(t)) });
|
||||||
if (on('temperature') || on('cloud_cover')) {
|
|
||||||
const series: ChartSeries[] = [];
|
|
||||||
if (on('cloud_cover')) {
|
|
||||||
series.push({
|
|
||||||
name: 'Cloud Cover',
|
|
||||||
type: 'line',
|
|
||||||
color: 'rgb(150, 150, 150)',
|
|
||||||
data: hourly.cloud_cover,
|
|
||||||
width: 0,
|
|
||||||
fill: true,
|
|
||||||
fillOpacity: 0.25,
|
|
||||||
axis: 'right',
|
|
||||||
format: (v) => `${v.toFixed(0)}%`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (on('temperature')) {
|
|
||||||
series.push({
|
|
||||||
name: 'Temperature',
|
|
||||||
type: 'line',
|
|
||||||
color: '#ef6c00',
|
|
||||||
data: hourly.temperature_2m,
|
|
||||||
width: 3,
|
|
||||||
fill: true,
|
|
||||||
fillOpacity: 0.2,
|
|
||||||
format: (v) => `${v.toFixed(1)} ${tempUnit}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
defs.push({
|
|
||||||
title: [on('temperature') && 'Temperature', on('cloud_cover') && 'Cloud Cover']
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' & '),
|
|
||||||
unit: tempUnit,
|
|
||||||
// Hidden inverted right axis (0-250) so cloud cover hangs from the top,
|
|
||||||
// occupying at most the upper 40% of the plot
|
|
||||||
yMinRight: 0,
|
|
||||||
yMaxRight: 250,
|
|
||||||
invertRight: true,
|
|
||||||
series
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
return out;
|
||||||
if (on('precipitation') || on('precipitation_probability')) {
|
|
||||||
const series: ChartSeries[] = [];
|
|
||||||
if (on('precipitation')) {
|
|
||||||
series.push({
|
|
||||||
name: 'Precipitation',
|
|
||||||
type: 'bar',
|
|
||||||
color: 'rgba(30, 136, 229, 0.8)',
|
|
||||||
data: hourly.precipitation,
|
|
||||||
format: (v) => `${v.toFixed(1)} ${precipUnit}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (on('precipitation_probability')) {
|
|
||||||
series.push({
|
|
||||||
name: 'Precip. Probability',
|
|
||||||
type: 'line',
|
|
||||||
color: '#5c6bc0',
|
|
||||||
data: hourly.precipitation_probability,
|
|
||||||
width: 2,
|
|
||||||
dashed: true,
|
|
||||||
axis: 'right',
|
|
||||||
format: (v) => `${v.toFixed(0)}%`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
defs.push({
|
|
||||||
title: [
|
|
||||||
on('precipitation') && 'Precipitation',
|
|
||||||
on('precipitation_probability') && 'Probability'
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' & '),
|
|
||||||
unit: precipUnit,
|
|
||||||
unitRight: on('precipitation_probability') ? '%' : undefined,
|
|
||||||
yMin: 0,
|
|
||||||
yMinRight: 0,
|
|
||||||
yMaxRight: 100,
|
|
||||||
series
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (on('wind') || on('humidity')) {
|
|
||||||
const series: ChartSeries[] = [];
|
|
||||||
if (on('wind')) {
|
|
||||||
series.push({
|
|
||||||
name: 'Wind Speed',
|
|
||||||
type: 'line',
|
|
||||||
color: '#26a69a',
|
|
||||||
data: hourly.windspeed_10m,
|
|
||||||
width: 2,
|
|
||||||
fill: true,
|
|
||||||
fillOpacity: 0.15,
|
|
||||||
format: (v, i) => {
|
|
||||||
const dir = hourly.winddirection_10m[i];
|
|
||||||
const dirLabel = dir != null && !isNaN(dir) ? ` (${getWindDirectionLabel(dir)})` : '';
|
|
||||||
return `${v.toFixed(0)} ${windUnit}${dirLabel}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (on('humidity')) {
|
|
||||||
series.push({
|
|
||||||
name: 'Humidity',
|
|
||||||
type: 'line',
|
|
||||||
color: '#8d6e63',
|
|
||||||
data: hourly.relative_humidity_2m,
|
|
||||||
width: 2,
|
|
||||||
dashed: true,
|
|
||||||
axis: 'right',
|
|
||||||
format: (v) => `${v.toFixed(0)}%`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
defs.push({
|
|
||||||
title: [on('wind') && 'Wind Speed', on('humidity') && 'Humidity']
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' & '),
|
|
||||||
unit: windUnit,
|
|
||||||
unitRight: on('humidity') ? '%' : undefined,
|
|
||||||
yMin: 0,
|
|
||||||
yMinRight: 0,
|
|
||||||
yMaxRight: 100,
|
|
||||||
series
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (defs.length > 0) defs[defs.length - 1].showCredit = true;
|
|
||||||
|
|
||||||
return defs;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Panel definitions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RenderPanel extends ChartPanel {
|
||||||
|
def: ReturnType<typeof buildPanelDef>;
|
||||||
|
title: string;
|
||||||
|
titleShort: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let renderPanels = $derived.by((): RenderPanel[] =>
|
||||||
|
panels.map((p) => {
|
||||||
|
const def = buildPanelDef(p.variables, data.hourly, units);
|
||||||
|
const title = def.series.map((s) => s.name).join(' · ');
|
||||||
|
const titleShort = def.series.map((s) => s.shortName ?? s.name).join(' · ');
|
||||||
|
return { ...p, def, title, titleShort };
|
||||||
|
})
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="mt-8" in:fade={{ duration: 200 }}>
|
<section class="mt-8" in:fade={{ duration: 200 }}>
|
||||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<h3 class="text-lg font-bold">
|
<h3 class="text-lg font-bold">
|
||||||
Meteogram
|
Meteograms
|
||||||
<span class="font-semibold text-muted-foreground">
|
<span class="font-semibold text-muted-foreground">
|
||||||
– {formatZoned(selectedDay, data.timezone, 'EEEE')}{getRelativeDayLabel(
|
– {formatZoned(selectedDay, data.timezone, 'EEEE')}{getRelativeDayLabel(
|
||||||
selectedDay,
|
selectedDay,
|
||||||
@@ -266,42 +149,73 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
|
||||||
|
onclick={() => (customizerOpen = true)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" d="M4 6h16M4 12h16M4 18h16M8 4v4m8 2v4M6 16v4" />
|
||||||
|
</svg>
|
||||||
|
Customize
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if chartDefs.length > 0}
|
{#if renderPanels.length === 0}
|
||||||
<ChartContainer {loading} chartCount={chartDefs.length} chartHeight={300}>
|
<div
|
||||||
{#each chartDefs as def, i (def.title)}
|
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
|
||||||
<CanvasChart
|
>
|
||||||
bind:this={chartComponents[i]}
|
No meteograms configured — <button
|
||||||
timestamps={timestampsSec}
|
class="cursor-pointer font-semibold text-primary underline-offset-2 hover:underline"
|
||||||
timezone={data.timezone}
|
onclick={() => (customizerOpen = true)}>add some variables</button
|
||||||
series={def.series}
|
>.
|
||||||
bands={data.daylightBands}
|
</div>
|
||||||
highlight={selectedDayHighlight}
|
{:else}
|
||||||
unit={def.unit}
|
<div class="flex flex-col gap-6">
|
||||||
unitRight={def.unitRight}
|
{#each renderPanels as panel, i (panel.id)}
|
||||||
yMin={def.yMin}
|
<div class="rounded-2xl border border-border/70 bg-card p-3 shadow-sm md:p-4">
|
||||||
yMinRight={def.yMinRight}
|
<div class="mb-1 flex items-center justify-between px-1">
|
||||||
yMaxRight={def.yMaxRight}
|
<h4 class="truncate text-sm font-bold text-muted-foreground">
|
||||||
invertRight={def.invertRight}
|
<span class="hidden md:inline">{panel.title}</span>
|
||||||
title={def.title}
|
<span class="md:hidden">{panel.titleShort}</span>
|
||||||
showCredit={def.showCredit}
|
</h4>
|
||||||
showLegend
|
</div>
|
||||||
height={300}
|
<ChartContainer {loading} chartCount={1} chartHeight={CHART_HEIGHT} minWidth={520}>
|
||||||
group={CHART_GROUP}
|
<CanvasChart
|
||||||
/>
|
bind:this={chartComponents[i]}
|
||||||
|
timestamps={timestampsSec}
|
||||||
|
timezone={data.timezone}
|
||||||
|
series={panel.def.series}
|
||||||
|
bands={data.daylightBands}
|
||||||
|
pictograms={panel.def.hasPictograms ? pictograms : []}
|
||||||
|
highlight={selectedDayHighlight}
|
||||||
|
unit={panel.def.unit}
|
||||||
|
unitRight={panel.def.unitRight}
|
||||||
|
yMin={panel.def.yMin}
|
||||||
|
zeroBaseLeft={panel.def.zeroBaseLeft}
|
||||||
|
yMinRight={panel.def.yMinRight}
|
||||||
|
yMaxRight={panel.def.yMaxRight}
|
||||||
|
showCredit={i === renderPanels.length - 1}
|
||||||
|
showLegend
|
||||||
|
height={CHART_HEIGHT}
|
||||||
|
group={CHART_GROUP}
|
||||||
|
/>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</ChartContainer>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 md:mt-10">
|
<div class="mt-6 md:mt-10">
|
||||||
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
|
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
All chart variables are hidden — enable some under “Variables”.
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<ChartCustomizer open={customizerOpen} onClose={() => (customizerOpen = false)} />
|
||||||
|
|||||||
@@ -23,15 +23,6 @@
|
|||||||
{ key: 'precipitation', label: 'Precipitation' }
|
{ key: 'precipitation', label: 'Precipitation' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const chartVariables = [
|
|
||||||
{ key: 'temperature', label: 'Temperature' },
|
|
||||||
{ key: 'cloud_cover', label: 'Cloud cover' },
|
|
||||||
{ key: 'precipitation', label: 'Precipitation' },
|
|
||||||
{ key: 'precipitation_probability', label: 'Precipitation probability' },
|
|
||||||
{ key: 'wind', label: 'Wind speed' },
|
|
||||||
{ key: 'humidity', label: 'Humidity' }
|
|
||||||
];
|
|
||||||
|
|
||||||
function toggle(section: 'table' | 'charts', key: string) {
|
function toggle(section: 'table' | 'charts', key: string) {
|
||||||
storedVariablePrefs.update((prefs) => {
|
storedVariablePrefs.update((prefs) => {
|
||||||
const current = { ...defaultVariablePrefs[section], ...prefs[section] };
|
const current = { ...defaultVariablePrefs[section], ...prefs[section] };
|
||||||
@@ -107,26 +98,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<p class="text-xs text-muted-foreground">
|
||||||
<h3 class="mb-2 text-[11px] font-bold tracking-wider text-primary uppercase">
|
Meteogram variables are configured with the <span class="font-semibold">Customize</span>
|
||||||
Meteogram charts
|
button above the charts.
|
||||||
</h3>
|
</p>
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
{#each chartVariables as variable (variable.key)}
|
|
||||||
<div class="flex items-center gap-2.5 rounded-md px-1 py-1 hover:bg-muted/60">
|
|
||||||
<Checkbox
|
|
||||||
id="chart_var_{variable.key}"
|
|
||||||
class="cursor-pointer"
|
|
||||||
checked={$storedVariablePrefs.charts?.[variable.key] ?? true}
|
|
||||||
onCheckedChange={() => toggle('charts', variable.key)}
|
|
||||||
/>
|
|
||||||
<Label class="flex-1 cursor-pointer text-sm" for="chart_var_{variable.key}">
|
|
||||||
{variable.label}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-border px-5 py-3">
|
<div class="border-t border-border px-5 py-3">
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
/**
|
||||||
|
* Registry of every variable that can be plotted on the customizable
|
||||||
|
* meteograms. Each entry carries the metadata needed to build a chart series
|
||||||
|
* (data field, render style, colour, unit family) so the panels can be
|
||||||
|
* assembled dynamically from a user-defined layout.
|
||||||
|
*/
|
||||||
|
import { getColor } from '../../utils/colors';
|
||||||
|
import {
|
||||||
|
type WeatherUnits,
|
||||||
|
getPrecipUnit,
|
||||||
|
getTempUnit,
|
||||||
|
getWindDirectionLabel,
|
||||||
|
getWindUnit
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
import type { ChartSeries } from '$lib/charts';
|
||||||
|
import type { WeekHourlyData } from '$lib/services/weather';
|
||||||
|
|
||||||
|
/** Families of variables that share a y-axis and unit. */
|
||||||
|
export type UnitKind =
|
||||||
|
'temp' | 'precip' | 'snow' | 'wind' | 'percent' | 'pressure' | 'uv' | 'distance' | 'energy';
|
||||||
|
|
||||||
|
export interface ChartVariableDef {
|
||||||
|
/** Stable id used in the persisted layout */
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
/** Short label for the tooltip / legend */
|
||||||
|
short: string;
|
||||||
|
/** Data array on the hourly response */
|
||||||
|
field: keyof WeekHourlyData;
|
||||||
|
/** Open-Meteo API variable name (defaults to `field` when identical) */
|
||||||
|
api?: string;
|
||||||
|
type: 'line' | 'bar';
|
||||||
|
kind: UnitKind;
|
||||||
|
color: string;
|
||||||
|
dashed?: boolean;
|
||||||
|
fill?: boolean;
|
||||||
|
fillOpacity?: number;
|
||||||
|
width?: number;
|
||||||
|
/** Stroke the line coloured by the temperature scale */
|
||||||
|
colorScale?: boolean;
|
||||||
|
/** Draw a contrasting halo under the line */
|
||||||
|
outline?: boolean;
|
||||||
|
/** Annotate local minima / maxima with their value */
|
||||||
|
extrema?: boolean;
|
||||||
|
/** Draw weather-code pictograms across the top of the chart */
|
||||||
|
pictograms?: boolean;
|
||||||
|
/** Render as a puffy cloud band hanging from the top (0-100 → 0-40px) */
|
||||||
|
cloudBand?: boolean;
|
||||||
|
/** Transform raw values before plotting (e.g. m → km) */
|
||||||
|
transform?: (v: number) => number;
|
||||||
|
/** Preset range to use when this variable lands on a shared right axis */
|
||||||
|
rightPreset?: { min: number; max: number; invert?: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHART_VARIABLES: ChartVariableDef[] = [
|
||||||
|
{
|
||||||
|
key: 'temperature',
|
||||||
|
label: 'Temperature',
|
||||||
|
short: 'Temp',
|
||||||
|
field: 'temperature_2m',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'temp',
|
||||||
|
color: '#ef6c00',
|
||||||
|
width: 4,
|
||||||
|
fill: true,
|
||||||
|
fillOpacity: 0.12,
|
||||||
|
colorScale: true,
|
||||||
|
outline: true,
|
||||||
|
extrema: true,
|
||||||
|
pictograms: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'apparent_temperature',
|
||||||
|
label: 'Apparent Temp',
|
||||||
|
short: 'Feels',
|
||||||
|
field: 'apparent_temperature',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'temp',
|
||||||
|
color: '#c2410c',
|
||||||
|
width: 2,
|
||||||
|
dashed: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dew_point',
|
||||||
|
label: 'Dew Point',
|
||||||
|
short: 'Dew',
|
||||||
|
field: 'dew_point_2m',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'temp',
|
||||||
|
color: '#0e7490',
|
||||||
|
width: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cloud_cover',
|
||||||
|
label: 'Cloud Cover',
|
||||||
|
short: 'Cloud',
|
||||||
|
field: 'cloud_cover',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'percent',
|
||||||
|
color: 'rgb(150, 155, 165)',
|
||||||
|
cloudBand: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cloud_cover_low',
|
||||||
|
label: 'Cloud Cover Low',
|
||||||
|
short: 'Low',
|
||||||
|
field: 'cloud_cover_low',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'percent',
|
||||||
|
color: '#94a3b8',
|
||||||
|
width: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cloud_cover_mid',
|
||||||
|
label: 'Cloud Cover Mid',
|
||||||
|
short: 'Mid',
|
||||||
|
field: 'cloud_cover_mid',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'percent',
|
||||||
|
color: '#64748b',
|
||||||
|
width: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cloud_cover_high',
|
||||||
|
label: 'Cloud Cover High',
|
||||||
|
short: 'High',
|
||||||
|
field: 'cloud_cover_high',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'percent',
|
||||||
|
color: '#cbd5e1',
|
||||||
|
width: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'precipitation',
|
||||||
|
label: 'Precipitation',
|
||||||
|
short: 'Precip',
|
||||||
|
field: 'precipitation',
|
||||||
|
type: 'bar',
|
||||||
|
kind: 'precip',
|
||||||
|
color: 'rgba(30, 136, 229, 0.8)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'precipitation_probability',
|
||||||
|
label: 'Precip. Probability',
|
||||||
|
short: 'PoP',
|
||||||
|
field: 'precipitation_probability',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'percent',
|
||||||
|
color: '#5c6bc0',
|
||||||
|
width: 2,
|
||||||
|
dashed: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rain',
|
||||||
|
label: 'Rain',
|
||||||
|
short: 'Rain',
|
||||||
|
field: 'rain',
|
||||||
|
type: 'bar',
|
||||||
|
kind: 'precip',
|
||||||
|
color: 'rgba(37, 99, 235, 0.75)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'showers',
|
||||||
|
label: 'Showers',
|
||||||
|
short: 'Shwr',
|
||||||
|
field: 'showers',
|
||||||
|
type: 'bar',
|
||||||
|
kind: 'precip',
|
||||||
|
color: 'rgba(6, 182, 212, 0.75)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'snowfall',
|
||||||
|
label: 'Snowfall',
|
||||||
|
short: 'Snow',
|
||||||
|
field: 'snowfall',
|
||||||
|
type: 'bar',
|
||||||
|
kind: 'snow',
|
||||||
|
color: 'rgba(147, 197, 253, 0.9)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'wind',
|
||||||
|
label: 'Wind Speed',
|
||||||
|
short: 'Wind',
|
||||||
|
field: 'windspeed_10m',
|
||||||
|
api: 'wind_speed_10m',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'wind',
|
||||||
|
color: '#26a69a',
|
||||||
|
width: 2,
|
||||||
|
fill: true,
|
||||||
|
fillOpacity: 0.15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'wind_gusts',
|
||||||
|
label: 'Wind Gusts',
|
||||||
|
short: 'Gusts',
|
||||||
|
field: 'wind_gusts_10m',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'wind',
|
||||||
|
color: '#0d9488',
|
||||||
|
width: 2,
|
||||||
|
dashed: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'humidity',
|
||||||
|
label: 'Humidity',
|
||||||
|
short: 'RH',
|
||||||
|
field: 'relative_humidity_2m',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'percent',
|
||||||
|
color: '#8d6e63',
|
||||||
|
width: 2,
|
||||||
|
dashed: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pressure_msl',
|
||||||
|
label: 'Pressure (MSL)',
|
||||||
|
short: 'MSLP',
|
||||||
|
field: 'pressure_msl',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'pressure',
|
||||||
|
color: '#7c3aed',
|
||||||
|
width: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'surface_pressure',
|
||||||
|
label: 'Surface Pressure',
|
||||||
|
short: 'Psfc',
|
||||||
|
field: 'surface_pressure',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'pressure',
|
||||||
|
color: '#a855f7',
|
||||||
|
width: 2,
|
||||||
|
dashed: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'uv_index',
|
||||||
|
label: 'UV Index',
|
||||||
|
short: 'UV',
|
||||||
|
field: 'uv_index',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'uv',
|
||||||
|
color: '#eab308',
|
||||||
|
width: 2,
|
||||||
|
fill: true,
|
||||||
|
fillOpacity: 0.15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'visibility',
|
||||||
|
label: 'Visibility',
|
||||||
|
short: 'Vis',
|
||||||
|
field: 'visibility',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'distance',
|
||||||
|
color: '#0891b2',
|
||||||
|
width: 2,
|
||||||
|
transform: (v) => v / 1000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cape',
|
||||||
|
label: 'CAPE',
|
||||||
|
short: 'CAPE',
|
||||||
|
field: 'cape',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'energy',
|
||||||
|
color: '#dc2626',
|
||||||
|
width: 2,
|
||||||
|
fill: true,
|
||||||
|
fillOpacity: 0.12
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VARIABLE_BY_KEY: Map<string, ChartVariableDef> = new Map(
|
||||||
|
CHART_VARIABLES.map((v) => [v.key, v])
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Open-Meteo API variable name for a registry entry. */
|
||||||
|
export function apiNameOf(def: ChartVariableDef): string {
|
||||||
|
return def.api ?? def.field;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The set of API hourly variables needed to render the current table rows and
|
||||||
|
* chart layout, so the fetch requests only what is actually shown.
|
||||||
|
*/
|
||||||
|
export function neededHourlyApiVars(
|
||||||
|
tablePrefs: Record<string, boolean> | undefined,
|
||||||
|
layoutKeys: string[]
|
||||||
|
): string[] {
|
||||||
|
const on = (key: string): boolean => tablePrefs?.[key] ?? true;
|
||||||
|
const s = new Set<string>();
|
||||||
|
|
||||||
|
// Hourly table rows
|
||||||
|
if (on('icons')) s.add('weather_code');
|
||||||
|
if (on('temperature')) s.add('temperature_2m');
|
||||||
|
if (on('feels')) s.add('apparent_temperature');
|
||||||
|
if (on('wind')) {
|
||||||
|
s.add('wind_speed_10m');
|
||||||
|
s.add('wind_direction_10m');
|
||||||
|
}
|
||||||
|
if (on('humidity')) s.add('relative_humidity_2m');
|
||||||
|
if (on('clouds')) s.add('cloud_cover');
|
||||||
|
if (on('precipitation')) {
|
||||||
|
s.add('precipitation');
|
||||||
|
s.add('precipitation_probability');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meteogram variables
|
||||||
|
for (const key of layoutKeys) {
|
||||||
|
const def = VARIABLE_BY_KEY.get(key);
|
||||||
|
if (!def) continue;
|
||||||
|
s.add(apiNameOf(def));
|
||||||
|
if (def.pictograms) s.add('weather_code');
|
||||||
|
if (def.key === 'wind') s.add('wind_direction_10m');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...s];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unit label for a variable family, honouring the user's unit settings. */
|
||||||
|
export function unitForKind(kind: UnitKind, units: WeatherUnits): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'temp':
|
||||||
|
return getTempUnit(units);
|
||||||
|
case 'precip':
|
||||||
|
return getPrecipUnit(units);
|
||||||
|
case 'snow':
|
||||||
|
return 'cm';
|
||||||
|
case 'wind':
|
||||||
|
return getWindUnit(units);
|
||||||
|
case 'percent':
|
||||||
|
return '%';
|
||||||
|
case 'pressure':
|
||||||
|
return 'hPa';
|
||||||
|
case 'uv':
|
||||||
|
return '';
|
||||||
|
case 'distance':
|
||||||
|
return 'km';
|
||||||
|
case 'energy':
|
||||||
|
return 'J/kg';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Families whose axis should always start at zero. */
|
||||||
|
export function isZeroBased(kind: UnitKind): boolean {
|
||||||
|
return kind !== 'temp' && kind !== 'pressure';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sensible decimal places for tooltip / label formatting. */
|
||||||
|
export function decimalsForKind(kind: UnitKind): number {
|
||||||
|
switch (kind) {
|
||||||
|
case 'precip':
|
||||||
|
case 'snow':
|
||||||
|
case 'uv':
|
||||||
|
case 'distance':
|
||||||
|
return 1;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PanelDef {
|
||||||
|
series: ChartSeries[];
|
||||||
|
unit: string;
|
||||||
|
unitRight?: string;
|
||||||
|
yMin?: number;
|
||||||
|
yMinRight?: number;
|
||||||
|
yMaxRight?: number;
|
||||||
|
/** Whether the left axis should include zero (false for pressure) */
|
||||||
|
zeroBaseLeft: boolean;
|
||||||
|
hasPictograms: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a chart definition for one panel: turns its ordered variable keys into
|
||||||
|
* series and works out the left / right axis units. The first variable's family
|
||||||
|
* owns the left axis; the first differing family gets the right axis.
|
||||||
|
*/
|
||||||
|
export function buildPanelDef(
|
||||||
|
variableKeys: string[],
|
||||||
|
hourly: WeekHourlyData,
|
||||||
|
units: WeatherUnits
|
||||||
|
): PanelDef {
|
||||||
|
const defs = variableKeys
|
||||||
|
.map((k) => VARIABLE_BY_KEY.get(k))
|
||||||
|
.filter((d): d is ChartVariableDef => d != null);
|
||||||
|
|
||||||
|
// Cloud-band variables float above the plot and don't claim an axis.
|
||||||
|
const axisDefs = defs.filter((d) => !d.cloudBand);
|
||||||
|
const kinds: UnitKind[] = [];
|
||||||
|
for (const d of axisDefs) if (!kinds.includes(d.kind)) kinds.push(d.kind);
|
||||||
|
const leftKind = kinds[0];
|
||||||
|
const rightKind = kinds.find((k) => k !== leftKind);
|
||||||
|
|
||||||
|
const series: ChartSeries[] = defs.map((d) => {
|
||||||
|
const raw = (hourly[d.field] as number[]) ?? [];
|
||||||
|
const data: (number | null)[] = d.transform
|
||||||
|
? raw.map((v) => (v == null || !isFinite(v) ? null : d.transform!(v)))
|
||||||
|
: raw;
|
||||||
|
const kindUnit = unitForKind(d.kind, units);
|
||||||
|
const dec = decimalsForKind(d.kind);
|
||||||
|
const axis: 'left' | 'right' = d.cloudBand || d.kind === leftKind ? 'left' : 'right';
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: d.label,
|
||||||
|
shortName: d.short,
|
||||||
|
type: d.type,
|
||||||
|
color: d.color,
|
||||||
|
data,
|
||||||
|
width: d.width,
|
||||||
|
fill: d.fill,
|
||||||
|
fillOpacity: d.fillOpacity,
|
||||||
|
dashed: d.dashed,
|
||||||
|
axis,
|
||||||
|
cloudBand: d.cloudBand,
|
||||||
|
segmentColor: d.colorScale ? (v: number) => getColor(v, units.temperature_unit) : undefined,
|
||||||
|
outline: d.outline,
|
||||||
|
labelExtrema: d.extrema,
|
||||||
|
labelFormat: d.extrema
|
||||||
|
? (v: number) => (d.kind === 'temp' ? `${v.toFixed(0)}°` : `${v.toFixed(dec)}${kindUnit}`)
|
||||||
|
: undefined,
|
||||||
|
format:
|
||||||
|
d.key === 'wind'
|
||||||
|
? (v: number, i: number) => {
|
||||||
|
const dir = hourly.winddirection_10m?.[i];
|
||||||
|
const dl = dir != null && !isNaN(dir) ? ` (${getWindDirectionLabel(dir)})` : '';
|
||||||
|
return `${v.toFixed(dec)} ${kindUnit}${dl}`;
|
||||||
|
}
|
||||||
|
: (v: number) => `${v.toFixed(dec)}${kindUnit ? ' ' + kindUnit : ''}`
|
||||||
|
} satisfies ChartSeries;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rightZero = rightKind ? isZeroBased(rightKind) : false;
|
||||||
|
return {
|
||||||
|
series,
|
||||||
|
unit: leftKind ? unitForKind(leftKind, units) : '',
|
||||||
|
unitRight: rightKind ? unitForKind(rightKind, units) : undefined,
|
||||||
|
yMin: leftKind && isZeroBased(leftKind) ? 0 : undefined,
|
||||||
|
// pressure sits far from zero, so its axis is derived from the data
|
||||||
|
zeroBaseLeft: leftKind !== 'pressure',
|
||||||
|
yMinRight: rightKind && rightZero ? 0 : undefined,
|
||||||
|
yMaxRight: rightKind === 'percent' ? 100 : undefined,
|
||||||
|
hasPictograms: defs.some((d) => d.pictograms)
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user