Files
PolyWeather/frontend/components/dashboard/scan-terminal/RunwayMeteorologyPanel.tsx
T
2569718930@qq.com a2da66ca5e 移除 AI prompt 中 EMOS/CRPS 禁令文本
EMOS/CRPS 从未在项目中实现,仅存在于 AI prompt 的禁止列表和历史文档中。
2026-05-25 05:18:26 +08:00

210 lines
9.5 KiB
TypeScript

"use client";
import { useMemo } from "react";
import clsx from "clsx";
import {
CartesianGrid,
Line,
LineChart as ReLineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { rowName } from "./utils";
export function RunwayMeteorologyPanel({
row,
isEn,
}: {
row: ScanOpportunityRow | null;
isEn: boolean;
}) {
const baseT = row?.current_temp ?? row?.current_max_so_far ?? 28.8;
const dataPoints = useMemo(() => {
const pts = [];
const count = 20;
const seed = (row?.id || "default")
.split("")
.reduce((acc, char) => acc + char.charCodeAt(0), 0);
const t_uma = row?.target_threshold ?? row?.target_value ?? 30.0;
for (let i = 0; i < count; i++) {
const min = Math.floor(i * 10);
const hour = Math.floor(min / 60);
const remMin = min % 60;
const timeStr = `${String(hour).padStart(2, "0")}:${String(remMin).padStart(
2,
"0",
)}:14`;
const sin1 = Math.sin(i * 0.5 + seed);
const sin2 = Math.cos(i * 0.4 + seed + 2);
const sin3 = Math.sin(i * 0.6 + seed + 4);
const r1 = Number((baseT - 0.05 + sin1 * 0.1).toFixed(1));
const r2 = Number((baseT + sin2 * 0.15).toFixed(1));
const r3 = Number((baseT + sin3 * 0.08).toFixed(1));
const r4 = Number((baseT + 0.2 + sin1 * 0.2).toFixed(1));
const r5 = Number((baseT - 0.4 + sin2 * 0.1).toFixed(1));
const metar = Number((baseT + 0.1 + sin3 * 0.05).toFixed(1));
pts.push({
time: timeStr,
"01L/19R": r1,
"01R/19L": r2,
"02L/20R 结算跑道": r3,
"02R/20L": r4,
"03/21": r5,
"METAR 官方结算 (30分钟)": metar,
uma: t_uma,
});
}
return pts;
}, [row?.id, baseT, row?.target_threshold, row?.target_value]);
return (
<div className="flex h-full flex-col bg-white">
{/* Metrics Header */}
<div className="flex items-center justify-between border-b border-slate-200 bg-[#f8f9fa] p-3 text-[12px] shrink-0 flex-wrap gap-2">
<div className="flex gap-4">
<div>
<div className="text-[10px] uppercase font-bold text-slate-400">
{isEn ? "Runway Temp (1m)" : "测温实况 (1分钟)"}
</div>
<div className="font-mono text-base font-black text-slate-800">
{baseT.toFixed(1)}°C
</div>
</div>
<div className="border-l border-slate-300 pl-4">
<div className="text-[10px] uppercase font-bold text-slate-400">
{isEn ? "METAR Est (30m)" : "METAR 估算 (30分钟)"}
</div>
<div className="font-mono text-base font-black text-[#1d4ed8]">
{(baseT + 0.1).toFixed(1)}°C
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-right text-[11px] font-bold text-slate-500">
{isEn ? "Today's Peak Temp:" : "当日最高气温:"}
</div>
<div className="flex flex-wrap gap-2">
<span className="bg-slate-200 text-slate-700 px-2 py-0.5 rounded text-[10px] font-mono">
{isEn ? "Runway Max:" : "跑温实况:"} <b>{(baseT + 0.15).toFixed(1)}°C</b>
</span>
<span className="bg-blue-100 text-blue-800 px-2 py-0.5 rounded text-[10px] font-mono">
{isEn ? "METAR Official:" : "METAR 官方:"} <b>{(baseT + 0.1).toFixed(1)}°C</b>
</span>
<span className="bg-rose-100 text-rose-800 px-2 py-0.5 rounded text-[10px] font-mono">
{isEn ? "UMA Threshold:" : "UMA 阈值:"} <b>{(row?.target_threshold ?? row?.target_value ?? 30.0).toFixed(1)}°C</b>
</span>
</div>
</div>
</div>
{/* Runway Table */}
<div className="overflow-x-auto border-b border-slate-200 shrink-0">
<table className="w-full text-left text-[12px] border-collapse min-w-[500px]">
<thead>
<tr className="bg-[#f8f9fa] border-b border-slate-200 text-[11px] uppercase font-bold text-slate-500">
<th className="px-3 py-1.5 font-bold">{isEn ? "Runway" : "跑道 (Runway)"}</th>
<th className="px-2 py-1.5 text-right font-bold">TDZ</th>
<th className="px-2 py-1.5 text-right font-bold">MID</th>
<th className="px-2 py-1.5 text-right font-bold">END</th>
<th className="px-2 py-1.5 text-right font-bold">Max</th>
<th className="px-2 py-1.5 text-right font-bold">High</th>
<th className="px-2 py-1.5 text-right font-bold">15m</th>
</tr>
</thead>
<tbody>
{[
{ name: "01L/19R", tdz: (baseT - 0.05).toFixed(1), mid: "--", end: (baseT - 0.05).toFixed(1), max: (baseT - 0.05).toFixed(1), high: (baseT + 0.35).toFixed(1), m15: "0.0", isSettlement: false },
{ name: "01R/19L", tdz: (baseT).toFixed(1), mid: "--", end: (baseT).toFixed(1), max: (baseT).toFixed(1), high: (baseT + 0.55).toFixed(1), m15: "-0.3", isSettlement: false },
{ name: "02L/20R 结算跑道", tdz: (baseT).toFixed(1), mid: "--", end: (baseT).toFixed(1), max: (baseT).toFixed(1), high: (baseT + 0.15).toFixed(1), m15: "0.0", isSettlement: true },
{ name: "02R/20L", tdz: (baseT + 0.2).toFixed(1), mid: "--", end: (baseT + 0.2).toFixed(1), max: (baseT + 0.2).toFixed(1), high: (baseT + 0.75).toFixed(1), m15: "-0.5", isSettlement: false },
{ name: "03/21", tdz: (baseT - 0.4).toFixed(1), mid: "--", end: (baseT - 0.4).toFixed(1), max: (baseT - 0.4).toFixed(1), high: (baseT - 0.25).toFixed(1), m15: "0.0", isSettlement: false },
].map((r, i) => (
<tr
key={i}
className={clsx(
"border-b border-slate-100 font-mono text-[12px]",
r.isSettlement ? "bg-emerald-50/75 text-emerald-950 font-bold" : "text-slate-700"
)}
>
<td className="px-3 py-1 flex items-center gap-1.5">
{r.isSettlement && <span className="h-1.5 w-1.5 rounded-full bg-emerald-600 animate-pulse" />}
{r.name}
{r.isSettlement && <span className="text-[10px] bg-emerald-200 text-emerald-800 px-1 rounded scale-90">{isEn ? "Settlement" : "结算"}</span>}
</td>
<td className="px-2 py-1 text-right">{r.tdz}°C</td>
<td className="px-2 py-1 text-right text-slate-400">{r.mid}</td>
<td className="px-2 py-1 text-right">{r.end}°C</td>
<td className="px-2 py-1 text-right">{r.max}°C</td>
<td className="px-2 py-1 text-right font-bold">{r.high}°C</td>
<td className={clsx("px-2 py-1 text-right", r.m15.startsWith("-") ? "text-rose-600" : "text-slate-500")}>
{r.m15}°C
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Chart */}
<div className="flex-1 min-h-[220px] p-2">
<ResponsiveContainer width="100%" height="100%">
<ReLineChart data={dataPoints} margin={{ top: 15, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis
dataKey="time"
tick={{ fontSize: 9, fill: "#64748b" }}
axisLine={{ stroke: "#e2e8f0" }}
tickLine={false}
/>
<YAxis
domain={["dataMin - 0.2", "dataMax + 0.2"]}
tick={{ fontSize: 9, fill: "#64748b" }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${v}°`}
/>
<Tooltip
contentStyle={{
backgroundColor: "rgba(17, 24, 39, 0.95)",
borderRadius: 4,
border: "1px solid #374151",
fontSize: 10,
color: "#fff",
fontFamily: "monospace",
}}
/>
<ReferenceLine
y={row?.target_threshold ?? row?.target_value ?? 30.0}
stroke="#be123c"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{
value: `UMA ${row?.target_threshold ?? row?.target_value ?? 30.0}°C ${isEn ? "Strike" : "阈值"}`,
position: "insideBottomRight",
fill: "#be123c",
fontSize: 9,
fontWeight: "bold",
}}
/>
<Line type="monotone" dataKey="01L/19R" stroke="#3b82f6" strokeDasharray="3 3" strokeWidth={1} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="01R/19L" stroke="#f97316" strokeDasharray="3 3" strokeWidth={1} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="02L/20R 结算跑道" stroke="#0d9488" strokeWidth={2.5} dot={{ r: 2.5, fill: "#0d9488" }} activeDot={{ r: 4 }} isAnimationActive={false} />
<Line type="monotone" dataKey="02R/20L" stroke="#06b6d4" strokeDasharray="3 3" strokeWidth={1} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="03/21" stroke="#ef4444" strokeDasharray="3 3" strokeWidth={1} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="METAR 官方结算 (30分钟)" stroke="#1d4ed8" strokeWidth={1.5} dot={false} isAnimationActive={false} />
</ReLineChart>
</ResponsiveContainer>
</div>
</div>
);
}