From ea9eb17d7554a3314cd1fc358c4168c89fe5f5bf Mon Sep 17 00:00:00 2001 From: terraputix Date: Sun, 2 Aug 2026 22:24:51 +0200 Subject: [PATCH 1/9] multimodel improvements --- messages/de.json | 37 + messages/en.json | 37 + messages/es.json | 37 + messages/fr.json | 37 + messages/it.json | 37 + src/lib/charts/CanvasChart.svelte | 295 +++- src/lib/charts/data.ts | 19 +- src/lib/charts/index.ts | 8 +- src/lib/components/charts/ChartToolbar.svelte | 24 +- .../components/charts/downloadChartsPng.ts | 108 +- src/lib/components/charts/index.ts | 8 +- src/lib/services/weather.ts | 160 +- src/lib/utils/maps-domain.ts | 6 +- .../weather/compare/[location]/+page.svelte | 1342 ++++++++++++----- .../[location]/ModelPictogramTimeline.svelte | 839 +++++++++-- .../compare/[location]/comparison.spec.ts | 64 + .../weather/compare/[location]/comparison.ts | 130 ++ src/routes/weather/options.ts | 123 +- src/routes/weather/utils/weather-codes.ts | 8 + 19 files changed, 2493 insertions(+), 826 deletions(-) create mode 100644 src/routes/weather/compare/[location]/comparison.spec.ts create mode 100644 src/routes/weather/compare/[location]/comparison.ts diff --git a/messages/de.json b/messages/de.json index 84e1fb1..80c8bb4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -281,8 +281,45 @@ "model_selector_aria": "Auswahl: {label}", "compare_models_heading": "Modelle", "compare_variables_heading": "Stündliche Wettervariablen", + "compare_standard_preset": "Standardvergleich", + "compare_weather_conditions": "Wetterlage & Gesamtbewölkung", + "compare_wind_direction_height": "Windrichtung in {height} m", + "compare_standard_preset_description": "Ein ausgewogener Ausgangspunkt für den Vergleich von Vorhersagemodellen.", + "compare_preset_active": "Aktiv", + "compare_custom_selection": "Benutzerdefiniert · {count} ausgewählt", + "compare_customize_variables": "Variablen anpassen", + "compare_restore_defaults": "Standard wiederherstellen", + "compare_variables_pending": "Variablenauswahl geändert", + "compare_selected_models": "Ausgewählte Modelle", + "compare_selection_pending": "Auswahl geändert", + "compare_apply_selection": "Anwenden & Diagramme neu laden", + "compare_customize_models": "Modelle anpassen", + "compare_no_models_selected": "Keine Modelle ausgewählt.", + "compare_remove_model": "{model} entfernen", "compare_timeline_title": "Zeitleiste des Modellvergleichs", "compare_model_label": "Modell", + "page_compare_title": "Wettermodelle vergleichen", + "page_compare_description": "Stündliche Vorhersagen mehrerer Wettermodelle für jeden Ort vergleichen.", + "compare_direction_subtitle": "Richtungsstreuung über {count} Modelle · kein Mittelwert", + "compare_scalar_subtitle": "Über {count} Modelle · gestrichelt = Modellmittel", + "compare_precipitation_subtitle": "Über {count} Modelle · unterer Streifen = Niederschlagsübereinstimmung", + "compare_precipitation_agreement": "Übereinstimmung", + "compare_precipitation_agreement_tooltip": "{wet}/{available} mit Niederschlag · Median {median} {unit} · Spanne {min}–{max} {unit}", + "compare_model_mean": "Modellmittel", + "compare_showing_previous": "Der vorherige erfolgreiche Vergleich bleibt sichtbar.", + "compare_empty_selection": "Mindestens ein Modell und eine Wettervariable auswählen.", + "compare_no_data": "Keines der ausgewählten Modelle liefert für diesen Ort und diese Auswahl nutzbare Daten.", + "compare_weather_codes_only": "Wetterlage und Gesamtbewölkung erscheinen unten in der Modellzeitleiste. Für ein Diagramm eine weitere Variable auswählen.", + "compare_model_colors": "Modellfarben", + "compare_group_temperature": "Temperatur & Feuchte", + "compare_group_precipitation": "Niederschlag & Wetter", + "compare_group_clouds": "Druck & Wolken", + "compare_group_wind": "Wind & Atmosphäre", + "compare_group_upper_air": "Temperatur in der Höhe", + "compare_many_models_warning": "{count} Modelle sind ausgewählt. Der Vergleich kann langsamer und die Farben schwerer unterscheidbar sein.", + "compare_timeline_hint": "Wetter nach Modell; der Piktogramm-Hintergrund zeigt die Gesamtbewölkung", + "compare_timeline_scroll_aria": "Scrollbare Wetterzeitleiste der Modelle", + "compare_timeline_caption": "Stündliche Wetterbedingungen nach Vorhersagemodell", "ensemble_trimmed": "Das Ensemble dieses Modells reicht nur etwa {days} Tage voraus, die Streuung ist auf den verfügbaren Zeitraum gekürzt.", "seasonal_explainer_before": "Eine saisonale Vorhersage zeigt, wie stark ein ganzer Monat voraussichtlich", "seasonal_explainer_strong": "von seiner Klimanormalen abweicht", diff --git a/messages/en.json b/messages/en.json index a8b8526..63da40e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -281,8 +281,45 @@ "model_selector_aria": "{label} selection", "compare_models_heading": "Models", "compare_variables_heading": "Hourly Weather Variables", + "compare_standard_preset": "Standard comparison", + "compare_weather_conditions": "Weather conditions & cloud cover", + "compare_wind_direction_height": "Wind direction at {height} m", + "compare_standard_preset_description": "A balanced starting point for comparing forecast models.", + "compare_preset_active": "Active", + "compare_custom_selection": "Custom · {count} selected", + "compare_customize_variables": "Customize variables", + "compare_restore_defaults": "Restore defaults", + "compare_variables_pending": "Variable selection changed", + "compare_selected_models": "Selected models", + "compare_selection_pending": "Selection changed", + "compare_apply_selection": "Apply & reload charts", + "compare_customize_models": "Customize models", + "compare_no_models_selected": "No models selected.", + "compare_remove_model": "Remove {model}", "compare_timeline_title": "Model Comparison Timeline", "compare_model_label": "Model", + "page_compare_title": "Weather model comparison", + "page_compare_description": "Compare hourly forecasts from multiple weather models for any location.", + "compare_direction_subtitle": "Direction spread across {count} models · no mean", + "compare_scalar_subtitle": "Across {count} models · dashed = model mean", + "compare_precipitation_subtitle": "Across {count} models · lower strip = precipitation agreement", + "compare_precipitation_agreement": "Agreement", + "compare_precipitation_agreement_tooltip": "{wet}/{available} wet · median {median} {unit} · range {min}–{max} {unit}", + "compare_model_mean": "Model mean", + "compare_showing_previous": "The previous successful comparison remains visible.", + "compare_empty_selection": "Select at least one model and one weather variable to build a comparison.", + "compare_no_data": "None of the selected models provides usable data for this location and selection.", + "compare_weather_codes_only": "Weather conditions and cloud cover are shown in the model timeline below. Select another variable to add a chart.", + "compare_model_colors": "Model colors", + "compare_group_temperature": "Temperature & humidity", + "compare_group_precipitation": "Precipitation & conditions", + "compare_group_clouds": "Pressure & clouds", + "compare_group_wind": "Wind & atmosphere", + "compare_group_upper_air": "Upper-air temperature", + "compare_many_models_warning": "{count} models are selected. The comparison may be slower and individual colors harder to distinguish.", + "compare_timeline_hint": "Conditions by model; pictogram backgrounds show total cloud cover", + "compare_timeline_scroll_aria": "Scrollable model weather timeline", + "compare_timeline_caption": "Hourly weather conditions by forecast model", "ensemble_trimmed": "This model's ensemble only reaches about {days} days ahead, the spread is trimmed to its available range.", "seasonal_explainer_before": "A seasonal forecast shows how a whole month is likely to", "seasonal_explainer_strong": "depart from its climate normal", diff --git a/messages/es.json b/messages/es.json index 7b4924c..8355cfc 100644 --- a/messages/es.json +++ b/messages/es.json @@ -281,8 +281,45 @@ "model_selector_aria": "Selección de {label}", "compare_models_heading": "Modelos", "compare_variables_heading": "Variables meteorológicas horarias", + "compare_standard_preset": "Comparación estándar", + "compare_weather_conditions": "Condiciones y nubosidad", + "compare_wind_direction_height": "Dirección del viento a {height} m", + "compare_standard_preset_description": "Un punto de partida equilibrado para comparar modelos de previsión.", + "compare_preset_active": "Activa", + "compare_custom_selection": "Personalizada · {count} seleccionadas", + "compare_customize_variables": "Personalizar variables", + "compare_restore_defaults": "Restablecer selección estándar", + "compare_variables_pending": "Selección de variables modificada", + "compare_selected_models": "Modelos seleccionados", + "compare_selection_pending": "Selección modificada", + "compare_apply_selection": "Aplicar y recargar gráficos", + "compare_customize_models": "Personalizar modelos", + "compare_no_models_selected": "No hay modelos seleccionados.", + "compare_remove_model": "Quitar {model}", "compare_timeline_title": "Cronología de comparación de modelos", "compare_model_label": "Modelo", + "page_compare_title": "Comparación de modelos meteorológicos", + "page_compare_description": "Compara previsiones horarias de varios modelos meteorológicos para cualquier lugar.", + "compare_direction_subtitle": "Dispersión de dirección entre {count} modelos · sin media", + "compare_scalar_subtitle": "Entre {count} modelos · discontinuo = media de modelos", + "compare_precipitation_subtitle": "Entre {count} modelos · franja inferior = concordancia de precipitación", + "compare_precipitation_agreement": "Concordancia", + "compare_precipitation_agreement_tooltip": "{wet}/{available} con precipitación · mediana {median} {unit} · rango {min}–{max} {unit}", + "compare_model_mean": "Media de modelos", + "compare_showing_previous": "La comparación anterior sigue visible.", + "compare_empty_selection": "Selecciona al menos un modelo y una variable meteorológica.", + "compare_no_data": "Ninguno de los modelos seleccionados ofrece datos útiles para este lugar y selección.", + "compare_weather_codes_only": "Las condiciones y la nubosidad aparecen en la cronología inferior. Selecciona otra variable para añadir un gráfico.", + "compare_model_colors": "Colores de los modelos", + "compare_group_temperature": "Temperatura y humedad", + "compare_group_precipitation": "Precipitación y condiciones", + "compare_group_clouds": "Presión y nubes", + "compare_group_wind": "Viento y atmósfera", + "compare_group_upper_air": "Temperatura en altura", + "compare_many_models_warning": "Hay {count} modelos seleccionados. La comparación puede ser más lenta y los colores más difíciles de distinguir.", + "compare_timeline_hint": "Condiciones por modelo; el fondo de los pictogramas indica la nubosidad total", + "compare_timeline_scroll_aria": "Cronología meteorológica desplazable por modelos", + "compare_timeline_caption": "Condiciones meteorológicas horarias por modelo de previsión", "ensemble_trimmed": "El ensemble de este modelo solo llega unos {days} días, así que la dispersión se recorta a su rango disponible.", "seasonal_explainer_before": "Una previsión estacional muestra cuánto es probable que todo un mes", "seasonal_explainer_strong": "se desvíe de su normal climática", diff --git a/messages/fr.json b/messages/fr.json index 30c7e72..7169592 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -281,8 +281,45 @@ "model_selector_aria": "Sélection : {label}", "compare_models_heading": "Modèles", "compare_variables_heading": "Variables météo horaires", + "compare_standard_preset": "Comparaison standard", + "compare_weather_conditions": "Conditions & couverture nuageuse", + "compare_wind_direction_height": "Direction du vent à {height} m", + "compare_standard_preset_description": "Un point de départ équilibré pour comparer les modèles de prévision.", + "compare_preset_active": "Active", + "compare_custom_selection": "Personnalisée · {count} sélectionnées", + "compare_customize_variables": "Personnaliser les variables", + "compare_restore_defaults": "Rétablir la sélection standard", + "compare_variables_pending": "Sélection des variables modifiée", + "compare_selected_models": "Modèles sélectionnés", + "compare_selection_pending": "Sélection modifiée", + "compare_apply_selection": "Appliquer et recharger les graphiques", + "compare_customize_models": "Personnaliser les modèles", + "compare_no_models_selected": "Aucun modèle sélectionné.", + "compare_remove_model": "Supprimer {model}", "compare_timeline_title": "Chronologie de comparaison des modèles", "compare_model_label": "Modèle", + "page_compare_title": "Comparaison des modèles météo", + "page_compare_description": "Comparez les prévisions horaires de plusieurs modèles météo pour n’importe quel lieu.", + "compare_direction_subtitle": "Dispersion des directions sur {count} modèles · sans moyenne", + "compare_scalar_subtitle": "Sur {count} modèles · pointillés = moyenne des modèles", + "compare_precipitation_subtitle": "Sur {count} modèles · bande inférieure = accord sur les précipitations", + "compare_precipitation_agreement": "Accord", + "compare_precipitation_agreement_tooltip": "{wet}/{available} avec précipitations · médiane {median} {unit} · plage {min}–{max} {unit}", + "compare_model_mean": "Moyenne des modèles", + "compare_showing_previous": "La dernière comparaison réussie reste affichée.", + "compare_empty_selection": "Sélectionnez au moins un modèle et une variable météo.", + "compare_no_data": "Aucun des modèles sélectionnés ne fournit de données exploitables pour ce lieu et cette sélection.", + "compare_weather_codes_only": "Les conditions et la couverture nuageuse sont affichées dans la chronologie ci-dessous. Sélectionnez une autre variable pour ajouter un graphique.", + "compare_model_colors": "Couleurs des modèles", + "compare_group_temperature": "Température et humidité", + "compare_group_precipitation": "Précipitations et conditions", + "compare_group_clouds": "Pression et nuages", + "compare_group_wind": "Vent et atmosphère", + "compare_group_upper_air": "Température en altitude", + "compare_many_models_warning": "{count} modèles sont sélectionnés. La comparaison peut être plus lente et les couleurs plus difficiles à distinguer.", + "compare_timeline_hint": "Conditions par modèle ; le fond des pictogrammes indique la couverture nuageuse totale", + "compare_timeline_scroll_aria": "Chronologie météo des modèles à défilement horizontal", + "compare_timeline_caption": "Conditions météo horaires par modèle de prévision", "ensemble_trimmed": "L'ensemble de ce modèle ne va qu'à environ {days} jours, la dispersion est donc limitée à sa portée disponible.", "seasonal_explainer_before": "Une prévision saisonnière montre dans quelle mesure un mois entier devrait", "seasonal_explainer_strong": "s'écarter de sa normale climatique", diff --git a/messages/it.json b/messages/it.json index ad8ece2..e230e28 100644 --- a/messages/it.json +++ b/messages/it.json @@ -281,8 +281,45 @@ "model_selector_aria": "Selezione di {label}", "compare_models_heading": "Modelli", "compare_variables_heading": "Variabili meteo orarie", + "compare_standard_preset": "Confronto standard", + "compare_weather_conditions": "Condizioni e copertura nuvolosa", + "compare_wind_direction_height": "Direzione del vento a {height} m", + "compare_standard_preset_description": "Un punto di partenza equilibrato per confrontare i modelli previsionali.", + "compare_preset_active": "Attivo", + "compare_custom_selection": "Personalizzata · {count} selezionate", + "compare_customize_variables": "Personalizza le variabili", + "compare_restore_defaults": "Ripristina la selezione standard", + "compare_variables_pending": "Selezione delle variabili modificata", + "compare_selected_models": "Modelli selezionati", + "compare_selection_pending": "Selezione modificata", + "compare_apply_selection": "Applica e ricarica i grafici", + "compare_customize_models": "Personalizza modelli", + "compare_no_models_selected": "Nessun modello selezionato.", + "compare_remove_model": "Rimuovi {model}", "compare_timeline_title": "Cronologia del confronto tra modelli", "compare_model_label": "Modello", + "page_compare_title": "Confronto dei modelli meteo", + "page_compare_description": "Confronta le previsioni orarie di più modelli meteo per qualsiasi località.", + "compare_direction_subtitle": "Dispersione della direzione tra {count} modelli · senza media", + "compare_scalar_subtitle": "Tra {count} modelli · tratteggiato = media dei modelli", + "compare_precipitation_subtitle": "Tra {count} modelli · fascia inferiore = concordanza sulle precipitazioni", + "compare_precipitation_agreement": "Concordanza", + "compare_precipitation_agreement_tooltip": "{wet}/{available} con precipitazioni · mediana {median} {unit} · intervallo {min}–{max} {unit}", + "compare_model_mean": "Media dei modelli", + "compare_showing_previous": "Il confronto precedente rimane visibile.", + "compare_empty_selection": "Seleziona almeno un modello e una variabile meteo.", + "compare_no_data": "Nessuno dei modelli selezionati fornisce dati utilizzabili per questa località e selezione.", + "compare_weather_codes_only": "Le condizioni e la copertura nuvolosa sono mostrate nella cronologia in basso. Seleziona un’altra variabile per aggiungere un grafico.", + "compare_model_colors": "Colori dei modelli", + "compare_group_temperature": "Temperatura e umidità", + "compare_group_precipitation": "Precipitazioni e condizioni", + "compare_group_clouds": "Pressione e nuvole", + "compare_group_wind": "Vento e atmosfera", + "compare_group_upper_air": "Temperatura in quota", + "compare_many_models_warning": "Sono selezionati {count} modelli. Il confronto può essere più lento e i colori più difficili da distinguere.", + "compare_timeline_hint": "Condizioni per modello; lo sfondo dei pittogrammi indica la copertura nuvolosa totale", + "compare_timeline_scroll_aria": "Cronologia meteo scorrevole dei modelli", + "compare_timeline_caption": "Condizioni meteo orarie per modello di previsione", "ensemble_trimmed": "L'ensemble di questo modello arriva solo a circa {days} giorni, quindi la dispersione è ridotta al periodo disponibile.", "seasonal_explainer_before": "Una previsione stagionale mostra quanto un intero mese potrebbe", "seasonal_explainer_strong": "discostarsi dalla sua normale climatica", diff --git a/src/lib/charts/CanvasChart.svelte b/src/lib/charts/CanvasChart.svelte index ac480fe..825b22f 100644 --- a/src/lib/charts/CanvasChart.svelte +++ b/src/lib/charts/CanvasChart.svelte @@ -24,13 +24,15 @@ /** Series display name (used in legend and tooltip) */ name: string; /** Render style */ - type: 'line' | 'bar'; + type: 'line' | 'bar' | 'point'; /** Any CSS color string */ color: string; /** One value per timestamp; null values break lines */ data: (number | null)[]; /** Line width in px (default 2); 0 draws only the area fill */ width?: number; + /** Radius in px for point series (default 3). */ + pointRadius?: number; /** Draw a low-alpha area fill below (or above, on inverted axes) the line */ fill?: boolean; /** Area fill coloured by the value scale (segmentColor), fading to @@ -74,6 +76,20 @@ cloudLayer?: 'high' | 'mid' | 'low'; } + export interface ChartAgreementPoint { + wetCount: number; + availableCount: number; + median: number; + min: number; + max: number; + } + + export interface ChartAgreementStrip { + points: (ChartAgreementPoint | null)[]; + label: string; + format: (point: ChartAgreementPoint) => string; + } + interface GroupState { range: { start: number; end: number } | null; hover: number | null; @@ -101,6 +117,17 @@ } } + /** Register a non-chart surface that participates in synchronized interactions. */ + export function registerGroupMember(name: string): () => void { + acquireGroup(name); + let registered = true; + return () => { + if (!registered) return; + registered = false; + releaseGroup(name); + }; + } + /** * Drive the shared crosshair of a chart group from the outside (e.g. hovering * the hourly table). `time` is epoch seconds, or null to clear. No-op if no @@ -108,7 +135,16 @@ */ export function setGroupHover(name: string, time: number | null): void { const state = groups[name]; - if (state) state.hover = time; + if (state && state.hover !== time) state.hover = time; + } + + /** Set the synchronized visible time range for a registered chart group. */ + export function setGroupRange( + name: string, + range: { start: number; end: number } | null + ): void { + const state = groups[name]; + if (state) state.range = range; } /** Current shared zoom range of a group (null = full range), reactive. */ @@ -151,6 +187,8 @@ windArrows?: { t: number; deg: number }[]; /** Highlighted time range (epoch seconds), e.g. the selected day */ highlight?: { start: number; end: number }; + /** Compact per-timestamp model-agreement band below the plot. */ + agreementStrip?: ChartAgreementStrip; /** Unit label for the left y axis (also used in tooltip values) */ unit?: string; /** Unit label for the right y axis; when set, right-axis labels are drawn */ @@ -167,6 +205,10 @@ yPadBottom?: number; /** Fixed left-axis maximum */ yMax?: number; + /** Explicit left-axis tick values (for categorical/fixed domains). */ + yTicks?: number[]; + /** Custom left-axis tick formatter. */ + yTickFormat?: (value: number) => string; /** Force the derived left axis to include zero (default true) */ zeroBaseLeft?: boolean; /** Fixed right-axis minimum (default 0) */ @@ -181,6 +223,10 @@ /** Reserve this many top icon-rows even if this chart has fewer, so a row * of stacked charts share the same plot rectangle */ reserveTopRows?: number; + /** Override the plot's left inset so heterogeneous synchronized panels align. */ + plotInsetLeft?: number; + /** Override the plot's right inset so heterogeneous synchronized panels align. */ + plotInsetRight?: number; /** Chart title drawn top-left on the canvas */ title?: string; /** Smaller subtitle drawn under the title */ @@ -191,6 +237,8 @@ showNow?: boolean; /** Draw the Open-Meteo.com credit bottom-right */ showCredit?: boolean; + /** Accessible description for the interactive canvas. */ + ariaLabel?: string; /** Optional CSS class for the outer container */ class?: string; } @@ -203,12 +251,15 @@ pictograms = [], windArrows = [], highlight, + agreementStrip, unit = '', unitRight, height = 300, group, yMin, yMax, + yTicks, + yTickFormat, yPadTop, yPadBottom, zeroBaseLeft = true, @@ -217,11 +268,14 @@ invertRight = false, reserveRightAxis = false, reserveTopRows = 0, + plotInsetLeft, + plotInsetRight, title, subtitle, showLegend = false, showNow = true, showCredit = false, + ariaLabel, class: className = '' }: Props = $props(); @@ -232,6 +286,8 @@ // ─── Constants ────────────────────────────────────────────────────────────── const PAD_BOTTOM = 34; + const AGREEMENT_STRIP_HEIGHT = 10; + const AGREEMENT_STRIP_GAP = 3; const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours const HOUR = 3600; // Top icon rows (weather pictograms / wind arrows) @@ -274,6 +330,20 @@ return color; } + function agreementMarkerColor(point: ChartAgreementPoint): string { + if (point.wetCount === 0) return '#6b7280'; + const wetShare = point.wetCount / Math.max(1, point.availableCount); + return wetShare <= 0.5 ? '#d97706' : '#2563eb'; + } + + function agreementFillColor(point: ChartAgreementPoint, mutedColor: string): string { + if (point.wetCount === 0) return withAlpha(mutedColor, 0.3); + const wetShare = point.wetCount / Math.max(1, point.availableCount); + const agreement = Math.abs(wetShare - 0.5) * 2; + if (wetShare <= 0.5) return withAlpha('#d97706', 0.35 + (1 - agreement) * 0.35); + return withAlpha('#2563eb', 0.3 + agreement * 0.6); + } + /** * Light [1 2 1] smoothing pass. Cloud cover is noisy hour to hour; smoothing * the values before interpolating gives the band a slow, rolling silhouette @@ -368,6 +438,10 @@ const groupState: GroupState | null = groupName ? acquireGroup(groupName) : null; let localRange = $state<{ start: number; end: number } | null>(null); let localHover = $state(null); + // Pointer hover is visual only. Live-region announcements are enabled by + // keyboard navigation so mouse movement does not continuously invalidate the + // accessibility tree (and only the keyboard-focused chart announces changes). + let announceHover = $state(false); onDestroy(() => { if (groupName) releaseGroup(groupName); @@ -407,12 +481,14 @@ return () => mq.removeEventListener('change', apply); }); let isNarrow = $derived(belowDesktop || (width > 0 && width < 520)); - let padLeft = $derived(isNarrow ? 26 : 60); + let padLeft = $derived(plotInsetLeft ?? (isNarrow ? (yTicks?.length ? 52 : 26) : 60)); // Reserve the right gutter when this chart (or a sibling, via reserveRightAxis) // has a right axis, so a stacked row of charts share the same plot width. On // narrow (mobile) screens we don't reserve it — the graph uses the full width // and the right-axis labels are drawn overlaid on top instead (see below). - let padRight = $derived(isNarrow ? 6 : hasRightAxis || reserveRightAxis ? 56 : 20); + let padRight = $derived( + plotInsetRight ?? (isNarrow ? 6 : hasRightAxis || reserveRightAxis ? 56 : 20) + ); // Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps // a stacked row of charts the same height even if some have fewer icon rows — // but only on wide screens: on mobile that uniform band wastes precious @@ -422,7 +498,10 @@ // tighter top/bottom gutters on mobile so charts don't waste vertical space const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H); let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 14 : 28) + iconRows * iconRowH); - let padBottom = $derived(isNarrow ? 24 : PAD_BOTTOM); + let agreementStripOffset = $derived( + agreementStrip ? AGREEMENT_STRIP_HEIGHT + AGREEMENT_STRIP_GAP + 2 : 0 + ); + let padBottom = $derived((isNarrow ? 24 : PAD_BOTTOM) + agreementStripOffset); let plotW = $derived(Math.max(1, width - padLeft - padRight)); let plotH = $derived(Math.max(1, height - padTop - padBottom)); @@ -536,8 +615,11 @@ } function setHover(time: number | null): void { - if (groupState) groupState.hover = time; - else localHover = time; + if (groupState) { + if (groupState.hover !== time) groupState.hover = time; + } else if (localHover !== time) { + localHover = time; + } } function applyRange(start: number, end: number): void { @@ -569,7 +651,7 @@ // The canvas alone omits the DOM overlays (weather/wind icons), the panel // title (rendered by the parent), and the legend (an HTML row). This builds a // standalone canvas that includes all of them so downloads look like the page. - const iconImageCache = new Map>(); + const iconImageCache = new SvelteMap>(); function loadColoredIcon(name: string, color: string): Promise { const key = `${name}|${color}`; let p = iconImageCache.get(key); @@ -762,12 +844,23 @@ : `${v.toFixed(1)}${axisUnit ? ' ' + axisUnit : ''}`; rows.push({ name: s.shortName ?? s.name, color: s.color, value }); } + const agreementPoint = agreementStrip?.points[hoverIdx]; + if (agreementStrip && agreementPoint && agreementPoint.availableCount > 0) { + rows.push({ + name: agreementStrip.label, + color: agreementMarkerColor(agreementPoint), + value: agreementStrip.format(agreementPoint) + }); + } return rows; }); let tooltipVisible = $derived(hoverIdx >= 0 && tooltipRows.length > 0 && width > 0); let tooltipX = $derived(hoverIdx >= 0 ? xPix(timestamps[hoverIdx]) : 0); let tooltipFlip = $derived(tooltipX > width * 0.55); + let crosshairVisible = $derived( + hoverIdx >= 0 && timestamps[hoverIdx] >= viewStart && timestamps[hoverIdx] <= viewEnd && width > 0 + ); // ─── Pictograms (DOM overlay across the top) ───────────────────────────────── @@ -979,11 +1072,13 @@ ctx.font = font; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; - for ( - let v = firstTick(leftScale); - v <= leftScale.max + leftScale.step / 2; - v += leftScale.step - ) { + const leftTicks = + yTicks ?? + Array.from( + { length: Math.floor((leftScale.max - firstTick(leftScale)) / leftScale.step) + 1 }, + (_, i) => firstTick(leftScale) + i * leftScale.step + ); + for (const v of leftTicks) { const y = yPix(v, 'left'); ctx.strokeStyle = gridColor; ctx.lineWidth = 1; @@ -992,7 +1087,7 @@ ctx.lineTo(plotRight, y); ctx.stroke(); ctx.fillStyle = textColor; - ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), padLeft - 8, y); + ctx.fillText(yTickFormat?.(v) ?? v.toFixed(tickDecimals(leftScale.step)), padLeft - 8, y); } // Right axis labels in the gutter (desktop only). On mobile there is no @@ -1033,7 +1128,7 @@ ctx.fillStyle = textColor; } ctx.textAlign = 'center'; - ctx.fillText(tick.label, x, plotBottom + 7); + ctx.fillText(tick.label, x, plotBottom + agreementStripOffset + 7); } // Series (clipped to the plot area) @@ -1184,6 +1279,24 @@ run = []; continue; } + + if (s.type === 'point') { + const radius = s.pointRadius ?? 3; + ctx.fillStyle = s.color; + ctx.strokeStyle = outlineColor; + ctx.lineWidth = 1; + for (let i = 0; i < timestamps.length; i++) { + const v = s.data[i]; + if (v === null || v === undefined || !isFinite(v)) continue; + const t = timestamps[i]; + if (t < viewStart || t > viewEnd) continue; + ctx.beginPath(); + ctx.arc(xPix(t), yPix(v, axis), radius, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + continue; + } run.push([xPix(timestamps[i]), yPix(v, axis), s.bandTo ? yPix(b as number, axis) : 0, i]); } if (run.length > 0) runs.push(run); @@ -1406,24 +1519,42 @@ } } - // Crosshair (snapped to the nearest timestamp) - if (hoverIdx >= 0) { - const t = timestamps[hoverIdx]; - if (t >= viewStart && t <= viewEnd) { - const x = xPix(t); - ctx.strokeStyle = textColor; - ctx.lineWidth = 1; - ctx.setLineDash([4, 4]); - ctx.beginPath(); - ctx.moveTo(x, padTop); - ctx.lineTo(x, plotBottom); - ctx.stroke(); - ctx.setLineDash([]); - } - } - ctx.restore(); + if (agreementStrip) { + const stripTop = plotBottom + AGREEMENT_STRIP_GAP; + ctx.save(); + ctx.beginPath(); + ctx.rect(padLeft, stripTop, plotW, AGREEMENT_STRIP_HEIGHT); + ctx.clip(); + for (let index = 0; index < timestamps.length; index++) { + const point = agreementStrip.points[index]; + if (!point || point.availableCount === 0) continue; + const time = timestamps[index]; + if (time < viewStart - interval || time > viewEnd + interval) continue; + const left = Math.max(padLeft, xPix(time - interval / 2)); + const right = Math.min(plotRight, xPix(time + interval / 2)); + if (right <= left) continue; + ctx.fillStyle = agreementFillColor(point, textColor); + ctx.fillRect(left, stripTop, Math.max(1, right - left + 0.5), AGREEMENT_STRIP_HEIGHT); + } + ctx.restore(); + + ctx.strokeStyle = withAlpha(gridColor, 0.8); + ctx.lineWidth = 1; + ctx.strokeRect(padLeft, stripTop + 0.5, plotW, AGREEMENT_STRIP_HEIGHT - 1); + ctx.fillStyle = textColor; + ctx.font = '600 9px system-ui, sans-serif'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillText( + agreementStrip.label, + padLeft - 6, + stripTop + AGREEMENT_STRIP_HEIGHT / 2, + Math.max(24, padLeft - 10) + ); + } + // Axis unit labels ctx.font = font; ctx.textBaseline = 'alphabetic'; @@ -1515,13 +1646,47 @@ let gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' | 'select' = 'none'; let touchStart: { x: number; y: number; start: number; end: number } | null = null; let selStartX = 0; // drag-to-zoom anchor (canvas-local px) + let hoverFrame = 0; + let queuedClientX = 0; + let queuedHoverAction: 'none' | 'position' | 'clear' = 'none'; const localX = (e: { clientX: number }): number => e.clientX - el.getBoundingClientRect().left; const clampPlotX = (x: number): number => Math.max(padLeft, Math.min(width - padRight, x)); - const updateHover = (e: PointerEvent): void => { - const t = pixToTime(localX(e)); - setHover(t >= viewStart && t <= viewEnd ? t : null); + const scheduleHoverFrame = (): void => { + if (hoverFrame !== 0) return; + hoverFrame = requestAnimationFrame(() => { + hoverFrame = 0; + const action = queuedHoverAction; + queuedHoverAction = 'none'; + if (action === 'clear') { + setHover(null); + return; + } + if (action !== 'position') return; + + // Geometry reads and timestamp lookup happen once per animation frame, + // even when a high-polling pointer emits many events between frames. + const t = pixToTime(queuedClientX - el.getBoundingClientRect().left); + const time = + t >= viewStart && t <= viewEnd && timestamps.length > 0 + ? timestamps[nearestIndex(timestamps, t)] + : null; + setHover(time); + }); + }; + + const scheduleHoverPosition = (clientX: number): void => { + if (announceHover) announceHover = false; + queuedClientX = clientX; + queuedHoverAction = 'position'; + scheduleHoverFrame(); + }; + + const scheduleHoverClear = (): void => { + if (announceHover) announceHover = false; + queuedHoverAction = 'clear'; + scheduleHoverFrame(); }; const onPointerDown = (e: PointerEvent): void => { @@ -1533,7 +1698,7 @@ pinchStart = { dist: Math.max(10, Math.abs(a.x - b.x)), start: viewStart, end: viewEnd }; panStart = null; gesture = 'pinch'; - setHover(null); + scheduleHoverClear(); return; } @@ -1573,7 +1738,7 @@ const x = clampPlotX(localX(e)); if (dragSelect || Math.abs(x - selStartX) >= 3) { dragSelect = { x0: selStartX, x1: x }; - setHover(null); + scheduleHoverClear(); } return; } @@ -1603,7 +1768,7 @@ return; } - if (pointers.size <= 1) updateHover(e); + if (pointers.size <= 1) scheduleHoverPosition(e.clientX); }; const onPointerUp = (e: PointerEvent): void => { @@ -1622,11 +1787,11 @@ touchStart = null; gesture = 'none'; } - if (e.pointerType !== 'mouse') setHover(null); + if (e.pointerType !== 'mouse') scheduleHoverClear(); }; const onPointerLeave = (): void => { - if (pointers.size === 0) setHover(null); + if (pointers.size === 0) scheduleHoverClear(); }; const onWheel = (e: WheelEvent): void => { @@ -1660,6 +1825,7 @@ el.removeEventListener('pointerleave', onPointerLeave); el.removeEventListener('wheel', onWheel); el.removeEventListener('dblclick', onDblClick); + if (hoverFrame !== 0) cancelAnimationFrame(hoverFrame); }; }); @@ -1667,6 +1833,29 @@ if (legendHidden.has(name)) legendHidden.delete(name); else legendHidden.add(name); } + + function handleKeydown(event: KeyboardEvent): void { + if (timestamps.length === 0) return; + const current = hoverTime ?? viewStart; + const index = nearestIndex(timestamps, current); + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + const delta = event.key === 'ArrowLeft' ? -1 : 1; + const next = Math.max(0, Math.min(timestamps.length - 1, index + delta)); + announceHover = true; + setHover(timestamps[next]); + event.preventDefault(); + } else if (event.key === '+' || event.key === '=') { + zoomAt(current, 1 / 1.3); + event.preventDefault(); + } else if (event.key === '-' || event.key === '_') { + zoomAt(current, 1.3); + event.preventDefault(); + } else if (event.key === 'Escape') { + announceHover = false; + setViewRange(null); + setHover(null); + } + }
@@ -1676,7 +1865,16 @@ class="block w-full" style:height="{height}px" style:touch-action="pan-y" + tabindex="0" + aria-label={ariaLabel} + onkeydown={handleKeydown} > +

