Add map city detail panel and guard forecast modal
This commit is contained in:
@@ -711,15 +711,18 @@ export function FutureForecastModal() {
|
|||||||
const dateStr = store.futureModalDate;
|
const dateStr = store.futureModalDate;
|
||||||
const isPro = store.proAccess.subscriptionActive;
|
const isPro = store.proAccess.subscriptionActive;
|
||||||
const isProLoading = store.proAccess.loading;
|
const isProLoading = store.proAccess.loading;
|
||||||
|
const hasModalContext = Boolean(detail && dateStr);
|
||||||
const [showDeferredTodaySections, setShowDeferredTodaySections] =
|
const [showDeferredTodaySections, setShowDeferredTodaySections] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [freshMarketScan, setFreshMarketScan] = useState<MarketScan | null>(
|
const [freshMarketScan, setFreshMarketScan] = useState<MarketScan | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!detail || !dateStr) return null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!hasModalContext) {
|
||||||
|
setShowDeferredTodaySections(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setShowDeferredTodaySections(false);
|
setShowDeferredTodaySections(false);
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
setShowDeferredTodaySections(true);
|
setShowDeferredTodaySections(true);
|
||||||
@@ -750,23 +753,23 @@ export function FutureForecastModal() {
|
|||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [dateStr, detail]);
|
}, [dateStr, detail, hasModalContext]);
|
||||||
|
|
||||||
const isToday =
|
const isToday =
|
||||||
store.forecastModalMode === "today" ||
|
store.forecastModalMode === "today" ||
|
||||||
(store.forecastModalMode == null && dateStr === detail.local_date);
|
(store.forecastModalMode == null && dateStr === detail?.local_date);
|
||||||
const detailDepth = detail.detail_depth || "full";
|
const detailDepth = detail?.detail_depth || "full";
|
||||||
const isFullDetailReady = detailDepth === "full";
|
const isFullDetailReady = detailDepth === "full";
|
||||||
const isStructureSyncing =
|
const isStructureSyncing =
|
||||||
store.loadingState.futureDeep || !isFullDetailReady;
|
store.loadingState.futureDeep || !isFullDetailReady;
|
||||||
const isAnyLayerSyncing = isStructureSyncing;
|
const isAnyLayerSyncing = isStructureSyncing;
|
||||||
const isTodayBlockingRefresh = isToday && isStructureSyncing;
|
const isTodayBlockingRefresh = isToday && isStructureSyncing;
|
||||||
const activeMarketScan = freshMarketScan || detail.market_scan || null;
|
const activeMarketScan = freshMarketScan || detail?.market_scan || null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFreshMarketScan(null);
|
setFreshMarketScan(null);
|
||||||
if (!isToday || !isFullDetailReady || !isPro) return;
|
if (!hasModalContext || !isToday || !isFullDetailReady || !isPro) return;
|
||||||
const cityName = String(detail.name || detail.display_name || "").trim();
|
const cityName = String(detail?.name || detail?.display_name || "").trim();
|
||||||
if (!cityName || !dateStr) return;
|
if (!cityName || !dateStr) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -777,7 +780,7 @@ export function FutureForecastModal() {
|
|||||||
.getCityMarketScan(cityName, {
|
.getCityMarketScan(cityName, {
|
||||||
force: false,
|
force: false,
|
||||||
lite: false,
|
lite: false,
|
||||||
marketSlug: detail.market_scan?.primary_market?.slug || null,
|
marketSlug: detail?.market_scan?.primary_market?.slug || null,
|
||||||
targetDate: dateStr,
|
targetDate: dateStr,
|
||||||
})
|
})
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
@@ -810,16 +813,19 @@ export function FutureForecastModal() {
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
dateStr,
|
dateStr,
|
||||||
detail.display_name,
|
detail?.display_name,
|
||||||
detail.local_date,
|
detail?.local_date,
|
||||||
detail.market_scan?.primary_market?.slug,
|
detail?.market_scan?.primary_market?.slug,
|
||||||
detail.name,
|
detail?.name,
|
||||||
detail.updated_at,
|
detail?.updated_at,
|
||||||
|
hasModalContext,
|
||||||
isFullDetailReady,
|
isFullDetailReady,
|
||||||
isPro,
|
isPro,
|
||||||
isToday,
|
isToday,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (!detail || !dateStr) return null;
|
||||||
|
|
||||||
const view = getFutureModalView(detail, dateStr, locale);
|
const view = getFutureModalView(detail, dateStr, locale);
|
||||||
const scorePosition = `${50 + view.front.score / 2}%`;
|
const scorePosition = `${50 + view.front.score / 2}%`;
|
||||||
const barStyle = {
|
const barStyle = {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
||||||
import { dashboardClient } from "@/lib/dashboard-client";
|
import { dashboardClient } from "@/lib/dashboard-client";
|
||||||
import type {
|
import type {
|
||||||
|
CityDetail,
|
||||||
DistributionPreviewPoint,
|
DistributionPreviewPoint,
|
||||||
MarketScan,
|
MarketScan,
|
||||||
PrimarySignal,
|
PrimarySignal,
|
||||||
@@ -238,6 +239,122 @@ function buildComparisonBuckets(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MapCityDetailPanel({ detail }: { detail: CityDetail | null }) {
|
||||||
|
const { locale } = useI18n();
|
||||||
|
const isEn = locale === "en-US";
|
||||||
|
|
||||||
|
if (!detail) {
|
||||||
|
return (
|
||||||
|
<aside className="scan-detail-panel">
|
||||||
|
<div className="scan-empty-state">
|
||||||
|
<div className="scan-empty-title">
|
||||||
|
{isEn ? "Pick a city" : "选择一个城市"}
|
||||||
|
</div>
|
||||||
|
<div className="scan-empty-copy">
|
||||||
|
{isEn ? "The right rail will show city context from the map." : "右侧会展示地图里当前城市的信息。"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cityName = getLocalizedCityName(
|
||||||
|
detail.name,
|
||||||
|
detail.display_name || detail.name,
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
const tempSymbol = detail.temp_symbol || "°C";
|
||||||
|
const currentTemp = detail.current?.temp;
|
||||||
|
const dayHigh =
|
||||||
|
detail.current?.max_so_far ??
|
||||||
|
detail.airport_current?.max_so_far ??
|
||||||
|
detail.airport_primary?.max_so_far ??
|
||||||
|
null;
|
||||||
|
const airportLabel = getLocalizedAirportName(
|
||||||
|
detail.name,
|
||||||
|
detail.risk?.airport || detail.risk?.icao || "",
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
const peakLabel =
|
||||||
|
Number.isFinite(Number(detail.peak?.first_h)) && Number.isFinite(Number(detail.peak?.last_h))
|
||||||
|
? `${String(Number(detail.peak?.first_h)).padStart(2, "0")}:00-${String(
|
||||||
|
Number(detail.peak?.last_h) + 1,
|
||||||
|
).padStart(2, "0")}:00`
|
||||||
|
: "--";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="scan-detail-panel">
|
||||||
|
<div className="scan-detail-header">
|
||||||
|
<div className="scan-detail-top">
|
||||||
|
<div className="scan-detail-title-wrap">
|
||||||
|
<div className="scan-detail-city-name">{cityName}</div>
|
||||||
|
<div className="scan-detail-city-sub">
|
||||||
|
{airportLabel || (isEn ? "Airport anchor pending" : "机场锚点待确认")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scan-detail-volume-row">
|
||||||
|
<div>
|
||||||
|
<div className="scan-detail-volume-big">
|
||||||
|
{currentTemp != null
|
||||||
|
? formatTemperatureValue(currentTemp, tempSymbol)
|
||||||
|
: "--"}
|
||||||
|
</div>
|
||||||
|
<div className="scan-detail-volume-caption">
|
||||||
|
{isEn ? "Current observed temperature" : "当前实测温度"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="scan-detail-section">
|
||||||
|
<div className="scan-detail-section-title">
|
||||||
|
{isEn ? "Current Context" : "当前概况"}
|
||||||
|
</div>
|
||||||
|
<div className="scan-kv-list">
|
||||||
|
<div className="scan-kv">
|
||||||
|
<span>{isEn ? "Local Time" : "当前时间"}</span>
|
||||||
|
<strong>{detail.local_time || "--"}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="scan-kv">
|
||||||
|
<span>{isEn ? "Current Temp" : "当前温度"}</span>
|
||||||
|
<strong>
|
||||||
|
{currentTemp != null
|
||||||
|
? formatTemperatureValue(currentTemp, tempSymbol)
|
||||||
|
: "--"}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="scan-kv">
|
||||||
|
<span>{isEn ? "Day High (So Far)" : "今日最高(至今)"}</span>
|
||||||
|
<strong>
|
||||||
|
{dayHigh != null
|
||||||
|
? formatTemperatureValue(dayHigh, tempSymbol)
|
||||||
|
: "--"}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="scan-kv">
|
||||||
|
<span>{isEn ? "DEB Forecast" : "DEB 预测"}</span>
|
||||||
|
<strong>
|
||||||
|
{detail.deb?.prediction != null
|
||||||
|
? formatTemperatureValue(detail.deb.prediction, tempSymbol)
|
||||||
|
: "--"}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="scan-kv">
|
||||||
|
<span>{isEn ? "Peak Window" : "预计峰值"}</span>
|
||||||
|
<strong>{peakLabel}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="scan-kv">
|
||||||
|
<span>{isEn ? "Airport" : "机场锚点"}</span>
|
||||||
|
<strong>{airportLabel || "--"}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DetailPanel({
|
function DetailPanel({
|
||||||
row,
|
row,
|
||||||
marketScan,
|
marketScan,
|
||||||
@@ -788,6 +905,8 @@ function ScanTerminalScreen() {
|
|||||||
}, [selectedRow, detailByRowId]);
|
}, [selectedRow, detailByRowId]);
|
||||||
|
|
||||||
const selectedDetail = selectedRow ? detailByRowId[selectedRow.id] : null;
|
const selectedDetail = selectedRow ? detailByRowId[selectedRow.id] : null;
|
||||||
|
const mapFocusedDetail = store.selectedDetail;
|
||||||
|
const mapFocusedCity = store.selectedCity;
|
||||||
const resolvedView: ContentView =
|
const resolvedView: ContentView =
|
||||||
activeSection === "markets"
|
activeSection === "markets"
|
||||||
? "map"
|
? "map"
|
||||||
@@ -982,11 +1101,15 @@ function ScanTerminalScreen() {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<DetailPanel
|
{resolvedView === "map" && mapFocusedCity ? (
|
||||||
row={selectedRow}
|
<MapCityDetailPanel detail={mapFocusedDetail} />
|
||||||
marketScan={selectedDetail}
|
) : (
|
||||||
loading={detailLoadingId === selectedRow?.id}
|
<DetailPanel
|
||||||
/>
|
row={selectedRow}
|
||||||
|
marketScan={selectedDetail}
|
||||||
|
loading={detailLoadingId === selectedRow?.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user