清理旧 Dashboard 全栈死代码:useDashboardStore、dashboardClient、22个关联组件
删除 6650+ 行死代码:useDashboardStore.tsx(1666)、dashboardClient.ts(576)、CitySidebar/DetailPanel/PanelSections/ProbabilityDistribution 及关联 CSS、9个 opportunity-* 模块、ModelEvidencePanel/AiPinnedCityCard/MobileDecisionCard/AiPinnedForecastView/useAiPinnedCityWorkspace。scan-root-styles 清理 6 个无用 CSS 导入。
This commit is contained in:
@@ -1,378 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { Clock, Search } from "lucide-react";
|
|
||||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
|
||||||
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
|
||||||
import { CityListItem, DeviationMonitor } from "@/lib/dashboard-types";
|
|
||||||
|
|
||||||
type RiskGroupKey = "high" | "medium" | "low" | "other";
|
|
||||||
|
|
||||||
const GROUP_STATE_STORAGE_KEY = "polyWeather_sidebar_groups_v1";
|
|
||||||
const DEFAULT_EXPANDED_GROUPS: Record<RiskGroupKey, boolean> = {
|
|
||||||
high: true,
|
|
||||||
medium: true,
|
|
||||||
low: false,
|
|
||||||
other: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
function toRiskGroup(level?: string): RiskGroupKey {
|
|
||||||
if (level === "high" || level === "medium" || level === "low") return level;
|
|
||||||
return "other";
|
|
||||||
}
|
|
||||||
|
|
||||||
function toPerformanceGroup(city: CityListItem): RiskGroupKey {
|
|
||||||
return toRiskGroup(city.deb_recent_tier);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeExpandedGroups(
|
|
||||||
value: unknown,
|
|
||||||
): Record<RiskGroupKey, boolean> {
|
|
||||||
if (!value || typeof value !== "object") {
|
|
||||||
return DEFAULT_EXPANDED_GROUPS;
|
|
||||||
}
|
|
||||||
const candidate = value as Partial<Record<RiskGroupKey, unknown>>;
|
|
||||||
return {
|
|
||||||
high:
|
|
||||||
typeof candidate.high === "boolean"
|
|
||||||
? candidate.high
|
|
||||||
: DEFAULT_EXPANDED_GROUPS.high,
|
|
||||||
medium:
|
|
||||||
typeof candidate.medium === "boolean"
|
|
||||||
? candidate.medium
|
|
||||||
: DEFAULT_EXPANDED_GROUPS.medium,
|
|
||||||
low:
|
|
||||||
typeof candidate.low === "boolean"
|
|
||||||
? candidate.low
|
|
||||||
: DEFAULT_EXPANDED_GROUPS.low,
|
|
||||||
other:
|
|
||||||
typeof candidate.other === "boolean"
|
|
||||||
? candidate.other
|
|
||||||
: DEFAULT_EXPANDED_GROUPS.other,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CitySidebar() {
|
|
||||||
const store = useDashboardStore();
|
|
||||||
const { locale, t } = useI18n();
|
|
||||||
const selectedCity = store.selectedCity;
|
|
||||||
const riskOrder = { high: 0, medium: 1, low: 2, other: 3 };
|
|
||||||
const [expandedGroups, setExpandedGroups] = useState<
|
|
||||||
Record<RiskGroupKey, boolean>
|
|
||||||
>(DEFAULT_EXPANDED_GROUPS);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const cityItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
|
||||||
const normalizedQuery = searchQuery.trim().toLowerCase();
|
|
||||||
|
|
||||||
const sortedCities = useMemo(
|
|
||||||
() =>
|
|
||||||
[...store.cities].sort((a, b) => {
|
|
||||||
const aGroup = toPerformanceGroup(a);
|
|
||||||
const bGroup = toPerformanceGroup(b);
|
|
||||||
const aHitRate = Number(a.deb_recent_hit_rate ?? -1);
|
|
||||||
const bHitRate = Number(b.deb_recent_hit_rate ?? -1);
|
|
||||||
const aSamples = Number(a.deb_recent_sample_count ?? 0);
|
|
||||||
const bSamples = Number(b.deb_recent_sample_count ?? 0);
|
|
||||||
return (
|
|
||||||
(riskOrder[aGroup] ?? 3) - (riskOrder[bGroup] ?? 3) ||
|
|
||||||
bHitRate - aHitRate ||
|
|
||||||
bSamples - aSamples ||
|
|
||||||
a.display_name.localeCompare(b.display_name)
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
[store.cities],
|
|
||||||
);
|
|
||||||
|
|
||||||
const groupedCities = useMemo(() => {
|
|
||||||
const groups: Record<RiskGroupKey, CityListItem[]> = {
|
|
||||||
high: [],
|
|
||||||
medium: [],
|
|
||||||
low: [],
|
|
||||||
other: [],
|
|
||||||
};
|
|
||||||
sortedCities.forEach((city) => {
|
|
||||||
const summary = store.citySummariesByName[city.name];
|
|
||||||
const detail = store.cityDetailsByName[city.name];
|
|
||||||
const localizedName = getLocalizedCityName(
|
|
||||||
city.name,
|
|
||||||
summary?.display_name || detail?.display_name || city.display_name,
|
|
||||||
locale,
|
|
||||||
);
|
|
||||||
if (normalizedQuery) {
|
|
||||||
const searchCorpus = [
|
|
||||||
city.name,
|
|
||||||
city.display_name,
|
|
||||||
city.airport,
|
|
||||||
city.icao,
|
|
||||||
localizedName,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")
|
|
||||||
.toLowerCase();
|
|
||||||
if (!searchCorpus.includes(normalizedQuery)) return;
|
|
||||||
}
|
|
||||||
groups[toPerformanceGroup(city)].push(city);
|
|
||||||
});
|
|
||||||
return groups;
|
|
||||||
}, [
|
|
||||||
locale,
|
|
||||||
normalizedQuery,
|
|
||||||
sortedCities,
|
|
||||||
store.cityDetailsByName,
|
|
||||||
store.citySummariesByName,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedCity) return;
|
|
||||||
const selected = store.cities.find((city) => city.name === selectedCity);
|
|
||||||
if (!selected) return;
|
|
||||||
const groupKey = toPerformanceGroup(selected);
|
|
||||||
setExpandedGroups((current) =>
|
|
||||||
current[groupKey] ? current : { ...current, [groupKey]: true },
|
|
||||||
);
|
|
||||||
}, [selectedCity, store.cities]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedCity) return;
|
|
||||||
const selected = store.cities.find((city) => city.name === selectedCity);
|
|
||||||
if (!selected) return;
|
|
||||||
const groupKey = toPerformanceGroup(selected);
|
|
||||||
if (!expandedGroups[groupKey]) return;
|
|
||||||
|
|
||||||
const frameId = window.requestAnimationFrame(() => {
|
|
||||||
cityItemRefs.current[selectedCity]?.scrollIntoView({
|
|
||||||
block: "center",
|
|
||||||
inline: "nearest",
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return () => window.cancelAnimationFrame(frameId);
|
|
||||||
}, [expandedGroups, selectedCity, store.cities]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
const raw = window.localStorage.getItem(GROUP_STATE_STORAGE_KEY);
|
|
||||||
if (!raw) return;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
setExpandedGroups(normalizeExpandedGroups(parsed));
|
|
||||||
} catch {}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(
|
|
||||||
GROUP_STATE_STORAGE_KEY,
|
|
||||||
JSON.stringify(expandedGroups),
|
|
||||||
);
|
|
||||||
} catch {}
|
|
||||||
}, [expandedGroups]);
|
|
||||||
|
|
||||||
const formatDeviationText = (monitor?: DeviationMonitor | null) => {
|
|
||||||
if (!monitor?.available) return "";
|
|
||||||
const label = locale === "en-US" ? monitor.label_en : monitor.label_zh;
|
|
||||||
const trendLabel =
|
|
||||||
locale === "en-US" ? monitor.trend_label_en : monitor.trend_label_zh;
|
|
||||||
if (!label) return "";
|
|
||||||
return trendLabel ? `${label} · ${trendLabel}` : label;
|
|
||||||
};
|
|
||||||
|
|
||||||
const groupMeta: Array<{ key: RiskGroupKey; label: string }> = [
|
|
||||||
{ key: "high", label: t("sidebar.group.high") },
|
|
||||||
{ key: "medium", label: t("sidebar.group.medium") },
|
|
||||||
{ key: "low", label: t("sidebar.group.low") },
|
|
||||||
{ key: "other", label: t("sidebar.group.other") },
|
|
||||||
];
|
|
||||||
const syncTime = useMemo(
|
|
||||||
() =>
|
|
||||||
new Intl.DateTimeFormat(locale === "en-US" ? "en-US" : "zh-CN", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
}).format(new Date()),
|
|
||||||
[locale],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="city-list">
|
|
||||||
<div className="city-list-header">
|
|
||||||
<span>{t("sidebar.title")}</span>
|
|
||||||
<span className="city-count">{store.cities.length}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="city-search">
|
|
||||||
<Search size={14} strokeWidth={2} aria-hidden="true" />
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(event) => setSearchQuery(event.target.value)}
|
|
||||||
placeholder={locale === "en-US" ? "Search city" : "搜索城市"}
|
|
||||||
aria-label={locale === "en-US" ? "Search city" : "搜索城市"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="city-list-items">
|
|
||||||
{groupMeta.map((group) => {
|
|
||||||
const citiesInGroup = groupedCities[group.key];
|
|
||||||
if (!citiesInGroup.length) return null;
|
|
||||||
const expanded = expandedGroups[group.key];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
key={group.key}
|
|
||||||
className={clsx("city-group", !expanded && "collapsed")}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="city-group-header"
|
|
||||||
aria-expanded={expanded}
|
|
||||||
onClick={() =>
|
|
||||||
setExpandedGroups((current) => ({
|
|
||||||
...current,
|
|
||||||
[group.key]: !current[group.key],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="city-group-title">
|
|
||||||
<span
|
|
||||||
className={clsx("city-group-indicator", group.key)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{group.label}
|
|
||||||
</span>
|
|
||||||
<span className="city-group-meta">
|
|
||||||
<span className="city-group-count">
|
|
||||||
{citiesInGroup.length}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={clsx("city-group-arrow", expanded && "expanded")}
|
|
||||||
>
|
|
||||||
▾
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="city-group-items">
|
|
||||||
{citiesInGroup.map((city) => {
|
|
||||||
const detail = store.cityDetailsByName[city.name];
|
|
||||||
const summary = store.citySummariesByName[city.name];
|
|
||||||
const snapshot = detail || summary;
|
|
||||||
const localizedCityName = getLocalizedCityName(
|
|
||||||
city.name,
|
|
||||||
snapshot?.display_name || city.display_name,
|
|
||||||
locale,
|
|
||||||
);
|
|
||||||
const isActive = store.selectedCity === city.name;
|
|
||||||
const tempSymbol = snapshot?.temp_symbol || "°C";
|
|
||||||
const currentTempText =
|
|
||||||
snapshot?.current?.temp != null
|
|
||||||
? t("sidebar.currentTemp", {
|
|
||||||
temp: `${snapshot.current.temp}${tempSymbol}`,
|
|
||||||
})
|
|
||||||
: t("common.na");
|
|
||||||
const deviationText = formatDeviationText(
|
|
||||||
snapshot?.deviation_monitor,
|
|
||||||
);
|
|
||||||
const peakTempText =
|
|
||||||
detail?.current?.max_so_far != null &&
|
|
||||||
detail.current.max_temp_time
|
|
||||||
? t("sidebar.peakTempAt", {
|
|
||||||
temp: `${detail.current.max_so_far}${tempSymbol}`,
|
|
||||||
time: detail.current.max_temp_time,
|
|
||||||
})
|
|
||||||
: detail?.current?.max_temp_time
|
|
||||||
? t("sidebar.peakAt", {
|
|
||||||
time: detail.current.max_temp_time,
|
|
||||||
})
|
|
||||||
: "";
|
|
||||||
const deviationDirection =
|
|
||||||
snapshot?.deviation_monitor?.direction || "normal";
|
|
||||||
const deviationSeverity =
|
|
||||||
snapshot?.deviation_monitor?.severity || "normal";
|
|
||||||
const secondaryText = deviationText || peakTempText;
|
|
||||||
const performanceTier = toPerformanceGroup(city);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={city.name}
|
|
||||||
ref={(node) => {
|
|
||||||
cityItemRefs.current[city.name] = node;
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
className={clsx("city-item", isActive && "active")}
|
|
||||||
onClick={() =>
|
|
||||||
startTransition(() => {
|
|
||||||
void store.focusCity(city.name);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="city-item-main">
|
|
||||||
<span className={clsx("risk-dot", performanceTier)} />
|
|
||||||
<span className="city-name-text">
|
|
||||||
{localizedCityName}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"city-temp",
|
|
||||||
snapshot?.current?.temp != null && "loaded",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{currentTempText}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="city-item-info">
|
|
||||||
<span className="city-local-time">
|
|
||||||
{snapshot?.local_time ? (
|
|
||||||
<>
|
|
||||||
<Clock
|
|
||||||
size={10}
|
|
||||||
strokeWidth={2}
|
|
||||||
className="city-clock-icon"
|
|
||||||
/>
|
|
||||||
{snapshot.local_time}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"city-max-info",
|
|
||||||
deviationText && "city-deviation-info",
|
|
||||||
deviationText &&
|
|
||||||
`city-deviation-${deviationDirection}`,
|
|
||||||
deviationText &&
|
|
||||||
deviationSeverity === "strong" &&
|
|
||||||
"strong",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{secondaryText}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
|
||||||
<div>
|
|
||||||
{locale === "en-US" ? "Data sync" : "数据更新"} {syncTime}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{locale === "en-US"
|
|
||||||
? "Sources: METAR · Open-Meteo · DEB · Market"
|
|
||||||
: "数据来源:METAR · Open-Meteo · DEB · 市场"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
.root :global(.scan-select) {
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: linear-gradient(135deg, var(--color-accent-primary), #00b383);
|
|
||||||
color: #000;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 224, 164, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.scan-cta-button:hover:not(:disabled)) {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 6px 20px rgba(0, 224, 164, 0.4);
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.scan-cta-button:active:not(:disabled)) {
|
|
||||||
transform: translateY(1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.modal-overlay) {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(2, 6, 23, 0.75);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
z-index: 10000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.modal-content) {
|
|
||||||
background: #16213A;
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: 16px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 700px;
|
|
||||||
max-height: calc(100vh - 48px);
|
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.modal-header) {
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.root :global(.modal-header h2) {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.root :global(.future-modal-title-with-actions) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.future-refresh-btn) {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-muted);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.future-refresh-btn:hover) {
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
background: rgba(34, 211, 238, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.future-refresh-btn.spinning svg) {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.modal-close) {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 20px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
.root :global(.modal-close:hover) {
|
|
||||||
color: var(--accent-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.modal-body) {
|
|
||||||
padding: 20px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep scroll behavior but hide visual scrollbar in today's/future analysis modal */
|
|
||||||
.root :global(.modal-content.large.future-modal .modal-body) {
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root
|
|
||||||
:global(.modal-content.large.future-modal .modal-body::-webkit-scrollbar) {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.root :global(.modal-content) {
|
|
||||||
border-radius: 14px;
|
|
||||||
max-height: calc(100vh - 16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.modal-header) {
|
|
||||||
padding: 14px 14px 12px;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.modal-header h2) {
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.modal-body) {
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Info Button ── */
|
|
||||||
.root :global(.account-btn) {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid rgba(34, 211, 238, 0.34);
|
|
||||||
background: rgba(34, 211, 238, 0.08);
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
.root :global(.account-btn:hover) {
|
|
||||||
background: rgba(34, 211, 238, 0.16);
|
|
||||||
border-color: rgba(34, 211, 238, 0.62);
|
|
||||||
color: #f8fbff;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.account-renew-badge) {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid rgba(245, 158, 11, 0.34);
|
|
||||||
background: rgba(245, 158, 11, 0.1);
|
|
||||||
color: #fbbf24;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.account-renew-badge:hover) {
|
|
||||||
background: rgba(245, 158, 11, 0.18);
|
|
||||||
border-color: rgba(245, 158, 11, 0.6);
|
|
||||||
color: #fff6d5;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.account-renew-badge.expired) {
|
|
||||||
border-color: rgba(239, 68, 68, 0.34);
|
|
||||||
background: rgba(239, 68, 68, 0.12);
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.trial-promo-badge) {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid rgba(34, 211, 238, 0.28);
|
|
||||||
background: rgba(34, 211, 238, 0.1);
|
|
||||||
color: #a5f3fc;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.trial-promo-badge:hover) {
|
|
||||||
background: rgba(34, 211, 238, 0.16);
|
|
||||||
border-color: rgba(34, 211, 238, 0.45);
|
|
||||||
color: #ecfeff;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.info-btn) {
|
|
||||||
background: rgba(99, 102, 241, 0.1);
|
|
||||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
|
||||||
color: var(--accent-blue);
|
|
||||||
padding: 6px 14px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.root :global(.info-btn:hover) {
|
|
||||||
background: rgba(99, 102, 241, 0.2);
|
|
||||||
border-color: var(--accent-blue);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.info-btn.active) {
|
|
||||||
background: rgba(34, 211, 238, 0.14);
|
|
||||||
border-color: rgba(34, 211, 238, 0.42);
|
|
||||||
color: #e0fbff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Guide Modal Customizations ── */
|
|
||||||
.root :global(.modal-content.large) {
|
|
||||||
max-width: 900px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.root :global(.guide-grid) {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.guide-card) {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
.root :global(.guide-card:hover) {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border-color: var(--border-glass);
|
|
||||||
}
|
|
||||||
.root :global(.guide-card h3) {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.root :global(.guide-card p) {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
.root :global(.guide-card b) {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.guide-footer) {
|
|
||||||
margin-top: 24px;
|
|
||||||
padding-top: 16px;
|
|
||||||
border-top: 1px solid var(--border-subtle);
|
|
||||||
text-align: center;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
@@ -1,821 +0,0 @@
|
|||||||
/* ── Header ── */
|
|
||||||
.root :global(.header) {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: var(--header-height);
|
|
||||||
z-index: 1000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 24px;
|
|
||||||
padding: 0 16px;
|
|
||||||
background: rgba(7, 11, 18, 0.94);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
border-bottom: 1px solid var(--border-glass);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.brand) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.brand-mark) {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid rgba(0, 224, 164, 0.22);
|
|
||||||
background: rgba(8, 15, 28, 0.78);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 0 0 1px rgba(148, 163, 184, 0.08),
|
|
||||||
0 0 20px rgba(0, 224, 164, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.brand-mark img) {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
display: block;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.brand h1) {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
var(--accent-cyan) 0%,
|
|
||||||
var(--accent-blue) 100%
|
|
||||||
);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.subtitle) {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-muted);
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.header-nav) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.header-nav-link) {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
padding: 12px 18px;
|
|
||||||
border-radius: 10px;
|
|
||||||
color: rgba(203, 213, 225, 0.82);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: var(--transition);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.header-nav-link:hover) {
|
|
||||||
color: #f8fafc;
|
|
||||||
background: rgba(30, 41, 59, 0.44);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.header-nav-link.active) {
|
|
||||||
color: #f8fafc;
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.header-nav-link.active::after) {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 18px;
|
|
||||||
right: 18px;
|
|
||||||
bottom: 4px;
|
|
||||||
height: 2px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.header-right) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.lang-switch) {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 3px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid var(--border-glass);
|
|
||||||
background: var(--bg-glass);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.lang-btn) {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 7px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.lang-btn:hover) {
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: rgba(99, 102, 241, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.lang-btn.active) {
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: rgba(0, 224, 164, 0.12);
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.live-badge) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: rgba(0, 224, 164, 0.08);
|
|
||||||
border: 1px solid rgba(0, 224, 164, 0.22);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent-green);
|
|
||||||
letter-spacing: 1.2px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.pulse-dot) {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent-green);
|
|
||||||
animation: pulse 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
box-shadow: 0 0 0 0 rgba(0, 224, 164, 0.4);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.7;
|
|
||||||
box-shadow: 0 0 0 6px rgba(0, 224, 164, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.refresh-btn) {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--border-glass);
|
|
||||||
background: rgba(13, 19, 33, 0.6);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 150ms ease;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.root :global(.refresh-btn:hover) {
|
|
||||||
background: rgba(0, 224, 164, 0.1);
|
|
||||||
border-color: rgba(0, 224, 164, 0.3);
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
.root :global(.refresh-btn.spinning) {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.locale-switch) {
|
|
||||||
height: 32px;
|
|
||||||
padding: 3px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid rgba(0, 224, 164, 0.2);
|
|
||||||
background: rgba(8, 15, 27, 0.82);
|
|
||||||
color: rgba(148, 163, 184, 0.86);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.locale-switch span) {
|
|
||||||
min-width: 32px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: 7px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.locale-switch span.active) {
|
|
||||||
color: #f8fafc;
|
|
||||||
background: rgba(0, 224, 164, 0.16);
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.24);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.locale-switch:hover) {
|
|
||||||
border-color: rgba(0, 224, 164, 0.34);
|
|
||||||
background: rgba(11, 22, 40, 0.94);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.header-utility-btn) {
|
|
||||||
min-width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid rgba(115, 137, 161, 0.18);
|
|
||||||
background: rgba(8, 15, 27, 0.82);
|
|
||||||
color: rgba(226, 232, 240, 0.8);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.header-utility-btn:hover) {
|
|
||||||
color: #f8fafc;
|
|
||||||
border-color: rgba(0, 224, 164, 0.34);
|
|
||||||
background: rgba(11, 22, 40, 0.94);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.header-utility-btn.active) {
|
|
||||||
color: #f8fafc;
|
|
||||||
border-color: rgba(123, 97, 255, 0.34);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.header-utility-btn.more) {
|
|
||||||
padding: 0;
|
|
||||||
width: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── City List Sidebar ── */
|
|
||||||
.root :global(.city-list) {
|
|
||||||
position: fixed;
|
|
||||||
top: calc(var(--header-height) + 12px);
|
|
||||||
left: 12px;
|
|
||||||
width: var(--sidebar-width);
|
|
||||||
max-height: calc(100vh - var(--header-height) - 24px);
|
|
||||||
z-index: 900;
|
|
||||||
background: var(--bg-glass);
|
|
||||||
backdrop-filter: blur(var(--glass-blur));
|
|
||||||
border: 1px solid var(--border-glass);
|
|
||||||
border-radius: 18px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-list-header) {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 14px 16px 10px;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-search) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 0 14px 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid rgba(115, 137, 161, 0.14);
|
|
||||||
background: rgba(6, 12, 22, 0.82);
|
|
||||||
color: rgba(148, 163, 184, 0.86);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-search input) {
|
|
||||||
width: 100%;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
background: transparent;
|
|
||||||
color: #f8fafc;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-search input::placeholder) {
|
|
||||||
color: rgba(148, 163, 184, 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-count) {
|
|
||||||
background: rgba(123, 97, 255, 0.2);
|
|
||||||
color: var(--accent-blue);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
border: 1px solid rgba(123, 97, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-list-items) {
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
padding: 0 6px 8px;
|
|
||||||
}
|
|
||||||
.root :global(.city-list-items::-webkit-scrollbar) {
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
.root :global(.city-list-items::-webkit-scrollbar-track) {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
.root :global(.city-list-items::-webkit-scrollbar-thumb) {
|
|
||||||
background: var(--border-glass);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.sidebar-footer) {
|
|
||||||
border-top: 1px solid var(--border-subtle);
|
|
||||||
padding: 10px 12px;
|
|
||||||
color: rgba(148, 163, 184, 0.72);
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-group) {
|
|
||||||
border: 1px solid rgba(115, 137, 161, 0.12);
|
|
||||||
border-radius: 14px;
|
|
||||||
background: rgba(10, 17, 30, 0.48);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-group:last-child) {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-group-header) {
|
|
||||||
width: 100%;
|
|
||||||
border: none;
|
|
||||||
background: rgba(15, 28, 47, 0.56);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 7px 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: background 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-group-header:hover) {
|
|
||||||
background: rgba(18, 34, 55, 0.82);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-group-title) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.4px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-group-indicator) {
|
|
||||||
width: 3px;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.root :global(.city-group-indicator.high) {
|
|
||||||
background: var(--risk-high);
|
|
||||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.3);
|
|
||||||
}
|
|
||||||
.root :global(.city-group-indicator.medium) {
|
|
||||||
background: var(--risk-medium);
|
|
||||||
box-shadow: 0 0 6px rgba(245, 158, 11, 0.3);
|
|
||||||
}
|
|
||||||
.root :global(.city-group-indicator.low) {
|
|
||||||
background: var(--risk-low);
|
|
||||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.3);
|
|
||||||
}
|
|
||||||
.root :global(.city-group-indicator.other) {
|
|
||||||
background: var(--text-muted);
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-group-meta) {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-group-count) {
|
|
||||||
min-width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 9px;
|
|
||||||
padding: 0 6px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(123, 97, 255, 0.26);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-group-arrow) {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-group-arrow.expanded) {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-group-items) {
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-group.collapsed .city-group-items) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-item) {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
font-family: inherit;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.root :global(.city-item:hover) {
|
|
||||||
background: rgba(10, 26, 44, 0.84);
|
|
||||||
border-color: rgba(0, 224, 164, 0.18);
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.03);
|
|
||||||
}
|
|
||||||
.root :global(.city-item.active) {
|
|
||||||
background:
|
|
||||||
linear-gradient(135deg, rgba(0, 224, 164, 0.12), rgba(123, 97, 255, 0.1)),
|
|
||||||
rgba(9, 18, 32, 0.92);
|
|
||||||
border-color: rgba(0, 224, 164, 0.24);
|
|
||||||
box-shadow:
|
|
||||||
0 0 20px rgba(0, 224, 164, 0.08),
|
|
||||||
inset 0 0 0 1px rgba(0, 224, 164, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-item-main) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-item .city-name-text) {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-item-info) {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding-left: 20px; /* Align with name text, after the dot */
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-item .city-max-info) {
|
|
||||||
color: var(--accent-blue);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-item .city-deviation-info) {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-item .city-deviation-cold) {
|
|
||||||
color: var(--color-accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-item .city-deviation-hot) {
|
|
||||||
color: var(--color-signal-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-item .city-deviation-normal) {
|
|
||||||
color: #22d3ee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-item .city-deviation-info.strong) {
|
|
||||||
text-shadow: 0 0 10px rgba(56, 189, 248, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-item .city-deviation-hot.strong) {
|
|
||||||
text-shadow: 0 0 10px rgba(245, 158, 11, 0.24);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-item .risk-dot) {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.root :global(.city-item .risk-dot.high) {
|
|
||||||
background: var(--risk-high);
|
|
||||||
box-shadow: 0 0 6px var(--risk-high);
|
|
||||||
}
|
|
||||||
.root :global(.city-item .risk-dot.medium) {
|
|
||||||
background: var(--risk-medium);
|
|
||||||
box-shadow: 0 0 6px var(--risk-medium);
|
|
||||||
}
|
|
||||||
.root :global(.city-item .risk-dot.low) {
|
|
||||||
background: var(--risk-low);
|
|
||||||
box-shadow: 0 0 6px var(--risk-low);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-clock-icon) {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: -1px;
|
|
||||||
margin-right: 3px;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-item .city-temp) {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 200ms ease;
|
|
||||||
}
|
|
||||||
.root :global(.city-item .city-temp.loaded) {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive ── */
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.root {
|
|
||||||
--panel-width: 460px;
|
|
||||||
}
|
|
||||||
.root :global(.hero-value) {
|
|
||||||
font-size: 52px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.root :global(.header) {
|
|
||||||
height: auto;
|
|
||||||
min-height: var(--header-height);
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.brand) {
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.header-right) {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
justify-content: flex-end;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.lang-switch) {
|
|
||||||
order: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.account-btn),
|
|
||||||
.root :global(.info-btn) {
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.live-badge) {
|
|
||||||
order: 4;
|
|
||||||
padding: 4px 10px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.refresh-btn) {
|
|
||||||
order: 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-list) {
|
|
||||||
display: flex;
|
|
||||||
top: auto;
|
|
||||||
left: 8px;
|
|
||||||
right: 8px;
|
|
||||||
bottom: 8px;
|
|
||||||
width: auto;
|
|
||||||
max-height: min(36vh, 320px);
|
|
||||||
border-radius: 14px;
|
|
||||||
z-index: 920;
|
|
||||||
}
|
|
||||||
.root :global(.detail-panel) {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.root {
|
|
||||||
--panel-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-list-header) {
|
|
||||||
padding: 12px 14px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-list-items) {
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-group-header) {
|
|
||||||
padding: 8px 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-item) {
|
|
||||||
padding: 8px 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-item .city-name-text),
|
|
||||||
.root :global(.city-item .city-temp) {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-item-info) {
|
|
||||||
padding-left: 18px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
row-gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.panel-header) {
|
|
||||||
padding: 16px 16px 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.root :global(.header) {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.brand) {
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.subtitle) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.root :global(.brand h1) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.header-right) {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: flex-start;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.lang-switch) {
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.account-btn),
|
|
||||||
.root :global(.info-btn) {
|
|
||||||
min-height: 34px;
|
|
||||||
padding: 6px 9px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.account-btn span),
|
|
||||||
.root :global(.info-btn span) {
|
|
||||||
max-width: 88px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.live-badge) {
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 10px;
|
|
||||||
letter-spacing: 0.6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.refresh-btn) {
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.hero-value) {
|
|
||||||
font-size: 42px;
|
|
||||||
}
|
|
||||||
.root :global(.hero-details) {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.root :global(.hero-item .value) {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-list) {
|
|
||||||
left: 6px;
|
|
||||||
right: 6px;
|
|
||||||
bottom: 6px;
|
|
||||||
max-height: min(40vh, 300px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-list-header) {
|
|
||||||
padding: 10px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-group-title) {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-item-main) {
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-item-info) {
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.panel-header) {
|
|
||||||
padding: 14px 14px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.city-marker:hover) {
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.marker-bubble) {
|
|
||||||
min-width: 48px;
|
|
||||||
min-height: 32px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.marker-name) {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.prob-price-grid) {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.prob-price-head) {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
@@ -1,472 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import clsx from "clsx";
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { ForecastTable } from "@/components/dashboard/PanelSections";
|
|
||||||
import {
|
|
||||||
useDashboardModal,
|
|
||||||
useDashboardStore,
|
|
||||||
useProAccess,
|
|
||||||
} from "@/hooks/useDashboardStore";
|
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
|
||||||
import { useRelativeTime } from "@/hooks/useRelativeTime";
|
|
||||||
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
|
|
||||||
import { trackAppEvent } from "@/lib/app-analytics";
|
|
||||||
import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links";
|
|
||||||
import { getCityProfileStats } from "@/lib/dashboard-utils";
|
|
||||||
import { normalizeObservationSourceLabel } from "@/lib/source-labels";
|
|
||||||
import { getRiskBadgeLabel } from "@/lib/weather-summary-utils";
|
|
||||||
|
|
||||||
const DetailMiniTemperatureChart = dynamic(
|
|
||||||
() =>
|
|
||||||
import("@/components/dashboard/DetailMiniTemperatureChart").then(
|
|
||||||
(module) => module.DetailMiniTemperatureChart,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
loading: () => <div className="detail-mini-chart detail-mini-chart-loading" />,
|
|
||||||
ssr: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export function DetailPanel({
|
|
||||||
variant = "overlay",
|
|
||||||
}: {
|
|
||||||
variant?: "overlay" | "rail";
|
|
||||||
} = {}) {
|
|
||||||
const store = useDashboardStore();
|
|
||||||
const modal = useDashboardModal();
|
|
||||||
const { proAccess } = useProAccess();
|
|
||||||
const { locale, t } = useI18n();
|
|
||||||
const router = useRouter();
|
|
||||||
const isRail = variant === "rail";
|
|
||||||
const detail = store.selectedDetail;
|
|
||||||
const selectedCityItem = useMemo(
|
|
||||||
() =>
|
|
||||||
store.selectedCity
|
|
||||||
? store.cities.find((city) => city.name === store.selectedCity) || null
|
|
||||||
: null,
|
|
||||||
[store.cities, store.selectedCity],
|
|
||||||
);
|
|
||||||
const selectedSummary = useMemo(
|
|
||||||
() =>
|
|
||||||
store.selectedCity
|
|
||||||
? store.citySummariesByName[store.selectedCity] || null
|
|
||||||
: null,
|
|
||||||
[store.citySummariesByName, store.selectedCity],
|
|
||||||
);
|
|
||||||
const isPro = proAccess.subscriptionActive;
|
|
||||||
const isAuthenticated = proAccess.authenticated;
|
|
||||||
const panelRef = useRef<HTMLElement | null>(null);
|
|
||||||
const lastDetailFetchedAtRef = useRef<number>(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (detail?.name) {
|
|
||||||
lastDetailFetchedAtRef.current = Date.now();
|
|
||||||
}
|
|
||||||
}, [detail?.name]);
|
|
||||||
|
|
||||||
const detailAgeText = useRelativeTime(
|
|
||||||
lastDetailFetchedAtRef.current
|
|
||||||
? new Date(lastDetailFetchedAtRef.current).toISOString()
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
const [heavyContentReady, setHeavyContentReady] = useState(false);
|
|
||||||
const isOverlayOpen =
|
|
||||||
Boolean(modal.futureModalDate);
|
|
||||||
const isVisible = isRail
|
|
||||||
? Boolean(store.selectedCity) && !isOverlayOpen
|
|
||||||
: store.isPanelOpen && Boolean(store.selectedCity) && !isOverlayOpen;
|
|
||||||
const hasBasicPanelContent = Boolean(
|
|
||||||
detail || selectedSummary || selectedCityItem,
|
|
||||||
);
|
|
||||||
const panelDisplayName =
|
|
||||||
detail?.display_name ||
|
|
||||||
selectedSummary?.display_name ||
|
|
||||||
selectedCityItem?.display_name ||
|
|
||||||
store.selectedCity ||
|
|
||||||
"...";
|
|
||||||
const panelRiskLevel =
|
|
||||||
detail?.risk?.level ||
|
|
||||||
selectedSummary?.risk?.level ||
|
|
||||||
selectedCityItem?.risk_level ||
|
|
||||||
"low";
|
|
||||||
const profileStats = useMemo(
|
|
||||||
() => (detail ? getCityProfileStats(detail, locale) : []),
|
|
||||||
[detail, locale],
|
|
||||||
);
|
|
||||||
const officialLinks = useMemo(
|
|
||||||
() => (detail ? getOfficialSourceLinks(detail) : []),
|
|
||||||
[detail],
|
|
||||||
);
|
|
||||||
const marketUrl = useMemo(
|
|
||||||
() => getTodayPolymarketUrl(detail, locale),
|
|
||||||
[detail, locale],
|
|
||||||
);
|
|
||||||
const basicSettlementLabel = normalizeObservationSourceLabel(
|
|
||||||
selectedSummary?.current?.settlement_source_label ||
|
|
||||||
selectedCityItem?.settlement_source_label ||
|
|
||||||
selectedCityItem?.settlement_source,
|
|
||||||
locale === "en-US" ? "Settlement source pending" : "结算口径待确认",
|
|
||||||
);
|
|
||||||
const basicAirportLabel =
|
|
||||||
selectedCityItem?.airport ||
|
|
||||||
selectedSummary?.icao ||
|
|
||||||
(locale === "en-US" ? "Airport pending" : "机场待确认");
|
|
||||||
const heroSettlementLabel = normalizeObservationSourceLabel(
|
|
||||||
detail?.current?.settlement_source_label,
|
|
||||||
basicSettlementLabel,
|
|
||||||
);
|
|
||||||
const heroAirportLabel = detail?.risk?.airport || basicAirportLabel;
|
|
||||||
const isSparsePanelDetail = Boolean(
|
|
||||||
detail &&
|
|
||||||
(detail.detail_depth !== "full" ||
|
|
||||||
(detail.forecast?.daily?.length ?? 0) <= 1),
|
|
||||||
);
|
|
||||||
const isPanelSyncing = store.loadingState.cityDetail;
|
|
||||||
const [panelSyncTimedOut, setPanelSyncTimedOut] = useState(false);
|
|
||||||
const showPanelSyncing = isPanelSyncing && !panelSyncTimedOut;
|
|
||||||
const isShowingCachedDetailDuringSync = false;
|
|
||||||
|
|
||||||
const blurActiveElement = () => {
|
|
||||||
if (typeof document === "undefined") return;
|
|
||||||
const active = document.activeElement;
|
|
||||||
if (active instanceof HTMLElement) {
|
|
||||||
active.blur();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTodayAccess = () => {
|
|
||||||
blurActiveElement();
|
|
||||||
|
|
||||||
if (isPro) {
|
|
||||||
void modal.openTodayModal();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
trackAppEvent("paywall_feature_clicked", {
|
|
||||||
entry: "detail_panel",
|
|
||||||
feature: "today",
|
|
||||||
city: store.selectedCity,
|
|
||||||
user_state: isAuthenticated ? "logged_in" : "guest",
|
|
||||||
});
|
|
||||||
router.push("/account");
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPanelSyncing || !store.selectedCity) {
|
|
||||||
setPanelSyncTimedOut(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPanelSyncTimedOut(false);
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
|
||||||
setPanelSyncTimedOut(true);
|
|
||||||
}, 12_000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(timeoutId);
|
|
||||||
};
|
|
||||||
}, [isPanelSyncing, store.selectedCity]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const panel = panelRef.current;
|
|
||||||
if (!panel) return;
|
|
||||||
|
|
||||||
if (!isVisible) {
|
|
||||||
panel.setAttribute("inert", "");
|
|
||||||
if (
|
|
||||||
typeof document !== "undefined" &&
|
|
||||||
panel.contains(document.activeElement)
|
|
||||||
) {
|
|
||||||
const active = document.activeElement;
|
|
||||||
if (active instanceof HTMLElement) {
|
|
||||||
active.blur();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
panel.removeAttribute("inert");
|
|
||||||
}, [isVisible]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isVisible || !detail) {
|
|
||||||
setHeavyContentReady(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let canceled = false;
|
|
||||||
let timeoutId: number | null = null;
|
|
||||||
let idleId: number | null = null;
|
|
||||||
const win = typeof window !== "undefined" ? (window as any) : null;
|
|
||||||
|
|
||||||
const markReady = () => {
|
|
||||||
if (!canceled) {
|
|
||||||
setHeavyContentReady(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (win && typeof win.requestIdleCallback === "function") {
|
|
||||||
idleId = win.requestIdleCallback(markReady, { timeout: 180 });
|
|
||||||
} else if (typeof window !== "undefined") {
|
|
||||||
timeoutId = window.setTimeout(markReady, 80);
|
|
||||||
} else {
|
|
||||||
setHeavyContentReady(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
canceled = true;
|
|
||||||
if (
|
|
||||||
win &&
|
|
||||||
idleId != null &&
|
|
||||||
typeof win.cancelIdleCallback === "function"
|
|
||||||
) {
|
|
||||||
win.cancelIdleCallback(idleId);
|
|
||||||
}
|
|
||||||
if (timeoutId != null && typeof window !== "undefined") {
|
|
||||||
window.clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [detail, isVisible]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside
|
|
||||||
ref={panelRef}
|
|
||||||
className={clsx(
|
|
||||||
"detail-panel",
|
|
||||||
isRail && "scan-city-detail-rail",
|
|
||||||
(isVisible || isRail) && "visible",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="panel-header">
|
|
||||||
{!isRail ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="panel-close"
|
|
||||||
aria-label={t("detail.closeAria")}
|
|
||||||
onClick={() => {
|
|
||||||
blurActiveElement();
|
|
||||||
store.closePanel();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<div className="panel-title-area">
|
|
||||||
<div className="panel-title-stack">
|
|
||||||
<div className="panel-overline">
|
|
||||||
<span>{locale === "en-US" ? "City briefing" : "城市简报"}</span>
|
|
||||||
<span className="panel-overline-sep">•</span>
|
|
||||||
<span>{panelDisplayName.toUpperCase()}</span>
|
|
||||||
</div>
|
|
||||||
<h2>{panelDisplayName}</h2>
|
|
||||||
</div>
|
|
||||||
{showPanelSyncing && (
|
|
||||||
<div
|
|
||||||
className="panel-loading-hint"
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
<span className="panel-loading-spinner" aria-hidden="true" />
|
|
||||||
<span>
|
|
||||||
{locale === "en-US"
|
|
||||||
? isSparsePanelDetail
|
|
||||||
? `Completing ${panelDisplayName} cards...`
|
|
||||||
: `Syncing ${panelDisplayName}...`
|
|
||||||
: isSparsePanelDetail
|
|
||||||
? `正在补齐 ${panelDisplayName} 卡片...`
|
|
||||||
: `正在同步 ${panelDisplayName}...`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{panelSyncTimedOut && (
|
|
||||||
<div className="panel-loading-hint timed-out" role="alert">
|
|
||||||
<span>
|
|
||||||
{locale === "en-US"
|
|
||||||
? "Sync is taking longer than expected. Data shown may be incomplete."
|
|
||||||
: "同步时间较长,当前展示的数据可能不完整。"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="panel-meta">
|
|
||||||
<span className={clsx("risk-badge", panelRiskLevel)}>
|
|
||||||
{getRiskBadgeLabel(panelRiskLevel, locale)}
|
|
||||||
</span>
|
|
||||||
{detailAgeText ? (
|
|
||||||
<span
|
|
||||||
className="panel-meta-chip panel-meta-chip-muted"
|
|
||||||
title={
|
|
||||||
locale === "en-US"
|
|
||||||
? "Time since last data refresh"
|
|
||||||
: "上次数据更新时间"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{locale === "en-US" ? "Updated " : "更新于 "}
|
|
||||||
{detailAgeText}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<span className="panel-meta-chip panel-meta-chip-strong">
|
|
||||||
{heroSettlementLabel}
|
|
||||||
</span>
|
|
||||||
<span className="panel-meta-chip panel-meta-chip-muted">
|
|
||||||
{heroAirportLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="panel-actions">
|
|
||||||
{marketUrl ? (
|
|
||||||
<a
|
|
||||||
className="panel-action-button panel-action-button-ghost"
|
|
||||||
href={marketUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
title={
|
|
||||||
locale === "en-US"
|
|
||||||
? "Open today's Polymarket market"
|
|
||||||
: "打开今日 Polymarket 题目页"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{locale === "en-US" ? "Market" : "市场页"}
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"panel-body",
|
|
||||||
isShowingCachedDetailDuringSync && "is-syncing",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isShowingCachedDetailDuringSync ? (
|
|
||||||
<div className="panel-sync-blocker" role="status" aria-live="polite">
|
|
||||||
<span className="panel-loading-spinner" aria-hidden="true" />
|
|
||||||
<span>
|
|
||||||
{locale === "en-US"
|
|
||||||
? `Refreshing ${panelDisplayName}. Current cards are paused until the latest data arrives.`
|
|
||||||
: `正在刷新 ${panelDisplayName},最新数据返回前暂停展示旧卡片。`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
isShowingCachedDetailDuringSync && "panel-content-stale",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{!hasBasicPanelContent ? (
|
|
||||||
<section className="detail-summary-shell detail-empty-state">
|
|
||||||
<div className="detail-section-head">
|
|
||||||
<div>
|
|
||||||
<div className="detail-section-kicker">
|
|
||||||
{locale === "en-US" ? "No city selected" : "尚未选择城市"}
|
|
||||||
</div>
|
|
||||||
<h3>
|
|
||||||
{locale === "en-US"
|
|
||||||
? "Pick a city to start."
|
|
||||||
: "先选择一个城市。"}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
|
|
||||||
{showPanelSyncing
|
|
||||||
? t("detail.loading")
|
|
||||||
: t("detail.emptyHint")}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : !detail ? (
|
|
||||||
<div className="detail-mini-meta" role="status" aria-live="polite">
|
|
||||||
{showPanelSyncing
|
|
||||||
? locale === "en-US"
|
|
||||||
? "Loading city cards..."
|
|
||||||
: "正在加载城市卡片..."
|
|
||||||
: locale === "en-US"
|
|
||||||
? "City cards will appear here."
|
|
||||||
: "城市卡片会显示在这里。"}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<section className="detail-structured-section">
|
|
||||||
<div className="detail-section-head">
|
|
||||||
<div>
|
|
||||||
<div className="detail-section-kicker">
|
|
||||||
{locale === "en-US" ? "Profile" : "城市画像"}
|
|
||||||
</div>
|
|
||||||
<h3>{t("detail.profile")}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="detail-grid">
|
|
||||||
{profileStats.map((item) => (
|
|
||||||
<article key={item.label} className="detail-card">
|
|
||||||
<span className="detail-label">{item.label}</span>
|
|
||||||
<span className="detail-value">{item.value}</span>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{officialLinks.length > 0 ? (
|
|
||||||
<section className="detail-structured-section">
|
|
||||||
<div className="detail-section-head">
|
|
||||||
<div>
|
|
||||||
<div className="detail-section-kicker">
|
|
||||||
{locale === "en-US" ? "Primary references" : "官方参考"}
|
|
||||||
</div>
|
|
||||||
<h3>
|
|
||||||
{locale === "en-US"
|
|
||||||
? "Settlement and observation references"
|
|
||||||
: "结算与观测参考来源"}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="detail-source-note">
|
|
||||||
{locale === "en-US"
|
|
||||||
? "AGENCY = national meteorological service, METAR = airport observation, AIRPORT = airport official page."
|
|
||||||
: "AGENCY = 国家气象机构,METAR = 机场实测报文,AIRPORT = 机场官网页面。"}
|
|
||||||
</p>
|
|
||||||
<div className="detail-source-list">
|
|
||||||
{officialLinks.map((link) => (
|
|
||||||
<a
|
|
||||||
key={`${link.label}-${link.href}`}
|
|
||||||
className="detail-source-link"
|
|
||||||
href={link.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<span className="detail-source-kind">
|
|
||||||
{link.kind.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<span className="detail-source-label">
|
|
||||||
{link.label}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section className="detail-structured-section rounded-2xl">
|
|
||||||
<div className="detail-section-head">
|
|
||||||
<div>
|
|
||||||
<div className="detail-section-kicker">
|
|
||||||
{locale === "en-US" ? "Mini trend" : "温度微趋势"}
|
|
||||||
</div>
|
|
||||||
<h3>{t("detail.todayMiniTrend")}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{heavyContentReady ? (
|
|
||||||
<DetailMiniTemperatureChart detail={detail} />
|
|
||||||
) : (
|
|
||||||
<div className="detail-mini-meta">{t("detail.loading")}</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{heavyContentReady ? <ForecastTable /> : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
.root :global(.panel-title-stack) {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.panel-overline) {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.panel-overline-sep) {
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.panel-subtitle) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.panel-weather-chip) {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.panel-meta-chip) {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 28px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.panel-meta-chip-strong) {
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: rgba(34, 211, 238, 0.1);
|
|
||||||
border-color: rgba(34, 211, 238, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.panel-meta-chip-muted) {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.panel-actions) {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.panel-action-button) {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 38px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease, color 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.panel-action-button:hover) {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.panel-action-button-primary) {
|
|
||||||
color: #FFFFFF;
|
|
||||||
background: linear-gradient(135deg, var(--color-accent-primary), #3B82F6);
|
|
||||||
border-color: rgba(77, 163, 255, 0.34);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.panel-action-button-secondary) {
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.panel-action-button-ghost) {
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
background: rgba(34, 211, 238, 0.08);
|
|
||||||
border-color: rgba(34, 211, 238, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-summary-shell) {
|
|
||||||
background: linear-gradient(180deg, rgba(255,255,255,0.035), rgba(255,255,255,0.018));
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
border-radius: 18px;
|
|
||||||
padding: 16px;
|
|
||||||
box-shadow: 0 18px 42px rgba(2, 6, 23, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.panel-body section.detail-summary-shell) {
|
|
||||||
padding: 16px;
|
|
||||||
margin: 18px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-summary-shell-live) {
|
|
||||||
background: linear-gradient(180deg, rgba(22, 78, 99, 0.18), rgba(15, 23, 42, 0.3));
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-section-head) {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-section-kicker) {
|
|
||||||
display: inline-block;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-summary-main) {
|
|
||||||
display: grid;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-summary-hero) {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-summary-temp) {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 38px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-summary-supporting) {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-summary-grid) {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-card) {
|
|
||||||
background: rgba(255, 255, 255, 0.035);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-card-hero) {
|
|
||||||
background: rgba(34, 211, 238, 0.08);
|
|
||||||
border-color: rgba(34, 211, 238, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-card-wide) {
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-callout-card) {
|
|
||||||
background: rgba(255, 255, 255, 0.025);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-value-muted) {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-structured-section) {
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 18px;
|
|
||||||
padding: 16px 16px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-empty-state) {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.panel-loading-hint) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: rgba(77, 163, 255, 0.06);
|
|
||||||
border: 1px solid rgba(77, 163, 255, 0.1);
|
|
||||||
color: var(--color-accent-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.panel-loading-hint.timed-out) {
|
|
||||||
background: rgba(245, 158, 11, 0.06);
|
|
||||||
border-color: rgba(245, 158, 11, 0.16);
|
|
||||||
color: var(--color-signal-warning);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
/* Detail panel content styles. */
|
|
||||||
|
|
||||||
.root :global(.detail-grid) {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-card) {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-source-list) {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-source-note) {
|
|
||||||
margin: 8px 0 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-source-link) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
color: var(--text-main);
|
|
||||||
text-decoration: none;
|
|
||||||
transition:
|
|
||||||
background 0.18s ease,
|
|
||||||
border-color 0.18s ease,
|
|
||||||
transform 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-source-link:hover) {
|
|
||||||
background: rgba(34, 211, 238, 0.08);
|
|
||||||
border-color: rgba(34, 211, 238, 0.28);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-source-kind) {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
text-transform: uppercase;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-source-label) {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-main);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-label) {
|
|
||||||
display: block;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-value) {
|
|
||||||
display: block;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.5;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.profile-lead) {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-mini-chart-wrap) {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-mini-chart) {
|
|
||||||
position: relative;
|
|
||||||
height: 210px;
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.scan-city-detail-rail .detail-mini-chart) {
|
|
||||||
height: 230px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-mini-chart canvas) {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-mini-chart-legend) {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-mini-chart-legend span) {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-mini-chart-legend i) {
|
|
||||||
width: 16px;
|
|
||||||
height: 3px;
|
|
||||||
border-radius: 999px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-mini-chart-legend i.forecast) {
|
|
||||||
background: rgba(100, 116, 139, 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-mini-chart-legend i.forecast.calibrated) {
|
|
||||||
background: #38bdf8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-mini-chart-legend i.observation) {
|
|
||||||
width: 7px;
|
|
||||||
height: 7px;
|
|
||||||
background: #22c55e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-mini-meta) {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.insight-list) {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.insight-item) {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.insight-title) {
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.insight-text) {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-scenery-card) {
|
|
||||||
position: relative;
|
|
||||||
min-height: 210px;
|
|
||||||
border-radius: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(15, 23, 42, 0.18), rgba(15, 23, 42, 0.78)),
|
|
||||||
rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-scenery-image) {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-scenery-overlay) {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
min-height: 210px;
|
|
||||||
padding: 18px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
background: linear-gradient(
|
|
||||||
180deg,
|
|
||||||
rgba(2, 6, 23, 0.08) 0%,
|
|
||||||
rgba(2, 6, 23, 0.86) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-scenery-copy),
|
|
||||||
.root :global(.detail-scenery-fallback) {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-scenery-fallback) {
|
|
||||||
min-height: 210px;
|
|
||||||
padding: 18px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
background:
|
|
||||||
radial-gradient(
|
|
||||||
circle at top left,
|
|
||||||
rgba(34, 211, 238, 0.12),
|
|
||||||
transparent 34%
|
|
||||||
),
|
|
||||||
linear-gradient(180deg, rgba(15, 23, 42, 0.92), rgba(2, 6, 23, 0.96));
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-scenery-kicker) {
|
|
||||||
color: rgba(226, 232, 240, 0.78);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.16em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-scenery-title) {
|
|
||||||
color: #fff;
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
text-shadow: 0 6px 22px rgba(2, 6, 23, 0.42);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-scenery-subtitle) {
|
|
||||||
max-width: 320px;
|
|
||||||
color: rgba(226, 232, 240, 0.9);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
text-shadow: 0 2px 10px rgba(2, 6, 23, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-scenery-credit) {
|
|
||||||
align-self: flex-start;
|
|
||||||
color: rgba(226, 232, 240, 0.9);
|
|
||||||
font-size: 11px;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
||||||
background: rgba(2, 6, 23, 0.35);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-scenery-credit:hover) {
|
|
||||||
border-color: rgba(34, 211, 238, 0.4);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.root :global(.detail-grid) {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-mini-chart) {
|
|
||||||
height: 170px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-source-link) {
|
|
||||||
padding: 10px 12px;
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.detail-source-label) {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,82 +0,0 @@
|
|||||||
.root :global(.modal-title-stack) {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.modal-overline) {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.modal-overline-sep) {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.modal-subtitle) {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.modal-header-meta) {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.modal-meta-pill) {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 28px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
background: rgba(34, 211, 238, 0.08);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.modal-callout) {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 14px;
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
|
||||||
background: rgba(255, 255, 255, 0.035);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.modal-callout-info) {
|
|
||||||
border-color: rgba(56, 189, 248, 0.24);
|
|
||||||
background: rgba(14, 165, 233, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.modal-section-heading) {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.modal-section-kicker) {
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root :global(.modal-section-note) {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
function getTempExtremeClass(temp: number | null | undefined, symbol?: string | null) {
|
|
||||||
if (temp == null || !Number.isFinite(temp)) return "";
|
|
||||||
const isF = String(symbol || "").toUpperCase().includes("F");
|
|
||||||
if (isF ? temp >= 104 : temp >= 40) return "temp-extreme-hot";
|
|
||||||
if (isF ? temp <= 23 : temp <= -5) return "temp-extreme-cold";
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
import type { ChartConfiguration } from "chart.js";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { startTransition, useMemo } from "react";
|
|
||||||
import { useChart } from "@/hooks/useChart";
|
|
||||||
import {
|
|
||||||
useCityData,
|
|
||||||
useCityDetails,
|
|
||||||
useDashboardModal,
|
|
||||||
} from "@/hooks/useDashboardStore";
|
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
|
||||||
import { CityDetail } from "@/lib/dashboard-types";
|
|
||||||
import { getTemperatureChartData } from "@/lib/chart-utils";
|
|
||||||
import {
|
|
||||||
normalizeObservationSourceCode,
|
|
||||||
normalizeObservationSourceLabel,
|
|
||||||
} from "@/lib/source-labels";
|
|
||||||
import {
|
|
||||||
getHeroMetaItems,
|
|
||||||
getRiskBadgeLabel,
|
|
||||||
getWeatherSummary,
|
|
||||||
} from "@/lib/weather-summary-utils";
|
|
||||||
|
|
||||||
function EmptyState({ text }: { text: string }) {
|
|
||||||
return (
|
|
||||||
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>{text}</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HeroSummary() {
|
|
||||||
const { data } = useCityData();
|
|
||||||
const { locale } = useI18n();
|
|
||||||
if (!data) return null;
|
|
||||||
|
|
||||||
const { weatherIcon, weatherText } = getWeatherSummary(data, locale);
|
|
||||||
const metaItems = getHeroMetaItems(data, locale);
|
|
||||||
const current = data.current || {};
|
|
||||||
const settlementSourceCode = normalizeObservationSourceCode(
|
|
||||||
current.settlement_source || "metar",
|
|
||||||
);
|
|
||||||
const settlementIcao = String(current.station_code || data.risk?.icao || "")
|
|
||||||
.trim()
|
|
||||||
.toUpperCase();
|
|
||||||
const settlementSource =
|
|
||||||
settlementSourceCode === "metar" && settlementIcao
|
|
||||||
? `${settlementIcao} METAR`
|
|
||||||
: normalizeObservationSourceLabel(
|
|
||||||
current.settlement_source_label || current.settlement_source,
|
|
||||||
"METAR",
|
|
||||||
).toUpperCase();
|
|
||||||
const isMax =
|
|
||||||
current.max_so_far != null &&
|
|
||||||
current.temp != null &&
|
|
||||||
current.max_so_far <= current.temp;
|
|
||||||
const currentObsText =
|
|
||||||
current.temp != null
|
|
||||||
? `${current.temp}${data.temp_symbol} @${current.obs_time || "--"}`
|
|
||||||
: data.metar_status?.stale_for_today
|
|
||||||
? locale === "en-US"
|
|
||||||
? "No same-day METAR"
|
|
||||||
: "今日暂无 METAR"
|
|
||||||
: "--";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="hero-section">
|
|
||||||
<div className="hero-weather">
|
|
||||||
<span>
|
|
||||||
{weatherIcon} {weatherText}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={`hero-temp ${getTempExtremeClass(current.temp, data.temp_symbol)}`}>
|
|
||||||
<span className="hero-value">
|
|
||||||
{current.temp != null ? current.temp.toFixed(1) : "--"}
|
|
||||||
</span>
|
|
||||||
<span className="hero-unit">{data.temp_symbol || "°C"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="hero-max-time">
|
|
||||||
{isMax && current.max_temp_time
|
|
||||||
? locale === "en-US"
|
|
||||||
? `Today's peak temperature appeared at local time ${current.max_temp_time}`
|
|
||||||
: `该城市今日最高温出现在当地时间 ${current.max_temp_time}`
|
|
||||||
: ""}
|
|
||||||
</div>
|
|
||||||
<div className="hero-details">
|
|
||||||
<div className="hero-item">
|
|
||||||
<span className="label">
|
|
||||||
{locale === "en-US" ? "Current Obs" : "当前实测"}
|
|
||||||
</span>
|
|
||||||
<span className="value">{currentObsText}</span>
|
|
||||||
</div>
|
|
||||||
<div className="hero-item">
|
|
||||||
<span className="label">
|
|
||||||
{locale === "en-US"
|
|
||||||
? `${settlementSource} Anchor`
|
|
||||||
: `${settlementSource} 锚点`}
|
|
||||||
</span>
|
|
||||||
<span className="value highlight">
|
|
||||||
{current.wu_settlement != null
|
|
||||||
? `${current.wu_settlement}${data.temp_symbol}`
|
|
||||||
: "--"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="hero-item">
|
|
||||||
<span className="label">
|
|
||||||
{locale === "en-US" ? "DEB Forecast" : "DEB 预测"}
|
|
||||||
</span>
|
|
||||||
<span className="value">
|
|
||||||
{data.deb?.prediction != null
|
|
||||||
? `${data.deb.prediction}${data.temp_symbol}`
|
|
||||||
: "--"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="hero-sub">
|
|
||||||
{metaItems.map((item) => (
|
|
||||||
<span key={item}>{item}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TemperatureChart() {
|
|
||||||
const { data } = useCityData();
|
|
||||||
const { locale, t } = useI18n();
|
|
||||||
const chartData = useMemo(
|
|
||||||
() => (data ? getTemperatureChartData(data, locale) : null),
|
|
||||||
[data, locale],
|
|
||||||
);
|
|
||||||
|
|
||||||
const canvasRef = useChart(() => {
|
|
||||||
if (!data || !chartData) {
|
|
||||||
return {
|
|
||||||
data: { datasets: [], labels: [] },
|
|
||||||
type: "line",
|
|
||||||
} satisfies ChartConfiguration<"line">;
|
|
||||||
}
|
|
||||||
|
|
||||||
const datasets: NonNullable<
|
|
||||||
ChartConfiguration<"line">["data"]
|
|
||||||
>["datasets"] = [];
|
|
||||||
|
|
||||||
if (chartData.datasets.hasMgmHourly) {
|
|
||||||
datasets.push({
|
|
||||||
backgroundColor: "rgba(234, 179, 8, 0.05)",
|
|
||||||
borderColor: "rgba(234, 179, 8, 0.8)",
|
|
||||||
borderWidth: 2,
|
|
||||||
data: chartData.datasets.mgmHourlyPoints,
|
|
||||||
fill: false,
|
|
||||||
label: locale === "en-US" ? "MGM Forecast" : "MGM 预报",
|
|
||||||
pointHoverRadius: 6,
|
|
||||||
pointRadius: 3,
|
|
||||||
spanGaps: true,
|
|
||||||
tension: 0.3,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
datasets.push({
|
|
||||||
backgroundColor: "rgba(77, 163, 255, 0.06)",
|
|
||||||
borderColor: "rgba(77, 163, 255, 0.66)",
|
|
||||||
borderWidth: 1.5,
|
|
||||||
data: chartData.datasets.debPast,
|
|
||||||
fill: true,
|
|
||||||
label: locale === "en-US" ? "DEB Forecast" : "DEB 预报",
|
|
||||||
pointHoverRadius: 3,
|
|
||||||
pointRadius: 0,
|
|
||||||
tension: 0.3,
|
|
||||||
});
|
|
||||||
datasets.push({
|
|
||||||
borderColor: "rgba(77, 163, 255, 0.36)",
|
|
||||||
borderDash: [5, 3],
|
|
||||||
borderWidth: 1.5,
|
|
||||||
data: chartData.datasets.debFuture,
|
|
||||||
fill: false,
|
|
||||||
label: locale === "en-US" ? "DEB Forecast" : "DEB 预报",
|
|
||||||
pointRadius: 0,
|
|
||||||
tension: 0.3,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
datasets.push({
|
|
||||||
backgroundColor: "#4DA3FF",
|
|
||||||
borderColor: "#4DA3FF",
|
|
||||||
borderWidth: 0,
|
|
||||||
data: chartData.datasets.metarPoints,
|
|
||||||
fill: false,
|
|
||||||
label:
|
|
||||||
chartData.observationLabel ||
|
|
||||||
(locale === "en-US" ? "METAR Observation" : "METAR 实况"),
|
|
||||||
order: 0,
|
|
||||||
pointHoverRadius: 7,
|
|
||||||
pointRadius: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (chartData.datasets.mgmPoints.some((value) => value != null)) {
|
|
||||||
datasets.push({
|
|
||||||
backgroundColor: "#facc15",
|
|
||||||
borderColor: "#facc15",
|
|
||||||
borderWidth: 0,
|
|
||||||
data: chartData.datasets.mgmPoints,
|
|
||||||
fill: false,
|
|
||||||
label: locale === "en-US" ? "MGM Observation" : "MGM 实测",
|
|
||||||
order: -1,
|
|
||||||
pointHoverRadius: 9,
|
|
||||||
pointRadius: 7,
|
|
||||||
showLine: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!chartData.datasets.hasMgmHourly &&
|
|
||||||
Math.abs(chartData.datasets.offset) > 0.3
|
|
||||||
) {
|
|
||||||
datasets.push({
|
|
||||||
borderColor: "rgba(77, 163, 255, 0.22)",
|
|
||||||
borderDash: [2, 4],
|
|
||||||
borderWidth: 1,
|
|
||||||
data: chartData.datasets.temps,
|
|
||||||
fill: false,
|
|
||||||
label: locale === "en-US" ? "OM Raw" : "OM 原始",
|
|
||||||
pointRadius: 0,
|
|
||||||
tension: 0.3,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: {
|
|
||||||
datasets,
|
|
||||||
labels: chartData.times,
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
interaction: { intersect: false, mode: "index" },
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: { display: false },
|
|
||||||
tooltip: {
|
|
||||||
backgroundColor: "rgba(15, 23, 42, 0.9)",
|
|
||||||
borderColor: "rgba(77, 163, 255, 0.28)",
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
responsive: true,
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
grid: { color: "rgba(255,255,255,0.04)" },
|
|
||||||
ticks: {
|
|
||||||
callback: (_value, index) =>
|
|
||||||
typeof index === "number" && index % 3 === 0
|
|
||||||
? chartData.times[index]
|
|
||||||
: "",
|
|
||||||
color: "#6B7A90",
|
|
||||||
maxRotation: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
grid: { color: "rgba(255,255,255,0.04)" },
|
|
||||||
max: chartData.max,
|
|
||||||
min: chartData.min,
|
|
||||||
ticks: {
|
|
||||||
callback: (value) =>
|
|
||||||
`${Number(value).toFixed(chartData.yTickStep < 1 ? 1 : 0)}${data.temp_symbol || "°C"}`,
|
|
||||||
color: "#6B7A90",
|
|
||||||
stepSize: chartData.yTickStep,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
type: "line",
|
|
||||||
} satisfies ChartConfiguration<"line">;
|
|
||||||
}, [data, chartData, locale]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="chart-section">
|
|
||||||
<h3>{t("section.todayTempTrend")}</h3>
|
|
||||||
<div className="chart-wrapper">
|
|
||||||
<canvas ref={canvasRef} />
|
|
||||||
</div>
|
|
||||||
<div className="chart-legend">
|
|
||||||
{chartData?.legendText || t("section.chartEmpty")}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ProbabilityDistribution } from "./ProbabilityDistribution";
|
|
||||||
|
|
||||||
export { ModelForecast } from "./ModelForecast";
|
|
||||||
|
|
||||||
export function ForecastTable() {
|
|
||||||
const modal = useDashboardModal();
|
|
||||||
const { loadingState } = useCityDetails();
|
|
||||||
const { data } = useCityData();
|
|
||||||
const { locale, t } = useI18n();
|
|
||||||
const daily = useMemo(() => {
|
|
||||||
if (!data) return [];
|
|
||||||
const rawDaily = Array.isArray(data.forecast?.daily)
|
|
||||||
? data.forecast?.daily || []
|
|
||||||
: [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return rawDaily.filter((day) => {
|
|
||||||
const date = String(day?.date || "").trim();
|
|
||||||
if (!date || seen.has(date)) return false;
|
|
||||||
seen.add(date);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}, [data]);
|
|
||||||
if (!data) return null;
|
|
||||||
const isSparseDaily = daily.length <= 1;
|
|
||||||
const isForecastCompleting =
|
|
||||||
loadingState.cityDetail &&
|
|
||||||
(data.detail_depth !== "full" || isSparseDaily);
|
|
||||||
const resolveForecastTemp = (
|
|
||||||
date: string,
|
|
||||||
fallback: number | null | undefined,
|
|
||||||
) => {
|
|
||||||
const debPrediction = data.multi_model_daily?.[date]?.deb?.prediction;
|
|
||||||
return debPrediction ?? fallback ?? null;
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<section className="forecast-section">
|
|
||||||
<h3>{t("forecast.title")}</h3>
|
|
||||||
{isSparseDaily && (
|
|
||||||
<div className="forecast-inline-note">
|
|
||||||
{isForecastCompleting
|
|
||||||
? locale === "en-US"
|
|
||||||
? "Multi-day forecast is syncing. Only the current-day card has arrived."
|
|
||||||
: "多日预报同步中,当前只到达当日卡片。"
|
|
||||||
: locale === "en-US"
|
|
||||||
? "Only the current-day forecast is available right now."
|
|
||||||
: "当前只收到当日预报,其他日期结果暂未回传。"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="forecast-table">
|
|
||||||
{daily.length === 0 ? (
|
|
||||||
<EmptyState text={t("forecast.empty")} />
|
|
||||||
) : (
|
|
||||||
daily
|
|
||||||
.map((day, index) => {
|
|
||||||
const isToday = data.local_date
|
|
||||||
? day.date === data.local_date
|
|
||||||
: index === 0;
|
|
||||||
const isSelected =
|
|
||||||
(isToday &&
|
|
||||||
modal.forecastModalMode === "today" &&
|
|
||||||
Boolean(modal.futureModalDate)) ||
|
|
||||||
(modal.forecastModalMode !== "today" &&
|
|
||||||
modal.futureModalDate === day.date) ||
|
|
||||||
modal.selectedForecastDate === day.date;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={day.date}
|
|
||||||
type="button"
|
|
||||||
className={clsx(
|
|
||||||
"forecast-day",
|
|
||||||
isToday && "today",
|
|
||||||
isSelected && "selected",
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
startTransition(() => {
|
|
||||||
if (isToday) {
|
|
||||||
modal.openTodayModal();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
modal.openFutureModal(day.date);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="f-date">
|
|
||||||
{isToday
|
|
||||||
? t("forecast.today")
|
|
||||||
: day.date.substring(5).replace("-", "/")}
|
|
||||||
</div>
|
|
||||||
<div className="f-temp">
|
|
||||||
{resolveForecastTemp(day.date, day.max_temp)}
|
|
||||||
{data.temp_symbol}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.concat(
|
|
||||||
isForecastCompleting
|
|
||||||
? Array.from({ length: Math.max(0, 5 - daily.length) }).map(
|
|
||||||
(_, index) => (
|
|
||||||
<button
|
|
||||||
key={`forecast-sync-${index}`}
|
|
||||||
type="button"
|
|
||||||
className="forecast-day forecast-day-sync"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<div className="f-date">
|
|
||||||
{locale === "en-US" ? "Syncing" : "同步中"}
|
|
||||||
</div>
|
|
||||||
<div className="f-temp">--</div>
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: [],
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RiskInfo() {
|
|
||||||
const { data } = useCityData();
|
|
||||||
const { t } = useI18n();
|
|
||||||
if (!data) return null;
|
|
||||||
const risk = data.risk || {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="risk-section">
|
|
||||||
<h3>{t("section.risk")}</h3>
|
|
||||||
<div className="risk-info">
|
|
||||||
{!risk.airport ? (
|
|
||||||
<span style={{ color: "var(--text-muted)" }}>
|
|
||||||
{t("section.noRiskProfile")}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="risk-row">
|
|
||||||
<span className="risk-label">{t("section.airport")}</span>
|
|
||||||
<span>
|
|
||||||
{risk.airport} ({risk.icao})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="risk-row">
|
|
||||||
<span className="risk-label">{t("section.distance")}</span>
|
|
||||||
<span>{risk.distance_km}km</span>
|
|
||||||
</div>
|
|
||||||
{risk.warning && (
|
|
||||||
<div className="risk-row">
|
|
||||||
<span className="risk-label">{t("section.note")}</span>
|
|
||||||
<span>{risk.warning}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,955 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
|
||||||
import {
|
|
||||||
CityDetail,
|
|
||||||
MarketScan,
|
|
||||||
MarketTopBucket,
|
|
||||||
ProbabilityBucket,
|
|
||||||
} from "@/lib/dashboard-types";
|
|
||||||
import { getModelView, getProbabilityView } from "@/lib/model-utils";
|
|
||||||
|
|
||||||
function EmptyState({ text }: { text: string }) {
|
|
||||||
return (
|
|
||||||
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>{text}</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toPercent(value?: number | null) {
|
|
||||||
if (value == null) return null;
|
|
||||||
const numeric = Number(value);
|
|
||||||
if (!Number.isFinite(numeric)) return null;
|
|
||||||
return `${(numeric * 100).toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toPriceCents(value?: number | null) {
|
|
||||||
if (value == null) return null;
|
|
||||||
const numeric = Number(value);
|
|
||||||
if (!Number.isFinite(numeric)) return null;
|
|
||||||
const normalized = numeric > 1 ? numeric / 100 : numeric;
|
|
||||||
const cents = normalized * 100;
|
|
||||||
const rounded = Math.round(cents * 10) / 10;
|
|
||||||
const text = Number.isInteger(rounded)
|
|
||||||
? String(rounded.toFixed(0))
|
|
||||||
: String(rounded);
|
|
||||||
return `${text}c`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTempFromText(value: unknown) {
|
|
||||||
const text = String(value || "");
|
|
||||||
const match = text.match(/(-?\d+(?:\.\d+)?)/);
|
|
||||||
if (!match) return null;
|
|
||||||
const numeric = Number(match[1]);
|
|
||||||
return Number.isFinite(numeric) ? numeric : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBucketTemp(bucket: ProbabilityBucket) {
|
|
||||||
if (bucket.value != null) {
|
|
||||||
const byValue = Number(bucket.value);
|
|
||||||
if (Number.isFinite(byValue)) return byValue;
|
|
||||||
}
|
|
||||||
return parseTempFromText(bucket.label || bucket.bucket || bucket.range);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMarketYesPrice(scan?: MarketScan | null) {
|
|
||||||
if (scan?.market_price != null) {
|
|
||||||
const preferred = Number(scan.market_price);
|
|
||||||
if (Number.isFinite(preferred)) return preferred;
|
|
||||||
}
|
|
||||||
if (scan?.yes_token?.implied_probability != null) {
|
|
||||||
const implied = Number(scan.yes_token.implied_probability);
|
|
||||||
if (Number.isFinite(implied)) return implied;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFahrenheitSymbol(symbol?: string | null) {
|
|
||||||
return String(symbol || "")
|
|
||||||
.toUpperCase()
|
|
||||||
.includes("F");
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayTempToMarketCelsius(
|
|
||||||
value: number | null,
|
|
||||||
detail: Pick<CityDetail, "temp_symbol">,
|
|
||||||
) {
|
|
||||||
if (value == null || !Number.isFinite(value)) return null;
|
|
||||||
if (isFahrenheitSymbol(detail.temp_symbol)) {
|
|
||||||
return ((value - 32) * 5) / 9;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBucketDisplayLabel(
|
|
||||||
bucket: ProbabilityBucket,
|
|
||||||
detail: Pick<CityDetail, "temp_symbol">,
|
|
||||||
) {
|
|
||||||
let bucketLabel = bucket.label || `${bucket.value}${detail.temp_symbol}`;
|
|
||||||
if (!bucketLabel) return "";
|
|
||||||
let str = String(bucketLabel).toUpperCase().replace(/\s+/g, "");
|
|
||||||
const symbol = detail.temp_symbol || "°C";
|
|
||||||
if (isFahrenheitSymbol(symbol)) {
|
|
||||||
str = str.replace(/℃/g, "°F").replace(/°C/g, "°F");
|
|
||||||
} else {
|
|
||||||
str = str.replace(/℃/g, "°C").replace(/°F/g, "°C");
|
|
||||||
}
|
|
||||||
str = str.replace(/°?C($|\+|-)/g, "°C$1");
|
|
||||||
str = str.replace(/°?F($|\+|-)/g, "°F$1");
|
|
||||||
if (!/[°℃][CF]/.test(str) && /[0-9]/.test(str)) {
|
|
||||||
str += symbol;
|
|
||||||
}
|
|
||||||
return str;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMarketBucketUnit(bucket?: MarketTopBucket | null) {
|
|
||||||
return String(bucket?.unit || "").toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMarketBucketAbove(bucket?: MarketTopBucket | null) {
|
|
||||||
const text =
|
|
||||||
`${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\s+/g, "");
|
|
||||||
return (
|
|
||||||
text.includes("+") ||
|
|
||||||
text.includes("orhigher") ||
|
|
||||||
text.includes("or-higher")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMarketBucketBelow(bucket?: MarketTopBucket | null) {
|
|
||||||
const text =
|
|
||||||
`${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\s+/g, "");
|
|
||||||
return (
|
|
||||||
text.includes("<=") || text.includes("orlower") || text.includes("or-lower")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findMarketBucketForDisplayTemp(
|
|
||||||
buckets: MarketTopBucket[],
|
|
||||||
displayTemp: number | null,
|
|
||||||
detail: Pick<CityDetail, "temp_symbol">,
|
|
||||||
) {
|
|
||||||
if (displayTemp == null || !Number.isFinite(displayTemp)) return null;
|
|
||||||
|
|
||||||
let best: MarketTopBucket | null = null;
|
|
||||||
let bestDelta = Number.POSITIVE_INFINITY;
|
|
||||||
for (const bucket of buckets) {
|
|
||||||
const bucketUnit = String(bucket.unit || "").toUpperCase();
|
|
||||||
const compareTemp =
|
|
||||||
bucketUnit === "F"
|
|
||||||
? displayTemp
|
|
||||||
: displayTempToMarketCelsius(displayTemp, detail);
|
|
||||||
if (compareTemp == null) continue;
|
|
||||||
|
|
||||||
const lower = bucket.lower != null ? Number(bucket.lower) : null;
|
|
||||||
const upper = bucket.upper != null ? Number(bucket.upper) : null;
|
|
||||||
if (
|
|
||||||
lower != null &&
|
|
||||||
upper != null &&
|
|
||||||
Number.isFinite(lower) &&
|
|
||||||
Number.isFinite(upper) &&
|
|
||||||
compareTemp >= lower - 0.01 &&
|
|
||||||
compareTemp <= upper + 0.01
|
|
||||||
) {
|
|
||||||
return bucket;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawTemp = bucket.temp ?? bucket.value ?? null;
|
|
||||||
if (rawTemp == null) continue;
|
|
||||||
const candidateTemp = Number(rawTemp);
|
|
||||||
if (!Number.isFinite(candidateTemp)) continue;
|
|
||||||
const delta = Math.abs(candidateTemp - compareTemp);
|
|
||||||
if (delta < bestDelta) {
|
|
||||||
best = bucket;
|
|
||||||
bestDelta = delta;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const tolerance = isFahrenheitSymbol(detail.temp_symbol) ? 0.56 : 0.26;
|
|
||||||
return best && bestDelta <= tolerance ? best : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function marketBucketContainsDisplayTemp(
|
|
||||||
bucket: MarketTopBucket | null,
|
|
||||||
displayTemp: number | null,
|
|
||||||
detail: Pick<CityDetail, "temp_symbol">,
|
|
||||||
) {
|
|
||||||
if (!bucket || displayTemp == null || !Number.isFinite(displayTemp))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
const bucketUnit = getMarketBucketUnit(bucket);
|
|
||||||
const compareTemp =
|
|
||||||
bucketUnit === "F"
|
|
||||||
? displayTemp
|
|
||||||
: displayTempToMarketCelsius(displayTemp, detail);
|
|
||||||
if (compareTemp == null) return false;
|
|
||||||
|
|
||||||
const lower = bucket.lower != null ? Number(bucket.lower) : null;
|
|
||||||
const upper = bucket.upper != null ? Number(bucket.upper) : null;
|
|
||||||
if (lower != null && !Number.isFinite(lower)) return false;
|
|
||||||
if (upper != null && !Number.isFinite(upper)) return false;
|
|
||||||
|
|
||||||
if (lower != null && upper != null) {
|
|
||||||
return compareTemp >= lower - 0.01 && compareTemp <= upper + 0.01;
|
|
||||||
}
|
|
||||||
if (lower != null && isMarketBucketAbove(bucket)) {
|
|
||||||
return compareTemp >= lower - 0.01;
|
|
||||||
}
|
|
||||||
if (lower != null && isMarketBucketBelow(bucket)) {
|
|
||||||
return compareTemp <= lower + 0.01;
|
|
||||||
}
|
|
||||||
const reference = bucket.temp ?? bucket.value ?? lower;
|
|
||||||
const numeric = reference != null ? Number(reference) : null;
|
|
||||||
if (numeric == null || !Number.isFinite(numeric)) return false;
|
|
||||||
const tolerance = bucketUnit === "F" ? 0.56 : 0.26;
|
|
||||||
return Math.abs(compareTemp - numeric) <= tolerance;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAggregatedModelProbabilityForMarketBucket(
|
|
||||||
probabilities: ProbabilityBucket[],
|
|
||||||
bucket: MarketTopBucket | null,
|
|
||||||
detail: Pick<CityDetail, "temp_symbol">,
|
|
||||||
) {
|
|
||||||
if (!bucket) return null;
|
|
||||||
|
|
||||||
let total = 0;
|
|
||||||
let matched = 0;
|
|
||||||
for (const probabilityBucket of probabilities) {
|
|
||||||
const temp = getBucketTemp(probabilityBucket);
|
|
||||||
if (!marketBucketContainsDisplayTemp(bucket, temp, detail)) continue;
|
|
||||||
const probability = Number(probabilityBucket.probability);
|
|
||||||
if (!Number.isFinite(probability)) continue;
|
|
||||||
total += probability;
|
|
||||||
matched += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return matched > 0 ? Math.max(0, Math.min(1, total)) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProbabilityDisplayRow = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
probability: number;
|
|
||||||
marketBucket?: MarketTopBucket | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatMarketBucketDisplayLabel(
|
|
||||||
bucket: MarketTopBucket,
|
|
||||||
detail: Pick<CityDetail, "temp_symbol">,
|
|
||||||
) {
|
|
||||||
const label = String(bucket.label || "").trim();
|
|
||||||
if (label) {
|
|
||||||
const unit = getMarketBucketUnit(bucket);
|
|
||||||
let normalized = label.toUpperCase().replace(/\s+/g, "");
|
|
||||||
if (unit === "F" || isFahrenheitSymbol(detail.temp_symbol)) {
|
|
||||||
normalized = normalized
|
|
||||||
.replace(/ORHIGHER/g, "+")
|
|
||||||
.replace(/ORLOWER/g, "-")
|
|
||||||
.replace(/℃/g, "°F")
|
|
||||||
.replace(/°C/g, "°F")
|
|
||||||
.replace(/(?<=\d)F/g, "°F");
|
|
||||||
} else {
|
|
||||||
normalized = normalized
|
|
||||||
.replace(/ORHIGHER/g, "+")
|
|
||||||
.replace(/ORLOWER/g, "-")
|
|
||||||
.replace(/℃/g, "°C")
|
|
||||||
.replace(/°F/g, "°C")
|
|
||||||
.replace(/(?<=\d)C/g, "°C");
|
|
||||||
}
|
|
||||||
return normalized.replace(/\+/g, "+");
|
|
||||||
}
|
|
||||||
|
|
||||||
const unit =
|
|
||||||
getMarketBucketUnit(bucket) === "F" ||
|
|
||||||
isFahrenheitSymbol(detail.temp_symbol)
|
|
||||||
? "°F"
|
|
||||||
: "°C";
|
|
||||||
const lower = bucket.lower != null ? Number(bucket.lower) : null;
|
|
||||||
const upper = bucket.upper != null ? Number(bucket.upper) : null;
|
|
||||||
if (
|
|
||||||
lower != null &&
|
|
||||||
upper != null &&
|
|
||||||
Number.isFinite(lower) &&
|
|
||||||
Number.isFinite(upper)
|
|
||||||
) {
|
|
||||||
return `${lower}-${upper}${unit}`;
|
|
||||||
}
|
|
||||||
const value = bucket.value ?? bucket.temp ?? lower;
|
|
||||||
const numeric = value != null ? Number(value) : null;
|
|
||||||
if (numeric != null && Number.isFinite(numeric)) {
|
|
||||||
return isMarketBucketAbove(bucket)
|
|
||||||
? `${numeric}${unit}+`
|
|
||||||
: `${numeric}${unit}`;
|
|
||||||
}
|
|
||||||
return "--";
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModelMetadata = NonNullable<
|
|
||||||
NonNullable<CityDetail["source_forecasts"]>["open_meteo_multi_model"]
|
|
||||||
>["model_metadata"];
|
|
||||||
|
|
||||||
|
|
||||||
function normalizeModelNameForVote(name: string) {
|
|
||||||
return String(name || "")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[\s_/-]/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getModelVoteFamily(name: string) {
|
|
||||||
const normalized = normalizeModelNameForVote(name);
|
|
||||||
if (["icon", "iconeu", "icond2"].includes(normalized)) return "dwd_icon";
|
|
||||||
if (["gem", "gdps", "rdps", "hrdps"].includes(normalized)) return "eccc_gem";
|
|
||||||
if (["ecmwfaifs", "aifs"].includes(normalized)) return "ecmwf_aifs";
|
|
||||||
if (normalized === "ecmwf") return "ecmwf_ifs";
|
|
||||||
return normalized || name;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getModelVotePriority(name: string) {
|
|
||||||
const normalized = normalizeModelNameForVote(name);
|
|
||||||
return (
|
|
||||||
{
|
|
||||||
icond2: 40,
|
|
||||||
iconeu: 30,
|
|
||||||
icon: 20,
|
|
||||||
hrdps: 40,
|
|
||||||
rdps: 35,
|
|
||||||
gdps: 30,
|
|
||||||
gem: 20,
|
|
||||||
ecmwfaifs: 30,
|
|
||||||
ecmwf: 30,
|
|
||||||
gfs: 30,
|
|
||||||
jma: 30,
|
|
||||||
mgm: 45,
|
|
||||||
nws: 45,
|
|
||||||
openmeteo: 15,
|
|
||||||
}[normalized] || 10
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRoundedModelVoteDistribution(
|
|
||||||
detail: CityDetail,
|
|
||||||
targetDate?: string | null,
|
|
||||||
) {
|
|
||||||
const view = getModelView(detail, targetDate);
|
|
||||||
const representatives = new Map<
|
|
||||||
string,
|
|
||||||
{ name: string; priority: number; value: number }
|
|
||||||
>();
|
|
||||||
|
|
||||||
Object.entries(view.models || {}).forEach(([name, rawValue]) => {
|
|
||||||
const normalized = normalizeModelNameForVote(name);
|
|
||||||
const value = Number(rawValue);
|
|
||||||
if (!Number.isFinite(value)) return;
|
|
||||||
const family = getModelVoteFamily(name);
|
|
||||||
const priority = getModelVotePriority(name);
|
|
||||||
const current = representatives.get(family);
|
|
||||||
if (!current || priority > current.priority) {
|
|
||||||
representatives.set(family, { name, priority, value });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const bucketMap = new Map<number, { count: number; models: string[] }>();
|
|
||||||
representatives.forEach(({ name, value }) => {
|
|
||||||
const rounded = Math.round(value);
|
|
||||||
const row = bucketMap.get(rounded) || { count: 0, models: [] };
|
|
||||||
row.count += 1;
|
|
||||||
row.models.push(name);
|
|
||||||
bucketMap.set(rounded, row);
|
|
||||||
});
|
|
||||||
|
|
||||||
const total = representatives.size;
|
|
||||||
const rows = Array.from(bucketMap.entries())
|
|
||||||
.map(([value, row]) => ({
|
|
||||||
count: row.count,
|
|
||||||
models: row.models,
|
|
||||||
percent: total > 0 ? row.count / total : 0,
|
|
||||||
value,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.count - a.count || b.value - a.value);
|
|
||||||
|
|
||||||
return {
|
|
||||||
rows,
|
|
||||||
total,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeMarketProbability(value?: number | null) {
|
|
||||||
if (value == null) return null;
|
|
||||||
const numeric = Number(value);
|
|
||||||
if (!Number.isFinite(numeric)) return null;
|
|
||||||
if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100));
|
|
||||||
return Math.max(0, Math.min(1, numeric));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSignedProbability(value?: number | null) {
|
|
||||||
if (value == null) return null;
|
|
||||||
const numeric = Number(value);
|
|
||||||
if (!Number.isFinite(numeric)) return null;
|
|
||||||
if (Math.abs(numeric) > 1) return numeric / 100;
|
|
||||||
return numeric;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSignedPercent(value?: number | null, digits = 1) {
|
|
||||||
const normalized = normalizeSignedProbability(value);
|
|
||||||
if (normalized == null) return "--";
|
|
||||||
const percent = normalized * 100;
|
|
||||||
const sign = percent > 0 ? "+" : "";
|
|
||||||
return `${sign}${percent.toFixed(digits)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMarketTopBuckets(scan?: MarketScan | null) {
|
|
||||||
const buckets = Array.isArray(scan?.top_buckets) ? scan.top_buckets : [];
|
|
||||||
if (!buckets.length) return [];
|
|
||||||
|
|
||||||
return buckets
|
|
||||||
.map((item) => ({
|
|
||||||
...item,
|
|
||||||
probability: normalizeMarketProbability(item.probability),
|
|
||||||
}))
|
|
||||||
.filter(
|
|
||||||
(item): item is MarketTopBucket & { probability: number } =>
|
|
||||||
item.probability != null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMarketAllBuckets(scan?: MarketScan | null) {
|
|
||||||
const buckets = Array.isArray(scan?.all_buckets)
|
|
||||||
? scan.all_buckets
|
|
||||||
: Array.isArray(scan?.top_buckets)
|
|
||||||
? scan.top_buckets
|
|
||||||
: [];
|
|
||||||
if (!buckets.length) return [];
|
|
||||||
|
|
||||||
return buckets
|
|
||||||
.map((item) => ({
|
|
||||||
...item,
|
|
||||||
probability: normalizeMarketProbability(item.probability),
|
|
||||||
}))
|
|
||||||
.filter(
|
|
||||||
(item): item is MarketTopBucket & { probability: number } =>
|
|
||||||
item.probability != null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMarketTopBucketKey(bucket: MarketTopBucket) {
|
|
||||||
if (bucket?.value != null) {
|
|
||||||
const valueNum = Number(bucket.value);
|
|
||||||
if (Number.isFinite(valueNum)) return `v:${valueNum.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bucket?.temp != null) {
|
|
||||||
const tempNum = Number(bucket.temp);
|
|
||||||
if (Number.isFinite(tempNum)) return `t:${tempNum.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseTempFromText(bucket?.label);
|
|
||||||
if (parsed != null) return `l:${parsed.toFixed(2)}`;
|
|
||||||
|
|
||||||
return `s:${String(bucket?.slug || bucket?.question || bucket?.label || "")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasLgbmModel(detail: CityDetail, targetDate?: string | null) {
|
|
||||||
const view = getModelView(detail, targetDate);
|
|
||||||
return Object.keys(view.models || {}).some((name) =>
|
|
||||||
normalizeModelNameForVote(name).includes("lgbm"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatProbabilityEngineLabel(
|
|
||||||
detail: CityDetail,
|
|
||||||
targetDate: string | null | undefined,
|
|
||||||
locale: string,
|
|
||||||
) {
|
|
||||||
const view = getProbabilityView(detail, targetDate);
|
|
||||||
if (hasLgbmModel(detail, targetDate)) {
|
|
||||||
return locale === "en-US" ? "LGBM-calibrated probability" : "LGBM 校准概率";
|
|
||||||
}
|
|
||||||
const engine = String(view.engine || "")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
const calibrationMode = String(view.calibrationMode || "")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
if (engine === "emos" || calibrationMode.includes("emos")) {
|
|
||||||
return locale === "en-US" ? "EMOS-calibrated probability" : "EMOS 校准概率";
|
|
||||||
}
|
|
||||||
return locale === "en-US" ? "Model probability" : "模型概率";
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function ProbabilityDistribution({
|
|
||||||
detail,
|
|
||||||
hideTitle = false,
|
|
||||||
targetDate,
|
|
||||||
marketScan,
|
|
||||||
}: {
|
|
||||||
detail: CityDetail;
|
|
||||||
hideTitle?: boolean;
|
|
||||||
targetDate?: string | null;
|
|
||||||
marketScan?: MarketScan | null;
|
|
||||||
}) {
|
|
||||||
const { locale, t } = useI18n();
|
|
||||||
const view = getProbabilityView(detail, targetDate);
|
|
||||||
const modelView = getModelView(detail, targetDate);
|
|
||||||
const marketYesPrice = getMarketYesPrice(marketScan);
|
|
||||||
const marketYesText = toPercent(marketYesPrice);
|
|
||||||
const isToday = !targetDate || targetDate === detail.local_date;
|
|
||||||
const probabilityEngineLabel = formatProbabilityEngineLabel(
|
|
||||||
detail,
|
|
||||||
targetDate,
|
|
||||||
locale,
|
|
||||||
);
|
|
||||||
const hasLgbmProbability = hasLgbmModel(detail, targetDate);
|
|
||||||
const modelVoteView = useMemo(
|
|
||||||
() => getRoundedModelVoteDistribution(detail, targetDate),
|
|
||||||
[detail, targetDate],
|
|
||||||
);
|
|
||||||
const modelVoteHint = modelVoteView.rows
|
|
||||||
.slice(0, 2)
|
|
||||||
.map(
|
|
||||||
(row) =>
|
|
||||||
`${row.value}${detail.temp_symbol} ${row.count}/${modelVoteView.total}`,
|
|
||||||
)
|
|
||||||
.join(" · ");
|
|
||||||
const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : [];
|
|
||||||
const marketAllBuckets = isToday ? getMarketAllBuckets(marketScan) : [];
|
|
||||||
const sortedMarketTopBuckets = useMemo(() => {
|
|
||||||
const sorted = [...marketTopBuckets].sort(
|
|
||||||
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
|
|
||||||
);
|
|
||||||
const deduped: Array<MarketTopBucket & { probability: number }> = [];
|
|
||||||
const seenKeys = new Set<string>();
|
|
||||||
for (const row of sorted) {
|
|
||||||
const key = getMarketTopBucketKey(row);
|
|
||||||
if (seenKeys.has(key)) continue;
|
|
||||||
seenKeys.add(key);
|
|
||||||
deduped.push(row);
|
|
||||||
if (deduped.length >= 4) break;
|
|
||||||
}
|
|
||||||
return deduped;
|
|
||||||
}, [marketTopBuckets]);
|
|
||||||
const useMarketTopBuckets =
|
|
||||||
marketScan?.available && sortedMarketTopBuckets.length >= 2;
|
|
||||||
const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability);
|
|
||||||
const topProbability = [...(view.probabilities || [])].sort(
|
|
||||||
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
|
|
||||||
)[0];
|
|
||||||
const topProbabilityText = toPercent(topProbability?.probability);
|
|
||||||
const topProbabilityLabel = topProbability
|
|
||||||
? formatBucketDisplayLabel(topProbability, detail)
|
|
||||||
: null;
|
|
||||||
const topProbabilityTemp = topProbability
|
|
||||||
? getBucketTemp(topProbability)
|
|
||||||
: null;
|
|
||||||
const probabilitiesForMarketContracts =
|
|
||||||
view.probabilitiesAll?.length > 0
|
|
||||||
? view.probabilitiesAll
|
|
||||||
: view.probabilities || [];
|
|
||||||
const marketContractRows = useMemo<ProbabilityDisplayRow[]>(() => {
|
|
||||||
if (!isToday || !marketScan?.available || marketAllBuckets.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows: ProbabilityDisplayRow[] = [];
|
|
||||||
const seenKeys = new Set<string>();
|
|
||||||
for (const marketBucket of marketAllBuckets) {
|
|
||||||
const probability = getAggregatedModelProbabilityForMarketBucket(
|
|
||||||
probabilitiesForMarketContracts,
|
|
||||||
marketBucket,
|
|
||||||
detail,
|
|
||||||
);
|
|
||||||
|
|
||||||
const key =
|
|
||||||
marketBucket.slug ||
|
|
||||||
marketBucket.label ||
|
|
||||||
`${marketBucket.lower ?? marketBucket.value ?? marketBucket.temp}-${marketBucket.upper ?? ""}`;
|
|
||||||
if (seenKeys.has(key)) continue;
|
|
||||||
seenKeys.add(key);
|
|
||||||
rows.push({
|
|
||||||
key,
|
|
||||||
label: formatMarketBucketDisplayLabel(marketBucket, detail),
|
|
||||||
probability: probability ?? 0,
|
|
||||||
marketBucket,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}, [
|
|
||||||
detail,
|
|
||||||
isToday,
|
|
||||||
marketAllBuckets,
|
|
||||||
marketScan?.available,
|
|
||||||
probabilitiesForMarketContracts,
|
|
||||||
]);
|
|
||||||
const modelProbabilityRows = useMemo<ProbabilityDisplayRow[]>(
|
|
||||||
() =>
|
|
||||||
(view.probabilities || []).slice(0, 6).map((bucket, index) => {
|
|
||||||
const bucketTemp = getBucketTemp(bucket);
|
|
||||||
return {
|
|
||||||
key: `${bucket.label || bucket.value || index}`,
|
|
||||||
label: formatBucketDisplayLabel(bucket, detail),
|
|
||||||
probability: Number(bucket.probability || 0),
|
|
||||||
marketBucket: findMarketBucketForDisplayTemp(
|
|
||||||
marketAllBuckets,
|
|
||||||
bucketTemp,
|
|
||||||
detail,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
[detail, marketAllBuckets, view.probabilities],
|
|
||||||
);
|
|
||||||
const probabilityRows =
|
|
||||||
marketContractRows.length > 0
|
|
||||||
? marketContractRows.slice(0, 8)
|
|
||||||
: modelProbabilityRows;
|
|
||||||
const topContractRow =
|
|
||||||
marketContractRows.length > 0
|
|
||||||
? marketContractRows.reduce((best, row) =>
|
|
||||||
row.probability > best.probability ? row : best,
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const displayTopLabel = topContractRow?.label || topProbabilityLabel || null;
|
|
||||||
const displayTopProbability =
|
|
||||||
topContractRow?.probability ??
|
|
||||||
(topProbability?.probability != null
|
|
||||||
? Number(topProbability.probability)
|
|
||||||
: null);
|
|
||||||
const displayTopProbabilityText = toPercent(displayTopProbability);
|
|
||||||
const displayUsesMarketBuckets = marketContractRows.length > 0;
|
|
||||||
const linkedMarketBucket = useMemo(() => {
|
|
||||||
if (topContractRow?.marketBucket) return topContractRow.marketBucket;
|
|
||||||
if (topProbabilityTemp == null) return null;
|
|
||||||
return findMarketBucketForDisplayTemp(
|
|
||||||
marketAllBuckets,
|
|
||||||
topProbabilityTemp,
|
|
||||||
detail,
|
|
||||||
);
|
|
||||||
}, [detail, marketAllBuckets, topContractRow, topProbabilityTemp]);
|
|
||||||
const priceAnalysis = marketScan?.price_analysis;
|
|
||||||
const yesPriceView = priceAnalysis?.yes;
|
|
||||||
const noPriceView = priceAnalysis?.no;
|
|
||||||
const linkedMarketAsk =
|
|
||||||
linkedMarketBucket?.yes_buy ??
|
|
||||||
linkedMarketBucket?.market_price ??
|
|
||||||
yesPriceView?.ask ??
|
|
||||||
null;
|
|
||||||
const linkedNoAsk = linkedMarketBucket?.no_buy ?? noPriceView?.ask ?? null;
|
|
||||||
const linkedContractLabel =
|
|
||||||
topContractRow?.label ||
|
|
||||||
(linkedMarketBucket
|
|
||||||
? formatMarketBucketDisplayLabel(linkedMarketBucket, detail)
|
|
||||||
: null) ||
|
|
||||||
topProbabilityLabel ||
|
|
||||||
null;
|
|
||||||
const aggregatedMarketProbability =
|
|
||||||
getAggregatedModelProbabilityForMarketBucket(
|
|
||||||
probabilitiesForMarketContracts,
|
|
||||||
linkedMarketBucket,
|
|
||||||
detail,
|
|
||||||
);
|
|
||||||
const linkedMarketProbability =
|
|
||||||
topContractRow?.probability ??
|
|
||||||
aggregatedMarketProbability ??
|
|
||||||
(topProbability?.probability != null
|
|
||||||
? Number(topProbability.probability)
|
|
||||||
: null);
|
|
||||||
const linkedMarketProbabilityText = toPercent(linkedMarketProbability);
|
|
||||||
const linkedMarketEdge =
|
|
||||||
linkedMarketProbability != null && linkedMarketAsk != null
|
|
||||||
? linkedMarketProbability - Number(linkedMarketAsk)
|
|
||||||
: null;
|
|
||||||
const linkedNoEdge =
|
|
||||||
linkedMarketProbability != null && linkedNoAsk != null
|
|
||||||
? 1 - linkedMarketProbability - Number(linkedNoAsk)
|
|
||||||
: null;
|
|
||||||
const linkedBestSide =
|
|
||||||
linkedMarketBucket && linkedNoEdge != null && linkedMarketEdge != null
|
|
||||||
? linkedNoEdge > linkedMarketEdge
|
|
||||||
? "no"
|
|
||||||
: "yes"
|
|
||||||
: null;
|
|
||||||
const linkedBestAsk = linkedBestSide === "no" ? linkedNoAsk : linkedMarketAsk;
|
|
||||||
const linkedBestEdge =
|
|
||||||
linkedBestSide === "no" ? linkedNoEdge : linkedMarketEdge;
|
|
||||||
const preferredPriceView = linkedMarketBucket
|
|
||||||
? {
|
|
||||||
ask: linkedBestAsk,
|
|
||||||
edge: linkedBestEdge,
|
|
||||||
}
|
|
||||||
: priceAnalysis?.best_side === "no"
|
|
||||||
? noPriceView
|
|
||||||
: yesPriceView;
|
|
||||||
const preferredSideLabel = linkedMarketBucket
|
|
||||||
? linkedBestSide === "no"
|
|
||||||
? "NO"
|
|
||||||
: "YES"
|
|
||||||
: priceAnalysis?.best_side === "no"
|
|
||||||
? locale === "en-US"
|
|
||||||
? "NO"
|
|
||||||
: "NO"
|
|
||||||
: locale === "en-US"
|
|
||||||
? "YES"
|
|
||||||
: "YES";
|
|
||||||
const yesDisplayPrice = linkedMarketBucket
|
|
||||||
? linkedMarketAsk
|
|
||||||
: yesPriceView?.ask;
|
|
||||||
const noDisplayPrice = linkedMarketBucket ? linkedNoAsk : noPriceView?.ask;
|
|
||||||
const yesDisplayEdge = linkedMarketBucket
|
|
||||||
? linkedMarketEdge
|
|
||||||
: yesPriceView?.edge;
|
|
||||||
const noDisplayEdge = linkedMarketBucket ? linkedNoEdge : noPriceView?.edge;
|
|
||||||
const hasPriceAnalysis =
|
|
||||||
isToday &&
|
|
||||||
(Boolean(priceAnalysis?.available) ||
|
|
||||||
Boolean(marketScan) ||
|
|
||||||
Boolean(topProbability));
|
|
||||||
const lockEdge = normalizeSignedProbability(priceAnalysis?.lock?.edge);
|
|
||||||
const lockAvailable = Boolean(
|
|
||||||
priceAnalysis?.lock?.available && lockEdge != null,
|
|
||||||
);
|
|
||||||
const quoteSource =
|
|
||||||
linkedMarketBucket?.quote_source ||
|
|
||||||
marketScan?.yes_token?.quote_source ||
|
|
||||||
marketScan?.no_token?.quote_source ||
|
|
||||||
null;
|
|
||||||
const quoteAgeMs =
|
|
||||||
linkedMarketBucket?.quote_age_ms ??
|
|
||||||
marketScan?.yes_token?.quote_age_ms ??
|
|
||||||
marketScan?.no_token?.quote_age_ms;
|
|
||||||
const quoteSourceLabel =
|
|
||||||
quoteSource === "polymarket_ws"
|
|
||||||
? locale === "en-US"
|
|
||||||
? `WS live${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}s` : ""}`
|
|
||||||
: `WS 实时${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}秒` : ""}`
|
|
||||||
: locale === "en-US"
|
|
||||||
? "CLOB fallback"
|
|
||||||
: "CLOB 兜底";
|
|
||||||
const actionableEdge = normalizeSignedProbability(preferredPriceView?.edge);
|
|
||||||
const linkedContractOverpriced =
|
|
||||||
Boolean(linkedMarketBucket) &&
|
|
||||||
linkedBestSide === "no" &&
|
|
||||||
linkedMarketProbability != null &&
|
|
||||||
linkedMarketAsk != null &&
|
|
||||||
linkedMarketEdge != null &&
|
|
||||||
linkedMarketEdge < 0 &&
|
|
||||||
linkedNoEdge != null &&
|
|
||||||
linkedNoEdge > 0;
|
|
||||||
const linkedContractOverpay =
|
|
||||||
linkedContractOverpriced &&
|
|
||||||
linkedMarketProbability != null &&
|
|
||||||
linkedMarketAsk != null
|
|
||||||
? Number(linkedMarketAsk) - linkedMarketProbability
|
|
||||||
: null;
|
|
||||||
const actionText = !marketScan
|
|
||||||
? locale === "en-US"
|
|
||||||
? "Waiting"
|
|
||||||
: "等待"
|
|
||||||
: !marketScan.available
|
|
||||||
? locale === "en-US"
|
|
||||||
? "No market"
|
|
||||||
: "无盘口"
|
|
||||||
: actionableEdge == null
|
|
||||||
? locale === "en-US"
|
|
||||||
? "No quote"
|
|
||||||
: "无报价"
|
|
||||||
: actionableEdge >= 0.02
|
|
||||||
? linkedContractOverpriced
|
|
||||||
? locale === "en-US"
|
|
||||||
? "Overpriced"
|
|
||||||
: "市场偏贵"
|
|
||||||
: locale === "en-US"
|
|
||||||
? `Watch ${preferredSideLabel}`
|
|
||||||
: `可关注 ${preferredSideLabel}`
|
|
||||||
: actionableEdge > 0
|
|
||||||
? linkedContractOverpriced
|
|
||||||
? locale === "en-US"
|
|
||||||
? "Slightly overpriced"
|
|
||||||
: "略偏贵"
|
|
||||||
: locale === "en-US"
|
|
||||||
? `Small ${preferredSideLabel}`
|
|
||||||
: `${preferredSideLabel} 优势较小`
|
|
||||||
: locale === "en-US"
|
|
||||||
? "No clear edge"
|
|
||||||
: "暂无优势";
|
|
||||||
const actionNote =
|
|
||||||
linkedContractOverpriced && linkedContractOverpay != null
|
|
||||||
? locale === "en-US"
|
|
||||||
? `YES above model by ${formatSignedPercent(linkedContractOverpay)}`
|
|
||||||
: `YES 高于模型 ${formatSignedPercent(linkedContractOverpay)}`
|
|
||||||
: actionableEdge != null && actionableEdge >= 0.02
|
|
||||||
? locale === "en-US"
|
|
||||||
? `${formatSignedPercent(actionableEdge)} vs ask`
|
|
||||||
: `相对买价 ${formatSignedPercent(actionableEdge)}`
|
|
||||||
: locale === "en-US"
|
|
||||||
? `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`
|
|
||||||
: `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="prob-section">
|
|
||||||
{!hideTitle && <h3>{t("section.probability")}</h3>}
|
|
||||||
<div className="prob-bars">
|
|
||||||
<div className="prob-calibration-head">
|
|
||||||
<div>
|
|
||||||
<span className="prob-source-chip">{probabilityEngineLabel}</span>
|
|
||||||
<strong>
|
|
||||||
{displayTopLabel && displayTopProbabilityText
|
|
||||||
? locale === "en-US"
|
|
||||||
? displayUsesMarketBuckets
|
|
||||||
? `${displayTopLabel} is the top displayed contract bucket at ${displayTopProbabilityText}`
|
|
||||||
: `${displayTopLabel} is the top single bucket at ${displayTopProbabilityText}`
|
|
||||||
: displayUsesMarketBuckets
|
|
||||||
? `${displayTopLabel} 为当前显示分布最高,${displayTopProbabilityText}`
|
|
||||||
: `${displayTopLabel} 单点最高,${displayTopProbabilityText}`
|
|
||||||
: locale === "en-US"
|
|
||||||
? "Awaiting calibrated buckets"
|
|
||||||
: "等待校准概率桶"}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
{hasLgbmProbability
|
|
||||||
? locale === "en-US"
|
|
||||||
? "LGBM is the learned intraday adjustment; raw model points below are only diagnostic."
|
|
||||||
: "LGBM 作为日内学习校准项;下方原始模型落点仅用于诊断。"
|
|
||||||
: locale === "en-US"
|
|
||||||
? "Using the calibrated probability distribution; raw model points below are not probabilities."
|
|
||||||
: "使用校准后的概率分布;下方原始模型落点不是概率。"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{marketScan?.available && (topMarketBucketText || marketYesText) && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
fontSize: "11px",
|
|
||||||
marginBottom: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{useMarketTopBuckets
|
|
||||||
? locale === "en-US"
|
|
||||||
? `Market reference only: top traded bucket ${topMarketBucketText}`
|
|
||||||
: `市场仅作参考:最高交易温度桶 ${topMarketBucketText}`
|
|
||||||
: locale === "en-US"
|
|
||||||
? `Market reference only: this bucket ${marketYesText}`
|
|
||||||
: `市场仅作参考:该温度桶 ${marketYesText}`}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="prob-distribution-panel">
|
|
||||||
<div className="prob-distribution-head">
|
|
||||||
<span>
|
|
||||||
{locale === "en-US"
|
|
||||||
? "Probability distribution"
|
|
||||||
: "概率分布"}
|
|
||||||
</span>
|
|
||||||
<em>
|
|
||||||
{marketContractRows.length > 0
|
|
||||||
? locale === "en-US"
|
|
||||||
? "market buckets are aggregated from single-degree probability buckets"
|
|
||||||
: "市场合约桶由单点概率分布聚合"
|
|
||||||
: locale === "en-US"
|
|
||||||
? "temperature probability buckets"
|
|
||||||
: "温度概率桶"}
|
|
||||||
</em>
|
|
||||||
</div>
|
|
||||||
{probabilityRows.length === 0 ? (
|
|
||||||
<EmptyState text={t("section.noProb")} />
|
|
||||||
) : (
|
|
||||||
probabilityRows.map((row, index) => {
|
|
||||||
const probability = Math.round(
|
|
||||||
Number(row.probability || 0) * 100,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={`${row.key || index}`} className="prob-row">
|
|
||||||
<div className="prob-label">{row.label}</div>
|
|
||||||
<div className="prob-bar-track">
|
|
||||||
<div
|
|
||||||
className={clsx("prob-bar-fill", `rank-${index}`)}
|
|
||||||
style={{ width: `${Math.max(probability, 8)}%` }}
|
|
||||||
>
|
|
||||||
{probability}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{hasPriceAnalysis && (
|
|
||||||
<div className="prob-price-card">
|
|
||||||
<div className="prob-price-head">
|
|
||||||
<span>
|
|
||||||
{locale === "en-US" ? "Win-rate reference" : "胜率参考"}
|
|
||||||
</span>
|
|
||||||
<strong>
|
|
||||||
{!marketScan
|
|
||||||
? locale === "en-US"
|
|
||||||
? "Waiting for market context"
|
|
||||||
: "等待市场参照"
|
|
||||||
: !marketScan.available
|
|
||||||
? locale === "en-US"
|
|
||||||
? "No matched active market"
|
|
||||||
: "未匹配到活跃盘口"
|
|
||||||
: locale === "en-US"
|
|
||||||
? `${linkedContractLabel || topProbabilityLabel || "Temperature bucket"} · model ${linkedMarketProbabilityText || topProbabilityText || "--"}`
|
|
||||||
: `${linkedContractLabel || topProbabilityLabel || "温度桶"} · 模型 ${linkedMarketProbabilityText || topProbabilityText || "--"}`}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<div className="prob-price-grid">
|
|
||||||
<div>
|
|
||||||
<span>
|
|
||||||
{locale === "en-US" ? "Bucket" : "温度桶"}
|
|
||||||
</span>
|
|
||||||
<strong>
|
|
||||||
{linkedContractLabel || topProbabilityLabel || "--"}
|
|
||||||
</strong>
|
|
||||||
<em>
|
|
||||||
{linkedMarketProbabilityText || topProbabilityText || "--"}
|
|
||||||
</em>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>{locale === "en-US" ? "DEB" : "DEB"}</span>
|
|
||||||
<strong>
|
|
||||||
{modelView.deb != null && Number.isFinite(Number(modelView.deb))
|
|
||||||
? `${Number(modelView.deb).toFixed(1)}${detail.temp_symbol}`
|
|
||||||
: "--"}
|
|
||||||
</strong>
|
|
||||||
<em>{locale === "en-US" ? "final fused forecast" : "最终融合预测"}</em>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>{locale === "en-US" ? "Model support" : "模型支持"}</span>
|
|
||||||
<strong>{modelVoteHint || "--"}</strong>
|
|
||||||
<em>{locale === "en-US" ? "raw model agreement" : "原始模型一致性"}</em>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>{locale === "en-US" ? "Market role" : "盘口角色"}</span>
|
|
||||||
<strong>{locale === "en-US" ? "Reference only" : "仅作参考"}</strong>
|
|
||||||
<em>{quoteSourceLabel}</em>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
{locale === "en-US"
|
|
||||||
? "This card follows the same rule as AI forecast: DEB first, model agreement second, METAR conflict check before settlement."
|
|
||||||
: "该卡片与 AI 预测口径一致:先看 DEB,再看模型支持,最后检查 METAR 是否冲突。"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{modelVoteHint && (
|
|
||||||
<div className="prob-model-hint">
|
|
||||||
<span>
|
|
||||||
{locale === "en-US" ? "Raw model points" : "原始模型落点"}
|
|
||||||
</span>
|
|
||||||
<strong>{modelVoteHint}</strong>
|
|
||||||
<em>
|
|
||||||
{locale === "en-US"
|
|
||||||
? "diagnostic only; EMOS and contract rows use calibrated probabilities"
|
|
||||||
: "仅作诊断;EMOS 与合约行使用校准概率"}
|
|
||||||
</em>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
|
||||||
import { formatTemperatureValue } from "@/lib/temperature-utils";
|
|
||||||
|
|
||||||
export function decodeRawMetarCloud(rawMetar?: string | null, locale = "zh-CN") {
|
|
||||||
const raw = String(rawMetar || "").toUpperCase();
|
|
||||||
const matches = Array.from(raw.matchAll(/\b(FEW|SCT|BKN|OVC)(\d{3})?\b/g));
|
|
||||||
if (!matches.length) return "";
|
|
||||||
const coverText: Record<string, { zh: string; en: string }> = {
|
|
||||||
FEW: { zh: "少云", en: "few" },
|
|
||||||
SCT: { zh: "散云", en: "scattered" },
|
|
||||||
BKN: { zh: "多云", en: "broken" },
|
|
||||||
OVC: { zh: "阴天", en: "overcast" },
|
|
||||||
};
|
|
||||||
return matches
|
|
||||||
.slice(0, 3)
|
|
||||||
.map((match) => {
|
|
||||||
const cover = coverText[match[1]] || { zh: match[1], en: match[1] };
|
|
||||||
const base = match[2] ? `${Number(match[2]) * 100}ft` : "";
|
|
||||||
return locale === "en-US"
|
|
||||||
? [cover.en, base].filter(Boolean).join(" ")
|
|
||||||
: [cover.zh, base].filter(Boolean).join(" ");
|
|
||||||
})
|
|
||||||
.join(locale === "en-US" ? ", " : "、");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeRawMetarVisibility(rawMetar?: string | null) {
|
|
||||||
const raw = String(rawMetar || "").toUpperCase();
|
|
||||||
if (/\b9999\b/.test(raw)) return "10km+";
|
|
||||||
const meterMatch = raw.match(/\b(\d{4})\b/);
|
|
||||||
if (meterMatch) return `${Number(meterMatch[1]) / 1000}km`;
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeMetarWeatherToken(token?: string | null, locale = "zh-CN") {
|
|
||||||
const raw = String(token || "").trim().toUpperCase();
|
|
||||||
if (!raw) return "";
|
|
||||||
const isEn = locale === "en-US";
|
|
||||||
const intensity = raw.startsWith("-")
|
|
||||||
? isEn
|
|
||||||
? "light "
|
|
||||||
: "轻"
|
|
||||||
: raw.startsWith("+")
|
|
||||||
? isEn
|
|
||||||
? "heavy "
|
|
||||||
: "强"
|
|
||||||
: "";
|
|
||||||
const cleaned = raw.replace(/^[+-]/, "");
|
|
||||||
const descriptors: Record<string, { zh: string; en: string }> = {
|
|
||||||
VC: { zh: "附近", en: "nearby " },
|
|
||||||
SH: { zh: "阵性", en: "showery " },
|
|
||||||
TS: { zh: "雷暴性", en: "thunderstorm " },
|
|
||||||
FZ: { zh: "冻", en: "freezing " },
|
|
||||||
BL: { zh: "吹扬", en: "blowing " },
|
|
||||||
DR: { zh: "低吹", en: "drifting " },
|
|
||||||
MI: { zh: "浅层", en: "shallow " },
|
|
||||||
BC: { zh: "碎片状", en: "patches of " },
|
|
||||||
PR: { zh: "部分", en: "partial " },
|
|
||||||
};
|
|
||||||
const phenomena: Record<string, { zh: string; en: string }> = {
|
|
||||||
DZ: { zh: "毛毛雨", en: "drizzle" },
|
|
||||||
RA: { zh: "雨", en: "rain" },
|
|
||||||
SN: { zh: "雪", en: "snow" },
|
|
||||||
SG: { zh: "米雪", en: "snow grains" },
|
|
||||||
IC: { zh: "冰晶", en: "ice crystals" },
|
|
||||||
PL: { zh: "冰粒", en: "ice pellets" },
|
|
||||||
GR: { zh: "冰雹", en: "hail" },
|
|
||||||
GS: { zh: "小冰雹", en: "small hail" },
|
|
||||||
UP: { zh: "未知降水", en: "unknown precipitation" },
|
|
||||||
BR: { zh: "薄雾", en: "mist" },
|
|
||||||
FG: { zh: "雾", en: "fog" },
|
|
||||||
FU: { zh: "烟", en: "smoke" },
|
|
||||||
VA: { zh: "火山灰", en: "volcanic ash" },
|
|
||||||
DU: { zh: "浮尘", en: "dust" },
|
|
||||||
SA: { zh: "沙", en: "sand" },
|
|
||||||
HZ: { zh: "霾", en: "haze" },
|
|
||||||
PY: { zh: "喷雾", en: "spray" },
|
|
||||||
PO: { zh: "尘卷风", en: "dust whirls" },
|
|
||||||
SQ: { zh: "飑", en: "squall" },
|
|
||||||
FC: { zh: "漏斗云", en: "funnel cloud" },
|
|
||||||
SS: { zh: "沙暴", en: "sandstorm" },
|
|
||||||
DS: { zh: "尘暴", en: "duststorm" },
|
|
||||||
};
|
|
||||||
const descriptorText = Object.entries(descriptors)
|
|
||||||
.filter(([code]) => cleaned.includes(code))
|
|
||||||
.map(([, text]) => (isEn ? text.en : text.zh))
|
|
||||||
.join("");
|
|
||||||
const phenomenonText = Object.entries(phenomena)
|
|
||||||
.filter(([code]) => cleaned.includes(code))
|
|
||||||
.map(([, text]) => (isEn ? text.en : text.zh))
|
|
||||||
.join(isEn ? " / " : "、");
|
|
||||||
if (!phenomenonText) return "";
|
|
||||||
return `${intensity}${descriptorText}${phenomenonText}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeRawMetarWeather(rawMetar?: string | null, locale = "zh-CN") {
|
|
||||||
const raw = String(rawMetar || "").toUpperCase();
|
|
||||||
const matches = Array.from(
|
|
||||||
raw.matchAll(/\b([+-]?(?:VC)?(?:MI|PR|BC|DR|BL|SH|TS|FZ)?(?:DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS))\b/g),
|
|
||||||
);
|
|
||||||
return Array.from(
|
|
||||||
new Set(
|
|
||||||
matches
|
|
||||||
.map((match) => decodeMetarWeatherToken(match[1], locale))
|
|
||||||
.filter(Boolean),
|
|
||||||
),
|
|
||||||
).join(locale === "en-US" ? ", " : "、");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAirportWeatherInputs(row: ScanOpportunityRow, detail: CityDetail | null) {
|
|
||||||
const context = row.metar_context || {};
|
|
||||||
const airport: Partial<NonNullable<CityDetail["airport_current"]>> =
|
|
||||||
detail?.airport_current || {};
|
|
||||||
const rawMetar = String(context.airport_raw_metar || airport.raw_metar || "").trim();
|
|
||||||
return {
|
|
||||||
cloud: String(context.airport_cloud_desc || airport.cloud_desc || "").trim(),
|
|
||||||
rawMetar,
|
|
||||||
visibility:
|
|
||||||
context.airport_visibility_mi != null && Number.isFinite(Number(context.airport_visibility_mi))
|
|
||||||
? Number(context.airport_visibility_mi)
|
|
||||||
: airport.visibility_mi != null && Number.isFinite(Number(airport.visibility_mi))
|
|
||||||
? Number(airport.visibility_mi)
|
|
||||||
: null,
|
|
||||||
weather: String(context.airport_wx_desc || airport.wx_desc || "").trim(),
|
|
||||||
windSpeed:
|
|
||||||
context.airport_wind_speed_kt != null && Number.isFinite(Number(context.airport_wind_speed_kt))
|
|
||||||
? Number(context.airport_wind_speed_kt)
|
|
||||||
: airport.wind_speed_kt != null && Number.isFinite(Number(airport.wind_speed_kt))
|
|
||||||
? Number(airport.wind_speed_kt)
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatAirportWeatherRead(
|
|
||||||
row: ScanOpportunityRow,
|
|
||||||
detail: CityDetail | null,
|
|
||||||
locale: string,
|
|
||||||
) {
|
|
||||||
const isEn = locale === "en-US";
|
|
||||||
const inputs = getAirportWeatherInputs(row, detail);
|
|
||||||
const decodedCloud = inputs.cloud || decodeRawMetarCloud(inputs.rawMetar, locale);
|
|
||||||
const decodedWeather =
|
|
||||||
decodeMetarWeatherToken(inputs.weather, locale) ||
|
|
||||||
inputs.weather ||
|
|
||||||
decodeRawMetarWeather(inputs.rawMetar, locale);
|
|
||||||
const visibilityText =
|
|
||||||
inputs.visibility != null ? `${inputs.visibility.toFixed(1)}mi` : decodeRawMetarVisibility(inputs.rawMetar);
|
|
||||||
const cloudRaw = `${inputs.cloud} ${inputs.rawMetar}`.toUpperCase();
|
|
||||||
const weatherRaw = `${inputs.weather} ${inputs.rawMetar}`.toUpperCase();
|
|
||||||
const suppressors: string[] = [];
|
|
||||||
const supporters: string[] = [];
|
|
||||||
|
|
||||||
if (/(RA|DZ|SN|TS|SH|FG|BR|HZ|OVC|BKN)/.test(weatherRaw) || /(OVC|BKN)/.test(cloudRaw)) {
|
|
||||||
suppressors.push(
|
|
||||||
isEn
|
|
||||||
? "cloud, precipitation or restricted visibility can suppress solar heating"
|
|
||||||
: "云雨、薄雾或低能见度会压制太阳辐射升温",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (inputs.visibility != null && inputs.visibility < 6) {
|
|
||||||
suppressors.push(
|
|
||||||
isEn
|
|
||||||
? `visibility is only ${visibilityText}, so the airport path may warm more slowly`
|
|
||||||
: `能见度仅 ${visibilityText},机场路径可能升温偏慢`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (/(FEW|SCT)/.test(cloudRaw) && !/(RA|DZ|SN|TS|FG|BR|HZ|OVC|BKN)/.test(weatherRaw)) {
|
|
||||||
supporters.push(
|
|
||||||
isEn
|
|
||||||
? "few or scattered clouds do not block the heating path materially"
|
|
||||||
: "少云或散云对日间升温压制不明显",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (inputs.windSpeed != null && inputs.windSpeed >= 15) {
|
|
||||||
suppressors.push(
|
|
||||||
isEn
|
|
||||||
? "stronger wind mixing can change the airport temperature path"
|
|
||||||
: "风速偏大,边界层混合可能改写机场温度路径",
|
|
||||||
);
|
|
||||||
} else if (inputs.windSpeed != null && inputs.windSpeed <= 5 && !suppressors.length) {
|
|
||||||
supporters.push(
|
|
||||||
isEn
|
|
||||||
? "light wind leaves the temperature path mainly driven by local sunshine"
|
|
||||||
: "风速较弱,温度路径更取决于本地日照",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const descriptors = [
|
|
||||||
decodedWeather ? (isEn ? `weather ${decodedWeather}` : `天气 ${decodedWeather}`) : null,
|
|
||||||
decodedCloud ? (isEn ? `cloud ${decodedCloud}` : `云况 ${decodedCloud}`) : null,
|
|
||||||
visibilityText ? (isEn ? `visibility ${visibilityText}` : `能见度 ${visibilityText}`) : null,
|
|
||||||
].filter(Boolean);
|
|
||||||
const read = suppressors[0] || supporters[0];
|
|
||||||
if (!descriptors.length && !read) return null;
|
|
||||||
const prefix = isEn ? "Airport weather read" : "机场气象解读";
|
|
||||||
const evidence = descriptors.length ? `${descriptors.join(isEn ? ", " : ",")};` : "";
|
|
||||||
return `${prefix}:${evidence}${read || (isEn ? "no clear weather suppression signal yet" : "暂未看到明确天气压温信号")}。`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatAirportReportRead(
|
|
||||||
row: ScanOpportunityRow,
|
|
||||||
detail: CityDetail | null,
|
|
||||||
locale: string,
|
|
||||||
tempSymbol?: string | null,
|
|
||||||
) {
|
|
||||||
const isEn = locale === "en-US";
|
|
||||||
const context = row.metar_context || {};
|
|
||||||
const airport: Partial<NonNullable<CityDetail["airport_current"]>> =
|
|
||||||
detail?.airport_current || {};
|
|
||||||
const station =
|
|
||||||
context.station ||
|
|
||||||
detail?.risk?.icao ||
|
|
||||||
airport.station_code ||
|
|
||||||
null;
|
|
||||||
const obsTime =
|
|
||||||
context.airport_obs_time ||
|
|
||||||
context.last_time ||
|
|
||||||
airport.obs_time ||
|
|
||||||
row.metar_status?.last_observation_time ||
|
|
||||||
null;
|
|
||||||
const temp =
|
|
||||||
context.airport_current_temp != null && Number.isFinite(Number(context.airport_current_temp))
|
|
||||||
? Number(context.airport_current_temp)
|
|
||||||
: airport.temp != null && Number.isFinite(Number(airport.temp))
|
|
||||||
? Number(airport.temp)
|
|
||||||
: null;
|
|
||||||
const windSpeed =
|
|
||||||
context.airport_wind_speed_kt != null && Number.isFinite(Number(context.airport_wind_speed_kt))
|
|
||||||
? Number(context.airport_wind_speed_kt)
|
|
||||||
: airport.wind_speed_kt != null && Number.isFinite(Number(airport.wind_speed_kt))
|
|
||||||
? Number(airport.wind_speed_kt)
|
|
||||||
: null;
|
|
||||||
const windDir =
|
|
||||||
context.airport_wind_dir != null && Number.isFinite(Number(context.airport_wind_dir))
|
|
||||||
? Number(context.airport_wind_dir)
|
|
||||||
: airport.wind_dir != null && Number.isFinite(Number(airport.wind_dir))
|
|
||||||
? Number(airport.wind_dir)
|
|
||||||
: null;
|
|
||||||
const cloud = String(context.airport_cloud_desc || airport.cloud_desc || "").trim();
|
|
||||||
const weather = String(context.airport_wx_desc || airport.wx_desc || "").trim();
|
|
||||||
const rawMetar = String(context.airport_raw_metar || airport.raw_metar || "").trim();
|
|
||||||
const decodedCloud = cloud || decodeRawMetarCloud(rawMetar, locale);
|
|
||||||
const decodedWeather =
|
|
||||||
decodeMetarWeatherToken(weather, locale) ||
|
|
||||||
weather ||
|
|
||||||
decodeRawMetarWeather(rawMetar, locale);
|
|
||||||
const visibility =
|
|
||||||
context.airport_visibility_mi != null && Number.isFinite(Number(context.airport_visibility_mi))
|
|
||||||
? Number(context.airport_visibility_mi)
|
|
||||||
: airport.visibility_mi != null && Number.isFinite(Number(airport.visibility_mi))
|
|
||||||
? Number(airport.visibility_mi)
|
|
||||||
: null;
|
|
||||||
const decodedVisibility = visibility != null ? `${visibility.toFixed(1)}mi` : decodeRawMetarVisibility(rawMetar);
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (temp != null) parts.push(formatTemperatureValue(temp, tempSymbol, { digits: 1 }));
|
|
||||||
if (windSpeed != null) {
|
|
||||||
parts.push(
|
|
||||||
windDir != null
|
|
||||||
? isEn
|
|
||||||
? `wind ${Math.round(windDir)}°/${Math.round(windSpeed)}kt`
|
|
||||||
: `风 ${Math.round(windDir)}°/${Math.round(windSpeed)}kt`
|
|
||||||
: isEn
|
|
||||||
? `wind ${Math.round(windSpeed)}kt`
|
|
||||||
: `风 ${Math.round(windSpeed)}kt`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (decodedCloud) parts.push(isEn ? `cloud ${decodedCloud}` : `云况 ${decodedCloud}`);
|
|
||||||
if (decodedWeather) parts.push(isEn ? `weather ${decodedWeather}` : `天气 ${decodedWeather}`);
|
|
||||||
if (decodedVisibility) parts.push(isEn ? `visibility ${decodedVisibility}` : `能见度 ${decodedVisibility}`);
|
|
||||||
if (!parts.length) return null;
|
|
||||||
const prefix = isEn ? "Latest airport METAR read" : "最新机场报文解读";
|
|
||||||
const head = [station, obsTime].filter(Boolean).join(" ");
|
|
||||||
return `${prefix}${head ? ` ${head}` : ""}:${parts.join(",")}。`;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
|
||||||
|
|
||||||
export function getLocalizedRowText(
|
|
||||||
row: ScanOpportunityRow,
|
|
||||||
locale: string,
|
|
||||||
zh?: string | null,
|
|
||||||
en?: string | null,
|
|
||||||
) {
|
|
||||||
return locale === "en-US" ? en || zh || null : zh || en || null;
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
|
||||||
|
|
||||||
export function normalizeLookupKey(value?: string | null) {
|
|
||||||
return String(value || "")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[\s_-]+/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDetailForRow(
|
|
||||||
row: Pick<ScanOpportunityRow, "city" | "city_display_name" | "display_name">,
|
|
||||||
cityDetailsByName?: Record<string, CityDetail>,
|
|
||||||
) {
|
|
||||||
if (!cityDetailsByName) return null;
|
|
||||||
const rowKeys = [row.city, row.city_display_name, row.display_name]
|
|
||||||
.map(normalizeLookupKey)
|
|
||||||
.filter(Boolean);
|
|
||||||
return (
|
|
||||||
Object.entries(cityDetailsByName).find(([name, detail]) => {
|
|
||||||
const detailKeys = [name, detail.name, detail.display_name]
|
|
||||||
.map(normalizeLookupKey)
|
|
||||||
.filter(Boolean);
|
|
||||||
return rowKeys.some((key) => detailKeys.includes(key));
|
|
||||||
})?.[1] || null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDetailViewDate(detail: CityDetail, row?: ScanOpportunityRow | null) {
|
|
||||||
if (!row) return detail.local_date;
|
|
||||||
const rawDate = row.selected_date || row.local_date || "";
|
|
||||||
const phase = String(row.window_phase || "").toLowerCase();
|
|
||||||
if ((phase === "tomorrow" || phase === "week_ahead") && rawDate) return rawDate;
|
|
||||||
if (!rawDate || rawDate === detail.local_date || row.local_date === detail.local_date) {
|
|
||||||
return detail.local_date;
|
|
||||||
}
|
|
||||||
return detail.local_date || rawDate;
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
|
||||||
import {
|
|
||||||
formatTemperatureValue,
|
|
||||||
normalizeTemperatureLabel,
|
|
||||||
} from "@/lib/temperature-utils";
|
|
||||||
import { getTargetRange } from "./opportunity-target";
|
|
||||||
|
|
||||||
export function formatPercent(value?: number | null, signed = false) {
|
|
||||||
if (value == null || Number.isNaN(Number(value))) return "--";
|
|
||||||
const numeric = Number(value);
|
|
||||||
return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeProbability(value?: number | null) {
|
|
||||||
if (value == null) return null;
|
|
||||||
const numeric = Number(value);
|
|
||||||
if (!Number.isFinite(numeric)) return null;
|
|
||||||
return Math.max(0, Math.min(1, numeric > 1 ? numeric / 100 : numeric));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatWindowMinutes(value: number | null | undefined, locale: string) {
|
|
||||||
if (value == null || !Number.isFinite(Number(value))) return "--";
|
|
||||||
const minutes = Math.max(0, Math.round(Number(value)));
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const remains = minutes % 60;
|
|
||||||
if (locale === "en-US") {
|
|
||||||
if (hours <= 0) return `${remains}m left`;
|
|
||||||
return `${hours}h ${remains}m left`;
|
|
||||||
}
|
|
||||||
if (hours <= 0) return `剩余 ${remains} 分钟`;
|
|
||||||
return `剩余 ${hours}h ${remains}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatMinuteSpan(value: number | null | undefined, locale: string) {
|
|
||||||
if (value == null || !Number.isFinite(Number(value))) return "--";
|
|
||||||
const minutes = Math.max(0, Math.round(Number(value)));
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const remains = minutes % 60;
|
|
||||||
if (locale === "en-US") {
|
|
||||||
if (hours <= 0) return `${remains}m`;
|
|
||||||
return `${hours}h ${remains}m`;
|
|
||||||
}
|
|
||||||
if (hours <= 0) return `${remains} 分钟`;
|
|
||||||
return `${hours}h ${remains}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatAction(
|
|
||||||
row: ScanOpportunityRow,
|
|
||||||
locale: string,
|
|
||||||
tempSymbol?: string | null,
|
|
||||||
) {
|
|
||||||
return formatTradeSide(row, locale, tempSymbol);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatQuoteCents(value?: number | null) {
|
|
||||||
if (value == null || Number.isNaN(Number(value))) return "--";
|
|
||||||
const cents = Number(value) * 100;
|
|
||||||
const text =
|
|
||||||
cents < 1 || cents >= 99 || Math.abs(cents - Math.round(cents)) >= 0.05
|
|
||||||
? cents.toFixed(1)
|
|
||||||
: Math.round(cents).toFixed(0);
|
|
||||||
return `${text.replace(/\.0$/, "")}¢`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatTradeSide(
|
|
||||||
row: ScanOpportunityRow,
|
|
||||||
locale: string,
|
|
||||||
tempSymbol?: string | null,
|
|
||||||
) {
|
|
||||||
const side = String(row.side || "").toLowerCase();
|
|
||||||
const isEn = locale === "en-US";
|
|
||||||
const { lower, upper } = getTargetRange(row);
|
|
||||||
const threshold =
|
|
||||||
lower != null && upper == null
|
|
||||||
? formatTemperatureValue(lower, tempSymbol)
|
|
||||||
: upper != null && lower == null
|
|
||||||
? formatTemperatureValue(upper, tempSymbol)
|
|
||||||
: null;
|
|
||||||
if (threshold && lower != null && upper == null) {
|
|
||||||
if (side === "yes") return isEn ? `High reaches ${threshold}` : `最高温达到 ${threshold}`;
|
|
||||||
if (side === "no") return isEn ? `High stays below ${threshold}` : `最高温低于 ${threshold}`;
|
|
||||||
}
|
|
||||||
if (threshold && upper != null && lower == null) {
|
|
||||||
if (side === "yes") return isEn ? `High stays at/below ${threshold}` : `最高温不高于 ${threshold}`;
|
|
||||||
if (side === "no") return isEn ? `High exceeds ${threshold}` : `最高温高于 ${threshold}`;
|
|
||||||
}
|
|
||||||
if (lower != null && upper != null && Math.abs(lower - upper) > 0.01) {
|
|
||||||
const range = `${formatTemperatureValue(lower, tempSymbol)} ~ ${formatTemperatureValue(upper, tempSymbol)}`;
|
|
||||||
if (side === "yes") return isEn ? `High lands in ${range}` : `最高温落在 ${range}`;
|
|
||||||
if (side === "no") return isEn ? `High avoids ${range}` : `最高温不在 ${range}`;
|
|
||||||
}
|
|
||||||
const bucket = formatThreshold(row, tempSymbol);
|
|
||||||
if (side === "yes") return isEn ? `High lands on ${bucket}` : `最高温落在 ${bucket} 桶`;
|
|
||||||
if (side === "no") return isEn ? `High avoids ${bucket}` : `最高温不落在 ${bucket} 桶`;
|
|
||||||
if (row.action) {
|
|
||||||
return normalizeTemperatureLabel(
|
|
||||||
String(row.action).replace(String(row.target_label || ""), ""),
|
|
||||||
tempSymbol,
|
|
||||||
)
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim()
|
|
||||||
.toUpperCase();
|
|
||||||
}
|
|
||||||
return locale === "en-US" ? "WATCH" : "观察";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatThreshold(row: ScanOpportunityRow, tempSymbol?: string | null) {
|
|
||||||
const targetLabel = normalizeTemperatureLabel(row.target_label, tempSymbol);
|
|
||||||
if (targetLabel) return targetLabel;
|
|
||||||
if (row.target_lower != null && row.target_upper != null) {
|
|
||||||
return `${formatTemperatureValue(Number(row.target_lower), tempSymbol)} ~ ${formatTemperatureValue(Number(row.target_upper), tempSymbol)}`;
|
|
||||||
}
|
|
||||||
if (row.target_threshold != null) {
|
|
||||||
return formatTemperatureValue(Number(row.target_threshold), tempSymbol);
|
|
||||||
}
|
|
||||||
if (row.target_value != null) {
|
|
||||||
return formatTemperatureValue(Number(row.target_value), tempSymbol);
|
|
||||||
}
|
|
||||||
return "--";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatTemperatureDelta(value: number, tempSymbol?: string | null) {
|
|
||||||
return formatTemperatureValue(Math.abs(value), tempSymbol, { digits: 1 });
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
|
||||||
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
|
||||||
import { getModelView, getProbabilityView } from "@/lib/model-utils";
|
|
||||||
import {
|
|
||||||
formatTemperatureValue,
|
|
||||||
normalizeTemperatureSymbol,
|
|
||||||
} from "@/lib/temperature-utils";
|
|
||||||
import { getWindowPhaseMeta, type PhaseMeta } from "./opportunity-window-phase";
|
|
||||||
import {
|
|
||||||
getDetailForRow,
|
|
||||||
getDetailViewDate,
|
|
||||||
} from "./opportunity-detail";
|
|
||||||
import {
|
|
||||||
formatThreshold,
|
|
||||||
normalizeProbability,
|
|
||||||
} from "./opportunity-format";
|
|
||||||
import { formatModelClusterRange } from "./opportunity-model-summary";
|
|
||||||
import {
|
|
||||||
extractNumbers,
|
|
||||||
getTargetRange,
|
|
||||||
normalizeBucketLabel,
|
|
||||||
} from "./opportunity-target";
|
|
||||||
|
|
||||||
export function getBucketText(bucket: { label?: string | null; bucket?: string | null; range?: string | null }) {
|
|
||||||
return [bucket.label, bucket.bucket, bucket.range]
|
|
||||||
.map((value) => String(value || "").trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bucketMatchesRow(
|
|
||||||
bucket: {
|
|
||||||
label?: string | null;
|
|
||||||
bucket?: string | null;
|
|
||||||
range?: string | null;
|
|
||||||
value?: number | string | null;
|
|
||||||
},
|
|
||||||
row: ScanOpportunityRow,
|
|
||||||
tempSymbol?: string | null,
|
|
||||||
) {
|
|
||||||
const targetLabel = normalizeBucketLabel(row.target_label, tempSymbol);
|
|
||||||
const bucketLabels = getBucketText(bucket).map((label) =>
|
|
||||||
normalizeBucketLabel(label, tempSymbol),
|
|
||||||
);
|
|
||||||
if (targetLabel && bucketLabels.some((label) => label === targetLabel)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawTargetLabel = String(row.target_label || "");
|
|
||||||
const targetNumbers = extractNumbers(rawTargetLabel);
|
|
||||||
const targetValue =
|
|
||||||
row.target_value ?? row.target_threshold ?? row.target_lower ?? row.target_upper ?? targetNumbers[0] ?? null;
|
|
||||||
if (targetValue == null || !Number.isFinite(Number(targetValue))) return false;
|
|
||||||
|
|
||||||
const bucketNumbers = [
|
|
||||||
...(bucket.value != null && Number.isFinite(Number(bucket.value))
|
|
||||||
? [Number(bucket.value)]
|
|
||||||
: []),
|
|
||||||
...getBucketText(bucket).flatMap(extractNumbers),
|
|
||||||
];
|
|
||||||
const matchesNumber = bucketNumbers.some(
|
|
||||||
(value) => Math.abs(Number(value) - Number(targetValue)) < 0.05,
|
|
||||||
);
|
|
||||||
if (!matchesNumber) return false;
|
|
||||||
|
|
||||||
const targetIsUpper =
|
|
||||||
/(\+|以上|or\s*above|above|greater|>=|≥)/i.test(rawTargetLabel) ||
|
|
||||||
(row.target_lower != null && row.target_upper == null);
|
|
||||||
const targetIsLower =
|
|
||||||
/(<=|≤|below|or\s*below|以下)/i.test(rawTargetLabel) ||
|
|
||||||
(row.target_upper != null && row.target_lower == null);
|
|
||||||
const bucketRaw = getBucketText(bucket).join(" ");
|
|
||||||
const bucketIsUpper = /(\+|以上|or\s*above|above|greater|>=|≥|inf|∞)/i.test(bucketRaw);
|
|
||||||
const bucketIsLower = /(<=|≤|below|or\s*below|以下|-inf|-∞)/i.test(bucketRaw);
|
|
||||||
|
|
||||||
if (targetIsUpper || bucketIsUpper) return targetIsUpper === bucketIsUpper;
|
|
||||||
if (targetIsLower || bucketIsLower) return targetIsLower === bucketIsLower;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDetailBucketEventProbability(
|
|
||||||
detail: CityDetail | null,
|
|
||||||
row: ScanOpportunityRow,
|
|
||||||
tempSymbol?: string | null,
|
|
||||||
) {
|
|
||||||
if (!detail) return null;
|
|
||||||
const view = getProbabilityView(detail, getDetailViewDate(detail, row));
|
|
||||||
const buckets = Array.isArray(view.probabilitiesAll)
|
|
||||||
? view.probabilitiesAll
|
|
||||||
: [];
|
|
||||||
if (!buckets.length) return null;
|
|
||||||
const matched = buckets.find((bucket) => bucketMatchesRow(bucket, row, tempSymbol));
|
|
||||||
return normalizeProbability(matched?.probability);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OpportunityGroup = {
|
|
||||||
key: string;
|
|
||||||
cityName: string;
|
|
||||||
date?: string | null;
|
|
||||||
tempSymbol?: string | null;
|
|
||||||
debLabel: string;
|
|
||||||
peakLabel: string;
|
|
||||||
peakProbability?: number | null;
|
|
||||||
phaseMeta: PhaseMeta;
|
|
||||||
localTime?: string | null;
|
|
||||||
remainingMinutes?: number | null;
|
|
||||||
rows: ScanOpportunityRow[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildOpportunityGroups(
|
|
||||||
rows: ScanOpportunityRow[],
|
|
||||||
locale: string,
|
|
||||||
cityDetailsByName?: Record<string, CityDetail>,
|
|
||||||
): OpportunityGroup[] {
|
|
||||||
const groups = new Map<string, OpportunityGroup>();
|
|
||||||
for (const row of rows) {
|
|
||||||
const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol);
|
|
||||||
const detail = getDetailForRow(row, cityDetailsByName);
|
|
||||||
const cityName = getLocalizedCityName(
|
|
||||||
row.city,
|
|
||||||
row.city_display_name || row.display_name || row.city,
|
|
||||||
locale,
|
|
||||||
);
|
|
||||||
const date = detail ? getDetailViewDate(detail, row) : row.selected_date || row.local_date || "";
|
|
||||||
const key = `${row.city || cityName}|${date}`;
|
|
||||||
const modelView = detail ? getModelView(detail, date) : null;
|
|
||||||
const debPrediction = modelView?.deb ?? row.deb_prediction ?? null;
|
|
||||||
const modelClusterLabel = formatModelClusterRange(
|
|
||||||
modelView?.models || row.model_cluster_sources,
|
|
||||||
tempSymbol,
|
|
||||||
);
|
|
||||||
const existing = groups.get(key);
|
|
||||||
if (!existing) {
|
|
||||||
groups.set(key, {
|
|
||||||
key,
|
|
||||||
cityName,
|
|
||||||
date,
|
|
||||||
tempSymbol,
|
|
||||||
debLabel:
|
|
||||||
debPrediction != null
|
|
||||||
? formatTemperatureValue(Number(debPrediction), tempSymbol, { digits: 1 })
|
|
||||||
: "--",
|
|
||||||
peakLabel: modelClusterLabel,
|
|
||||||
peakProbability: null,
|
|
||||||
phaseMeta: getWindowPhaseMeta(row, locale),
|
|
||||||
localTime: row.local_time,
|
|
||||||
remainingMinutes: row.remaining_window_minutes,
|
|
||||||
rows: [row],
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
existing.rows.push(row);
|
|
||||||
if (existing.peakLabel === "--" && modelClusterLabel !== "--") {
|
|
||||||
existing.peakLabel = modelClusterLabel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(groups.values()).map((group) => ({
|
|
||||||
...group,
|
|
||||||
rows: [...group.rows].sort(
|
|
||||||
(a, b) =>
|
|
||||||
Number(b.edge_percent ?? -Infinity) - Number(a.edge_percent ?? -Infinity) ||
|
|
||||||
Number(b.final_score ?? -Infinity) - Number(a.final_score ?? -Infinity),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBucketDisplayLabel(
|
|
||||||
row: ScanOpportunityRow,
|
|
||||||
locale: string,
|
|
||||||
tempSymbol?: string | null,
|
|
||||||
) {
|
|
||||||
const isEn = locale === "en-US";
|
|
||||||
const { lower, upper } = getTargetRange(row);
|
|
||||||
if (lower != null && upper == null) {
|
|
||||||
const value = formatTemperatureValue(lower, tempSymbol);
|
|
||||||
return isEn ? `${value} or higher` : `${value} 以上`;
|
|
||||||
}
|
|
||||||
if (upper != null && lower == null) {
|
|
||||||
const value = formatTemperatureValue(upper, tempSymbol);
|
|
||||||
return isEn ? `${value} or lower` : `${value} 以下`;
|
|
||||||
}
|
|
||||||
if (lower != null && upper != null && Math.abs(lower - upper) > 0.01) {
|
|
||||||
return `${formatTemperatureValue(lower, tempSymbol)} ~ ${formatTemperatureValue(upper, tempSymbol)}`;
|
|
||||||
}
|
|
||||||
return formatThreshold(row, tempSymbol);
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
|
||||||
import { formatTemperatureValue } from "@/lib/temperature-utils";
|
|
||||||
|
|
||||||
export function formatModelSources(row: ScanOpportunityRow, tempSymbol?: string | null) {
|
|
||||||
const sources = row.model_cluster_sources || {};
|
|
||||||
return Object.entries(sources)
|
|
||||||
.filter(([, value]) => value != null && Number.isFinite(Number(value)))
|
|
||||||
.sort(([left], [right]) => left.localeCompare(right))
|
|
||||||
.map(([name, value]) => ({
|
|
||||||
name,
|
|
||||||
value: formatTemperatureValue(Number(value), tempSymbol, { digits: 1 }),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatModelClusterRange(
|
|
||||||
sources?: Record<string, number | null> | null,
|
|
||||||
tempSymbol?: string | null,
|
|
||||||
) {
|
|
||||||
const values = Object.values(sources || {})
|
|
||||||
.map((value) => Number(value))
|
|
||||||
.filter((value) => Number.isFinite(value));
|
|
||||||
if (!values.length) return "--";
|
|
||||||
const low = Math.min(...values);
|
|
||||||
const high = Math.max(...values);
|
|
||||||
if (Math.abs(low - high) < 0.05) {
|
|
||||||
return formatTemperatureValue(low, tempSymbol, { digits: 1 });
|
|
||||||
}
|
|
||||||
return `${formatTemperatureValue(low, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(high, tempSymbol, { digits: 1 })}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getModelSourceSummary(
|
|
||||||
row: ScanOpportunityRow,
|
|
||||||
locale: string,
|
|
||||||
tempSymbol?: string | null,
|
|
||||||
) {
|
|
||||||
const sources = formatModelSources(row, tempSymbol);
|
|
||||||
if (!sources.length) {
|
|
||||||
return locale === "en-US"
|
|
||||||
? "model cluster pending"
|
|
||||||
: "模型集群暂未回传";
|
|
||||||
}
|
|
||||||
const shown = sources.map((item) => `${item.name} ${item.value}`).join(" / ");
|
|
||||||
return locale === "en-US"
|
|
||||||
? `all models: ${shown}`
|
|
||||||
: `全部模型:${shown}`;
|
|
||||||
}
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
|
||||||
import {
|
|
||||||
formatTemperatureValue,
|
|
||||||
normalizeTemperatureSymbol,
|
|
||||||
} from "@/lib/temperature-utils";
|
|
||||||
import {
|
|
||||||
formatAirportReportRead,
|
|
||||||
formatAirportWeatherRead,
|
|
||||||
} from "./opportunity-airport-read";
|
|
||||||
import { formatMinuteSpan } from "./opportunity-format";
|
|
||||||
import { getTargetRange } from "./opportunity-target";
|
|
||||||
|
|
||||||
export type ObservationPoint = { time?: string; temp?: number | null };
|
|
||||||
|
|
||||||
export function normalizeObservationPoints(points?: ObservationPoint[] | null) {
|
|
||||||
if (!Array.isArray(points)) return [];
|
|
||||||
return points
|
|
||||||
.map((point) => ({
|
|
||||||
time: String(point?.time || "").trim(),
|
|
||||||
temp:
|
|
||||||
point?.temp != null && Number.isFinite(Number(point.temp))
|
|
||||||
? Number(point.temp)
|
|
||||||
: null,
|
|
||||||
}))
|
|
||||||
.filter((point): point is { time: string; temp: number } =>
|
|
||||||
Boolean(point.time && point.temp != null),
|
|
||||||
)
|
|
||||||
.sort((a, b) => getObservationSortMinutes(a.time) - getObservationSortMinutes(b.time));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getObservationSortMinutes(time: string) {
|
|
||||||
const parsed = Date.parse(time);
|
|
||||||
if (Number.isFinite(parsed)) {
|
|
||||||
const date = new Date(parsed);
|
|
||||||
return date.getUTCHours() * 60 + date.getUTCMinutes();
|
|
||||||
}
|
|
||||||
const match = time.match(/(\d{1,2}):(\d{2})/);
|
|
||||||
if (!match) return Number.MAX_SAFE_INTEGER;
|
|
||||||
return Number(match[1]) * 60 + Number(match[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatPeakWindowTiming(row: ScanOpportunityRow, locale: string) {
|
|
||||||
const isEn = locale === "en-US";
|
|
||||||
const phase = String(row.window_phase || "").toLowerCase();
|
|
||||||
const label = String(row.peak_window_label || "").trim();
|
|
||||||
const untilStart =
|
|
||||||
row.minutes_until_peak_start != null && Number.isFinite(Number(row.minutes_until_peak_start))
|
|
||||||
? Number(row.minutes_until_peak_start)
|
|
||||||
: null;
|
|
||||||
const untilEnd =
|
|
||||||
row.minutes_until_peak_end != null && Number.isFinite(Number(row.minutes_until_peak_end))
|
|
||||||
? Number(row.minutes_until_peak_end)
|
|
||||||
: null;
|
|
||||||
const windowText = label ? `${isEn ? "peak window" : "峰值窗口"} ${label}` : isEn ? "peak window" : "峰值窗口";
|
|
||||||
if (phase === "active_peak" || (untilStart != null && untilStart <= 0 && untilEnd != null && untilEnd > 0)) {
|
|
||||||
return isEn ? `Currently inside the ${windowText}.` : `当前已进入${windowText}。`;
|
|
||||||
}
|
|
||||||
if (phase === "post_peak" || (untilEnd != null && untilEnd <= 0)) {
|
|
||||||
return isEn ? `The ${windowText} has passed.` : `${windowText}已结束。`;
|
|
||||||
}
|
|
||||||
if (untilStart != null && untilStart > 0) {
|
|
||||||
return isEn
|
|
||||||
? `${windowText} starts in ${formatMinuteSpan(untilStart, locale)}.`
|
|
||||||
: `${windowText}尚未开始,约 ${formatMinuteSpan(untilStart, locale)} 后进入。`;
|
|
||||||
}
|
|
||||||
if (phase === "early_today" || phase === "setup_today") {
|
|
||||||
return isEn ? `Before the ${windowText}; latest METAR is not final peak evidence yet.` : `尚处峰值前,最新 METAR 还不能当作最终峰值证据。`;
|
|
||||||
}
|
|
||||||
return label ? (isEn ? `Reference ${windowText}.` : `参考${windowText}。`) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function firstNonEmptyPoints(...groups: Array<ReturnType<typeof normalizeObservationPoints>>) {
|
|
||||||
return groups.find((group) => group.length > 0) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMetarObservationContext(
|
|
||||||
row: ScanOpportunityRow,
|
|
||||||
detail: CityDetail | null,
|
|
||||||
) {
|
|
||||||
const context = row.metar_context || {};
|
|
||||||
const metarToday = firstNonEmptyPoints(
|
|
||||||
normalizeObservationPoints(context.today_obs),
|
|
||||||
normalizeObservationPoints(row.metar_today_obs),
|
|
||||||
);
|
|
||||||
const detailMetarToday = normalizeObservationPoints(detail?.metar_today_obs);
|
|
||||||
const metarRecent = firstNonEmptyPoints(
|
|
||||||
normalizeObservationPoints(context.recent_obs),
|
|
||||||
normalizeObservationPoints(row.metar_recent_obs),
|
|
||||||
);
|
|
||||||
const detailMetarRecent = normalizeObservationPoints(detail?.metar_recent_obs);
|
|
||||||
const settlementToday = firstNonEmptyPoints(
|
|
||||||
normalizeObservationPoints(context.settlement_today_obs),
|
|
||||||
normalizeObservationPoints(row.settlement_today_obs),
|
|
||||||
);
|
|
||||||
const detailSettlementToday = normalizeObservationPoints(detail?.settlement_today_obs);
|
|
||||||
|
|
||||||
const primaryPoints =
|
|
||||||
metarToday.length
|
|
||||||
? metarToday
|
|
||||||
: detailMetarToday.length
|
|
||||||
? detailMetarToday
|
|
||||||
: metarRecent.length
|
|
||||||
? metarRecent
|
|
||||||
: detailMetarRecent.length
|
|
||||||
? detailMetarRecent
|
|
||||||
: settlementToday.length
|
|
||||||
? settlementToday
|
|
||||||
: detailSettlementToday;
|
|
||||||
const trendPoints =
|
|
||||||
(metarRecent.length
|
|
||||||
? metarRecent
|
|
||||||
: detailMetarRecent.length
|
|
||||||
? detailMetarRecent
|
|
||||||
: primaryPoints.slice(-4));
|
|
||||||
const explicitLast = context.last_temp ?? row.metar_status?.last_temp ?? detail?.metar_status?.last_temp;
|
|
||||||
const lastPoint = primaryPoints[primaryPoints.length - 1] || null;
|
|
||||||
const maxPoint = primaryPoints.reduce<{ time: string; temp: number } | null>(
|
|
||||||
(best, point) => (!best || point.temp >= best.temp ? point : best),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const trendFirst = trendPoints[0] || null;
|
|
||||||
const trendLast = trendPoints[trendPoints.length - 1] || null;
|
|
||||||
const trendDelta =
|
|
||||||
context.trend_delta != null && Number.isFinite(Number(context.trend_delta))
|
|
||||||
? Number(context.trend_delta)
|
|
||||||
: trendFirst && trendLast && trendPoints.length >= 2
|
|
||||||
? trendLast.temp - trendFirst.temp
|
|
||||||
: null;
|
|
||||||
const lastTemp =
|
|
||||||
explicitLast != null && Number.isFinite(Number(explicitLast))
|
|
||||||
? Number(explicitLast)
|
|
||||||
: lastPoint?.temp ?? null;
|
|
||||||
const maxTemp =
|
|
||||||
context.max_temp != null && Number.isFinite(Number(context.max_temp))
|
|
||||||
? Number(context.max_temp)
|
|
||||||
: maxPoint?.temp ?? null;
|
|
||||||
const stale =
|
|
||||||
context.stale_for_today === true ||
|
|
||||||
row.metar_status?.stale_for_today === true ||
|
|
||||||
detail?.metar_status?.stale_for_today === true;
|
|
||||||
|
|
||||||
return {
|
|
||||||
points: primaryPoints,
|
|
||||||
lastTime: String(context.last_time || lastPoint?.time || ""),
|
|
||||||
lastTemp,
|
|
||||||
maxTime: String(context.max_time || maxPoint?.time || ""),
|
|
||||||
maxTemp,
|
|
||||||
trendDelta,
|
|
||||||
stale,
|
|
||||||
station: context.station || detail?.risk?.icao || detail?.airport_current?.station_code || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMetarGate(
|
|
||||||
row: ScanOpportunityRow,
|
|
||||||
detail: CityDetail | null,
|
|
||||||
locale: string,
|
|
||||||
tempSymbol?: string | null,
|
|
||||||
) {
|
|
||||||
const isEn = locale === "en-US";
|
|
||||||
const side = String(row.side || "").toLowerCase();
|
|
||||||
const selectedDate = String(row.selected_date || "");
|
|
||||||
const localDate = String(row.local_date || detail?.local_date || "");
|
|
||||||
const futureContract = Boolean(selectedDate && localDate && selectedDate > localDate);
|
|
||||||
if (futureContract) return null;
|
|
||||||
|
|
||||||
const obs = getMetarObservationContext(row, detail);
|
|
||||||
const evidence: string[] = [];
|
|
||||||
const unit = normalizeTemperatureSymbol(tempSymbol);
|
|
||||||
const peakTiming = formatPeakWindowTiming(row, locale);
|
|
||||||
const airportReport = formatAirportReportRead(row, detail, locale, unit);
|
|
||||||
const airportWeatherRead = formatAirportWeatherRead(row, detail, locale);
|
|
||||||
if (peakTiming) evidence.push(peakTiming);
|
|
||||||
if (airportReport) evidence.push(airportReport);
|
|
||||||
if (airportWeatherRead) evidence.push(airportWeatherRead);
|
|
||||||
if (obs.lastTemp != null) {
|
|
||||||
evidence.push(
|
|
||||||
`${isEn ? "METAR latest" : "METAR 最新"} ${formatTemperatureValue(obs.lastTemp, unit, { digits: 1 })}${obs.lastTime ? ` @ ${obs.lastTime}` : ""}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (obs.maxTemp != null) {
|
|
||||||
evidence.push(
|
|
||||||
`${isEn ? "METAR max" : "METAR 最高"} ${formatTemperatureValue(obs.maxTemp, unit, { digits: 1 })}${obs.maxTime ? ` @ ${obs.maxTime}` : ""}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (obs.trendDelta != null) {
|
|
||||||
evidence.push(
|
|
||||||
`${isEn ? "Recent METAR delta" : "近端 METAR 变化"} ${formatTemperatureValue(obs.trendDelta, unit, { digits: 1 })}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (obs.station) evidence.push(`${isEn ? "Station" : "站点"} ${obs.station}`);
|
|
||||||
|
|
||||||
if (obs.stale || !obs.points.length || obs.maxTemp == null) {
|
|
||||||
return {
|
|
||||||
decision: "downgrade" as const,
|
|
||||||
reason: isEn
|
|
||||||
? "AI has no same-day METAR confirmation yet, so this bucket remains forecast-only."
|
|
||||||
: "AI 还没有拿到同日 METAR 确认,该桶暂时只能作为预测映射。",
|
|
||||||
evidence,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const { lower, upper } = getTargetRange(row);
|
|
||||||
if (lower == null && upper == null) {
|
|
||||||
return {
|
|
||||||
decision: "watchlist" as const,
|
|
||||||
reason: isEn
|
|
||||||
? "AI has METAR data, but the contract threshold cannot be mapped cleanly to a bucket."
|
|
||||||
: "AI 已读取 METAR,但该合约阈值无法稳定映射到温度桶。",
|
|
||||||
evidence,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const epsilon = String(unit).toUpperCase().includes("F") ? 0.7 : 0.4;
|
|
||||||
const trendDelta = obs.trendDelta;
|
|
||||||
const isNotRising = trendDelta != null && trendDelta <= epsilon;
|
|
||||||
const isFalling = trendDelta != null && trendDelta <= -epsilon;
|
|
||||||
const phase = String(row.window_phase || "").toLowerCase();
|
|
||||||
const remaining =
|
|
||||||
row.remaining_window_minutes != null && Number.isFinite(Number(row.remaining_window_minutes))
|
|
||||||
? Number(row.remaining_window_minutes)
|
|
||||||
: null;
|
|
||||||
const minutesUntilPeakStart =
|
|
||||||
row.minutes_until_peak_start != null && Number.isFinite(Number(row.minutes_until_peak_start))
|
|
||||||
? Number(row.minutes_until_peak_start)
|
|
||||||
: null;
|
|
||||||
const lateWindow =
|
|
||||||
phase === "active_peak" ||
|
|
||||||
phase === "post_peak" ||
|
|
||||||
(remaining != null && remaining <= 180);
|
|
||||||
const beforePeak =
|
|
||||||
phase === "early_today" ||
|
|
||||||
phase === "setup_today" ||
|
|
||||||
phase === "tomorrow" ||
|
|
||||||
phase === "week_ahead" ||
|
|
||||||
(minutesUntilPeakStart != null && minutesUntilPeakStart > 0);
|
|
||||||
const aboveUpper = upper != null && obs.maxTemp > upper + epsilon;
|
|
||||||
const belowLower = lower != null && obs.maxTemp < lower - epsilon;
|
|
||||||
const insideBucket =
|
|
||||||
(lower == null || obs.maxTemp >= lower - epsilon) &&
|
|
||||||
(upper == null || obs.maxTemp <= upper + epsilon);
|
|
||||||
|
|
||||||
if (side === "no") {
|
|
||||||
if (aboveUpper) {
|
|
||||||
return {
|
|
||||||
decision: "approve" as const,
|
|
||||||
reason: isEn
|
|
||||||
? "METAR max has already moved above this bucket, so AI marks the NO bucket as observation-supported."
|
|
||||||
: "METAR 实测最高已越过目标桶上沿,AI 判断 NO 桶有实测支撑。",
|
|
||||||
evidence,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (belowLower && (lateWindow || isFalling || isNotRising)) {
|
|
||||||
if (beforePeak && !lateWindow) {
|
|
||||||
return {
|
|
||||||
decision: "watchlist" as const,
|
|
||||||
reason: isEn
|
|
||||||
? "The peak window has not arrived, so a still-low METAR path cannot confirm this NO bucket yet; AI keeps it on watch."
|
|
||||||
: "峰值窗口尚未到来,METAR 暂未触达不能直接确认 NO 桶,AI 先列观察。",
|
|
||||||
evidence,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
decision: "approve" as const,
|
|
||||||
reason: isEn
|
|
||||||
? "METAR max remains below this bucket and recent observations are not strengthening, so AI favors the NO bucket."
|
|
||||||
: "METAR 最高仍低于目标桶且近期走势不强,AI 倾向 NO 桶。",
|
|
||||||
evidence,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (insideBucket && lateWindow && isNotRising) {
|
|
||||||
return {
|
|
||||||
decision: "downgrade" as const,
|
|
||||||
reason: isEn
|
|
||||||
? "METAR max is still close to this bucket, so AI cannot treat the NO bucket as confirmed."
|
|
||||||
: "METAR 最高仍贴近目标桶,AI 不能把 NO 桶视为已确认。",
|
|
||||||
evidence,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (side === "yes") {
|
|
||||||
if (aboveUpper) {
|
|
||||||
return {
|
|
||||||
decision: "veto" as const,
|
|
||||||
reason: isEn
|
|
||||||
? "METAR max has already exceeded the bucket, so AI marks this YES bucket as outside the observed path."
|
|
||||||
: "METAR 实测最高已越过目标桶上沿,AI 判断该 YES 桶已偏离实测路径。",
|
|
||||||
evidence,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (belowLower && (lateWindow || isFalling || isNotRising)) {
|
|
||||||
if (beforePeak && !lateWindow) {
|
|
||||||
return {
|
|
||||||
decision: "watchlist" as const,
|
|
||||||
reason: isEn
|
|
||||||
? "The peak window has not arrived, so METAR not reaching the bucket only means this bucket still needs peak-window confirmation."
|
|
||||||
: "峰值窗口尚未到来,METAR 未触达目标桶只能说明仍需等待峰值验证,AI 暂列观察。",
|
|
||||||
evidence,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
decision: "downgrade" as const,
|
|
||||||
reason: isEn
|
|
||||||
? "METAR max has not reached the bucket and recent observations are weak, so AI downgrades the YES bucket."
|
|
||||||
: "METAR 最高仍未触达目标桶且走势不强,AI 将 YES 桶降级观察。",
|
|
||||||
evidence,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (insideBucket) {
|
|
||||||
return {
|
|
||||||
decision: "approve" as const,
|
|
||||||
reason: isEn
|
|
||||||
? "METAR max is inside the target bucket, so AI sees observation support while monitoring an overshoot."
|
|
||||||
: "METAR 实测最高已落入目标桶,AI 认为 YES 桶有实测依据,但仍需防止继续升穿上沿。",
|
|
||||||
evidence,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
decision: "watchlist" as const,
|
|
||||||
reason: isEn
|
|
||||||
? "METAR does not give a clean final confirmation yet, so AI keeps this as watchlist."
|
|
||||||
: "METAR 还没有给出干净的最终确认,AI 暂列观察。",
|
|
||||||
evidence,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
|
||||||
import { normalizeTemperatureLabel } from "@/lib/temperature-utils";
|
|
||||||
|
|
||||||
export function normalizeBucketLabel(value?: string | null, tempSymbol?: string | null) {
|
|
||||||
return normalizeTemperatureLabel(value, tempSymbol)
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\s+/g, "")
|
|
||||||
.replace(/℃/g, "°c");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractNumbers(value?: string | null) {
|
|
||||||
return Array.from(String(value || "").matchAll(/-?\d+(?:\.\d+)?/g)).map((match) =>
|
|
||||||
Number(match[0]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTargetRange(row: ScanOpportunityRow) {
|
|
||||||
const lower =
|
|
||||||
row.target_lower != null && Number.isFinite(Number(row.target_lower))
|
|
||||||
? Number(row.target_lower)
|
|
||||||
: null;
|
|
||||||
const upper =
|
|
||||||
row.target_upper != null && Number.isFinite(Number(row.target_upper))
|
|
||||||
? Number(row.target_upper)
|
|
||||||
: null;
|
|
||||||
if (lower != null || upper != null) return { lower, upper };
|
|
||||||
|
|
||||||
const rawLabel = String(row.target_label || row.action || "");
|
|
||||||
const numbers = extractNumbers(rawLabel);
|
|
||||||
if (numbers.length >= 2) {
|
|
||||||
return { lower: Math.min(numbers[0], numbers[1]), upper: Math.max(numbers[0], numbers[1]) };
|
|
||||||
}
|
|
||||||
const value =
|
|
||||||
row.target_threshold ??
|
|
||||||
row.target_value ??
|
|
||||||
(numbers.length ? numbers[0] : null);
|
|
||||||
if (value == null || !Number.isFinite(Number(value))) {
|
|
||||||
return { lower: null, upper: null };
|
|
||||||
}
|
|
||||||
const numeric = Number(value);
|
|
||||||
if (/(\+|above|higher|or\s+higher|>=|≥|以上)/i.test(rawLabel)) {
|
|
||||||
return { lower: numeric, upper: null };
|
|
||||||
}
|
|
||||||
if (/(below|or\s+below|<=|≤|以下)/i.test(rawLabel)) {
|
|
||||||
return { lower: null, upper: numeric };
|
|
||||||
}
|
|
||||||
return { lower: numeric, upper: numeric };
|
|
||||||
}
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
|
||||||
import { getTodayPaceView } from "@/lib/pace-utils";
|
|
||||||
import {
|
|
||||||
formatTemperatureValue,
|
|
||||||
normalizeTemperatureSymbol,
|
|
||||||
} from "@/lib/temperature-utils";
|
|
||||||
import {
|
|
||||||
formatAirportReportRead,
|
|
||||||
formatAirportWeatherRead,
|
|
||||||
} from "./opportunity-airport-read";
|
|
||||||
import { getLocalizedRowText } from "./opportunity-copy";
|
|
||||||
import { formatPeakWindowTiming } from "./opportunity-observation";
|
|
||||||
import { getModelSourceSummary } from "./opportunity-model-summary";
|
|
||||||
import { getTargetRange } from "./opportunity-target";
|
|
||||||
import type { OpportunityGroup } from "./opportunity-groups";
|
|
||||||
import type { V4CityForecast } from "./opportunity-v4-types";
|
|
||||||
|
|
||||||
export function getPaceDeviationRead(
|
|
||||||
detail: CityDetail | null,
|
|
||||||
locale: string,
|
|
||||||
tempSymbol?: string | null,
|
|
||||||
) {
|
|
||||||
if (!detail) return null;
|
|
||||||
const paceView = getTodayPaceView(detail, locale === "en-US" ? "en-US" : "zh-CN");
|
|
||||||
if (!paceView) return null;
|
|
||||||
const unit = normalizeTemperatureSymbol(tempSymbol || detail.temp_symbol);
|
|
||||||
const observed = formatTemperatureValue(paceView.observedNow, unit, { digits: 1 });
|
|
||||||
const expected = formatTemperatureValue(paceView.expectedNow, unit, { digits: 1 });
|
|
||||||
const delta = `${paceView.delta > 0 ? "+" : ""}${paceView.delta.toFixed(1)}${unit}`;
|
|
||||||
const isEn = locale === "en-US";
|
|
||||||
const toneText =
|
|
||||||
paceView.biasTone === "warm"
|
|
||||||
? isEn
|
|
||||||
? "running hotter"
|
|
||||||
: "偏热"
|
|
||||||
: paceView.biasTone === "cold"
|
|
||||||
? isEn
|
|
||||||
? "running cooler"
|
|
||||||
: "偏冷"
|
|
||||||
: isEn
|
|
||||||
? "tracking"
|
|
||||||
: "基本跟踪";
|
|
||||||
return {
|
|
||||||
adjustedHigh: paceView.paceAdjustedHigh,
|
|
||||||
delta: paceView.delta,
|
|
||||||
label: paceView.badge,
|
|
||||||
read: isEn
|
|
||||||
? `Observed path vs DEB curve: ${observed} now vs ${expected} expected, ${delta} (${toneText}).`
|
|
||||||
: `实测路径对比 DEB 曲线:当前 ${observed},同刻预期 ${expected},偏差 ${delta}(${toneText})。`,
|
|
||||||
tone: paceView.biasTone,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPaceSignalLabel(forecast: V4CityForecast, locale: string, tempSymbol?: string | null) {
|
|
||||||
const isEn = locale === "en-US";
|
|
||||||
if (forecast.paceDelta == null || !Number.isFinite(Number(forecast.paceDelta))) {
|
|
||||||
return isEn ? "Path pending" : "路径待确认";
|
|
||||||
}
|
|
||||||
const unit = normalizeTemperatureSymbol(tempSymbol);
|
|
||||||
const delta = `${forecast.paceDelta > 0 ? "+" : ""}${Number(forecast.paceDelta).toFixed(1)}${unit}`;
|
|
||||||
if (forecast.paceTone === "warm") return isEn ? `Hot path ${delta}` : `实测偏热 ${delta}`;
|
|
||||||
if (forecast.paceTone === "cold") return isEn ? `Cool path ${delta}` : `实测偏冷 ${delta}`;
|
|
||||||
return isEn ? `On path ${delta}` : `路径跟踪 ${delta}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPaceDecisionTail(forecast: V4CityForecast, locale: string, tempSymbol?: string | null) {
|
|
||||||
if (forecast.paceDelta == null || !Number.isFinite(Number(forecast.paceDelta))) return "";
|
|
||||||
const isEn = locale === "en-US";
|
|
||||||
const unit = normalizeTemperatureSymbol(tempSymbol);
|
|
||||||
const delta = `${forecast.paceDelta > 0 ? "+" : ""}${Number(forecast.paceDelta).toFixed(1)}${unit}`;
|
|
||||||
if (forecast.paceTone === "warm") {
|
|
||||||
return isEn
|
|
||||||
? ` Observations are running ${delta} above the DEB path, so upside boundaries need extra caution.`
|
|
||||||
: ` 实测比 DEB 路径偏高 ${delta},上方阈值要额外谨慎。`;
|
|
||||||
}
|
|
||||||
if (forecast.paceTone === "cold") {
|
|
||||||
return isEn
|
|
||||||
? ` Observations are running ${delta} below the DEB path, which weakens upside breakout odds.`
|
|
||||||
: ` 实测比 DEB 路径偏低 ${delta},上破概率需要下修。`;
|
|
||||||
}
|
|
||||||
return isEn
|
|
||||||
? " Observations are still tracking the DEB path."
|
|
||||||
: " 实测仍基本跟踪 DEB 路径。";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function median(values: number[]) {
|
|
||||||
if (!values.length) return null;
|
|
||||||
const sorted = [...values].sort((a, b) => a - b);
|
|
||||||
const mid = Math.floor(sorted.length / 2);
|
|
||||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getV4CityForecast(
|
|
||||||
row: ScanOpportunityRow,
|
|
||||||
group: OpportunityGroup,
|
|
||||||
detail: CityDetail | null,
|
|
||||||
locale: string,
|
|
||||||
tempSymbol?: string | null,
|
|
||||||
): V4CityForecast {
|
|
||||||
const isEn = locale === "en-US";
|
|
||||||
const aiPredicted =
|
|
||||||
row.ai_predicted_max != null && Number.isFinite(Number(row.ai_predicted_max))
|
|
||||||
? Number(row.ai_predicted_max)
|
|
||||||
: null;
|
|
||||||
const aiLow =
|
|
||||||
row.ai_predicted_low != null && Number.isFinite(Number(row.ai_predicted_low))
|
|
||||||
? Number(row.ai_predicted_low)
|
|
||||||
: null;
|
|
||||||
const aiHigh =
|
|
||||||
row.ai_predicted_high != null && Number.isFinite(Number(row.ai_predicted_high))
|
|
||||||
? Number(row.ai_predicted_high)
|
|
||||||
: null;
|
|
||||||
const modelValues = Object.values(row.model_cluster_sources || {})
|
|
||||||
.map((value) => Number(value))
|
|
||||||
.filter((value) => Number.isFinite(value));
|
|
||||||
const fallbackPredicted =
|
|
||||||
aiPredicted ??
|
|
||||||
(row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction))
|
|
||||||
? Number(row.deb_prediction)
|
|
||||||
: median(modelValues));
|
|
||||||
const fallbackLow = aiLow ?? (modelValues.length ? Math.min(...modelValues) : fallbackPredicted);
|
|
||||||
const fallbackHigh = aiHigh ?? (modelValues.length ? Math.max(...modelValues) : fallbackPredicted);
|
|
||||||
const peakWindow =
|
|
||||||
getLocalizedRowText(row, locale, row.ai_peak_window_zh, row.ai_peak_window_en) ||
|
|
||||||
formatPeakWindowTiming(row, locale);
|
|
||||||
const airportRead =
|
|
||||||
getLocalizedRowText(
|
|
||||||
row,
|
|
||||||
locale,
|
|
||||||
row.ai_airport_metar_read_zh,
|
|
||||||
row.ai_airport_metar_read_en,
|
|
||||||
) || formatAirportReportRead(row, detail, locale, tempSymbol);
|
|
||||||
const weatherRead = formatAirportWeatherRead(row, detail, locale);
|
|
||||||
const paceRead = getPaceDeviationRead(detail, locale, tempSymbol);
|
|
||||||
const modelNote =
|
|
||||||
row.ai_city_model_cluster_note ||
|
|
||||||
row.ai_model_cluster_note ||
|
|
||||||
getModelSourceSummary(row, locale, tempSymbol);
|
|
||||||
const reason =
|
|
||||||
getLocalizedRowText(row, locale, row.ai_forecast_reason_zh, row.ai_forecast_reason_en) ||
|
|
||||||
getLocalizedRowText(row, locale, row.ai_city_thesis_zh, row.ai_city_thesis_en) ||
|
|
||||||
(fallbackPredicted != null
|
|
||||||
? isEn
|
|
||||||
? `${group.cityName} final high is centered near ${formatTemperatureValue(fallbackPredicted, tempSymbol, { digits: 1 })}; market temperature buckets are only mapped against that forecast range.`
|
|
||||||
: `${group.cityName} 最终最高温先以 ${formatTemperatureValue(fallbackPredicted, tempSymbol, { digits: 1 })} 附近为中枢,市场温度桶只用于对照 AI 预测区间。`
|
|
||||||
: null);
|
|
||||||
return {
|
|
||||||
predicted: fallbackPredicted,
|
|
||||||
low: fallbackLow,
|
|
||||||
high: fallbackHigh,
|
|
||||||
confidence: row.ai_forecast_confidence || row.ai_city_confidence,
|
|
||||||
peakWindow,
|
|
||||||
airportRead,
|
|
||||||
weatherRead,
|
|
||||||
paceRead: paceRead?.read || null,
|
|
||||||
paceTone: paceRead?.tone || null,
|
|
||||||
paceDelta: paceRead?.delta ?? null,
|
|
||||||
paceAdjustedHigh: paceRead?.adjustedHigh ?? null,
|
|
||||||
reason,
|
|
||||||
modelNote,
|
|
||||||
source: aiPredicted != null ? "ai" : "fallback",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getForecastRangeLabel(forecast: V4CityForecast, tempSymbol?: string | null) {
|
|
||||||
if (forecast.low == null && forecast.high == null) return "--";
|
|
||||||
if (forecast.low != null && forecast.high != null) {
|
|
||||||
if (Math.abs(forecast.low - forecast.high) < 0.05) {
|
|
||||||
return formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 });
|
|
||||||
}
|
|
||||||
return `${formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(forecast.high, tempSymbol, { digits: 1 })}`;
|
|
||||||
}
|
|
||||||
if (forecast.low != null) return `>= ${formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 })}`;
|
|
||||||
return `<= ${formatTemperatureValue(Number(forecast.high), tempSymbol, { digits: 1 })}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getForecastContractFit(
|
|
||||||
row: ScanOpportunityRow,
|
|
||||||
forecast: V4CityForecast,
|
|
||||||
locale: string,
|
|
||||||
tempSymbol?: string | null,
|
|
||||||
) {
|
|
||||||
const isEn = locale === "en-US";
|
|
||||||
const explicitMatch = String(row.ai_forecast_match || "").toLowerCase();
|
|
||||||
const explicitReason = getLocalizedRowText(
|
|
||||||
row,
|
|
||||||
locale,
|
|
||||||
row.ai_forecast_match_reason_zh,
|
|
||||||
row.ai_forecast_match_reason_en,
|
|
||||||
);
|
|
||||||
if (explicitMatch && explicitReason) {
|
|
||||||
return {
|
|
||||||
label:
|
|
||||||
explicitMatch === "core"
|
|
||||||
? isEn ? "Core bucket" : "核心桶"
|
|
||||||
: explicitMatch === "outside"
|
|
||||||
? isEn ? "Outside forecast" : "预测区间外"
|
|
||||||
: explicitMatch === "edge"
|
|
||||||
? isEn ? "Boundary bucket" : "边界桶"
|
|
||||||
: isEn ? "Watch" : "观察",
|
|
||||||
tone: explicitMatch === "core" ? "approve" : explicitMatch === "outside" ? "veto" : "watchlist",
|
|
||||||
reason: explicitReason,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const { lower, upper } = getTargetRange(row);
|
|
||||||
const predicted = forecast.predicted;
|
|
||||||
if (predicted == null || (lower == null && upper == null)) {
|
|
||||||
return {
|
|
||||||
label: isEn ? "Await forecast" : "等待预测",
|
|
||||||
tone: "watchlist",
|
|
||||||
reason: isEn ? "AI has no stable max-temperature center yet." : "AI 还没有稳定的最高温中枢。",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const unit = normalizeTemperatureSymbol(tempSymbol);
|
|
||||||
const tolerance = String(unit).toUpperCase().includes("F") ? 1.0 : 0.5;
|
|
||||||
const inside =
|
|
||||||
(lower == null || predicted >= lower - tolerance) &&
|
|
||||||
(upper == null || predicted <= upper + tolerance);
|
|
||||||
const rangeOverlaps =
|
|
||||||
forecast.low != null &&
|
|
||||||
forecast.high != null &&
|
|
||||||
(lower == null || forecast.high >= lower - tolerance) &&
|
|
||||||
(upper == null || forecast.low <= upper + tolerance);
|
|
||||||
if (inside) {
|
|
||||||
return {
|
|
||||||
label: isEn ? "Core bucket" : "核心桶",
|
|
||||||
tone: "approve",
|
|
||||||
reason: isEn
|
|
||||||
? `AI max-temperature center ${formatTemperatureValue(predicted, unit, { digits: 1 })} sits inside this bucket.`
|
|
||||||
: `AI 最高温中枢 ${formatTemperatureValue(predicted, unit, { digits: 1 })} 落在这个温度桶内。`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (rangeOverlaps) {
|
|
||||||
return {
|
|
||||||
label: isEn ? "Boundary bucket" : "边界桶",
|
|
||||||
tone: "watchlist",
|
|
||||||
reason: isEn
|
|
||||||
? `This bucket touches the AI interval ${getForecastRangeLabel(forecast, unit)}, but is not the center.`
|
|
||||||
: `该桶触及 AI 区间 ${getForecastRangeLabel(forecast, unit)},但不是预测中枢。`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
label: isEn ? "Outside forecast" : "预测区间外",
|
|
||||||
tone: "veto",
|
|
||||||
reason: isEn
|
|
||||||
? `This bucket is outside the AI max-temperature interval ${getForecastRangeLabel(forecast, unit)}.`
|
|
||||||
: `该桶位于 AI 最高温区间 ${getForecastRangeLabel(forecast, unit)} 之外。`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
export type V4TradeDecision = {
|
|
||||||
decision: "approve" | "downgrade" | "veto" | "watchlist";
|
|
||||||
label: string;
|
|
||||||
tone: "approve" | "downgrade" | "veto" | "watchlist";
|
|
||||||
reason: string;
|
|
||||||
metarSummary?: string | null;
|
|
||||||
airportReport?: string | null;
|
|
||||||
metarEvidence: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type V4CityForecast = {
|
|
||||||
predicted: number | null;
|
|
||||||
low: number | null;
|
|
||||||
high: number | null;
|
|
||||||
confidence?: string | null;
|
|
||||||
peakWindow?: string | null;
|
|
||||||
airportRead?: string | null;
|
|
||||||
weatherRead?: string | null;
|
|
||||||
paceRead?: string | null;
|
|
||||||
paceTone?: "warm" | "cold" | "neutral" | string | null;
|
|
||||||
paceDelta?: number | null;
|
|
||||||
paceAdjustedHigh?: number | null;
|
|
||||||
reason?: string | null;
|
|
||||||
modelNote?: string | null;
|
|
||||||
source: "ai" | "fallback";
|
|
||||||
};
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
|
||||||
|
|
||||||
export type PhaseMeta = {
|
|
||||||
label: string;
|
|
||||||
tone: "green" | "amber" | "blue" | "red";
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getWindowPhaseMeta(
|
|
||||||
row: Pick<ScanOpportunityRow, "window_phase" | "trend_alignment">,
|
|
||||||
locale: string,
|
|
||||||
): PhaseMeta {
|
|
||||||
const mode = String(row.window_phase || "").toLowerCase();
|
|
||||||
if (mode === "city_snapshot") {
|
|
||||||
return {
|
|
||||||
label: locale === "en-US" ? "City Snapshot" : "城市概况",
|
|
||||||
tone: "blue",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (mode === "active_peak") {
|
|
||||||
return {
|
|
||||||
label: locale === "en-US" ? "Peak Window" : "峰值窗口",
|
|
||||||
tone: "red",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (mode === "setup_today") {
|
|
||||||
return {
|
|
||||||
label: locale === "en-US" ? "Touch Play" : "触达博弈",
|
|
||||||
tone: "red",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (mode === "early_today") {
|
|
||||||
return {
|
|
||||||
label: locale === "en-US" ? "Early Today" : "日内早段",
|
|
||||||
tone: "blue",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (mode === "tomorrow" || mode === "week_ahead") {
|
|
||||||
return {
|
|
||||||
label: locale === "en-US" ? "Early" : "早期机会",
|
|
||||||
tone: "blue",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (mode === "post_peak") {
|
|
||||||
return {
|
|
||||||
label: locale === "en-US" ? "Post Peak" : "峰后确认",
|
|
||||||
tone: "amber",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (row.trend_alignment) {
|
|
||||||
return {
|
|
||||||
label: locale === "en-US" ? "Trend" : "趋势确认",
|
|
||||||
tone: "amber",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
label: locale === "en-US" ? "Tradable" : "可交易",
|
|
||||||
tone: "green",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,9 @@
|
|||||||
Consolidates 20 CSS Modules that are always co-imported into
|
Consolidates 20 CSS Modules that are always co-imported into
|
||||||
a single className, keeping ScanTerminalDashboard.tsx lean. */
|
a single className, keeping ScanTerminalDashboard.tsx lean. */
|
||||||
|
|
||||||
|
/* Barrel: pre-combined scan-terminal root class name. */
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import dashboardModalGuideStyles from "./DashboardModalGuide.module.css";
|
|
||||||
import dashboardShellStyles from "./DashboardShell.module.css";
|
|
||||||
import detailChromeStyles from "./DetailPanelChrome.module.css";
|
|
||||||
import detailContentStyles from "./DetailPanelContent.module.css";
|
|
||||||
import detailSectionsStyles from "./DetailPanelSections.module.css";
|
|
||||||
import modalChromeStyles from "./ModalChrome.module.css";
|
|
||||||
import scanTerminalStyles from "./ScanTerminal.module.css";
|
import scanTerminalStyles from "./ScanTerminal.module.css";
|
||||||
import scanTerminalBoardStyles from "./ScanTerminalBoard.module.css";
|
import scanTerminalBoardStyles from "./ScanTerminalBoard.module.css";
|
||||||
import scanTerminalCardStyles from "./ScanTerminalCard.module.css";
|
import scanTerminalCardStyles from "./ScanTerminalCard.module.css";
|
||||||
@@ -23,8 +18,6 @@ import scanTerminalContinentStyles from "./ScanTerminalContinent.module.css";
|
|||||||
import scanTerminalStateStyles from "./ScanTerminalState.module.css";
|
import scanTerminalStateStyles from "./ScanTerminalState.module.css";
|
||||||
|
|
||||||
export const scanRootClass = clsx(
|
export const scanRootClass = clsx(
|
||||||
dashboardShellStyles.root,
|
|
||||||
dashboardModalGuideStyles.root,
|
|
||||||
scanTerminalStyles.root,
|
scanTerminalStyles.root,
|
||||||
scanTerminalShellStyles.root,
|
scanTerminalShellStyles.root,
|
||||||
scanTerminalFiltersStyles.root,
|
scanTerminalFiltersStyles.root,
|
||||||
@@ -36,8 +29,4 @@ export const scanRootClass = clsx(
|
|||||||
scanTerminalCardStyles.root,
|
scanTerminalCardStyles.root,
|
||||||
scanTerminalContinentStyles.root,
|
scanTerminalContinentStyles.root,
|
||||||
scanTerminalMobileStyles.root,
|
scanTerminalMobileStyles.root,
|
||||||
detailChromeStyles.root,
|
|
||||||
detailContentStyles.root,
|
|
||||||
detailSectionsStyles.root,
|
|
||||||
modalChromeStyles.root,
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,567 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import clsx from "clsx";
|
|
||||||
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 { CityCardHeader } from "@/components/dashboard/scan-terminal/CityCardHeader";
|
|
||||||
import { MobileDecisionCard } from "@/components/dashboard/scan-terminal/MobileDecisionCard";
|
|
||||||
import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel";
|
|
||||||
import { WeatherDecisionBand } from "@/components/dashboard/scan-terminal/WeatherDecisionBand";
|
|
||||||
import {
|
|
||||||
buildWeatherDecisionView,
|
|
||||||
resolveExpectedHighCandidate,
|
|
||||||
} from "@/components/dashboard/scan-terminal/city-card-decision-utils";
|
|
||||||
import { buildCityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state";
|
|
||||||
import {
|
|
||||||
getAiReadCopy,
|
|
||||||
getCityLoadingCopy,
|
|
||||||
} from "@/components/dashboard/scan-terminal/decision-copy";
|
|
||||||
import { getPeakWindowLabel, normalizeCityKey } from "@/components/dashboard/scan-terminal/decision-utils";
|
|
||||||
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
|
|
||||||
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
|
|
||||||
import {
|
|
||||||
useAiCityForecast,
|
|
||||||
} from "@/components/dashboard/scan-terminal/use-ai-city-card-data";
|
|
||||||
import { getDisplayAirportPrimary } from "@/lib/airport-observation-display";
|
|
||||||
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
|
||||||
import { getModelView } from "@/lib/model-utils";
|
|
||||||
import { getTodayPaceView } from "@/lib/pace-utils";
|
|
||||||
import { formatTemperatureValue } from "@/lib/temperature-utils";
|
|
||||||
|
|
||||||
function toFiniteDecisionNumber(value: unknown) {
|
|
||||||
if (value == null || value === "") return null;
|
|
||||||
const numeric = Number(value);
|
|
||||||
return Number.isFinite(numeric) ? numeric : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRowModelEntries(row: ScanOpportunityRow | null) {
|
|
||||||
const sources = row?.model_cluster_sources;
|
|
||||||
if (!sources || typeof sources !== "object") return [];
|
|
||||||
return Object.entries(sources)
|
|
||||||
.map(([name, value]) => [name, Number(value)] as const)
|
|
||||||
.filter(([, value]) => Number.isFinite(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEpochMs(value: unknown) {
|
|
||||||
if (value == null || value === "") return null;
|
|
||||||
const numeric = Number(value);
|
|
||||||
if (Number.isFinite(numeric)) return numeric > 1_000_000_000_000 ? numeric : numeric * 1000;
|
|
||||||
const parsed = new Date(String(value));
|
|
||||||
return Number.isNaN(parsed.getTime()) ? null : parsed.getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMetarReportTime(detail: CityDetail | null, report: string, isEn: boolean) {
|
|
||||||
const offsetSeconds = Number(detail?.utc_offset_seconds);
|
|
||||||
const epochMs =
|
|
||||||
parseEpochMs(detail?.airport_current?.report_time) ??
|
|
||||||
parseEpochMs(detail?.airport_current?.obs_time_epoch) ??
|
|
||||||
parseEpochMs(detail?.airport_current?.obs_time) ??
|
|
||||||
parseEpochMs(detail?.current?.report_time) ??
|
|
||||||
parseEpochMs(detail?.current?.obs_time_epoch) ??
|
|
||||||
parseEpochMs(detail?.current?.obs_time);
|
|
||||||
if (epochMs != null) {
|
|
||||||
const utc = new Date(epochMs);
|
|
||||||
const zText = `${String(utc.getUTCHours()).padStart(2, "0")}:${String(
|
|
||||||
utc.getUTCMinutes(),
|
|
||||||
).padStart(2, "0")}Z`;
|
|
||||||
if (Number.isFinite(offsetSeconds)) {
|
|
||||||
const local = new Date(epochMs + offsetSeconds * 1000);
|
|
||||||
const localText = `${String(local.getUTCHours()).padStart(2, "0")}:${String(
|
|
||||||
local.getUTCMinutes(),
|
|
||||||
).padStart(2, "0")}`;
|
|
||||||
return isEn ? `${zText} / local ${localText}` : `${zText} / 当地 ${localText}`;
|
|
||||||
}
|
|
||||||
return zText;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawToken = String(report || "").match(/\b(\d{2})(\d{2})(\d{2})Z\b/i);
|
|
||||||
if (!rawToken) return "";
|
|
||||||
const zText = `${rawToken[2]}:${rawToken[3]}Z`;
|
|
||||||
if (!Number.isFinite(offsetSeconds)) return zText;
|
|
||||||
const utcMinutes = Number(rawToken[2]) * 60 + Number(rawToken[3]);
|
|
||||||
if (!Number.isFinite(utcMinutes)) return zText;
|
|
||||||
const localMinutes = Math.round(
|
|
||||||
((utcMinutes + offsetSeconds / 60) % 1440 + 1440) % 1440,
|
|
||||||
);
|
|
||||||
const localText = `${String(Math.floor(localMinutes / 60)).padStart(2, "0")}:${String(
|
|
||||||
localMinutes % 60,
|
|
||||||
).padStart(2, "0")}`;
|
|
||||||
return isEn ? `${zText} / local ${localText}` : `${zText} / 当地 ${localText}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeMetarReadTime(text: string, displayTime: string, isEn: boolean) {
|
|
||||||
if (!text || !displayTime) return text;
|
|
||||||
const timeLabel = isEn ? `report time ${displayTime}` : `报文时间 ${displayTime}`;
|
|
||||||
return text
|
|
||||||
.replace(/报文时间\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, timeLabel)
|
|
||||||
.replace(/report time\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, timeLabel)
|
|
||||||
.replace(/\bat\s+\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, `at ${displayTime}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHkoObservationCity(detail?: CityDetail | null) {
|
|
||||||
const source = String(
|
|
||||||
detail?.current?.settlement_source ||
|
|
||||||
detail?.settlement_station?.settlement_source ||
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
return source === "hko";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFreshnessAge(value: unknown, isEn: boolean) {
|
|
||||||
const minutes = Number(value);
|
|
||||||
if (!Number.isFinite(minutes) || minutes < 0) return "";
|
|
||||||
if (minutes < 1) return isEn ? "just now" : "刚刚";
|
|
||||||
if (minutes < 60) {
|
|
||||||
const rounded = Math.max(1, Math.round(minutes));
|
|
||||||
return isEn ? `${rounded}m ago` : `${rounded} 分钟前`;
|
|
||||||
}
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const remaining = Math.round(minutes % 60);
|
|
||||||
if (remaining <= 0) return isEn ? `${hours}h ago` : `${hours} 小时前`;
|
|
||||||
return isEn ? `${hours}h ${remaining}m ago` : `${hours} 小时 ${remaining} 分钟前`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUpdateTime(value: unknown, locale: string) {
|
|
||||||
const epochMs = parseEpochMs(value);
|
|
||||||
if (epochMs == null) return "";
|
|
||||||
const date = new Date(epochMs);
|
|
||||||
const now = new Date();
|
|
||||||
const sameDay = date.toDateString() === now.toDateString();
|
|
||||||
const time = date.toLocaleTimeString(locale === "en-US" ? "en-US" : "zh-CN", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
if (locale === "en-US") {
|
|
||||||
const day = sameDay
|
|
||||||
? "today"
|
|
||||||
: date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
||||||
return `${day} ${time} updated`;
|
|
||||||
}
|
|
||||||
const day = sameDay
|
|
||||||
? "今日"
|
|
||||||
: date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
|
|
||||||
return `${day} ${time} 更新`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildObservationFreshnessValue({
|
|
||||||
detail,
|
|
||||||
displayTime,
|
|
||||||
isEn,
|
|
||||||
isHkoObservation,
|
|
||||||
}: {
|
|
||||||
detail: CityDetail | null;
|
|
||||||
displayTime: string;
|
|
||||||
isEn: boolean;
|
|
||||||
isHkoObservation: boolean;
|
|
||||||
}) {
|
|
||||||
const stale = Boolean(
|
|
||||||
detail?.metar_status?.stale_for_today ||
|
|
||||||
detail?.airport_current?.stale_for_today ||
|
|
||||||
detail?.current?.observation_status === "stale",
|
|
||||||
);
|
|
||||||
if (stale) {
|
|
||||||
return isEn ? "stale; background only" : "已过旧,仅作背景参考";
|
|
||||||
}
|
|
||||||
const ageLabel = formatFreshnessAge(
|
|
||||||
isHkoObservation ? detail?.current?.obs_age_min : detail?.airport_current?.obs_age_min ?? detail?.current?.obs_age_min,
|
|
||||||
isEn,
|
|
||||||
);
|
|
||||||
if (ageLabel) return ageLabel;
|
|
||||||
if (displayTime) return displayTime;
|
|
||||||
return isEn ? "time pending" : "时间待确认";
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildModelFreshnessValue(detail: CityDetail | null, locale: string, isEn: boolean) {
|
|
||||||
return (
|
|
||||||
formatUpdateTime(detail?.updated_at, locale) ||
|
|
||||||
(isEn ? "latest run loaded" : "已加载最新模型")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AiPinnedCityCard({
|
|
||||||
item,
|
|
||||||
detail,
|
|
||||||
row,
|
|
||||||
locale,
|
|
||||||
collapsed,
|
|
||||||
removing,
|
|
||||||
onRefreshCityDetail,
|
|
||||||
onRemove,
|
|
||||||
onToggleCollapsed,
|
|
||||||
}: {
|
|
||||||
item: AiPinnedCity;
|
|
||||||
detail: CityDetail | null;
|
|
||||||
row: ScanOpportunityRow | null;
|
|
||||||
locale: string;
|
|
||||||
collapsed: boolean;
|
|
||||||
removing?: boolean;
|
|
||||||
onRefreshCityDetail: (cityName: string) => Promise<void>;
|
|
||||||
onRemove: () => void;
|
|
||||||
onToggleCollapsed: () => void;
|
|
||||||
}) {
|
|
||||||
const isEn = locale === "en-US";
|
|
||||||
const displayName =
|
|
||||||
detail?.display_name ||
|
|
||||||
row?.city_display_name ||
|
|
||||||
row?.display_name ||
|
|
||||||
item.displayName ||
|
|
||||||
item.cityName;
|
|
||||||
const tempSymbol = detail?.temp_symbol || row?.temp_symbol || "°C";
|
|
||||||
const modelView = detail ? getModelView(detail, detail.local_date) : null;
|
|
||||||
const detailModelEntries = modelView
|
|
||||||
? Object.entries(modelView.models || {})
|
|
||||||
.map(([name, value]) => [name, Number(value)] as const)
|
|
||||||
.filter(([, value]) => Number.isFinite(value))
|
|
||||||
: [];
|
|
||||||
const modelEntries = detailModelEntries.length ? detailModelEntries : getRowModelEntries(row);
|
|
||||||
const modelValues = modelEntries.map(([, value]) => value);
|
|
||||||
const modelMin = modelValues.length ? Math.min(...modelValues) : null;
|
|
||||||
const modelMax = modelValues.length ? Math.max(...modelValues) : null;
|
|
||||||
const paceView = detail ? getTodayPaceView(detail, locale as "zh-CN" | "en-US") : null;
|
|
||||||
const peakWindow =
|
|
||||||
paceView?.peakWindowText ||
|
|
||||||
(row ? getPeakWindowLabel(row) : null) ||
|
|
||||||
"--";
|
|
||||||
const deb = detail?.deb?.prediction ?? row?.deb_prediction ?? null;
|
|
||||||
const isHkoObservation = isHkoObservationCity(detail);
|
|
||||||
const displayAirportPrimary = getDisplayAirportPrimary(detail);
|
|
||||||
const currentTemp =
|
|
||||||
(isHkoObservation
|
|
||||||
? detail?.current?.temp ?? row?.current_temp
|
|
||||||
: displayAirportPrimary?.temp ??
|
|
||||||
detail?.airport_current?.temp ??
|
|
||||||
detail?.current?.temp ??
|
|
||||||
row?.current_temp) ?? null;
|
|
||||||
const debNumber = toFiniteDecisionNumber(deb);
|
|
||||||
const currentTempNumber = toFiniteDecisionNumber(currentTemp);
|
|
||||||
const modelRange =
|
|
||||||
modelMin != null && modelMax != null
|
|
||||||
? `${formatTemperatureValue(modelMin, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(modelMax, tempSymbol, { digits: 1 })}`
|
|
||||||
: "--";
|
|
||||||
const paceTone = paceView?.biasTone || "neutral";
|
|
||||||
const paceText =
|
|
||||||
paceView?.summary ||
|
|
||||||
(isEn
|
|
||||||
? "Waiting for intraday observations to compare against the DEB path."
|
|
||||||
: "等待更多日内实测,用来对照 DEB 预测路径。");
|
|
||||||
const report = isHkoObservation
|
|
||||||
? ""
|
|
||||||
: detail?.current?.raw_metar || detail?.airport_current?.raw_metar || "";
|
|
||||||
const observationSourceZh = isHkoObservation ? "香港天文台观测" : "METAR 实测";
|
|
||||||
const observationSourceEn = isHkoObservation ? "HKO observations" : "METAR observations";
|
|
||||||
const detailCityName = detail?.name || item.cityName;
|
|
||||||
const [refreshingDetail, setRefreshingDetail] = useState(false);
|
|
||||||
const { aiForecast, refreshAiForecast } = useAiCityForecast({
|
|
||||||
detail,
|
|
||||||
detailCityName,
|
|
||||||
enabled: Boolean(detail),
|
|
||||||
isEn,
|
|
||||||
locale,
|
|
||||||
report,
|
|
||||||
});
|
|
||||||
const isRefreshing = refreshingDetail || aiForecast.status === "loading";
|
|
||||||
const [isCompactCard, setIsCompactCard] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
||||||
const media = window.matchMedia("(max-width: 820px)");
|
|
||||||
const syncCompactMode = () => setIsCompactCard(media.matches);
|
|
||||||
syncCompactMode();
|
|
||||||
media.addEventListener("change", syncCompactMode);
|
|
||||||
return () => media.removeEventListener("change", syncCompactMode);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const aiCityForecast = aiForecast.payload?.city_forecast || null;
|
|
||||||
const localizedFinalJudgment =
|
|
||||||
(isEn ? aiCityForecast?.final_judgment_en : aiCityForecast?.final_judgment_zh) ||
|
|
||||||
(isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) ||
|
|
||||||
"";
|
|
||||||
const localizedMetarRead =
|
|
||||||
(isEn ? aiCityForecast?.metar_read_en : aiCityForecast?.metar_read_zh) ||
|
|
||||||
"";
|
|
||||||
const localizedReasoning =
|
|
||||||
(isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) ||
|
|
||||||
"";
|
|
||||||
const localizedModelNote =
|
|
||||||
(isEn
|
|
||||||
? aiCityForecast?.model_cluster_note_en
|
|
||||||
: aiCityForecast?.model_cluster_note_zh) || "";
|
|
||||||
const modelPreview = modelEntries
|
|
||||||
.slice(0, 4)
|
|
||||||
.map(([name, value]) => `${name} ${formatTemperatureValue(value, tempSymbol, { digits: 1 })}`)
|
|
||||||
.join(isEn ? " / " : " / ");
|
|
||||||
const localModelSupportNote = modelEntries.length
|
|
||||||
? isEn
|
|
||||||
? modelEntries.length <= 2
|
|
||||||
? `Model support is sparse: only ${modelEntries.length} sources are available${modelPreview ? ` (${modelPreview})` : ""}, so the read should lean more on DEB path and ${observationSourceEn}.`
|
|
||||||
: `Model support: ${modelEntries.length} sources cluster between ${modelRange}; ${modelPreview}.`
|
|
||||||
: modelEntries.length <= 2
|
|
||||||
? `多模型支撑偏少:当前只有 ${modelEntries.length} 个模型${modelPreview ? `(${modelPreview})` : ""},需要更重视 DEB 路径和${observationSourceZh}。`
|
|
||||||
: `多模型支撑:${modelEntries.length} 个模型集中在 ${modelRange},代表模型为 ${modelPreview}。`
|
|
||||||
: isEn
|
|
||||||
? `Model support is unavailable, so this city must rely on DEB path and ${observationSourceEn}.`
|
|
||||||
: `暂无可用多模型支撑,需要主要参考 DEB 路径和${observationSourceZh}。`;
|
|
||||||
const aiPredictedMax =
|
|
||||||
aiForecast.status === "ready"
|
|
||||||
? toFiniteDecisionNumber(aiCityForecast?.predicted_max)
|
|
||||||
: null;
|
|
||||||
const aiRangeLow =
|
|
||||||
toFiniteDecisionNumber(aiCityForecast?.range_low) ??
|
|
||||||
toFiniteDecisionNumber(row?.ai_predicted_low) ??
|
|
||||||
modelMin;
|
|
||||||
const aiRangeHigh =
|
|
||||||
toFiniteDecisionNumber(aiCityForecast?.range_high) ??
|
|
||||||
toFiniteDecisionNumber(row?.ai_predicted_high) ??
|
|
||||||
modelMax;
|
|
||||||
const aiConfidence =
|
|
||||||
String(aiCityForecast?.confidence || row?.ai_forecast_confidence || "").trim() || null;
|
|
||||||
const decisionExpectedHighNumber = resolveExpectedHighCandidate({
|
|
||||||
aiPredictedMax,
|
|
||||||
currentTemp: currentTempNumber,
|
|
||||||
deb: debNumber,
|
|
||||||
modelMax,
|
|
||||||
modelMin,
|
|
||||||
paceAdjustedHigh: paceView?.paceAdjustedHigh ?? null,
|
|
||||||
});
|
|
||||||
const decisionView = buildWeatherDecisionView({
|
|
||||||
aiCityForecast,
|
|
||||||
currentTemp: currentTempNumber,
|
|
||||||
deb: debNumber,
|
|
||||||
isEn,
|
|
||||||
localModelSupportNote,
|
|
||||||
modelEntries,
|
|
||||||
modelMax,
|
|
||||||
modelMin,
|
|
||||||
paceTone,
|
|
||||||
paceView,
|
|
||||||
peakWindow,
|
|
||||||
tempSymbol,
|
|
||||||
});
|
|
||||||
const expectedHighText =
|
|
||||||
decisionExpectedHighNumber != null
|
|
||||||
? formatTemperatureValue(decisionExpectedHighNumber, tempSymbol, { digits: 1 })
|
|
||||||
: "--";
|
|
||||||
const debText =
|
|
||||||
debNumber != null
|
|
||||||
? formatTemperatureValue(debNumber, tempSymbol, { digits: 1 })
|
|
||||||
: "--";
|
|
||||||
const aiMeta = aiCityForecast?._polyweather_meta || null;
|
|
||||||
const guardReason = aiMeta?.deterministic_guard_reason || {};
|
|
||||||
const observationStale = Boolean(
|
|
||||||
detail?.metar_status?.stale_for_today ||
|
|
||||||
detail?.airport_current?.stale_for_today ||
|
|
||||||
detail?.current?.observation_status === "stale" ||
|
|
||||||
guardReason.observation_stale,
|
|
||||||
);
|
|
||||||
const observedHighBreak = Boolean(
|
|
||||||
guardReason.observed_high_break ||
|
|
||||||
(currentTempNumber != null &&
|
|
||||||
modelMax != null &&
|
|
||||||
currentTempNumber > modelMax + 0.2),
|
|
||||||
);
|
|
||||||
const observedLowBreak = Boolean(guardReason.observed_low_break);
|
|
||||||
const observedLowLag = Boolean(guardReason.observed_low_lag);
|
|
||||||
const peakHasPassed = Boolean(
|
|
||||||
guardReason.peak_has_passed ||
|
|
||||||
["past", "post_peak", "after_peak"].includes(
|
|
||||||
String((row as { window_phase?: string | null } | null)?.window_phase || "").toLowerCase(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const modelSpread = modelMax != null && modelMin != null ? modelMax - modelMin : null;
|
|
||||||
const modelHighlyConsistent =
|
|
||||||
modelEntries.length >= 4 && modelSpread != null && modelSpread <= 2;
|
|
||||||
const needsNextBulletin =
|
|
||||||
!observationStale &&
|
|
||||||
!observedHighBreak &&
|
|
||||||
!observedLowBreak &&
|
|
||||||
!peakHasPassed &&
|
|
||||||
(observedLowLag || paceTone === "neutral" || aiForecast.status === "loading");
|
|
||||||
const aiRuleEvidenceMode = Boolean(
|
|
||||||
aiForecast.status === "failed" ||
|
|
||||||
(aiForecast.status === "ready" && !aiCityForecast) ||
|
|
||||||
aiForecast.payload?.degraded ||
|
|
||||||
aiMeta?.fallback,
|
|
||||||
);
|
|
||||||
const aiReadCopy = getAiReadCopy({ isEn, isHkoObservation });
|
|
||||||
const aiReadInProgressText = aiReadCopy.inProgress;
|
|
||||||
const aiReadCompleteText = aiReadCopy.complete;
|
|
||||||
const aiRuleEvidenceText = aiReadCopy.ruleEvidence;
|
|
||||||
const decisionState = buildCityDecisionState({
|
|
||||||
aiCityForecast,
|
|
||||||
aiForecast,
|
|
||||||
aiRuleEvidenceMode,
|
|
||||||
isEn,
|
|
||||||
isHkoObservation,
|
|
||||||
modelHighlyConsistent,
|
|
||||||
needsNextBulletin,
|
|
||||||
observationStale,
|
|
||||||
observedHighBreak,
|
|
||||||
observedLowBreak,
|
|
||||||
observedLowLag,
|
|
||||||
peakHasPassed,
|
|
||||||
});
|
|
||||||
const dataFreshnessRows = [
|
|
||||||
{
|
|
||||||
label: isEn ? "Models" : "模型",
|
|
||||||
value: buildModelFreshnessValue(detail, locale, isEn),
|
|
||||||
tone: "fresh" as const,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const freshnessSeparator = isEn ? ": " : ":";
|
|
||||||
const localizedRisksRaw =
|
|
||||||
(isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh) || [];
|
|
||||||
const localizedRisks = Array.isArray(localizedRisksRaw)
|
|
||||||
? localizedRisksRaw
|
|
||||||
: localizedRisksRaw
|
|
||||||
? [String(localizedRisksRaw)]
|
|
||||||
: [];
|
|
||||||
const aiBullets = [
|
|
||||||
localizedMetarRead,
|
|
||||||
localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "",
|
|
||||||
localizedModelNote || localModelSupportNote,
|
|
||||||
...localizedRisks,
|
|
||||||
].filter((line) => String(line || "").trim());
|
|
||||||
const fallbackAiReason =
|
|
||||||
(isEn ? aiForecast.payload?.reason_en : aiForecast.payload?.reason_zh) ||
|
|
||||||
aiForecast.payload?.reason ||
|
|
||||||
"";
|
|
||||||
const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`;
|
|
||||||
const loadingCopy = getCityLoadingCopy({ isEn, isHkoObservation });
|
|
||||||
const handleRefresh = (event: MouseEvent<HTMLButtonElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
if (refreshingDetail) return;
|
|
||||||
setRefreshingDetail(true);
|
|
||||||
void onRefreshCityDetail(detailCityName)
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => {
|
|
||||||
refreshAiForecast();
|
|
||||||
setRefreshingDetail(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const handleRemove = (event: MouseEvent<HTMLButtonElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
onRemove();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
className={clsx(
|
|
||||||
"scan-ai-city-card",
|
|
||||||
isCompactCard && "scan-mobile-decision-card",
|
|
||||||
collapsed && !isCompactCard && "collapsed",
|
|
||||||
removing && "removing",
|
|
||||||
)}
|
|
||||||
tabIndex={-1}
|
|
||||||
data-ai-status={decisionState.aiStatus}
|
|
||||||
data-evidence-quality={decisionState.evidenceQuality}
|
|
||||||
data-recommendation={decisionState.recommendation}
|
|
||||||
data-urgency={decisionState.urgency}
|
|
||||||
>
|
|
||||||
{isCompactCard ? (
|
|
||||||
<MobileDecisionCard
|
|
||||||
aiBullets={aiBullets}
|
|
||||||
aiCityForecast={aiCityForecast}
|
|
||||||
aiConfidence={aiConfidence}
|
|
||||||
aiForecast={aiForecast}
|
|
||||||
aiPredictedMax={aiPredictedMax}
|
|
||||||
aiRangeHigh={aiRangeHigh}
|
|
||||||
aiRangeLow={aiRangeLow}
|
|
||||||
aiReadCompleteText={aiReadCompleteText}
|
|
||||||
aiReadInProgressText={aiReadInProgressText}
|
|
||||||
aiRuleEvidenceMode={aiRuleEvidenceMode}
|
|
||||||
aiRuleEvidenceText={aiRuleEvidenceText}
|
|
||||||
debPrediction={debNumber}
|
|
||||||
decisionState={decisionState}
|
|
||||||
detail={detail}
|
|
||||||
displayName={displayName}
|
|
||||||
expectedHighText={expectedHighText}
|
|
||||||
fallbackAiReason={fallbackAiReason}
|
|
||||||
isEn={isEn}
|
|
||||||
isHkoObservation={isHkoObservation}
|
|
||||||
isRefreshing={isRefreshing}
|
|
||||||
localModelSupportNote={localModelSupportNote}
|
|
||||||
localizedFinalJudgment={localizedFinalJudgment}
|
|
||||||
onRefresh={handleRefresh}
|
|
||||||
onRemove={handleRemove}
|
|
||||||
peakWindow={peakWindow}
|
|
||||||
removing={removing}
|
|
||||||
tempSymbol={tempSymbol}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CityCardHeader
|
|
||||||
aiStatusLabel={decisionState.aiStatusLabel}
|
|
||||||
aiStatusTone={decisionState.aiStatusTone}
|
|
||||||
collapseId={collapseId}
|
|
||||||
collapsed={collapsed}
|
|
||||||
debText={debText}
|
|
||||||
detailLocalTime={detail?.local_time}
|
|
||||||
displayName={displayName}
|
|
||||||
expectedHighText={expectedHighText}
|
|
||||||
isEn={isEn}
|
|
||||||
isRefreshing={isRefreshing}
|
|
||||||
modelRange={modelRange}
|
|
||||||
onRefresh={handleRefresh}
|
|
||||||
onRemove={handleRemove}
|
|
||||||
onToggleCollapsed={onToggleCollapsed}
|
|
||||||
peakWindow={peakWindow}
|
|
||||||
removing={removing}
|
|
||||||
rowLocalTime={row?.local_time}
|
|
||||||
statusTags={decisionState.badges}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{detail && !collapsed ? (
|
|
||||||
<div className="scan-ai-city-body" id={collapseId}>
|
|
||||||
<WeatherDecisionBand
|
|
||||||
decisionView={decisionView}
|
|
||||||
decisionWhyText={decisionState.primaryReason}
|
|
||||||
isEn={isEn}
|
|
||||||
paceDeltaText={paceView?.deltaText || "--"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="scan-ai-city-analysis-grid">
|
|
||||||
<AiCityTemperatureChart detail={detail} />
|
|
||||||
<AiEvidencePanel
|
|
||||||
aiBullets={aiBullets}
|
|
||||||
aiCityForecast={aiCityForecast}
|
|
||||||
aiConfidence={aiConfidence}
|
|
||||||
aiForecast={aiForecast}
|
|
||||||
aiPredictedMax={aiPredictedMax}
|
|
||||||
aiRangeHigh={aiRangeHigh}
|
|
||||||
aiRangeLow={aiRangeLow}
|
|
||||||
aiReadCompleteText={aiReadCompleteText}
|
|
||||||
aiReadInProgressText={aiReadInProgressText}
|
|
||||||
aiRuleEvidenceMode={aiRuleEvidenceMode}
|
|
||||||
aiRuleEvidenceText={aiRuleEvidenceText}
|
|
||||||
debPrediction={debNumber}
|
|
||||||
fallbackAiReason={fallbackAiReason}
|
|
||||||
isEn={isEn}
|
|
||||||
isHkoObservation={isHkoObservation}
|
|
||||||
localModelSupportNote={localModelSupportNote}
|
|
||||||
localizedFinalJudgment={localizedFinalJudgment}
|
|
||||||
tempSymbol={tempSymbol}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ModelEvidencePanel detail={detail} isEn={isEn} />
|
|
||||||
</div>
|
|
||||||
) : !detail ? (
|
|
||||||
<div className="scan-ai-city-loading">
|
|
||||||
<LoadingSignal
|
|
||||||
title={loadingCopy.title}
|
|
||||||
description={loadingCopy.description}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { AiPinnedCityCard } from "@/components/dashboard/scan-terminal/AiPinnedCityCard";
|
|
||||||
import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils";
|
|
||||||
import { findRowForCity, normalizeCityKey } from "@/components/dashboard/scan-terminal/decision-utils";
|
|
||||||
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
|
|
||||||
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
|
||||||
|
|
||||||
export function AiPinnedForecastView({
|
|
||||||
items,
|
|
||||||
rows,
|
|
||||||
detailsByName,
|
|
||||||
locale,
|
|
||||||
onRefreshCityDetail,
|
|
||||||
onRemoveCity,
|
|
||||||
}: {
|
|
||||||
items: AiPinnedCity[];
|
|
||||||
rows: ScanOpportunityRow[];
|
|
||||||
detailsByName: Record<string, CityDetail>;
|
|
||||||
locale: string;
|
|
||||||
onRefreshCityDetail: (cityName: string) => Promise<void>;
|
|
||||||
onRemoveCity: (cityName: string) => void;
|
|
||||||
}) {
|
|
||||||
const isEn = locale === "en-US";
|
|
||||||
const [expandedCityKey, setExpandedCityKey] = useState<string | null>(null);
|
|
||||||
const [removingCities, setRemovingCities] = useState<Set<string>>(
|
|
||||||
() => new Set(),
|
|
||||||
);
|
|
||||||
const cityCardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
|
||||||
const knownCityKeysRef = useRef<Set<string>>(new Set());
|
|
||||||
const previousFirstCityKeyRef = useRef<string | null>(null);
|
|
||||||
const removeTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const activeKeyList = items.map(
|
|
||||||
(item) => normalizeCityKey(item.cityName) || item.cityName,
|
|
||||||
);
|
|
||||||
const activeKeys = new Set(activeKeyList);
|
|
||||||
const firstCityKey = activeKeyList[0] ?? null;
|
|
||||||
const hasNewCity = activeKeyList.some((key) => !knownCityKeysRef.current.has(key));
|
|
||||||
const firstCityChanged = firstCityKey !== previousFirstCityKeyRef.current;
|
|
||||||
|
|
||||||
setExpandedCityKey((current) => {
|
|
||||||
if (!firstCityKey) return null;
|
|
||||||
if (hasNewCity || firstCityChanged) return firstCityKey;
|
|
||||||
if (current && activeKeys.has(current)) return current;
|
|
||||||
return firstCityKey;
|
|
||||||
});
|
|
||||||
Object.keys(cityCardRefs.current).forEach((key) => {
|
|
||||||
if (!activeKeys.has(key)) {
|
|
||||||
delete cityCardRefs.current[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
knownCityKeysRef.current = activeKeys;
|
|
||||||
previousFirstCityKeyRef.current = firstCityKey;
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
removeTimersRef.current.forEach((timer) => clearTimeout(timer));
|
|
||||||
removeTimersRef.current.clear();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const removeCityWithMotion = useCallback(
|
|
||||||
(item: AiPinnedCity, stableKey: string) => {
|
|
||||||
if (removeTimersRef.current.has(stableKey)) return;
|
|
||||||
setRemovingCities((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
next.add(stableKey);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
onRemoveCity(item.cityName);
|
|
||||||
setRemovingCities((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
next.delete(stableKey);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
removeTimersRef.current.delete(stableKey);
|
|
||||||
}, 260);
|
|
||||||
removeTimersRef.current.set(stableKey, timer);
|
|
||||||
},
|
|
||||||
[onRemoveCity],
|
|
||||||
);
|
|
||||||
|
|
||||||
const scrollToCity = useCallback((stableKey: string) => {
|
|
||||||
cityCardRefs.current[stableKey]?.scrollIntoView({
|
|
||||||
behavior: "smooth",
|
|
||||||
block: "start",
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const focusCity = useCallback((stableKey: string) => {
|
|
||||||
setExpandedCityKey(stableKey);
|
|
||||||
window.requestAnimationFrame(() => scrollToCity(stableKey));
|
|
||||||
}, [scrollToCity]);
|
|
||||||
|
|
||||||
if (!items.length) {
|
|
||||||
return (
|
|
||||||
<div className="scan-ai-workspace empty">
|
|
||||||
<div className="scan-empty-state">
|
|
||||||
<div className="scan-empty-title">
|
|
||||||
{isEn ? "Select a city from the market list" : "从市场列表选择城市"}
|
|
||||||
</div>
|
|
||||||
<div className="scan-empty-copy">
|
|
||||||
{isEn
|
|
||||||
? "Selected cities will appear here as deep analysis blocks."
|
|
||||||
: "选中的城市会加入深度分析页,并保留为城市分析区块。"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="scan-ai-workspace">
|
|
||||||
<div className="scan-ai-workspace-head">
|
|
||||||
<div>
|
|
||||||
<span>{isEn ? "Selected city workspace" : "城市分析工作区"}</span>
|
|
||||||
<strong>
|
|
||||||
{isEn
|
|
||||||
? `${items.length} cities under deep analysis`
|
|
||||||
: `${items.length} 个城市正在深度分析`}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
{isEn
|
|
||||||
? "Market-list selections add cities here. City analysis stays here until you remove it."
|
|
||||||
: "市场列表选择会把城市加入这里;城市分析会保留,直到你手动移除。"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="scan-ai-city-jumpbar" aria-label={isEn ? "Selected city shortcuts" : "已选城市快捷跳转"}>
|
|
||||||
{items.map((item) => {
|
|
||||||
const stableKey = normalizeCityKey(item.cityName) || item.cityName;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={stableKey}
|
|
||||||
type="button"
|
|
||||||
onClick={() => focusCity(stableKey)}
|
|
||||||
>
|
|
||||||
{item.cityName}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="scan-ai-city-stack">
|
|
||||||
{items.map((item) => {
|
|
||||||
const detail = findDetailForCity(detailsByName, item.cityName);
|
|
||||||
const row = findRowForCity(rows, item.cityName);
|
|
||||||
const key = normalizeCityKey(item.cityName);
|
|
||||||
const stableKey = key || item.cityName;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={stableKey}
|
|
||||||
ref={(node) => {
|
|
||||||
cityCardRefs.current[stableKey] = node;
|
|
||||||
}}
|
|
||||||
className="scan-ai-city-anchor"
|
|
||||||
>
|
|
||||||
<AiPinnedCityCard
|
|
||||||
item={item}
|
|
||||||
detail={detail}
|
|
||||||
row={row}
|
|
||||||
locale={locale}
|
|
||||||
collapsed={expandedCityKey !== stableKey}
|
|
||||||
removing={removingCities.has(stableKey)}
|
|
||||||
onRefreshCityDetail={onRefreshCityDetail}
|
|
||||||
onRemove={() => removeCityWithMotion(item, stableKey)}
|
|
||||||
onToggleCollapsed={() => {
|
|
||||||
setExpandedCityKey((current) =>
|
|
||||||
current === stableKey ? null : stableKey,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { RefreshCw, X } from "lucide-react";
|
|
||||||
import type { MouseEvent } from "react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart";
|
|
||||||
import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel";
|
|
||||||
import {
|
|
||||||
CityStatusTags,
|
|
||||||
type CityStatusTag,
|
|
||||||
type StatusTone,
|
|
||||||
} from "@/components/dashboard/scan-terminal/CityStatusTags";
|
|
||||||
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
|
|
||||||
import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel";
|
|
||||||
import type { CityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state";
|
|
||||||
import {
|
|
||||||
getCityLoadingCopy,
|
|
||||||
getMobileDecisionCopy,
|
|
||||||
} from "@/components/dashboard/scan-terminal/decision-copy";
|
|
||||||
import type {
|
|
||||||
AiCityForecastPayload,
|
|
||||||
AiCityForecastState,
|
|
||||||
} from "@/components/dashboard/scan-terminal/types";
|
|
||||||
import type { CityDetail } from "@/lib/dashboard-types";
|
|
||||||
|
|
||||||
export function MobileDecisionCard({
|
|
||||||
aiBullets,
|
|
||||||
aiCityForecast,
|
|
||||||
aiConfidence,
|
|
||||||
aiForecast,
|
|
||||||
aiPredictedMax,
|
|
||||||
aiRangeHigh,
|
|
||||||
aiRangeLow,
|
|
||||||
aiReadCompleteText,
|
|
||||||
aiReadInProgressText,
|
|
||||||
aiRuleEvidenceMode,
|
|
||||||
aiRuleEvidenceText,
|
|
||||||
debPrediction,
|
|
||||||
decisionState,
|
|
||||||
detail,
|
|
||||||
displayName,
|
|
||||||
expectedHighText,
|
|
||||||
fallbackAiReason,
|
|
||||||
isEn,
|
|
||||||
isHkoObservation,
|
|
||||||
isRefreshing,
|
|
||||||
localModelSupportNote,
|
|
||||||
localizedFinalJudgment,
|
|
||||||
onRefresh,
|
|
||||||
onRemove,
|
|
||||||
peakWindow,
|
|
||||||
removing,
|
|
||||||
tempSymbol,
|
|
||||||
}: {
|
|
||||||
aiBullets: string[];
|
|
||||||
aiCityForecast: AiCityForecastPayload["city_forecast"] | null;
|
|
||||||
aiConfidence: string | null;
|
|
||||||
aiForecast: AiCityForecastState;
|
|
||||||
aiPredictedMax: number | null;
|
|
||||||
aiRangeHigh: number | null;
|
|
||||||
aiRangeLow: number | null;
|
|
||||||
aiReadCompleteText: string;
|
|
||||||
aiReadInProgressText: string;
|
|
||||||
aiRuleEvidenceMode: boolean;
|
|
||||||
aiRuleEvidenceText: string;
|
|
||||||
debPrediction: number | null;
|
|
||||||
decisionState: CityDecisionState;
|
|
||||||
detail: CityDetail | null;
|
|
||||||
displayName: string;
|
|
||||||
expectedHighText: string;
|
|
||||||
fallbackAiReason: string;
|
|
||||||
isEn: boolean;
|
|
||||||
isHkoObservation: boolean;
|
|
||||||
isRefreshing: boolean;
|
|
||||||
localModelSupportNote: string;
|
|
||||||
localizedFinalJudgment: string;
|
|
||||||
onRefresh: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
||||||
onRemove: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
||||||
peakWindow: string;
|
|
||||||
removing?: boolean;
|
|
||||||
tempSymbol: string;
|
|
||||||
}) {
|
|
||||||
const copy = getMobileDecisionCopy(isEn);
|
|
||||||
const loadingCopy = getCityLoadingCopy({ isEn, isHkoObservation });
|
|
||||||
const [modelOpen, setModelOpen] = useState(false);
|
|
||||||
const [chartOpen, setChartOpen] = useState(false);
|
|
||||||
const statusTags: CityStatusTag[] = decisionState.badges.length
|
|
||||||
? decisionState.badges
|
|
||||||
: [{ label: decisionState.aiStatusLabel, tone: decisionState.aiStatusTone as StatusTone }];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<header className="scan-mobile-decision-head">
|
|
||||||
<div>
|
|
||||||
<span className="scan-ai-city-kicker">
|
|
||||||
{isEn ? "Mobile action card" : "移动端行动卡"}
|
|
||||||
</span>
|
|
||||||
<h3>{displayName}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="scan-ai-city-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="scan-ai-city-icon-button"
|
|
||||||
onClick={onRefresh}
|
|
||||||
aria-label={`${copy.refresh} ${displayName}`}
|
|
||||||
title={copy.refresh}
|
|
||||||
disabled={isRefreshing}
|
|
||||||
>
|
|
||||||
<RefreshCw size={15} className={isRefreshing ? "spin" : undefined} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="scan-ai-city-icon-button danger"
|
|
||||||
onClick={onRemove}
|
|
||||||
aria-label={`${copy.remove} ${displayName}`}
|
|
||||||
title={copy.remove}
|
|
||||||
disabled={removing}
|
|
||||||
>
|
|
||||||
<X size={15} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="scan-mobile-decision-metrics">
|
|
||||||
<span className="primary">
|
|
||||||
<small>{copy.expectedHigh}</small>
|
|
||||||
<b>{expectedHighText}</b>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<small>{copy.peakWindow}</small>
|
|
||||||
<b>{peakWindow}</b>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="scan-mobile-decision-reason">{decisionState.primaryReason}</p>
|
|
||||||
<CityStatusTags tags={statusTags} />
|
|
||||||
|
|
||||||
{!detail ? (
|
|
||||||
<div className="scan-ai-city-loading">
|
|
||||||
<LoadingSignal
|
|
||||||
title={loadingCopy.title}
|
|
||||||
description={loadingCopy.description}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="scan-mobile-decision-folds">
|
|
||||||
<AiEvidencePanel
|
|
||||||
aiBullets={aiBullets}
|
|
||||||
aiCityForecast={aiCityForecast}
|
|
||||||
aiConfidence={aiConfidence}
|
|
||||||
aiForecast={aiForecast}
|
|
||||||
aiPredictedMax={aiPredictedMax}
|
|
||||||
aiRangeHigh={aiRangeHigh}
|
|
||||||
aiRangeLow={aiRangeLow}
|
|
||||||
aiReadCompleteText={aiReadCompleteText}
|
|
||||||
aiReadInProgressText={aiReadInProgressText}
|
|
||||||
aiRuleEvidenceMode={aiRuleEvidenceMode}
|
|
||||||
aiRuleEvidenceText={aiRuleEvidenceText}
|
|
||||||
debPrediction={debPrediction}
|
|
||||||
fallbackAiReason={fallbackAiReason}
|
|
||||||
isEn={isEn}
|
|
||||||
isHkoObservation={isHkoObservation}
|
|
||||||
localModelSupportNote={localModelSupportNote}
|
|
||||||
localizedFinalJudgment={localizedFinalJudgment}
|
|
||||||
tempSymbol={tempSymbol}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<details
|
|
||||||
className="scan-ai-city-section scan-mobile-fold"
|
|
||||||
open={modelOpen}
|
|
||||||
onToggle={(event) => setModelOpen(event.currentTarget.open)}
|
|
||||||
>
|
|
||||||
<summary className="scan-ai-city-section-title">{copy.modelEvidence}</summary>
|
|
||||||
{modelOpen ? <ModelEvidencePanel detail={detail} isEn={isEn} /> : null}
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details
|
|
||||||
className="scan-ai-city-section scan-mobile-fold"
|
|
||||||
open={chartOpen}
|
|
||||||
onToggle={(event) => setChartOpen(event.currentTarget.open)}
|
|
||||||
>
|
|
||||||
<summary className="scan-ai-city-section-title">{copy.chart}</summary>
|
|
||||||
{chartOpen ? <AiCityTemperatureChart detail={detail} /> : null}
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ModelForecast } from "@/components/dashboard/PanelSections";
|
|
||||||
import type { CityDetail } from "@/lib/dashboard-types";
|
|
||||||
|
|
||||||
export function ModelEvidencePanel({
|
|
||||||
detail,
|
|
||||||
isEn,
|
|
||||||
}: {
|
|
||||||
detail: CityDetail;
|
|
||||||
isEn: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<section className="scan-ai-city-section models">
|
|
||||||
<div className="scan-ai-city-section-title">
|
|
||||||
{isEn ? "Evidence · multi-model support" : "证据 · 多模型支撑"}
|
|
||||||
</div>
|
|
||||||
<ModelForecast detail={detail} targetDate={detail.local_date} hideTitle />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
|
||||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
|
||||||
import type { useDashboardStore } from "@/hooks/useDashboardStore";
|
|
||||||
import {
|
|
||||||
findDetailForCity,
|
|
||||||
isFullEnoughForDeepAnalysis,
|
|
||||||
} from "@/components/dashboard/scan-terminal/city-detail-utils";
|
|
||||||
import {
|
|
||||||
findRowForCity,
|
|
||||||
normalizeCityKey,
|
|
||||||
prettifyCityName,
|
|
||||||
} from "@/components/dashboard/scan-terminal/decision-utils";
|
|
||||||
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
|
|
||||||
|
|
||||||
type DashboardStore = ReturnType<typeof useDashboardStore>;
|
|
||||||
|
|
||||||
export function useAiPinnedCityWorkspace({
|
|
||||||
locale,
|
|
||||||
store,
|
|
||||||
timeSortedRows,
|
|
||||||
}: {
|
|
||||||
locale: string;
|
|
||||||
store: DashboardStore;
|
|
||||||
timeSortedRows: ScanOpportunityRow[];
|
|
||||||
}) {
|
|
||||||
const [aiPinnedCities, setAiPinnedCities] = useState<AiPinnedCity[]>([]);
|
|
||||||
const aiFullHydrationRef = useRef<Set<string>>(new Set());
|
|
||||||
const aiHydrationQueueRef = useRef<string[]>([]);
|
|
||||||
const aiHydrationRunningRef = useRef(false);
|
|
||||||
const aiHydrationRetriesRef = useRef<Map<string, number>>(new Map());
|
|
||||||
|
|
||||||
const runAiHydrationQueue = useCallback(async () => {
|
|
||||||
if (aiHydrationRunningRef.current) return;
|
|
||||||
aiHydrationRunningRef.current = true;
|
|
||||||
try {
|
|
||||||
while (aiHydrationQueueRef.current.length > 0) {
|
|
||||||
const nextCity = aiHydrationQueueRef.current.shift();
|
|
||||||
const key = normalizeCityKey(nextCity || "");
|
|
||||||
if (!nextCity || !key) continue;
|
|
||||||
try {
|
|
||||||
const detail = await store.ensureCityDetail(
|
|
||||||
nextCity,
|
|
||||||
false,
|
|
||||||
"full",
|
|
||||||
);
|
|
||||||
if (!isFullEnoughForDeepAnalysis(detail)) {
|
|
||||||
const retries = aiHydrationRetriesRef.current.get(key) || 0;
|
|
||||||
if (retries >= 3) {
|
|
||||||
aiFullHydrationRef.current.delete(key);
|
|
||||||
aiHydrationRetriesRef.current.delete(key);
|
|
||||||
} else {
|
|
||||||
aiHydrationRetriesRef.current.set(key, retries + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
const retries = aiHydrationRetriesRef.current.get(key) || 0;
|
|
||||||
if (retries >= 3) {
|
|
||||||
aiFullHydrationRef.current.delete(key);
|
|
||||||
aiHydrationRetriesRef.current.delete(key);
|
|
||||||
} else {
|
|
||||||
aiHydrationRetriesRef.current.set(key, retries + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
aiHydrationRunningRef.current = false;
|
|
||||||
if (aiHydrationQueueRef.current.length > 0) {
|
|
||||||
void runAiHydrationQueue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [store.ensureCityDetail]);
|
|
||||||
|
|
||||||
const queueAiFullHydration = useCallback(
|
|
||||||
(cityName: string) => {
|
|
||||||
const key = normalizeCityKey(cityName);
|
|
||||||
if (!key || aiFullHydrationRef.current.has(key)) return;
|
|
||||||
aiFullHydrationRef.current.add(key);
|
|
||||||
aiHydrationQueueRef.current.push(cityName);
|
|
||||||
void runAiHydrationQueue();
|
|
||||||
},
|
|
||||||
[runAiHydrationQueue],
|
|
||||||
);
|
|
||||||
|
|
||||||
const addAiPinnedCity = useCallback((cityName: string) => {
|
|
||||||
const cleanName = String(cityName || "").trim();
|
|
||||||
const key = normalizeCityKey(cleanName);
|
|
||||||
if (!key) return;
|
|
||||||
const matchedRow = findRowForCity(timeSortedRows, cleanName);
|
|
||||||
const prettyName = prettifyCityName(cleanName);
|
|
||||||
const displayName =
|
|
||||||
matchedRow?.city_display_name ||
|
|
||||||
matchedRow?.display_name ||
|
|
||||||
getLocalizedCityName(cleanName, prettyName || cleanName, locale) ||
|
|
||||||
prettyName ||
|
|
||||||
cleanName;
|
|
||||||
// Clear the hydration guard so that re-selecting this city always
|
|
||||||
// triggers a fresh hydration attempt (fixes second-city loading failure).
|
|
||||||
aiFullHydrationRef.current.delete(key);
|
|
||||||
aiHydrationRetriesRef.current.delete(key);
|
|
||||||
setAiPinnedCities((current) => {
|
|
||||||
const existing = current.findIndex(
|
|
||||||
(item) => normalizeCityKey(item.cityName) === key,
|
|
||||||
);
|
|
||||||
const nextItem = {
|
|
||||||
cityName: matchedRow?.city || cleanName,
|
|
||||||
displayName,
|
|
||||||
addedAt: Date.now(),
|
|
||||||
};
|
|
||||||
if (existing >= 0) {
|
|
||||||
const next = [...current];
|
|
||||||
next[existing] = { ...next[existing], ...nextItem };
|
|
||||||
return [
|
|
||||||
next[existing],
|
|
||||||
...next.filter((_, index) => index !== existing),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [nextItem, ...current].slice(0, 8);
|
|
||||||
});
|
|
||||||
queueAiFullHydration(matchedRow?.city || cleanName);
|
|
||||||
}, [locale, queueAiFullHydration, timeSortedRows]);
|
|
||||||
|
|
||||||
const removeAiPinnedCity = useCallback((cityName: string) => {
|
|
||||||
const key = normalizeCityKey(cityName);
|
|
||||||
aiFullHydrationRef.current.delete(key);
|
|
||||||
aiHydrationQueueRef.current = aiHydrationQueueRef.current.filter(
|
|
||||||
(queuedCity) => normalizeCityKey(queuedCity) !== key,
|
|
||||||
);
|
|
||||||
setAiPinnedCities((current) =>
|
|
||||||
current.filter((item) => normalizeCityKey(item.cityName) !== key),
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refreshAiPinnedCityDetail = useCallback(
|
|
||||||
async (cityName: string) => {
|
|
||||||
const key = normalizeCityKey(cityName);
|
|
||||||
if (key) {
|
|
||||||
aiFullHydrationRef.current.delete(key);
|
|
||||||
}
|
|
||||||
await store.ensureCityDetail(cityName, true, "full");
|
|
||||||
},
|
|
||||||
[store.ensureCityDetail],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
aiPinnedCities.forEach((item) => {
|
|
||||||
const key = normalizeCityKey(item.cityName);
|
|
||||||
if (!key || aiFullHydrationRef.current.has(key)) return;
|
|
||||||
const retries = aiHydrationRetriesRef.current.get(key) || 0;
|
|
||||||
if (retries >= 3) return;
|
|
||||||
const detail = findDetailForCity(store.cityDetailsByName, item.cityName);
|
|
||||||
const needsFullHydration = !isFullEnoughForDeepAnalysis(detail);
|
|
||||||
if (!needsFullHydration) return;
|
|
||||||
queueAiFullHydration(item.cityName);
|
|
||||||
});
|
|
||||||
}, [aiPinnedCities, queueAiFullHydration, store.cityDetailsByName]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
addAiPinnedCity,
|
|
||||||
aiPinnedCities,
|
|
||||||
refreshAiPinnedCityDetail,
|
|
||||||
removeAiPinnedCity,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,575 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
CityDetail,
|
|
||||||
CityListItem,
|
|
||||||
CitySummary,
|
|
||||||
MarketScan,
|
|
||||||
ScanTerminalFilters,
|
|
||||||
ScanTerminalResponse,
|
|
||||||
} from "@/lib/dashboard-types";
|
|
||||||
import {
|
|
||||||
buildBrowserBackendHeaders,
|
|
||||||
fetchBackendApi,
|
|
||||||
} from "@/lib/backend-api";
|
|
||||||
import { formatHttpErrorMessage } from "@/lib/http-error";
|
|
||||||
|
|
||||||
const CACHE_KEY = "polyWeather_v2_chart_full_day";
|
|
||||||
const CACHE_TTL_MS = 30 * 60 * 1000;
|
|
||||||
const SCAN_TERMINAL_CLIENT_TIMEOUT_MS = 35_000;
|
|
||||||
const CITY_DETAIL_CLIENT_TIMEOUT_MS = 35_000;
|
|
||||||
const pendingCityDetailRequests = new Map<string, Promise<CityDetail>>();
|
|
||||||
const pendingCitySummaryRequests = new Map<string, Promise<CitySummary>>();
|
|
||||||
const pendingCityMarketScanRequests = new Map<
|
|
||||||
string,
|
|
||||||
Promise<{
|
|
||||||
fetched_at?: string | null;
|
|
||||||
market_scan?: MarketScan | null;
|
|
||||||
selected_date?: string | null;
|
|
||||||
}>
|
|
||||||
>();
|
|
||||||
const pendingScanTerminalRequests = new Map<string, Promise<ScanTerminalResponse>>();
|
|
||||||
const pendingScanTerminalAiRequests = new Map<string, Promise<ScanTerminalResponse>>();
|
|
||||||
const PRIORITY_WARM_SESSION_KEY = "polyWeather_priority_warm_v1";
|
|
||||||
|
|
||||||
type CityCacheMeta = {
|
|
||||||
cachedAt: number;
|
|
||||||
revision: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CityCacheBundle = {
|
|
||||||
details: Record<string, CityDetail>;
|
|
||||||
meta: Record<string, CityCacheMeta>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeCityName(cityName: string) {
|
|
||||||
return encodeURIComponent(String(cityName).replace(/\s/g, "-"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDetailDepth(depth?: "panel" | "market" | "nearby" | "full") {
|
|
||||||
if (depth === "full") return "full";
|
|
||||||
if (depth === "nearby") return "nearby";
|
|
||||||
if (depth === "market") return "market";
|
|
||||||
return "panel";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJson<T>(
|
|
||||||
url: string,
|
|
||||||
options?: { cache?: RequestCache; timeoutMs?: number },
|
|
||||||
): Promise<T> {
|
|
||||||
const timeoutMs = options?.timeoutMs;
|
|
||||||
const controller =
|
|
||||||
timeoutMs && typeof AbortController !== "undefined"
|
|
||||||
? new AbortController()
|
|
||||||
: null;
|
|
||||||
const timeoutId =
|
|
||||||
controller &&
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
typeof window.setTimeout === "function"
|
|
||||||
? window.setTimeout(() => controller.abort(), timeoutMs)
|
|
||||||
: null;
|
|
||||||
const headers = await buildBrowserBackendHeaders({
|
|
||||||
Accept: "application/json",
|
|
||||||
});
|
|
||||||
|
|
||||||
let response: Response;
|
|
||||||
try {
|
|
||||||
response = await fetchBackendApi(url, {
|
|
||||||
headers,
|
|
||||||
cache: options?.cache ?? "default",
|
|
||||||
signal: controller?.signal,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (controller?.signal.aborted) {
|
|
||||||
throw new Error("Request timed out");
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
if (timeoutId != null) {
|
|
||||||
if (typeof window.clearTimeout === "function") {
|
|
||||||
window.clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.text().catch(() => "");
|
|
||||||
throw new Error(
|
|
||||||
formatHttpErrorMessage(response.status, response.statusText, body),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStaleMarketSlugResponse(payload: {
|
|
||||||
market_scan?: MarketScan | null;
|
|
||||||
} | null | undefined) {
|
|
||||||
const scan = payload?.market_scan;
|
|
||||||
const reason = String(scan?.reason || "").toLowerCase();
|
|
||||||
return (
|
|
||||||
scan?.available === false &&
|
|
||||||
(reason.includes("market_slug not found") ||
|
|
||||||
reason.includes("specified market_slug not found") ||
|
|
||||||
reason.includes("slug not found"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isClient() {
|
|
||||||
return typeof window !== "undefined";
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRevisionPart(value: unknown) {
|
|
||||||
return value == null ? "" : String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCityRevision(source?: CityDetail | CitySummary | null) {
|
|
||||||
if (!source) return "";
|
|
||||||
const modelDaily =
|
|
||||||
"multi_model_daily" in source && source.multi_model_daily
|
|
||||||
? source.multi_model_daily?.[source.local_date || ""]
|
|
||||||
: null;
|
|
||||||
const modelFootprint = modelDaily?.models || ("multi_model" in source ? source.multi_model : null);
|
|
||||||
const forecastFootprint =
|
|
||||||
"forecast" in source && Array.isArray(source.forecast?.daily)
|
|
||||||
? source.forecast.daily
|
|
||||||
.map((item) => `${normalizeRevisionPart(item?.date)}:${normalizeRevisionPart(item?.max_temp)}`)
|
|
||||||
.join("|")
|
|
||||||
: "";
|
|
||||||
const marketScan = "market_scan" in source ? source.market_scan : null;
|
|
||||||
const marketFootprint = marketScan
|
|
||||||
? [
|
|
||||||
normalizeRevisionPart(marketScan.selected_slug),
|
|
||||||
normalizeRevisionPart(marketScan.market_price),
|
|
||||||
normalizeRevisionPart(marketScan.yes_buy),
|
|
||||||
normalizeRevisionPart(marketScan.yes_sell),
|
|
||||||
normalizeRevisionPart(marketScan.no_buy),
|
|
||||||
normalizeRevisionPart(marketScan.no_sell),
|
|
||||||
normalizeRevisionPart(marketScan.price_analysis?.best_side),
|
|
||||||
normalizeRevisionPart(
|
|
||||||
Array.isArray(marketScan.all_buckets)
|
|
||||||
? marketScan.all_buckets
|
|
||||||
.slice(0, 6)
|
|
||||||
.map(
|
|
||||||
(bucket) =>
|
|
||||||
`${normalizeRevisionPart(bucket?.temp ?? bucket?.value)}:${normalizeRevisionPart(
|
|
||||||
bucket?.market_price ?? bucket?.yes_buy,
|
|
||||||
)}`,
|
|
||||||
)
|
|
||||||
.join(",")
|
|
||||||
: "",
|
|
||||||
),
|
|
||||||
].join("|")
|
|
||||||
: "";
|
|
||||||
return [
|
|
||||||
normalizeRevisionPart(source.updated_at),
|
|
||||||
normalizeRevisionPart(source.current?.obs_time),
|
|
||||||
normalizeRevisionPart(source.current?.temp),
|
|
||||||
normalizeRevisionPart(source.deb?.prediction),
|
|
||||||
normalizeRevisionPart(
|
|
||||||
modelFootprint && typeof modelFootprint === "object"
|
|
||||||
? Object.keys(modelFootprint)
|
|
||||||
.sort()
|
|
||||||
.map((key) => `${key}:${normalizeRevisionPart(modelFootprint[key])}`)
|
|
||||||
.join("|")
|
|
||||||
: "",
|
|
||||||
),
|
|
||||||
normalizeRevisionPart(forecastFootprint),
|
|
||||||
normalizeRevisionPart(marketFootprint),
|
|
||||||
].join("|");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toCitySummary(detail: CityDetail): CitySummary {
|
|
||||||
return {
|
|
||||||
name: detail.name,
|
|
||||||
display_name: detail.display_name,
|
|
||||||
icao: detail.risk?.icao,
|
|
||||||
local_time: detail.local_time,
|
|
||||||
temp_symbol: detail.temp_symbol,
|
|
||||||
current: {
|
|
||||||
obs_time: detail.current?.obs_time,
|
|
||||||
temp: detail.current?.temp,
|
|
||||||
},
|
|
||||||
deb: {
|
|
||||||
prediction: detail.deb?.prediction,
|
|
||||||
},
|
|
||||||
deviation_monitor: detail.deviation_monitor,
|
|
||||||
risk: {
|
|
||||||
level: detail.risk?.level,
|
|
||||||
warning: detail.risk?.warning,
|
|
||||||
},
|
|
||||||
updated_at: detail.updated_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFresh(meta?: CityCacheMeta | null) {
|
|
||||||
return Boolean(meta && Date.now() - meta.cachedAt < CACHE_TTL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readLegacyCache(raw: string): CityCacheBundle {
|
|
||||||
const parsed = JSON.parse(raw) as {
|
|
||||||
timestamp?: number;
|
|
||||||
data?: Record<string, CityDetail>;
|
|
||||||
};
|
|
||||||
const details = parsed.data || {};
|
|
||||||
const cachedAt = parsed.timestamp || 0;
|
|
||||||
const freshDetails: Record<string, CityDetail> = {};
|
|
||||||
const meta: Record<string, CityCacheMeta> = {};
|
|
||||||
Object.entries(details).forEach(([cityName, detail]) => {
|
|
||||||
const nextMeta = {
|
|
||||||
cachedAt,
|
|
||||||
revision: getCityRevision(detail),
|
|
||||||
};
|
|
||||||
if (!isFresh(nextMeta)) return;
|
|
||||||
freshDetails[cityName] = detail;
|
|
||||||
meta[cityName] = nextMeta;
|
|
||||||
});
|
|
||||||
return { details: freshDetails, meta };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dashboardClient = {
|
|
||||||
clearCityDetailCache() {
|
|
||||||
if (!isClient()) return;
|
|
||||||
try {
|
|
||||||
window.sessionStorage?.removeItem(CACHE_KEY);
|
|
||||||
} catch {
|
|
||||||
// Storage can be unavailable in embedded/private browser contexts.
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async getCities() {
|
|
||||||
const data = await fetchJson<{ cities?: CityListItem[] }>("/api/cities");
|
|
||||||
return data.cities || [];
|
|
||||||
},
|
|
||||||
|
|
||||||
sendPriorityWarmHint(timezone?: string | null) {
|
|
||||||
if (!isClient()) return;
|
|
||||||
if (
|
|
||||||
typeof fetch !== "function" ||
|
|
||||||
typeof Intl === "undefined" ||
|
|
||||||
!window.sessionStorage
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tz = String(
|
|
||||||
timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "",
|
|
||||||
).trim();
|
|
||||||
if (!tz) return;
|
|
||||||
const cacheKey = `${PRIORITY_WARM_SESSION_KEY}:${tz}`;
|
|
||||||
try {
|
|
||||||
if (window.sessionStorage.getItem(cacheKey)) return;
|
|
||||||
window.sessionStorage.setItem(cacheKey, "1");
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const params = new URLSearchParams({ timezone: tz });
|
|
||||||
void fetch(`/api/system/priority-warm?${params.toString()}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
cache: "default",
|
|
||||||
keepalive: true,
|
|
||||||
}).catch(() => {});
|
|
||||||
},
|
|
||||||
|
|
||||||
async getCitySummary(cityName: string, options?: { force?: boolean }) {
|
|
||||||
const force = options?.force ?? false;
|
|
||||||
const requestKey = `${cityName}::${force ? "force" : "cached"}`;
|
|
||||||
const existing = pendingCitySummaryRequests.get(requestKey);
|
|
||||||
if (existing) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = fetchJson<CitySummary>(
|
|
||||||
`/api/city/${normalizeCityName(cityName)}/summary?force_refresh=${force}`,
|
|
||||||
force ? { cache: "no-store" } : undefined,
|
|
||||||
).finally(() => {
|
|
||||||
pendingCitySummaryRequests.delete(requestKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
pendingCitySummaryRequests.set(requestKey, request);
|
|
||||||
return request;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getCityDetail(
|
|
||||||
cityName: string,
|
|
||||||
options?: { force?: boolean; depth?: "panel" | "market" | "nearby" | "full" },
|
|
||||||
) {
|
|
||||||
const force = options?.force ?? false;
|
|
||||||
const depth = normalizeDetailDepth(options?.depth);
|
|
||||||
if (!force) {
|
|
||||||
const requestKey = `${cityName}::${depth}::cached`;
|
|
||||||
const existing = pendingCityDetailRequests.get(requestKey);
|
|
||||||
if (existing) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = fetchJson<CityDetail>(
|
|
||||||
`/api/city/${normalizeCityName(cityName)}?force_refresh=false&depth=${depth}`,
|
|
||||||
{ timeoutMs: CITY_DETAIL_CLIENT_TIMEOUT_MS },
|
|
||||||
).finally(() => {
|
|
||||||
pendingCityDetailRequests.delete(requestKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
pendingCityDetailRequests.set(requestKey, request);
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
force_refresh: "true",
|
|
||||||
depth,
|
|
||||||
_ts: String(Date.now()),
|
|
||||||
});
|
|
||||||
return fetchJson<CityDetail>(
|
|
||||||
`/api/city/${normalizeCityName(cityName)}?${params.toString()}`,
|
|
||||||
{ cache: "no-store", timeoutMs: CITY_DETAIL_CLIENT_TIMEOUT_MS },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async getCityMarketScan(
|
|
||||||
cityName: string,
|
|
||||||
options?: {
|
|
||||||
force?: boolean;
|
|
||||||
lite?: boolean;
|
|
||||||
marketSlug?: string | null;
|
|
||||||
targetDate?: string | null;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const force = options?.force ?? false;
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
force_refresh: String(force),
|
|
||||||
_ts: String(Date.now()),
|
|
||||||
});
|
|
||||||
if (options?.targetDate) {
|
|
||||||
params.set("target_date", options.targetDate);
|
|
||||||
}
|
|
||||||
if (options?.marketSlug) {
|
|
||||||
params.set("market_slug", options.marketSlug);
|
|
||||||
}
|
|
||||||
if (options?.lite) {
|
|
||||||
params.set("lite", "true");
|
|
||||||
}
|
|
||||||
const requestKey = [
|
|
||||||
cityName,
|
|
||||||
force ? "force" : "cached",
|
|
||||||
options?.lite ? "lite" : "full",
|
|
||||||
options?.targetDate || "",
|
|
||||||
options?.marketSlug || "",
|
|
||||||
].join("::");
|
|
||||||
if (!force) {
|
|
||||||
const existing = pendingCityMarketScanRequests.get(requestKey);
|
|
||||||
if (existing) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
type MarketScanPayload = {
|
|
||||||
fetched_at?: string | null;
|
|
||||||
market_scan?: MarketScan | null;
|
|
||||||
selected_date?: string | null;
|
|
||||||
};
|
|
||||||
const request = (async () => {
|
|
||||||
const payload = await fetchJson<MarketScanPayload>(
|
|
||||||
`/api/city/${normalizeCityName(cityName)}/market-scan?${params.toString()}`,
|
|
||||||
force ? { cache: "no-store" } : undefined,
|
|
||||||
);
|
|
||||||
if (!force && options?.marketSlug && isStaleMarketSlugResponse(payload)) {
|
|
||||||
const fallbackParams = new URLSearchParams({
|
|
||||||
force_refresh: "false",
|
|
||||||
_ts: String(Date.now()),
|
|
||||||
});
|
|
||||||
if (options?.lite) {
|
|
||||||
fallbackParams.set("lite", "true");
|
|
||||||
}
|
|
||||||
return fetchJson<MarketScanPayload>(
|
|
||||||
`/api/city/${normalizeCityName(cityName)}/market-scan?${fallbackParams.toString()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return payload;
|
|
||||||
})().finally(() => {
|
|
||||||
pendingCityMarketScanRequests.delete(requestKey);
|
|
||||||
});
|
|
||||||
if (!force) {
|
|
||||||
pendingCityMarketScanRequests.set(requestKey, request);
|
|
||||||
}
|
|
||||||
return request;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getScanTerminal(
|
|
||||||
filters: ScanTerminalFilters,
|
|
||||||
options?: { force?: boolean },
|
|
||||||
) {
|
|
||||||
const force = options?.force ?? false;
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
scan_mode: String(filters.scan_mode),
|
|
||||||
min_price: String(filters.min_price),
|
|
||||||
max_price: String(filters.max_price),
|
|
||||||
min_edge_pct: String(filters.min_edge_pct),
|
|
||||||
min_liquidity: String(filters.min_liquidity),
|
|
||||||
high_liquidity_only: String(filters.high_liquidity_only),
|
|
||||||
market_type: String(filters.market_type),
|
|
||||||
time_range: String(filters.time_range),
|
|
||||||
limit: String(filters.limit),
|
|
||||||
force_refresh: String(force),
|
|
||||||
});
|
|
||||||
const requestKey = `${params.toString()}::${force ? "force" : "cached"}`;
|
|
||||||
if (!force) {
|
|
||||||
const existing = pendingScanTerminalRequests.get(requestKey);
|
|
||||||
if (existing) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const request = fetchJson<ScanTerminalResponse>(
|
|
||||||
`/api/scan/terminal?${params.toString()}`,
|
|
||||||
{
|
|
||||||
cache: force ? "no-store" : "default",
|
|
||||||
timeoutMs: SCAN_TERMINAL_CLIENT_TIMEOUT_MS,
|
|
||||||
},
|
|
||||||
).finally(() => {
|
|
||||||
pendingScanTerminalRequests.delete(requestKey);
|
|
||||||
});
|
|
||||||
if (!force) {
|
|
||||||
pendingScanTerminalRequests.set(requestKey, request);
|
|
||||||
}
|
|
||||||
return request;
|
|
||||||
},
|
|
||||||
|
|
||||||
async reviewScanTerminalWithAi(payload: {
|
|
||||||
filters: ScanTerminalFilters;
|
|
||||||
snapshotId?: string | null;
|
|
||||||
}) {
|
|
||||||
const snapshotId = String(payload.snapshotId || "").trim();
|
|
||||||
const requestKey = [
|
|
||||||
snapshotId || "latest",
|
|
||||||
JSON.stringify(payload.filters || {}),
|
|
||||||
].join("::");
|
|
||||||
const existing = pendingScanTerminalAiRequests.get(requestKey);
|
|
||||||
if (existing) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
const request = buildBrowserBackendHeaders({
|
|
||||||
Accept: "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}).then((headers) => fetchBackendApi("/api/scan/terminal/ai", {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
cache: "default",
|
|
||||||
body: JSON.stringify({
|
|
||||||
filters: payload.filters,
|
|
||||||
snapshot_id: snapshotId || null,
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
.then(async (response) => {
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.text().catch(() => "");
|
|
||||||
throw new Error(
|
|
||||||
formatHttpErrorMessage(response.status, response.statusText, body),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return response.json() as Promise<ScanTerminalResponse>;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
pendingScanTerminalAiRequests.delete(requestKey);
|
|
||||||
});
|
|
||||||
pendingScanTerminalAiRequests.set(requestKey, request);
|
|
||||||
return request;
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
isCityDetailFresh(meta?: CityCacheMeta | null) {
|
|
||||||
return isFresh(meta);
|
|
||||||
},
|
|
||||||
|
|
||||||
readCityDetailCacheBundle() {
|
|
||||||
if (!isClient()) {
|
|
||||||
return {
|
|
||||||
details: {},
|
|
||||||
meta: {},
|
|
||||||
} satisfies CityCacheBundle;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cached = window.sessionStorage.getItem(CACHE_KEY);
|
|
||||||
if (!cached) {
|
|
||||||
return {
|
|
||||||
details: {},
|
|
||||||
meta: {},
|
|
||||||
} satisfies CityCacheBundle;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = JSON.parse(cached) as
|
|
||||||
| {
|
|
||||||
entries?: Record<
|
|
||||||
string,
|
|
||||||
{ cachedAt?: number; detail?: CityDetail; revision?: string }
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
timestamp?: number;
|
|
||||||
data?: Record<string, CityDetail>;
|
|
||||||
};
|
|
||||||
|
|
||||||
if ("entries" in parsed && parsed.entries) {
|
|
||||||
const details: Record<string, CityDetail> = {};
|
|
||||||
const meta: Record<string, CityCacheMeta> = {};
|
|
||||||
Object.entries(parsed.entries).forEach(([cityName, entry]) => {
|
|
||||||
if (!entry?.detail) return;
|
|
||||||
const nextMeta = {
|
|
||||||
cachedAt: entry.cachedAt || 0,
|
|
||||||
revision: entry.revision || getCityRevision(entry.detail),
|
|
||||||
};
|
|
||||||
if (!isFresh(nextMeta)) return;
|
|
||||||
details[cityName] = entry.detail;
|
|
||||||
meta[cityName] = nextMeta;
|
|
||||||
});
|
|
||||||
return { details, meta };
|
|
||||||
}
|
|
||||||
|
|
||||||
return readLegacyCache(cached);
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
details: {},
|
|
||||||
meta: {},
|
|
||||||
} satisfies CityCacheBundle;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
readCityDetailCache() {
|
|
||||||
return this.readCityDetailCacheBundle().details;
|
|
||||||
},
|
|
||||||
|
|
||||||
writeCityDetailCacheBundle(
|
|
||||||
details: Record<string, CityDetail>,
|
|
||||||
meta: Record<string, CityCacheMeta>,
|
|
||||||
) {
|
|
||||||
if (!isClient()) return;
|
|
||||||
// Keep only the 12 most-recently-accessed cities to prevent sessionStorage bloat
|
|
||||||
const MAX_CACHED_CITIES = 12;
|
|
||||||
const allEntries = Object.entries(details).map(([cityName, detail]) => ({
|
|
||||||
cityName,
|
|
||||||
cachedAt: meta[cityName]?.cachedAt || 0,
|
|
||||||
detail,
|
|
||||||
revision: meta[cityName]?.revision || getCityRevision(detail),
|
|
||||||
}));
|
|
||||||
const topEntries = allEntries
|
|
||||||
.sort((a, b) => b.cachedAt - a.cachedAt)
|
|
||||||
.slice(0, MAX_CACHED_CITIES);
|
|
||||||
const entries = Object.fromEntries(
|
|
||||||
topEntries.map((e) => [e.cityName, { cachedAt: e.cachedAt, detail: e.detail, revision: e.revision }]),
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
window.sessionStorage?.setItem(CACHE_KEY, JSON.stringify({ entries }));
|
|
||||||
} catch {
|
|
||||||
// Storage can be unavailable in embedded/private browser contexts.
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
writeCityDetailCache(data: Record<string, CityDetail>) {
|
|
||||||
const now = Date.now();
|
|
||||||
const meta = Object.fromEntries(
|
|
||||||
Object.entries(data).map(([cityName, detail]) => [
|
|
||||||
cityName,
|
|
||||||
{ cachedAt: now, revision: getCityRevision(detail) },
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
this.writeCityDetailCacheBundle(data, meta);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user