+ {#if announceHover && tooltipVisible} + {formatZoned(new Date(timestamps[hoverIdx] * 1000), timezone, 'EEE d MMM HH:mm')}: + {tooltipRows.map((row) => `${row.name} ${row.value}`).join(', ')} + {/if} +

{#if dragSelect} @@ -1685,7 +1883,7 @@ style:left="{Math.min(dragSelect.x0, dragSelect.x1)}px" style:top="{padTop}px" style:width="{Math.abs(dragSelect.x1 - dragSelect.x0)}px" - style:height="{plotH}px" + style:height="{plotH + agreementStripOffset}px" >
{/if} @@ -1713,7 +1911,7 @@ {/if} - {#if visibleWindArrows.length > 0} + {#if visibleWindArrows.length > 0}
{/each}
- {/if} + {/if} - {#if tooltipVisible} + + {#if crosshairVisible} +
+ {/if} + + {#if tooltipVisible}
(timeLength).fill(0); + const totals = new Array(timeLength).fill(0); const averageCount = new Array(timeLength).fill(0); for (const [model, values] of Object.entries(hourlyData)) { @@ -71,18 +71,17 @@ export function calculateAverage( for (const [index, val] of (values as number[]).entries()) { if (val !== null && val !== undefined && isFinite(val)) { - average[index] += val; + if (index >= timeLength) continue; + totals[index] += val; averageCount[index]++; } } } // Finalize average values - for (let i = 0; i < timeLength; i++) { - if (averageCount[i] > 0) { - average[i] = Math.round((average[i] / averageCount[i]) * 10) / 10; - } - } + const average = totals.map((total, i) => + averageCount[i] > 0 ? Math.round((total / averageCount[i]) * 10) / 10 : null + ); return { average, averageCount }; } diff --git a/src/lib/charts/index.ts b/src/lib/charts/index.ts index 820a60e..bb529ed 100644 --- a/src/lib/charts/index.ts +++ b/src/lib/charts/index.ts @@ -8,10 +8,16 @@ export { default as CanvasChart, setGroupHover, + setGroupRange, + registerGroupMember, groupRange, groupHover } from './CanvasChart.svelte'; -export type { ChartSeries } from './CanvasChart.svelte'; +export type { + ChartAgreementPoint, + ChartAgreementStrip, + ChartSeries +} from './CanvasChart.svelte'; export { buildDaylightBands } from './bands'; export type { DaylightBand } from './bands'; diff --git a/src/lib/components/charts/ChartToolbar.svelte b/src/lib/components/charts/ChartToolbar.svelte index 941b8d5..d4ae086 100644 --- a/src/lib/components/charts/ChartToolbar.svelte +++ b/src/lib/components/charts/ChartToolbar.svelte @@ -18,14 +18,15 @@ {/snippet} --> - - - - -{#snippet heroActions()} - -
- + + {m.page_compare_title()} | Drizz.li + + + + +{#snippet rangeControls(compact = false)} +
{#if zoomActive} - - - - - {m.reset_zoom()} - {/if} -
- {#each rangePresets as preset (preset.label)} +
+ {#each rangePresets as preset (preset.label())} - {preset.label} - {/each}
{/snippet} - +{#snippet heroActions()} + +{/snippet} + +
{@render rangeControls(true)}
{#if loadError} -{/if} - - -{#if fetchedData} - -
- {#each chartDefs as def, i (i)} -
-
-

{def.title}

- {#if def.subtitle} -

{def.subtitle}

- {/if} -
- - - -
- {/each} -
-{:else} - - v !== 'weather_code').length || 1} - chartHeight={340} - bleed={false} - /> -{/if} - -{#if fetchedData && !loading} - } - models={params.models || []} - sunrise={fetchedData.sunrise} - sunset={fetchedData.sunset} - timezone={fetchedData.timezone} - /> -{/if} - - - -
- - {#snippet controls()} -
- - -
- {/snippet} -
-
- - - -
-
-

{m.compare_models_heading()}

{loadError.title}

+ {#if loadError.hint}

{loadError.hint}

{/if} + {#if fetchedData}

+ {m.compare_showing_previous()} +

{/if} + - {#if params.models && params.models.length > 0} -
+ {#if loadError.detail} +
+ {m.error_technical_details()} +

{loadError.detail}

+
+ {/if} +
+{/if} + +{#if selectionEmpty} +
+ {m.compare_empty_selection()} +
+{:else if noUsableData} +
+ {m.compare_no_data()} +
+{:else if loadError && !fetchedData} + +{:else if fetchedData && chartVariablesEmpty} +
+ {m.compare_weather_codes_only()} +
+{:else if fetchedData} +
+
+ {#each chartDefs as def, i (def.title)} +
+
+

{def.title}

+

{def.subtitle}

+
+ + + +
+ {/each} +
+ {#if comparisonMuted} +
- {params.models?.length || 0} / {models.flat().length} + + {m.compare_selection_pending()} + +
{/if}
+{:else} + v !== 'weather_code' && v !== 'cloud_cover').length || 1} + chartHeight={300} + bleed={false} + /> +{/if} -
- {#each models as group, i (i)} -
- {#each group as item (item.value)} - {@const { value, label } = item as { value: string; label: string }} -
- { - if (params.models?.includes(value)) { - params.models = params.models.filter((item) => { - return item !== value; - }); - } else if (params.models) { - params.models = [...params.models, value]; - } - }} - /> - -
- {/each} +{#if fetchedData && !loading && appliedHourly.includes('weather_code')} +
+ (pictogramExporter = exporter)} + /> +
+{/if} + +{#if fetchedData && displayedModels.length > 0} +
+
+

{m.compare_model_colors()}

+
+ +
- {/each} +
+ {#if showLegend} +
+ {#each displayedModels as model (model.modelId)} + + {/each} + {#if chartDefs.some( (def) => def.series.some((series) => series.name === m.compare_model_mean()) )} + + + {m.compare_model_mean()} + + {/if} +
+ {/if}
- +
+ ({ chart: chartComponents[i], title: def.title })), + { chart: pictogramExporter, title: m.compare_timeline_title() } + ]} + fileName="model-comparison" + exportOptions={{ + title: `${location.name} · ${m.page_compare_subtitle()}`, + legend: exportLegend + }} + /> +
+{/if} -
-
-

- {m.compare_variables_heading()} -

- {#if params.hourly && params.hourly.length > 0} -
-
- {params.hourly?.length || 0} / {hourly.flat().length} -
+ +
+
+

{m.compare_models_heading()}

+ + {params.models.length}/{modelOptions.length} + +
+ {#if selectedModelWarning} +

+ {m.compare_many_models_warning({ count: params.models.length })} +

+ {/if} +
+
+
+
+

{m.compare_selected_models()}

+ {#if modelsDirty} + + {m.compare_selection_pending()} + + {/if}
- {/if} + {#if selectedModels.length > 0} +
+ {#each selectedModels as model, index (model.value)} + + {/each} +
+ {:else} +

{m.compare_no_models_selected()}

+ {/if} +
+
+ + {#if modelsDirty} + + {/if} +
-
- {#each hourly as group, i (i)} -
- {#each group as { value, label } (value)} -
- { - if (params.hourly?.includes(value)) { - params.hourly = params.hourly.filter((item) => { - return item !== value; - }); - } else if (params.hourly) { - params.hourly = [...params.hourly, value]; - } - }} - /> - +
+ {#each modelSelectionGroups as group (group.value)} +
+ -
+ {group.label} + + {group.selectedCount}/{group.models.length} + + + +
+ {#each group.models as model (model.value)} +
+ toggleSelection('models', model.value)} + /> + +
+ {/each} +
+ {/each}
- {/each} -
+
+ {/if}
-
+ + +
+
+

+ {m.compare_variables_heading()} +

+ + {params.hourly.length}/{allHourlyVariables.length} + +
+ +
+
+
+ +
+
+

{m.compare_standard_preset()}

+ + {variablesDirty + ? m.compare_variables_pending() + : standardVariablesActive + ? m.compare_preset_active() + : m.compare_custom_selection({ count: params.hourly.length })} + +
+

+ {m.compare_standard_preset_description()} +

+ {#if selectedVariables.length > 0} +

+ {selectedVariables.map((variable) => variableLabel(variable.value)).join(' · ')} +

+ {/if} +
+
+
+ {#if !standardVariablesActive} + + {/if} + + {#if variablesDirty} + + {/if} +
+
+ + {#if customVariablesOpen} +
+
+ {#each variableSelectionGroups as variableGroup (variableGroup.label())} +
+ + {variableGroup.label()} + + {variableGroup.selectedCount}/{variableGroup.variables.length} + + + +
+ {#each variableGroup.variables as variable (variable.value)} +
+ toggleSelection('hourly', variable.value)} + /> + +
+ {/each} +
+
+ {/each} +
+
+ {/if} +
+
diff --git a/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte b/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte index ef64421..6a27d71 100644 --- a/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte +++ b/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte @@ -1,154 +1,739 @@ -{#snippet weatherIcon(name: string, size: number = 24)} - - - -{/snippet} - {#if displayModels.length > 0} -
-
-

{m.compare_timeline_title()}

-
- 3h - - 1h +
+
+

+ {m.compare_timeline_title()} +

+

{m.compare_timeline_hint()}

+
+
+
+ + {#if dragSelect} +
+ {/if} + {#if hoverX !== null} +
+ {/if} + {#if tooltipVisible && hoverX !== null && sharedHover !== null} +
+
+ {formatZoned(new Date(sharedHover * 1000), timezone, 'EEE d MMM HH:mm')} +
+ {#each tooltipRows as row (row.modelId)} +
+ + {row.label}: + + {row.condition}{#if row.cloudCover !== null} + · {m.var_cloud()} {row.cloudCover}% + {/if} + +
+ {/each} +
+ {/if}
-
- - - - - {#each filteredIndices as idx, i (idx)} - {@const ts = timestamps[idx]} - {@const isNewDay = checkNewDay(i, ts)} - - {/each} - - - - {#each displayModels as model (model)} - - - {#each filteredIndices as idx, i (idx)} - {@const ts = timestamps[idx]} - {@const codes = hourlyFlat[`weather_code_${model}`]} - {@const code = codes ? codes[idx] : 0} - {@const day = allDaytimeFlags[idx]} - {@const isNewDay = checkNewDay(i, ts)} - - {/each} - - {/each} - -
- {m.compare_model_label()} - -
{formatZoned(new Date(ts), timezone, 'HH')}
-
- {isNewDay ? formatZoned(new Date(ts), timezone, 'EEE d') : ''} -
-
- {model.replace(/_/g, ' ')} - - {@render weatherIcon(getWeatherIconName(code, day))} -
-
-
+ {/if} - - diff --git a/src/routes/weather/compare/[location]/comparison.spec.ts b/src/routes/weather/compare/[location]/comparison.spec.ts new file mode 100644 index 0000000..9659b0c --- /dev/null +++ b/src/routes/weather/compare/[location]/comparison.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { + cardinalDirection, + isWindDirection, + modelColor, + modelMean, + sanitizeList, + validDirectionValues +} from './comparison'; + +import type { ModelSeriesData } from '$lib/services/weather'; + +const models: ModelSeriesData[] = [ + { + modelId: 'model-a', + resolvedModelId: 'model-a', + variables: { + precipitation: [1, Number.NaN, 3], + precipitation_probability: [80, 90, 100] + } + }, + { + modelId: 'model-b', + resolvedModelId: 'model-b', + variables: { precipitation: [3, Number.NaN, 5] } + } +]; + +describe('multimodel comparison helpers', () => { + it('averages only the exact scalar variable and preserves missing values', () => { + expect(modelMean(models, 'precipitation', 3)).toEqual([2, null, 4]); + }); + + it('validates and deduplicates shared URL selections', () => { + const allowed = new Set(['a', 'b']); + expect(sanitizeList(['a', 'unknown', 'a', 'b'], allowed)).toEqual(['a', 'b']); + expect(sanitizeList(['unknown'], allowed)).toBeNull(); + }); + + it('treats every wind direction height as a direction series', () => { + expect(isWindDirection('wind_direction_10m')).toBe(true); + expect(isWindDirection('wind_direction_180m')).toBe(true); + expect(isWindDirection('wind_speed_10m')).toBe(false); + }); + + it('drops direction values outside the fixed 0–360 degree domain', () => { + expect(validDirectionValues([-1, 0, 90, 360, 361, Number.NaN])).toEqual([ + null, + 0, + 90, + 360, + null, + null + ]); + expect(cardinalDirection(0)).toBe('N'); + expect(cardinalDirection(270)).toBe('W'); + }); + + it('assigns deterministic model colors', () => { + expect(modelColor('ecmwf_ifs')).toBe(modelColor('ecmwf_ifs')); + expect(modelColor('ecmwf_ifs')).not.toBe(modelColor('gfs_global')); + }); +}); diff --git a/src/routes/weather/compare/[location]/comparison.ts b/src/routes/weather/compare/[location]/comparison.ts new file mode 100644 index 0000000..eda06a9 --- /dev/null +++ b/src/routes/weather/compare/[location]/comparison.ts @@ -0,0 +1,130 @@ +import { findModel, models as modelOptions } from '../../options'; + +import type { ModelSeriesData } from '$lib/services/weather'; + +const MODEL_COLORS = [ + '#0072b2', + '#d55e00', + '#009e73', + '#cc79a7', + '#e69f00', + '#56b4e9', + '#6f4ead', + '#8c564b', + '#e11d48', + '#65a30d', + '#0891b2', + '#c026d3', + '#2563eb', + '#ea580c', + '#059669', + '#9333ea' +] as const; + +/** + * High-contrast categorical colour. Within a comparison, selection order owns + * the palette slot so adjacent series never receive accidentally similar hues. + */ +export function modelColor(modelId: string, modelOrder?: readonly string[]): string { + const selectedIndex = modelOrder?.indexOf(modelId) ?? -1; + if (selectedIndex >= 0) return MODEL_COLORS[selectedIndex % MODEL_COLORS.length]!; + + const canonicalIndex = modelOptions.findIndex((model) => model.value === modelId); + if (canonicalIndex >= 0) return MODEL_COLORS[canonicalIndex % MODEL_COLORS.length]!; + + let hash = 2166136261; + for (let i = 0; i < modelId.length; i++) { + hash ^= modelId.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return MODEL_COLORS[Math.abs(hash) % MODEL_COLORS.length]!; +} + +export function modelLabel(modelId: string): string { + return findModel(modelId)?.label ?? modelId.replace(/_/g, ' '); +} + +export function sanitizeList( + values: string[] | null, + allowed: ReadonlySet +): string[] | null { + if (!values) return null; + const clean = [...new Set(values.filter((value) => allowed.has(value)))]; + return clean.length > 0 ? clean : null; +} + +export function isWindDirection(variable: string): boolean { + return variable.startsWith('wind_direction_'); +} + +export function validDirectionValues(values: number[] | undefined): (number | null)[] { + return (values ?? []).map((value) => + Number.isFinite(value) && value >= 0 && value <= 360 ? value : null + ); +} + +export interface PrecipitationAgreementPoint { + wetCount: number; + availableCount: number; + median: number; + min: number; + max: number; +} + +/** Per-timestamp precipitation occurrence agreement and amount spread. */ +export function precipitationAgreement( + models: ModelSeriesData[], + variable: string, + timeLength: number, + wetThreshold: number +): (PrecipitationAgreementPoint | null)[] { + const points: (PrecipitationAgreementPoint | null)[] = new Array(timeLength).fill(null); + for (let index = 0; index < timeLength; index++) { + const values: number[] = []; + for (const model of models) { + const value = model.variables[variable]?.[index]; + if (value === undefined || !Number.isFinite(value) || value < 0) continue; + values.push(value); + } + if (values.length === 0) continue; + values.sort((left, right) => left - right); + const middle = Math.floor(values.length / 2); + const median = + values.length % 2 === 0 ? (values[middle - 1] + values[middle]) / 2 : values[middle]; + points[index] = { + wetCount: values.filter((value) => value > wetThreshold).length, + availableCount: values.length, + median, + min: values[0], + max: values[values.length - 1] + }; + } + return points; +} + +export function cardinalDirection(value: number): string { + const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + return directions[Math.round((((value % 360) + 360) % 360) / 45) % 8]; +} + +/** Arithmetic model mean for scalar variables; missing consensus stays missing. */ +export function modelMean( + models: ModelSeriesData[], + variable: string, + timeLength: number +): (number | null)[] { + const totals = new Array(timeLength).fill(0); + const counts = new Array(timeLength).fill(0); + for (const model of models) { + const values = model.variables[variable] ?? []; + for (let i = 0; i < Math.min(values.length, timeLength); i++) { + const value = values[i]; + if (!Number.isFinite(value)) continue; + totals[i] += value; + counts[i]++; + } + } + return totals.map((total, i) => + counts[i] > 0 ? Math.round((total / counts[i]) * 10) / 10 : null + ); +} diff --git a/src/routes/weather/options.ts b/src/routes/weather/options.ts index 7ae430c..79cfa4f 100644 --- a/src/routes/weather/options.ts +++ b/src/routes/weather/options.ts @@ -27,9 +27,51 @@ export interface WeatherModelGroup { */ export const modelGroups: WeatherModelGroup[] = [ { - value: 'auto', - label: 'Automatic', - models: [{ value: 'best_match', label: 'Best match', resolution: 'varies', update: 'varies' }] + value: 'automatic_seamless', + label: 'Automatic & seamless', + models: [ + { value: 'best_match', label: 'Best match', resolution: 'varies', update: 'varies' }, + { + value: 'icon_seamless', + label: 'DWD ICON Seamless', + resolution: '2-13 km', + update: 'every 3 h' + }, + { value: 'gfs_seamless', label: 'GFS Seamless', resolution: '3-25 km', update: 'every hour' }, + { + value: 'meteofrance_seamless', + label: 'MF Seamless', + resolution: '1-25 km', + update: 'every 3 h' + }, + { + value: 'ukmo_seamless', + label: 'UKMO Seamless', + resolution: '2-10 km', + update: 'every 3 h' + }, + { + value: 'knmi_seamless', + label: 'KNMI Seamless', + resolution: '2-3 km', + update: 'every hour' + }, + { value: 'dmi_seamless', label: 'DMI Seamless', resolution: '2 km', update: 'every 3 h' }, + { + value: 'metno_seamless', + label: 'MET Norway Seamless', + resolution: '1 km', + update: 'every 3 h' + }, + { + value: 'meteoswiss_icon_seamless', + label: 'MeteoSwiss ICON Seamless', + resolution: '1-2 km', + update: 'every 3 h' + }, + { value: 'jma_seamless', label: 'JMA Seamless', resolution: '5-55 km', update: 'every 3 h' }, + { value: 'gem_seamless', label: 'GEM Seamless', resolution: '1-15 km', update: 'every 6 h' } + ] }, { value: 'ecmwf', @@ -49,12 +91,6 @@ export const modelGroups: WeatherModelGroup[] = [ 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' } @@ -64,7 +100,6 @@ export const modelGroups: WeatherModelGroup[] = [ 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' }, { @@ -98,12 +133,6 @@ export const modelGroups: WeatherModelGroup[] = [ 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', @@ -134,12 +163,6 @@ export const modelGroups: WeatherModelGroup[] = [ 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', @@ -158,12 +181,6 @@ export const modelGroups: WeatherModelGroup[] = [ 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', @@ -182,7 +199,6 @@ export const modelGroups: WeatherModelGroup[] = [ 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', @@ -195,12 +211,6 @@ export const modelGroups: WeatherModelGroup[] = [ 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' } ] }, @@ -208,12 +218,6 @@ export const modelGroups: WeatherModelGroup[] = [ 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', @@ -228,25 +232,10 @@ export const modelGroups: WeatherModelGroup[] = [ } ] }, - { - 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' } ] @@ -263,23 +252,10 @@ export const modelGroups: WeatherModelGroup[] = [ } ] }, - { - 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' }, { @@ -433,11 +409,6 @@ export const ensembleModelGroups: WeatherModelGroup[] = [ } ] }, - { - value: 'bom', - label: 'BOM Australia', - models: [{ value: 'bom_access_global_ensemble', label: 'BOM ACCESS Global' }] - }, { value: 'ukmo', label: 'UK Met Office', @@ -503,9 +474,7 @@ const IN_DOMAIN_CITY: [string, { slug: string; label: string }][] = [ ['nbm', { slug: 'new-york', label: 'New York' }], ['gem', { slug: 'toronto', label: 'Toronto' }], ['jma', { slug: 'tokyo', label: 'Tokyo' }], - ['kma', { slug: 'seoul', label: 'Seoul' }], - ['cma', { slug: 'beijing', label: 'Beijing' }], - ['bom', { slug: 'sydney', label: 'Sydney' }] + ['cma', { slug: 'beijing', label: 'Beijing' }] ]; /** Where this model definitely has data, or null for a global model. */ diff --git a/src/routes/weather/utils/weather-codes.ts b/src/routes/weather/utils/weather-codes.ts index a222a3a..a274966 100644 --- a/src/routes/weather/utils/weather-codes.ts +++ b/src/routes/weather/utils/weather-codes.ts @@ -83,6 +83,14 @@ const weatherCodes: Record = { // These conditions ship only as a single neutral glyph (no day/night variant). const NEUTRAL_ICONS = new Set(['snowflake-cold', 'strong-wind', 'dust', 'tornado']); +export function hasWeatherIcon(code: unknown): code is number { + return ( + typeof code === 'number' && + Number.isFinite(code) && + Object.prototype.hasOwnProperty.call(weatherCodes, code) + ); +} + export function getWeatherIconName(code: number, daytime: boolean): string { const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear'; if (NEUTRAL_ICONS.has(name)) return `wi-${name}`; -- 2.54.0 From 77af339df445c6168398ad3eb52cf7da1b14affa Mon Sep 17 00:00:00 2001 From: terraputix Date: Sun, 2 Aug 2026 22:35:36 +0200 Subject: [PATCH 2/9] redesign model and variable selection --- messages/de.json | 10 + messages/en.json | 10 + messages/es.json | 10 + messages/fr.json | 10 + messages/it.json | 10 + .../weather/compare/[location]/+page.svelte | 173 ++++++------- .../ComparisonSelectionDrawer.svelte | 233 ++++++++++++++++++ 7 files changed, 358 insertions(+), 98 deletions(-) create mode 100644 src/routes/weather/compare/[location]/ComparisonSelectionDrawer.svelte diff --git a/messages/de.json b/messages/de.json index 80c8bb4..0103ab7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -294,6 +294,16 @@ "compare_selection_pending": "Auswahl geändert", "compare_apply_selection": "Anwenden & Diagramme neu laden", "compare_customize_models": "Modelle anpassen", + "compare_edit_models": "Modelle bearbeiten", + "compare_edit_variables": "Variablen bearbeiten", + "compare_edit_models_description": "Modelle suchen oder ganze Anbietergruppen auswählen.", + "compare_edit_variables_description": "Variablen suchen oder ganze Themengruppen auswählen.", + "compare_search_models": "Modelle suchen", + "compare_search_variables": "Variablen suchen", + "compare_only_selected": "Nur ausgewählte", + "compare_select_group": "Alle auswählen", + "compare_clear_group": "Alle abwählen", + "compare_no_matching_options": "Keine passenden Optionen.", "compare_no_models_selected": "Keine Modelle ausgewählt.", "compare_remove_model": "{model} entfernen", "compare_timeline_title": "Zeitleiste des Modellvergleichs", diff --git a/messages/en.json b/messages/en.json index 63da40e..7c35009 100644 --- a/messages/en.json +++ b/messages/en.json @@ -294,6 +294,16 @@ "compare_selection_pending": "Selection changed", "compare_apply_selection": "Apply & reload charts", "compare_customize_models": "Customize models", + "compare_edit_models": "Edit models", + "compare_edit_variables": "Edit variables", + "compare_edit_models_description": "Search models or select complete provider families.", + "compare_edit_variables_description": "Search variables or select complete thematic groups.", + "compare_search_models": "Search models", + "compare_search_variables": "Search variables", + "compare_only_selected": "Only selected", + "compare_select_group": "Select all", + "compare_clear_group": "Clear all", + "compare_no_matching_options": "No matching options.", "compare_no_models_selected": "No models selected.", "compare_remove_model": "Remove {model}", "compare_timeline_title": "Model Comparison Timeline", diff --git a/messages/es.json b/messages/es.json index 8355cfc..4388189 100644 --- a/messages/es.json +++ b/messages/es.json @@ -294,6 +294,16 @@ "compare_selection_pending": "Selección modificada", "compare_apply_selection": "Aplicar y recargar gráficos", "compare_customize_models": "Personalizar modelos", + "compare_edit_models": "Editar modelos", + "compare_edit_variables": "Editar variables", + "compare_edit_models_description": "Busca modelos o selecciona familias completas de proveedores.", + "compare_edit_variables_description": "Busca variables o selecciona grupos temáticos completos.", + "compare_search_models": "Buscar modelos", + "compare_search_variables": "Buscar variables", + "compare_only_selected": "Solo seleccionados", + "compare_select_group": "Seleccionar todo", + "compare_clear_group": "Deseleccionar todo", + "compare_no_matching_options": "No hay opciones coincidentes.", "compare_no_models_selected": "No hay modelos seleccionados.", "compare_remove_model": "Quitar {model}", "compare_timeline_title": "Cronología de comparación de modelos", diff --git a/messages/fr.json b/messages/fr.json index 7169592..0adf192 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -294,6 +294,16 @@ "compare_selection_pending": "Sélection modifiée", "compare_apply_selection": "Appliquer et recharger les graphiques", "compare_customize_models": "Personnaliser les modèles", + "compare_edit_models": "Modifier les modèles", + "compare_edit_variables": "Modifier les variables", + "compare_edit_models_description": "Recherchez des modèles ou sélectionnez des fournisseurs entiers.", + "compare_edit_variables_description": "Recherchez des variables ou sélectionnez des groupes thématiques entiers.", + "compare_search_models": "Rechercher des modèles", + "compare_search_variables": "Rechercher des variables", + "compare_only_selected": "Sélection uniquement", + "compare_select_group": "Tout sélectionner", + "compare_clear_group": "Tout désélectionner", + "compare_no_matching_options": "Aucune option correspondante.", "compare_no_models_selected": "Aucun modèle sélectionné.", "compare_remove_model": "Supprimer {model}", "compare_timeline_title": "Chronologie de comparaison des modèles", diff --git a/messages/it.json b/messages/it.json index e230e28..c65a859 100644 --- a/messages/it.json +++ b/messages/it.json @@ -294,6 +294,16 @@ "compare_selection_pending": "Selezione modificata", "compare_apply_selection": "Applica e ricarica i grafici", "compare_customize_models": "Personalizza modelli", + "compare_edit_models": "Modifica modelli", + "compare_edit_variables": "Modifica variabili", + "compare_edit_models_description": "Cerca modelli o seleziona intere famiglie di fornitori.", + "compare_edit_variables_description": "Cerca variabili o seleziona interi gruppi tematici.", + "compare_search_models": "Cerca modelli", + "compare_search_variables": "Cerca variabili", + "compare_only_selected": "Solo selezionati", + "compare_select_group": "Seleziona tutto", + "compare_clear_group": "Deseleziona tutto", + "compare_no_matching_options": "Nessuna opzione corrispondente.", "compare_no_models_selected": "Nessun modello selezionato.", "compare_remove_model": "Rimuovi {model}", "compare_timeline_title": "Cronologia del confronto tra modelli", diff --git a/src/routes/weather/compare/[location]/+page.svelte b/src/routes/weather/compare/[location]/+page.svelte index dbb6eed..fb8e03c 100644 --- a/src/routes/weather/compare/[location]/+page.svelte +++ b/src/routes/weather/compare/[location]/+page.svelte @@ -18,7 +18,6 @@ type ExportableChart, type ExportLegendItem } from '$lib/components/charts'; - import { Checkbox } from '$lib/components/ui/checkbox'; import { Label } from '$lib/components/ui/label'; import { Switch } from '$lib/components/ui/switch'; @@ -41,6 +40,7 @@ import { useHeroActions } from '../../hero.svelte'; import { defaultParameters, hourly, modelGroups, models as modelOptions } from '../../options'; + import ComparisonSelectionDrawer from './ComparisonSelectionDrawer.svelte'; import ModelPictogramTimeline from './ModelPictogramTimeline.svelte'; import { cardinalDirection, @@ -67,6 +67,9 @@ group.filter((variable) => variable.value !== 'cloud_cover') ); const VARIABLE_IDS = new Set(COMPARISON_HOURLY.flat().map((variable) => variable.value)); + const VARIABLE_ORDER = new Map( + COMPARISON_HOURLY.flat().map((variable, index) => [variable.value, index]) + ); const SECONDS_PER_HOUR = 3600; const FETCH_DEBOUNCE_MS = 300; const STANDARD_COMPARE_VARIABLES = [ @@ -95,8 +98,7 @@ let isMobile = $state(false); let comparisonPlotInsetLeft = $derived(isMobile ? 92 : 116); let comparisonPlotInsetRight = $derived(isMobile ? 8 : 20); - let modelsOpen = $state(false); - let customVariablesOpen = $state(false); + let selectionDrawerMode = $state<'models' | 'variables' | null>(null); let params = $state({ ...defaultParameters, @@ -138,6 +140,14 @@ ); } + function orderVariables(values: string[]): string[] { + return [...values].sort( + (left, right) => + (VARIABLE_ORDER.get(left) ?? Number.MAX_SAFE_INTEGER) - + (VARIABLE_ORDER.get(right) ?? Number.MAX_SAFE_INTEGER) + ); + } + let variablesDirty = $derived(!hasSameVariables(params.hourly, appliedHourly)); let modelsDirty = $derived(!hasSameOrder(params.models, appliedModels)); let displayedVariablesStale = $derived( @@ -501,7 +511,19 @@ const nextValues = values.includes(value) ? values.filter((item) => item !== value) : [...values, value]; - params[list] = list === 'models' ? orderModels(nextValues) : nextValues; + params[list] = list === 'models' ? orderModels(nextValues) : orderVariables(nextValues); + } + + function toggleGroupSelection( + list: 'models' | 'hourly', + values: string[], + select: boolean + ): void { + const groupValues = new Set(values); + const nextValues = select + ? [...new Set([...params[list], ...values])] + : params[list].filter((value) => !groupValues.has(value)); + params[list] = list === 'models' ? orderModels(nextValues) : orderVariables(nextValues); } function restoreStandardVariables(): void { @@ -535,10 +557,15 @@ return model ? [model] : []; }) ); - let modelSelectionGroups = $derived( + let modelDrawerGroups = $derived( modelGroups.map((group) => ({ - ...group, - selectedCount: group.models.filter((model) => params.models.includes(model.value)).length + value: group.value, + label: group.label, + options: group.models.map((model) => ({ + value: model.value, + label: model.label, + metadata: [model.resolution, model.update].filter(Boolean).join(' · ') + })) })) ); let selectedVariables = $derived( @@ -551,11 +578,14 @@ params.hourly.length === STANDARD_COMPARE_VARIABLES.length && STANDARD_COMPARE_VARIABLES.every((variable) => params.hourly.includes(variable)) ); - let variableSelectionGroups = $derived( + let variableDrawerGroups = $derived( COMPARISON_HOURLY.map((variables, index) => ({ - label: variableGroupLabels[index], - variables, - selectedCount: variables.filter((variable) => params.hourly.includes(variable.value)).length + value: `variables_${index}`, + label: variableGroupLabels[index](), + options: variables.map((variable) => ({ + value: variable.value, + label: variableLabel(variable.value) + })) })) ); @@ -812,7 +842,7 @@
{/if} - +

{m.compare_models_heading()}

@@ -870,11 +900,10 @@ {#if modelsDirty}
- - {#if modelsOpen} -
-
- {#each modelSelectionGroups as group (group.value)} -
- - {group.label} - - {group.selectedCount}/{group.models.length} - - - -
- {#each group.models as model (model.value)} -
- toggleSelection('models', model.value)} - /> - -
- {/each} -
-
- {/each} -
-
- {/if}
@@ -999,11 +987,10 @@ {#if variablesDirty}
- - {#if customVariablesOpen} -
-
- {#each variableSelectionGroups as variableGroup (variableGroup.label())} -
- - {variableGroup.label()} - - {variableGroup.selectedCount}/{variableGroup.variables.length} - - - -
- {#each variableGroup.variables as variable (variable.value)} -
- toggleSelection('hourly', variable.value)} - /> - -
- {/each} -
-
- {/each} -
-
- {/if}
+ +{#if selectionDrawerMode === 'models'} + toggleSelection('models', value)} + onToggleGroup={(values, select) => toggleGroupSelection('models', values, select)} + onApply={applyComparisonSelection} + onClose={() => (selectionDrawerMode = null)} + /> +{:else if selectionDrawerMode === 'variables'} + toggleSelection('hourly', value)} + onToggleGroup={(values, select) => toggleGroupSelection('hourly', values, select)} + onApply={applyComparisonSelection} + onClose={() => (selectionDrawerMode = null)} + onRestoreDefaults={restoreStandardVariables} + /> +{/if} diff --git a/src/routes/weather/compare/[location]/ComparisonSelectionDrawer.svelte b/src/routes/weather/compare/[location]/ComparisonSelectionDrawer.svelte new file mode 100644 index 0000000..f4e629e --- /dev/null +++ b/src/routes/weather/compare/[location]/ComparisonSelectionDrawer.svelte @@ -0,0 +1,233 @@ + + + !nextOpen && onClose()}> + +
+
+
+ + {mode === 'models' ? m.compare_edit_models() : m.compare_edit_variables()} + + + {mode === 'models' + ? m.compare_edit_models_description() + : m.compare_edit_variables_description()} + +
+ +
+ +
+ +
+ + +
+
+
+ +
+ {#if visibleGroups.length > 0} +
+ {#each visibleGroups as visibleGroup (visibleGroup.value)} + {@const fullGroup = groups.find((group) => group.value === visibleGroup.value) ?? visibleGroup} + {@const selection = groupSelection(fullGroup)} +
+
+ + onToggleGroup( + fullGroup.options.map((option) => option.value), + !selection.all + )} + /> + + + {selection.count}/{fullGroup.options.length} + + +
+
+ {#each visibleGroup.options as option (option.value)} +
+ onToggle(option.value)} + /> + +
+ {/each} +
+
+ {/each} +
+ {:else} +
+ {m.compare_no_matching_options()} +
+ {/if} +
+ +
+
+
+ {#if mode === 'variables' && onRestoreDefaults} + + {/if} +
+
+ + +
+
+
+
+
-- 2.54.0 From 5541df20e1f4e2bf7f0004ab150329166a87ba24 Mon Sep 17 00:00:00 2001 From: terraputix Date: Sun, 2 Aug 2026 22:42:38 +0200 Subject: [PATCH 3/9] add aladin models --- src/lib/utils/maps-domain.ts | 3 +++ src/routes/weather/options.ts | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/lib/utils/maps-domain.ts b/src/lib/utils/maps-domain.ts index 5fa9c3b..c6dbc38 100644 --- a/src/lib/utils/maps-domain.ts +++ b/src/lib/utils/maps-domain.ts @@ -46,6 +46,9 @@ const modelDomainCandidates: Record = { // MeteoSwiss (CH2 covers a wider area than CH1) meteoswiss_icon_seamless: ['meteoswiss_icon_ch2'], + // CHMI Czech Republic (Central Europe is the widest native domain) + chmi_aladin_seamless: ['chmi_aladin_seamless', 'chmi_aladin_central_europe_2km'], + // JMA Japan jma_seamless: ['jma_seamless', 'jma_gsm'], diff --git a/src/routes/weather/options.ts b/src/routes/weather/options.ts index 79cfa4f..361ea08 100644 --- a/src/routes/weather/options.ts +++ b/src/routes/weather/options.ts @@ -69,6 +69,12 @@ export const modelGroups: WeatherModelGroup[] = [ resolution: '1-2 km', update: 'every 3 h' }, + { + value: 'chmi_aladin_seamless', + label: 'CHMI Aladin Seamless', + resolution: '1-2.3 km', + update: 'every 6 h' + }, { value: 'jma_seamless', label: 'JMA Seamless', resolution: '5-55 km', update: 'every 3 h' }, { value: 'gem_seamless', label: 'GEM Seamless', resolution: '1-15 km', update: 'every 6 h' } ] @@ -252,6 +258,24 @@ export const modelGroups: WeatherModelGroup[] = [ } ] }, + { + value: 'chmi', + label: 'CHMI Czech Republic', + models: [ + { + value: 'chmi_aladin_central_europe_2km', + label: 'CHMI Aladin Central Europe 2km', + resolution: '2.3 km', + update: 'every 6 h' + }, + { + value: 'chmi_aladin_cz_1km', + label: 'CHMI Aladin CZ 1km', + resolution: '1 km', + update: 'every 6 h' + } + ] + }, { value: 'cmc_gem', label: 'GEM Canada', -- 2.54.0 From edee9fe33d4c3ea156f51dd66ddc67f31109f479 Mon Sep 17 00:00:00 2001 From: terraputix Date: Sun, 2 Aug 2026 22:45:52 +0200 Subject: [PATCH 4/9] heading improvement --- .../[location]/ModelPictogramTimeline.svelte | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte b/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte index 6a27d71..d458aa1 100644 --- a/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte +++ b/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte @@ -45,6 +45,11 @@ x: number; } + interface SunlightSegment { + x1: number; + x2: number; + } + interface TooltipRow { modelId: string; label: string; @@ -75,6 +80,7 @@ const ICON_BOX_WIDTH = 20; const ICON_BOX_HEIGHT = 30; const MIN_ICON_GAP = 20; + const SUNLIGHT_STRIP_H = 4; const EXPORT_TITLE_H = 30; const SAMPLE_STEPS = [1, 2, 3, 4, 6, 8, 12, 24] as const; @@ -212,6 +218,16 @@ } return separators; }); + let sunlightSegments = $derived.by((): SunlightSegment[] => { + const segments: SunlightSegment[] = []; + for (let index = 0; index < sunrise.length; index++) { + const start = Math.max(viewStart, sunrise[index]); + const end = Math.min(viewEnd, sunset[index] ?? sunrise[index]); + if (end <= start) continue; + segments.push({ x1: xForTime(start), x2: xForTime(end) }); + } + return segments; + }); let hoverX = $derived( sharedHover !== null && sharedHover >= viewStart && sharedHover <= viewEnd @@ -498,6 +514,7 @@ const foreground = color('--foreground', '#1f2937'); const muted = color('--muted-foreground', '#6b7280'); const grid = color('--border', 'rgba(0, 0, 0, 0.12)'); + const sunlight = color('--chart-3', '#f6c453'); context.fillStyle = background; context.fillRect(0, 0, width, height + titleHeight); @@ -513,6 +530,16 @@ context.beginPath(); context.rect(plotInsetLeft, yOffset, plotWidth, height); context.clip(); + const sunlightY = yOffset + HEADER_H - SUNLIGHT_STRIP_H; + context.fillStyle = muted; + context.globalAlpha = 0.12; + context.fillRect(plotInsetLeft, sunlightY, plotWidth, SUNLIGHT_STRIP_H); + context.fillStyle = sunlight; + context.globalAlpha = 0.9; + for (const segment of sunlightSegments) { + context.fillRect(segment.x1, sunlightY, segment.x2 - segment.x1, SUNLIGHT_STRIP_H); + } + context.globalAlpha = 1; context.strokeStyle = grid; context.lineWidth = 1; for (const separator of daySeparators) { @@ -522,13 +549,6 @@ context.stroke(); } context.restore(); - context.fillStyle = muted; - context.font = '600 9px system-ui, -apple-system, sans-serif'; - context.textAlign = 'left'; - context.textBaseline = 'middle'; - context.fillText(m.var_cloud(), 8, yOffset + 11, Math.max(20, plotInsetLeft - 16)); - context.font = '9px system-ui, -apple-system, sans-serif'; - context.fillText('0–100%', 8, yOffset + 27); context.font = '600 10px system-ui, -apple-system, sans-serif'; context.textAlign = 'center'; @@ -637,6 +657,7 @@ void themeVersion; void visiblePoints; void daySeparators; + void sunlightSegments; void displayModels; if (!target || currentWidth <= 0 || currentHeight <= 0) return; -- 2.54.0 From 8b58bc01d769cc53d037e661132efdba656b7a02 Mon Sep 17 00:00:00 2001 From: terraputix Date: Sun, 2 Aug 2026 23:05:09 +0200 Subject: [PATCH 5/9] review fixes --- src/lib/charts/CanvasChart.svelte | 36 ++++++++-------- src/lib/services/weather.ts | 13 ++++-- .../weather/compare/[location]/+page.svelte | 41 +++++-------------- 3 files changed, 39 insertions(+), 51 deletions(-) diff --git a/src/lib/charts/CanvasChart.svelte b/src/lib/charts/CanvasChart.svelte index 825b22f..327b4cf 100644 --- a/src/lib/charts/CanvasChart.svelte +++ b/src/lib/charts/CanvasChart.svelte @@ -1265,6 +1265,24 @@ continue; } + if (s.type === 'point') { + const radius = s.pointRadius ?? 3; + ctx.fillStyle = s.color; + ctx.strokeStyle = outlineColor; + ctx.lineWidth = 1; + for (let i = 0; i < timestamps.length; i++) { + const v = s.data[i]; + if (v === null || v === undefined || !isFinite(v)) continue; + const t = timestamps[i]; + if (t < viewStart || t > viewEnd) continue; + ctx.beginPath(); + ctx.arc(xPix(t), yPix(v, axis), radius, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + continue; + } + // Line series: draw fill and stroke per contiguous non-null run // (points outside the view are handled by the clip rect). Each point // is [x, y, yBand, sourceIndex] — yBand only used when s.bandTo is set. @@ -1279,24 +1297,6 @@ run = []; continue; } - - if (s.type === 'point') { - const radius = s.pointRadius ?? 3; - ctx.fillStyle = s.color; - ctx.strokeStyle = outlineColor; - ctx.lineWidth = 1; - for (let i = 0; i < timestamps.length; i++) { - const v = s.data[i]; - if (v === null || v === undefined || !isFinite(v)) continue; - const t = timestamps[i]; - if (t < viewStart || t > viewEnd) continue; - ctx.beginPath(); - ctx.arc(xPix(t), yPix(v, axis), radius, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - } - continue; - } run.push([xPix(timestamps[i]), yPix(v, axis), s.bandTo ? yPix(b as number, axis) : 0, i]); } if (run.length > 0) runs.push(run); diff --git a/src/lib/services/weather.ts b/src/lib/services/weather.ts index 90aadc1..3f7b9e8 100644 --- a/src/lib/services/weather.ts +++ b/src/lib/services/weather.ts @@ -563,6 +563,11 @@ export async function fetchModelComparison( { signal: options.signal } ); if (responses.length === 0) throw new Error('The weather service returned no model data.'); + if (responses.length !== params.models.length) { + throw new Error( + `The weather service returned ${responses.length} model responses for ${params.models.length} requested models.` + ); + } // With multiple models, we get one response per model const firstResponse = responses[0]; @@ -604,9 +609,11 @@ export async function fetchModelComparison( const modelHourly = response.hourly(); if (!modelHourly) continue; - // Keep the requested id as the stable UI key. The concrete id reported by - // Open-Meteo is metadata only: seamless and best-match requests may resolve - // to a different underlying domain. + // The multi-model endpoint preserves request order and returns one response + // per requested model, including unavailable regional models. Keep that + // requested id as the stable UI key. The concrete id reported by Open-Meteo + // is metadata only: seamless and best-match requests may resolve to a + // different underlying domain. const modelEnum = response.model(); const resolvedModelId = Model[modelEnum] ?? `model_${modelEnum}`; const modelId = params.models[responseIndex] ?? resolvedModelId; diff --git a/src/routes/weather/compare/[location]/+page.svelte b/src/routes/weather/compare/[location]/+page.svelte index fb8e03c..5d9c54a 100644 --- a/src/routes/weather/compare/[location]/+page.svelte +++ b/src/routes/weather/compare/[location]/+page.svelte @@ -1,6 +1,5 @@ - - !nextOpen && onClose()}> - -
-
-
- - {mode === 'models' ? m.compare_edit_models() : m.compare_edit_variables()} - - - {mode === 'models' - ? m.compare_edit_models_description() - : m.compare_edit_variables_description()} - -
- -
- -
- -
- - -
-
-
- -
- {#if visibleGroups.length > 0} -
- {#each visibleGroups as visibleGroup (visibleGroup.value)} - {@const fullGroup = groups.find((group) => group.value === visibleGroup.value) ?? visibleGroup} - {@const selection = groupSelection(fullGroup)} -
-
- - onToggleGroup( - fullGroup.options.map((option) => option.value), - !selection.all - )} - /> - - - {selection.count}/{fullGroup.options.length} - - -
-
- {#each visibleGroup.options as option (option.value)} -
- onToggle(option.value)} - /> - -
- {/each} -
-
- {/each} -
- {:else} -
- {m.compare_no_matching_options()} -
- {/if} -
- -
-
-
- {#if mode === 'variables' && onRestoreDefaults} - - {/if} -
-
- - -
-
-
-
-
diff --git a/src/routes/weather/compare/[location]/ComparisonSelectionPanel.svelte b/src/routes/weather/compare/[location]/ComparisonSelectionPanel.svelte new file mode 100644 index 0000000..8b660da --- /dev/null +++ b/src/routes/weather/compare/[location]/ComparisonSelectionPanel.svelte @@ -0,0 +1,190 @@ + + +
+
+
+ +
+ + +
+
+
+ +
+ {#if visibleGroups.length > 0} +
+ {#each visibleGroups as visibleGroup (visibleGroup.value)} + {@const fullGroup = + groups.find((group) => group.value === visibleGroup.value) ?? visibleGroup} + {@const selection = groupSelection(fullGroup)} +
+
+ + onToggleGroup( + fullGroup.options.map((option) => option.value), + !selection.all + )} + /> + + + {selection.count}/{fullGroup.options.length} + + +
+
+ {#each visibleGroup.options as option (option.value)} +
+ onToggle(option.value)} + /> + +
+ {/each} +
+
+ {/each} +
+ {:else} +
+ {m.compare_no_matching_options()} +
+ {/if} +
+ +
+
+ {#if mode === 'variables' && onRestoreDefaults} + + {/if} +
+ +
+
-- 2.54.0 From 506831cf889b5bc8f3bf45b8c903b747f0501ba2 Mon Sep 17 00:00:00 2001 From: terraputix Date: Mon, 3 Aug 2026 23:14:23 +0200 Subject: [PATCH 9/9] mobile improvements mostly --- messages/de.json | 3 + messages/en.json | 3 + messages/es.json | 3 + messages/fr.json | 3 + messages/it.json | 3 + src/lib/charts/CanvasChart.svelte | 35 ++-- .../components/charts/downloadChartsPng.ts | 17 +- .../weather/compare/[location]/+page.svelte | 150 +++++++++++------- .../ComparisonSelectionPanel.svelte | 56 ++----- .../[location]/ModelPictogramTimeline.svelte | 38 ++++- 10 files changed, 183 insertions(+), 128 deletions(-) diff --git a/messages/de.json b/messages/de.json index 0103ab7..56e29ee 100644 --- a/messages/de.json +++ b/messages/de.json @@ -293,6 +293,7 @@ "compare_selected_models": "Ausgewählte Modelle", "compare_selection_pending": "Auswahl geändert", "compare_apply_selection": "Anwenden & Diagramme neu laden", + "compare_discard_changes": "Änderungen verwerfen", "compare_customize_models": "Modelle anpassen", "compare_edit_models": "Modelle bearbeiten", "compare_edit_variables": "Variablen bearbeiten", @@ -300,6 +301,8 @@ "compare_edit_variables_description": "Variablen suchen oder ganze Themengruppen auswählen.", "compare_search_models": "Modelle suchen", "compare_search_variables": "Variablen suchen", + "compare_show_model_names": "Modellnamen anzeigen", + "compare_hide_model_names": "Modellnamen ausblenden", "compare_only_selected": "Nur ausgewählte", "compare_select_group": "Alle auswählen", "compare_clear_group": "Alle abwählen", diff --git a/messages/en.json b/messages/en.json index 7c35009..162b91a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -293,6 +293,7 @@ "compare_selected_models": "Selected models", "compare_selection_pending": "Selection changed", "compare_apply_selection": "Apply & reload charts", + "compare_discard_changes": "Discard changes", "compare_customize_models": "Customize models", "compare_edit_models": "Edit models", "compare_edit_variables": "Edit variables", @@ -300,6 +301,8 @@ "compare_edit_variables_description": "Search variables or select complete thematic groups.", "compare_search_models": "Search models", "compare_search_variables": "Search variables", + "compare_show_model_names": "Show model names", + "compare_hide_model_names": "Hide model names", "compare_only_selected": "Only selected", "compare_select_group": "Select all", "compare_clear_group": "Clear all", diff --git a/messages/es.json b/messages/es.json index 4388189..52c1847 100644 --- a/messages/es.json +++ b/messages/es.json @@ -293,6 +293,7 @@ "compare_selected_models": "Modelos seleccionados", "compare_selection_pending": "Selección modificada", "compare_apply_selection": "Aplicar y recargar gráficos", + "compare_discard_changes": "Descartar cambios", "compare_customize_models": "Personalizar modelos", "compare_edit_models": "Editar modelos", "compare_edit_variables": "Editar variables", @@ -300,6 +301,8 @@ "compare_edit_variables_description": "Busca variables o selecciona grupos temáticos completos.", "compare_search_models": "Buscar modelos", "compare_search_variables": "Buscar variables", + "compare_show_model_names": "Mostrar nombres de modelos", + "compare_hide_model_names": "Ocultar nombres de modelos", "compare_only_selected": "Solo seleccionados", "compare_select_group": "Seleccionar todo", "compare_clear_group": "Deseleccionar todo", diff --git a/messages/fr.json b/messages/fr.json index 0adf192..2e04997 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -293,6 +293,7 @@ "compare_selected_models": "Modèles sélectionnés", "compare_selection_pending": "Sélection modifiée", "compare_apply_selection": "Appliquer et recharger les graphiques", + "compare_discard_changes": "Annuler les modifications", "compare_customize_models": "Personnaliser les modèles", "compare_edit_models": "Modifier les modèles", "compare_edit_variables": "Modifier les variables", @@ -300,6 +301,8 @@ "compare_edit_variables_description": "Recherchez des variables ou sélectionnez des groupes thématiques entiers.", "compare_search_models": "Rechercher des modèles", "compare_search_variables": "Rechercher des variables", + "compare_show_model_names": "Afficher les noms des modèles", + "compare_hide_model_names": "Masquer les noms des modèles", "compare_only_selected": "Sélection uniquement", "compare_select_group": "Tout sélectionner", "compare_clear_group": "Tout désélectionner", diff --git a/messages/it.json b/messages/it.json index c65a859..3d16c06 100644 --- a/messages/it.json +++ b/messages/it.json @@ -293,6 +293,7 @@ "compare_selected_models": "Modelli selezionati", "compare_selection_pending": "Selezione modificata", "compare_apply_selection": "Applica e ricarica i grafici", + "compare_discard_changes": "Annulla modifiche", "compare_customize_models": "Personalizza modelli", "compare_edit_models": "Modifica modelli", "compare_edit_variables": "Modifica variabili", @@ -300,6 +301,8 @@ "compare_edit_variables_description": "Cerca variabili o seleziona interi gruppi tematici.", "compare_search_models": "Cerca modelli", "compare_search_variables": "Cerca variabili", + "compare_show_model_names": "Mostra nomi dei modelli", + "compare_hide_model_names": "Nascondi nomi dei modelli", "compare_only_selected": "Solo selezionati", "compare_select_group": "Seleziona tutto", "compare_clear_group": "Deseleziona tutto", diff --git a/src/lib/charts/CanvasChart.svelte b/src/lib/charts/CanvasChart.svelte index 327b4cf..a82d53e 100644 --- a/src/lib/charts/CanvasChart.svelte +++ b/src/lib/charts/CanvasChart.svelte @@ -209,6 +209,8 @@ yTicks?: number[]; /** Custom left-axis tick formatter. */ yTickFormat?: (value: number) => string; + /** Use abbreviated y-axis labels and omit verbose gutter labels. */ + compactYAxis?: boolean; /** Force the derived left axis to include zero (default true) */ zeroBaseLeft?: boolean; /** Fixed right-axis minimum (default 0) */ @@ -260,6 +262,7 @@ yMax, yTicks, yTickFormat, + compactYAxis = false, yPadTop, yPadBottom, zeroBaseLeft = true, @@ -497,7 +500,7 @@ let iconRows = $derived(isNarrow ? ownIconRows : Math.max(ownIconRows, reserveTopRows)); // tighter top/bottom gutters on mobile so charts don't waste vertical space const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H); - let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 14 : 28) + iconRows * iconRowH); + let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 22 : 28) + iconRows * iconRowH); let agreementStripOffset = $derived( agreementStrip ? AGREEMENT_STRIP_HEIGHT + AGREEMENT_STRIP_GAP + 2 : 0 ); @@ -1087,7 +1090,13 @@ ctx.lineTo(plotRight, y); ctx.stroke(); ctx.fillStyle = textColor; - ctx.fillText(yTickFormat?.(v) ?? v.toFixed(tickDecimals(leftScale.step)), padLeft - 8, y); + ctx.fillText( + compactYAxis + ? v.toFixed(tickDecimals(leftScale.step)) + : (yTickFormat?.(v) ?? v.toFixed(tickDecimals(leftScale.step))), + padLeft - 8, + y + ); } // Right axis labels in the gutter (desktop only). On mobile there is no @@ -1543,16 +1552,18 @@ ctx.strokeStyle = withAlpha(gridColor, 0.8); ctx.lineWidth = 1; ctx.strokeRect(padLeft, stripTop + 0.5, plotW, AGREEMENT_STRIP_HEIGHT - 1); - ctx.fillStyle = textColor; - ctx.font = '600 9px system-ui, sans-serif'; - ctx.textAlign = 'right'; - ctx.textBaseline = 'middle'; - ctx.fillText( - agreementStrip.label, - padLeft - 6, - stripTop + AGREEMENT_STRIP_HEIGHT / 2, - Math.max(24, padLeft - 10) - ); + if (!compactYAxis) { + ctx.fillStyle = textColor; + ctx.font = '600 9px system-ui, sans-serif'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillText( + agreementStrip.label, + padLeft - 6, + stripTop + AGREEMENT_STRIP_HEIGHT / 2, + Math.max(24, padLeft - 10) + ); + } } // Axis unit labels diff --git a/src/lib/components/charts/downloadChartsPng.ts b/src/lib/components/charts/downloadChartsPng.ts index 7459df5..d12fc90 100644 --- a/src/lib/components/charts/downloadChartsPng.ts +++ b/src/lib/components/charts/downloadChartsPng.ts @@ -108,10 +108,9 @@ export async function downloadChartsPng( } if (row.length > 0) legendRows.push(row); } - const headerHeight = - (options.title ? 34 * dpr : 0) + - (legendRows.length > 0 ? legendRows.length * rowHeight + 8 * dpr : 0); - const totalHeight = headerHeight + canvases.reduce((sum, c) => sum + c.height, 0); + const titleHeight = options.title ? 34 * dpr : 0; + const legendHeight = legendRows.length > 0 ? legendRows.length * rowHeight + 8 * dpr : 0; + const totalHeight = titleHeight + canvases.reduce((sum, c) => sum + c.height, 0) + legendHeight; const canvas = document.createElement('canvas'); canvas.width = maxWidth; @@ -134,7 +133,12 @@ export async function downloadChartsPng( ctx.fillText(options.title, padding, 17 * dpr); y += 34 * dpr; } + for (const c of canvases) { + ctx.drawImage(c, 0, y); + y += c.height; + } if (legendRows.length > 0) { + y += 8 * dpr; ctx.font = `${12 * dpr}px system-ui, -apple-system, sans-serif`; ctx.textBaseline = 'middle'; for (const row of legendRows) { @@ -165,11 +169,6 @@ export async function downloadChartsPng( } y += rowHeight; } - y += 8 * dpr; - } - for (const c of canvases) { - ctx.drawImage(c, 0, y); - y += c.height; } triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`); diff --git a/src/routes/weather/compare/[location]/+page.svelte b/src/routes/weather/compare/[location]/+page.svelte index e86fe13..1080eb1 100644 --- a/src/routes/weather/compare/[location]/+page.svelte +++ b/src/routes/weather/compare/[location]/+page.svelte @@ -1,7 +1,6 @@ - {m.page_compare_title()} | Drizz.li + Drizz.li | {m.page_compare_title()} @@ -689,6 +712,7 @@ yMax={def.yMax} yTicks={def.yTicks} yTickFormat={def.yTickFormat} + {compactYAxis} plotInsetLeft={comparisonPlotInsetLeft} plotInsetRight={comparisonPlotInsetRight} showCredit={i === chartDefs.length - 1} @@ -711,14 +735,6 @@ {m.compare_selection_pending()} -
{/if} @@ -748,6 +764,8 @@ group={CHART_GROUP} plotInsetLeft={comparisonPlotInsetLeft} plotInsetRight={comparisonPlotInsetRight} + {showModelNames} + onToggleModelNames={() => (showModelNames = !showModelNames)} registerExporter={(exporter) => (pictogramExporter = exporter)} />
@@ -759,36 +777,25 @@ ? 'pointer-events-none opacity-40' : ''}" > -
-

{m.compare_model_colors()}

-
- - -
+

{m.compare_model_colors()}

+
+ {#each displayedModels as model (model.modelId)} + + + {modelLabel(model.modelId)} + + {/each} + {#if chartDefs.some( (def) => def.series.some((series) => series.name === m.compare_model_mean()) )} + + + {m.compare_model_mean()} + + {/if}
- {#if showLegend} -
- {#each displayedModels as model (model.modelId)} - - - {modelLabel(model.modelId)} - - {/each} - {#if chartDefs.some( (def) => def.series.some((series) => series.name === m.compare_model_mean()) )} - - - {m.compare_model_mean()} - - {/if} -
- {/if}
toggleSelection('hourly', value)} onToggleGroup={(values, select) => toggleGroupSelection('hourly', values, select)} - onApply={applyComparisonSelection} onRestoreDefaults={restoreStandardVariables} /> @@ -853,9 +858,46 @@ mode="models" groups={modelSelectionGroups} selected={params.models} - dirty={modelsDirty} onToggle={(value) => toggleSelection('models', value)} onToggleGroup={(values, select) => toggleGroupSelection('models', values, select)} - onApply={applyComparisonSelection} /> + +{#if comparisonSelectionDirty} + +
+
+
+

{m.compare_selection_pending()}

+
+ {#if variablesDirty} + {m.compare_variables_heading()}: {pendingVariableChanges} + {/if} + {#if modelsDirty} + {m.compare_models_heading()}: {pendingModelChanges} + {/if} +
+
+
+ + +
+
+
+{/if} diff --git a/src/routes/weather/compare/[location]/ComparisonSelectionPanel.svelte b/src/routes/weather/compare/[location]/ComparisonSelectionPanel.svelte index 8b660da..ef6196f 100644 --- a/src/routes/weather/compare/[location]/ComparisonSelectionPanel.svelte +++ b/src/routes/weather/compare/[location]/ComparisonSelectionPanel.svelte @@ -21,23 +21,12 @@ mode: 'models' | 'variables'; groups: OptionGroup[]; selected: string[]; - dirty: boolean; onToggle: (value: string) => void; onToggleGroup: (values: string[], select: boolean) => void; - onApply: () => void; onRestoreDefaults?: () => void; } - let { - mode, - groups, - selected, - dirty, - onToggle, - onToggleGroup, - onApply, - onRestoreDefaults - }: Props = $props(); + let { mode, groups, selected, onToggle, onToggleGroup, onRestoreDefaults }: Props = $props(); let search = $state(''); let onlySelected = $state(false); @@ -118,17 +107,6 @@ {selection.count}/{fullGroup.options.length} -
{#each visibleGroup.options as option (option.value)} @@ -164,27 +142,15 @@ {/if}
-
-
- {#if mode === 'variables' && onRestoreDefaults} - - {/if} + {#if mode === 'variables' && onRestoreDefaults} +
+
- -
+ {/if}
diff --git a/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte b/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte index d458aa1..cf16bf3 100644 --- a/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte +++ b/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte @@ -26,6 +26,8 @@ group: string; plotInsetLeft: number; plotInsetRight: number; + showModelNames: boolean; + onToggleModelNames: () => void; registerExporter?: (exporter: TimelineExporter | null) => void; } @@ -68,6 +70,8 @@ group, plotInsetLeft, plotInsetRight, + showModelNames, + onToggleModelNames, registerExporter }: Props = $props(); @@ -600,9 +604,16 @@ context.beginPath(); context.arc(9, rowCenter, 4, 0, Math.PI * 2); context.fill(); - context.fillStyle = foreground; - context.textAlign = 'left'; - context.fillText(modelLabel(model.modelId), 18, rowCenter, Math.max(20, plotInsetLeft - 24)); + if (showModelNames) { + context.fillStyle = foreground; + context.textAlign = 'left'; + context.fillText( + modelLabel(model.modelId), + 18, + rowCenter, + Math.max(20, plotInsetLeft - 24) + ); + } context.save(); context.beginPath(); @@ -659,6 +670,7 @@ void daySeparators; void sunlightSegments; void displayModels; + void showModelNames; if (!target || currentWidth <= 0 || currentHeight <= 0) return; const version = ++screenRenderVersion; @@ -684,11 +696,21 @@ class="mt-7 -mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border" aria-labelledby="model-pictogram-title" > -
-

- {m.compare_timeline_title()} -

-

{m.compare_timeline_hint()}

+
+
+

+ {m.compare_timeline_title()} +

+

{m.compare_timeline_hint()}

+
+