Skip to content

Commit dbc69ee

Browse files
committed
feat(chart): combinable temp/precip, visible night shading, All toggle
Three chart refinements: - Drop the standalone "Temp + Precip" button (it caused the 6-option rail to wrap to a second line). Temperature and precipitation are now an independently-toggleable pair: both are active by default (the combined view), and clicking either narrows to a single axis. The five remaining options fit one row, so the window selector no longer wraps. Gated to the forecast page (no truth) — verify keeps temperature and precipitation as separate views so each can show its own ERA5 truth. - Night shading now renders on a dedicated zero-z background series so it sits behind the spread band and lines (it previously rode band-base and drew on top). Recoloured to a cool marine wash that's actually visible against the warm dark theme. - Replace the models all/none pair with a single "All" toggle that is active only when every available model is enabled and flips between all-on and all-off. Assisted-by: Claude:claude-opus-4-8
1 parent 494e767 commit dbc69ee

2 files changed

Lines changed: 96 additions & 39 deletions

File tree

src/components/HourlySeriesChart.vue

Lines changed: 92 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ 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
4848
const BAND_SWATCH = "rgba(232, 130, 107, 0.45)"; // more visible coral for the legend chip
4949
const TRUTH_AREA = "rgba(245, 185, 66, 0.12)"; // sodium, low alpha — precip truth fill
50-
const NIGHT_FILL = "rgba(10, 16, 24, 0.55)";
50+
const NIGHT_FILL = "rgba(120, 140, 200, 0.12)"; // cool marine wash — reads as night against the warm theme
5151
const MODEL_PALETTE = ["#6dc6c2", "#9bb87a", "#bfa9d6", "#f0a285", "#7fb8e0", "#d99a1e", "#e8826b", "#9ddad6", "#c7b69a", "#a8c182", "#b88c8c"];
5252
const MODEL_OPACITY = 0.55;
5353
@@ -63,7 +63,10 @@ const WINDOW_CHOICES = [
6363
] as const;
6464
6565
// ---- UI state ---------------------------------------------------------------
66-
const view = ref<ChartViewId>(props.variables[0] ?? "temperature_2m");
66+
// Forecast (no truth) opens on the combined temperature + precipitation view;
67+
// verify keeps per-variable views so each can show its own ERA5 truth line.
68+
const canCombineTempPrecip = !props.data.truth && props.variables.includes("temperature_2m") && props.variables.includes("precipitation");
69+
const view = ref<ChartViewId>(canCombineTempPrecip ? "temp_precip" : (props.variables[0] ?? "temperature_2m"));
6770
const hoursWindow = ref<number>(props.defaultWindow);
6871
6972
// Visibility toggles. Kept OUT of `option` so toggling them patches the chart
@@ -97,14 +100,47 @@ function selectView(v: ChartViewId): void {
97100
view.value = v;
98101
}
99102
100-
// Variable dropdown (replaces the old full-width button rail; sits next to the
101-
// window selector in the chart header).
103+
// Variable picker: an expanded rail on desktop, a dropdown on mobile.
102104
const varOpen = ref(false);
103105
const varRoot = ref<HTMLElement | null>(null);
104106
onClickOutside(varRoot, () => (varOpen.value = false));
105107
106-
function selectVariable(v: ChartViewId): void {
107-
selectView(v);
108+
/** Whether a picker entry reads as "active". Temperature and precipitation are
109+
* a combinable pair on the forecast page: either is active in the composite. */
110+
function isVarActive(vid: ChartViewId): boolean {
111+
if (canCombineTempPrecip) {
112+
if (vid === "temperature_2m") return view.value === "temp_precip" || view.value === "temperature_2m";
113+
if (vid === "precipitation") return view.value === "temp_precip" || view.value === "precipitation";
114+
}
115+
return view.value === vid;
116+
}
117+
118+
function selectVariable(vid: ChartViewId): void {
119+
// Temperature & precipitation toggle independently (dual-axis), so the two
120+
// can be shown together — that combination *is* the composite view. The
121+
// remaining variables are exclusive single-axis views.
122+
if (canCombineTempPrecip && (vid === "temperature_2m" || vid === "precipitation")) {
123+
const inPair = view.value === "temp_precip" || view.value === "temperature_2m" || view.value === "precipitation";
124+
let tempOn = view.value === "temp_precip" || view.value === "temperature_2m";
125+
let precipOn = view.value === "temp_precip" || view.value === "precipitation";
126+
if (!inPair) {
127+
// Coming from an exclusive view (wind / cloud / prob) → focus the click.
128+
tempOn = vid === "temperature_2m";
129+
precipOn = vid === "precipitation";
130+
} else if (vid === "temperature_2m") {
131+
tempOn = !tempOn;
132+
} else {
133+
precipOn = !precipOn;
134+
}
135+
// Never leave the pair empty: toggling off the last one is a no-op.
136+
if (!tempOn && !precipOn) {
137+
tempOn = vid === "temperature_2m";
138+
precipOn = vid === "precipitation";
139+
}
140+
selectView(tempOn && precipOn ? "temp_precip" : tempOn ? "temperature_2m" : "precipitation");
141+
} else {
142+
selectView(vid);
143+
}
108144
varOpen.value = false;
109145
}
110146
@@ -224,6 +260,17 @@ function selectNoModels(): void {
224260
applyModelVisibility();
225261
}
226262
263+
// Single "All" toggle: active only when every available model is enabled;
264+
// clicking flips between all-on and all-off.
265+
const allModelsActive = computed(() => {
266+
const available = allModels.value.filter((m) => modelHasData.value[m.id]);
267+
return available.length > 0 && available.every((m) => enabledModels.value.has(m.id));
268+
});
269+
function toggleAllModels(): void {
270+
if (allModelsActive.value) selectNoModels();
271+
else selectAllModels();
272+
}
273+
227274
// ---- Tooltip formatting -----------------------------------------------------
228275
function fmtVar(dv: DataVarId, base: number | null | undefined): string {
229276
if (dv === "temperature_2m") return formatTemp.value(base, 1);
@@ -281,15 +328,32 @@ const option = computed<EChartsOption>(() => {
281328
}
282329
: undefined;
283330
331+
// Night shading lives on its own zero-z background series so it always sits
332+
// *behind* the spread band and lines (it used to ride the band-base series,
333+
// which drew it on top of the spread).
334+
if (markArea) {
335+
series.push({
336+
id: "night",
337+
type: "line",
338+
yAxisIndex: 0,
339+
data: Array.from({ length: n }, () => null),
340+
symbol: "none",
341+
lineStyle: { opacity: 0 },
342+
silent: true,
343+
z: 0,
344+
tooltip: { show: false },
345+
markArea,
346+
});
347+
}
348+
284349
// --- helper: push an aggregate line + band for a line variable -------------
285350
const pushLineAggregate = (dv: DataVarId, axisIndex: number, attachMarks: boolean): void => {
286351
const pts = (props.data.aggregate[dv] ?? []).slice(0, n);
287352
const values = pts.map((p) => convertVar(p.value, dv, units.value));
288353
const smooth = dv === "temperature_2m";
289354
290355
// 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.
356+
// toggled independently and stays visible behind the per-model lines.
293357
const lower = pts.map((p) => convertVar(p.value - p.stdDev, dv, units.value));
294358
const delta = pts.map((p) => (Number.isFinite(p.stdDev) ? convertDelta(p.stdDev * 2, dv, units.value) : 0));
295359
series.push({
@@ -303,7 +367,7 @@ const option = computed<EChartsOption>(() => {
303367
itemStyle: { opacity: 0 },
304368
tooltip: { show: false },
305369
data: lower,
306-
...(attachMarks && markArea ? { markArea } : {}),
370+
z: 1,
307371
});
308372
series.push({
309373
id: "band-delta",
@@ -315,7 +379,7 @@ const option = computed<EChartsOption>(() => {
315379
areaStyle: { color: BAND_FILL },
316380
tooltip: { show: false },
317381
data: delta,
318-
z: 0,
382+
z: 1,
319383
});
320384
321385
series.push({
@@ -346,8 +410,7 @@ const option = computed<EChartsOption>(() => {
346410
data: values,
347411
itemStyle: { color: PRECIP_BAR_COLOR },
348412
barWidth: "60%",
349-
z: 1,
350-
...(attachMarks && markArea ? { markArea } : {}),
413+
z: 2,
351414
...(attachMarks && markLine ? { markLine } : {}),
352415
});
353416
const truth = props.data.truth?.precipitation;
@@ -552,8 +615,8 @@ const hasBand = computed(() => view.value !== "precipitation");
552615
v-for="vid in variables"
553616
:key="vid"
554617
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)"
618+
:class="isVarActive(vid) ? 'bg-sodium-300/15 text-sodium-200' : 'text-paper-300 hover:bg-ink-800 hover:text-paper-50'"
619+
@click="selectVariable(vid)"
557620
>
558621
{{ CHART_VIEWS[vid].label }}
559622
</button>
@@ -583,9 +646,9 @@ const hasBand = computed(() => view.value !== "precipitation");
583646
:key="vid"
584647
type="button"
585648
role="menuitemradio"
586-
:aria-checked="view === vid"
649+
:aria-checked="isVarActive(vid)"
587650
class="block w-full px-3 py-2 text-left font-mono text-xs tracking-wide transition-colors"
588-
:class="view === vid ? 'bg-ink-800 text-sodium-200' : 'text-paper-200 hover:bg-ink-800 hover:text-sodium-200'"
651+
:class="isVarActive(vid) ? 'bg-ink-800 text-sodium-200' : 'text-paper-200 hover:bg-ink-800 hover:text-sodium-200'"
589652
@click="selectVariable(vid)"
590653
>
591654
{{ CHART_VIEWS[vid].label }}
@@ -617,10 +680,9 @@ const hasBand = computed(() => view.value !== "precipitation");
617680
</div>
618681

619682
<!-- Legend / filter strip ---------------------------------------------
620-
Two labelled sections — SERIES (always-on toggles: aggregate, truth)
621-
and MODELS (per-model spaghetti chips). Replaces the old "Show
622-
contributing models" checkbox: enabling any model chip turns the
623-
spaghetti on; "none" turns it back off. -->
683+
Two labelled sections — SERIES (aggregate / spread / truth toggles)
684+
and MODELS (an "All" toggle plus per-model spaghetti chips). Enabling
685+
any model chip turns the spaghetti on; "All" flips every model at once. -->
624686
<div class="border-ink-700/60 mt-4 space-y-2.5 border-t pt-3 font-mono text-[11px] tracking-wide">
625687
<!-- SERIES -->
626688
<div class="flex flex-wrap items-center gap-1.5">
@@ -657,24 +719,16 @@ const hasBand = computed(() => view.value !== "precipitation");
657719
<!-- MODELS -->
658720
<div v-if="hasModels" class="flex flex-wrap items-center gap-1.5">
659721
<span class="text-paper-400 mr-2 w-14 shrink-0">Models</span>
660-
<div class="flex items-center gap-px">
661-
<button
662-
type="button"
663-
class="border-ink-700 hover:border-sodium-300/60 text-paper-300 hover:text-sodium-200 bg-ink-950 border px-2 py-1 transition-colors"
664-
title="Enable all models"
665-
@click="selectAllModels"
666-
>
667-
all
668-
</button>
669-
<button
670-
type="button"
671-
class="border-ink-700 hover:border-sodium-300/60 text-paper-300 hover:text-sodium-200 bg-ink-950 -ml-px border px-2 py-1 transition-colors"
672-
title="Disable all models"
673-
@click="selectNoModels"
674-
>
675-
none
676-
</button>
677-
</div>
722+
<button
723+
type="button"
724+
class="border px-2 py-1 transition-colors"
725+
:class="allModelsActive ? 'border-ink-600 bg-ink-800 text-paper-50' : 'border-ink-700 bg-ink-950 text-paper-400 hover:text-paper-200'"
726+
:aria-pressed="allModelsActive"
727+
:title="allModelsActive ? 'Disable all models' : 'Enable all models'"
728+
@click="toggleAllModels"
729+
>
730+
All
731+
</button>
678732
<span class="bg-ink-700 mx-1 hidden h-4 w-px sm:inline-block" aria-hidden="true" />
679733
<button
680734
v-for="m in allModels"

src/views/ForecastView.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ const { loading, error, raw, hourly, daily, solar } = useForecast(current);
1414
1515
// Full variable set: the composite Temp+Precip overview plus the five
1616
// single-variable views. Temp+Precip is the calm default (variables[0]).
17-
const FORECAST_VARIABLES: ChartViewId[] = ["temp_precip", "temperature_2m", "precipitation", "precipitation_probability", "wind_speed_10m", "cloud_cover"];
17+
// Temperature + precipitation are combinable (shown together by default); the
18+
// composite "Temp + Precip" view is the default but no longer a standalone
19+
// button — see HourlySeriesChart's variable toggle logic.
20+
const FORECAST_VARIABLES: ChartViewId[] = ["temperature_2m", "precipitation", "precipitation_probability", "wind_speed_10m", "cloud_cover"];
1821
1922
const locationLabel = computed(() => {
2023
const loc = current.value;

0 commit comments

Comments
 (0)