From b6194b4756f9e6328aca62caa45251c02be18fdc Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 26 May 2026 23:12:48 +0800 Subject: [PATCH] feat: implement live temperature threshold chart logic with auto-visibility policies and snapshot testing --- ...2026-05-26-live-temperature-chart-split.md | 26 + .../LiveTemperatureThresholdChart.tsx | 1636 ++--------------- .../__tests__/refreshCadencePolicy.test.ts | 10 +- .../__tests__/ssePatchArchitecture.test.ts | 18 +- ...temperatureDefaultVisibilityPolicy.test.ts | 110 +- .../scan-terminal/temperature-chart-logic.ts | 1504 +++++++++++++++ 6 files changed, 1829 insertions(+), 1475 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-26-live-temperature-chart-split.md create mode 100644 frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts diff --git a/docs/superpowers/plans/2026-05-26-live-temperature-chart-split.md b/docs/superpowers/plans/2026-05-26-live-temperature-chart-split.md new file mode 100644 index 00000000..ba60218f --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-live-temperature-chart-split.md @@ -0,0 +1,26 @@ +# Live Temperature Chart Split Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split `LiveTemperatureThresholdChart.tsx` so chart data generation and SSE patch merge logic live in focused pure modules, while the visible chart behavior stays unchanged. + +**Architecture:** Keep the React component as the orchestration/rendering layer. Move types, constants, time helpers, series builders, fallback rules, and patch merge into adjacent modules under `frontend/components/dashboard/scan-terminal/`. Preserve existing test exports through the component file during the first split to avoid changing test callers. + +**Tech Stack:** Next.js/React, TypeScript, Recharts, existing business-state test runner. + +--- + +## Files + +- Create: `frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts` +- Modify: `frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx` +- Modify: `frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts` +- Verify: `frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts` + +## Tasks + +- [ ] Add a failing architecture test that rejects keeping all chart data builders inside `LiveTemperatureThresholdChart.tsx`. +- [ ] Extract pure chart logic into `temperature-chart-logic.ts`. +- [ ] Import extracted functions/types back into `LiveTemperatureThresholdChart.tsx`. +- [ ] Keep the current test-only exports stable from `LiveTemperatureThresholdChart.tsx`. +- [ ] Run `npm run test:business`, `npm run typecheck`, and `npm run build`. diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index c3d09486..6a1df25b 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -14,1401 +14,107 @@ import { XAxis, YAxis, } from "recharts"; -import type { - AmosData, - AirportCurrentConditions, - CityDetail, - ScanOpportunityRow, - ForecastDay, - DailyModelForecast, -} from "@/lib/dashboard-types"; -import { buildDebBaselinePath } from "@/lib/temperature-chart-paths"; -import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy"; -import { useLatestPatch, useSseResyncVersion, type CityPatch } from "@/hooks/use-sse-patches"; +import type { ScanOpportunityRow } from "@/lib/dashboard-types"; +import { useLatestPatch, useSseResyncVersion } from "@/hooks/use-sse-patches"; import { Panel } from "@/components/dashboard/scan-terminal/Panel"; import { rowName, temp } from "@/components/dashboard/scan-terminal/utils"; -const ROLLING_WINDOW_BEFORE_MS = 12 * 60 * 60 * 1000; -const ROLLING_WINDOW_AFTER_LIVE_MS = 2 * 60 * 60 * 1000; -const ROLLING_WINDOW_AFTER_FORECAST_MS = 8 * 60 * 60 * 1000; +import { + HOURLY_CACHE_TTL_MS, + _hourlyCache, + buildChartDomain, + buildFullDayChartData, + buildIntDegreeTicks, + buildModelSummaryCards, + buildRunwayPlates, + fetchHourlyForecastForCity, + getActiveTemperatureSeries, + getDebPeakWindowRange, + getLiveObservationLabels, + getObservationDisplayMetrics, + getVisibleTemperatureSeries, + isTemperatureSeriesVisibleByDefault, + mergePatchIntoHourly, + normObs, + normalizeCityKey, + readSessionCache, + seedHourlyForecastFromRow, + seriesStats, + shouldPollLiveChart, + validNumber, + type HourlyForecast, +} from "@/components/dashboard/scan-terminal/temperature-chart-logic"; +export { clearCityDetailCache } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; -const SETTLEMENT_RUNWAY_PAIRS: Record> = { - shanghai: [["17L", "35R"]], - beijing: [["19", "01"]], - guangzhou: [["02L", "20R"]], - chengdu: [["02L", "20R"]], - chongqing: [["20R", "02L"]], - wuhan: [["04", "22"]], - seoul: [["15R", "33L"]], +type TooltipSeries = { + key: string; + label: string; + color: string; }; -function normalizeRunwayLabel(value?: string | null) { - return String(value || "").trim().toUpperCase().replace(/\s+/g, ""); -} - -function normalizeCityKey(value?: string | null) { - return String(value || "").trim().toLowerCase().replace(/[\s_-]+/g, ""); -} - -function pairKey(pair: [string, string]) { - return pair.map(normalizeRunwayLabel).sort().join("/"); -} - -function runwaySeriesKey(rwy: string) { - return `runway_${String(rwy || "unknown") - .split("/") - .map(normalizeRunwayLabel) - .filter(Boolean) - .join("_")}`; -} - -function isTemperatureSeriesVisibleByDefault(city: string, seriesKey: string) { - if (seriesKey.startsWith("model_curve_")) { - return normalizeCityKey(city) === "paris" && seriesKey === "model_curve_AROME HD"; +function nearestSeriesValue( + data: Array>, + seriesKey: string, + activeIndex: number, +) { + if (!data.length || activeIndex < 0) return null; + for (let offset = 0; offset < data.length; offset += 1) { + const left = activeIndex - offset; + if (left >= 0) { + const value = validNumber(data[left]?.[seriesKey]); + if (value !== null) return value; + } + const right = activeIndex + offset; + if (right < data.length) { + const value = validNumber(data[right]?.[seriesKey]); + if (value !== null) return value; + } } - return true; -} - -function getVisibleTemperatureSeries( - city: string, - series: EvidenceSeries[], - userToggledKeys: Record, -) { - return series.filter((item) => { - if (userToggledKeys[item.key] !== undefined) { - return userToggledKeys[item.key]; - } - return isTemperatureSeriesVisibleByDefault(city, item.key); - }); -} - -function getActiveTemperatureSeries( - city: string, - chartSeries: EvidenceSeries[], - userToggledKeys: Record, - showRunwayDetails: boolean, -) { - const rawVisible = getVisibleTemperatureSeries(city, chartSeries, userToggledKeys); - const hasRunwayMax = rawVisible.some((item) => item.key === "runway_max"); - - return rawVisible.filter((item) => { - const isIndividualRunway = - item.key.startsWith("runway_") && item.key !== "runway_max"; - if (showRunwayDetails) { - return item.key !== "runway_max"; - } - if (!hasRunwayMax) { - return true; - } - return !isIndividualRunway; - }); -} - -function buildRunwayPlates( - amos: AmosData | null | undefined, - row: ScanOpportunityRow | null, - settlementObs?: Array<{ ts: number; value: number }>, -) { - if (!amos) return []; - const runwayObs = amos.runway_obs || {}; - const runwayPairs = runwayObs.runway_pairs || []; - const runwayTemps = runwayObs.temperatures || []; - const pointTemps = runwayObs.point_temperatures || []; - - const cityKey = normalizeCityKey(row?.city); - const settlementPairs = SETTLEMENT_RUNWAY_PAIRS[cityKey] || []; - const settlementKeys = new Set(settlementPairs.map(pairKey)); - - const list: Array<{ - rwy: string; - isSettlement: boolean; - tdzTemp: number | null; - midTemp: number | null; - endTemp: number | null; - maxTemp: number | null; - dailyHigh: number | null; - trend_15m: number | null; - }> = []; - - runwayPairs.forEach((rawPair: any, index: number) => { - const pair = rawPair as [string, string]; - if (!Array.isArray(pair) || pair.length < 2) return; - const isSettlement = settlementKeys.has(pairKey(pair)); - - const tdz = validNumber(pointTemps[index]?.tdz_temp); - const mid = validNumber(pointTemps[index]?.mid_temp); - const end = validNumber(pointTemps[index]?.end_temp); - - const historyVals = Array.isArray(runwayTemps[index]) - ? (runwayTemps[index] as Array).map(validNumber).filter((v): v is number => v !== null) - : []; - - const tdzVal = tdz !== null ? [tdz] : []; - const midVal = mid !== null ? [mid] : []; - const endVal = end !== null ? [end] : []; - const allVals = [...historyVals, ...tdzVal, ...midVal, ...endVal]; - - const maxTemp = allVals.length ? Math.max(...allVals) : null; - const dailyHigh = historyVals.length ? Math.max(...historyVals) : maxTemp; - - // Calculate 15-minute trend - const latest = historyVals.length > 0 ? historyVals[historyVals.length - 1] : (tdz ?? mid ?? end ?? null); - const val15 = historyVals.length > 15 ? historyVals[historyVals.length - 16] : (historyVals.length > 0 ? historyVals[0] : null); - let trend_15m = (latest !== null && val15 !== null) ? latest - val15 : null; - - if (isSettlement && settlementObs && settlementObs.length >= 2) { - const latestObs = settlementObs[settlementObs.length - 1]; - const targetTs = latestObs.ts - 15 * 60 * 1000; - let closestPoint = settlementObs[0]; - let minDiff = Math.abs(closestPoint.ts - targetTs); - for (let i = 1; i < settlementObs.length; i++) { - const diff = Math.abs(settlementObs[i].ts - targetTs); - if (diff < minDiff) { - minDiff = diff; - closestPoint = settlementObs[i]; - } - } - if (Math.abs(closestPoint.ts - targetTs) < 5 * 60 * 1000) { - trend_15m = latestObs.value - closestPoint.value; - } - } - - list.push({ - rwy: `${normalizeRunwayLabel(pair[0])}/${normalizeRunwayLabel(pair[1])}`, - isSettlement, - tdzTemp: tdz, - midTemp: mid, - endTemp: end, - maxTemp, - dailyHigh, - trend_15m, - }); - }); - - return list; -} - -type ObsPoint = { time?: string | null; temp?: number | null }; -type RawObsPoint = ObsPoint | [string | number | null, number | null | undefined]; - -type EvidenceSeries = { - key: string; - label: string; - source: string; - color: string; - dashed?: boolean; - featured?: boolean; - smooth?: boolean; - curve?: "linear" | "monotone" | "stepAfter"; - connectNulls?: boolean; - showDot?: boolean; - values: Array; -}; - -type RunwayHistorySeries = { - key: string; - label: string; - rwy: string; - isSettlement: boolean; - color: string; - points: Array<{ ts: number; value: number }>; -}; - -const MAX_OBS_POINTS = 1440; -const HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar; -const FULL_DAY_SLOT_MINUTES = 1; -const FULL_DAY_SLOTS = (24 * 60) / FULL_DAY_SLOT_MINUTES; -const SLOT_INTERVAL_MS = FULL_DAY_SLOT_MINUTES * 60 * 1000; -const _hourlyCache = new Map(); -const _hourlyRequestCache = new Map>(); -const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"]; - -const SESSION_CACHE_PREFIX = "polyweather_city_detail_v1:"; -const SESSION_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar; - -function readSessionCache(city: string): { ts: number; data: HourlyForecast } | null { - if (typeof window === "undefined") return null; - try { - const raw = sessionStorage.getItem(`${SESSION_CACHE_PREFIX}${city}`); - if (!raw) return null; - const item = JSON.parse(raw); - if (item && item.ts && Date.now() - item.ts < SESSION_CACHE_TTL_MS) { - return item; - } - } catch {} return null; } -function writeSessionCache(city: string, data: HourlyForecast) { - if (typeof window === "undefined" || !data) return; - try { - sessionStorage.setItem( - `${SESSION_CACHE_PREFIX}${city}`, - JSON.stringify({ ts: Date.now(), data }) - ); - } catch {} -} - -export function clearCityDetailCache() { - _hourlyCache.clear(); - _hourlyRequestCache.clear(); - if (typeof window !== "undefined") { - try { - for (let i = sessionStorage.length - 1; i >= 0; i--) { - const key = sessionStorage.key(i); - if (key && key.startsWith(SESSION_CACHE_PREFIX)) { - sessionStorage.removeItem(key); - } - } - } catch {} - } -} - -function validNumber(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - -function getCityLocalUtcTimestamp( - value: string | number | null | undefined, - tzOffsetSeconds: number, - referenceLocalDate?: string | null -): number | null { - if (value == null) return null; - - if (typeof value === "number") { - const d = new Date(value + tzOffsetSeconds * 1000); - return Date.UTC( - d.getUTCFullYear(), - d.getUTCMonth(), - d.getUTCDate(), - d.getUTCHours(), - d.getUTCMinutes() - ); - } - - const raw = String(value).trim(); - if (!raw) return null; - - if (raw.includes("T") || raw.includes("Z") || raw.includes("-")) { - const d = new Date(raw); - if (!Number.isNaN(d.getTime())) { - const localMs = d.getTime() + tzOffsetSeconds * 1000; - const localDate = new Date(localMs); - return Date.UTC( - localDate.getUTCFullYear(), - localDate.getUTCMonth(), - localDate.getUTCDate(), - localDate.getUTCHours(), - localDate.getUTCMinutes() - ); - } - } - - const m = raw.match(/(\d{1,2}):(\d{2})/); - if (m) { - const h = +m[1]; - const min = +m[2]; - - let year = new Date().getUTCFullYear(); - let month = new Date().getUTCMonth(); - let date = new Date().getUTCDate(); - - if (referenceLocalDate) { - const dateParts = referenceLocalDate.split("-"); - if (dateParts.length === 3) { - year = parseInt(dateParts[0]); - month = parseInt(dateParts[1]) - 1; - date = parseInt(dateParts[2]); - } - } - - return Date.UTC(year, month, date, h, min); - } - - return null; -} - -function formatTimestamp(ts: number): string { - const d = new Date(ts); - return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`; -} - -function normalizeRawObsPoint(point: RawObsPoint): ObsPoint | null { - if (Array.isArray(point)) { - return { time: point[0] == null ? null : String(point[0]), temp: validNumber(point[1]) }; - } - return point; -} - -function normObs(points: RawObsPoint[] | null | undefined, tzOffsetSeconds: number, limit = MAX_OBS_POINTS) { - return (points || []) - .map(normalizeRawObsPoint) - .filter((p): p is ObsPoint => p !== null) - .filter((p) => validNumber(p.temp) !== null && p.time) - .map((p) => ({ - ts: getCityLocalUtcTimestamp(p.time, tzOffsetSeconds)!, - value: Number(p.temp), - })) - .filter((p) => p.ts !== null) - .slice(-limit); -} - -function seriesStats(values: Array) { - const nums = values.filter((v): v is number => validNumber(v) !== null); - const latest = nums.length ? nums[nums.length - 1] : null; - const high = nums.length ? Math.max(...nums) : null; - const first15 = nums.length > 1 ? nums[Math.max(0, nums.length - 15)] : null; - const delta15 = latest !== null && first15 !== null ? latest - first15 : null; - return { latest, high, delta15 }; -} - -function latestObservationValue(obs: Array<{ ts: number; value: number }>) { - if (!obs.length) return null; - return obs.reduce((latest, point) => (point.ts > latest.ts ? point : latest), obs[0]).value; -} - -function maxObservationValue(obs: Array<{ ts: number; value: number }>) { - if (!obs.length) return null; - return Math.max(...obs.map((point) => point.value)); -} - -function observationSetContains( - superset: Array<{ ts: number; value: number }>, - subset: Array<{ ts: number; value: number }>, -) { - if (!superset.length || !subset.length) return false; - return subset.every((point) => - superset.some((candidate) => candidate.ts === point.ts && Math.abs(candidate.value - point.value) < 0.01), - ); -} - -function getObservationDisplayMetrics( - row: ScanOpportunityRow | null, - hourly: HourlyForecast, - settlementPlate?: { maxTemp: number | null } | null, -) { - const tzOffset = row?.tz_offset_seconds ?? 0; - const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset); - const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset); - const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset); - const latestSettlement = latestObservationValue(settlementObs); - const latestMetar = latestObservationValue(metarObs); - const latestMadis = latestObservationValue(madisObs); - const highSettlement = maxObservationValue(settlementObs); - const highMetar = maxObservationValue(metarObs); - const highMadis = maxObservationValue(madisObs); - const airportCurrentTemp = validNumber(hourly?.airportCurrent?.temp) ?? validNumber(hourly?.airportPrimary?.temp); - const airportHigh = validNumber(hourly?.airportCurrent?.max_so_far) ?? validNumber(hourly?.airportPrimary?.max_so_far); - const rowMetarHigh = validNumber(row?.metar_context?.airport_max_so_far ?? row?.metar_context?.max_temp ?? row?.current_max_so_far); - - const settlementCityKey = normalizeCityKey(row?.city); - const isShenzhen = settlementCityKey === 'shenzhen'; - const isHKO = (settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan' - || (row?.city || '').toLowerCase().includes('hong kong') - || (row?.city || '').toLowerCase().includes('lau fau shan')) && !isShenzhen; - - let currentRunwayTemp: number | null = null; - let observedHighRunway: number | null = null; - - if (isHKO) { - currentRunwayTemp = - latestMadis ?? - latestSettlement ?? - latestMetar ?? - airportCurrentTemp ?? - validNumber(row?.current_temp) ?? - null; - observedHighRunway = - highMadis ?? - highSettlement ?? - airportHigh ?? - highMetar ?? - validNumber(row?.current_max_so_far) ?? - currentRunwayTemp ?? - null; - } else { - currentRunwayTemp = - validNumber(hourly?.amos?.temp_c) ?? - settlementPlate?.maxTemp ?? - latestSettlement ?? - latestMetar ?? - airportCurrentTemp ?? - validNumber(row?.current_temp) ?? - null; - observedHighRunway = - settlementPlate?.maxTemp ?? - highSettlement ?? - airportHigh ?? - highMetar ?? - validNumber(row?.current_max_so_far) ?? - currentRunwayTemp ?? - null; - } - - const observedHighMetar = airportHigh ?? highSettlement ?? highMetar ?? rowMetarHigh ?? null; - - return { currentRunwayTemp, observedHighMetar, observedHighRunway }; -} - -function isSettlementRunway(row: ScanOpportunityRow | null, rwy: string) { - const cityKey = normalizeCityKey(row?.city); - const settlementPairs = SETTLEMENT_RUNWAY_PAIRS[cityKey] || []; - if (!settlementPairs.length) return false; - const normalized = rwy - .split("/") - .map(normalizeRunwayLabel) - .filter(Boolean) - .sort() - .join("/"); - return settlementPairs.some((pair) => pairKey(pair) === normalized); -} - -function runwayLabelFromPair(rawPair: unknown, index: number) { - if (Array.isArray(rawPair) && rawPair.length >= 2) { - return `${normalizeRunwayLabel(rawPair[0])}/${normalizeRunwayLabel(rawPair[1])}`; - } - return `RWY ${index + 1}`; -} - -type HourlyForecast = { - forecastTodayHigh?: number | null; - debPrediction?: number | null; - localTime?: string | null; - times: string[]; - temps: Array; - modelCurves?: Record>; - runwayPlateHistory?: Record>>; - runwayBandHistory?: Array<{ time: string; high_temp: number; low_temp: number; avg_temp: number }>; - amos?: AmosData | null; - airportCurrent?: AirportCurrentConditions | null; - airportPrimary?: AirportCurrentConditions | null; - forecastDaily?: ForecastDay[]; - multiModelDaily?: Record; - settlementTodayObs?: ObsPoint[]; - settlementStationLabel?: string | null; - metarTodayObs?: ObsPoint[]; - airportPrimaryTodayObs?: RawObsPoint[]; -} | null; - -function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForecast { - if (!row) return null; - return { - forecastTodayHigh: null, - debPrediction: validNumber(row.deb_prediction), - localTime: row.local_time || null, - times: [], - temps: [], - modelCurves: undefined, - runwayPlateHistory: (row as any)?.runway_plate_history || undefined, - runwayBandHistory: undefined, - amos: null, - airportCurrent: null, - airportPrimary: null, - forecastDaily: [], - multiModelDaily: {}, - settlementTodayObs: row.settlement_today_obs || row.metar_context?.settlement_today_obs || undefined, - metarTodayObs: row.metar_today_obs || row.metar_context?.today_obs || row.metar_recent_obs || row.metar_context?.recent_obs || undefined, - airportPrimaryTodayObs: undefined, - }; -} - -type HourlyForecastFetchOptions = { - ignoreCache?: boolean; - resolution?: string; -}; - -function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForecast { - const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly; - if (!json || !hourlySource) return null; - return { - forecastTodayHigh: json.forecast?.today_high ?? null, - debPrediction: json.deb?.prediction ?? (json as any)?.overview?.deb_prediction ?? null, - localTime: json.local_time || null, - times: hourlySource.times || [], - temps: hourlySource.temps || [], - modelCurves: (json.models_hourly ?? (json as any)?.timeseries?.models_hourly)?.curves || undefined, - runwayPlateHistory: (json as any)?.runway_plate_history || (json.amos as any)?.runway_plate_history || undefined, - runwayBandHistory: (json as any)?.runway_band_history || undefined, - amos: json.amos || null, - airportCurrent: json.airport_current || null, - airportPrimary: json.airport_primary || null, - forecastDaily: json.forecast?.daily || [], - multiModelDaily: json.multi_model_daily || {}, - settlementTodayObs: (json as any).timeseries?.settlement_today_obs || (json as any)?.settlement_today_obs || undefined, - settlementStationLabel: (json as any)?.settlement_station?.settlement_station_label || null, - metarTodayObs: (json as any).timeseries?.metar_today_obs || (json as any)?.metar_today_obs || undefined, - airportPrimaryTodayObs: (json as any)?.official?.airport_primary_today_obs || (json as any)?.airport_primary_today_obs || undefined, - }; -} - -async function fetchHourlyForecastForCity( - city: string, - options: HourlyForecastFetchOptions = {}, -): Promise { - const resParam = options.resolution || "10m"; - const cacheKey = `${city}:${resParam}`; - - if (!options.ignoreCache) { - const cached = _hourlyCache.get(cacheKey); - if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) { - return cached.data; - } - - const sessionCached = readSessionCache(cacheKey); - if (sessionCached) { - _hourlyCache.set(cacheKey, sessionCached); - return sessionCached.data; - } - } - - const requestKey = options.ignoreCache ? `${city}:${resParam}:live` : `${city}:${resParam}`; - const pending = _hourlyRequestCache.get(requestKey); - if (pending) return pending; - - const request = fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false&resolution=${resParam}`, { - headers: { Accept: "application/json" }, - }) - .then(async (res) => { - if (!res.ok) return null; - return res.json() as Promise; - }) - .then((json) => { - const data = parseHourlyForecastFromCityDetail(json); - if (!data) return null; - _hourlyCache.set(cacheKey, { ts: Date.now(), data }); - writeSessionCache(cacheKey, data); - return data; - }) - .finally(() => { - _hourlyRequestCache.delete(requestKey); - }); - - _hourlyRequestCache.set(requestKey, request); - return request; -} - -function shouldPollLiveChart({ - city, - compact, - isActive, - isMaximized, +function TemperatureTooltipContent({ + active, + label, + payload, + data, + series, }: { - city: string; - compact: boolean; - isActive: boolean; - isMaximized: boolean; + active?: boolean; + label?: string | number; + payload?: ReadonlyArray<{ payload?: Record }>; + data: Array>; + series: TooltipSeries[]; }) { - return Boolean(city) && (compact || isActive || isMaximized); -} - -function mergePatchIntoHourly( - prev: HourlyForecast, - patch: CityPatch, -): HourlyForecast { - const changes = patch.changes || {}; - const tempValue = validNumber(changes.temp); - const obsTime = typeof changes.obs_time === "string" ? changes.obs_time : null; - const source = typeof changes.source === "string" ? changes.source : ""; - const explicitHourlyPatch = changes.hourly && typeof changes.hourly === "object" - ? changes.hourly as Partial> - : {}; - - const next: NonNullable = { - ...(prev || { - forecastTodayHigh: null, - debPrediction: null, - localTime: null, - times: [], - temps: [], - forecastDaily: [], - multiModelDaily: {}, - }), - ...explicitHourlyPatch, - }; - - if (changes.amos && typeof changes.amos === "object") { - const oldAmos = prev?.amos || {}; - const newAmos = changes.amos as AmosData; - next.amos = { - ...oldAmos, - ...newAmos, - } as any; - } - - // Preserve runwayPlateHistory in next state - if (prev?.runwayPlateHistory) { - next.runwayPlateHistory = prev.runwayPlateHistory; - } - - // Append new runway observations to history if available in the patch - const amosChanges = changes.amos as Record | undefined; - const obsTimeVal = obsTime || amosChanges?.observation_time || amosChanges?.observation_time_local; - const runwayObs = amosChanges?.runway_obs; - const runwayPoints = Array.isArray(changes.runway_points) - ? changes.runway_points - : runwayObs && Array.isArray(runwayObs.point_temperatures) - ? runwayObs.point_temperatures - : []; - if (runwayPoints.length && obsTimeVal) { - const history: Record>> = {}; - const sourceHistory = next.runwayPlateHistory || (next.amos as any)?.runway_plate_history || {}; - - // Copy existing history points - Object.entries(sourceHistory).forEach(([rwy, pts]) => { - if (Array.isArray(pts)) { - history[rwy] = [...pts]; - } - }); - - // Append new points from point_temperatures - runwayPoints.forEach((pt: any) => { - const rwy = pt.runway || ""; - if (!rwy) return; - const tempVal = validNumber(pt.temp) ?? validNumber(pt.target_runway_max) ?? validNumber(pt.tdz_temp) ?? validNumber(pt.end_temp); - if (tempVal === null) return; - - const rwyHistory = history[rwy] || []; - const exists = rwyHistory.some((p: any) => p.timestamp === obsTimeVal || p.time === obsTimeVal || p.observed_at === obsTimeVal); - if (!exists) { - rwyHistory.push({ - timestamp: obsTimeVal, - temp_c: tempVal, - value: tempVal, - }); - history[rwy] = rwyHistory.slice(-MAX_OBS_POINTS); - } - }); - - next.runwayPlateHistory = history; - next.amos = { - ...(next.amos || {}), - runway_obs: { - ...((next.amos as any)?.runway_obs || {}), - point_temperatures: runwayPoints, - }, - } as any; - if (next.amos) { - (next.amos as any).runway_plate_history = history; - } - } - - if (tempValue !== null) { - next.airportCurrent = { - ...(next.airportCurrent || {}), - obs_time: next.airportCurrent?.obs_time ?? null, - temp: tempValue, - max_so_far: Math.max( - tempValue, - validNumber(next.airportCurrent?.max_so_far) ?? tempValue, - ), - }; - next.airportPrimary = { - ...(next.airportPrimary || {}), - obs_time: next.airportPrimary?.obs_time ?? null, - temp: tempValue, - max_so_far: Math.max( - tempValue, - validNumber(next.airportPrimary?.max_so_far) ?? tempValue, - ), - source_label: next.airportPrimary?.source_label || source || undefined, - }; - } - - if (tempValue !== null && obsTime) { - const obsPoint: RawObsPoint = [obsTime, tempValue]; - const currentObs = Array.isArray(next.airportPrimaryTodayObs) - ? next.airportPrimaryTodayObs - : []; - next.airportPrimaryTodayObs = [...currentObs, obsPoint].slice(-MAX_OBS_POINTS); - } - - return next; -} - -function parseRunwayHistoryValue(point: Record) { - return validNumber(point.max_temp_c) ?? validNumber(point.temp_c) ?? validNumber(point.temp) ?? validNumber(point.value); -} - -function parseRunwayHistoryTime( - point: Record, - tzOffset: number, - localDateStr: string, -) { - return getCityLocalUtcTimestamp( - (point.timestamp as string | number | null | undefined) ?? - (point.time as string | number | null | undefined) ?? - (point.observed_at as string | number | null | undefined), - tzOffset, - localDateStr, - ); -} - -function buildRunwayHistorySeries( - row: ScanOpportunityRow | null, - hourly: HourlyForecast, - tzOffset: number, - localDateStr: string, -): RunwayHistorySeries[] { - const directHistory = - hourly?.runwayPlateHistory ?? - ((hourly?.amos as any)?.runway_plate_history as Record>> | undefined) ?? - ((row as any)?.runway_plate_history as Record>> | undefined); - - if (directHistory && typeof directHistory === "object") { - return Object.entries(directHistory) - .map(([rwy, rawPoints], index) => { - const normalizedRwy = String(rwy || `RWY ${index + 1}`).trim(); - const points = (Array.isArray(rawPoints) ? rawPoints : []) - .map((point) => { - const ts = parseRunwayHistoryTime(point, tzOffset, localDateStr); - const value = parseRunwayHistoryValue(point); - return ts !== null && value !== null ? { ts, value } : null; - }) - .filter((point): point is { ts: number; value: number } => point !== null) - .sort((a, b) => a.ts - b.ts) - .slice(-MAX_OBS_POINTS); - const isSettlement = isSettlementRunway(row, normalizedRwy); - return { - key: runwaySeriesKey(normalizedRwy), - label: `${normalizedRwy}${isSettlement ? (row ? " 结算跑道" : " Settlement") : ""}`, - rwy: normalizedRwy, - isSettlement, - color: isSettlement ? "#009688" : RUNWAY_LINE_COLORS[index % RUNWAY_LINE_COLORS.length], - points, - }; - }) - .filter((series) => series.points.length > 1); - } - - const amos = hourly?.amos; - const runwayObs = amos?.runway_obs; - const runwayPairs = runwayObs?.runway_pairs || []; - const runwayTemps = runwayObs?.temperatures || []; - const pointTemps = runwayObs?.point_temperatures || []; - const anchor = - getCityLocalUtcTimestamp(amos?.observation_time_local || amos?.observation_time || hourly?.localTime || row?.local_time, tzOffset, localDateStr) ?? - getCityLocalUtcTimestamp(row?.local_time, tzOffset, localDateStr); - - if (!anchor || !Array.isArray(runwayTemps)) return []; - - return runwayTemps - .map((rawTemps, index) => { - if (!Array.isArray(rawTemps)) return null; - const rwy = runwayLabelFromPair(runwayPairs[index], index); - const isSettlement = isSettlementRunway(row, rwy); - const pointTemp = Array.isArray(pointTemps) ? pointTemps[index] : null; - const snapshotValues = [ - validNumber((pointTemp as any)?.tdz_temp), - validNumber((pointTemp as any)?.mid_temp), - validNumber((pointTemp as any)?.end_temp), - validNumber((pointTemp as any)?.target_runway_max), - ].filter((value): value is number => value !== null); - const samples = rawTemps.map(validNumber).filter((value): value is number => value !== null); - const valuesForLine = samples.length > 1 - ? samples - : snapshotValues.length > 1 - ? snapshotValues - : samples.length === 1 - ? [samples[0], samples[0]] - : snapshotValues.length === 1 - ? [snapshotValues[0], snapshotValues[0]] - : []; - const values = valuesForLine - .map((value, pointIndex) => { - const minutesFromEnd = (valuesForLine.length - 1 - pointIndex); - return { - ts: anchor - minutesFromEnd * 60 * 1000, - value, - }; - }) - .filter((point) => validNumber(point.value) !== null); - if (values.length <= 1) return null; - return { - key: runwaySeriesKey(rwy), - label: `${rwy}${isSettlement ? " 结算跑道" : ""}`, - rwy, - isSettlement, - color: isSettlement ? "#009688" : RUNWAY_LINE_COLORS[index % RUNWAY_LINE_COLORS.length], - points: values.slice(-MAX_OBS_POINTS), - }; + if (!active || !payload?.length || !series.length) return null; + const activePoint = payload[0]?.payload || {}; + const activeIndex = data.findIndex((point) => point.ts === activePoint.ts); + const rows = series + .map((item) => { + const directValue = validNumber(activePoint[item.key]); + const value = directValue ?? nearestSeriesValue(data, item.key, activeIndex); + return value === null ? null : { ...item, value }; }) - .filter((series): series is RunwayHistorySeries => series !== null); -} + .filter((item): item is TooltipSeries & { value: number } => item !== null); + if (!rows.length) return null; -function generate3DaySlots(localDateStr: string): number[] { - const parts = localDateStr.split("-"); - if (parts.length !== 3) return []; - const year = parseInt(parts[0], 10); - const month = parseInt(parts[1], 10) - 1; - const day = parseInt(parts[2], 10); - - const slots: number[] = []; - // Generate 72 hours starting from local date 00:00 - for (let h = 0; h < 72; h++) { - slots.push(Date.UTC(year, month, day, h, 0)); - } - return slots; -} - -function format3DayTimestamp(ts: number): string { - const d = new Date(ts); - const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); - const dd = String(d.getUTCDate()).padStart(2, "0"); - const hh = String(d.getUTCHours()).padStart(2, "0"); - return `${mm}/${dd} ${hh}:00`; -} - -function build3DayChartData( - row: ScanOpportunityRow | null, - hourly: HourlyForecast, - isEn: boolean, -): { data: Array>; series: EvidenceSeries[] } { - const tzOffset = row?.tz_offset_seconds ?? 0; - const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10); - - const slots = generate3DaySlots(localDateStr); - if (!slots.length) return { data: [], series: [] }; - const n = slots.length; - - const series: EvidenceSeries[] = []; - const na = (): Array => new Array(n).fill(null); - - // ── Runway history series ── - const runwayHistorySeries = buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr); - runwayHistorySeries.forEach((rhs) => { - const binned = binObservationsToSlots(slots, rhs.points); - if (!binned.some((v) => v !== null)) return; - series.push({ - key: rhs.key, - label: rhs.label, - source: "", - color: rhs.color, - featured: rhs.isSettlement, - dashed: !rhs.isSettlement, - values: binned, - }); - }); - - // ── Runway band & max series ── - const normBandObs = (hourly?.runwayBandHistory || []).map((pt) => { - try { - const ts = getCityLocalUtcTimestamp(pt.time, tzOffset, localDateStr); - if (ts === null) return null; - return { - ts, - high: pt.high_temp, - low: pt.low_temp, - avg: pt.avg_temp - }; - } catch { - return null; - } - }).filter((v): v is NonNullable => v !== null); - - const bandVals = binBandObservationsToSlots(slots, normBandObs); - const maxVals = bandVals.map((val) => val ? val[1] : null); - - if (maxVals.some((v) => v !== null)) { - series.push({ - key: "runway_max", - label: isEn ? "Runway Max" : "跑道最高温", - source: "Runway Max", - color: "#009688", - featured: true, - values: maxVals, - }); - } - - // DEB forecast curve (from hourly.times & hourly.temps) - if (hourly?.times?.length && hourly?.temps?.length) { - const debVals = na(); - hourly.times.forEach((t, i) => { - const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr); - if (ts === null) return; - const slotIdx = slots.findIndex((s) => s === ts); - if (slotIdx >= 0) { - debVals[slotIdx] = validNumber(hourly.temps[i]); - } - }); - if (debVals.some((v) => v !== null)) { - series.push({ - key: "hourly_forecast", - label: "DEB Forecast", - source: "DEB Hourly", - color: "#f97316", - featured: true, - smooth: true, - values: debVals, - }); - } - - // Per-model curves - if (hourly.modelCurves) { - const modelColors = ["#2563eb", "#7c3aed", "#059669", "#d97706", "#dc2626", "#0891b2"]; - Object.keys(hourly.modelCurves).forEach((model, idx) => { - const modelTemps = hourly.modelCurves![model]; - if (!modelTemps?.length) return; - const vals = na(); - hourly.times.forEach((t, i) => { - const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr); - if (ts === null) return; - const slotIdx = slots.findIndex((s) => s === ts); - if (slotIdx >= 0 && i < modelTemps.length) { - vals[slotIdx] = validNumber(modelTemps[i]); - } - }); - if (vals.some((v) => v !== null)) { - series.push({ - key: `model_curve_${model}`, - label: model, - source: "Multi-model hourly", - color: modelColors[idx % modelColors.length], - dashed: true, - smooth: true, - values: vals, - }); - } - }); - } - } - - // Historical METAR observations (past timestamps of the 3 days) - const metarObs = normObs( - row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, - tzOffset + return ( +
+
{label}
+
+ {rows.slice(0, 8).map((item) => ( +
+ + + {item.label} + + {item.value.toFixed(2)}° +
+ ))} +
+
); - if (metarObs.length) { - const mvals = binObservationsToSlots(slots, metarObs); - if (mvals.some((v) => v !== null)) { - series.push({ - key: "metar", - label: "METAR", - source: row?.airport || "METAR", - color: "#0ea5e9", - dashed: true, - values: mvals, - }); - } - } - - // Build data rows - const data = slots.map((ts, i) => { - const point: Record = { - label: format3DayTimestamp(ts), - ts, - runway_band: bandVals[i] ?? null, - }; - series.forEach((s) => { - point[s.key] = s.values[i] ?? null; - }); - return point; - }); - - return { data, series }; } - -function generateDailySlots(localDateStr: string, daysCount: number): string[] { - const parts = localDateStr.split("-"); - if (parts.length !== 3) return []; - const year = parseInt(parts[0], 10); - const month = parseInt(parts[1], 10) - 1; - const day = parseInt(parts[2], 10); - - const dates: string[] = []; - for (let i = 0; i < daysCount; i++) { - const d = new Date(Date.UTC(year, month, day + i)); - const yyyy = d.getUTCFullYear(); - const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); - const dd = String(d.getUTCDate()).padStart(2, "0"); - dates.push(`${yyyy}-${mm}-${dd}`); - } - return dates; -} - -function formatDailyDateLabel(dateStr: string): string { - const parts = dateStr.split("-"); - if (parts.length !== 3) return dateStr; - return `${parts[1]}/${parts[2]}`; -} - -function buildDailyChartData( - row: ScanOpportunityRow | null, - hourly: HourlyForecast, - daysCount: number, -): { data: Array>; series: EvidenceSeries[] } { - const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10); - const slots = generateDailySlots(localDateStr, daysCount); - - const series: EvidenceSeries[] = [ - { - key: "deb_prediction", - label: "DEB Daily Max", - source: "DEB", - color: "#f97316", // orange - featured: true, - values: [], - }, - { - key: "max_temp", - label: "Model Daily Max", - source: "Standard Forecast", - color: "#dc2626", // red - dashed: true, - values: [], - }, - { - key: "min_temp", - label: "Model Daily Min", - source: "Standard Forecast", - color: "#2563eb", // blue - dashed: true, - values: [], - }, - ]; - - const data = slots.map((dateStr) => { - const dayForecast = hourly?.forecastDaily?.find((d) => d.date === dateStr); - const dayMultiModel = hourly?.multiModelDaily?.[dateStr]; - - const label = formatDailyDateLabel(dateStr); - - const debMax = validNumber(dayMultiModel?.deb?.prediction) ?? - (dateStr === localDateStr ? validNumber(hourly?.debPrediction) ?? validNumber(row?.deb_prediction) : null); - const maxTemp = validNumber(dayForecast?.max_temp); - const minTemp = validNumber(dayForecast?.min_temp); - - return { - label, - date: dateStr, - deb_prediction: debMax, - max_temp: maxTemp, - min_temp: minTemp, - }; - }); - - // Populate series values - series[0].values = data.map((d) => d.deb_prediction); - series[1].values = data.map((d) => d.max_temp); - series[2].values = data.map((d) => d.min_temp); - - // Filter out series that have no valid data points - const activeSeries = series.filter((s) => s.values.some((v) => v !== null)); - - return { data, series: activeSeries }; -} - -function binBandObservationsToSlots( - slots: number[], - obs: Array<{ ts: number; high: number; low: number; avg: number }>, -): Array<[number, number] | null> { - const result: Array<[number, number] | null> = new Array(slots.length).fill(null); - for (const point of obs) { - for (let i = slots.length - 1; i >= 0; i--) { - if (point.ts >= slots[i]) { - result[i] = [point.low, point.high]; - break; - } - } - } - return result; -} - -function buildFullDayChartData( - row: ScanOpportunityRow | null, - hourly: HourlyForecast, - isEn: boolean, -): { data: Array>; series: EvidenceSeries[] } { - const tzOffset = row?.tz_offset_seconds ?? 0; - const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10); - - const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset); - const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset); - const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset); - const runwayHistorySeries = buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr); - - const settlementCityKey = normalizeCityKey(row?.city); - const isShenzhen = settlementCityKey === 'shenzhen'; - const isHKO = (settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan' - || (row?.city || '').toLowerCase().includes('hong kong') - || (row?.city || '').toLowerCase().includes('lau fau shan')) && !isShenzhen; - - let finalSettlementObs = settlementObs; - let finalMadisObs = madisObs; - if (isHKO) { - finalSettlementObs = madisObs; - finalMadisObs = settlementObs; - } - - const slots = generateFullDaySlots(localDateStr); - if (!slots.length) return { data: [], series: [] }; - const slotLabels = slots.map(formatTimestamp); - const n = slots.length; - - const series: EvidenceSeries[] = []; - const na = (): Array => new Array(n).fill(null); - - // ── Runway history series ── - runwayHistorySeries.forEach((rhs) => { - const binned = binObservationsToSlots(slots, rhs.points); - if (!binned.some((v) => v !== null)) return; - series.push({ - key: rhs.key, - label: rhs.label, - source: "", - color: rhs.color, - featured: rhs.isSettlement, - dashed: !rhs.isSettlement, - values: binned, - }); - }); - - // ── Runway band & max series ── - const normBandObs = (hourly?.runwayBandHistory || []).map((pt) => { - try { - const ts = getCityLocalUtcTimestamp(pt.time, tzOffset, localDateStr); - if (ts === null) return null; - return { - ts, - high: pt.high_temp, - low: pt.low_temp, - avg: pt.avg_temp - }; - } catch { - return null; - } - }).filter((v): v is NonNullable => v !== null); - - const bandVals = binBandObservationsToSlots(slots, normBandObs); - const maxVals = bandVals.map((val) => val ? val[1] : null); - - if (maxVals.some((v) => v !== null)) { - series.push({ - key: "runway_max", - label: isEn ? "Runway Max" : "跑道最高温", - source: "Runway Max", - color: "#009688", - featured: true, - values: maxVals, - }); - } - - // ── Settlement observations ── - // Determine if this is an HKO-sourced city to force the label - const isHKOCity = settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan' - || settlementCityKey === 'shenzhen' || (row?.city || '').toLowerCase().includes('hong kong') - || (row?.city || '').toLowerCase().includes('lau fau shan'); - if (finalSettlementObs.length) { - const svals = binObservationsToSlots(slots, finalSettlementObs); - if (svals.some((v) => v !== null)) { - series.push({ - key: "settlement", - label: isHKO ? "CoWIN 6087" : (isHKOCity ? "HKO" : (hourly?.settlementStationLabel || row?.metar_context?.station_label || row?.metar_context?.station || "Settlement")), - source: isHKO ? "cowin_obs" : (row?.metar_context?.station || row?.airport || "Settlement"), - color: "#009688", - featured: true, - values: svals, - }); - } - } - - // ── Airport Primary (MADIS / AMSC AWOS) ── - // Skip this series for AMSC AWOS cities — their data is redundant with - // runway sensor data and adds a confusing "AMSC AWOS" label to the chart. - const isAmscSource = - (hourly?.airportPrimary as any)?.source === "amsc_awos" || - String(hourly?.airportPrimary?.source_label || "").toLowerCase().includes("amsc"); - if (finalMadisObs.length && !isAmscSource) { - const madisVals = binObservationsToSlots(slots, finalMadisObs); - if (madisVals.some((v) => v !== null)) { - series.push({ - key: "madis", - label: isHKO ? "HKO" : (hourly?.airportPrimary?.source_label || "NOAA MADIS"), - source: isHKO ? "HKO" : (hourly?.airportPrimary?.station_code || row?.airport || "MADIS"), - color: "#0284c7", - dashed: isHKO ? true : false, - values: madisVals, - }); - } - } - - if (metarObs.length && !observationSetContains(finalMadisObs, metarObs)) { - const mvals = binObservationsToSlots(slots, metarObs); - if (mvals.some((v) => v !== null)) { - series.push({ - key: "metar", - label: isHKO ? "VHHH METAR" : (isHKOCity ? "HKO" : (row?.metar_context?.station_label || "METAR")), - source: row?.airport || "METAR", - color: "#0ea5e9", - dashed: true, - values: mvals, - }); - } - } - - // ── DEB forecast curve ── - if (hourly?.times?.length && hourly?.temps?.length) { - const debPath = buildDebBaselinePath( - hourly.times, - hourly.temps, - validNumber(hourly?.debPrediction) ?? row?.deb_prediction, - hourly.localTime || row?.local_time, - hourly.forecastTodayHigh, - ); - const debVals = na(); - hourly.times.forEach((t, i) => { - const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr); - if (ts === null) return; - const slotIdx = slots.findIndex((s) => s === ts); - if (slotIdx >= 0 && i < debPath.debTemps.length) { - debVals[slotIdx] = validNumber(debPath.debTemps[i]); - } - }); - if (debVals.some((v) => v !== null)) { - series.push({ - key: "hourly_forecast", - label: "DEB Forecast", - source: "DEB Hourly", - color: "#f97316", - featured: true, - smooth: true, - values: debVals, - }); - } - - // Per-model curves - if (hourly.modelCurves) { - const modelColors = ["#2563eb", "#7c3aed", "#059669", "#d97706", "#dc2626", "#0891b2"]; - Object.keys(hourly.modelCurves).forEach((model, idx) => { - const modelTemps = hourly.modelCurves![model]; - if (!modelTemps?.length) return; - const vals = na(); - hourly.times.forEach((t, i) => { - const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr); - if (ts === null) return; - const slotIdx = slots.findIndex((s) => s === ts); - if (slotIdx >= 0 && i < modelTemps.length) vals[slotIdx] = validNumber(modelTemps[i]); - }); - if (vals.some((v) => v !== null)) { - series.push({ - key: `model_curve_${model}`, - label: model, - source: "Multi-model hourly", - color: modelColors[idx % modelColors.length], - dashed: true, - smooth: true, - values: vals, - }); - } - }); - } - } - - // ── Fallback ── - if (!series.length) { - const fb = validNumber(row?.current_temp) ?? validNumber(hourly?.debPrediction) ?? validNumber(row?.deb_prediction) ?? validNumber(row?.target_threshold); - if (fb !== null) { - series.push({ - key: "current", - label: "Current reference", - source: row?.metar_context?.source || "Live", - color: "#009688", - featured: true, - values: Array.from({ length: n }, () => fb), - }); - } - } - - // ── Build data rows ── - const data = slots.map((ts, i) => { - const point: Record = { - label: formatTimestamp(ts), - ts, - runway_band: bandVals[i] ?? null, - }; - series.forEach((s) => { point[s.key] = s.values[i] ?? null; }); - return point; - }); - - return { data, series }; -} - -// ── Model summary cards (daily high point predictions) ───────────────── - -function buildModelSummaryCards(row: ScanOpportunityRow | null): EvidenceSeries[] { - return Object.entries(row?.model_cluster_sources || {}) - .map(([label, value]) => [label, validNumber(value)] as const) - .filter((entry): entry is readonly [string, number] => entry[1] !== null) - .slice(0, 4) - .map(([label, value], index) => ({ - key: `model_summary_${index}`, - label, - source: "Multi-model daily high", - color: ["#2563eb", "#14b8a6", "#7c3aed", "#64748b"][index] || "#64748b", - dashed: true, - values: [value], - })); -} - -// ── Integer-degree ticks for Y-axis ────────────────────────────────── - -function buildIntDegreeTicks(series: EvidenceSeries[], data?: Array>): number[] | null { - const vals = data?.length - ? data.flatMap((point) => series.map((s) => point[s.key])).filter((v): v is number => validNumber(v) !== null) - : series.flatMap((s) => s.values).filter((v): v is number => validNumber(v) !== null); - if (!vals.length) return null; - const min = Math.floor(Math.min(...vals)); - const max = Math.ceil(Math.max(...vals)); - const ticks: number[] = []; - for (let d = min; d <= max; d++) ticks.push(d); - return ticks.length > 0 ? ticks : null; -} - -function buildChartDomain( - series: EvidenceSeries[], - data?: Array>, -): [number, number] | ["auto", "auto"] { - const vals = data?.length - ? data.flatMap((point) => series.map((s) => point[s.key])).filter((v): v is number => validNumber(v) !== null) - : series.flatMap((s) => s.values).filter((v): v is number => validNumber(v) !== null); - if (!vals.length) return ["auto", "auto"]; - const min = Math.min(...vals); - const max = Math.max(...vals); - const span = Math.max(1, max - min); - const pad = Math.max(0.5, span * 0.08); - return [Number((min - pad).toFixed(1)), Number((max + pad).toFixed(1))]; -} - -function generateFullDaySlots(localDateStr: string): number[] { - const parts = localDateStr.split("-"); - if (parts.length !== 3) return []; - const year = parseInt(parts[0], 10); - const month = parseInt(parts[1], 10) - 1; - const day = parseInt(parts[2], 10); - const slots: number[] = []; - for (let h = 0; h < 24; h++) { - for (let m = 0; m < 60; m += FULL_DAY_SLOT_MINUTES) { - slots.push(Date.UTC(year, month, day, h, m)); - } - } - return slots; -} - -function binObservationsToSlots( - slots: number[], - obs: Array<{ ts: number; value: number }>, -): Array { - const result: Array = new Array(slots.length).fill(null); - for (const point of obs) { - for (let i = slots.length - 1; i >= 0; i--) { - if (point.ts >= slots[i]) { - result[i] = point.value; - break; - } - } - } - return result; -} - // ── Main component ───────────────────────────────────────────────────── export function LiveTemperatureThresholdChart({ @@ -1440,9 +146,11 @@ export function LiveTemperatureThresholdChart({ const city = String(row?.city || "").toLowerCase().trim(); const latestPatch = useLatestPatch(city); const resyncVersion = useSseResyncVersion(); - const [timeframe, setTimeframe] = useState<"1D" | "3D">("1D"); + const timeframe = "1D"; + const [viewMode, setViewMode] = useState<"auto" | "full">("auto"); const [userToggledKeys, setUserToggledKeys] = useState>({}); const [liveTemp, setLiveTemp] = useState(null); + const [isHourlyLoading, setIsHourlyLoading] = useState(false); const lastPatchAtRef = useRef(Date.now()); const lastAppliedPatchRevisionRef = useRef(0); @@ -1455,19 +163,17 @@ export function LiveTemperatureThresholdChart({ useEffect(() => { setUserToggledKeys({}); setZoomRange(null); + setViewMode("auto"); setShowRunwayDetails(true); lastPatchAtRef.current = Date.now(); lastAppliedPatchRevisionRef.current = 0; - }, [city, timeframe]); + }, [city]); useEffect(() => { - setUserToggledKeys({}); - lastPatchAtRef.current = Date.now(); - lastAppliedPatchRevisionRef.current = 0; - }, [city, timeframe]); - - useEffect(() => { - if (!city) return; + if (!city) { + setIsHourlyLoading(false); + return; + } const cacheKey = `${city}:${targetResolution}`; // Check in-memory cache first @@ -1483,10 +189,12 @@ export function LiveTemperatureThresholdChart({ if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) { setHourly(cached.data); + setIsHourlyLoading(false); return; } setHourly(seedHourlyForecastFromRow(row)); + setIsHourlyLoading(true); let cancelled = false; // Prioritize active slots, stagger/delay background slots to optimize load performance @@ -1498,7 +206,10 @@ export function LiveTemperatureThresholdChart({ if (cancelled || !data) return; setHourly(data); }) - .catch(() => {}); + .catch(() => {}) + .finally(() => { + if (!cancelled) setIsHourlyLoading(false); + }); }, delay); return () => { @@ -1519,12 +230,16 @@ export function LiveTemperatureThresholdChart({ useEffect(() => { if (!resyncVersion || !city) return; let cancelled = false; + setIsHourlyLoading(true); fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution }) .then((data) => { if (cancelled || !data) return; setHourly(data); }) - .catch(() => {}); + .catch(() => {}) + .finally(() => { + if (!cancelled) setIsHourlyLoading(false); + }); return () => { cancelled = true; }; @@ -1537,13 +252,17 @@ export function LiveTemperatureThresholdChart({ const refreshFullDetail = () => { lastPatchAtRef.current = Date.now(); + setIsHourlyLoading(true); fetchHourlyForecastForCity(city, { ignoreCache: true }) .then((data) => { if (cancelled || !data) return; setHourly(data); }) - .catch(() => {}); + .catch(() => {}) + .finally(() => { + if (!cancelled) setIsHourlyLoading(false); + }); }; const checkFallback = () => { @@ -1569,24 +288,23 @@ export function LiveTemperatureThresholdChart({ }; }, [city, compact, isActive, isMaximized]); - const { data, series } = useMemo(() => { - if (timeframe === "3D") { - return build3DayChartData(row, hourly, isEn); - } - return buildFullDayChartData(row, hourly, isEn); - }, [row, hourly, timeframe, isEn]); + const { data, series } = useMemo(() => buildFullDayChartData(row, hourly, isEn), [row, hourly, isEn]); + + const autoWindowRange = useMemo( + () => (viewMode === "auto" ? getDebPeakWindowRange(data, series) : null), + [data, series, viewMode], + ); + const visibleRange = zoomRange ?? autoWindowRange; const zoomedData = useMemo(() => { - if (!zoomRange || data.length === 0) return data; - const [start, end] = zoomRange; + if (!visibleRange || data.length === 0) return data; + const [start, end] = visibleRange; return data.slice(start, end + 1); - }, [data, zoomRange]); + }, [data, visibleRange]); useEffect(() => { - if (timeframe === "3D") { - setTargetResolution("30m"); - } else if (zoomRange && data.length > 0) { - const zoomedData = data.slice(zoomRange[0], zoomRange[1] + 1); + if (visibleRange && data.length > 0) { + const zoomedData = data.slice(visibleRange[0], visibleRange[1] + 1); if (zoomedData.length > 0) { const startTs = zoomedData[0].ts; const endTs = zoomedData[zoomedData.length - 1].ts; @@ -1601,7 +319,7 @@ export function LiveTemperatureThresholdChart({ } else { setTargetResolution("10m"); } - }, [timeframe, zoomRange, data]); + }, [visibleRange, data]); const tzOffset = row?.tz_offset_seconds ?? 0; const settlementObs = useMemo(() => { @@ -1639,48 +357,15 @@ export function LiveTemperatureThresholdChart({ ); }, [chartSeries, userToggledKeys, city, showRunwayDetails]); - const normalizedKey = normalizeCityKey(row?.city); - const runwaySensorCities = new Set([ - 'beijing', 'shanghai', 'guangzhou', 'qingdao', - 'chengdu', 'chongqing', 'wuhan', // AMSC runway sensors - 'seoul', 'busan', // AMOS runway sensors - ]); - const isShenzhen = normalizedKey === 'shenzhen'; - const isHKO = (normalizedKey === 'hongkong' || normalizedKey === 'laufaushan') && !isShenzhen; - const isTokyo = normalizedKey === 'tokyo'; - const isSingapore = normalizedKey === 'singapore'; - const isParis = normalizedKey === 'paris'; - const isTaipei = normalizedKey === 'taipei'; - const stationSourceCode = hourly?.airportPrimary?.source_code || (row as any)?.station_source_code || ''; - const hasRealStationNetwork = /^(mgm|jma_amedas|fmi|knmi|cowin_obs|ims|ncm|aeroweb|madis_hfmetar|singapore_mss)$/.test(stationSourceCode); - const isWeatherStation = !runwaySensorCities.has(normalizedKey) - && !isHKO && !isShenzhen && !isTokyo && !isSingapore && !isParis && !isTaipei - && hasRealStationNetwork; - - const runwayHeaderLabel = isShenzhen ? '天文台实测 (10分钟)' - : isHKO ? '参考站点 (1分钟)' - : isTokyo ? '机场气象站 (10分钟)' - : isSingapore ? '航站楼温度' - : isParis ? '官方机场观测 (15分钟)' - : isTaipei ? 'CWA (10分钟)' - : isWeatherStation ? '气象站实测' - : '跑道实测 (1分钟)'; - - const metarHeaderLabel = (isShenzhen || isHKO) ? '天文台实测 (10分钟)' - : 'METAR 结算 (30分钟)'; - - const runwayHighLabel = isShenzhen ? '天文台实测' - : isHKO ? '参考站点' - : isTokyo ? '机场气象站' - : isSingapore ? '航站楼' - : isParis ? '官方机场观测' - : isTaipei ? 'CWA' - : isWeatherStation ? '气象站' - : '跑道实测'; - - const metarHighLabel = isShenzhen ? '天文台' - : isHKO ? '天文台' - : 'METAR 官方'; + const { + isHKO, + isParis, + isShenzhen, + metarHeaderLabel, + metarHighLabel, + runwayHeaderLabel, + runwayHighLabel, + } = useMemo(() => getLiveObservationLabels(row, hourly), [row, hourly]); const { currentRunwayTemp, observedHighMetar, observedHighRunway } = useMemo( () => getObservationDisplayMetrics(row, hourly, settlementPlate), @@ -1755,15 +440,7 @@ export function LiveTemperatureThresholdChart({ [activeSeries, zoomedData], ); - const subtitle = row - ? isEn - ? timeframe === "1D" - ? "Live & Forecast" - : `${timeframe} Forecast` - : timeframe === "1D" - ? "实测与预测" - : `${timeframe}预报` - : ""; + const subtitle = row ? (isEn ? "Live & Forecast" : "实测与预测") : ""; const panelTitle = row ? (
@@ -1799,19 +476,22 @@ export function LiveTemperatureThresholdChart({ )}
- {(["1D", "3D"] as const).map((tf) => ( + {(["auto", "full"] as const).map((mode) => ( ))}
@@ -1883,7 +563,7 @@ export function LiveTemperatureThresholdChart({ } if (rightIdx - leftIdx >= 1) { - const originalStartIndex = zoomRange ? zoomRange[0] : 0; + const originalStartIndex = visibleRange ? visibleRange[0] : 0; const newStart = originalStartIndex + leftIdx; const newEnd = originalStartIndex + rightIdx; setZoomRange([newStart, newEnd]); @@ -2167,7 +847,8 @@ export function LiveTemperatureThresholdChart({ tick={{ fontSize: 9, fill: "#64748b" }} tickLine={false} axisLine={{ stroke: "#cbd5e1" }} - interval={Math.max(1, Math.floor(zoomedData.length / (compact ? 6 : 10)))} + interval={Math.max(0, Math.floor(zoomedData.length / (compact ? 6 : 10)))} + minTickGap={compact ? 24 : 32} /> ( + }> | undefined} + data={zoomedData} + series={activeSeries} + /> + )} formatter={(value: unknown) => { if (Array.isArray(value)) { const [low, high] = value; @@ -2242,13 +934,13 @@ export function LiveTemperatureThresholdChart({ {activeSeries.map((item) => ( + {isHourlyLoading && ( +
+
+ + {isEn ? "Loading chart" : "加载图表"} +
+
+ )}
@@ -2265,15 +965,17 @@ export function LiveTemperatureThresholdChart({ export function __buildTemperatureChartDataForTest( row: ScanOpportunityRow | null, hourly: HourlyForecast, - timeframe: "1D" | "3D" = "1D", + _timeframe = "1D", isEn = false, ) { - return timeframe === "3D" ? build3DayChartData(row, hourly, isEn) : buildFullDayChartData(row, hourly, isEn); + return buildFullDayChartData(row, hourly, isEn); } export const __isTemperatureSeriesVisibleByDefaultForTest = isTemperatureSeriesVisibleByDefault; export const __getVisibleTemperatureSeriesForTest = getVisibleTemperatureSeries; export const __getActiveTemperatureSeriesForTest = getActiveTemperatureSeries; +export const __getDebPeakWindowRangeForTest = getDebPeakWindowRange; +export const __getLiveObservationLabelsForTest = getLiveObservationLabels; export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics; export const __shouldPollLiveChartForTest = shouldPollLiveChart; export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly; diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index 713fd79c..81d2b251 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -28,6 +28,10 @@ export function runTests() { path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"), "utf8", ); + const chartLogicSource = fs.readFileSync( + path.join(projectRoot, "components", "dashboard", "scan-terminal", "temperature-chart-logic.ts"), + "utf8", + ); assert( querySource.includes("useSsePatchVersion") && @@ -35,7 +39,7 @@ export function runTests() { "scan list should subscribe to SSE patch state instead of running a 5-minute interval", ); assert( - chartSource.includes("DASHBOARD_REFRESH_POLICY_MS.metar") && + chartLogicSource.includes("DASHBOARD_REFRESH_POLICY_MS.metar") && !chartSource.includes("window.setInterval"), "selected city detail chart cache should align with 5-minute scan/metar cadence", ); @@ -59,8 +63,8 @@ export function runTests() { "inactive non-compact charts should not run live polling", ); assert( - chartSource.includes("_hourlyRequestCache") && - chartSource.includes("seedHourlyForecastFromRow") && + chartLogicSource.includes("_hourlyRequestCache") && + chartLogicSource.includes("seedHourlyForecastFromRow") && chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"), "terminal charts should render from row data immediately and dedupe concurrent city detail requests", ); diff --git a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts index 969527e6..ea2b97b4 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts @@ -87,11 +87,27 @@ export function runTests() { assert(bffEventsRoute.includes("searchParams"), "Next.js SSE proxy must forward query parameters to FastAPI"); const chart = readFrontendFile("components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"); + const chartLogicPath = path.join(process.cwd(), "components", "dashboard", "scan-terminal", "temperature-chart-logic.ts"); + assert(fs.existsSync(chartLogicPath), "temperature chart pure data logic must live in temperature-chart-logic.ts"); + const chartLogic = fs.readFileSync(chartLogicPath, "utf8"); + assert(chartLogic.includes("buildFullDayChartData"), "temperature-chart-logic.ts must own full-day chart data generation"); + assert(chartLogic.includes("mergePatchIntoHourly"), "temperature-chart-logic.ts must own SSE patch merge logic"); + assert(!chart.includes("function buildFullDayChartData"), "LiveTemperatureThresholdChart.tsx must not define full-day chart data generation inline"); + assert(!chart.includes("function mergePatchIntoHourly"), "LiveTemperatureThresholdChart.tsx must not define SSE patch merge logic inline"); assert(chart.includes("useLatestPatch"), "temperature chart must consume useLatestPatch(city)"); assert(chart.includes("latestPatch"), "temperature chart must react to incoming SSE patches"); assert(chart.includes("useSseResyncVersion"), "temperature chart must resync full detail when SSE replay is incomplete"); - assert(chart.includes("runway_points"), "temperature chart must merge v1 runway_points into runway history"); + assert(chartLogic.includes("runway_points"), "temperature chart must merge v1 runway_points into runway history"); assert(chart.includes("2 * 60_000"), "temperature chart must wait two minutes without patches before full-fetch fallback"); + assert(chart.includes("TemperatureTooltipContent"), "temperature chart must use a custom tooltip content component"); + assert(chart.includes("filterNull={false}"), "temperature chart tooltip must keep null-slot payload so hover works between sparse points"); + assert(chart.includes("nearestSeriesValue"), "temperature chart tooltip must fall back to nearest non-null value for connected sparse lines"); + assert(chart.includes("isHourlyLoading"), "temperature chart must keep a per-panel hourly loading state"); + assert(chart.includes("加载图表") && chart.includes("absolute inset-2"), "temperature chart must render an in-chart loading overlay"); + assert(chart.includes("viewMode"), "temperature chart must expose a view mode for DEB-peak auto view versus full-day view"); + assert(chart.includes("getDebPeakWindowRange"), "temperature chart must derive its default view from the DEB peak window"); + assert(!chart.includes("3D"), "temperature chart UI must not expose a 3D/future-forecast mode"); + assert(!chart.includes("build3DayChartData"), "temperature chart component must not render future prediction curves"); assert( !chart.includes("setInterval(poll, 60_000)"), "temperature chart must not use unconditional 60-second full-detail polling after SSE patch migration", diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts index 53d2e5ab..e710b446 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts @@ -1,6 +1,8 @@ import { __buildTemperatureChartDataForTest, __getActiveTemperatureSeriesForTest, + __getDebPeakWindowRangeForTest, + __getLiveObservationLabelsForTest, __getObservationDisplayMetricsForTest, __getVisibleTemperatureSeriesForTest, __isTemperatureSeriesVisibleByDefaultForTest, @@ -114,6 +116,51 @@ export function runTests() { defaultVisibleSeries.some((item) => item.key === "hourly_forecast"), "DEB fusion forecast should be visible by default", ); + + const debPeakWindowChart = __buildTemperatureChartDataForTest( + { + city: "beijing", + local_date: "2026-05-26", + local_time: "12:00", + tz_offset_seconds: 8 * 60 * 60, + deb_prediction: 35, + } as any, + { + localTime: "12:00", + times: [ + "00:00", "01:00", "02:00", "03:00", "04:00", "05:00", + "06:00", "07:00", "08:00", "09:00", "10:00", "11:00", + "12:00", "13:00", "14:00", "15:00", "16:00", "17:00", + "18:00", "19:00", "20:00", "21:00", "22:00", "23:00", + ], + temps: [ + 20, 20.5, 21, 21.5, 22, 23, + 24, 25, 26, 27, 29, 31, + 32, 33, 34.2, 35, 34.4, 33.3, + 31.8, 30.2, 28.5, 27, 25.5, 24, + ], + debPrediction: 35, + } as any, + "1D", + ); + const debPeakWindowRange = __getDebPeakWindowRangeForTest( + debPeakWindowChart.data, + debPeakWindowChart.series as any, + ); + assert(debPeakWindowRange, "default chart view should derive an auto high-temperature window from the DEB curve"); + const debPeakWindowRows = debPeakWindowChart.data.slice(debPeakWindowRange![0], debPeakWindowRange![1] + 1); + const debPeakWindowStart = debPeakWindowRows[0].ts; + const debPeakWindowEnd = debPeakWindowRows[debPeakWindowRows.length - 1].ts; + assert( + debPeakWindowStart <= Date.UTC(2026, 4, 26, 11, 0, 0) && + debPeakWindowEnd >= Date.UTC(2026, 4, 26, 19, 0, 0), + "DEB peak auto window should cover roughly peak -4h through peak +4h by default", + ); + assert( + debPeakWindowEnd - debPeakWindowStart <= 12 * 60 * 60 * 1000, + "DEB peak auto window should not expand beyond 12 hours", + ); + assert( __isTemperatureSeriesVisibleByDefaultForTest("paris", "model_curve_AROME HD"), "Paris AROME HD should be the only default-visible model curve exception", @@ -264,6 +311,26 @@ export function runTests() { assert(newYorkMetrics.currentRunwayTemp === 73.9, "weather-station header should use detail METAR/current temp before stale row zero"); assert(newYorkMetrics.observedHighMetar === 73.9, "METAR high header should use detail METAR high before stale row zero"); + const istanbulLabels = __getLiveObservationLabelsForTest( + { + city: "istanbul", + airport: "LTFM", + metar_context: { + source: "mgm", + station_label: "MGM Istanbul Airport", + }, + } as any, + null, + ); + assert( + istanbulLabels.runwayHeaderLabel === "气象站实测", + "Istanbul/MGM should be labeled as weather-station observations, not runway observations", + ); + assert( + istanbulLabels.runwayHighLabel === "气象站", + "Istanbul/MGM high label should be weather station", + ); + const newYorkWithMadis = __buildTemperatureChartDataForTest( { city: "new york", @@ -319,14 +386,49 @@ export function runTests() { } as any, "1D", ); + assert( + newYorkMinuteStream.data.length < 120, + "1D live chart should use real timestamp rows instead of preallocating 1440 empty full-day minute slots", + ); const minuteLabels = newYorkMinuteStream.data .filter((point) => point.madis !== null) .map((point) => point.label); assert( - minuteLabels.includes("10:01") && - minuteLabels.includes("10:02") && - minuteLabels.includes("10:03"), - "live observation chart should preserve minute-level SSE patch points instead of collapsing them into a 30-minute bucket", + minuteLabels.includes("10:01:00") && + minuteLabels.includes("10:02:00") && + minuteLabels.includes("10:03:00"), + "live observation chart should preserve real observation timestamps on the x-axis", + ); + + const longLivedSingleObservation = __buildTemperatureChartDataForTest( + { + city: "ankara", + local_date: "2026-05-26", + local_time: "14:28", + tz_offset_seconds: 3 * 60 * 60, + current_temp: 21.9, + current_max_so_far: 21.9, + airport: "LTAC", + } as any, + { + localTime: "14:28", + times: [], + temps: [], + airportPrimary: { + source_code: "mgm", + source_label: "MGM", + temp: 21.9, + max_so_far: 21.9, + }, + airportPrimaryTodayObs: [["2026-05-26T11:28:00Z", 21.9]], + } as any, + "1D", + ); + assert( + longLivedSingleObservation.series.some( + (item) => item.key === "current" && item.values.filter((value: number | null) => value !== null).length >= 2, + ), + "long-lived chart with only one fresh observation should keep a renderable current reference line instead of an invisible single-point series", ); const chengduMergedHourly = __mergePatchIntoHourlyForTest( diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts new file mode 100644 index 00000000..f624634b --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -0,0 +1,1504 @@ +import type { + AmosData, + AirportCurrentConditions, + CityDetail, + ScanOpportunityRow, + ForecastDay, + DailyModelForecast, +} from "@/lib/dashboard-types"; +import { buildDebBaselinePath } from "@/lib/temperature-chart-paths"; +import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy"; +import type { CityPatch } from "@/hooks/use-sse-patches"; +const ROLLING_WINDOW_BEFORE_MS = 12 * 60 * 60 * 1000; +const ROLLING_WINDOW_AFTER_LIVE_MS = 2 * 60 * 60 * 1000; +const ROLLING_WINDOW_AFTER_FORECAST_MS = 8 * 60 * 60 * 1000; + +const SETTLEMENT_RUNWAY_PAIRS: Record> = { + shanghai: [["17L", "35R"]], + beijing: [["19", "01"]], + guangzhou: [["02L", "20R"]], + chengdu: [["02L", "20R"]], + chongqing: [["20R", "02L"]], + wuhan: [["04", "22"]], + seoul: [["15R", "33L"]], +}; + +function normalizeRunwayLabel(value?: string | null) { + return String(value || "").trim().toUpperCase().replace(/\s+/g, ""); +} + +function normalizeCityKey(value?: string | null) { + return String(value || "").trim().toLowerCase().replace(/[\s_-]+/g, ""); +} + +function pairKey(pair: [string, string]) { + return pair.map(normalizeRunwayLabel).sort().join("/"); +} + +function runwaySeriesKey(rwy: string) { + return `runway_${String(rwy || "unknown") + .split("/") + .map(normalizeRunwayLabel) + .filter(Boolean) + .join("_")}`; +} + +function isTemperatureSeriesVisibleByDefault(city: string, seriesKey: string) { + if (seriesKey.startsWith("model_curve_")) { + return normalizeCityKey(city) === "paris" && seriesKey === "model_curve_AROME HD"; + } + return true; +} + +function getVisibleTemperatureSeries( + city: string, + series: EvidenceSeries[], + userToggledKeys: Record, +) { + return series.filter((item) => { + if (userToggledKeys[item.key] !== undefined) { + return userToggledKeys[item.key]; + } + return isTemperatureSeriesVisibleByDefault(city, item.key); + }); +} + +function getActiveTemperatureSeries( + city: string, + chartSeries: EvidenceSeries[], + userToggledKeys: Record, + showRunwayDetails: boolean, +) { + const rawVisible = getVisibleTemperatureSeries(city, chartSeries, userToggledKeys); + const hasRunwayMax = rawVisible.some((item) => item.key === "runway_max"); + + return rawVisible.filter((item) => { + const isIndividualRunway = + item.key.startsWith("runway_") && item.key !== "runway_max"; + if (showRunwayDetails) { + return item.key !== "runway_max"; + } + if (!hasRunwayMax) { + return true; + } + return !isIndividualRunway; + }); +} + +function buildRunwayPlates( + amos: AmosData | null | undefined, + row: ScanOpportunityRow | null, + settlementObs?: Array<{ ts: number; value: number }>, +) { + if (!amos) return []; + const runwayObs = amos.runway_obs || {}; + const runwayPairs = runwayObs.runway_pairs || []; + const runwayTemps = runwayObs.temperatures || []; + const pointTemps = runwayObs.point_temperatures || []; + + const cityKey = normalizeCityKey(row?.city); + const settlementPairs = SETTLEMENT_RUNWAY_PAIRS[cityKey] || []; + const settlementKeys = new Set(settlementPairs.map(pairKey)); + + const list: Array<{ + rwy: string; + isSettlement: boolean; + tdzTemp: number | null; + midTemp: number | null; + endTemp: number | null; + maxTemp: number | null; + dailyHigh: number | null; + trend_15m: number | null; + }> = []; + + runwayPairs.forEach((rawPair: any, index: number) => { + const pair = rawPair as [string, string]; + if (!Array.isArray(pair) || pair.length < 2) return; + const isSettlement = settlementKeys.has(pairKey(pair)); + + const tdz = validNumber(pointTemps[index]?.tdz_temp); + const mid = validNumber(pointTemps[index]?.mid_temp); + const end = validNumber(pointTemps[index]?.end_temp); + + const historyVals = Array.isArray(runwayTemps[index]) + ? (runwayTemps[index] as Array).map(validNumber).filter((v): v is number => v !== null) + : []; + + const tdzVal = tdz !== null ? [tdz] : []; + const midVal = mid !== null ? [mid] : []; + const endVal = end !== null ? [end] : []; + const allVals = [...historyVals, ...tdzVal, ...midVal, ...endVal]; + + const maxTemp = allVals.length ? Math.max(...allVals) : null; + const dailyHigh = historyVals.length ? Math.max(...historyVals) : maxTemp; + + // Calculate 15-minute trend + const latest = historyVals.length > 0 ? historyVals[historyVals.length - 1] : (tdz ?? mid ?? end ?? null); + const val15 = historyVals.length > 15 ? historyVals[historyVals.length - 16] : (historyVals.length > 0 ? historyVals[0] : null); + let trend_15m = (latest !== null && val15 !== null) ? latest - val15 : null; + + if (isSettlement && settlementObs && settlementObs.length >= 2) { + const latestObs = settlementObs[settlementObs.length - 1]; + const targetTs = latestObs.ts - 15 * 60 * 1000; + let closestPoint = settlementObs[0]; + let minDiff = Math.abs(closestPoint.ts - targetTs); + for (let i = 1; i < settlementObs.length; i++) { + const diff = Math.abs(settlementObs[i].ts - targetTs); + if (diff < minDiff) { + minDiff = diff; + closestPoint = settlementObs[i]; + } + } + if (Math.abs(closestPoint.ts - targetTs) < 5 * 60 * 1000) { + trend_15m = latestObs.value - closestPoint.value; + } + } + + list.push({ + rwy: `${normalizeRunwayLabel(pair[0])}/${normalizeRunwayLabel(pair[1])}`, + isSettlement, + tdzTemp: tdz, + midTemp: mid, + endTemp: end, + maxTemp, + dailyHigh, + trend_15m, + }); + }); + + return list; +} + +type ObsPoint = { time?: string | null; temp?: number | null }; +type RawObsPoint = ObsPoint | [string | number | null, number | null | undefined]; + +type EvidenceSeries = { + key: string; + label: string; + source: string; + color: string; + dashed?: boolean; + featured?: boolean; + smooth?: boolean; + curve?: "linear" | "monotone" | "stepAfter"; + connectNulls?: boolean; + showDot?: boolean; + values: Array; +}; + +type RunwayHistorySeries = { + key: string; + label: string; + rwy: string; + isSettlement: boolean; + color: string; + points: Array<{ ts: number; value: number }>; +}; + +type TemperatureBandPoint = { ts: number; high: number; low: number; avg: number }; + +const MAX_OBS_POINTS = 1440; +const HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar; +const _hourlyCache = new Map(); +const _hourlyRequestCache = new Map>(); +const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"]; + +const SESSION_CACHE_PREFIX = "polyweather_city_detail_v1:"; +const SESSION_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar; + +function readSessionCache(city: string): { ts: number; data: HourlyForecast } | null { + if (typeof window === "undefined") return null; + try { + const raw = sessionStorage.getItem(`${SESSION_CACHE_PREFIX}${city}`); + if (!raw) return null; + const item = JSON.parse(raw); + if (item && item.ts && Date.now() - item.ts < SESSION_CACHE_TTL_MS) { + return item; + } + } catch {} + return null; +} + +function writeSessionCache(city: string, data: HourlyForecast) { + if (typeof window === "undefined" || !data) return; + try { + sessionStorage.setItem( + `${SESSION_CACHE_PREFIX}${city}`, + JSON.stringify({ ts: Date.now(), data }) + ); + } catch {} +} + +export function clearCityDetailCache() { + _hourlyCache.clear(); + _hourlyRequestCache.clear(); + if (typeof window !== "undefined") { + try { + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key && key.startsWith(SESSION_CACHE_PREFIX)) { + sessionStorage.removeItem(key); + } + } + } catch {} + } +} + +function validNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function getCityLocalUtcTimestamp( + value: string | number | null | undefined, + tzOffsetSeconds: number, + referenceLocalDate?: string | null +): number | null { + if (value == null) return null; + + if (typeof value === "number") { + const d = new Date(value + tzOffsetSeconds * 1000); + return Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + d.getUTCHours(), + d.getUTCMinutes(), + d.getUTCSeconds() + ); + } + + const raw = String(value).trim(); + if (!raw) return null; + + if (raw.includes("T") || raw.includes("Z") || raw.includes("-")) { + const d = new Date(raw); + if (!Number.isNaN(d.getTime())) { + const localMs = d.getTime() + tzOffsetSeconds * 1000; + const localDate = new Date(localMs); + return Date.UTC( + localDate.getUTCFullYear(), + localDate.getUTCMonth(), + localDate.getUTCDate(), + localDate.getUTCHours(), + localDate.getUTCMinutes(), + localDate.getUTCSeconds() + ); + } + } + + const m = raw.match(/(\d{1,2}):(\d{2})(?::(\d{2}))?/); + if (m) { + const h = +m[1]; + const min = +m[2]; + const sec = m[3] ? +m[3] : 0; + + let year = new Date().getUTCFullYear(); + let month = new Date().getUTCMonth(); + let date = new Date().getUTCDate(); + + if (referenceLocalDate) { + const dateParts = referenceLocalDate.split("-"); + if (dateParts.length === 3) { + year = parseInt(dateParts[0]); + month = parseInt(dateParts[1]) - 1; + date = parseInt(dateParts[2]); + } + } + + return Date.UTC(year, month, date, h, min, sec); + } + + return null; +} + +function formatTimestamp(ts: number): string { + const d = new Date(ts); + return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")}`; +} + +function normalizeRawObsPoint(point: RawObsPoint): ObsPoint | null { + if (Array.isArray(point)) { + return { time: point[0] == null ? null : String(point[0]), temp: validNumber(point[1]) }; + } + return point; +} + +function normObs(points: RawObsPoint[] | null | undefined, tzOffsetSeconds: number, limit = MAX_OBS_POINTS) { + return (points || []) + .map(normalizeRawObsPoint) + .filter((p): p is ObsPoint => p !== null) + .filter((p) => validNumber(p.temp) !== null && p.time) + .map((p) => ({ + ts: getCityLocalUtcTimestamp(p.time, tzOffsetSeconds)!, + value: Number(p.temp), + })) + .filter((p) => p.ts !== null) + .slice(-limit); +} + +function seriesStats(values: Array) { + const nums = values.filter((v): v is number => validNumber(v) !== null); + const latest = nums.length ? nums[nums.length - 1] : null; + const high = nums.length ? Math.max(...nums) : null; + const first15 = nums.length > 1 ? nums[Math.max(0, nums.length - 15)] : null; + const delta15 = latest !== null && first15 !== null ? latest - first15 : null; + return { latest, high, delta15 }; +} + +function latestObservationValue(obs: Array<{ ts: number; value: number }>) { + if (!obs.length) return null; + return obs.reduce((latest, point) => (point.ts > latest.ts ? point : latest), obs[0]).value; +} + +function maxObservationValue(obs: Array<{ ts: number; value: number }>) { + if (!obs.length) return null; + return Math.max(...obs.map((point) => point.value)); +} + +function hasRenderableLineSeries(series: EvidenceSeries[]) { + return series.some( + (item) => item.values.filter((value) => validNumber(value) !== null).length >= 2, + ); +} + +function observationSetContains( + superset: Array<{ ts: number; value: number }>, + subset: Array<{ ts: number; value: number }>, +) { + if (!superset.length || !subset.length) return false; + return subset.every((point) => + superset.some((candidate) => candidate.ts === point.ts && Math.abs(candidate.value - point.value) < 0.01), + ); +} + +function getObservationDisplayMetrics( + row: ScanOpportunityRow | null, + hourly: HourlyForecast, + settlementPlate?: { maxTemp: number | null } | null, +) { + const tzOffset = row?.tz_offset_seconds ?? 0; + const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset); + const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset); + const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset); + const latestSettlement = latestObservationValue(settlementObs); + const latestMetar = latestObservationValue(metarObs); + const latestMadis = latestObservationValue(madisObs); + const highSettlement = maxObservationValue(settlementObs); + const highMetar = maxObservationValue(metarObs); + const highMadis = maxObservationValue(madisObs); + const airportCurrentTemp = validNumber(hourly?.airportCurrent?.temp) ?? validNumber(hourly?.airportPrimary?.temp); + const airportHigh = validNumber(hourly?.airportCurrent?.max_so_far) ?? validNumber(hourly?.airportPrimary?.max_so_far); + const rowMetarHigh = validNumber(row?.metar_context?.airport_max_so_far ?? row?.metar_context?.max_temp ?? row?.current_max_so_far); + + const settlementCityKey = normalizeCityKey(row?.city); + const isShenzhen = settlementCityKey === 'shenzhen'; + const isHKO = (settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan' + || (row?.city || '').toLowerCase().includes('hong kong') + || (row?.city || '').toLowerCase().includes('lau fau shan')) && !isShenzhen; + + let currentRunwayTemp: number | null = null; + let observedHighRunway: number | null = null; + + if (isHKO) { + currentRunwayTemp = + latestMadis ?? + latestSettlement ?? + latestMetar ?? + airportCurrentTemp ?? + validNumber(row?.current_temp) ?? + null; + observedHighRunway = + highMadis ?? + highSettlement ?? + airportHigh ?? + highMetar ?? + validNumber(row?.current_max_so_far) ?? + currentRunwayTemp ?? + null; + } else { + currentRunwayTemp = + validNumber(hourly?.amos?.temp_c) ?? + settlementPlate?.maxTemp ?? + latestSettlement ?? + latestMetar ?? + airportCurrentTemp ?? + validNumber(row?.current_temp) ?? + null; + observedHighRunway = + settlementPlate?.maxTemp ?? + highSettlement ?? + airportHigh ?? + highMetar ?? + validNumber(row?.current_max_so_far) ?? + currentRunwayTemp ?? + null; + } + + const observedHighMetar = airportHigh ?? highSettlement ?? highMetar ?? rowMetarHigh ?? null; + + return { currentRunwayTemp, observedHighMetar, observedHighRunway }; +} + +function isSettlementRunway(row: ScanOpportunityRow | null, rwy: string) { + const cityKey = normalizeCityKey(row?.city); + const settlementPairs = SETTLEMENT_RUNWAY_PAIRS[cityKey] || []; + if (!settlementPairs.length) return false; + const normalized = rwy + .split("/") + .map(normalizeRunwayLabel) + .filter(Boolean) + .sort() + .join("/"); + return settlementPairs.some((pair) => pairKey(pair) === normalized); +} + +function runwayLabelFromPair(rawPair: unknown, index: number) { + if (Array.isArray(rawPair) && rawPair.length >= 2) { + return `${normalizeRunwayLabel(rawPair[0])}/${normalizeRunwayLabel(rawPair[1])}`; + } + return `RWY ${index + 1}`; +} + +type HourlyForecast = { + forecastTodayHigh?: number | null; + debPrediction?: number | null; + localTime?: string | null; + times: string[]; + temps: Array; + modelCurves?: Record>; + runwayPlateHistory?: Record>>; + runwayBandHistory?: Array<{ time: string; high_temp: number; low_temp: number; avg_temp: number }>; + amos?: AmosData | null; + airportCurrent?: AirportCurrentConditions | null; + airportPrimary?: AirportCurrentConditions | null; + forecastDaily?: ForecastDay[]; + multiModelDaily?: Record; + settlementTodayObs?: ObsPoint[]; + settlementStationLabel?: string | null; + metarTodayObs?: ObsPoint[]; + airportPrimaryTodayObs?: RawObsPoint[]; +} | null; + +function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForecast { + if (!row) return null; + return { + forecastTodayHigh: null, + debPrediction: validNumber(row.deb_prediction), + localTime: row.local_time || null, + times: [], + temps: [], + modelCurves: undefined, + runwayPlateHistory: (row as any)?.runway_plate_history || undefined, + runwayBandHistory: undefined, + amos: null, + airportCurrent: null, + airportPrimary: null, + forecastDaily: [], + multiModelDaily: {}, + settlementTodayObs: row.settlement_today_obs || row.metar_context?.settlement_today_obs || undefined, + metarTodayObs: row.metar_today_obs || row.metar_context?.today_obs || row.metar_recent_obs || row.metar_context?.recent_obs || undefined, + airportPrimaryTodayObs: undefined, + }; +} + +type HourlyForecastFetchOptions = { + ignoreCache?: boolean; + resolution?: string; +}; + +function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForecast { + const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly; + if (!json || !hourlySource) return null; + return { + forecastTodayHigh: json.forecast?.today_high ?? null, + debPrediction: json.deb?.prediction ?? (json as any)?.overview?.deb_prediction ?? null, + localTime: json.local_time || null, + times: hourlySource.times || [], + temps: hourlySource.temps || [], + modelCurves: (json.models_hourly ?? (json as any)?.timeseries?.models_hourly)?.curves || undefined, + runwayPlateHistory: (json as any)?.runway_plate_history || (json.amos as any)?.runway_plate_history || undefined, + runwayBandHistory: (json as any)?.runway_band_history || undefined, + amos: json.amos || null, + airportCurrent: json.airport_current || null, + airportPrimary: json.airport_primary || null, + forecastDaily: json.forecast?.daily || [], + multiModelDaily: json.multi_model_daily || {}, + settlementTodayObs: (json as any).timeseries?.settlement_today_obs || (json as any)?.settlement_today_obs || undefined, + settlementStationLabel: (json as any)?.settlement_station?.settlement_station_label || null, + metarTodayObs: (json as any).timeseries?.metar_today_obs || (json as any)?.metar_today_obs || undefined, + airportPrimaryTodayObs: (json as any)?.official?.airport_primary_today_obs || (json as any)?.airport_primary_today_obs || undefined, + }; +} + +async function fetchHourlyForecastForCity( + city: string, + options: HourlyForecastFetchOptions = {}, +): Promise { + const resParam = options.resolution || "10m"; + const cacheKey = `${city}:${resParam}`; + + if (!options.ignoreCache) { + const cached = _hourlyCache.get(cacheKey); + if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) { + return cached.data; + } + + const sessionCached = readSessionCache(cacheKey); + if (sessionCached) { + _hourlyCache.set(cacheKey, sessionCached); + return sessionCached.data; + } + } + + const requestKey = options.ignoreCache ? `${city}:${resParam}:live` : `${city}:${resParam}`; + const pending = _hourlyRequestCache.get(requestKey); + if (pending) return pending; + + const request = fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false&resolution=${resParam}`, { + headers: { Accept: "application/json" }, + }) + .then(async (res) => { + if (!res.ok) return null; + return res.json() as Promise; + }) + .then((json) => { + const data = parseHourlyForecastFromCityDetail(json); + if (!data) return null; + _hourlyCache.set(cacheKey, { ts: Date.now(), data }); + writeSessionCache(cacheKey, data); + return data; + }) + .finally(() => { + _hourlyRequestCache.delete(requestKey); + }); + + _hourlyRequestCache.set(requestKey, request); + return request; +} + +function shouldPollLiveChart({ + city, + compact, + isActive, + isMaximized, +}: { + city: string; + compact: boolean; + isActive: boolean; + isMaximized: boolean; +}) { + return Boolean(city) && (compact || isActive || isMaximized); +} + +function getLiveObservationLabels( + row: ScanOpportunityRow | null, + hourly: HourlyForecast, +) { + const normalizedKey = normalizeCityKey(row?.city); + const runwaySensorCities = new Set([ + "beijing", "shanghai", "guangzhou", "qingdao", + "chengdu", "chongqing", "wuhan", + "seoul", "busan", + ]); + const weatherStationCities = new Set(["ankara", "istanbul"]); + const isShenzhen = normalizedKey === "shenzhen"; + const isHKO = (normalizedKey === "hongkong" || normalizedKey === "laufaushan") && !isShenzhen; + const isTokyo = normalizedKey === "tokyo"; + const isSingapore = normalizedKey === "singapore"; + const isParis = normalizedKey === "paris"; + const isTaipei = normalizedKey === "taipei"; + const sourceTokens = [ + (hourly?.airportPrimary as any)?.source, + hourly?.airportPrimary?.source_code, + hourly?.airportPrimary?.source_label, + (hourly?.airportCurrent as any)?.source, + (hourly?.airportCurrent as any)?.source_code, + (hourly?.airportCurrent as any)?.source_label, + (row as any)?.station_source_code, + (row as any)?.network_provider, + (row as any)?.network_provider_label, + row?.metar_context?.source, + row?.metar_context?.station, + row?.metar_context?.station_label, + ] + .map((value) => String(value || "").trim().toLowerCase()) + .filter(Boolean) + .join(" "); + const hasRealStationNetwork = + weatherStationCities.has(normalizedKey) || + /\b(mgm|turkey_mgm|jma_amedas|fmi|knmi|cowin_obs|ims|ncm|aeroweb|madis_hfmetar|singapore_mss)\b/.test(sourceTokens); + const isWeatherStation = !runwaySensorCities.has(normalizedKey) + && !isHKO && !isShenzhen && !isTokyo && !isSingapore && !isParis && !isTaipei + && hasRealStationNetwork; + + const runwayHeaderLabel = isShenzhen ? "天文台实测 (10分钟)" + : isHKO ? "参考站点 (1分钟)" + : isTokyo ? "机场气象站 (10分钟)" + : isSingapore ? "航站楼温度" + : isParis ? "官方机场观测 (15分钟)" + : isTaipei ? "CWA (10分钟)" + : isWeatherStation ? "气象站实测" + : "跑道实测 (1分钟)"; + + const metarHeaderLabel = (isShenzhen || isHKO) ? "天文台实测 (10分钟)" + : "METAR 结算 (30分钟)"; + + const runwayHighLabel = isShenzhen ? "天文台实测" + : isHKO ? "参考站点" + : isTokyo ? "机场气象站" + : isSingapore ? "航站楼" + : isParis ? "官方机场观测" + : isTaipei ? "CWA" + : isWeatherStation ? "气象站" + : "跑道实测"; + + const metarHighLabel = isShenzhen ? "天文台" + : isHKO ? "天文台" + : "METAR 官方"; + + return { + isHKO, + isParis, + isShenzhen, + isTaipei, + isWeatherStation, + metarHeaderLabel, + metarHighLabel, + runwayHeaderLabel, + runwayHighLabel, + }; +} + +function mergePatchIntoHourly( + prev: HourlyForecast, + patch: CityPatch, +): HourlyForecast { + const changes = patch.changes || {}; + const tempValue = validNumber(changes.temp); + const obsTime = typeof changes.obs_time === "string" ? changes.obs_time : null; + const source = typeof changes.source === "string" ? changes.source : ""; + const explicitHourlyPatch = changes.hourly && typeof changes.hourly === "object" + ? changes.hourly as Partial> + : {}; + + const next: NonNullable = { + ...(prev || { + forecastTodayHigh: null, + debPrediction: null, + localTime: null, + times: [], + temps: [], + forecastDaily: [], + multiModelDaily: {}, + }), + ...explicitHourlyPatch, + }; + + if (changes.amos && typeof changes.amos === "object") { + const oldAmos = prev?.amos || {}; + const newAmos = changes.amos as AmosData; + next.amos = { + ...oldAmos, + ...newAmos, + } as any; + } + + // Preserve runwayPlateHistory in next state + if (prev?.runwayPlateHistory) { + next.runwayPlateHistory = prev.runwayPlateHistory; + } + + // Append new runway observations to history if available in the patch + const amosChanges = changes.amos as Record | undefined; + const obsTimeVal = obsTime || amosChanges?.observation_time || amosChanges?.observation_time_local; + const runwayObs = amosChanges?.runway_obs; + const runwayPoints = Array.isArray(changes.runway_points) + ? changes.runway_points + : runwayObs && Array.isArray(runwayObs.point_temperatures) + ? runwayObs.point_temperatures + : []; + if (runwayPoints.length && obsTimeVal) { + const history: Record>> = {}; + const sourceHistory = next.runwayPlateHistory || (next.amos as any)?.runway_plate_history || {}; + + // Copy existing history points + Object.entries(sourceHistory).forEach(([rwy, pts]) => { + if (Array.isArray(pts)) { + history[rwy] = [...pts]; + } + }); + + // Append new points from point_temperatures + runwayPoints.forEach((pt: any) => { + const rwy = pt.runway || ""; + if (!rwy) return; + const tempVal = validNumber(pt.temp) ?? validNumber(pt.target_runway_max) ?? validNumber(pt.tdz_temp) ?? validNumber(pt.end_temp); + if (tempVal === null) return; + + const rwyHistory = history[rwy] || []; + const exists = rwyHistory.some((p: any) => p.timestamp === obsTimeVal || p.time === obsTimeVal || p.observed_at === obsTimeVal); + if (!exists) { + rwyHistory.push({ + timestamp: obsTimeVal, + temp_c: tempVal, + value: tempVal, + }); + history[rwy] = rwyHistory.slice(-MAX_OBS_POINTS); + } + }); + + next.runwayPlateHistory = history; + next.amos = { + ...(next.amos || {}), + runway_obs: { + ...((next.amos as any)?.runway_obs || {}), + point_temperatures: runwayPoints, + }, + } as any; + if (next.amos) { + (next.amos as any).runway_plate_history = history; + } + } + + if (tempValue !== null) { + next.airportCurrent = { + ...(next.airportCurrent || {}), + obs_time: next.airportCurrent?.obs_time ?? null, + temp: tempValue, + max_so_far: Math.max( + tempValue, + validNumber(next.airportCurrent?.max_so_far) ?? tempValue, + ), + }; + next.airportPrimary = { + ...(next.airportPrimary || {}), + obs_time: next.airportPrimary?.obs_time ?? null, + temp: tempValue, + max_so_far: Math.max( + tempValue, + validNumber(next.airportPrimary?.max_so_far) ?? tempValue, + ), + source_label: next.airportPrimary?.source_label || source || undefined, + }; + } + + if (tempValue !== null && obsTime) { + const obsPoint: RawObsPoint = [obsTime, tempValue]; + const currentObs = Array.isArray(next.airportPrimaryTodayObs) + ? next.airportPrimaryTodayObs + : []; + next.airportPrimaryTodayObs = [...currentObs, obsPoint].slice(-MAX_OBS_POINTS); + } + + return next; +} + +function parseRunwayHistoryValue(point: Record) { + return validNumber(point.max_temp_c) ?? validNumber(point.temp_c) ?? validNumber(point.temp) ?? validNumber(point.value); +} + +function parseRunwayHistoryTime( + point: Record, + tzOffset: number, + localDateStr: string, +) { + return getCityLocalUtcTimestamp( + (point.timestamp as string | number | null | undefined) ?? + (point.time as string | number | null | undefined) ?? + (point.observed_at as string | number | null | undefined), + tzOffset, + localDateStr, + ); +} + +function buildRunwayHistorySeries( + row: ScanOpportunityRow | null, + hourly: HourlyForecast, + tzOffset: number, + localDateStr: string, +): RunwayHistorySeries[] { + const directHistory = + hourly?.runwayPlateHistory ?? + ((hourly?.amos as any)?.runway_plate_history as Record>> | undefined) ?? + ((row as any)?.runway_plate_history as Record>> | undefined); + + if (directHistory && typeof directHistory === "object") { + return Object.entries(directHistory) + .map(([rwy, rawPoints], index) => { + const normalizedRwy = String(rwy || `RWY ${index + 1}`).trim(); + const points = (Array.isArray(rawPoints) ? rawPoints : []) + .map((point) => { + const ts = parseRunwayHistoryTime(point, tzOffset, localDateStr); + const value = parseRunwayHistoryValue(point); + return ts !== null && value !== null ? { ts, value } : null; + }) + .filter((point): point is { ts: number; value: number } => point !== null) + .sort((a, b) => a.ts - b.ts) + .slice(-MAX_OBS_POINTS); + const isSettlement = isSettlementRunway(row, normalizedRwy); + return { + key: runwaySeriesKey(normalizedRwy), + label: `${normalizedRwy}${isSettlement ? (row ? " 结算跑道" : " Settlement") : ""}`, + rwy: normalizedRwy, + isSettlement, + color: isSettlement ? "#009688" : RUNWAY_LINE_COLORS[index % RUNWAY_LINE_COLORS.length], + points, + }; + }) + .filter((series) => series.points.length > 1); + } + + const amos = hourly?.amos; + const runwayObs = amos?.runway_obs; + const runwayPairs = runwayObs?.runway_pairs || []; + const runwayTemps = runwayObs?.temperatures || []; + const pointTemps = runwayObs?.point_temperatures || []; + const anchor = + getCityLocalUtcTimestamp(amos?.observation_time_local || amos?.observation_time || hourly?.localTime || row?.local_time, tzOffset, localDateStr) ?? + getCityLocalUtcTimestamp(row?.local_time, tzOffset, localDateStr); + + if (!anchor || !Array.isArray(runwayTemps)) return []; + + return runwayTemps + .map((rawTemps, index) => { + if (!Array.isArray(rawTemps)) return null; + const rwy = runwayLabelFromPair(runwayPairs[index], index); + const isSettlement = isSettlementRunway(row, rwy); + const pointTemp = Array.isArray(pointTemps) ? pointTemps[index] : null; + const snapshotValues = [ + validNumber((pointTemp as any)?.tdz_temp), + validNumber((pointTemp as any)?.mid_temp), + validNumber((pointTemp as any)?.end_temp), + validNumber((pointTemp as any)?.target_runway_max), + ].filter((value): value is number => value !== null); + const samples = rawTemps.map(validNumber).filter((value): value is number => value !== null); + const valuesForLine = samples.length > 1 + ? samples + : snapshotValues.length > 1 + ? snapshotValues + : samples.length === 1 + ? [samples[0], samples[0]] + : snapshotValues.length === 1 + ? [snapshotValues[0], snapshotValues[0]] + : []; + const values = valuesForLine + .map((value, pointIndex) => { + const minutesFromEnd = (valuesForLine.length - 1 - pointIndex); + return { + ts: anchor - minutesFromEnd * 60 * 1000, + value, + }; + }) + .filter((point) => validNumber(point.value) !== null); + if (values.length <= 1) return null; + return { + key: runwaySeriesKey(rwy), + label: `${rwy}${isSettlement ? " 结算跑道" : ""}`, + rwy, + isSettlement, + color: isSettlement ? "#009688" : RUNWAY_LINE_COLORS[index % RUNWAY_LINE_COLORS.length], + points: values.slice(-MAX_OBS_POINTS), + }; + }) + .filter((series): series is RunwayHistorySeries => series !== null); +} + +function generateDailySlots(localDateStr: string, daysCount: number): string[] { + const parts = localDateStr.split("-"); + if (parts.length !== 3) return []; + const year = parseInt(parts[0], 10); + const month = parseInt(parts[1], 10) - 1; + const day = parseInt(parts[2], 10); + + const dates: string[] = []; + for (let i = 0; i < daysCount; i++) { + const d = new Date(Date.UTC(year, month, day + i)); + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + dates.push(`${yyyy}-${mm}-${dd}`); + } + return dates; +} + +function formatDailyDateLabel(dateStr: string): string { + const parts = dateStr.split("-"); + if (parts.length !== 3) return dateStr; + return `${parts[1]}/${parts[2]}`; +} + +function buildDailyChartData( + row: ScanOpportunityRow | null, + hourly: HourlyForecast, + daysCount: number, +): { data: Array>; series: EvidenceSeries[] } { + const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10); + const slots = generateDailySlots(localDateStr, daysCount); + + const series: EvidenceSeries[] = [ + { + key: "deb_prediction", + label: "DEB Daily Max", + source: "DEB", + color: "#f97316", // orange + featured: true, + values: [], + }, + { + key: "max_temp", + label: "Model Daily Max", + source: "Standard Forecast", + color: "#dc2626", // red + dashed: true, + values: [], + }, + { + key: "min_temp", + label: "Model Daily Min", + source: "Standard Forecast", + color: "#2563eb", // blue + dashed: true, + values: [], + }, + ]; + + const data = slots.map((dateStr) => { + const dayForecast = hourly?.forecastDaily?.find((d) => d.date === dateStr); + const dayMultiModel = hourly?.multiModelDaily?.[dateStr]; + + const label = formatDailyDateLabel(dateStr); + + const debMax = validNumber(dayMultiModel?.deb?.prediction) ?? + (dateStr === localDateStr ? validNumber(hourly?.debPrediction) ?? validNumber(row?.deb_prediction) : null); + const maxTemp = validNumber(dayForecast?.max_temp); + const minTemp = validNumber(dayForecast?.min_temp); + + return { + label, + date: dateStr, + deb_prediction: debMax, + max_temp: maxTemp, + min_temp: minTemp, + }; + }); + + // Populate series values + series[0].values = data.map((d) => d.deb_prediction); + series[1].values = data.map((d) => d.max_temp); + series[2].values = data.map((d) => d.min_temp); + + // Filter out series that have no valid data points + const activeSeries = series.filter((s) => s.values.some((v) => v !== null)); + + return { data, series: activeSeries }; +} + +function binBandObservationsToSlots( + slots: number[], + obs: TemperatureBandPoint[], +): Array<[number, number] | null> { + const result: Array<[number, number] | null> = new Array(slots.length).fill(null); + for (const point of obs) { + for (let i = slots.length - 1; i >= 0; i--) { + if (point.ts >= slots[i]) { + result[i] = [point.low, point.high]; + break; + } + } + } + return result; +} + +function sortedTimeline(timestamps: Iterable) { + return Array.from(new Set(Array.from(timestamps).filter((ts) => Number.isFinite(ts)))).sort((a, b) => a - b); +} + +function resolveFullDayFallbackAnchor( + row: ScanOpportunityRow | null, + hourly: HourlyForecast, + tzOffsetSeconds: number, + localDateStr: string, +) { + return ( + getCityLocalUtcTimestamp(hourly?.localTime || row?.local_time, tzOffsetSeconds, localDateStr) ?? + Date.UTC( + Number(localDateStr.slice(0, 4)) || new Date().getUTCFullYear(), + (Number(localDateStr.slice(5, 7)) || 1) - 1, + Number(localDateStr.slice(8, 10)) || new Date().getUTCDate(), + 12, + 0, + 0, + ) + ); +} + +function ensureRenderableTimeline(timeline: number[], fallbackAnchor: number) { + if (timeline.length >= 2) return timeline; + const anchor = timeline[0] ?? fallbackAnchor; + return [anchor - 30 * 60 * 1000, anchor]; +} + +function buildTimelineIndex(timeline: number[]) { + return new Map(timeline.map((ts, index) => [ts, index])); +} + +function valuesAtTimeline( + size: number, + indexByTs: Map, + obs: Array<{ ts: number; value: number }>, +) { + const result: Array = new Array(size).fill(null); + obs.forEach((point) => { + const idx = indexByTs.get(point.ts); + if (idx !== undefined) result[idx] = point.value; + }); + return result; +} + +function bandValuesAtTimeline( + size: number, + indexByTs: Map, + obs: TemperatureBandPoint[], +) { + const result: Array<[number, number] | null> = new Array(size).fill(null); + obs.forEach((point) => { + const idx = indexByTs.get(point.ts); + if (idx !== undefined) result[idx] = [point.low, point.high]; + }); + return result; +} + +function valuesForHourlyTimes( + size: number, + indexByTs: Map, + times: string[] | undefined, + values: Array, + tzOffsetSeconds: number, + localDateStr: string, +) { + const result: Array = new Array(size).fill(null); + (times || []).forEach((time, index) => { + const ts = getCityLocalUtcTimestamp(time, tzOffsetSeconds, localDateStr); + if (ts === null) return; + const value = validNumber(values[index]); + if (value === null) return; + const idx = indexByTs.get(ts); + if (idx !== undefined) result[idx] = value; + }); + return result; +} + +function addHourlyTimesToTimeline( + timeline: Set, + times: string[] | undefined, + values: Array | undefined, + tzOffsetSeconds: number, + localDateStr: string, +) { + if (!times?.length || !values?.length) return; + times.forEach((time, index) => { + if (validNumber(values[index]) === null) return; + const ts = getCityLocalUtcTimestamp(time, tzOffsetSeconds, localDateStr); + if (ts !== null) timeline.add(ts); + }); +} + +function buildFullDayChartData( + row: ScanOpportunityRow | null, + hourly: HourlyForecast, + isEn: boolean, +): { data: Array>; series: EvidenceSeries[] } { + const tzOffset = row?.tz_offset_seconds ?? 0; + const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10); + + const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset); + const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset); + const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset); + const runwayHistorySeries = buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr); + + const settlementCityKey = normalizeCityKey(row?.city); + const isShenzhen = settlementCityKey === 'shenzhen'; + const isHKO = (settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan' + || (row?.city || '').toLowerCase().includes('hong kong') + || (row?.city || '').toLowerCase().includes('lau fau shan')) && !isShenzhen; + + let finalSettlementObs = settlementObs; + let finalMadisObs = madisObs; + if (isHKO) { + finalSettlementObs = madisObs; + finalMadisObs = settlementObs; + } + + // ── Runway band & max series ── + const normBandObs: TemperatureBandPoint[] = (hourly?.runwayBandHistory || []).map((pt) => { + try { + const ts = getCityLocalUtcTimestamp(pt.time, tzOffset, localDateStr); + if (ts === null) return null; + return { + ts, + high: pt.high_temp, + low: pt.low_temp, + avg: pt.avg_temp + }; + } catch { + return null; + } + }).filter((v): v is NonNullable => v !== null); + + const isHKOCity = settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan' + || settlementCityKey === 'shenzhen' || (row?.city || '').toLowerCase().includes('hong kong') + || (row?.city || '').toLowerCase().includes('lau fau shan'); + const isAmscSource = + (hourly?.airportPrimary as any)?.source === "amsc_awos" || + String(hourly?.airportPrimary?.source_label || "").toLowerCase().includes("amsc"); + const shouldRenderMetar = metarObs.length > 0 && !observationSetContains(finalMadisObs, metarObs); + + const timelineSet = new Set(); + runwayHistorySeries.forEach((rhs) => rhs.points.forEach((point) => timelineSet.add(point.ts))); + normBandObs.forEach((point) => timelineSet.add(point.ts)); + finalSettlementObs.forEach((point) => timelineSet.add(point.ts)); + if (!isAmscSource) finalMadisObs.forEach((point) => timelineSet.add(point.ts)); + if (shouldRenderMetar) metarObs.forEach((point) => timelineSet.add(point.ts)); + + let debPath: ReturnType | null = null; + if (hourly?.times?.length && hourly?.temps?.length) { + debPath = buildDebBaselinePath( + hourly.times, + hourly.temps, + validNumber(hourly?.debPrediction) ?? row?.deb_prediction, + hourly.localTime || row?.local_time, + hourly.forecastTodayHigh, + ); + addHourlyTimesToTimeline(timelineSet, hourly.times, debPath.debTemps, tzOffset, localDateStr); + } + if (hourly?.times?.length && hourly?.modelCurves) { + Object.values(hourly.modelCurves).forEach((modelTemps) => { + addHourlyTimesToTimeline(timelineSet, hourly.times, modelTemps, tzOffset, localDateStr); + }); + } + + const fallbackAnchor = resolveFullDayFallbackAnchor(row, hourly, tzOffset, localDateStr); + const timeline = ensureRenderableTimeline(sortedTimeline(timelineSet), fallbackAnchor); + const n = timeline.length; + const indexByTs = buildTimelineIndex(timeline); + const series: EvidenceSeries[] = []; + + // ── Runway history series ── + runwayHistorySeries.forEach((rhs) => { + const values = valuesAtTimeline(n, indexByTs, rhs.points); + if (!values.some((v) => v !== null)) return; + series.push({ + key: rhs.key, + label: rhs.label, + source: "", + color: rhs.color, + featured: rhs.isSettlement, + dashed: !rhs.isSettlement, + curve: "monotone", + showDot: rhs.isSettlement, + values, + }); + }); + + const bandVals = bandValuesAtTimeline(n, indexByTs, normBandObs); + const maxVals = bandVals.map((val) => val ? val[1] : null); + if (maxVals.some((v) => v !== null)) { + series.push({ + key: "runway_max", + label: isEn ? "Runway Max" : "跑道最高温", + source: "Runway Max", + color: "#009688", + featured: true, + values: maxVals, + }); + } + + // ── Settlement observations ── + if (finalSettlementObs.length) { + const svals = valuesAtTimeline(n, indexByTs, finalSettlementObs); + if (svals.some((v) => v !== null)) { + series.push({ + key: "settlement", + label: isHKO ? "CoWIN 6087" : (isHKOCity ? "HKO" : (hourly?.settlementStationLabel || row?.metar_context?.station_label || row?.metar_context?.station || "Settlement")), + source: isHKO ? "cowin_obs" : (row?.metar_context?.station || row?.airport || "Settlement"), + color: "#009688", + featured: true, + values: svals, + }); + } + } + + // ── Airport Primary (MADIS / AMSC AWOS) ── + // Skip this series for AMSC AWOS cities — their data is redundant with + // runway sensor data and adds a confusing "AMSC AWOS" label to the chart. + if (finalMadisObs.length && !isAmscSource) { + const madisVals = valuesAtTimeline(n, indexByTs, finalMadisObs); + if (madisVals.some((v) => v !== null)) { + series.push({ + key: "madis", + label: isHKO ? "HKO" : (hourly?.airportPrimary?.source_label || "NOAA MADIS"), + source: isHKO ? "HKO" : (hourly?.airportPrimary?.station_code || row?.airport || "MADIS"), + color: "#0284c7", + dashed: isHKO ? true : false, + values: madisVals, + }); + } + } + + if (shouldRenderMetar) { + const mvals = valuesAtTimeline(n, indexByTs, metarObs); + if (mvals.some((v) => v !== null)) { + series.push({ + key: "metar", + label: isHKO ? "VHHH METAR" : (isHKOCity ? "HKO" : (row?.metar_context?.station_label || "METAR")), + source: row?.airport || "METAR", + color: "#0ea5e9", + dashed: true, + curve: "stepAfter", + showDot: true, + values: mvals, + }); + } + } + + // ── DEB forecast curve ── + if (hourly?.times?.length && debPath?.debTemps.length) { + const debVals = valuesForHourlyTimes(n, indexByTs, hourly.times, debPath.debTemps, tzOffset, localDateStr); + if (debVals.some((v) => v !== null)) { + series.push({ + key: "hourly_forecast", + label: "DEB Forecast", + source: "DEB Hourly", + color: "#f97316", + featured: true, + smooth: true, + values: debVals, + }); + } + + // Per-model curves + if (hourly.modelCurves) { + const modelColors = ["#2563eb", "#7c3aed", "#059669", "#d97706", "#dc2626", "#0891b2"]; + Object.keys(hourly.modelCurves).forEach((model, idx) => { + const modelTemps = hourly.modelCurves![model]; + if (!modelTemps?.length) return; + const vals = valuesForHourlyTimes(n, indexByTs, hourly.times, modelTemps, tzOffset, localDateStr); + if (vals.some((v) => v !== null)) { + series.push({ + key: `model_curve_${model}`, + label: model, + source: "Multi-model hourly", + color: modelColors[idx % modelColors.length], + dashed: true, + smooth: true, + values: vals, + }); + } + }); + } + } + + // ── Fallback ── + if (!hasRenderableLineSeries(series)) { + const fb = + validNumber(hourly?.airportCurrent?.temp) ?? + validNumber(hourly?.airportPrimary?.temp) ?? + latestObservationValue(finalMadisObs) ?? + latestObservationValue(finalSettlementObs) ?? + latestObservationValue(metarObs) ?? + validNumber(row?.current_temp) ?? + validNumber(hourly?.debPrediction) ?? + validNumber(row?.deb_prediction) ?? + validNumber(row?.target_threshold); + if (fb !== null) { + series.push({ + key: "current", + label: "Current reference", + source: row?.metar_context?.source || "Live", + color: "#009688", + featured: true, + values: Array.from({ length: n }, () => fb), + }); + } + } + + // ── Build data rows ── + const data = timeline.map((ts, i) => { + const point: Record = { + label: formatTimestamp(ts), + ts, + runway_band: bandVals[i] ?? null, + }; + series.forEach((s) => { point[s.key] = s.values[i] ?? null; }); + return point; + }); + + return { data, series }; +} + +// ── Model summary cards (daily high point predictions) ───────────────── + +function buildModelSummaryCards(row: ScanOpportunityRow | null): EvidenceSeries[] { + return Object.entries(row?.model_cluster_sources || {}) + .map(([label, value]) => [label, validNumber(value)] as const) + .filter((entry): entry is readonly [string, number] => entry[1] !== null) + .slice(0, 4) + .map(([label, value], index) => ({ + key: `model_summary_${index}`, + label, + source: "Multi-model daily high", + color: ["#2563eb", "#14b8a6", "#7c3aed", "#64748b"][index] || "#64748b", + dashed: true, + values: [value], + })); +} + +// ── Integer-degree ticks for Y-axis ────────────────────────────────── + +function buildIntDegreeTicks(series: EvidenceSeries[], data?: Array>): number[] | null { + const vals = data?.length + ? data.flatMap((point) => series.map((s) => point[s.key])).filter((v): v is number => validNumber(v) !== null) + : series.flatMap((s) => s.values).filter((v): v is number => validNumber(v) !== null); + if (!vals.length) return null; + const min = Math.floor(Math.min(...vals)); + const max = Math.ceil(Math.max(...vals)); + const ticks: number[] = []; + for (let d = min; d <= max; d++) ticks.push(d); + return ticks.length > 0 ? ticks : null; +} + +function buildChartDomain( + series: EvidenceSeries[], + data?: Array>, +): [number, number] | ["auto", "auto"] { + const vals = data?.length + ? data.flatMap((point) => series.map((s) => point[s.key])).filter((v): v is number => validNumber(v) !== null) + : series.flatMap((s) => s.values).filter((v): v is number => validNumber(v) !== null); + if (!vals.length) return ["auto", "auto"]; + const min = Math.min(...vals); + const max = Math.max(...vals); + const span = Math.max(1, max - min); + const pad = Math.max(0.5, span * 0.08); + return [Number((min - pad).toFixed(1)), Number((max + pad).toFixed(1))]; +} + +function getDebPeakWindowRange( + data: Array>, + series: EvidenceSeries[], +): [number, number] | null { + const debSeries = series.find((item) => item.key === "hourly_forecast"); + if (!debSeries || data.length < 2) return null; + + const debPoints = debSeries.values + .map((value, index) => { + const ts = typeof data[index]?.ts === "number" ? data[index].ts : null; + const temp = validNumber(value); + return ts === null || temp === null ? null : { index, ts, temp }; + }) + .filter((point): point is { index: number; ts: number; temp: number } => point !== null); + + if (debPoints.length < 2) return null; + + const peak = debPoints.reduce((best, point) => (point.temp > best.temp ? point : best), debPoints[0]); + const peakPointIndex = debPoints.findIndex((point) => point.index === peak.index); + if (peakPointIndex < 0) return null; + + const hotThreshold = peak.temp - 2; + let hotStartPoint = peakPointIndex; + let hotEndPoint = peakPointIndex; + while (hotStartPoint > 0 && debPoints[hotStartPoint - 1].temp >= hotThreshold) { + hotStartPoint -= 1; + } + while (hotEndPoint < debPoints.length - 1 && debPoints[hotEndPoint + 1].temp >= hotThreshold) { + hotEndPoint += 1; + } + + const hour = 60 * 60 * 1000; + const targetSpan = 8 * hour; + const minSpan = 6 * hour; + const maxSpan = 12 * hour; + const firstTs = data.find((point) => typeof point.ts === "number")?.ts; + const lastTs = [...data].reverse().find((point) => typeof point.ts === "number")?.ts; + if (typeof firstTs !== "number" || typeof lastTs !== "number" || lastTs <= firstTs) return null; + + let startTs = debPoints[hotStartPoint].ts - 1.5 * hour; + let endTs = debPoints[hotEndPoint].ts + 2 * hour; + const centerTs = peak.ts; + + if (endTs - startTs < targetSpan) { + startTs = centerTs - targetSpan / 2; + endTs = centerTs + targetSpan / 2; + } + if (endTs - startTs > maxSpan) { + startTs = centerTs - maxSpan / 2; + endTs = centerTs + maxSpan / 2; + } + + if (startTs < firstTs) { + endTs = Math.min(lastTs, endTs + firstTs - startTs); + startTs = firstTs; + } + if (endTs > lastTs) { + startTs = Math.max(firstTs, startTs - (endTs - lastTs)); + endTs = lastTs; + } + if (endTs - startTs < minSpan && lastTs - firstTs >= minSpan) { + const missing = minSpan - (endTs - startTs); + startTs = Math.max(firstTs, startTs - missing / 2); + endTs = Math.min(lastTs, endTs + missing / 2); + } + + const startIndex = data.findIndex((point) => typeof point.ts === "number" && point.ts >= startTs); + let endIndex = -1; + for (let index = data.length - 1; index >= 0; index -= 1) { + if (typeof data[index]?.ts === "number" && data[index].ts <= endTs) { + endIndex = index; + break; + } + } + + return startIndex >= 0 && endIndex > startIndex ? [startIndex, endIndex] : null; +} + +function binObservationsToSlots( + slots: number[], + obs: Array<{ ts: number; value: number }>, +): Array { + const result: Array = new Array(slots.length).fill(null); + for (const point of obs) { + for (let i = slots.length - 1; i >= 0; i--) { + if (point.ts >= slots[i]) { + result[i] = point.value; + break; + } + } + } + return result; +} + +export { + HOURLY_CACHE_TTL_MS, + _hourlyCache, + buildChartDomain, + buildFullDayChartData, + getDebPeakWindowRange, + buildIntDegreeTicks, + buildModelSummaryCards, + buildRunwayPlates, + fetchHourlyForecastForCity, + getActiveTemperatureSeries, + getLiveObservationLabels, + getObservationDisplayMetrics, + getVisibleTemperatureSeries, + isTemperatureSeriesVisibleByDefault, + mergePatchIntoHourly, + normObs, + normalizeCityKey, + readSessionCache, + seedHourlyForecastFromRow, + seriesStats, + shouldPollLiveChart, + validNumber, +}; + +export type { EvidenceSeries, HourlyForecast };