From 28e0cf8f5576fa32a11bde76e2ecb7d04cae70f9 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 25 May 2026 05:44:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=82=B9=E9=A2=84=E6=B5=8B?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E5=8F=AA=E6=98=BE=E7=A4=BA=E6=B8=A9=E5=BA=A6?= =?UTF-8?q?=E5=80=BC=EF=BC=8C=E7=A7=BB=E9=99=A4=E6=97=A0=E6=84=8F=E4=B9=89?= =?UTF-8?q?=E7=9A=84=20max/15m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LiveTemperatureThresholdChart.tsx | 183 +++++++++++++----- 1 file changed, 132 insertions(+), 51 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 668faae8..f3abdc34 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -14,7 +14,8 @@ import { XAxis, YAxis, } from "recharts"; -import type { ScanOpportunityRow } from "@/lib/dashboard-types"; +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"; @@ -47,6 +48,8 @@ const DAILY_CHART_HOURS = Array.from( (_, index) => `${String(index).padStart(2, "0")}:00`, ); +const VISIBLE_WINDOW_HOURS = 12; + function validNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } @@ -85,18 +88,30 @@ function seriesStats(values: Array) { return { latest, high, delta15 }; } -type HourlyForecast = { times: string[]; temps: Array } | null; +type HourlyForecast = { + forecastTodayHigh?: number | null; + localTime?: string | null; + times: string[]; + temps: Array; +} | null; function buildModelCurves(row: ScanOpportunityRow | null, length: number, hourly: HourlyForecast) { const result: EvidenceSeries[] = []; - // Use hourly forecast data if available (DEB-blended ensemble curve) + // Use hourly forecast data if available. Daily model highs are not plotted + // as curves because they are single terminal values, not a time series. 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 values = Array.from({ length }, (): number | null => null); hourly.times.forEach((t, i) => { - const hour = /(\d{1,2}):/.exec(String(t || ""))?.[1]; - const h = hour ? parseInt(hour, 10) : null; + const h = parseHourOfDay(t); if (h !== null && h >= 0 && h < length && i < hourly.temps.length) { - values[h] = validNumber(hourly.temps[i]); + values[h] = validNumber(debPath.debTemps[i]); } }); if (values.some((v) => v !== null)) { @@ -111,25 +126,22 @@ function buildModelCurves(row: ScanOpportunityRow | null, length: number, hourly }); } } - // Fallback: show model daily-high points as anchors - const modelEntries = Object.entries(row?.model_cluster_sources || {}) + return result; +} + +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, 3); - modelEntries.forEach(([label, value], index) => { - // Place anchor points at peak hours (14-17 local) - const values = Array.from({ length }, (): number | null => null); - for (let h = 14; h <= 17; h++) values[h] = value; - result.push({ - key: `model_${index}`, + .slice(0, 4) + .map(([label, value], index) => ({ + key: `model_summary_${index}`, label, - source: "Multi-model", - color: ["#2563eb", "#14b8a6", "#7c3aed"][index] || "#64748b", + source: "Multi-model daily high", + color: ["#2563eb", "#14b8a6", "#7c3aed", "#64748b"][index] || "#64748b", dashed: true, - values, - }); - }); - return result; + values: [value], + })); } function extractRunwayPointSeries(row: ScanOpportunityRow | null, length: number): EvidenceSeries[] { @@ -243,6 +255,74 @@ function buildEvidenceChart(row: ScanOpportunityRow | null, hourly: HourlyForeca return { data, series }; } +function currentHourForWindow(row: ScanOpportunityRow | null, hourly: HourlyForecast) { + return parseHourOfDay(hourly?.localTime || row?.local_time) ?? new Date().getHours(); +} + +function buildMovingWindowData( + data: Array>, + row: ScanOpportunityRow | null, + hourly: HourlyForecast, +) { + if (!data.length) return data; + const currentHour = currentHourForWindow(row, hourly); + const endHour = Math.min(23, Math.max(VISIBLE_WINDOW_HOURS - 1, currentHour + 4)); + const startHour = Math.max(0, endHour - VISIBLE_WINDOW_HOURS + 1); + return data.slice(startHour, endHour + 1); +} + +function parseTemperatureOptionsFromText(value?: string | null) { + const raw = String(value || ""); + const matches = raw.match(/-?\d+(?:\.\d+)?/g) || []; + return matches + .map((item) => Number(item)) + .filter((item) => Number.isFinite(item) && item > -80 && item < 80); +} + +function buildMarketTemperatureOptions(row: ScanOpportunityRow | null) { + const buckets = row?.distribution_full?.length + ? row.distribution_full + : row?.distribution_preview; + const values = new Set(); + (buckets || []).forEach((bucket) => { + const value = validNumber(bucket.value); + if (value !== null) values.add(value); + parseTemperatureOptionsFromText(bucket.label).forEach((item) => values.add(item)); + }); + [ + row?.target_lower, + row?.target_upper, + row?.target_value, + row?.target_threshold, + ].forEach((value) => { + const numeric = validNumber(value); + if (numeric !== null) values.add(numeric); + }); + parseTemperatureOptionsFromText(row?.target_label).forEach((item) => values.add(item)); + parseTemperatureOptionsFromText(row?.market_question).forEach((item) => values.add(item)); + const sorted = [...values].sort((a, b) => a - b); + if (sorted.length) return sorted; + const threshold = validNumber(row?.target_threshold) ?? validNumber(row?.target_value); + if (threshold === null) return null; + return [threshold - 2, threshold - 1, threshold, threshold + 1, threshold + 2]; +} + +function buildChartDomain( + marketTicks: number[] | null, + series: EvidenceSeries[], +): [number, number] | ["auto", "auto"] { + const values = series + .flatMap((item) => item.values) + .filter((value): value is number => validNumber(value) !== null); + const domainValues = [...(marketTicks || []), ...values]; + if (!domainValues.length) return ["auto", "auto"]; + const min = Math.min(...domainValues); + const max = Math.max(...domainValues); + const span = Math.max(1, max - min); + const padding = Math.max(0.5, span * 0.08); + return [Number((min - padding).toFixed(1)), Number((max + padding).toFixed(1))]; +} + export function LiveTemperatureThresholdChart({ isEn, row, @@ -257,19 +337,21 @@ export function LiveTemperatureThresholdChart({ setHourly(null); if (!city) return; let cancelled = false; - fetch(`/api/city/${encodeURIComponent(city)}/summary`, { + 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<{ timeseries?: { hourly?: { times?: string[]; temps?: Array } } }>; + return res.json() as Promise; }) .then((json) => { - if (cancelled || !json?.timeseries?.hourly) return; + if (cancelled || !json?.hourly) return; setHourly({ - times: json.timeseries.hourly.times || [], - temps: json.timeseries.hourly.temps || [], + forecastTodayHigh: json.forecast?.today_high ?? null, + localTime: json.local_time || null, + times: json.hourly.times || [], + temps: json.hourly.temps || [], }); }) .catch(() => {}); @@ -277,24 +359,17 @@ export function LiveTemperatureThresholdChart({ }, [city]); const { data, series } = useMemo(() => buildEvidenceChart(row, hourly), [row, hourly]); + const visibleData = useMemo(() => buildMovingWindowData(data, row, hourly), [data, row, hourly]); const threshold = validNumber(row?.target_threshold) ?? validNumber(row?.target_value); - const tableRows = series.slice(0, 5).map((item) => ({ ...item, ...seriesStats(item.values) })); - const marketTemperatures = useMemo(() => { - const buckets = row?.distribution_full?.length - ? row.distribution_full - : row?.distribution_preview; - if (!buckets?.length) return null; - const temps = buckets - .map((b) => validNumber(b.value)) - .filter((v): v is number => v !== null) - .sort((a, b) => a - b); - if (!temps.length) return null; - const padding = temps.length > 1 ? temps[1] - temps[0] : 2; - return { - ticks: temps, - domain: [temps[0] - padding, temps[temps.length - 1] + padding] as [number, number], - }; - }, [row]); + const modelSummaryCards = useMemo(() => buildModelSummaryCards(row), [row]); + const tableRows = [...series, ...modelSummaryCards] + .slice(0, 5) + .map((item) => ({ ...item, ...seriesStats(item.values) })); + const marketTemperatureTicks = useMemo(() => buildMarketTemperatureOptions(row), [row]); + const chartDomain = useMemo( + () => buildChartDomain(marketTemperatureTicks, series), + [marketTemperatureTicks, series], + ); return ( @@ -326,10 +401,16 @@ export function LiveTemperatureThresholdChart({ {item.label} -
- now: {temp(item.latest)} - max: {temp(item.high)} - 15m: {item.delta15 === null ? "--" : `${item.delta15 >= 0 ? "+" : ""}${item.delta15.toFixed(1)}°`} +
+ {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)}°`} +
+ )}
))} @@ -340,16 +421,16 @@ export function LiveTemperatureThresholdChart({ {rowName(row)} {row?.target_label || row?.market_direction || ""} - + - + `${Number(v).toFixed(1)}°`} axisLine={{ stroke: "#cbd5e1" }} tickLine={false} - domain={marketTemperatures?.domain ?? ["auto", "auto"]} - ticks={marketTemperatures?.ticks} + domain={chartDomain} + ticks={marketTemperatureTicks || undefined} /> {threshold !== null && (