From 61d84e4450ae1b8c977c9d7e4ecd9887ba7871cc Mon Sep 17 00:00:00 2001 From: Vincent van der Wal Date: Sat, 25 Jul 2026 15:24:03 +0200 Subject: [PATCH] historical weather --- .mcp.json | 0 src/lib/components/navigation/header.svelte | 7 + .../components/navigation/weather-nav.svelte | 6 + src/lib/paywall/PaywallGate.svelte | 102 ++++++ src/lib/paywall/PremiumBadge.svelte | 47 +++ src/lib/paywall/UnlockDialog.svelte | 138 ++++++++ src/lib/paywall/config.ts | 29 ++ src/lib/paywall/premium.ts | 129 +++++++ src/lib/services/weather.ts | 331 ++++++++++++++++-- src/routes/weather/historical/+page.svelte | 23 ++ .../historical/[location]/+page.svelte | 276 +++++++++++++++ .../weather/historical/[location]/+page.ts | 13 + .../[location]/DateRangeControls.svelte | 108 ++++++ .../[location]/HistoricalDaily.svelte | 260 ++++++++++++++ .../[location]/HistoricalMeteograms.svelte | 209 +++++++++++ 15 files changed, 1653 insertions(+), 25 deletions(-) delete mode 100644 .mcp.json create mode 100644 src/lib/paywall/PaywallGate.svelte create mode 100644 src/lib/paywall/PremiumBadge.svelte create mode 100644 src/lib/paywall/UnlockDialog.svelte create mode 100644 src/lib/paywall/config.ts create mode 100644 src/lib/paywall/premium.ts create mode 100644 src/routes/weather/historical/+page.svelte create mode 100644 src/routes/weather/historical/[location]/+page.svelte create mode 100644 src/routes/weather/historical/[location]/+page.ts create mode 100644 src/routes/weather/historical/[location]/DateRangeControls.svelte create mode 100644 src/routes/weather/historical/[location]/HistoricalDaily.svelte create mode 100644 src/routes/weather/historical/[location]/HistoricalMeteograms.svelte diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index e69de29..0000000 diff --git a/src/lib/components/navigation/header.svelte b/src/lib/components/navigation/header.svelte index 0fa613c..514a175 100644 --- a/src/lib/components/navigation/header.svelte +++ b/src/lib/components/navigation/header.svelte @@ -12,6 +12,8 @@ import LocationSearch from '$lib/components/location/location-search.svelte'; import UnitSelector from '$lib/components/unit-selector.svelte'; + import PremiumBadge from '$lib/paywall/PremiumBadge.svelte'; + interface Props { onMenuToggle?: () => void; } @@ -46,6 +48,8 @@ goto(resolve('/weather/compare/[location]', { location: locationRoute })); } else if (currentPath.startsWith('/weather/14-day')) { goto(resolve('/weather/14-day/[location]', { location: locationRoute })); + } else if (currentPath.startsWith('/weather/historical')) { + goto(resolve('/weather/historical/[location]', { location: locationRoute })); } else { goto(resolve('/weather/week/[location]', { location: locationRoute })); } @@ -105,6 +109,9 @@ + + + + + + {#if $premiumState.status === 'invalid'} +

+ Your saved key is no longer valid or has expired. +

+ {/if} + +{/if} + + diff --git a/src/lib/paywall/PremiumBadge.svelte b/src/lib/paywall/PremiumBadge.svelte new file mode 100644 index 0000000..49f11f8 --- /dev/null +++ b/src/lib/paywall/PremiumBadge.svelte @@ -0,0 +1,47 @@ + + + + + diff --git a/src/lib/paywall/UnlockDialog.svelte b/src/lib/paywall/UnlockDialog.svelte new file mode 100644 index 0000000..6f71c93 --- /dev/null +++ b/src/lib/paywall/UnlockDialog.svelte @@ -0,0 +1,138 @@ + + + + + + Drizz.li Premium + + Paste the access key from your subscription email to unlock premium pages. + + + + {#if $isPremium && $premiumState.status === 'valid'} +
+ + + + +
+

Premium is active

+ {#if expiresLabel}

{expiresLabel}

{/if} +
+
+ {/if} + +
+
+ + +
+ + {#if localError} +

{localError}

+ {:else if $premiumState.status === 'error'} +

+ Couldn't reach the server. Check your connection and try again. +

+ {/if} + + +
+ + + {#if $isPremium} + + {:else} +

+ No key yet? + + Subscribe from {PREMIUM_PRICE} + +

+ {/if} +
+
+
diff --git a/src/lib/paywall/config.ts b/src/lib/paywall/config.ts new file mode 100644 index 0000000..e2842d5 --- /dev/null +++ b/src/lib/paywall/config.ts @@ -0,0 +1,29 @@ +/** + * Paywall configuration. + * + * The frontend stays fully static and open source; the only server piece is the + * tiny `drizzli-paywall` verify API (a separate, self-hosted repo). Point the + * build at your deployment with the `VITE_PAYWALL_*` env vars (e.g. in a + * `.env` file), otherwise the sensible drizz.li defaults are used. + */ + +const env = import.meta.env as Record; + +const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, ''); + +/** Base URL of the self-hosted verify API (drizzli-paywall). */ +export const PAYWALL_API_BASE = stripTrailingSlash( + env.VITE_PAYWALL_API_BASE ?? 'https://paywall.drizz.li' +); + +/** Where prospective subscribers go to sign up (the paywall repo's signup form). */ +export const SIGNUP_URL = env.VITE_PAYWALL_SIGNUP_URL ?? `${PAYWALL_API_BASE}/`; + +/** Display price, shown on the paywall panel. */ +export const PREMIUM_PRICE = env.VITE_PREMIUM_PRICE ?? '€3 / month'; + +/** Short, human list of what premium unlocks (shown on the locked panel). */ +export const PREMIUM_PERKS = [ + 'Historical weather & climate-normal comparisons', + 'New premium features as they land' +]; diff --git a/src/lib/paywall/premium.ts b/src/lib/paywall/premium.ts new file mode 100644 index 0000000..91f212a --- /dev/null +++ b/src/lib/paywall/premium.ts @@ -0,0 +1,129 @@ +/** + * Premium (subscription) state. + * + * The user pastes an access key once; it is stored locally and re-verified + * against the self-hosted verify API on load. The last good result is cached so + * gated content shows instantly on reload (and keeps working briefly offline) + * without waiting for the network round-trip. + * + * This gate is a convenience/honor-system gate: the frontend is open source and + * static, so it can be bypassed. Keeping the subscriber list server-side (in the + * paywall repo) is what makes it meaningful in practice. + */ +import { derived, get, writable } from 'svelte/store'; + +import { persisted } from 'svelte-persisted-store'; + +import { PAYWALL_API_BASE } from './config'; + +/** The subscriber's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */ +export const storedLicenseKey = persisted('license_key', ''); + +export interface PremiumCache { + valid: boolean; + tier?: string; + /** ISO date the subscription lapses, or null for a lifetime key. */ + expires?: string | null; + /** epoch ms of the last verify call */ + checkedAt: number; +} + +/** Last verify result, persisted so the UI doesn't flash "locked" on reload. */ +export const storedPremiumCache = persisted('premium_cache_v1', null); + +export type PremiumStatus = 'idle' | 'checking' | 'valid' | 'invalid' | 'error'; + +export interface PremiumState { + status: PremiumStatus; + tier?: string; + expires?: string | null; + error?: string; +} + +/** Live verification state for the current session. */ +export const premiumState = writable({ status: 'idle' }); + +function notExpired(expires: string | null | undefined): boolean { + if (!expires) return true; // lifetime key + const t = Date.parse(expires); + return Number.isFinite(t) && t > Date.now(); +} + +/** + * Whether premium content should be shown. A live "valid"/"invalid" result wins; + * otherwise we fall back to the cached result (so a reload or a brief network + * blip doesn't lock a paying user out). + */ +export const isPremium = derived( + [premiumState, storedPremiumCache], + ([$state, $cache]): boolean => { + if ($state.status === 'valid') return true; + if ($state.status === 'invalid') return false; + return !!($cache && $cache.valid && notExpired($cache.expires)); + } +); + +export interface VerifyResult { + valid: boolean; + tier?: string; + expires?: string | null; + error?: string; +} + +/** + * Verify a key against the API. On success the key is persisted and the cache + * updated. On an invalid key the stored key is left untouched (so an expired + * subscription can still show a "renew" state) but the cache is marked invalid. + */ +export async function verifyKey(key: string): Promise { + const trimmed = key.trim(); + if (!trimmed) { + premiumState.set({ status: 'invalid' }); + return { valid: false, error: 'Enter your access key.' }; + } + + premiumState.set({ status: 'checking' }); + try { + const res = await fetch(`${PAYWALL_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, { + headers: { accept: 'application/json' } + }); + const data = (await res.json()) as VerifyResult; + + if (res.ok && data.valid) { + storedLicenseKey.set(trimmed); + storedPremiumCache.set({ + valid: true, + tier: data.tier, + expires: data.expires ?? null, + checkedAt: Date.now() + }); + premiumState.set({ status: 'valid', tier: data.tier, expires: data.expires ?? null }); + return { valid: true, tier: data.tier, expires: data.expires ?? null }; + } + + storedPremiumCache.set({ valid: false, checkedAt: Date.now() }); + premiumState.set({ status: 'invalid' }); + return { valid: false }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + premiumState.set({ status: 'error', error: message }); + return { valid: false, error: message }; + } +} + +/** Re-verify the stored key (call on app / gated-page load). No-op if signed out. */ +export async function refreshPremium(): Promise { + const key = get(storedLicenseKey); + if (!key) { + premiumState.set({ status: 'idle' }); + return; + } + await verifyKey(key); +} + +/** Forget the key and premium state ("sign out"). */ +export function clearLicense(): void { + storedLicenseKey.set(''); + storedPremiumCache.set(null); + premiumState.set({ status: 'idle' }); +} diff --git a/src/lib/services/weather.ts b/src/lib/services/weather.ts index e6df4a9..27a617a 100644 --- a/src/lib/services/weather.ts +++ b/src/lib/services/weather.ts @@ -22,6 +22,7 @@ import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time'; const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast'; const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble'; +const ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive'; // ─── Core Helpers ─────────────────────────────────────────────────────────────── @@ -303,6 +304,40 @@ const WEEK_DAILY_VARS = [ 'wind_direction_10m_dominant' ] as const; +/** + * Assembles a WeekHourlyData structure from a name→values map, so variables that + * were not requested resolve to empty arrays. Shared by the week and historical + * fetchers (both return the same hourly shape, which the meteograms and hourly + * table consume). + */ +function weekHourlyFromByName(byName: Record): WeekHourlyData { + const g = (name: string): number[] => byName[name] ?? []; + return { + temperature_2m: g('temperature_2m'), + precipitation: g('precipitation'), + precipitation_probability: g('precipitation_probability'), + weather_code: g('weather_code'), + windspeed_10m: g('wind_speed_10m'), + winddirection_10m: g('wind_direction_10m'), + cloud_cover: g('cloud_cover'), + relative_humidity_2m: g('relative_humidity_2m'), + apparent_temperature: g('apparent_temperature'), + dew_point_2m: g('dew_point_2m'), + wind_gusts_10m: g('wind_gusts_10m'), + pressure_msl: g('pressure_msl'), + surface_pressure: g('surface_pressure'), + rain: g('rain'), + showers: g('showers'), + snowfall: g('snowfall'), + cloud_cover_low: g('cloud_cover_low'), + cloud_cover_mid: g('cloud_cover_mid'), + cloud_cover_high: g('cloud_cover_high'), + uv_index: g('uv_index'), + visibility: g('visibility'), + cape: g('cape') + }; +} + /** * Fetches the 7-day (week) weather forecast for a single location and model. * Returns typed hourly and daily data structures. @@ -359,32 +394,8 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise byName[name] ?? []; - const hourly: WeekHourlyData = { - temperature_2m: g('temperature_2m'), - precipitation: g('precipitation'), - precipitation_probability: g('precipitation_probability'), - weather_code: g('weather_code'), - windspeed_10m: g('wind_speed_10m'), - winddirection_10m: g('wind_direction_10m'), - cloud_cover: g('cloud_cover'), - relative_humidity_2m: g('relative_humidity_2m'), - apparent_temperature: g('apparent_temperature'), - dew_point_2m: g('dew_point_2m'), - wind_gusts_10m: g('wind_gusts_10m'), - pressure_msl: g('pressure_msl'), - surface_pressure: g('surface_pressure'), - rain: g('rain'), - showers: g('showers'), - snowfall: g('snowfall'), - cloud_cover_low: g('cloud_cover_low'), - cloud_cover_mid: g('cloud_cover_mid'), - cloud_cover_high: g('cloud_cover_high'), - uv_index: g('uv_index'), - visibility: g('visibility'), - cape: g('cape') - }; + const hourly = weekHourlyFromByName(byName); // Daily: variables are in the same order as WEEK_DAILY_VARS const dailyDates = getDates(dailyBlock); @@ -803,3 +814,273 @@ function modelEnumToString(modelEnum: number): string { }; return modelMap[modelEnum] ?? `model_${modelEnum}`; } + +// ─── Historical (Archive) Types ───────────────────────────────────────────── + +export interface HistoricalDailyData { + weather_code: number[]; + temperature_2m_max: number[]; + temperature_2m_min: number[]; + temperature_2m_mean: number[]; + apparent_temperature_max: number[]; + apparent_temperature_min: number[]; + sunrise: number[]; + sunset: number[]; + sunshine_duration: number[]; + precipitation_sum: number[]; + rain_sum: number[]; + snowfall_sum: number[]; + precipitation_hours: number[]; + windspeed_10m_max: number[]; + windgusts_10m_max: number[]; + winddirection_10m_dominant: number[]; +} + +export interface HistoricalForecastParams extends WeatherLocation, WeatherUnitParams { + /** Inclusive range, YYYY-MM-DD (location-local dates). */ + start_date: string; + end_date: string; + /** Hourly API variables to request; defaults to the core week set. */ + hourlyVariables?: string[]; +} + +export interface HistoricalForecastResult { + hourly: WeekHourlyData; + daily: HistoricalDailyData; + utcOffsetSeconds: number; + timezone: string; + hourlyTimestamps: number[]; + hourlyDates: Date[]; + dailyDates: Date[]; + daylightBands: DaylightBand[]; +} + +// Requested in this exact order; the daily block returns variables positionally. +const HISTORICAL_DAILY_VARS = [ + 'weather_code', + 'temperature_2m_max', + 'temperature_2m_min', + 'temperature_2m_mean', + 'apparent_temperature_max', + 'apparent_temperature_min', + 'sunrise', + 'sunset', + 'sunshine_duration', + 'precipitation_sum', + 'rain_sum', + 'snowfall_sum', + 'precipitation_hours', + 'wind_speed_10m_max', + 'wind_gusts_10m_max', + 'wind_direction_10m_dominant' +] as const; + +// ─── Historical (Archive) Fetch ───────────────────────────────────────────── + +/** + * Fetches reanalysis (ERA5) weather for a past date range from the Open-Meteo + * archive API. Returns the same hourly shape as the week forecast (so the + * existing meteograms and hourly table render it unchanged) plus a richer daily + * block for the climate/statistics view. + */ +export async function fetchHistoricalWeather( + params: HistoricalForecastParams +): Promise { + const hourlyVars = + params.hourlyVariables && params.hourlyVariables.length > 0 + ? [...new Set(params.hourlyVariables)] + : [...WEEK_HOURLY_VARS]; + + const apiParams: Record = { + latitude: params.latitude, + longitude: params.longitude, + start_date: params.start_date, + end_date: params.end_date, + hourly: hourlyVars.join(','), + daily: HISTORICAL_DAILY_VARS.join(','), + temperature_unit: params.temperature_unit ?? 'celsius', + wind_speed_unit: params.wind_speed_unit ?? 'kmh', + precipitation_unit: params.precipitation_unit ?? 'mm', + timezone: params.timezone + }; + + const cleanParams: Record = {}; + for (const [key, value] of Object.entries(apiParams)) { + if (value !== undefined) cleanParams[key] = String(value); + } + + const responses = await fetchWeatherApi(ARCHIVE_URL, cleanParams); + const response = responses[0]; + const utcOffsetSeconds = response.utcOffsetSeconds(); + const timezone = response.timezone() ?? params.timezone ?? 'UTC'; + + const hourlyBlock = response.hourly()!; + const dailyBlock = response.daily()!; + + const hourlyTimestamps = getTimestamps(hourlyBlock); + const hourlyDates = hourlyTimestamps.map((t) => new Date(t)); + + const byName: Record = {}; + hourlyVars.forEach((name, i) => { + const variable = hourlyBlock.variables(i); + byName[name] = variable ? getValues(variable) : []; + }); + const hourly = weekHourlyFromByName(byName); + + // Daily variables come back in HISTORICAL_DAILY_VARS order. + const dailyDates = getDates(dailyBlock); + const d = (i: number): number[] => { + const v = dailyBlock.variables(i); + return v ? getValues(v) : []; + }; + const sunrise = getInt64Values(dailyBlock.variables(6)!); + const sunset = getInt64Values(dailyBlock.variables(7)!); + + const daily: HistoricalDailyData = { + weather_code: d(0), + temperature_2m_max: d(1), + temperature_2m_min: d(2), + temperature_2m_mean: d(3), + apparent_temperature_max: d(4), + apparent_temperature_min: d(5), + sunrise, + sunset, + sunshine_duration: d(8), + precipitation_sum: d(9), + rain_sum: d(10), + snowfall_sum: d(11), + precipitation_hours: d(12), + windspeed_10m_max: d(13), + windgusts_10m_max: d(14), + winddirection_10m_dominant: d(15) + }; + + const daylightBands = buildDaylightBands(sunrise, sunset); + + return { + hourly, + daily, + utcOffsetSeconds, + timezone, + hourlyTimestamps, + hourlyDates, + dailyDates, + daylightBands + }; +} + +// ─── Climate Normals ──────────────────────────────────────────────────────── + +export interface ClimateNormals { + /** Indexed by day-of-year ordinal 1..366 (index 0 unused); NaN where no data. */ + tmax: number[]; + tmin: number[]; + tmean: number[]; + /** Mean daily precipitation (per calendar day). */ + precip: number[]; + baseStart: string; + baseEnd: string; + temperature_unit: string; + precipitation_unit: string; +} + +export interface ClimateNormalsParams extends WeatherLocation, WeatherUnitParams { + /** Baseline period; defaults to the 1991-2020 WMO normal period. */ + baseStart?: string; + baseEnd?: string; +} + +// Days before the first of each month in a leap reference year, so that a +// (month, day) pair maps to a stable 1..366 ordinal regardless of leap years. +const CUM_DAYS_LEAP = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335]; + +/** Day-of-year ordinal (1..366) from month (1-12) and day-of-month (1-31). */ +export function monthDayToOrdinal(month: number, day: number): number { + const m = Math.min(12, Math.max(1, Math.round(month))); + return CUM_DAYS_LEAP[m - 1] + day; +} + +/** + * Computes daily climate normals for a location by averaging a multi-decade + * archive across years, per day-of-year, with a ±7-day smoothing window so the + * curve is stable. One archive request; used for the "vs normal" comparison. + */ +export async function fetchClimateNormals(params: ClimateNormalsParams): Promise { + const baseStart = params.baseStart ?? '1991-01-01'; + const baseEnd = params.baseEnd ?? '2020-12-31'; + + // UTC keeps the day-of-year bucketing exact (no offset spill across midnight); + // timezone is irrelevant to a per-calendar-day normal. + const apiParams: Record = { + latitude: String(params.latitude), + longitude: String(params.longitude), + start_date: baseStart, + end_date: baseEnd, + daily: 'temperature_2m_max,temperature_2m_min,temperature_2m_mean,precipitation_sum', + temperature_unit: params.temperature_unit ?? 'celsius', + precipitation_unit: params.precipitation_unit ?? 'mm', + timezone: 'UTC' + }; + + const responses = await fetchWeatherApi(ARCHIVE_URL, apiParams); + const response = responses[0]; + const dailyBlock = response.daily()!; + const dates = getDates(dailyBlock); + const tmaxV = getValues(dailyBlock.variables(0)!); + const tminV = getValues(dailyBlock.variables(1)!); + const tmeanV = getValues(dailyBlock.variables(2)!); + const precipV = getValues(dailyBlock.variables(3)!); + + const N = 367; // ordinals 1..366 + const mk = () => ({ sum: new Array(N).fill(0), cnt: new Array(N).fill(0) }); + const acc = { tmax: mk(), tmin: mk(), tmean: mk(), precip: mk() }; + + const add = (bucket: { sum: number[]; cnt: number[] }, ord: number, val: number) => { + if (Number.isFinite(val)) { + bucket.sum[ord] += val; + bucket.cnt[ord] += 1; + } + }; + + for (let i = 0; i < dates.length; i++) { + const dt = dates[i]; + const ord = monthDayToOrdinal(dt.getUTCMonth() + 1, dt.getUTCDate()); + add(acc.tmax, ord, tmaxV[i]); + add(acc.tmin, ord, tminV[i]); + add(acc.tmean, ord, tmeanV[i]); + add(acc.precip, ord, precipV[i]); + } + + const mean = (bucket: { sum: number[]; cnt: number[] }): number[] => + bucket.sum.map((s, i) => (bucket.cnt[i] > 0 ? s / bucket.cnt[i] : NaN)); + + // Circular ±window smoothing across the 366 ordinals (skips empty days). + const smooth = (arr: number[], window = 7): number[] => { + const out = new Array(N).fill(NaN); + for (let o = 1; o <= 366; o++) { + let s = 0; + let c = 0; + for (let k = -window; k <= window; k++) { + const idx = ((o - 1 + k + 366) % 366) + 1; + const v = arr[idx]; + if (Number.isFinite(v)) { + s += v; + c++; + } + } + out[o] = c > 0 ? s / c : NaN; + } + return out; + }; + + return { + tmax: smooth(mean(acc.tmax)), + tmin: smooth(mean(acc.tmin)), + tmean: smooth(mean(acc.tmean)), + precip: smooth(mean(acc.precip)), + baseStart, + baseEnd, + temperature_unit: params.temperature_unit ?? 'celsius', + precipitation_unit: params.precipitation_unit ?? 'mm' + }; +} diff --git a/src/routes/weather/historical/+page.svelte b/src/routes/weather/historical/+page.svelte new file mode 100644 index 0000000..253be36 --- /dev/null +++ b/src/routes/weather/historical/+page.svelte @@ -0,0 +1,23 @@ + diff --git a/src/routes/weather/historical/[location]/+page.svelte b/src/routes/weather/historical/[location]/+page.svelte new file mode 100644 index 0000000..3889f1b --- /dev/null +++ b/src/routes/weather/historical/[location]/+page.svelte @@ -0,0 +1,276 @@ + + + + Drizz.li | Historical weather + + + + +
+
+ {location.country +
+

+ {location.name} +

+

+ {#if location.admin1}{location.admin1}, + {/if}{location.country ?? ''}·Historical weather +

+
+
+
+ + + + + {#if loadError} +
+ Failed to load historical data: {loadError} +
+ {/if} + +
+ {#if result && fetchedHourly && fetchedDaily} + + +
+ +
+ + + {:else} +
+
+ +
+ {/if} +
+
diff --git a/src/routes/weather/historical/[location]/+page.ts b/src/routes/weather/historical/[location]/+page.ts new file mode 100644 index 0000000..3c550ac --- /dev/null +++ b/src/routes/weather/historical/[location]/+page.ts @@ -0,0 +1,13 @@ +import { resolveLocationFromRoute } from '$lib/utils/location'; + +import type { PageLoad } from './$types'; + +export const load: PageLoad = async (event) => { + const location = await resolveLocationFromRoute({ + urlLocation: event.params.location, + routePrefix: '/weather/historical/', + event + }); + + return { location }; +}; diff --git a/src/routes/weather/historical/[location]/DateRangeControls.svelte b/src/routes/weather/historical/[location]/DateRangeControls.svelte new file mode 100644 index 0000000..e6c4549 --- /dev/null +++ b/src/routes/weather/historical/[location]/DateRangeControls.svelte @@ -0,0 +1,108 @@ + + +
+
+
+ + +
+
+ + +
+
+ +
+ {#each presets as preset (preset.label)} + + {/each} +
+
diff --git a/src/routes/weather/historical/[location]/HistoricalDaily.svelte b/src/routes/weather/historical/[location]/HistoricalDaily.svelte new file mode 100644 index 0000000..7360c2b --- /dev/null +++ b/src/routes/weather/historical/[location]/HistoricalDaily.svelte @@ -0,0 +1,260 @@ + + + +
+
+

Average temperature

+

+ {fmtTemp(stats.avg)}{tempUnit.replace('°', '')} +

+ {#if stats.tempAnomaly != null} +

= 0} + class:text-blue-600={stats.tempAnomaly < 0} + class:dark:text-red-400={stats.tempAnomaly >= 0} + class:dark:text-blue-400={stats.tempAnomaly < 0} + > + {fmtSigned(stats.tempAnomaly)} vs normal +

+ {:else} +

1991–2020 normal loading…

+ {/if} +
+ +
+

Total precipitation

+

{fmtPrecip(stats.totalPrecip)}

+ {#if stats.normalPrecip != null} +

+ normal {fmtPrecip(stats.normalPrecip)} · {stats.wetDays} wet {stats.wetDays === 1 + ? 'day' + : 'days'} +

+ {:else} +

{stats.wetDays} wet days

+ {/if} +
+ +
+

Warmest day

+

{fmtTemp(stats.warm.t)}

+ {#if stats.warm.i >= 0} +

+ {formatZoned(dailyDates[stats.warm.i], timezone, 'EEE d LLL')} +

+ {/if} +
+ +
+

Coldest day

+

{fmtTemp(stats.cold.t)}

+ {#if stats.cold.i >= 0} +

+ {formatZoned(dailyDates[stats.cold.i], timezone, 'EEE d LLL')} +

+ {/if} +
+
+ + +
+
+

+ Daily – select a day for hourly detail +

+ {#if normals} + + {/if} +
+ +
+
+ {#each dailyDates as date, i (date.getTime())} + {@const selected = isSameDayInZone(date, selectedDay, timezone)} + {@const tMax = daily.temperature_2m_max[i]} + {@const tMin = daily.temperature_2m_min[i]} + {@const tMean = daily.temperature_2m_mean[i]} + {@const norm = normals ? normals.tmean[ordinals[i]] : NaN} + {@const anomaly = finite(tMean) && finite(norm) ? tMean - norm : null} + {@const precip = daily.precipitation_sum[i]} + + {/each} +
+
+
diff --git a/src/routes/weather/historical/[location]/HistoricalMeteograms.svelte b/src/routes/weather/historical/[location]/HistoricalMeteograms.svelte new file mode 100644 index 0000000..c475657 --- /dev/null +++ b/src/routes/weather/historical/[location]/HistoricalMeteograms.svelte @@ -0,0 +1,209 @@ + + +
+
+

+ Meteograms – full range +

+
+ + {#if zoomActive} + + {/if} + +
+
+ + {#if renderPanels.length === 0} +
+ No meteograms configured. Add variables from the 7-day forecast page. +
+ {:else} +
+ {#each renderPanels as panel, i (panel.id)} +
+
+

+ + {panel.titleShort} +

+
+ + + +
+ {/each} +
+ {/if} +