温度链增加 settlement_current 兜底:CWA/HKO 等官方结算站温度优先于 METAR
This commit is contained in:
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||||
import { REGIONS, getCityRegion } from "./continent-grouping";
|
import { REGIONS, getCityRegion } from "./continent-grouping";
|
||||||
import { rowName, temp } from "./utils";
|
import { rowName } from "./utils";
|
||||||
|
|
||||||
interface CitySelectorDropdownProps {
|
interface CitySelectorDropdownProps {
|
||||||
isEn: boolean;
|
isEn: boolean;
|
||||||
@@ -14,6 +14,105 @@ interface CitySelectorDropdownProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map each city to its airport code (IATA) to match the financial symbol style of Koyfin
|
||||||
|
const CITY_IATA_MAP: Record<string, string> = {
|
||||||
|
beijing: "PEK",
|
||||||
|
shanghai: "SHA",
|
||||||
|
shenzhen: "SZX",
|
||||||
|
guangzhou: "CAN",
|
||||||
|
chengdu: "CTU",
|
||||||
|
chongqing: "CKG",
|
||||||
|
wuhan: "WUH",
|
||||||
|
taipei: "TPE",
|
||||||
|
"hong kong": "HKG",
|
||||||
|
tokyo: "HND",
|
||||||
|
seoul: "ICN",
|
||||||
|
singapore: "SIN",
|
||||||
|
"kuala lumpur": "KUL",
|
||||||
|
manila: "MNL",
|
||||||
|
jakarta: "CGK",
|
||||||
|
karachi: "KHI",
|
||||||
|
lucknow: "LKO",
|
||||||
|
london: "LHR",
|
||||||
|
paris: "CDG",
|
||||||
|
munich: "MUC",
|
||||||
|
milan: "MXP",
|
||||||
|
madrid: "MAD",
|
||||||
|
amsterdam: "AMS",
|
||||||
|
warsaw: "WAW",
|
||||||
|
helsinki: "HEL",
|
||||||
|
"cape town": "CPT",
|
||||||
|
jeddah: "JED",
|
||||||
|
toronto: "YYZ",
|
||||||
|
"new york": "LGA",
|
||||||
|
"los angeles": "LAX",
|
||||||
|
"san francisco": "SFO",
|
||||||
|
denver: "DEN",
|
||||||
|
austin: "AUS",
|
||||||
|
houston: "HOU",
|
||||||
|
dallas: "DAL",
|
||||||
|
miami: "MIA",
|
||||||
|
atlanta: "ATL",
|
||||||
|
seattle: "SEA",
|
||||||
|
"mexico city": "MEX",
|
||||||
|
"panama city": "PAC",
|
||||||
|
"buenos aires": "EZE",
|
||||||
|
"sao paulo": "GRU",
|
||||||
|
wellington: "WLG",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map each city to its ICAO code to prevent referencing non-existent field row.icao in TypeScript
|
||||||
|
const CITY_ICAO_MAP: Record<string, string> = {
|
||||||
|
beijing: "ZBAA",
|
||||||
|
shanghai: "ZSPD",
|
||||||
|
shenzhen: "LFS",
|
||||||
|
guangzhou: "ZGGG",
|
||||||
|
chengdu: "ZUUU",
|
||||||
|
chongqing: "ZUCK",
|
||||||
|
wuhan: "ZHHH",
|
||||||
|
taipei: "RCSS",
|
||||||
|
"hong kong": "VHHH",
|
||||||
|
tokyo: "RJTT",
|
||||||
|
seoul: "RKSI",
|
||||||
|
singapore: "WSSS",
|
||||||
|
"kuala lumpur": "WMKK",
|
||||||
|
manila: "RPLL",
|
||||||
|
jakarta: "WIHH",
|
||||||
|
karachi: "OPKC",
|
||||||
|
lucknow: "VILK",
|
||||||
|
london: "EGLC",
|
||||||
|
paris: "LFPB",
|
||||||
|
munich: "EDDM",
|
||||||
|
milan: "LIMC",
|
||||||
|
madrid: "LEMD",
|
||||||
|
amsterdam: "EHAM",
|
||||||
|
warsaw: "EPWA",
|
||||||
|
helsinki: "EFHK",
|
||||||
|
"cape town": "FACT",
|
||||||
|
jeddah: "OEJN",
|
||||||
|
toronto: "CYYZ",
|
||||||
|
"new york": "KLGA",
|
||||||
|
"los angeles": "KLAX",
|
||||||
|
"san francisco": "KSFO",
|
||||||
|
denver: "KBKF",
|
||||||
|
austin: "KAUS",
|
||||||
|
houston: "KHOU",
|
||||||
|
dallas: "KDAL",
|
||||||
|
miami: "KMIA",
|
||||||
|
atlanta: "KATL",
|
||||||
|
seattle: "KSEA",
|
||||||
|
"mexico city": "MMMX",
|
||||||
|
"panama city": "MPMG",
|
||||||
|
"buenos aires": "SAEZ",
|
||||||
|
"sao paulo": "SBGR",
|
||||||
|
wellington: "NZWN",
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCityCode = (city: string): string => {
|
||||||
|
const normalized = String(city || "").toLowerCase().trim();
|
||||||
|
return CITY_IATA_MAP[normalized] || normalized.substring(0, 3).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
export function CitySelectorDropdown({
|
export function CitySelectorDropdown({
|
||||||
isEn,
|
isEn,
|
||||||
rows,
|
rows,
|
||||||
@@ -53,13 +152,13 @@ export function CitySelectorDropdown({
|
|||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
// Tab definitions
|
// Tab definitions matching the Koyfin pill row style
|
||||||
const tabs = useMemo(() => {
|
const tabs = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{ key: "all", labelEn: "ALL", labelZh: "全部" },
|
{ key: "all", labelEn: "ALL", labelZh: "全部" },
|
||||||
...REGIONS.map((r) => ({
|
...REGIONS.map((r) => ({
|
||||||
key: r.key,
|
key: r.key,
|
||||||
labelEn: r.labelEn.replace("Asia", "").replace("America", "").trim() || r.labelEn,
|
labelEn: r.labelEn.replace("Asia", "").replace("America", "").trim().toUpperCase() || r.labelEn,
|
||||||
labelZh: r.labelZh,
|
labelZh: r.labelZh,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
@@ -77,11 +176,16 @@ export function CitySelectorDropdown({
|
|||||||
|
|
||||||
// 2. Query filter
|
// 2. Query filter
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
|
const key = String(row.city || "").toLowerCase().trim();
|
||||||
|
const code = getCityCode(row.city || "");
|
||||||
|
const icao = CITY_ICAO_MAP[key] || "";
|
||||||
const haystack = [
|
const haystack = [
|
||||||
row.city,
|
row.city,
|
||||||
row.city_display_name,
|
row.city_display_name,
|
||||||
row.display_name,
|
row.display_name,
|
||||||
row.airport,
|
row.airport,
|
||||||
|
icao,
|
||||||
|
code,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((s) => s!.toLowerCase());
|
.map((s) => s!.toLowerCase());
|
||||||
@@ -89,36 +193,35 @@ export function CitySelectorDropdown({
|
|||||||
});
|
});
|
||||||
}, [rows, searchQuery, activeTab]);
|
}, [rows, searchQuery, activeTab]);
|
||||||
|
|
||||||
|
const getRegionLabel = (regionKey: string): string => {
|
||||||
|
const match = REGIONS.find((r) => r.key === regionKey);
|
||||||
|
if (!match) return regionKey;
|
||||||
|
return isEn ? match.labelEn : match.labelZh;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col bg-white border border-slate-200 rounded-md shadow-2xl overflow-hidden text-xs text-[#202833] animate-in fade-in-50 zoom-in-95 duration-100",
|
"flex flex-col bg-white border border-slate-300 rounded shadow-2xl overflow-hidden text-xs text-[#202833] animate-in fade-in-50 zoom-in-95 duration-100",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
onClick={(e) => e.stopPropagation()} // Prevent card activation
|
onClick={(e) => e.stopPropagation()} // Prevent triggering slot clicks
|
||||||
>
|
>
|
||||||
{/* Search Input Area */}
|
{/* Search Input Area */}
|
||||||
<div className="flex items-center gap-2 p-2 border-b border-slate-100 bg-[#f8fafc]">
|
<div className="p-2 bg-white">
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder={isEn ? "Search city or airport..." : "搜索城市、机场..."}
|
placeholder="Search..."
|
||||||
className="w-full px-2.5 py-1.5 border border-slate-300 rounded bg-white text-xs outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
|
className="w-full px-2.5 py-1.5 border border-[#3b82f6] rounded bg-white text-xs outline-none ring-2 ring-blue-500/10 focus:border-blue-500"
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-slate-400 hover:text-slate-600 px-1 font-mono"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Region filter tabs */}
|
{/* Koyfin-style Category Pills Bar */}
|
||||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 border-b border-slate-100 bg-slate-50/50 overflow-x-auto scrollbar-none">
|
<div className="flex items-center gap-1 px-2.5 py-1 border-b border-slate-100 bg-[#f8fafc] overflow-x-auto scrollbar-none">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = activeTab === tab.key;
|
const isActive = activeTab === tab.key;
|
||||||
return (
|
return (
|
||||||
@@ -127,10 +230,10 @@ export function CitySelectorDropdown({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"px-2 py-0.5 text-[10px] font-bold rounded whitespace-nowrap transition-all",
|
"px-2 py-0.5 text-[9px] font-bold rounded transition-all uppercase tracking-wider",
|
||||||
isActive
|
isActive
|
||||||
? "bg-blue-600 text-white shadow-sm"
|
? "bg-[#0070f3] text-white shadow-sm"
|
||||||
: "bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-700"
|
: "text-slate-500 hover:text-slate-800 hover:bg-slate-200/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isEn ? tab.labelEn : tab.labelZh}
|
{isEn ? tab.labelEn : tab.labelZh}
|
||||||
@@ -139,8 +242,8 @@ export function CitySelectorDropdown({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable list */}
|
{/* Results List */}
|
||||||
<div className="flex-1 overflow-y-auto max-h-[220px] divide-y divide-slate-50">
|
<div className="flex-1 overflow-y-auto max-h-[240px] divide-y divide-slate-100">
|
||||||
{filteredRows.length === 0 ? (
|
{filteredRows.length === 0 ? (
|
||||||
<div className="p-4 text-center text-slate-400 font-medium">
|
<div className="p-4 text-center text-slate-400 font-medium">
|
||||||
{isEn ? "No matching cities" : "无匹配城市"}
|
{isEn ? "No matching cities" : "无匹配城市"}
|
||||||
@@ -151,45 +254,51 @@ export function CitySelectorDropdown({
|
|||||||
const obsTemp = row.current_temp ?? row.current_max_so_far;
|
const obsTemp = row.current_temp ?? row.current_max_so_far;
|
||||||
const debPrediction = row.deb_prediction;
|
const debPrediction = row.deb_prediction;
|
||||||
const symbol = row.temp_symbol || "°C";
|
const symbol = row.temp_symbol || "°C";
|
||||||
|
const regionKey = getCityRegion(row) || "unknown";
|
||||||
|
const regionLabel = getRegionLabel(regionKey);
|
||||||
|
const cityKey = String(row.city || "").toLowerCase().trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={row.id}
|
key={row.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectCity(String(row.city || "").toLowerCase())}
|
onClick={() => onSelectCity(String(row.city || "").toLowerCase())}
|
||||||
className="flex w-full items-center justify-between px-3 py-2 text-left hover:bg-blue-50/50 transition-colors group"
|
className="flex w-full items-center justify-between px-3 py-2 text-left hover:bg-slate-100 transition-colors group"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1 pr-3">
|
{/* Left Column: Symbol & Name */}
|
||||||
<div className="font-bold text-slate-800 group-hover:text-blue-600 truncate">
|
<div className="min-w-0 flex-1 pr-2">
|
||||||
{cityName}
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-bold text-slate-900 text-[12px] tracking-tight group-hover:text-[#0070f3] transition-colors">
|
||||||
|
{getCityCode(row.city || "")}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400 font-mono font-medium">
|
||||||
|
{CITY_ICAO_MAP[cityKey] || ""}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{row.airport && (
|
<div className="text-[10px] text-slate-500 font-medium truncate mt-0.5">
|
||||||
<div className="text-[10px] text-slate-400 font-mono truncate">
|
{cityName} · {row.airport || ""}
|
||||||
{row.airport}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Middle Column: Temps (Obs & DEB) */}
|
||||||
|
<div className="flex items-center gap-2.5 font-mono text-[11px] shrink-0 px-2 text-slate-600">
|
||||||
|
{obsTemp !== undefined && obsTemp !== null && (
|
||||||
|
<span title={isEn ? "Observed" : "实测"}>
|
||||||
|
<span className="text-[9px] text-slate-400 font-sans mr-0.5 font-bold">O:</span>
|
||||||
|
<strong className="text-slate-800 font-bold">{obsTemp}{symbol}</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{debPrediction !== undefined && debPrediction !== null && (
|
||||||
|
<span title={isEn ? "DEB Prediction" : "DEB 预估"}>
|
||||||
|
<span className="text-[9px] text-slate-400 font-sans mr-0.5 font-bold">D:</span>
|
||||||
|
<strong className="text-orange-600 font-bold">{debPrediction}{symbol}</strong>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Weather info */}
|
{/* Right Column: Region (matches Equity/Asset Class style) */}
|
||||||
<div className="flex items-center gap-3 font-mono text-[11px] shrink-0 text-slate-500">
|
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-wider text-right min-w-[75px] shrink-0 font-sans">
|
||||||
{obsTemp !== undefined && obsTemp !== null && (
|
{regionLabel}
|
||||||
<div className="flex flex-col items-end">
|
|
||||||
<span className="text-[8px] text-slate-400 font-sans scale-90 uppercase">Obs</span>
|
|
||||||
<strong className="text-slate-700 font-bold">
|
|
||||||
{obsTemp}
|
|
||||||
<span className="text-[9px] font-normal font-sans">{symbol}</span>
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{debPrediction !== undefined && debPrediction !== null && (
|
|
||||||
<div className="flex flex-col items-end">
|
|
||||||
<span className="text-[8px] text-slate-400 font-sans scale-90 uppercase">DEB</span>
|
|
||||||
<strong className="text-orange-600 font-bold">
|
|
||||||
{debPrediction}
|
|
||||||
<span className="text-[9px] font-normal font-sans">{symbol}</span>
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -825,6 +825,14 @@ def _analyze(
|
|||||||
cur_temp = _sf(live_mc.get("temp"))
|
cur_temp = _sf(live_mc.get("temp"))
|
||||||
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
||||||
cur_temp = None
|
cur_temp = None
|
||||||
|
# Official settlement station: e.g. CWA for Taipei, HKO for Hong Kong
|
||||||
|
if cur_temp is None:
|
||||||
|
cur_temp = _sf((settlement_current.get("current") or {}).get("temp"))
|
||||||
|
if cur_temp is not None:
|
||||||
|
current_source = settlement_source or "settlement"
|
||||||
|
current_source_label = settlement_source_label or "Settlement Station"
|
||||||
|
current_station_code = settlement_current.get("station_code") or current_station_code
|
||||||
|
current_station_name = settlement_current.get("station_name") or current_station_name
|
||||||
if cur_temp is None:
|
if cur_temp is None:
|
||||||
cur_temp = _sf(mg_cur.get("temp"))
|
cur_temp = _sf(mg_cur.get("temp"))
|
||||||
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
||||||
@@ -1837,6 +1845,11 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
|||||||
cur_temp = _sf(live_mc.get("temp"))
|
cur_temp = _sf(live_mc.get("temp"))
|
||||||
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
||||||
cur_temp = None
|
cur_temp = None
|
||||||
|
if cur_temp is None:
|
||||||
|
cur_temp = _sf((settlement_current.get("current") or {}).get("temp"))
|
||||||
|
if cur_temp is not None:
|
||||||
|
current_source = settlement_source or "settlement"
|
||||||
|
current_source_label = settlement_source_label or "Settlement Station"
|
||||||
if cur_temp is None:
|
if cur_temp is None:
|
||||||
cur_temp = _sf(mg_cur.get("temp"))
|
cur_temp = _sf(mg_cur.get("temp"))
|
||||||
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
|
||||||
|
|||||||
Reference in New Issue
Block a user