156 lines
5.3 KiB
TypeScript
156 lines
5.3 KiB
TypeScript
"use client";
|
|
|
|
import type { CityDetail } from "@/lib/dashboard-types";
|
|
import { getDisplayAirportPrimary } from "@/lib/airport-observation-display";
|
|
import { formatTemperatureValue } from "@/lib/temperature-utils";
|
|
|
|
// Settlement runway mapping — matches settlement anchors
|
|
const SETTLEMENT_RUNWAY_PAIRS: Record<string, Array<[string, string]>> = {
|
|
shanghai: [["17L", "35R"]],
|
|
beijing: [["01", "19"]],
|
|
guangzhou: [["02L", "20R"]],
|
|
chengdu: [["02L", "20R"]],
|
|
chongqing: [["02L", "20R"]],
|
|
wuhan: [["04", "22"]],
|
|
seoul: [["15R", "33L"]],
|
|
};
|
|
|
|
function normalizeRunwayLabel(value?: string | null) {
|
|
return String(value || "").trim().toUpperCase().replace(/\s+/g, "");
|
|
}
|
|
|
|
function normalizeCityKey(value?: string | null) {
|
|
return String(value || "").trim().toLowerCase().replace(/[\s_-]+/g, "");
|
|
}
|
|
|
|
function pairKey(pair: [string, string]) {
|
|
return pair.map(normalizeRunwayLabel).sort().join("/");
|
|
}
|
|
|
|
function toFiniteNumber(value: unknown) {
|
|
const numeric = Number(value);
|
|
return Number.isFinite(numeric) ? numeric : null;
|
|
}
|
|
|
|
function formatObsTime(value: unknown) {
|
|
const raw = String(value || "").trim();
|
|
if (!raw) return "";
|
|
if (raw.includes("T")) {
|
|
const parsed = new Date(raw);
|
|
if (!Number.isNaN(parsed.getTime())) {
|
|
return `${String(parsed.getUTCHours()).padStart(2, "0")}:${String(
|
|
parsed.getUTCMinutes(),
|
|
).padStart(2, "0")}Z`;
|
|
}
|
|
}
|
|
return raw.length >= 16 && raw[10] === " " ? raw.slice(11, 16) : raw;
|
|
}
|
|
|
|
function buildRunwayEvidence(detail: CityDetail | null) {
|
|
if (!detail) return null;
|
|
const cityKey = normalizeCityKey(detail.name) || normalizeCityKey(detail.display_name);
|
|
const settlementPairs = SETTLEMENT_RUNWAY_PAIRS[cityKey] || [];
|
|
const settlementKeys = new Set(settlementPairs.map(pairKey));
|
|
const runwayObs = detail.amos?.runway_obs || {};
|
|
const runwayPairs = runwayObs.runway_pairs || [];
|
|
const runwayTemps = runwayObs.temperatures || [];
|
|
const pointTemps = runwayObs.point_temperatures || [];
|
|
const rows: Array<{
|
|
label: string;
|
|
maxTemp: number;
|
|
values: number[];
|
|
isSettlement: boolean;
|
|
}> = [];
|
|
|
|
runwayPairs.forEach((rawPair, index) => {
|
|
const pair = rawPair as [string, string];
|
|
if (!Array.isArray(pair) || pair.length < 2) return;
|
|
const isSettlement = settlementKeys.has(pairKey(pair));
|
|
const values = [
|
|
...(Array.isArray(runwayTemps[index]) ? runwayTemps[index] : []),
|
|
toFiniteNumber(pointTemps[index]?.tdz_temp),
|
|
toFiniteNumber(pointTemps[index]?.mid_temp),
|
|
toFiniteNumber(pointTemps[index]?.end_temp),
|
|
].filter((value): value is number => Number.isFinite(value));
|
|
if (!values.length) return;
|
|
rows.push({
|
|
label: `${normalizeRunwayLabel(pair[0])}/${normalizeRunwayLabel(pair[1])}`,
|
|
maxTemp: Math.max(...values),
|
|
values,
|
|
isSettlement,
|
|
});
|
|
});
|
|
|
|
if (!rows.length) return null;
|
|
return {
|
|
observedAt:
|
|
formatObsTime(detail.amos?.observation_time_local) ||
|
|
formatObsTime(detail.amos?.observation_time),
|
|
rows,
|
|
sourceLabel: detail.amos?.source_label || detail.amos?.source || "AMOS",
|
|
};
|
|
}
|
|
|
|
export function AirportEvidencePanel({
|
|
detail,
|
|
isEn,
|
|
}: {
|
|
detail: CityDetail | null;
|
|
isEn: boolean;
|
|
}) {
|
|
const airportPrimary = getDisplayAirportPrimary(detail);
|
|
const airportCurrent = detail?.airport_current;
|
|
const station = airportPrimary || airportCurrent || null;
|
|
const runwayEvidence = buildRunwayEvidence(detail);
|
|
const tempSymbol = detail?.temp_symbol || "°C";
|
|
if (!station && !runwayEvidence) return null;
|
|
|
|
return (
|
|
<section className="scan-ai-city-section scan-airport-evidence">
|
|
<div className="scan-ai-city-section-head">
|
|
<div>
|
|
<span className="scan-ai-city-kicker">
|
|
{isEn ? "Airport live evidence" : "机场实时证据"}
|
|
</span>
|
|
<h4>{isEn ? "Airport / runway observations" : "机场 / 跑道观测"}</h4>
|
|
</div>
|
|
</div>
|
|
<div className="scan-airport-evidence-grid">
|
|
{station ? (
|
|
<div className="scan-airport-evidence-card">
|
|
<span>{isEn ? "Airport station" : "机场主站"}</span>
|
|
<b>
|
|
{station.temp != null && Number.isFinite(Number(station.temp))
|
|
? formatTemperatureValue(Number(station.temp), tempSymbol, { digits: 1 })
|
|
: "--"}
|
|
</b>
|
|
<small>
|
|
{[station.station_label || station.station_code, station.source_label || "METAR", formatObsTime(station.obs_time || station.report_time)]
|
|
.filter(Boolean)
|
|
.join(" · ")}
|
|
</small>
|
|
</div>
|
|
) : null}
|
|
{runwayEvidence?.rows.map((row) => (
|
|
<div
|
|
className={`scan-airport-evidence-card runway${row.isSettlement ? " settlement" : ""}`}
|
|
key={row.label}
|
|
>
|
|
<span>
|
|
{row.isSettlement
|
|
? isEn ? "★ Settlement runway" : "★ 结算跑道"
|
|
: isEn ? "Runway" : "跑道"}
|
|
</span>
|
|
<b>{formatTemperatureValue(row.maxTemp, tempSymbol, { digits: 1 })}</b>
|
|
<small>
|
|
{[row.label, runwayEvidence.sourceLabel, runwayEvidence.observedAt]
|
|
.filter(Boolean)
|
|
.join(" · ")}
|
|
</small>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|