重命名图表渲染态类型
This commit is contained in:
@@ -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
-13
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user