darkmode
This commit is contained in:
@@ -40,11 +40,10 @@
|
||||
let fetchedHourly: FetchedHourly | null = $state(null);
|
||||
let fetchedDaily: FetchedDaily | null = $state(null);
|
||||
|
||||
let meteogramCharts: MeteogramCharts | undefined = $state();
|
||||
|
||||
// Charts intentionally keep their current range: they show the full week
|
||||
// unless the user narrows it via the range presets or Ctrl+scroll.
|
||||
const switchDay = (date: Date) => {
|
||||
selectedDay.setTime(date.getTime());
|
||||
meteogramCharts?.scrollToDay(date);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
@@ -109,6 +108,37 @@
|
||||
|
||||
<div class="week-page">
|
||||
<div class="weather-content" style="min-height: 50vh">
|
||||
<!-- Page hero: prominent location + weather model selection -->
|
||||
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<img
|
||||
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
||||
src="/images/country-flags/{(
|
||||
location.country_code || 'united_nations'
|
||||
).toLowerCase()}.svg"
|
||||
alt={location.country ?? ''}
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModelSelector
|
||||
selectedModel={params.models?.[0] ?? 'best_match'}
|
||||
onModelChange={(model) => {
|
||||
params.models = [model];
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if loadError}
|
||||
<div
|
||||
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
@@ -130,20 +160,7 @@
|
||||
{/if}
|
||||
|
||||
{#if fetchedHourly}
|
||||
<MeteogramCharts
|
||||
bind:this={meteogramCharts}
|
||||
data={fetchedHourly}
|
||||
{selectedDay}
|
||||
units={params}
|
||||
{loading}
|
||||
/>
|
||||
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
|
||||
{/if}
|
||||
|
||||
<ModelSelector
|
||||
selectedModel={params.models?.[0] ?? 'best_match'}
|
||||
onModelChange={(model) => {
|
||||
params.models = [model];
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -159,6 +159,24 @@
|
||||
let sunsetPercent = $derived(
|
||||
sunTimes && cellData.length > 0 ? timeToFraction(sunTimes.sunset) * 100 : null
|
||||
);
|
||||
|
||||
// ─── "Now" indicator ─────────────────────────────────────────────────────
|
||||
// Full-height vertical line across the table, positioned at the current
|
||||
// time within the selected day (only when the selected day is today).
|
||||
let isTodaySelected = $derived(
|
||||
cellData.length > 0 && isSameDayInZone(today, selectedDay, data.timezone)
|
||||
);
|
||||
let nowPercent = $derived(isTodaySelected ? timeToFraction(today) * 100 : null);
|
||||
|
||||
// Width of the row-header column, measured so the now-line can be
|
||||
// positioned relative to the data columns only.
|
||||
let headerColWidth = $state(0);
|
||||
let tableWidth = $state(0);
|
||||
let nowLeftPx = $derived(
|
||||
nowPercent != null && tableWidth > 0
|
||||
? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth)
|
||||
: null
|
||||
);
|
||||
</script>
|
||||
|
||||
{#snippet weatherIcon(name: string, size: number = 16)}
|
||||
@@ -169,314 +187,324 @@
|
||||
|
||||
{#snippet rowHeader(iconName?: string, unit?: string, label?: string)}
|
||||
<th class="hdr" scope="row">
|
||||
<div class="flex flex-col items-center leading-tight">
|
||||
<div class="flex flex-col items-center gap-0.5 leading-tight">
|
||||
{#if iconName}
|
||||
{@render weatherIcon(iconName)}
|
||||
{@render weatherIcon(iconName, 18)}
|
||||
{/if}
|
||||
{#if label}
|
||||
<span class="text-[11px] font-semibold text-muted-foreground">{label}</span>
|
||||
<span class="text-[11px] font-semibold">{label}</span>
|
||||
{/if}
|
||||
{#if unit}
|
||||
<span class="text-[10px] font-semibold text-muted-foreground">{unit}</span>
|
||||
<span class="text-[10px] font-medium text-muted-foreground">{unit}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
{/snippet}
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="text-lg font-bold">
|
||||
{formatZoned(selectedDay, data.timezone, 'EEEE')} – Hourly
|
||||
<span class="ml-1 text-xs font-normal text-muted-foreground">({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)}
|
||||
<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>
|
||||
|
||||
{#if cellData.length > 0}
|
||||
{@const hourly = data.hourly}
|
||||
{@const iconPx = is3h ? 40 : 26}
|
||||
<div class="overflow-hidden rounded-xl border border-border/70 bg-card shadow-xs">
|
||||
<table class="w-full table-fixed border-collapse whitespace-nowrap">
|
||||
<caption class="sr-only">Hourly weather details for {locationName}</caption>
|
||||
<colgroup>
|
||||
<col class="w-14 md:w-16" />
|
||||
{#each cellData as _ (_.idx)}
|
||||
<col />
|
||||
{@const iconPx = is3h ? 38 : 26}
|
||||
<section class="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
|
||||
<!-- Card toolbar -->
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-2 border-b border-border/70 bg-muted/40 px-4 py-2.5"
|
||||
>
|
||||
<h3 class="text-base font-bold">
|
||||
{formatZoned(selectedDay, data.timezone, 'EEEE')}
|
||||
<span class="font-semibold text-muted-foreground">– hourly</span>
|
||||
<span
|
||||
class="ms-2 rounded-full bg-muted px-2 py-0.5 align-middle text-[10px] font-semibold text-muted-foreground"
|
||||
>
|
||||
{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)}
|
||||
<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}
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<!-- Time + Daylight bar (merged) -->
|
||||
<tr class="!border-t-0">
|
||||
<th class="hdr" scope="row">
|
||||
<span class="text-[10px] font-semibold text-muted-foreground">{timezoneLabel}</span>
|
||||
</th>
|
||||
<td colspan={cellData.length} class="relative h-10 overflow-visible p-0">
|
||||
<!-- Daylight background -->
|
||||
{#if sunTimes && sunrisePercent != null && sunsetPercent != null}
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 bg-indigo-950/10 dark:bg-indigo-950/30"
|
||||
style="width:{sunrisePercent}%"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-y-0 bg-amber-400/15 dark:bg-amber-400/10"
|
||||
style="left:{sunrisePercent}%;width:{sunsetPercent - sunrisePercent}%"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 bg-indigo-950/10 dark:bg-indigo-950/30"
|
||||
style="width:{100 - sunsetPercent}%"
|
||||
></div>
|
||||
<!-- Sunrise marker + label -->
|
||||
<div class="absolute inset-y-0 w-px bg-amber-500/70" style="left:{sunrisePercent}%">
|
||||
<span
|
||||
class="absolute bottom-0.5 left-1 whitespace-nowrap text-[10px] font-semibold leading-none text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<svg
|
||||
class="fill-foreground inline-block"
|
||||
width="12px"
|
||||
height="12px"
|
||||
aria-hidden="true"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative" bind:clientWidth={tableWidth}>
|
||||
<table class="w-full table-fixed border-collapse whitespace-nowrap">
|
||||
<caption class="sr-only">Hourly weather details for {locationName}</caption>
|
||||
<colgroup>
|
||||
<col class="w-16 md:w-20" />
|
||||
{#each cellData as _ (_.idx)}
|
||||
<col />
|
||||
{/each}
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<!-- Time + Daylight bar (merged) -->
|
||||
<tr class="row">
|
||||
<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">
|
||||
<!-- Daylight background -->
|
||||
{#if sunTimes && sunrisePercent != null && sunsetPercent != null}
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 bg-indigo-950/10 dark:bg-indigo-950/40"
|
||||
style="width:{sunrisePercent}%"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-y-0 bg-amber-400/15 dark:bg-amber-300/10"
|
||||
style="left:{sunrisePercent}%;width:{sunsetPercent - sunrisePercent}%"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 bg-indigo-950/10 dark:bg-indigo-950/40"
|
||||
style="width:{100 - sunsetPercent}%"
|
||||
></div>
|
||||
<!-- Sunrise marker + label -->
|
||||
<div class="absolute inset-y-0 w-px bg-amber-500/70" style="left:{sunrisePercent}%">
|
||||
<span
|
||||
class="absolute bottom-0.5 left-1 text-[10px] leading-none font-semibold whitespace-nowrap text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"
|
||||
></use>
|
||||
</svg>
|
||||
<span class="align-middle">{formatTime(sunTimes.sunrise)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Sunset marker + label -->
|
||||
<div class="absolute inset-y-0 w-px bg-indigo-400/70" style="left:{sunsetPercent}%">
|
||||
<span
|
||||
class="absolute bottom-0.5 right-1 whitespace-nowrap text-[10px] font-semibold leading-none text-indigo-600 dark:text-indigo-300 inline-flex items-center gap-1"
|
||||
>
|
||||
<svg
|
||||
class="fill-foreground inline-block"
|
||||
width="12px"
|
||||
height="12px"
|
||||
aria-hidden="true"
|
||||
<svg
|
||||
class="inline-block fill-foreground"
|
||||
width="12px"
|
||||
height="12px"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use
|
||||
class="stroke-2"
|
||||
xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"
|
||||
></use>
|
||||
</svg>
|
||||
<span class="align-middle">{formatTime(sunTimes.sunrise)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Sunset marker + label -->
|
||||
<div class="absolute inset-y-0 w-px bg-indigo-400/70" style="left:{sunsetPercent}%">
|
||||
<span
|
||||
class="absolute right-1 bottom-0.5 inline-flex items-center gap-1 text-[10px] leading-none font-semibold whitespace-nowrap text-indigo-600 dark:text-indigo-300"
|
||||
>
|
||||
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"
|
||||
></use>
|
||||
</svg>
|
||||
<span class="align-middle">{formatTime(sunTimes.sunset)}</span>
|
||||
<svg
|
||||
class="inline-block fill-foreground"
|
||||
width="12px"
|
||||
height="12px"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"
|
||||
></use>
|
||||
</svg>
|
||||
<span class="align-middle">{formatTime(sunTimes.sunset)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/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
|
||||
{cell.isNow ? 'text-red-600 dark:text-red-400' : ''}"
|
||||
style="left:{leftPct}%;width:{widthPct}%"
|
||||
>
|
||||
{#if is3h}
|
||||
{formatZoned(cell.date, data.timezone, 'HH')}
|
||||
{: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
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Hour labels -->
|
||||
{/each}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Weather Icons -->
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-day-cloudy')}
|
||||
{#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 font-bold pl-0.5 text-sm
|
||||
{cell.isNow ? 'text-destructive' : ''}"
|
||||
style="left:{leftPct}%;width:{widthPct}%"
|
||||
{@const wCode = hourly.weather_code[cell.idx]}
|
||||
<td
|
||||
class="cell leading-0 {is3h ? 'h-14' : 'h-11'}"
|
||||
class:icon-day={cell.isDaytime}
|
||||
class:icon-night={!cell.isDaytime}
|
||||
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
|
||||
class:icon-dusk={cell.isDaytime && cellData[i + 1] && !cellData[i + 1].isDaytime}
|
||||
>
|
||||
{#if is3h}
|
||||
{formatZoned(cell.date, data.timezone, 'HH')}
|
||||
{:else}
|
||||
<span class="inline-flex items-baseline gap-1">
|
||||
<span class="text-[11px] font-semibold"
|
||||
>{formatZoned(cell.date, data.timezone, 'HH')}</span
|
||||
>
|
||||
<sup
|
||||
class="text-[9px] font-semibold text-muted-foreground leading-none align-baseline"
|
||||
>00</sup
|
||||
>
|
||||
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
|
||||
<!-- Temperature -->
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-thermometer', tempUnit, 'Temp')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const temp = hourly.temperature_2m[cell.idx]}
|
||||
{@const style = getTempStyle(temp, String(units.temperature_unit))}
|
||||
<td
|
||||
class="cell h-10 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
|
||||
style="background-color:{style.bg};color:{style.fg}"
|
||||
>
|
||||
{formatTemp(temp)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
|
||||
<!-- Feels Like -->
|
||||
<tr class="row">
|
||||
{@render rowHeader(undefined, tempUnit, 'Feels')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const temp = hourly.apparent_temperature[cell.idx]}
|
||||
<td class="cell h-8 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}">
|
||||
{formatTemp(temp)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
|
||||
<!-- Wind -->
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-strong-wind', windUnit, 'Wind')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const wind = hourly.windspeed_10m[cell.idx]}
|
||||
{@const windDir = hourly.winddirection_10m[cell.idx]}
|
||||
<td class="cell h-13 align-middle leading-tight">
|
||||
{#if windDir != null && !isNaN(windDir)}
|
||||
<span
|
||||
class="inline-block leading-0"
|
||||
style="transform:{getWindArrowRotation(windDir)}"
|
||||
>
|
||||
{@render weatherIcon('wi-direction-down', 22)}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="block font-semibold {is3h ? 'text-sm' : 'text-xs'}">
|
||||
{formatValue(wind)}
|
||||
</span>
|
||||
</td>
|
||||
{/each}
|
||||
</td>
|
||||
</tr>
|
||||
</tr>
|
||||
|
||||
<!-- Weather Icons -->
|
||||
<tr>
|
||||
{@render rowHeader('wi-day-cloudy')}
|
||||
{#each cellData as cell, i (cell.idx)}
|
||||
{@const wCode = hourly.weather_code[cell.idx]}
|
||||
<td
|
||||
class="cell leading-[0] {is3h ? 'px-1 py-2.5' : 'px-0.5 py-1.5'}"
|
||||
class:now={cell.isNow}
|
||||
class:icon-day={cell.isDaytime}
|
||||
class:icon-night={!cell.isDaytime}
|
||||
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
|
||||
class:icon-dusk={cell.isDaytime && cellData[i + 1] && !cellData[i + 1].isDaytime}
|
||||
>
|
||||
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
<!-- Humidity -->
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-humidity', '%', 'Humidity')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const hum = hourly.relative_humidity_2m[cell.idx]}
|
||||
<td class="cell h-8" style="background:{getHumidityBg(hum ?? 0)}">
|
||||
{formatValue(hum)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
|
||||
<!-- Temperature -->
|
||||
<tr>
|
||||
{@render rowHeader('wi-thermometer', tempUnit)}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const temp = hourly.temperature_2m[cell.idx]}
|
||||
{@const style = getTempStyle(temp, String(units.temperature_unit))}
|
||||
<td
|
||||
class="cell font-bold {is3h ? 'py-2.5 text-lg' : 'py-2 text-[15px]'}"
|
||||
class:now={cell.isNow}
|
||||
style="background-color:{style.bg};color:{style.fg}"
|
||||
>
|
||||
{formatTemp(temp)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
<!-- Cloud Cover -->
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-cloud', '%', 'Clouds')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const cloud = hourly.cloud_cover[cell.idx]}
|
||||
<td
|
||||
class="cell h-8"
|
||||
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
|
||||
>
|
||||
{formatValue(cloud)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
|
||||
<!-- Feels Like -->
|
||||
<tr>
|
||||
{@render rowHeader(undefined, tempUnit, 'Feels')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const temp = hourly.apparent_temperature[cell.idx]}
|
||||
<td
|
||||
class="cell text-muted-foreground {is3h ? 'py-1 text-[13px]' : 'py-0.5 text-[11px]'}"
|
||||
class:now={cell.isNow}
|
||||
>
|
||||
{formatTemp(temp)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
<!-- Precipitation -->
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-raindrop', precipUnit, 'Precip')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const precip = hourly.precipitation[cell.idx]}
|
||||
{@const prob = hourly.precipitation_probability[cell.idx]}
|
||||
<td
|
||||
class="cell precip-cell {is3h ? 'h-14' : 'h-11'}"
|
||||
style="background:{getPrecipProbBg(prob ?? 0)}"
|
||||
title={formatPrecipTooltip(precip, prob)}
|
||||
>
|
||||
{#if precip > 0}
|
||||
<div class="precip-bar" style="height:{getPrecipBarHeight(precip)}%"></div>
|
||||
<span class="precip-label {is3h ? 'text-[13px]' : 'text-[10px]'}">
|
||||
{precip.toFixed(1)}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Wind -->
|
||||
<tr>
|
||||
{@render rowHeader('wi-strong-wind', windUnit)}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const wind = hourly.windspeed_10m[cell.idx]}
|
||||
{@const windDir = hourly.winddirection_10m[cell.idx]}
|
||||
<td class="cell text-center align-middle leading-tight" class:now={cell.isNow}>
|
||||
{#if windDir != null && !isNaN(windDir)}
|
||||
<span
|
||||
class="inline-block leading-[0]"
|
||||
style="transform:{getWindArrowRotation(windDir)}"
|
||||
>
|
||||
{@render weatherIcon('wi-direction-down', 24)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="block font-semibold {is3h ? 'mt-0.5 text-sm' : 'text-xs'}">
|
||||
{formatValue(wind)}
|
||||
</span>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
|
||||
<!-- Humidity -->
|
||||
<tr>
|
||||
{@render rowHeader('wi-humidity', '%')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const hum = hourly.relative_humidity_2m[cell.idx]}
|
||||
<td class="cell" class:now={cell.isNow} style="background:{getHumidityBg(hum ?? 0)}">
|
||||
{formatValue(hum)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
|
||||
<!-- Cloud Cover -->
|
||||
<tr>
|
||||
{@render rowHeader('wi-cloud', '%')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const cloud = hourly.cloud_cover[cell.idx]}
|
||||
<td
|
||||
class="cell"
|
||||
class:now={cell.isNow}
|
||||
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
|
||||
>
|
||||
{formatValue(cloud)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
|
||||
<!-- Precipitation -->
|
||||
<tr>
|
||||
{@render rowHeader('wi-raindrop', precipUnit)}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const precip = hourly.precipitation[cell.idx]}
|
||||
{@const prob = hourly.precipitation_probability[cell.idx]}
|
||||
<td
|
||||
class="precip-cell {is3h ? 'h-14' : 'h-11'}"
|
||||
class:now={cell.isNow}
|
||||
style="background:{getPrecipProbBg(prob ?? 0)}"
|
||||
title={formatPrecipTooltip(precip, prob)}
|
||||
>
|
||||
{#if precip > 0}
|
||||
<div class="precip-bar" style="height:{getPrecipBarHeight(precip)}%"></div>
|
||||
<span class="precip-label {is3h ? 'text-[13px]' : 'text-[10px]'}">
|
||||
{precip.toFixed(1)}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- "Now" column highlight + exact-time line -->
|
||||
{#if nowLeftPx != null}
|
||||
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
|
||||
{@const nowIdx = cellData.findIndex((c) => c.isNow)}
|
||||
{#if nowIdx >= 0}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 z-10 border-x border-red-500/30 bg-red-500/5"
|
||||
style="left:{headerColWidth + nowIdx * colWidth}px;width:{colWidth}px"
|
||||
></div>
|
||||
{/if}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 z-10 w-0.5 -translate-x-1/2 bg-red-500/80"
|
||||
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>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
tr {
|
||||
border-top: 1px solid hsl(var(--border) / 0.6);
|
||||
/* ── Uniform grid ───────────────────────────────────────────── */
|
||||
.row + .row {
|
||||
border-top: 1px solid color-mix(in oklab, var(--color-border) 70%, transparent);
|
||||
}
|
||||
|
||||
/* ── Base cell ──────────────────────────────────────────── */
|
||||
.cell {
|
||||
padding: 6px 2px;
|
||||
padding: 2px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
border-right: 1px solid hsl(var(--border) / 0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cell:last-child {
|
||||
border-right: none;
|
||||
.cell + .cell {
|
||||
border-left: 1px solid color-mix(in oklab, var(--color-border) 35%, transparent);
|
||||
}
|
||||
|
||||
.cell.now {
|
||||
font-weight: 700;
|
||||
box-shadow: inset 0 0 0 2px hsl(var(--primary) / 0.55);
|
||||
}
|
||||
|
||||
/* ── Row header ─────────────────────────────────────────── */
|
||||
/* ── Row header ─────────────────────────────────────────────── */
|
||||
.hdr {
|
||||
padding: 4px;
|
||||
padding: 4px 2px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
background: color-mix(in oklab, var(--color-muted) 45%, transparent);
|
||||
border-right: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Precipitation ──────────────────────────────────────── */
|
||||
/* ── Precipitation ──────────────────────────────────────────── */
|
||||
.precip-cell {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid hsl(var(--border) / 0.2);
|
||||
}
|
||||
|
||||
.precip-cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.precip-cell.now {
|
||||
box-shadow: inset 0 0 0 2px hsl(var(--primary) / 0.55);
|
||||
}
|
||||
|
||||
.precip-bar {
|
||||
@@ -503,10 +531,10 @@
|
||||
}
|
||||
|
||||
:global(.dark) .precip-label {
|
||||
color: rgba(120, 180, 255, 0.95);
|
||||
color: rgba(140, 190, 255, 0.95);
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────── */
|
||||
/* ── Responsive ─────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.hdr {
|
||||
padding: 3px 2px;
|
||||
@@ -514,7 +542,6 @@
|
||||
}
|
||||
.cell {
|
||||
font-size: 11px;
|
||||
padding: 4px 1px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
const CHART_GROUP = 'week-meteogram';
|
||||
const SECONDS_PER_DAY = 24 * 3600;
|
||||
|
||||
let showCharts = $state(false);
|
||||
let chartComponents: CanvasChart[] = $state([]);
|
||||
|
||||
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
|
||||
@@ -37,20 +36,21 @@
|
||||
|
||||
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
|
||||
|
||||
export function scrollToDay(day: Date): void {
|
||||
if (!data || liveCharts.length === 0) return;
|
||||
|
||||
function dayStartSec(day: Date): number | null {
|
||||
if (!data) return null;
|
||||
const tz = data.timezone;
|
||||
const targetDayStr = formatZoned(day, tz, 'yyyy-MM-dd');
|
||||
const firstHourIdx = data.hourlyDates.findIndex(
|
||||
(d) => formatZoned(d, tz, 'yyyy-MM-dd') === targetDayStr
|
||||
);
|
||||
return firstHourIdx === -1 ? null : data.timestamps[firstHourIdx] / 1000;
|
||||
}
|
||||
|
||||
if (firstHourIdx === -1) return;
|
||||
|
||||
const dayStart = data.timestamps[firstHourIdx] / 1000;
|
||||
function setRangeDays(from: Date, days: number): void {
|
||||
const start = dayStartSec(from);
|
||||
if (start == null || liveCharts.length === 0) return;
|
||||
// Charts share the group, so setting the range on one syncs all of them
|
||||
liveCharts[0].setRange(dayStart, dayStart + SECONDS_PER_DAY);
|
||||
liveCharts[0].setRange(start, start + days * SECONDS_PER_DAY);
|
||||
}
|
||||
|
||||
function resetZoom(): void {
|
||||
@@ -58,18 +58,15 @@
|
||||
onResetZoom?.();
|
||||
}
|
||||
|
||||
// Zoom to the selected day once all three charts are mounted
|
||||
let scrolledOnMount = false;
|
||||
$effect(() => {
|
||||
if (!showCharts) {
|
||||
scrolledOnMount = false;
|
||||
return;
|
||||
}
|
||||
if (!scrolledOnMount && liveCharts.length === 3 && data) {
|
||||
scrolledOnMount = true;
|
||||
requestAnimationFrame(() => scrollToDay(selectedDay));
|
||||
}
|
||||
});
|
||||
// Range presets: charts show the full week by default, these (or
|
||||
// Ctrl+scroll) narrow the window.
|
||||
const rangePresets = [
|
||||
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
|
||||
{ label: 'Selected day', apply: () => setRangeDays(selectedDay, 1) },
|
||||
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
|
||||
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
|
||||
{ label: 'All', apply: () => resetZoom() }
|
||||
];
|
||||
|
||||
// ─── Series Building ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -195,142 +192,68 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="charts-toggle-section">
|
||||
<button class="charts-toggle-btn" onclick={() => (showCharts = !showCharts)}>
|
||||
<span>Detailed Meteogram Charts</span>
|
||||
<svg
|
||||
class="toggle-chevron {showCharts ? 'open' : ''}"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showCharts}
|
||||
<div class="detailed-charts" in:fade={{ duration: 200 }}>
|
||||
<div class="charts-header">
|
||||
<h3 class="charts-title">
|
||||
{formatZoned(selectedDay, data.timezone, 'EEEE')}
|
||||
<small>
|
||||
{getRelativeDayLabel(selectedDay, data.timezone) !==
|
||||
formatZoned(selectedDay, data.timezone, 'EEEE')
|
||||
? ` (${getRelativeDayLabel(selectedDay, data.timezone)})`
|
||||
: ''}
|
||||
</small>
|
||||
</h3>
|
||||
<button type="button" class="zoom-reset-btn" title="Show all days (Esc)" onclick={resetZoom}>
|
||||
Show All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ChartContainer {loading} chartCount={3} chartHeight={300}>
|
||||
{#each chartDefs as def, i (def.title)}
|
||||
<CanvasChart
|
||||
bind:this={chartComponents[i]}
|
||||
timestamps={timestampsSec}
|
||||
timezone={data.timezone}
|
||||
series={def.series}
|
||||
bands={data.daylightBands}
|
||||
unit={def.unit}
|
||||
unitRight={def.unitRight}
|
||||
yMin={def.yMin}
|
||||
yMinRight={def.yMinRight}
|
||||
yMaxRight={def.yMaxRight}
|
||||
invertRight={def.invertRight}
|
||||
title={def.title}
|
||||
showCredit={def.showCredit}
|
||||
showLegend
|
||||
height={300}
|
||||
group={CHART_GROUP}
|
||||
/>
|
||||
{/each}
|
||||
</ChartContainer>
|
||||
|
||||
<div class="mt-6 md:mt-10">
|
||||
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
|
||||
<section class="mt-8" in:fade={{ duration: 200 }}>
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="text-lg font-bold">
|
||||
Meteogram
|
||||
<span class="font-semibold text-muted-foreground">
|
||||
– {formatZoned(selectedDay, data.timezone, 'EEEE')}{getRelativeDayLabel(
|
||||
selectedDay,
|
||||
data.timezone
|
||||
) !== formatZoned(selectedDay, data.timezone, 'EEEE')
|
||||
? ` (${getRelativeDayLabel(selectedDay, data.timezone)})`
|
||||
: ''}
|
||||
</span>
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="hidden text-xs text-muted-foreground md:inline">
|
||||
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]"
|
||||
>Ctrl</kbd
|
||||
>
|
||||
+ scroll to zoom
|
||||
</span>
|
||||
<div
|
||||
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
|
||||
role="group"
|
||||
aria-label="Chart time range"
|
||||
>
|
||||
{#each rangePresets as preset (preset.label)}
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded-md px-2.5 py-1 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
|
||||
onclick={preset.apply}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.charts-toggle-section {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
<ChartContainer {loading} chartCount={3} chartHeight={300}>
|
||||
{#each chartDefs as def, i (def.title)}
|
||||
<CanvasChart
|
||||
bind:this={chartComponents[i]}
|
||||
timestamps={timestampsSec}
|
||||
timezone={data.timezone}
|
||||
series={def.series}
|
||||
bands={data.daylightBands}
|
||||
unit={def.unit}
|
||||
unitRight={def.unitRight}
|
||||
yMin={def.yMin}
|
||||
yMinRight={def.yMinRight}
|
||||
yMaxRight={def.yMaxRight}
|
||||
invertRight={def.invertRight}
|
||||
title={def.title}
|
||||
showCredit={def.showCredit}
|
||||
showLegend
|
||||
height={300}
|
||||
group={CHART_GROUP}
|
||||
/>
|
||||
{/each}
|
||||
</ChartContainer>
|
||||
|
||||
.charts-toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius, 0.375rem);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.charts-toggle-btn:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.toggle-chevron {
|
||||
transition: transform 200ms;
|
||||
}
|
||||
|
||||
.toggle-chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.detailed-charts {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.charts-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.charts-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.zoom-reset-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius, 0.375rem);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 150ms ease,
|
||||
background-color 150ms ease;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.zoom-reset-btn:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
</style>
|
||||
<div class="mt-6 md:mt-10">
|
||||
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
|
||||
import { models } from '../../options';
|
||||
import { findModel, modelGroups } from '../../options';
|
||||
|
||||
interface Props {
|
||||
selectedModel: string;
|
||||
@@ -11,35 +10,73 @@
|
||||
|
||||
let { selectedModel, onModelChange }: Props = $props();
|
||||
|
||||
let modelLabel = $derived(
|
||||
models.find((mo) => String(mo.value) === selectedModel)?.label ?? selectedModel
|
||||
let model = $derived(findModel(selectedModel));
|
||||
let modelLabel = $derived(model?.label ?? selectedModel);
|
||||
let modelMeta = $derived(
|
||||
model?.resolution && model.resolution !== 'varies'
|
||||
? `${model.resolution} · updated ${model.update}`
|
||||
: 'Automatically picks the best model for this location'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="mt-6 flex gap-6 md:mt-12">
|
||||
<div class="relative w-1/2">
|
||||
<Select.Root
|
||||
name="model_selection"
|
||||
type="single"
|
||||
value={selectedModel}
|
||||
onValueChange={(val) => {
|
||||
if (val) onModelChange(val);
|
||||
}}
|
||||
<Select.Root
|
||||
name="model_selection"
|
||||
type="single"
|
||||
value={selectedModel}
|
||||
onValueChange={(val) => {
|
||||
if (val) onModelChange(val);
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
aria-label="Forecast model selection"
|
||||
class="group h-auto min-w-64 cursor-pointer gap-3 rounded-xl border-2 border-primary/35 bg-card py-2 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-w-72"
|
||||
>
|
||||
<div
|
||||
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary"
|
||||
>
|
||||
<Select.Trigger
|
||||
aria-label="Forecast model selection"
|
||||
class="h-12 cursor-pointer pt-6 [&_svg]:mb-3"
|
||||
>
|
||||
{modelLabel}
|
||||
</Select.Trigger>
|
||||
<Select.Content preventScroll={false} class="border-border">
|
||||
{#each models as mo (mo.value)}
|
||||
<Select.Item class="cursor-pointer" value={mo.value}>{mo.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
<Label class="absolute top-[0.35rem] left-2 z-10 px-1 text-xs text-muted-foreground">
|
||||
<!-- layered-globe icon: weather model -->
|
||||
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
d="M3.6 9h16.8M3.6 15h16.8M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col items-start gap-0 overflow-hidden text-left">
|
||||
<span class="text-[11px] font-semibold tracking-wide text-primary uppercase">
|
||||
Weather model
|
||||
</Label>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<span class="max-w-full truncate text-sm font-bold text-foreground">{modelLabel}</span>
|
||||
<span class="max-w-full truncate text-[11px] leading-tight text-muted-foreground">
|
||||
{modelMeta}
|
||||
</span>
|
||||
</div>
|
||||
</Select.Trigger>
|
||||
<Select.Content preventScroll={false} class="max-h-[min(480px,60vh)] border-border">
|
||||
{#each modelGroups as group (group.value)}
|
||||
<Select.Group>
|
||||
<Select.GroupHeading
|
||||
class="text-[10.5px] font-bold tracking-wider text-primary/80 uppercase"
|
||||
>
|
||||
{group.label}
|
||||
</Select.GroupHeading>
|
||||
{#each group.models as mo (mo.value)}
|
||||
<Select.Item class="cursor-pointer" value={mo.value} label={mo.label}>
|
||||
<!-- div, not span: the item base styles force flex row on spans -->
|
||||
<div class="flex w-full flex-col items-start gap-0 leading-tight">
|
||||
<span class="font-medium">{mo.label}</span>
|
||||
{#if mo.resolution && mo.resolution !== 'varies'}
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{mo.resolution} · updated {mo.update}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-[11px] text-muted-foreground">Automatic selection</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
Reference in New Issue
Block a user