feat: week view overhaul (#8)
This commit was merged in pull request #8.
This commit is contained in:
@@ -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}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 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 { buildDaylightBands } from './bands';
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
extraPadding?: number;
|
||||
/** Minimum chart width in pixels; narrower viewports scroll sideways (default: 560) */
|
||||
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 */
|
||||
class?: string;
|
||||
/** Slot content (charts go here) */
|
||||
@@ -45,6 +47,7 @@
|
||||
chartHeight = 300,
|
||||
extraPadding = 2,
|
||||
minWidth = 560,
|
||||
bleed = true,
|
||||
class: className = '',
|
||||
children
|
||||
}: Props = $props();
|
||||
@@ -54,11 +57,11 @@
|
||||
let minHeight = $derived(chartHeight * chartCount + extraPadding);
|
||||
</script>
|
||||
|
||||
<div class="chart-bleed">
|
||||
<div class="chart-bleed" class:no-bleed={!bleed}>
|
||||
<div
|
||||
class="chart-container relative {className}"
|
||||
style:min-height="{minHeight}px"
|
||||
style:min-width="{minWidth}px"
|
||||
style="--chart-min-width: {minWidth}px"
|
||||
>
|
||||
<!-- Chart content area -->
|
||||
<div class="chart-content" in:fade={{ duration: 300 }} out:fade={{ duration: 300 }}>
|
||||
@@ -100,18 +103,41 @@
|
||||
.chart-bleed {
|
||||
/* 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
|
||||
column on md+ (main has 2rem padding) for extra readability.
|
||||
Charts narrower than their min-width scroll sideways. */
|
||||
column on md+ (main has 2rem padding) for extra readability. */
|
||||
margin-left: -1.25rem;
|
||||
margin-right: -1.25rem;
|
||||
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) {
|
||||
.chart-bleed {
|
||||
margin-left: -1.5rem;
|
||||
margin-right: -1.5rem;
|
||||
}
|
||||
.chart-bleed.no-bleed {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-content {
|
||||
|
||||
@@ -75,15 +75,11 @@
|
||||
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
||||
alt={location.country}
|
||||
/>
|
||||
<!-- the region lives here; the page hero carries the place name -->
|
||||
<span class="text-sm font-semibold text-foreground">
|
||||
{location.name}
|
||||
{#if location.admin1}{location.admin1}, {location.country}{:else}{location.country ??
|
||||
location.name}{/if}
|
||||
</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>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ export interface 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-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. */
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
throw redirect(303, canonicalPath);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
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 { Label } from '$lib/components/ui/label';
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
import { defaultParameters, ensembleModelGroups } from '../../options';
|
||||
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
|
||||
import UnitSelector from '../../week/[location]/UnitSelector.svelte';
|
||||
|
||||
import type { PageData } from './$types';
|
||||
|
||||
@@ -46,10 +47,25 @@
|
||||
|
||||
let params = $state({
|
||||
...defaultParameters,
|
||||
hourly: ['temperature_2m'],
|
||||
hourly: [
|
||||
'temperature_2m',
|
||||
'precipitation',
|
||||
'wind_speed_10m',
|
||||
'relative_humidity_2m',
|
||||
'cloud_cover',
|
||||
'pressure_msl'
|
||||
],
|
||||
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 ────────────────────────────────────────────────────
|
||||
|
||||
interface FetchedData {
|
||||
@@ -119,9 +135,24 @@
|
||||
|
||||
// ─── 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.
|
||||
let timestampsSec = $derived.by(() =>
|
||||
fetchedData ? fetchedData.timestamps.map((t) => t / 1000) : []
|
||||
fetchedData ? fetchedData.timestamps.slice(0, validLength).map((t) => t / 1000) : []
|
||||
);
|
||||
|
||||
interface ChartDef {
|
||||
@@ -213,24 +244,22 @@
|
||||
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
||||
{location.name}
|
||||
</h1>
|
||||
<p class="truncate text-sm text-muted-foreground">
|
||||
{#if location.admin1}{location.admin1},
|
||||
{/if}{location.country ?? ''}
|
||||
<span class="mx-1 opacity-50">·</span>
|
||||
14-day ensemble forecast
|
||||
</p>
|
||||
<p class="truncate text-sm text-muted-foreground">14-day ensemble forecast</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModelSelector
|
||||
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
|
||||
groups={ensembleModelGroups}
|
||||
label="Ensemble model"
|
||||
onModelChange={(model) => {
|
||||
params.models = [model];
|
||||
storedEnsembleModel.set(model);
|
||||
}}
|
||||
/>
|
||||
<div class="flex w-full items-center gap-3 sm:w-auto">
|
||||
<ModelSelector
|
||||
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
|
||||
groups={ensembleModelGroups}
|
||||
label="Ensemble model"
|
||||
onModelChange={(model) => {
|
||||
params.models = [model];
|
||||
storedEnsembleModel.set(model);
|
||||
}}
|
||||
/>
|
||||
<UnitSelector />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
||||
@@ -269,9 +298,9 @@
|
||||
<div class="mt-6 md:mt-10">
|
||||
<ChartToolbar charts={liveCharts} fileName="14-day-forecast">
|
||||
{#snippet controls()}
|
||||
<div class="flex gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
{/snippet}
|
||||
</ChartToolbar>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { get } from 'svelte/store';
|
||||
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 { Checkbox } from '$lib/components/ui/checkbox';
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
import { findModel, hourly, modelGroups } from '../../options';
|
||||
import { defaultParameters } from '../../options';
|
||||
import UnitSelector from '../../week/[location]/UnitSelector.svelte';
|
||||
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
|
||||
|
||||
import type { PageData } from './$types';
|
||||
@@ -63,6 +64,14 @@
|
||||
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 ────────────────────────────────────────────────────
|
||||
|
||||
interface FetchedData {
|
||||
@@ -268,9 +277,12 @@
|
||||
<div class="mt-6 md:mt-10">
|
||||
<ChartToolbar charts={liveCharts} fileName="model-comparison">
|
||||
{#snippet controls()}
|
||||
<div class="flex gap-2">
|
||||
<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>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
||||
<Label for="show_legend" class="cursor-pointer text-base leading-none">Show legend</Label>
|
||||
</div>
|
||||
<UnitSelector />
|
||||
</div>
|
||||
{/snippet}
|
||||
</ChartToolbar>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
storedChartLayout,
|
||||
storedLocation,
|
||||
storedModel,
|
||||
storedUnits,
|
||||
storedVariablePrefs
|
||||
} from '$lib/stores/settings';
|
||||
|
||||
@@ -19,6 +20,7 @@
|
||||
import HourlyTable from './HourlyTable.svelte';
|
||||
import MeteogramCharts from './MeteogramCharts.svelte';
|
||||
import ModelSelector from './ModelSelector.svelte';
|
||||
import UnitSelector from './UnitSelector.svelte';
|
||||
import VariableSidebar from './VariableSidebar.svelte';
|
||||
import { neededHourlyApiVars } from './variables';
|
||||
|
||||
@@ -32,6 +34,14 @@
|
||||
...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);
|
||||
|
||||
// Number of meteogram panels: reserves the chart area height before data
|
||||
@@ -60,6 +70,9 @@
|
||||
let loadError = $state<string | null>(null);
|
||||
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();
|
||||
|
||||
let fetchedHourly: FetchedHourly | null = $state(null);
|
||||
@@ -97,7 +110,7 @@
|
||||
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
||||
forecast_days: 7,
|
||||
forecast_days: forecastDays,
|
||||
past_days: 0,
|
||||
timezone: loc.timezone
|
||||
})
|
||||
@@ -151,12 +164,7 @@
|
||||
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
||||
{location.name}
|
||||
</h1>
|
||||
<p class="truncate text-sm text-muted-foreground">
|
||||
{#if location.admin1}{location.admin1},
|
||||
{/if}{location.country ?? ''}
|
||||
<span class="mx-1 opacity-50">·</span>
|
||||
7-day forecast
|
||||
</p>
|
||||
<p class="truncate text-sm text-muted-foreground">7-day forecast</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -166,28 +174,10 @@
|
||||
onModelChange={(model) => {
|
||||
params.models = [model];
|
||||
storedModel.set(model);
|
||||
forecastDays = 7; // a new model may not support the extended range
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
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>
|
||||
<UnitSelector />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -201,7 +191,14 @@
|
||||
</div>
|
||||
{/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}
|
||||
<HourlyTable
|
||||
@@ -210,6 +207,7 @@
|
||||
{selectedDay}
|
||||
units={params}
|
||||
locationName={location.name ?? ''}
|
||||
onCustomize={() => (variableSidebarOpen = true)}
|
||||
/>
|
||||
{:else}
|
||||
<!-- placeholder with the table's approximate height: no layout shift -->
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
||||
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
@@ -12,9 +12,12 @@
|
||||
selectedDay: Date;
|
||||
units: WeatherUnits;
|
||||
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 {
|
||||
if (!daily) return 0;
|
||||
@@ -34,13 +37,49 @@
|
||||
const ratio = (sunshineSeconds ?? 0) / daylightSeconds;
|
||||
if (ratio >= 0.7) return '#f59e0b';
|
||||
if (ratio >= 0.45) return '#fbbf24';
|
||||
if (ratio >= 0.2) return '#fcd34d';
|
||||
if (ratio >= 0.1) return '#fcd34d';
|
||||
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>
|
||||
|
||||
<!-- 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 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}
|
||||
{#each daily.dailyDates as time, index (index)}
|
||||
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
||||
@@ -57,41 +96,62 @@
|
||||
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
|
||||
{@const unit = String(units.temperature_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
|
||||
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
|
||||
? 'border-primary/50 bg-primary/5 shadow-md ring-2 ring-primary/40'
|
||||
: 'border-border/60 bg-card shadow-xs hover:-translate-y-0.5 hover:border-border hover:shadow-md'}"
|
||||
? '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:z-10 hover:-translate-y-1 hover:scale-[1.02] hover:border-border hover:shadow-lg'}"
|
||||
aria-pressed={selected}
|
||||
onclick={() => onSelectDay(time, index)}
|
||||
>
|
||||
<!-- 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()}
|
||||
</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)}
|
||||
</span>
|
||||
|
||||
<!-- Weather icon -->
|
||||
<svg class="day-icon my-1 fill-foreground" width="46px" height="46px">
|
||||
<use
|
||||
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
|
||||
wCode as keyof typeof weatherCodes
|
||||
] ?? 'clear'}.svg#Layer_1"
|
||||
></use>
|
||||
</svg>
|
||||
<!-- Weather icon: large day with a night badge in the corner -->
|
||||
<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
|
||||
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
|
||||
></use>
|
||||
</svg>
|
||||
<svg
|
||||
class="night-icon absolute -right-2 -bottom-1 rounded-full bg-card fill-foreground/60 p-0.5 ring-1 ring-border/60"
|
||||
width="42px"
|
||||
height="42px"
|
||||
>
|
||||
<use
|
||||
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, false)}.svg#Layer_1"
|
||||
></use>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Temperature max/min -->
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<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}"
|
||||
>
|
||||
{tempMax.toFixed(0)}°
|
||||
</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)}°
|
||||
</span>
|
||||
</div>
|
||||
@@ -99,8 +159,8 @@
|
||||
<!-- Details -->
|
||||
<div class="mt-1.5 flex w-full flex-col gap-1 border-t border-border/50 px-1 pt-1.5">
|
||||
<!-- Sunshine -->
|
||||
<div class="flex w-full items-center gap-1.5">
|
||||
<svg class="shrink-0" width="13px" height="13px" style="fill: {sunColor}">
|
||||
<div class="flex w-full items-center gap-1.5 {lowSun ? 'opacity-45' : ''}">
|
||||
<svg class="shrink-0" width="20px" height="20px" style="fill: {sunColor}">
|
||||
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
|
||||
</svg>
|
||||
<div class="h-1 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
@@ -118,30 +178,52 @@
|
||||
<div
|
||||
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">
|
||||
<svg class="shrink-0 fill-foreground/70" width="13px" height="13px">
|
||||
<span
|
||||
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>
|
||||
</svg>
|
||||
{Number(precipSum ?? 0).toFixed(
|
||||
precipSum >= 10 ? 0 : 1
|
||||
)}{units.precipitation_unit === 'mm' ? ' mm' : "'"}
|
||||
</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)}
|
||||
<span
|
||||
class="inline-flex shrink-0"
|
||||
class="inline-flex shrink-0 -mr-2"
|
||||
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>
|
||||
</svg>
|
||||
</span>
|
||||
{: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>
|
||||
</svg>
|
||||
{/if}
|
||||
{windMax?.toFixed(0) ?? '-'}<span class="text-muted-foreground"
|
||||
{windMax?.toFixed(0) ?? '-'}<span class="opacity-70"
|
||||
>-{gustMax?.toFixed(0) ?? '-'}</span
|
||||
>
|
||||
</span>
|
||||
@@ -150,19 +232,47 @@
|
||||
</button>
|
||||
{/if}
|
||||
{/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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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) {
|
||||
button {
|
||||
min-width: 96px !important;
|
||||
}
|
||||
|
||||
button :global(.day-icon) {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
.day-card {
|
||||
flex: 0 0 auto;
|
||||
width: 168px;
|
||||
max-width: none;
|
||||
zoom: 0.82;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
|
||||
|
||||
import { setGroupHover } from '$lib/charts';
|
||||
|
||||
import { getTempStyle } from '../../utils/colors';
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
import {
|
||||
@@ -21,9 +23,33 @@
|
||||
selectedDay: Date;
|
||||
units: WeatherUnits;
|
||||
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);
|
||||
|
||||
@@ -209,7 +235,7 @@
|
||||
|
||||
{#if cellData.length > 0}
|
||||
{@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);
|
||||
a contained rounded card on md+ -->
|
||||
<section
|
||||
@@ -228,23 +254,47 @@
|
||||
{timezoneLabel}
|
||||
</span>
|
||||
</h3>
|
||||
<div
|
||||
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-[13px] font-semibold"
|
||||
role="group"
|
||||
aria-label="Hourly interval"
|
||||
>
|
||||
{#each [3, 1] as interval (interval)}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if onCustomize}
|
||||
<button
|
||||
class="cursor-pointer rounded-md px-3 py-1 transition-colors {hourlyInterval ===
|
||||
interval
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
aria-pressed={hourlyInterval === interval}
|
||||
onclick={() => (hourlyInterval = interval as 1 | 3)}
|
||||
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"
|
||||
>
|
||||
{interval}h
|
||||
<!-- sliders icon -->
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
d="M4 6h9m5 0h2M4 12h2m5 0h9M4 18h9m5 0h2M13 4.5v3M6 10.5v3M18 16.5v3"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Variables</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
<div
|
||||
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-[13px] font-semibold"
|
||||
role="group"
|
||||
aria-label="Hourly interval"
|
||||
>
|
||||
{#each [3, 1] as interval (interval)}
|
||||
<button
|
||||
class="cursor-pointer rounded-md px-3 py-1 transition-colors {hourlyInterval ===
|
||||
interval
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
aria-pressed={hourlyInterval === interval}
|
||||
onclick={() => (hourlyInterval = interval as 1 | 3)}
|
||||
>
|
||||
{interval}h
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -269,7 +319,12 @@
|
||||
<th class="hdr" scope="row" bind:clientWidth={headerColWidth}>
|
||||
<span class="text-[10px] font-semibold text-muted-foreground">Time</span>
|
||||
</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 -->
|
||||
{#if sunTimes && sunrisePercent != null && sunsetPercent != null}
|
||||
<div
|
||||
@@ -329,25 +384,44 @@
|
||||
</span>
|
||||
</div>
|
||||
{/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 -->
|
||||
{#each cellData as cell, i (cell.idx)}
|
||||
{@const leftPct = (i / cellData.length) * 100}
|
||||
{@const widthPct = 100 / cellData.length}
|
||||
<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' : ''}"
|
||||
style="left:{leftPct}%;width:{widthPct}%"
|
||||
>
|
||||
{#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-px text-[10px] leading-none font-semibold {cell.isNow
|
||||
? 'text-red-500 dark:text-red-400'
|
||||
: 'text-muted-foreground'}">00</sup
|
||||
>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-baseline gap-1">
|
||||
<span class="text-[11px] font-semibold"
|
||||
>{formatZoned(cell.date, data.timezone, 'HH')}</span
|
||||
>
|
||||
<sup
|
||||
class="align-baseline text-[9px] leading-none font-semibold text-muted-foreground"
|
||||
>00</sup
|
||||
class="inline-block -translate-x-0.5 translate-y-[0.16rem] align-baseline text-[9px] leading-none font-semibold {cell.isNow
|
||||
? 'text-red-500 dark:text-red-400'
|
||||
: 'text-muted-foreground'}">00</sup
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
@@ -495,16 +569,11 @@
|
||||
style="left:{headerColWidth + nowIdx * colWidth}px;width:{colWidth}px"
|
||||
></div>
|
||||
{/if}
|
||||
<!-- full-height current-time line (its "Now" label lives in the time row) -->
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
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 ChartCustomizer from './ChartCustomizer.svelte';
|
||||
@@ -39,8 +39,22 @@
|
||||
$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.
|
||||
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 {
|
||||
if (!data) return null;
|
||||
@@ -96,6 +110,21 @@
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
interface RenderPanel extends ChartPanel {
|
||||
@@ -112,6 +141,16 @@
|
||||
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>
|
||||
|
||||
<section class="mt-8" in:fade={{ duration: 200 }}>
|
||||
@@ -129,11 +168,31 @@
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<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]"
|
||||
>Ctrl</kbd
|
||||
>
|
||||
+ scroll to zoom
|
||||
</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
|
||||
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
|
||||
role="group"
|
||||
@@ -180,14 +239,23 @@
|
||||
{:else}
|
||||
<div class="flex flex-col gap-6">
|
||||
{#each renderPanels as panel, i (panel.id)}
|
||||
<div class="rounded-2xl border border-border/70 bg-card p-3 shadow-sm md:p-4">
|
||||
<div class="mb-1 flex items-center justify-between px-1">
|
||||
<!-- full-bleed to the screen edges on mobile; a contained card on md+ -->
|
||||
<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">
|
||||
<span class="hidden md:inline">{panel.title}</span>
|
||||
<span class="md:hidden">{panel.titleShort}</span>
|
||||
</h4>
|
||||
</div>
|
||||
<ChartContainer {loading} chartCount={1} chartHeight={CHART_HEIGHT} minWidth={520}>
|
||||
<ChartContainer
|
||||
{loading}
|
||||
chartCount={1}
|
||||
chartHeight={CHART_HEIGHT}
|
||||
minWidth={520}
|
||||
bleed={false}
|
||||
>
|
||||
<CanvasChart
|
||||
bind:this={chartComponents[i]}
|
||||
timestamps={timestampsSec}
|
||||
@@ -195,6 +263,9 @@
|
||||
series={panel.def.series}
|
||||
bands={data.daylightBands}
|
||||
pictograms={panel.def.hasPictograms ? pictograms : []}
|
||||
windArrows={panel.def.hasWindArrows ? windArrowMarks : []}
|
||||
reserveRightAxis={anyRightAxis}
|
||||
reserveTopRows={maxTopRows}
|
||||
highlight={selectedDayHighlight}
|
||||
unit={panel.def.unit}
|
||||
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;
|
||||
/** Draw weather-code pictograms across the top of the chart */
|
||||
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) */
|
||||
cloudBand?: boolean;
|
||||
/** Transform raw values before plotting (e.g. m → km) */
|
||||
@@ -62,13 +66,21 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
|
||||
type: 'line',
|
||||
kind: 'temp',
|
||||
color: '#ef6c00',
|
||||
width: 4,
|
||||
fill: true,
|
||||
fillOpacity: 0.12,
|
||||
width: 9,
|
||||
colorScale: true,
|
||||
outline: true,
|
||||
extrema: true,
|
||||
pictograms: true
|
||||
extrema: 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',
|
||||
@@ -189,7 +201,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
|
||||
color: '#26a69a',
|
||||
width: 2,
|
||||
fill: true,
|
||||
fillOpacity: 0.15
|
||||
fillOpacity: 0.15,
|
||||
windArrows: true
|
||||
},
|
||||
{
|
||||
key: 'wind_gusts',
|
||||
@@ -347,7 +360,7 @@ export function isZeroBased(kind: UnitKind): boolean {
|
||||
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 {
|
||||
switch (kind) {
|
||||
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 {
|
||||
series: ChartSeries[];
|
||||
unit: string;
|
||||
@@ -370,6 +397,7 @@ export interface PanelDef {
|
||||
/** Whether the left axis should include zero (false for pressure) */
|
||||
zeroBaseLeft: boolean;
|
||||
hasPictograms: boolean;
|
||||
hasWindArrows: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -382,10 +410,13 @@ export function buildPanelDef(
|
||||
hourly: WeekHourlyData,
|
||||
units: WeatherUnits
|
||||
): PanelDef {
|
||||
const defs = variableKeys
|
||||
const allDefs = variableKeys
|
||||
.map((k) => VARIABLE_BY_KEY.get(k))
|
||||
.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.
|
||||
const axisDefs = defs.filter((d) => !d.cloudBand);
|
||||
const kinds: UnitKind[] = [];
|
||||
@@ -400,6 +431,7 @@ export function buildPanelDef(
|
||||
: raw;
|
||||
const kindUnit = unitForKind(d.kind, units);
|
||||
const dec = decimalsForKind(d.kind);
|
||||
const tipDec = tooltipDecimalsForKind(d.kind);
|
||||
const axis: 'left' | 'right' = d.cloudBand || d.kind === leftKind ? 'left' : 'right';
|
||||
|
||||
return {
|
||||
@@ -425,9 +457,9 @@ export function buildPanelDef(
|
||||
? (v: number, i: number) => {
|
||||
const dir = hourly.winddirection_10m?.[i];
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -441,6 +473,7 @@ export function buildPanelDef(
|
||||
zeroBaseLeft: leftKind !== 'pressure',
|
||||
yMinRight: rightKind && rightZero ? 0 : 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