feat: implement temperature chart visibility logic with comprehensive unit testing and analysis service support

This commit is contained in:
2569718930@qq.com
2026-05-29 22:40:33 +08:00
parent 95c2258db0
commit 68416b058e
6 changed files with 385 additions and 22 deletions
@@ -4,13 +4,13 @@ import clsx from "clsx";
import Link from "next/link";
import {
Activity,
BookOpenCheck,
ChevronDown,
ChevronLeft,
ChevronRight,
GraduationCap,
Menu,
Search,
Table2,
UserRound,
Users,
} from "lucide-react";
@@ -45,6 +45,7 @@ import { scanRootClass } from "@/components/dashboard/scan-root-styles";
import { useRelativeTime } from "@/hooks/useRelativeTime";
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
import { TrainingDashboard } from "@/components/dashboard/scan-terminal/TrainingDashboard";
import { UsageGuideDashboard } from "@/components/dashboard/scan-terminal/UsageGuideDashboard";
import { LiveTemperatureThresholdChart, clearCityDetailCache } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
import { KoyfinRowsTable } from "@/components/dashboard/scan-terminal/KoyfinRowsTable";
import { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils";
@@ -100,7 +101,7 @@ const TERM = {
liq: { en: "Liq", zh: "流动性" },
signal: { en: "Signal", zh: "信号" },
searchPlaceholder: { en: "Search city, threshold, station, or signal", zh: "搜索城市、阈值、站点或信号" },
weatherThresholds: { en: "Weather Thresholds", zh: "天气阈值" },
weatherThresholds: { en: "Weather Decisions", zh: "天气决策" },
selectedThresholdMonitor: { en: "Selected Threshold Monitor", zh: "选中阈值监控" },
probabilityDistribution: { en: "Probability Distribution", zh: "概率分布" },
signalList: { en: "Signal List", zh: "信号列表" },
@@ -465,8 +466,9 @@ function PolyWeatherTerminal({
};
const NAV_ITEMS = [
{ key: "thresholds", Icon: Table2, labelEn: "Thresholds", labelZh: "天气阈值" },
{ key: "thresholds", Icon: Activity, labelEn: "Decision", labelZh: "天气决策" },
{ key: "training", Icon: GraduationCap, labelEn: "Training", labelZh: "训练数据" },
{ key: "guide", Icon: BookOpenCheck, labelEn: "Guide", labelZh: "使用指南" },
];
useEffect(() => {
@@ -719,6 +721,8 @@ function PolyWeatherTerminal({
<main className="min-h-0 flex-1 overflow-hidden flex flex-col p-2 bg-[#eef2f6]">
{activeNavKey === "training" ? (
<TrainingDashboard isEn={isEn} />
) : activeNavKey === "guide" ? (
<UsageGuideDashboard isEn={isEn} />
) : (
<>
{/* Mobile layout */}
@@ -0,0 +1,277 @@
"use client";
import {
BookOpenCheck,
ChartSpline,
CheckCircle2,
CircleHelp,
Eye,
Gauge,
Layers3,
MapPinned,
Monitor,
MousePointer2,
Plane,
RadioTower,
SlidersHorizontal,
Sparkles,
Users,
} from "lucide-react";
type GuideCopy = {
title: string;
body: string;
};
const quickStart: Record<"zh" | "en", GuideCopy[]> = {
zh: [
{
title: "先选城市",
body: "左上角城市名用于切换当前图表,网格布局可同时观察多个城市。",
},
{
title: "先看实测",
body: "青绿色粗线是当前更重要的实况锚点,结算跑道或官方站优先于普通机场报文。",
},
{
title: "再看 DEB",
body: "橙色 DEB Forecast 是融合模型和日内修正后的路径,用来判断后续升温或降温空间。",
},
{
title: "最后看概率",
body: "紫色区域和虚线表示高概率温度带,适合判断当前实测是否偏离主预期。",
},
],
en: [
{
title: "Pick cities first",
body: "Use the city name in each chart header to switch slots and monitor multiple cities in the grid.",
},
{
title: "Read live evidence",
body: "The teal anchor is the key live observation layer; settlement runway or official station data takes priority.",
},
{
title: "Compare DEB",
body: "The orange DEB Forecast blends model context with intraday correction to frame the remaining move.",
},
{
title: "Check probability",
body: "The purple band and dotted line show the high-probability temperature zone for fast deviation checks.",
},
],
};
const legendItems: Record<"zh" | "en", GuideCopy[]> = {
zh: [
{ title: "实测 / 结算线", body: "优先展示结算跑道、官方站或城市核心实况,用于判断已兑现温度。" },
{ title: "DEB Forecast", body: "橙色预测路径,重点看它和实测线在峰值窗口前后的分歧。" },
{ title: "高概率带", body: "紫色带表示当前概率分布的主要落点,虚线是概率均值附近。" },
{ title: "机场报文", body: "METAR / MGM 作为机场站参考,默认只在适合的城市自动显示。" },
{ title: "模型线", body: "ECMWF、GFS、ICON、GEM 等提供背景,默认弱化为辅助判断。" },
{ title: "跑道明细", body: "打开后可查看各跑道传感器,关闭后仍保留结算跑道温度。" },
],
en: [
{ title: "Live / settlement", body: "Settlement runway, official station, or core live observation used as the realized anchor." },
{ title: "DEB Forecast", body: "Orange forecast path; focus on its gap versus live observations near the peak window." },
{ title: "Probability band", body: "Purple band marks the main probability zone, with the dotted line near the probability mean." },
{ title: "Airport reports", body: "METAR / MGM are airport references and are auto-shown only where they are useful by default." },
{ title: "Model lines", body: "ECMWF, GFS, ICON, GEM, and related model layers provide background context." },
{ title: "Runway details", body: "When disabled, the chart still keeps the settlement runway temperature visible." },
],
};
const operations: Record<"zh" | "en", GuideCopy[]> = {
zh: [
{ title: "布局", body: "右上角可切换 1x1 到 3x3,适合从单城复盘切到多城巡检。" },
{ title: "换城市", body: "点击图表标题栏城市名,在当前卡片内搜索并替换城市。" },
{ title: "高温模式", body: "卡片右上角高温按钮用于聚焦最高温兑现窗口。" },
{ title: "曲线显隐", body: "图例可自定义显示机场报文、模型线和跑道明细。" },
],
en: [
{ title: "Layout", body: "Switch from 1x1 to 3x3 in the top-right control for review or multi-city scanning." },
{ title: "Change city", body: "Click the city name in a chart header to search and replace that slot." },
{ title: "High mode", body: "Use the High button to focus the chart on the high-temperature payoff window." },
{ title: "Layer toggles", body: "Use the legend to customize airport reports, model lines, and runway details." },
],
};
function GuideCard({
icon: Icon,
title,
body,
}: {
icon: typeof BookOpenCheck;
title: string;
body: string;
}) {
return (
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-md border border-blue-100 bg-blue-50 text-blue-600">
<Icon size={18} />
</div>
<h3 className="text-sm font-black text-slate-900">{title}</h3>
<p className="mt-2 text-xs leading-5 text-slate-500">{body}</p>
</div>
);
}
function SectionTitle({
icon: Icon,
title,
eyebrow,
}: {
icon: typeof BookOpenCheck;
title: string;
eyebrow: string;
}) {
return (
<div className="mb-3 flex items-center gap-2">
<div className="grid h-7 w-7 place-items-center rounded-md border border-slate-200 bg-white text-blue-600">
<Icon size={15} />
</div>
<div>
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">
{eyebrow}
</div>
<h2 className="text-sm font-black text-slate-900">{title}</h2>
</div>
</div>
);
}
export function UsageGuideDashboard({ isEn }: { isEn: boolean }) {
const locale = isEn ? "en" : "zh";
const quickIcons = [MapPinned, RadioTower, ChartSpline, Gauge];
const legendIcons = [RadioTower, ChartSpline, Sparkles, Plane, Layers3, SlidersHorizontal];
return (
<div className="h-full overflow-auto bg-[#f5f7fa]">
<div className="mx-auto max-w-6xl p-4">
<div className="mb-4 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="mb-3 inline-flex items-center gap-2 rounded-md border border-blue-100 bg-blue-50 px-2.5 py-1 text-[11px] font-black text-blue-700">
<BookOpenCheck size={13} />
{isEn ? "Terminal Guide" : "决策台使用指南"}
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-xl font-black tracking-tight text-slate-950">
{isEn ? "Read the terminal in four passes" : "按四步阅读天气决策台"}
</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
{isEn
? "Start from live evidence, compare DEB, then use probability and layer toggles to confirm whether the city is moving away from the main path."
: "先看实况锚点,再对照 DEB 路径,最后用概率带和图层显隐确认城市是否偏离主预期。"}
</p>
</div>
<div className="grid grid-cols-3 gap-2 text-center text-[11px] font-bold text-slate-600">
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2">
<div className="font-mono text-sm font-black text-slate-950">1-9</div>
{isEn ? "Charts" : "图表位"}
</div>
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2">
<div className="font-mono text-sm font-black text-slate-950">Live</div>
{isEn ? "Anchor" : "锚点"}
</div>
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2">
<div className="font-mono text-sm font-black text-slate-950">DEB</div>
{isEn ? "Path" : "路径"}
</div>
</div>
</div>
</div>
<SectionTitle
icon={Monitor}
eyebrow={isEn ? "Quick start" : "快速开始"}
title={isEn ? "The default reading order" : "默认阅读顺序"}
/>
<div className="mb-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{quickStart[locale].map((item, index) => (
<GuideCard
key={item.title}
icon={quickIcons[index]}
title={`${index + 1}. ${item.title}`}
body={item.body}
/>
))}
</div>
<SectionTitle
icon={Eye}
eyebrow={isEn ? "Legend" : "图表图例"}
title={isEn ? "What each layer means" : "每条曲线代表什么"}
/>
<div className="mb-5 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{legendItems[locale].map((item, index) => (
<GuideCard
key={item.title}
icon={legendIcons[index]}
title={item.title}
body={item.body}
/>
))}
</div>
<div className="mb-5 grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
<div>
<SectionTitle
icon={MousePointer2}
eyebrow={isEn ? "Controls" : "常用操作"}
title={isEn ? "Daily workflow controls" : "日常巡检操作"}
/>
<div className="grid gap-3 md:grid-cols-2">
{operations[locale].map((item) => (
<div key={item.title} className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="mb-2 flex items-center gap-2">
<CheckCircle2 size={15} className="text-emerald-600" />
<h3 className="text-sm font-black text-slate-900">{item.title}</h3>
</div>
<p className="text-xs leading-5 text-slate-500">{item.body}</p>
</div>
))}
</div>
</div>
<div>
<SectionTitle
icon={CircleHelp}
eyebrow={isEn ? "Rules" : "默认规则"}
title={isEn ? "Visibility and access rules" : "图层和权益规则"}
/>
<div className="space-y-3">
{[
isEn
? "For cities other than Hong Kong and Shenzhen, airport METAR temperature is hidden by default. Users can still enable it manually."
: "除香港和深圳外,机场 METAR 温度默认不参与图表展示;用户仍可手动打开。",
isEn
? "Turkey airport-station curves use MGM data when available, so Ankara and Istanbul should be read from the MGM airport anchor."
: "土耳其机场站优先使用 MGM 数据,安卡拉和伊斯坦布尔应以 MGM 机场锚点阅读。",
isEn
? "The 3-day trial has the same core terminal access as Pro, except the paid Telegram group link is hidden."
: "3 天试用拥有和 Pro 一致的核心决策台权益,仅不显示付费 Telegram 群链接。",
].map((text) => (
<div key={text} className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs font-semibold leading-5 text-amber-900">
{text}
</div>
))}
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="mb-2 flex items-center gap-2">
<Users size={15} className="text-blue-600" />
<h3 className="text-sm font-black text-slate-900">
{isEn ? "Pro membership" : "Pro 会员"}
</h3>
</div>
<p className="text-xs leading-5 text-slate-500">
{isEn
? "Monthly and quarterly Pro unlock the full paid workflow, including the Telegram group entry after subscription activation."
: "月付和季度 Pro 开通后解锁完整付费工作流,并在账户页显示 Telegram 群入口。"}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -192,22 +192,30 @@ export function runTests() {
"settlement/HKO observations should be visible by default",
);
assert(
!__isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "metar"),
"METAR observations should be hidden by default outside Hong Kong and Shenzhen",
__isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "metar"),
"METAR observations should be visible by default outside Hong Kong and Shenzhen",
);
assert(
!activeDefaultSeries.some((item) => item.key === "metar"),
"non-Hong Kong/Shenzhen airport METAR observations should not affect the active chart series by default",
activeDefaultSeries.some((item) => item.key === "metar"),
"non-Hong Kong/Shenzhen airport METAR observations should be part of the active chart series by default",
);
assert(
__getVisibleTemperatureSeriesForTest("guangzhou", series, { metar: true }).some(
!__getVisibleTemperatureSeriesForTest("guangzhou", series, { metar: false }).some(
(item) => item.key === "metar",
),
"users should still be able to enable the hidden airport METAR series from the legend",
"users should still be able to hide the airport METAR series from the legend",
);
assert(
__isTemperatureSeriesVisibleByDefaultForTest("hong kong", "metar"),
"Hong Kong METAR/HKO observations should remain visible by default",
!__isTemperatureSeriesVisibleByDefaultForTest("hong kong", "metar"),
"Hong Kong airport METAR observations should stay hidden by default because HKO is the primary local station",
);
assert(
!__isTemperatureSeriesVisibleByDefaultForTest("Lau Fau Shan", "metar"),
"Lau Fau Shan airport METAR observations should stay hidden by default because HKO is the primary local station",
);
assert(
!__isTemperatureSeriesVisibleByDefaultForTest("shenzhen", "metar"),
"Shenzhen airport METAR observations should stay hidden by default because HKO is the primary local station",
);
assert(
__isTemperatureSeriesVisibleByDefaultForTest("Lau Fau Shan", "madis"),
@@ -221,6 +229,51 @@ export function runTests() {
!__isTemperatureSeriesVisibleByDefaultForTest("new york", "madis"),
"non-Hong Kong/Shenzhen airport-primary observations should be hidden by default",
);
const ankaraMgmWithMetarBackup = __buildTemperatureChartDataForTest(
{
city: "ankara",
local_date: "2026-05-29",
local_time: "17:28",
tz_offset_seconds: 3 * 60 * 60,
airport: "LTAC",
metar_today_obs: [
{ time: "2026-05-29T12:00:00Z", temp: 15.0 },
{ time: "2026-05-29T13:00:00Z", temp: 16.0 },
{ time: "2026-05-29T14:00:00Z", temp: 17.0 },
],
} as any,
{
localTime: "17:28",
times: ["00:00", "06:00", "12:00", "18:00"],
temps: [15, 14, 16, 15],
airportPrimary: {
source_code: "mgm",
source_label: "MGM",
temp: 14.0,
obs_time: "2026-05-29T14:28:00Z",
},
airportPrimaryTodayObs: [
{ time: "2026-05-29T13:28:00Z", temp: 17.0 },
{ time: "2026-05-29T14:28:00Z", temp: 14.0 },
],
} as any,
"1D",
);
const ankaraDefaultSeries = __getActiveTemperatureSeriesForTest(
"ankara",
ankaraMgmWithMetarBackup.series as any,
{},
true,
);
assert(
ankaraDefaultSeries.some((item: any) => item.key === "madis" && item.label === "MGM"),
"Ankara MGM airport-primary weather-station curve should be visible by default",
);
assert(
ankaraDefaultSeries.some((item: any) => item.key === "metar"),
"Ankara local METAR backup curve should remain visible by default because MGM history can be incomplete",
);
assert(
!__isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "model_curve_ECMWF"),
"multi-model curves should be hidden by default",
@@ -86,9 +86,23 @@ function isTemperatureSeriesVisibleByDefault(city: string, seriesKey: string) {
if (seriesKey.startsWith("model_curve_")) {
return normalizeCityKey(city) === "paris" && seriesKey === "model_curve_AROME HD";
}
if (seriesKey === "metar" || seriesKey === "madis") {
if (seriesKey === "metar") {
const cityKey = normalizeCityKey(city);
return cityKey === "hongkong" || cityKey === "laufaushan" || cityKey === "shenzhen";
return (
cityKey !== "hongkong" &&
cityKey !== "laufaushan" &&
cityKey !== "shenzhen"
);
}
if (seriesKey === "madis") {
const cityKey = normalizeCityKey(city);
return (
cityKey === "hongkong" ||
cityKey === "laufaushan" ||
cityKey === "shenzhen" ||
cityKey === "ankara" ||
cityKey === "istanbul"
);
}
return true;
}
+15 -3
View File
@@ -1,5 +1,6 @@
import threading
from datetime import datetime, timedelta, timezone
from typing import Any
from src.data_collection.country_networks import build_country_network_snapshot
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
@@ -20,6 +21,14 @@ class _DummyMetarSource(MetarSourceMixin):
metar_cache_ttl_sec = 600
metar_fast_cache_ttl_sec = 60
def __init__(self):
self._metar_cache = {}
self._metar_cache_lock = threading.Lock()
self.metar_timeout_sec = 0.0
self.metar_latest_timeout_sec = 0.0
self.timeout = 0.0
self._http_get: Any = None
def test_new_city_registry_entries_are_wired():
assert CITY_REGISTRY["manila"]["settlement_source"] == "wunderground"
@@ -59,7 +68,9 @@ def test_paris_registry_uses_le_bourget_anchor():
assert paris["icao"] == "LFPB"
assert paris["settlement_source"] == "aeroweb"
assert paris["settlement_station_code"] == "LFPB"
assert "bonneuil-en-france/LFPB" in paris["settlement_url"]
settlement_url = paris.get("settlement_url")
assert isinstance(settlement_url, str)
assert "bonneuil-en-france/LFPB" in settlement_url
assert CITIES["paris"]["lat"] == paris["lat"]
assert CITIES["paris"]["settlement_source"] == "aeroweb"
assert _DummyMetarSource.CITY_TO_ICAO["paris"] == "LFPB"
@@ -76,12 +87,12 @@ def test_turkey_metar_uses_fast_cache_ttl():
def test_metar_marks_previous_local_day_report_stale(monkeypatch):
class FixedDateTime(datetime):
@classmethod
def now(cls, tz=None):
def now(cls, tz=None): # type: ignore
value = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc)
return value if tz is None else value.astimezone(tz)
@classmethod
def utcnow(cls):
def utcnow(cls): # type: ignore
return datetime(2026, 4, 21, 12, 0)
class FakeResponse:
@@ -112,6 +123,7 @@ def test_metar_marks_previous_local_day_report_stale(monkeypatch):
result = source.fetch_metar("karachi", utc_offset=18000)
assert result is not None
assert result["stale_for_today"] is True
assert result["observation_local_date"] == "2026-04-20"
assert result["current_local_date"] == "2026-04-21"
+9 -6
View File
@@ -117,7 +117,7 @@ def _should_build_country_network_snapshot(
if not is_panel_mode:
return True
city_lower = str(city or "").strip().lower()
city_lower = (city or "").strip().lower()
if city_lower not in TURKISH_MGM_CITIES:
return False
@@ -147,7 +147,7 @@ def _normalize_runway_label(value: Any) -> str:
def _split_runway_pair_label(value: Any) -> tuple[str, str]:
parts = [_normalize_runway_label(part) for part in str(value or "").split("/") if str(part).strip()]
parts = [_normalize_runway_label(part) for part in str(value or "").split("/") if part.strip()]
if len(parts) >= 2:
return parts[0], parts[1]
runway = _normalize_runway_label(value)
@@ -166,7 +166,10 @@ def _settlement_runway_endpoint_temp(city: str, row: Dict[str, Any]) -> Optional
return None
pair = _split_runway_pair_label(row.get("runway"))
configured = tuple(_normalize_runway_label(part) for part in configured_pair)
configured = (
_normalize_runway_label(configured_pair[0]),
_normalize_runway_label(configured_pair[1]),
)
if not _runway_pair_matches(pair, configured):
return None
@@ -1676,8 +1679,9 @@ def _analyze(
if day_m:
try:
deb_result = calculate_deb_prediction(city, day_m)
if deb_result.get("prediction") is not None:
d_val = deb_result.get("prediction")
d_prediction = _sf(deb_result.get("prediction"))
if d_prediction is not None:
d_val = d_prediction
d_raw_val = deb_result.get("raw_prediction")
d_version = deb_result.get("version")
d_bias_adjustment = deb_result.get("bias_adjustment") or 0.0
@@ -1727,7 +1731,6 @@ def _analyze(
continue
temp_val = _runway_history_temp_for_city(city, r)
if temp_val is not None:
temp_val = float(temp_val)
if is_f:
temp_val = round(temp_val * 9.0 / 5.0 + 32.0, 1)
else: