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