feat: week view overhaul #8

Merged
vincent merged 2 commits from feat/week-view-overhaul into main 2026-07-25 09:53:40 +02:00
14 changed files with 753 additions and 185 deletions
Showing only changes of commit 409096a550 - Show all commits
+149 -38
View File
@@ -86,6 +86,21 @@
delete groups[name]; delete groups[name];
} }
} }
/**
* Drive the shared crosshair of a chart group from the outside (e.g. hovering
* the hourly table). `time` is epoch seconds, or null to clear. No-op if no
* chart in that group is currently mounted.
*/
export function setGroupHover(name: string, time: number | null): void {
const state = groups[name];
if (state) state.hover = time;
}
/** Current shared zoom range of a group (null = full range), reactive. */
export function groupRange(name: string): { start: number; end: number } | null {
return groups[name]?.range ?? null;
}
</script> </script>
<script lang="ts"> <script lang="ts">
@@ -109,6 +124,8 @@
bands?: { start: number; end: number }[]; bands?: { start: number; end: number }[];
/** Weather pictograms drawn across the top (t in epoch seconds) */ /** Weather pictograms drawn across the top (t in epoch seconds) */
pictograms?: { t: number; icon: string }[]; pictograms?: { t: number; icon: string }[];
/** Wind-direction arrows drawn across the top (t in epoch seconds, deg from N) */
windArrows?: { t: number; deg: number }[];
/** Highlighted time range (epoch seconds), e.g. the selected day */ /** 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) */
@@ -131,6 +148,12 @@
yMaxRight?: number; yMaxRight?: number;
/** Invert the right axis (min at the top) */ /** Invert the right axis (min at the top) */
invertRight?: boolean; invertRight?: boolean;
/** Reserve the right-axis gutter even without a right axis (keeps a row of
* stacked charts identically sized) */
reserveRightAxis?: boolean;
/** Reserve this many top icon-rows even if this chart has fewer, so a row
* of stacked charts share the same plot rectangle */
reserveTopRows?: number;
/** Chart title drawn top-left on the canvas */ /** Chart title drawn top-left on the canvas */
title?: string; title?: string;
/** Smaller subtitle drawn under the title */ /** Smaller subtitle drawn under the title */
@@ -151,6 +174,7 @@
series, series,
bands = [], bands = [],
pictograms = [], pictograms = [],
windArrows = [],
highlight, highlight,
unit = '', unit = '',
unitRight, unitRight,
@@ -162,6 +186,8 @@
yMinRight, yMinRight,
yMaxRight, yMaxRight,
invertRight = false, invertRight = false,
reserveRightAxis = false,
reserveTopRows = 0,
title, title,
subtitle, subtitle,
showLegend = false, showLegend = false,
@@ -175,6 +201,10 @@
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;
// Top icon rows (weather pictograms / wind arrows)
const ICON_ROW_H = 30; // reserved height per icon row
const ICON_BAND_H = 28; // visible band height
const ICON_PX = 26; // pictogram size
// Puffy cloud band: 100% cover hangs 40px from the top of the plot // Puffy cloud band: 100% cover hangs 40px from the top of the plot
const CLOUD_BAND_MAX = 40; const CLOUD_BAND_MAX = 40;
@@ -186,15 +216,8 @@
let themeVersion = $state(0); let themeVersion = $state(0);
const legendHidden = new SvelteSet<string>(); const legendHidden = new SvelteSet<string>();
// Briefly shown when the user scrolls over the chart without holding Ctrl // Drag-to-zoom selection rectangle (plot-local pixel x), null when inactive.
let zoomHintVisible = $state(false); let dragSelect = $state<{ x0: number; x1: number } | null>(null);
let zoomHintTimeout: ReturnType<typeof setTimeout> | undefined;
function showZoomHint(): void {
zoomHintVisible = true;
clearTimeout(zoomHintTimeout);
zoomHintTimeout = setTimeout(() => (zoomHintVisible = false), 1200);
}
// Zoom range and crosshair: either group-shared or local to this chart. // Zoom range and crosshair: either group-shared or local to this chart.
// The group is acquired once at component init (the prop is treated as fixed). // The group is acquired once at component init (the prop is treated as fixed).
@@ -205,7 +228,6 @@
onDestroy(() => { onDestroy(() => {
if (groupName) releaseGroup(groupName); if (groupName) releaseGroup(groupName);
clearTimeout(zoomHintTimeout);
}); });
// ─── Derived: view window & scales ────────────────────────────────────────── // ─── Derived: view window & scales ──────────────────────────────────────────
@@ -230,9 +252,14 @@
// Tighter left gutter on narrow screens so axis labels sit near the edge // Tighter left gutter on narrow screens so axis labels sit near the edge
let padLeft = $derived(width > 0 && width < 520 ? 38 : 60); let padLeft = $derived(width > 0 && width < 520 ? 38 : 60);
let padRight = $derived(hasRightAxis ? 56 : 20); // Reserve the right gutter when this chart (or a sibling, via reserveRightAxis)
// Reserve a slim row at the very top for weather pictograms when present // has a right axis, so a stacked row of charts share the same plot width.
let padTop = $derived((title ? (subtitle ? 66 : 46) : 28) + (pictograms.length > 0 ? 22 : 0)); let padRight = $derived(hasRightAxis || reserveRightAxis ? 56 : 20);
// Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps
// a stacked row of charts the same height even if some have fewer icon rows.
let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0));
let iconRows = $derived(Math.max(ownIconRows, reserveTopRows));
let padTop = $derived((title ? (subtitle ? 66 : 46) : 28) + iconRows * ICON_ROW_H);
let plotW = $derived(Math.max(1, width - padLeft - padRight)); let 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));
@@ -408,21 +435,50 @@
// ─── Pictograms (DOM overlay across the top) ───────────────────────────────── // ─── Pictograms (DOM overlay across the top) ─────────────────────────────────
// Thin the icons so they never crowd: keep ≥ 34px apart within the view. // The icon band spans exactly the plot area so the icons line up with the
// data (and axis) below and never overhang the plot's cut-off edge.
let iconBandLeft = $derived(padLeft);
let iconBandWidth = $derived(plotW);
function iconBandX(t: number): number {
return xPix(t) - padLeft;
}
// Thin the icons so they never crowd: keep ≥ 40px apart within the band.
let visiblePictograms = $derived.by((): { x: number; icon: string }[] => { let visiblePictograms = $derived.by((): { x: number; icon: string }[] => {
if (pictograms.length === 0 || width <= 0) return []; if (pictograms.length === 0 || width <= 0) return [];
const out: { x: number; icon: string }[] = []; const out: { x: number; icon: string }[] = [];
let lastX = -Infinity; let lastX = -Infinity;
for (const p of pictograms) { for (const p of pictograms) {
if (p.t < viewStart || p.t > viewEnd) continue; if (p.t < viewStart || p.t > viewEnd) continue;
const x = xPix(p.t); const x = iconBandX(p.t);
if (x - lastX < 34) continue; if (x - lastX < 40) continue;
out.push({ x, icon: p.icon }); out.push({ x, icon: p.icon });
lastX = x; lastX = x;
} }
return out; return out;
}); });
// Same thinning for the wind-direction arrow row.
let visibleWindArrows = $derived.by((): { x: number; deg: number }[] => {
if (windArrows.length === 0 || width <= 0) return [];
const out: { x: number; deg: number }[] = [];
let lastX = -Infinity;
for (const a of windArrows) {
if (a.t < viewStart || a.t > viewEnd) continue;
const x = iconBandX(a.t);
if (x - lastX < 40) continue;
out.push({ x, deg: a.deg });
lastX = x;
}
return out;
});
// Vertical offset (px from container top) of each icon row's top edge, anchored
// just above the plot. When both rows are present, pictograms sit above the
// wind arrows (which stay closest to the plot).
let pictoRowTop = $derived(padTop - (windArrows.length > 0 ? 2 : 1) * ICON_ROW_H + 2);
let windRowTop = $derived(padTop - ICON_ROW_H + 2);
// ─── Local minima / maxima (for value labels) ──────────────────────────────── // ─── Local minima / maxima (for value labels) ────────────────────────────────
function findExtrema(data: (number | null)[]): { i: number; type: 'min' | 'max' }[] { function findExtrema(data: (number | null)[]): { i: number; type: 'min' | 'max' }[] {
@@ -760,6 +816,8 @@
ctx.lineWidth = 3; ctx.lineWidth = 3;
ctx.strokeStyle = bgColor; ctx.strokeStyle = bgColor;
ctx.fillStyle = strongColor; ctx.fillStyle = strongColor;
// clear the (possibly thick) line + its outline before the text sits
const off = (s.width ?? 2) / 2 + 7;
for (const ext of findExtrema(s.data)) { for (const ext of findExtrema(s.data)) {
const t = timestamps[ext.i]; const t = timestamps[ext.i];
if (t < viewStart || t > viewEnd) continue; if (t < viewStart || t > viewEnd) continue;
@@ -767,9 +825,12 @@
const x = xPix(t); const x = xPix(t);
const y = yPix(v, axis); const y = yPix(v, axis);
const label = fmt(v); const label = fmt(v);
const ly = ext.type === 'max' ? y - 8 : y + 15; const ly = ext.type === 'max' ? y - off : y + off + 8;
ctx.strokeText(label, x, ly); // keep the centred label fully inside the plot so it never clips
ctx.fillText(label, x, ly); const halfW = ctx.measureText(label).width / 2 + 2;
const lx = Math.max(padLeft + halfW, Math.min(plotRight - halfW, x));
ctx.strokeText(label, lx, ly);
ctx.fillText(label, lx, ly);
} }
} }
} }
@@ -890,10 +951,12 @@
// Touch gesture intent. On touch we defer pointer capture until we know // 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 // 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). // so the meteograms don't hijack scrolling (and don't flash the tooltip).
let gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' = 'none'; let gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' | 'select' = 'none';
let touchStart: { x: number; y: number; start: number; end: number } | null = null; let touchStart: { x: number; y: number; start: number; end: number } | null = null;
let selStartX = 0; // drag-to-zoom anchor (canvas-local px)
const localX = (e: { clientX: number }): number => e.clientX - el.getBoundingClientRect().left; const localX = (e: { clientX: number }): number => e.clientX - el.getBoundingClientRect().left;
const clampPlotX = (x: number): number => Math.max(padLeft, Math.min(width - padRight, x));
const updateHover = (e: PointerEvent): void => { const updateHover = (e: PointerEvent): void => {
const t = pixToTime(localX(e)); const t = pixToTime(localX(e));
@@ -914,10 +977,13 @@
} }
if (e.pointerType === 'mouse') { if (e.pointerType === 'mouse') {
// Mouse: click-drag selects a range to zoom into.
el.setPointerCapture(e.pointerId); el.setPointerCapture(e.pointerId);
panStart = { x: e.clientX, start: viewStart, end: viewEnd }; selStartX = clampPlotX(localX(e));
dragSelect = null;
panStart = null;
pinchStart = null; pinchStart = null;
gesture = zoomed ? 'pan' : 'inspect'; gesture = 'select';
} else { } else {
// Touch: wait for the first move to reveal scroll vs inspect intent. // Touch: wait for the first move to reveal scroll vs inspect intent.
touchStart = { x: e.clientX, y: e.clientY, start: viewStart, end: viewEnd }; touchStart = { x: e.clientX, y: e.clientY, start: viewStart, end: viewEnd };
@@ -941,6 +1007,16 @@
return; return;
} }
// Mouse drag-to-zoom: track the selection rectangle
if (gesture === 'select' && e.pointerType === 'mouse' && pointers.size === 1) {
const x = clampPlotX(localX(e));
if (dragSelect || Math.abs(x - selStartX) >= 3) {
dragSelect = { x0: selStartX, x1: x };
setHover(null);
}
return;
}
// Resolve touch intent from the initial drag direction // Resolve touch intent from the initial drag direction
if (e.pointerType !== 'mouse' && gesture === 'none' && touchStart && pointers.size === 1) { if (e.pointerType !== 'mouse' && gesture === 'none' && touchStart && pointers.size === 1) {
const dx = Math.abs(e.clientX - touchStart.x); const dx = Math.abs(e.clientX - touchStart.x);
@@ -970,6 +1046,14 @@
}; };
const onPointerUp = (e: PointerEvent): void => { const onPointerUp = (e: PointerEvent): void => {
// Commit a mouse drag-to-zoom selection (if it spans a real range).
if (gesture === 'select' && e.pointerType === 'mouse' && dragSelect) {
const a = pixToTime(dragSelect.x0);
const b = pixToTime(dragSelect.x1);
if (Math.abs(a - b) > 0) applyRange(Math.min(a, b), Math.max(a, b));
}
dragSelect = null;
pointers.delete(e.pointerId); pointers.delete(e.pointerId);
if (pointers.size < 2) pinchStart = null; if (pointers.size < 2) pinchStart = null;
if (pointers.size < 1) { if (pointers.size < 1) {
@@ -988,7 +1072,6 @@
// Zoom only while Ctrl (or ⌘) is held — a plain scroll should keep // Zoom only while Ctrl (or ⌘) is held — a plain scroll should keep
// scrolling the page. Trackpad pinch also arrives as ctrlKey wheel. // scrolling the page. Trackpad pinch also arrives as ctrlKey wheel.
if (!e.ctrlKey && !e.metaKey) { if (!e.ctrlKey && !e.metaKey) {
showZoomHint();
return; return;
} }
e.preventDefault(); e.preventDefault();
@@ -1034,16 +1117,32 @@
style:touch-action="pan-y" style:touch-action="pan-y"
></canvas> ></canvas>
<!-- Weather pictograms across the top of the plot --> <!-- Drag-to-zoom selection rectangle -->
{#if dragSelect}
<div
class="pointer-events-none absolute z-20 border-x-2 border-primary/70 bg-primary/15"
style:left="{Math.min(dragSelect.x0, dragSelect.x1)}px"
style:top="{padTop}px"
style:width="{Math.abs(dragSelect.x1 - dragSelect.x0)}px"
style:height="{plotH}px"
></div>
{/if}
<!-- Weather pictograms: a bordered band across the top of the plot -->
{#if visiblePictograms.length > 0} {#if visiblePictograms.length > 0}
<div class="pointer-events-none absolute inset-0 z-10" style:top="{padTop - 24}px"> <div
class="pointer-events-none absolute z-10 overflow-hidden rounded-lg border border-border/60 bg-muted/30"
style:left="{iconBandLeft}px"
style:top="{pictoRowTop}px"
style:width="{iconBandWidth}px"
style:height="{ICON_BAND_H}px"
>
{#each visiblePictograms as p (p.x)} {#each visiblePictograms as p (p.x)}
<svg <svg
class="absolute fill-foreground" class="absolute top-px fill-foreground"
width="20" width={ICON_PX}
height="20" height={ICON_PX}
style:left="{p.x - 10}px" style:left="{Math.max(2, Math.min(iconBandWidth - ICON_PX - 2, p.x - ICON_PX / 2))}px"
style:top="0"
> >
<use xlink:href="/images/weather-icons/{p.icon}.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/{p.icon}.svg#Layer_1"></use>
</svg> </svg>
@@ -1051,16 +1150,28 @@
</div> </div>
{/if} {/if}
{#if zoomHintVisible} <!-- Wind-direction arrows: a matching band -->
{#if visibleWindArrows.length > 0}
<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 z-10 overflow-hidden rounded-lg border border-border/60 bg-muted/30"
style:left="{iconBandLeft}px"
style:top="{windRowTop}px"
style:width="{iconBandWidth}px"
style:height="{ICON_BAND_H}px"
> >
<span {#each visibleWindArrows as a (a.x)}
class="rounded-lg bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg" <span
> class="absolute top-px inline-flex items-center justify-center"
Hold <kbd class="rounded border border-border bg-muted px-1.5 py-0.5 text-xs">Ctrl</kbd> style:width="{ICON_PX}px"
and scroll to zoom style:height="{ICON_PX}px"
</span> style:left="{Math.max(2, Math.min(iconBandWidth - ICON_PX - 2, a.x - ICON_PX / 2))}px"
style:transform="rotate({a.deg}deg)"
>
<svg class="fill-foreground/80" width="22" height="22">
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
</svg>
</span>
{/each}
</div> </div>
{/if} {/if}
+1 -1
View File
@@ -5,7 +5,7 @@
* import { CanvasChart, buildDaylightBands, SERIES_COLORS } from '$lib/charts'; * import { CanvasChart, buildDaylightBands, SERIES_COLORS } from '$lib/charts';
*/ */
export { default as CanvasChart } from './CanvasChart.svelte'; export { default as CanvasChart, setGroupHover, groupRange } from './CanvasChart.svelte';
export type { ChartSeries } from './CanvasChart.svelte'; export type { ChartSeries } from './CanvasChart.svelte';
export { buildDaylightBands } from './bands'; export { buildDaylightBands } from './bands';
@@ -33,6 +33,8 @@
extraPadding?: number; extraPadding?: number;
/** Minimum chart width in pixels; narrower viewports scroll sideways (default: 560) */ /** Minimum chart width in pixels; narrower viewports scroll sideways (default: 560) */
minWidth?: number; minWidth?: number;
/** Bleed the chart into the page gutters (edge-to-edge). Off when nested in a card. */
bleed?: boolean;
/** Optional CSS class for the outer wrapper */ /** Optional CSS class for the outer wrapper */
class?: string; class?: string;
/** Slot content (charts go here) */ /** Slot content (charts go here) */
@@ -45,6 +47,7 @@
chartHeight = 300, chartHeight = 300,
extraPadding = 2, extraPadding = 2,
minWidth = 560, minWidth = 560,
bleed = true,
class: className = '', class: className = '',
children children
}: Props = $props(); }: Props = $props();
@@ -54,11 +57,11 @@
let minHeight = $derived(chartHeight * chartCount + extraPadding); let minHeight = $derived(chartHeight * chartCount + extraPadding);
</script> </script>
<div class="chart-bleed"> <div class="chart-bleed" class:no-bleed={!bleed}>
<div <div
class="chart-container relative {className}" class="chart-container relative {className}"
style:min-height="{minHeight}px" style:min-height="{minHeight}px"
style:min-width="{minWidth}px" style="--chart-min-width: {minWidth}px"
> >
<!-- Chart content area --> <!-- Chart content area -->
<div class="chart-content" in:fade={{ duration: 300 }} out:fade={{ duration: 300 }}> <div class="chart-content" in:fade={{ duration: 300 }} out:fade={{ duration: 300 }}>
@@ -100,18 +103,41 @@
.chart-bleed { .chart-bleed {
/* Bleed exactly into the page padding on mobile (main has p-5 = /* Bleed exactly into the page padding on mobile (main has p-5 =
1.25rem) for edge-to-edge charts, and a bit past the content 1.25rem) for edge-to-edge charts, and a bit past the content
column on md+ (main has 2rem padding) for extra readability. column on md+ (main has 2rem padding) for extra readability. */
Charts narrower than their min-width scroll sideways. */
margin-left: -1.25rem; margin-left: -1.25rem;
margin-right: -1.25rem; margin-right: -1.25rem;
overflow-x: auto; overflow-x: auto;
} }
.chart-bleed.no-bleed {
margin-left: 0;
margin-right: 0;
}
.chart-container {
min-width: var(--chart-min-width);
}
/* Mobile: fit the chart to the viewport instead of forcing a min-width
sideways scroll (which fights touch inspection). Pinch to zoom for detail. */
@media (max-width: 767px) {
.chart-container {
min-width: 0;
}
.chart-bleed {
overflow-x: hidden;
}
}
@media (min-width: 768px) { @media (min-width: 768px) {
.chart-bleed { .chart-bleed {
margin-left: -1.5rem; margin-left: -1.5rem;
margin-right: -1.5rem; margin-right: -1.5rem;
} }
.chart-bleed.no-bleed {
margin-left: 0;
margin-right: 0;
}
} }
.chart-content { .chart-content {
+3 -7
View File
@@ -75,15 +75,11 @@
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg" src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
alt={location.country} alt={location.country}
/> />
<!-- the region lives here; the page hero carries the place name -->
<span class="text-sm font-semibold text-foreground"> <span class="text-sm font-semibold text-foreground">
{location.name} {#if location.admin1}{location.admin1}, {location.country}{:else}{location.country ??
location.name}{/if}
</span> </span>
{#if location.admin1 || location.country}
<span class="hidden text-xs text-muted-foreground lg:inline">
{#if location.admin1}{location.admin1},{/if}
{location.country}
</span>
{/if}
</div> </div>
{/if} {/if}
+16 -1
View File
@@ -90,7 +90,7 @@ export interface ChartPanel {
} }
export const defaultChartLayout: ChartPanel[] = [ export const defaultChartLayout: ChartPanel[] = [
{ id: 'panel-1', variables: ['temperature', 'cloud_cover'] }, { id: 'panel-1', variables: ['weather_icons', 'temperature', 'cloud_cover'] },
{ id: 'panel-2', variables: ['precipitation', 'precipitation_probability'] }, { id: 'panel-2', variables: ['precipitation', 'precipitation_probability'] },
{ id: 'panel-3', variables: ['wind', 'humidity'] } { id: 'panel-3', variables: ['wind', 'humidity'] }
]; ];
@@ -99,3 +99,18 @@ export const storedChartLayout = persisted<ChartPanel[]>('chart_layout_v1', defa
/** 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');
/** Measurement units, shared across every forecast page and persisted. */
export interface UnitPrefs {
temperature_unit: 'celsius' | 'fahrenheit';
wind_speed_unit: 'kmh' | 'ms' | 'mph' | 'kn';
precipitation_unit: 'mm' | 'inch';
}
export const defaultUnits: UnitPrefs = {
temperature_unit: 'celsius',
wind_speed_unit: 'kmh',
precipitation_unit: 'mm'
};
export const storedUnits = persisted<UnitPrefs>('units_v1', defaultUnits);
+4 -1
View File
@@ -115,7 +115,10 @@ export async function resolveLocationFromRoute({
location = candidate; location = candidate;
} }
const canonicalPath = `${routePrefix}${buildLocationRoute(location)}`; // trailingSlash is 'always' (see routes/+layout.ts), so the router serves
// every path with a trailing slash. Match that here or the equality check
// never holds and the redirect loops forever.
const canonicalPath = `${routePrefix}${buildLocationRoute(location)}/`;
if (event.url.pathname !== canonicalPath) { if (event.url.pathname !== canonicalPath) {
throw redirect(303, canonicalPath); throw redirect(303, canonicalPath);
} }
@@ -2,7 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { storedEnsembleModel, storedLocation } from '$lib/stores/settings'; import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
import { ChartContainer, ChartToolbar } from '$lib/components/charts'; import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
@@ -17,6 +17,7 @@
import { defaultParameters, ensembleModelGroups } from '../../options'; import { defaultParameters, ensembleModelGroups } from '../../options';
import ModelSelector from '../../week/[location]/ModelSelector.svelte'; import ModelSelector from '../../week/[location]/ModelSelector.svelte';
import UnitSelector from '../../week/[location]/UnitSelector.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
@@ -46,10 +47,25 @@
let params = $state({ let params = $state({
...defaultParameters, ...defaultParameters,
hourly: ['temperature_2m'], hourly: [
'temperature_2m',
'precipitation',
'wind_speed_10m',
'relative_humidity_2m',
'cloud_cover',
'pressure_msl'
],
models: ['ncep_gefs_seamless'] models: ['ncep_gefs_seamless']
}); });
// units live in a persisted store; mirror them into params so a change
// re-runs the fetch effect (which reads params.*_unit)
$effect(() => {
params.temperature_unit = $storedUnits.temperature_unit;
params.wind_speed_unit = $storedUnits.wind_speed_unit;
params.precipitation_unit = $storedUnits.precipitation_unit;
});
// ─── Cached API Response ──────────────────────────────────────────────────── // ─── Cached API Response ────────────────────────────────────────────────────
interface FetchedData { interface FetchedData {
@@ -119,9 +135,24 @@
// ─── Chart Building (runs when fetchedData or the variable list changes) ──── // ─── Chart Building (runs when fetchedData or the variable list changes) ────
// Ensemble members stop at the model's horizon; past it the service collapses
// every value to 0 (min = max = mean = 0). Trim the axis to the last hour that
// actually has data so the charts cut off instead of flat-lining to zero.
let validLength = $derived.by((): number => {
if (!fetchedData) return 0;
const temp = fetchedData.ensembleResult.variables['temperature_2m'];
const n = fetchedData.timestamps.length;
if (!temp) return n;
let last = 0;
for (let i = 0; i < n; i++) {
if (!(temp.max[i] === 0 && temp.min[i] === 0 && temp.average[i] === 0)) last = i + 1;
}
return last || n;
});
// Timestamps from the service are in milliseconds; CanvasChart uses seconds. // Timestamps from the service are in milliseconds; CanvasChart uses seconds.
let timestampsSec = $derived.by(() => let timestampsSec = $derived.by(() =>
fetchedData ? fetchedData.timestamps.map((t) => t / 1000) : [] fetchedData ? fetchedData.timestamps.slice(0, validLength).map((t) => t / 1000) : []
); );
interface ChartDef { interface ChartDef {
@@ -213,24 +244,22 @@
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl"> <h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
{location.name} {location.name}
</h1> </h1>
<p class="truncate text-sm text-muted-foreground"> <p class="truncate text-sm text-muted-foreground">14-day ensemble forecast</p>
{#if location.admin1}{location.admin1},
{/if}{location.country ?? ''}
<span class="mx-1 opacity-50">·</span>
14-day ensemble forecast
</p>
</div> </div>
</div> </div>
<ModelSelector <div class="flex w-full items-center gap-3 sm:w-auto">
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'} <ModelSelector
groups={ensembleModelGroups} selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
label="Ensemble model" groups={ensembleModelGroups}
onModelChange={(model) => { label="Ensemble model"
params.models = [model]; onModelChange={(model) => {
storedEnsembleModel.set(model); params.models = [model];
}} storedEnsembleModel.set(model);
/> }}
/>
<UnitSelector />
</div>
</div> </div>
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── --> <!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
@@ -269,9 +298,9 @@
<div class="mt-6 md:mt-10"> <div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="14-day-forecast"> <ChartToolbar charts={liveCharts} fileName="14-day-forecast">
{#snippet controls()} {#snippet controls()}
<div class="flex gap-2"> <div class="flex items-center gap-2">
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} /> <Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label> <Label for="show_legend" class="cursor-pointer text-base leading-none">Show legend</Label>
</div> </div>
{/snippet} {/snippet}
</ChartToolbar> </ChartToolbar>
@@ -3,7 +3,7 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { storedLocation, storedModel } from '$lib/stores/settings'; import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
import { ChartContainer, ChartToolbar } from '$lib/components/charts'; import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
@@ -27,6 +27,7 @@
import { findModel, hourly, modelGroups } from '../../options'; import { findModel, hourly, modelGroups } from '../../options';
import { defaultParameters } from '../../options'; import { defaultParameters } from '../../options';
import UnitSelector from '../../week/[location]/UnitSelector.svelte';
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte'; import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
@@ -63,6 +64,14 @@
models: ['ecmwf_ifs', 'meteofrance_seamless', 'ukmo_seamless', 'icon_seamless', 'gfs_seamless'] models: ['ecmwf_ifs', 'meteofrance_seamless', 'ukmo_seamless', 'icon_seamless', 'gfs_seamless']
}); });
// units live in a persisted store; mirror them into params so a change
// re-runs the fetch effect (which reads params.*_unit)
$effect(() => {
params.temperature_unit = $storedUnits.temperature_unit;
params.wind_speed_unit = $storedUnits.wind_speed_unit;
params.precipitation_unit = $storedUnits.precipitation_unit;
});
// ─── Cached API Response ──────────────────────────────────────────────────── // ─── Cached API Response ────────────────────────────────────────────────────
interface FetchedData { interface FetchedData {
@@ -268,9 +277,12 @@
<div class="mt-6 md:mt-10"> <div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="model-comparison"> <ChartToolbar charts={liveCharts} fileName="model-comparison">
{#snippet controls()} {#snippet controls()}
<div class="flex gap-2"> <div class="flex items-center gap-4">
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} /> <div class="flex items-center gap-2">
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label> <Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
<Label for="show_legend" class="cursor-pointer text-base leading-none">Show legend</Label>
</div>
<UnitSelector />
</div> </div>
{/snippet} {/snippet}
</ChartToolbar> </ChartToolbar>
+26 -28
View File
@@ -7,6 +7,7 @@
storedChartLayout, storedChartLayout,
storedLocation, storedLocation,
storedModel, storedModel,
storedUnits,
storedVariablePrefs storedVariablePrefs
} from '$lib/stores/settings'; } from '$lib/stores/settings';
@@ -19,6 +20,7 @@
import HourlyTable from './HourlyTable.svelte'; import HourlyTable from './HourlyTable.svelte';
import MeteogramCharts from './MeteogramCharts.svelte'; import MeteogramCharts from './MeteogramCharts.svelte';
import ModelSelector from './ModelSelector.svelte'; import ModelSelector from './ModelSelector.svelte';
import UnitSelector from './UnitSelector.svelte';
import VariableSidebar from './VariableSidebar.svelte'; import VariableSidebar from './VariableSidebar.svelte';
import { neededHourlyApiVars } from './variables'; import { neededHourlyApiVars } from './variables';
@@ -32,6 +34,14 @@
...defaultParameters ...defaultParameters
}); });
// units live in a persisted store; mirror them into params so a change
// re-runs the fetch effect below (which reads params.*_unit)
$effect(() => {
params.temperature_unit = $storedUnits.temperature_unit;
params.wind_speed_unit = $storedUnits.wind_speed_unit;
params.precipitation_unit = $storedUnits.precipitation_unit;
});
let variableSidebarOpen = $state(false); let variableSidebarOpen = $state(false);
// Number of meteogram panels: reserves the chart area height before data // Number of meteogram panels: reserves the chart area height before data
@@ -60,6 +70,9 @@
let loadError = $state<string | null>(null); let loadError = $state<string | null>(null);
let requestVersion = 0; let requestVersion = 0;
// 7 by default; the user can extend to the model's longer range (up to 16 days)
let forecastDays = $state(7);
const selectedDay = new SvelteDate(); const selectedDay = new SvelteDate();
let fetchedHourly: FetchedHourly | null = $state(null); let fetchedHourly: FetchedHourly | null = $state(null);
@@ -97,7 +110,7 @@
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',
forecast_days: 7, forecast_days: forecastDays,
past_days: 0, past_days: 0,
timezone: loc.timezone timezone: loc.timezone
}) })
@@ -151,12 +164,7 @@
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl"> <h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
{location.name} {location.name}
</h1> </h1>
<p class="truncate text-sm text-muted-foreground"> <p class="truncate text-sm text-muted-foreground">7-day forecast</p>
{#if location.admin1}{location.admin1},
{/if}{location.country ?? ''}
<span class="mx-1 opacity-50">·</span>
7-day forecast
</p>
</div> </div>
</div> </div>
@@ -166,28 +174,10 @@
onModelChange={(model) => { onModelChange={(model) => {
params.models = [model]; params.models = [model];
storedModel.set(model); storedModel.set(model);
forecastDays = 7; // a new model may not support the extended range
}} }}
/> />
<button <UnitSelector />
class="flex h-11 cursor-pointer items-center gap-2 rounded-xl border-2 border-border bg-card px-3.5 text-sm font-semibold text-muted-foreground shadow-sm transition-colors hover:border-primary/50 hover:text-foreground"
onclick={() => (variableSidebarOpen = true)}
aria-label="Choose visible variables"
>
<!-- sliders icon -->
<svg
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path
stroke-linecap="round"
d="M4 6h9m5 0h2M4 12h2m5 0h9M4 18h9m5 0h2M13 4.5v3M6 10.5v3M18 16.5v3"
/>
</svg>
<span class="hidden md:inline">Variables</span>
</button>
</div> </div>
</div> </div>
@@ -201,7 +191,14 @@
</div> </div>
{/if} {/if}
<DailyCards daily={fetchedDaily} {selectedDay} units={params} onSelectDay={switchDay} /> <DailyCards
daily={fetchedDaily}
{selectedDay}
units={params}
onSelectDay={switchDay}
canExtend={forecastDays < 15}
onExtend={() => (forecastDays = 15)}
/>
{#if fetchedHourly && fetchedDaily} {#if fetchedHourly && fetchedDaily}
<HourlyTable <HourlyTable
@@ -210,6 +207,7 @@
{selectedDay} {selectedDay}
units={params} units={params}
locationName={location.name ?? ''} locationName={location.name ?? ''}
onCustomize={() => (variableSidebarOpen = true)}
/> />
{:else} {:else}
<!-- placeholder with the table's approximate height: no layout shift --> <!-- placeholder with the table's approximate height: no layout shift -->
@@ -4,7 +4,7 @@
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date'; import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
import { getTempStyle } from '../../utils/colors'; import { getTempStyle } from '../../utils/colors';
import weatherCodes from '../../utils/weather-codes'; import { getWeatherIconName } from '../../utils/weather-codes';
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types'; import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
interface Props { interface Props {
@@ -12,9 +12,12 @@
selectedDay: Date; selectedDay: Date;
units: WeatherUnits; units: WeatherUnits;
onSelectDay: (date: Date, index: number) => void; onSelectDay: (date: Date, index: number) => void;
/** Offer a button after the last day to load the model's longer range */
canExtend?: boolean;
onExtend?: () => void;
} }
let { daily, selectedDay, units, onSelectDay }: Props = $props(); let { daily, selectedDay, units, onSelectDay, canExtend = false, onExtend }: Props = $props();
function getDaylightSeconds(index: number): number { function getDaylightSeconds(index: number): number {
if (!daily) return 0; if (!daily) return 0;
@@ -34,13 +37,49 @@
const ratio = (sunshineSeconds ?? 0) / daylightSeconds; const ratio = (sunshineSeconds ?? 0) / daylightSeconds;
if (ratio >= 0.7) return '#f59e0b'; if (ratio >= 0.7) return '#f59e0b';
if (ratio >= 0.45) return '#fbbf24'; if (ratio >= 0.45) return '#fbbf24';
if (ratio >= 0.2) return '#fcd34d'; if (ratio >= 0.1) return '#fcd34d';
return '#d1d5db'; return '#d1d5db';
} }
// ─── "Is this metric worth highlighting?" thresholds ────────────────────────
// Below these, the sun / precip / wind bits are greyed out so a card at a
// glance only emphasises what's actually notable that day.
function sunIsSignificant(sunshineSeconds: number | null, daylightSeconds: number): boolean {
if (daylightSeconds <= 0) return false;
return (sunshineSeconds ?? 0) / daylightSeconds >= 0.1;
}
function precipIsSignificant(sum: number | null, unit: string): boolean {
const min = unit === 'mm' ? 0.1 : 0.005; // anything above a trace
return (sum ?? 0) >= min;
}
function windIsSignificant(speed: number | null, gust: number | null, unit: string): boolean {
// separate bars: sustained wind ~ a light breeze (~12 km/h), gusts a bit
// higher (~22 km/h). If EITHER is met, the whole wind readout is coloured.
const windMin = unit === 'ms' ? 3 : unit === 'mph' ? 7 : unit === 'kn' ? 6 : 12;
const gustMin = unit === 'ms' ? 6 : unit === 'mph' ? 14 : unit === 'kn' ? 12 : 22;
const s = speed != null && !isNaN(speed) ? speed : -Infinity;
const g = gust != null && !isNaN(gust) ? gust : -Infinity;
return s >= windMin || g >= gustMin;
}
</script> </script>
<!-- Shared filter: erodes the filled weather glyphs slightly so their
lines read a touch thinner at large sizes (radius = how much to shave) -->
<svg aria-hidden="true" width="0" height="0" class="absolute">
<defs>
<filter id="thin-day-icon" x="-10%" y="-10%" width="120%" height="120%">
<feMorphology operator="erode" radius="0.45" />
</filter>
</defs>
</svg>
<div in:fade out:fade class="mb-6 min-h-[260px]"> <div in:fade out:fade class="mb-6 min-h-[260px]">
<div class="flex gap-2 overflow-x-auto p-1 pb-2" style="scrollbar-width: thin"> <!-- generous padding on all sides so a lifted/scaled active or hovered card
(and its shadow) is never clipped by the horizontal scroll container -->
<div class="flex gap-2 overflow-x-auto px-3 pt-3 pb-5" style="scrollbar-width: thin">
{#if daily} {#if daily}
{#each daily.dailyDates as time, index (index)} {#each daily.dailyDates as time, index (index)}
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)} {@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
@@ -57,41 +96,62 @@
{@const windDir = daily.daily.winddirection_10m_dominant[index]} {@const windDir = daily.daily.winddirection_10m_dominant[index]}
{@const unit = String(units.temperature_unit)} {@const unit = String(units.temperature_unit)}
{@const maxStyle = getTempStyle(tempMax, unit)} {@const maxStyle = getTempStyle(tempMax, unit)}
{#if tempMax != null && !isNaN(tempMax)} {@const lowSun = !sunIsSignificant(sunDuration, daylightSec)}
{@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))}
{@const lowWind = !windIsSignificant(windMax, gustMax, String(units.wind_speed_unit))}
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
<button <button
class="group relative flex min-w-[112px] max-w-[170px] flex-1 cursor-pointer flex-col items-center gap-1 rounded-2xl border px-2 pt-3 pb-2.5 transition-all duration-200 class="day-card group relative flex w-35 shrink-0 cursor-pointer flex-col items-center gap-1 rounded-2xl border px-2 pt-3 pb-2.5 transition-all duration-200 ease-out will-change-transform motion-reduce:transition-none
{selected {selected
? 'border-primary/50 bg-primary/5 shadow-md ring-2 ring-primary/40' ? 'z-10 -translate-y-1 scale-[1.04] border-primary bg-primary/10 shadow-xl ring-2 ring-primary/60'
: 'border-border/60 bg-card shadow-xs hover:-translate-y-0.5 hover:border-border hover:shadow-md'}" : 'border-border/60 bg-card shadow-xs hover:z-10 hover:-translate-y-1 hover:scale-[1.02] hover:border-border hover:shadow-lg'}"
aria-pressed={selected} aria-pressed={selected}
onclick={() => onSelectDay(time, index)} onclick={() => onSelectDay(time, index)}
> >
<!-- Day label --> <!-- Day label -->
<span class="text-[13px] font-semibold tracking-wider"> <span class="text-[13px] font-semibold tracking-wider {selected ? 'text-primary' : ''}">
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()} {formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
</span> </span>
<span class="-mt-1 text-[11px] text-muted-foreground"> <span
class="-mt-1 text-[11px] {selected
? 'font-medium text-primary/80'
: 'text-muted-foreground'}"
>
{getRelativeDayLabel(time, daily.timezone)} {getRelativeDayLabel(time, daily.timezone)}
</span> </span>
<!-- Weather icon --> <!-- Weather icon: large day with a night badge in the corner -->
<svg class="day-icon my-1 fill-foreground" width="46px" height="46px"> <div class="relative my-1 px-3 -ml-2.5">
<use <svg
xlink:href="/images/weather-icons/wi-day-{weatherCodes[ class="day-icon fill-foreground"
wCode as keyof typeof weatherCodes width="100px"
] ?? 'clear'}.svg#Layer_1" height="100px"
></use> style="filter: url(#thin-day-icon)"
</svg> >
<use
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
></use>
</svg>
<svg
class="night-icon absolute -right-2 -bottom-1 rounded-full bg-card fill-foreground/60 p-0.5 ring-1 ring-border/60"
width="42px"
height="42px"
>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, false)}.svg#Layer_1"
></use>
</svg>
</div>
<!-- Temperature max/min --> <!-- Temperature max/min -->
<div class="flex items-baseline gap-1.5"> <div class="flex items-baseline gap-1.5">
<span <span
class="rounded-lg px-2 py-0.5 text-[15px] font-bold tabular-nums" class="ml-1 rounded-xl px-5 py-1.5 text-xl font-extrabold tabular-nums"
style="background-color: {maxStyle.bg}; color: {maxStyle.fg}" style="background-color: {maxStyle.bg}; color: {maxStyle.fg}"
> >
{tempMax.toFixed(0)}° {tempMax.toFixed(0)}°
</span> </span>
<span class="text-sm font-medium tabular-nums text-muted-foreground"> <span class="text-lg font-semibold tabular-nums text-muted-foreground">
{tempMin.toFixed(0)}° {tempMin.toFixed(0)}°
</span> </span>
</div> </div>
@@ -99,8 +159,8 @@
<!-- Details --> <!-- Details -->
<div class="mt-1.5 flex w-full flex-col gap-1 border-t border-border/50 px-1 pt-1.5"> <div class="mt-1.5 flex w-full flex-col gap-1 border-t border-border/50 px-1 pt-1.5">
<!-- Sunshine --> <!-- Sunshine -->
<div class="flex w-full items-center gap-1.5"> <div class="flex w-full items-center gap-1.5 {lowSun ? 'opacity-45' : ''}">
<svg class="shrink-0" width="13px" height="13px" style="fill: {sunColor}"> <svg class="shrink-0" width="20px" height="20px" style="fill: {sunColor}">
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
</svg> </svg>
<div class="h-1 flex-1 overflow-hidden rounded-full bg-muted"> <div class="h-1 flex-1 overflow-hidden rounded-full bg-muted">
@@ -118,30 +178,52 @@
<div <div
class="flex w-full items-center justify-center gap-2.5 text-[11px] tabular-nums text-foreground/80" class="flex w-full items-center justify-center gap-2.5 text-[11px] tabular-nums text-foreground/80"
> >
<span class="inline-flex items-center gap-0.5"> <span
<svg class="shrink-0 fill-foreground/70" width="13px" height="13px"> class="inline-flex items-center gap-0.5 {lowPrecip
? 'text-muted-foreground/50'
: ''}"
>
<svg
class="shrink-0 {lowPrecip ? 'fill-muted-foreground/40' : 'fill-foreground/70'}"
width="23px"
height="23px"
>
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
</svg> </svg>
{Number(precipSum ?? 0).toFixed( {Number(precipSum ?? 0).toFixed(
precipSum >= 10 ? 0 : 1 precipSum >= 10 ? 0 : 1
)}{units.precipitation_unit === 'mm' ? ' mm' : "'"} )}{units.precipitation_unit === 'mm' ? ' mm' : "'"}
</span> </span>
<span class="inline-flex items-center gap-0.5"> <span
class="inline-flex items-center gap-0.5 {lowWind
? 'text-muted-foreground/50'
: ''}"
>
{#if windDir != null && !isNaN(windDir)} {#if windDir != null && !isNaN(windDir)}
<span <span
class="inline-flex shrink-0" class="inline-flex shrink-0 -mr-2"
style="transform: {getWindArrowRotation(windDir)}" style="transform: {getWindArrowRotation(windDir)}"
> >
<svg class="fill-foreground/70" width="16px" height="16px"> <svg
class={lowWind ? 'fill-muted-foreground/40' : 'fill-foreground/70'}
width="40px"
height="40px"
>
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
</svg> </svg>
</span> </span>
{:else} {:else}
<svg class="shrink-0 fill-foreground/70" width="16px" height="16px"> <svg
class="shrink-0 -mr-2 {lowWind
? 'fill-muted-foreground/40'
: 'fill-foreground/70'}"
width="40px"
height="40px"
>
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg> </svg>
{/if} {/if}
{windMax?.toFixed(0) ?? '-'}<span class="text-muted-foreground" {windMax?.toFixed(0) ?? '-'}<span class="opacity-70"
>-{gustMax?.toFixed(0) ?? '-'}</span >-{gustMax?.toFixed(0) ?? '-'}</span
> >
</span> </span>
@@ -150,19 +232,47 @@
</button> </button>
{/if} {/if}
{/each} {/each}
{#if canExtend && onExtend}
<button
type="button"
class="flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
onclick={onExtend}
aria-label="Load the longer-range forecast"
>
<svg
class="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 7V3m8 4V3M4 11h16M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 14v4m-2-2h4" />
</svg>
<span class="text-center text-[11px] leading-tight font-semibold">
Load<br />15 days
</span>
</button>
{/if}
{/if} {/if}
</div> </div>
</div> </div>
<style> <style>
/* Mobile: keep the exact desktop layout (so nothing wraps), then scale the
whole card down uniformly. A fixed width matches a full desktop card and
the row scrolls horizontally. */
@media (max-width: 768px) { @media (max-width: 768px) {
button { .day-card {
min-width: 96px !important; flex: 0 0 auto;
} width: 168px;
max-width: none;
button :global(.day-icon) { zoom: 0.82;
width: 38px;
height: 38px;
} }
} }
</style> </style>
@@ -3,6 +3,8 @@
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date'; import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
import { setGroupHover } from '$lib/charts';
import { getTempStyle } from '../../utils/colors'; import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes'; import { getWeatherIconName } from '../../utils/weather-codes';
import { import {
@@ -21,9 +23,33 @@
selectedDay: Date; selectedDay: Date;
units: WeatherUnits; units: WeatherUnits;
locationName: string; locationName: string;
/** Opens the variable-customization sidebar (button lives in the header). */
onCustomize?: () => void;
} }
let { data, daily, selectedDay, units, locationName }: Props = $props(); let { data, daily, selectedDay, units, locationName, onCustomize }: Props = $props();
// Must match MeteogramCharts' CHART_GROUP so hovering the time row drives the
// meteogram crosshairs.
const METEOGRAM_GROUP = 'week-meteogram';
// Scrubbing the time row moves the shared meteogram cursor to the hovered
// time (interpolated across the row so it feels continuous), and clears it on
// leave.
function hoverTimeRow(e: MouseEvent) {
const el = e.currentTarget as HTMLElement;
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || cellData.length === 0) return;
const frac = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
const stepMs = (is3h ? 3 : 1) * 3600 * 1000;
const first = cellData[0].date.getTime();
const last = cellData[cellData.length - 1].date.getTime() + stepMs;
setGroupHover(METEOGRAM_GROUP, (first + frac * (last - first)) / 1000);
}
function clearTimeRowHover() {
setGroupHover(METEOGRAM_GROUP, null);
}
let hourlyInterval = $state<1 | 3>(3); let hourlyInterval = $state<1 | 3>(3);
@@ -209,7 +235,7 @@
{#if cellData.length > 0} {#if cellData.length > 0}
{@const hourly = data.hourly} {@const hourly = data.hourly}
{@const iconPx = is3h ? 38 : 26} {@const iconPx = is3h ? 38 : 33}
<!-- Full-bleed to the viewport edges on mobile (main has p-5 = 1.25rem); <!-- Full-bleed to the viewport edges on mobile (main has p-5 = 1.25rem);
a contained rounded card on md+ --> a contained rounded card on md+ -->
<section <section
@@ -228,23 +254,47 @@
{timezoneLabel} {timezoneLabel}
</span> </span>
</h3> </h3>
<div <div class="flex items-center gap-2">
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-[13px] font-semibold" {#if onCustomize}
role="group"
aria-label="Hourly interval"
>
{#each [3, 1] as interval (interval)}
<button <button
class="cursor-pointer rounded-md px-3 py-1 transition-colors {hourlyInterval === class="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 text-[13px] font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
interval onclick={onCustomize}
? 'bg-background text-foreground shadow-sm' aria-label="Customize variables"
: 'text-muted-foreground hover:text-foreground'}"
aria-pressed={hourlyInterval === interval}
onclick={() => (hourlyInterval = interval as 1 | 3)}
> >
{interval}h <!-- sliders icon -->
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path
stroke-linecap="round"
d="M4 6h9m5 0h2M4 12h2m5 0h9M4 18h9m5 0h2M13 4.5v3M6 10.5v3M18 16.5v3"
/>
</svg>
<span class="hidden sm:inline">Variables</span>
</button> </button>
{/each} {/if}
<div
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-[13px] font-semibold"
role="group"
aria-label="Hourly interval"
>
{#each [3, 1] as interval (interval)}
<button
class="cursor-pointer rounded-md px-3 py-1 transition-colors {hourlyInterval ===
interval
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
aria-pressed={hourlyInterval === interval}
onclick={() => (hourlyInterval = interval as 1 | 3)}
>
{interval}h
</button>
{/each}
</div>
</div> </div>
</div> </div>
@@ -269,7 +319,12 @@
<th class="hdr" scope="row" bind:clientWidth={headerColWidth}> <th class="hdr" scope="row" bind:clientWidth={headerColWidth}>
<span class="text-[10px] font-semibold text-muted-foreground">Time</span> <span class="text-[10px] font-semibold text-muted-foreground">Time</span>
</th> </th>
<td colspan={cellData.length} class="relative h-10 overflow-visible p-0"> <td
colspan={cellData.length}
class="relative h-11 overflow-visible p-0"
onmousemove={hoverTimeRow}
onmouseleave={clearTimeRowHover}
>
<!-- Daylight background --> <!-- Daylight background -->
{#if sunTimes && sunrisePercent != null && sunsetPercent != null} {#if sunTimes && sunrisePercent != null && sunsetPercent != null}
<div <div
@@ -329,25 +384,44 @@
</span> </span>
</div> </div>
{/if} {/if}
<!-- "Now" label, aligned with the sunrise/sunset labels along the bottom -->
{#if isTodaySelected && nowPercent != null}
<span
class="absolute bottom-0.5 z-30 -translate-x-1/2 rounded-full bg-red-500 px-1.5 py-px text-[9px] font-bold tracking-wide whitespace-nowrap text-white uppercase shadow-sm"
style="left:{nowPercent}%"
>
Now
</span>
{/if}
<!-- Hour labels --> <!-- Hour labels -->
{#each cellData as cell, i (cell.idx)} {#each cellData as cell, i (cell.idx)}
{@const leftPct = (i / cellData.length) * 100} {@const leftPct = (i / cellData.length) * 100}
{@const widthPct = 100 / cellData.length} {@const widthPct = 100 / cellData.length}
<span <span
class="absolute top-0 flex items-start pt-1.5 pl-1 text-sm font-bold class="absolute top-0 flex items-start pl-1 font-bold {is3h
? 'pt-2 text-sm'
: 'pt-2.5'}
{cell.isNow ? 'text-red-600 dark:text-red-400' : ''}" {cell.isNow ? 'text-red-600 dark:text-red-400' : ''}"
style="left:{leftPct}%;width:{widthPct}%" style="left:{leftPct}%;width:{widthPct}%"
> >
{#if is3h} {#if is3h}
{formatZoned(cell.date, data.timezone, 'HH')} <span class="inline-flex items-baseline gap-0.5">
<span>{formatZoned(cell.date, data.timezone, 'HH')}</span>
<sup
class="align-baseline translate-y-0.25 text-[10px] leading-none font-semibold {cell.isNow
? 'text-red-500 dark:text-red-400'
: 'text-muted-foreground'}">00</sup
>
</span>
{:else} {:else}
<span class="inline-flex items-baseline gap-1"> <span class="inline-flex items-baseline gap-1">
<span class="text-[11px] font-semibold" <span class="text-[11px] font-semibold"
>{formatZoned(cell.date, data.timezone, 'HH')}</span >{formatZoned(cell.date, data.timezone, 'HH')}</span
> >
<sup <sup
class="align-baseline text-[9px] leading-none font-semibold text-muted-foreground" class="inline-block -translate-x-0.5 translate-y-[0.16rem] align-baseline text-[9px] leading-none font-semibold {cell.isNow
>00</sup ? 'text-red-500 dark:text-red-400'
: 'text-muted-foreground'}">00</sup
> >
</span> </span>
{/if} {/if}
@@ -495,16 +569,11 @@
style="left:{headerColWidth + nowIdx * colWidth}px;width:{colWidth}px" style="left:{headerColWidth + nowIdx * colWidth}px;width:{colWidth}px"
></div> ></div>
{/if} {/if}
<!-- full-height current-time line (its "Now" label lives in the time row) -->
<div <div
class="pointer-events-none absolute inset-y-0 z-10 w-0.5 -translate-x-1/2 bg-red-500/80" class="pointer-events-none absolute inset-y-0 z-10 w-0.5 -translate-x-1/2 bg-red-500/75"
style="left:{nowLeftPx}px" style="left:{nowLeftPx}px"
> ></div>
<span
class="absolute top-1 left-1/2 -translate-x-1/2 rounded-full bg-red-500 px-1.5 py-px text-[9px] font-bold tracking-wide text-white uppercase shadow-sm"
>
Now
</span>
</div>
{/if} {/if}
</div> </div>
</div> </div>
@@ -7,7 +7,7 @@
import { ChartContainer, ChartToolbar } from '$lib/components/charts'; import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { CanvasChart } from '$lib/charts'; import { CanvasChart, groupRange } from '$lib/charts';
import { getWeatherIconName } from '../../utils/weather-codes'; import { getWeatherIconName } from '../../utils/weather-codes';
import ChartCustomizer from './ChartCustomizer.svelte'; import ChartCustomizer from './ChartCustomizer.svelte';
@@ -39,8 +39,22 @@
$storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k))) $storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k)))
); );
// When an extended range runs past the model's horizon the service pads with
// zeros; trim the axis to the last hour that actually has data so the charts
// cut off instead of flat-lining to zero.
let validLength = $derived.by((): number => {
const temp = data.hourly.temperature_2m ?? [];
const n = data.timestamps.length;
if (temp.length === 0) return n;
let last = 0;
for (let i = 0; i < n; i++) {
if (temp[i] != null && !isNaN(temp[i]) && temp[i] !== 0) last = i + 1;
}
return last || n;
});
// 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.slice(0, validLength).map((t) => t / 1000));
function dayStartSec(day: Date): number | null { function dayStartSec(day: Date): number | null {
if (!data) return null; if (!data) return null;
@@ -96,6 +110,21 @@
return out; return out;
}); });
// Wind-direction arrows for panels showing wind (deg = direction from North).
let windArrowMarks = $derived.by((): { t: number; deg: number }[] => {
const dirs = data.hourly.winddirection_10m ?? [];
const out: { t: number; deg: number }[] = [];
for (let i = 0; i < timestampsSec.length; i++) {
const d = dirs[i];
if (d == null || !isFinite(d)) continue;
out.push({ t: timestampsSec[i], deg: d });
}
return out;
});
// True while the shared group is zoomed in (not the full range).
let zoomActive = $derived(groupRange(CHART_GROUP) != null);
// ─── Panel definitions ────────────────────────────────────────────────────── // ─── Panel definitions ──────────────────────────────────────────────────────
interface RenderPanel extends ChartPanel { interface RenderPanel extends ChartPanel {
@@ -112,6 +141,16 @@
return { ...p, def, title, titleShort }; return { ...p, def, title, titleShort };
}) })
); );
// Uniform sizing across every panel: reserve the right-axis gutter and the
// tallest icon-row count so all meteograms share one plot rectangle.
let anyRightAxis = $derived(renderPanels.some((p) => p.def.unitRight != null));
let maxTopRows = $derived(
Math.max(
0,
...renderPanels.map((p) => (p.def.hasPictograms ? 1 : 0) + (p.def.hasWindArrows ? 1 : 0))
)
);
</script> </script>
<section class="mt-8" in:fade={{ duration: 200 }}> <section class="mt-8" in:fade={{ duration: 200 }}>
@@ -129,11 +168,31 @@
</h3> </h3>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<span class="hidden text-xs text-muted-foreground md:inline"> <span class="hidden text-xs text-muted-foreground md:inline">
drag or
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]" <kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]"
>Ctrl</kbd >Ctrl</kbd
> >
+ scroll to zoom + scroll to zoom
</span> </span>
{#if zoomActive}
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
onclick={resetZoom}
>
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6" />
<path stroke-linecap="round" d="M20 10a8 8 0 0 0-14-4M4 14a8 8 0 0 0 14 4" />
</svg>
Reset zoom
</button>
{/if}
<div <div
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold" class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
role="group" role="group"
@@ -180,14 +239,23 @@
{:else} {:else}
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
{#each renderPanels as panel, i (panel.id)} {#each renderPanels as panel, i (panel.id)}
<div class="rounded-2xl border border-border/70 bg-card p-3 shadow-sm md:p-4"> <!-- full-bleed to the screen edges on mobile; a contained card on md+ -->
<div class="mb-1 flex items-center justify-between px-1"> <div
class="-mx-5 border-y border-border/70 bg-card px-1 py-3 shadow-sm md:mx-0 md:rounded-2xl md:border md:px-4 md:py-4"
>
<div class="mb-1 flex items-center justify-between px-3 md:px-1">
<h4 class="truncate text-sm font-bold text-muted-foreground"> <h4 class="truncate text-sm font-bold text-muted-foreground">
<span class="hidden md:inline">{panel.title}</span> <span class="hidden md:inline">{panel.title}</span>
<span class="md:hidden">{panel.titleShort}</span> <span class="md:hidden">{panel.titleShort}</span>
</h4> </h4>
</div> </div>
<ChartContainer {loading} chartCount={1} chartHeight={CHART_HEIGHT} minWidth={520}> <ChartContainer
{loading}
chartCount={1}
chartHeight={CHART_HEIGHT}
minWidth={520}
bleed={false}
>
<CanvasChart <CanvasChart
bind:this={chartComponents[i]} bind:this={chartComponents[i]}
timestamps={timestampsSec} timestamps={timestampsSec}
@@ -195,6 +263,9 @@
series={panel.def.series} series={panel.def.series}
bands={data.daylightBands} bands={data.daylightBands}
pictograms={panel.def.hasPictograms ? pictograms : []} pictograms={panel.def.hasPictograms ? pictograms : []}
windArrows={panel.def.hasWindArrows ? windArrowMarks : []}
reserveRightAxis={anyRightAxis}
reserveTopRows={maxTopRows}
highlight={selectedDayHighlight} highlight={selectedDayHighlight}
unit={panel.def.unit} unit={panel.def.unit}
unitRight={panel.def.unitRight} unitRight={panel.def.unitRight}
@@ -0,0 +1,95 @@
<script lang="ts">
import { type UnitPrefs, storedUnits } from '$lib/stores/settings';
import * as Popover from '$lib/components/ui/popover';
// each group maps a stored unit key to its selectable options
const UNIT_GROUPS: {
key: keyof UnitPrefs;
label: string;
options: { value: string; label: string }[];
}[] = [
{
key: 'temperature_unit',
label: 'Temperature',
options: [
{ value: 'celsius', label: '°C' },
{ value: 'fahrenheit', label: '°F' }
]
},
{
key: 'wind_speed_unit',
label: 'Wind speed',
options: [
{ value: 'kmh', label: 'km/h' },
{ value: 'ms', label: 'm/s' },
{ value: 'mph', label: 'mph' },
{ value: 'kn', label: 'kn' }
]
},
{
key: 'precipitation_unit',
label: 'Precipitation',
options: [
{ value: 'mm', label: 'mm' },
{ value: 'inch', label: 'inch' }
]
}
];
function setUnit(key: keyof UnitPrefs, value: string) {
storedUnits.update((u) => ({ ...u, [key]: value }));
}
</script>
<Popover.Root>
<Popover.Trigger
class="flex h-11 cursor-pointer items-center gap-2 rounded-xl border-2 border-border bg-card px-3.5 text-sm font-semibold text-muted-foreground shadow-sm transition-colors hover:border-primary/50 hover:text-foreground data-[state=open]:border-primary/60 data-[state=open]:text-foreground"
aria-label="Choose measurement units"
>
<!-- gauge icon -->
<svg
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path stroke-linecap="round" d="M12 13a9 9 0 0 1 8.5-9a9 9 0 0 1-8.5 15" opacity="0" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4.5 15a7.5 7.5 0 1 1 15 0M12 15l3.2-3.2"
/>
<circle cx="12" cy="15" r="1" fill="currentColor" stroke="none" />
</svg>
<span class="hidden md:inline">Units</span>
</Popover.Trigger>
<Popover.Content align="end" class="w-64 border-border">
<div class="flex flex-col gap-4">
{#each UNIT_GROUPS as group (group.key)}
<div>
<span
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
>
{group.label}
</span>
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
{#each group.options as opt (opt.value)}
{@const active = $storedUnits[group.key] === opt.value}
<button
class="flex-1 cursor-pointer rounded-md px-2 py-1.5 text-[13px] font-semibold transition-colors {active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
aria-pressed={active}
onclick={() => setUnit(group.key, opt.value)}
>
{opt.label}
</button>
{/each}
</div>
</div>
{/each}
</div>
</Popover.Content>
</Popover.Root>
+44 -11
View File
@@ -45,6 +45,10 @@ export interface ChartVariableDef {
extrema?: boolean; extrema?: boolean;
/** Draw weather-code pictograms across the top of the chart */ /** Draw weather-code pictograms across the top of the chart */
pictograms?: boolean; pictograms?: boolean;
/** Draw wind-direction arrows across the top of the chart */
windArrows?: boolean;
/** Marker-only variable (icons / arrows): contributes no plotted series */
marker?: boolean;
/** Render as a puffy cloud band hanging from the top (0-100 → 0-40px) */ /** Render as a puffy cloud band hanging from the top (0-100 → 0-40px) */
cloudBand?: boolean; cloudBand?: boolean;
/** Transform raw values before plotting (e.g. m → km) */ /** Transform raw values before plotting (e.g. m → km) */
@@ -62,13 +66,21 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
type: 'line', type: 'line',
kind: 'temp', kind: 'temp',
color: '#ef6c00', color: '#ef6c00',
width: 4, width: 9,
fill: true,
fillOpacity: 0.12,
colorScale: true, colorScale: true,
outline: true, outline: true,
extrema: true, extrema: true
pictograms: true },
{
key: 'weather_icons',
label: 'Weather icons',
short: 'Icons',
field: 'weather_code',
type: 'line',
kind: 'temp',
color: '#94a3b8',
pictograms: true,
marker: true
}, },
{ {
key: 'apparent_temperature', key: 'apparent_temperature',
@@ -189,7 +201,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
color: '#26a69a', color: '#26a69a',
width: 2, width: 2,
fill: true, fill: true,
fillOpacity: 0.15 fillOpacity: 0.15,
windArrows: true
}, },
{ {
key: 'wind_gusts', key: 'wind_gusts',
@@ -347,7 +360,7 @@ export function isZeroBased(kind: UnitKind): boolean {
return kind !== 'temp' && kind !== 'pressure'; return kind !== 'temp' && kind !== 'pressure';
} }
/** Sensible decimal places for tooltip / label formatting. */ /** Decimal places for on-chart extrema labels (kept coarse, like the cards). */
export function decimalsForKind(kind: UnitKind): number { export function decimalsForKind(kind: UnitKind): number {
switch (kind) { switch (kind) {
case 'precip': case 'precip':
@@ -360,6 +373,20 @@ export function decimalsForKind(kind: UnitKind): number {
} }
} }
/**
* Decimal places for the hover tooltip — finer than the cards / extrema labels
* so the meteogram reveals more detail. Percentages stay whole numbers.
*/
export function tooltipDecimalsForKind(kind: UnitKind): number {
switch (kind) {
case 'percent':
case 'energy':
return 0;
default:
return 1;
}
}
export interface PanelDef { export interface PanelDef {
series: ChartSeries[]; series: ChartSeries[];
unit: string; unit: string;
@@ -370,6 +397,7 @@ export interface PanelDef {
/** Whether the left axis should include zero (false for pressure) */ /** Whether the left axis should include zero (false for pressure) */
zeroBaseLeft: boolean; zeroBaseLeft: boolean;
hasPictograms: boolean; hasPictograms: boolean;
hasWindArrows: boolean;
} }
/** /**
@@ -382,10 +410,13 @@ export function buildPanelDef(
hourly: WeekHourlyData, hourly: WeekHourlyData,
units: WeatherUnits units: WeatherUnits
): PanelDef { ): PanelDef {
const defs = variableKeys const allDefs = variableKeys
.map((k) => VARIABLE_BY_KEY.get(k)) .map((k) => VARIABLE_BY_KEY.get(k))
.filter((d): d is ChartVariableDef => d != null); .filter((d): d is ChartVariableDef => d != null);
// Marker-only variables (weather icons) render no series, just a top row.
const defs = allDefs.filter((d) => !d.marker);
// Cloud-band variables float above the plot and don't claim an axis. // Cloud-band variables float above the plot and don't claim an axis.
const axisDefs = defs.filter((d) => !d.cloudBand); const axisDefs = defs.filter((d) => !d.cloudBand);
const kinds: UnitKind[] = []; const kinds: UnitKind[] = [];
@@ -400,6 +431,7 @@ export function buildPanelDef(
: raw; : raw;
const kindUnit = unitForKind(d.kind, units); const kindUnit = unitForKind(d.kind, units);
const dec = decimalsForKind(d.kind); const dec = decimalsForKind(d.kind);
const tipDec = tooltipDecimalsForKind(d.kind);
const axis: 'left' | 'right' = d.cloudBand || d.kind === leftKind ? 'left' : 'right'; const axis: 'left' | 'right' = d.cloudBand || d.kind === leftKind ? 'left' : 'right';
return { return {
@@ -425,9 +457,9 @@ export function buildPanelDef(
? (v: number, i: number) => { ? (v: number, i: number) => {
const dir = hourly.winddirection_10m?.[i]; const dir = hourly.winddirection_10m?.[i];
const dl = dir != null && !isNaN(dir) ? ` (${getWindDirectionLabel(dir)})` : ''; const dl = dir != null && !isNaN(dir) ? ` (${getWindDirectionLabel(dir)})` : '';
return `${v.toFixed(dec)} ${kindUnit}${dl}`; return `${v.toFixed(tipDec)} ${kindUnit}${dl}`;
} }
: (v: number) => `${v.toFixed(dec)}${kindUnit ? ' ' + kindUnit : ''}` : (v: number) => `${v.toFixed(tipDec)}${kindUnit ? ' ' + kindUnit : ''}`
} satisfies ChartSeries; } satisfies ChartSeries;
}); });
@@ -441,6 +473,7 @@ export function buildPanelDef(
zeroBaseLeft: leftKind !== 'pressure', zeroBaseLeft: leftKind !== 'pressure',
yMinRight: rightKind && rightZero ? 0 : undefined, yMinRight: rightKind && rightZero ? 0 : undefined,
yMaxRight: rightKind === 'percent' ? 100 : undefined, yMaxRight: rightKind === 'percent' ? 100 : undefined,
hasPictograms: defs.some((d) => d.pictograms) hasPictograms: allDefs.some((d) => d.pictograms),
hasWindArrows: allDefs.some((d) => d.windArrows)
}; };
} }