1710 lines
59 KiB
TypeScript
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;
|