"use client"; import clsx from "clsx"; import { memo, 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 { getTemperatureSeriesForRunwayDetailsMode, type EvidenceSeries, type ProbabilityOverlay, } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; type CityThreshold = { threshold: number; label: string; isBreached: boolean; kind: "gte" | "lte"; }; function isFiniteChartValue(value: unknown) { return typeof value === "number" && Number.isFinite(value); } function hasDrawableTemperatureChartContent({ activeSeries, probabilityOverlay, zoomedData, }: { activeSeries: EvidenceSeries[]; probabilityOverlay: ProbabilityOverlay | null; zoomedData: Array>; }) { if (probabilityOverlay?.muLine || probabilityOverlay?.bands.length) return true; return activeSeries.some((series) => zoomedData.some((point, index) => { const value = point?.[series.key] ?? series.values[index]; return isFiniteChartValue(value); }), ); } function shouldKeepTemperatureChartLoading({ row, isHourlyLoading, activeSeries, probabilityOverlay, zoomedData, }: { row: ScanOpportunityRow | null; isHourlyLoading: boolean; activeSeries: EvidenceSeries[]; probabilityOverlay: ProbabilityOverlay | null; zoomedData: Array>; }) { if (!row?.city) return false; if (!isHourlyLoading) return false; return !hasDrawableTemperatureChartContent({ activeSeries, probabilityOverlay, zoomedData }); } function TemperatureChartSkeleton({ compact }: { compact: boolean }) { const horizontalLines = compact ? 5 : 7; const verticalLines = compact ? 5 : 8; return (
{Array.from({ length: horizontalLines }).map((_, index) => ( ))} {Array.from({ length: verticalLines }).map((_, index) => ( ))}
); } function TemperatureChartCanvasComponent({ isEn, compact, timeframe, row, cityThresholds, chartSeries, activeSeries, probabilityOverlay, zoomedData, chartDomain, intDegreeTicks, hasRunwayData, showRunwayDetails, isHourlyLoading, detailError, showingStaleDetail, refAreaLeft, refAreaRight, onMouseDown, onMouseMove, onMouseUp, onZoomReset, isSeriesVisible, onSeriesToggle, onShowRunwayDetailsChange, onRetryDetail, }: { isEn: boolean; compact: boolean; timeframe: string; row: ScanOpportunityRow | null; cityThresholds: CityThreshold[]; chartSeries: EvidenceSeries[]; activeSeries: EvidenceSeries[]; probabilityOverlay: ProbabilityOverlay | null; zoomedData: Array>; chartDomain: [number, number] | ["auto", "auto"]; intDegreeTicks: number[] | null; hasRunwayData: boolean; showRunwayDetails: boolean; isHourlyLoading: boolean; detailError?: string | null; showingStaleDetail?: 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; onRetryDetail?: () => void; }) { const chartHostRef = useRef(null); const [chartSize, setChartSize] = useState({ width: 0, height: 0 }); const tempSymbol = row?.temp_symbol || "°C"; 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 minChartHeight = compact ? 120 : 220; const chartHeight = Math.max(minChartHeight, chartSize.height); const individualRunwaySeriesCount = chartSeries.filter( (series) => series.key.startsWith("runway_") && series.key !== "runway_max", ).length; const collapsedRunwaySeries = getTemperatureSeriesForRunwayDetailsMode( row?.city || "", chartSeries, false, ); const canToggleRunwayDetails = hasRunwayData && individualRunwaySeriesCount > 1 && collapsedRunwaySeries.length < chartSeries.length; const hasDrawableChartContent = hasDrawableTemperatureChartContent({ activeSeries, probabilityOverlay, zoomedData, }); const shouldShowChartLoading = shouldKeepTemperatureChartLoading({ row, isHourlyLoading, activeSeries, probabilityOverlay, zoomedData, }); const shouldRenderChart = canRenderChart && hasDrawableChartContent; const shouldShowEmptyState = Boolean(row?.city) && !isHourlyLoading && !hasDrawableChartContent; const shouldShowBackgroundRefresh = isHourlyLoading && hasDrawableChartContent; const shouldShowUnavailableState = Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && !hasDrawableChartContent; const shouldShowBackgroundError = Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && hasDrawableChartContent; return (
{chartSeries.length > 1 && getTemperatureSeriesForRunwayDetailsMode( row?.city || "", chartSeries, showRunwayDetails, ) .map((s) => ( ))} {canToggleRunwayDetails && ( )} {probabilityOverlay && ( {isEn ? "Gaussian" : "高斯概率"} {probabilityOverlay.muLine && ( μ {probabilityOverlay.muLine.value.toFixed(1)}{tempSymbol} )} )}
{!shouldRenderChart && } {shouldRenderChart && ( `${Number(v).toFixed(0)}${tempSymbol}`} axisLine={{ stroke: "#cbd5e1" }} tickLine={false} domain={chartDomain} ticks={intDegreeTicks ?? undefined} /> {timeframe === "1D" && probabilityOverlay?.bands.map((band) => ( ))} {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)}${tempSymbol} [${t.isBreached ? "Excluded" : "Active"}]` : `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}${tempSymbol} [${t.isBreached ? "已排除" : "活跃"}]`; return ( ); })} {timeframe === "1D" && probabilityOverlay?.muLine && ( )} ( }> | undefined} data={zoomedData} series={activeSeries} tempSymbol={tempSymbol} /> )} formatter={(value: unknown) => { if (Array.isArray(value)) { const [low, high] = value; if (typeof low === "number" && typeof high === "number") { return `${low.toFixed(1)}${tempSymbol} - ${high.toFixed(1)}${tempSymbol}`; } } const num = Number(value); return Number.isFinite(num) ? `${num.toFixed(2)}${tempSymbol}` : String(value); }} /> {hasRunwayData && ( )} {refAreaLeft !== null && refAreaRight !== null && zoomedData[refAreaLeft] && zoomedData[refAreaRight] && ( )} {activeSeries.map((item) => ( ))} )} {shouldShowUnavailableState && (
{isEn ? "Data temporarily unavailable" : "数据暂不可用"}
)} {shouldShowEmptyState && !shouldShowUnavailableState && (
{isEn ? "No drawable chart data yet" : "暂无可绘制图表数据"}
)}
{shouldShowBackgroundError && (
{showingStaleDetail ? (isEn ? "Showing cache" : "显示缓存") : (isEn ? "Update failed" : "更新失败")}
)} {shouldShowBackgroundRefresh && (
{isEn ? "Updating" : "更新中"}
)} {shouldShowChartLoading && (
{isEn ? "Loading chart" : "加载图表"}
)}
); } export const TemperatureChartCanvas = memo(TemperatureChartCanvasComponent); export const __shouldKeepTemperatureChartLoadingForTest = shouldKeepTemperatureChartLoading;