重命名图表渲染态类型

This commit is contained in:
2569718930@qq.com
2026-06-17 00:14:02 +08:00
parent 491415427c
commit e1376108f9
4 changed files with 101 additions and 94 deletions
@@ -19,7 +19,7 @@ import {
buildFullDayChartData, buildFullDayChartData,
buildIntDegreeTicks, buildIntDegreeTicks,
buildRunwayPlates, buildRunwayPlates,
fetchHourlyForecastForCity, fetchFullChartDetailForCity,
fetchLiveObservationForCity, fetchLiveObservationForCity,
getActiveTemperatureSeries, getActiveTemperatureSeries,
getDebPeakWindowRange, getDebPeakWindowRange,
@@ -41,10 +41,10 @@ import {
selectCompactSecondaryTemp, selectCompactSecondaryTemp,
selectDisplayRunwayTemp, selectDisplayRunwayTemp,
selectInitialHourlyForRowChange, selectInitialHourlyForRowChange,
seedHourlyForecastFromRow, seedChartRenderStateFromRow,
shouldPollLiveChart, shouldPollLiveChart,
validNumber, validNumber,
type HourlyForecast, type ChartRenderState,
type ObservationSnapshot, type ObservationSnapshot,
} from "@/components/dashboard/scan-terminal/temperature-chart-logic"; } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
export { clearCityDetailCache } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; export { clearCityDetailCache } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
@@ -125,7 +125,7 @@ function formatCityLocalDateTime(tzOffsetSeconds: number | null | undefined) {
return `${y}-${mo}-${d} ${hh}:${mm}:${ss}`; return `${y}-${mo}-${d} ${hh}:${mm}:${ss}`;
} }
function getLiveTempFromHourly(data: HourlyForecast) { function getLiveTempFromHourly(data: ChartRenderState) {
return validNumber(data?.airportCurrent?.temp) ?? validNumber(data?.airportPrimary?.temp) ?? null; return validNumber(data?.airportCurrent?.temp) ?? validNumber(data?.airportPrimary?.temp) ?? null;
} }
@@ -268,7 +268,7 @@ function patchObservationTimeForFreshness(patch: { changes?: Record<string, unkn
).trim() || null; ).trim() || null;
} }
function getWundergroundDailyHigh(hourly: HourlyForecast) { function getWundergroundDailyHigh(hourly: ChartRenderState) {
return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null; return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null;
} }
@@ -319,7 +319,7 @@ function sourceStatusLabel(status: string | null | undefined, isEn: boolean) {
function buildSourceCadenceSummary( function buildSourceCadenceSummary(
row: ScanOpportunityRow | null, row: ScanOpportunityRow | null,
hourly: HourlyForecast, hourly: ChartRenderState,
isEn: boolean, isEn: boolean,
): SourceCadenceSummary | null { ): SourceCadenceSummary | null {
const primary = hourly?.airportPrimary || hourly?.airportCurrent || null; const primary = hourly?.airportPrimary || hourly?.airportCurrent || null;
@@ -355,7 +355,7 @@ function buildSourceCadenceSummary(
function buildAdvancedWeatherVariableItems( function buildAdvancedWeatherVariableItems(
row: ScanOpportunityRow | null, row: ScanOpportunityRow | null,
hourly: HourlyForecast, hourly: ChartRenderState,
isEn: boolean, isEn: boolean,
): AdvancedWeatherVariableItem[] { ): AdvancedWeatherVariableItem[] {
const primary = hourly?.airportPrimary || hourly?.airportCurrent || null; const primary = hourly?.airportPrimary || hourly?.airportCurrent || null;
@@ -578,7 +578,7 @@ function useHourlyDetailFetcher({
targetResolution: string; targetResolution: string;
markDetailRequest: (source: ChartDetailSource) => void; markDetailRequest: (source: ChartDetailSource) => void;
markDetailDegraded: (options?: { showUserError?: boolean }) => void; markDetailDegraded: (options?: { showUserError?: boolean }) => void;
applySuccessfulHourlyDetail: (data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => void; applySuccessfulHourlyDetail: (data: ChartRenderState, options?: { updateLiveTemp?: boolean }) => void;
}) { }) {
return useCallback( return useCallback(
async ({ async ({
@@ -596,7 +596,7 @@ function useHourlyDetailFetcher({
markDetailRequest(source); markDetailRequest(source);
try { try {
const data = await fetchHourlyForecastForCity(city, { const data = await fetchFullChartDetailForCity(city, {
...fetchOptions, ...fetchOptions,
resolution: targetResolution, resolution: targetResolution,
}); });
@@ -660,7 +660,7 @@ export function LiveTemperatureThresholdChart({
activationRefreshKey?: number; activationRefreshKey?: number;
slotIndex?: number; slotIndex?: number;
}) { }) {
const [hourly, setHourly] = useState<HourlyForecast>(null); const [hourly, setHourly] = useState<ChartRenderState>(null);
const city = String(row?.city || "").toLowerCase().trim(); const city = String(row?.city || "").toLowerCase().trim();
const latestPatch = useLatestPatch(city); const latestPatch = useLatestPatch(city);
const resyncVersion = useSseResyncVersion(); const resyncVersion = useSseResyncVersion();
@@ -807,15 +807,15 @@ export function LiveTemperatureThresholdChart({
return () => clearInterval(id); return () => clearInterval(id);
}, [row?.tz_offset_seconds]); }, [row?.tz_offset_seconds]);
const commitHourlySnapshot = useCallback((buildNext: (previous: HourlyForecast) => HourlyForecast) => { const commitHourlySnapshot = useCallback((buildNext: (previous: ChartRenderState) => ChartRenderState) => {
setHourly((prev) => buildNext(prev)); setHourly((prev) => buildNext(prev));
}, []); }, []);
const applySuccessfulHourlyDetail = useCallback((data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => { const applySuccessfulHourlyDetail = useCallback((data: ChartRenderState, options?: { updateLiveTemp?: boolean }) => {
if (!data) return; if (!data) return;
const loadedAtMs = Date.now(); const loadedAtMs = Date.now();
const latestRow = getLatestRowSnapshot(); const latestRow = getLatestRowSnapshot();
const rowSeed = seedHourlyForecastFromRow(latestRow); const rowSeed = seedChartRenderStateFromRow(latestRow);
const dataWithCurrentRow = mergeHourlyWithLiveObservations(data, rowSeed, latestRow); const dataWithCurrentRow = mergeHourlyWithLiveObservations(data, rowSeed, latestRow);
hasLoadedHourlyDetailRef.current = true; hasLoadedHourlyDetailRef.current = true;
if (options?.updateLiveTemp) { if (options?.updateLiveTemp) {
@@ -859,7 +859,7 @@ export function LiveTemperatureThresholdChart({
} }
commitHourlySnapshot((prev) => commitHourlySnapshot((prev) =>
mergeObservationSnapshotIntoHourly( mergeObservationSnapshotIntoHourly(
prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()), prev ?? seedChartRenderStateFromRow(getLatestRowSnapshot()),
snapshot, snapshot,
), ),
); );
@@ -875,7 +875,7 @@ export function LiveTemperatureThresholdChart({
if (lastRowObservationSignatureRef.current === currentRowObservationSignature) return; if (lastRowObservationSignatureRef.current === currentRowObservationSignature) return;
const now = Date.now(); const now = Date.now();
lastRowObservationSignatureRef.current = currentRowObservationSignature; lastRowObservationSignatureRef.current = currentRowObservationSignature;
const rowSeed = seedHourlyForecastFromRow(row); const rowSeed = seedChartRenderStateFromRow(row);
const temp = getLiveTempFromHourly(rowSeed); const temp = getLiveTempFromHourly(rowSeed);
if (temp !== null) setLiveTemp(temp); if (temp !== null) setLiveTemp(temp);
commitHourlySnapshot((prev) => mergeRowObservationIntoHourly(prev, row)); commitHourlySnapshot((prev) => mergeRowObservationIntoHourly(prev, row));
@@ -1003,7 +1003,7 @@ export function LiveTemperatureThresholdChart({
const tempValue = validNumber(latestPatch.changes.temp); const tempValue = validNumber(latestPatch.changes.temp);
if (tempValue !== null) setLiveTemp(tempValue); if (tempValue !== null) setLiveTemp(tempValue);
commitHourlySnapshot((prev) => { commitHourlySnapshot((prev) => {
const mergedHourly = mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()), latestPatch); const mergedHourly = mergePatchIntoHourly(prev ?? seedChartRenderStateFromRow(getLatestRowSnapshot()), latestPatch);
return mergedHourly; return mergedHourly;
}); });
setChartFreshness((prev) => ({ setChartFreshness((prev) => ({
@@ -1160,7 +1160,7 @@ export function LiveTemperatureThresholdChart({
}; };
}, [city, currentCityLocalDate, hourly?.localDate, row?.local_date, targetResolution, markDetailDegraded, runHourlyDetailFetch]); }, [city, currentCityLocalDate, hourly?.localDate, row?.local_date, targetResolution, markDetailDegraded, runHourlyDetailFetch]);
const chartHourly = useMemo<HourlyForecast>(() => { const chartHourly = useMemo<ChartRenderState>(() => {
if (!hourly) return hourly; if (!hourly) return hourly;
const loadedLocalDate = hourly.localDate || row?.local_date || ""; const loadedLocalDate = hourly.localDate || row?.local_date || "";
if (currentCityLocalDate && currentCityLocalDate !== loadedLocalDate) { if (currentCityLocalDate && currentCityLocalDate !== loadedLocalDate) {
@@ -1681,7 +1681,7 @@ export function LiveTemperatureThresholdChart({
export function __buildTemperatureChartDataForTest( export function __buildTemperatureChartDataForTest(
row: ScanOpportunityRow | null, row: ScanOpportunityRow | null,
hourly: HourlyForecast, hourly: ChartRenderState,
_timeframe = "1D", _timeframe = "1D",
isEn = false, isEn = false,
) { ) {
@@ -20,7 +20,7 @@ import {
__resetHourlyDetailRequestQueueForTest, __resetHourlyDetailRequestQueueForTest,
__runQueuedHourlyDetailRequestForTest, __runQueuedHourlyDetailRequestForTest,
clearCityDetailCache, clearCityDetailCache,
fetchHourlyForecastForCity, fetchFullChartDetailForCity,
readCityDetailBatchDiagnostics, readCityDetailBatchDiagnostics,
} from "@/components/dashboard/scan-terminal/temperature-chart-logic"; } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
@@ -95,13 +95,13 @@ export async function runTests() {
chartLogicSource.includes("const forceRefresh = Boolean(options.ignoreCache)"), chartLogicSource.includes("const forceRefresh = Boolean(options.ignoreCache)"),
"visible charts should use the no-store observation endpoint for 180-second SSE fallback without forcing detail-batch refreshes", "visible charts should use the no-store observation endpoint for 180-second SSE fallback without forcing detail-batch refreshes",
); );
const componentHourlyFetchCalls = chartSource.match(/fetchHourlyForecastForCity\(city,/g) || []; const componentHourlyFetchCalls = chartSource.match(/fetchFullChartDetailForCity\(city,/g) || [];
const hourlyFetcherBlock = const hourlyFetcherBlock =
/function useHourlyDetailFetcher\([\s\S]*?\n}\r?\n\r?\n\/\/ 岸岸 Main component/.exec(chartSource)?.[0] || ""; /function useHourlyDetailFetcher\([\s\S]*?\n}\r?\n\r?\n\/\/ 岸岸 Main component/.exec(chartSource)?.[0] || "";
assert( assert(
chartSource.includes("function useHourlyDetailFetcher") && chartSource.includes("function useHourlyDetailFetcher") &&
componentHourlyFetchCalls.length === 1 && componentHourlyFetchCalls.length === 1 &&
!/fetchHourlyForecastForCity\(city,[\s\S]*?\)\s*\.then\(/.test(hourlyFetcherBlock), !/fetchFullChartDetailForCity\(city,[\s\S]*?\)\s*\.then\(/.test(hourlyFetcherBlock),
"temperature chart should centralize full-detail fetch lifecycle in useHourlyDetailFetcher instead of duplicating then/catch branches across effects", "temperature chart should centralize full-detail fetch lifecycle in useHourlyDetailFetcher instead of duplicating then/catch branches across effects",
); );
assert( assert(
@@ -171,9 +171,16 @@ export async function runTests() {
"live observation fetches must call the no-store per-city observation endpoint and merge without touching cached model detail", "live observation fetches must call the no-store per-city observation endpoint and merge without touching cached model detail",
); );
assert( assert(
/type FullChartDetail\s*=\s*NonNullable<HourlyForecast>\s*&\s*\{[\s\S]*__detailKind:\s*"full_chart_detail"/.test(chartLogicSource) && chartLogicSource.includes("type ChartRenderState = {") &&
/type FullChartDetail\s*=\s*NonNullable<ChartRenderState>\s*&\s*\{[\s\S]*__detailKind:\s*"full_chart_detail"/.test(chartLogicSource) &&
!chartLogicSource.includes("HourlyForecast") &&
!chartSource.includes("HourlyForecast"),
"chart render state should be named ChartRenderState; the historical HourlyForecast name must not appear in chart data APIs",
);
assert(
/type FullChartDetail\s*=\s*NonNullable<ChartRenderState>\s*&\s*\{[\s\S]*__detailKind:\s*"full_chart_detail"/.test(chartLogicSource) &&
/type ObservationSnapshot\s*=\s*CityObservationPayload\s*&\s*\{[\s\S]*__observationKind:\s*"observation_snapshot"/.test(chartLogicSource), /type ObservationSnapshot\s*=\s*CityObservationPayload\s*&\s*\{[\s\S]*__observationKind:\s*"observation_snapshot"/.test(chartLogicSource),
"full detail and no-store observation payloads should be separate branded types instead of sharing raw HourlyForecast", "full detail and no-store observation payloads should be separate branded types instead of sharing raw ChartRenderState",
); );
assert( assert(
chartLogicSource.includes("type HourlyCacheEntry = { ts: number; data: FullChartDetail }") && chartLogicSource.includes("type HourlyCacheEntry = { ts: number; data: FullChartDetail }") &&
@@ -187,7 +194,7 @@ export async function runTests() {
"live observation fetches should return ObservationSnapshot and enter chart state through the observation snapshot merge path", "live observation fetches should return ObservationSnapshot and enter chart state through the observation snapshot merge path",
); );
assert( assert(
/async function fetchHourlyForecastForCity\([\s\S]*Promise<FullChartDetail \| null>/.test(chartLogicSource) && /async function fetchFullChartDetailForCity\([\s\S]*Promise<FullChartDetail \| null>/.test(chartLogicSource) &&
/type CityDetailBatchWaiter = \{[\s\S]*resolve: \(value: FullChartDetail \| null\)/.test(chartLogicSource), /type CityDetailBatchWaiter = \{[\s\S]*resolve: \(value: FullChartDetail \| null\)/.test(chartLogicSource),
"model/detail fetches and batch waiters should return FullChartDetail or null, not observation-shaped hourly state", "model/detail fetches and batch waiters should return FullChartDetail or null, not observation-shaped hourly state",
); );
@@ -290,8 +297,8 @@ export async function runTests() {
); );
assert( assert(
chartLogicSource.includes("_hourlyRequestCache") && chartLogicSource.includes("_hourlyRequestCache") &&
chartLogicSource.includes("seedHourlyForecastFromRow") && chartLogicSource.includes("seedChartRenderStateFromRow") &&
!chartSource.includes("setHourly(seedHourlyForecastFromRow(getLatestRowSnapshot()))") && !chartSource.includes("setHourly(seedChartRenderStateFromRow(getLatestRowSnapshot()))") &&
chartSource.includes("mergeRowObservationIntoHourly"), chartSource.includes("mergeRowObservationIntoHourly"),
"terminal charts should render from row data through the same merge path instead of racing a row-only skeleton against detail fetches", "terminal charts should render from row data through the same merge path instead of racing a row-only skeleton against detail fetches",
); );
@@ -335,7 +342,7 @@ export async function runTests() {
!flushCityDetailBatchBlock.includes("resolveCityDetailBatchWithSingleFallback"), !flushCityDetailBatchBlock.includes("resolveCityDetailBatchWithSingleFallback"),
"whole-batch failures should stop at the chart batch layer instead of fanning out into single-city full-detail requests", "whole-batch failures should stop at the chart batch layer instead of fanning out into single-city full-detail requests",
); );
const fetchHourlyBlock = chartLogicSource.match(/async function fetchHourlyForecastForCity[\s\S]*?\r?\n}\r?\n\r?\nfunction shouldPollLiveChart/)?.[0] || ""; const fetchHourlyBlock = chartLogicSource.match(/async function fetchFullChartDetailForCity[\s\S]*?\r?\n}\r?\n\r?\nfunction shouldPollLiveChart/)?.[0] || "";
assert( assert(
fetchHourlyBlock.includes("queueCityDetailBatch(city, resParam, forceRefresh)") && fetchHourlyBlock.includes("queueCityDetailBatch(city, resParam, forceRefresh)") &&
!fetchHourlyBlock.includes("runQueuedHourlyDetailRequest"), !fetchHourlyBlock.includes("runQueuedHourlyDetailRequest"),
@@ -616,9 +623,9 @@ export async function runTests() {
}; };
}; };
const firstDetail = await fetchHourlyForecastForCity("fallback-revalidate", { resolution: "10m" }); const firstDetail = await fetchFullChartDetailForCity("fallback-revalidate", { resolution: "10m" });
const cachedDetail = await fetchHourlyForecastForCity("fallback-revalidate", { resolution: "10m" }); const cachedDetail = await fetchFullChartDetailForCity("fallback-revalidate", { resolution: "10m" });
const revalidatedDetail = await fetchHourlyForecastForCity("fallback-revalidate", { const revalidatedDetail = await fetchFullChartDetailForCity("fallback-revalidate", {
bypassLocalCache: true, bypassLocalCache: true,
resolution: "10m", resolution: "10m",
}); });
@@ -717,7 +724,7 @@ export async function runTests() {
partial: false, partial: false,
}), }),
}); });
const forceRefreshedRunwayDetail = await fetchHourlyForecastForCity("chengdu", { const forceRefreshedRunwayDetail = await fetchFullChartDetailForCity("chengdu", {
ignoreCache: true, ignoreCache: true,
resolution: "1m", resolution: "1m",
}); });
@@ -11,7 +11,7 @@ import {
readCachedHourlyForInitialRow, readCachedHourlyForInitialRow,
rememberHourlyDetailSnapshot, rememberHourlyDetailSnapshot,
selectInitialHourlyForRowChange, selectInitialHourlyForRowChange,
seedHourlyForecastFromRow, seedChartRenderStateFromRow,
toFullChartDetail, toFullChartDetail,
_hourlyCache, _hourlyCache,
} from "@/components/dashboard/scan-terminal/temperature-chart-logic"; } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
@@ -434,7 +434,7 @@ export function runTests() {
airport_max_so_far: 29, airport_max_so_far: 29,
}, },
} as any; } as any;
const seededGuangzhou = seedHourlyForecastFromRow(guangzhouRow); const seededGuangzhou = seedChartRenderStateFromRow(guangzhouRow);
const guangzhouLiveChart = buildFullDayChartData(guangzhouRow, seededGuangzhou, false); const guangzhouLiveChart = buildFullDayChartData(guangzhouRow, seededGuangzhou, false);
const guangzhouSettlementRunway = guangzhouLiveChart.series.find((item) => item.key === "runway_02L_20R"); const guangzhouSettlementRunway = guangzhouLiveChart.series.find((item) => item.key === "runway_02L_20R");
assert( assert(
@@ -851,7 +851,7 @@ export function runTests() {
tz_offset_seconds: 8 * 3600, tz_offset_seconds: 8 * 3600,
} as any; } as any;
const shenzhenFullDetail = { const shenzhenFullDetail = {
...seedHourlyForecastFromRow(shenzhenRow), ...seedChartRenderStateFromRow(shenzhenRow),
localDate: "2026-06-10", localDate: "2026-06-10",
localTime: "12:00", localTime: "12:00",
times: ["10:00", "11:00", "12:00"], times: ["10:00", "11:00", "12:00"],
@@ -899,7 +899,7 @@ export function runTests() {
); );
const hongKongCachedDetail = { const hongKongCachedDetail = {
...seedHourlyForecastFromRow({ ...shenzhenRow, city: "hongkong" } as any), ...seedChartRenderStateFromRow({ ...shenzhenRow, city: "hongkong" } as any),
localDate: "2026-06-10", localDate: "2026-06-10",
times: ["10:00", "11:00"], times: ["10:00", "11:00"],
temps: [29.5, 30.2], temps: [29.5, 30.2],
@@ -956,14 +956,14 @@ export function runTests() {
tz_offset_seconds: 8 * 3600, tz_offset_seconds: 8 * 3600,
metar_context: { source: "amsc_awos" }, metar_context: { source: "amsc_awos" },
} as any; } as any;
rememberHourlyDetailSnapshot("chengdu", "1m", seedHourlyForecastFromRow(cachedChengduRow) as any); rememberHourlyDetailSnapshot("chengdu", "1m", seedChartRenderStateFromRow(cachedChengduRow) as any);
assert( assert(
!_hourlyCache.has(chengduCacheKey), !_hourlyCache.has(chengduCacheKey),
"instant-restore cache must not persist a row-only seed that would block the full detail fetch", "instant-restore cache must not persist a row-only seed that would block the full detail fetch",
); );
const cachedChengduDetail = toFullChartDetail({ const cachedChengduDetail = toFullChartDetail({
...seedHourlyForecastFromRow(cachedChengduRow), ...seedChartRenderStateFromRow(cachedChengduRow),
localDate: "2026-06-15", localDate: "2026-06-15",
times: ["08:00", "09:00"], times: ["08:00", "09:00"],
temps: [25.8, 26.4], temps: [25.8, 26.4],
@@ -1063,7 +1063,7 @@ export function runTests() {
tz_offset_seconds: 0, tz_offset_seconds: 0,
} as any; } as any;
const cacheDetail = toFullChartDetail({ const cacheDetail = toFullChartDetail({
...seedHourlyForecastFromRow(row), ...seedChartRenderStateFromRow(row),
times: ["09:00"], times: ["09:00"],
temps: [20 + i / 100], temps: [20 + i / 100],
modelTimes: ["09:00"], modelTimes: ["09:00"],
@@ -109,7 +109,7 @@ function isTemperatureSeriesVisibleByDefault(city: string, seriesKey: string) {
function prefersHighFrequencyRunwayResolution( function prefersHighFrequencyRunwayResolution(
row: ScanOpportunityRow | null, row: ScanOpportunityRow | null,
hourly: HourlyForecast, hourly: ChartRenderState,
) { ) {
const cityKey = normalizeCityKey(row?.city); const cityKey = normalizeCityKey(row?.city);
if ((SETTLEMENT_RUNWAY_PAIRS[cityKey] || []).length > 0) return true; if ((SETTLEMENT_RUNWAY_PAIRS[cityKey] || []).length > 0) return true;
@@ -590,7 +590,7 @@ function readHourlyDetailSnapshotAgeMs(
function readCachedHourlyForInitialRow( function readCachedHourlyForInitialRow(
city: string, city: string,
preferredResolution: string, preferredResolution: string,
): HourlyForecast { ): ChartRenderState {
const cityKey = normalizeCityKey(city); const cityKey = normalizeCityKey(city);
if (!cityKey) return null; if (!cityKey) return null;
const resolutions = [ const resolutions = [
@@ -777,7 +777,7 @@ function laterLocalDate(left?: string | null, right?: string | null) {
return isDate(a) ? a : isDate(b) ? b : null; return isDate(a) ? a : isDate(b) ? b : null;
} }
function resolveChartLocalDate(row: ScanOpportunityRow | null, hourly: HourlyForecast) { function resolveChartLocalDate(row: ScanOpportunityRow | null, hourly: ChartRenderState) {
const hourlyDate = hourly?.localDate || dateFromLocalTime(hourly?.localTime); const hourlyDate = hourly?.localDate || dateFromLocalTime(hourly?.localTime);
const rowDate = row?.local_date || dateFromLocalTime(row?.local_time); const rowDate = row?.local_date || dateFromLocalTime(row?.local_time);
return ( return (
@@ -871,7 +871,7 @@ function appendLatestAirportObservation(
return merged; return merged;
} }
function isMgmAirportPrimary(hourly: HourlyForecast) { function isMgmAirportPrimary(hourly: ChartRenderState) {
const primary = hourly?.airportPrimary; const primary = hourly?.airportPrimary;
const sourceTokens = [ const sourceTokens = [
primary?.source_code, primary?.source_code,
@@ -881,7 +881,7 @@ function isMgmAirportPrimary(hourly: HourlyForecast) {
return sourceTokens.some((value) => value === "mgm" || value.includes("turkey_mgm")); return sourceTokens.some((value) => value === "mgm" || value.includes("turkey_mgm"));
} }
function canonicalAirportPrimarySourceLabel(hourly: HourlyForecast) { function canonicalAirportPrimarySourceLabel(hourly: ChartRenderState) {
const primary = hourly?.airportPrimary; const primary = hourly?.airportPrimary;
const tokens = [ const tokens = [
primary?.source_code, primary?.source_code,
@@ -900,7 +900,7 @@ function canonicalAirportPrimarySourceLabel(hourly: HourlyForecast) {
} }
function airportCodeForSeriesLabel( function airportCodeForSeriesLabel(
hourly: HourlyForecast, hourly: ChartRenderState,
row?: ScanOpportunityRow | null, row?: ScanOpportunityRow | null,
) { ) {
const candidates = [ const candidates = [
@@ -933,7 +933,7 @@ function isGenericAirportPrimaryLabel(label: string) {
} }
function airportPrimarySeriesLabel( function airportPrimarySeriesLabel(
hourly: HourlyForecast, hourly: ChartRenderState,
isHKO: boolean, isHKO: boolean,
row?: ScanOpportunityRow | null, row?: ScanOpportunityRow | null,
) { ) {
@@ -956,7 +956,7 @@ function airportPrimarySeriesLabel(
} }
function airportPrimaryUsesMetarFallback( function airportPrimaryUsesMetarFallback(
hourly: HourlyForecast, hourly: ChartRenderState,
isHKO: boolean, isHKO: boolean,
row?: ScanOpportunityRow | null, row?: ScanOpportunityRow | null,
) { ) {
@@ -969,7 +969,7 @@ function airportPrimaryUsesMetarFallback(
return !payloadLabel || isGenericAirportPrimaryLabel(payloadLabel); return !payloadLabel || isGenericAirportPrimaryLabel(payloadLabel);
} }
function metarStationCodeForSeries(row?: ScanOpportunityRow | null, hourly?: HourlyForecast) { function metarStationCodeForSeries(row?: ScanOpportunityRow | null, hourly?: ChartRenderState) {
const candidates = [ const candidates = [
row?.metar_context?.station, row?.metar_context?.station,
hourly?.settlementStationCode, hourly?.settlementStationCode,
@@ -981,13 +981,13 @@ function metarStationCodeForSeries(row?: ScanOpportunityRow | null, hourly?: Hou
.find((value) => /^[A-Z0-9]{4}$/.test(value)) || ""; .find((value) => /^[A-Z0-9]{4}$/.test(value)) || "";
} }
function airportPrimaryMatchesMetarStation(hourly: HourlyForecast, row?: ScanOpportunityRow | null) { function airportPrimaryMatchesMetarStation(hourly: ChartRenderState, row?: ScanOpportunityRow | null) {
const primaryCode = airportCodeForSeriesLabel(hourly, row); const primaryCode = airportCodeForSeriesLabel(hourly, row);
const metarCode = metarStationCodeForSeries(row, hourly); const metarCode = metarStationCodeForSeries(row, hourly);
return Boolean(primaryCode && metarCode && primaryCode === metarCode); return Boolean(primaryCode && metarCode && primaryCode === metarCode);
} }
function airportPrimaryObservationPoints(hourly: HourlyForecast) { function airportPrimaryObservationPoints(hourly: ChartRenderState) {
return appendLatestAirportObservation( return appendLatestAirportObservation(
hourly?.airportPrimaryTodayObs, hourly?.airportPrimaryTodayObs,
hourly?.airportPrimary, hourly?.airportPrimary,
@@ -1016,7 +1016,7 @@ function maxObservationValue(obs: Array<{ ts: number; value: number }>) {
function getRunwayHistoryObservationMetrics( function getRunwayHistoryObservationMetrics(
row: ScanOpportunityRow | null, row: ScanOpportunityRow | null,
hourly: HourlyForecast, hourly: ChartRenderState,
) { ) {
const tzOffset = row?.tz_offset_seconds ?? 0; const tzOffset = row?.tz_offset_seconds ?? 0;
const localDateStr = resolveChartLocalDate(row, hourly); const localDateStr = resolveChartLocalDate(row, hourly);
@@ -1061,7 +1061,7 @@ function observationSetContains(
function getObservationDisplayMetrics( function getObservationDisplayMetrics(
row: ScanOpportunityRow | null, row: ScanOpportunityRow | null,
hourly: HourlyForecast, hourly: ChartRenderState,
settlementPlate?: { maxTemp: number | null } | null, settlementPlate?: { maxTemp: number | null } | null,
) { ) {
const tzOffset = row?.tz_offset_seconds ?? 0; const tzOffset = row?.tz_offset_seconds ?? 0;
@@ -1372,7 +1372,7 @@ function mergeRunwayPlateHistory(
return Object.keys(result).length ? result : undefined; return Object.keys(result).length ? result : undefined;
} }
function hasFullHourlyDetailPayload(hourly: HourlyForecast) { function hasFullHourlyDetailPayload(hourly: ChartRenderState) {
if (!hourly) return false; if (!hourly) return false;
const probabilityBuckets = const probabilityBuckets =
hourly.probabilities?.distribution_all || hourly.probabilities?.distribution_all ||
@@ -1389,7 +1389,7 @@ function hasFullHourlyDetailPayload(hourly: HourlyForecast) {
); );
} }
function toFullChartDetail(hourly: HourlyForecast): FullChartDetail | null { function toFullChartDetail(hourly: ChartRenderState): FullChartDetail | null {
if (!hourly || !hasFullHourlyDetailPayload(hourly)) return null; if (!hourly || !hasFullHourlyDetailPayload(hourly)) return null;
if ((hourly as any).__detailKind === "full_chart_detail") return hourly as FullChartDetail; if ((hourly as any).__detailKind === "full_chart_detail") return hourly as FullChartDetail;
return { return {
@@ -1470,7 +1470,7 @@ function latestRunwayHistoryRank(
} }
function latestHourlyObservationRank( function latestHourlyObservationRank(
hourly: HourlyForecast, hourly: ChartRenderState,
row: ScanOpportunityRow | null, row: ScanOpportunityRow | null,
) { ) {
if (!hourly) return null; if (!hourly) return null;
@@ -1488,8 +1488,8 @@ function latestHourlyObservationRank(
} }
function shouldKeepLiveHourlyDetailPayload( function shouldKeepLiveHourlyDetailPayload(
base: HourlyForecast, base: ChartRenderState,
live: HourlyForecast, live: ChartRenderState,
row: ScanOpportunityRow | null, row: ScanOpportunityRow | null,
) { ) {
if (!base || !live) return false; if (!base || !live) return false;
@@ -1500,8 +1500,8 @@ function shouldKeepLiveHourlyDetailPayload(
} }
function hourlyLocalDatesConflict( function hourlyLocalDatesConflict(
base: HourlyForecast, base: ChartRenderState,
live: HourlyForecast, live: ChartRenderState,
row: ScanOpportunityRow | null, row: ScanOpportunityRow | null,
) { ) {
const baseDate = String(base?.localDate || "").trim(); const baseDate = String(base?.localDate || "").trim();
@@ -1563,7 +1563,7 @@ function seedRunwayPlateHistoryFromRow(
return Object.keys(history).length ? history : existing; return Object.keys(history).length ? history : existing;
} }
type HourlyForecast = { type ChartRenderState = {
forecastTodayHigh?: number | null; forecastTodayHigh?: number | null;
debPrediction?: number | null; debPrediction?: number | null;
debQuality?: Pick<DebForecast, "quality_tier" | "recommendation" | "recent_hit_rate" | "recent_samples" | "recent_hits" | "recent_mae"> | null; debQuality?: Pick<DebForecast, "quality_tier" | "recommendation" | "recent_hit_rate" | "recent_samples" | "recent_hits" | "recent_mae"> | null;
@@ -1591,11 +1591,11 @@ type HourlyForecast = {
airportPrimaryTodayObs?: RawObsPoint[]; airportPrimaryTodayObs?: RawObsPoint[];
} | null; } | null;
type FullChartDetail = NonNullable<HourlyForecast> & { type FullChartDetail = NonNullable<ChartRenderState> & {
readonly __detailKind: "full_chart_detail"; readonly __detailKind: "full_chart_detail";
}; };
function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForecast { function seedChartRenderStateFromRow(row: ScanOpportunityRow | null): ChartRenderState {
if (!row) return null; if (!row) return null;
const current = rowCurrentObservation(row); const current = rowCurrentObservation(row);
const sourceCode = current?.sourceCode || undefined; const sourceCode = current?.sourceCode || undefined;
@@ -1658,10 +1658,10 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
} }
function mergeHourlyWithLiveObservations( function mergeHourlyWithLiveObservations(
base: HourlyForecast, base: ChartRenderState,
live: HourlyForecast, live: ChartRenderState,
row: ScanOpportunityRow | null, row: ScanOpportunityRow | null,
): HourlyForecast { ): ChartRenderState {
if (!base) return live; if (!base) return live;
if (!live) return base; if (!live) return base;
if (hourlyLocalDatesConflict(base, live, row)) return base; if (hourlyLocalDatesConflict(base, live, row)) return base;
@@ -1708,9 +1708,9 @@ function mergeHourlyWithLiveObservations(
} }
function mergeObservationSnapshotIntoHourly( function mergeObservationSnapshotIntoHourly(
prev: HourlyForecast, prev: ChartRenderState,
snapshot: ObservationSnapshot | null | undefined, snapshot: ObservationSnapshot | null | undefined,
): HourlyForecast { ): ChartRenderState {
const live = observationSnapshotToHourly(snapshot); const live = observationSnapshotToHourly(snapshot);
if (!prev) return live; if (!prev) return live;
if (!live) return prev; if (!live) return prev;
@@ -1718,10 +1718,10 @@ function mergeObservationSnapshotIntoHourly(
} }
function mergeRowObservationIntoHourly( function mergeRowObservationIntoHourly(
prev: HourlyForecast, prev: ChartRenderState,
row: ScanOpportunityRow | null, row: ScanOpportunityRow | null,
): HourlyForecast { ): ChartRenderState {
const seeded = seedHourlyForecastFromRow(row); const seeded = seedChartRenderStateFromRow(row);
if (!prev) return seeded; if (!prev) return seeded;
return mergeHourlyWithLiveObservations(prev, seeded, row); return mergeHourlyWithLiveObservations(prev, seeded, row);
} }
@@ -1732,12 +1732,12 @@ function selectInitialHourlyForRowChange({
previousHourly, previousHourly,
row, row,
}: { }: {
cachedHourly?: HourlyForecast; cachedHourly?: ChartRenderState;
previousCity?: string | null; previousCity?: string | null;
previousHourly?: HourlyForecast; previousHourly?: ChartRenderState;
row: ScanOpportunityRow | null; row: ScanOpportunityRow | null;
}): HourlyForecast { }): ChartRenderState {
const seeded = seedHourlyForecastFromRow(row); const seeded = seedChartRenderStateFromRow(row);
const nextCity = normalizeCityKey(row?.city); const nextCity = normalizeCityKey(row?.city);
if (!nextCity) return seeded; if (!nextCity) return seeded;
@@ -1752,7 +1752,7 @@ function selectInitialHourlyForRowChange({
return seeded; return seeded;
} }
type HourlyForecastFetchOptions = { type ChartDetailFetchOptions = {
bypassLocalCache?: boolean; bypassLocalCache?: boolean;
ignoreCache?: boolean; ignoreCache?: boolean;
resolution?: string; resolution?: string;
@@ -1890,7 +1890,7 @@ function observationPayloadToSnapshot(payload: CityObservationPayload | null | u
}; };
} }
function observationSnapshotToHourly(snapshot: ObservationSnapshot | null | undefined): HourlyForecast { function observationSnapshotToHourly(snapshot: ObservationSnapshot | null | undefined): ChartRenderState {
if (!snapshot || typeof snapshot !== "object") return null; if (!snapshot || typeof snapshot !== "object") return null;
const airportCurrent = normalizeObservationCondition(snapshot.airport_current || snapshot.current); const airportCurrent = normalizeObservationCondition(snapshot.airport_current || snapshot.current);
const airportPrimary = normalizeObservationCondition(snapshot.airport_primary || snapshot.airport_current || snapshot.current); const airportPrimary = normalizeObservationCondition(snapshot.airport_primary || snapshot.airport_current || snapshot.current);
@@ -1933,10 +1933,10 @@ function observationSnapshotToHourly(snapshot: ObservationSnapshot | null | unde
}; };
} }
function parseHourlyForecastFromCityDetail(json: CityDetail | null): FullChartDetail | null { function parseFullChartDetailFromCityDetail(json: CityDetail | null): FullChartDetail | null {
const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly; const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly;
if (!json || !hourlySource) return null; if (!json || !hourlySource) return null;
const parsed: HourlyForecast = { const parsed: ChartRenderState = {
forecastTodayHigh: json.forecast?.today_high ?? null, forecastTodayHigh: json.forecast?.today_high ?? null,
debPrediction: json.deb?.prediction ?? (json as any)?.overview?.deb_prediction ?? null, debPrediction: json.deb?.prediction ?? (json as any)?.overview?.deb_prediction ?? null,
debQuality: json.deb ? { debQuality: json.deb ? {
@@ -2001,7 +2001,7 @@ function primeCityDetailCache(
resolution: string, resolution: string,
detail: CityDetail | null | undefined, detail: CityDetail | null | undefined,
): FullChartDetail | null { ): FullChartDetail | null {
let data = parseHourlyForecastFromCityDetail(detail || null); let data = parseFullChartDetailFromCityDetail(detail || null);
if (!data) return null; if (!data) return null;
const cacheKey = hourlyCacheKey(city, resolution); const cacheKey = hourlyCacheKey(city, resolution);
data = preserveCachedRunwayHistory(cacheKey, data); data = preserveCachedRunwayHistory(cacheKey, data);
@@ -2176,9 +2176,9 @@ async function fetchLiveObservationForCity(city: string): Promise<ObservationSna
.catch(() => null); .catch(() => null);
} }
async function fetchHourlyForecastForCity( async function fetchFullChartDetailForCity(
city: string, city: string,
options: HourlyForecastFetchOptions = {}, options: ChartDetailFetchOptions = {},
): Promise<FullChartDetail | null> { ): Promise<FullChartDetail | null> {
const resParam = options.resolution || "10m"; const resParam = options.resolution || "10m";
const cacheKey = hourlyCacheKey(city, resParam); const cacheKey = hourlyCacheKey(city, resParam);
@@ -2232,7 +2232,7 @@ function shouldPollLiveChart({
function getLiveObservationLabels( function getLiveObservationLabels(
row: ScanOpportunityRow | null, row: ScanOpportunityRow | null,
hourly: HourlyForecast, hourly: ChartRenderState,
) { ) {
const normalizedKey = normalizeCityKey(row?.city); const normalizedKey = normalizeCityKey(row?.city);
const runwaySensorCities = new Set([ const runwaySensorCities = new Set([
@@ -2316,19 +2316,19 @@ function getLiveObservationLabels(
} }
function mergePatchIntoHourly( function mergePatchIntoHourly(
prev: HourlyForecast, prev: ChartRenderState,
patch: CityPatch, patch: CityPatch,
): HourlyForecast { ): ChartRenderState {
const changes = patch.changes || {}; const changes = patch.changes || {};
const tempValue = validNumber(changes.temp); const tempValue = validNumber(changes.temp);
const observedAtUtc = typeof changes.observed_at_utc === "string" ? changes.observed_at_utc : null; const observedAtUtc = typeof changes.observed_at_utc === "string" ? changes.observed_at_utc : null;
const obsTime = observedAtUtc || (typeof changes.obs_time === "string" ? changes.obs_time : null); const obsTime = observedAtUtc || (typeof changes.obs_time === "string" ? changes.obs_time : null);
const source = typeof changes.source === "string" ? changes.source : ""; const source = typeof changes.source === "string" ? changes.source : "";
const explicitHourlyPatch = changes.hourly && typeof changes.hourly === "object" const explicitHourlyPatch = changes.hourly && typeof changes.hourly === "object"
? changes.hourly as Partial<NonNullable<HourlyForecast>> ? changes.hourly as Partial<NonNullable<ChartRenderState>>
: {}; : {};
const next: NonNullable<HourlyForecast> = { const next: NonNullable<ChartRenderState> = {
...(prev || { ...(prev || {
forecastTodayHigh: null, forecastTodayHigh: null,
debPrediction: null, debPrediction: null,
@@ -2470,7 +2470,7 @@ function parseRunwayHistoryTime(
function buildRunwayHistorySeries( function buildRunwayHistorySeries(
row: ScanOpportunityRow | null, row: ScanOpportunityRow | null,
hourly: HourlyForecast, hourly: ChartRenderState,
tzOffset: number, tzOffset: number,
localDateStr: string, localDateStr: string,
minPoints = 2, minPoints = 2,
@@ -2607,7 +2607,7 @@ function formatDailyDateLabel(dateStr: string): string {
function buildDailyChartData( function buildDailyChartData(
row: ScanOpportunityRow | null, row: ScanOpportunityRow | null,
hourly: HourlyForecast, hourly: ChartRenderState,
daysCount: number, daysCount: number,
): { data: Array<Record<string, string | number | null>>; series: EvidenceSeries[] } { ): { data: Array<Record<string, string | number | null>>; series: EvidenceSeries[] } {
const localDateStr = resolveChartLocalDate(row, hourly); const localDateStr = resolveChartLocalDate(row, hourly);
@@ -2700,7 +2700,7 @@ function addLocalDayAxisSlots(timeline: Set<number>, bounds: LocalDayBounds | nu
function resolveFullDayFallbackAnchor( function resolveFullDayFallbackAnchor(
row: ScanOpportunityRow | null, row: ScanOpportunityRow | null,
hourly: HourlyForecast, hourly: ChartRenderState,
tzOffsetSeconds: number, tzOffsetSeconds: number,
localDateStr: string, localDateStr: string,
) { ) {
@@ -2792,7 +2792,7 @@ function addHourlyTimesToTimeline(
} }
function resolveModelCurveTimes( function resolveModelCurveTimes(
hourly: HourlyForecast, hourly: ChartRenderState,
modelTemps: Array<number | null>, modelTemps: Array<number | null>,
) { ) {
if (hourly?.modelTimes?.length) return hourly.modelTimes; if (hourly?.modelTimes?.length) return hourly.modelTimes;
@@ -2827,7 +2827,7 @@ function probabilityBucketRange(bucket: ProbabilityBucket, value: number) {
function buildLegacyGaussianProbabilityOverlay( function buildLegacyGaussianProbabilityOverlay(
row: ScanOpportunityRow | null, row: ScanOpportunityRow | null,
hourly: HourlyForecast, hourly: ChartRenderState,
): ProbabilityOverlay | null { ): ProbabilityOverlay | null {
const source = hourly?.probabilities || null; const source = hourly?.probabilities || null;
const rowBuckets = ((row as any)?.distribution_full || (row as any)?.distribution_preview || []) as ProbabilityBucket[]; const rowBuckets = ((row as any)?.distribution_full || (row as any)?.distribution_preview || []) as ProbabilityBucket[];
@@ -2880,7 +2880,7 @@ function buildLegacyGaussianProbabilityOverlay(
function buildFullDayChartData( function buildFullDayChartData(
row: ScanOpportunityRow | null, row: ScanOpportunityRow | null,
hourly: HourlyForecast, hourly: ChartRenderState,
isEn: boolean, isEn: boolean,
): { data: Array<Record<string, any>>; series: EvidenceSeries[]; probabilityOverlay: ProbabilityOverlay | null } { ): { data: Array<Record<string, any>>; series: EvidenceSeries[]; probabilityOverlay: ProbabilityOverlay | null } {
const tzOffset = row?.tz_offset_seconds ?? 0; const tzOffset = row?.tz_offset_seconds ?? 0;
@@ -3510,7 +3510,7 @@ export {
buildIntDegreeTicks, buildIntDegreeTicks,
buildModelSummaryCards, buildModelSummaryCards,
buildRunwayPlates, buildRunwayPlates,
fetchHourlyForecastForCity, fetchFullChartDetailForCity,
fetchLiveObservationForCity, fetchLiveObservationForCity,
getActiveTemperatureSeries, getActiveTemperatureSeries,
getTemperatureSeriesForRunwayDetailsMode, getTemperatureSeriesForRunwayDetailsMode,
@@ -3534,7 +3534,7 @@ export {
selectCompactSecondaryTemp, selectCompactSecondaryTemp,
selectDisplayRunwayTemp, selectDisplayRunwayTemp,
selectInitialHourlyForRowChange, selectInitialHourlyForRowChange,
seedHourlyForecastFromRow, seedChartRenderStateFromRow,
seriesStats, seriesStats,
shouldPollLiveChart, shouldPollLiveChart,
observationPayloadToSnapshot, observationPayloadToSnapshot,
@@ -3543,4 +3543,4 @@ export {
rememberCityDetailBatchDiagnostics as __rememberCityDetailBatchDiagnosticsForTest, rememberCityDetailBatchDiagnostics as __rememberCityDetailBatchDiagnosticsForTest,
}; };
export type { EvidenceSeries, FullChartDetail, HourlyForecast, ObservationSnapshot, PeakGlowMeta, PeakGlowState, ProbabilityOverlay }; export type { EvidenceSeries, FullChartDetail, ChartRenderState, ObservationSnapshot, PeakGlowMeta, PeakGlowState, ProbabilityOverlay };