more translations

This commit is contained in:
Vincent van der Wal
2026-08-01 16:36:04 +02:00
parent ae88140f79
commit d7fe0bafc8
24 changed files with 762 additions and 215 deletions
+3
View File
@@ -30,3 +30,6 @@ AGENTS.md
# paraglide compiles the message runtime into the source tree # paraglide compiles the message runtime into the source tree
/src/lib/paraglide /src/lib/paraglide
/.inlang /.inlang
# Claude Code scratch: throwaway probe scripts, never committed
/.scratch
-20
View File
@@ -1,20 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch();
for (const [label, supporter] of [['plain', false], ['supporter', true]]) {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 950 } });
if (supporter) await ctx.addInitScript(() => localStorage.setItem('supporter_cache_v1', JSON.stringify({ valid: true, expires: null, checkedAt: 1785578719118 })));
const page = await ctx.newPage();
await page.goto('http://localhost:5186/en/weather/week/berlin/', { waitUntil: 'load', timeout: 120000 });
await page.waitForTimeout(9000);
const btn = page.getByRole('button', { name: /Support Drizz|Supporter extras/ }).first();
await btn.screenshot({ path: `/tmp/badge-${label}.png` });
await ctx.close();
}
// locked panel
const ctx = await browser.newContext({ viewport: { width: 1280, height: 950 } });
const page = await ctx.newPage();
await page.goto('http://localhost:5186/en/weather/seasonal/berlin/', { waitUntil: 'load', timeout: 120000 });
await page.waitForTimeout(6000);
await page.locator('h2').first().locator('xpath=..').screenshot({ path: '/tmp/badge-panel.png' }).catch(() => {});
console.log('ok');
await browser.close();
+52 -1
View File
@@ -152,5 +152,56 @@
"calm_3": "Den ganzen Tag so gut wie windstill.", "calm_3": "Den ganzen Tag so gut wie windstill.",
"uv_1": "UV-Index bis {value} ({label}) - mittags an Sonnenschutz denken.", "uv_1": "UV-Index bis {value} ({label}) - mittags an Sonnenschutz denken.",
"uv_2": "Mittags brennt die Sonne: UV {value}, {label}.", "uv_2": "Mittags brennt die Sonne: UV {value}, {label}.",
"uv_3": "Mittags besser in den Schatten - der UV-Index erreicht {value} ({label})." "uv_3": "Mittags besser in den Schatten - der UV-Index erreicht {value} ({label}).",
"legal_about": "Über uns",
"legal_imprint": "Impressum",
"legal_privacy": "Datenschutz",
"legal_terms": "Bedingungen",
"legal_nav": "Rechtliches",
"city_weather": "Wetter in {city}",
"var_temperature": "Temperatur",
"var_temperature_short": "Temp",
"var_icons": "Wettersymbole",
"var_icons_short": "Symbole",
"var_apparent": "Gefühlte Temp.",
"var_apparent_short": "Gefühlt",
"var_dew_point": "Taupunkt",
"var_dew_point_short": "Tau",
"var_cloud": "Bewölkung",
"var_cloud_short": "Wolken",
"var_cloud_low": "Bewölkung tief",
"var_cloud_low_short": "Tief",
"var_cloud_mid": "Bewölkung mittel",
"var_cloud_mid_short": "Mittel",
"var_cloud_high": "Bewölkung hoch",
"var_cloud_high_short": "Hoch",
"var_precipitation": "Niederschlag",
"var_precipitation_short": "Nied.",
"var_pop": "Niederschlagswahrsch.",
"var_pop_short": "Wahrsch.",
"var_rain": "Regen",
"var_rain_short": "Regen",
"var_showers": "Schauer",
"var_showers_short": "Schauer",
"var_snowfall": "Schneefall",
"var_snowfall_short": "Schnee",
"var_wind": "Windgeschwindigkeit",
"var_wind_short": "Wind",
"var_wind_dir": "Windrichtung",
"var_wind_dir_short": "Richt.",
"var_gusts": "Windböen",
"var_gusts_short": "Böen",
"var_humidity": "Luftfeuchte",
"var_humidity_short": "RF",
"var_pressure": "Luftdruck (MSL)",
"var_pressure_short": "MSLP",
"var_surface_pressure": "Bodendruck",
"var_surface_pressure_short": "Psfc",
"var_uv": "UV-Index",
"var_uv_short": "UV",
"var_visibility": "Sichtweite",
"var_visibility_short": "Sicht",
"var_cape": "CAPE",
"var_cape_short": "CAPE",
"var_time": "Zeit"
} }
+52 -1
View File
@@ -152,5 +152,56 @@
"calm_3": "Next to no wind all day.", "calm_3": "Next to no wind all day.",
"uv_1": "UV peaks at {value} ({label}), so cover up around midday.", "uv_1": "UV peaks at {value} ({label}), so cover up around midday.",
"uv_2": "The sun bites around noon: UV {value}, {label}.", "uv_2": "The sun bites around noon: UV {value}, {label}.",
"uv_3": "Worth some shade in the middle of the day - UV reaches {value} ({label})." "uv_3": "Worth some shade in the middle of the day - UV reaches {value} ({label}).",
"legal_about": "About",
"legal_imprint": "Imprint",
"legal_privacy": "Privacy",
"legal_terms": "Terms",
"legal_nav": "Legal",
"city_weather": "{city} weather",
"var_temperature": "Temperature",
"var_temperature_short": "Temp",
"var_icons": "Weather icons",
"var_icons_short": "Icons",
"var_apparent": "Apparent Temp",
"var_apparent_short": "Feels",
"var_dew_point": "Dew Point",
"var_dew_point_short": "Dew",
"var_cloud": "Cloud Cover",
"var_cloud_short": "Cloud",
"var_cloud_low": "Cloud Cover Low",
"var_cloud_low_short": "Low",
"var_cloud_mid": "Cloud Cover Mid",
"var_cloud_mid_short": "Mid",
"var_cloud_high": "Cloud Cover High",
"var_cloud_high_short": "High",
"var_precipitation": "Precipitation",
"var_precipitation_short": "Precip",
"var_pop": "Precip. Probability",
"var_pop_short": "PoP",
"var_rain": "Rain",
"var_rain_short": "Rain",
"var_showers": "Showers",
"var_showers_short": "Shwr",
"var_snowfall": "Snowfall",
"var_snowfall_short": "Snow",
"var_wind": "Wind Speed",
"var_wind_short": "Wind",
"var_wind_dir": "Wind direction",
"var_wind_dir_short": "Dir",
"var_gusts": "Wind Gusts",
"var_gusts_short": "Gusts",
"var_humidity": "Humidity",
"var_humidity_short": "RH",
"var_pressure": "Pressure (MSL)",
"var_pressure_short": "MSLP",
"var_surface_pressure": "Surface Pressure",
"var_surface_pressure_short": "Psfc",
"var_uv": "UV Index",
"var_uv_short": "UV",
"var_visibility": "Visibility",
"var_visibility_short": "Vis",
"var_cape": "CAPE",
"var_cape_short": "CAPE",
"var_time": "Time"
} }
+52 -1
View File
@@ -152,5 +152,56 @@
"calm_3": "Prácticamente sin viento en todo el día.", "calm_3": "Prácticamente sin viento en todo el día.",
"uv_1": "El índice UV llega a {value} ({label}), así que protégete al mediodía.", "uv_1": "El índice UV llega a {value} ({label}), así que protégete al mediodía.",
"uv_2": "El sol aprieta al mediodía: UV {value}, {label}.", "uv_2": "El sol aprieta al mediodía: UV {value}, {label}.",
"uv_3": "Conviene buscar sombra al mediodía: el UV alcanza {value} ({label})." "uv_3": "Conviene buscar sombra al mediodía: el UV alcanza {value} ({label}).",
"legal_about": "Acerca de",
"legal_imprint": "Aviso legal",
"legal_privacy": "Privacidad",
"legal_terms": "Condiciones",
"legal_nav": "Legal",
"city_weather": "El tiempo en {city}",
"var_temperature": "Temperatura",
"var_temperature_short": "Temp",
"var_icons": "Iconos",
"var_icons_short": "Iconos",
"var_apparent": "Sensación térmica",
"var_apparent_short": "Sensación",
"var_dew_point": "Punto de rocío",
"var_dew_point_short": "Rocío",
"var_cloud": "Nubosidad",
"var_cloud_short": "Nubes",
"var_cloud_low": "Nubosidad baja",
"var_cloud_low_short": "Baja",
"var_cloud_mid": "Nubosidad media",
"var_cloud_mid_short": "Media",
"var_cloud_high": "Nubosidad alta",
"var_cloud_high_short": "Alta",
"var_precipitation": "Precipitación",
"var_precipitation_short": "Precip.",
"var_pop": "Prob. de precipitación",
"var_pop_short": "Prob.",
"var_rain": "Lluvia",
"var_rain_short": "Lluvia",
"var_showers": "Chubascos",
"var_showers_short": "Chub.",
"var_snowfall": "Nevadas",
"var_snowfall_short": "Nieve",
"var_wind": "Velocidad del viento",
"var_wind_short": "Viento",
"var_wind_dir": "Dirección del viento",
"var_wind_dir_short": "Dir.",
"var_gusts": "Rachas de viento",
"var_gusts_short": "Rachas",
"var_humidity": "Humedad",
"var_humidity_short": "HR",
"var_pressure": "Presión (MSL)",
"var_pressure_short": "MSLP",
"var_surface_pressure": "Presión en superficie",
"var_surface_pressure_short": "Psfc",
"var_uv": "Índice UV",
"var_uv_short": "UV",
"var_visibility": "Visibilidad",
"var_visibility_short": "Vis.",
"var_cape": "CAPE",
"var_cape_short": "CAPE",
"var_time": "Hora"
} }
+52 -1
View File
@@ -152,5 +152,56 @@
"calm_3": "Quasiment aucun vent de la journée.", "calm_3": "Quasiment aucun vent de la journée.",
"uv_1": "Indice UV jusqu'à {value} ({label}) : protégez-vous en milieu de journée.", "uv_1": "Indice UV jusqu'à {value} ({label}) : protégez-vous en milieu de journée.",
"uv_2": "Le soleil tape à la mi-journée : UV {value}, {label}.", "uv_2": "Le soleil tape à la mi-journée : UV {value}, {label}.",
"uv_3": "Mieux vaut chercher l'ombre à midi : l'UV atteint {value} ({label})." "uv_3": "Mieux vaut chercher l'ombre à midi : l'UV atteint {value} ({label}).",
"legal_about": "À propos",
"legal_imprint": "Mentions légales",
"legal_privacy": "Confidentialité",
"legal_terms": "Conditions",
"legal_nav": "Informations légales",
"city_weather": "Météo à {city}",
"var_temperature": "Température",
"var_temperature_short": "Temp",
"var_icons": "Icônes météo",
"var_icons_short": "Icônes",
"var_apparent": "Ressenti",
"var_apparent_short": "Ressenti",
"var_dew_point": "Point de rosée",
"var_dew_point_short": "Rosée",
"var_cloud": "Nébulosité",
"var_cloud_short": "Nuages",
"var_cloud_low": "Nébulosité basse",
"var_cloud_low_short": "Basse",
"var_cloud_mid": "Nébulosité moyenne",
"var_cloud_mid_short": "Moyenne",
"var_cloud_high": "Nébulosité haute",
"var_cloud_high_short": "Haute",
"var_precipitation": "Précipitations",
"var_precipitation_short": "Précip.",
"var_pop": "Probabilité de précip.",
"var_pop_short": "Prob.",
"var_rain": "Pluie",
"var_rain_short": "Pluie",
"var_showers": "Averses",
"var_showers_short": "Averses",
"var_snowfall": "Chutes de neige",
"var_snowfall_short": "Neige",
"var_wind": "Vitesse du vent",
"var_wind_short": "Vent",
"var_wind_dir": "Direction du vent",
"var_wind_dir_short": "Dir.",
"var_gusts": "Rafales",
"var_gusts_short": "Rafales",
"var_humidity": "Humidité",
"var_humidity_short": "HR",
"var_pressure": "Pression (MSL)",
"var_pressure_short": "MSLP",
"var_surface_pressure": "Pression au sol",
"var_surface_pressure_short": "Psfc",
"var_uv": "Indice UV",
"var_uv_short": "UV",
"var_visibility": "Visibilité",
"var_visibility_short": "Vis.",
"var_cape": "CAPE",
"var_cape_short": "CAPE",
"var_time": "Heure"
} }
+52 -1
View File
@@ -152,5 +152,56 @@
"calm_3": "Praticamente senza vento per tutto il giorno.", "calm_3": "Praticamente senza vento per tutto il giorno.",
"uv_1": "Indice UV fino a {value} ({label}): proteggiti a metà giornata.", "uv_1": "Indice UV fino a {value} ({label}): proteggiti a metà giornata.",
"uv_2": "Il sole picchia a mezzogiorno: UV {value}, {label}.", "uv_2": "Il sole picchia a mezzogiorno: UV {value}, {label}.",
"uv_3": "Meglio cercare ombra a metà giornata: l'UV arriva a {value} ({label})." "uv_3": "Meglio cercare ombra a metà giornata: l'UV arriva a {value} ({label}).",
"legal_about": "Informazioni",
"legal_imprint": "Note legali",
"legal_privacy": "Privacy",
"legal_terms": "Condizioni",
"legal_nav": "Informazioni legali",
"city_weather": "Meteo a {city}",
"var_temperature": "Temperatura",
"var_temperature_short": "Temp",
"var_icons": "Icone meteo",
"var_icons_short": "Icone",
"var_apparent": "Percepita",
"var_apparent_short": "Percepita",
"var_dew_point": "Punto di rugiada",
"var_dew_point_short": "Rugiada",
"var_cloud": "Nuvolosità",
"var_cloud_short": "Nuvole",
"var_cloud_low": "Nuvolosità bassa",
"var_cloud_low_short": "Bassa",
"var_cloud_mid": "Nuvolosità media",
"var_cloud_mid_short": "Media",
"var_cloud_high": "Nuvolosità alta",
"var_cloud_high_short": "Alta",
"var_precipitation": "Precipitazioni",
"var_precipitation_short": "Precip.",
"var_pop": "Probabilità di precip.",
"var_pop_short": "Prob.",
"var_rain": "Pioggia",
"var_rain_short": "Pioggia",
"var_showers": "Rovesci",
"var_showers_short": "Rovesci",
"var_snowfall": "Nevicate",
"var_snowfall_short": "Neve",
"var_wind": "Velocità del vento",
"var_wind_short": "Vento",
"var_wind_dir": "Direzione del vento",
"var_wind_dir_short": "Dir.",
"var_gusts": "Raffiche",
"var_gusts_short": "Raffiche",
"var_humidity": "Umidità",
"var_humidity_short": "UR",
"var_pressure": "Pressione (MSL)",
"var_pressure_short": "MSLP",
"var_surface_pressure": "Pressione al suolo",
"var_surface_pressure_short": "Psfc",
"var_uv": "Indice UV",
"var_uv_short": "UV",
"var_visibility": "Visibilità",
"var_visibility_short": "Vis.",
"var_cape": "CAPE",
"var_cape_short": "CAPE",
"var_time": "Ora"
} }
-20
View File
@@ -1,20 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch();
const out = {};
for (const [label, vp, mobile] of [['mobile', { width: 390, height: 844 }, true], ['desktop', { width: 1280, height: 950 }, false]]) {
const ctx = await browser.newContext({ viewport: vp, isMobile: mobile, hasTouch: mobile });
const page = await ctx.newPage();
await page.goto('http://localhost:5186/en/weather/week/berlin/', { waitUntil: 'load', timeout: 120000 });
await page.waitForTimeout(14000);
out[label] = await page.evaluate(() => ({
rows: [...document.querySelectorAll('tr')].map((r) => ({
label: r.querySelector('.hdr')?.textContent?.trim().replace(/\s+/g, ' ').slice(0, 10) || '-',
h: Math.round(r.getBoundingClientRect().height)
})),
tableHeight: Math.round(document.querySelector('table').getBoundingClientRect().height)
}));
await page.screenshot({ path: `/tmp/rows-${label}.png`, clip: { x: 0, y: 0, width: vp.width, height: Math.min(820, vp.height) } });
await ctx.close();
}
console.log(JSON.stringify(out, null, 1));
await browser.close();
-15
View File
@@ -1,15 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 950 } });
await page.goto('http://localhost:5186/en/weather/week/berlin/', { waitUntil: 'load', timeout: 120000 });
await page.waitForTimeout(14000);
// scroll right down to the footer and click a link there
await page.evaluate(() => { const m = document.querySelector('main'); m.scrollTop = m.scrollHeight; });
await page.waitForTimeout(700);
const before = await page.evaluate(() => document.querySelector('main').scrollTop);
await page.locator('footer a[href*="/weather/14-day"]').first().click();
await page.waitForTimeout(7000);
const after = await page.evaluate(() => document.querySelector('main').scrollTop);
console.log(JSON.stringify({ scrollBeforeClick: Math.round(before), scrollAfterNav: Math.round(after), url: page.url() }));
await browser.close();
@@ -0,0 +1,29 @@
<script lang="ts">
import { page } from '$app/stores';
import { getLocale } from '$lib/paraglide/runtime';
import type { Component } from 'svelte';
/**
* Picks the content component for the current locale.
*
* Long-form pages (about, imprint, privacy, terms) are kept as one component
* per language rather than as message strings: the prose is full of inline
* links, lists and headings, and splitting that into placeholders makes both
* the source and the translations harder to read and to keep correct.
*/
interface Props {
variants: Record<string, Component>;
}
let { variants }: Props = $props();
let Content = $derived.by(() => {
// the URL decides the locale, so re-resolve on navigation
void $page.url.pathname;
return variants[getLocale()] ?? variants.en;
});
</script>
<Content />
+6 -6
View File
@@ -89,7 +89,7 @@
<a <a
class="text-foreground/80 underline-offset-2 hover:text-foreground hover:underline" class="text-foreground/80 underline-offset-2 hover:text-foreground hover:underline"
href={href('/weather/week/[location]', { location: city.slug })} href={href('/weather/week/[location]', { location: city.slug })}
>{city.label} weather</a >{m.city_weather({ city: city.label })}</a
> >
</li> </li>
{/each} {/each}
@@ -102,21 +102,21 @@
class="mt-10 flex flex-col items-start justify-between gap-3 border-t border-border/70 pt-5 text-xs text-muted-foreground sm:flex-row sm:items-center" class="mt-10 flex flex-col items-start justify-between gap-3 border-t border-border/70 pt-5 text-xs text-muted-foreground sm:flex-row sm:items-center"
> >
<span>© {year} Drizz.li</span> <span>© {year} Drizz.li</span>
<nav aria-label="Legal" class="flex flex-wrap items-center gap-x-5 gap-y-1"> <nav aria-label={m.legal_nav()} class="flex flex-wrap items-center gap-x-5 gap-y-1">
<a class="underline-offset-2 hover:text-foreground hover:underline" href={href('/about')} <a class="underline-offset-2 hover:text-foreground hover:underline" href={href('/about')}
>About</a >{m.legal_about()}</a
> >
<a <a
class="underline-offset-2 hover:text-foreground hover:underline" class="underline-offset-2 hover:text-foreground hover:underline"
href={href('/legal/imprint')}>Imprint</a href={href('/legal/imprint')}>{m.legal_imprint()}</a
> >
<a <a
class="underline-offset-2 hover:text-foreground hover:underline" class="underline-offset-2 hover:text-foreground hover:underline"
href={href('/legal/privacy')}>Privacy</a href={href('/legal/privacy')}>{m.legal_privacy()}</a
> >
<a <a
class="underline-offset-2 hover:text-foreground hover:underline" class="underline-offset-2 hover:text-foreground hover:underline"
href={href('/legal/terms')}>Terms</a href={href('/legal/terms')}>{m.legal_terms()}</a
> >
</nav> </nav>
</div> </div>
+16 -3
View File
@@ -1,3 +1,16 @@
<svelte:head> <script lang="ts">
<title>Drizzli</title> import { onMount } from 'svelte';
</svelte:head>
import { goto } from '$app/navigation';
import { localizeHref } from '$lib/paraglide/runtime';
// Deliberately a client-side redirect, not one from `load`: this page is
// prerendered, so a redirect resolved at build time would bake in the base
// locale and every visitor would land on English. Deciding it here lets the
// locale strategy read the URL, the cookie and finally the browser's own
// languages before choosing.
onMount(() => {
goto(localizeHref('/weather/week/'), { replaceState: true });
});
</script>
-11
View File
@@ -1,11 +0,0 @@
import { redirect } from '@sveltejs/kit';
import { localizeHref } from '$lib/paraglide/runtime';
import type { PageLoad } from './$types';
export const load = (async () => {
// bare `/` lands in the locale the strategy resolved (URL, cookie, then the
// browser's own languages), not always English
throw redirect(303, localizeHref('/weather/week/'));
}) satisfies PageLoad;
+8 -38
View File
@@ -1,41 +1,11 @@
<script lang="ts"> <script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte'; import LocalizedContent from '$lib/components/localized-content.svelte';
import de from './content/de.svelte';
import en from './content/en.svelte';
import es from './content/es.svelte';
import fr from './content/fr.svelte';
import it from './content/it.svelte';
</script> </script>
<ProsePage title="About Drizz.li" subtitle="A small, open-source weather site."> <LocalizedContent variants={{ en, de, es, fr, it }} />
<p>
Drizz.li is an independent weather site. It shows multi-model forecasts, model comparisons,
14-day outlooks, weather maps and historical weather - without accounts, ads or trackers.
</p>
<h2>How it works</h2>
<p>
All weather data comes from the excellent open-data APIs of
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, which aggregates
national weather services (DWD, NOAA, Météo-France, MeteoSwiss and more). Drizz.li is a static
site: forecasts are fetched directly by your browser and rendered on your device.
</p>
<p>
The site is open source. Anyone can read the code, report issues or contribute improvements.
</p>
<h2>Supporting the project</h2>
<p>
Drizz.li is free to use. If you find it useful, you can support it with a small monthly
contribution (from €3 / month) - it helps cover the domain, hosting and development time. As a
thank-you, supporters unlock the extras: historical weather with climate-normal comparisons, and
new supporter features as they land.
</p>
<p>
Contributions are handled by a simple bank transfer - no payment processor, no stored card
details. The details of how that works are in the
<a href="/legal/terms/">supporter terms</a> and the
<a href="/legal/privacy/">privacy policy</a>.
</p>
<h2>Contact</h2>
<p>
Questions, feedback or bug reports:
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</p>
</ProsePage>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
import { href } from '$lib/i18n';
</script>
<ProsePage title="Über Drizz.li" subtitle="Eine kleine, quelloffene Wetterseite.">
<p>
Drizz.li ist eine unabhängige Wetterseite. Sie zeigt Vorhersagen mehrerer Modelle,
Modellvergleiche, 14-Tage-Aussichten, Wetterkarten und historisches Wetter - ohne Konten,
Werbung oder Tracker.
</p>
<h2>So funktioniert es</h2>
<p>
Alle Wetterdaten stammen aus den hervorragenden Open-Data-Schnittstellen von
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, die nationale
Wetterdienste bündeln (DWD, NOAA, Météo-France, MeteoSchweiz und weitere). Drizz.li ist eine
statische Seite: Die Vorhersagen werden direkt von Ihrem Browser abgerufen und auf Ihrem Gerät
dargestellt.
</p>
<p>
Die Seite ist quelloffen. Jede und jeder kann den Code lesen, Fehler melden oder Verbesserungen
beitragen.
</p>
<h2>Das Projekt unterstützen</h2>
<p>
Drizz.li ist kostenlos nutzbar. Wenn es Ihnen nützt, können Sie es mit einem kleinen monatlichen
Beitrag unterstützen (ab 3 € / Monat) - das hilft, Domain, Hosting und Entwicklungszeit zu
decken. Als Dankeschön schalten Unterstützerinnen und Unterstützer die Extras frei: historisches
Wetter mit Vergleich zu den Klimanormalen sowie neue Funktionen, sobald sie erscheinen.
</p>
<p>
Beiträge laufen über eine einfache Banküberweisung - kein Zahlungsdienstleister, keine
gespeicherten Kartendaten. Wie das genau abläuft, steht in den
<a href={href('/legal/terms')}>Unterstützerbedingungen</a> und in der
<a href={href('/legal/privacy')}>Datenschutzerklärung</a>.
</p>
<h2>Kontakt</h2>
<p>
Fragen, Rückmeldungen oder Fehlerberichte:
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</p>
</ProsePage>
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
import { href } from '$lib/i18n';
</script>
<ProsePage title="About Drizz.li" subtitle="A small, open-source weather site.">
<p>
Drizz.li is an independent weather site. It shows multi-model forecasts, model comparisons,
14-day outlooks, weather maps and historical weather - without accounts, ads or trackers.
</p>
<h2>How it works</h2>
<p>
All weather data comes from the excellent open-data APIs of
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, which aggregates
national weather services (DWD, NOAA, Météo-France, MeteoSwiss and more). Drizz.li is a static
site: forecasts are fetched directly by your browser and rendered on your device.
</p>
<p>
The site is open source. Anyone can read the code, report issues or contribute improvements.
</p>
<h2>Supporting the project</h2>
<p>
Drizz.li is free to use. If you find it useful, you can support it with a small monthly
contribution (from €3 / month) - it helps cover the domain, hosting and development time. As a
thank-you, supporters unlock the extras: historical weather with climate-normal comparisons, and
new supporter features as they land.
</p>
<p>
Contributions are handled by a simple bank transfer - no payment processor, no stored card
details. The details of how that works are in the
<a href={href('/legal/terms')}>supporter terms</a> and the
<a href={href('/legal/privacy')}>privacy policy</a>.
</p>
<h2>Contact</h2>
<p>
Questions, feedback or bug reports:
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</p>
</ProsePage>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
import { href } from '$lib/i18n';
</script>
<ProsePage title="Sobre Drizz.li" subtitle="Un pequeño sitio meteorológico de código abierto.">
<p>
Drizz.li es un sitio meteorológico independiente. Muestra pronósticos de varios modelos,
comparaciones entre modelos, previsiones a 14 días, mapas meteorológicos y datos históricos, sin
cuentas, sin anuncios y sin rastreadores.
</p>
<h2>Cómo funciona</h2>
<p>
Todos los datos proceden de las excelentes API de datos abiertos de
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, que agrega los
servicios meteorológicos nacionales (DWD, NOAA, Météo-France, MeteoSwiss y más). Drizz.li es un
sitio estático: tu navegador descarga los pronósticos directamente y los representa en tu
dispositivo.
</p>
<p>
El sitio es de código abierto. Cualquiera puede leer el código, informar de errores o contribuir
con mejoras.
</p>
<h2>Apoyar el proyecto</h2>
<p>
Drizz.li es gratuito. Si te resulta útil, puedes apoyarlo con una pequeña aportación mensual
(desde 3 € al mes): ayuda a cubrir el dominio, el alojamiento y el tiempo de desarrollo. Como
agradecimiento, quienes colaboran desbloquean los extras: clima histórico con comparación frente
a las normales climáticas y las nuevas funciones que vayan llegando.
</p>
<p>
Las aportaciones se gestionan mediante una simple transferencia bancaria: sin pasarela de pago y
sin guardar datos de tarjetas. Los detalles están en las
<a href={href('/legal/terms')}>condiciones para colaboradores</a> y en la
<a href={href('/legal/privacy')}>política de privacidad</a>.
</p>
<h2>Contacto</h2>
<p>
Dudas, comentarios o informes de errores:
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</p>
</ProsePage>
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
import { href } from '$lib/i18n';
</script>
<ProsePage title="À propos de Drizz.li" subtitle="Un petit site météo open source.">
<p>
Drizz.li est un site météo indépendant. Il propose des prévisions multi-modèles, des
comparaisons de modèles, des tendances à 14 jours, des cartes météo et des données historiques,
sans compte, sans publicité et sans traceurs.
</p>
<h2>Comment ça marche</h2>
<p>
Toutes les données proviennent des excellentes API ouvertes d'<a
href="https://open-meteo.com"
target="_blank"
rel="noopener">Open-Meteo</a
>, qui agrègent les services météorologiques nationaux (DWD, NOAA, Météo-France, MeteoSwiss et
d'autres). Drizz.li est un site statique : les prévisions sont récupérées directement par votre
navigateur et affichées sur votre appareil.
</p>
<p>
Le site est open source. Chacun peut lire le code, signaler un problème ou proposer des
améliorations.
</p>
<h2>Soutenir le projet</h2>
<p>
Drizz.li est gratuit. S'il vous est utile, vous pouvez le soutenir par une petite contribution
mensuelle (à partir de 3 € par mois) : elle aide à couvrir le nom de domaine, l'hébergement et
le temps de développement. En remerciement, les contributeurs débloquent les bonus : la météo
historique avec comparaison aux normales climatiques, ainsi que les nouvelles fonctions à venir.
</p>
<p>
Les contributions passent par un simple virement bancaire : aucun prestataire de paiement,
aucune donnée de carte conservée. Le détail figure dans les
<a href={href('/legal/terms')}>conditions contributeur</a> et dans la
<a href={href('/legal/privacy')}>politique de confidentialité</a>.
</p>
<h2>Contact</h2>
<p>
Questions, retours ou signalements de bugs :
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</p>
</ProsePage>
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
import { href } from '$lib/i18n';
</script>
<ProsePage title="Informazioni su Drizz.li" subtitle="Un piccolo sito meteo open source.">
<p>
Drizz.li è un sito meteo indipendente. Offre previsioni multi-modello, confronti tra modelli,
tendenze a 14 giorni, mappe meteo e dati storici, senza account, senza pubblicità e senza
tracciatori.
</p>
<h2>Come funziona</h2>
<p>
Tutti i dati provengono dalle ottime API aperte di
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, che aggregano i
servizi meteorologici nazionali (DWD, NOAA, Météo-France, MeteoSwiss e altri). Drizz.li è un
sito statico: le previsioni vengono scaricate direttamente dal tuo browser e disegnate sul tuo
dispositivo.
</p>
<p>
Il sito è open source. Chiunque può leggere il codice, segnalare problemi o contribuire con
miglioramenti.
</p>
<h2>Sostenere il progetto</h2>
<p>
Drizz.li è gratuito. Se lo trovi utile, puoi sostenerlo con un piccolo contributo mensile (da 3
€ al mese): aiuta a coprire dominio, hosting e tempo di sviluppo. Come ringraziamento, chi
sostiene il progetto sblocca gli extra: meteo storico con confronto rispetto alle normali
climatiche e le nuove funzioni man mano che arrivano.
</p>
<p>
I contributi avvengono con un semplice bonifico bancario: nessun gestore di pagamenti, nessun
dato della carta conservato. I dettagli sono nelle
<a href={href('/legal/terms')}>condizioni per i sostenitori</a> e nell'<a
href={href('/legal/privacy')}>informativa sulla privacy</a
>.
</p>
<h2>Contatti</h2>
<p>
Domande, riscontri o segnalazioni di bug:
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</p>
</ProsePage>
+13
View File
@@ -0,0 +1,13 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { localizeHref } from '$lib/paraglide/runtime';
// Same reasoning as the root redirect: resolved in the browser so the
// visitor's language is known (see routes/+page.svelte).
onMount(() => {
goto(localizeHref('/weather/week/'), { replaceState: true });
});
</script>
-10
View File
@@ -1,10 +0,0 @@
import { redirect } from '@sveltejs/kit';
import { localizeHref } from '$lib/paraglide/runtime';
import type { PageLoad } from './$types';
export const load = (async () => {
// keep the visitor in their language when the bare /weather path is hit
throw redirect(303, localizeHref('/weather/week/'));
}) satisfies PageLoad;
@@ -173,15 +173,38 @@
// Apply ?day= once the forecast is in: the parameter is a plain calendar date, // Apply ?day= once the forecast is in: the parameter is a plain calendar date,
// which only means something against the location's own timezone. // which only means something against the location's own timezone.
let appliedDayParam = false; // A `?day=` outside what this page can ever show (more than 3 days back, or
// beyond the 15-day horizon) is dropped and we open on today. A day that IS
// reachable but sits outside the default 7-day window widens the request up
// front, so the link loads straight into the right day instead of showing
// today and making the visitor click.
let wantedDay: string | null = $state(null);
onMount(() => {
const param = get(page).url.searchParams.get('day');
if (!param || !/^\d{4}-\d{2}-\d{2}$/.test(param)) return;
const target = Date.parse(`${param}T12:00:00Z`);
if (Number.isNaN(target)) return;
const today = new Date();
today.setUTCHours(12, 0, 0, 0);
const offset = Math.round((target - today.getTime()) / 86_400_000);
if (offset < -3 || offset > 15) return; // out of reach: stay on today
wantedDay = param;
if (offset < 0) pastDays = 3;
if (offset >= 7) forecastDays = 15;
});
// Select it as soon as a fetch arrives that actually covers the day.
$effect(() => { $effect(() => {
const fd = fetchedDaily; const fd = fetchedDaily;
if (appliedDayParam || !fd) return; const wanted = wantedDay;
appliedDayParam = true; if (!fd || !wanted) return;
const wanted = get(page).url.searchParams.get('day');
if (!wanted) return;
const match = fd.dailyDates.find((d) => formatZoned(d, fd.timezone, 'yyyy-MM-dd') === wanted); const match = fd.dailyDates.find((d) => formatZoned(d, fd.timezone, 'yyyy-MM-dd') === wanted);
if (match) selectedDay.setTime(match.getTime()); if (match) {
selectedDay.setTime(match.getTime());
wantedDay = null;
}
}); });
// Mirror the open day and the plotted model back into the URL. // Mirror the open day and the plotted model back into the URL.
@@ -189,8 +212,11 @@
const dayKey = selectedDayKey; const dayKey = selectedDayKey;
const model = params.models?.[0]; const model = params.models?.[0];
if (!mounted || !dayKey) return; if (!mounted || !dayKey) return;
const isToday = fetchedDaily
? dayKey === formatZoned(new Date(), fetchedDaily.timezone, 'yyyy-MM-dd')
: false;
syncSearchParams($page.url, { syncSearchParams($page.url, {
day: dayKey, day: isToday ? null : dayKey,
model: model && model !== 'best_match' ? model : null model: model && model !== 'best_match' ? model : null
}); });
}); });
@@ -235,12 +235,15 @@
return parts.join('\n'); return parts.join('\n');
} }
/** A value the model actually produced (NaN pads everything past its horizon). */
const finite = (v: number | null | undefined): v is number => v != null && Number.isFinite(v);
function formatTemp(temp: number | null): string { function formatTemp(temp: number | null): string {
return temp != null ? `${temp.toFixed(0)}°` : '-'; return finite(temp) ? `${temp.toFixed(0)}°` : '';
} }
function formatValue(val: number | null): string { function formatValue(val: number | null): string {
return val != null ? val.toFixed(0) : '-'; return finite(val) ? val.toFixed(0) : '';
} }
let dayIdx = $derived(getDayIndices(data.hourlyDates, selectedDay)); let dayIdx = $derived(getDayIndices(data.hourlyDates, selectedDay));
@@ -254,15 +257,48 @@
const isNow = const isNow =
formatZoned(date, tz, 'yyyy-MM-dd HH') === formatZoned(today, tz, 'yyyy-MM-dd HH'); formatZoned(date, tz, 'yyyy-MM-dd HH') === formatZoned(today, tz, 'yyyy-MM-dd HH');
// a column past the model's horizon: nothing to show in any row
const hasData = [
data.hourly.temperature_2m,
data.hourly.weather_code,
data.hourly.windspeed_10m,
data.hourly.relative_humidity_2m,
data.hourly.cloud_cover,
data.hourly.precipitation
].some((arr) => arr != null && Number.isFinite(arr[idx]));
return { return {
idx, idx,
date, date,
isNow, isNow,
isDaytime: daytimeFlags[i] isDaytime: daytimeFlags[i],
hasData
}; };
}) })
); );
// A model that doesn't carry a variable returns an empty or all-NaN array.
// Those rows are dimmed rather than shown as a wall of dashes.
let rowHasData = $derived.by((): Record<string, boolean> => {
const has = (arr: number[] | undefined) =>
arr != null && cellData.some((c) => finite(arr[c.idx]));
return {
icons: has(data.hourly.weather_code),
temperature: has(data.hourly.temperature_2m),
feels: has(data.hourly.apparent_temperature),
dew_point: has(data.hourly.dew_point_2m),
wind: has(data.hourly.windspeed_10m),
gusts: has(data.hourly.wind_gusts_10m),
humidity: has(data.hourly.relative_humidity_2m),
clouds: has(data.hourly.cloud_cover),
pressure: has(data.hourly.pressure_msl),
uv: has(data.hourly.uv_index),
visibility: has(data.hourly.visibility),
precipitation: has(data.hourly.precipitation),
snowfall: has(data.hourly.snowfall)
};
});
let sunrisePercent = $derived( let sunrisePercent = $derived(
sunTimes && cellData.length > 0 ? timeToFraction(sunTimes.sunrise) * 100 : null sunTimes && cellData.length > 0 ? timeToFraction(sunTimes.sunrise) * 100 : null
); );
@@ -475,7 +511,7 @@
<!-- Time + Daylight bar (merged) --> <!-- Time + Daylight bar (merged) -->
<tr class="row"> <tr class="row">
<th class="hdr" scope="row" bind:clientWidth={headerColWidth}> <th class="hdr" scope="row" bind:clientWidth={headerColWidth}>
<span class="text-[10px] font-semibold text-muted-foreground">Time</span> <span class="text-[10px] font-semibold text-muted-foreground">{m.var_time()}</span>
</th> </th>
<td <td
colspan={cellData.length} colspan={cellData.length}
@@ -556,6 +592,7 @@
{@const leftPct = (i / cellData.length) * 100} {@const leftPct = (i / cellData.length) * 100}
{@const widthPct = 100 / cellData.length} {@const widthPct = 100 / cellData.length}
<span <span
class:col-empty={!cell.hasData}
class="absolute top-0 flex items-start pl-1 font-bold {is3h class="absolute top-0 flex items-start pl-1 font-bold {is3h
? 'pt-2 text-sm' ? 'pt-2 text-sm'
: 'pt-2.5'} : 'pt-2.5'}
@@ -591,12 +628,14 @@
<!-- Weather Icons --> <!-- Weather Icons -->
{#each rowOrder as rowKey (rowKey)} {#each rowOrder as rowKey (rowKey)}
{#if rowKey === 'icons' && showRow('icons')} {#if rowKey === 'icons' && showRow('icons')}
<tr class="row"> <tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-day-cloudy')} {@render rowHeader('wi-day-cloudy')}
{#each cellData as cell, i (cell.idx)} {#each cellData as cell, i (cell.idx)}
{@const wCode = hourly.weather_code[cell.idx]} {@const wCode = hourly.weather_code[cell.idx]}
<td <td
class:col-empty={!cell.hasData}
class="cell h-12 leading-0" class="cell h-12 leading-0"
class:cell-empty={!finite(wCode)}
class:icon-day={cell.isDaytime} class:icon-day={cell.isDaytime}
class:icon-night={!cell.isDaytime} class:icon-night={!cell.isDaytime}
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime} class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
@@ -604,20 +643,23 @@
cellData[i + 1] && cellData[i + 1] &&
!cellData[i + 1].isDaytime} !cellData[i + 1].isDaytime}
> >
{#if finite(wCode)}
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)} {@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
{/if}
</td> </td>
{/each} {/each}
</tr> </tr>
<!-- Temperature --> <!-- Temperature -->
{:else if rowKey === 'temperature' && showRow('temperature')} {:else if rowKey === 'temperature' && showRow('temperature')}
<tr class="row"> <tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-thermometer', tempUnit, 'Temp')} {@render rowHeader('wi-thermometer', tempUnit, m.var_temperature_short())}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const temp = hourly.temperature_2m[cell.idx]} {@const temp = hourly.temperature_2m[cell.idx]}
{@const style = getTempStyle(temp, String(units.temperature_unit))} {@const style = getTempStyle(temp, String(units.temperature_unit))}
<td <td
class:col-empty={!cell.hasData}
class="cell h-12 font-bold {is3h ? 'text-lg' : 'text-[15px]'}" class="cell h-12 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
style="background-color:{style.bg};color:{style.fg}" style={finite(temp) ? `background-color:${style.bg};color:${style.fg}` : ''}
> >
{formatTemp(temp)} {formatTemp(temp)}
</td> </td>
@@ -625,11 +667,12 @@
</tr> </tr>
<!-- Feels Like (short row) --> <!-- Feels Like (short row) -->
{:else if rowKey === 'feels' && showRow('feels')} {:else if rowKey === 'feels' && showRow('feels')}
<tr class="row"> <tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader(undefined, tempUnit, 'Feels')} {@render rowHeader(undefined, tempUnit, m.var_apparent_short())}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const temp = hourly.apparent_temperature[cell.idx]} {@const temp = hourly.apparent_temperature[cell.idx]}
<td <td
class:col-empty={!cell.hasData}
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}" class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
> >
{formatTemp(temp)} {formatTemp(temp)}
@@ -638,11 +681,12 @@
</tr> </tr>
<!-- Dew Point (short row; off by default) --> <!-- Dew Point (short row; off by default) -->
{:else if rowKey === 'dew_point' && showRow('dew_point')} {:else if rowKey === 'dew_point' && showRow('dew_point')}
<tr class="row"> <tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-raindrop', tempUnit, 'Dew')} {@render rowHeader('wi-raindrop', tempUnit, m.var_dew_point_short())}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const temp = hourly.dew_point_2m?.[cell.idx]} {@const temp = hourly.dew_point_2m?.[cell.idx]}
<td <td
class:col-empty={!cell.hasData}
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}" class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
> >
{formatTemp(temp)} {formatTemp(temp)}
@@ -651,12 +695,15 @@
</tr> </tr>
<!-- Wind --> <!-- Wind -->
{:else if rowKey === 'wind' && showRow('wind')} {:else if rowKey === 'wind' && showRow('wind')}
<tr class="row"> <tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-strong-wind', windUnit, 'Wind')} {@render rowHeader('wi-strong-wind', windUnit, m.var_wind_short())}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const wind = hourly.windspeed_10m[cell.idx]} {@const wind = hourly.windspeed_10m[cell.idx]}
{@const windDir = hourly.winddirection_10m[cell.idx]} {@const windDir = hourly.winddirection_10m[cell.idx]}
<td class="relative cell h-12 align-middle leading-none"> <td
class:col-empty={!cell.hasData}
class="relative cell h-12 align-middle leading-none"
>
{#if windDir != null && !isNaN(windDir)} {#if windDir != null && !isNaN(windDir)}
<span <span
class="absolute top-0 left-1/2 inline-block origin-center leading-0" class="absolute top-0 left-1/2 inline-block origin-center leading-0"
@@ -673,11 +720,12 @@
</tr> </tr>
<!-- Wind Gusts (off by default) --> <!-- Wind Gusts (off by default) -->
{:else if rowKey === 'gusts' && showRow('gusts')} {:else if rowKey === 'gusts' && showRow('gusts')}
<tr class="row"> <tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-strong-wind', windUnit, 'Gusts')} {@render rowHeader('wi-strong-wind', windUnit, m.var_gusts_short())}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const v = hourly.wind_gusts_10m?.[cell.idx]} {@const v = hourly.wind_gusts_10m?.[cell.idx]}
<td <td
class:col-empty={!cell.hasData}
class="cell h-12 font-semibold text-foreground/80 {is3h class="cell h-12 font-semibold text-foreground/80 {is3h
? 'text-sm' ? 'text-sm'
: 'text-xs'}" : 'text-xs'}"
@@ -688,24 +736,31 @@
</tr> </tr>
<!-- Humidity (short row) --> <!-- Humidity (short row) -->
{:else if rowKey === 'humidity' && showRow('humidity')} {:else if rowKey === 'humidity' && showRow('humidity')}
<tr class="row"> <tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-humidity', '%', 'Humidity')} {@render rowHeader('wi-humidity', '%', m.var_humidity())}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const hum = hourly.relative_humidity_2m[cell.idx]} {@const hum = hourly.relative_humidity_2m[cell.idx]}
<td class="cell h-12" style="background:{getHumidityBg(hum ?? 0)}"> <td
class:col-empty={!cell.hasData}
class="cell h-12"
style={finite(hum) ? `background:${getHumidityBg(hum)}` : ''}
>
{formatValue(hum)} {formatValue(hum)}
</td> </td>
{/each} {/each}
</tr> </tr>
<!-- Cloud Cover --> <!-- Cloud Cover -->
{:else if rowKey === 'clouds' && showRow('clouds')} {:else if rowKey === 'clouds' && showRow('clouds')}
<tr class="row"> <tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-cloud', '%', 'Clouds')} {@render rowHeader('wi-cloud', '%', m.var_cloud_short())}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const cloud = hourly.cloud_cover[cell.idx]} {@const cloud = hourly.cloud_cover[cell.idx]}
<td <td
class:col-empty={!cell.hasData}
class="cell h-12" class="cell h-12"
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})" style={finite(cloud)
? `background:rgba(140,160,180,${getCloudOpacity(cloud)})`
: ''}
> >
{formatValue(cloud)} {formatValue(cloud)}
</td> </td>
@@ -713,11 +768,12 @@
</tr> </tr>
<!-- Pressure (off by default) --> <!-- Pressure (off by default) -->
{:else if rowKey === 'pressure' && showRow('pressure')} {:else if rowKey === 'pressure' && showRow('pressure')}
<tr class="row"> <tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-barometer', 'hPa', 'Pressure')} {@render rowHeader('wi-barometer', 'hPa', m.var_pressure_short())}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const v = hourly.pressure_msl?.[cell.idx]} {@const v = hourly.pressure_msl?.[cell.idx]}
<td <td
class:col-empty={!cell.hasData}
class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}" class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}"
style="background:{getPressureBg(v ?? null)}" style="background:{getPressureBg(v ?? null)}"
> >
@@ -727,11 +783,12 @@
</tr> </tr>
<!-- UV Index (off by default) --> <!-- UV Index (off by default) -->
{:else if rowKey === 'uv' && showRow('uv')} {:else if rowKey === 'uv' && showRow('uv')}
<tr class="row"> <tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-day-sunny', 'UV', 'UV')} {@render rowHeader('wi-day-sunny', 'UV', m.var_uv_short())}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const v = hourly.uv_index?.[cell.idx]} {@const v = hourly.uv_index?.[cell.idx]}
<td <td
class:col-empty={!cell.hasData}
class="cell h-12 font-semibold {is3h ? 'text-sm' : 'text-xs'}" class="cell h-12 font-semibold {is3h ? 'text-sm' : 'text-xs'}"
style="background:{getUvBg(v ?? null)}" style="background:{getUvBg(v ?? null)}"
> >
@@ -741,23 +798,27 @@
</tr> </tr>
<!-- Visibility (off by default) --> <!-- Visibility (off by default) -->
{:else if rowKey === 'visibility' && showRow('visibility')} {:else if rowKey === 'visibility' && showRow('visibility')}
<tr class="row"> <tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-fog', 'km', 'Visibility')} {@render rowHeader('wi-fog', 'km', m.var_visibility_short())}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const v = hourly.visibility?.[cell.idx]} {@const v = hourly.visibility?.[cell.idx]}
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}"> <td
class:col-empty={!cell.hasData}
class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}"
>
{v != null && !isNaN(v) ? (v / 1000).toFixed(0) : '-'} {v != null && !isNaN(v) ? (v / 1000).toFixed(0) : '-'}
</td> </td>
{/each} {/each}
</tr> </tr>
<!-- Precipitation --> <!-- Precipitation -->
{:else if rowKey === 'precipitation' && showRow('precipitation')} {:else if rowKey === 'precipitation' && showRow('precipitation')}
<tr class="row"> <tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-raindrop', precipUnit, 'Precip')} {@render rowHeader('wi-raindrop', precipUnit, m.var_precipitation_short())}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const precip = hourly.precipitation[cell.idx]} {@const precip = hourly.precipitation[cell.idx]}
{@const prob = hourly.precipitation_probability[cell.idx]} {@const prob = hourly.precipitation_probability[cell.idx]}
<td <td
class:col-empty={!cell.hasData}
class="cell precip-cell h-12" class="cell precip-cell h-12"
style="background:{getPrecipProbBg(prob ?? 0)}" style="background:{getPrecipProbBg(prob ?? 0)}"
title={formatPrecipTooltip(precip, prob)} title={formatPrecipTooltip(precip, prob)}
@@ -773,11 +834,14 @@
</tr> </tr>
<!-- Snowfall (off by default) --> <!-- Snowfall (off by default) -->
{:else if rowKey === 'snowfall' && showRow('snowfall')} {:else if rowKey === 'snowfall' && showRow('snowfall')}
<tr class="row"> <tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-snow', 'cm', 'Snow')} {@render rowHeader('wi-snow', 'cm', m.var_snowfall_short())}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const v = hourly.snowfall?.[cell.idx]} {@const v = hourly.snowfall?.[cell.idx]}
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}"> <td
class:col-empty={!cell.hasData}
class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}"
>
{v != null && !isNaN(v) && v > 0 ? v.toFixed(1) : '-'} {v != null && !isNaN(v) && v > 0 ? v.toFixed(1) : '-'}
</td> </td>
{/each} {/each}
@@ -853,6 +917,24 @@
overflow: hidden; overflow: hidden;
} }
/* A row the selected model carries no data for: legible, clearly inactive,
and not competing with the rows that do have values. */
.row-empty {
opacity: 0.4;
}
/* An individual cell past the point where the model stopped: drop the
day/night tinting so the gap reads as "no data" rather than clear sky. */
.cell-empty {
background: none !important;
}
/* A whole column the model never reached - dimmed end to end, hour label
included, so the point where the forecast runs out is obvious. */
.col-empty {
opacity: 0.35;
}
/* ── Precipitation ──────────────────────────────────────────── */ /* ── Precipitation ──────────────────────────────────────────── */
.precip-cell { .precip-cell {
position: relative; position: relative;
+46 -44
View File
@@ -6,6 +6,8 @@
*/ */
import { defaultVariablePrefs } from '$lib/stores/settings'; import { defaultVariablePrefs } from '$lib/stores/settings';
import * as m from '$lib/paraglide/messages';
import { getColor } from '../../utils/colors'; import { getColor } from '../../utils/colors';
import { import {
type WeatherUnits, type WeatherUnits,
@@ -69,8 +71,8 @@ export interface ChartVariableDef {
export const CHART_VARIABLES: ChartVariableDef[] = [ export const CHART_VARIABLES: ChartVariableDef[] = [
{ {
key: 'temperature', key: 'temperature',
label: 'Temperature', label: m.var_temperature(),
short: 'Temp', short: m.var_temperature_short(),
field: 'temperature_2m', field: 'temperature_2m',
type: 'line', type: 'line',
kind: 'temp', kind: 'temp',
@@ -83,8 +85,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'weather_icons', key: 'weather_icons',
label: 'Weather icons', label: m.var_icons(),
short: 'Icons', short: m.var_icons_short(),
field: 'weather_code', field: 'weather_code',
type: 'line', type: 'line',
kind: 'temp', kind: 'temp',
@@ -94,8 +96,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'apparent_temperature', key: 'apparent_temperature',
label: 'Apparent Temp', label: m.var_apparent(),
short: 'Feels', short: m.var_apparent_short(),
field: 'apparent_temperature', field: 'apparent_temperature',
type: 'line', type: 'line',
kind: 'temp', kind: 'temp',
@@ -105,8 +107,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'dew_point', key: 'dew_point',
label: 'Dew Point', label: m.var_dew_point(),
short: 'Dew', short: m.var_dew_point_short(),
field: 'dew_point_2m', field: 'dew_point_2m',
type: 'line', type: 'line',
kind: 'temp', kind: 'temp',
@@ -115,8 +117,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'cloud_cover', key: 'cloud_cover',
label: 'Cloud Cover', label: m.var_cloud(),
short: 'Cloud', short: m.var_cloud_short(),
field: 'cloud_cover', field: 'cloud_cover',
type: 'line', type: 'line',
kind: 'percent', kind: 'percent',
@@ -128,8 +130,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
// the way the layers actually look from below. // the way the layers actually look from below.
{ {
key: 'cloud_cover_low', key: 'cloud_cover_low',
label: 'Cloud Cover Low', label: m.var_cloud_low(),
short: 'Low', short: m.var_cloud_low_short(),
field: 'cloud_cover_low', field: 'cloud_cover_low',
type: 'line', type: 'line',
kind: 'percent', kind: 'percent',
@@ -139,8 +141,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'cloud_cover_mid', key: 'cloud_cover_mid',
label: 'Cloud Cover Mid', label: m.var_cloud_mid(),
short: 'Mid', short: m.var_cloud_mid_short(),
field: 'cloud_cover_mid', field: 'cloud_cover_mid',
type: 'line', type: 'line',
kind: 'percent', kind: 'percent',
@@ -150,8 +152,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'cloud_cover_high', key: 'cloud_cover_high',
label: 'Cloud Cover High', label: m.var_cloud_high(),
short: 'High', short: m.var_cloud_high_short(),
field: 'cloud_cover_high', field: 'cloud_cover_high',
type: 'line', type: 'line',
kind: 'percent', kind: 'percent',
@@ -161,8 +163,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'precipitation', key: 'precipitation',
label: 'Precipitation', label: m.var_precipitation(),
short: 'Precip', short: m.var_precipitation_short(),
field: 'precipitation', field: 'precipitation',
type: 'bar', type: 'bar',
kind: 'precip', kind: 'precip',
@@ -170,8 +172,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'precipitation_probability', key: 'precipitation_probability',
label: 'Precip. Probability', label: m.var_pop(),
short: 'PoP', short: m.var_pop_short(),
field: 'precipitation_probability', field: 'precipitation_probability',
type: 'line', type: 'line',
kind: 'percent', kind: 'percent',
@@ -181,8 +183,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'rain', key: 'rain',
label: 'Rain', label: m.var_rain(),
short: 'Rain', short: m.var_rain_short(),
field: 'rain', field: 'rain',
type: 'bar', type: 'bar',
kind: 'precip', kind: 'precip',
@@ -190,8 +192,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'showers', key: 'showers',
label: 'Showers', label: m.var_showers(),
short: 'Shwr', short: m.var_showers_short(),
field: 'showers', field: 'showers',
type: 'bar', type: 'bar',
kind: 'precip', kind: 'precip',
@@ -199,8 +201,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'snowfall', key: 'snowfall',
label: 'Snowfall', label: m.var_snowfall(),
short: 'Snow', short: m.var_snowfall_short(),
field: 'snowfall', field: 'snowfall',
type: 'bar', type: 'bar',
kind: 'snow', kind: 'snow',
@@ -208,8 +210,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'wind', key: 'wind',
label: 'Wind Speed', label: m.var_wind(),
short: 'Wind', short: m.var_wind_short(),
field: 'windspeed_10m', field: 'windspeed_10m',
api: 'wind_speed_10m', api: 'wind_speed_10m',
type: 'line', type: 'line',
@@ -221,8 +223,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'wind_direction', key: 'wind_direction',
label: 'Wind direction', label: m.var_wind_dir(),
short: 'Dir', short: m.var_wind_dir_short(),
field: 'winddirection_10m', field: 'winddirection_10m',
api: 'wind_direction_10m', api: 'wind_direction_10m',
type: 'line', type: 'line',
@@ -233,8 +235,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'wind_gusts', key: 'wind_gusts',
label: 'Wind Gusts', label: m.var_gusts(),
short: 'Gusts', short: m.var_gusts_short(),
field: 'wind_gusts_10m', field: 'wind_gusts_10m',
type: 'line', type: 'line',
kind: 'wind', kind: 'wind',
@@ -244,8 +246,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'humidity', key: 'humidity',
label: 'Humidity', label: m.var_humidity(),
short: 'RH', short: m.var_humidity_short(),
field: 'relative_humidity_2m', field: 'relative_humidity_2m',
type: 'line', type: 'line',
kind: 'percent', kind: 'percent',
@@ -255,8 +257,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'pressure_msl', key: 'pressure_msl',
label: 'Pressure (MSL)', label: m.var_pressure(),
short: 'MSLP', short: m.var_pressure_short(),
field: 'pressure_msl', field: 'pressure_msl',
type: 'line', type: 'line',
kind: 'pressure', kind: 'pressure',
@@ -265,8 +267,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'surface_pressure', key: 'surface_pressure',
label: 'Surface Pressure', label: m.var_surface_pressure(),
short: 'Psfc', short: m.var_surface_pressure_short(),
field: 'surface_pressure', field: 'surface_pressure',
type: 'line', type: 'line',
kind: 'pressure', kind: 'pressure',
@@ -276,8 +278,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'uv_index', key: 'uv_index',
label: 'UV Index', label: m.var_uv(),
short: 'UV', short: m.var_uv_short(),
field: 'uv_index', field: 'uv_index',
type: 'line', type: 'line',
kind: 'uv', kind: 'uv',
@@ -288,8 +290,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'visibility', key: 'visibility',
label: 'Visibility', label: m.var_visibility(),
short: 'Vis', short: m.var_visibility_short(),
field: 'visibility', field: 'visibility',
type: 'line', type: 'line',
kind: 'distance', kind: 'distance',
@@ -299,8 +301,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
}, },
{ {
key: 'cape', key: 'cape',
label: 'CAPE', label: m.var_cape(),
short: 'CAPE', short: m.var_cape_short(),
field: 'cape', field: 'cape',
type: 'line', type: 'line',
kind: 'energy', kind: 'energy',