@@ -45,6 +45,7 @@ const AGG_COLOR = "#e8826b"; // coral — aggregate forecast
4545const TRUTH_COLOR = " #f5b942" ; // sodium amber — ERA5-Seamless truth
4646const PRECIP_BAR_COLOR = " rgba(127, 184, 224, 0.65)" ; // dusty rain blue
4747const 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
4849const TRUTH_AREA = " rgba(245, 185, 66, 0.12)" ; // sodium, low alpha — precip truth fill
4950const NIGHT_FILL = " rgba(10, 16, 24, 0.55)" ;
5051const 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.
7172const showAggregate = ref (true );
73+ const showBand = ref (true );
7274const showTruth = ref (true );
7375const enabledModels = ref <Set <string >>(new Set ());
7476
@@ -169,14 +171,15 @@ function patch(patches: Array<Record<string, unknown> & { id: string }>): void {
169171
170172function 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
182185function 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+ }
199206function 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>(() => {
519524watch (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).
530536const 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