Skip to content

Commit 89b0b61

Browse files
committed
Merge branch 'zed/thermo-nuclear-code-quality-review'
2 parents 5c6ac8f + 5e79298 commit 89b0b61

11 files changed

Lines changed: 206 additions & 93 deletions

README.md

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ The badge maps the result to one of three tiers — high (≥70 %, emerald), mid
7777

7878
- **Vue 3** (`<script setup>`, Composition API) + **Vite** + **TypeScript** (strict)
7979
- **Tailwind CSS v4** via `@tailwindcss/vite`
80-
- **vue-echarts** (ECharts 5) for the charts
80+
- **vue-echarts** (ECharts 6) for the charts
8181
- **vue-router** for URL state, **@vueuse/core** for localStorage / debounce
8282
- **Erik Flowers' [weather-icons](https://github.com/erikflowers/weather-icons)** for the icon set
8383
- **Vitest** for unit tests
@@ -87,31 +87,32 @@ The badge maps the result to one of three tiers — high (≥70 %, emerald), mid
8787
## Architecture
8888

8989
```
90-
UI (Vue components) Composables Domain layer (pure TS)
91-
┌──────────────────────────────────┐ ┌──────────────────┐ ┌────────────────────────────────┐
92-
│ LocationBar ─────┐ │ │ useLocation │ │ models.ts │
93-
│ AggregateSummary │ │ │ ─ URL sync │ │ ─ registry + bboxes │
94-
│ HourlyChart │ │ │ ─ favourites │ │ weighting.ts │
95-
│ DailyStrip ──────►│ ForecastView│◄──┤ useForecast │◄──┤ ─ region bonus + decay │
96-
│ ModelBreakdown │ │ │ ─ fetch+aggreg.│ │ aggregate.ts │
97-
│ WeatherIcon │ │ │ useUnits │ │ ─ weighted mean / circ. mean │
98-
│ ConfidenceBadge ──┘ │ │ ─ formatters │ │ ─ severity-weighted mode │
99-
└──────────────────────────────────┘ └──────────────────┘ │ confidence.ts │
100-
│ ─ spread + model-count factor │
101-
│ weatherCodes.ts │
102-
│ ─ WMO ↔ icon ↔ severity │
103-
└────────────────────────────────┘
90+
UI (Vue components) Composables Domain layer (pure TS)
91+
┌──────────────────────────────────┐ ┌──────────────────┐ ┌────────────────────────────────┐
92+
│ LocationBar │ │ useLocation │ │ models.ts │
93+
│ AggregateSummary │ │ ─ URL sync │ │ ─ registry + bboxes │
94+
│ HourlySeriesChart (shared) │ │ ─ favourites │ │ weighting.ts │
95+
│ DailyStrip / DayCard │ │ useForecast │ │ ─ region bonus + decay │
96+
│ VerificationDayCard ►│◄─┤ ─ fetch+aggreg.│◄─┤ aggregate.ts │
97+
│ HitMissStrip │ │ useVerification │ │ aggregateVariables.ts (triad) │
98+
│ WeatherIcon / ConfidenceBadge │ │ ─ fetch+score │ │ confidence.ts │
99+
│ │ │ useUnits │ │ verification.ts (bias/MAE …) │
100+
│ ForecastView · VerificationView │ │ ─ formatters │ │ weatherCodes.ts │
101+
└──────────────────────────────────┘ └──────────────────┘ └────────────────────────────────┘
104102
105103
106-
┌─────────────┴──────────────────┐
107-
│ api/openMeteo.ts │
108-
│ ─ typed forecast client + │
109-
│ 30-min in-memory cache │
110-
│ api/geocoding.ts │
111-
└────────────────────────────────┘
104+
┌───────────────────────────────┴──────────────────┐
105+
│ api/omForecast.ts ─ live forecast client │
106+
│ api/omSingleRuns.ts ─ historical model runs │
107+
│ api/omHistoricalWeather.ts ─ ERA5-Seamless truth │
108+
│ api/geocoding.ts ─ location search │
109+
│ (HTTP caching via the service worker, SWR) │
110+
└────────────────────────────────────────────────────┘
112111
```
113112

