"use client"; import clsx from "clsx"; import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { ExternalLink } from "lucide-react"; import { CartesianGrid, Legend, Line, LineChart as ReLineChart, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import type { AmosData, AirportCurrentConditions, CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types"; import { buildDebBaselinePath } from "@/lib/temperature-chart-paths"; import { Panel } from "@/components/dashboard/scan-terminal/Panel"; import { rowName, temp } from "@/components/dashboard/scan-terminal/utils"; const SETTLEMENT_RUNWAY_PAIRS: Record> = { shanghai: [["17L", "35R"]], beijing: [["01", "19"]], guangzhou: [["02L", "20R"]], chengdu: [["02L", "20R"]], chongqing: [["02L", "20R"]], 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 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 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 }>; }; // Sliding window: keep at most this many observation points (24h at 1-min ≈ 1440) const MAX_OBS_POINTS = 1440; const HOURLY_CACHE_TTL_MS = 30 * 60 * 1000; const ROLLING_WINDOW_BEFORE_MS = 6 * 60 * 60 * 1000; const ROLLING_WINDOW_AFTER_LIVE_MS = 45 * 60 * 1000; const ROLLING_WINDOW_AFTER_FORECAST_MS = 6 * 60 * 60 * 1000; const _hourlyCache = new Map(); const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"]; 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 normObs(points: ObsPoint[] | null | undefined, tzOffsetSeconds: number, limit = MAX_OBS_POINTS) { return (points || []) .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 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; localTime?: string | null; times: string[]; temps: Array; modelCurves?: Record>; runwayPlateHistory?: Record>>; amos?: AmosData | null; airportCurrent?: AirportCurrentConditions | null; airportPrimary?: AirportCurrentConditions | null; } | null; 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: `runway_${index}`, 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 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) || rawTemps.length <= 2) return null; const rwy = runwayLabelFromPair(runwayPairs[index], index); const isSettlement = isSettlementRunway(row, rwy); const values = rawTemps .map(validNumber) .map((value, pointIndex) => { if (value === null) return null; const minutesFromEnd = rawTemps.length - 1 - pointIndex; return { ts: anchor - minutesFromEnd * 60 * 1000, value, }; }) .filter((point): point is { ts: number; value: number } => point !== null); if (values.length <= 1) return null; return { key: `runway_${index}`, 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); } // ── Build aligned data rows for the sliding-window chart ──────────────── function buildSlidingChartData( row: ScanOpportunityRow | null, hourly: HourlyForecast, ) { const tzOffset = row?.tz_offset_seconds ?? 0; const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10); const settlementObs = normObs(row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset); const metarObs = normObs(row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset); const runwayHistorySeries = buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr); // Collect all timestamps from observations + forecasts const allTimes = new Set(); const pushObs = (obs: ReturnType) => { obs.forEach((o) => allTimes.add(o.ts)); }; pushObs(settlementObs); pushObs(metarObs); runwayHistorySeries.forEach((item) => { item.points.forEach((point) => allTimes.add(point.ts)); }); // Forecast timestamps const forecastTimes: number[] = []; if (hourly?.times?.length && hourly?.temps?.length) { hourly.times.forEach((t, i) => { const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr); if (ts !== null && i < hourly.temps.length) { allTimes.add(ts); forecastTimes.push(ts); } }); } // Sort timestamps const sorted = [...allTimes].sort((a, b) => a - b); if (!sorted.length) return { data: [], series: [] }; // Build a lookup: timestamp → index in the sorted array const tsToIdx = new Map(); sorted.forEach((ts, i) => tsToIdx.set(ts, i)); const n = sorted.length; const na = (): Array => Array.from({ length: n }, () => null); const series: EvidenceSeries[] = []; runwayHistorySeries.forEach((item) => { const vals = na(); item.points.forEach((o) => { const idx = tsToIdx.get(o.ts); if (idx !== undefined) vals[idx] = o.value; }); if (vals.some((v) => v !== null)) { series.push({ key: item.key, label: item.label, source: "Runway", color: item.color, dashed: !item.isSettlement, featured: item.isSettlement, curve: "monotone", connectNulls: true, showDot: item.isSettlement, values: vals, }); } }); // Settlement const sVals = na(); settlementObs.forEach((o) => { const idx = tsToIdx.get(o.ts); if (idx !== undefined) sVals[idx] = o.value; }); if (!runwayHistorySeries.length && sVals.some((v) => v !== null)) { const cityKey = String(row?.city || "").toLowerCase().trim(); const runwaySensorCities = new Set([ 'beijing', 'shanghai', 'guangzhou', 'shenzhen', 'qingdao', 'chengdu', 'chongqing', 'wuhan', // AMSC runway sensors 'seoul', 'busan', // AMOS runway sensors ]); const isHKO = cityKey === 'hong kong' || cityKey === 'lau fau shan' || cityKey.includes('hongkong') || cityKey.includes('laufau'); const isTokyo = cityKey === 'tokyo'; const isSingapore = cityKey === 'singapore'; const isWeatherStation = !runwaySensorCities.has(cityKey) && !isHKO && !isTokyo && !isSingapore; const runwayHeaderLabel = isHKO ? '参考站点 (1分钟)' : isTokyo ? '机场气象站 (10分钟)' : isSingapore ? '航站楼温度' : isWeatherStation ? '气象站实测' : '跑道实测 (1分钟)'; series.push({ key: "settlement", label: runwayHeaderLabel, source: row?.metar_context?.station || row?.airport || "Settlement", color: "#009688", featured: true, curve: "monotone", connectNulls: true, values: sVals, }); } // METAR const mVals = na(); metarObs.forEach((o) => { const idx = tsToIdx.get(o.ts); if (idx !== undefined) mVals[idx] = o.value; }); if (mVals.some((v) => v !== null)) { series.push({ key: "metar", label: "METAR", source: row?.airport || "METAR", color: "#0ea5e9", dashed: true, curve: "stepAfter", connectNulls: true, showDot: true, values: mVals, }); } // DEB forecast curve if (hourly?.times?.length && hourly?.temps?.length) { const debPath = buildDebBaselinePath( hourly.times, hourly.temps, row?.deb_prediction, hourly.localTime || row?.local_time, hourly.forecastTodayHigh, ); const debVals = na(); hourly.times.forEach((t, i) => { const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr); const idx = ts !== null ? tsToIdx.get(ts) : undefined; if (idx !== undefined && i < debPath.debTemps.length) { debVals[idx] = 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, curve: "monotone", connectNulls: true, values: debVals, }); } // Per-model hourly 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 finiteModelTemps = modelTemps .map(validNumber) .filter((v): v is number => v !== null); if ( finiteModelTemps.length < 2 || Math.max(...finiteModelTemps) - Math.min(...finiteModelTemps) < 0.05 ) { return; } const vals = na(); hourly.times.forEach((t, i) => { const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr); const x = ts !== null ? tsToIdx.get(ts) : undefined; if (x !== undefined && i < modelTemps.length) vals[x] = 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, curve: "monotone", connectNulls: true, values: vals, }); } }); } } // Fallback: if no series, use current temp as a flat line if (!series.length) { const fallback = validNumber(row?.current_temp) ?? validNumber(row?.deb_prediction) ?? validNumber(row?.target_threshold); if (fallback !== null) { const vals = na().map(() => fallback); series.push({ key: "current", label: "Current", source: "Live", color: "#009688", featured: true, curve: "monotone", connectNulls: true, values: vals, }); } } // Build data rows: one per timestamp const data = sorted.map((ts, i) => { const point: Record = { label: formatTimestamp(ts), ts, }; series.forEach((s) => { point[s.key] = s.values[i]; }); return point; }); return { data, series }; } function hasNumericValue(row: Record, keys: string[]) { return keys.some((key) => validNumber(row[key]) !== null); } function buildRollingWindowData( data: Array>, series: EvidenceSeries[], row: ScanOpportunityRow | null, hourly: HourlyForecast, ) { if (data.length <= 1) return data; const liveKeys = series .filter((item) => item.key !== "hourly_forecast" && !item.key.startsWith("model_curve_")) .map((item) => item.key); const forecastKeys = series .filter((item) => item.key === "hourly_forecast" || item.key.startsWith("model_curve_")) .map((item) => item.key); const timestampRows = data .filter((point) => typeof point.ts === "number") .sort((a, b) => Number(a.ts) - Number(b.ts)); if (!timestampRows.length) return data; const latestLiveTs = [...timestampRows] .reverse() .find((point) => hasNumericValue(point, liveKeys))?.ts as number | undefined; const tzOffset = row?.tz_offset_seconds ?? 0; const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10); const currentLocalTs = getCityLocalUtcTimestamp( hourly?.localTime || row?.local_time, tzOffset, localDateStr, ); const maxDataTs = Number(timestampRows[timestampRows.length - 1].ts); const anchor = latestLiveTs ?? currentLocalTs ?? maxDataTs; const afterMs = latestLiveTs ? ROLLING_WINDOW_AFTER_LIVE_MS : ROLLING_WINDOW_AFTER_FORECAST_MS; const start = anchor - ROLLING_WINDOW_BEFORE_MS; const end = anchor + afterMs; const visible = timestampRows.filter((point) => { const ts = Number(point.ts); if (ts < start || ts > end) return false; return hasNumericValue(point, liveKeys) || hasNumericValue(point, forecastKeys); }); return visible.length >= 2 ? visible : timestampRows.slice(-120); } // ── 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], })); } // ── Market temperature ticks for Y-axis ───────────────────────────────── function parseTemperatureOptionsFromText(value?: string | null) { const raw = String(value || ""); const matches = raw.match(/-?\d+(?:\.\d+)?/g) || []; return matches.map(Number).filter((v) => Number.isFinite(v) && v > -80 && v < 80); } function buildMarketTemperatureOptions(row: ScanOpportunityRow | null) { const buckets = row?.distribution_full?.length ? row.distribution_full : row?.distribution_preview; const values = new Set(); (buckets || []).forEach((b) => { const v = validNumber(b.value); if (v !== null) values.add(v); parseTemperatureOptionsFromText(b.label).forEach((x) => values.add(x)); }); [row?.target_lower, row?.target_upper, row?.target_value, row?.target_threshold] .forEach((v) => { if (validNumber(v) !== null) values.add(validNumber(v)!); }); parseTemperatureOptionsFromText(row?.target_label).forEach((x) => values.add(x)); const sorted = [...values].sort((a, b) => a - b); if (sorted.length) return sorted; const t = validNumber(row?.target_threshold) ?? validNumber(row?.target_value); if (t === null) return null; return [t - 2, t - 1, t, t + 1, t + 2]; } function buildChartDomain( ticks: number[] | null, series: EvidenceSeries[], visibleData?: Array>, ): [number, number] | ["auto", "auto"] { const vals = visibleData?.length ? visibleData.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); const all = [...(ticks || []), ...vals]; if (!all.length) return ["auto", "auto"]; const min = Math.min(...all); const max = Math.max(...all); 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))]; } // ── Main component ───────────────────────────────────────────────────── export function LiveTemperatureThresholdChart({ isEn, row, allRows = [], }: { isEn: boolean; row: ScanOpportunityRow | null; allRows?: ScanOpportunityRow[]; }) { const [hourly, setHourly] = useState(null); const city = String(row?.city || "").toLowerCase().trim(); useEffect(() => { if (!city) return; const cached = _hourlyCache.get(city); if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) { setHourly(cached.data); return; } let cancelled = false; fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false`, { cache: "no-store", headers: { Accept: "application/json" }, }) .then(async (res) => { if (!res.ok) return null; return res.json() as Promise; }) .then((json) => { const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly; if (cancelled || !json || !hourlySource) return; const data: HourlyForecast = { forecastTodayHigh: json.forecast?.today_high ?? 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, }; _hourlyCache.set(city, { ts: Date.now(), data }); setHourly(data); }) .catch(() => {}); return () => { cancelled = true; }; }, [city]); const { data, series } = useMemo(() => buildSlidingChartData(row, hourly), [row, hourly]); const visibleData = useMemo( () => buildRollingWindowData(data, series, row, hourly), [data, series, row, hourly], ); const tzOffset = row?.tz_offset_seconds ?? 0; const settlementObs = useMemo(() => { return normObs(row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset); }, [row, 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 cityKey = String(row?.city || "").toLowerCase().trim(); const runwaySensorCities = new Set([ 'beijing', 'shanghai', 'guangzhou', 'shenzhen', 'qingdao', 'chengdu', 'chongqing', 'wuhan', // AMSC runway sensors 'seoul', 'busan', // AMOS runway sensors ]); const isHKO = cityKey === 'hong kong' || cityKey === 'lau fau shan' || cityKey.includes('hongkong') || cityKey.includes('laufau'); const isTokyo = cityKey === 'tokyo'; const isSingapore = cityKey === 'singapore'; const isWeatherStation = !runwaySensorCities.has(cityKey) && !isHKO && !isTokyo && !isSingapore; const runwayHeaderLabel = isHKO ? '参考站点 (1分钟)' : isTokyo ? '机场气象站 (10分钟)' : isSingapore ? '航站楼温度' : isWeatherStation ? '气象站实测' : '跑道实测 (1分钟)'; const metarHeaderLabel = isHKO ? '天文台实测 (10分钟)' : 'METAR 结算 (30分钟)'; const runwayHighLabel = isHKO ? '参考站点' : isTokyo ? '机场气象站' : isSingapore ? '航站楼' : isWeatherStation ? '气象站' : '跑道实测'; const metarHighLabel = isHKO ? '天文台' : 'METAR 官方'; const currentRunwayTemp = validNumber(hourly?.amos?.temp_c) ?? validNumber(row?.current_temp) ?? settlementPlate?.maxTemp ?? null; const observedHighMetar = validNumber(row?.metar_context?.airport_max_so_far ?? row?.metar_context?.max_temp ?? row?.current_max_so_far) ?? null; const observedHighRunway = validNumber(row?.current_max_so_far) ?? settlementPlate?.maxTemp ?? currentRunwayTemp ?? null; 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(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 marketTicks = useMemo(() => buildMarketTemperatureOptions(row), [row]); const chartDomain = useMemo( () => buildChartDomain(marketTicks, series, visibleData), [marketTicks, series, visibleData], ); return (
{/* Stats bar */}
{/* Top Row: Large temperatures */}
{isEn ? "Runway Live (1m)" : `${runwayHeaderLabel}`} {temp(currentRunwayTemp)}
{isEn ? "METAR Settlement (30m) · Daily High" : `${metarHeaderLabel} · 当日最高`} {temp(observedHighMetar)}
{isEn ? "Daily Peak" : "当日最高气温"}
{isEn ? "Runway" : runwayHighLabel}: {temp(observedHighRunway)} | {isEn ? "METAR" : metarHighLabel}: {temp(observedHighMetar)} {wundergroundDailyHigh !== null && ( <> | WU: {temp(wundergroundDailyHigh)} )}
{/* Bottom Row: Model Range Panel */}
{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 */} {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 when runway data is on chart) */} {hasRunwayData && series.some((s) => s.key.startsWith("model_curve_")) && (
{isEn ? "Models:" : "多模型:"} {series .filter((s) => s.key.startsWith("model_curve_")) .map((s) => { const stats = seriesStats(s.values); return ( {s.label} {temp(stats.latest)} ); })}
)} {/* Chart */}
{rowName(row)} {row?.market_url ? ( ) : null}
`${Number(v).toFixed(1)}°`} axisLine={{ stroke: "#cbd5e1" }} tickLine={false} domain={chartDomain} ticks={marketTicks ?? undefined} /> {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)}°`} /> 5 ? 56 : 36} iconType="plainline" wrapperStyle={{ fontSize: 11 }} /> {series .filter((item) => !hasRunwayData || !item.key.startsWith("model_curve_")) .map((item) => ( ))}
); }