"use client"; import clsx from "clsx"; import { useEffect, useMemo, useState } from "react"; import { Brush, 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 { 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: [["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 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 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 }>; }; const MAX_OBS_POINTS = 1440; const HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.model; 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 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; forecastDaily?: ForecastDay[]; multiModelDaily?: Record; settlementTodayObs?: ObsPoint[]; metarTodayObs?: ObsPoint[]; } | 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: 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 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: 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(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 runwayHistorySeries = buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr); 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: "AMOS/AMSC", color: rhs.color, featured: rhs.isSettlement, dashed: !rhs.isSettlement, values: binned, }); }); // ── Settlement observations ── if (settlementObs.length) { const svals = binObservationsToSlots(slots, settlementObs); if (svals.some((v) => v !== null)) { series.push({ key: "settlement", label: row?.metar_context?.station_label || row?.metar_context?.station || "Settlement", source: row?.metar_context?.station || row?.airport || "Settlement", color: "#009688", featured: true, values: svals, }); } } // ── METAR ── if (metarObs.length) { const mvals = binObservationsToSlots(slots, metarObs); if (mvals.some((v) => v !== null)) { series.push({ key: "metar", label: 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, 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(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, }: { isEn: boolean; row: ScanOpportunityRow | null; allRows?: ScanOpportunityRow[]; compact?: boolean; onSearchClick?: () => void; onMaximize?: () => void; onClose?: () => void; isMaximized?: boolean; disableClose?: boolean; }) { const [hourly, setHourly] = useState(null); const city = String(row?.city || "").toLowerCase().trim(); const [timeframe, setTimeframe] = useState<"1D" | "3D">("1D"); const [userToggledKeys, setUserToggledKeys] = useState>({}); useEffect(() => { setUserToggledKeys({}); }, [city, timeframe]); 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, 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, }; _hourlyCache.set(city, { ts: Date.now(), data }); setHourly(data); }) .catch(() => {}); return () => { cancelled = true; }; }, [city]); 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 chartSeries.filter((s) => isSeriesVisible(s.key)); }, [chartSeries, userToggledKeys, city]); 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 isParis = cityKey === 'paris'; const isWeatherStation = !runwaySensorCities.has(cityKey) && !isHKO && !isTokyo && !isSingapore && !isParis; const runwayHeaderLabel = isHKO ? '参考站点 (1分钟)' : isTokyo ? '机场气象站 (10分钟)' : isSingapore ? '航站楼温度' : isParis ? '官方机场观测 (15分钟)' : isWeatherStation ? '气象站实测' : '跑道实测 (1分钟)'; const metarHeaderLabel = isHKO ? '天文台实测 (10分钟)' : 'METAR 结算 (30分钟)'; const runwayHighLabel = isHKO ? '参考站点' : isTokyo ? '机场气象站' : isSingapore ? '航站楼' : isParis ? '官方机场观测' : 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 intDegreeTicks = useMemo(() => buildIntDegreeTicks(series, data), [series, data]); const chartDomain = useMemo( () => buildChartDomain(series, data), [series, 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(currentRunwayTemp)} | {isEn ? "METAR" : 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(currentRunwayTemp)}
{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 && 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 */}
{/* 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) => ( ))} {!compact && (timeframe === "1D" || timeframe === "3D") && ( )}
); } 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;