revamp week view: unit selector, redesigned day cards, richer meteograms and extended forecast
This commit is contained in:
@@ -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"
|
||||||
>
|
>
|
||||||
|
{#each visibleWindArrows as a (a.x)}
|
||||||
<span
|
<span
|
||||||
class="rounded-lg bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
|
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)"
|
||||||
>
|
>
|
||||||
Hold <kbd class="rounded border border-border bg-muted px-1.5 py-0.5 text-xs">Ctrl</kbd>
|
<svg class="fill-foreground/80" width="22" height="22">
|
||||||
and scroll to zoom
|
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,15 +244,11 @@
|
|||||||
<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>
|
||||||
|
|
||||||
|
<div class="flex w-full items-center gap-3 sm:w-auto">
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
|
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
|
||||||
groups={ensembleModelGroups}
|
groups={ensembleModelGroups}
|
||||||
@@ -231,6 +258,8 @@
|
|||||||
storedEnsembleModel.set(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">
|
||||||
|
<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>
|
||||||
|
<UnitSelector />
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ChartToolbar>
|
</ChartToolbar>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<svg
|
||||||
|
class="day-icon fill-foreground"
|
||||||
|
width="100px"
|
||||||
|
height="100px"
|
||||||
|
style="filter: url(#thin-day-icon)"
|
||||||
|
>
|
||||||
<use
|
<use
|
||||||
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
|
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
|
||||||
wCode as keyof typeof weatherCodes
|
|
||||||
] ?? 'clear'}.svg#Layer_1"
|
|
||||||
></use>
|
></use>
|
||||||
</svg>
|
</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,6 +254,29 @@
|
|||||||
{timezoneLabel}
|
{timezoneLabel}
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if onCustomize}
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
onclick={onCustomize}
|
||||||
|
aria-label="Customize variables"
|
||||||
|
>
|
||||||
|
<!-- 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>
|
||||||
|
{/if}
|
||||||
<div
|
<div
|
||||||
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-[13px] font-semibold"
|
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-[13px] font-semibold"
|
||||||
role="group"
|
role="group"
|
||||||
@@ -247,6 +296,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Below the min-width the table scrolls sideways instead of squeezing;
|
<!-- Below the min-width the table scrolls sideways instead of squeezing;
|
||||||
1h needs far more room than 3h (24 vs 8 columns) -->
|
1h needs far more room than 3h (24 vs 8 columns) -->
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user