revamp weather UI: cards, meteograms, units in header, location favorites and range controls

This commit is contained in:
2026-07-25 10:26:14 +02:00
committed by Vincent van der Wal
parent bac4759662
commit b4e509f9cb
18 changed files with 1023 additions and 275 deletions
+149 -38
View File
@@ -86,6 +86,21 @@
delete groups[name];
}
}
/**
* Drive the shared crosshair of a chart group from the outside (e.g. hovering
* the hourly table). `time` is epoch seconds, or null to clear. No-op if no
* chart in that group is currently mounted.
*/
export function setGroupHover(name: string, time: number | null): void {
const state = groups[name];
if (state) state.hover = time;
}
/** Current shared zoom range of a group (null = full range), reactive. */
export function groupRange(name: string): { start: number; end: number } | null {
return groups[name]?.range ?? null;
}
</script>
<script lang="ts">
@@ -109,6 +124,8 @@
bands?: { start: number; end: number }[];
/** Weather pictograms drawn across the top (t in epoch seconds) */
pictograms?: { t: number; icon: string }[];
/** Wind-direction arrows drawn across the top (t in epoch seconds, deg from N) */
windArrows?: { t: number; deg: number }[];
/** Highlighted time range (epoch seconds), e.g. the selected day */
highlight?: { start: number; end: number };
/** Unit label for the left y axis (also used in tooltip values) */
@@ -131,6 +148,12 @@
yMaxRight?: number;
/** Invert the right axis (min at the top) */
invertRight?: boolean;
/** Reserve the right-axis gutter even without a right axis (keeps a row of
* stacked charts identically sized) */
reserveRightAxis?: boolean;
/** Reserve this many top icon-rows even if this chart has fewer, so a row
* of stacked charts share the same plot rectangle */
reserveTopRows?: number;
/** Chart title drawn top-left on the canvas */
title?: string;
/** Smaller subtitle drawn under the title */
@@ -151,6 +174,7 @@
series,
bands = [],
pictograms = [],
windArrows = [],
highlight,
unit = '',
unitRight,
@@ -162,6 +186,8 @@
yMinRight,
yMaxRight,
invertRight = false,
reserveRightAxis = false,
reserveTopRows = 0,
title,
subtitle,
showLegend = false,
@@ -175,6 +201,10 @@
const PAD_BOTTOM = 34;
const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours
const HOUR = 3600;
// Top icon rows (weather pictograms / wind arrows)
const ICON_ROW_H = 30; // reserved height per icon row
const ICON_BAND_H = 28; // visible band height
const ICON_PX = 26; // pictogram size
// Puffy cloud band: 100% cover hangs 40px from the top of the plot
const CLOUD_BAND_MAX = 40;
@@ -186,15 +216,8 @@
let themeVersion = $state(0);
const legendHidden = new SvelteSet<string>();
// Briefly shown when the user scrolls over the chart without holding Ctrl
let zoomHintVisible = $state(false);
let zoomHintTimeout: ReturnType<typeof setTimeout> | undefined;
function showZoomHint(): void {
zoomHintVisible = true;
clearTimeout(zoomHintTimeout);
zoomHintTimeout = setTimeout(() => (zoomHintVisible = false), 1200);
}
// Drag-to-zoom selection rectangle (plot-local pixel x), null when inactive.
let dragSelect = $state<{ x0: number; x1: number } | null>(null);
// Zoom range and crosshair: either group-shared or local to this chart.
// The group is acquired once at component init (the prop is treated as fixed).
@@ -205,7 +228,6 @@
onDestroy(() => {
if (groupName) releaseGroup(groupName);
clearTimeout(zoomHintTimeout);
});
// ─── Derived: view window & scales ──────────────────────────────────────────
@@ -230,9 +252,14 @@
// 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);
// Reserve a slim row at the very top for weather pictograms when present
let padTop = $derived((title ? (subtitle ? 66 : 46) : 28) + (pictograms.length > 0 ? 22 : 0));
// Reserve the right gutter when this chart (or a sibling, via reserveRightAxis)
// has a right axis, so a stacked row of charts share the same plot width.
let padRight = $derived(hasRightAxis || reserveRightAxis ? 56 : 20);
// Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps
// a stacked row of charts the same height even if some have fewer icon rows.
let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0));
let iconRows = $derived(Math.max(ownIconRows, reserveTopRows));
let padTop = $derived((title ? (subtitle ? 66 : 46) : 28) + iconRows * ICON_ROW_H);
let plotW = $derived(Math.max(1, width - padLeft - padRight));
let plotH = $derived(Math.max(1, height - padTop - PAD_BOTTOM));
@@ -408,21 +435,50 @@
// ─── 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 }[] => {
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;
const x = iconBandX(p.t);
if (x - lastX < 40) continue;
out.push({ x, icon: p.icon });
lastX = x;
}
return out;
});
// Same thinning for the wind-direction arrow row.
let visibleWindArrows = $derived.by((): { x: number; deg: number }[] => {
if (windArrows.length === 0 || width <= 0) return [];
const out: { x: number; deg: number }[] = [];
let lastX = -Infinity;
for (const a of windArrows) {
if (a.t < viewStart || a.t > viewEnd) continue;
const x = iconBandX(a.t);
if (x - lastX < 40) continue;
out.push({ x, deg: a.deg });
lastX = x;
}
return out;
});
// Vertical offset (px from container top) of each icon row's top edge, anchored
// just above the plot. When both rows are present, pictograms sit above the
// wind arrows (which stay closest to the plot).
let pictoRowTop = $derived(padTop - (windArrows.length > 0 ? 2 : 1) * ICON_ROW_H + 2);
let windRowTop = $derived(padTop - ICON_ROW_H + 2);
// ─── Local minima / maxima (for value labels) ────────────────────────────────
function findExtrema(data: (number | null)[]): { i: number; type: 'min' | 'max' }[] {
@@ -760,6 +816,8 @@
ctx.lineWidth = 3;
ctx.strokeStyle = bgColor;
ctx.fillStyle = strongColor;
// clear the (possibly thick) line + its outline before the text sits
const off = (s.width ?? 2) / 2 + 7;
for (const ext of findExtrema(s.data)) {
const t = timestamps[ext.i];
if (t < viewStart || t > viewEnd) continue;
@@ -767,9 +825,12 @@
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);
const ly = ext.type === 'max' ? y - off : y + off + 8;
// keep the centred label fully inside the plot so it never clips
const halfW = ctx.measureText(label).width / 2 + 2;
const lx = Math.max(padLeft + halfW, Math.min(plotRight - halfW, x));
ctx.strokeText(label, lx, ly);
ctx.fillText(label, lx, ly);
}
}
}
@@ -890,10 +951,12 @@
// 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 gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' | 'select' = 'none';
let touchStart: { x: number; y: number; start: number; end: number } | null = null;
let selStartX = 0; // drag-to-zoom anchor (canvas-local px)
const localX = (e: { clientX: number }): number => e.clientX - el.getBoundingClientRect().left;
const clampPlotX = (x: number): number => Math.max(padLeft, Math.min(width - padRight, x));
const updateHover = (e: PointerEvent): void => {
const t = pixToTime(localX(e));
@@ -914,10 +977,13 @@
}
if (e.pointerType === 'mouse') {
// Mouse: click-drag selects a range to zoom into.
el.setPointerCapture(e.pointerId);
panStart = { x: e.clientX, start: viewStart, end: viewEnd };
selStartX = clampPlotX(localX(e));
dragSelect = null;
panStart = null;
pinchStart = null;
gesture = zoomed ? 'pan' : 'inspect';
gesture = 'select';
} else {
// Touch: wait for the first move to reveal scroll vs inspect intent.
touchStart = { x: e.clientX, y: e.clientY, start: viewStart, end: viewEnd };
@@ -941,6 +1007,16 @@
return;
}
// Mouse drag-to-zoom: track the selection rectangle
if (gesture === 'select' && e.pointerType === 'mouse' && pointers.size === 1) {
const x = clampPlotX(localX(e));
if (dragSelect || Math.abs(x - selStartX) >= 3) {
dragSelect = { x0: selStartX, x1: x };
setHover(null);
}
return;
}
// Resolve touch intent from the initial drag direction
if (e.pointerType !== 'mouse' && gesture === 'none' && touchStart && pointers.size === 1) {
const dx = Math.abs(e.clientX - touchStart.x);
@@ -970,6 +1046,14 @@
};
const onPointerUp = (e: PointerEvent): void => {
// Commit a mouse drag-to-zoom selection (if it spans a real range).
if (gesture === 'select' && e.pointerType === 'mouse' && dragSelect) {
const a = pixToTime(dragSelect.x0);
const b = pixToTime(dragSelect.x1);
if (Math.abs(a - b) > 0) applyRange(Math.min(a, b), Math.max(a, b));
}
dragSelect = null;
pointers.delete(e.pointerId);
if (pointers.size < 2) pinchStart = null;
if (pointers.size < 1) {
@@ -988,7 +1072,6 @@
// Zoom only while Ctrl (or ⌘) is held — a plain scroll should keep
// scrolling the page. Trackpad pinch also arrives as ctrlKey wheel.
if (!e.ctrlKey && !e.metaKey) {
showZoomHint();
return;
}
e.preventDefault();
@@ -1034,16 +1117,32 @@
style:touch-action="pan-y"
></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}
<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)}
<svg
class="absolute fill-foreground"
width="20"
height="20"
style:left="{p.x - 10}px"
style:top="0"
class="absolute top-px fill-foreground"
width={ICON_PX}
height={ICON_PX}
style:left="{Math.max(2, Math.min(iconBandWidth - ICON_PX - 2, p.x - ICON_PX / 2))}px"
>
<use xlink:href="/images/weather-icons/{p.icon}.svg#Layer_1"></use>
</svg>
@@ -1051,16 +1150,28 @@
</div>
{/if}
{#if zoomHintVisible}
<!-- Wind-direction arrows: a matching band -->
{#if visibleWindArrows.length > 0}
<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
class="rounded-lg bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
>
Hold <kbd class="rounded border border-border bg-muted px-1.5 py-0.5 text-xs">Ctrl</kbd>
and scroll to zoom
</span>
{#each visibleWindArrows as a (a.x)}
<span
class="absolute top-px inline-flex items-center justify-center"
style:width="{ICON_PX}px"
style:height="{ICON_PX}px"
style:left="{Math.max(2, Math.min(iconBandWidth - ICON_PX - 2, a.x - ICON_PX / 2))}px"
style:transform="rotate({a.deg}deg)"
>
<svg class="fill-foreground/80" width="22" height="22">
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
</svg>
</span>
{/each}
</div>
{/if}