From 9b8c69123ccd0a7c1b29a6ab9997d30c3342155c Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 17 Apr 2026 13:57:05 +0200 Subject: [PATCH] feat(wetter): add multi-model source comparison view New "Quellen-Vergleich" tab on the weather page that fetches the same location from 5 weather models in parallel (DWD ICON-D2, ICON-EU, ECMWF IFS, NOAA GFS, Open-Meteo Best Match) and displays them stacked for easy comparison of temperature, precipitation, and daily forecasts. Adds /api/v1/wetter/compare endpoint and SourceComparison.svelte. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/modules/wetter/routes.ts | 76 ++++ .../apps/web/src/lib/modules/wetter/api.ts | 37 ++ .../wetter/components/SourceComparison.svelte | 408 ++++++++++++++++++ .../web/src/routes/(app)/wetter/+page.svelte | 49 ++- 4 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 apps/mana/apps/web/src/lib/modules/wetter/components/SourceComparison.svelte diff --git a/apps/api/src/modules/wetter/routes.ts b/apps/api/src/modules/wetter/routes.ts index a6724e3ec..33bf6c1d8 100644 --- a/apps/api/src/modules/wetter/routes.ts +++ b/apps/api/src/modules/wetter/routes.ts @@ -343,4 +343,80 @@ routes.get('/radar-tiles', (c) => { }); }); +// ─── Multi-Model Comparison ──────────────────────────────── +// Fetches the same location from multiple weather models in parallel +// so the frontend can show a side-by-side comparison. + +const COMPARE_MODELS = [ + { id: 'icon_d2', label: 'DWD ICON-D2', description: '2km, Deutschland', source: 'DWD' }, + { id: 'icon_eu', label: 'DWD ICON-EU', description: '6.5km, Europa', source: 'DWD' }, + { id: 'ecmwf_ifs025', label: 'ECMWF IFS', description: '25km, Global', source: 'ECMWF' }, + { id: 'gfs_seamless', label: 'GFS', description: '25km, Global', source: 'NOAA' }, + { + id: 'best_match', + label: 'Open-Meteo Best Match', + description: 'Automatisch bestes Modell', + source: 'Open-Meteo', + }, +] as const; + +const COMPARE_CURRENT = [ + 'temperature_2m', + 'apparent_temperature', + 'weather_code', + 'wind_speed_10m', + 'precipitation', + 'relative_humidity_2m', + 'is_day', +].join(','); +const COMPARE_DAILY = [ + 'temperature_2m_min', + 'temperature_2m_max', + 'weather_code', + 'precipitation_sum', + 'precipitation_probability_max', +].join(','); + +routes.post('/compare', async (c) => { + const { lat, lon } = await c.req.json<{ lat: number; lon: number }>(); + if (lat == null || lon == null) return c.json({ error: 'lat and lon required' }, 400); + + const key = `compare:${coordKey(lat, lon)}`; + const cached = getCached(key); + if (cached) return c.json(cached); + + const results = await Promise.all( + COMPARE_MODELS.map(async (model) => { + try { + const url = `${OPEN_METEO_BASE}/forecast?latitude=${lat}&longitude=${lon}¤t=${COMPARE_CURRENT}&daily=${COMPARE_DAILY}&models=${model.id}&timezone=auto&forecast_days=7`; + const res = await fetch(url); + if (!res.ok) return { ...model, error: true, current: null, daily: null }; + const data = (await res.json()) as { + current?: Record; + daily?: Record; + }; + return { ...model, error: false, current: data.current ?? null, daily: data.daily ?? null }; + } catch { + return { ...model, error: true, current: null, daily: null }; + } + }) + ); + + // Also fetch DWD alerts + nowcast for context + let alerts: unknown[] = []; + try { + const alertRes = await fetch(DWD_WARNINGS_URL, { headers: { Accept: 'application/json' } }); + if (alertRes.ok) { + const raw = await alertRes.json(); + alerts = extractNearbyAlerts(raw, lat, lon); + } + } catch { + /* noop */ + } + + const payload = { models: results, alerts, fetchedAt: Date.now() }; + setCache(key, payload, WEATHER_TTL); + return c.json(payload); +}); + export { routes as wetterRoutes }; diff --git a/apps/mana/apps/web/src/lib/modules/wetter/api.ts b/apps/mana/apps/web/src/lib/modules/wetter/api.ts index 713cc18c4..f05f79d37 100644 --- a/apps/mana/apps/web/src/lib/modules/wetter/api.ts +++ b/apps/mana/apps/web/src/lib/modules/wetter/api.ts @@ -169,3 +169,40 @@ export async function geocode(query: string): Promise { const raw = await post<{ results: GeocodingResult[] }>('/geocode', { query }); return raw.results ?? []; } + +// ─── Model Comparison ────────────────────────────────────── + +export interface ModelComparison { + id: string; + label: string; + description: string; + source: string; + error: boolean; + current: { + temperature_2m?: number; + apparent_temperature?: number; + weather_code?: number; + wind_speed_10m?: number; + precipitation?: number; + relative_humidity_2m?: number; + is_day?: number; + } | null; + daily: { + time?: string[]; + temperature_2m_min?: number[]; + temperature_2m_max?: number[]; + weather_code?: number[]; + precipitation_sum?: number[]; + precipitation_probability_max?: number[]; + } | null; +} + +export interface CompareResponse { + models: ModelComparison[]; + alerts: WeatherAlert[]; + fetchedAt: number; +} + +export async function getComparison(lat: number, lon: number): Promise { + return post('/compare', { lat, lon }); +} diff --git a/apps/mana/apps/web/src/lib/modules/wetter/components/SourceComparison.svelte b/apps/mana/apps/web/src/lib/modules/wetter/components/SourceComparison.svelte new file mode 100644 index 000000000..81af561a5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wetter/components/SourceComparison.svelte @@ -0,0 +1,408 @@ + + + +
+
+ + {locationName} +
+ + {#if loading} +
Modelle werden verglichen...
+ {:else if error} +
{error}
+ {:else if data} + +
+ Aktuell +
+ {#each data.models as model (model.id)} + {@const c = model.current} +
+
+ + {model.source} + + {model.label} +
+
{model.description}
+ {#if model.error || !c} +
Nicht verfuegbar
+ {:else} +
+ {getWeatherIcon(c.weather_code ?? 0, (c.is_day ?? 1) === 1)} + {Math.round(c.temperature_2m ?? 0)}° + {getWeatherLabel(c.weather_code ?? 0)} +
+
+
+ Gefuehlt + {Math.round(c.apparent_temperature ?? 0)}° +
+
+ Wind + {Math.round(c.wind_speed_10m ?? 0)} km/h +
+
+ Niederschlag + {(c.precipitation ?? 0).toFixed(1)} mm +
+
+ Feuchtigkeit + {c.relative_humidity_2m ?? 0}% +
+
+ {/if} +
+ {/each} +
+
+ + +
+ 7-Tage-Vergleich + {#each Array.from( { length: Math.min(7, data.models[0]?.daily?.time?.length ?? 0) } ) as _, dayIdx} + {@const dateStr = data.models[0]?.daily?.time?.[dayIdx] ?? ''} + {@const dayLabel = + dayIdx === 0 + ? 'Heute' + : dayIdx === 1 + ? 'Morgen' + : new Date(dateStr).toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric' })} +
+
{dayLabel}
+
+ {#each data.models as model (model.id)} + {@const d = model.daily} +
+ + {model.source} + + {#if model.error || !d} + + {:else} + {getWeatherIcon(d.weather_code?.[dayIdx] ?? 0)} + + {Math.round(d.temperature_2m_min?.[dayIdx] ?? 0)}° / {Math.round( + d.temperature_2m_max?.[dayIdx] ?? 0 + )}° + + + {(d.precipitation_sum?.[dayIdx] ?? 0).toFixed(1)} mm + + {#if (d.precipitation_probability_max?.[dayIdx] ?? 0) > 0} + + ({d.precipitation_probability_max?.[dayIdx]}%) + + {/if} + {/if} +
+ {/each} +
+
+ {/each} +
+ + + {#if data.alerts.length > 0} +
+ DWD Wetterwarnungen + {#each data.alerts.slice(0, 5) as alert} +
+ + {alert.severity} + + {alert.headline} +
+ {/each} +
+ {/if} + +
+ Abgerufen: {new Date(data.fetchedAt).toLocaleTimeString('de-DE')} + +
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/routes/(app)/wetter/+page.svelte b/apps/mana/apps/web/src/routes/(app)/wetter/+page.svelte index 4ef593773..346a164b2 100644 --- a/apps/mana/apps/web/src/routes/(app)/wetter/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/wetter/+page.svelte @@ -14,15 +14,19 @@ import WeatherAlerts from '$lib/modules/wetter/components/WeatherAlerts.svelte'; import NowcastBar from '$lib/modules/wetter/components/NowcastBar.svelte'; import LocationPicker from '$lib/modules/wetter/components/LocationPicker.svelte'; + import SourceComparison from '$lib/modules/wetter/components/SourceComparison.svelte'; const locationsQuery = useLocations(); let locations = $derived(locationsQuery.value); let selectedLat = $state(null); let selectedLon = $state(null); + let selectedName = $state(''); + let activeTab = $state<'overview' | 'compare'>('overview'); function selectLocation(lat: number, lon: number, name: string) { selectedLat = lat; selectedLon = lon; + selectedName = name; weatherStore.fetchWeather(lat, lon, name); weatherStore.fetchNowcast(lat, lon); } @@ -70,7 +74,27 @@ onSave={saveLocation} /> - {#if weatherStore.loading && !weatherStore.weatherData} + +
+ + +
+ + {#if activeTab === 'compare' && selectedLat != null && selectedLon != null} + + {:else if weatherStore.loading && !weatherStore.weatherData}
🌤 Wetterdaten werden geladen... @@ -121,6 +145,29 @@ margin: 0 auto; padding: 16px; } + .tab-bar { + display: flex; + gap: 4px; + background: var(--card-bg, rgba(255, 255, 255, 0.06)); + border-radius: 10px; + padding: 3px; + } + .tab { + flex: 1; + padding: 8px 12px; + border: none; + border-radius: 8px; + background: none; + color: var(--text-secondary, #9ca3af); + font-size: 0.8rem; + cursor: pointer; + transition: all 0.15s ease; + } + .tab.active { + background: var(--card-bg-hover, rgba(255, 255, 255, 0.1)); + color: var(--text-primary, #f3f4f6); + font-weight: 500; + } .loading-state { display: flex; flex-direction: column;