"use client"; import clsx from "clsx"; import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { ExternalLink } from "lucide-react"; import { CartesianGrid, Line, LineChart as ReLineChart, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import type { 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"; type ObsPoint = { time?: string | null; temp?: number | null }; type EvidenceSeries = { key: string; label: string; source: string; color: string; dashed?: boolean; featured?: boolean; smooth?: boolean; values: Array; }; type RunwayObsPayload = { runway_pairs?: Array<[string, string] | string[] | null> | null; temperatures?: Array<[number | null, number | null] | Array | null> | null; point_temperatures?: Array<{ runway?: string | null; tdz_temp?: number | null; mid_temp?: number | null; end_temp?: number | null; } | null> | null; }; // Sliding window: keep at most this many observation points (24h at 1-min ≈ 1440) const MAX_OBS_POINTS = 1440; function validNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } function toTimestamp(value?: string | null): number | null { const raw = String(value || "").trim(); if (!raw) return null; const d = new Date(raw); if (!Number.isNaN(d.getTime())) return d.getTime(); // HH:MM or HH:MM:SS — treat as today const m = raw.match(/(\d{1,2}):(\d{2})/); if (m) { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), now.getDate(), +m[1], +m[2]).getTime(); } return null; } function formatTimestamp(ts: number): string { const d = new Date(ts); return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; } function normObs(points?: ObsPoint[] | null, limit = MAX_OBS_POINTS) { return (points || []) .filter((p) => validNumber(p.temp) !== null) .slice(-limit) .map((p, i) => ({ ts: toTimestamp(p.time) ?? (Date.now() - (limit - i) * 60_000), value: Number(p.temp), })); } 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 }; } type HourlyForecast = { forecastTodayHigh?: number | null; localTime?: string | null; times: string[]; temps: Array; modelCurves?: Record>; } | null; // ── Build aligned data rows for the sliding-window chart ──────────────── function buildSlidingChartData( row: ScanOpportunityRow | null, hourly: HourlyForecast, ) { const settlementObs = normObs(row?.settlement_today_obs || row?.metar_context?.settlement_today_obs); const metarObs = normObs(row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs); // 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); // Forecast timestamps const forecastTimes: number[] = []; if (hourly?.times?.length && hourly?.temps?.length) { hourly.times.forEach((t, i) => { const ts = toTimestamp(t); if (ts !== null && i < hourly.temps.length) { allTimes.add(ts); forecastTimes.push(ts); } }); } // Runway obs const runwayObs = (row as any)?.amos?.runway_obs || (row as any)?.runway_obs; if (runwayObs) { const pairs = runwayObs.runway_pairs || []; const temps = runwayObs.temperatures || []; pairs.forEach((_: any, idx: number) => { const tArr = Array.isArray(temps[idx]) ? temps[idx] || [] : []; tArr.forEach((tVal: unknown) => { if (validNumber(tVal) !== null) allTimes.add(Date.now() - (MAX_OBS_POINTS - idx) * 60_000); }); }); } // 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[] = []; // Settlement const sVals = na(); settlementObs.forEach((o) => { const idx = tsToIdx.get(o.ts); if (idx !== undefined) sVals[idx] = o.value; }); 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 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, 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 = toTimestamp(t); 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, 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 vals = na(); hourly.times.forEach((t, i) => { const ts = toTimestamp(t); 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, 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, 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 }; } // ── 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)); parseTemperatureOptionsFromText(row?.market_question).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[], ): [number, number] | ["auto", "auto"] { const vals = 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, }: { isEn: boolean; row: ScanOpportunityRow | null; }) { const hourlyCache = new Map(); const HOURLY_CACHE_TTL_MS = 30 * 60 * 1000; // 30 min 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; } setHourly(null); let cancelled = false; fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=panel&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) => { if (cancelled || !json?.hourly) return; const data: HourlyForecast = { forecastTodayHigh: json.forecast?.today_high ?? null, localTime: json.local_time || null, times: json.hourly.times || [], temps: json.hourly.temps || [], modelCurves: json.models_hourly?.curves || undefined, }; hourlyCache.set(city, { ts: Date.now(), data }); setHourly(data); }) .catch(() => {}); return () => { cancelled = true; }; }, [city]); const { data, series } = useMemo(() => buildSlidingChartData(row, hourly), [row, hourly]); const threshold = validNumber(row?.target_threshold) ?? validNumber(row?.target_value); const modelSummaryCards = useMemo(() => { const cards = buildModelSummaryCards(row); if (!hourly?.modelCurves) return cards; const curveKeys = new Set(Object.keys(hourly.modelCurves)); return cards.filter((c) => !curveKeys.has(c.label)); }, [row, hourly]); const tableRows = [...series, ...modelSummaryCards] .slice(0, 5) .map((item) => ({ ...item, ...seriesStats(item.values) })); const marketTicks = useMemo(() => buildMarketTemperatureOptions(row), [row]); const chartDomain = useMemo(() => buildChartDomain(marketTicks, series), [marketTicks, series]); return (
{/* Stats bar */}
{isEn ? "Settlement live" : "跑道实测"} {temp(validNumber(row?.current_temp))}
METAR {temp(validNumber(row?.metar_context?.airport_current_temp ?? row?.metar_context?.last_temp))}
{isEn ? "Threshold" : "当日阈值"} {temp(threshold)}
{tableRows.map((item) => (
{item.label}
{item.key.startsWith("model_summary_") ? ( {temp(item.latest)} ) : (
now: {temp(item.latest)} max: {temp(item.high)} 15m: {item.delta15 === null ? "--" : `${item.delta15 >= 0 ? "+" : ""}${item.delta15.toFixed(1)}°`}
)}
))}
{/* Chart */}
{rowName(row)} {row?.market_url ? ( ) : null}
`${Number(v).toFixed(1)}°`} axisLine={{ stroke: "#cbd5e1" }} tickLine={false} domain={chartDomain} ticks={marketTicks ?? undefined} /> {threshold !== null && ( )} `${Number(value).toFixed(2)}°`} /> {series.map((item) => ( ))}
); }