diff --git a/docs/CITY_DATA_SOURCES.md b/docs/CITY_DATA_SOURCES.md index fd97cdc1..9a1226d5 100644 --- a/docs/CITY_DATA_SOURCES.md +++ b/docs/CITY_DATA_SOURCES.md @@ -13,6 +13,13 @@ | hong kong | HKO 官方 CSV | ~1 min | data.weather.gov.hk, 4 路 CSV | | lau fau shan | HKO 官方 CSV | ~1 min | 同上,站号 LFS | | singapore | MSS 官方 API | ~1 min | api.data.gov.sg, 站号 S24 | +| beijing | AMSC AWOS (ZBAA) | ~1 min | 中国 | +| shanghai | AMSC AWOS (ZSPD) | ~1 min | 中国 | +| guangzhou | AMSC AWOS (ZGGG) | ~1 min | 中国 | +| chengdu | AMSC AWOS (ZUUU) | ~1 min | 中国 | +| chongqing | AMSC AWOS (ZUCK) | ~1 min | 中国 | +| wuhan | AMSC AWOS (ZHHH) | ~1 min | 中国 | +| qingdao | AMSC AWOS (ZSQD) | ~1 min | 中国 | ### Tier 2 — 5 分钟高频 (MADIS) @@ -34,13 +41,6 @@ | 城市 | 来源 | 频率 | 国家/地区 | |------|------|------|------| -| beijing | AMSC AWOS (ZBAA) | 准实时 | 中国 | -| shanghai | AMSC AWOS (ZSPD) | 准实时 | 中国 | -| guangzhou | AMSC AWOS (ZGGG) | 准实时 | 中国 | -| chengdu | AMSC AWOS (ZUUU) | 准实时 | 中国 | -| chongqing | AMSC AWOS (ZUCK) | 准实时 | 中国 | -| wuhan | AMSC AWOS (ZHHH) | 准实时 | 中国 | -| qingdao | AMSC AWOS (ZSQD) | 准实时 | 中国 | | tokyo | JMA AMeDAS (44166) | 10 min | 日本 | | ankara | MGM (17128) | 5-15 min | 土耳其 | | istanbul | MGM (17058) | 5-15 min | 土耳其 | @@ -48,7 +48,6 @@ | amsterdam | KNMI 数据平台 | 10 min | 荷兰 | | taipei | CWA 开放数据 (466920) | ~10 min | 台湾 | | tel aviv | IMS Lod (225) | 实时 | 以色列 | -| jeddah | NCM 官方 | 实时 | 沙特 | | paris | AEROWEB 实况 / AROME HD 15min | 实时/15min | 法国 | ### Tier 4 — 仅 METAR(10 分钟缓存) @@ -56,6 +55,7 @@ | 城市 | ICAO | 备注 | |------|------|------| | london | EGLC | Met Office 仅 1 小时更新 | +| jeddah | OEJN | NCM 数据源目前不可用 | | moscow | UUWW | 俄罗斯 METAR 集群 + NOAA WRH 结算 | | shenzhen | ZGSZ | 唯一无 AMSC AWOS 的中国城市 | | munich | EDDM | DWD 延迟约 1 小时 | diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index e3ce2cb3..0bcc49cd 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -67,6 +67,19 @@ function isTemperatureSeriesVisibleByDefault(city: string, seriesKey: string) { return true; } +function getVisibleTemperatureSeries( + city: string, + series: EvidenceSeries[], + userToggledKeys: Record, +) { + return series.filter((item) => { + if (userToggledKeys[item.key] !== undefined) { + return userToggledKeys[item.key]; + } + return isTemperatureSeriesVisibleByDefault(city, item.key); + }); +} + function buildRunwayPlates( amos: AmosData | null | undefined, row: ScanOpportunityRow | null, @@ -152,6 +165,7 @@ function buildRunwayPlates( } type ObsPoint = { time?: string | null; temp?: number | null }; +type RawObsPoint = ObsPoint | [string | number | null, number | null | undefined]; type EvidenceSeries = { key: string; @@ -253,8 +267,17 @@ function formatTimestamp(ts: number): string { return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`; } -function normObs(points: ObsPoint[] | null | undefined, tzOffsetSeconds: number, limit = MAX_OBS_POINTS) { +function normalizeRawObsPoint(point: RawObsPoint): ObsPoint | null { + if (Array.isArray(point)) { + return { time: point[0] == null ? null : String(point[0]), temp: validNumber(point[1]) }; + } + return point; +} + +function normObs(points: RawObsPoint[] | null | undefined, tzOffsetSeconds: number, limit = MAX_OBS_POINTS) { return (points || []) + .map(normalizeRawObsPoint) + .filter((p): p is ObsPoint => p !== null) .filter((p) => validNumber(p.temp) !== null && p.time) .map((p) => ({ ts: getCityLocalUtcTimestamp(p.time, tzOffsetSeconds)!, @@ -273,6 +296,63 @@ function seriesStats(values: Array) { return { latest, high, delta15 }; } +function latestObservationValue(obs: Array<{ ts: number; value: number }>) { + if (!obs.length) return null; + return obs.reduce((latest, point) => (point.ts > latest.ts ? point : latest), obs[0]).value; +} + +function maxObservationValue(obs: Array<{ ts: number; value: number }>) { + if (!obs.length) return null; + return Math.max(...obs.map((point) => point.value)); +} + +function observationSetContains( + superset: Array<{ ts: number; value: number }>, + subset: Array<{ ts: number; value: number }>, +) { + if (!superset.length || !subset.length) return false; + return subset.every((point) => + superset.some((candidate) => candidate.ts === point.ts && Math.abs(candidate.value - point.value) < 0.01), + ); +} + +function getObservationDisplayMetrics( + row: ScanOpportunityRow | null, + hourly: HourlyForecast, + settlementPlate?: { maxTemp: number | null } | null, +) { + const tzOffset = row?.tz_offset_seconds ?? 0; + const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset); + const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset); + const latestSettlement = latestObservationValue(settlementObs); + const latestMetar = latestObservationValue(metarObs); + const highSettlement = maxObservationValue(settlementObs); + const highMetar = maxObservationValue(metarObs); + const airportCurrentTemp = validNumber(hourly?.airportCurrent?.temp) ?? validNumber(hourly?.airportPrimary?.temp); + const airportHigh = validNumber(hourly?.airportCurrent?.max_so_far) ?? validNumber(hourly?.airportPrimary?.max_so_far); + const rowMetarHigh = validNumber(row?.metar_context?.airport_max_so_far ?? row?.metar_context?.max_temp ?? row?.current_max_so_far); + + const currentRunwayTemp = + validNumber(hourly?.amos?.temp_c) ?? + settlementPlate?.maxTemp ?? + latestSettlement ?? + latestMetar ?? + airportCurrentTemp ?? + validNumber(row?.current_temp) ?? + null; + const observedHighMetar = airportHigh ?? highSettlement ?? highMetar ?? rowMetarHigh ?? null; + const observedHighRunway = + settlementPlate?.maxTemp ?? + highSettlement ?? + airportHigh ?? + highMetar ?? + validNumber(row?.current_max_so_far) ?? + currentRunwayTemp ?? + null; + + return { currentRunwayTemp, observedHighMetar, observedHighRunway }; +} + function isSettlementRunway(row: ScanOpportunityRow | null, rwy: string) { const cityKey = normalizeCityKey(row?.city); const settlementPairs = SETTLEMENT_RUNWAY_PAIRS[cityKey] || []; @@ -307,6 +387,7 @@ type HourlyForecast = { multiModelDaily?: Record; settlementTodayObs?: ObsPoint[]; metarTodayObs?: ObsPoint[]; + airportPrimaryTodayObs?: RawObsPoint[]; } | null; function parseRunwayHistoryValue(point: Record) { @@ -368,6 +449,7 @@ function buildRunwayHistorySeries( const runwayObs = amos?.runway_obs; const runwayPairs = runwayObs?.runway_pairs || []; const runwayTemps = runwayObs?.temperatures || []; + const pointTemps = runwayObs?.point_temperatures || []; const anchor = getCityLocalUtcTimestamp(amos?.observation_time_local || amos?.observation_time || hourly?.localTime || row?.local_time, tzOffset, localDateStr) ?? getCityLocalUtcTimestamp(row?.local_time, tzOffset, localDateStr); @@ -376,20 +458,35 @@ function buildRunwayHistorySeries( return runwayTemps .map((rawTemps, index) => { - if (!Array.isArray(rawTemps) || rawTemps.length <= 2) return null; + if (!Array.isArray(rawTemps)) return null; const rwy = runwayLabelFromPair(runwayPairs[index], index); const isSettlement = isSettlementRunway(row, rwy); - const values = rawTemps - .map(validNumber) + const pointTemp = Array.isArray(pointTemps) ? pointTemps[index] : null; + const snapshotValues = [ + validNumber((pointTemp as any)?.tdz_temp), + validNumber((pointTemp as any)?.mid_temp), + validNumber((pointTemp as any)?.end_temp), + validNumber((pointTemp as any)?.target_runway_max), + ].filter((value): value is number => value !== null); + const samples = rawTemps.map(validNumber).filter((value): value is number => value !== null); + const valuesForLine = samples.length > 1 + ? samples + : snapshotValues.length > 1 + ? snapshotValues + : samples.length === 1 + ? [samples[0], samples[0]] + : snapshotValues.length === 1 + ? [snapshotValues[0], snapshotValues[0]] + : []; + const values = valuesForLine .map((value, pointIndex) => { - if (value === null) return null; - const minutesFromEnd = rawTemps.length - 1 - pointIndex; + const minutesFromEnd = (valuesForLine.length - 1 - pointIndex) * FULL_DAY_SLOT_MINUTES; return { ts: anchor - minutesFromEnd * 60 * 1000, value, }; }) - .filter((point): point is { ts: number; value: number } => point !== null); + .filter((point) => validNumber(point.value) !== null); if (values.length <= 1) return null; return { key: runwaySeriesKey(rwy), @@ -625,6 +722,7 @@ function buildFullDayChartData( const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset); const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset); + const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset); const runwayHistorySeries = buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr); const slots = generateFullDaySlots(localDateStr); @@ -666,7 +764,21 @@ function buildFullDayChartData( } // ── METAR ── - if (metarObs.length) { + if (madisObs.length) { + const madisVals = binObservationsToSlots(slots, madisObs); + if (madisVals.some((v) => v !== null)) { + series.push({ + key: "madis", + label: hourly?.airportPrimary?.source_label || "NOAA MADIS", + source: hourly?.airportPrimary?.station_code || row?.airport || "MADIS", + color: "#0284c7", + dashed: false, + values: madisVals, + }); + } + } + + if (metarObs.length && !observationSetContains(madisObs, metarObs)) { const mvals = binObservationsToSlots(slots, metarObs); if (mvals.some((v) => v !== null)) { series.push({ @@ -908,6 +1020,7 @@ export function LiveTemperatureThresholdChart({ multiModelDaily: json.multi_model_daily || {}, settlementTodayObs: (json as any).timeseries?.settlement_today_obs || (json as any)?.settlement_today_obs || undefined, metarTodayObs: (json as any).timeseries?.metar_today_obs || (json as any)?.metar_today_obs || undefined, + airportPrimaryTodayObs: (json as any)?.official?.airport_primary_today_obs || (json as any)?.airport_primary_today_obs || undefined, }; _hourlyCache.set(city, { ts: Date.now(), data }); setHourly(data); @@ -951,7 +1064,7 @@ export function LiveTemperatureThresholdChart({ }; const activeSeries = useMemo(() => { - return chartSeries.filter((s) => isSeriesVisible(s.key)); + return getVisibleTemperatureSeries(city, chartSeries, userToggledKeys); }, [chartSeries, userToggledKeys, city]); const cityKey = String(row?.city || "").toLowerCase().trim(); @@ -987,9 +1100,10 @@ export function LiveTemperatureThresholdChart({ const metarHighLabel = isHKO ? '天文台' : 'METAR 官方'; - const currentRunwayTemp = validNumber(hourly?.amos?.temp_c) ?? validNumber(row?.current_temp) ?? settlementPlate?.maxTemp ?? null; - const observedHighMetar = validNumber(row?.metar_context?.airport_max_so_far ?? row?.metar_context?.max_temp ?? row?.current_max_so_far) ?? null; - const observedHighRunway = validNumber(row?.current_max_so_far) ?? settlementPlate?.maxTemp ?? currentRunwayTemp ?? null; + const { currentRunwayTemp, observedHighMetar, observedHighRunway } = useMemo( + () => getObservationDisplayMetrics(row, hourly, settlementPlate), + [row, hourly, settlementPlate], + ); const wundergroundDailyHigh = validNumber(hourly?.airportCurrent?.max_so_far ?? hourly?.airportPrimary?.max_so_far) ?? null; const modelValues = Object.values(row?.model_cluster_sources || {}) @@ -1041,10 +1155,10 @@ export function LiveTemperatureThresholdChart({ return list.sort((a, b) => a.threshold - b.threshold); }, [row, allRows]); - const intDegreeTicks = useMemo(() => buildIntDegreeTicks(series, data), [series, data]); + const intDegreeTicks = useMemo(() => buildIntDegreeTicks(activeSeries, data), [activeSeries, data]); const chartDomain = useMemo( - () => buildChartDomain(series, data), - [series, data], + () => buildChartDomain(activeSeries, data), + [activeSeries, data], ); const subtitle = row @@ -1328,13 +1442,13 @@ export function LiveTemperatureThresholdChart({ )} {/* Multi-model list (Only in 1D mode and when not compact) */} - {timeframe === "1D" && !compact && hasRunwayData && series.some((s) => s.key.startsWith("model_curve_")) && ( + {timeframe === "1D" && !compact && hasRunwayData && activeSeries.some((s) => s.key.startsWith("model_curve_")) && (
{isEn ? "Models:" : "多模型:"} - {series + {activeSeries .filter((s) => s.key.startsWith("model_curve_")) .map((s) => { const stats = seriesStats(s.values); @@ -1467,3 +1581,5 @@ export function __buildTemperatureChartDataForTest( } export const __isTemperatureSeriesVisibleByDefaultForTest = isTemperatureSeriesVisibleByDefault; +export const __getVisibleTemperatureSeriesForTest = getVisibleTemperatureSeries; +export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics; diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts index be674986..8007c638 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts @@ -1,5 +1,7 @@ import { __buildTemperatureChartDataForTest, + __getObservationDisplayMetricsForTest, + __getVisibleTemperatureSeriesForTest, __isTemperatureSeriesVisibleByDefaultForTest, } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart"; @@ -50,6 +52,7 @@ export function runTests() { } as any; const { series } = __buildTemperatureChartDataForTest(guangzhou, hourly, "1D"); + const defaultVisibleSeries = __getVisibleTemperatureSeriesForTest("guangzhou", series, {}); const settlementRunway = seriesByKey(series, "runway_02L_20R") as any; assert(settlementRunway, "settlement runway should use a stable runway-pair key"); @@ -82,6 +85,16 @@ export function runTests() { !__isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "model_curve_ECMWF"), "multi-model curves should be hidden by default", ); + assert( + !defaultVisibleSeries.some((item) => item.key === "model_curve_ECMWF"), + "hidden multi-model curves should not affect the active chart series by default", + ); + assert( + __getVisibleTemperatureSeriesForTest("guangzhou", series, { model_curve_ECMWF: true }).some( + (item) => item.key === "model_curve_ECMWF", + ), + "users should still be able to enable a hidden multi-model curve from the legend", + ); assert( __isTemperatureSeriesVisibleByDefaultForTest("paris", "model_curve_AROME HD"), "Paris AROME HD should be the only default-visible model curve exception", @@ -107,4 +120,111 @@ export function runTests() { ); assert(seriesByKey(shenzhen.series, "metar"), "Shenzhen/Lau Fau Shan observations should stay as METAR/HKO observations, not runway data"); assert(!shenzhen.series.some((item) => item.key.startsWith("runway_")), "Shenzhen should not be treated as an AMSC runway city"); + + const chengduFromAmosSnapshot = __buildTemperatureChartDataForTest( + { + city: "chengdu", + local_date: "2026-05-26", + local_time: "05:25", + tz_offset_seconds: 8 * 60 * 60, + airport: "ZUUU", + } as any, + { + localTime: "05:25", + times: ["00:00", "06:00", "12:00", "18:00"], + temps: [24, 28, 31, 27], + amos: { + observation_time: "2026-05-25T21:25:00+00:00", + observation_time_local: "2026-05-26 05:25:00", + runway_obs: { + runway_pairs: [ + ["02L", "20R"], + ["02R", "20L"], + ], + temperatures: [ + [24.4, null], + [24.2, null], + ], + point_temperatures: [ + { runway: "02L/20R", tdz_temp: 24.4, mid_temp: null, end_temp: 24.8 }, + { runway: "02R/20L", tdz_temp: 24.2, mid_temp: null, end_temp: 24.6 }, + ], + }, + }, + } as any, + "1D", + ); + + const chengduSettlementRunway = seriesByKey(chengduFromAmosSnapshot.series, "runway_02L_20R") as any; + assert(chengduSettlementRunway, "AMOS runway_obs snapshot should still create the settlement runway chart line"); + assert(chengduSettlementRunway.color === "#009688", "AMOS snapshot settlement runway should use highlight cyan"); + assert(chengduSettlementRunway.featured === true, "AMOS snapshot settlement runway should be featured"); + assert(!chengduSettlementRunway.dashed, "AMOS snapshot settlement runway should be solid"); + + const chengduAuxRunway = seriesByKey(chengduFromAmosSnapshot.series, "runway_02R_20L") as any; + assert(chengduAuxRunway, "AMOS runway_obs snapshot should create auxiliary runway chart lines"); + assert(chengduAuxRunway.dashed === true, "AMOS snapshot auxiliary runway should be dashed"); + + const newYorkMetrics = __getObservationDisplayMetricsForTest( + { + city: "new york", + local_date: "2026-05-25", + local_time: "17:30", + tz_offset_seconds: -4 * 60 * 60, + current_temp: 0, + current_max_so_far: 0, + metar_context: { + airport_max_so_far: 0, + }, + } as any, + { + localTime: "17:30", + times: ["00:00"], + temps: [55], + airportCurrent: { + temp: 73.9, + max_so_far: 73.9, + }, + metarTodayObs: [ + { time: "16:51", temp: 73.9 }, + { time: "15:51", temp: 73.0 }, + { time: "00:34", temp: 55.0 }, + ], + } as any, + null, + ); + + assert(newYorkMetrics.currentRunwayTemp === 73.9, "weather-station header should use detail METAR/current temp before stale row zero"); + assert(newYorkMetrics.observedHighMetar === 73.9, "METAR high header should use detail METAR high before stale row zero"); + + const newYorkWithMadis = __buildTemperatureChartDataForTest( + { + city: "new york", + local_date: "2026-05-25", + local_time: "17:30", + tz_offset_seconds: -4 * 60 * 60, + airport: "KLGA", + } as any, + { + localTime: "17:30", + times: ["00:00", "06:00", "12:00", "18:00"], + temps: [55, 57, 65, 72], + airportPrimary: { + source_code: "madis_hfmetar", + source_label: "NOAA MADIS", + }, + airportPrimaryTodayObs: [ + ["16:51", 73.9], + ["15:51", 73], + ["15:47", 71.6], + ["15:44", 72], + ], + metarTodayObs: [{ time: "16:51", temp: 73.9 }], + } as any, + "1D", + ); + const madisSeries = seriesByKey(newYorkWithMadis.series, "madis") as any; + assert(madisSeries, "US MADIS airport-primary observations should render as a dedicated chart series"); + assert(madisSeries.label.includes("MADIS"), "US MADIS series should be labeled as NOAA MADIS instead of plain METAR"); + assert(madisSeries.values.filter((value: number | null) => value !== null).length >= 2, "MADIS series should keep sub-hourly observations"); }