Skip to content

Commit 494e767

Browse files
committed
feat(chart): inline variable rail by title + standalone spread toggle
Two header/legend refinements: - Variable picker moves next to the "Hourly forecast" title on the left. On desktop (lg+) it's the full expanded rail with every option in one row; below lg it collapses to the dropdown. The window selector keeps to the right and wraps to its own right-aligned line on the forecast page where the six-option rail fills the first row. - The ±1σ spread is now its own legend chip, toggled independently of the Aggregate line, and the band is built in every line view — so it stays visible when per-model series are switched on (previously the band was suppressed in spaghetti mode and rode the aggregate toggle). The tooltip ± readout now follows the spread toggle. Assisted-by: Claude:claude-opus-4-8
1 parent b826022 commit 494e767

1 file changed

Lines changed: 93 additions & 60 deletions

File tree

src/components/HourlySeriesChart.vue

Lines changed: 93 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const AGG_COLOR = "#e8826b"; // coral — aggregate forecast
4545
const TRUTH_COLOR = "#f5b942"; // sodium amber — ERA5-Seamless truth
4646
const PRECIP_BAR_COLOR = "rgba(127, 184, 224, 0.65)"; // dusty rain blue
4747
const BAND_FILL = "rgba(232, 130, 107, 0.16)"; // coral, low alpha — ±1σ band
48+
const BAND_SWATCH = "rgba(232, 130, 107, 0.45)"; // more visible coral for the legend chip
4849
const TRUTH_AREA = "rgba(245, 185, 66, 0.12)"; // sodium, low alpha — precip truth fill
4950
const NIGHT_FILL = "rgba(10, 16, 24, 0.55)";
5051
const MODEL_PALETTE = ["#6dc6c2", "#9bb87a", "#bfa9d6", "#f0a285", "#7fb8e0", "#d99a1e", "#e8826b", "#9ddad6", "#c7b69a", "#a8c182", "#b88c8c"];
@@ -69,6 +70,7 @@ const hoursWindow = ref<number>(props.defaultWindow);
6970
// directly (no full redraw) — only re-read inside the tooltip formatter at
7071
// hover time, where they don't register as reactive dependencies.
7172
const showAggregate = ref(true);
73+
const showBand = ref(true);
7274
const showTruth = ref(true);
7375
const enabledModels = ref<Set<string>>(new Set());
7476
@@ -169,14 +171,15 @@ function patch(patches: Array<Record<string, unknown> & { id: string }>): void {
169171
170172
function applyAggregateVisibility(): void {
171173
const op = showAggregate.value ? 1 : 0;
172-
patch([
173-
{ id: "agg", lineStyle: { opacity: op }, itemStyle: { opacity: op } },
174-
// The band's translucency lives in its fill colour (BAND_FILL, alpha ~0.16),
175-
// so the "shown" areaStyle opacity must be a full 1 — setting it to the
176-
// alpha value again would multiply the two and wash the band out after a
177-
// recompute. Only toggle between 1 (shown) and 0 (hidden).
178-
{ id: "band-delta", areaStyle: { opacity: op } },
179-
]);
174+
patch([{ id: "agg", lineStyle: { opacity: op }, itemStyle: { opacity: op } }]);
175+
}
176+
177+
function applyBandVisibility(): void {
178+
// The band's translucency lives in its fill colour (BAND_FILL, alpha ~0.16),
179+
// so the "shown" areaStyle opacity must be a full 1 — setting it to the alpha
180+
// value again would multiply the two and wash the band out after a recompute.
181+
// Toggled independently of the aggregate line.
182+
patch([{ id: "band-delta", areaStyle: { opacity: showBand.value ? 1 : 0 } }]);
180183
}
181184
182185
function applyTruthVisibility(): void {
@@ -196,6 +199,10 @@ function toggleAggregate(): void {
196199
showAggregate.value = !showAggregate.value;
197200
applyAggregateVisibility();
198201
}
202+
function toggleBand(): void {
203+
showBand.value = !showBand.value;
204+
applyBandVisibility();
205+
}
199206
function toggleTruth(): void {
200207
showTruth.value = !showTruth.value;
201208
applyTruthVisibility();
@@ -280,35 +287,36 @@ const option = computed<EChartsOption>(() => {
280287
const values = pts.map((p) => convertVar(p.value, dv, units.value));
281288
const smooth = dv === "temperature_2m";
282289
283-
// Band (±1σ) — only when not in spaghetti mode (the spaghetti *is* the spread).
284-
if (!spaghetti) {
285-
const lower = pts.map((p) => convertVar(p.value - p.stdDev, dv, units.value));
286-
const delta = pts.map((p) => (Number.isFinite(p.stdDev) ? convertDelta(p.stdDev * 2, dv, units.value) : 0));
287-
series.push({
288-
id: "band-base",
289-
type: "line",
290-
stack: `band-${axisIndex}`,
291-
yAxisIndex: axisIndex,
292-
symbol: "none",
293-
lineStyle: { opacity: 0 },
294-
areaStyle: { opacity: 0 },
295-
itemStyle: { opacity: 0 },
296-
tooltip: { show: false },
297-
data: lower,
298-
...(attachMarks && markArea ? { markArea } : {}),
299-
});
300-
series.push({
301-
id: "band-delta",
302-
type: "line",
303-
stack: `band-${axisIndex}`,
304-
yAxisIndex: axisIndex,
305-
symbol: "none",
306-
lineStyle: { opacity: 0 },
307-
areaStyle: { color: BAND_FILL },
308-
tooltip: { show: false },
309-
data: delta,
310-
});
311-
}
290+
// Band (±1σ). Always built — even in spaghetti mode — so the spread can be
291+
// toggled independently and stays visible behind the per-model lines. The
292+
// invisible band-base carries the night-shading markArea.
293+
const lower = pts.map((p) => convertVar(p.value - p.stdDev, dv, units.value));
294+
const delta = pts.map((p) => (Number.isFinite(p.stdDev) ? convertDelta(p.stdDev * 2, dv, units.value) : 0));
295+
series.push({
296+
id: "band-base",
297+
type: "line",
298+
stack: `band-${axisIndex}`,
299+
yAxisIndex: axisIndex,
300+
symbol: "none",
301+
lineStyle: { opacity: 0 },
302+
areaStyle: { opacity: 0 },
303+
itemStyle: { opacity: 0 },
304+
tooltip: { show: false },
305+
data: lower,
306+
...(attachMarks && markArea ? { markArea } : {}),
307+
});
308+
series.push({
309+
id: "band-delta",
310+
type: "line",
311+
stack: `band-${axisIndex}`,
312+
yAxisIndex: axisIndex,
313+
symbol: "none",
314+
lineStyle: { opacity: 0 },
315+
areaStyle: { color: BAND_FILL },
316+
tooltip: { show: false },
317+
data: delta,
318+
z: 0,
319+
});
312320
313321
series.push({
314322
id: "agg",
@@ -320,9 +328,6 @@ const option = computed<EChartsOption>(() => {
320328
symbol: "none",
321329
lineStyle: { width: spaghetti ? 4 : 2.5, color: AGG_COLOR },
322330
z: 5,
323-
// When the band isn't drawn (spaghetti / precip), the agg line carries the
324-
// night shading + Now marker so they're always present.
325-
...(attachMarks && spaghetti && markArea ? { markArea } : {}),
326331
...(attachMarks && markLine ? { markLine } : {}),
327332
});
328333
};
@@ -450,7 +455,7 @@ const option = computed<EChartsOption>(() => {
450455
const isLine = DATA_VAR_META[dv].render === "line";
451456
if (showAggregate.value && aggPt && !Number.isNaN(aggPt.value)) {
452457
const std =
453-
isLine && !spaghetti && Number.isFinite(aggPt.stdDev) ? ` <span style="color:#94a3b8">± ${fmtVar(dv, aggPt.stdDev).replace(/a-zA-Z%/ ]+$/, "")}</span>` : "";
458+
isLine && showBand.value && Number.isFinite(aggPt.stdDev) ? ` <span style="color:#94a3b8">± ${fmtVar(dv, aggPt.stdDev).replace(/a-zA-Z%/ ]+$/, "")}</span>` : "";
454459
const label = vars.length > 1 ? `${dv === "temperature_2m" ? "Temp" : "Precip"} ` : "Forecast ";
455460
const color = dv === "precipitation" ? "#7dd3fc" : AGG_COLOR;
456461
lines.push(`<span style="color:${color}">${label}</span>${fmtVar(dv, aggPt.value)}${std}`);
@@ -519,6 +524,7 @@ const option = computed<EChartsOption>(() => {
519524
watch(option, () => {
520525
void nextTick(() => {
521526
applyAggregateVisibility();
527+
applyBandVisibility();
522528
applyTruthVisibility();
523529
applyModelVisibility();
524530
});
@@ -528,16 +534,33 @@ watch(option, () => {
528534
// The series row is shown when there's *anything* to toggle — i.e. always for
529535
// the forecast view (just aggregate) and on verify (aggregate + truth).
530536
const hasModels = computed(() => allModels.value.length > 0);
537+
// The ±1σ band is drawn for every line view; the precipitation-only view shows
538+
// bars instead, so its spread chip would be a no-op.
539+
const hasBand = computed(() => view.value !== "precipitation");
531540
</script>
532541

533542
<template>
534543
<section class="border-ink-700 bg-ink-900/60 relative border p-4 sm:p-6">
535-
<!-- Header: title, variable dropdown, window selector -->
536-
<div class="border-ink-700 mb-4 flex flex-wrap items-center justify-between gap-3 border-b pb-3">
537-
<h2 class="eyebrow">{{ title }}</h2>
538-
<div class="flex items-center gap-2">
539-
<!-- Variable dropdown (left of the window selector) -->
540-
<div ref="varRoot" class="relative">
544+
<!-- Header: title + variable picker (left), window selector (right) -->
545+
<div class="border-ink-700 mb-4 flex flex-wrap items-center gap-x-4 gap-y-2 border-b pb-3">
546+
<div class="flex flex-wrap items-center gap-3">
547+
<h2 class="eyebrow">{{ title }}</h2>
548+
549+
<!-- Desktop (lg+): expanded variable rail, all options inline -->
550+
<div class="border-ink-700 hidden border font-mono text-xs tracking-wide lg:flex">
551+
<button
552+
v-for="vid in variables"
553+
:key="vid"
554+
class="border-ink-700 border-r px-2.5 py-1 whitespace-nowrap transition-colors last:border-r-0"
555+
:class="view === vid ? 'bg-sodium-300/15 text-sodium-200' : 'text-paper-300 hover:bg-ink-800 hover:text-paper-50'"
556+
@click="selectView(vid)"
557+
>
558+
{{ CHART_VIEWS[vid].label }}
559+
</button>
560+
</div>
561+
562+
<!-- Mobile / tablet (< lg): collapse the rail into a dropdown -->
563+
<div ref="varRoot" class="relative lg:hidden">
541564
<button
542565
type="button"
543566
class="group border-ink-700 bg-ink-900/60 text-paper-200 hover:border-sodium-300/60 hover:text-paper-50 flex items-center gap-2 border px-3 py-1 font-mono text-xs tracking-wide transition-colors"
@@ -569,19 +592,20 @@ const hasModels = computed(() => allModels.value.length > 0);
569592
</button>
570593
</div>
571594
</div>
595+
</div>
572596

573-
<!-- Window selector -->
574-
<div class="border-ink-700 flex border font-mono text-xs tracking-wide">
575-
<button
576-
v-for="c in WINDOW_CHOICES"
577-
:key="c.hours"
578-
class="px-3 py-1 transition-colors"
579-
:class="hoursWindow === c.hours ? 'bg-sodium-300/15 text-sodium-200' : 'text-paper-300 hover:bg-ink-800 hover:text-paper-50'"
580-
@click="hoursWindow = c.hours"
581-
>
582-
{{ c.label }}
583-
</button>
584-
</div>
597+
<!-- Window selector (right-aligned; wraps to its own line on the
598+
forecast page where the 6-option rail fills the first row). -->
599+
<div class="border-ink-700 ml-auto flex border font-mono text-xs tracking-wide">
600+
<button
601+
v-for="c in WINDOW_CHOICES"
602+
:key="c.hours"
603+
class="px-3 py-1 transition-colors"
604+
:class="hoursWindow === c.hours ? 'bg-sodium-300/15 text-sodium-200' : 'text-paper-300 hover:bg-ink-800 hover:text-paper-50'"
605+
@click="hoursWindow = c.hours"
606+
>
607+
{{ c.label }}
608+
</button>
585609
</div>
586610
</div>
587611

@@ -609,6 +633,16 @@ const hasModels = computed(() => allModels.value.length > 0);
609633
>
610634
<span class="inline-block size-2" :style="{ backgroundColor: AGG_COLOR }" />Aggregate
611635
</button>
636+
<button
637+
v-if="hasBand"
638+
type="button"
639+
class="flex items-center gap-1.5 border px-2 py-1 transition-colors"
640+
:class="showBand ? 'border-ink-600 bg-ink-800 text-paper-50' : 'border-ink-700 bg-ink-950 text-paper-400 hover:text-paper-200'"
641+
title="Model spread (±1σ)"
642+
@click="toggleBand"
643+
>
644+
<span class="inline-block size-2" :style="{ backgroundColor: BAND_SWATCH }" />Spread ±1σ
645+
</button>
612646
<button
613647
v-if="hasTruth"
614648
type="button"
@@ -618,7 +652,6 @@ const hasModels = computed(() => allModels.value.length > 0);
618652
>
619653
<span class="inline-block size-2" :style="{ backgroundColor: TRUTH_COLOR }" />Truth
620654
</button>
621-
<span v-if="!showModels" class="text-paper-500 ml-1 hidden sm:inline"> <span class="text-aggregate-400">▒</span> spread ±1σ </span>
622655
</div>
623656

624657
<!-- MODELS -->

0 commit comments

Comments
 (0)