more fluent texts

This commit is contained in:
Vincent van der Wal
2026-08-01 15:27:26 +02:00
parent 774afa6c65
commit 0d5dbbc61f
18 changed files with 694 additions and 201 deletions
+46 -16
View File
@@ -101,26 +101,56 @@
"period_morning": "morgens",
"period_afternoon": "nachmittags",
"period_evening": "in den Abendstunden",
"forecast_sky_all_day": "Den ganzen Tag {condition}.",
"forecast_sky_two": "{p1} {c1}, {p2} {c2}.",
"forecast_sky_three": "{p1} {c1}, {p2} {c2}, {p3} dann {c3}.",
"forecast_temp": "Höchstwerte um {high}, nachts Abkühlung auf {low}.",
"forecast_temp_feels": "Höchstwerte um {high}, nachts Abkühlung auf {low} - gefühlt eher {feels}.",
"forecast_precip_window": "Rund {amount} Niederschlag, das meiste davon {when}.",
"forecast_precip_spread": "Rund {amount} Niederschlag über den Tag verteilt.",
"forecast_precip_chance": "Überwiegend trocken, mit bis zu {percent}% Schauerwahrscheinlichkeit.",
"forecast_precip_dry": "Es bleibt durchweg trocken.",
"forecast_wind_dir": "Wind aus {direction} mit bis zu {speed}.",
"forecast_wind_dir_gusts": "Wind aus {direction} mit bis zu {speed}, in Böen {gust}.",
"forecast_wind": "Wind mit bis zu {speed}.",
"forecast_wind_gusts": "Wind mit bis zu {speed}, in Böen {gust}.",
"forecast_uv": "UV-Index bis {value} ({label}) - mittags an Sonnenschutz denken.",
"forecast_calm": "Kaum ein Lüftchen.",
"footer_tagline": "Schnelle, unkomplizierte Wettervorhersagen auf Basis offener Daten.",
"footer_data_by": "Wetterdaten von",
"footer_forecasts": "Vorhersagen",
"footer_popular": "Beliebte Orte",
"footer_about_section": "Über",
"model_weather": "Wettermodell",
"model_ensemble": "Ensemblemodell"
"model_ensemble": "Ensemblemodell",
"sky_all_1": "Den ganzen Tag {condition}.",
"sky_all_2": "Ein durchweg {condition} Tag.",
"sky_all_3": "Es bleibt durchgehend {condition}.",
"sky_two_1": "{p1} {c1}, {p2} dann {c2}.",
"sky_two_2": "Der Tag beginnt {p1} {c1}, bevor es {p2} {c2} wird.",
"sky_two_3": "{p1} {c1}, {p2} dann {c2}.",
"sky_three_1": "{p1} {c1}, {p2} {c2} und {p3} dann {c3}.",
"sky_three_2": "Es beginnt {p1} {c1}, wird {p2} {c2} und endet {p3} {c3}.",
"sky_three_3": "{p1} {c1}, {p2} {c2}, {p3} schließlich {c3}.",
"temp_1": "Höchstwerte um {high}, nachts Abkühlung auf {low}.",
"temp_2": "Die Temperaturen steigen auf {high} und fallen nach Einbruch der Dunkelheit auf {low}.",
"temp_3": "Zwischen {low} und {high} im Tagesverlauf.",
"temp_feels_1": "Höchstwerte um {high}, nachts {low} - gefühlt eher {feels}.",
"temp_feels_2": "Bis {high} am Thermometer, gefühlt {feels}, bevor es auf {low} zurückgeht.",
"temp_feels_3": "Es werden {high} erreicht, gefühlt sind es {feels}, später Abkühlung auf {low}.",
"precip_window_1": "Rund {amount} Niederschlag, das meiste davon {when}.",
"precip_window_2": "Zu erwarten sind etwa {amount}, konzentriert {when}.",
"precip_window_3": "Am nassesten wird es {when}, insgesamt rund {amount}.",
"precip_spread_1": "Rund {amount} Niederschlag über den Tag verteilt.",
"precip_spread_2": "Etwa {amount} fallen in Schüben über den Tag.",
"precip_spread_3": "Immer wieder Schauer, zusammen etwa {amount}.",
"precip_chance_1": "Überwiegend trocken, mit bis zu {percent}% Schauerwahrscheinlichkeit.",
"precip_chance_2": "Kaum Regen zu erwarten, die Schauerwahrscheinlichkeit liegt bei {percent}%.",
"precip_chance_3": "{percent}% Schauerrisiko, aber kaum der Rede wert.",
"precip_dry_1": "Es bleibt durchweg trocken.",
"precip_dry_2": "Kein Tropfen in Sicht.",
"precip_dry_3": "Von früh bis spät trocken.",
"wind_dir_1": "Wind aus {direction} mit bis zu {speed}.",
"wind_dir_2": "Eine Brise aus {direction} erreicht in der Spitze {speed}.",
"wind_dir_3": "Der Wind kommt aus {direction} und erreicht Spitzen um {speed}.",
"wind_dir_gusts_1": "Wind aus {direction} mit bis zu {speed}, in Böen {gust}.",
"wind_dir_gusts_2": "Ein {direction}-Wind von {speed}, in Böen bis {gust}.",
"wind_dir_gusts_3": "Aus {direction} sind {speed} zu erwarten, zeitweise Böen von {gust}.",
"wind_1": "Wind mit bis zu {speed}.",
"wind_2": "Die Brise erreicht rund {speed}.",
"wind_3": "Luftbewegung von bis zu {speed}.",
"wind_gusts_1": "Wind mit bis zu {speed}, in Böen {gust}.",
"wind_gusts_2": "Bis zu {speed}, mit Böen von {gust}.",
"wind_gusts_3": "Ein böiger Tag: {speed} im Mittel, {gust} in der Spitze.",
"calm_1": "Kaum ein Lüftchen.",
"calm_2": "Die Luft bleibt nahezu still.",
"calm_3": "Den ganzen Tag so gut wie windstill.",
"uv_1": "UV-Index bis {value} ({label}) - mittags an Sonnenschutz denken.",
"uv_2": "Mittags brennt die Sonne: UV {value}, {label}.",
"uv_3": "Mittags besser in den Schatten - der UV-Index erreicht {value} ({label})."
}
+46 -16
View File
@@ -101,26 +101,56 @@
"period_morning": "in the morning",
"period_afternoon": "in the afternoon",
"period_evening": "in the evening",
"forecast_sky_all_day": "{condition} all day.",
"forecast_sky_two": "{c1} {p1}, turning {c2} {p2}.",
"forecast_sky_three": "{c1} {p1}, turning {c2} {p2}, then {c3} {p3}.",
"forecast_temp": "Highs near {high}, dropping to {low} overnight.",
"forecast_temp_feels": "Highs near {high}, dropping to {low} overnight, though it will feel closer to {feels}.",
"forecast_precip_window": "Around {amount} of precipitation, most of it {when}.",
"forecast_precip_spread": "Around {amount} of precipitation spread across the day.",
"forecast_precip_chance": "Mostly dry, with up to a {percent}% chance of catching a shower.",
"forecast_precip_dry": "Staying dry throughout.",
"forecast_wind_dir": "Wind from the {direction} reaching {speed}.",
"forecast_wind_dir_gusts": "Wind from the {direction} reaching {speed}, with gusts to {gust}.",
"forecast_wind": "Wind reaching {speed}.",
"forecast_wind_gusts": "Wind reaching {speed}, with gusts to {gust}.",
"forecast_uv": "UV peaks at {value} ({label}), so cover up around midday.",
"forecast_calm": "Barely a breath of wind.",
"footer_tagline": "Fast, no-nonsense weather forecasts built on open data.",
"footer_data_by": "Weather data by",
"footer_forecasts": "Forecasts",
"footer_popular": "Popular locations",
"footer_about_section": "About",
"model_weather": "Weather model",
"model_ensemble": "Ensemble model"
"model_ensemble": "Ensemble model",
"sky_all_1": "{condition} all day.",
"sky_all_2": "A {condition} day from start to finish.",
"sky_all_3": "It stays {condition} throughout.",
"sky_two_1": "{c1} {p1}, turning {c2} {p2}.",
"sky_two_2": "The day opens {c1} {p1} before turning {c2} {p2}.",
"sky_two_3": "Expect {c1} skies {p1}, then {c2} {p2}.",
"sky_three_1": "{c1} {p1}, turning {c2} {p2}, then {c3} {p3}.",
"sky_three_2": "It starts {c1} {p1}, becomes {c2} {p2} and ends up {c3} {p3}.",
"sky_three_3": "From {c1} skies {p1} to {c2} {p2}, before settling {c3} {p3}.",
"temp_1": "Highs near {high}, dropping to {low} overnight.",
"temp_2": "Temperatures climb to {high} and fall back to {low} after dark.",
"temp_3": "Between {low} and {high} through the day.",
"temp_feels_1": "Highs near {high}, down to {low} overnight, though it feels closer to {feels}.",
"temp_feels_2": "Up to {high} on the thermometer - closer to {feels} in the air - before easing to {low}.",
"temp_feels_3": "It reaches {high} but feels more like {feels}, cooling to {low} later.",
"precip_window_1": "Around {amount} of precipitation, most of it {when}.",
"precip_window_2": "Expect about {amount}, concentrated {when}.",
"precip_window_3": "The wettest stretch is {when}, adding up to roughly {amount}.",
"precip_spread_1": "Around {amount} of precipitation spread across the day.",
"precip_spread_2": "Roughly {amount} falls in bursts through the day.",
"precip_spread_3": "Showers come and go, totalling about {amount}.",
"precip_chance_1": "Mostly dry, with up to a {percent}% chance of catching a shower.",
"precip_chance_2": "Little rain expected, though there is a {percent}% chance of a passing shower.",
"precip_chance_3": "A {percent}% shower risk, but nothing worth an umbrella.",
"precip_dry_1": "Staying dry throughout.",
"precip_dry_2": "Not a drop expected.",
"precip_dry_3": "Dry from start to finish.",
"wind_dir_1": "Wind from the {direction} reaching {speed}.",
"wind_dir_2": "A breeze out of the {direction} tops out at {speed}.",
"wind_dir_3": "The wind sits in the {direction}, peaking near {speed}.",
"wind_dir_gusts_1": "Wind from the {direction} reaching {speed}, with gusts to {gust}.",
"wind_dir_gusts_2": "Wind out of the {direction} at {speed}, gusting as high as {gust}.",
"wind_dir_gusts_3": "Expect {speed} from the {direction}, gusting {gust} at times.",
"wind_1": "Wind reaching {speed}.",
"wind_2": "The breeze peaks around {speed}.",
"wind_3": "Air moving at up to {speed}.",
"wind_gusts_1": "Wind reaching {speed}, with gusts to {gust}.",
"wind_gusts_2": "Up to {speed}, with gusts punching through at {gust}.",
"wind_gusts_3": "A gusty day: {speed} sustained, {gust} at the peaks.",
"calm_1": "Barely a breath of wind.",
"calm_2": "The air stays almost still.",
"calm_3": "Next to no wind all day.",
"uv_1": "UV peaks at {value} ({label}), so cover up around midday.",
"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})."
}
+46 -16
View File
@@ -101,26 +101,56 @@
"period_morning": "por la mañana",
"period_afternoon": "por la tarde",
"period_evening": "al anochecer",
"forecast_sky_all_day": "{condition} todo el día.",
"forecast_sky_two": "{c1} {p1} y {c2} {p2}.",
"forecast_sky_three": "{c1} {p1}, {c2} {p2} y {c3} {p3}.",
"forecast_temp": "Máximas de {high} y mínimas de {low} por la noche.",
"forecast_temp_feels": "Máximas de {high} y mínimas de {low} por la noche, aunque la sensación será de {feels}.",
"forecast_precip_window": "Unos {amount} de precipitación, sobre todo {when}.",
"forecast_precip_spread": "Unos {amount} de precipitación repartidos a lo largo del día.",
"forecast_precip_chance": "Mayormente seco, con hasta un {percent}% de probabilidad de chubascos.",
"forecast_precip_dry": "Se mantendrá seco todo el día.",
"forecast_wind_dir": "Viento del {direction} de hasta {speed}.",
"forecast_wind_dir_gusts": "Viento del {direction} de hasta {speed}, con rachas de {gust}.",
"forecast_wind": "Viento de hasta {speed}.",
"forecast_wind_gusts": "Viento de hasta {speed}, con rachas de {gust}.",
"forecast_uv": "El índice UV llega a {value} ({label}), así que protégete al mediodía.",
"forecast_calm": "Apenas hay viento.",
"footer_tagline": "Pronósticos rápidos y directos basados en datos abiertos.",
"footer_data_by": "Datos meteorológicos de",
"footer_forecasts": "Pronósticos",
"footer_popular": "Ubicaciones populares",
"footer_about_section": "Acerca de",
"model_weather": "Modelo meteorológico",
"model_ensemble": "Modelo de conjunto"
"model_ensemble": "Modelo de conjunto",
"sky_all_1": "{condition} todo el día.",
"sky_all_2": "Un día {condition} de principio a fin.",
"sky_all_3": "Se mantiene {condition} durante toda la jornada.",
"sky_two_1": "{c1} {p1} y {c2} {p2}.",
"sky_two_2": "La jornada empieza {c1} {p1} antes de volverse {c2} {p2}.",
"sky_two_3": "Se espera cielo {c1} {p1} y {c2} {p2}.",
"sky_three_1": "{c1} {p1}, {c2} {p2} y {c3} {p3}.",
"sky_three_2": "Empieza {c1} {p1}, se vuelve {c2} {p2} y acaba {c3} {p3}.",
"sky_three_3": "De cielo {c1} {p1} a {c2} {p2}, para quedarse {c3} {p3}.",
"temp_1": "Máximas de {high} y mínimas de {low} por la noche.",
"temp_2": "Las temperaturas suben hasta {high} y bajan a {low} tras el anochecer.",
"temp_3": "Entre {low} y {high} a lo largo del día.",
"temp_feels_1": "Máximas de {high} y mínimas de {low}, aunque la sensación será de {feels}.",
"temp_feels_2": "Hasta {high} en el termómetro, aunque se sentirán {feels}, antes de bajar a {low}.",
"temp_feels_3": "Alcanza {high} pero se sentirá como {feels}, con descenso a {low} más tarde.",
"precip_window_1": "Unos {amount} de precipitación, sobre todo {when}.",
"precip_window_2": "Se esperan unos {amount}, concentrados {when}.",
"precip_window_3": "El tramo más húmedo será {when}, con un total aproximado de {amount}.",
"precip_spread_1": "Unos {amount} de precipitación repartidos a lo largo del día.",
"precip_spread_2": "Aproximadamente {amount} caen a intervalos durante el día.",
"precip_spread_3": "Chubascos intermitentes que suman unos {amount}.",
"precip_chance_1": "Mayormente seco, con hasta un {percent}% de probabilidad de chubascos.",
"precip_chance_2": "Apenas se espera lluvia, aunque hay un {percent}% de probabilidad de un chubasco pasajero.",
"precip_chance_3": "Un {percent}% de riesgo de chubascos, pero nada que exija paraguas.",
"precip_dry_1": "Se mantendrá seco todo el día.",
"precip_dry_2": "No se espera ni una gota.",
"precip_dry_3": "Seco de principio a fin.",
"wind_dir_1": "Viento del {direction} de hasta {speed}.",
"wind_dir_2": "La brisa del {direction} alcanza los {speed}.",
"wind_dir_3": "El viento sopla del {direction}, con picos cercanos a {speed}.",
"wind_dir_gusts_1": "Viento del {direction} de hasta {speed}, con rachas de {gust}.",
"wind_dir_gusts_2": "Un viento del {direction} de {speed} con rachas de hasta {gust}.",
"wind_dir_gusts_3": "Se esperan {speed} del {direction}, con rachas puntuales de {gust}.",
"wind_1": "Viento de hasta {speed}.",
"wind_2": "La brisa alcanza unos {speed}.",
"wind_3": "Aire en movimiento de hasta {speed}.",
"wind_gusts_1": "Viento de hasta {speed}, con rachas de {gust}.",
"wind_gusts_2": "Hasta {speed}, con rachas que llegan a {gust}.",
"wind_gusts_3": "Un día racheado: {speed} sostenidos y {gust} en los picos.",
"calm_1": "Apenas hay viento.",
"calm_2": "El aire se mantiene casi en calma.",
"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_2": "El sol aprieta al mediodía: UV {value}, {label}.",
"uv_3": "Conviene buscar sombra al mediodía: el UV alcanza {value} ({label})."
}
+46 -16
View File
@@ -101,26 +101,56 @@
"period_morning": "le matin",
"period_afternoon": "l'après-midi",
"period_evening": "en soirée",
"forecast_sky_all_day": "{condition} toute la journée.",
"forecast_sky_two": "{c1} {p1}, puis {c2} {p2}.",
"forecast_sky_three": "{c1} {p1}, puis {c2} {p2} et enfin {c3} {p3}.",
"forecast_temp": "Maximales proches de {high}, minimales de {low} la nuit.",
"forecast_temp_feels": "Maximales proches de {high}, minimales de {low} la nuit, mais ressenti plutôt {feels}.",
"forecast_precip_window": "Environ {amount} de précipitations, surtout {when}.",
"forecast_precip_spread": "Environ {amount} de précipitations réparties sur la journée.",
"forecast_precip_chance": "Temps plutôt sec, avec jusqu'à {percent}% de risque d'averse.",
"forecast_precip_dry": "Le temps reste sec toute la journée.",
"forecast_wind_dir": "Vent de {direction} atteignant {speed}.",
"forecast_wind_dir_gusts": "Vent de {direction} atteignant {speed}, avec des rafales à {gust}.",
"forecast_wind": "Vent atteignant {speed}.",
"forecast_wind_gusts": "Vent atteignant {speed}, avec des rafales à {gust}.",
"forecast_uv": "Indice UV jusqu'à {value} ({label}) : protégez-vous en milieu de journée.",
"forecast_calm": "Pratiquement pas de vent.",
"footer_tagline": "Des prévisions météo rapides et sans fioritures, basées sur des données ouvertes.",
"footer_data_by": "Données météo par",
"footer_forecasts": "Prévisions",
"footer_popular": "Lieux populaires",
"footer_about_section": "À propos",
"model_weather": "Modèle météo",
"model_ensemble": "Modèle d'ensemble"
"model_ensemble": "Modèle d'ensemble",
"sky_all_1": "{condition} toute la journée.",
"sky_all_2": "Une journée {condition} du matin au soir.",
"sky_all_3": "Le ciel reste {condition} tout du long.",
"sky_two_1": "{c1} {p1}, puis {c2} {p2}.",
"sky_two_2": "La journée débute {c1} {p1} avant de devenir {c2} {p2}.",
"sky_two_3": "Ciel {c1} {p1}, puis {c2} {p2}.",
"sky_three_1": "{c1} {p1}, puis {c2} {p2} et enfin {c3} {p3}.",
"sky_three_2": "Le ciel est {c1} {p1}, devient {c2} {p2} et finit {c3} {p3}.",
"sky_three_3": "D'un ciel {c1} {p1} à {c2} {p2}, avant de rester {c3} {p3}.",
"temp_1": "Maximales proches de {high}, minimales de {low} la nuit.",
"temp_2": "Les températures montent à {high} et retombent à {low} après la tombée de la nuit.",
"temp_3": "Entre {low} et {high} au fil de la journée.",
"temp_feels_1": "Maximales proches de {high}, minimales de {low}, mais ressenti plutôt {feels}.",
"temp_feels_2": "Jusqu'à {high} au thermomètre, ressenti {feels}, avant de redescendre à {low}.",
"temp_feels_3": "Il fera {high} mais on ressentira {feels}, avec {low} plus tard.",
"precip_window_1": "Environ {amount} de précipitations, surtout {when}.",
"precip_window_2": "Comptez environ {amount}, concentrés {when}.",
"precip_window_3": "Le passage le plus humide se situe {when}, pour environ {amount} au total.",
"precip_spread_1": "Environ {amount} de précipitations réparties sur la journée.",
"precip_spread_2": "Environ {amount} tombent par intermittence au cours de la journée.",
"precip_spread_3": "Des averses vont et viennent, pour un total d'environ {amount}.",
"precip_chance_1": "Temps plutôt sec, avec jusqu'à {percent}% de risque d'averse.",
"precip_chance_2": "Peu de pluie attendue, mais {percent}% de risque d'une averse passagère.",
"precip_chance_3": "{percent}% de risque d'averse, rien qui justifie un parapluie.",
"precip_dry_1": "Le temps reste sec toute la journée.",
"precip_dry_2": "Pas une goutte attendue.",
"precip_dry_3": "Sec du début à la fin.",
"wind_dir_1": "Vent de secteur {direction} atteignant {speed}.",
"wind_dir_2": "Une brise de secteur {direction} culmine à {speed}.",
"wind_dir_3": "Le vent s'oriente au secteur {direction}, avec des pointes proches de {speed}.",
"wind_dir_gusts_1": "Vent de secteur {direction} atteignant {speed}, avec des rafales à {gust}.",
"wind_dir_gusts_2": "Un vent de secteur {direction} de {speed}, avec des rafales jusqu'à {gust}.",
"wind_dir_gusts_3": "Attendez-vous à {speed} de secteur {direction}, avec des rafales de {gust} par moments.",
"wind_1": "Vent atteignant {speed}.",
"wind_2": "La brise culmine autour de {speed}.",
"wind_3": "Un vent allant jusqu'à {speed}.",
"wind_gusts_1": "Vent atteignant {speed}, avec des rafales à {gust}.",
"wind_gusts_2": "Jusqu'à {speed}, avec des rafales atteignant {gust}.",
"wind_gusts_3": "Une journée à rafales : {speed} en continu, {gust} en pointe.",
"calm_1": "Pratiquement pas de vent.",
"calm_2": "L'air reste quasiment immobile.",
"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_2": "Le soleil tape à la mi-journée : UV {value}, {label}.",
"uv_3": "Mieux vaut chercher l'ombre à midi : l'UV atteint {value} ({label})."
}
+46 -16
View File
@@ -101,26 +101,56 @@
"period_morning": "al mattino",
"period_afternoon": "nel pomeriggio",
"period_evening": "in serata",
"forecast_sky_all_day": "{condition} per tutta la giornata.",
"forecast_sky_two": "{c1} {p1}, poi {c2} {p2}.",
"forecast_sky_three": "{c1} {p1}, poi {c2} {p2} e infine {c3} {p3}.",
"forecast_temp": "Massime intorno a {high} e minime di {low} durante la notte.",
"forecast_temp_feels": "Massime intorno a {high} e minime di {low} durante la notte, ma con una percezione di {feels}.",
"forecast_precip_window": "Circa {amount} di precipitazioni, soprattutto {when}.",
"forecast_precip_spread": "Circa {amount} di precipitazioni distribuite nell'arco della giornata.",
"forecast_precip_chance": "Perlopiù asciutto, con una probabilità di rovesci fino al {percent}%.",
"forecast_precip_dry": "Resterà asciutto tutto il giorno.",
"forecast_wind_dir": "Vento da {direction} fino a {speed}.",
"forecast_wind_dir_gusts": "Vento da {direction} fino a {speed}, con raffiche a {gust}.",
"forecast_wind": "Vento fino a {speed}.",
"forecast_wind_gusts": "Vento fino a {speed}, con raffiche a {gust}.",
"forecast_uv": "Indice UV fino a {value} ({label}): proteggiti a metà giornata.",
"forecast_calm": "Quasi assenza di vento.",
"footer_tagline": "Previsioni meteo rapide e senza fronzoli, basate su dati aperti.",
"footer_data_by": "Dati meteo da",
"footer_forecasts": "Previsioni",
"footer_popular": "Località popolari",
"footer_about_section": "Informazioni",
"model_weather": "Modello meteo",
"model_ensemble": "Modello d'insieme"
"model_ensemble": "Modello d'insieme",
"sky_all_1": "{condition} per tutta la giornata.",
"sky_all_2": "Una giornata {condition} dall'inizio alla fine.",
"sky_all_3": "Il cielo resta {condition} per tutto il giorno.",
"sky_two_1": "{c1} {p1}, poi {c2} {p2}.",
"sky_two_2": "La giornata si apre {c1} {p1} prima di diventare {c2} {p2}.",
"sky_two_3": "Cielo {c1} {p1} e poi {c2} {p2}.",
"sky_three_1": "{c1} {p1}, poi {c2} {p2} e infine {c3} {p3}.",
"sky_three_2": "Inizia {c1} {p1}, diventa {c2} {p2} e finisce {c3} {p3}.",
"sky_three_3": "Da cielo {c1} {p1} a {c2} {p2}, per poi restare {c3} {p3}.",
"temp_1": "Massime intorno a {high} e minime di {low} durante la notte.",
"temp_2": "Le temperature salgono fino a {high} e tornano a {low} dopo il tramonto.",
"temp_3": "Tra {low} e {high} nel corso della giornata.",
"temp_feels_1": "Massime intorno a {high} e minime di {low}, ma con una percezione di {feels}.",
"temp_feels_2": "Fino a {high} sul termometro, percepiti {feels}, prima di scendere a {low}.",
"temp_feels_3": "Si arriva a {high} ma se ne percepiscono {feels}, con {low} più tardi.",
"precip_window_1": "Circa {amount} di precipitazioni, soprattutto {when}.",
"precip_window_2": "Sono attesi circa {amount}, concentrati {when}.",
"precip_window_3": "Il periodo più piovoso è {when}, per un totale di circa {amount}.",
"precip_spread_1": "Circa {amount} di precipitazioni distribuite nell'arco della giornata.",
"precip_spread_2": "Circa {amount} cadono a intermittenza durante il giorno.",
"precip_spread_3": "Rovesci a intermittenza, per un totale di circa {amount}.",
"precip_chance_1": "Perlopiù asciutto, con una probabilità di rovesci fino al {percent}%.",
"precip_chance_2": "Poca pioggia prevista, ma c'è il {percent}% di probabilità di un rovescio di passaggio.",
"precip_chance_3": "Un {percent}% di rischio rovesci, ma nulla da ombrello.",
"precip_dry_1": "Resterà asciutto tutto il giorno.",
"precip_dry_2": "Non è attesa una goccia.",
"precip_dry_3": "Asciutto dall'inizio alla fine.",
"wind_dir_1": "Vento da {direction} fino a {speed}.",
"wind_dir_2": "Una brezza da {direction} tocca i {speed}.",
"wind_dir_3": "Il vento si mantiene da {direction}, con punte vicine a {speed}.",
"wind_dir_gusts_1": "Vento da {direction} fino a {speed}, con raffiche a {gust}.",
"wind_dir_gusts_2": "Un vento da {direction} di {speed}, con raffiche fino a {gust}.",
"wind_dir_gusts_3": "Attesi {speed} da {direction}, con raffiche di {gust} a tratti.",
"wind_1": "Vento fino a {speed}.",
"wind_2": "La brezza tocca circa {speed}.",
"wind_3": "Aria in movimento fino a {speed}.",
"wind_gusts_1": "Vento fino a {speed}, con raffiche a {gust}.",
"wind_gusts_2": "Fino a {speed}, con raffiche che arrivano a {gust}.",
"wind_gusts_3": "Giornata ventosa: {speed} costanti, {gust} nelle raffiche.",
"calm_1": "Quasi assenza di vento.",
"calm_2": "L'aria resta quasi immobile.",
"calm_3": "Praticamente senza vento per tutto il giorno.",
"uv_1": "Indice UV fino a {value} ({label}): proteggiti a metà giornata.",
"uv_2": "Il sole picchia a mezzogiorno: UV {value}, {label}.",
"uv_3": "Meglio cercare ombra a metà giornata: l'UV arriva a {value} ({label})."
}
+3 -3
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import * as Popover from '$lib/components/ui/popover';
import { page } from '$app/stores';
import LanguageOptions from '$lib/components/language-options.svelte';
import * as Popover from '$lib/components/ui/popover';
import * as m from '$lib/paraglide/messages';
import { getLocale } from '$lib/paraglide/runtime';
import { page } from '$app/stores';
// the URL decides the locale, so re-read it on navigation
let current = $derived.by(() => {
void $page.url.pathname;
+23
View File
@@ -0,0 +1,23 @@
import { writable } from 'svelte/store';
/**
* Whether the current page has enough data on screen to be worth revealing.
*
* The weather pages fetch their forecast *after* the route swap, so a plain
* navigation transition would cross-fade one skeleton into another and then cut
* hard to the real content. The layout waits on this instead, so the fade-in
* lines up with the page actually being loaded.
*/
export const pageContentReady = writable(true);
/**
* Declare a page's readiness. Pass a getter for "my data has arrived"; the flag
* is released when the page unmounts, so a page that never reports readiness
* (static content) is revealed immediately.
*/
export function reportPageReady(isReady: () => boolean): void {
$effect(() => {
pageContentReady.set(isReady());
return () => pageContentReady.set(true);
});
}
+45 -14
View File
@@ -1,24 +1,55 @@
import { tick } from 'svelte';
const prefersReducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/**
* Fades a block back in whenever the value passed to it changes - used to make
* a day switch visible in the hourly table, the written forecast and the
* meteograms.
* Runs a day change inside a view transition, so the outgoing day is still on
* screen while the incoming one fades in - a real cross-fade rather than the
* old content vanishing and the new one fading up from nothing.
*
* It animates the existing node instead of remounting it: the meteograms hold
* canvas charts with their own zoom state, and rebuilding those on every day
* change would be both expensive and lossy.
* Only the regions that actually change carry a `view-transition-name` (see
* `.day-region-*` in routes/layout.css); everything else - the strip, the
* header, the page chrome - is pinned by the `day-switch` class so it stays
* completely still.
*/
export async function runDayTransition(update: () => void): Promise<void> {
if (prefersReducedMotion() || !document.startViewTransition) {
update();
return;
}
const root = document.documentElement;
root.classList.add('day-switch');
try {
// Svelte applies the change on the next tick; the transition has to wait
// for that before it snapshots the new state.
await document.startViewTransition(async () => {
update();
await tick();
}).finished;
} catch {
/* a superseded transition is fine - the DOM is already up to date */
} finally {
root.classList.remove('day-switch');
}
}
/**
* Fallback for browsers without view transitions: fade the block back in when
* the value passed to it changes. Animates the existing node rather than
* remounting it, so the canvas charts keep their zoom state.
*/
export function daySwap(node: HTMLElement, key: unknown) {
let current = key;
const play = () => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
node.animate(
[
{ opacity: 0.15, transform: 'translateY(4px)' },
{ opacity: 1, transform: 'translateY(0)' }
],
{ duration: 280, easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)' }
);
// view transitions handle it properly where they exist
if (typeof document.startViewTransition === 'function' || prefersReducedMotion()) return;
node.animate([{ opacity: 0.1 }, { opacity: 1 }], {
duration: 460,
easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)'
});
};
return {
+46
View File
@@ -0,0 +1,46 @@
/**
* Mirrors UI state into the query string so a view can be linked and reloaded
* exactly as it was: which day is open, which model is plotted, which variables
* are compared.
*
* Writes use `replaceState` rather than `goto`, so mirroring state never adds a
* history entry or re-runs a load - the back button still means "the page
* before", not "the previous day I clicked".
*/
import { browser } from '$app/environment';
import { replaceState } from '$app/navigation';
export function syncSearchParams(url: URL, updates: Record<string, string | null>): void {
if (!browser) return;
const next = new URL(url);
for (const [key, value] of Object.entries(updates)) {
if (value == null || value === '') next.searchParams.delete(key);
else next.searchParams.set(key, value);
}
if (next.href === url.href) return;
try {
replaceState(next, {});
} catch {
// A page whose state settles during mount can get here before the router
// has taken over. The URL is cosmetic, so retry on the next frame rather
// than letting it break the page.
requestAnimationFrame(() => {
try {
replaceState(next, {});
} catch {
/* give up: the view still works, it just isn't linkable yet */
}
});
}
}
/** Reads a comma-separated list, dropping empties. */
export function readList(url: URL, key: string): string[] | null {
const raw = url.searchParams.get(key);
if (!raw) return null;
const list = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return list.length > 0 ? list : null;
}
+65 -5
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { get } from 'svelte/store';
import { fade, fly } from 'svelte/transition';
import { afterNavigate, onNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { storedTheme } from '$lib/stores/settings';
@@ -13,6 +15,8 @@
import { routePath } from '$lib/i18n';
import { pageContentReady } from '$lib/stores/page-transition.svelte';
import './layout.css';
let { children } = $props();
@@ -26,21 +30,74 @@
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => {
const root = document.documentElement;
const dark = theme === 'dark' || (theme === 'system' && mq.matches);
const paint = () => root.classList.toggle('dark', dark);
// The very first application is just painting the stored theme - only
// an actual switch afterwards is worth cross-fading.
if (themeSettled) {
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!themeSettled || reduced) {
themeSettled = true;
paint();
return;
}
if (document.startViewTransition) {
// one cross-fade of the whole document; component transitions untouched
document.startViewTransition(paint);
return;
}
// no view transitions: fall back to fading the colours for one window
root.classList.add('theme-transition');
clearTimeout(themeTimer);
themeTimer = window.setTimeout(() => root.classList.remove('theme-transition'), 400);
}
themeSettled = true;
root.classList.toggle('dark', theme === 'dark' || (theme === 'system' && mq.matches));
paint();
};
apply();
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
});
// ── Page cross-fade ───────────────────────────────────────────────────────
// The weather pages fetch their forecast after the route swap, so tying the
// fade to navigation alone would cross-fade one skeleton into another and
// then cut hard to the real content. Instead the outgoing page fades out on
// navigation and the incoming one fades in once it reports that its data has
// landed - with a ceiling so a slow or silent page is never left hidden.
const FADE_OUT_MS = 170;
const REVEAL_CEILING_MS = 2500;
let contentVisible = $state(true);
let revealTimer = 0;
const reducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
onNavigate(() => {
if (reducedMotion()) return;
clearTimeout(revealTimer);
contentVisible = false;
return new Promise((resolve) => setTimeout(resolve, FADE_OUT_MS));
});
afterNavigate(() => {
if (reducedMotion()) {
contentVisible = true;
return;
}
const startedAt = Date.now();
const reveal = () => {
if (get(pageContentReady) || Date.now() - startedAt > REVEAL_CEILING_MS) {
contentVisible = true;
return;
}
revealTimer = window.setTimeout(reveal, 60);
};
reveal();
});
// the maps page embeds a full-bleed map: no padding, no scrolling
let fullBleed = $derived(routePath($page.url.pathname).startsWith('/weather/maps'));
@@ -110,7 +167,10 @@
{:else}
<!-- cap the content width on very large screens; the footer below
gives the page its ending, so only modest bottom room is needed -->
<div class="mx-auto w-full max-w-[1536px] flex-1 pb-24">
<div
class="page-fade mx-auto w-full max-w-[1536px] flex-1 pb-24"
class:page-fade-hidden={!contentVisible}
>
{@render children()}
</div>
<!-- full-bleed footer inside the scroll area (its own inner max-w),
+73 -7
View File
@@ -133,10 +133,73 @@
@apply bg-background text-foreground;
}
/* Added around a theme change only (see routes/+layout.svelte), so the whole
page cross-fades between light and dark instead of snapping. It is off the
rest of the time: a permanent global transition would smear every hover
and every chart repaint. */
/* Page cross-fade: driven from routes/+layout.svelte, revealed when the new
page reports its data has arrived. */
.page-fade {
transition: opacity 320ms ease;
}
.page-fade-hidden {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.page-fade {
transition: none;
}
.page-fade-hidden {
opacity: 1;
}
}
/* ── Day switching ────────────────────────────────────────────────────────
Only the three regions whose content depends on the selected day take part
in the cross-fade. Everything else keeps its pixels: the `day-switch`
class cancels the root animation, so the strip, header and page chrome do
not so much as flicker while the table, summary and charts swap over. */
.day-region-table {
view-transition-name: day-table;
}
.day-region-summary {
view-transition-name: day-summary;
}
.day-region-charts {
view-transition-name: day-charts;
}
::view-transition-old(day-table),
::view-transition-new(day-table),
::view-transition-old(day-summary),
::view-transition-new(day-summary),
::view-transition-old(day-charts),
::view-transition-new(day-charts) {
animation-duration: 420ms;
animation-timing-function: ease;
}
:root.day-switch::view-transition-old(root),
:root.day-switch::view-transition-new(root) {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
.day-region-table,
.day-region-summary,
.day-region-charts {
view-transition-name: none;
}
}
/* Theme changes cross-fade the whole document in one pass (see
routes/+layout.svelte). Doing it as a view transition instead of a blanket
`* { transition }` matters: that blanket rule also stretched every
component's own hover and focus transitions to 400ms for the duration of
the switch, which read as lag on interactive controls. */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 400ms;
animation-timing-function: ease;
}
/* Fallback for browsers without view transitions: fade the colours only. */
:root.theme-transition,
:root.theme-transition *,
:root.theme-transition *::before,
@@ -146,16 +209,19 @@
border-color 400ms ease,
color 400ms ease,
fill 400ms ease,
stroke 400ms ease,
box-shadow 400ms ease !important;
stroke 400ms ease;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
:root.theme-transition,
:root.theme-transition *,
:root.theme-transition *::before,
:root.theme-transition *::after {
transition: none !important;
transition: none;
}
}
}
@@ -2,8 +2,14 @@
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { page } from '$app/stores';
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { syncSearchParams } from '$lib/utils/url-state';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
@@ -38,6 +44,9 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => fetchedData != null);
useHeroActions(heroActions);
// the URL is the source of truth: location comes from the load function,
@@ -76,11 +85,19 @@
// ─── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => {
// preselect the persisted ensemble model (client-only, keeps SSR stable)
params.models = [get(storedEnsembleModel)];
// a shared link carries its model; otherwise fall back to the stored choice
const fromUrl = get(page).url.searchParams.get('model');
params.models = [fromUrl || get(storedEnsembleModel)];
mounted = true;
});
// keep the plotted ensemble in the URL
$effect(() => {
const model = params.models?.[0];
if (!mounted || !model) return;
syncSearchParams($page.url, { model });
});
// components persist across refetches; entries are null while unmounted
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
@@ -3,9 +3,14 @@
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { page } from '$app/stores';
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { formatZoned } from '$lib/utils/date';
import { readList, syncSearchParams } from '$lib/utils/url-state';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Checkbox } from '$lib/components/ui/checkbox';
@@ -53,6 +58,9 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => fetchedData != null);
useHeroActions(heroActions);
// the URL is the source of truth: location comes from the load function,
@@ -94,14 +102,34 @@
// ─── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => {
// the model chosen elsewhere on the site is always part of the comparison
// A link carries the exact comparison it was shared with; without one, the
// model chosen elsewhere on the site joins the default line-up.
const url = get(page).url;
const urlModels = readList(url, 'models');
const urlVars = readList(url, 'vars');
if (urlModels) params.models = urlModels;
else {
const selectedModel = get(storedModel);
if (selectedModel !== 'best_match' && !params.models.includes(selectedModel)) {
params.models = [selectedModel, ...params.models];
}
}
if (urlVars) params.hourly = urlVars;
mounted = true;
});
// Mirror the comparison back into the URL so it can be shared or reloaded.
$effect(() => {
const models = params.models;
const vars = params.hourly;
if (!mounted) return;
syncSearchParams($page.url, {
models: models?.length ? models.join(',') : null,
vars: vars?.length ? vars.join(',') : null
});
});
// components persist across refetches; entries are null while unmounted
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
@@ -10,6 +10,8 @@
storedVariablePrefs
} from '$lib/stores/settings';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { ChartContainer } from '$lib/components/charts';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
@@ -33,6 +35,9 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => result != null);
let location = $derived(data.location);
$effect(() => {
storedLocation.set(data.location);
@@ -4,6 +4,8 @@
import { storedLocation, storedUnits } from '$lib/stores/settings';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
@@ -29,6 +31,9 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => result != null);
useHeroActions(heroActions);
// the URL is the source of truth: location comes from the load function,
@@ -4,6 +4,8 @@
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { page } from '$app/stores';
import {
storedChartLayout,
storedLocation,
@@ -12,9 +14,12 @@
storedVariablePrefs
} from '$lib/stores/settings';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { formatZoned } from '$lib/utils/date';
import { daySwap } from '$lib/utils/day-swap';
import { daySwap, runDayTransition } from '$lib/utils/day-swap';
import { buildLocationRoute } from '$lib/utils/location';
import { syncSearchParams } from '$lib/utils/url-state';
import { ChartContainer } from '$lib/components/charts';
@@ -41,6 +46,9 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => fetchedDaily != null && fetchedHourly != null);
useHeroActions(heroActions);
let params = $state({
@@ -140,15 +148,41 @@
// Charts intentionally keep their current range: they show the full week
// unless the user narrows it via the range presets or Ctrl+scroll.
const switchDay = (date: Date) => {
selectedDay.setTime(date.getTime());
runDayTransition(() => selectedDay.setTime(date.getTime()));
};
onMount(() => {
// preselect the persisted model (client-only so prerendered HTML stays stable)
params.models = [get(storedModel)];
// the URL wins over the persisted choice, so a shared link opens on the
// same model the sender was looking at
const fromUrl = get(page).url.searchParams.get('model');
params.models = [fromUrl || get(storedModel)];
mounted = true;
});
// Apply ?day= once the forecast is in: the parameter is a plain calendar date,
// which only means something against the location's own timezone.
let appliedDayParam = false;
$effect(() => {
const fd = fetchedDaily;
if (appliedDayParam || !fd) return;
appliedDayParam = true;
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);
if (match) selectedDay.setTime(match.getTime());
});
// Mirror the open day and the plotted model back into the URL.
$effect(() => {
const dayKey = selectedDayKey;
const model = params.models?.[0];
if (!mounted || !dayKey) return;
syncSearchParams($page.url, {
day: dayKey,
model: model && model !== 'best_match' ? model : null
});
});
$effect(() => {
const loc = location;
const modelList = params.models;
@@ -328,7 +362,7 @@
/>
{#if fetchedHourly && fetchedDaily}
<div use:daySwap={selectedDayKey}>
<div class="day-region-table" use:daySwap={selectedDayKey}>
<HourlyTable
data={fetchedHourly}
daily={fetchedDaily}
@@ -371,7 +405,7 @@
{/if}
{#if fetchedHourly && fetchedDaily}
<div use:daySwap={selectedDayKey}>
<div class="day-region-summary" use:daySwap={selectedDayKey}>
<DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} />
</div>
{:else}
@@ -383,7 +417,7 @@
{/if}
{#if fetchedHourly}
<div use:daySwap={selectedDayKey}>
<div class="day-region-charts" use:daySwap={selectedDayKey}>
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
</div>
{:else}
@@ -136,6 +136,24 @@ function capitalise(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/**
* Stable pseudo-random pick. The same day always reads the same way (so the
* text doesn't churn on every re-render) while different days get different
* phrasings - that variety is what stops the summary sounding like a template.
*/
function seedFrom(key: string): number {
let h = 2166136261;
for (let i = 0; i < key.length; i++) {
h ^= key.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return Math.abs(h);
}
function pick<T>(variants: T[], seed: number, salt: number): T {
return variants[(seed + salt) % variants.length];
}
/**
* Builds the summary as a list of sentences (the caller renders them as one
* paragraph). Returns an empty list when the day has no usable data.
@@ -158,7 +176,33 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
const temp = (v: number) => `${v.toFixed(0)}${tempUnit}`;
const speed = (v: number) => `${v.toFixed(0)} ${windUnit}`;
const sentences: string[] = [];
// one seed per day, so the wording is stable for a given day but varies
// from one day (and one place) to the next
const seed = seedFrom(formatZoned(day, timezone, 'yyyy-MM-dd') + timezone);
const SKY_ALL = [m.sky_all_1, m.sky_all_2, m.sky_all_3];
const SKY_TWO = [m.sky_two_1, m.sky_two_2, m.sky_two_3];
const SKY_THREE = [m.sky_three_1, m.sky_three_2, m.sky_three_3];
const TEMP = [m.temp_1, m.temp_2, m.temp_3];
const TEMP_FEELS = [m.temp_feels_1, m.temp_feels_2, m.temp_feels_3];
const PRECIP_WINDOW = [m.precip_window_1, m.precip_window_2, m.precip_window_3];
const PRECIP_SPREAD = [m.precip_spread_1, m.precip_spread_2, m.precip_spread_3];
const PRECIP_CHANCE = [m.precip_chance_1, m.precip_chance_2, m.precip_chance_3];
const PRECIP_DRY = [m.precip_dry_1, m.precip_dry_2, m.precip_dry_3];
const WIND_DIR = [m.wind_dir_1, m.wind_dir_2, m.wind_dir_3];
const WIND_DIR_GUSTS = [m.wind_dir_gusts_1, m.wind_dir_gusts_2, m.wind_dir_gusts_3];
const WIND = [m.wind_1, m.wind_2, m.wind_3];
const WIND_GUSTS = [m.wind_gusts_1, m.wind_gusts_2, m.wind_gusts_3];
const CALM = [m.calm_1, m.calm_2, m.calm_3];
const UV = [m.uv_1, m.uv_2, m.uv_3];
// each fact becomes its own sentence; the order of the middle three varies
let sky: string | null = null;
let temperature: string | null = null;
// always set by the branch below, so no initial value to overwrite
let precipitation: string;
let wind: string | null = null;
let ultraviolet: string | null = null;
// ─── How the sky behaves through the day ────────────────────────────────────
const segments: { period: Period; category: Category }[] = [];
@@ -182,25 +226,30 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
const phrase = (r: { category: Category }) => CATEGORY_MESSAGE[r.category]();
if (runs.length === 1) {
sentences.push(capitalise(m.forecast_sky_all_day({ condition: phrase(runs[0]) })));
sky = capitalise(pick(SKY_ALL, seed, 0)({ condition: phrase(runs[0]) }));
} else if (runs.length === 2) {
sentences.push(
capitalise(
m.forecast_sky_two({
sky = capitalise(
pick(
SKY_TWO,
seed,
1
)({
c1: phrase(runs[0]),
p1: runs[0].period.message(),
c2: phrase(runs[1]),
p2: runs[1].period.message()
})
)
);
} else {
// Four clauses is a mouthful: keep the opening, the first change and
// where the day ends up.
const kept = [runs[0], runs[1], runs[runs.length - 1]];
sentences.push(
capitalise(
m.forecast_sky_three({
sky = capitalise(
pick(
SKY_THREE,
seed,
2
)({
c1: phrase(kept[0]),
p1: kept[0].period.message(),
c2: phrase(kept[1]),
@@ -208,7 +257,6 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
c3: phrase(kept[2]),
p3: kept[2].period.message()
})
)
);
}
}
@@ -220,11 +268,10 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
const low = Math.min(...temps);
const feels = idx.map((i) => hourly.apparent_temperature?.[i]).filter(finite);
const feelsHigh = feels.length > 0 ? Math.max(...feels) : null;
sentences.push(
temperature =
feelsHigh != null && Math.abs(feelsHigh - high) >= 3
? m.forecast_temp_feels({ high: temp(high), low: temp(low), feels: temp(feelsHigh) })
: m.forecast_temp({ high: temp(high), low: temp(low) })
);
? pick(TEMP_FEELS, seed, 3)({ high: temp(high), low: temp(low), feels: temp(feelsHigh) })
: pick(TEMP, seed, 4)({ high: temp(high), low: temp(low) });
}
// ─── Precipitation ──────────────────────────────────────────────────────────
@@ -252,15 +299,14 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
}
}
const amount = `${total.toFixed(total < 10 ? 1 : 0)} ${precipUnit}`;
sentences.push(
precipitation =
bestPeriod && bestAmount / total >= 0.5
? m.forecast_precip_window({ amount, when: bestPeriod.message() })
: m.forecast_precip_spread({ amount })
);
? pick(PRECIP_WINDOW, seed, 5)({ amount, when: bestPeriod.message() })
: pick(PRECIP_SPREAD, seed, 6)({ amount });
} else if (peakProb >= 30) {
sentences.push(m.forecast_precip_chance({ percent: Math.round(peakProb) }));
precipitation = pick(PRECIP_CHANCE, seed, 7)({ percent: Math.round(peakProb) });
} else {
sentences.push(m.forecast_precip_dry());
precipitation = pick(PRECIP_DRY, seed, 8)();
}
// ─── Wind ───────────────────────────────────────────────────────────────────
@@ -275,30 +321,44 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
const calm = maxWind < (windUnit === 'm/s' ? 1.5 : windUnit === 'kn' ? 3 : 5);
if (calm && !gusty) {
sentences.push(m.forecast_calm());
wind = pick(CALM, seed, 9)();
} else if (finite(dir)) {
const direction = getWindDirectionLabel(dir);
sentences.push(
gusty
? m.forecast_wind_dir_gusts({ direction, speed: speed(maxWind), gust: speed(maxGust) })
: m.forecast_wind_dir({ direction, speed: speed(maxWind) })
);
wind = gusty
? pick(
WIND_DIR_GUSTS,
seed,
10
)({
direction,
speed: speed(maxWind),
gust: speed(maxGust)
})
: pick(WIND_DIR, seed, 11)({ direction, speed: speed(maxWind) });
} else {
sentences.push(
gusty
? m.forecast_wind_gusts({ speed: speed(maxWind), gust: speed(maxGust) })
: m.forecast_wind({ speed: speed(maxWind) })
);
wind = gusty
? pick(WIND_GUSTS, seed, 12)({ speed: speed(maxWind), gust: speed(maxGust) })
: pick(WIND, seed, 13)({ speed: speed(maxWind) });
}
}
// ─── UV ─────────────────────────────────────────────────────────────────────
const uv = dayIndex >= 0 ? at(daily.uv_index_max, dayIndex) : undefined;
if (finite(uv) && uv >= 6) {
sentences.push(m.forecast_uv({ value: uv.toFixed(0), label: uvLabel(uv).toLowerCase() }));
ultraviolet = pick(UV, seed, 14)({ value: uv.toFixed(0), label: uvLabel(uv).toLowerCase() });
}
return sentences;
// Position varies too: the sky always opens and any UV warning always closes,
// but which of temperature, rain and wind comes next rotates per day.
const ORDERS = [
[temperature, precipitation, wind],
[precipitation, temperature, wind],
[temperature, wind, precipitation],
[wind, temperature, precipitation]
];
const middle = pick(ORDERS, seed, 15);
return [sky, ...middle, ultraviolet].filter((s): s is string => s != null && s.length > 0);
}
/** WHO exposure category for a UV index value. */
-32
View File
@@ -1,32 +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:5181/en/weather/week/berlin/', { waitUntil: 'load', timeout: 120000 });
await page.waitForTimeout(12000);
const info = await page.evaluate(() => {
const btn = document.querySelector('[data-slot="select-trigger"], button.group.min-h-12') ||
[...document.querySelectorAll('button')].find((b) => b.className.includes('min-h-12'));
const card = document.querySelector('.strip-cell');
const cs = (el) => {
const s = getComputedStyle(el);
return { prop: s.transitionProperty, dur: s.transitionDuration, delay: s.transitionDelay };
};
return { button: btn ? cs(btn) : null, cardEl: card ? cs(card) : null, btnClass: btn?.className.slice(0, 120) };
});
console.log('BEFORE switch:', JSON.stringify(info, null, 1));
// flip the theme and watch when each element's background actually moves
await page.getByRole('button', { name: /^Theme: / }).click();
const samples = [];
for (let i = 0; i < 12; i++) {
samples.push(await page.evaluate(() => {
const btn = [...document.querySelectorAll('button')].find((b) => b.className.includes('min-h-12'));
const card = document.querySelector('.strip-cell');
const g = (el) => (el ? getComputedStyle(el).backgroundColor : null);
return { t: Math.round(performance.now() % 100000), btn: g(btn), card: g(card), dur: btn ? getComputedStyle(btn).transitionDuration : null };
}));
await page.waitForTimeout(60);
}
console.log(JSON.stringify(samples.map((s) => `${s.btn} | ${s.card} | ${s.dur}`), null, 1));
await browser.close();