feat: multimodel improvements #9
@@ -1265,21 +1265,6 @@
|
||||
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.
|
||||
const runs: Array<Array<[number, number, number, number]>> = [];
|
||||
let run: Array<[number, number, number, number]> = [];
|
||||
for (let i = 0; i < timestamps.length; i++) {
|
||||
const v = s.data[i];
|
||||
const b = s.bandTo?.[i];
|
||||
const bandInvalid = s.bandTo != null && (b === null || b === undefined || !isFinite(b));
|
||||
if (v === null || v === undefined || !isFinite(v) || bandInvalid) {
|
||||
if (run.length > 0) runs.push(run);
|
||||
run = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (s.type === 'point') {
|
||||
const radius = s.pointRadius ?? 3;
|
||||
ctx.fillStyle = s.color;
|
||||
@@ -1297,6 +1282,21 @@
|
||||
}
|
||||
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.
|
||||
const runs: Array<Array<[number, number, number, number]>> = [];
|
||||
let run: Array<[number, number, number, number]> = [];
|
||||
for (let i = 0; i < timestamps.length; i++) {
|
||||
const v = s.data[i];
|
||||
const b = s.bandTo?.[i];
|
||||
const bandInvalid = s.bandTo != null && (b === null || b === undefined || !isFinite(b));
|
||||
if (v === null || v === undefined || !isFinite(v) || bandInvalid) {
|
||||
if (run.length > 0) runs.push(run);
|
||||
run = [];
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { get } from 'svelte/store';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
@@ -93,7 +92,6 @@
|
||||
let chartComponents: CanvasChart[] = $state([]);
|
||||
let pictogramExporter: ExportableChart | null = $state(null);
|
||||
let showLegend = $state(true);
|
||||
const hiddenModels = new SvelteSet<string>();
|
||||
|
||||
let isMobile = $state(false);
|
||||
let comparisonPlotInsetLeft = $derived(isMobile ? 92 : 116);
|
||||
@@ -150,12 +148,14 @@
|
||||
|
||||
let variablesDirty = $derived(!hasSameVariables(params.hourly, appliedHourly));
|
||||
let modelsDirty = $derived(!hasSameOrder(params.models, appliedModels));
|
||||
let displayedVariablesStale = $derived(
|
||||
fetchedData !== null && !hasSameVariables(fetchedData.selection.hourly, appliedHourly)
|
||||
);
|
||||
let displayedModelsStale = $derived(
|
||||
fetchedData !== null && !hasSameOrder(fetchedData.selection.models, appliedModels)
|
||||
);
|
||||
let displayedVariablesStale = $derived.by(() => {
|
||||
const current: FetchedData | null = fetchedData;
|
||||
return current !== null && !hasSameVariables(current.selection.hourly, appliedHourly);
|
||||
});
|
||||
let displayedModelsStale = $derived.by(() => {
|
||||
const current: FetchedData | null = fetchedData;
|
||||
return current !== null && !hasSameOrder(current.selection.models, appliedModels);
|
||||
});
|
||||
let comparisonSelectionDirty = $derived(variablesDirty || modelsDirty);
|
||||
let comparisonMuted = $derived(
|
||||
comparisonSelectionDirty || displayedVariablesStale || displayedModelsStale
|
||||
@@ -384,7 +384,6 @@
|
||||
: (model.variables[variable] ?? []),
|
||||
width: 2,
|
||||
pointRadius: 3.25,
|
||||
hidden: hiddenModels.has(model.modelId),
|
||||
format: direction
|
||||
? (value) => `${value.toFixed(0)}° ${cardinalDirection(value)}`
|
||||
: undefined
|
||||
@@ -422,7 +421,7 @@
|
||||
variable === 'precipitation'
|
||||
? {
|
||||
points: precipitationAgreement(
|
||||
result.models.filter((model) => !hiddenModels.has(model.modelId)),
|
||||
result.models,
|
||||
variable,
|
||||
result.timestamps.length,
|
||||
wetThreshold
|
||||
@@ -488,7 +487,6 @@
|
||||
|
||||
let exportLegend = $derived.by((): ExportLegendItem[] => [
|
||||
...displayedModels
|
||||
.filter((model) => !hiddenModels.has(model.modelId))
|
||||
.map((model) => ({
|
||||
name: modelLabel(model.modelId),
|
||||
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 {
|
||||
const values = params[list];
|
||||
const nextValues = values.includes(value)
|
||||
@@ -534,9 +527,6 @@
|
||||
const variablesChanged = !hasSameVariables(params.hourly, appliedHourly);
|
||||
const modelsChanged = !hasSameOrder(params.models, appliedModels);
|
||||
if (modelsChanged) {
|
||||
for (const modelId of params.models) {
|
||||
if (!appliedModels.includes(modelId)) hiddenModels.delete(modelId);
|
||||
}
|
||||
appliedModels = [...params.models];
|
||||
}
|
||||
if (variablesChanged) appliedHourly = [...params.hourly];
|
||||
@@ -795,22 +785,13 @@
|
||||
transition:fade={{ duration: 150 }}
|
||||
>
|
||||
{#each displayedModels as model (model.modelId)}
|
||||
<button
|
||||
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 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)}
|
||||
</button>
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user