114-
The **domain layer** is pure TS, unit-tested with Vitest (21 tests). The UI sits on top of it via three composables. There is no global store — the URL is the source of truth for the location, and localStorage holds units, favourites, and recent searches.
113+
The **domain layer** is pure TS, unit-tested with Vitest. The UI sits on top of it via the
114+
composables. There is no global store — the URL is the source of truth for the location, and
115+
localStorage holds units, favourites, and recent searches.
115116

116117
## Develop
117118

@@ -127,7 +128,7 @@ npm run dev # Vite dev server
127128
npm run build # type-check + production build to ./dist
128129
npm run preview # serve ./dist locally
129130

130-
npm test # Vitest unit tests (21 cases)
131+
npm test # Vitest unit tests
131132
npm run test:watch # interactive
132133

133134
npm run lint # oxlint + oxfmt --check + vue-tsc (CI gate)

src/components/AggregateSummary.vue

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ForecastResponse } from "@/api/omForecast";
55
import type { DailyAggregate } from "@/composables/useForecast";
66
import { useLocation } from "@/composables/useLocation";
77
import { useUnits } from "@/composables/useUnits";
8+
import { overallConfidence } from "@/domain/confidence";
89
import { weatherLabel } from "@/domain/weatherCodes";
910
1011
import ConfidenceBadge from "./ConfidenceBadge.vue";
@@ -34,13 +35,9 @@ const currentIsDay = computed(() => (props.raw.current.is_day ?? 1) === 1);
3435
const todayHigh = computed(() => props.daily.series.temperature_2m_max[0]?.value ?? null);
3536
const todayLow = computed(() => props.daily.series.temperature_2m_min[0]?.value ?? null);
3637
const todayPrecipProb = computed(() => props.daily.series.precipitation_probability_max[0]?.value ?? null);
37-
const todayConfidence = computed(() => {
38-
const vals = [props.daily.confidence.temperature_2m_max[0], props.daily.confidence.precipitation_sum[0], props.daily.confidence.weather_code[0]].filter((v): v is number =>
39-
Number.isFinite(v),
40-
);
41-
if (vals.length === 0) return 0;
42-
return vals.reduce((a, b) => a + b, 0) / vals.length;
43-
});
38+
const todayConfidence = computed(() =>
39+
overallConfidence([props.daily.confidence.temperature_2m_max[0], props.daily.confidence.precipitation_sum[0], props.daily.confidence.weather_code[0]]),
40+
);
4441
const lastUpdated = computed(() => new Date(props.raw.current.time));
4542
4643
const sunrise = computed(() => props.solar?.sunrise[0] ?? null);

