重命名图表渲染态类型

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,
buildIntDegreeTicks,
buildRunwayPlates,
fetchHourlyForecastForCity,
fetchFullChartDetailForCity,
fetchLiveObservationForCity,
getActiveTemperatureSeries,
getDebPeakWindowRange,
@@ -41,10 +41,10 @@ import {
selectCompactSecondaryTemp,
selectDisplayRunwayTemp,
selectInitialHourlyForRowChange,
seedHourlyForecastFromRow,
seedChartRenderStateFromRow,
shouldPollLiveChart,
validNumber,
type HourlyForecast,
type ChartRenderState,
type ObservationSnapshot,
} 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}`;
}
function getLiveTempFromHourly(data: HourlyForecast) {
function getLiveTempFromHourly(data: ChartRenderState) {
return validNumber(data?.airportCurrent?.temp) ?? validNumber(data?.airportPrimary?.temp) ?? null;
}
@@ -268,7 +268,7 @@ function patchObservationTimeForFreshness(patch: { changes?: Record<string, unkn
).trim() || null;
}
function getWundergroundDailyHigh(hourly: HourlyForecast) {
function getWundergroundDailyHigh(hourly: ChartRenderState) {
return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null;
}
@@ -319,7 +319,7 @@ function sourceStatusLabel(status: string | null | undefined, isEn: boolean) {
function buildSourceCadenceSummary(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
hourly: ChartRenderState,
isEn: boolean,
): SourceCadenceSummary | null {
const primary = hourly?.airportPrimary || hourly?.airportCurrent || null;
@@ -355,7 +355,7 @@ function buildSourceCadenceSummary(
function buildAdvancedWeatherVariableItems(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
hourly: ChartRenderState,
isEn: boolean,
): AdvancedWeatherVariableItem[] {
const primary = hourly?.airportPrimary || hourly?.airportCurrent || null;
@@ -578,7 +578,7 @@ function useHourlyDetailFetcher({
targetResolution: string;
markDetailRequest: (source: ChartDetailSource) => void;
markDetailDegraded: (options?: { showUserError?: boolean }) => void;
applySuccessfulHourlyDetail: (data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => void;
applySuccessfulHourlyDetail: (data: ChartRenderState, options?: { updateLiveTemp?: boolean }) => void;
}) {
return useCallback(
async ({
@@ -596,7 +596,7 @@ function useHourlyDetailFetcher({
markDetailRequest(source);
try {
const data = await fetchHourlyForecastForCity(city, {
const data = await fetchFullChartDetailForCity(city, {
...fetchOptions,
resolution: targetResolution,
});
@@ -660,7 +660,7 @@ export function LiveTemperatureThresholdChart({
activationRefreshKey?: number;
slotIndex?: number;
}) {
const [hourly, setHourly] = useState<HourlyForecast>(null);
const [hourly, setHourly] = useState<ChartRenderState>(null);
const city = String(row?.city || "").toLowerCase().trim();
const latestPatch = useLatestPatch(city);
const resyncVersion = useSseResyncVersion();
@@ -807,15 +807,15 @@ export function LiveTemperatureThresholdChart({
return () => clearInterval(id);
}, [row?.tz_offset_seconds]);
const commitHourlySnapshot = useCallback((buildNext: (previous: HourlyForecast) => HourlyForecast) => {
const commitHourlySnapshot = useCallback((buildNext: (previous: ChartRenderState) => ChartRenderState) => {
setHourly((prev) => buildNext(prev));
}, []);
const applySuccessfulHourlyDetail = useCallback((data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => {
const applySuccessfulHourlyDetail = useCallback((data: ChartRenderState, options?: { updateLiveTemp?: boolean }) => {
if (!data) return;
const loadedAtMs = Date.now();
const latestRow = getLatestRowSnapshot();
const rowSeed = seedHourlyForecastFromRow(latestRow);
const rowSeed = seedChartRenderStateFromRow(latestRow);
const dataWithCurrentRow = mergeHourlyWithLiveObservations(data, rowSeed, latestRow);
hasLoadedHourlyDetailRef.current = true;
if (options?.updateLiveTemp) {
@@ -859,7 +859,7 @@ export function LiveTemperatureThresholdChart({
}
commitHourlySnapshot((prev) =>
mergeObservationSnapshotIntoHourly(
prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()),
prev ?? seedChartRenderStateFromRow(getLatestRowSnapshot()),
snapshot,
),
);
@@ -875,7 +875,7 @@ export function LiveTemperatureThresholdChart({
if (lastRowObservationSignatureRef.current === currentRowObservationSignature) return;
const now = Date.now();
lastRowObservationSignatureRef.current = currentRowObservationSignature;
const rowSeed = seedHourlyForecastFromRow(row);
const rowSeed = seedChartRenderStateFromRow(row);
const temp = getLiveTempFromHourly(rowSeed);
if (temp !== null) setLiveTemp(temp);
commitHourlySnapshot((prev) => mergeRowObservationIntoHourly(prev, row));
@@ -1003,7 +1003,7 @@ export function LiveTemperatureThresholdChart({
const tempValue = validNumber(latestPatch.changes.temp);
if (tempValue !== null) setLiveTemp(tempValue);
commitHourlySnapshot((prev) => {
const mergedHourly = mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()), latestPatch);
const mergedHourly = mergePatchIntoHourly(prev ?? seedChartRenderStateFromRow(getLatestRowSnapshot()), latestPatch);
return mergedHourly;
});
setChartFreshness((prev) => ({
@@ -1160,7 +1160,7 @@ export function LiveTemperatureThresholdChart({
};
}, [city, currentCityLocalDate, hourly?.localDate, row?.local_date, targetResolution, markDetailDegraded, runHourlyDetailFetch]);
const chartHourly = useMemo<HourlyForecast>(() => {
const chartHourly = useMemo<ChartRenderState>(() => {
if (!hourly) return hourly;
const loadedLocalDate = hourly.localDate || row?.local_date || "";
if (currentCityLocalDate && currentCityLocalDate !== loadedLocalDate) {
@@ -1681,7 +1681,7 @@ export function LiveTemperatureThresholdChart({
export function __buildTemperatureChartDataForTest(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
hourly: ChartRenderState,
_timeframe = "1D",
isEn = false,
) {
@@ -20,7 +20,7 @@ import {
__resetHourlyDetailRequestQueueForTest,
__runQueuedHourlyDetailRequestForTest,
clearCityDetailCache,
fetchHourlyForecastForCity,
fetchFullChartDetailForCity,
readCityDetailBatchDiagnostics,
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
@@ -95,13 +95,13 @@ export async function runTests() {
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",
);
const componentHourlyFetchCalls = chartSource.match(/fetchHourlyForecastForCity\(city,/g) || [];
const componentHourlyFetchCalls = chartSource.match(/fetchFullChartDetailForCity\(city,/g) || [];
const hourlyFetcherBlock =
/function useHourlyDetailFetcher\([\s\S]*?\n}\r?\n\r?\n\/\/ 岸岸 Main component/.exec(chartSource)?.[0] || "";
assert(
chartSource.includes("function useHourlyDetailFetcher") &&
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",
);
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",
);
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),
"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(
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",
);
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),
"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(
chartLogicSource.includes("_hourlyRequestCache") &&
chartLogicSource.includes("seedHourlyForecastFromRow") &&
!chartSource.includes("setHourly(seedHourlyForecastFromRow(getLatestRowSnapshot()))") &&
chartLogicSource.includes("seedChartRenderStateFromRow") &&
!chartSource.includes("setHourly(seedChartRenderStateFromRow(getLatestRowSnapshot()))") &&
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",
);
@@ -335,7 +342,7 @@ export async function runTests() {
!flushCityDetailBatchBlock.includes("resolveCityDetailBatchWithSingleFallback"),
"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(
fetchHourlyBlock.includes("queueCityDetailBatch(city, resParam, forceRefresh)") &&
!fetchHourlyBlock.includes("runQueuedHourlyDetailRequest"),
@@ -616,9 +623,9 @@ export async function runTests() {
};
};
const firstDetail = await fetchHourlyForecastForCity("fallback-revalidate", { resolution: "10m" });
const cachedDetail = await fetchHourlyForecastForCity("fallback-revalidate", { resolution: "10m" });
const revalidatedDetail = await fetchHourlyForecastForCity("fallback-revalidate", {
const firstDetail = await fetchFullChartDetailForCity("fallback-revalidate", { resolution: "10m" });
const cachedDetail = await fetchFullChartDetailForCity("fallback-revalidate", { resolution: "10m" });
const revalidatedDetail = await fetchFullChartDetailForCity("fallback-revalidate", {
bypassLocalCache: true,
resolution: "10m",
});
@@ -717,7 +724,7 @@ export async function runTests() {
partial: false,
}),
});
const forceRefreshedRunwayDetail = await fetchHourlyForecastForCity("chengdu", {
const forceRefreshedRunwayDetail = await fetchFullChartDetailForCity("chengdu", {
ignoreCache: true,
resolution: "1m",
});
@@ -11,7 +11,7 @@ import {
readCachedHourlyForInitialRow,
rememberHourlyDetailSnapshot,
selectInitialHourlyForRowChange,
seedHourlyForecastFromRow,
seedChartRenderStateFromRow,
toFullChartDetail,
_hourlyCache,
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
@@ -434,7 +434,7 @@ export function runTests() {
airport_max_so_far: 29,
},
} as any;
const seededGuangzhou = seedHourlyForecastFromRow(guangzhouRow);
const seededGuangzhou = seedChartRenderStateFromRow(guangzhouRow);
const guangzhouLiveChart = buildFullDayChartData(guangzhouRow, seededGuangzhou, false);
const guangzhouSettlementRunway = guangzhouLiveChart.series.find((item) => item.key === "runway_02L_20R");
assert(
@@ -851,7 +851,7 @@ export function runTests() {
tz_offset_seconds: 8 * 3600,
} as any;
const shenzhenFullDetail = {
...seedHourlyForecastFromRow(shenzhenRow),
...seedChartRenderStateFromRow(shenzhenRow),
localDate: "2026-06-10",
localTime: "12:00",
times: ["10:00", "11:00", "12:00"],
@@ -899,7 +899,7 @@ export function runTests() {
);
const hongKongCachedDetail = {
...seedHourlyForecastFromRow({ ...shenzhenRow, city: "hongkong" } as any),
...seedChartRenderStateFromRow({ ...shenzhenRow, city: "hongkong" } as any),
localDate: "2026-06-10",
times: ["10:00", "11:00"],
temps: [29.5, 30.2],
@@ -956,14 +956,14 @@ export function runTests() {
tz_offset_seconds: 8 * 3600,
metar_context: { source: "amsc_awos" },
} as any;
rememberHourlyDetailSnapshot("chengdu", "1m", seedHourlyForecastFromRow(cachedChengduRow) as any);
rememberHourlyDetailSnapshot("chengdu", "1m", seedChartRenderStateFromRow(cachedChengduRow) as any);
assert(
!_hourlyCache.has(chengduCacheKey),
"instant-restore cache must not persist a row-only seed that would block the full detail fetch",
);
const cachedChengduDetail = toFullChartDetail({
...seedHourlyForecastFromRow(cachedChengduRow),
...seedChartRenderStateFromRow(cachedChengduRow),
localDate: "2026-06-15",
times: ["08:00", "09:00"],
temps: [25.8, 26.4],
@@ -1063,7 +1063,7 @@ export function runTests() {
tz_offset_seconds: 0,
} as any;
const cacheDetail = toFullChartDetail({
...seedHourlyForecastFromRow(row),
...seedChartRenderStateFromRow(row),
times: ["09:00"],
temps: [20 + i / 100],
modelTimes: ["09:00"],
@@ -109,7 +109,7 @@ function isTemperatureSeriesVisibleByDefault(city: string, seriesKey: string) {
function prefersHighFrequencyRunwayResolution(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
hourly: ChartRenderState,
) {
const cityKey = normalizeCityKey(row?.city);
if ((SETTLEMENT_RUNWAY_PAIRS[cityKey] || []).length > 0) return true;
@@ -590,7 +590,7 @@ function readHourlyDetailSnapshotAgeMs(
function readCachedHourlyForInitialRow(
city: string,
preferredResolution: string,
): HourlyForecast {
): ChartRenderState {
const cityKey = normalizeCityKey(city);
if (!cityKey) return null;
const resolutions = [
@@ -777,7 +777,7 @@ function laterLocalDate(left?: string | null, right?: string | 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 rowDate = row?.local_date || dateFromLocalTime(row?.local_time);
return (
@@ -871,7 +871,7 @@ function appendLatestAirportObservation(
return merged;
}
function isMgmAirportPrimary(hourly: HourlyForecast) {
function isMgmAirportPrimary(hourly: ChartRenderState) {
const primary = hourly?.airportPrimary;
const sourceTokens = [
primary?.source_code,
@@ -881,7 +881,7 @@ function isMgmAirportPrimary(hourly: HourlyForecast) {
return sourceTokens.some((value) => value === "mgm" || value.includes("turkey_mgm"));
}
function canonicalAirportPrimarySourceLabel(hourly: HourlyForecast) {
function canonicalAirportPrimarySourceLabel(hourly: ChartRenderState) {
const primary = hourly?.airportPrimary;
const tokens = [
primary?.source_code,
@@ -900,7 +900,7 @@ function canonicalAirportPrimarySourceLabel(hourly: HourlyForecast) {
}
function airportCodeForSeriesLabel(
hourly: HourlyForecast,
hourly: ChartRenderState,
row?: ScanOpportunityRow | null,
) {
const candidates = [
@@ -933,7 +933,7 @@ function isGenericAirportPrimaryLabel(label: string) {
}
function airportPrimarySeriesLabel(
hourly: HourlyForecast,
hourly: ChartRenderState,
isHKO: boolean,
row?: ScanOpportunityRow | null,
) {
@@ -956,7 +956,7 @@ function airportPrimarySeriesLabel(
}
function airportPrimaryUsesMetarFallback(
hourly: HourlyForecast,
hourly: ChartRenderState,
isHKO: boolean,
row?: ScanOpportunityRow | null,
) {
@@ -969,7 +969,7 @@ function airportPrimaryUsesMetarFallback(
return !payloadLabel || isGenericAirportPrimaryLabel(payloadLabel);
}
function metarStationCodeForSeries(row?: ScanOpportunityRow | null, hourly?: HourlyForecast) {
function metarStationCodeForSeries(row?: ScanOpportunityRow | null, hourly?: ChartRenderState) {
const candidates = [
row?.metar_context?.station,
hourly?.settlementStationCode,
@@ -981,13 +981,13 @@ function metarStationCodeForSeries(row?: ScanOpportunityRow | null, hourly?: Hou
.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 metarCode = metarStationCodeForSeries(row, hourly);
return Boolean(primaryCode && metarCode && primaryCode === metarCode);
}
function airportPrimaryObservationPoints(hourly: HourlyForecast) {
function airportPrimaryObservationPoints(hourly: ChartRenderState) {
return appendLatestAirportObservation(
hourly?.airportPrimaryTodayObs,
hourly?.airportPrimary,
@@ -1016,7 +1016,7 @@ function maxObservationValue(obs: Array<{ ts: number; value: number }>) {
function getRunwayHistoryObservationMetrics(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
hourly: ChartRenderState,
) {
const tzOffset = row?.tz_offset_seconds ?? 0;
const localDateStr = resolveChartLocalDate(row, hourly);
@@ -1061,7 +1061,7 @@ function observationSetContains(
function getObservationDisplayMetrics(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
hourly: ChartRenderState,
settlementPlate?: { maxTemp: number | null } | null,
) {
const tzOffset = row?.tz_offset_seconds ?? 0;
@@ -1372,7 +1372,7 @@ function mergeRunwayPlateHistory(
return Object.keys(result).length ? result : undefined;
}
function hasFullHourlyDetailPayload(hourly: HourlyForecast) {
function hasFullHourlyDetailPayload(hourly: ChartRenderState) {
if (!hourly) return false;
const probabilityBuckets =
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 as any).__detailKind === "full_chart_detail") return hourly as FullChartDetail;
return {
@@ -1470,7 +1470,7 @@ function latestRunwayHistoryRank(
}
function latestHourlyObservationRank(
hourly: HourlyForecast,
hourly: ChartRenderState,
row: ScanOpportunityRow | null,
) {
if (!hourly) return null;
@@ -1488,8 +1488,8 @@ function latestHourlyObservationRank(
}
function shouldKeepLiveHourlyDetailPayload(
base: HourlyForecast,
live: HourlyForecast,
base: ChartRenderState,
live: ChartRenderState,
row: ScanOpportunityRow | null,
) {
if (!base || !live) return false;
@@ -1500,8 +1500,8 @@ function shouldKeepLiveHourlyDetailPayload(
}
function hourlyLocalDatesConflict(
base: HourlyForecast,
live: HourlyForecast,
base: ChartRenderState,
live: ChartRenderState,
row: ScanOpportunityRow | null,
) {
const baseDate = String(base?.localDate || "").trim();
@@ -1563,7 +1563,7 @@ function seedRunwayPlateHistoryFromRow(
return Object.keys(history).length ? history : existing;
}
type HourlyForecast = {
type ChartRenderState = {
forecastTodayHigh?: number | null;
debPrediction?: number | 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[];
} | null;
type FullChartDetail = NonNullable<HourlyForecast> & {
type FullChartDetail = NonNullable<ChartRenderState> & {
readonly __detailKind: "full_chart_detail";
};
function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForecast {
function seedChartRenderStateFromRow(row: ScanOpportunityRow | null): ChartRenderState {
if (!row) return null;
const current = rowCurrentObservation(row);
const sourceCode = current?.sourceCode || undefined;
@@ -1658,10 +1658,10 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
}
function mergeHourlyWithLiveObservations(
base: HourlyForecast,
live: HourlyForecast,
base: ChartRenderState,
live: ChartRenderState,
row: ScanOpportunityRow | null,
): HourlyForecast {
): ChartRenderState {
if (!base) return live;
if (!live) return base;
if (hourlyLocalDatesConflict(base, live, row)) return base;
@@ -1708,9 +1708,9 @@ function mergeHourlyWithLiveObservations(
}
function mergeObservationSnapshotIntoHourly(
prev: HourlyForecast,
prev: ChartRenderState,
snapshot: ObservationSnapshot | null | undefined,
): HourlyForecast {
): ChartRenderState {
const live = observationSnapshotToHourly(snapshot);
if (!prev) return live;
if (!live) return prev;
@@ -1718,10 +1718,10 @@ function mergeObservationSnapshotIntoHourly(
}
function mergeRowObservationIntoHourly(
prev: HourlyForecast,
prev: ChartRenderState,
row: ScanOpportunityRow | null,
): HourlyForecast {
const seeded = seedHourlyForecastFromRow(row);
): ChartRenderState {
const seeded = seedChartRenderStateFromRow(row);
if (!prev) return seeded;
return mergeHourlyWithLiveObservations(prev, seeded, row);
}
@@ -1732,12 +1732,12 @@ function selectInitialHourlyForRowChange({
previousHourly,
row,
}: {
cachedHourly?: HourlyForecast;
cachedHourly?: ChartRenderState;
previousCity?: string | null;
previousHourly?: HourlyForecast;
previousHourly?: ChartRenderState;
row: ScanOpportunityRow | null;
}): HourlyForecast {
const seeded = seedHourlyForecastFromRow(row);
}): ChartRenderState {
const seeded = seedChartRenderStateFromRow(row);
const nextCity = normalizeCityKey(row?.city);
if (!nextCity) return seeded;
@@ -1752,7 +1752,7 @@ function selectInitialHourlyForRowChange({
return seeded;
}
type HourlyForecastFetchOptions = {
type ChartDetailFetchOptions = {
bypassLocalCache?: boolean;
ignoreCache?: boolean;
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;
const airportCurrent = normalizeObservationCondition(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;
if (!json || !hourlySource) return null;
const parsed: HourlyForecast = {
const parsed: ChartRenderState = {
forecastTodayHigh: json.forecast?.today_high ?? null,
debPrediction: json.deb?.prediction ?? (json as any)?.overview?.deb_prediction ?? null,
debQuality: json.deb ? {
@@ -2001,7 +2001,7 @@ function primeCityDetailCache(
resolution: string,
detail: CityDetail | null | undefined,
): FullChartDetail | null {
let data = parseHourlyForecastFromCityDetail(detail || null);
let data = parseFullChartDetailFromCityDetail(detail || null);
if (!data) return null;
const cacheKey = hourlyCacheKey(city, resolution);
data = preserveCachedRunwayHistory(cacheKey, data);
@@ -2176,9 +2176,9 @@ async function fetchLiveObservationForCity(city: string): Promise<ObservationSna
.catch(() => null);
}
async function fetchHourlyForecastForCity(
async function fetchFullChartDetailForCity(
city: string,
options: HourlyForecastFetchOptions = {},
options: ChartDetailFetchOptions = {},
): Promise<FullChartDetail | null> {
const resParam = options.resolution || "10m";
const cacheKey = hourlyCacheKey(city, resParam);
@@ -2232,7 +2232,7 @@ function shouldPollLiveChart({
function getLiveObservationLabels(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
hourly: ChartRenderState,
) {
const normalizedKey = normalizeCityKey(row?.city);
const runwaySensorCities = new Set([
@@ -2316,19 +2316,19 @@ function getLiveObservationLabels(
}
function mergePatchIntoHourly(
prev: HourlyForecast,
prev: ChartRenderState,
patch: CityPatch,
): HourlyForecast {
): ChartRenderState {
const changes = patch.changes || {};
const tempValue = validNumber(changes.temp);
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 source = typeof changes.source === "string" ? changes.source : "";
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 || {
forecastTodayHigh: null,
debPrediction: null,
@@ -2470,7 +2470,7 @@ function parseRunwayHistoryTime(
function buildRunwayHistorySeries(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
hourly: ChartRenderState,
tzOffset: number,
localDateStr: string,
minPoints = 2,
@@ -2607,7 +2607,7 @@ function formatDailyDateLabel(dateStr: string): string {
function buildDailyChartData(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
hourly: ChartRenderState,
daysCount: number,
): { data: Array<Record<string, string | number | null>>; series: EvidenceSeries[] } {
const localDateStr = resolveChartLocalDate(row, hourly);
@@ -2700,7 +2700,7 @@ function addLocalDayAxisSlots(timeline: Set<number>, bounds: LocalDayBounds | nu
function resolveFullDayFallbackAnchor(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
hourly: ChartRenderState,
tzOffsetSeconds: number,
localDateStr: string,
) {
@@ -2792,7 +2792,7 @@ function addHourlyTimesToTimeline(
}
function resolveModelCurveTimes(
hourly: HourlyForecast,
hourly: ChartRenderState,
modelTemps: Array<number | null>,
) {
if (hourly?.modelTimes?.length) return hourly.modelTimes;
@@ -2827,7 +2827,7 @@ function probabilityBucketRange(bucket: ProbabilityBucket, value: number) {
function buildLegacyGaussianProbabilityOverlay(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
hourly: ChartRenderState,
): ProbabilityOverlay | null {
const source = hourly?.probabilities || null;
const rowBuckets = ((row as any)?.distribution_full || (row as any)?.distribution_preview || []) as ProbabilityBucket[];
@@ -2880,7 +2880,7 @@ function buildLegacyGaussianProbabilityOverlay(
function buildFullDayChartData(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
hourly: ChartRenderState,
isEn: boolean,
): { data: Array<Record<string, any>>; series: EvidenceSeries[]; probabilityOverlay: ProbabilityOverlay | null } {
const tzOffset = row?.tz_offset_seconds ?? 0;
@@ -3510,7 +3510,7 @@ export {
buildIntDegreeTicks,
buildModelSummaryCards,
buildRunwayPlates,
fetchHourlyForecastForCity,
fetchFullChartDetailForCity,
fetchLiveObservationForCity,
getActiveTemperatureSeries,
getTemperatureSeriesForRunwayDetailsMode,
@@ -3534,7 +3534,7 @@ export {
selectCompactSecondaryTemp,
selectDisplayRunwayTemp,
selectInitialHourlyForRowChange,
seedHourlyForecastFromRow,
seedChartRenderStateFromRow,
seriesStats,
shouldPollLiveChart,
observationPayloadToSnapshot,
@@ -3543,4 +3543,4 @@ export {
rememberCityDetailBatchDiagnostics as __rememberCityDetailBatchDiagnosticsForTest,
};
export type { EvidenceSeries, FullChartDetail, HourlyForecast, ObservationSnapshot, PeakGlowMeta, PeakGlowState, ProbabilityOverlay };
export type { EvidenceSeries, FullChartDetail, ChartRenderState, ObservationSnapshot, PeakGlowMeta, PeakGlowState, ProbabilityOverlay };