3 Commits
Author SHA1 Message Date
terraputix 506831cf88 mobile improvements mostly 2026-08-03 23:14:23 +02:00
terraputix 39f36819ae selection in page 2026-08-03 22:33:54 +02:00
terraputix b53eb77c75 fix: reapply hourly weather_code 2026-08-03 22:12:55 +02:00
11 changed files with 362 additions and 505 deletions
+3
View File
@@ -293,6 +293,7 @@
"compare_selected_models": "Ausgewählte Modelle", "compare_selected_models": "Ausgewählte Modelle",
"compare_selection_pending": "Auswahl geändert", "compare_selection_pending": "Auswahl geändert",
"compare_apply_selection": "Anwenden & Diagramme neu laden", "compare_apply_selection": "Anwenden & Diagramme neu laden",
"compare_discard_changes": "Änderungen verwerfen",
"compare_customize_models": "Modelle anpassen", "compare_customize_models": "Modelle anpassen",
"compare_edit_models": "Modelle bearbeiten", "compare_edit_models": "Modelle bearbeiten",
"compare_edit_variables": "Variablen bearbeiten", "compare_edit_variables": "Variablen bearbeiten",
@@ -300,6 +301,8 @@
"compare_edit_variables_description": "Variablen suchen oder ganze Themengruppen auswählen.", "compare_edit_variables_description": "Variablen suchen oder ganze Themengruppen auswählen.",
"compare_search_models": "Modelle suchen", "compare_search_models": "Modelle suchen",
"compare_search_variables": "Variablen 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_only_selected": "Nur ausgewählte",
"compare_select_group": "Alle auswählen", "compare_select_group": "Alle auswählen",
"compare_clear_group": "Alle abwählen", "compare_clear_group": "Alle abwählen",
+3
View File
@@ -293,6 +293,7 @@
"compare_selected_models": "Selected models", "compare_selected_models": "Selected models",
"compare_selection_pending": "Selection changed", "compare_selection_pending": "Selection changed",
"compare_apply_selection": "Apply & reload charts", "compare_apply_selection": "Apply & reload charts",
"compare_discard_changes": "Discard changes",
"compare_customize_models": "Customize models", "compare_customize_models": "Customize models",
"compare_edit_models": "Edit models", "compare_edit_models": "Edit models",
"compare_edit_variables": "Edit variables", "compare_edit_variables": "Edit variables",
@@ -300,6 +301,8 @@
"compare_edit_variables_description": "Search variables or select complete thematic groups.", "compare_edit_variables_description": "Search variables or select complete thematic groups.",
"compare_search_models": "Search models", "compare_search_models": "Search models",
"compare_search_variables": "Search variables", "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_only_selected": "Only selected",
"compare_select_group": "Select all", "compare_select_group": "Select all",
"compare_clear_group": "Clear all", "compare_clear_group": "Clear all",
+3
View File
@@ -293,6 +293,7 @@
"compare_selected_models": "Modelos seleccionados", "compare_selected_models": "Modelos seleccionados",
"compare_selection_pending": "Selección modificada", "compare_selection_pending": "Selección modificada",
"compare_apply_selection": "Aplicar y recargar gráficos", "compare_apply_selection": "Aplicar y recargar gráficos",
"compare_discard_changes": "Descartar cambios",
"compare_customize_models": "Personalizar modelos", "compare_customize_models": "Personalizar modelos",
"compare_edit_models": "Editar modelos", "compare_edit_models": "Editar modelos",
"compare_edit_variables": "Editar variables", "compare_edit_variables": "Editar variables",
@@ -300,6 +301,8 @@
"compare_edit_variables_description": "Busca variables o selecciona grupos temáticos completos.", "compare_edit_variables_description": "Busca variables o selecciona grupos temáticos completos.",
"compare_search_models": "Buscar modelos", "compare_search_models": "Buscar modelos",
"compare_search_variables": "Buscar variables", "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_only_selected": "Solo seleccionados",
"compare_select_group": "Seleccionar todo", "compare_select_group": "Seleccionar todo",
"compare_clear_group": "Deseleccionar todo", "compare_clear_group": "Deseleccionar todo",
+3
View File
@@ -293,6 +293,7 @@
"compare_selected_models": "Modèles sélectionnés", "compare_selected_models": "Modèles sélectionnés",
"compare_selection_pending": "Sélection modifiée", "compare_selection_pending": "Sélection modifiée",
"compare_apply_selection": "Appliquer et recharger les graphiques", "compare_apply_selection": "Appliquer et recharger les graphiques",
"compare_discard_changes": "Annuler les modifications",
"compare_customize_models": "Personnaliser les modèles", "compare_customize_models": "Personnaliser les modèles",
"compare_edit_models": "Modifier les modèles", "compare_edit_models": "Modifier les modèles",
"compare_edit_variables": "Modifier les variables", "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_edit_variables_description": "Recherchez des variables ou sélectionnez des groupes thématiques entiers.",
"compare_search_models": "Rechercher des modèles", "compare_search_models": "Rechercher des modèles",
"compare_search_variables": "Rechercher des variables", "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_only_selected": "Sélection uniquement",
"compare_select_group": "Tout sélectionner", "compare_select_group": "Tout sélectionner",
"compare_clear_group": "Tout désélectionner", "compare_clear_group": "Tout désélectionner",
+3
View File
@@ -293,6 +293,7 @@
"compare_selected_models": "Modelli selezionati", "compare_selected_models": "Modelli selezionati",
"compare_selection_pending": "Selezione modificata", "compare_selection_pending": "Selezione modificata",
"compare_apply_selection": "Applica e ricarica i grafici", "compare_apply_selection": "Applica e ricarica i grafici",
"compare_discard_changes": "Annulla modifiche",
"compare_customize_models": "Personalizza modelli", "compare_customize_models": "Personalizza modelli",
"compare_edit_models": "Modifica modelli", "compare_edit_models": "Modifica modelli",
"compare_edit_variables": "Modifica variabili", "compare_edit_variables": "Modifica variabili",
@@ -300,6 +301,8 @@
"compare_edit_variables_description": "Cerca variabili o seleziona interi gruppi tematici.", "compare_edit_variables_description": "Cerca variabili o seleziona interi gruppi tematici.",
"compare_search_models": "Cerca modelli", "compare_search_models": "Cerca modelli",
"compare_search_variables": "Cerca variabili", "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_only_selected": "Solo selezionati",
"compare_select_group": "Seleziona tutto", "compare_select_group": "Seleziona tutto",
"compare_clear_group": "Deseleziona tutto", "compare_clear_group": "Deseleziona tutto",
+23 -12
View File
@@ -209,6 +209,8 @@
yTicks?: number[]; yTicks?: number[];
/** Custom left-axis tick formatter. */ /** Custom left-axis tick formatter. */
yTickFormat?: (value: number) => string; 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) */ /** Force the derived left axis to include zero (default true) */
zeroBaseLeft?: boolean; zeroBaseLeft?: boolean;
/** Fixed right-axis minimum (default 0) */ /** Fixed right-axis minimum (default 0) */
@@ -260,6 +262,7 @@
yMax, yMax,
yTicks, yTicks,
yTickFormat, yTickFormat,
compactYAxis = false,
yPadTop, yPadTop,
yPadBottom, yPadBottom,
zeroBaseLeft = true, zeroBaseLeft = true,
@@ -497,7 +500,7 @@
let iconRows = $derived(isNarrow ? ownIconRows : Math.max(ownIconRows, reserveTopRows)); let iconRows = $derived(isNarrow ? ownIconRows : Math.max(ownIconRows, reserveTopRows));
// tighter top/bottom gutters on mobile so charts don't waste vertical space // tighter top/bottom gutters on mobile so charts don't waste vertical space
const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H); 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( let agreementStripOffset = $derived(
agreementStrip ? AGREEMENT_STRIP_HEIGHT + AGREEMENT_STRIP_GAP + 2 : 0 agreementStrip ? AGREEMENT_STRIP_HEIGHT + AGREEMENT_STRIP_GAP + 2 : 0
); );
@@ -1087,7 +1090,13 @@
ctx.lineTo(plotRight, y); ctx.lineTo(plotRight, y);
ctx.stroke(); ctx.stroke();
ctx.fillStyle = textColor; 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 // Right axis labels in the gutter (desktop only). On mobile there is no
@@ -1543,16 +1552,18 @@
ctx.strokeStyle = withAlpha(gridColor, 0.8); ctx.strokeStyle = withAlpha(gridColor, 0.8);
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.strokeRect(padLeft, stripTop + 0.5, plotW, AGREEMENT_STRIP_HEIGHT - 1); ctx.strokeRect(padLeft, stripTop + 0.5, plotW, AGREEMENT_STRIP_HEIGHT - 1);
ctx.fillStyle = textColor; if (!compactYAxis) {
ctx.font = '600 9px system-ui, sans-serif'; ctx.fillStyle = textColor;
ctx.textAlign = 'right'; ctx.font = '600 9px system-ui, sans-serif';
ctx.textBaseline = 'middle'; ctx.textAlign = 'right';
ctx.fillText( ctx.textBaseline = 'middle';
agreementStrip.label, ctx.fillText(
padLeft - 6, agreementStrip.label,
stripTop + AGREEMENT_STRIP_HEIGHT / 2, padLeft - 6,
Math.max(24, padLeft - 10) stripTop + AGREEMENT_STRIP_HEIGHT / 2,
); Math.max(24, padLeft - 10)
);
}
} }
// Axis unit labels // Axis unit labels
@@ -108,10 +108,9 @@ export async function downloadChartsPng(
} }
if (row.length > 0) legendRows.push(row); if (row.length > 0) legendRows.push(row);
} }
const headerHeight = const titleHeight = options.title ? 34 * dpr : 0;
(options.title ? 34 * dpr : 0) + const legendHeight = legendRows.length > 0 ? legendRows.length * rowHeight + 8 * dpr : 0;
(legendRows.length > 0 ? legendRows.length * rowHeight + 8 * dpr : 0); const totalHeight = titleHeight + canvases.reduce((sum, c) => sum + c.height, 0) + legendHeight;
const totalHeight = headerHeight + canvases.reduce((sum, c) => sum + c.height, 0);
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = maxWidth; canvas.width = maxWidth;
@@ -134,7 +133,12 @@ export async function downloadChartsPng(
ctx.fillText(options.title, padding, 17 * dpr); ctx.fillText(options.title, padding, 17 * dpr);
y += 34 * dpr; y += 34 * dpr;
} }
for (const c of canvases) {
ctx.drawImage(c, 0, y);
y += c.height;
}
if (legendRows.length > 0) { if (legendRows.length > 0) {
y += 8 * dpr;
ctx.font = `${12 * dpr}px system-ui, -apple-system, sans-serif`; ctx.font = `${12 * dpr}px system-ui, -apple-system, sans-serif`;
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
for (const row of legendRows) { for (const row of legendRows) {
@@ -165,11 +169,6 @@ export async function downloadChartsPng(
} }
y += rowHeight; 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`); triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
+130 -243
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount, untrack } from 'svelte'; import { onDestroy, onMount, tick, untrack } from 'svelte';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { page } from '$app/stores'; import { page } from '$app/stores';
@@ -17,9 +16,6 @@
type ExportableChart, type ExportableChart,
type ExportLegendItem type ExportLegendItem
} from '$lib/components/charts'; } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import { import {
CHART_COLORS, CHART_COLORS,
CanvasChart, CanvasChart,
@@ -39,7 +35,7 @@
import { useHeroActions } from '../../hero.svelte'; import { useHeroActions } from '../../hero.svelte';
import { defaultParameters, hourly, modelGroups, models as modelOptions } from '../../options'; import { defaultParameters, hourly, modelGroups, models as modelOptions } from '../../options';
import ComparisonSelectionDrawer from './ComparisonSelectionDrawer.svelte'; import ComparisonSelectionPanel from './ComparisonSelectionPanel.svelte';
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte'; import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
import { import {
cardinalDirection, cardinalDirection,
@@ -60,6 +56,10 @@
const AUTOMATIC_SEAMLESS_MODEL_IDS = new Set( const AUTOMATIC_SEAMLESS_MODEL_IDS = new Set(
modelGroups.find((group) => group.value === 'automatic_seamless')?.models.map((model) => model.value) modelGroups.find((group) => group.value === 'automatic_seamless')?.models.map((model) => model.value)
); );
const COMPARE_MODEL_GROUPS = [
...modelGroups.filter((group) => group.value !== 'automatic_seamless'),
...modelGroups.filter((group) => group.value === 'automatic_seamless')
];
// Weather code and total cloud cover form one comparison feature. Cloud cover // Weather code and total cloud cover form one comparison feature. Cloud cover
// remains an auxiliary API field for pictogram shading, not a standalone chart. // remains an auxiliary API field for pictogram shading, not a standalone chart.
const COMPARISON_HOURLY = hourly.map((group) => const COMPARISON_HOURLY = hourly.map((group) =>
@@ -84,20 +84,22 @@
let location = $derived(data.location); let location = $derived(data.location);
let mounted = $state(false); let mounted = $state(false);
let loading = $state(true); let loading = $state(true);
let refreshing = $state(false);
let loadError = $state<FriendlyWeatherError | null>(null); let loadError = $state<FriendlyWeatherError | null>(null);
let retryNonce = $state(0); let retryNonce = $state(0);
let requestVersion = 0; let requestVersion = 0;
let activeController: AbortController | null = null; let activeController: AbortController | null = null;
let chartComponents: CanvasChart[] = $state([]); let chartComponents: CanvasChart[] = $state([]);
let pictogramExporter: ExportableChart | null = $state(null); let pictogramExporter: ExportableChart | null = $state(null);
let showLegend = $state(true);
let isMobile = $state(false); let isMobile = $state(false);
let comparisonPlotInsetLeft = $derived(isMobile ? 92 : 116); let showModelNames = $state(true);
let modelNamesInitialized = false;
let initialRangeInitialized = false;
let compactYAxis = $derived(!showModelNames);
let comparisonPlotInsetLeft = $derived(
showModelNames ? (isMobile ? 92 : 116) : isMobile ? 34 : 40
);
let comparisonPlotInsetRight = $derived(isMobile ? 8 : 20); let comparisonPlotInsetRight = $derived(isMobile ? 8 : 20);
let selectionDrawerMode = $state<'models' | 'variables' | null>(null);
let params = $state({ let params = $state({
...defaultParameters, ...defaultParameters,
hourly: [...STANDARD_COMPARE_VARIABLES], hourly: [...STANDARD_COMPARE_VARIABLES],
@@ -130,6 +132,15 @@
return left.length === right.length && left.every((value, index) => value === right[index]); return left.length === right.length && left.every((value, index) => value === right[index]);
} }
function changedValueCount(left: string[], right: string[]): number {
const leftValues = new Set(left);
const rightValues = new Set(right);
let count = 0;
for (const value of leftValues) if (!rightValues.has(value)) count++;
for (const value of rightValues) if (!leftValues.has(value)) count++;
return count;
}
function orderModels(values: string[]): string[] { function orderModels(values: string[]): string[] {
return [...values].sort( return [...values].sort(
(left, right) => (left, right) =>
@@ -157,6 +168,8 @@
return current !== null && !hasSameOrder(current.selection.models, appliedModels); return current !== null && !hasSameOrder(current.selection.models, appliedModels);
}); });
let comparisonSelectionDirty = $derived(variablesDirty || modelsDirty); let comparisonSelectionDirty = $derived(variablesDirty || modelsDirty);
let pendingVariableChanges = $derived(changedValueCount(params.hourly, appliedHourly));
let pendingModelChanges = $derived(changedValueCount(params.models, appliedModels));
let comparisonMuted = $derived( let comparisonMuted = $derived(
comparisonSelectionDirty || displayedVariablesStale || displayedModelsStale comparisonSelectionDirty || displayedVariablesStale || displayedModelsStale
); );
@@ -196,6 +209,10 @@
const media = window.matchMedia('(max-width: 639px)'); const media = window.matchMedia('(max-width: 639px)');
const applyMedia = () => { const applyMedia = () => {
isMobile = media.matches; isMobile = media.matches;
if (!modelNamesInitialized) {
showModelNames = !media.matches;
modelNamesInitialized = true;
}
}; };
applyMedia(); applyMedia();
media.addEventListener('change', applyMedia); media.addEventListener('change', applyMedia);
@@ -234,14 +251,12 @@
if (modelList.length === 0 || hourlyVars.length === 0) { if (modelList.length === 0 || hourlyVars.length === 0) {
fetchedData = null; fetchedData = null;
loading = false; loading = false;
refreshing = false;
loadError = null; loadError = null;
return; return;
} }
const hasFetchedData = untrack(() => fetchedData !== null); const hasFetchedData = untrack(() => fetchedData !== null);
loading = !hasFetchedData; loading = !hasFetchedData;
refreshing = hasFetchedData;
loadError = null; loadError = null;
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
const controller = new AbortController(); const controller = new AbortController();
@@ -264,15 +279,20 @@
) )
.then((result) => { .then((result) => {
if (version !== requestVersion) return; if (version !== requestVersion) return;
const applyInitialMobileRange = !initialRangeInitialized && isMobile;
initialRangeInitialized = true;
fetchedData = { result, selection: { models: modelList, hourly: hourlyVars } }; fetchedData = { result, selection: { models: modelList, hourly: hourlyVars } };
loading = false; loading = false;
refreshing = false; if (applyInitialMobileRange) {
void tick().then(() => {
if (version === requestVersion && groupRange(CHART_GROUP) === null) setRangeDays(3);
});
}
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (version !== requestVersion || controller.signal.aborted) return; if (version !== requestVersion || controller.signal.aborted) return;
loadError = humanizeWeatherError(error); loadError = humanizeWeatherError(error);
loading = false; loading = false;
refreshing = false;
}); });
}, FETCH_DEBOUNCE_MS); }, FETCH_DEBOUNCE_MS);
@@ -534,6 +554,11 @@
if (!variablesChanged && !modelsChanged) retryNonce++; if (!variablesChanged && !modelsChanged) retryNonce++;
} }
function discardComparisonSelection(): void {
params.hourly = [...appliedHourly];
params.models = [...appliedModels];
}
const variableGroupLabels = [ const variableGroupLabels = [
m.compare_group_temperature, m.compare_group_temperature,
m.compare_group_precipitation, m.compare_group_precipitation,
@@ -542,14 +567,8 @@
m.compare_group_upper_air m.compare_group_upper_air
]; ];
const allHourlyVariables = COMPARISON_HOURLY.flat(); const allHourlyVariables = COMPARISON_HOURLY.flat();
let selectedModels = $derived( let modelSelectionGroups = $derived(
params.models.flatMap((value) => { COMPARE_MODEL_GROUPS.map((group) => ({
const model = modelOptions.find((candidate) => candidate.value === value);
return model ? [model] : [];
})
);
let modelDrawerGroups = $derived(
modelGroups.map((group) => ({
value: group.value, value: group.value,
label: group.label, label: group.label,
options: group.models.map((model) => ({ options: group.models.map((model) => ({
@@ -559,17 +578,7 @@
})) }))
})) }))
); );
let selectedVariables = $derived( let variableSelectionGroups = $derived(
params.hourly.flatMap((value) => {
const variable = allHourlyVariables.find((candidate) => candidate.value === value);
return variable ? [variable] : [];
})
);
let standardVariablesActive = $derived(
params.hourly.length === STANDARD_COMPARE_VARIABLES.length &&
STANDARD_COMPARE_VARIABLES.every((variable) => params.hourly.includes(variable))
);
let variableDrawerGroups = $derived(
COMPARISON_HOURLY.map((variables, index) => ({ COMPARISON_HOURLY.map((variables, index) => ({
value: `variables_${index}`, value: `variables_${index}`,
label: variableGroupLabels[index](), label: variableGroupLabels[index](),
@@ -582,7 +591,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{m.page_compare_title()} | Drizz.li</title> <title>Drizz.li | {m.page_compare_title()}</title>
<link rel="canonical" href="https://drizz.li/weather/compare" /> <link rel="canonical" href="https://drizz.li/weather/compare" />
<meta name="description" content={m.page_compare_description()} /> <meta name="description" content={m.page_compare_description()} />
</svelte:head> </svelte:head>
@@ -703,6 +712,7 @@
yMax={def.yMax} yMax={def.yMax}
yTicks={def.yTicks} yTicks={def.yTicks}
yTickFormat={def.yTickFormat} yTickFormat={def.yTickFormat}
{compactYAxis}
plotInsetLeft={comparisonPlotInsetLeft} plotInsetLeft={comparisonPlotInsetLeft}
plotInsetRight={comparisonPlotInsetRight} plotInsetRight={comparisonPlotInsetRight}
showCredit={i === chartDefs.length - 1} showCredit={i === chartDefs.length - 1}
@@ -725,14 +735,6 @@
<span class="text-xs font-semibold text-muted-foreground"> <span class="text-xs font-semibold text-muted-foreground">
{m.compare_selection_pending()} {m.compare_selection_pending()}
</span> </span>
<button
type="button"
class="min-h-10 rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-wait disabled:opacity-70"
disabled={(displayedVariablesStale || displayedModelsStale) && refreshing}
onclick={applyComparisonSelection}
>
{m.compare_apply_selection()}
</button>
</div> </div>
</div> </div>
{/if} {/if}
@@ -746,7 +748,7 @@
/> />
{/if} {/if}
{#if fetchedData && !loading && appliedHourly.includes('weather_code')} {#if fetchedData && !loading && fetchedData.selection.hourly.includes('weather_code')}
<div <div
class="transition-[opacity,filter] duration-200 {comparisonMuted class="transition-[opacity,filter] duration-200 {comparisonMuted
? 'pointer-events-none opacity-35 saturate-50' ? 'pointer-events-none opacity-35 saturate-50'
@@ -762,6 +764,8 @@
group={CHART_GROUP} group={CHART_GROUP}
plotInsetLeft={comparisonPlotInsetLeft} plotInsetLeft={comparisonPlotInsetLeft}
plotInsetRight={comparisonPlotInsetRight} plotInsetRight={comparisonPlotInsetRight}
{showModelNames}
onToggleModelNames={() => (showModelNames = !showModelNames)}
registerExporter={(exporter) => (pictogramExporter = exporter)} registerExporter={(exporter) => (pictogramExporter = exporter)}
/> />
</div> </div>
@@ -773,36 +777,25 @@
? 'pointer-events-none opacity-40' ? 'pointer-events-none opacity-40'
: ''}" : ''}"
> >
<div class="flex items-center justify-between gap-3"> <p class="text-xs font-semibold text-muted-foreground">{m.compare_model_colors()}</p>
<p class="text-xs font-semibold text-muted-foreground">{m.compare_model_colors()}</p> <div class="-mx-1 mt-2 flex gap-3 overflow-x-auto px-1 pb-1 sm:flex-wrap">
<div class="flex items-center gap-2"> {#each displayedModels as model (model.modelId)}
<Switch id="show_legend" bind:checked={showLegend} /> <span class="flex min-h-8 shrink-0 items-center gap-1.5 text-xs">
<Label for="show_legend" class="text-xs">{m.legend_show()}</Label> <span
</div> class="size-2.5 rounded-full"
style:background-color={modelColor(model.modelId, fetchedData.selection.models)}
></span>
{modelLabel(model.modelId)}
</span>
{/each}
{#if chartDefs.some( (def) => def.series.some((series) => series.name === m.compare_model_mean()) )}
<span class="flex shrink-0 items-center gap-1.5 text-xs">
<span class="w-4 border-t-2 border-dashed" style:border-color={CHART_COLORS.average}
></span>
{m.compare_model_mean()}
</span>
{/if}
</div> </div>
{#if showLegend}
<div
class="-mx-1 mt-2 flex gap-3 overflow-x-auto px-1 pb-1 sm:flex-wrap"
transition:fade={{ duration: 150 }}
>
{#each displayedModels as model (model.modelId)}
<span class="flex min-h-8 shrink-0 items-center gap-1.5 text-xs">
<span
class="size-2.5 rounded-full"
style:background-color={modelColor(model.modelId, fetchedData.selection.models)}
></span>
{modelLabel(model.modelId)}
</span>
{/each}
{#if chartDefs.some( (def) => def.series.some((series) => series.name === m.compare_model_mean()) )}
<span class="flex shrink-0 items-center gap-1.5 text-xs">
<span class="w-4 border-t-2 border-dashed" style:border-color={CHART_COLORS.average}
></span>
{m.compare_model_mean()}
</span>
{/if}
</div>
{/if}
</div> </div>
<div <div
@@ -824,8 +817,29 @@
</div> </div>
{/if} {/if}
<!-- Compact summaries keep the page scannable; full editing happens in the shared drawer. --> <section
<section class="mt-6 border-t border-border/60 pt-5 md:mt-10" aria-labelledby="models"> class="mt-6 border-t border-border/60 pt-5 md:mt-10"
aria-labelledby="hourly_weather_variables"
>
<div class="flex items-baseline justify-between gap-3">
<h2 id="hourly_weather_variables" class="text-2xl md:text-3xl">
{m.compare_variables_heading()}
</h2>
<span class="shrink-0 text-sm text-muted-foreground">
{params.hourly.length}/{allHourlyVariables.length}
</span>
</div>
<ComparisonSelectionPanel
mode="variables"
groups={variableSelectionGroups}
selected={params.hourly}
onToggle={(value) => toggleSelection('hourly', value)}
onToggleGroup={(values, select) => toggleGroupSelection('hourly', values, select)}
onRestoreDefaults={restoreStandardVariables}
/>
</section>
<section class="mt-5 border-t border-border/60 pt-5 md:mt-10" aria-labelledby="models">
<div class="flex items-baseline justify-between gap-3"> <div class="flex items-baseline justify-between gap-3">
<h2 id="models" class="text-2xl md:text-3xl">{m.compare_models_heading()}</h2> <h2 id="models" class="text-2xl md:text-3xl">{m.compare_models_heading()}</h2>
<span class="shrink-0 text-sm text-muted-foreground"> <span class="shrink-0 text-sm text-muted-foreground">
@@ -840,177 +854,50 @@
{m.compare_many_models_warning({ count: params.models.length })} {m.compare_many_models_warning({ count: params.models.length })}
</p> </p>
{/if} {/if}
<div class="mt-3 rounded-xl border border-border/70 bg-card p-3 shadow-sm sm:p-4"> <ComparisonSelectionPanel
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-bold">{m.compare_selected_models()}</h3>
{#if modelsDirty}
<span
class="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold tracking-wide text-amber-700 uppercase dark:text-amber-300"
>
{m.compare_selection_pending()}
</span>
{/if}
</div>
{#if selectedModels.length > 0}
<div class="mt-2 flex flex-wrap gap-1.5">
{#each selectedModels as model, index (model.value)}
<button
type="button"
class="inline-flex min-h-8 max-w-full items-center gap-1.5 rounded-full border border-border/70 bg-background px-2.5 py-1 text-xs hover:border-destructive/40 hover:bg-destructive/5"
onclick={() => toggleSelection('models', model.value)}
aria-label={m.compare_remove_model({ model: model.label })}
>
<span class="text-[10px] font-bold text-muted-foreground tabular-nums"
>{index + 1}</span
>
<span
class="size-2.5 shrink-0 rounded-full"
style:background-color={modelColor(model.value, params.models)}
></span>
<span class="truncate">{model.label}</span>
<span aria-hidden="true" class="text-muted-foreground">×</span>
</button>
{/each}
</div>
{:else}
<p class="mt-1.5 text-xs text-muted-foreground">{m.compare_no_models_selected()}</p>
{/if}
</div>
<div class="flex shrink-0 flex-wrap gap-2 sm:justify-end">
<button
type="button"
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
onclick={() => (selectionDrawerMode = 'models')}
aria-haspopup="dialog"
>
{m.compare_customize_models()}
</button>
{#if modelsDirty}
<button
type="button"
class="min-h-9 rounded-lg bg-primary px-3 py-1.5 text-xs font-bold text-primary-foreground hover:bg-primary/90"
onclick={applyComparisonSelection}
>
{m.compare_apply_selection()}
</button>
{/if}
</div>
</div>
</div>
</section>
<section
class="mt-5 border-t border-border/60 pt-5 md:mt-10"
aria-labelledby="hourly_weather_variables"
>
<div class="flex items-baseline justify-between gap-3">
<h2 id="hourly_weather_variables" class="text-2xl md:text-3xl">
{m.compare_variables_heading()}
</h2>
<span class="shrink-0 text-sm text-muted-foreground">
{params.hourly.length}/{allHourlyVariables.length}
</span>
</div>
<div
class="mt-3 rounded-xl border bg-card shadow-sm {standardVariablesActive
? 'border-primary/35'
: 'border-border/70'}"
>
<div class="flex flex-col gap-3 p-3 sm:flex-row sm:items-start sm:justify-between sm:p-4">
<div class="flex min-w-0 gap-3">
<span
class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border {standardVariablesActive
? 'border-primary bg-primary'
: 'border-muted-foreground/40 bg-muted'}"
aria-hidden="true"
>
{#if standardVariablesActive}<span class="size-2 rounded-full bg-primary-foreground"></span>{/if}
</span>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-bold">{m.compare_standard_preset()}</h3>
<span
class="rounded-full px-2 py-0.5 text-[10px] font-bold tracking-wide uppercase {variablesDirty
? 'bg-amber-500/15 text-amber-700 dark:text-amber-300'
: standardVariablesActive
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground'}"
>
{variablesDirty
? m.compare_variables_pending()
: standardVariablesActive
? m.compare_preset_active()
: m.compare_custom_selection({ count: params.hourly.length })}
</span>
</div>
<p class="mt-0.5 text-sm text-muted-foreground">
{m.compare_standard_preset_description()}
</p>
{#if selectedVariables.length > 0}
<p class="mt-2 text-xs leading-relaxed text-foreground/80">
{selectedVariables.map((variable) => variableLabel(variable.value)).join(' · ')}
</p>
{/if}
</div>
</div>
<div class="flex shrink-0 flex-wrap gap-2 sm:justify-end">
{#if !standardVariablesActive}
<button
type="button"
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
onclick={restoreStandardVariables}
>
{m.compare_restore_defaults()}
</button>
{/if}
<button
type="button"
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
onclick={() => (selectionDrawerMode = 'variables')}
aria-haspopup="dialog"
>
{m.compare_customize_variables()}
</button>
{#if variablesDirty}
<button
type="button"
class="min-h-9 rounded-lg bg-primary px-3 py-1.5 text-xs font-bold text-primary-foreground hover:bg-primary/90"
onclick={applyComparisonSelection}
>
{m.compare_apply_selection()}
</button>
{/if}
</div>
</div>
</div>
</section>
{#if selectionDrawerMode === 'models'}
<ComparisonSelectionDrawer
open
mode="models" mode="models"
groups={modelDrawerGroups} groups={modelSelectionGroups}
selected={params.models} selected={params.models}
dirty={comparisonSelectionDirty}
onToggle={(value) => toggleSelection('models', value)} onToggle={(value) => toggleSelection('models', value)}
onToggleGroup={(values, select) => toggleGroupSelection('models', values, select)} onToggleGroup={(values, select) => toggleGroupSelection('models', values, select)}
onApply={applyComparisonSelection}
onClose={() => (selectionDrawerMode = null)}
/>
{:else if selectionDrawerMode === 'variables'}
<ComparisonSelectionDrawer
open
mode="variables"
groups={variableDrawerGroups}
selected={params.hourly}
dirty={comparisonSelectionDirty}
onToggle={(value) => toggleSelection('hourly', value)}
onToggleGroup={(values, select) => toggleGroupSelection('hourly', values, select)}
onApply={applyComparisonSelection}
onClose={() => (selectionDrawerMode = null)}
onRestoreDefaults={restoreStandardVariables}
/> />
</section>
{#if comparisonSelectionDirty}
<div class="h-24" aria-hidden="true"></div>
<div
class="pointer-events-none fixed right-3 bottom-3 left-3 z-50 md:left-auto md:w-[42rem] md:max-w-[calc(100vw-2rem)] lg:right-8"
>
<div
class="pointer-events-auto flex flex-col gap-3 rounded-2xl border border-primary/30 bg-background/95 p-3 shadow-2xl backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:p-4"
>
<div class="min-w-0" aria-live="polite">
<p class="text-sm font-bold">{m.compare_selection_pending()}</p>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{#if variablesDirty}
<span>{m.compare_variables_heading()}: {pendingVariableChanges}</span>
{/if}
{#if modelsDirty}
<span>{m.compare_models_heading()}: {pendingModelChanges}</span>
{/if}
</div>
</div>
<div class="grid shrink-0 grid-cols-2 gap-2">
<button
type="button"
class="min-h-10 rounded-lg border border-border bg-background px-3 py-2 text-sm font-semibold hover:bg-muted"
onclick={discardComparisonSelection}
>
{m.compare_discard_changes()}
</button>
<button
type="button"
class="min-h-10 rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90"
onclick={applyComparisonSelection}
>
{m.compare_apply_selection()}
</button>
</div>
</div>
</div>
{/if} {/if}
@@ -1,233 +0,0 @@
<script lang="ts">
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as m from '$lib/paraglide/messages';
interface Option {
value: string;
label: string;
metadata?: string;
}
interface OptionGroup {
value: string;
label: string;
options: Option[];
}
interface Props {
open: boolean;
mode: 'models' | 'variables';
groups: OptionGroup[];
selected: string[];
dirty: boolean;
onToggle: (value: string) => void;
onToggleGroup: (values: string[], select: boolean) => void;
onApply: () => void;
onClose: () => void;
onRestoreDefaults?: () => void;
}
let {
open,
mode,
groups,
selected,
dirty,
onToggle,
onToggleGroup,
onApply,
onClose,
onRestoreDefaults
}: Props = $props();
let search = $state('');
let onlySelected = $state(false);
let normalizedSearch = $derived(search.trim().toLocaleLowerCase());
let visibleGroups = $derived.by(() =>
groups.flatMap((group) => {
const options = group.options.filter((option) => {
if (onlySelected && !selected.includes(option.value)) return false;
if (!normalizedSearch) return true;
return `${group.label} ${option.label} ${option.value} ${option.metadata ?? ''}`
.toLocaleLowerCase()
.includes(normalizedSearch);
});
return options.length > 0 ? [{ ...group, options }] : [];
})
);
$effect(() => {
if (open) return;
search = '';
onlySelected = false;
});
function groupSelection(group: OptionGroup): { count: number; all: boolean; partial: boolean } {
const count = group.options.filter((option) => selected.includes(option.value)).length;
return { count, all: count === group.options.length, partial: count > 0 && count < group.options.length };
}
function applyAndClose(): void {
onApply();
onClose();
}
</script>
<Dialog.Dialog {open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
<Dialog.DialogContent
showCloseButton={false}
class="top-0 right-0 bottom-0 left-auto flex h-dvh w-full max-w-none translate-x-0 translate-y-0 flex-col gap-0 rounded-none border-0 p-0 sm:w-[640px] sm:max-w-[90vw] sm:border-l"
>
<header class="shrink-0 border-b border-border bg-background px-4 py-4 sm:px-5">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<Dialog.DialogTitle class="text-lg font-bold">
{mode === 'models' ? m.compare_edit_models() : m.compare_edit_variables()}
</Dialog.DialogTitle>
<Dialog.DialogDescription class="mt-0.5 text-xs">
{mode === 'models'
? m.compare_edit_models_description()
: m.compare_edit_variables_description()}
</Dialog.DialogDescription>
</div>
<button
type="button"
class="flex size-9 shrink-0 items-center justify-center rounded-lg text-xl text-muted-foreground hover:bg-muted hover:text-foreground"
onclick={onClose}
aria-label={m.action_close()}
>
<span aria-hidden="true">×</span>
</button>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<Input
type="search"
bind:value={search}
placeholder={mode === 'models'
? m.compare_search_models()
: m.compare_search_variables()}
aria-label={mode === 'models'
? m.compare_search_models()
: m.compare_search_variables()}
/>
<div
class="flex min-h-9 shrink-0 cursor-pointer items-center gap-2 rounded-lg border border-border px-3 text-xs font-semibold hover:bg-muted"
>
<Checkbox id="drawer_{mode}_only_selected" bind:checked={onlySelected} />
<Label for="drawer_{mode}_only_selected" class="cursor-pointer text-xs">
{m.compare_only_selected()}
</Label>
</div>
</div>
</header>
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-5">
{#if visibleGroups.length > 0}
<div class="grid items-start gap-3 sm:grid-cols-2">
{#each visibleGroups as visibleGroup (visibleGroup.value)}
{@const fullGroup = groups.find((group) => group.value === visibleGroup.value) ?? visibleGroup}
{@const selection = groupSelection(fullGroup)}
<section class="overflow-hidden rounded-xl border border-border/70 bg-card">
<div class="flex min-h-11 items-center gap-2 border-b border-border/60 bg-muted/30 px-3 py-2">
<Checkbox
id="drawer_{mode}_group_{fullGroup.value}"
checked={selection.all}
indeterminate={selection.partial}
onCheckedChange={() =>
onToggleGroup(
fullGroup.options.map((option) => option.value),
!selection.all
)}
/>
<Label
for="drawer_{mode}_group_{fullGroup.value}"
class="min-w-0 flex-1 cursor-pointer text-sm font-bold"
>
{fullGroup.label}
</Label>
<span class="shrink-0 text-[11px] text-muted-foreground tabular-nums">
{selection.count}/{fullGroup.options.length}
</span>
<button
type="button"
class="shrink-0 rounded-md px-2 py-1 text-[11px] font-semibold text-primary hover:bg-primary/10"
onclick={() =>
onToggleGroup(
fullGroup.options.map((option) => option.value),
!selection.all
)}
>
{selection.all ? m.compare_clear_group() : m.compare_select_group()}
</button>
</div>
<div class="p-2">
{#each visibleGroup.options as option (option.value)}
<div class="flex min-h-10 items-center rounded-lg px-1.5 hover:bg-muted/60">
<Checkbox
id="drawer_{mode}_{option.value}"
checked={selected.includes(option.value)}
onCheckedChange={() => onToggle(option.value)}
/>
<Label
for="drawer_{mode}_{option.value}"
class="min-w-0 flex-1 cursor-pointer py-2 pl-2"
>
<span class="block text-sm font-medium">{option.label}</span>
{#if option.metadata}
<span class="block text-[10px] text-muted-foreground">
{option.metadata}
</span>
{/if}
</Label>
</div>
{/each}
</div>
</section>
{/each}
</div>
{:else}
<div class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground">
{m.compare_no_matching_options()}
</div>
{/if}
</div>
<footer class="shrink-0 border-t border-border bg-background px-4 py-3 sm:px-5">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
{#if mode === 'variables' && onRestoreDefaults}
<button
type="button"
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
onclick={onRestoreDefaults}
>
{m.compare_restore_defaults()}
</button>
{/if}
</div>
<div class="flex gap-2">
<button
type="button"
class="min-h-10 rounded-lg border border-border px-4 py-2 text-sm font-semibold hover:bg-muted"
onclick={onClose}
>
{m.action_close()}
</button>
<button
type="button"
class="min-h-10 rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
disabled={!dirty}
onclick={applyAndClose}
>
{m.compare_apply_selection()}
</button>
</div>
</div>
</footer>
</Dialog.DialogContent>
</Dialog.Dialog>
@@ -0,0 +1,156 @@
<script lang="ts">
import { Checkbox } from '$lib/components/ui/checkbox';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as m from '$lib/paraglide/messages';
interface Option {
value: string;
label: string;
metadata?: string;
}
interface OptionGroup {
value: string;
label: string;
options: Option[];
}
interface Props {
mode: 'models' | 'variables';
groups: OptionGroup[];
selected: string[];
onToggle: (value: string) => void;
onToggleGroup: (values: string[], select: boolean) => void;
onRestoreDefaults?: () => void;
}
let { mode, groups, selected, onToggle, onToggleGroup, onRestoreDefaults }: Props = $props();
let search = $state('');
let onlySelected = $state(false);
let normalizedSearch = $derived(search.trim().toLocaleLowerCase());
let visibleGroups = $derived.by(() =>
groups.flatMap((group) => {
const options = group.options.filter((option) => {
if (onlySelected && !selected.includes(option.value)) return false;
if (!normalizedSearch) return true;
return `${group.label} ${option.label} ${option.value} ${option.metadata ?? ''}`
.toLocaleLowerCase()
.includes(normalizedSearch);
});
return options.length > 0 ? [{ ...group, options }] : [];
})
);
function groupSelection(group: OptionGroup): { count: number; all: boolean; partial: boolean } {
const count = group.options.filter((option) => selected.includes(option.value)).length;
return {
count,
all: count === group.options.length,
partial: count > 0 && count < group.options.length
};
}
</script>
<div class="mt-3 overflow-hidden rounded-xl border border-border/70 bg-card shadow-sm">
<div class="border-b border-border/70 bg-muted/20 p-3 sm:p-4">
<div class="flex flex-col gap-2 sm:flex-row">
<Input
type="search"
bind:value={search}
placeholder={mode === 'models' ? m.compare_search_models() : m.compare_search_variables()}
aria-label={mode === 'models' ? m.compare_search_models() : m.compare_search_variables()}
/>
<div
class="flex min-h-9 shrink-0 cursor-pointer items-center gap-2 rounded-lg border border-border bg-background px-3 text-xs font-semibold hover:bg-muted"
>
<Checkbox id="panel_{mode}_only_selected" bind:checked={onlySelected} />
<Label for="panel_{mode}_only_selected" class="cursor-pointer text-xs">
{m.compare_only_selected()}
</Label>
</div>
</div>
</div>
<div class="p-3 sm:p-4">
{#if visibleGroups.length > 0}
<div class="columns-1 gap-3 md:columns-2">
{#each visibleGroups as visibleGroup (visibleGroup.value)}
{@const fullGroup =
groups.find((group) => group.value === visibleGroup.value) ?? visibleGroup}
{@const selection = groupSelection(fullGroup)}
<section
class="mb-3 inline-block w-full break-inside-avoid overflow-hidden rounded-xl border border-border/70 bg-background align-top"
>
<div
class="flex min-h-11 items-center gap-2 border-b border-border/60 bg-muted/30 px-3 py-2"
>
<Checkbox
id="panel_{mode}_group_{fullGroup.value}"
checked={selection.all}
indeterminate={selection.partial}
onCheckedChange={() =>
onToggleGroup(
fullGroup.options.map((option) => option.value),
!selection.all
)}
/>
<Label
for="panel_{mode}_group_{fullGroup.value}"
class="min-w-0 flex-1 cursor-pointer text-sm font-bold"
>
{fullGroup.label}
</Label>
<span class="shrink-0 text-[11px] text-muted-foreground tabular-nums">
{selection.count}/{fullGroup.options.length}
</span>
</div>
<div class="p-2">
{#each visibleGroup.options as option (option.value)}
<div class="flex min-h-10 items-center rounded-lg px-1.5 hover:bg-muted/60">
<Checkbox
id="panel_{mode}_{option.value}"
checked={selected.includes(option.value)}
onCheckedChange={() => onToggle(option.value)}
/>
<Label
for="panel_{mode}_{option.value}"
class="min-w-0 flex-1 cursor-pointer py-2 pl-2"
>
<span class="block text-sm font-medium">{option.label}</span>
{#if option.metadata}
<span class="block text-[10px] text-muted-foreground">
{option.metadata}
</span>
{/if}
</Label>
</div>
{/each}
</div>
</section>
{/each}
</div>
{:else}
<div
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
>
{m.compare_no_matching_options()}
</div>
{/if}
</div>
{#if mode === 'variables' && onRestoreDefaults}
<div class="border-t border-border bg-muted/20 px-3 py-3 sm:px-4">
<button
type="button"
class="min-h-9 rounded-lg border border-border bg-background px-3 py-1.5 text-xs font-semibold hover:bg-muted"
onclick={onRestoreDefaults}
>
{m.compare_restore_defaults()}
</button>
</div>
{/if}
</div>
@@ -26,6 +26,8 @@
group: string; group: string;
plotInsetLeft: number; plotInsetLeft: number;
plotInsetRight: number; plotInsetRight: number;
showModelNames: boolean;
onToggleModelNames: () => void;
registerExporter?: (exporter: TimelineExporter | null) => void; registerExporter?: (exporter: TimelineExporter | null) => void;
} }
@@ -68,6 +70,8 @@
group, group,
plotInsetLeft, plotInsetLeft,
plotInsetRight, plotInsetRight,
showModelNames,
onToggleModelNames,
registerExporter registerExporter
}: Props = $props(); }: Props = $props();
@@ -600,9 +604,16 @@
context.beginPath(); context.beginPath();
context.arc(9, rowCenter, 4, 0, Math.PI * 2); context.arc(9, rowCenter, 4, 0, Math.PI * 2);
context.fill(); context.fill();
context.fillStyle = foreground; if (showModelNames) {
context.textAlign = 'left'; context.fillStyle = foreground;
context.fillText(modelLabel(model.modelId), 18, rowCenter, Math.max(20, plotInsetLeft - 24)); context.textAlign = 'left';
context.fillText(
modelLabel(model.modelId),
18,
rowCenter,
Math.max(20, plotInsetLeft - 24)
);
}
context.save(); context.save();
context.beginPath(); context.beginPath();
@@ -659,6 +670,7 @@
void daySeparators; void daySeparators;
void sunlightSegments; void sunlightSegments;
void displayModels; void displayModels;
void showModelNames;
if (!target || currentWidth <= 0 || currentHeight <= 0) return; if (!target || currentWidth <= 0 || currentHeight <= 0) return;
const version = ++screenRenderVersion; 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" 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" aria-labelledby="model-pictogram-title"
> >
<div class="px-3 pt-3 pb-2 lg:px-4"> <div class="flex items-start justify-between gap-3 px-3 pt-3 pb-2 lg:px-4">
<h2 id="model-pictogram-title" class="text-sm font-bold tracking-tight"> <div class="min-w-0">
{m.compare_timeline_title()} <h2 id="model-pictogram-title" class="text-sm font-bold tracking-tight">
</h2> {m.compare_timeline_title()}
<p class="text-xs text-muted-foreground">{m.compare_timeline_hint()}</p> </h2>
<p class="text-xs text-muted-foreground">{m.compare_timeline_hint()}</p>
</div>
<button
type="button"
class="min-h-8 shrink-0 rounded-lg border border-border bg-background px-2.5 py-1 text-xs font-semibold whitespace-nowrap text-muted-foreground hover:bg-muted hover:text-foreground"
aria-pressed={showModelNames}
onclick={onToggleModelNames}
>
{showModelNames ? m.compare_hide_model_names() : m.compare_show_model_names()}
</button>
</div> </div>
<div class="lg:px-4"> <div class="lg:px-4">
<div <div