Files
PolyWeather/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx
2026-06-17 00:14:02 +08:00

1710 lines
59 KiB
TypeScript

"use client";
import clsx from "clsx";
import { Bug, ChevronDown, ChevronUp } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { useLatestPatch, useSseResyncVersion } from "@/hooks/use-sse-patches";
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
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 { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy";
import {
HOURLY_CACHE_TTL_MS,
buildChartDomain,
buildFullDayChartData,
buildIntDegreeTicks,
buildRunwayPlates,
fetchFullChartDetailForCity,
fetchLiveObservationForCity,
getActiveTemperatureSeries,
getDebPeakWindowRange,
getPeakGlowState,
getLiveObservationLabels,
getObservationDisplayMetrics,
getVisibleTemperatureSeries,
isTemperatureSeriesVisibleByDefault,
mergeHourlyWithLiveObservations,
mergeObservationSnapshotIntoHourly,
mergePatchIntoHourly,
mergeRowObservationIntoHourly,
normObs,
prefersHighFrequencyRunwayResolution,
readCachedHourlyForInitialRow,
readCityDetailBatchDiagnostics,
readHourlyDetailSnapshot,
readHourlyDetailSnapshotAgeMs,
selectCompactSecondaryTemp,
selectDisplayRunwayTemp,
selectInitialHourlyForRowChange,
seedChartRenderStateFromRow,
shouldPollLiveChart,
validNumber,
type ChartRenderState,
type ObservationSnapshot,
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
export { clearCityDetailCache } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
const PEAK_GLOW_PANEL_CLASS = {
none: "",
watch: "peak-glow-card peak-glow-watch",
near_peak: "peak-glow-card peak-glow-near",
breakout: "peak-glow-card peak-glow-breakout",
cooling: "peak-glow-card peak-glow-cooling",
} as const;
const PEAK_GLOW_BADGE_CLASS = {
none: "",
watch: "border-amber-200 bg-amber-50 text-amber-700",
near_peak: "border-orange-200 bg-orange-50 text-orange-700",
breakout: "border-rose-200 bg-rose-50 text-rose-700",
cooling: "border-slate-200 bg-slate-100 text-slate-500",
} as const;
const FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS = 90_000;
const LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback;
const DETAIL_LOAD_BATCH_DELAY_MS = 0;
const TRANSIENT_DETAIL_RETRY_DELAY_MS = 3_000;
const INITIAL_DETAIL_LOAD_SLOTS = 3;
const DEFERRED_DETAIL_LOAD_DELAY_MS = 1_200;
const DEFERRED_DETAIL_LOAD_GROUP_SIZE = 3;
const DEFERRED_DETAIL_LOAD_WAVE_STEP_MS = 900;
export function preloadTemperatureChartCanvas() {
return Promise.resolve();
}
function peakGlowLabel(state: keyof typeof PEAK_GLOW_PANEL_CLASS, isEn: boolean) {
if (state === "watch") return isEn ? "Watch" : "关注";
if (state === "near_peak") return isEn ? "Near peak" : "接近峰值";
if (state === "breakout") return isEn ? "Breakout" : "突破";
if (state === "cooling") return isEn ? "Cooling" : "降温";
return "";
}
function peakGlowTitle(
state: keyof typeof PEAK_GLOW_PANEL_CLASS,
distanceToHigh: number | null,
isEn: boolean,
) {
const label = peakGlowLabel(state, isEn);
if (!label) return "";
if (distanceToHigh === null) return label;
const absDistance = Math.abs(distanceToHigh).toFixed(1);
if (state === "breakout") {
return isEn ? `${label}: new observed high` : `${label}:刷新实测高点`;
}
if (state === "cooling") return isEn ? `${label}: peak likely passed` : `${label}:峰值可能已过`;
return isEn ? `${label}: ${absDistance}° below observed high` : `${label}:距实测高点 ${absDistance}°`;
}
function formatCityLocalDate(tzOffsetSeconds: number | null | undefined) {
const cityOffsetMs = (tzOffsetSeconds ?? 0) * 1000;
const cityNow = new Date(Date.now() + cityOffsetMs);
const y = cityNow.getUTCFullYear();
const m = String(cityNow.getUTCMonth() + 1).padStart(2, "0");
const d = String(cityNow.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function formatCityLocalDateTime(tzOffsetSeconds: number | null | undefined) {
const nowUtc = Date.now();
const cityOffsetMs = (tzOffsetSeconds ?? 0) * 1000;
const cityNow = new Date(nowUtc + cityOffsetMs);
const pad = (n: number) => String(n).padStart(2, "0");
const y = cityNow.getUTCFullYear();
const mo = pad(cityNow.getUTCMonth() + 1);
const d = pad(cityNow.getUTCDate());
const hh = pad(cityNow.getUTCHours());
const mm = pad(cityNow.getUTCMinutes());
const ss = pad(cityNow.getUTCSeconds());
return `${y}-${mo}-${d} ${hh}:${mm}:${ss}`;
}
function getLiveTempFromHourly(data: ChartRenderState) {
return validNumber(data?.airportCurrent?.temp) ?? validNumber(data?.airportPrimary?.temp) ?? null;
}
function rowObservationSignature(row: ScanOpportunityRow | null) {
if (!row) return "";
const metarContext = row.metar_context || null;
const pieces = [
row.city,
row.current_temp,
row.current_max_so_far,
row.local_time,
(row as any).sse_revision,
metarContext?.airport_current_temp,
metarContext?.airport_max_so_far,
metarContext?.airport_obs_time,
metarContext?.last_temp,
metarContext?.last_time,
metarContext?.last_observation_time,
metarContext?.source,
metarContext?.station_label,
];
const hasObservation =
validNumber(row.current_temp) !== null ||
validNumber(metarContext?.airport_current_temp) !== null ||
validNumber(metarContext?.last_temp) !== null;
return hasObservation ? pieces.map((piece) => String(piece ?? "")).join("|") : "";
}
type ChartDetailStatus = "idle" | "loading" | "fresh" | "stale_cache" | "degraded";
type ChartDetailSource =
| "none"
| "memory_cache"
| "session_cache"
| "network"
| "force_refresh"
| "partial_or_timeout";
type ChartFreshnessState = {
rowAppliedAtMs: number | null;
rowObservationTime: string | null;
ssePatchAppliedAtMs: number | null;
sseObservedAt: string | null;
sseRevision: number | null;
sseEmittedAtMs: number | null;
sseServerToClientLatencySec: number | null;
sseCollectorToClientLatencySec: number | null;
sseSourceToCollectorLatencySec: number | null;
detailRequestedAtMs: number | null;
detailResolvedAtMs: number | null;
detailErrorAtMs: number | null;
detailStatus: ChartDetailStatus;
detailSource: ChartDetailSource;
};
function createChartFreshnessState(
overrides: Partial<ChartFreshnessState> = {},
): ChartFreshnessState {
return {
rowAppliedAtMs: null,
rowObservationTime: null,
ssePatchAppliedAtMs: null,
sseObservedAt: null,
sseRevision: null,
sseEmittedAtMs: null,
sseServerToClientLatencySec: null,
sseCollectorToClientLatencySec: null,
sseSourceToCollectorLatencySec: null,
detailRequestedAtMs: null,
detailResolvedAtMs: null,
detailErrorAtMs: null,
detailStatus: "idle",
detailSource: "none",
...overrides,
};
}
function ageSeconds(fromMs: number | null | undefined, nowMs: number) {
if (!Number.isFinite(fromMs)) return null;
return Math.max(0, Math.round((nowMs - Number(fromMs)) / 1000));
}
function buildChartFreshnessContext(state: ChartFreshnessState, nowMs = Date.now()) {
const rowAgeSec = ageSeconds(state.rowAppliedAtMs, nowMs);
const sseAgeSec = ageSeconds(state.ssePatchAppliedAtMs, nowMs);
let livePath: "none" | "row" | "sse" = "none";
let liveAgeSec: number | null = null;
if (rowAgeSec !== null) {
livePath = "row";
liveAgeSec = rowAgeSec;
}
if (sseAgeSec !== null && (liveAgeSec === null || sseAgeSec <= liveAgeSec)) {
livePath = "sse";
liveAgeSec = sseAgeSec;
}
return {
live_path: livePath,
live_age_sec: liveAgeSec,
row_applied_age_sec: rowAgeSec,
row_observation_time: state.rowObservationTime,
sse_patch_age_sec: sseAgeSec,
sse_observed_at: state.sseObservedAt,
sse_revision: state.sseRevision,
sse_emitted_at_ms: state.sseEmittedAtMs,
sse_server_to_client_latency_sec: state.sseServerToClientLatencySec,
sse_collector_to_client_latency_sec: state.sseCollectorToClientLatencySec,
sse_source_to_collector_latency_sec: state.sseSourceToCollectorLatencySec,
detail_status: state.detailStatus,
detail_source: state.detailSource,
detail_request_age_sec: ageSeconds(state.detailRequestedAtMs, nowMs),
detail_age_sec: ageSeconds(state.detailResolvedAtMs, nowMs),
detail_error_age_sec: ageSeconds(state.detailErrorAtMs, nowMs),
};
}
function rowObservationTimeForFreshness(row: ScanOpportunityRow | null) {
if (!row) return null;
const metarContext = row.metar_context || null;
return String(
metarContext?.airport_obs_time ||
metarContext?.last_observation_time ||
metarContext?.last_time ||
row.local_time ||
"",
).trim() || null;
}
function observationSnapshotTimeForFreshness(snapshot: ObservationSnapshot) {
const block = snapshot.airport_current || snapshot.airport_primary || snapshot.current || {};
return String(block.obs_time || block.observed_at || block.observation_time || snapshot.local_time || "").trim() || null;
}
function patchObservationTimeForFreshness(patch: { changes?: Record<string, unknown> } | null | undefined) {
const changes = patch?.changes || {};
return String(
changes.observed_at_utc ||
changes.observed_at_local ||
changes.obs_time ||
"",
).trim() || null;
}
function getWundergroundDailyHigh(hourly: ChartRenderState) {
return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null;
}
type AdvancedWeatherVariableItem = {
key: "wind_dir" | "wind_speed" | "dewpoint" | "humidity" | "pressure";
label: string;
value: string;
};
type SourceCadenceSummary = {
label: string;
cadence: string;
status: string | null;
};
function formatCompactNumber(value: number, decimals = 1) {
const rounded = Number(value.toFixed(decimals));
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(decimals);
}
function fallbackCadenceSeconds(sourceText: string) {
const value = sourceText.toLowerCase();
if (value.includes("amos")) return 60;
if (value.includes("amsc")) return 180;
if (value.includes("madis") || value.includes("hfmetar")) return 300;
if (value.includes("cowin")) return 60;
if (value.includes("hko")) return 600;
if (value.includes("cwa") || value.includes("jma") || value.includes("fmi") || value.includes("knmi")) return 600;
if (value.includes("mgm")) return 900;
if (value.includes("mss") || value.includes("singapore")) return 60;
return null;
}
function formatCadence(seconds: number) {
return `${Math.round(seconds)}s`;
}
function sourceStatusLabel(status: string | null | undefined, isEn: boolean) {
if (!status) return null;
const normalized = status.toLowerCase();
if (normalized === "fresh") return isEn ? "fresh" : "新鲜";
if (normalized === "expected_wait") return isEn ? "waiting for source" : "等待源头";
if (normalized === "delayed") return isEn ? "delayed" : "延迟";
if (normalized === "stale") return isEn ? "stale" : "过旧";
if (normalized === "offline") return isEn ? "offline" : "离线";
return status;
}
function buildSourceCadenceSummary(
row: ScanOpportunityRow | null,
hourly: ChartRenderState,
isEn: boolean,
): SourceCadenceSummary | null {
const primary = hourly?.airportPrimary || hourly?.airportCurrent || null;
const sourceParts = [
(primary as any)?.source,
primary?.source_code,
primary?.source_label,
primary?.station_code,
row?.metar_context?.source,
row?.metar_context?.station_label,
row?.airport,
]
.map((item) => String(item || "").trim())
.filter(Boolean);
const sourceText = sourceParts.join(" ");
const nativeCadence = validNumber(primary?.freshness?.native_update_interval_sec);
const cadenceSeconds = nativeCadence ?? fallbackCadenceSeconds(sourceText);
if (cadenceSeconds === null) return null;
const label =
primary?.source_label ||
primary?.source_code ||
(primary as any)?.source ||
row?.metar_context?.station_label ||
row?.airport ||
(isEn ? "Source" : "数据源");
return {
label,
cadence: formatCadence(cadenceSeconds),
status: sourceStatusLabel(primary?.freshness?.freshness_status, isEn),
};
}
function buildAdvancedWeatherVariableItems(
row: ScanOpportunityRow | null,
hourly: ChartRenderState,
isEn: boolean,
): AdvancedWeatherVariableItem[] {
const primary = hourly?.airportPrimary || hourly?.airportCurrent || null;
const current = hourly?.current || null;
const metarContext = row?.metar_context || null;
const tempSymbol = row?.temp_symbol || primary?.temp_symbol || "°C";
const windDir =
validNumber(primary?.wind_dir) ??
validNumber(current?.wind_dir) ??
validNumber(metarContext?.airport_wind_dir);
const windSpeed =
validNumber(primary?.wind_speed_kt) ??
validNumber(current?.wind_speed_kt) ??
validNumber(metarContext?.airport_wind_speed_kt);
const dewpoint =
validNumber(current?.dewpoint) ??
validNumber((current as any)?.dew_point) ??
validNumber((primary as any)?.dewpoint) ??
validNumber((primary as any)?.dew_point);
const humidity =
validNumber(primary?.humidity) ??
validNumber(current?.humidity) ??
validNumber(metarContext?.airport_humidity);
const pressure =
validNumber(primary?.pressure_hpa) ??
validNumber((current as any)?.pressure_hpa) ??
validNumber((current as any)?.pressure);
const items: AdvancedWeatherVariableItem[] = [];
if (windDir !== null) {
items.push({
key: "wind_dir",
label: isEn ? "Wind Dir" : "风向",
value: `${Math.round(windDir)}°`,
});
}
if (windSpeed !== null) {
items.push({
key: "wind_speed",
label: isEn ? "Wind Speed" : "风速",
value: `${formatCompactNumber(windSpeed)} kt`,
});
}
if (dewpoint !== null) {
items.push({
key: "dewpoint",
label: isEn ? "Dew Point" : "露点",
value: `${formatCompactNumber(dewpoint)}${tempSymbol}`,
});
}
if (humidity !== null) {
items.push({
key: "humidity",
label: isEn ? "Humidity" : "湿度",
value: `${formatCompactNumber(humidity)}%`,
});
}
if (pressure !== null) {
items.push({
key: "pressure",
label: isEn ? "Pressure" : "气压",
value: `${formatCompactNumber(pressure)} hPa`,
});
}
return items;
}
function SourceCadenceStrip({
isEn,
summary,
}: {
isEn: boolean;
summary: SourceCadenceSummary | null;
}) {
if (!summary) return null;
return (
<div className="shrink-0 border-b border-slate-200 bg-slate-50/70 px-4 py-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-600">
<span className="font-bold text-slate-700">
{isEn ? "Source" : "数据源"}: {summary.label}
</span>
<span className="font-mono font-black text-slate-900">
{isEn ? "native cadence" : "源头频率"} {summary.cadence}
</span>
{summary.status && (
<span className="rounded border border-slate-200 bg-white px-1.5 py-0.5 font-semibold text-slate-500">
{summary.status}
</span>
)}
</div>
</div>
);
}
function AdvancedWeatherVariablesStrip({
isEn,
items,
}: {
isEn: boolean;
items: AdvancedWeatherVariableItem[];
}) {
const [expanded, setExpanded] = useState(false);
if (!items.length) return null;
return (
<div className="shrink-0 border-b border-slate-200 bg-white px-4 py-2">
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
className="inline-flex min-h-9 items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-3 text-[11px] font-bold text-slate-600 shadow-sm transition-colors hover:border-slate-300 hover:bg-white hover:text-slate-900"
>
<span>{isEn ? "Advanced Variables" : "高级气象变量"}</span>
<span className="font-mono text-slate-400">{items.length}</span>
{expanded ? <ChevronUp size={13} aria-hidden="true" /> : <ChevronDown size={13} aria-hidden="true" />}
</button>
{expanded && (
<div className="mt-2 grid gap-2 sm:grid-cols-5">
{items.map((item) => (
<div
key={item.key}
className="rounded-md border border-slate-100 bg-slate-50 px-3 py-2"
>
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
{item.label}
</div>
<div className="mt-1 font-mono text-sm font-black text-slate-800">
{item.value}
</div>
</div>
))}
</div>
)}
{expanded && (
<p className="mt-2 text-[11px] leading-5 text-slate-500">
{isEn
? "Context only. These variables help explain capping and boundary-layer structure; they are not settlement-temperature curves."
: "仅作上下文。它们用于解释压温和边界层结构,不是结算温度曲线。"}
</p>
)}
</div>
);
}
function shouldFetchCityDetailForChart({
city,
documentHidden,
isChartVisible,
compact = false,
isActive = false,
isMaximized = false,
slotIndex = 0,
detailLoadReady = true,
}: {
city: string;
documentHidden: boolean;
isChartVisible: boolean;
compact?: boolean;
isActive?: boolean;
isMaximized?: boolean;
slotIndex?: number;
detailLoadReady?: boolean;
}) {
if (!city || !isChartVisible || documentHidden) return false;
if (!compact || isActive || isMaximized) return true;
if (normalizeSlotIndex(slotIndex) < INITIAL_DETAIL_LOAD_SLOTS) return true;
return detailLoadReady;
}
function normalizeSlotIndex(slotIndex: number | null | undefined) {
return Number.isFinite(slotIndex) && Number(slotIndex) >= 0 ? Math.floor(Number(slotIndex)) : 0;
}
function getInitialDetailLoadDelayMs({
compact,
isActive,
isMaximized,
slotIndex,
}: {
compact?: boolean;
isActive?: boolean;
isMaximized?: boolean;
slotIndex?: number;
}) {
if (!compact || isActive || isMaximized) return 0;
const normalizedIndex = normalizeSlotIndex(slotIndex);
if (normalizedIndex < INITIAL_DETAIL_LOAD_SLOTS) return 0;
const deferredWave = Math.floor(
(normalizedIndex - INITIAL_DETAIL_LOAD_SLOTS) / DEFERRED_DETAIL_LOAD_GROUP_SIZE,
);
return DEFERRED_DETAIL_LOAD_DELAY_MS + deferredWave * DEFERRED_DETAIL_LOAD_WAVE_STEP_MS;
}
type HourlyDetailFetchOptions = {
ignoreCache?: boolean;
bypassLocalCache?: boolean;
};
type HourlyDetailFetchRequest = {
source: ChartDetailSource;
fetchOptions?: HourlyDetailFetchOptions;
applyOptions?: { updateLiveTemp?: boolean };
showUserError?: boolean;
isCancelled?: () => boolean;
onEmpty?: () => void;
onError?: () => void;
onSettled?: () => void;
};
function useHourlyDetailFetcher({
city,
targetResolution,
markDetailRequest,
markDetailDegraded,
applySuccessfulHourlyDetail,
}: {
city: string;
targetResolution: string;
markDetailRequest: (source: ChartDetailSource) => void;
markDetailDegraded: (options?: { showUserError?: boolean }) => void;
applySuccessfulHourlyDetail: (data: ChartRenderState, options?: { updateLiveTemp?: boolean }) => void;
}) {
return useCallback(
async ({
source,
fetchOptions = {},
applyOptions,
showUserError = false,
isCancelled,
onEmpty,
onError,
onSettled,
}: HourlyDetailFetchRequest) => {
const cancelled = () => Boolean(isCancelled?.());
if (!city || cancelled()) return null;
markDetailRequest(source);
try {
const data = await fetchFullChartDetailForCity(city, {
...fetchOptions,
resolution: targetResolution,
});
if (cancelled()) return null;
if (!data) {
if (onEmpty) {
onEmpty();
} else {
markDetailDegraded({ showUserError });
}
return null;
}
applySuccessfulHourlyDetail(data, applyOptions);
return data;
} catch {
if (!cancelled()) {
if (onError) {
onError();
} else {
markDetailDegraded({ showUserError });
}
}
return null;
} finally {
if (!cancelled()) onSettled?.();
}
},
[city, targetResolution, markDetailRequest, markDetailDegraded, applySuccessfulHourlyDetail],
);
}
// ── Main component ─────────────────────────────────────────────────────
export function LiveTemperatureThresholdChart({
isEn,
row,
allRows = [],
compact = false,
onSearchClick,
onMaximize,
onClose,
onReportIssue,
isMaximized = false,
disableClose = false,
isActive = !compact,
activationRefreshKey = 0,
slotIndex = 0,
}: {
isEn: boolean;
row: ScanOpportunityRow | null;
allRows?: ScanOpportunityRow[];
compact?: boolean;
onSearchClick?: () => void;
onMaximize?: () => void;
onClose?: () => void;
onReportIssue?: (context: Record<string, unknown>) => void;
isMaximized?: boolean;
disableClose?: boolean;
isActive?: boolean;
activationRefreshKey?: number;
slotIndex?: number;
}) {
const [hourly, setHourly] = useState<ChartRenderState>(null);
const city = String(row?.city || "").toLowerCase().trim();
const latestPatch = useLatestPatch(city);
const resyncVersion = useSseResyncVersion();
const timeframe = "1D";
const [viewMode, setViewMode] = useState<"auto" | "full">("full");
const [userToggledKeys, setUserToggledKeys] = useState<Record<string, boolean>>({});
const [liveTemp, setLiveTemp] = useState<number | null>(null);
const [isHourlyLoading, setIsHourlyLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
const [detailRetryNonce, setDetailRetryNonce] = useState(0);
const [showingStaleDetail, setShowingStaleDetail] = useState(false);
const [chartFreshness, setChartFreshness] = useState<ChartFreshnessState>(() =>
createChartFreshnessState(),
);
const latestRowRef = useRef<ScanOpportunityRow | null>(row);
latestRowRef.current = row;
const getLatestRowSnapshot = useCallback(() => latestRowRef.current, []);
const hasLoadedHourlyDetailRef = useRef(false);
const hourlyCityRef = useRef<string>("");
const chartVisibilityRef = useRef<HTMLDivElement | null>(null);
const lastPatchAtRef = useRef<number>(Date.now());
const lastAppliedPatchRevisionRef = useRef<number>(0);
const lastForegroundRefreshAtRef = useRef<number>(0);
const lastRowObservationSignatureRef = useRef<string>("");
const localDayRolloverFetchDateRef = useRef<string>("");
const [isChartVisible, setIsChartVisible] = useState(
() => typeof IntersectionObserver === "undefined",
);
const [showRunwayDetails, setShowRunwayDetails] = useState<boolean>(true);
const [refAreaLeft, setRefAreaLeft] = useState<number | null>(null);
const [refAreaRight, setRefAreaRight] = useState<number | null>(null);
const [zoomRange, setZoomRange] = useState<[number, number] | null>(null);
const [targetResolution, setTargetResolution] = useState<string>(() =>
prefersHighFrequencyRunwayResolution(row, null) ? "1m" : "10m",
);
const detailLoadDelayMs = useMemo(
() => getInitialDetailLoadDelayMs({ compact, isActive, isMaximized, slotIndex }),
[compact, isActive, isMaximized, slotIndex],
);
const [detailLoadReady, setDetailLoadReady] = useState(() => detailLoadDelayMs === 0);
const [currentCityLocalDate, setCurrentCityLocalDate] = useState(() =>
formatCityLocalDate(row?.tz_offset_seconds),
);
const currentRowObservationSignature = useMemo(() => rowObservationSignature(row), [row]);
const markDetailRequest = useCallback((source: ChartDetailSource) => {
setChartFreshness((prev) => ({
...prev,
detailRequestedAtMs: Date.now(),
detailStatus: "loading",
detailSource: source,
}));
}, []);
const markDetailDegraded = useCallback((options?: { showUserError?: boolean }) => {
const detailErrorMessage = options?.showUserError
? (isEn ? "Detail temporarily unavailable." : "详情暂不可用")
: null;
setDetailError(detailErrorMessage);
setChartFreshness((prev) => ({
...prev,
detailErrorAtMs: Date.now(),
detailStatus: "degraded",
detailSource: "partial_or_timeout",
}));
}, [isEn]);
useEffect(() => {
const now = Date.now();
const previousCity = hourlyCityRef.current;
hourlyCityRef.current = city;
const nextTargetResolution = prefersHighFrequencyRunwayResolution(row, null) ? "1m" : "10m";
const cachedHourly = readCachedHourlyForInitialRow(city, nextTargetResolution);
setUserToggledKeys({});
setZoomRange(null);
setViewMode("full");
setShowRunwayDetails(true);
setTargetResolution(nextTargetResolution);
setHourly((prev) =>
selectInitialHourlyForRowChange({
cachedHourly,
previousCity,
previousHourly: prev,
row,
}),
);
setLiveTemp(null);
setIsHourlyLoading(Boolean(city) && detailLoadDelayMs === 0);
setDetailError(null);
setDetailRetryNonce(0);
setShowingStaleDetail(false);
setChartFreshness(createChartFreshnessState({
rowAppliedAtMs: currentRowObservationSignature ? now : null,
rowObservationTime: rowObservationTimeForFreshness(row),
detailRequestedAtMs: city && detailLoadDelayMs === 0 ? now : null,
detailStatus: city && detailLoadDelayMs === 0 ? "loading" : "idle",
detailSource: city && detailLoadDelayMs === 0 ? "network" : "none",
}));
hasLoadedHourlyDetailRef.current = false;
lastPatchAtRef.current = now;
lastAppliedPatchRevisionRef.current = 0;
lastForegroundRefreshAtRef.current = 0;
lastRowObservationSignatureRef.current = "";
localDayRolloverFetchDateRef.current = "";
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
}, [city, detailLoadDelayMs]);
useEffect(() => {
if (!city) {
setDetailLoadReady(false);
return;
}
if (detailLoadDelayMs <= 0) {
setDetailLoadReady(true);
return;
}
setDetailLoadReady(false);
const id = setTimeout(() => setDetailLoadReady(true), detailLoadDelayMs);
return () => clearTimeout(id);
}, [city, detailLoadDelayMs]);
useEffect(() => {
const node = chartVisibilityRef.current;
if (!node || typeof IntersectionObserver === "undefined") {
setIsChartVisible(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
setIsChartVisible(entry.isIntersecting || entry.intersectionRatio > 0);
},
{ root: null, rootMargin: "160px 0px", threshold: 0.01 },
);
observer.observe(node);
return () => observer.disconnect();
}, []);
useEffect(() => {
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
const id = setInterval(() => {
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
}, 60_000);
return () => clearInterval(id);
}, [row?.tz_offset_seconds]);
const commitHourlySnapshot = useCallback((buildNext: (previous: ChartRenderState) => ChartRenderState) => {
setHourly((prev) => buildNext(prev));
}, []);
const applySuccessfulHourlyDetail = useCallback((data: ChartRenderState, options?: { updateLiveTemp?: boolean }) => {
if (!data) return;
const loadedAtMs = Date.now();
const latestRow = getLatestRowSnapshot();
const rowSeed = seedChartRenderStateFromRow(latestRow);
const dataWithCurrentRow = mergeHourlyWithLiveObservations(data, rowSeed, latestRow);
hasLoadedHourlyDetailRef.current = true;
if (options?.updateLiveTemp) {
const temp = getLiveTempFromHourly(dataWithCurrentRow);
if (temp !== null) setLiveTemp(temp);
}
commitHourlySnapshot((prev) => {
const mergedHourly = mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, latestRow);
return mergedHourly;
});
setDetailError(null);
setShowingStaleDetail(false);
setChartFreshness((prev) => ({
...prev,
detailResolvedAtMs: loadedAtMs,
detailErrorAtMs: null,
detailStatus: "fresh",
detailSource:
prev.detailSource === "none" || prev.detailSource === "partial_or_timeout"
? "network"
: prev.detailSource,
}));
}, [commitHourlySnapshot, getLatestRowSnapshot]);
const runHourlyDetailFetch = useHourlyDetailFetcher({
city,
targetResolution,
markDetailRequest,
markDetailDegraded,
applySuccessfulHourlyDetail,
});
const applyLiveObservationSnapshot = useCallback((snapshot: ObservationSnapshot) => {
if (!snapshot || typeof snapshot !== "object") return;
const appliedAtMs = Date.now();
const condition = snapshot.airport_current || snapshot.airport_primary || snapshot.current || {};
const temp = validNumber(condition.temp);
if (temp !== null) setLiveTemp(temp);
if (typeof snapshot.local_date === "string" && snapshot.local_date) {
setCurrentCityLocalDate(snapshot.local_date);
}
commitHourlySnapshot((prev) =>
mergeObservationSnapshotIntoHourly(
prev ?? seedChartRenderStateFromRow(getLatestRowSnapshot()),
snapshot,
),
);
setChartFreshness((prev) => ({
...prev,
rowAppliedAtMs: appliedAtMs,
rowObservationTime: observationSnapshotTimeForFreshness(snapshot),
}));
}, [commitHourlySnapshot, getLatestRowSnapshot]);
useEffect(() => {
if (!city || !currentRowObservationSignature) return;
if (lastRowObservationSignatureRef.current === currentRowObservationSignature) return;
const now = Date.now();
lastRowObservationSignatureRef.current = currentRowObservationSignature;
const rowSeed = seedChartRenderStateFromRow(row);
const temp = getLiveTempFromHourly(rowSeed);
if (temp !== null) setLiveTemp(temp);
commitHourlySnapshot((prev) => mergeRowObservationIntoHourly(prev, row));
setChartFreshness((prev) => ({
...prev,
rowAppliedAtMs: now,
rowObservationTime: rowObservationTimeForFreshness(row),
}));
}, [city, currentRowObservationSignature, row, commitHourlySnapshot]);
useEffect(() => {
if (!city) {
setIsHourlyLoading(false);
return;
}
const cached = readHourlyDetailSnapshot(city, targetResolution, { allowStale: true });
const cacheAge = cached ? Date.now() - Number(cached.ts || 0) : Number.POSITIVE_INFINITY;
const hasFreshCache = cached && cacheAge >= 0 && cacheAge < HOURLY_CACHE_TTL_MS;
if (cached) {
hasLoadedHourlyDetailRef.current = true;
setHourly(cached.data);
setShowingStaleDetail(!hasFreshCache);
setChartFreshness((prev) => ({
...prev,
detailResolvedAtMs: Number(cached.ts || Date.now()),
detailStatus: hasFreshCache ? "fresh" : "stale_cache",
detailSource: cached.source,
}));
}
if (hasFreshCache) {
setIsHourlyLoading(false);
setDetailError(null);
return;
}
// ── Stale-while-revalidate: show cached data, refresh in background ──
const hasStaleCache = cached && !hasFreshCache;
if (
!shouldFetchCityDetailForChart({
city,
documentHidden:
typeof document !== "undefined" && document.visibilityState === "hidden",
isChartVisible,
compact,
isActive,
isMaximized,
slotIndex,
detailLoadReady,
})
) {
setIsHourlyLoading(false);
return;
}
if (!cached && !hasLoadedHourlyDetailRef.current) {
commitHourlySnapshot((prev) => mergeRowObservationIntoHourly(prev, getLatestRowSnapshot()));
setShowingStaleDetail(false);
}
// Stale cache: don't show loading spinner, refresh silently
if (!hasStaleCache) {
setIsHourlyLoading(true);
}
let cancelled = false;
let retryScheduled = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleTransientDetailRetry = () => {
retryScheduled = true;
markDetailDegraded();
retryTimer = setTimeout(() => {
if (cancelled) return;
void runHourlyDetailFetch({
source: "network",
fetchOptions: { bypassLocalCache: true },
showUserError: true,
isCancelled: () => cancelled,
onSettled: () => setIsHourlyLoading(false),
});
}, TRANSIENT_DETAIL_RETRY_DELAY_MS);
};
const timer = setTimeout(() => {
void runHourlyDetailFetch({
source: "network",
showUserError: true,
isCancelled: () => cancelled,
onEmpty: scheduleTransientDetailRetry,
onSettled: () => {
if (!retryScheduled) setIsHourlyLoading(false);
},
});
}, DETAIL_LOAD_BATCH_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
if (retryTimer !== null) clearTimeout(retryTimer);
};
}, [
city,
targetResolution,
isChartVisible,
compact,
isActive,
isMaximized,
slotIndex,
detailLoadReady,
detailRetryNonce,
getLatestRowSnapshot,
commitHourlySnapshot,
markDetailDegraded,
runHourlyDetailFetch,
]);
useEffect(() => {
if (!latestPatch || latestPatch.revision <= lastAppliedPatchRevisionRef.current) return;
const patchAppliedAtMs = Date.now();
lastAppliedPatchRevisionRef.current = latestPatch.revision;
lastPatchAtRef.current = patchAppliedAtMs;
const tempValue = validNumber(latestPatch.changes.temp);
if (tempValue !== null) setLiveTemp(tempValue);
commitHourlySnapshot((prev) => {
const mergedHourly = mergePatchIntoHourly(prev ?? seedChartRenderStateFromRow(getLatestRowSnapshot()), latestPatch);
return mergedHourly;
});
setChartFreshness((prev) => ({
...prev,
ssePatchAppliedAtMs: patchAppliedAtMs,
sseObservedAt: patchObservationTimeForFreshness(latestPatch),
sseRevision: latestPatch.revision,
sseEmittedAtMs: latestPatch.delivery?.sse_emitted_at_ms ?? null,
sseServerToClientLatencySec: latestPatch.delivery?.server_to_client_latency_sec ?? null,
sseCollectorToClientLatencySec: latestPatch.delivery?.collector_to_client_latency_sec ?? null,
sseSourceToCollectorLatencySec: latestPatch.delivery?.source_to_collector_latency_sec ?? null,
}));
}, [latestPatch, getLatestRowSnapshot, commitHourlySnapshot]);
useEffect(() => {
if (!resyncVersion || !city) return;
let cancelled = false;
void fetchLiveObservationForCity(city).then((snapshot) => {
if (cancelled || !snapshot) return;
applyLiveObservationSnapshot(snapshot);
});
return () => {
cancelled = true;
};
}, [resyncVersion, city, applyLiveObservationSnapshot]);
// ── SSE fallback: visible charts merge no-store observations if patches stop. ──
useEffect(() => {
if (!shouldPollLiveChart({ city, compact, isActive, isMaximized })) return;
let cancelled = false;
const refreshLiveObservation = () => {
const now = Date.now();
lastPatchAtRef.current = now;
void fetchLiveObservationForCity(city).then((snapshot) => {
if (cancelled || !snapshot) return;
applyLiveObservationSnapshot(snapshot);
});
};
const checkFallback = () => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
if (Date.now() - lastPatchAtRef.current < LIVE_OBSERVATION_FALLBACK_MS) return;
refreshLiveObservation();
};
refreshLiveObservation();
const id = setInterval(checkFallback, 60_000);
return () => {
cancelled = true;
clearInterval(id);
};
}, [city, compact, isActive, isMaximized, applyLiveObservationSnapshot]);
useEffect(() => {
if (!activationRefreshKey) return;
if (!shouldPollLiveChart({ city, compact, isActive, isMaximized })) return;
if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
let cancelled = false;
const refreshActivatedCachedDetail = () => {
const now = Date.now();
if (now - lastForegroundRefreshAtRef.current < 10_000) return;
lastForegroundRefreshAtRef.current = now;
lastPatchAtRef.current = now;
void runHourlyDetailFetch({
source: "network",
fetchOptions: { bypassLocalCache: true },
applyOptions: { updateLiveTemp: true },
isCancelled: () => cancelled,
onSettled: () => setIsHourlyLoading(false),
});
};
refreshActivatedCachedDetail();
return () => {
cancelled = true;
};
}, [
activationRefreshKey,
city,
compact,
isActive,
isMaximized,
targetResolution,
runHourlyDetailFetch,
]);
useEffect(() => {
if (!shouldPollLiveChart({ city, compact, isActive, isMaximized })) return;
let cancelled = false;
const refreshForegroundFullDetail = () => {
const now = Date.now();
const cacheAge = readHourlyDetailSnapshotAgeMs(city, targetResolution);
if (
now - lastForegroundRefreshAtRef.current < FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS ||
(cacheAge >= 0 && cacheAge < FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS)
) {
return;
}
lastForegroundRefreshAtRef.current = now;
lastPatchAtRef.current = now;
void runHourlyDetailFetch({
source: "network",
fetchOptions: { bypassLocalCache: true },
applyOptions: { updateLiveTemp: true },
isCancelled: () => cancelled,
});
};
const handleVisibilityChange = () => {
if (document.visibilityState !== "visible") return;
refreshForegroundFullDetail();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("focus", refreshForegroundFullDetail);
return () => {
cancelled = true;
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("focus", refreshForegroundFullDetail);
};
}, [city, compact, isActive, isMaximized, targetResolution, runHourlyDetailFetch]);
useEffect(() => {
if (!city || !currentCityLocalDate) return;
const loadedLocalDate = hourly?.localDate || row?.local_date || "";
if (currentCityLocalDate === loadedLocalDate) return;
if (localDayRolloverFetchDateRef.current === currentCityLocalDate) return;
localDayRolloverFetchDateRef.current = currentCityLocalDate;
let cancelled = false;
void runHourlyDetailFetch({
source: "force_refresh",
fetchOptions: { ignoreCache: true },
isCancelled: () => cancelled,
onError: () => {
localDayRolloverFetchDateRef.current = "";
markDetailDegraded();
},
});
return () => {
cancelled = true;
};
}, [city, currentCityLocalDate, hourly?.localDate, row?.local_date, targetResolution, markDetailDegraded, runHourlyDetailFetch]);
const chartHourly = useMemo<ChartRenderState>(() => {
if (!hourly) return hourly;
const loadedLocalDate = hourly.localDate || row?.local_date || "";
if (currentCityLocalDate && currentCityLocalDate !== loadedLocalDate) {
return { ...hourly, localDate: currentCityLocalDate };
}
return hourly;
}, [hourly, currentCityLocalDate, row?.local_date]);
const chartLocalDate = chartHourly?.localDate || row?.local_date || currentCityLocalDate;
const { data, series, probabilityOverlay } = useMemo(() => buildFullDayChartData(row, chartHourly, isEn), [row, chartHourly, isEn]);
const peakGlow = useMemo(() => getPeakGlowState(row, data, series), [row, data, series]);
const autoWindowRange = useMemo(
() => (viewMode === "auto" ? getDebPeakWindowRange(data, series) : null),
[data, series, viewMode],
);
const visibleRange = zoomRange ?? autoWindowRange;
const visibleRangeKey = visibleRange ? `${visibleRange[0]}:${visibleRange[1]}` : "full";
const shouldUseRunwayResolution = useMemo(
() => prefersHighFrequencyRunwayResolution(row, chartHourly),
[row, chartHourly],
);
const zoomedData = useMemo(() => {
if (!visibleRange || data.length === 0) return data;
const [start, end] = visibleRange;
return data.slice(start, end + 1);
}, [data, visibleRangeKey]);
const nextTargetResolution = useMemo(() => {
if (shouldUseRunwayResolution) {
return "1m";
}
if (visibleRange && data.length > 0) {
const zoomedData = data.slice(visibleRange[0], visibleRange[1] + 1);
if (zoomedData.length > 0) {
const startTs = zoomedData[0].ts;
const endTs = zoomedData[zoomedData.length - 1].ts;
if (endTs - startTs <= 2 * 60 * 60 * 1000) {
return "1m";
}
}
}
return "10m";
}, [data, visibleRangeKey, shouldUseRunwayResolution]);
useEffect(() => {
if (targetResolution !== nextTargetResolution) {
setTargetResolution(nextTargetResolution);
}
}, [targetResolution, nextTargetResolution]);
const tzOffset = row?.tz_offset_seconds ?? 0;
const settlementObs = useMemo(() => {
let obs = normObs(chartHourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset, undefined, chartLocalDate || null);
if (!obs.length && !chartHourly?.runwayPlateHistory) {
const mObs = normObs(chartHourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs, tzOffset, undefined, chartLocalDate || null);
if (mObs.length > 0) {
obs = mObs;
}
}
return obs;
}, [row, chartHourly, tzOffset, chartLocalDate]);
const runwayPlates = useMemo(() => buildRunwayPlates(chartHourly?.amos, row, settlementObs), [chartHourly?.amos, row, settlementObs]);
const hasRunwaySeries = useMemo(
() =>
series.some(
(item) =>
item.key.startsWith("runway_") &&
item.key !== "runway_max" &&
item.values.some((value) => validNumber(value) !== null),
),
[series],
);
const hasRunwayData = runwayPlates.length > 0 || hasRunwaySeries;
const settlementPlate = useMemo(() => runwayPlates.find((p) => p.isSettlement), [runwayPlates]);
const chartSeries = useMemo(() => {
return series;
}, [series]);
const isSeriesVisible = useCallback((sKey: string) => {
if (userToggledKeys[sKey] !== undefined) {
return userToggledKeys[sKey];
}
return isTemperatureSeriesVisibleByDefault(city, sKey);
}, [city, userToggledKeys]);
const activeSeries = useMemo(() => {
return getActiveTemperatureSeries(
city,
chartSeries,
userToggledKeys,
showRunwayDetails,
);
}, [chartSeries, userToggledKeys, city, showRunwayDetails]);
const {
isHKO,
isShenzhen,
metarHeaderLabel,
metarHighLabel,
runwayHeaderLabel,
runwayHighLabel,
} = useMemo(() => getLiveObservationLabels(row, chartHourly), [row, chartHourly]);
const { currentMetarTemp, currentRunwayTemp, observedHighMetar, observedHighRunway } = useMemo(
() => getObservationDisplayMetrics(row, chartHourly, settlementPlate),
[row, chartHourly, settlementPlate],
);
const displayRunwayTemp = selectDisplayRunwayTemp(liveTemp, currentRunwayTemp, hasRunwayData);
const displayMetarTemp = selectCompactSecondaryTemp({
isHKO,
isShenzhen,
displayMetarTemp: currentMetarTemp,
observedHighMetar,
});
const wundergroundDailyHigh = getWundergroundDailyHigh(chartHourly);
const localDateStr = chartLocalDate || new Date().toISOString().slice(0, 10);
const modelSources = (row?.model_cluster_sources && Object.keys(row.model_cluster_sources).length > 0)
? row.model_cluster_sources
: (chartHourly?.multiModelDaily?.[localDateStr]?.models || null);
const modelValues = Object.values(modelSources || {})
.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(chartHourly?.debPrediction) ?? 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 sourceCadenceSummary = useMemo(
() => buildSourceCadenceSummary(row, chartHourly, isEn),
[chartHourly, isEn, row],
);
const advancedWeatherVariables = useMemo(
() => buildAdvancedWeatherVariableItems(row, chartHourly, isEn),
[chartHourly, isEn, row],
);
const formattedUpdateTime = useMemo(() => {
return formatCityLocalDateTime(row?.tz_offset_seconds);
}, [row]);
const cityThresholds = useMemo(() => {
if (!row || !allRows || !allRows.length) return [];
const tempSymbol = row.temp_symbol || "°C";
const cityKey = String(row.city || "").toLowerCase().trim();
const sameCityRows = allRows.filter(
(r) => String(r.city || "").toLowerCase().trim() === cityKey
);
const seen = new Set<number>();
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}${tempSymbol}`,
isBreached,
kind,
});
});
return list.sort((a, b) => a.threshold - b.threshold);
}, [row, allRows]);
const intDegreeTicks = useMemo(
() => buildIntDegreeTicks(activeSeries, zoomedData, probabilityOverlay),
[activeSeries, zoomedData, probabilityOverlay],
);
const chartDomain = useMemo(
() => buildChartDomain(activeSeries, zoomedData, probabilityOverlay),
[activeSeries, zoomedData, probabilityOverlay],
);
const subtitle = row ? (isEn ? "Live & Forecast" : "实测与预测") : "";
const showDetailErrorBadge = !compact || isActive || isMaximized;
const chartFreshnessContext = useMemo(
() => buildChartFreshnessContext(chartFreshness),
[chartFreshness],
);
const detailBatchDiagnostics = useMemo(
() => readCityDetailBatchDiagnostics(city, targetResolution),
[city, targetResolution, chartFreshness.detailRequestedAtMs, chartFreshness.detailErrorAtMs, chartFreshness.detailResolvedAtMs],
);
const handleZoomReset = useCallback(() => {
setZoomRange(null);
}, []);
const handleViewModeChange = useCallback((mode: "auto" | "full") => {
setViewMode(mode);
setZoomRange(null);
}, []);
const handleMouseDown = useCallback((e: any) => {
if (compact || !e) return;
if (typeof e.activeTooltipIndex === "number") {
setRefAreaLeft(e.activeTooltipIndex);
setRefAreaRight(e.activeTooltipIndex);
}
}, [compact]);
const handleMouseMove = useCallback((e: any) => {
if (compact || !e || refAreaLeft === null) return;
if (typeof e.activeTooltipIndex === "number") {
setRefAreaRight(e.activeTooltipIndex);
}
}, [compact, refAreaLeft]);
const handleMouseUp = useCallback(() => {
if (refAreaLeft === null || refAreaRight === null) {
setRefAreaLeft(null);
setRefAreaRight(null);
return;
}
let leftIdx = refAreaLeft;
let rightIdx = refAreaRight;
if (leftIdx > rightIdx) {
[leftIdx, rightIdx] = [rightIdx, leftIdx];
}
if (rightIdx - leftIdx >= 1) {
const originalStartIndex = visibleRange ? visibleRange[0] : 0;
const newStart = originalStartIndex + leftIdx;
const newEnd = originalStartIndex + rightIdx;
setZoomRange([newStart, newEnd]);
}
setRefAreaLeft(null);
setRefAreaRight(null);
}, [refAreaLeft, refAreaRight, visibleRange]);
const handleSeriesToggle = useCallback((seriesKey: string) => {
setUserToggledKeys((prev) => ({
...prev,
[seriesKey]: !isSeriesVisible(seriesKey),
}));
}, [isSeriesVisible]);
const handleRetryDetail = useCallback(() => {
setDetailError(null);
setIsHourlyLoading(true);
markDetailRequest("network");
setDetailRetryNonce((value) => value + 1);
}, [markDetailRequest]);
const handleReportIssue = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onReportIssue?.({
source: "chart",
city,
display_city: row ? rowName(row) : "",
row_id: row?.id || "",
slot_index: slotIndex,
compact,
is_active: isActive,
is_maximized: isMaximized,
detail_error: detailError,
freshness: chartFreshnessContext,
detail_batch_diagnostics: detailBatchDiagnostics,
live_path: chartFreshnessContext.live_path,
live_age_sec: chartFreshnessContext.live_age_sec,
detail_status: chartFreshnessContext.detail_status,
detail_source: chartFreshnessContext.detail_source,
is_hourly_loading: isHourlyLoading,
showing_stale_detail: showingStaleDetail,
target_resolution: targetResolution,
view_mode: viewMode,
loaded_local_date: chartLocalDate || "",
has_runway_data: hasRunwayData,
series: chartSeries.map((item) => item.key),
visible_series: activeSeries.map((item) => item.key),
live_temp: liveTemp,
current_runway_temp: currentRunwayTemp,
current_metar_temp: currentMetarTemp,
observed_high_metar: observedHighMetar,
observed_high_runway: observedHighRunway,
});
}, [
activeSeries,
chartLocalDate,
chartFreshnessContext,
chartSeries,
city,
compact,
currentMetarTemp,
currentRunwayTemp,
detailBatchDiagnostics,
detailError,
hasRunwayData,
isActive,
isHourlyLoading,
isMaximized,
liveTemp,
observedHighMetar,
observedHighRunway,
onReportIssue,
row,
showingStaleDetail,
slotIndex,
targetResolution,
viewMode,
]);
const panelTitle = row ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={onSearchClick}
className={clsx(
"flex items-center gap-1.5 px-1.5 py-0.5 rounded text-left transition-colors font-bold text-slate-800 outline-none select-none",
onSearchClick ? "hover:bg-slate-200/80 cursor-pointer" : ""
)}
>
<span>{rowName(row)}</span>
{onSearchClick && <span className="text-[8px] text-slate-400"></span>}
</button>
<span className="text-slate-400 font-normal">·</span>
<span className="text-slate-500 font-normal">{subtitle}</span>
{peakGlow.state !== "none" && (
<span
className={clsx(
"ml-1 rounded border px-1.5 py-0.5 text-[9px] font-black normal-case tracking-normal",
PEAK_GLOW_BADGE_CLASS[peakGlow.state],
)}
title={peakGlowTitle(peakGlow.state, peakGlow.distanceToHigh, isEn)}
>
{peakGlowLabel(peakGlow.state, isEn)}
</span>
)}
</div>
) : isEn ? (
"Temperature Chart"
) : (
"气温图表"
);
const timeframeActions = (
<div className="flex items-center gap-1.5">
{zoomRange && (
<button
type="button"
onClick={handleZoomReset}
className="px-2 py-0.5 text-[9px] font-bold rounded bg-slate-100 hover:bg-slate-200 text-slate-700 border border-slate-300 shadow-sm transition-all cursor-pointer"
>
{isEn ? "Reset Zoom" : "重置缩放"}
</button>
)}
<div className="flex items-center gap-1 rounded bg-[#eef2f6] p-0.5 border border-slate-200">
{(["auto", "full"] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => handleViewModeChange(mode)}
className={clsx(
"px-2 py-0.5 text-[9px] font-bold rounded transition-all cursor-pointer",
viewMode === mode
? "bg-white text-blue-600 shadow-sm border border-slate-200/50"
: "text-slate-500 hover:text-slate-800"
)}
>
{mode === "auto" ? (isEn ? "Peak" : "高温") : (isEn ? "All Day" : "全天")}
</button>
))}
</div>
{(onMaximize || onClose || onReportIssue) && (
<div className="flex items-center gap-1">
{onReportIssue && (
<button
type="button"
onClick={handleReportIssue}
className="grid h-6 w-6 place-items-center rounded bg-white hover:bg-slate-50 border border-slate-200 text-slate-500 hover:text-amber-600 transition-colors shadow-sm"
title={isEn ? "Report this chart" : "反馈此图表"}
>
<Bug size={13} />
</button>
)}
{onMaximize && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onMaximize();
}}
className="grid h-6 w-6 place-items-center rounded bg-white hover:bg-slate-50 border border-slate-200 text-slate-500 hover:text-slate-800 transition-colors shadow-sm"
title={isMaximized ? (isEn ? "Restore Grid" : "还原网格") : (isEn ? "Maximize" : "最大化")}
>
{isMaximized ? "❐" : "⛶"}
</button>
)}
{onClose && (
<button
type="button"
disabled={disableClose}
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className={clsx(
"grid h-6 w-6 place-items-center rounded border transition-colors shadow-sm",
disableClose
? "bg-slate-50 text-slate-300 border-slate-100 cursor-not-allowed"
: "bg-white hover:bg-slate-50 border-slate-200 text-slate-500 hover:text-red-600"
)}
title={isEn ? "Clear Slot" : "清除槽位"}
>
</button>
)}
</div>
)}
</div>
);
return (
<Panel
title={panelTitle}
actions={timeframeActions}
className={PEAK_GLOW_PANEL_CLASS[peakGlow.state]}
>
<div ref={chartVisibilityRef} className={clsx("flex h-full flex-col", compact ? "min-h-0" : "min-h-[300px]")}>
<TemperatureStatsBars
isEn={isEn}
compact={compact}
timeframe={timeframe}
tempSymbol={row?.temp_symbol || "°C"}
runwayHeaderLabel={runwayHeaderLabel}
metarHeaderLabel={metarHeaderLabel}
runwayHighLabel={runwayHighLabel}
metarHighLabel={metarHighLabel}
isShenzhen={isShenzhen}
displayRunwayTemp={displayRunwayTemp}
displayMetarTemp={displayMetarTemp}
observedHighMetar={observedHighMetar}
observedHighRunway={observedHighRunway}
wundergroundDailyHigh={wundergroundDailyHigh}
debVal={debVal}
debQuality={chartHourly?.debQuality || null}
modelMin={modelMin}
modelMax={modelMax}
spread={spread}
spreadLabel={spreadLabel}
spreadLabelEn={spreadLabelEn}
formattedUpdateTime={formattedUpdateTime}
/>
{timeframe === "1D" && !compact && (
<TemperatureRunwayDetails isEn={isEn} plates={runwayPlates} tempSymbol={row?.temp_symbol || "°C"} />
)}
{timeframe === "1D" && !compact && (
<ModelCurvesSummary isEn={isEn} activeSeries={activeSeries} tempSymbol={row?.temp_symbol || "°C"} />
)}
{timeframe === "1D" && !compact && (
<SourceCadenceStrip isEn={isEn} summary={sourceCadenceSummary} />
)}
{timeframe === "1D" && !compact && (
<AdvancedWeatherVariablesStrip
key={city || "advanced-weather"}
isEn={isEn}
items={advancedWeatherVariables}
/>
)}
<TemperatureChartCanvas
isEn={isEn}
compact={compact}
timeframe={timeframe}
row={row}
cityThresholds={cityThresholds}
chartSeries={chartSeries}
activeSeries={activeSeries}
probabilityOverlay={probabilityOverlay}
zoomedData={zoomedData}
chartDomain={chartDomain}
intDegreeTicks={intDegreeTicks}
hasRunwayData={hasRunwayData}
showRunwayDetails={showRunwayDetails}
isHourlyLoading={isHourlyLoading}
detailError={detailError}
detailStatus={chartFreshness.detailStatus}
showingStaleDetail={showingStaleDetail}
showDetailErrorBadge={showDetailErrorBadge}
refAreaLeft={refAreaLeft}
refAreaRight={refAreaRight}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onZoomReset={handleZoomReset}
isSeriesVisible={isSeriesVisible}
onSeriesToggle={handleSeriesToggle}
onShowRunwayDetailsChange={setShowRunwayDetails}
onRetryDetail={handleRetryDetail}
/>
</div>
</Panel>
);
}
export function __buildTemperatureChartDataForTest(
row: ScanOpportunityRow | null,
hourly: ChartRenderState,
_timeframe = "1D",
isEn = false,
) {
return buildFullDayChartData(row, hourly, isEn);
}
export const __isTemperatureSeriesVisibleByDefaultForTest = isTemperatureSeriesVisibleByDefault;
export const __getVisibleTemperatureSeriesForTest = getVisibleTemperatureSeries;
export const __getActiveTemperatureSeriesForTest = getActiveTemperatureSeries;
export const __getDebPeakWindowRangeForTest = getDebPeakWindowRange;
export const __getLiveObservationLabelsForTest = getLiveObservationLabels;
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
export const __getPeakGlowStateForTest = getPeakGlowState;
export const __getWundergroundDailyHighForTest = getWundergroundDailyHigh;
export const __formatCityLocalDateForTest = formatCityLocalDate;
export const __formatCityLocalDateTimeForTest = formatCityLocalDateTime;
export const __getInitialDetailLoadDelayMsForTest = getInitialDetailLoadDelayMs;
export const __shouldFetchCityDetailForChartForTest = shouldFetchCityDetailForChart;
export const __shouldPollLiveChartForTest = shouldPollLiveChart;
export const __buildChartFreshnessContextForTest = buildChartFreshnessContext;
export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly;
export const __selectCompactSecondaryTempForTest = selectCompactSecondaryTemp;
export const __selectDisplayRunwayTempForTest = selectDisplayRunwayTemp;
export const __buildAdvancedWeatherVariableItemsForTest = buildAdvancedWeatherVariableItems;
export const __buildSourceCadenceSummaryForTest = buildSourceCadenceSummary;