清理旧 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
|
||||
a single className, keeping ScanTerminalDashboard.tsx lean. */
|
||||
|
||||
/* Barrel: pre-combined scan-terminal root class name. */
|
||||
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 scanTerminalBoardStyles from "./ScanTerminalBoard.module.css";
|
||||
import scanTerminalCardStyles from "./ScanTerminalCard.module.css";
|
||||
@@ -23,8 +18,6 @@ import scanTerminalContinentStyles from "./ScanTerminalContinent.module.css";
|
||||
import scanTerminalStateStyles from "./ScanTerminalState.module.css";
|
||||
|
||||
export const scanRootClass = clsx(
|
||||
dashboardShellStyles.root,
|
||||
dashboardModalGuideStyles.root,
|
||||
scanTerminalStyles.root,
|
||||
scanTerminalShellStyles.root,
|
||||
scanTerminalFiltersStyles.root,
|
||||
@@ -36,8 +29,4 @@ export const scanRootClass = clsx(
|
||||
scanTerminalCardStyles.root,
|
||||
scanTerminalContinentStyles.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