This commit is contained in:
Vincent van der Wal
2026-07-19 16:13:51 +02:00
parent e031716ce6
commit baa4840b38
26 changed files with 1077 additions and 702 deletions
+16
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { page } from '$app/stores';
import { storedTheme } from '$lib/stores/settings';
import Header from '$lib/components/navigation/header.svelte';
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
@@ -10,6 +12,20 @@
let { children } = $props();
// keep the .dark class in sync with the persisted theme; in 'system' mode
// follow the OS preference live
$effect(() => {
const theme = $storedTheme;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => {
const dark = theme === 'dark' || (theme === 'system' && mq.matches);
document.documentElement.classList.toggle('dark', dark);
};
apply();
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
});
// the maps page embeds a full-bleed map: no padding, no scrolling
let fullBleed = $derived($page.url.pathname.startsWith('/weather/maps'));
+2 -2
View File
@@ -18,8 +18,8 @@
--secondary-foreground: oklch(0.25 0.02 60);
--muted: oklch(0.96 0.008 85);
--muted-foreground: oklch(0.5 0.02 60);
--accent: oklch(0.96 0.008 85);
--accent-foreground: oklch(1 0 0);
--accent: oklch(0.95 0.02 70);
--accent-foreground: oklch(0.25 0.02 60);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.91 0.01 80);
--input: oklch(0.91 0.01 80);
@@ -24,13 +24,13 @@
fetchModelComparison
} from '$lib/services/weather';
import { hourly, models as modelsFlat } from '../../options';
import { hourly, modelGroups } from '../../options';
import { defaultParameters } from '../../options';
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
import type { PageData } from './$types';
const models = [modelsFlat];
const models = modelGroups.map((group) => group.models);
const CHART_GROUP = 'model-compare';
+302 -48
View File
@@ -5,56 +5,310 @@ export const defaultParameters = {
precipitation_unit: 'mm'
};
export const models = [
{ value: 'best_match', label: 'Best match' },
{ value: 'ecmwf_ifs', label: 'ECMWF IFS' },
{ value: 'ecmwf_ifs025', label: 'ECMWF IFS 0.25' },
{ value: 'ecmwf_aifs025_single', label: 'ECMWF AIFS 0.25 Single' },
{ value: 'cma_grapes_global', label: 'CMA GRAPES Global' },
{ value: 'bom_access_global', label: 'BOM ACCESS Global' },
{ value: 'kma_seamless', label: 'KMA Seamless' },
{ value: 'kma_ldps', label: 'KMA LDPS' },
{ value: 'kma_gdps', label: 'KMA GDPS' },
{ value: 'meteofrance_seamless', label: 'Meteo-France Seamless' },
{ value: 'meteofrance_arpege_world', label: 'Meteo-France ARPEGE World' },
{ value: 'meteofrance_arpege_europe', label: 'Meteo-France ARPEGE Europe' },
{ value: 'meteofrance_arome_france', label: 'Meteo-France AROME France' },
{ value: 'meteofrance_arome_france_hd', label: 'Meteo-France AROME France HD' },
{ value: 'knmi_seamless', label: 'KNMI Seamless' },
{ value: 'knmi_harmonie_arome_europe', label: 'KNMI Harmonie Arome Europe' },
{ value: 'knmi_harmonie_arome_netherlands', label: 'KNMI Harmonie Arome Netherlands' },
{ value: 'dmi_seamless', label: 'DMI Seamless' },
{ value: 'dmi_harmonie_arome_europe', label: 'DMI Harmonie Arome Europe' },
{ value: 'ukmo_seamless', label: 'UKMO Seamless' },
{ value: 'ukmo_global_deterministic_10km', label: 'UKMO Global Deterministic 10km' },
{ value: 'ukmo_uk_deterministic_2km', label: 'UKMO UK Deterministic 2km' },
{ value: 'meteoswiss_icon_seamless', label: 'MeteoSwiss ICON Seamless' },
{ value: 'meteoswiss_icon_ch2', label: 'MeteoSwiss ICON CH2' },
{ value: 'meteoswiss_icon_ch1', label: 'MeteoSwiss ICON CH1' },
{ value: 'metno_nordic', label: 'MET Norway Nordic' },
{ value: 'metno_seamless', label: 'MET Norway Seamless' },
{ value: 'gem_hrdps_west', label: 'GEM HRDPS West' },
{ value: 'gem_regional', label: 'GEM Regional' },
{ value: 'gem_global', label: 'GEM Global' },
{ value: 'gem_seamless', label: 'GEM Seamless' },
{ value: 'jma_seamless', label: 'JMA Seamless' },
{ value: 'jma_msm', label: 'JMA MSM' },
{ value: 'jma_gsm', label: 'JMA GSM' },
{ value: 'gfs_seamless', label: 'GFS Seamless' },
{ value: 'gfs_global', label: 'GFS Global' },
{ value: 'gfs_hrrr', label: 'GFS HRRR' },
{ value: 'gfs_graphcast025', label: 'GFS Graphcast 0.25' },
{ value: 'ncep_nbm_conus', label: 'NCEP NBM CONUS' },
{ value: 'ncep_nam_conus', label: 'NCEP NAM CONUS' },
{ value: 'ncep_aigfs025', label: 'NCEP AIGFS 0.25' },
{ value: 'ncep_hgefs025_ensemble_mean', label: 'NCEP HG-EFS 0.25 Ensemble Mean' },
{ value: 'icon_seamless', label: 'ICON Seamless (DWD)' },
{ value: 'icon_global', label: 'ICON Global' },
{ value: 'icon_eu', label: 'ICON EU' },
{ value: 'icon_d2', label: 'ICON-D2' },
{ value: 'italia_meteo_arpae_icon_2i', label: 'Italia Meteo ARPAE ICON 2i' }
export interface WeatherModel {
value: string;
label: string;
/** Native grid resolution, from open-meteo/weather-map-layer domains.ts */
resolution?: string;
/** Model-run cadence (model_interval), from domains.ts */
update?: string;
}
export interface WeatherModelGroup {
value: string;
label: string;
models: WeatherModel[];
}
/**
* Forecast models grouped by provider. Labels, grid resolutions and update
* cadences are synced from the open-meteo/weather-map-layer project
* (src/domains.ts: domainGroups + domainOptions grid/model_interval data).
*/
export const modelGroups: WeatherModelGroup[] = [
{
value: 'auto',
label: 'Automatic',
models: [{ value: 'best_match', label: 'Best match', resolution: 'varies', update: 'varies' }]
},
{
value: 'ecmwf',
label: 'ECMWF',
models: [
{ value: 'ecmwf_ifs', label: 'ECMWF IFS HRES', resolution: '9 km', update: 'every 6 h' },
{ value: 'ecmwf_ifs025', label: 'ECMWF IFS 0.25°', resolution: '25 km', update: 'every 6 h' },
{
value: 'ecmwf_aifs025_single',
label: 'ECMWF AIFS 0.25° Single',
resolution: '25 km',
update: 'every 6 h'
}
]
},
{
value: 'dwd',
label: 'DWD Germany',
models: [
{
value: 'icon_seamless',
label: 'DWD ICON Seamless',
resolution: '2-13 km',
update: 'every 3 h'
},
{ value: 'icon_global', label: 'DWD ICON', resolution: '13 km', update: 'every 6 h' },
{ value: 'icon_eu', label: 'DWD ICON EU', resolution: '7 km', update: 'every 3 h' },
{ value: 'icon_d2', label: 'DWD ICON D2', resolution: '2 km', update: 'every 3 h' }
]
},
{
value: 'ncep',
label: 'NOAA U.S.',
models: [
{ value: 'gfs_seamless', label: 'GFS Seamless', resolution: '3-25 km', update: 'every hour' },
{ value: 'gfs_global', label: 'GFS Global', resolution: '13 km', update: 'every 6 h' },
{ value: 'gfs_hrrr', label: 'GFS HRRR Conus', resolution: '3 km', update: 'every hour' },
{
value: 'gfs_graphcast025',
label: 'GFS GraphCast 0.25°',
resolution: '25 km',
update: 'every 6 h'
},
{
value: 'ncep_aigfs025',
label: 'GFS AIGFS 0.25°',
resolution: '25 km',
update: 'every 6 h'
},
{
value: 'ncep_hgefs025_ensemble_mean',
label: 'GFS HGEFS 0.25° Ensemble Mean',
resolution: '25 km',
update: 'every 6 h'
},
{
value: 'ncep_nbm_conus',
label: 'GFS NBM Conus',
resolution: '2.5 km',
update: 'every hour'
},
{ value: 'ncep_nam_conus', label: 'GFS NAM Conus', resolution: '12 km', update: 'every 6 h' }
]
},
{
value: 'meteofrance',
label: 'Météo-France',
models: [
{
value: 'meteofrance_seamless',
label: 'MF Seamless',
resolution: '1-25 km',
update: 'every 3 h'
},
{
value: 'meteofrance_arpege_world',
label: 'MF ARPEGE World',
resolution: '25 km',
update: 'every 3 h'
},
{
value: 'meteofrance_arpege_europe',
label: 'MF ARPEGE Europe',
resolution: '10 km',
update: 'every 3 h'
},
{
value: 'meteofrance_arome_france',
label: 'MF AROME France',
resolution: '2.5 km',
update: 'every 3 h'
},
{
value: 'meteofrance_arome_france_hd',
label: 'MF AROME France HD',
resolution: '1 km',
update: 'every 3 h'
}
]
},
{
value: 'ukmo',
label: 'UK Met Office',
models: [
{
value: 'ukmo_seamless',
label: 'UKMO Seamless',
resolution: '2-10 km',
update: 'every 3 h'
},
{
value: 'ukmo_global_deterministic_10km',
label: 'UK Met Office 10km',
resolution: '10 km',
update: 'every 3 h'
},
{
value: 'ukmo_uk_deterministic_2km',
label: 'UK Met Office 2km',
resolution: '2 km',
update: 'every 3 h'
}
]
},
{
value: 'knmi',
label: 'KNMI Netherlands',
models: [
{
value: 'knmi_seamless',
label: 'KNMI Seamless',
resolution: '2-3 km',
update: 'every hour'
},
{
value: 'knmi_harmonie_arome_europe',
label: 'KNMI Harmonie Arome Europe',
resolution: '5.5 km',
update: 'every hour'
},
{
value: 'knmi_harmonie_arome_netherlands',
label: 'KNMI Harmonie Arome Netherlands',
resolution: '2 km',
update: 'every hour'
}
]
},
{
value: 'dmi',
label: 'DMI Denmark',
models: [
{ value: 'dmi_seamless', label: 'DMI Seamless', resolution: '2 km', update: 'every 3 h' },
{
value: 'dmi_harmonie_arome_europe',
label: 'DMI Harmonie Arome Europe',
resolution: '2 km',
update: 'every 3 h'
}
]
},
{
value: 'metno',
label: 'MET Norway',
models: [
{
value: 'metno_seamless',
label: 'MET Norway Seamless',
resolution: '1 km',
update: 'every 3 h'
},
{ value: 'metno_nordic', label: 'MET Norway Nordic', resolution: '1 km', update: 'every 3 h' }
]
},
{
value: 'meteoswiss',
label: 'MeteoSwiss',
models: [
{
value: 'meteoswiss_icon_seamless',
label: 'MeteoSwiss ICON Seamless',
resolution: '1-2 km',
update: 'every 3 h'
},
{
value: 'meteoswiss_icon_ch1',
label: 'MeteoSwiss ICON CH1',
resolution: '1 km',
update: 'every 3 h'
},
{
value: 'meteoswiss_icon_ch2',
label: 'MeteoSwiss ICON CH2',
resolution: '2 km',
update: 'every 3 h'
}
]
},
{
value: 'kma',
label: 'KMA Korea',
models: [
{
value: 'kma_seamless',
label: 'KMA Seamless',
resolution: '1.5-13 km',
update: 'every 3 h'
},
{ value: 'kma_ldps', label: 'KMA LDPS', resolution: '1.5 km', update: 'every 3 h' },
{ value: 'kma_gdps', label: 'KMA GDPS 12km', resolution: '13 km', update: 'every 3 h' }
]
},
{
value: 'jma',
label: 'JMA Japan',
models: [
{ value: 'jma_seamless', label: 'JMA Seamless', resolution: '5-55 km', update: 'every 3 h' },
{ value: 'jma_msm', label: 'JMA MSM', resolution: '5 km', update: 'every 3 h' },
{ value: 'jma_gsm', label: 'JMA GSM', resolution: '55 km', update: 'every 6 h' }
]
},
{
value: 'cma',
label: 'CMA China',
models: [
{
value: 'cma_grapes_global',
label: 'CMA GRAPES Global',
resolution: '14 km',
update: 'every 6 h'
}
]
},
{
value: 'bom',
label: 'BOM Australia',
models: [
{
value: 'bom_access_global',
label: 'BOM ACCESS Global',
resolution: '15 km',
update: 'every 12 h'
}
]
},
{
value: 'cmc_gem',
label: 'GEM Canada',
models: [
{ value: 'gem_seamless', label: 'GEM Seamless', resolution: '1-15 km', update: 'every 6 h' },
{ value: 'gem_global', label: 'GEM Global', resolution: '15 km', update: 'every 12 h' },
{ value: 'gem_regional', label: 'GEM Regional', resolution: '10 km', update: 'every 6 h' },
{
value: 'gem_hrdps_west',
label: 'GEM HRDPS West',
resolution: '1 km',
update: 'every 12 h'
}
]
},
{
value: 'italia_meteo',
label: 'ItaliaMeteo',
models: [
{
value: 'italia_meteo_arpae_icon_2i',
label: 'IM ARPAE ICON 2i',
resolution: '2.5 km',
update: 'every 3 h'
}
]
}
];
export const models: WeatherModel[] = modelGroups.flatMap((group) => group.models);
export const findModel = (value: string): WeatherModel | undefined =>
models.find((model) => model.value === value);
export const hourly = [
[
{ value: 'temperature_2m', label: 'Temperature 2m' },
-102
View File
@@ -1,102 +0,0 @@
export default [
'#800080',
'#800083',
'#800087',
'#7f008a',
'#7f008d',
'#7e0090',
'#7d0094',
'#7c0097',
'#7a009a',
'#79009d',
'#7700a1',
'#7600a4',
'#7400a7',
'#7200aa',
'#6f00ae',
'#6d00b1',
'#6a00b4',
'#6700b7',
'#6400bb',
'#6100be',
'#5e00c1',
'#5b00c4',
'#5700c8',
'#5300cb',
'#4f00ce',
'#4b00d1',
'#4700d5',
'#4200d8',
'#3e00db',
'#3900de',
'#3400e2',
'#2f00e5',
'#2a00e8',
'#2400eb',
'#1f00ef',
'#1900f2',
'#1300f5',
'#0d00f8',
'#0600fc',
'#0000ff',
'#0000ff',
'#0021f7',
'#003fee',
'#005ce6',
'#0076dd',
'#008ed5',
'#00a3cc',
'#00b7c4',
'#00bbaf',
'#00b38f',
'#00aa72',
'#00a256',
'#00993d',
'#009127',
'#008812',
'#008000',
'#008000',
'#118c00',
'#259700',
'#3ca300',
'#56ae00',
'#72ba00',
'#92c500',
'#b4d100',
'#d9dc00',
'#e8cf00',
'#f3bb00',
'#ffa500',
'#ffa500',
'#ff9800',
'#ff8c00',
'#ff7f00',
'#ff7200',
'#ff6600',
'#ff5900',
'#ff4c00',
'#ff3f00',
'#ff3300',
'#ff2600',
'#ff1900',
'#ff0d00',
'#ff0000',
'#ff0000',
'#f8000f',
'#f0001c',
'#e90029',
'#e10035',
'#da0040',
'#d2004a',
'#cb0053',
'#c3005c',
'#bc0063',
'#b4006a',
'#ad0070',
'#a50075',
'#9e0079',
'#96007c',
'#8f007e',
'#870080',
'#800080'
];
+100
View File
@@ -0,0 +1,100 @@
/**
* Temperature color scale ported from the open-meteo/weather-map-layer
* project (src/utils/color-scales.ts) so tables and maps share the same
* color language. Values between breakpoints are linearly interpolated.
*/
export type RGBA = [number, number, number, number];
export interface BreakpointScale {
unit: string;
breakpoints: number[];
colors: RGBA[];
}
export const temperatureScale: BreakpointScale = {
unit: '°C',
breakpoints: [
-80, -65, -50, -40, -32, -28, -24, -20, -17.5, -15, -12.5, -10, -8, -6, -4, -2, 0, 2, 4, 6, 8,
10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50
],
colors: [
[74, 13, 0, 1],
[130, 1, 29, 1],
[185, 4, 114, 1],
[221, 6, 193, 1],
[207, 6, 241, 1],
[163, 5, 243, 1],
[118, 4, 246, 1],
[71, 3, 249, 1],
[41, 2, 250, 1],
[11, 1, 252, 1],
[1, 22, 253, 1],
[0, 52, 255, 1],
[35, 99, 251, 1],
[69, 139, 247, 1],
[102, 171, 245, 1],
[134, 197, 245, 1],
[114, 232, 165, 1],
[78, 232, 133, 1],
[40, 233, 96, 1],
[17, 220, 61, 1],
[9, 191, 36, 1],
[4, 160, 15, 1],
[0, 128, 0, 1],
[40, 160, 0, 1],
[96, 192, 0, 1],
[167, 223, 0, 1],
[255, 255, 0, 1],
[255, 237, 0, 1],
[255, 219, 0, 1],
[255, 201, 0, 1],
[255, 183, 0, 1],
[255, 165, 0, 1],
[255, 138, 0, 1],
[255, 110, 0, 1],
[255, 83, 0, 1],
[255, 55, 0, 1],
[255, 27, 0, 1],
[255, 0, 0, 1],
[228, 0, 10, 1],
[201, 0, 18, 1],
[174, 0, 23, 1],
[147, 0, 26, 1]
]
};
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
/** Linearly interpolated color for `value` on a breakpoint scale. */
export const sampleScale = (breakpoints: number[], colors: RGBA[], value: number): RGBA => {
if (!Number.isFinite(value) || value <= breakpoints[0]) return colors[0];
const last = breakpoints.length - 1;
if (value >= breakpoints[last]) return colors[last];
let i = 0;
while (value > breakpoints[i + 1]) i++;
const t = (value - breakpoints[i]) / (breakpoints[i + 1] - breakpoints[i]);
const [r1, g1, b1, a1] = colors[i];
const [r2, g2, b2, a2] = colors[i + 1];
return [
Math.round(lerp(r1, r2, t)),
Math.round(lerp(g1, g2, t)),
Math.round(lerp(b1, b2, t)),
lerp(a1, a2, t)
];
};
export const rgbaCss = ([r, g, b, a]: RGBA): string =>
`rgba(${r}, ${g}, ${b}, ${Math.round(a * 1000) / 1000})`;
/**
* Whether text over `color` should be white, after alpha-compositing the
* color onto the page background (light or dark).
*/
export const needsWhiteText = ([r, g, b, a]: RGBA, dark = false): boolean => {
const base = dark ? 26 : 255;
const cr = r * a + base * (1 - a);
const cg = g * a + base * (1 - a);
const cb = b * a + base * (1 - a);
return cr * 0.299 + cg * 0.587 + cb * 0.114 <= 150;
};
+9 -71
View File
@@ -1,57 +1,13 @@
import colorScaleHex from './color-scale-hex';
import { type RGBA, needsWhiteText, rgbaCss, sampleScale, temperatureScale } from './color-scales';
const componentFromStr = (numStr: string, percent: number) => {
const num = Math.max(0, parseInt(numStr, 10));
return percent ? Math.floor((255 * Math.min(100, num)) / 100) : Math.min(255, num);
};
const toCelsius = (temperature: number, unit: string): number =>
unit === 'celsius' ? temperature : ((temperature - 32) * 5) / 9;
export const rgbToHex = (rgb: string): string => {
const rgbRegex = /^rgb\(\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*\)$/;
let result,
r,
g,
b,
hex = '';
if ((result = rgbRegex.exec(rgb))) {
r = componentFromStr(result[1], Number(result[2]));
g = componentFromStr(result[3], Number(result[4]));
b = componentFromStr(result[5], Number(result[6]));
export const getTempColor = (temperature: number, unit = 'celsius'): RGBA =>
sampleScale(temperatureScale.breakpoints, temperatureScale.colors, toCelsius(temperature, unit));
hex = (0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
if (!rgb) {
return '355522';
}
return hex;
};
export const hexToRgb = (hex: string): [number, number, number] => {
const h = hex.replace('#', '');
return [
parseInt(h.substring(0, 2), 16),
parseInt(h.substring(2, 4), 16),
parseInt(h.substring(4, 6), 16)
];
};
export const getColor = (temperature: number, unit = 'celsius'): string => {
if (unit !== 'celsius') {
temperature = Math.round(((temperature - 32) * 5) / 9);
}
let index: number;
if (temperature <= -40) {
index = 0;
} else if (temperature >= 60) {
index = colorScaleHex.length - 1;
} else {
// clamp: the scale has exactly 100 entries (-45..54), temperatures in
// [55, 60) would otherwise index past the end
index = Math.min(colorScaleHex.length - 1, Math.max(0, Math.round(temperature) + 45));
}
return colorScaleHex[index];
};
export const getColor = (temperature: number, unit = 'celsius'): string =>
rgbaCss(getTempColor(temperature, unit));
export interface TempStyle {
bg: string;
@@ -59,24 +15,6 @@ export interface TempStyle {
}
export const getTempStyle = (temp: number, unit: string): TempStyle => {
const bg = getColor(temp, unit);
const fg = textWhite(hexToRgb(bg)) ? 'white' : 'black';
return { bg, fg };
};
export const textWhite = (
[r, g, b, a]: [number, number, number, number] | [number, number, number],
dark?: boolean,
globalOpacity?: number
): boolean => {
const alpha = ((a || 1) * (globalOpacity || 100)) / 100;
if (alpha < 0.65) {
if (dark) {
return true;
} else {
return false;
}
}
// check luminance
return r * 0.299 + g * 0.587 + b * 0.114 <= 150;
const rgba = getTempColor(temp, unit);
return { bg: rgbaCss(rgba), fg: needsWhiteText(rgba) ? 'white' : 'black' };
};
+34 -17
View File
@@ -40,11 +40,10 @@
let fetchedHourly: FetchedHourly | null = $state(null);
let fetchedDaily: FetchedDaily | null = $state(null);
let meteogramCharts: MeteogramCharts | undefined = $state();
// 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());
meteogramCharts?.scrollToDay(date);
};
onMount(() => {
@@ -109,6 +108,37 @@
<div class="week-page">
<div class="weather-content" style="min-height: 50vh">
<!-- Page hero: prominent location + weather model selection -->
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
<div class="flex min-w-0 items-center gap-3">
<img
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
src="/images/country-flags/{(
location.country_code || 'united_nations'
).toLowerCase()}.svg"
alt={location.country ?? ''}
/>
<div class="min-w-0">
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
{location.name}
</h1>
<p class="truncate text-sm text-muted-foreground">
{#if location.admin1}{location.admin1},
{/if}{location.country ?? ''}
<span class="mx-1 opacity-50">·</span>
7-day forecast
</p>
</div>
</div>
<ModelSelector
selectedModel={params.models?.[0] ?? 'best_match'}
onModelChange={(model) => {
params.models = [model];
}}
/>
</div>
{#if loadError}
<div
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
@@ -130,20 +160,7 @@
{/if}
{#if fetchedHourly}
<MeteogramCharts
bind:this={meteogramCharts}
data={fetchedHourly}
{selectedDay}
units={params}
{loading}
/>
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
{/if}
<ModelSelector
selectedModel={params.models?.[0] ?? 'best_match'}
onModelChange={(model) => {
params.models = [model];
}}
/>
</div>
</div>
@@ -159,6 +159,24 @@
let sunsetPercent = $derived(
sunTimes && cellData.length > 0 ? timeToFraction(sunTimes.sunset) * 100 : null
);
// ─── "Now" indicator ─────────────────────────────────────────────────────
// Full-height vertical line across the table, positioned at the current
// time within the selected day (only when the selected day is today).
let isTodaySelected = $derived(
cellData.length > 0 && isSameDayInZone(today, selectedDay, data.timezone)
);
let nowPercent = $derived(isTodaySelected ? timeToFraction(today) * 100 : null);
// Width of the row-header column, measured so the now-line can be
// positioned relative to the data columns only.
let headerColWidth = $state(0);
let tableWidth = $state(0);
let nowLeftPx = $derived(
nowPercent != null && tableWidth > 0
? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth)
: null
);
</script>
{#snippet weatherIcon(name: string, size: number = 16)}
@@ -169,314 +187,324 @@
{#snippet rowHeader(iconName?: string, unit?: string, label?: string)}
<th class="hdr" scope="row">
<div class="flex flex-col items-center leading-tight">
<div class="flex flex-col items-center gap-0.5 leading-tight">
{#if iconName}
{@render weatherIcon(iconName)}
{@render weatherIcon(iconName, 18)}
{/if}
{#if label}
<span class="text-[11px] font-semibold text-muted-foreground">{label}</span>
<span class="text-[11px] font-semibold">{label}</span>
{/if}
{#if unit}
<span class="text-[10px] font-semibold text-muted-foreground">{unit}</span>
<span class="text-[10px] font-medium text-muted-foreground">{unit}</span>
{/if}
</div>
</th>
{/snippet}
<!-- Header -->
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
<h3 class="text-lg font-bold">
{formatZoned(selectedDay, data.timezone, 'EEEE')} Hourly
<span class="ml-1 text-xs font-normal text-muted-foreground">({timezoneLabel})</span>
</h3>
<div
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-[13px] font-semibold"
role="group"
aria-label="Hourly interval"
>
{#each [3, 1] as interval (interval)}
<button
class="cursor-pointer rounded-md px-3 py-1 transition-colors {hourlyInterval === interval
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
aria-pressed={hourlyInterval === interval}
onclick={() => (hourlyInterval = interval as 1 | 3)}
>
{interval}h
</button>
{/each}
</div>
</div>
{#if cellData.length > 0}
{@const hourly = data.hourly}
{@const iconPx = is3h ? 40 : 26}
<div class="overflow-hidden rounded-xl border border-border/70 bg-card shadow-xs">
<table class="w-full table-fixed border-collapse whitespace-nowrap">
<caption class="sr-only">Hourly weather details for {locationName}</caption>
<colgroup>
<col class="w-14 md:w-16" />
{#each cellData as _ (_.idx)}
<col />
{@const iconPx = is3h ? 38 : 26}
<section class="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
<!-- Card toolbar -->
<div
class="flex flex-wrap items-center justify-between gap-2 border-b border-border/70 bg-muted/40 px-4 py-2.5"
>
<h3 class="text-base font-bold">
{formatZoned(selectedDay, data.timezone, 'EEEE')}
<span class="font-semibold text-muted-foreground"> hourly</span>
<span
class="ms-2 rounded-full bg-muted px-2 py-0.5 align-middle text-[10px] font-semibold text-muted-foreground"
>
{timezoneLabel}
</span>
</h3>
<div
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-[13px] font-semibold"
role="group"
aria-label="Hourly interval"
>
{#each [3, 1] as interval (interval)}
<button
class="cursor-pointer rounded-md px-3 py-1 transition-colors {hourlyInterval ===
interval
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
aria-pressed={hourlyInterval === interval}
onclick={() => (hourlyInterval = interval as 1 | 3)}
>
{interval}h
</button>
{/each}
</colgroup>
<tbody>
<!-- Time + Daylight bar (merged) -->
<tr class="!border-t-0">
<th class="hdr" scope="row">
<span class="text-[10px] font-semibold text-muted-foreground">{timezoneLabel}</span>
</th>
<td colspan={cellData.length} class="relative h-10 overflow-visible p-0">
<!-- Daylight background -->
{#if sunTimes && sunrisePercent != null && sunsetPercent != null}
<div
class="absolute inset-y-0 left-0 bg-indigo-950/10 dark:bg-indigo-950/30"
style="width:{sunrisePercent}%"
></div>
<div
class="absolute inset-y-0 bg-amber-400/15 dark:bg-amber-400/10"
style="left:{sunrisePercent}%;width:{sunsetPercent - sunrisePercent}%"
></div>
<div
class="absolute inset-y-0 right-0 bg-indigo-950/10 dark:bg-indigo-950/30"
style="width:{100 - sunsetPercent}%"
></div>
<!-- Sunrise marker + label -->
<div class="absolute inset-y-0 w-px bg-amber-500/70" style="left:{sunrisePercent}%">
<span
class="absolute bottom-0.5 left-1 whitespace-nowrap text-[10px] font-semibold leading-none text-amber-700 dark:text-amber-300"
>
<svg
class="fill-foreground inline-block"
width="12px"
height="12px"
aria-hidden="true"
</div>
</div>
<div class="relative" bind:clientWidth={tableWidth}>
<table class="w-full table-fixed border-collapse whitespace-nowrap">
<caption class="sr-only">Hourly weather details for {locationName}</caption>
<colgroup>
<col class="w-16 md:w-20" />
{#each cellData as _ (_.idx)}
<col />
{/each}
</colgroup>
<tbody>
<!-- 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>
</th>
<td colspan={cellData.length} class="relative h-10 overflow-visible p-0">
<!-- Daylight background -->
{#if sunTimes && sunrisePercent != null && sunsetPercent != null}
<div
class="absolute inset-y-0 left-0 bg-indigo-950/10 dark:bg-indigo-950/40"
style="width:{sunrisePercent}%"
></div>
<div
class="absolute inset-y-0 bg-amber-400/15 dark:bg-amber-300/10"
style="left:{sunrisePercent}%;width:{sunsetPercent - sunrisePercent}%"
></div>
<div
class="absolute inset-y-0 right-0 bg-indigo-950/10 dark:bg-indigo-950/40"
style="width:{100 - sunsetPercent}%"
></div>
<!-- Sunrise marker + label -->
<div class="absolute inset-y-0 w-px bg-amber-500/70" style="left:{sunrisePercent}%">
<span
class="absolute bottom-0.5 left-1 text-[10px] leading-none font-semibold whitespace-nowrap text-amber-700 dark:text-amber-300"
>
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"
></use>
</svg>
<span class="align-middle">{formatTime(sunTimes.sunrise)}</span>
</span>
</div>
<!-- Sunset marker + label -->
<div class="absolute inset-y-0 w-px bg-indigo-400/70" style="left:{sunsetPercent}%">
<span
class="absolute bottom-0.5 right-1 whitespace-nowrap text-[10px] font-semibold leading-none text-indigo-600 dark:text-indigo-300 inline-flex items-center gap-1"
>
<svg
class="fill-foreground inline-block"
width="12px"
height="12px"
aria-hidden="true"
<svg
class="inline-block fill-foreground"
width="12px"
height="12px"
aria-hidden="true"
>
<use
class="stroke-2"
xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"
></use>
</svg>
<span class="align-middle">{formatTime(sunTimes.sunrise)}</span>
</span>
</div>
<!-- Sunset marker + label -->
<div class="absolute inset-y-0 w-px bg-indigo-400/70" style="left:{sunsetPercent}%">
<span
class="absolute right-1 bottom-0.5 inline-flex items-center gap-1 text-[10px] leading-none font-semibold whitespace-nowrap text-indigo-600 dark:text-indigo-300"
>
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"
></use>
</svg>
<span class="align-middle">{formatTime(sunTimes.sunset)}</span>
<svg
class="inline-block fill-foreground"
width="12px"
height="12px"
aria-hidden="true"
>
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"
></use>
</svg>
<span class="align-middle">{formatTime(sunTimes.sunset)}</span>
</span>
</div>
{/if}
<!-- Hour labels -->
{#each cellData as cell, i (cell.idx)}
{@const leftPct = (i / cellData.length) * 100}
{@const widthPct = 100 / cellData.length}
<span
class="absolute top-0 flex items-start pt-1.5 pl-1 text-sm font-bold
{cell.isNow ? 'text-red-600 dark:text-red-400' : ''}"
style="left:{leftPct}%;width:{widthPct}%"
>
{#if is3h}
{formatZoned(cell.date, data.timezone, 'HH')}
{:else}
<span class="inline-flex items-baseline gap-1">
<span class="text-[11px] font-semibold"
>{formatZoned(cell.date, data.timezone, 'HH')}</span
>
<sup
class="align-baseline text-[9px] leading-none font-semibold text-muted-foreground"
>00</sup
>
</span>
{/if}
</span>
</div>
{/if}
<!-- Hour labels -->
{/each}
</td>
</tr>
<!-- Weather Icons -->
<tr class="row">
{@render rowHeader('wi-day-cloudy')}
{#each cellData as cell, i (cell.idx)}
{@const leftPct = (i / cellData.length) * 100}
{@const widthPct = 100 / cellData.length}
<span
class="absolute top-0 flex items-start pt-1 font-bold pl-0.5 text-sm
{cell.isNow ? 'text-destructive' : ''}"
style="left:{leftPct}%;width:{widthPct}%"
{@const wCode = hourly.weather_code[cell.idx]}
<td
class="cell leading-0 {is3h ? 'h-14' : 'h-11'}"
class:icon-day={cell.isDaytime}
class:icon-night={!cell.isDaytime}
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
class:icon-dusk={cell.isDaytime && cellData[i + 1] && !cellData[i + 1].isDaytime}
>
{#if is3h}
{formatZoned(cell.date, data.timezone, 'HH')}
{:else}
<span class="inline-flex items-baseline gap-1">
<span class="text-[11px] font-semibold"
>{formatZoned(cell.date, data.timezone, 'HH')}</span
>
<sup
class="text-[9px] font-semibold text-muted-foreground leading-none align-baseline"
>00</sup
>
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
</td>
{/each}
</tr>
<!-- Temperature -->
<tr class="row">
{@render rowHeader('wi-thermometer', tempUnit, 'Temp')}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.temperature_2m[cell.idx]}
{@const style = getTempStyle(temp, String(units.temperature_unit))}
<td
class="cell h-10 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
style="background-color:{style.bg};color:{style.fg}"
>
{formatTemp(temp)}
</td>
{/each}
</tr>
<!-- Feels Like -->
<tr class="row">
{@render rowHeader(undefined, tempUnit, 'Feels')}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.apparent_temperature[cell.idx]}
<td class="cell h-8 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}">
{formatTemp(temp)}
</td>
{/each}
</tr>
<!-- Wind -->
<tr class="row">
{@render rowHeader('wi-strong-wind', windUnit, 'Wind')}
{#each cellData as cell (cell.idx)}
{@const wind = hourly.windspeed_10m[cell.idx]}
{@const windDir = hourly.winddirection_10m[cell.idx]}
<td class="cell h-13 align-middle leading-tight">
{#if windDir != null && !isNaN(windDir)}
<span
class="inline-block leading-0"
style="transform:{getWindArrowRotation(windDir)}"
>
{@render weatherIcon('wi-direction-down', 22)}
</span>
{/if}
</span>
<span class="block font-semibold {is3h ? 'text-sm' : 'text-xs'}">
{formatValue(wind)}
</span>
</td>
{/each}
</td>
</tr>
</tr>
<!-- Weather Icons -->
<tr>
{@render rowHeader('wi-day-cloudy')}
{#each cellData as cell, i (cell.idx)}
{@const wCode = hourly.weather_code[cell.idx]}
<td
class="cell leading-[0] {is3h ? 'px-1 py-2.5' : 'px-0.5 py-1.5'}"
class:now={cell.isNow}
class:icon-day={cell.isDaytime}
class:icon-night={!cell.isDaytime}
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
class:icon-dusk={cell.isDaytime && cellData[i + 1] && !cellData[i + 1].isDaytime}
>
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
</td>
{/each}
</tr>
<!-- Humidity -->
<tr class="row">
{@render rowHeader('wi-humidity', '%', 'Humidity')}
{#each cellData as cell (cell.idx)}
{@const hum = hourly.relative_humidity_2m[cell.idx]}
<td class="cell h-8" style="background:{getHumidityBg(hum ?? 0)}">
{formatValue(hum)}
</td>
{/each}
</tr>
<!-- Temperature -->
<tr>
{@render rowHeader('wi-thermometer', tempUnit)}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.temperature_2m[cell.idx]}
{@const style = getTempStyle(temp, String(units.temperature_unit))}
<td
class="cell font-bold {is3h ? 'py-2.5 text-lg' : 'py-2 text-[15px]'}"
class:now={cell.isNow}
style="background-color:{style.bg};color:{style.fg}"
>
{formatTemp(temp)}
</td>
{/each}
</tr>
<!-- Cloud Cover -->
<tr class="row">
{@render rowHeader('wi-cloud', '%', 'Clouds')}
{#each cellData as cell (cell.idx)}
{@const cloud = hourly.cloud_cover[cell.idx]}
<td
class="cell h-8"
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
>
{formatValue(cloud)}
</td>
{/each}
</tr>
<!-- Feels Like -->
<tr>
{@render rowHeader(undefined, tempUnit, 'Feels')}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.apparent_temperature[cell.idx]}
<td
class="cell text-muted-foreground {is3h ? 'py-1 text-[13px]' : 'py-0.5 text-[11px]'}"
class:now={cell.isNow}
>
{formatTemp(temp)}
</td>
{/each}
</tr>
<!-- Precipitation -->
<tr class="row">
{@render rowHeader('wi-raindrop', precipUnit, 'Precip')}
{#each cellData as cell (cell.idx)}
{@const precip = hourly.precipitation[cell.idx]}
{@const prob = hourly.precipitation_probability[cell.idx]}
<td
class="cell precip-cell {is3h ? 'h-14' : 'h-11'}"
style="background:{getPrecipProbBg(prob ?? 0)}"
title={formatPrecipTooltip(precip, prob)}
>
{#if precip > 0}
<div class="precip-bar" style="height:{getPrecipBarHeight(precip)}%"></div>
<span class="precip-label {is3h ? 'text-[13px]' : 'text-[10px]'}">
{precip.toFixed(1)}
</span>
{/if}
</td>
{/each}
</tr>
</tbody>
</table>
<!-- Wind -->
<tr>
{@render rowHeader('wi-strong-wind', windUnit)}
{#each cellData as cell (cell.idx)}
{@const wind = hourly.windspeed_10m[cell.idx]}
{@const windDir = hourly.winddirection_10m[cell.idx]}
<td class="cell text-center align-middle leading-tight" class:now={cell.isNow}>
{#if windDir != null && !isNaN(windDir)}
<span
class="inline-block leading-[0]"
style="transform:{getWindArrowRotation(windDir)}"
>
{@render weatherIcon('wi-direction-down', 24)}
</span>
{/if}
<span class="block font-semibold {is3h ? 'mt-0.5 text-sm' : 'text-xs'}">
{formatValue(wind)}
</span>
</td>
{/each}
</tr>
<!-- Humidity -->
<tr>
{@render rowHeader('wi-humidity', '%')}
{#each cellData as cell (cell.idx)}
{@const hum = hourly.relative_humidity_2m[cell.idx]}
<td class="cell" class:now={cell.isNow} style="background:{getHumidityBg(hum ?? 0)}">
{formatValue(hum)}
</td>
{/each}
</tr>
<!-- Cloud Cover -->
<tr>
{@render rowHeader('wi-cloud', '%')}
{#each cellData as cell (cell.idx)}
{@const cloud = hourly.cloud_cover[cell.idx]}
<td
class="cell"
class:now={cell.isNow}
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
>
{formatValue(cloud)}
</td>
{/each}
</tr>
<!-- Precipitation -->
<tr>
{@render rowHeader('wi-raindrop', precipUnit)}
{#each cellData as cell (cell.idx)}
{@const precip = hourly.precipitation[cell.idx]}
{@const prob = hourly.precipitation_probability[cell.idx]}
<td
class="precip-cell {is3h ? 'h-14' : 'h-11'}"
class:now={cell.isNow}
style="background:{getPrecipProbBg(prob ?? 0)}"
title={formatPrecipTooltip(precip, prob)}
>
{#if precip > 0}
<div class="precip-bar" style="height:{getPrecipBarHeight(precip)}%"></div>
<span class="precip-label {is3h ? 'text-[13px]' : 'text-[10px]'}">
{precip.toFixed(1)}
</span>
{/if}
</td>
{/each}
</tr>
</tbody>
</table>
</div>
<!-- "Now" column highlight + exact-time line -->
{#if nowLeftPx != null}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
{@const nowIdx = cellData.findIndex((c) => c.isNow)}
{#if nowIdx >= 0}
<div
class="pointer-events-none absolute inset-y-0 z-10 border-x border-red-500/30 bg-red-500/5"
style="left:{headerColWidth + nowIdx * colWidth}px;width:{colWidth}px"
></div>
{/if}
<div
class="pointer-events-none absolute inset-y-0 z-10 w-0.5 -translate-x-1/2 bg-red-500/80"
style="left:{nowLeftPx}px"
>
<span
class="absolute top-1 left-1/2 -translate-x-1/2 rounded-full bg-red-500 px-1.5 py-px text-[9px] font-bold tracking-wide text-white uppercase shadow-sm"
>
Now
</span>
</div>
{/if}
</div>
</section>
{/if}
<style>
tr {
border-top: 1px solid hsl(var(--border) / 0.6);
/* ── Uniform grid ───────────────────────────────────────────── */
.row + .row {
border-top: 1px solid color-mix(in oklab, var(--color-border) 70%, transparent);
}
/* ── Base cell ──────────────────────────────────────────── */
.cell {
padding: 6px 2px;
padding: 2px;
text-align: center;
font-size: 13px;
font-weight: 500;
font-variant-numeric: tabular-nums;
border-right: 1px solid hsl(var(--border) / 0.15);
overflow: hidden;
}
.cell:last-child {
border-right: none;
.cell + .cell {
border-left: 1px solid color-mix(in oklab, var(--color-border) 35%, transparent);
}
.cell.now {
font-weight: 700;
box-shadow: inset 0 0 0 2px hsl(var(--primary) / 0.55);
}
/* ── Row header ─────────────────────────────────────────── */
/* ── Row header ─────────────────────────────────────────────── */
.hdr {
padding: 4px;
padding: 4px 2px;
text-align: center;
font-weight: 600;
font-size: 11px;
background: hsl(var(--muted) / 0.35);
border-right: 1px solid hsl(var(--border));
background: color-mix(in oklab, var(--color-muted) 45%, transparent);
border-right: 1px solid var(--color-border);
white-space: nowrap;
overflow: hidden;
}
/* ── Precipitation ──────────────────────────────────────── */
/* ── Precipitation ──────────────────────────────────────────── */
.precip-cell {
position: relative;
padding: 0;
text-align: center;
overflow: hidden;
border-right: 1px solid hsl(var(--border) / 0.2);
}
.precip-cell:last-child {
border-right: none;
}
.precip-cell.now {
box-shadow: inset 0 0 0 2px hsl(var(--primary) / 0.55);
}
.precip-bar {
@@ -503,10 +531,10 @@
}
:global(.dark) .precip-label {
color: rgba(120, 180, 255, 0.95);
color: rgba(140, 190, 255, 0.95);
}
/* ── Responsive ─────────────────────────────────────────── */
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 768px) {
.hdr {
padding: 3px 2px;
@@ -514,7 +542,6 @@
}
.cell {
font-size: 11px;
padding: 4px 1px;
}
}
</style>
@@ -29,7 +29,6 @@
const CHART_GROUP = 'week-meteogram';
const SECONDS_PER_DAY = 24 * 3600;
let showCharts = $state(false);
let chartComponents: CanvasChart[] = $state([]);
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
@@ -37,20 +36,21 @@
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
export function scrollToDay(day: Date): void {
if (!data || liveCharts.length === 0) return;
function dayStartSec(day: Date): number | null {
if (!data) return null;
const tz = data.timezone;
const targetDayStr = formatZoned(day, tz, 'yyyy-MM-dd');
const firstHourIdx = data.hourlyDates.findIndex(
(d) => formatZoned(d, tz, 'yyyy-MM-dd') === targetDayStr
);
return firstHourIdx === -1 ? null : data.timestamps[firstHourIdx] / 1000;
}
if (firstHourIdx === -1) return;
const dayStart = data.timestamps[firstHourIdx] / 1000;
function setRangeDays(from: Date, days: number): void {
const start = dayStartSec(from);
if (start == null || liveCharts.length === 0) return;
// Charts share the group, so setting the range on one syncs all of them
liveCharts[0].setRange(dayStart, dayStart + SECONDS_PER_DAY);
liveCharts[0].setRange(start, start + days * SECONDS_PER_DAY);
}
function resetZoom(): void {
@@ -58,18 +58,15 @@
onResetZoom?.();
}
// Zoom to the selected day once all three charts are mounted
let scrolledOnMount = false;
$effect(() => {
if (!showCharts) {
scrolledOnMount = false;
return;
}
if (!scrolledOnMount && liveCharts.length === 3 && data) {
scrolledOnMount = true;
requestAnimationFrame(() => scrollToDay(selectedDay));
}
});
// Range presets: charts show the full week by default, these (or
// Ctrl+scroll) narrow the window.
const rangePresets = [
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
{ label: 'Selected day', apply: () => setRangeDays(selectedDay, 1) },
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
{ label: 'All', apply: () => resetZoom() }
];
// ─── Series Building ────────────────────────────────────────────────────────
@@ -195,142 +192,68 @@
});
</script>
<div class="charts-toggle-section">
<button class="charts-toggle-btn" onclick={() => (showCharts = !showCharts)}>
<span>Detailed Meteogram Charts</span>
<svg
class="toggle-chevron {showCharts ? 'open' : ''}"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
{#if showCharts}
<div class="detailed-charts" in:fade={{ duration: 200 }}>
<div class="charts-header">
<h3 class="charts-title">
{formatZoned(selectedDay, data.timezone, 'EEEE')}
<small>
{getRelativeDayLabel(selectedDay, data.timezone) !==
formatZoned(selectedDay, data.timezone, 'EEEE')
? ` (${getRelativeDayLabel(selectedDay, data.timezone)})`
: ''}
</small>
</h3>
<button type="button" class="zoom-reset-btn" title="Show all days (Esc)" onclick={resetZoom}>
Show All
</button>
</div>
<ChartContainer {loading} chartCount={3} chartHeight={300}>
{#each chartDefs as def, i (def.title)}
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={data.timezone}
series={def.series}
bands={data.daylightBands}
unit={def.unit}
unitRight={def.unitRight}
yMin={def.yMin}
yMinRight={def.yMinRight}
yMaxRight={def.yMaxRight}
invertRight={def.invertRight}
title={def.title}
showCredit={def.showCredit}
showLegend
height={300}
group={CHART_GROUP}
/>
{/each}
</ChartContainer>
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
<section class="mt-8" in:fade={{ duration: 200 }}>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 class="text-lg font-bold">
Meteogram
<span class="font-semibold text-muted-foreground">
{formatZoned(selectedDay, data.timezone, 'EEEE')}{getRelativeDayLabel(
selectedDay,
data.timezone
) !== formatZoned(selectedDay, data.timezone, 'EEEE')
? ` (${getRelativeDayLabel(selectedDay, data.timezone)})`
: ''}
</span>
</h3>
<div class="flex flex-wrap items-center gap-3">
<span class="hidden text-xs text-muted-foreground md:inline">
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]"
>Ctrl</kbd
>
+ scroll to zoom
</span>
<div
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
role="group"
aria-label="Chart time range"
>
{#each rangePresets as preset (preset.label)}
<button
type="button"
class="cursor-pointer rounded-md px-2.5 py-1 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
onclick={preset.apply}
>
{preset.label}
</button>
{/each}
</div>
</div>
</div>
{/if}
<style>
.charts-toggle-section {
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
<ChartContainer {loading} chartCount={3} chartHeight={300}>
{#each chartDefs as def, i (def.title)}
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={data.timezone}
series={def.series}
bands={data.daylightBands}
unit={def.unit}
unitRight={def.unitRight}
yMin={def.yMin}
yMinRight={def.yMinRight}
yMaxRight={def.yMaxRight}
invertRight={def.invertRight}
title={def.title}
showCredit={def.showCredit}
showLegend
height={300}
group={CHART_GROUP}
/>
{/each}
</ChartContainer>
.charts-toggle-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
border-radius: var(--radius, 0.375rem);
cursor: pointer;
transition: all 150ms ease;
}
.charts-toggle-btn:hover {
color: hsl(var(--foreground));
background: hsl(var(--muted));
}
.toggle-chevron {
transition: transform 200ms;
}
.toggle-chevron.open {
transform: rotate(180deg);
}
.detailed-charts {
margin-top: 0.5rem;
}
.charts-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.charts-title {
font-size: 1.25rem;
font-weight: 700;
}
.zoom-reset-btn {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
border-radius: var(--radius, 0.375rem);
cursor: pointer;
transition:
color 150ms ease,
background-color 150ms ease;
white-space: nowrap;
user-select: none;
}
.zoom-reset-btn:hover {
color: hsl(var(--foreground));
background: hsl(var(--muted));
}
</style>
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
</div>
</section>
@@ -1,8 +1,7 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import { models } from '../../options';
import { findModel, modelGroups } from '../../options';
interface Props {
selectedModel: string;
@@ -11,35 +10,73 @@
let { selectedModel, onModelChange }: Props = $props();
let modelLabel = $derived(
models.find((mo) => String(mo.value) === selectedModel)?.label ?? selectedModel
let model = $derived(findModel(selectedModel));
let modelLabel = $derived(model?.label ?? selectedModel);
let modelMeta = $derived(
model?.resolution && model.resolution !== 'varies'
? `${model.resolution} · updated ${model.update}`
: 'Automatically picks the best model for this location'
);
</script>
<div class="mt-6 flex gap-6 md:mt-12">
<div class="relative w-1/2">
<Select.Root
name="model_selection"
type="single"
value={selectedModel}
onValueChange={(val) => {
if (val) onModelChange(val);
}}
<Select.Root
name="model_selection"
type="single"
value={selectedModel}
onValueChange={(val) => {
if (val) onModelChange(val);
}}
>
<Select.Trigger
aria-label="Forecast model selection"
class="group h-auto min-w-64 cursor-pointer gap-3 rounded-xl border-2 border-primary/35 bg-card py-2 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-w-72"
>
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary"
>
<Select.Trigger
aria-label="Forecast model selection"
class="h-12 cursor-pointer pt-6 [&_svg]:mb-3"
>
{modelLabel}
</Select.Trigger>
<Select.Content preventScroll={false} class="border-border">
{#each models as mo (mo.value)}
<Select.Item class="cursor-pointer" value={mo.value}>{mo.label}</Select.Item>
{/each}
</Select.Content>
<Label class="absolute top-[0.35rem] left-2 z-10 px-1 text-xs text-muted-foreground">
<!-- layered-globe icon: weather model -->
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
<circle cx="12" cy="12" r="9" />
<path
stroke-linecap="round"
d="M3.6 9h16.8M3.6 15h16.8M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18"
/>
</svg>
</div>
<div class="flex min-w-0 flex-1 flex-col items-start gap-0 overflow-hidden text-left">
<span class="text-[11px] font-semibold tracking-wide text-primary uppercase">
Weather model
</Label>
</Select.Root>
</div>
</div>
</span>
<span class="max-w-full truncate text-sm font-bold text-foreground">{modelLabel}</span>
<span class="max-w-full truncate text-[11px] leading-tight text-muted-foreground">
{modelMeta}
</span>
</div>
</Select.Trigger>
<Select.Content preventScroll={false} class="max-h-[min(480px,60vh)] border-border">
{#each modelGroups as group (group.value)}
<Select.Group>
<Select.GroupHeading
class="text-[10.5px] font-bold tracking-wider text-primary/80 uppercase"
>
{group.label}
</Select.GroupHeading>
{#each group.models as mo (mo.value)}
<Select.Item class="cursor-pointer" value={mo.value} label={mo.label}>
<!-- div, not span: the item base styles force flex row on spans -->
<div class="flex w-full flex-col items-start gap-0 leading-tight">
<span class="font-medium">{mo.label}</span>
{#if mo.resolution && mo.resolution !== 'varies'}
<span class="text-[11px] text-muted-foreground">
{mo.resolution} · updated {mo.update}
</span>
{:else}
<span class="text-[11px] text-muted-foreground">Automatic selection</span>
{/if}
</div>
</Select.Item>
{/each}
</Select.Group>
{/each}
</Select.Content>
</Select.Root>