From 6b2c99cea4e3f671f80b7150e1a54bd41117e54d Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 27 May 2026 00:24:19 +0800 Subject: [PATCH] feat: implement SSE architecture for real-time temperature updates with componentized dashboard charts and validation tests --- .../LiveTemperatureThresholdChart.tsx | 528 ++---------------- .../scan-terminal/ModelCurvesSummary.tsx | 35 ++ .../scan-terminal/TemperatureChartCanvas.tsx | 285 ++++++++++ .../TemperatureRunwayDetails.tsx | 68 +++ .../scan-terminal/TemperatureStatsBars.tsx | 187 +++++++ .../TemperatureTooltipContent.tsx | 69 +++ .../__tests__/ssePatchArchitecture.test.ts | 33 +- 7 files changed, 730 insertions(+), 475 deletions(-) create mode 100644 frontend/components/dashboard/scan-terminal/ModelCurvesSummary.tsx create mode 100644 frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx create mode 100644 frontend/components/dashboard/scan-terminal/TemperatureRunwayDetails.tsx create mode 100644 frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx create mode 100644 frontend/components/dashboard/scan-terminal/TemperatureTooltipContent.tsx diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 295ad878..aec414f3 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -2,22 +2,14 @@ import clsx from "clsx"; import { useEffect, useMemo, useRef, useState } from "react"; -import { - CartesianGrid, - Line, - Area, - ComposedChart as ReComposedChart, - ReferenceLine, - ReferenceArea, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import { useLatestPatch, useSseResyncVersion } from "@/hooks/use-sse-patches"; import { Panel } from "@/components/dashboard/scan-terminal/Panel"; -import { rowName, temp } from "@/components/dashboard/scan-terminal/utils"; +import { ModelCurvesSummary } from "@/components/dashboard/scan-terminal/ModelCurvesSummary"; +import { TemperatureChartCanvas } from "@/components/dashboard/scan-terminal/TemperatureChartCanvas"; +import { TemperatureRunwayDetails } from "@/components/dashboard/scan-terminal/TemperatureRunwayDetails"; +import { TemperatureStatsBars } from "@/components/dashboard/scan-terminal/TemperatureStatsBars"; +import { rowName } from "@/components/dashboard/scan-terminal/utils"; import { HOURLY_CACHE_TTL_MS, @@ -25,7 +17,6 @@ import { buildChartDomain, buildFullDayChartData, buildIntDegreeTicks, - buildModelSummaryCards, buildRunwayPlates, fetchHourlyForecastForCity, getActiveTemperatureSeries, @@ -36,85 +27,14 @@ import { isTemperatureSeriesVisibleByDefault, mergePatchIntoHourly, normObs, - normalizeCityKey, readSessionCache, seedHourlyForecastFromRow, - seriesStats, shouldPollLiveChart, validNumber, type HourlyForecast, } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; export { clearCityDetailCache } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; -type TooltipSeries = { - key: string; - label: string; - color: string; -}; - -function nearestSeriesValue( - data: Array>, - seriesKey: string, - activeIndex: number, -) { - if (!data.length || activeIndex < 0) return null; - for (let offset = 0; offset < data.length; offset += 1) { - const left = activeIndex - offset; - if (left >= 0) { - const value = validNumber(data[left]?.[seriesKey]); - if (value !== null) return value; - } - const right = activeIndex + offset; - if (right < data.length) { - const value = validNumber(data[right]?.[seriesKey]); - if (value !== null) return value; - } - } - return null; -} - -function TemperatureTooltipContent({ - active, - label, - payload, - data, - series, -}: { - active?: boolean; - label?: string | number; - payload?: ReadonlyArray<{ payload?: Record }>; - data: Array>; - series: TooltipSeries[]; -}) { - if (!active || !payload?.length || !series.length) return null; - const activePoint = payload[0]?.payload || {}; - const activeIndex = data.findIndex((point) => point.ts === activePoint.ts); - const rows = series - .map((item) => { - const directValue = validNumber(activePoint[item.key]); - const value = directValue ?? nearestSeriesValue(data, item.key, activeIndex); - return value === null ? null : { ...item, value }; - }) - .filter((item): item is TooltipSeries & { value: number } => item !== null); - if (!rows.length) return null; - - return ( -
-
{label}
-
- {rows.slice(0, 8).map((item) => ( -
- - - {item.label} - - {item.value.toFixed(2)}° -
- ))} -
-
- ); -} // ── Main component ───────────────────────────────────────────────────── export function LiveTemperatureThresholdChart({ @@ -368,8 +288,6 @@ export function LiveTemperatureThresholdChart({ }, [chartSeries, userToggledKeys, city, showRunwayDetails]); const { - isHKO, - isParis, isShenzhen, metarHeaderLabel, metarHighLabel, @@ -586,393 +504,65 @@ export function LiveTemperatureThresholdChart({ return (
- {/* Compact stats bar */} - {compact ? ( -
- {timeframe === "1D" ? ( -
- - {isEn ? "Runway" : runwayHeaderLabel}:{" "} - {temp(displayRunwayTemp)} - - | - - {isEn ? "METAR" : (isShenzhen ? "当日最高" : metarHeaderLabel)}:{" "} - {temp(observedHighMetar)} - -
- ) : ( -
- - DEB: {temp(debVal)} - - {modelMin !== null && modelMax !== null && ( - <> - | - - {isEn ? "Models" : "多模型"}:{" "} - - {temp(modelMin)} - {temp(modelMax)} - - - - )} -
- )} -
- {timeframe === "1D" && formattedUpdateTime.includes(" ") ? formattedUpdateTime.split(" ")[1].slice(0, 5) : ""} -
-
- ) : ( - /* Normal detailed stats bar */ -
- {/* Top Row: Large temperatures */} -
- {timeframe === "1D" ? ( -
-
- - {isEn ? "Runway Live (1m)" : `${runwayHeaderLabel}`} - - - {temp(displayRunwayTemp)} - -
-
- - {isEn ? "METAR Settlement · 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} - -
-
- )} -
+ {timeframe === "1D" && !compact && ( + )} - {/* 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` : "--"} -
-
- ))} -
-
+ {timeframe === "1D" && !compact && ( + )} - {/* Multi-model list (Only in 1D mode and when not compact) */} - {timeframe === "1D" && !compact && activeSeries.some((s) => s.key.startsWith("model_curve_")) && ( -
-
- - {isEn ? "Models:" : "多模型:"} - - {activeSeries - .filter((s) => s.key.startsWith("model_curve_")) - .map((s) => { - const stats = seriesStats(s.values); - return ( - - - {s.label} - {temp(stats.latest)} - - ); - })} -
-
- )} - - {/* Chart */} -
- {/* Interactive legend */} -
- {chartSeries.length > 1 && - chartSeries - .filter((s) => { - const isIndividualRunway = s.key.startsWith("runway_") && s.key !== "runway_max"; - if (showRunwayDetails) { - return s.key !== "runway_max"; - } else { - return !isIndividualRunway; - } - }) - .map((s) => ( - - ))} - - {/* "Show Runway Details" toggle switch */} - {hasRunwayData && ( - - )} -
- - setZoomRange(null)} - > - - - `${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 ( - - ); - })} - ( - }> | undefined} - data={zoomedData} - series={activeSeries} - /> - )} - formatter={(value: unknown) => { - if (Array.isArray(value)) { - const [low, high] = value; - if (typeof low === "number" && typeof high === "number") { - return `${low.toFixed(1)}° - ${high.toFixed(1)}°`; - } - } - const num = Number(value); - return Number.isFinite(num) ? `${num.toFixed(2)}°` : String(value); - }} - /> - {/* Runway Temperature Band (low-high range area) */} - {hasRunwayData && ( - - )} - {refAreaLeft !== null && refAreaRight !== null && zoomedData[refAreaLeft] && zoomedData[refAreaRight] && ( - - )} - {activeSeries.map((item) => ( - - ))} - - - {isHourlyLoading && ( -
-
- - {isEn ? "Loading chart" : "加载图表"} -
-
- )} -
+ setZoomRange(null)} + isSeriesVisible={isSeriesVisible} + onSeriesToggle={(seriesKey) => { + setUserToggledKeys((prev) => ({ + ...prev, + [seriesKey]: !isSeriesVisible(seriesKey), + })); + }} + onShowRunwayDetailsChange={setShowRunwayDetails} + />
); diff --git a/frontend/components/dashboard/scan-terminal/ModelCurvesSummary.tsx b/frontend/components/dashboard/scan-terminal/ModelCurvesSummary.tsx new file mode 100644 index 00000000..bbc9deca --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/ModelCurvesSummary.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { seriesStats, type EvidenceSeries } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; +import { temp } from "@/components/dashboard/scan-terminal/utils"; + +export function ModelCurvesSummary({ + isEn, + activeSeries, +}: { + isEn: boolean; + activeSeries: EvidenceSeries[]; +}) { + const modelSeries = activeSeries.filter((s) => s.key.startsWith("model_curve_")); + if (!modelSeries.length) return null; + + return ( +
+
+ + {isEn ? "Models:" : "多模型:"} + + {modelSeries.map((s) => { + const stats = seriesStats(s.values); + return ( + + + {s.label} + {temp(stats.latest)} + + ); + })} +
+
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx b/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx new file mode 100644 index 00000000..9ca731df --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx @@ -0,0 +1,285 @@ +"use client"; + +import clsx from "clsx"; +import { useEffect, useRef, useState } from "react"; +import { + Area, + CartesianGrid, + ComposedChart as ReComposedChart, + Line, + ReferenceArea, + ReferenceLine, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { ScanOpportunityRow } from "@/lib/dashboard-types"; +import { TemperatureTooltipContent } from "@/components/dashboard/scan-terminal/TemperatureTooltipContent"; +import type { EvidenceSeries } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; + +type CityThreshold = { + threshold: number; + label: string; + isBreached: boolean; + kind: "gte" | "lte"; +}; + +export function TemperatureChartCanvas({ + isEn, + compact, + timeframe, + row, + cityThresholds, + chartSeries, + activeSeries, + zoomedData, + chartDomain, + intDegreeTicks, + hasRunwayData, + showRunwayDetails, + isHourlyLoading, + refAreaLeft, + refAreaRight, + onMouseDown, + onMouseMove, + onMouseUp, + onZoomReset, + isSeriesVisible, + onSeriesToggle, + onShowRunwayDetailsChange, +}: { + isEn: boolean; + compact: boolean; + timeframe: string; + row: ScanOpportunityRow | null; + cityThresholds: CityThreshold[]; + chartSeries: EvidenceSeries[]; + activeSeries: EvidenceSeries[]; + zoomedData: Array>; + chartDomain: [number, number] | ["auto", "auto"]; + intDegreeTicks: number[] | null; + hasRunwayData: boolean; + showRunwayDetails: boolean; + isHourlyLoading: boolean; + refAreaLeft: number | null; + refAreaRight: number | null; + onMouseDown: (event: any) => void; + onMouseMove: (event: any) => void; + onMouseUp: () => void; + onZoomReset: () => void; + isSeriesVisible: (seriesKey: string) => boolean; + onSeriesToggle: (seriesKey: string) => void; + onShowRunwayDetailsChange: (value: boolean) => void; +}) { + const chartHostRef = useRef(null); + const [chartSize, setChartSize] = useState({ width: 0, height: 0 }); + + useEffect(() => { + const host = chartHostRef.current; + if (!host) return; + + let frame = 0; + const measure = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const rect = host.getBoundingClientRect(); + const width = Math.floor(rect.width); + const height = Math.floor(rect.height); + setChartSize((prev) => { + if (prev.width === width && prev.height === height) return prev; + return { width, height }; + }); + }); + }; + + measure(); + + if (typeof ResizeObserver !== "undefined") { + const observer = new ResizeObserver(measure); + observer.observe(host); + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + }; + } + + window.addEventListener("resize", measure); + return () => { + cancelAnimationFrame(frame); + window.removeEventListener("resize", measure); + }; + }, []); + + const canRenderChart = chartSize.width > 0 && chartSize.height > 0; + const chartWidth = Math.max(1, chartSize.width); + const chartHeight = Math.max(220, chartSize.height); + + return ( +
+
+ {chartSeries.length > 1 && + chartSeries + .filter((s) => { + const isIndividualRunway = s.key.startsWith("runway_") && s.key !== "runway_max"; + if (showRunwayDetails) { + return s.key !== "runway_max"; + } + return !isIndividualRunway; + }) + .map((s) => ( + + ))} + + {hasRunwayData && ( + + )} +
+
+ {canRenderChart && ( + + + + `${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 ( + + ); + })} + ( + }> | undefined} + data={zoomedData} + series={activeSeries} + /> + )} + formatter={(value: unknown) => { + if (Array.isArray(value)) { + const [low, high] = value; + if (typeof low === "number" && typeof high === "number") { + return `${low.toFixed(1)}° - ${high.toFixed(1)}°`; + } + } + const num = Number(value); + return Number.isFinite(num) ? `${num.toFixed(2)}°` : String(value); + }} + /> + {hasRunwayData && ( + + )} + {refAreaLeft !== null && refAreaRight !== null && zoomedData[refAreaLeft] && zoomedData[refAreaRight] && ( + + )} + {activeSeries.map((item) => ( + + ))} + + )} +
+ {isHourlyLoading && ( +
+
+ + {isEn ? "Loading chart" : "加载图表"} +
+
+ )} +
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/TemperatureRunwayDetails.tsx b/frontend/components/dashboard/scan-terminal/TemperatureRunwayDetails.tsx new file mode 100644 index 00000000..7c81faab --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/TemperatureRunwayDetails.tsx @@ -0,0 +1,68 @@ +"use client"; + +import clsx from "clsx"; + +type RunwayPlate = { + rwy: string; + isSettlement: boolean; + tdzTemp: number | null; + midTemp: number | null; + endTemp: number | null; + maxTemp: number | null; + dailyHigh: number | null; + trend_15m: number | null; +}; + +export function TemperatureRunwayDetails({ + isEn, + plates, +}: { + isEn: boolean; + plates: RunwayPlate[]; +}) { + if (!plates.length) return null; + + return ( +
+
+ {isEn ? "Runway Observations" : "跑道观测"} + {plates.some((p) => p.trend_15m !== null && p.trend_15m > 0 && !p.isSettlement) && ( + + {isEn ? "Non-settlement Runway Warming Alert" : "非结算跑道升温提醒"} + + )} +
+
+ {plates.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` : "--"} +
+
+ ))} +
+
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx b/frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx new file mode 100644 index 00000000..af496df8 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx @@ -0,0 +1,187 @@ +"use client"; + +import clsx from "clsx"; +import { temp } from "@/components/dashboard/scan-terminal/utils"; + +export function TemperatureStatsBars({ + isEn, + compact, + timeframe, + runwayHeaderLabel, + metarHeaderLabel, + runwayHighLabel, + metarHighLabel, + isShenzhen, + displayRunwayTemp, + observedHighMetar, + observedHighRunway, + wundergroundDailyHigh, + debVal, + modelMin, + modelMax, + spread, + spreadLabel, + spreadLabelEn, + formattedUpdateTime, +}: { + isEn: boolean; + compact: boolean; + timeframe: string; + runwayHeaderLabel: string; + metarHeaderLabel: string; + runwayHighLabel: string; + metarHighLabel: string; + isShenzhen: boolean; + displayRunwayTemp: number | null; + observedHighMetar: number | null; + observedHighRunway: number | null; + wundergroundDailyHigh: number | null; + debVal: number | null; + modelMin: number | null; + modelMax: number | null; + spread: number | null; + spreadLabel: string; + spreadLabelEn: string; + formattedUpdateTime: string; +}) { + if (compact) { + return ( +
+ {timeframe === "1D" ? ( +
+ + {isEn ? "Runway" : runwayHeaderLabel}:{" "} + {temp(displayRunwayTemp)} + + | + + {isEn ? "METAR" : (isShenzhen ? "当日最高" : metarHeaderLabel)}:{" "} + {temp(observedHighMetar)} + +
+ ) : ( +
+ + DEB: {temp(debVal)} + + {modelMin !== null && modelMax !== null && ( + <> + | + + {isEn ? "Models" : "多模型"}:{" "} + + {temp(modelMin)} - {temp(modelMax)} + + + + )} +
+ )} +
+ {timeframe === "1D" && formattedUpdateTime.includes(" ") ? formattedUpdateTime.split(" ")[1].slice(0, 5) : ""} +
+
+ ); + } + + return ( +
+
+ {timeframe === "1D" ? ( +
+
+ + {isEn ? "Runway Live (1m)" : `${runwayHeaderLabel}`} + + + {temp(displayRunwayTemp)} + +
+
+ + {isEn ? "METAR Settlement · 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)} + + )} +
+
+
+ + {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} + +
+
+ )} +
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/TemperatureTooltipContent.tsx b/frontend/components/dashboard/scan-terminal/TemperatureTooltipContent.tsx new file mode 100644 index 00000000..3a0c0bc1 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/TemperatureTooltipContent.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { validNumber, type EvidenceSeries } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; + +type TooltipSeries = Pick; + +function nearestSeriesValue( + data: Array>, + seriesKey: string, + activeIndex: number, +) { + if (!data.length || activeIndex < 0) return null; + for (let offset = 0; offset < data.length; offset += 1) { + const left = activeIndex - offset; + if (left >= 0) { + const value = validNumber(data[left]?.[seriesKey]); + if (value !== null) return value; + } + const right = activeIndex + offset; + if (right < data.length) { + const value = validNumber(data[right]?.[seriesKey]); + if (value !== null) return value; + } + } + return null; +} + +export function TemperatureTooltipContent({ + active, + label, + payload, + data, + series, +}: { + active?: boolean; + label?: string | number; + payload?: ReadonlyArray<{ payload?: Record }>; + data: Array>; + series: TooltipSeries[]; +}) { + if (!active || !payload?.length || !series.length) return null; + const activePoint = payload[0]?.payload || {}; + const activeIndex = data.findIndex((point) => point.ts === activePoint.ts); + const rows = series + .map((item) => { + const directValue = validNumber(activePoint[item.key]); + const value = directValue ?? nearestSeriesValue(data, item.key, activeIndex); + return value === null ? null : { ...item, value }; + }) + .filter((item): item is TooltipSeries & { value: number } => item !== null); + if (!rows.length) return null; + + return ( +
+
{label}
+
+ {rows.slice(0, 8).map((item) => ( +
+ + + {item.label} + + {item.value.toFixed(2)}° +
+ ))} +
+
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts index 9d14b489..4af44e37 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts @@ -87,6 +87,16 @@ export function runTests() { assert(bffEventsRoute.includes("searchParams"), "Next.js SSE proxy must forward query parameters to FastAPI"); const chart = readFrontendFile("components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"); + const chartCanvasPath = path.join(process.cwd(), "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"); + const chartStatsPath = path.join(process.cwd(), "components", "dashboard", "scan-terminal", "TemperatureStatsBars.tsx"); + const chartRunwayPath = path.join(process.cwd(), "components", "dashboard", "scan-terminal", "TemperatureRunwayDetails.tsx"); + const chartTooltipPath = path.join(process.cwd(), "components", "dashboard", "scan-terminal", "TemperatureTooltipContent.tsx"); + assert(fs.existsSync(chartCanvasPath), "temperature chart Recharts canvas must live in TemperatureChartCanvas.tsx"); + assert(fs.existsSync(chartStatsPath), "temperature chart stat bars must live in TemperatureStatsBars.tsx"); + assert(fs.existsSync(chartRunwayPath), "temperature chart runway detail panel must live in TemperatureRunwayDetails.tsx"); + assert(fs.existsSync(chartTooltipPath), "temperature chart tooltip must live in TemperatureTooltipContent.tsx"); + const chartCanvas = fs.readFileSync(chartCanvasPath, "utf8"); + const chartTooltip = fs.readFileSync(chartTooltipPath, "utf8"); const chartLogicPath = path.join(process.cwd(), "components", "dashboard", "scan-terminal", "temperature-chart-logic.ts"); assert(fs.existsSync(chartLogicPath), "temperature chart pure data logic must live in temperature-chart-logic.ts"); const chartLogic = fs.readFileSync(chartLogicPath, "utf8"); @@ -99,11 +109,16 @@ export function runTests() { assert(chart.includes("useSseResyncVersion"), "temperature chart must resync full detail when SSE replay is incomplete"); assert(chartLogic.includes("runway_points"), "temperature chart must merge v1 runway_points into runway history"); assert(chart.includes("2 * 60_000"), "temperature chart must wait two minutes without patches before full-fetch fallback"); - assert(chart.includes("TemperatureTooltipContent"), "temperature chart must use a custom tooltip content component"); - assert(chart.includes("filterNull={false}"), "temperature chart tooltip must keep null-slot payload so hover works between sparse points"); - assert(chart.includes("nearestSeriesValue"), "temperature chart tooltip must fall back to nearest non-null value for connected sparse lines"); + assert(chart.includes("TemperatureChartCanvas"), "temperature chart shell must compose the extracted chart canvas"); + assert(chart.includes("TemperatureStatsBars"), "temperature chart shell must compose the extracted stat bars"); + assert(chart.includes("TemperatureRunwayDetails"), "temperature chart shell must compose the extracted runway panel"); + assert(!chart.includes("from \"recharts\""), "LiveTemperatureThresholdChart.tsx must not import Recharts directly"); + assert(!chart.includes("function TemperatureTooltipContent"), "LiveTemperatureThresholdChart.tsx must not define tooltip content inline"); + assert(chartCanvas.includes("TemperatureTooltipContent"), "temperature chart canvas must use a custom tooltip content component"); + assert(chartCanvas.includes("filterNull={false}"), "temperature chart tooltip must keep null-slot payload so hover works between sparse points"); + assert(chartTooltip.includes("nearestSeriesValue"), "temperature chart tooltip must fall back to nearest non-null value for connected sparse lines"); assert(chart.includes("isHourlyLoading"), "temperature chart must keep a per-panel hourly loading state"); - assert(chart.includes("加载图表") && chart.includes("absolute inset-2"), "temperature chart must render an in-chart loading overlay"); + assert(chartCanvas.includes("加载图表") && chartCanvas.includes("absolute inset-2"), "temperature chart must render an in-chart loading overlay"); assert(chart.includes("hasLoadedHourlyDetailRef"), "temperature chart must distinguish first load from background refreshes"); const fallbackRefreshBlock = chart.match(/const refreshFullDetail = \(\) => \{[\s\S]*?\n \};/)?.[0] || ""; assert( @@ -122,9 +137,15 @@ export function runTests() { chart.includes("targetResolution !== nextTargetResolution"), "temperature chart must guard target-resolution state updates to prevent render/update loops", ); + assert(!chartCanvas.includes("ResponsiveContainer"), "temperature chart canvas must not mount Recharts through ResponsiveContainer at 0x0"); + assert(chartCanvas.includes("ResizeObserver"), "temperature chart canvas must measure its host with ResizeObserver"); assert( - chart.includes("initialDimension") && chart.includes("minHeight={"), - "temperature chart ResponsiveContainer must have stable initial/min dimensions to avoid zero-size resize loops", + chartCanvas.includes("chartSize.width > 0") && chartCanvas.includes("chartSize.height > 0"), + "temperature chart canvas must render Recharts only after positive host dimensions are measured", + ); + assert( + chartCanvas.includes("width={chartWidth}") && chartCanvas.includes("height={chartHeight}"), + "temperature chart canvas must pass explicit positive width/height to Recharts", ); assert(!chart.includes("3D"), "temperature chart UI must not expose a 3D/future-forecast mode"); assert(!chart.includes("build3DayChartData"), "temperature chart component must not render future prediction curves");