feat: multimodel improvements #9

Merged
vincent merged 10 commits from multimodel-improvements into main 2026-08-10 09:15:58 +02:00
3 changed files with 39 additions and 51 deletions
Showing only changes of commit 8b58bc01d7 - Show all commits
+18 -18
View File
@@ -1265,6 +1265,24 @@
continue; 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 // Line series: draw fill and stroke per contiguous non-null run
// (points outside the view are handled by the clip rect). Each point // (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. // is [x, y, yBand, sourceIndex] — yBand only used when s.bandTo is set.
@@ -1279,24 +1297,6 @@
run = []; run = [];
continue; 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]); run.push([xPix(timestamps[i]), yPix(v, axis), s.bandTo ? yPix(b as number, axis) : 0, i]);
} }
if (run.length > 0) runs.push(run); if (run.length > 0) runs.push(run);
+10 -3
View File
@@ -563,6 +563,11 @@ export async function fetchModelComparison(
{ signal: options.signal } { signal: options.signal }
); );
if (responses.length === 0) throw new Error('The weather service returned no model data.'); 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 // With multiple models, we get one response per model
const firstResponse = responses[0]; const firstResponse = responses[0];
@@ -604,9 +609,11 @@ export async function fetchModelComparison(
const modelHourly = response.hourly(); const modelHourly = response.hourly();
if (!modelHourly) continue; if (!modelHourly) continue;
// Keep the requested id as the stable UI key. The concrete id reported by // The multi-model endpoint preserves request order and returns one response
// Open-Meteo is metadata only: seamless and best-match requests may resolve // per requested model, including unavailable regional models. Keep that
// to a different underlying domain. // 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 modelEnum = response.model();
const resolvedModelId = Model[modelEnum] ?? `model_${modelEnum}`; const resolvedModelId = Model[modelEnum] ?? `model_${modelEnum}`;
const modelId = params.models[responseIndex] ?? resolvedModelId; const modelId = params.models[responseIndex] ?? resolvedModelId;
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@@ -93,7 +92,6 @@
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 showLegend = $state(true);
const hiddenModels = new SvelteSet<string>();
let isMobile = $state(false); let isMobile = $state(false);
let comparisonPlotInsetLeft = $derived(isMobile ? 92 : 116); let comparisonPlotInsetLeft = $derived(isMobile ? 92 : 116);
@@ -150,12 +148,14 @@
let variablesDirty = $derived(!hasSameVariables(params.hourly, appliedHourly)); let variablesDirty = $derived(!hasSameVariables(params.hourly, appliedHourly));
let modelsDirty = $derived(!hasSameOrder(params.models, appliedModels)); let modelsDirty = $derived(!hasSameOrder(params.models, appliedModels));
let displayedVariablesStale = $derived( let displayedVariablesStale = $derived.by(() => {
fetchedData !== null && !hasSameVariables(fetchedData.selection.hourly, appliedHourly) const current: FetchedData | null = fetchedData;
); return current !== null && !hasSameVariables(current.selection.hourly, appliedHourly);
let displayedModelsStale = $derived( });
fetchedData !== null && !hasSameOrder(fetchedData.selection.models, appliedModels) let displayedModelsStale = $derived.by(() => {
); const current: FetchedData | null = fetchedData;
return current !== null && !hasSameOrder(current.selection.models, appliedModels);
});
let comparisonSelectionDirty = $derived(variablesDirty || modelsDirty); let comparisonSelectionDirty = $derived(variablesDirty || modelsDirty);
let comparisonMuted = $derived( let comparisonMuted = $derived(
comparisonSelectionDirty || displayedVariablesStale || displayedModelsStale comparisonSelectionDirty || displayedVariablesStale || displayedModelsStale
@@ -384,7 +384,6 @@
: (model.variables[variable] ?? []), : (model.variables[variable] ?? []),
width: 2, width: 2,
pointRadius: 3.25, pointRadius: 3.25,
hidden: hiddenModels.has(model.modelId),
format: direction format: direction
? (value) => `${value.toFixed(0)}° ${cardinalDirection(value)}` ? (value) => `${value.toFixed(0)}° ${cardinalDirection(value)}`
: undefined : undefined
@@ -422,7 +421,7 @@
variable === 'precipitation' variable === 'precipitation'
? { ? {
points: precipitationAgreement( points: precipitationAgreement(
result.models.filter((model) => !hiddenModels.has(model.modelId)), result.models,
variable, variable,
result.timestamps.length, result.timestamps.length,
wetThreshold wetThreshold
@@ -488,7 +487,6 @@
let exportLegend = $derived.by((): ExportLegendItem[] => [ let exportLegend = $derived.by((): ExportLegendItem[] => [
...displayedModels ...displayedModels
.filter((model) => !hiddenModels.has(model.modelId))
.map((model) => ({ .map((model) => ({
name: modelLabel(model.modelId), name: modelLabel(model.modelId),
color: modelColor(model.modelId, fetchedData?.selection.models), color: modelColor(model.modelId, fetchedData?.selection.models),
@@ -501,11 +499,6 @@
: []) : [])
]); ]);
function toggleModel(modelId: string): void {
if (hiddenModels.has(modelId)) hiddenModels.delete(modelId);
else hiddenModels.add(modelId);
}
function toggleSelection(list: 'models' | 'hourly', value: string): void { function toggleSelection(list: 'models' | 'hourly', value: string): void {
const values = params[list]; const values = params[list];
const nextValues = values.includes(value) const nextValues = values.includes(value)
@@ -534,9 +527,6 @@
const variablesChanged = !hasSameVariables(params.hourly, appliedHourly); const variablesChanged = !hasSameVariables(params.hourly, appliedHourly);
const modelsChanged = !hasSameOrder(params.models, appliedModels); const modelsChanged = !hasSameOrder(params.models, appliedModels);
if (modelsChanged) { if (modelsChanged) {
for (const modelId of params.models) {
if (!appliedModels.includes(modelId)) hiddenModels.delete(modelId);
}
appliedModels = [...params.models]; appliedModels = [...params.models];
} }
if (variablesChanged) appliedHourly = [...params.hourly]; if (variablesChanged) appliedHourly = [...params.hourly];
@@ -795,22 +785,13 @@
transition:fade={{ duration: 150 }} transition:fade={{ duration: 150 }}
> >
{#each displayedModels as model (model.modelId)} {#each displayedModels as model (model.modelId)}
<button <span class="flex min-h-8 shrink-0 items-center gap-1.5 text-xs">
type="button"
class="flex min-h-8 shrink-0 items-center gap-1.5 text-xs {hiddenModels.has(
model.modelId
)
? 'opacity-40'
: ''}"
onclick={() => toggleModel(model.modelId)}
aria-pressed={!hiddenModels.has(model.modelId)}
>
<span <span
class="size-2.5 rounded-full" class="size-2.5 rounded-full"
style:background-color={modelColor(model.modelId, fetchedData.selection.models)} style:background-color={modelColor(model.modelId, fetchedData.selection.models)}
></span> ></span>
{modelLabel(model.modelId)} {modelLabel(model.modelId)}
</button> </span>
{/each} {/each}
{#if chartDefs.some( (def) => def.series.some((series) => series.name === m.compare_model_mean()) )} {#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="flex shrink-0 items-center gap-1.5 text-xs">