feat: implement SSE-based real-time event distribution system with replay support and heartbeat functionality

This commit is contained in:
2569718930@qq.com
2026-05-26 22:27:55 +08:00
parent 5c6ffa4742
commit 645e304b3e
20 changed files with 1449 additions and 119 deletions
@@ -24,7 +24,7 @@ import type {
} from "@/lib/dashboard-types";
import { buildDebBaselinePath } from "@/lib/temperature-chart-paths";
import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy";
import { useLatestPatch, type CityPatch } from "@/hooks/use-sse-patches";
import { useLatestPatch, useSseResyncVersion, type CityPatch } from "@/hooks/use-sse-patches";
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
import { rowName, temp } from "@/components/dashboard/scan-terminal/utils";
@@ -82,6 +82,28 @@ function getVisibleTemperatureSeries(
});
}
function getActiveTemperatureSeries(
city: string,
chartSeries: EvidenceSeries[],
userToggledKeys: Record<string, boolean>,
showRunwayDetails: boolean,
) {
const rawVisible = getVisibleTemperatureSeries(city, chartSeries, userToggledKeys);
const hasRunwayMax = rawVisible.some((item) => item.key === "runway_max");
return rawVisible.filter((item) => {
const isIndividualRunway =
item.key.startsWith("runway_") && item.key !== "runway_max";
if (showRunwayDetails) {
return item.key !== "runway_max";
}
if (!hasRunwayMax) {
return true;
}
return !isIndividualRunway;
});
}
function buildRunwayPlates(
amos: AmosData | null | undefined,
row: ScanOpportunityRow | null,
@@ -194,8 +216,8 @@ type RunwayHistorySeries = {
const MAX_OBS_POINTS = 1440;
const HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
const FULL_DAY_SLOT_MINUTES = 30;
const FULL_DAY_SLOTS = 48;
const FULL_DAY_SLOT_MINUTES = 1;
const FULL_DAY_SLOTS = (24 * 60) / FULL_DAY_SLOT_MINUTES;
const SLOT_INTERVAL_MS = FULL_DAY_SLOT_MINUTES * 60 * 1000;
const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>();
const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>();
@@ -622,7 +644,12 @@ function mergePatchIntoHourly(
const amosChanges = changes.amos as Record<string, any> | undefined;
const obsTimeVal = obsTime || amosChanges?.observation_time || amosChanges?.observation_time_local;
const runwayObs = amosChanges?.runway_obs;
if (runwayObs && Array.isArray(runwayObs.point_temperatures) && obsTimeVal) {
const runwayPoints = Array.isArray(changes.runway_points)
? changes.runway_points
: runwayObs && Array.isArray(runwayObs.point_temperatures)
? runwayObs.point_temperatures
: [];
if (runwayPoints.length && obsTimeVal) {
const history: Record<string, Array<Record<string, unknown>>> = {};
const sourceHistory = next.runwayPlateHistory || (next.amos as any)?.runway_plate_history || {};
@@ -634,10 +661,10 @@ function mergePatchIntoHourly(
});
// Append new points from point_temperatures
runwayObs.point_temperatures.forEach((pt: any) => {
runwayPoints.forEach((pt: any) => {
const rwy = pt.runway || "";
if (!rwy) return;
const tempVal = validNumber(pt.target_runway_max) ?? validNumber(pt.tdz_temp) ?? validNumber(pt.end_temp);
const tempVal = validNumber(pt.temp) ?? validNumber(pt.target_runway_max) ?? validNumber(pt.tdz_temp) ?? validNumber(pt.end_temp);
if (tempVal === null) return;
const rwyHistory = history[rwy] || [];
@@ -653,6 +680,13 @@ function mergePatchIntoHourly(
});
next.runwayPlateHistory = history;
next.amos = {
...(next.amos || {}),
runway_obs: {
...((next.amos as any)?.runway_obs || {}),
point_temperatures: runwayPoints,
},
} as any;
if (next.amos) {
(next.amos as any).runway_plate_history = history;
}
@@ -1405,13 +1439,14 @@ export function LiveTemperatureThresholdChart({
const [hourly, setHourly] = useState<HourlyForecast>(null);
const city = String(row?.city || "").toLowerCase().trim();
const latestPatch = useLatestPatch(city);
const resyncVersion = useSseResyncVersion();
const [timeframe, setTimeframe] = useState<"1D" | "3D">("1D");
const [userToggledKeys, setUserToggledKeys] = useState<Record<string, boolean>>({});
const [liveTemp, setLiveTemp] = useState<number | null>(null);
const lastPatchAtRef = useRef<number>(Date.now());
const lastAppliedPatchRevisionRef = useRef<number>(0);
const [showRunwayDetails, setShowRunwayDetails] = useState<boolean>(false);
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);
@@ -1420,6 +1455,7 @@ export function LiveTemperatureThresholdChart({
useEffect(() => {
setUserToggledKeys({});
setZoomRange(null);
setShowRunwayDetails(true);
lastPatchAtRef.current = Date.now();
lastAppliedPatchRevisionRef.current = 0;
}, [city, timeframe]);
@@ -1480,6 +1516,20 @@ export function LiveTemperatureThresholdChart({
setHourly((prev) => mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(row), latestPatch));
}, [latestPatch, row]);
useEffect(() => {
if (!resyncVersion || !city) return;
let cancelled = false;
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
.then((data) => {
if (cancelled || !data) return;
setHourly(data);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [resyncVersion, city, targetResolution]);
// ── SSE fallback: only full-fetch if a visible chart has seen no patch for 2 minutes ──
useEffect(() => {
if (!shouldPollLiveChart({ city, compact, isActive, isMaximized })) return;
@@ -1581,15 +1631,12 @@ export function LiveTemperatureThresholdChart({
};
const activeSeries = useMemo(() => {
const rawVisible = getVisibleTemperatureSeries(city, chartSeries, userToggledKeys);
return rawVisible.filter((s) => {
const isIndividualRunway = s.key.startsWith("runway_") && s.key !== "runway_max";
if (showRunwayDetails) {
return s.key !== "runway_max";
} else {
return !isIndividualRunway;
}
});
return getActiveTemperatureSeries(
city,
chartSeries,
userToggledKeys,
showRunwayDetails,
);
}, [chartSeries, userToggledKeys, city, showRunwayDetails]);
const normalizedKey = normalizeCityKey(row?.city);
@@ -1903,7 +1950,7 @@ export function LiveTemperatureThresholdChart({
</div>
<div className="flex flex-col">
<span className="text-[11px] font-semibold text-slate-500 uppercase tracking-wider">
{isEn ? "METAR Settlement (30m) · Daily High" : `${metarHeaderLabel} · 当日最高`}
{isEn ? "METAR Settlement · Daily High" : `${metarHeaderLabel} · 当日最高`}
</span>
<span className="text-2xl font-bold font-mono text-blue-600 mt-1">
{temp(observedHighMetar)}
@@ -2226,5 +2273,7 @@ export function __buildTemperatureChartDataForTest(
export const __isTemperatureSeriesVisibleByDefaultForTest = isTemperatureSeriesVisibleByDefault;
export const __getVisibleTemperatureSeriesForTest = getVisibleTemperatureSeries;
export const __getActiveTemperatureSeriesForTest = getActiveTemperatureSeries;
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
export const __shouldPollLiveChartForTest = shouldPollLiveChart;
export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly;