城市决策卡新增 AMOS 跑道温度面板

- 新增 AmosRunwayPanel 组件:展示每条跑道的温度/露点/能见度/RVR/风速
- 跑道卡片网格布局 (auto-fit minmax 160px)
- 每条跑道独立显示:跑道编号、温度(含露点)、能见度、RVR、风速范围
- 官方 METAR 标签 vs 跑道中位数标签
- CityDetail 类型新增 AmosData 接口
- 暗色/浅色主题均已适配
- 仅首尔/釜山(有 AMOS 数据)时显示
This commit is contained in:
2569718930@qq.com
2026-05-10 18:30:59 +08:00
parent ba352feb34
commit fe3f48f255
6 changed files with 232 additions and 0 deletions
@@ -1225,3 +1225,67 @@
font-weight: 800;
line-height: 1.5;
}
/* ── AMOS runway observation panel ── */
.root :global(.scan-amos-runway-panel) {
margin-top: 14px;
border: 1px solid rgba(77, 163, 255, 0.12);
border-radius: 16px;
background: rgba(11, 18, 32, 0.24);
padding: 14px;
}
.root :global(.scan-amos-source-tag) {
display: inline-block;
margin-left: 8px;
padding: 1px 7px;
border-radius: 999px;
background: rgba(77, 163, 255, 0.12);
color: var(--color-accent-secondary);
font-size: 10px;
font-weight: 700;
}
.root :global(.scan-amos-runway-grid) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
margin-top: 10px;
}
.root :global(.scan-amos-runway-card) {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: 1px solid rgba(77, 163, 255, 0.08);
border-radius: 12px;
background: rgba(11, 18, 32, 0.38);
}
.root :global(.scan-amos-runway-label) {
font-size: 11px;
font-weight: 700;
color: var(--color-accent-secondary);
letter-spacing: 0.02em;
}
.root :global(.scan-amos-runway-temp) {
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--color-text-primary);
line-height: 1.15;
}
.root :global(.scan-amos-runway-temp small) {
font-size: 11px;
font-weight: 600;
color: var(--color-text-muted);
}
.root :global(.scan-amos-runway-detail) {
font-size: 11px;
font-weight: 600;
color: var(--color-text-muted);
}
@@ -982,3 +982,31 @@ html.light .root :global(.scan-terminal.light .scan-retry-button) {
html.light .root :global(.scan-terminal.light .scan-retry-button:hover) {
background: rgba(37, 99, 235, 0.14);
}
/* ── AMOS runway panel light overrides ── */
html.light .root :global(.scan-terminal.light .scan-amos-runway-panel) {
background: rgba(37, 99, 235, 0.03);
border-color: rgba(37, 99, 235, 0.12);
}
html.light .root :global(.scan-terminal.light .scan-amos-runway-card) {
background: #ffffff;
border-color: rgba(37, 99, 235, 0.08);
}
html.light .root :global(.scan-terminal.light .scan-amos-runway-label) {
color: #2563EB;
}
html.light .root :global(.scan-terminal.light .scan-amos-runway-temp) {
color: #0F172A;
}
html.light .root :global(.scan-terminal.light .scan-amos-source-tag) {
background: rgba(37, 99, 235, 0.08);
color: #2563EB;
}
html.light .root :global(.scan-terminal.light .scan-amos-runway-detail) {
color: #64748B;
}
@@ -5,6 +5,7 @@ import type { MouseEvent } from "react";
import { useEffect, useState } from "react";
import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart";
import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel";
import { AmosRunwayPanel } from "@/components/dashboard/scan-terminal/AmosRunwayPanel";
import { CityCardHeader } from "@/components/dashboard/scan-terminal/CityCardHeader";
import { MobileDecisionCard } from "@/components/dashboard/scan-terminal/MobileDecisionCard";
import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel";
@@ -650,6 +651,14 @@ export function AiPinnedCityCard({
/>
</div>
{detail?.amos ? (
<AmosRunwayPanel
amos={detail.amos}
isEn={isEn}
tempSymbol={tempSymbol}
/>
) : null}
<ModelEvidencePanel detail={detail} isEn={isEn} />
</div>
) : !detail ? (
@@ -0,0 +1,86 @@
"use client";
import type { AmosData } from "@/lib/dashboard-types";
function runwayTempClass(temp: number | null | undefined): string {
if (temp == null || !Number.isFinite(temp)) return "";
if (temp >= 40) return "temp-extreme-hot";
if (temp <= -5) return "temp-extreme-cold";
return "";
}
export function AmosRunwayPanel({
amos,
isEn,
tempSymbol,
}: {
amos: AmosData;
isEn: boolean;
tempSymbol: string;
}) {
const runwayPairs = amos.runway_obs?.runway_pairs;
const runwayTemps = amos.runway_obs?.temperatures ?? amos.runway_temps;
const runwayWinds = amos.runway_obs?.wind_speeds;
const runwayVis = amos.runway_obs?.visibility_mor;
const runwayRvr = amos.runway_obs?.rvr;
if (!runwayPairs || !runwayTemps || runwayPairs.length === 0) return null;
const pairs = runwayPairs.slice(0, runwayTemps.length);
return (
<div className="scan-amos-runway-panel">
<div className="scan-ai-city-section-title">
{isEn ? "Runway Observations" : "跑道实测"} · {amos.station_label || amos.icao || ""}
<span className="scan-amos-source-tag">
{amos.temp_source === "metar"
? isEn ? "Official METAR" : "官方 METAR"
: isEn ? "Runway median" : "跑道中位数"}
</span>
</div>
<div className="scan-amos-runway-grid">
{pairs.map(([rwyA, rwyB], idx) => {
const temps = runwayTemps[idx];
const wind = runwayWinds?.[idx];
const vis = runwayVis?.[idx];
const rvr = runwayRvr?.[idx];
return (
<div key={`${rwyA}/${rwyB}`} className="scan-amos-runway-card">
<div className="scan-amos-runway-label">
{rwyA}/{rwyB}
</div>
{temps ? (
<div className={`scan-amos-runway-temp ${runwayTempClass(temps[0])}`}>
{temps[0].toFixed(1)}{tempSymbol}
{temps[1] != null ? (
<small>
{isEn ? " Dew " : " 露点 "}
{temps[1].toFixed(1)}{tempSymbol}
</small>
) : null}
</div>
) : null}
{vis != null && vis > 0 ? (
<div className="scan-amos-runway-detail">
{isEn ? "Vis " : "能见度 "}
{vis >= 10000 ? (isEn ? "≥10km" : "≥10公里") : `${vis}m`}
</div>
) : null}
{rvr != null && rvr > 0 ? (
<div className="scan-amos-runway-detail">
RVR {rvr >= 2000 ? "≥2000m" : `${rvr}m`}
</div>
) : null}
{wind ? (
<div className="scan-amos-runway-detail">
{wind[0].toFixed(1)}kt
{wind[1] != null ? ` (${wind[1].toFixed(1)}${wind[2].toFixed(1)})` : ""}
</div>
) : null}
</div>
);
})}
</div>
</div>
);
}