more fluent texts
This commit is contained in:
+46
-16
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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})."
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
root.classList.add('theme-transition');
|
||||
clearTimeout(themeTimer);
|
||||
themeTimer = window.setTimeout(() => root.classList.remove('theme-transition'), 400);
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (!themeSettled || reduced) {
|
||||
themeSettled = true;
|
||||
paint();
|
||||
return;
|
||||
}
|
||||
themeSettled = true;
|
||||
root.classList.toggle('dark', theme === 'dark' || (theme === 'system' && mq.matches));
|
||||
|
||||
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);
|
||||
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
@@ -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
|
||||
const selectedModel = get(storedModel);
|
||||
if (selectedModel !== 'best_match' && !params.models.includes(selectedModel)) {
|
||||
params.models = [selectedModel, ...params.models];
|
||||
// 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,33 +226,37 @@ 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({
|
||||
c1: phrase(runs[0]),
|
||||
p1: runs[0].period.message(),
|
||||
c2: phrase(runs[1]),
|
||||
p2: runs[1].period.message()
|
||||
})
|
||||
)
|
||||
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({
|
||||
c1: phrase(kept[0]),
|
||||
p1: kept[0].period.message(),
|
||||
c2: phrase(kept[1]),
|
||||
p2: kept[1].period.message(),
|
||||
c3: phrase(kept[2]),
|
||||
p3: kept[2].period.message()
|
||||
})
|
||||
)
|
||||
sky = capitalise(
|
||||
pick(
|
||||
SKY_THREE,
|
||||
seed,
|
||||
2
|
||||
)({
|
||||
c1: phrase(kept[0]),
|
||||
p1: kept[0].period.message(),
|
||||
c2: phrase(kept[1]),
|
||||
p2: kept[1].period.message(),
|
||||
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. */
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user