src/components/DailyStrip.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { computed } from "vue";
33
44
import type { DailyAggregate } from "@/composables/useForecast";
5+
import { overallConfidence } from "@/domain/confidence";
56
import { MODELS } from "@/domain/models";
67
78
import DayCard, { type ModelRow } from "./DayCard.vue";
@@ -23,7 +24,6 @@ interface DayRow {
2324
2425
const days = computed<DayRow[]>(() =>
2526
props.daily.times.map((date, i) => {
26-
const conf = [props.daily.confidence.temperature_2m_max[i] ?? 0, props.daily.confidence.weather_code[i] ?? 0, props.daily.confidence.precipitation_sum[i] ?? 0];
2727
const models: ModelRow[] = MODELS.map((m) => ({
2828
id: m.id,
2929
label: m.label,
@@ -40,7 +40,7 @@ const days = computed<DayRow[]>(() =>
4040
precipSum: props.daily.series.precipitation_sum[i]?.value ?? null,
4141
windSpeed: props.daily.series.wind_speed_10m_max[i]?.value ?? null,
4242
windDirection: props.daily.series.wind_direction_10m_dominant[i]?.value ?? null,
43-
confidence: conf.reduce((a, b) => a + b, 0) / conf.length,
43+
confidence: overallConfidence([props.daily.confidence.temperature_2m_max[i], props.daily.confidence.precipitation_sum[i], props.daily.confidence.weather_code[i]]),
4444
models,
4545
};
4646
}),

src/components/HourlySeriesChart.vue

Lines changed: 10 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
<script setup lang="ts">
22
import { onClickOutside } from "@vueuse/core";
33
import type { EChartsOption } from "echarts";
4+
import type { ECharts } from "echarts/core";
45
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
56
import VChart from "vue-echarts";
67
78
import type { DataVarId, HourlySeries } from "@/composables/hourlySeries";
89
import { useUnits } from "@/composables/useUnits";
910
import { MODELS, type ModelDef } from "@/domain/models";
1011
11-
import { CHART_VIEWS, convertVar, type ChartViewId, type UnitPrefs } from "./chartHelpers";
12+
import { CHART_VIEWS, convertVar, isVarActive as isVarActiveFor, nextCombinableView, type ChartViewId, type UnitPrefs } from "./chartHelpers";
1213
import { AGG_COLOR, BAND_SWATCH, buildHourlyChartOption, paletteFor, TRUTH_COLOR, visibilityPatches } from "./chartOption";
1314
1415
const props = withDefaults(
@@ -64,8 +65,8 @@ const showTruth = ref(true);
6465
const enabledModels = ref<Set<string>>(new Set());
6566
6667
// Direct handle to the ECharts instance for no-redraw merge patches.
67-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
68-
const chartRef = ref<any>(null);
68+
// vue-echarts exposes the underlying instance as `.chart` on its component ref.
69+
const chartRef = ref<{ chart?: ECharts } | null>(null);
6970
7071
const hasTruth = computed(() => !!props.data.truth);
7172
@@ -94,36 +95,15 @@ onClickOutside(varRoot, () => (varOpen.value = false));
9495
/** Whether a picker entry reads as "active". Temperature and precipitation are
9596
* a combinable pair on the forecast page: either is active in the composite. */
9697
function isVarActive(vid: ChartViewId): boolean {
97-
if (canCombineTempPrecip) {
98-
if (vid === "temperature_2m") return view.value === "temp_precip" || view.value === "temperature_2m";
99-
if (vid === "precipitation") return view.value === "temp_precip" || view.value === "precipitation";
100-
}
101-
return view.value === vid;
98+
return isVarActiveFor(view.value, vid, canCombineTempPrecip);
10299
}
103100
104101
function selectVariable(vid: ChartViewId): void {
105-
// Temperature & precipitation toggle independently (dual-axis), so the two
106-
// can be shown together — that combination *is* the composite view. The
107-
// remaining variables are exclusive single-axis views.
102+
// Temperature & precipitation toggle independently (dual-axis) into the
103+
// composite; every other variable is an exclusive single-axis view. The set
104+
// arithmetic for the combinable pair lives in chartHelpers.nextCombinableView.
108105
if (canCombineTempPrecip && (vid === "temperature_2m" || vid === "precipitation")) {
109-
const inPair = view.value === "temp_precip" || view.value === "temperature_2m" || view.value === "precipitation";
110-
let tempOn = view.value === "temp_precip" || view.value === "temperature_2m";
111-
let precipOn = view.value === "temp_precip" || view.value === "precipitation";
112-
if (!inPair) {
113-
// Coming from an exclusive view (wind / cloud / prob) → focus the click.
114-
tempOn = vid === "temperature_2m";
115-
precipOn = vid === "precipitation";
116-
} else if (vid === "temperature_2m") {
117-
tempOn = !tempOn;
118-
} else {
119-
precipOn = !precipOn;
120-
}
121-
// Never leave the pair empty: toggling off the last one is a no-op.
122-
if (!tempOn && !precipOn) {
123-
tempOn = vid === "temperature_2m";
124-
precipOn = vid === "precipitation";
125-
}
126-
selectView(tempOn && precipOn ? "temp_precip" : tempOn ? "temperature_2m" : "precipitation");
106+
selectView(nextCombinableView(view.value, vid));
127107
} else {
128108
selectView(vid);
129109
}
@@ -180,7 +160,6 @@ watch(
180160
// area-fill) live in the builder's `toggles`; visibilityPatches() maps those +
181161
// the current toggle state to the patches.
182162
function applyVisibility(): void {
183-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
184163
const chart = chartRef.value?.chart;
185164
if (!chart) return;
186165
const patches = visibilityPatches(toggles.value, {
@@ -189,7 +168,6 @@ function applyVisibility(): void {
189168
showTruth: showTruth.value,
190169
enabledModels: enabledModels.value,
191170
});
192-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
193171
if (patches.length) chart.setOption({ series: patches }, false);
194172
}
195173
@@ -244,35 +222,27 @@ const spaghettiAxis = computed(() => (activeVar.value === "precipitation" ? 1 :
244222
245223
let detachCursor: (() => void) | null = null;
246224
watch(
247-
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
248225
() => chartRef.value?.chart,
249226
(chart) => {
250227
detachCursor?.();
251228
detachCursor = null;
252229
if (!chart) return;
253-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
254230
const zr = chart.getZr();
255231
const onMove = (e: { offsetX: number; offsetY: number }): void => {
256-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
257232
if (!chart.containPixel("grid", [e.offsetX, e.offsetY])) {
258233
cursorValue.value = null;
259234
return;
260235
}
261-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
262-
const v = chart.convertFromPixel({ yAxisIndex: spaghettiAxis.value }, e.offsetY) as unknown;
236+
const v = chart.convertFromPixel({ yAxisIndex: spaghettiAxis.value }, e.offsetY);
263237
cursorValue.value = typeof v === "number" ? v : null;
264238
};
265239
const onOut = (): void => {
266240
cursorValue.value = null;
267241
};
268-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
269242
zr.on("mousemove", onMove);
270-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
271243
zr.on("globalout", onOut);
272244
detachCursor = (): void => {
273-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
274245
zr.off("mousemove", onMove);
275-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
276246
zr.off("globalout", onOut);
277247
};
278248
},
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { isVarActive, nextCombinableView } from "./chartHelpers";
4+
5+
describe("nextCombinableView", () => {
6+
it("adds the second variable to form the composite", () => {
7+
expect(nextCombinableView("temperature_2m", "precipitation")).toBe("temp_precip");
8+
expect(nextCombinableView("precipitation", "temperature_2m")).toBe("temp_precip");
9+
});
10+
11+
it("toggling one half of the composite drops back to the other single view", () => {
12+
expect(nextCombinableView("temp_precip", "precipitation")).toBe("temperature_2m");
13+
expect(nextCombinableView("temp_precip", "temperature_2m")).toBe("precipitation");
14+
});
15+
16+
it("toggling off the last remaining variable is a no-op (never empties)", () => {
17+
expect(nextCombinableView("temperature_2m", "temperature_2m")).toBe("temperature_2m");
18+
expect(nextCombinableView("precipitation", "precipitation")).toBe("precipitation");
19+
});
20+
21+
it("focuses the click when coming from an exclusive view", () => {
22+
expect(nextCombinableView("wind_speed_10m", "temperature_2m")).toBe("temperature_2m");
23+
expect(nextCombinableView("cloud_cover", "precipitation")).toBe("precipitation");
24+
});
25+
});
26+
27+
describe("isVarActive", () => {
28+
it("treats either half of the composite as active when combinable", () => {
29+
expect(isVarActive("temp_precip", "temperature_2m", true)).toBe(true);
30+
expect(isVarActive("temp_precip", "precipitation", true)).toBe(true);
31+
expect(isVarActive("temperature_2m", "temperature_2m", true)).toBe(true);
32+
expect(isVarActive("temperature_2m", "precipitation", true)).toBe(false);
33+
});
34+
35+
it("falls back to exact-match when not combinable", () => {
36+
expect(isVarActive("temperature_2m", "temperature_2m", false)).toBe(true);
37+
expect(isVarActive("temp_precip", "temperature_2m", false)).toBe(false);
38+
});
39+
40+
it("matches exclusive views exactly regardless of combinability", () => {
41+
expect(isVarActive("wind_speed_10m", "wind_speed_10m", true)).toBe(true);
42+
expect(isVarActive("wind_speed_10m", "cloud_cover", true)).toBe(false);
43+
});
44+
});

0 commit comments

Comments
 (0)