more translations

This commit is contained in:
Vincent van der Wal
2026-08-01 16:36:04 +02:00
parent ae88140f79
commit d7fe0bafc8
24 changed files with 762 additions and 215 deletions
+13
View File
@@ -0,0 +1,13 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { localizeHref } from '$lib/paraglide/runtime';
// Same reasoning as the root redirect: resolved in the browser so the
// visitor's language is known (see routes/+page.svelte).
onMount(() => {
goto(localizeHref('/weather/week/'), { replaceState: true });
});
</script>
-10
View File
@@ -1,10 +0,0 @@
import { redirect } from '@sveltejs/kit';
import { localizeHref } from '$lib/paraglide/runtime';
import type { PageLoad } from './$types';
export const load = (async () => {
// keep the visitor in their language when the bare /weather path is hit
throw redirect(303, localizeHref('/weather/week/'));
}) satisfies PageLoad;
@@ -173,15 +173,38 @@
// Apply ?day= once the forecast is in: the parameter is a plain calendar date,
// which only means something against the location's own timezone.
let appliedDayParam = false;
// A `?day=` outside what this page can ever show (more than 3 days back, or
// beyond the 15-day horizon) is dropped and we open on today. A day that IS
// reachable but sits outside the default 7-day window widens the request up
// front, so the link loads straight into the right day instead of showing
// today and making the visitor click.
let wantedDay: string | null = $state(null);
onMount(() => {
const param = get(page).url.searchParams.get('day');
if (!param || !/^\d{4}-\d{2}-\d{2}$/.test(param)) return;
const target = Date.parse(`${param}T12:00:00Z`);
if (Number.isNaN(target)) return;
const today = new Date();
today.setUTCHours(12, 0, 0, 0);
const offset = Math.round((target - today.getTime()) / 86_400_000);
if (offset < -3 || offset > 15) return; // out of reach: stay on today
wantedDay = param;
if (offset < 0) pastDays = 3;
if (offset >= 7) forecastDays = 15;
});
// Select it as soon as a fetch arrives that actually covers the day.
$effect(() => {
const fd = fetchedDaily;
if (appliedDayParam || !fd) return;
appliedDayParam = true;
const wanted = get(page).url.searchParams.get('day');
if (!wanted) return;
const wanted = wantedDay;
if (!fd || !wanted) return;
const match = fd.dailyDates.find((d) => formatZoned(d, fd.timezone, 'yyyy-MM-dd') === wanted);
if (match) selectedDay.setTime(match.getTime());
if (match) {
selectedDay.setTime(match.getTime());
wantedDay = null;
}
});
// Mirror the open day and the plotted model back into the URL.
@@ -189,8 +212,11 @@
const dayKey = selectedDayKey;
const model = params.models?.[0];
if (!mounted || !dayKey) return;
const isToday = fetchedDaily
? dayKey === formatZoned(new Date(), fetchedDaily.timezone, 'yyyy-MM-dd')
: false;
syncSearchParams($page.url, {
day: dayKey,
day: isToday ? null : dayKey,
model: model && model !== 'best_match' ? model : null
});
});
@@ -235,12 +235,15 @@
return parts.join('\n');
}
/** A value the model actually produced (NaN pads everything past its horizon). */
const finite = (v: number | null | undefined): v is number => v != null && Number.isFinite(v);
function formatTemp(temp: number | null): string {
return temp != null ? `${temp.toFixed(0)}°` : '-';
return finite(temp) ? `${temp.toFixed(0)}°` : '';
}
function formatValue(val: number | null): string {
return val != null ? val.toFixed(0) : '-';
return finite(val) ? val.toFixed(0) : '';
}
let dayIdx = $derived(getDayIndices(data.hourlyDates, selectedDay));
@@ -254,15 +257,48 @@
const isNow =
formatZoned(date, tz, 'yyyy-MM-dd HH') === formatZoned(today, tz, 'yyyy-MM-dd HH');
// a column past the model's horizon: nothing to show in any row
const hasData = [
data.hourly.temperature_2m,
data.hourly.weather_code,
data.hourly.windspeed_10m,
data.hourly.relative_humidity_2m,
data.hourly.cloud_cover,
data.hourly.precipitation
].some((arr) => arr != null && Number.isFinite(arr[idx]));
return {
idx,
date,
isNow,
isDaytime: daytimeFlags[i]
isDaytime: daytimeFlags[i],
hasData
};
})
);
// A model that doesn't carry a variable returns an empty or all-NaN array.
// Those rows are dimmed rather than shown as a wall of dashes.
let rowHasData = $derived.by((): Record<string, boolean> => {
const has = (arr: number[] | undefined) =>
arr != null && cellData.some((c) => finite(arr[c.idx]));
return {
icons: has(data.hourly.weather_code),
temperature: has(data.hourly.temperature_2m),
feels: has(data.hourly.apparent_temperature),
dew_point: has(data.hourly.dew_point_2m),
wind: has(data.hourly.windspeed_10m),
gusts: has(data.hourly.wind_gusts_10m),
humidity: has(data.hourly.relative_humidity_2m),
clouds: has(data.hourly.cloud_cover),
pressure: has(data.hourly.pressure_msl),
uv: has(data.hourly.uv_index),
visibility: has(data.hourly.visibility),
precipitation: has(data.hourly.precipitation),
snowfall: has(data.hourly.snowfall)
};
});
let sunrisePercent = $derived(
sunTimes && cellData.length > 0 ? timeToFraction(sunTimes.sunrise) * 100 : null
);
@@ -475,7 +511,7 @@
<!-- Time + Daylight bar (merged) -->
<tr class="row">
<th class="hdr" scope="row" bind:clientWidth={headerColWidth}>
<span class="text-[10px] font-semibold text-muted-foreground">Time</span>
<span class="text-[10px] font-semibold text-muted-foreground">{m.var_time()}</span>
</th>
<td
colspan={cellData.length}
@@ -556,6 +592,7 @@
{@const leftPct = (i / cellData.length) * 100}
{@const widthPct = 100 / cellData.length}
<span
class:col-empty={!cell.hasData}
class="absolute top-0 flex items-start pl-1 font-bold {is3h
? 'pt-2 text-sm'
: 'pt-2.5'}
@@ -591,12 +628,14 @@
<!-- Weather Icons -->
{#each rowOrder as rowKey (rowKey)}
{#if rowKey === 'icons' && showRow('icons')}
<tr class="row">
<tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-day-cloudy')}
{#each cellData as cell, i (cell.idx)}
{@const wCode = hourly.weather_code[cell.idx]}
<td
class:col-empty={!cell.hasData}
class="cell h-12 leading-0"
class:cell-empty={!finite(wCode)}
class:icon-day={cell.isDaytime}
class:icon-night={!cell.isDaytime}
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
@@ -604,20 +643,23 @@
cellData[i + 1] &&
!cellData[i + 1].isDaytime}
>
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
{#if finite(wCode)}
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
{/if}
</td>
{/each}
</tr>
<!-- Temperature -->
{:else if rowKey === 'temperature' && showRow('temperature')}
<tr class="row">
{@render rowHeader('wi-thermometer', tempUnit, 'Temp')}
<tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-thermometer', tempUnit, m.var_temperature_short())}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.temperature_2m[cell.idx]}
{@const style = getTempStyle(temp, String(units.temperature_unit))}
<td
class:col-empty={!cell.hasData}
class="cell h-12 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
style="background-color:{style.bg};color:{style.fg}"
style={finite(temp) ? `background-color:${style.bg};color:${style.fg}` : ''}
>
{formatTemp(temp)}
</td>
@@ -625,11 +667,12 @@
</tr>
<!-- Feels Like (short row) -->
{:else if rowKey === 'feels' && showRow('feels')}
<tr class="row">
{@render rowHeader(undefined, tempUnit, 'Feels')}
<tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader(undefined, tempUnit, m.var_apparent_short())}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.apparent_temperature[cell.idx]}
<td
class:col-empty={!cell.hasData}
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
>
{formatTemp(temp)}
@@ -638,11 +681,12 @@
</tr>
<!-- Dew Point (short row; off by default) -->
{:else if rowKey === 'dew_point' && showRow('dew_point')}
<tr class="row">
{@render rowHeader('wi-raindrop', tempUnit, 'Dew')}
<tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-raindrop', tempUnit, m.var_dew_point_short())}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.dew_point_2m?.[cell.idx]}
<td
class:col-empty={!cell.hasData}
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
>
{formatTemp(temp)}
@@ -651,12 +695,15 @@
</tr>
<!-- Wind -->
{:else if rowKey === 'wind' && showRow('wind')}
<tr class="row">
{@render rowHeader('wi-strong-wind', windUnit, 'Wind')}
<tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-strong-wind', windUnit, m.var_wind_short())}
{#each cellData as cell (cell.idx)}
{@const wind = hourly.windspeed_10m[cell.idx]}
{@const windDir = hourly.winddirection_10m[cell.idx]}
<td class="relative cell h-12 align-middle leading-none">
<td
class:col-empty={!cell.hasData}
class="relative cell h-12 align-middle leading-none"
>
{#if windDir != null && !isNaN(windDir)}
<span
class="absolute top-0 left-1/2 inline-block origin-center leading-0"
@@ -673,11 +720,12 @@
</tr>
<!-- Wind Gusts (off by default) -->
{:else if rowKey === 'gusts' && showRow('gusts')}
<tr class="row">
{@render rowHeader('wi-strong-wind', windUnit, 'Gusts')}
<tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-strong-wind', windUnit, m.var_gusts_short())}
{#each cellData as cell (cell.idx)}
{@const v = hourly.wind_gusts_10m?.[cell.idx]}
<td
class:col-empty={!cell.hasData}
class="cell h-12 font-semibold text-foreground/80 {is3h
? 'text-sm'
: 'text-xs'}"
@@ -688,24 +736,31 @@
</tr>
<!-- Humidity (short row) -->
{:else if rowKey === 'humidity' && showRow('humidity')}
<tr class="row">
{@render rowHeader('wi-humidity', '%', 'Humidity')}
<tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-humidity', '%', m.var_humidity())}
{#each cellData as cell (cell.idx)}
{@const hum = hourly.relative_humidity_2m[cell.idx]}
<td class="cell h-12" style="background:{getHumidityBg(hum ?? 0)}">
<td
class:col-empty={!cell.hasData}
class="cell h-12"
style={finite(hum) ? `background:${getHumidityBg(hum)}` : ''}
>
{formatValue(hum)}
</td>
{/each}
</tr>
<!-- Cloud Cover -->
{:else if rowKey === 'clouds' && showRow('clouds')}
<tr class="row">
{@render rowHeader('wi-cloud', '%', 'Clouds')}
<tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-cloud', '%', m.var_cloud_short())}
{#each cellData as cell (cell.idx)}
{@const cloud = hourly.cloud_cover[cell.idx]}
<td
class:col-empty={!cell.hasData}
class="cell h-12"
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
style={finite(cloud)
? `background:rgba(140,160,180,${getCloudOpacity(cloud)})`
: ''}
>
{formatValue(cloud)}
</td>
@@ -713,11 +768,12 @@
</tr>
<!-- Pressure (off by default) -->
{:else if rowKey === 'pressure' && showRow('pressure')}
<tr class="row">
{@render rowHeader('wi-barometer', 'hPa', 'Pressure')}
<tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-barometer', 'hPa', m.var_pressure_short())}
{#each cellData as cell (cell.idx)}
{@const v = hourly.pressure_msl?.[cell.idx]}
<td
class:col-empty={!cell.hasData}
class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}"
style="background:{getPressureBg(v ?? null)}"
>
@@ -727,11 +783,12 @@
</tr>
<!-- UV Index (off by default) -->
{:else if rowKey === 'uv' && showRow('uv')}
<tr class="row">
{@render rowHeader('wi-day-sunny', 'UV', 'UV')}
<tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-day-sunny', 'UV', m.var_uv_short())}
{#each cellData as cell (cell.idx)}
{@const v = hourly.uv_index?.[cell.idx]}
<td
class:col-empty={!cell.hasData}
class="cell h-12 font-semibold {is3h ? 'text-sm' : 'text-xs'}"
style="background:{getUvBg(v ?? null)}"
>
@@ -741,23 +798,27 @@
</tr>
<!-- Visibility (off by default) -->
{:else if rowKey === 'visibility' && showRow('visibility')}
<tr class="row">
{@render rowHeader('wi-fog', 'km', 'Visibility')}
<tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-fog', 'km', m.var_visibility_short())}
{#each cellData as cell (cell.idx)}
{@const v = hourly.visibility?.[cell.idx]}
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}">
<td
class:col-empty={!cell.hasData}
class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}"
>
{v != null && !isNaN(v) ? (v / 1000).toFixed(0) : '-'}
</td>
{/each}
</tr>
<!-- Precipitation -->
{:else if rowKey === 'precipitation' && showRow('precipitation')}
<tr class="row">
{@render rowHeader('wi-raindrop', precipUnit, 'Precip')}
<tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-raindrop', precipUnit, m.var_precipitation_short())}
{#each cellData as cell (cell.idx)}
{@const precip = hourly.precipitation[cell.idx]}
{@const prob = hourly.precipitation_probability[cell.idx]}
<td
class:col-empty={!cell.hasData}
class="cell precip-cell h-12"
style="background:{getPrecipProbBg(prob ?? 0)}"
title={formatPrecipTooltip(precip, prob)}
@@ -773,11 +834,14 @@
</tr>
<!-- Snowfall (off by default) -->
{:else if rowKey === 'snowfall' && showRow('snowfall')}
<tr class="row">
{@render rowHeader('wi-snow', 'cm', 'Snow')}
<tr class="row" class:row-empty={rowHasData[rowKey] === false}>
{@render rowHeader('wi-snow', 'cm', m.var_snowfall_short())}
{#each cellData as cell (cell.idx)}
{@const v = hourly.snowfall?.[cell.idx]}
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}">
<td
class:col-empty={!cell.hasData}
class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}"
>
{v != null && !isNaN(v) && v > 0 ? v.toFixed(1) : '-'}
</td>
{/each}
@@ -853,6 +917,24 @@
overflow: hidden;
}
/* A row the selected model carries no data for: legible, clearly inactive,
and not competing with the rows that do have values. */
.row-empty {
opacity: 0.4;
}
/* An individual cell past the point where the model stopped: drop the
day/night tinting so the gap reads as "no data" rather than clear sky. */
.cell-empty {
background: none !important;
}
/* A whole column the model never reached - dimmed end to end, hour label
included, so the point where the forecast runs out is obvious. */
.col-empty {
opacity: 0.35;
}
/* ── Precipitation ──────────────────────────────────────────── */
.precip-cell {
position: relative;
+46 -44
View File
@@ -6,6 +6,8 @@
*/
import { defaultVariablePrefs } from '$lib/stores/settings';
import * as m from '$lib/paraglide/messages';
import { getColor } from '../../utils/colors';
import {
type WeatherUnits,
@@ -69,8 +71,8 @@ export interface ChartVariableDef {
export const CHART_VARIABLES: ChartVariableDef[] = [
{
key: 'temperature',
label: 'Temperature',
short: 'Temp',
label: m.var_temperature(),
short: m.var_temperature_short(),
field: 'temperature_2m',
type: 'line',
kind: 'temp',
@@ -83,8 +85,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'weather_icons',
label: 'Weather icons',
short: 'Icons',
label: m.var_icons(),
short: m.var_icons_short(),
field: 'weather_code',
type: 'line',
kind: 'temp',
@@ -94,8 +96,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'apparent_temperature',
label: 'Apparent Temp',
short: 'Feels',
label: m.var_apparent(),
short: m.var_apparent_short(),
field: 'apparent_temperature',
type: 'line',
kind: 'temp',
@@ -105,8 +107,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'dew_point',
label: 'Dew Point',
short: 'Dew',
label: m.var_dew_point(),
short: m.var_dew_point_short(),
field: 'dew_point_2m',
type: 'line',
kind: 'temp',
@@ -115,8 +117,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'cloud_cover',
label: 'Cloud Cover',
short: 'Cloud',
label: m.var_cloud(),
short: m.var_cloud_short(),
field: 'cloud_cover',
type: 'line',
kind: 'percent',
@@ -128,8 +130,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
// the way the layers actually look from below.
{
key: 'cloud_cover_low',
label: 'Cloud Cover Low',
short: 'Low',
label: m.var_cloud_low(),
short: m.var_cloud_low_short(),
field: 'cloud_cover_low',
type: 'line',
kind: 'percent',
@@ -139,8 +141,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'cloud_cover_mid',
label: 'Cloud Cover Mid',
short: 'Mid',
label: m.var_cloud_mid(),
short: m.var_cloud_mid_short(),
field: 'cloud_cover_mid',
type: 'line',
kind: 'percent',
@@ -150,8 +152,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'cloud_cover_high',
label: 'Cloud Cover High',
short: 'High',
label: m.var_cloud_high(),
short: m.var_cloud_high_short(),
field: 'cloud_cover_high',
type: 'line',
kind: 'percent',
@@ -161,8 +163,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'precipitation',
label: 'Precipitation',
short: 'Precip',
label: m.var_precipitation(),
short: m.var_precipitation_short(),
field: 'precipitation',
type: 'bar',
kind: 'precip',
@@ -170,8 +172,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'precipitation_probability',
label: 'Precip. Probability',
short: 'PoP',
label: m.var_pop(),
short: m.var_pop_short(),
field: 'precipitation_probability',
type: 'line',
kind: 'percent',
@@ -181,8 +183,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'rain',
label: 'Rain',
short: 'Rain',
label: m.var_rain(),
short: m.var_rain_short(),
field: 'rain',
type: 'bar',
kind: 'precip',
@@ -190,8 +192,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'showers',
label: 'Showers',
short: 'Shwr',
label: m.var_showers(),
short: m.var_showers_short(),
field: 'showers',
type: 'bar',
kind: 'precip',
@@ -199,8 +201,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'snowfall',
label: 'Snowfall',
short: 'Snow',
label: m.var_snowfall(),
short: m.var_snowfall_short(),
field: 'snowfall',
type: 'bar',
kind: 'snow',
@@ -208,8 +210,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'wind',
label: 'Wind Speed',
short: 'Wind',
label: m.var_wind(),
short: m.var_wind_short(),
field: 'windspeed_10m',
api: 'wind_speed_10m',
type: 'line',
@@ -221,8 +223,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'wind_direction',
label: 'Wind direction',
short: 'Dir',
label: m.var_wind_dir(),
short: m.var_wind_dir_short(),
field: 'winddirection_10m',
api: 'wind_direction_10m',
type: 'line',
@@ -233,8 +235,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'wind_gusts',
label: 'Wind Gusts',
short: 'Gusts',
label: m.var_gusts(),
short: m.var_gusts_short(),
field: 'wind_gusts_10m',
type: 'line',
kind: 'wind',
@@ -244,8 +246,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'humidity',
label: 'Humidity',
short: 'RH',
label: m.var_humidity(),
short: m.var_humidity_short(),
field: 'relative_humidity_2m',
type: 'line',
kind: 'percent',
@@ -255,8 +257,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'pressure_msl',
label: 'Pressure (MSL)',
short: 'MSLP',
label: m.var_pressure(),
short: m.var_pressure_short(),
field: 'pressure_msl',
type: 'line',
kind: 'pressure',
@@ -265,8 +267,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'surface_pressure',
label: 'Surface Pressure',
short: 'Psfc',
label: m.var_surface_pressure(),
short: m.var_surface_pressure_short(),
field: 'surface_pressure',
type: 'line',
kind: 'pressure',
@@ -276,8 +278,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'uv_index',
label: 'UV Index',
short: 'UV',
label: m.var_uv(),
short: m.var_uv_short(),
field: 'uv_index',
type: 'line',
kind: 'uv',
@@ -288,8 +290,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'visibility',
label: 'Visibility',
short: 'Vis',
label: m.var_visibility(),
short: m.var_visibility_short(),
field: 'visibility',
type: 'line',
kind: 'distance',
@@ -299,8 +301,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
},
{
key: 'cape',
label: 'CAPE',
short: 'CAPE',
label: m.var_cape(),
short: m.var_cape_short(),
field: 'cape',
type: 'line',
kind: 'energy',