"use client"; import clsx from "clsx"; import { useEffect, useMemo, useRef, useState } from "react"; import { CartesianGrid, Line, LineChart as ReLineChart, ReferenceLine, ResponsiveContainer, Tooltip, 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, type CityPatch } 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; 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 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 = 30; const FULL_DAY_SLOTS = 48; 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>>; amos?: AmosData | null; airportCurrent?: AirportCurrentConditions | null; airportPrimary?: AirportCurrentConditions | null; forecastDaily?: ForecastDay[]; multiModelDaily?: Record; settlementTodayObs?: ObsPoint[]; 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, 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; }; 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, 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, 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 { if (!options.ignoreCache) { const cached = _hourlyCache.get(city); if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) { return cached.data; } const sessionCached = readSessionCache(city); if (sessionCached) { _hourlyCache.set(city, sessionCached); return sessionCached.data; } } const requestKey = options.ignoreCache ? `${city}:live` : city; const pending = _hourlyRequestCache.get(requestKey); if (pending) return pending; const request = fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false`, { 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(city, { ts: Date.now(), data }); writeSessionCache(city, 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 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") { next.amos = changes.amos as AmosData; } 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 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, ): { 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); // 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 ); 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, }; 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 buildFullDayChartData( row: ScanOpportunityRow | null, hourly: HourlyForecast, ): { 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, }); }); // ── 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" : (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, }; 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({ isEn, row, allRows = [], compact = false, onSearchClick, onMaximize, onClose, isMaximized = false, disableClose = false, isActive = !compact, slotIndex = 0, }: { isEn: boolean; row: ScanOpportunityRow | null; allRows?: ScanOpportunityRow[]; compact?: boolean; onSearchClick?: () => void; onMaximize?: () => void; onClose?: () => void; isMaximized?: boolean; disableClose?: boolean; isActive?: boolean; slotIndex?: number; }) { const [hourly, setHourly] = useState(null); const city = String(row?.city || "").toLowerCase().trim(); const latestPatch = useLatestPatch(city); const [timeframe, setTimeframe] = useState<"1D" | "3D">("1D"); const [userToggledKeys, setUserToggledKeys] = useState>({}); const [liveTemp, setLiveTemp] = useState(null); const lastPatchAtRef = useRef(Date.now()); const lastAppliedPatchRevisionRef = useRef(0); useEffect(() => { setUserToggledKeys({}); lastPatchAtRef.current = Date.now(); lastAppliedPatchRevisionRef.current = 0; }, [city, timeframe]); useEffect(() => { if (!city) return; // Check in-memory cache first let cached = _hourlyCache.get(city); if (!cached) { // Fallback to session cache const sessionEntry = readSessionCache(city); if (sessionEntry) { cached = sessionEntry; _hourlyCache.set(city, sessionEntry); } } if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) { setHourly(cached.data); return; } setHourly(seedHourlyForecastFromRow(row)); let cancelled = false; // Prioritize active slots, stagger/delay background slots to optimize load performance const delay = isActive ? 0 : (slotIndex ? 300 + slotIndex * 250 : 350); const timer = setTimeout(() => { fetchHourlyForecastForCity(city) .then((data) => { if (cancelled || !data) return; setHourly(data); }) .catch(() => {}); }, delay); return () => { cancelled = true; clearTimeout(timer); }; }, [city, row, isActive, slotIndex]); useEffect(() => { if (!latestPatch || latestPatch.revision <= lastAppliedPatchRevisionRef.current) return; lastAppliedPatchRevisionRef.current = latestPatch.revision; lastPatchAtRef.current = Date.now(); const tempValue = validNumber(latestPatch.changes.temp); if (tempValue !== null) setLiveTemp(tempValue); setHourly((prev) => mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(row), latestPatch)); }, [latestPatch, row]); // ── SSE fallback: only full-fetch if a visible chart has seen no patch for 2 minutes ── useEffect(() => { if (!shouldPollLiveChart({ city, compact, isActive, isMaximized })) return; let cancelled = false; const refreshFullDetail = () => { lastPatchAtRef.current = Date.now(); fetchHourlyForecastForCity(city, { ignoreCache: true }) .then((data) => { if (cancelled || !data) return; setHourly(data); }) .catch(() => {}); }; const checkFallback = () => { if (typeof document !== "undefined" && document.visibilityState === "hidden") return; if (Date.now() - lastPatchAtRef.current < 2 * 60_000) return; fetch(`/api/city/${encodeURIComponent(city)}/summary`) .then((res) => (res.ok ? res.json() : null)) .then((payload) => { if (cancelled || !payload) return; const temp = validNumber(payload?.current?.temp); if (temp !== null) setLiveTemp(temp); }) .catch(() => {}); refreshFullDetail(); }; const id = setInterval(checkFallback, 60_000); return () => { cancelled = true; clearInterval(id); }; }, [city, compact, isActive, isMaximized]); const { data, series } = useMemo(() => { if (timeframe === "3D") { return build3DayChartData(row, hourly); } return buildFullDayChartData(row, hourly); }, [row, hourly, timeframe]); const tzOffset = row?.tz_offset_seconds ?? 0; const settlementObs = useMemo(() => { let obs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset); if (!obs.length && !hourly?.runwayPlateHistory) { const mObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs, tzOffset); if (mObs.length > 0) { obs = mObs; } } return obs; }, [row, hourly, tzOffset]); const runwayPlates = useMemo(() => buildRunwayPlates(hourly?.amos, row, settlementObs), [hourly?.amos, row, settlementObs]); const hasRunwayData = runwayPlates.length > 0; const settlementPlate = useMemo(() => runwayPlates.find((p) => p.isSettlement), [runwayPlates]); const chartSeries = useMemo(() => { return series; }, [series]); const isSeriesVisible = (sKey: string) => { if (userToggledKeys[sKey] !== undefined) { return userToggledKeys[sKey]; } return isTemperatureSeriesVisibleByDefault(city, sKey); }; const activeSeries = useMemo(() => { return getVisibleTemperatureSeries(city, chartSeries, userToggledKeys); }, [chartSeries, userToggledKeys, city]); 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 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 && hasRealStationNetwork; const runwayHeaderLabel = isShenzhen ? '天文台实测 (10分钟)' : isHKO ? '参考站点 (1分钟)' : isTokyo ? '机场气象站 (10分钟)' : isSingapore ? '航站楼温度' : isParis ? '官方机场观测 (15分钟)' : isWeatherStation ? '气象站实测' : '跑道实测 (1分钟)'; const metarHeaderLabel = (isShenzhen || isHKO) ? '天文台实测 (10分钟)' : 'METAR 结算 (30分钟)'; const runwayHighLabel = isShenzhen ? '天文台实测' : isHKO ? '参考站点' : isTokyo ? '机场气象站' : isSingapore ? '航站楼' : isParis ? '官方机场观测' : isWeatherStation ? '气象站' : '跑道实测'; const metarHighLabel = isShenzhen ? '天文台' : isHKO ? '天文台' : 'METAR 官方'; const { currentRunwayTemp, observedHighMetar, observedHighRunway } = useMemo( () => getObservationDisplayMetrics(row, hourly, settlementPlate), [row, hourly, settlementPlate], ); const displayRunwayTemp = liveTemp ?? currentRunwayTemp; const wundergroundDailyHigh = validNumber(hourly?.airportCurrent?.max_so_far ?? hourly?.airportPrimary?.max_so_far) ?? null; const modelValues = Object.values(row?.model_cluster_sources || {}) .map(validNumber) .filter((v): v is number => v !== null); const modelMin = modelValues.length ? Math.min(...modelValues) : (row?.cluster_core_low ?? null); const modelMax = modelValues.length ? Math.max(...modelValues) : (row?.cluster_core_high ?? null); const debVal = validNumber(hourly?.debPrediction) ?? validNumber(row?.deb_prediction) ?? null; const spread = (modelMax !== null && modelMin !== null) ? modelMax - modelMin : null; const spreadLabel = spread === null ? "" : (spread <= 2.0 ? "低分歧" : (spread <= 4.0 ? "中等分歧" : "高分歧")); const spreadLabelEn = spread === null ? "" : (spread <= 2.0 ? "Low" : (spread <= 4.0 ? "Medium" : "High")); const formattedUpdateTime = useMemo(() => { if (row?.local_date && row?.local_time) { return `${row.local_date} ${row.local_time.slice(0, 8)}`; } const d = new Date(); return d.toISOString().replace('T', ' ').slice(0, 19); }, [row]); const cityThresholds = useMemo(() => { if (!row || !allRows || !allRows.length) return []; const cityKey = String(row.city || "").toLowerCase().trim(); const sameCityRows = allRows.filter( (r) => String(r.city || "").toLowerCase().trim() === cityKey ); const seen = new Set(); const list: { threshold: number; label: string; isBreached: boolean; kind: "gte" | "lte" }[] = []; sameCityRows.forEach((r) => { const t = Number(r.target_threshold ?? r.target_value ?? r.target_lower ?? r.target_upper); if (!Number.isFinite(t) || seen.has(t)) return; seen.add(t); const maxTemp = Number(r.current_max_so_far ?? r.current_temp ?? 0); const q = String(r.market_question || r.target_label || "").toLowerCase(); const kind: "gte" | "lte" = q.includes("below") || q.includes("under") || q.includes("lte") ? "lte" : "gte"; const isBreached = kind === "lte" ? maxTemp > t : maxTemp >= t; list.push({ threshold: t, label: r.target_label || `${t}°C`, isBreached, kind, }); }); return list.sort((a, b) => a.threshold - b.threshold); }, [row, allRows]); const intDegreeTicks = useMemo(() => buildIntDegreeTicks(activeSeries, data), [activeSeries, data]); const chartDomain = useMemo( () => buildChartDomain(activeSeries, data), [activeSeries, data], ); const subtitle = row ? isEn ? timeframe === "1D" ? "Live & Forecast" : `${timeframe} Forecast` : timeframe === "1D" ? "实测与预测" : `${timeframe}预报` : ""; const panelTitle = row ? (
· {subtitle}
) : isEn ? ( "Temperature Chart" ) : ( "气温图表" ); const timeframeActions = (
{(["1D", "3D"] as const).map((tf) => ( ))}
{(onMaximize || onClose) && (
{onMaximize && ( )} {onClose && ( )}
)}
); return (
{/* Compact stats bar */} {compact ? (
{timeframe === "1D" ? (
{isEn ? "Runway" : runwayHeaderLabel}:{" "} {temp(displayRunwayTemp)} | {isEn ? "METAR" : (isShenzhen ? "当日最高" : metarHeaderLabel)}:{" "} {temp(observedHighMetar)}
) : (
DEB: {temp(debVal)} {modelMin !== null && modelMax !== null && ( <> | {isEn ? "Models" : "多模型"}:{" "} {temp(modelMin)} - {temp(modelMax)} )}
)}
{timeframe === "1D" && formattedUpdateTime.includes(" ") ? formattedUpdateTime.split(" ")[1].slice(0, 5) : ""}
) : ( /* Normal detailed stats bar */
{/* Top Row: Large temperatures */}
{timeframe === "1D" ? (
{isEn ? "Runway Live (1m)" : `${runwayHeaderLabel}`} {temp(displayRunwayTemp)}
{isEn ? "METAR Settlement (30m) · Daily High" : `${metarHeaderLabel} · 当日最高`} {temp(observedHighMetar)}
) : (
DEB Max {temp(debVal)}
{isEn ? "Model Range" : "多模型区间"} {modelMin !== null && modelMax !== null ? `${temp(modelMin)} - ${temp(modelMax)}` : "--"}
)}
{isEn ? "Daily Peak" : "当日最高气温"}
{isEn ? "Runway" : runwayHighLabel}: {temp(observedHighRunway)} | {isEn ? "METAR" : metarHighLabel}: {temp(observedHighMetar)} {wundergroundDailyHigh !== null && ( <> | WU: {temp(wundergroundDailyHigh)} )}
{/* Bottom Row: Model Range Panel (Only for 1D mode) */} {timeframe === "1D" && (
{isEn ? "Model Range" : "模型区间"} {modelMin !== null && modelMax !== null ? `${temp(modelMin)} - ${temp(modelMax)}` : "--"}
DEB {temp(debVal)}
{isEn ? "Spread" : "分歧"} {spread !== null ? `${spread.toFixed(1)}°C` : "--"} {spreadLabel && ` · ${isEn ? spreadLabelEn : spreadLabel}`}
{isEn ? "Updated" : "更新时间"} {formattedUpdateTime}
)}
)} {/* Runway observations (Only for 1D mode and when not compact) */} {timeframe === "1D" && !compact && runwayPlates.length > 0 && (
{isEn ? "Runway Observations" : "跑道观测"} {runwayPlates.some((p) => p.trend_15m !== null && p.trend_15m > 0 && !p.isSettlement) && ( {isEn ? "Non-settlement Runway Warming Alert" : "非结算跑道升温提醒"} )}
{runwayPlates.map((plate) => (
{plate.isSettlement && } {plate.rwy} {plate.isSettlement && ( {isEn ? "Settlement" : "结算"} )}
TDZ: {plate.tdzTemp !== null ? `${plate.tdzTemp.toFixed(1)}°C` : "--"}
MID: {plate.midTemp !== null ? `${plate.midTemp.toFixed(1)}°C` : "--"}
END: {plate.endTemp !== null ? `${plate.endTemp.toFixed(1)}°C` : "--"}
max: {plate.maxTemp !== null ? `${plate.maxTemp.toFixed(1)}°C` : "--"}
high: {plate.dailyHigh !== null ? `${plate.dailyHigh.toFixed(1)}°C` : "--"}
0 ? "text-orange-600 font-bold" : "text-slate-500")}> 15m: {plate.trend_15m !== null ? `${plate.trend_15m >= 0 ? "+" : ""}${plate.trend_15m.toFixed(1)}°C` : "--"}
))}
)} {/* Multi-model list (Only in 1D mode and when not compact) */} {timeframe === "1D" && !compact && hasRunwayData && activeSeries.some((s) => s.key.startsWith("model_curve_")) && (
{isEn ? "Models:" : "多模型:"} {activeSeries .filter((s) => s.key.startsWith("model_curve_")) .map((s) => { const stats = seriesStats(s.values); return ( {s.label} {temp(stats.latest)} ); })}
)} {/* Chart */}
{/* Interactive legend */}
{chartSeries.length > 1 && chartSeries.map((s) => ( ))}
`${Number(v).toFixed(0)}°`} axisLine={{ stroke: "#cbd5e1" }} tickLine={false} domain={chartDomain} ticks={intDegreeTicks ?? undefined} /> {timeframe === "1D" && cityThresholds.map((t, idx) => { const isSelected = row && (Number(row.target_threshold ?? row.target_value) === t.threshold); const labelText = isEn ? `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}° [${t.isBreached ? "Excluded" : "Active"}]` : `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}° [${t.isBreached ? "已排除" : "活跃"}]`; return ( ); })} `${Number(value).toFixed(2)}°`} /> {activeSeries.map((item) => ( ))}
); } export function __buildTemperatureChartDataForTest( row: ScanOpportunityRow | null, hourly: HourlyForecast, timeframe: "1D" | "3D" = "1D", ) { return timeframe === "3D" ? build3DayChartData(row, hourly) : buildFullDayChartData(row, hourly); } export const __isTemperatureSeriesVisibleByDefaultForTest = isTemperatureSeriesVisibleByDefault; export const __getVisibleTemperatureSeriesForTest = getVisibleTemperatureSeries; export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics; export const __shouldPollLiveChartForTest = shouldPollLiveChart;