revamp weather UI: cards, meteograms, units in header, location favorites and range controls
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
storedChartLayout,
|
||||
storedLocation,
|
||||
storedModel,
|
||||
storedUnits,
|
||||
storedVariablePrefs
|
||||
} from '$lib/stores/settings';
|
||||
|
||||
@@ -32,6 +33,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 +69,11 @@
|
||||
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);
|
||||
// 0 by default; the user can pull in a few recent past days
|
||||
let pastDays = $state(0);
|
||||
|
||||
const selectedDay = new SvelteDate();
|
||||
|
||||
let fetchedHourly: FetchedHourly | null = $state(null);
|
||||
@@ -97,8 +111,8 @@
|
||||
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,
|
||||
past_days: 0,
|
||||
forecast_days: forecastDays,
|
||||
past_days: pastDays,
|
||||
timezone: loc.timezone
|
||||
})
|
||||
.then((result: WeekForecastResult) => {
|
||||
@@ -152,10 +166,10 @@
|
||||
{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
|
||||
<span class="lg:hidden"
|
||||
>{#if location.admin1}{location.admin1},
|
||||
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
|
||||
>7-day forecast
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,28 +180,11 @@
|
||||
onModelChange={(model) => {
|
||||
params.models = [model];
|
||||
storedModel.set(model);
|
||||
// a new model may not support the extended / past range
|
||||
forecastDays = 7;
|
||||
pastDays = 0;
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -201,7 +198,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<DailyCards daily={fetchedDaily} {selectedDay} units={params} onSelectDay={switchDay} />
|
||||
<DailyCards
|
||||
daily={fetchedDaily}
|
||||
{selectedDay}
|
||||
units={params}
|
||||
onSelectDay={switchDay}
|
||||
canExtend={forecastDays < 15}
|
||||
onExtend={() => (forecastDays = 15)}
|
||||
canExtendPast={pastDays < 3}
|
||||
onExtendPast={() => (pastDays = 3)}
|
||||
/>
|
||||
|
||||
{#if fetchedHourly && fetchedDaily}
|
||||
<HourlyTable
|
||||
@@ -210,6 +216,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,42 @@
|
||||
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;
|
||||
/** Offer a button before the first day to load recent past days */
|
||||
canExtendPast?: boolean;
|
||||
onExtendPast?: () => void;
|
||||
}
|
||||
|
||||
let { daily, selectedDay, units, onSelectDay }: Props = $props();
|
||||
let {
|
||||
daily,
|
||||
selectedDay,
|
||||
units,
|
||||
onSelectDay,
|
||||
canExtend = false,
|
||||
onExtend,
|
||||
canExtendPast = false,
|
||||
onExtendPast
|
||||
}: Props = $props();
|
||||
|
||||
// The "past days" button sits before the first card but starts scrolled out
|
||||
// of view — the user reveals it by scrolling left. Re-hide only when the
|
||||
// dataset (location) changes, not on every re-render.
|
||||
let scrollEl = $state<HTMLDivElement>();
|
||||
let pastBtnEl = $state<HTMLButtonElement>();
|
||||
let hiddenForRef: FetchedDaily | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const d = daily;
|
||||
if (!d || !canExtendPast || !scrollEl || !pastBtnEl || hiddenForRef === d) return;
|
||||
hiddenForRef = d;
|
||||
const btn = pastBtnEl.getBoundingClientRect();
|
||||
const cont = scrollEl.getBoundingClientRect();
|
||||
// scroll so the button's right edge sits just past the left edge (a small
|
||||
// gap of extra margin keeps the first day card from hugging the edge)
|
||||
scrollEl.scrollLeft += btn.right - cont.left + 6;
|
||||
});
|
||||
|
||||
function getDaylightSeconds(index: number): number {
|
||||
if (!daily) return 0;
|
||||
@@ -34,14 +67,82 @@
|
||||
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">
|
||||
<!-- negative margin + matching padding: the scroll box gains room so a
|
||||
lifted/scaled/shadowed card is never clipped, while the first card still
|
||||
lines up with the page content edge -->
|
||||
<div
|
||||
bind:this={scrollEl}
|
||||
class="-mx-3 flex gap-2 overflow-x-auto px-3 pt-4 pb-8"
|
||||
style="scrollbar-width: thin"
|
||||
>
|
||||
{#if daily}
|
||||
{#if canExtendPast && onExtendPast}
|
||||
<button
|
||||
bind:this={pastBtnEl}
|
||||
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={onExtendPast}
|
||||
aria-label="Load recent past days"
|
||||
>
|
||||
<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="M14 13l-3 3 3 3" />
|
||||
</svg>
|
||||
<span class="text-center text-[11px] leading-tight font-semibold">
|
||||
Past<br />3 days
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#each daily.dailyDates as time, index (index)}
|
||||
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
||||
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
||||
@@ -57,41 +158,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 +221,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 +240,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 +294,42 @@
|
||||
</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, just scale the whole card down. */
|
||||
@media (max-width: 768px) {
|
||||
button {
|
||||
min-width: 96px !important;
|
||||
}
|
||||
|
||||
button :global(.day-icon) {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
.day-card {
|
||||
zoom: 0.72;
|
||||
}
|
||||
}
|
||||
</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-15 -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-0 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}
|
||||
|
||||
@@ -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