build generic weather week
This commit is contained in:
@@ -78,3 +78,6 @@ export const defaultVariablePrefs: VariablePrefs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const storedVariablePrefs = persisted<VariablePrefs>('variable_prefs', defaultVariablePrefs);
|
export const storedVariablePrefs = persisted<VariablePrefs>('variable_prefs', defaultVariablePrefs);
|
||||||
|
|
||||||
|
/** Selected ensemble model for the 14-day spread forecast. */
|
||||||
|
export const storedEnsembleModel = persisted<string>('ensemble_model', 'ncep_gefs_seamless');
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
|
|
||||||
|
// Static hosting: emit every page as <path>/index.html so plain file servers
|
||||||
|
// resolve URLs like /weather/week/ without pretty-URL rewrites.
|
||||||
|
export const trailingSlash = 'always';
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
import { storedEnsembleModel, storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
@@ -14,7 +15,8 @@
|
|||||||
fetchEnsembleForecast
|
fetchEnsembleForecast
|
||||||
} from '$lib/services/weather';
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
import { defaultParameters } from '../../options';
|
import { defaultParameters, ensembleModelGroups } from '../../options';
|
||||||
|
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
|
||||||
|
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@
|
|||||||
|
|
||||||
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
|
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
|
||||||
|
|
||||||
let showLegend = $state(false);
|
let showLegend = $state(true);
|
||||||
|
|
||||||
// ─── Data Fetch State ───────────────────────────────────────────────────────
|
// ─── Data Fetch State ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@
|
|||||||
let params = $state({
|
let params = $state({
|
||||||
...defaultParameters,
|
...defaultParameters,
|
||||||
hourly: ['temperature_2m'],
|
hourly: ['temperature_2m'],
|
||||||
models: ['gfs_seamless']
|
models: ['ncep_gefs_seamless']
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Cached API Response ────────────────────────────────────────────────────
|
// ─── Cached API Response ────────────────────────────────────────────────────
|
||||||
@@ -62,6 +64,8 @@
|
|||||||
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
// preselect the persisted ensemble model (client-only, keeps SSR stable)
|
||||||
|
params.models = [get(storedEnsembleModel)];
|
||||||
mounted = true;
|
mounted = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,6 +132,8 @@
|
|||||||
series: ChartSeries[];
|
series: ChartSeries[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BAND_COLOR = 'rgba(115, 192, 222, 0.9)';
|
||||||
|
|
||||||
let chartDefs = $derived.by((): ChartDef[] => {
|
let chartDefs = $derived.by((): ChartDef[] => {
|
||||||
if (!fetchedData) return [];
|
if (!fetchedData) return [];
|
||||||
|
|
||||||
@@ -142,37 +148,47 @@
|
|||||||
|
|
||||||
const unit = varData.unit;
|
const unit = varData.unit;
|
||||||
const isColumn = isColumnUnit(unit);
|
const isColumn = isColumnUnit(unit);
|
||||||
const series: ChartSeries[] = [];
|
const memberCount = varData.members.length;
|
||||||
|
|
||||||
// Individual ensemble members: thin, low-alpha lines
|
// Min/max spread band + mean, instead of every individual member
|
||||||
for (let mi = 0; mi < varData.members.length; mi++) {
|
const series: ChartSeries[] = [
|
||||||
series.push({
|
{
|
||||||
name: `${variable}_member${String(mi).padStart(2, '0')}`,
|
name: 'Max',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
color: CHART_COLORS.memberLine,
|
color: BAND_COLOR,
|
||||||
data: varData.members[mi],
|
data: varData.max,
|
||||||
width: 1,
|
width: 1,
|
||||||
showInLegend: false
|
fill: true,
|
||||||
});
|
fillOpacity: 0.25,
|
||||||
}
|
bandTo: varData.min,
|
||||||
|
format: (v) => `${v.toFixed(1)} ${unit}`
|
||||||
// Ensemble mean: bold dashed line on top
|
},
|
||||||
series.push({
|
{
|
||||||
name: variable + '_average',
|
name: 'Min',
|
||||||
|
type: 'line',
|
||||||
|
color: BAND_COLOR,
|
||||||
|
data: varData.min,
|
||||||
|
width: 1,
|
||||||
|
format: (v) => `${v.toFixed(1)} ${unit}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mean',
|
||||||
type: isColumn ? 'bar' : 'line',
|
type: isColumn ? 'bar' : 'line',
|
||||||
color: CHART_COLORS.average,
|
color: CHART_COLORS.average,
|
||||||
data: varData.average,
|
data: varData.average,
|
||||||
width: 4,
|
width: 3,
|
||||||
dashed: !isColumn
|
dashed: !isColumn,
|
||||||
});
|
format: (v) => `${v.toFixed(1)} ${unit}`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
const isFirst = vi === 0;
|
const isFirst = vi === 0;
|
||||||
const isLast = vi === variables.length - 1;
|
const isLast = vi === variables.length - 1;
|
||||||
|
|
||||||
defs.push({
|
defs.push({
|
||||||
title: isFirst ? 'Model Spread' : undefined,
|
title: isFirst ? 'Ensemble Spread' : undefined,
|
||||||
subtitle: isFirst
|
subtitle: isFirst
|
||||||
? `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
? `${variable} min/mean/max across ${memberCount} members (${params.models?.[0] ?? ''})`
|
||||||
: undefined,
|
: undefined,
|
||||||
unit,
|
unit,
|
||||||
showCredit: isLast,
|
showCredit: isLast,
|
||||||
@@ -184,6 +200,39 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- ─── Page hero: location + ensemble 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>
|
||||||
|
14-day ensemble forecast
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModelSelector
|
||||||
|
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
|
||||||
|
groups={ensembleModelGroups}
|
||||||
|
label="Ensemble model"
|
||||||
|
onModelChange={(model) => {
|
||||||
|
params.models = [model];
|
||||||
|
storedEnsembleModel.set(model);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
||||||
|
|
||||||
{#if loadError}
|
{#if loadError}
|
||||||
|
|||||||
@@ -351,3 +351,131 @@ export const hourly = [
|
|||||||
{ value: 'temperature_180m', label: 'Temperature 180m' }
|
{ value: 'temperature_180m', label: 'Temperature 180m' }
|
||||||
]
|
]
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensemble models for the 14-day spread forecast, synced from the
|
||||||
|
* open-meteo/website project (src/routes/en/docs/ensemble-api/options.ts).
|
||||||
|
* Resolutions/update cadences from weather-map-layer domains.ts where known.
|
||||||
|
*/
|
||||||
|
export const ensembleModelGroups: WeatherModelGroup[] = [
|
||||||
|
{
|
||||||
|
value: 'dwd',
|
||||||
|
label: 'DWD Germany',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'icon_seamless_eps',
|
||||||
|
label: 'DWD ICON EPS Seamless',
|
||||||
|
resolution: '2-25 km',
|
||||||
|
update: 'every 6 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'icon_global_eps',
|
||||||
|
label: 'DWD ICON EPS Global',
|
||||||
|
resolution: '25 km',
|
||||||
|
update: 'every 12 h'
|
||||||
|
},
|
||||||
|
{ value: 'icon_eu_eps', label: 'DWD ICON EPS EU', resolution: '13 km', update: 'every 6 h' },
|
||||||
|
{ value: 'icon_d2_eps', label: 'DWD ICON EPS D2', resolution: '2 km', update: 'every 6 h' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ncep',
|
||||||
|
label: 'NOAA U.S.',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'ncep_gefs_seamless',
|
||||||
|
label: 'GFS Ensemble Seamless',
|
||||||
|
resolution: '25-50 km',
|
||||||
|
update: 'every 6 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ncep_gefs025',
|
||||||
|
label: 'GFS Ensemble 0.25°',
|
||||||
|
resolution: '25 km',
|
||||||
|
update: 'every 6 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ncep_gefs05',
|
||||||
|
label: 'GFS Ensemble 0.5°',
|
||||||
|
resolution: '50 km',
|
||||||
|
update: 'every 6 h'
|
||||||
|
},
|
||||||
|
{ value: 'ncep_aigefs025', label: 'AIGEFS 0.25°', resolution: '25 km', update: 'every 6 h' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ecmwf',
|
||||||
|
label: 'ECMWF',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'ecmwf_ifs025_ensemble',
|
||||||
|
label: 'ECMWF IFS 0.25° Ensemble',
|
||||||
|
resolution: '25 km',
|
||||||
|
update: 'every 6 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ecmwf_aifs025_ensemble',
|
||||||
|
label: 'ECMWF AIFS 0.25° Ensemble',
|
||||||
|
resolution: '25 km',
|
||||||
|
update: 'every 6 h'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'cmc_gem',
|
||||||
|
label: 'GEM Canada',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'gem_global_ensemble',
|
||||||
|
label: 'GEM Global Ensemble',
|
||||||
|
resolution: '50 km',
|
||||||
|
update: 'every 12 h'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'bom',
|
||||||
|
label: 'BOM Australia',
|
||||||
|
models: [{ value: 'bom_access_global_ensemble', label: 'BOM ACCESS Global' }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ukmo',
|
||||||
|
label: 'UK Met Office',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'ukmo_global_ensemble_20km',
|
||||||
|
label: 'UK MetOffice Global 20km',
|
||||||
|
resolution: '20 km'
|
||||||
|
},
|
||||||
|
{ value: 'ukmo_uk_ensemble_2km', label: 'UK MetOffice UK 2km', resolution: '2 km' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'meteoswiss',
|
||||||
|
label: 'MeteoSwiss',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'meteoswiss_icon_ch1_ensemble',
|
||||||
|
label: 'MeteoSwiss ICON CH1',
|
||||||
|
resolution: '1 km',
|
||||||
|
update: 'every 12 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'meteoswiss_icon_ch2_ensemble',
|
||||||
|
label: 'MeteoSwiss ICON CH2',
|
||||||
|
resolution: '2 km',
|
||||||
|
update: 'every 12 h'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'google',
|
||||||
|
label: 'Google',
|
||||||
|
models: [{ value: 'google_weathernext2_ensemble', label: 'Google WeatherNext 2 Ensemble' }]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ensembleModels: WeatherModel[] = ensembleModelGroups.flatMap((group) => group.models);
|
||||||
|
|
||||||
|
export const findEnsembleModel = (value: string): WeatherModel | undefined =>
|
||||||
|
ensembleModels.find((model) => model.value === value);
|
||||||
|
|||||||
@@ -1,22 +1,35 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
|
||||||
import { findModel, modelGroups } from '../../options';
|
import { type WeatherModelGroup, modelGroups } from '../../options';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
onModelChange: (model: string) => void;
|
onModelChange: (model: string) => void;
|
||||||
|
/** Model list to offer; defaults to the deterministic forecast models */
|
||||||
|
groups?: WeatherModelGroup[];
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { selectedModel, onModelChange }: Props = $props();
|
let {
|
||||||
|
selectedModel,
|
||||||
|
onModelChange,
|
||||||
|
groups = modelGroups,
|
||||||
|
label = 'Weather model'
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let model = $derived(findModel(selectedModel));
|
let model = $derived(
|
||||||
let modelLabel = $derived(model?.label ?? selectedModel);
|
groups.flatMap((group) => group.models).find((mo) => mo.value === selectedModel)
|
||||||
let modelMeta = $derived(
|
|
||||||
model?.resolution && model.resolution !== 'varies'
|
|
||||||
? `${model.resolution} · updated ${model.update}`
|
|
||||||
: 'Automatically picks the best model for this location'
|
|
||||||
);
|
);
|
||||||
|
let modelLabel = $derived(model?.label ?? selectedModel);
|
||||||
|
let modelMeta = $derived.by(() => {
|
||||||
|
if (model?.resolution && model.resolution !== 'varies') {
|
||||||
|
return model.update ? `${model.resolution} · updated ${model.update}` : model.resolution;
|
||||||
|
}
|
||||||
|
return selectedModel === 'best_match'
|
||||||
|
? 'Automatically picks the best model for this location'
|
||||||
|
: '';
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Select.Root
|
<Select.Root
|
||||||
@@ -28,7 +41,7 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Select.Trigger
|
<Select.Trigger
|
||||||
aria-label="Forecast model selection"
|
aria-label="{label} 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"
|
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
|
<div
|
||||||
@@ -45,16 +58,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex min-w-0 flex-1 flex-col items-start gap-0 overflow-hidden text-left">
|
<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">
|
<span class="text-[11px] font-semibold tracking-wide text-primary uppercase">
|
||||||
Weather model
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<span class="max-w-full truncate text-sm font-bold text-foreground">{modelLabel}</span>
|
<span class="max-w-full truncate text-sm font-bold text-foreground">{modelLabel}</span>
|
||||||
|
{#if modelMeta}
|
||||||
<span class="max-w-full truncate text-[11px] leading-tight text-muted-foreground">
|
<span class="max-w-full truncate text-[11px] leading-tight text-muted-foreground">
|
||||||
{modelMeta}
|
{modelMeta}
|
||||||
</span>
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content preventScroll={false} class="max-h-[min(480px,60vh)] border-border">
|
<Select.Content preventScroll={false} class="max-h-[min(480px,60vh)] border-border">
|
||||||
{#each modelGroups as group (group.value)}
|
{#each groups as group (group.value)}
|
||||||
<Select.Group>
|
<Select.Group>
|
||||||
<Select.GroupHeading
|
<Select.GroupHeading
|
||||||
class="text-[10.5px] font-bold tracking-wider text-primary/80 uppercase"
|
class="text-[10.5px] font-bold tracking-wider text-primary/80 uppercase"
|
||||||
@@ -68,9 +83,9 @@
|
|||||||
<span class="font-medium">{mo.label}</span>
|
<span class="font-medium">{mo.label}</span>
|
||||||
{#if mo.resolution && mo.resolution !== 'varies'}
|
{#if mo.resolution && mo.resolution !== 'varies'}
|
||||||
<span class="text-[11px] text-muted-foreground">
|
<span class="text-[11px] text-muted-foreground">
|
||||||
{mo.resolution} · updated {mo.update}
|
{mo.resolution}{mo.update ? ` · updated ${mo.update}` : ''}
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else if mo.value === 'best_match'}
|
||||||
<span class="text-[11px] text-muted-foreground">Automatic selection</span>
|
<span class="text-[11px] text-muted-foreground">Automatic selection</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user