feat: add scan terminal dashboard components, monitoring panels, and associated utility hooks
This commit is contained in:
@@ -114,8 +114,22 @@ function ScanTerminalScreen() {
|
|||||||
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
|
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
|
||||||
const [showScanPaywall, setShowScanPaywall] = useState(false);
|
const [showScanPaywall, setShowScanPaywall] = useState(false);
|
||||||
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||||||
|
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined" || !window.matchMedia) return;
|
||||||
|
const media = window.matchMedia("(max-width: 768px)");
|
||||||
|
const syncMobileViewport = () => setIsMobileViewport(media.matches);
|
||||||
|
syncMobileViewport();
|
||||||
|
media.addEventListener("change", syncMobileViewport);
|
||||||
|
return () => media.removeEventListener("change", syncMobileViewport);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobileViewport) {
|
||||||
|
setShowAnnouncement(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const key = "polyweather_v156_announcement_seen_at";
|
const key = "polyweather_v156_announcement_seen_at";
|
||||||
const seen = localStorage.getItem(key);
|
const seen = localStorage.getItem(key);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -126,7 +140,7 @@ function ScanTerminalScreen() {
|
|||||||
}
|
}
|
||||||
const elapsed = now - Number(seen);
|
const elapsed = now - Number(seen);
|
||||||
setShowAnnouncement(elapsed < 3 * 24 * 60 * 60 * 1000);
|
setShowAnnouncement(elapsed < 3 * 24 * 60 * 60 * 1000);
|
||||||
}, []);
|
}, [isMobileViewport]);
|
||||||
const userLocalTime = useUserLocalClock();
|
const userLocalTime = useUserLocalClock();
|
||||||
const { setThemeMode, themeMode } = useScanTerminalTheme();
|
const { setThemeMode, themeMode } = useScanTerminalTheme();
|
||||||
const lastMapSelectedCityRef = useRef<string>("");
|
const lastMapSelectedCityRef = useRef<string>("");
|
||||||
@@ -426,7 +440,7 @@ function ScanTerminalScreen() {
|
|||||||
userLocalTime={userLocalTime}
|
userLocalTime={userLocalTime}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showAnnouncement ? (
|
{showAnnouncement && !isMobileViewport ? (
|
||||||
<ScanUpgradeAnnouncement
|
<ScanUpgradeAnnouncement
|
||||||
isEn={isEn}
|
isEn={isEn}
|
||||||
onDismiss={() => {
|
onDismiss={() => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type MonitorKey = (typeof MONITOR_KEYS)[number];
|
|||||||
type MonitorRefreshRequest = ReturnType<typeof getMonitorRefreshRequest>;
|
type MonitorRefreshRequest = ReturnType<typeof getMonitorRefreshRequest>;
|
||||||
|
|
||||||
const CONCURRENCY = 6;
|
const CONCURRENCY = 6;
|
||||||
|
const KOREA_RUNWAY_MONITOR_KEYS = new Set<MonitorKey>(["seoul", "busan"]);
|
||||||
|
|
||||||
/* ── Helpers ─────────────────────────────────────────────────── */
|
/* ── Helpers ─────────────────────────────────────────────────── */
|
||||||
type Lang = { isEn: boolean };
|
type Lang = { isEn: boolean };
|
||||||
@@ -93,7 +94,8 @@ function playNewHighBeep(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function trendClass(detail: CityDetail | undefined, key?: string): "rising" | "falling" | "flat" {
|
function trendClass(detail: CityDetail | undefined, key?: string): "rising" | "falling" | "flat" {
|
||||||
const { source } = resolveMonitorTemperature(detail);
|
const ignoreRunway = key === "seoul" || key === "busan";
|
||||||
|
const { source } = resolveMonitorTemperature(detail, { ignoreRunway });
|
||||||
// Runway surface temp vs air temp comparison is meaningless
|
// Runway surface temp vs air temp comparison is meaningless
|
||||||
if (
|
if (
|
||||||
source === "amos_runway_median" ||
|
source === "amos_runway_median" ||
|
||||||
@@ -101,7 +103,7 @@ function trendClass(detail: CityDetail | undefined, key?: string): "rising" | "f
|
|||||||
source === "amsc_awos_runway_max" ||
|
source === "amsc_awos_runway_max" ||
|
||||||
source === "amsc_awos_runway"
|
source === "amsc_awos_runway"
|
||||||
) return "flat";
|
) return "flat";
|
||||||
const cur = resolveMonitorTemperature(detail).value;
|
const cur = resolveMonitorTemperature(detail, { ignoreRunway }).value;
|
||||||
const max = resolveMaxSoFar(detail, key);
|
const max = resolveMaxSoFar(detail, key);
|
||||||
if (cur != null && max != null && cur >= max + 0.3) return "rising";
|
if (cur != null && max != null && cur >= max + 0.3) return "rising";
|
||||||
if (cur != null && max != null && cur < max - 1.0) return "falling";
|
if (cur != null && max != null && cur < max - 1.0) return "falling";
|
||||||
@@ -209,7 +211,9 @@ function resolveSourceLabel(
|
|||||||
return isEn ? SOURCE_LABELS[apSource].en : SOURCE_LABELS[apSource].zh;
|
return isEn ? SOURCE_LABELS[apSource].en : SOURCE_LABELS[apSource].zh;
|
||||||
}
|
}
|
||||||
// Use temperature resolution source
|
// Use temperature resolution source
|
||||||
const { source } = resolveMonitorTemperature(detail);
|
const { source } = resolveMonitorTemperature(detail, {
|
||||||
|
ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(key),
|
||||||
|
});
|
||||||
if (source && SOURCE_LABELS[source]) {
|
if (source && SOURCE_LABELS[source]) {
|
||||||
return isEn ? SOURCE_LABELS[source].en : SOURCE_LABELS[source].zh;
|
return isEn ? SOURCE_LABELS[source].en : SOURCE_LABELS[source].zh;
|
||||||
}
|
}
|
||||||
@@ -287,7 +291,9 @@ export default function MonitorPanel({
|
|||||||
const changed: MonitorKey[] = [];
|
const changed: MonitorKey[] = [];
|
||||||
for (const key of MONITOR_KEYS) {
|
for (const key of MONITOR_KEYS) {
|
||||||
const detail = details[key];
|
const detail = details[key];
|
||||||
const cur = resolveMonitorTemperature(detail).value;
|
const cur = resolveMonitorTemperature(detail, {
|
||||||
|
ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(key),
|
||||||
|
}).value;
|
||||||
const prev = prevTempsRef.current[key];
|
const prev = prevTempsRef.current[key];
|
||||||
if (cur != null && prev != null && cur !== prev) changed.push(key);
|
if (cur != null && prev != null && cur !== prev) changed.push(key);
|
||||||
if (cur != null) prevTempsRef.current[key] = cur;
|
if (cur != null) prevTempsRef.current[key] = cur;
|
||||||
@@ -397,8 +403,12 @@ export default function MonitorPanel({
|
|||||||
return [...MONITOR_KEYS]
|
return [...MONITOR_KEYS]
|
||||||
.map((k) => ({ key: k, detail: details[k] }))
|
.map((k) => ({ key: k, detail: details[k] }))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const ta = resolveMonitorTemperature(a.detail).value;
|
const ta = resolveMonitorTemperature(a.detail, {
|
||||||
const tb = resolveMonitorTemperature(b.detail).value;
|
ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(a.key),
|
||||||
|
}).value;
|
||||||
|
const tb = resolveMonitorTemperature(b.detail, {
|
||||||
|
ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(b.key),
|
||||||
|
}).value;
|
||||||
if (ta == null && tb == null) return 0;
|
if (ta == null && tb == null) return 0;
|
||||||
if (ta == null) return 1;
|
if (ta == null) return 1;
|
||||||
if (tb == null) return -1;
|
if (tb == null) return -1;
|
||||||
@@ -432,7 +442,9 @@ export default function MonitorPanel({
|
|||||||
if (alerted._day !== today) alerted = { _day: today };
|
if (alerted._day !== today) alerted = { _day: today };
|
||||||
|
|
||||||
for (const { key, detail } of sorted) {
|
for (const { key, detail } of sorted) {
|
||||||
const { source, value: cur } = resolveMonitorTemperature(detail);
|
const { source, value: cur } = resolveMonitorTemperature(detail, {
|
||||||
|
ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(key),
|
||||||
|
});
|
||||||
// Skip runway cities: runway surface temp ≠ air temp
|
// Skip runway cities: runway surface temp ≠ air temp
|
||||||
if (source === "amos_runway_median" || source === "amos_runway") continue;
|
if (source === "amos_runway_median" || source === "amos_runway") continue;
|
||||||
const max = resolveMaxSoFar(detail, key);
|
const max = resolveMaxSoFar(detail, key);
|
||||||
@@ -517,7 +529,9 @@ export default function MonitorPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ac = detail.airport_current;
|
const ac = detail.airport_current;
|
||||||
const tempInfo = resolveMonitorTemperature(detail);
|
const tempInfo = resolveMonitorTemperature(detail, {
|
||||||
|
ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(key),
|
||||||
|
});
|
||||||
const cur = tempInfo.value;
|
const cur = tempInfo.value;
|
||||||
const curSource = tempInfo.source;
|
const curSource = tempInfo.source;
|
||||||
const isRunwayTemp =
|
const isRunwayTemp =
|
||||||
@@ -546,6 +560,7 @@ export default function MonitorPanel({
|
|||||||
const tr = trendClass(detail, key);
|
const tr = trendClass(detail, key);
|
||||||
const rwPairs = detail.amos?.runway_obs?.runway_pairs ?? [];
|
const rwPairs = detail.amos?.runway_obs?.runway_pairs ?? [];
|
||||||
const rwTemps = detail.amos?.runway_obs?.temperatures ?? [];
|
const rwTemps = detail.amos?.runway_obs?.temperatures ?? [];
|
||||||
|
const showRunwayRows = !KOREA_RUNWAY_MONITOR_KEYS.has(key);
|
||||||
const isFlashing = flashingKeys.has(key);
|
const isFlashing = flashingKeys.has(key);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -633,7 +648,7 @@ export default function MonitorPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Runway temps */}
|
{/* Runway temps */}
|
||||||
{rwPairs.length > 0 && rwTemps.length > 0 && (
|
{showRunwayRows && rwPairs.length > 0 && rwTemps.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="monitor-divider" />
|
<div className="monitor-divider" />
|
||||||
{rwPairs.map((p, i) => {
|
{rwPairs.map((p, i) => {
|
||||||
|
|||||||
@@ -55,12 +55,19 @@ export function getAmosRunwayTemperature(detail?: CityDetail | null) {
|
|||||||
|
|
||||||
export function resolveMonitorTemperature(
|
export function resolveMonitorTemperature(
|
||||||
detail?: CityDetail | null,
|
detail?: CityDetail | null,
|
||||||
|
options?: { ignoreRunway?: boolean },
|
||||||
): MonitorTemperature {
|
): MonitorTemperature {
|
||||||
const runway = getAmosRunwayTemperature(detail);
|
if (!options?.ignoreRunway) {
|
||||||
if (runway) return runway;
|
const runway = getAmosRunwayTemperature(detail);
|
||||||
|
if (runway) return runway;
|
||||||
|
}
|
||||||
|
|
||||||
const amosTemp = finiteNumber(detail?.amos?.temp ?? detail?.amos?.temp_c);
|
const amosTemp = finiteNumber(detail?.amos?.temp ?? detail?.amos?.temp_c);
|
||||||
if (amosTemp != null && detail?.amos?.temp_source === "runway_median") {
|
if (
|
||||||
|
!options?.ignoreRunway &&
|
||||||
|
amosTemp != null &&
|
||||||
|
detail?.amos?.temp_source === "runway_median"
|
||||||
|
) {
|
||||||
return { source: "amos", value: amosTemp };
|
return { source: "amos", value: amosTemp };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,17 @@ import { useCityDetails, useDashboardActions } from "@/hooks/useDashboardStore";
|
|||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
import type { CityDetail } from "@/lib/dashboard-types";
|
import type { CityDetail } from "@/lib/dashboard-types";
|
||||||
|
|
||||||
const CHINA_RUNWAY_CITIES = [
|
const RUNWAY_OBSERVATION_CITIES = [
|
||||||
{ key: "beijing", zh: "北京", en: "Beijing", icao: "ZBAA" },
|
{ key: "seoul", zh: "首尔", en: "Seoul", icao: "RKSI", sourceLabel: "AMOS" },
|
||||||
{ key: "shanghai", zh: "上海", en: "Shanghai", icao: "ZSPD" },
|
{ key: "busan", zh: "釜山", en: "Busan", icao: "RKPK", sourceLabel: "AMOS" },
|
||||||
{ key: "guangzhou", zh: "广州", en: "Guangzhou", icao: "ZGGG" },
|
{ key: "beijing", zh: "北京", en: "Beijing", icao: "ZBAA", sourceLabel: "AMSC AWOS" },
|
||||||
{ key: "shenzhen", zh: "深圳", en: "Shenzhen", icao: "ZGSZ" },
|
{ key: "shanghai", zh: "上海", en: "Shanghai", icao: "ZSPD", sourceLabel: "AMSC AWOS" },
|
||||||
{ key: "chengdu", zh: "成都", en: "Chengdu", icao: "ZUUU" },
|
{ key: "guangzhou", zh: "广州", en: "Guangzhou", icao: "ZGGG", sourceLabel: "AMSC AWOS" },
|
||||||
{ key: "chongqing", zh: "重庆", en: "Chongqing", icao: "ZUCK" },
|
{ key: "shenzhen", zh: "深圳", en: "Shenzhen", icao: "ZGSZ", sourceLabel: "AMSC AWOS" },
|
||||||
{ key: "wuhan", zh: "武汉", en: "Wuhan", icao: "ZHHH" },
|
{ key: "qingdao", zh: "青岛", en: "Qingdao", icao: "ZSQD", sourceLabel: "AMSC AWOS" },
|
||||||
|
{ key: "chengdu", zh: "成都", en: "Chengdu", icao: "ZUUU", sourceLabel: "AMSC AWOS" },
|
||||||
|
{ key: "chongqing", zh: "重庆", en: "Chongqing", icao: "ZUCK", sourceLabel: "AMSC AWOS" },
|
||||||
|
{ key: "wuhan", zh: "武汉", en: "Wuhan", icao: "ZHHH", sourceLabel: "AMSC AWOS" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function formatTemp(value: number | null | undefined, symbol = "°C") {
|
function formatTemp(value: number | null | undefined, symbol = "°C") {
|
||||||
@@ -25,6 +28,19 @@ function getRunwayRows(detail?: CityDetail | null) {
|
|||||||
return detail?.amos?.runway_obs?.point_temperatures ?? [];
|
return detail?.amos?.runway_obs?.point_temperatures ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRunwayPairRows(detail?: CityDetail | null) {
|
||||||
|
const runway_pairs = detail?.amos?.runway_obs?.runway_pairs ?? [];
|
||||||
|
const runway_temps =
|
||||||
|
detail?.amos?.runway_obs?.temperatures ??
|
||||||
|
detail?.amos?.runway_temps ??
|
||||||
|
[];
|
||||||
|
return runway_pairs.map(([from, to], index) => ({
|
||||||
|
label: `${from}/${to}`,
|
||||||
|
temp: runway_temps[index]?.[0] ?? null,
|
||||||
|
dew: runway_temps[index]?.[1] ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function sourceIsAmsc(detail?: CityDetail | null) {
|
function sourceIsAmsc(detail?: CityDetail | null) {
|
||||||
return detail?.amos?.source === "amsc_awos";
|
return detail?.amos?.source === "amsc_awos";
|
||||||
}
|
}
|
||||||
@@ -36,14 +52,17 @@ function RunwayCityCard({
|
|||||||
}: {
|
}: {
|
||||||
detail?: CityDetail | null;
|
detail?: CityDetail | null;
|
||||||
isEn: boolean;
|
isEn: boolean;
|
||||||
label: (typeof CHINA_RUNWAY_CITIES)[number];
|
label: (typeof RUNWAY_OBSERVATION_CITIES)[number];
|
||||||
}) {
|
}) {
|
||||||
const rows = getRunwayRows(detail);
|
const rows = getRunwayRows(detail);
|
||||||
|
const pairRows = getRunwayPairRows(detail);
|
||||||
const tempSymbol = detail?.temp_symbol || "°C";
|
const tempSymbol = detail?.temp_symbol || "°C";
|
||||||
const range = detail?.amos?.runway_temp_range;
|
const range = detail?.amos?.runway_temp_range;
|
||||||
const obsLocal =
|
const obsLocal =
|
||||||
detail?.amos?.observation_time_local || detail?.amos?.observation_time;
|
detail?.amos?.observation_time_local || detail?.amos?.observation_time;
|
||||||
const hasAmsc = sourceIsAmsc(detail) && rows.length > 0;
|
const hasPointRows = sourceIsAmsc(detail) && rows.length > 0;
|
||||||
|
const hasPairRows = pairRows.some((row) => row.temp != null || row.dew != null);
|
||||||
|
const hasRunwayData = hasPointRows || hasPairRows;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="monitor-card">
|
<article className="monitor-card">
|
||||||
@@ -55,41 +74,65 @@ function RunwayCityCard({
|
|||||||
|
|
||||||
<div className="monitor-stats">
|
<div className="monitor-stats">
|
||||||
<div className="monitor-high-row">
|
<div className="monitor-high-row">
|
||||||
<span className="monitor-stat-label">AMSC AWOS</span>
|
<span className="monitor-stat-label">{label.sourceLabel}</span>
|
||||||
<span className="monitor-high-value">
|
<span className="monitor-high-value">
|
||||||
{range
|
{range
|
||||||
? `${range[0].toFixed(1)}–${range[1].toFixed(1)}${tempSymbol}`
|
? `${range[0].toFixed(1)}–${range[1].toFixed(1)}${tempSymbol}`
|
||||||
|
: hasPairRows
|
||||||
|
? pairRows
|
||||||
|
.map((row) => row.temp)
|
||||||
|
.filter((value): value is number => value != null)
|
||||||
|
.map((value) => value.toFixed(1))
|
||||||
|
.join(" / ") + tempSymbol
|
||||||
: "--"}
|
: "--"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasAmsc ? (
|
{hasRunwayData ? (
|
||||||
<>
|
<>
|
||||||
<div className="monitor-divider" />
|
<div className="monitor-divider" />
|
||||||
<div className="monitor-rw-row">
|
{hasPointRows ? (
|
||||||
<span className="monitor-rw-label">{isEn ? "Runway" : "跑道"}</span>
|
<>
|
||||||
<span className="monitor-rw-temp">TDZ / MID / END</span>
|
<div className="monitor-rw-row">
|
||||||
</div>
|
<span className="monitor-rw-label">{isEn ? "Runway" : "跑道"}</span>
|
||||||
{rows.map((row) => (
|
<span className="monitor-rw-temp">TDZ / MID / END</span>
|
||||||
<div
|
</div>
|
||||||
key={row.runway || `${row.tdz_temp}-${row.end_temp}`}
|
{rows.map((row) => (
|
||||||
className="monitor-rw-row"
|
<div
|
||||||
>
|
key={row.runway || `${row.tdz_temp}-${row.end_temp}`}
|
||||||
<span className="monitor-rw-label">{row.runway || "--"}</span>
|
className="monitor-rw-row"
|
||||||
<span className="monitor-rw-temp">
|
>
|
||||||
{formatTemp(row.tdz_temp, tempSymbol)} /{" "}
|
<span className="monitor-rw-label">{row.runway || "--"}</span>
|
||||||
{formatTemp(row.mid_temp, tempSymbol)} /{" "}
|
<span className="monitor-rw-temp">
|
||||||
{formatTemp(row.end_temp, tempSymbol)}
|
{formatTemp(row.tdz_temp, tempSymbol)} /{" "}
|
||||||
</span>
|
{formatTemp(row.mid_temp, tempSymbol)} /{" "}
|
||||||
|
{formatTemp(row.end_temp, tempSymbol)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="monitor-rw-row">
|
||||||
|
<span className="monitor-rw-label">{isEn ? "Runway" : "跑道"}</span>
|
||||||
|
<span className="monitor-rw-temp">{isEn ? "Temp / Dew" : "温度 / 露点"}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
{hasPairRows &&
|
||||||
|
pairRows.map((row) => (
|
||||||
|
<div key={row.label} className="monitor-rw-row">
|
||||||
|
<span className="monitor-rw-label">{row.label}</span>
|
||||||
|
<span className="monitor-rw-temp">
|
||||||
|
{formatTemp(row.temp, tempSymbol)} / {formatTemp(row.dew, tempSymbol)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="scan-empty-state compact">
|
<div className="scan-empty-state compact">
|
||||||
{isEn
|
{isEn
|
||||||
? "No AMSC runway observation loaded yet."
|
? `No ${label.sourceLabel} runway observation loaded yet.`
|
||||||
: "暂无 AMSC 跑道观测。"}
|
: `暂无 ${label.sourceLabel} 跑道观测。`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
@@ -104,7 +147,7 @@ export function RunwayObservationsPanel() {
|
|||||||
const loadAll = useCallback(
|
const loadAll = useCallback(
|
||||||
async () => {
|
async () => {
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
CHINA_RUNWAY_CITIES.map((city) =>
|
RUNWAY_OBSERVATION_CITIES.map((city) =>
|
||||||
ensureCityDetail(city.key, false, "panel"),
|
ensureCityDetail(city.key, false, "panel"),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -118,7 +161,7 @@ export function RunwayObservationsPanel() {
|
|||||||
void loadAll();
|
void loadAll();
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
void Promise.allSettled(
|
void Promise.allSettled(
|
||||||
CHINA_RUNWAY_CITIES.map((city) =>
|
RUNWAY_OBSERVATION_CITIES.map((city) =>
|
||||||
ensureCityDetail(city.key, true, "panel"),
|
ensureCityDetail(city.key, true, "panel"),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -130,7 +173,7 @@ export function RunwayObservationsPanel() {
|
|||||||
|
|
||||||
const cards = useMemo(
|
const cards = useMemo(
|
||||||
() =>
|
() =>
|
||||||
CHINA_RUNWAY_CITIES.map((city) => ({
|
RUNWAY_OBSERVATION_CITIES.map((city) => ({
|
||||||
...city,
|
...city,
|
||||||
detail: cityDetailsByName[city.key],
|
detail: cityDetailsByName[city.key],
|
||||||
})),
|
})),
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runTests() {
|
||||||
|
const projectRoot = process.cwd();
|
||||||
|
const clientPath = path.join(
|
||||||
|
projectRoot,
|
||||||
|
"components",
|
||||||
|
"dashboard",
|
||||||
|
"scan-terminal",
|
||||||
|
"scan-terminal-client.ts",
|
||||||
|
);
|
||||||
|
const source = fs.readFileSync(clientPath, "utf8");
|
||||||
|
const concurrencyMatch = source.match(/AI_CITY_READ_MAX_CONCURRENT_STREAMS\s*=\s*(\d+)/);
|
||||||
|
const concurrency = Number(concurrencyMatch?.[1]);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
Number.isFinite(concurrency) && concurrency >= 4,
|
||||||
|
"AI airport-read streams should allow at least 4 concurrent cards to avoid slow decision-card evidence queues",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
source.includes("queuedAiCityReadTasks.unshift(run)") &&
|
||||||
|
source.includes("queuedAiCityReadTasks.pop()"),
|
||||||
|
"newer AI airport-read requests should be prioritized instead of waiting behind older pinned cards",
|
||||||
|
);
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runTests() {
|
||||||
|
const projectRoot = process.cwd();
|
||||||
|
const workspacePath = path.join(
|
||||||
|
projectRoot,
|
||||||
|
"components",
|
||||||
|
"dashboard",
|
||||||
|
"scan-terminal",
|
||||||
|
"use-ai-pinned-city-workspace.ts",
|
||||||
|
);
|
||||||
|
const source = fs.readFileSync(workspacePath, "utf8");
|
||||||
|
|
||||||
|
assert(
|
||||||
|
!source.includes("waitForDeepAnalysisQueue"),
|
||||||
|
"decision-card deep analysis hydration must not add an artificial queue delay",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
/store\.ensureCityDetail\(\s*nextCity,\s*false,\s*"full",?\s*\)/.test(source),
|
||||||
|
"automatic deep analysis hydration should use cache-friendly full detail requests",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runTests() {
|
||||||
|
const projectRoot = process.cwd();
|
||||||
|
const dashboardPath = path.join(
|
||||||
|
projectRoot,
|
||||||
|
"components",
|
||||||
|
"dashboard",
|
||||||
|
"ScanTerminalDashboard.tsx",
|
||||||
|
);
|
||||||
|
const source = fs.readFileSync(dashboardPath, "utf8");
|
||||||
|
|
||||||
|
assert(
|
||||||
|
source.includes("isMobileViewport") &&
|
||||||
|
source.includes("showAnnouncement && !isMobileViewport"),
|
||||||
|
"scan upgrade announcement must not render on mobile viewports",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runTests() {
|
||||||
|
const projectRoot = process.cwd();
|
||||||
|
const hookPath = path.join(projectRoot, "hooks", "useLeafletMap.ts");
|
||||||
|
const source = fs.readFileSync(hookPath, "utf8");
|
||||||
|
|
||||||
|
assert(
|
||||||
|
source.includes("bindMarkerTouchSelect") &&
|
||||||
|
source.includes('"touchend"') &&
|
||||||
|
source.includes('"pointerup"'),
|
||||||
|
"Leaflet city markers must bind touch/pointer selection events for mobile taps",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
source.includes("L.DomEvent.stopPropagation") &&
|
||||||
|
source.includes("L.DomEvent.preventDefault"),
|
||||||
|
"mobile marker tap handling must stop map click propagation and prevent browser touch defaults",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,13 @@ export function runTests() {
|
|||||||
"scan-terminal",
|
"scan-terminal",
|
||||||
"RunwayObservationsPanel.tsx",
|
"RunwayObservationsPanel.tsx",
|
||||||
);
|
);
|
||||||
|
const monitorPath = path.join(
|
||||||
|
projectRoot,
|
||||||
|
"components",
|
||||||
|
"dashboard",
|
||||||
|
"monitoring",
|
||||||
|
"MonitorPanel.tsx",
|
||||||
|
);
|
||||||
|
|
||||||
const shellPartsSource = fs.readFileSync(shellPartsPath, "utf8");
|
const shellPartsSource = fs.readFileSync(shellPartsPath, "utf8");
|
||||||
const dashboardSource = fs.readFileSync(dashboardPath, "utf8");
|
const dashboardSource = fs.readFileSync(dashboardPath, "utf8");
|
||||||
@@ -50,4 +57,23 @@ export function runTests() {
|
|||||||
panelSource.includes("END"),
|
panelSource.includes("END"),
|
||||||
"runway tab must identify AMSC AWOS and show TDZ/MID/END point temperatures",
|
"runway tab must identify AMSC AWOS and show TDZ/MID/END point temperatures",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
panelSource.includes('key: "qingdao"') &&
|
||||||
|
panelSource.includes("青岛") &&
|
||||||
|
panelSource.includes("ZSQD"),
|
||||||
|
"runway tab must include Qingdao / ZSQD AMSC AWOS runway observations",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
panelSource.includes("RKSI") &&
|
||||||
|
panelSource.includes("RKPK") &&
|
||||||
|
panelSource.includes("runway_pairs") &&
|
||||||
|
panelSource.includes("runway_temps"),
|
||||||
|
"runway tab must also own Seoul/Busan AMOS runway-pair observations",
|
||||||
|
);
|
||||||
|
const monitorSource = fs.readFileSync(monitorPath, "utf8");
|
||||||
|
assert(
|
||||||
|
monitorSource.includes("ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(key)") &&
|
||||||
|
monitorSource.includes("showRunwayRows = !KOREA_RUNWAY_MONITOR_KEYS.has(key)"),
|
||||||
|
"market monitor must not render Seoul/Busan runway data after it moves to the runway tab",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,3 @@ export function isFullEnoughForDeepAnalysis(detail?: CityDetail | null) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function waitForDeepAnalysisQueue(ms: number) {
|
|
||||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ type AiCityReadOptions = {
|
|||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AI_CITY_READ_MAX_CONCURRENT_STREAMS = 2;
|
const AI_CITY_READ_MAX_CONCURRENT_STREAMS = 4;
|
||||||
const pendingAiCityReadRequests = new Map<string, Promise<AiCityForecastPayload>>();
|
const pendingAiCityReadRequests = new Map<string, Promise<AiCityForecastPayload>>();
|
||||||
const queuedAiCityReadTasks: Array<() => void> = [];
|
const queuedAiCityReadTasks: Array<() => void> = [];
|
||||||
let activeAiCityReadStreams = 0;
|
let activeAiCityReadStreams = 0;
|
||||||
@@ -329,7 +329,7 @@ function runQueuedAiCityReadTask<T>(task: () => Promise<T>, onQueued?: () => voi
|
|||||||
.then(resolve, reject)
|
.then(resolve, reject)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
activeAiCityReadStreams = Math.max(0, activeAiCityReadStreams - 1);
|
activeAiCityReadStreams = Math.max(0, activeAiCityReadStreams - 1);
|
||||||
const next = queuedAiCityReadTasks.shift();
|
const next = queuedAiCityReadTasks.pop();
|
||||||
if (next) next();
|
if (next) next();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -337,7 +337,7 @@ function runQueuedAiCityReadTask<T>(task: () => Promise<T>, onQueued?: () => voi
|
|||||||
run();
|
run();
|
||||||
} else {
|
} else {
|
||||||
onQueued?.();
|
onQueued?.();
|
||||||
queuedAiCityReadTasks.push(run);
|
queuedAiCityReadTasks.unshift(run);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type { useDashboardStore } from "@/hooks/useDashboardStore";
|
|||||||
import {
|
import {
|
||||||
findDetailForCity,
|
findDetailForCity,
|
||||||
isFullEnoughForDeepAnalysis,
|
isFullEnoughForDeepAnalysis,
|
||||||
waitForDeepAnalysisQueue,
|
|
||||||
} from "@/components/dashboard/scan-terminal/city-detail-utils";
|
} from "@/components/dashboard/scan-terminal/city-detail-utils";
|
||||||
import {
|
import {
|
||||||
findRowForCity,
|
findRowForCity,
|
||||||
@@ -44,7 +43,7 @@ export function useAiPinnedCityWorkspace({
|
|||||||
try {
|
try {
|
||||||
const detail = await store.ensureCityDetail(
|
const detail = await store.ensureCityDetail(
|
||||||
nextCity,
|
nextCity,
|
||||||
Boolean(existingDetail) && !isFullEnoughForDeepAnalysis(existingDetail),
|
false,
|
||||||
"full",
|
"full",
|
||||||
);
|
);
|
||||||
if (!isFullEnoughForDeepAnalysis(detail)) {
|
if (!isFullEnoughForDeepAnalysis(detail)) {
|
||||||
@@ -53,7 +52,6 @@ export function useAiPinnedCityWorkspace({
|
|||||||
} catch {
|
} catch {
|
||||||
aiFullHydrationRef.current.delete(key);
|
aiFullHydrationRef.current.delete(key);
|
||||||
}
|
}
|
||||||
await waitForDeepAnalysisQueue(1200);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
aiHydrationRunningRef.current = false;
|
aiHydrationRunningRef.current = false;
|
||||||
|
|||||||
@@ -464,6 +464,7 @@ export function useLeafletMap({
|
|||||||
const handlingAutoNearbyRef = useRef(false);
|
const handlingAutoNearbyRef = useRef(false);
|
||||||
const lastMovedCityRef = useRef<string | null>(null);
|
const lastMovedCityRef = useRef<string | null>(null);
|
||||||
const lastCameraSelectionRef = useRef<string | null>(null);
|
const lastCameraSelectionRef = useRef<string | null>(null);
|
||||||
|
const lastTouchMarkerSelectAtRef = useRef(0);
|
||||||
const lastUserCameraInteractionAtRef = useRef(0);
|
const lastUserCameraInteractionAtRef = useRef(0);
|
||||||
const suspendMotionRef = useRef(suspendMotion);
|
const suspendMotionRef = useRef(suspendMotion);
|
||||||
const hasFittedInitialBoundsRef = useRef(false);
|
const hasFittedInitialBoundsRef = useRef(false);
|
||||||
@@ -672,6 +673,56 @@ export function useLeafletMap({
|
|||||||
const existing = currentMarkers[city.name];
|
const existing = currentMarkers[city.name];
|
||||||
const signature = getMarkerSignature(city, snapshot);
|
const signature = getMarkerSignature(city, snapshot);
|
||||||
const previousSignature = lastCityDataRef.current[city.name];
|
const previousSignature = lastCityDataRef.current[city.name];
|
||||||
|
const selectCityFromMarker = () => {
|
||||||
|
const currentMap = mapRef.current;
|
||||||
|
currentMap?.stop();
|
||||||
|
if (currentMap && !suspendMotionRef.current) {
|
||||||
|
currentMap.flyTo([city.lat, city.lon], 11, {
|
||||||
|
animate: true,
|
||||||
|
duration: 1.05,
|
||||||
|
easeLinearity: 0.22,
|
||||||
|
});
|
||||||
|
lastMovedCityRef.current = city.name;
|
||||||
|
} else {
|
||||||
|
lastMovedCityRef.current = null;
|
||||||
|
}
|
||||||
|
onSelectCityRef.current(city.name);
|
||||||
|
};
|
||||||
|
const bindMarkerTouchSelect = (marker: L.Marker) => {
|
||||||
|
const element = marker.getElement();
|
||||||
|
if (!(element instanceof HTMLElement)) return;
|
||||||
|
const boundKey = `${city.name}:${signature}`;
|
||||||
|
if (element.dataset.polyweatherTouchSelectBound === boundKey) return;
|
||||||
|
element.dataset.polyweatherTouchSelectBound = boundKey;
|
||||||
|
const handleTouchSelect = (event: Event) => {
|
||||||
|
if (Date.now() - lastTouchMarkerSelectAtRef.current < 120) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastTouchMarkerSelectAtRef.current = Date.now();
|
||||||
|
L.DomEvent.stopPropagation(event);
|
||||||
|
L.DomEvent.preventDefault(event);
|
||||||
|
selectCityFromMarker();
|
||||||
|
};
|
||||||
|
const handlePointerSelect = (event: Event) => {
|
||||||
|
const pointerType = (event as PointerEvent).pointerType;
|
||||||
|
if (
|
||||||
|
pointerType &&
|
||||||
|
pointerType !== "touch" &&
|
||||||
|
pointerType !== "pen"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() - lastTouchMarkerSelectAtRef.current < 120) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastTouchMarkerSelectAtRef.current = Date.now();
|
||||||
|
L.DomEvent.stopPropagation(event);
|
||||||
|
L.DomEvent.preventDefault(event);
|
||||||
|
selectCityFromMarker();
|
||||||
|
};
|
||||||
|
L.DomEvent.on(element, "touchend", handleTouchSelect);
|
||||||
|
L.DomEvent.on(element, "pointerup", handlePointerSelect);
|
||||||
|
};
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.city.lat !== city.lat || existing.city.lon !== city.lon) {
|
if (existing.city.lat !== city.lat || existing.city.lon !== city.lon) {
|
||||||
@@ -680,6 +731,7 @@ export function useLeafletMap({
|
|||||||
if (previousSignature !== signature) {
|
if (previousSignature !== signature) {
|
||||||
existing.marker.setIcon(createMarkerIcon(city, snapshot));
|
existing.marker.setIcon(createMarkerIcon(city, snapshot));
|
||||||
}
|
}
|
||||||
|
bindMarkerTouchSelect(existing.marker);
|
||||||
currentMarkers[city.name] = { city, marker: existing.marker };
|
currentMarkers[city.name] = { city, marker: existing.marker };
|
||||||
lastCityDataRef.current[city.name] = signature;
|
lastCityDataRef.current[city.name] = signature;
|
||||||
return;
|
return;
|
||||||
@@ -692,20 +744,12 @@ export function useLeafletMap({
|
|||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
marker.on("click", () => {
|
marker.on("click", () => {
|
||||||
const currentMap = mapRef.current;
|
if (Date.now() - lastTouchMarkerSelectAtRef.current < 650) {
|
||||||
currentMap?.stop();
|
return;
|
||||||
if (currentMap && !suspendMotion) {
|
|
||||||
currentMap.flyTo([city.lat, city.lon], 11, {
|
|
||||||
animate: true,
|
|
||||||
duration: 1.05,
|
|
||||||
easeLinearity: 0.22,
|
|
||||||
});
|
|
||||||
lastMovedCityRef.current = city.name;
|
|
||||||
} else {
|
|
||||||
lastMovedCityRef.current = null;
|
|
||||||
}
|
}
|
||||||
onSelectCityRef.current(city.name);
|
selectCityFromMarker();
|
||||||
});
|
});
|
||||||
|
bindMarkerTouchSelect(marker);
|
||||||
|
|
||||||
currentMarkers[city.name] = { city, marker };
|
currentMarkers[city.name] = { city, marker };
|
||||||
lastCityDataRef.current[city.name] = signature;
|
lastCityDataRef.current[city.name] = signature;
|
||||||
|
|||||||
Reference in New Issue
Block a user