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 };