This commit is contained in:
Vincent van der Wal
2026-08-01 17:58:03 +02:00
parent b2c8108c8c
commit 01cc6225aa
430 changed files with 3150 additions and 441 deletions
@@ -327,8 +327,7 @@
/>
</svg>
<span>
This model's ensemble only reaches about <strong>{validDays} days</strong> ahead, the spread is
trimmed to its available range.
{m.ensemble_trimmed({ days: validDays })}
</span>
</div>
{/if}
@@ -385,7 +384,9 @@
{#snippet controls()}
<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>
<Label for="show_legend" class="cursor-pointer text-base leading-none"
>{m.legend_show()}</Label
>
</div>
{/snippet}
</ChartToolbar>
@@ -26,6 +26,7 @@
groupRange,
isColumnUnit
} from '$lib/charts';
import * as m from '$lib/paraglide/messages';
import {
type DaylightBand,
type ModelCompareResult,
@@ -315,10 +316,10 @@
<!-- Range / zoom controls, aligned with the title like the other pages -->
<div class="lg:absolute lg:right-0 lg:top-20 z-40 flex flex-wrap items-center gap-3">
<span class="hidden text-xs text-muted-foreground lg:inline">
drag or
{m.meteograms_zoom_hint()}
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]">Ctrl</kbd
>
+ scroll to zoom
{m.meteograms_zoom_hint_end()}
</span>
{#if zoomActive}
<button
@@ -336,13 +337,13 @@
<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
{m.reset_zoom()}
</button>
{/if}
<div
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
role="group"
aria-label="Chart time range"
aria-label={m.range_group_aria()}
>
{#each rangePresets as preset (preset.label)}
<button
@@ -426,7 +427,9 @@
{#snippet controls()}
<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>
<Label for="show_legend" class="cursor-pointer text-base leading-none"
>{m.legend_show()}</Label
>
</div>
{/snippet}
</ChartToolbar>
@@ -436,7 +439,9 @@
<div class="mt-4 md:mt-8">
<div class="flex">
<a href="#models"><h2 id="models" class="text-2xl md:text-3xl">Models</h2></a>
<a href="#models"
><h2 id="models" class="text-2xl md:text-3xl">{m.compare_models_heading()}</h2></a
>
{#if params.models && params.models.length > 0}
<div transition:fade={{ duration: 200 }} class="relative mt-1.25">
<div
@@ -487,7 +492,7 @@
<div class="flex">
<a href="#hourly_weather_variables"
><h2 id="hourly_weather_variables" class="text-2xl md:text-3xl">
Hourly Weather Variables
{m.compare_variables_heading()}
</h2></a
>
{#if params.hourly && params.hourly.length > 0}
@@ -1,6 +1,8 @@
<script lang="ts">
import { formatZoned, getZonedHour } from '$lib/utils/date';
import * as m from '$lib/paraglide/messages';
import { getWeatherIconName } from '../../utils/weather-codes';
interface Props {
@@ -69,14 +71,14 @@
{#if displayModels.length > 0}
<div class="mt-8">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-xl font-bold">Model Comparison Timeline</h3>
<h3 class="text-xl font-bold">{m.compare_timeline_title()}</h3>
<div class="flex items-center gap-1.5 text-[13px] font-semibold">
<span class="select-none text-muted-foreground">3h</span>
<button
class="relative h-6 w-11 cursor-pointer rounded-full border transition-colors
{hourlyInterval === 1 ? 'border-primary/40 bg-primary' : 'border-border bg-muted'}"
onclick={() => (hourlyInterval = hourlyInterval === 1 ? 3 : 1)}
title="Toggle between 1-hour and 3-hour intervals"
title={m.interval_toggle()}
>
<span
class="absolute top-0.75 size-4.5 rounded-full bg-white shadow-sm transition-[left] duration-200
@@ -96,7 +98,7 @@
<th
class="sticky left-0 z-20 w-32 border-b border-r border-border bg-muted/95 p-2 text-left text-xs font-bold"
>
Model
{m.compare_model_label()}
</th>
{#each filteredIndices as idx, i (idx)}
{@const ts = timestamps[idx]}
@@ -1,10 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import {
storedArchiveModel,
storedChartLayout,
storedLocation,
storedUnits,
@@ -13,6 +15,7 @@
import { ChartContainer } from '$lib/components/charts';
import * as m from '$lib/paraglide/messages';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import {
@@ -22,8 +25,10 @@
fetchHistoricalWeather
} from '$lib/services/weather';
import { defaultParameters } from '../../options';
import { useHeroActions } from '../../hero.svelte';
import { archiveModelGroups, defaultParameters } from '../../options';
import HourlyTable from '../../week/[location]/HourlyTable.svelte';
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
import { neededHourlyApiVars } from '../../week/[location]/variables';
import DateRangeControls from './DateRangeControls.svelte';
import HistoricalDaily from './HistoricalDaily.svelte';
@@ -37,6 +42,8 @@
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => result != null);
useHeroActions(heroActions);
let location = $derived(data.location);
$effect(() => {
storedLocation.set(data.location);
@@ -79,6 +86,11 @@
mounted = true;
});
let archiveModel = $state('best_match');
onMount(() => {
archiveModel = get(storedArchiveModel);
});
function onRangeChange(s: string, e: string) {
startDate = s;
endDate = e;
@@ -101,6 +113,8 @@
const s = startDate;
const e = endDate;
const vars = hourlyVars;
const model = archiveModel;
void model;
if (!mounted || !$isSupporter || !loc || !s || !e) return;
const version = ++requestVersion;
@@ -203,12 +217,27 @@
}
</script>
<!-- the reanalysis picker rides in the layout's location row -->
{#snippet heroActions()}
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
<ModelSelector
selectedModel={archiveModel}
groups={archiveModelGroups}
label={m.model_archive()}
onModelChange={(model) => {
archiveModel = model;
storedArchiveModel.set(model);
}}
/>
</div>
{/snippet}
<svelte:head>
<title>Drizz.li | Historical weather</title>
<title>Drizz.li | {m.page_historical_subtitle()}</title>
<meta name="description" content="Past weather and climate-normal comparisons for any location" />
</svelte:head>
<PaywallGate feature="Historical weather">
<PaywallGate feature={m.page_historical_subtitle()}>
<DateRangeControls
start={startDate}
end={endDate}
@@ -1,4 +1,6 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages';
interface Props {
start: string;
end: string;
@@ -34,10 +36,10 @@
}
const presets = [
{ label: '7 days', apply: () => applyLastDays(7) },
{ label: '30 days', apply: () => applyLastDays(30) },
{ label: '90 days', apply: () => applyLastDays(90) },
{ label: 'Month, last year', apply: applyThisMonthLastYear }
{ label: () => m.historical_last_days({ days: 7 }), apply: () => applyLastDays(7) },
{ label: () => m.historical_last_days({ days: 30 }), apply: () => applyLastDays(30) },
{ label: () => m.historical_last_days({ days: 90 }), apply: () => applyLastDays(90) },
{ label: () => m.historical_month_last_year(), apply: applyThisMonthLastYear }
];
// ─── Manual inputs ────────────────────────────────────────────────────────────
@@ -65,7 +67,9 @@
>
<div class="flex flex-wrap items-end gap-3">
<div class="grid gap-1">
<label for="hist-start" class="text-xs font-semibold text-muted-foreground">From</label>
<label for="hist-start" class="text-xs font-semibold text-muted-foreground"
>{m.historical_from()}</label
>
<input
id="hist-start"
type="date"
@@ -77,7 +81,9 @@
/>
</div>
<div class="grid gap-1">
<label for="hist-end" class="text-xs font-semibold text-muted-foreground">To</label>
<label for="hist-end" class="text-xs font-semibold text-muted-foreground"
>{m.historical_to()}</label
>
<input
id="hist-end"
type="date"
@@ -93,15 +99,15 @@
<div
class="inline-flex flex-wrap items-center gap-0.5 rounded-lg bg-muted p-0.5 text-xs font-semibold"
role="group"
aria-label="Quick ranges"
aria-label={m.historical_quick_ranges()}
>
{#each presets as preset (preset.label)}
{#each presets as preset (preset.label())}
<button
type="button"
class="cursor-pointer rounded-md px-2.5 py-1.5 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
onclick={preset.apply}
>
{preset.label}
{preset.label()}
</button>
{/each}
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { formatZoned, isSameDayInZone } from '$lib/utils/date';
import * as m from '$lib/paraglide/messages';
import {
type ClimateNormals,
type HistoricalDailyData,
@@ -104,7 +105,7 @@
<!-- ─── KPI tiles ────────────────────────────────────────────────────────────── -->
<div class="grid grid-cols-2 gap-2.5 lg:grid-cols-4">
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
<p class="text-xs font-medium text-muted-foreground">Average temperature</p>
<p class="text-xs font-medium text-muted-foreground">{m.stat_avg_temperature()}</p>
<p class="mt-1 text-2xl font-bold tabular-nums">
{fmtTemp(stats.avg)}<span class="text-base font-semibold text-muted-foreground"
>{tempUnit.replace('°', '')}</span
@@ -118,15 +119,15 @@
class:dark:text-red-400={stats.tempAnomaly >= 0}
class:dark:text-blue-400={stats.tempAnomaly < 0}
>
{fmtSigned(stats.tempAnomaly)} vs normal
{m.anomaly_vs_normal({ value: fmtSigned(stats.tempAnomaly) })}
</p>
{:else}
<p class="mt-0.5 text-xs text-muted-foreground">19912020 normal loading…</p>
<p class="mt-0.5 text-xs text-muted-foreground">{m.normals_loading_period()}</p>
{/if}
</div>
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
<p class="text-xs font-medium text-muted-foreground">Total precipitation</p>
<p class="text-xs font-medium text-muted-foreground">{m.stat_total_precipitation()}</p>
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtPrecip(stats.totalPrecip)}</p>
{#if stats.normalPrecip != null}
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
@@ -140,7 +141,7 @@
</div>
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
<p class="text-xs font-medium text-muted-foreground">Warmest day</p>
<p class="text-xs font-medium text-muted-foreground">{m.stat_warmest_day()}</p>
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtTemp(stats.warm.t)}</p>
{#if stats.warm.i >= 0}
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
@@ -150,7 +151,7 @@
</div>
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
<p class="text-xs font-medium text-muted-foreground">Coldest day</p>
<p class="text-xs font-medium text-muted-foreground">{m.stat_coldest_day()}</p>
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtTemp(stats.cold.t)}</p>
{#if stats.cold.i >= 0}
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
@@ -166,12 +167,12 @@
>
<div class="flex items-center justify-between border-b border-border/70 bg-muted/40 px-4 py-2.5">
<h3 class="text-base font-bold">
Daily <span class="font-semibold text-muted-foreground"> select a day for hourly detail</span
>
{m.historical_daily_heading()}
<span class="font-semibold text-muted-foreground">{m.historical_daily_hint()}</span>
</h3>
{#if normals}
<span class="hidden text-xs text-muted-foreground sm:inline">
normal band = 19912020 mean
{m.normal_band_legend()}
</span>
{/if}
</div>
@@ -242,7 +243,7 @@
<span
class="mt-0.5 inline-block h-1.5 w-6 rounded-full"
style="background:{anomalyColor(anomaly)}"
title="{fmtSigned(anomaly)} vs normal"
title={m.anomaly_vs_normal({ value: fmtSigned(anomaly) })}
></span>
{/if}
@@ -8,6 +8,7 @@
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
import { CanvasChart, groupRange } from '$lib/charts';
import * as m from '$lib/paraglide/messages';
import { getWeatherIconName } from '../../utils/weather-codes';
import { type FetchedHourly, type WeatherUnits } from '../../week/[location]/types';
@@ -121,22 +122,23 @@
<section class="mt-8" transition:fade={{ duration: 200 }}>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 class="text-lg font-bold">
Meteograms <span class="font-semibold text-muted-foreground"> full range</span>
{m.meteograms_heading()}
<span class="font-semibold text-muted-foreground">{m.historical_full_range()}</span>
</h3>
<div class="flex flex-wrap items-center gap-3">
<span class="hidden text-xs text-muted-foreground md:inline">
drag or
{m.meteograms_zoom_hint()}
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]"
>Ctrl</kbd
>
+ scroll to zoom
{m.meteograms_zoom_hint_end()}
</span>
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
disabled={liveCharts.length === 0 || downloadingPng}
onclick={downloadPng}
title="Download meteogram as PNG image"
title={m.chart_download()}
>
{#if downloadingPng}Rendering…{:else}PNG{/if}
</button>
@@ -148,7 +150,7 @@
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}
>
Reset zoom
{m.reset_zoom()}
</button>
{/if}
</div>
@@ -158,7 +160,7 @@
<div
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
>
No meteograms configured. Add variables from the 7-day forecast page.
{m.meteograms_none_historical()}
</div>
{:else}
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
+4 -2
View File
@@ -5,6 +5,8 @@
import { mapsDomainForModel } from '$lib/utils/maps-domain';
import * as m from '$lib/paraglide/messages';
// Hash piping, both directions:
// - inbound: a #zoom/lat/lng(/bearing/pitch) hash on OUR url seeds the
// map, so positions can be bookmarked/shared via drizzli links
@@ -74,7 +76,7 @@
</script>
<svelte:head>
<title>Weather Map | Open-Meteo.com</title>
<title>{m.page_maps_title()} | Open-Meteo.com</title>
<link rel="canonical" href="https://open-meteo.com/weather/maps" />
<meta name="description" content="Interactive weather map powered by Open-Meteo" />
</svelte:head>
@@ -88,7 +90,7 @@
<iframe
bind:this={iframeEl}
src={iframeSrc}
title="Open-Meteo Interactive Map"
title={m.maps_iframe_title()}
loading="lazy"
allowfullscreen
allow="cross-origin-isolated"
+45
View File
@@ -513,3 +513,48 @@ export function inDomainCity(model: string): { slug: string; label: string } | n
const hit = IN_DOMAIN_CITY.find(([prefix]) => model.startsWith(prefix));
return hit ? hit[1] : null;
}
/** Reanalyses the archive API accepts (verified against archive-api.open-meteo.com). */
export const archiveModelGroups: WeatherModelGroup[] = [
{
value: 'auto',
label: 'Automatic',
models: [{ value: 'best_match', label: 'Best match', resolution: 'varies', update: 'daily' }]
},
{
value: 'era5',
label: 'ECMWF reanalysis',
models: [
{ value: 'era5_seamless', label: 'ERA5 seamless', resolution: '9-25 km', update: 'daily' },
{ value: 'era5', label: 'ERA5', resolution: '25 km', update: 'daily' },
{ value: 'era5_land', label: 'ERA5-Land', resolution: '9 km', update: 'daily' },
{ value: 'ecmwf_ifs', label: 'ECMWF IFS', resolution: '9 km', update: 'daily' }
]
},
{
value: 'regional',
label: 'Regional reanalysis',
models: [{ value: 'cerra', label: 'CERRA (Europe)', resolution: '5 km', update: 'daily' }]
}
];
/** Seasonal models the seasonal API accepts. */
export const seasonalModelGroups: WeatherModelGroup[] = [
{
value: 'auto',
label: 'Automatic',
models: [{ value: 'best_match', label: 'Best match', resolution: 'varies', update: 'monthly' }]
},
{
value: 'ecmwf',
label: 'ECMWF',
models: [
{
value: 'ecmwf_seasonal_seamless',
label: 'ECMWF SEAS5',
resolution: '36 km',
update: 'monthly'
}
]
}
];
@@ -1,14 +1,16 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedLocation, storedUnits } from '$lib/stores/settings';
import { storedLocation, storedSeasonalModel, storedUnits } from '$lib/stores/settings';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import * as m from '$lib/paraglide/messages';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import {
@@ -19,7 +21,8 @@
} from '$lib/services/weather';
import { useHeroActions } from '../../hero.svelte';
import { defaultParameters } from '../../options';
import { defaultParameters, seasonalModelGroups } from '../../options';
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
import { getPrecipUnit } from '../../week/[location]/types';
import SeasonalCharts from './SeasonalCharts.svelte';
import SeasonalMonths from './SeasonalMonths.svelte';
@@ -79,7 +82,9 @@
let result = $state<SeasonalForecastResult | null>(null);
let normals = $state<ClimateNormals | null>(null);
let seasonalModel = $state('best_match');
onMount(() => {
seasonalModel = get(storedSeasonalModel);
mounted = true;
});
@@ -89,6 +94,7 @@
const loc = location;
const tempUnit = params.temperature_unit;
const precipUnit = params.precipitation_unit;
const model = seasonalModel;
if (!mounted || !$isSupporter || !loc) return;
const version = ++requestVersion;
@@ -99,6 +105,7 @@
latitude: loc.latitude!,
longitude: loc.longitude!,
dailyVariables: SEASONAL_VARS,
model,
temperature_unit: tempUnit as 'celsius' | 'fahrenheit',
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
precipitation_unit: precipUnit as 'mm' | 'inch',
@@ -173,7 +180,7 @@
</script>
<svelte:head>
<title>Drizz.li | Seasonal forecast</title>
<title>Drizz.li | {m.page_seasonal_subtitle()}</title>
<meta
name="description"
content="Multi-month seasonal outlook: monthly temperature and precipitation trends against the 1991-2020 climate normal"
@@ -182,32 +189,43 @@
<!-- the range buttons ride in the layout's location row (see weather/+layout) -->
{#snippet heroActions()}
{#if result}
<!-- range buttons reslice the already-fetched horizon (no refetch) -->
<div
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto"
role="group"
aria-label="Outlook range"
>
{#each RANGES as range, i (range.label)}
{@const disabled = range.days !== Infinity && range.days > horizonDays}
<button
type="button"
class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-colors sm:flex-none {rangeIndex ===
i
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'} {disabled
? 'cursor-not-allowed opacity-40'
: ''}"
aria-pressed={rangeIndex === i}
{disabled}
onclick={() => (rangeIndex = i)}
>
{range.label}
</button>
{/each}
</div>
{/if}
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto">
{#if result}
<!-- range buttons reslice the already-fetched horizon (no refetch) -->
<div
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto"
role="group"
aria-label={m.seasonal_range_aria()}
>
{#each RANGES as range, i (range.label)}
{@const disabled = range.days !== Infinity && range.days > horizonDays}
<button
type="button"
class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-colors sm:flex-none {rangeIndex ===
i
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'} {disabled
? 'cursor-not-allowed opacity-40'
: ''}"
aria-pressed={rangeIndex === i}
{disabled}
onclick={() => (rangeIndex = i)}
>
{range.label}
</button>
{/each}
</div>
{/if}
<ModelSelector
selectedModel={seasonalModel}
groups={seasonalModelGroups}
label={m.model_seasonal()}
onModelChange={(model) => {
seasonalModel = model;
storedSeasonalModel.set(model);
}}
/>
</div>
{/snippet}
<!-- What a seasonal forecast is (and is not): without this the daily-looking
@@ -226,17 +244,16 @@
<circle cx="12" cy="12" r="9" />
</svg>
<p class="text-muted-foreground">
A seasonal forecast shows how a whole month is likely to
<strong class="font-semibold text-foreground">depart from its climate normal</strong> - not the
weather on any given day. Read the monthly trend and the ensemble agreement, not the daily
wiggles.
{m.seasonal_explainer_before()}
<strong class="font-semibold text-foreground">{m.seasonal_explainer_strong()}</strong>
{m.seasonal_explainer_after()}
{#if lastDayLabel}
This outlook runs to <strong class="font-semibold text-foreground">{lastDayLabel}</strong>.
{m.seasonal_runs_to({ date: lastDayLabel })}
{/if}
</p>
</div>
<PaywallGate feature="The seasonal outlook">
<PaywallGate feature={m.page_seasonal_subtitle()}>
{#if loadError}
<div
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
@@ -265,7 +282,7 @@
<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
>{m.legend_show()}</Label
>
</div>
{/snippet}
@@ -1,4 +1,6 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages';
import { getColor } from '../../utils/colors';
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
@@ -50,7 +52,7 @@
<!-- headline: mean temperature and its departure from the 1991-2020 normal -->
<div class="mt-2.5 flex items-end gap-3">
<div>
<p class="text-xs font-medium text-muted-foreground">Mean temperature</p>
<p class="text-xs font-medium text-muted-foreground">{m.stat_mean_temperature()}</p>
<p class="text-2xl leading-tight font-bold tabular-nums">
{fmtTemp(month.tMean)}<span class="text-base font-semibold text-muted-foreground"
>{tempUnit.replace('°', '')}</span
@@ -70,9 +72,9 @@
</p>
<p class="text-[11px] text-muted-foreground">{anomalyLabel(month.anomaly)}</p>
{:else if normals}
<p class="text-[11px] text-muted-foreground">no normal for this month</p>
<p class="text-[11px] text-muted-foreground">{m.seasonal_no_normal()}</p>
{:else}
<p class="text-[11px] text-muted-foreground">normal loading…</p>
<p class="text-[11px] text-muted-foreground">{m.normals_loading()}</p>
{/if}
</div>
</div>
@@ -85,7 +87,7 @@
style="background:{getColor(month.tMax, units.temperature_unit)}"
></span>
<span class="font-semibold tabular-nums">{fmtTemp(month.tMax)}</span>
<span class="text-muted-foreground">day</span>
<span class="text-muted-foreground">{m.label_day()}</span>
</span>
<span class="inline-flex items-center gap-1.5">
<span
@@ -93,14 +95,14 @@
style="background:{getColor(month.tMin, units.temperature_unit)}"
></span>
<span class="font-semibold tabular-nums">{fmtTemp(month.tMin)}</span>
<span class="text-muted-foreground">night</span>
<span class="text-muted-foreground">{m.label_night()}</span>
</span>
</div>
<!-- precipitation against its own normal, as a share bar -->
<div class="mt-3 border-t border-border/60 pt-2.5">
<div class="flex items-baseline justify-between gap-2 text-xs">
<span class="font-medium text-muted-foreground">Precipitation</span>
<span class="font-medium text-muted-foreground">{m.var_precipitation()}</span>
<span class="font-semibold tabular-nums">
{fmtPrecip(month.precip)}
{precipUnit}
@@ -39,6 +39,7 @@
import HourlyTable from './HourlyTable.svelte';
import MeteogramCharts from './MeteogramCharts.svelte';
import ModelSelector from './ModelSelector.svelte';
import NearbyCities from './NearbyCities.svelte';
import VariableSidebar from './VariableSidebar.svelte';
import { neededHourlyApiVars } from './variables';
@@ -326,7 +327,7 @@
</script>
<svelte:head>
<title>Drizz.li | Weather</title>
<title>Drizz.li | {m.page_week_title()}</title>
<link rel="canonical" href="https://drizz.li/weather/week" />
<meta name="description" content="7-day weather forecast with detailed hourly data" />
</svelte:head>
@@ -362,7 +363,7 @@
class="cursor-pointer rounded-md border border-destructive/40 bg-background px-3 py-1 text-xs font-semibold text-destructive transition-colors hover:bg-destructive/10"
onclick={() => retryNonce++}
>
Try again
{m.action_try_again()}
</button>
{#if suggestedCity}
<a
@@ -383,7 +384,7 @@
</div>
{#if loadError.detail}
<details class="mt-2 text-xs text-destructive/70">
<summary class="cursor-pointer select-none">Technical details</summary>
<summary class="cursor-pointer select-none">{m.error_technical_details()}</summary>
<p class="mt-1 font-mono break-all">{loadError.detail}</p>
</details>
{/if}
@@ -535,6 +536,15 @@
<ChartContainer loading chartCount={enabledChartCount || 1} {chartHeight} />
</section>
{/if}
{#if selectedDayKey}
<NearbyCities
latitude={location.latitude}
longitude={location.longitude}
{selectedDayKey}
units={$storedUnits}
/>
{/if}
</div>
</div>
</div>
@@ -152,15 +152,15 @@
>
<div class="flex items-center justify-between border-b border-border px-5 py-4">
<div>
<h2 class="text-base font-bold">Customize meteograms</h2>
<h2 class="text-base font-bold">{m.customize_meteograms()}</h2>
<p class="text-xs text-muted-foreground">
Drag variables between charts to build your own layout.
{m.customizer_intro()}
</p>
</div>
<button
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onclick={onClose}
aria-label="Close"
aria-label={m.action_close()}
>
<svg
class="h-4.5 w-4.5"
@@ -212,12 +212,12 @@
>
<div class="mb-2 flex items-center justify-between">
<span class="text-[11px] font-bold tracking-wider text-primary uppercase"
>Chart {i + 1}</span
>{m.customizer_chart_n({ number: i + 1 })}</span
>
<button
class="cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
onclick={() => deletePanel(panel.id)}
aria-label="Delete chart {i + 1}"
aria-label={m.customizer_delete_chart({ number: i + 1 })}
>
<svg
class="h-4 w-4"
@@ -238,7 +238,7 @@
data-chip
role="button"
tabindex="0"
aria-label="Drag {def.label}"
aria-label={m.customizer_drag({ variable: def.label })}
class="flex cursor-grab touch-none items-center gap-1.5 rounded-lg border border-border bg-background py-1.5 pr-1 pl-2.5 text-sm shadow-sm select-none active:cursor-grabbing {dragKey ===
key
? 'opacity-30'
@@ -257,7 +257,7 @@
class="ml-0.5 flex h-5 w-5 cursor-pointer items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
onpointerdown={(e) => e.stopPropagation()}
onclick={() => removeVar(key)}
aria-label="Remove {def.label}"
aria-label={m.customizer_remove({ variable: def.label })}
>
<svg
class="h-3.5 w-3.5"
@@ -274,7 +274,7 @@
{/each}
{#if panel.variables.length === 0}
<span class="self-center text-xs text-muted-foreground italic"
>Drop variables here</span
>{m.customizer_drop_here()}</span
>
{/if}
</div>
@@ -285,12 +285,12 @@
class="w-full cursor-pointer rounded-xl border-2 border-dashed border-border py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
onclick={addPanel}
>
+ Add chart
{m.customizer_add_chart()}
</button>
<div>
<h3 class="mb-2 text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
Available variables
{m.customizer_available()}
</h3>
<div
data-zone="pool"
@@ -304,7 +304,7 @@
data-chip
role="button"
tabindex="0"
aria-label="Drag {def.label}"
aria-label={m.customizer_drag({ variable: def.label })}
class="flex cursor-grab touch-none items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 py-1.5 text-sm text-muted-foreground shadow-sm select-none active:cursor-grabbing {dragKey ===
def.key
? 'opacity-30'
@@ -321,7 +321,7 @@
{/each}
{#if availableVars.length === 0}
<span class="self-center text-xs text-muted-foreground italic"
>All variables are in use</span
>{m.customizer_all_in_use()}</span
>
{/if}
</div>
@@ -333,13 +333,13 @@
class="cursor-pointer text-xs font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
onclick={resetLayout}
>
Reset to defaults
{m.action_reset_defaults()}
</button>
<button
class="cursor-pointer rounded-lg bg-primary px-4 py-1.5 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90"
onclick={onClose}
>
Done
{m.action_done()}
</button>
</div>
</div>
@@ -3,6 +3,8 @@
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
import * as m from '$lib/paraglide/messages';
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import { precipIsSignificant, sunIsSignificant, windIsSignificant } from './significance';
@@ -118,7 +120,7 @@
type="button"
class="mr-4 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"
aria-label={m.strip_past_aria()}
>
<svg
class="h-6 w-6"
@@ -135,7 +137,7 @@
<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
{m.daycards_past()}
</span>
</button>
{/if}
@@ -317,7 +319,7 @@
type="button"
class="day-card-btn flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch 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 md:hidden"
onclick={onExtend}
aria-label="Load the longer-range forecast"
aria-label={m.strip_extend_aria()}
>
<svg
class="h-6 w-6"
@@ -334,7 +336,7 @@
<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
{m.daycards_load_15()}
</span>
</button>
{/if}
@@ -349,7 +351,7 @@
type="button"
class="mt-2 mb-3 hidden w-20 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch 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 md:mt-5 md:mb-11 md:flex md:w-24"
onclick={onExtend}
aria-label="Load the longer-range forecast"
aria-label={m.strip_extend_aria()}
>
<svg
class="h-6 w-6"
@@ -365,7 +367,8 @@
/>
<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>
<span class="text-center text-[11px] leading-tight font-semibold">{m.daycards_load_15()}</span
>
</button>
{/if}
</div>
@@ -451,7 +451,7 @@
<button
class="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 text-[13px] font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
onclick={onCustomize}
aria-label="Customize variables"
aria-label={m.table_customize()}
>
<!-- sliders icon -->
<svg
@@ -466,13 +466,13 @@
d="M4 6h9m5 0h2M4 12h2m5 0h9M4 18h9m5 0h2M13 4.5v3M6 10.5v3M18 16.5v3"
/>
</svg>
<span class="hidden sm:inline">Variables</span>
<span class="hidden sm:inline">{m.hourly_variables()}</span>
</button>
{/if}
<div
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-[13px] font-semibold"
role="group"
aria-label="Hourly interval"
aria-label={m.table_interval_aria()}
>
{#each [3, 1] as interval (interval)}
<button
@@ -584,7 +584,7 @@
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
{m.table_now()}
</span>
{/if}
<!-- Hour labels -->
@@ -231,7 +231,7 @@
</h3>
<div class="flex flex-wrap items-center gap-3">
<span class="hidden text-xs text-muted-foreground md:inline">
drag or
{m.meteograms_zoom_hint()}
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]"
>Ctrl</kbd
>
@@ -273,7 +273,7 @@
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
disabled={liveCharts.length === 0 || downloadingPng}
onclick={downloadPng}
title="Download meteogram as PNG image"
title={m.chart_download()}
>
{#if downloadingPng}
<svg
@@ -330,9 +330,10 @@
<div
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
>
No meteograms configured, <button
{m.meteograms_none_before()}
<button
class="cursor-pointer font-semibold text-primary underline-offset-2 hover:underline"
onclick={() => (customizerOpen = true)}>add some variables</button
onclick={() => (customizerOpen = true)}>{m.meteograms_none_action()}</button
>.
</div>
{:else}
@@ -43,7 +43,7 @@
}}
>
<Select.Trigger
aria-label="{label} selection"
aria-label={m.model_selector_aria({ label })}
class="group h-auto min-h-12 min-w-0 flex-1 cursor-pointer gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 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-h-14 sm:gap-3 sm:py-2 sm:min-w-72 sm:flex-none"
>
<div
@@ -99,7 +99,9 @@
{mo.resolution}{mo.update ? ` · updated ${mo.update}` : ''}
</span>
{:else if mo.value === 'best_match'}
<span class="text-[11px] text-muted-foreground">Automatic selection</span>
<span class="text-[11px] text-muted-foreground"
>{m.model_automatic_selection()}</span
>
{/if}
</div>
</Select.Item>
@@ -0,0 +1,133 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { buildLocationRoute } from '$lib/utils/location';
import { href } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import { getLocale } from '$lib/paraglide/runtime';
import { type NearbyCity, findNearbyCities } from '$lib/services/nearby-cities';
import { type NearbyDaily, fetchNearbyDaily } from '$lib/services/weather';
import { getWeatherIconName } from '../../utils/weather-codes';
import type { UnitPrefs } from '$lib/stores/settings';
interface Props {
latitude: number;
longitude: number;
/** "yyyy-MM-dd" of the day the rest of the page is showing */
selectedDayKey: string;
units: UnitPrefs;
}
let { latitude, longitude, selectedDayKey, units }: Props = $props();
const COUNT = 10;
let cities = $state<NearbyCity[]>([]);
let daily = $state<(NearbyDaily | null)[]>([]);
let failed = $state(false);
// Country names come free and localized from the platform; the flag images
// are the same set the header uses.
let countryNames = $derived(new Intl.DisplayNames([getLocale()], { type: 'region' }));
const countryName = (code: string) => {
try {
return countryNames.of(code) ?? code;
} catch {
return code;
}
};
// One request covers the whole strip's date range, so clicking through the
// days re-reads what is already here instead of refetching ten cities.
$effect(() => {
const lat = latitude;
const lon = longitude;
const unitPrefs = { ...units };
let cancelled = false;
cities = [];
daily = [];
failed = false;
(async () => {
try {
const found = await findNearbyCities(lat, lon, COUNT);
if (cancelled) return;
cities = found;
const snapshots = await fetchNearbyDaily({ points: found, ...unitPrefs });
if (cancelled) return;
daily = snapshots;
} catch {
if (!cancelled) failed = true;
}
})();
return () => {
cancelled = true;
};
});
const dayFor = (index: number) => daily[index]?.byDate[selectedDayKey];
const temp = (value: number | undefined) =>
value == null || !Number.isFinite(value) ? '' : `${Math.round(value)}°`;
const distance = (km: number) =>
units.wind_speed_unit === 'mph' ? `${Math.round(km * 0.621371)} mi` : `${Math.round(km)} km`;
</script>
{#if cities.length > 0 && !failed}
<section class="mt-8" in:fade={{ duration: 200 }}>
<div class="mb-3 flex flex-wrap items-baseline justify-between gap-2">
<h2 class="text-lg font-semibold">{m.nearby_cities_title()}</h2>
<p class="text-xs text-muted-foreground">{m.nearby_cities_subtitle()}</p>
</div>
<div
class="-mx-3 grid grid-cols-2 gap-px overflow-hidden border-y border-border/70 bg-border/70 sm:grid-cols-3 md:mx-0 md:rounded-2xl md:border lg:grid-cols-5"
>
{#each cities as city, i (city.id)}
{@const day = dayFor(i)}
<a
href={href('/weather/week/[location]', {
location: buildLocationRoute({
id: city.id,
name: city.name,
latitude: city.latitude,
longitude: city.longitude,
population: city.population,
feature_code: 'PPL'
})
})}
class="group flex items-center gap-2 bg-card px-3 py-2.5 transition-colors hover:bg-muted/60"
title="{city.name}, {countryName(city.countryCode)}"
>
<svg class="shrink-0 fill-foreground/80" width="34px" height="34px">
{#if day}
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
day.weatherCode,
true
)}.svg#Layer_1"
></use>
{/if}
</svg>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium group-hover:underline">{city.name}</div>
<div class="text-[11px] text-muted-foreground">
{distance(city.distanceKm)} · {countryName(city.countryCode)}
</div>
</div>
<div class="text-right text-sm tabular-nums">
<div class="font-semibold">{temp(day?.max)}</div>
<div class="text-[11px] text-muted-foreground">{temp(day?.min)}</div>
</div>
</a>
{/each}
</div>
</section>
{/if}
@@ -12,6 +12,8 @@
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import * as m from '$lib/paraglide/messages';
interface Props {
open: boolean;
onClose: () => void;
@@ -81,16 +83,16 @@
<aside
class="absolute inset-y-0 right-0 flex w-80 max-w-[90vw] flex-col overflow-y-auto border-l border-border bg-card shadow-xl"
transition:fly={{ x: 320, duration: 200, opacity: 1 }}
aria-label="Variable selection"
aria-label={m.variables_aria()}
>
<div
class="sticky top-0 flex items-center justify-between border-b border-border bg-card px-5 py-4"
>
<h2 class="text-base font-bold">Variables</h2>
<h2 class="text-base font-bold">{m.hourly_variables()}</h2>
<button
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onclick={onClose}
aria-label="Close variable selection"
aria-label={m.variables_close()}
>
<svg
class="h-4.5 w-4.5"
@@ -107,7 +109,7 @@
<div class="flex flex-1 flex-col gap-6 px-5 py-4">
<section>
<h3 class="mb-2 text-[11px] font-bold tracking-wider text-primary uppercase">
Hourly table
{m.variables_table_section()}
</h3>
<div class="flex flex-col gap-1">
{#each tableRowOrder as key, i (key)}
@@ -129,7 +131,7 @@
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
onclick={() => moveRow(key, -1)}
disabled={i === 0}
aria-label="Move {tableVariableLabels[key] ?? key} up"
aria-label={m.variables_move_up({ variable: tableVariableLabels[key] ?? key })}
>
<svg
class="h-3.5 w-3.5"
@@ -145,7 +147,9 @@
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
onclick={() => moveRow(key, 1)}
disabled={i === tableRowOrder.length - 1}
aria-label="Move {tableVariableLabels[key] ?? key} down"
aria-label={m.variables_move_down({
variable: tableVariableLabels[key] ?? key
})}
>
<svg
class="h-3.5 w-3.5"
@@ -164,8 +168,9 @@
</section>
<p class="text-xs text-muted-foreground">
Meteogram variables are configured with the <span class="font-semibold">Customize</span>
button above the charts.
{m.variables_charts_hint_before()}
<span class="font-semibold">{m.meteograms_customize()}</span>
{m.variables_charts_hint_after()}
</p>
</div>
@@ -174,7 +179,7 @@
class="cursor-pointer text-xs font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
onclick={resetDefaults}
>
Reset to defaults
{m.action_reset_defaults()}
</button>
</div>
</aside>