feat: add AMSC runway observations

This commit is contained in:
2569718930@qq.com
2026-05-15 01:41:49 +08:00
parent c4b1844a67
commit 4cc579ccb3
15 changed files with 823 additions and 14 deletions
@@ -59,6 +59,14 @@ const MonitorPanel = dynamic(
{ ssr: false },
);
const RunwayObservationsPanel = dynamic(
() =>
import(
"@/components/dashboard/scan-terminal/RunwayObservationsPanel"
).then((module) => module.RunwayObservationsPanel),
{ ssr: false },
);
const CityDetailPanel = dynamic(
() =>
import("@/components/dashboard/DetailPanel").then(
@@ -359,7 +367,7 @@ function ScanTerminalScreen() {
/>
);
}
if (resolvedView === "monitor") {
if (resolvedView === "monitor" || resolvedView === "runway") {
return null; // MonitorPanel is rendered below the main view switch
}
if (!isPro) {
@@ -472,6 +480,15 @@ function ScanTerminalScreen() {
>
🔥 {isEn ? "Monitor" : "市场监控"}
</button>
<button
type="button"
role="tab"
aria-selected={resolvedView === "runway"}
className={resolvedView === "runway" ? "active" : ""}
onClick={() => setActiveView("runway")}
>
🛬 {isEn ? "Runways" : "跑道观测"}
</button>
</div>
<div className="scan-list-status">
{terminalData?.generated_at ? (
@@ -539,6 +556,13 @@ function ScanTerminalScreen() {
<ProFeaturePaywall feature="monitor" />
)
)}
{resolvedView === "runway" && (
isPro ? (
<RunwayObservationsPanel />
) : (
<ProFeaturePaywall feature="monitor" />
)
)}
</section>
</main>
@@ -95,7 +95,12 @@ function playNewHighBeep(): void {
function trendClass(detail: CityDetail | undefined, key?: string): "rising" | "falling" | "flat" {
const { source } = resolveMonitorTemperature(detail);
// Runway surface temp vs air temp comparison is meaningless
if (source === "amos_runway_median" || source === "amos_runway") return "flat";
if (
source === "amos_runway_median" ||
source === "amos_runway" ||
source === "amsc_awos_runway_max" ||
source === "amsc_awos_runway"
) return "flat";
const cur = resolveMonitorTemperature(detail).value;
const max = resolveMaxSoFar(detail, key);
if (cur != null && max != null && cur >= max + 0.3) return "rising";
@@ -177,6 +182,9 @@ function airportLabel(key: string, isEn: boolean) {
const HKO_OBS_CITIES = new Set<MonitorKey>(["hong kong", "lau fau shan"]);
const SOURCE_LABELS: Record<string, { en: string; zh: string }> = {
amsc_awos_runway_max: { en: "AMSC AWOS Runway", zh: "AMSC AWOS 跑道观测" },
amsc_awos_runway: { en: "AMSC AWOS Runway", zh: "AMSC AWOS 跑道观测" },
amsc_awos: { en: "AMSC AWOS", zh: "AMSC AWOS" },
amos_runway_median: { en: "AMOS Runway", zh: "AMOS 跑道温度" },
amos_runway: { en: "AMOS Runway", zh: "AMOS 跑道温度" },
amos: { en: "AMOS", zh: "AMOS" },
@@ -512,7 +520,11 @@ export default function MonitorPanel({
const tempInfo = resolveMonitorTemperature(detail);
const cur = tempInfo.value;
const curSource = tempInfo.source;
const isRunwayTemp = curSource === "amos_runway_median" || curSource === "amos_runway";
const isRunwayTemp =
curSource === "amos_runway_median" ||
curSource === "amos_runway" ||
curSource === "amsc_awos_runway_max" ||
curSource === "amsc_awos_runway";
const max = resolveMaxSoFar(detail, key); // HKO cities fall back to current.max_so_far
const mtt = ac?.max_temp_time ?? detail.current?.max_temp_time ?? null;
const freshnessInfo = getObservationFreshness(detail);
@@ -1,6 +1,8 @@
import type { CityDetail } from "@/lib/dashboard-types";
export type MonitorTemperatureSource =
| "amsc_awos_runway_max"
| "amsc_awos_runway"
| "amos_runway_median"
| "amos_runway"
| "amos"
@@ -36,6 +38,13 @@ export function getAmosRunwayTemperature(detail?: CityDetail | null) {
const values = runwayTemps
.map((pair) => finiteNumber(pair?.[0]))
.filter((value): value is number => value != null);
if (detail?.amos?.source === "amsc_awos") {
if (!values.length) return null;
return {
source: values.length > 1 ? "amsc_awos_runway_max" : "amsc_awos_runway",
value: Math.max(...values),
} satisfies MonitorTemperature;
}
const value = median(values);
if (value == null) return null;
return {
@@ -0,0 +1,175 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { RefreshCw } from "lucide-react";
import { useCityDetails, useDashboardActions } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import type { CityDetail } from "@/lib/dashboard-types";
const CHINA_RUNWAY_CITIES = [
{ key: "beijing", zh: "北京", en: "Beijing", icao: "ZBAA" },
{ key: "shanghai", zh: "上海", en: "Shanghai", icao: "ZSPD" },
{ key: "guangzhou", zh: "广州", en: "Guangzhou", icao: "ZGGG" },
{ key: "shenzhen", zh: "深圳", en: "Shenzhen", icao: "ZGSZ" },
{ key: "chengdu", zh: "成都", en: "Chengdu", icao: "ZUUU" },
{ key: "chongqing", zh: "重庆", en: "Chongqing", icao: "ZUCK" },
{ key: "wuhan", zh: "武汉", en: "Wuhan", icao: "ZHHH" },
] as const;
function formatTemp(value: number | null | undefined, symbol = "°C") {
return value == null || !Number.isFinite(Number(value))
? "-"
: `${Number(value).toFixed(1)}${symbol}`;
}
function getRunwayRows(detail?: CityDetail | null) {
return detail?.amos?.runway_obs?.point_temperatures ?? [];
}
function sourceIsAmsc(detail?: CityDetail | null) {
return detail?.amos?.source === "amsc_awos";
}
function RunwayCityCard({
detail,
isEn,
label,
}: {
detail?: CityDetail | null;
isEn: boolean;
label: (typeof CHINA_RUNWAY_CITIES)[number];
}) {
const rows = getRunwayRows(detail);
const tempSymbol = detail?.temp_symbol || "°C";
const range = detail?.amos?.runway_temp_range;
const obsLocal = detail?.amos?.observation_time_local || detail?.amos?.observation_time;
const hasAmsc = sourceIsAmsc(detail) && rows.length > 0;
return (
<article className="monitor-card">
<div className="monitor-card-head">
<span className="monitor-city-name">{isEn ? label.en : label.zh}</span>
<span className="monitor-airport-name">/ {label.icao}</span>
<span className="monitor-obs-time">{obsLocal || "--"}</span>
</div>
<div className="monitor-stats">
<div className="monitor-high-row">
<span className="monitor-stat-label">AMSC AWOS</span>
<span className="monitor-high-value">
{range ? `${range[0].toFixed(1)}${range[1].toFixed(1)}${tempSymbol}` : "--"}
</span>
</div>
<div className="monitor-obs-row">
<span className="monitor-stat-label">
{isEn ? "Runway-point air temperature" : "跑道观测点气温"}
</span>
<span className="monitor-obs-age fresh">
{isEn ? "not pavement temp" : "非道面温度"}
</span>
</div>
</div>
{hasAmsc ? (
<>
<div className="monitor-divider" />
<div className="monitor-rw-row">
<span className="monitor-rw-label">{isEn ? "Runway" : "跑道"}</span>
<span className="monitor-rw-temp">TDZ / MID / END</span>
</div>
{rows.map((row) => (
<div key={row.runway || `${row.tdz_temp}-${row.end_temp}`} className="monitor-rw-row">
<span className="monitor-rw-label">{row.runway || "--"}</span>
<span className="monitor-rw-temp">
{formatTemp(row.tdz_temp, tempSymbol)} / {formatTemp(row.mid_temp, tempSymbol)} / {formatTemp(row.end_temp, tempSymbol)}
</span>
</div>
))}
{detail?.amos?.raw_metar ? (
<div className="scan-amos-runway-detail">
METAR {detail.amos.raw_metar.replace(/^METAR\s+/i, "")}
</div>
) : null}
</>
) : (
<div className="scan-empty-state compact">
{isEn ? "No AMSC runway observation loaded yet." : "暂无 AMSC 跑道观测。"}
</div>
)}
</article>
);
}
export function RunwayObservationsPanel() {
const { locale } = useI18n();
const isEn = locale === "en-US";
const { cityDetailsByName } = useCityDetails();
const { ensureCityDetail } = useDashboardActions();
const [refreshing, setRefreshing] = useState(false);
const loadAll = useCallback(
async (force: boolean) => {
setRefreshing(true);
try {
await Promise.allSettled(
CHINA_RUNWAY_CITIES.map((city) =>
ensureCityDetail(city.key, force, "panel"),
),
);
} finally {
setRefreshing(false);
}
},
[ensureCityDetail],
);
useEffect(() => {
void loadAll(false);
}, [loadAll]);
const cards = useMemo(
() =>
CHINA_RUNWAY_CITIES.map((city) => ({
...city,
detail: cityDetailsByName[city.key],
})),
[cityDetailsByName],
);
return (
<div className="monitor-panel runway-observations-panel">
<div className="monitor-toolbar">
<div>
<div className="monitor-title">
{isEn ? "Runway Observations" : "跑道观测"}
</div>
<div className="monitor-subtitle">
{isEn
? "AMSC AWOS · China mainland airports · TDZ/MID/END air temperature"
: "AMSC AWOS · 国内机场 · TDZ/MID/END 跑道观测点气温"}
</div>
</div>
<button
type="button"
className="monitor-refresh-button"
disabled={refreshing}
onClick={() => void loadAll(true)}
>
<RefreshCw size={14} className={refreshing ? "spin" : undefined} />
{isEn ? "Refresh" : "刷新"}
</button>
</div>
<div className="monitor-grid">
{cards.map((city) => (
<RunwayCityCard
key={city.key}
detail={city.detail}
isEn={isEn}
label={city}
/>
))}
</div>
</div>
);
}
@@ -14,7 +14,7 @@ import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
import type { Locale } from "@/lib/i18n";
export type ScanTerminalContentView = "analysis" | "map" | "monitor";
export type ScanTerminalContentView = "analysis" | "map" | "monitor" | "runway";
type ThemeMode = "dark" | "light";
@@ -54,6 +54,23 @@ export function runTests() {
assert.equal(seoulTemp.value, 25);
assert.equal(seoulTemp.source, "amos_runway_median");
const beijing = detail({
display_name: "Beijing",
name: "beijing",
amos: {
runway_obs: {
runway_pairs: [["18R", "36L"], ["18L", "36R"]],
temperatures: [[20.8, null], [21.0, null]],
},
source: "amsc_awos",
temp_c: 21,
temp_source: "runway_max",
},
});
const beijingTemp = resolveMonitorTemperature(beijing);
assert.equal(beijingTemp.value, 21);
assert.equal(beijingTemp.source, "amsc_awos_runway_max");
const metarOnly = detail({
airport_current: { obs_time: "15:00", temp: 30 },
current: { temp: 29 } as CityDetail["current"],
@@ -0,0 +1,53 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const shellPartsPath = path.join(
projectRoot,
"components",
"dashboard",
"scan-terminal",
"ScanTerminalShellParts.tsx",
);
const dashboardPath = path.join(
projectRoot,
"components",
"dashboard",
"ScanTerminalDashboard.tsx",
);
const panelPath = path.join(
projectRoot,
"components",
"dashboard",
"scan-terminal",
"RunwayObservationsPanel.tsx",
);
const shellPartsSource = fs.readFileSync(shellPartsPath, "utf8");
const dashboardSource = fs.readFileSync(dashboardPath, "utf8");
assert(
shellPartsSource.includes('"runway"'),
"scan terminal content view must include a runway tab state",
);
assert(
dashboardSource.includes("跑道观测") && dashboardSource.includes('setActiveView("runway")'),
"dashboard tabs must expose 跑道观测 next to 市场监控",
);
assert(
fs.existsSync(panelPath),
"RunwayObservationsPanel.tsx must render the dedicated runway tab body",
);
const panelSource = fs.readFileSync(panelPath, "utf8");
assert(
panelSource.includes("AMSC AWOS") &&
panelSource.includes("TDZ") &&
panelSource.includes("MID") &&
panelSource.includes("END"),
"runway tab must identify AMSC AWOS and show TDZ/MID/END point temperatures",
);
}
+7
View File
@@ -958,6 +958,12 @@ export interface AmosData {
runway_obs?: {
runway_pairs?: Array<[string, string]> | null;
temperatures?: Array<[number | null, number | null]> | null;
point_temperatures?: Array<{
runway?: string | null;
tdz_temp?: number | null;
mid_temp?: number | null;
end_temp?: number | null;
}> | null;
pressures_hpa?: Array<number | null> | null;
wind_directions?: Array<[number, number, number] | null> | null;
wind_speeds?: Array<[number, number, number] | null> | null;
@@ -967,6 +973,7 @@ export interface AmosData {
observation_source?: string | null;
observation_source_zh?: string | null;
observation_time?: string | null;
observation_time_local?: string | null;
}
export interface HistoryPoint {
+18 -4
View File
@@ -31,6 +31,15 @@ const DEFAULT_SOURCE_PROFILE: SourceProfile = {
};
const SOURCE_PROFILES: Record<string, SourceProfile> = {
amsc_awos: {
code: "amsc_awos",
label: "AMSC AWOS",
nativeUpdateIntervalSec: 60,
freshWindowSec: 180,
expectedGraceSec: 180,
staleAfterSec: 900,
pollIntervalSec: 60,
},
amos: {
code: "amos",
label: "AMOS",
@@ -111,6 +120,7 @@ const SOURCE_PROFILES: Record<string, SourceProfile> = {
function canonicalSourceCode(value?: string | null) {
const code = normalizeObservationSourceCode(value || "metar");
if (!code) return "metar";
if (code.includes("amsc")) return "amsc_awos";
if (code.includes("amos")) return "amos";
if (code.includes("jma")) return "jma";
if (code.includes("fmi")) return "fmi";
@@ -207,12 +217,16 @@ export function getObservationFreshness(detail?: CityDetail | null) {
const hasAmosRunway =
(detail.amos?.runway_obs?.temperatures?.length || 0) > 0 ||
(detail.amos?.runway_temps?.length || 0) > 0;
if (hasAmosRunway || detail.amos?.source === "amos") {
if (hasAmosRunway || detail.amos?.source === "amos" || detail.amos?.source === "amsc_awos") {
return buildObservationFreshness({
observedAt: detail.amos?.observation_time || null,
observedAtLocal: detail.airport_current?.obs_time || detail.current?.obs_time || null,
sourceCode: "amos",
sourceLabel: detail.amos?.source_label || "AMOS",
observedAtLocal:
detail.amos?.observation_time_local ||
detail.airport_current?.obs_time ||
detail.current?.obs_time ||
null,
sourceCode: detail.amos?.source || "amos",
sourceLabel: detail.amos?.source_label || (detail.amos?.source === "amsc_awos" ? "AMSC AWOS" : "AMOS"),
});
}
const currentSource = canonicalSourceCode(