diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index e8f06e51..16310103 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -114,8 +114,22 @@ function ScanTerminalScreen() { const [mapSelectedCityName, setMapSelectedCityName] = useState(null); const [showScanPaywall, setShowScanPaywall] = useState(false); const [showAnnouncement, setShowAnnouncement] = useState(false); + const [isMobileViewport, setIsMobileViewport] = useState(false); 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 seen = localStorage.getItem(key); const now = Date.now(); @@ -126,7 +140,7 @@ function ScanTerminalScreen() { } const elapsed = now - Number(seen); setShowAnnouncement(elapsed < 3 * 24 * 60 * 60 * 1000); - }, []); + }, [isMobileViewport]); const userLocalTime = useUserLocalClock(); const { setThemeMode, themeMode } = useScanTerminalTheme(); const lastMapSelectedCityRef = useRef(""); @@ -426,7 +440,7 @@ function ScanTerminalScreen() { userLocalTime={userLocalTime} /> - {showAnnouncement ? ( + {showAnnouncement && !isMobileViewport ? ( { diff --git a/frontend/components/dashboard/monitoring/MonitorPanel.tsx b/frontend/components/dashboard/monitoring/MonitorPanel.tsx index ed9472a1..73889220 100644 --- a/frontend/components/dashboard/monitoring/MonitorPanel.tsx +++ b/frontend/components/dashboard/monitoring/MonitorPanel.tsx @@ -29,6 +29,7 @@ type MonitorKey = (typeof MONITOR_KEYS)[number]; type MonitorRefreshRequest = ReturnType; const CONCURRENCY = 6; +const KOREA_RUNWAY_MONITOR_KEYS = new Set(["seoul", "busan"]); /* ── Helpers ─────────────────────────────────────────────────── */ type Lang = { isEn: boolean }; @@ -93,7 +94,8 @@ function playNewHighBeep(): void { } 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 if ( 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" ) return "flat"; - const cur = resolveMonitorTemperature(detail).value; + const cur = resolveMonitorTemperature(detail, { ignoreRunway }).value; const max = resolveMaxSoFar(detail, key); if (cur != null && max != null && cur >= max + 0.3) return "rising"; 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; } // Use temperature resolution source - const { source } = resolveMonitorTemperature(detail); + const { source } = resolveMonitorTemperature(detail, { + ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(key), + }); if (source && SOURCE_LABELS[source]) { return isEn ? SOURCE_LABELS[source].en : SOURCE_LABELS[source].zh; } @@ -287,7 +291,9 @@ export default function MonitorPanel({ const changed: MonitorKey[] = []; for (const key of MONITOR_KEYS) { 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]; if (cur != null && prev != null && cur !== prev) changed.push(key); if (cur != null) prevTempsRef.current[key] = cur; @@ -397,8 +403,12 @@ export default function MonitorPanel({ return [...MONITOR_KEYS] .map((k) => ({ key: k, detail: details[k] })) .sort((a, b) => { - const ta = resolveMonitorTemperature(a.detail).value; - const tb = resolveMonitorTemperature(b.detail).value; + const ta = resolveMonitorTemperature(a.detail, { + 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) return 1; if (tb == null) return -1; @@ -432,7 +442,9 @@ export default function MonitorPanel({ if (alerted._day !== today) alerted = { _day: today }; 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 if (source === "amos_runway_median" || source === "amos_runway") continue; const max = resolveMaxSoFar(detail, key); @@ -517,7 +529,9 @@ export default function MonitorPanel({ } 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 curSource = tempInfo.source; const isRunwayTemp = @@ -546,6 +560,7 @@ export default function MonitorPanel({ const tr = trendClass(detail, key); const rwPairs = detail.amos?.runway_obs?.runway_pairs ?? []; const rwTemps = detail.amos?.runway_obs?.temperatures ?? []; + const showRunwayRows = !KOREA_RUNWAY_MONITOR_KEYS.has(key); const isFlashing = flashingKeys.has(key); return ( @@ -633,7 +648,7 @@ export default function MonitorPanel({ {/* Runway temps */} - {rwPairs.length > 0 && rwTemps.length > 0 && ( + {showRunwayRows && rwPairs.length > 0 && rwTemps.length > 0 && ( <>
{rwPairs.map((p, i) => { diff --git a/frontend/components/dashboard/monitoring/monitor-temperature.ts b/frontend/components/dashboard/monitoring/monitor-temperature.ts index ab776edd..24f879ef 100644 --- a/frontend/components/dashboard/monitoring/monitor-temperature.ts +++ b/frontend/components/dashboard/monitoring/monitor-temperature.ts @@ -55,12 +55,19 @@ export function getAmosRunwayTemperature(detail?: CityDetail | null) { export function resolveMonitorTemperature( detail?: CityDetail | null, + options?: { ignoreRunway?: boolean }, ): MonitorTemperature { - const runway = getAmosRunwayTemperature(detail); - if (runway) return runway; + if (!options?.ignoreRunway) { + const runway = getAmosRunwayTemperature(detail); + if (runway) return runway; + } 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 }; } diff --git a/frontend/components/dashboard/scan-terminal/RunwayObservationsPanel.tsx b/frontend/components/dashboard/scan-terminal/RunwayObservationsPanel.tsx index 540a71b1..da849aeb 100644 --- a/frontend/components/dashboard/scan-terminal/RunwayObservationsPanel.tsx +++ b/frontend/components/dashboard/scan-terminal/RunwayObservationsPanel.tsx @@ -5,14 +5,17 @@ import { useCityDetails, useDashboardActions } from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; import type { CityDetail } from "@/lib/dashboard-types"; -const CHINA_RUNWAY_CITIES = [ - { key: "beijing", zh: "北京", en: "Beijing", icao: "ZBAA" }, - { key: "shanghai", zh: "上海", en: "Shanghai", icao: "ZSPD" }, - { key: "guangzhou", zh: "广州", en: "Guangzhou", icao: "ZGGG" }, - { key: "shenzhen", zh: "深圳", en: "Shenzhen", icao: "ZGSZ" }, - { key: "chengdu", zh: "成都", en: "Chengdu", icao: "ZUUU" }, - { key: "chongqing", zh: "重庆", en: "Chongqing", icao: "ZUCK" }, - { key: "wuhan", zh: "武汉", en: "Wuhan", icao: "ZHHH" }, +const RUNWAY_OBSERVATION_CITIES = [ + { key: "seoul", zh: "首尔", en: "Seoul", icao: "RKSI", sourceLabel: "AMOS" }, + { key: "busan", zh: "釜山", en: "Busan", icao: "RKPK", sourceLabel: "AMOS" }, + { key: "beijing", zh: "北京", en: "Beijing", icao: "ZBAA", sourceLabel: "AMSC AWOS" }, + { key: "shanghai", zh: "上海", en: "Shanghai", icao: "ZSPD", sourceLabel: "AMSC AWOS" }, + { key: "guangzhou", zh: "广州", en: "Guangzhou", icao: "ZGGG", sourceLabel: "AMSC AWOS" }, + { key: "shenzhen", zh: "深圳", en: "Shenzhen", icao: "ZGSZ", sourceLabel: "AMSC AWOS" }, + { 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; function formatTemp(value: number | null | undefined, symbol = "°C") { @@ -25,6 +28,19 @@ function getRunwayRows(detail?: CityDetail | null) { 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) { return detail?.amos?.source === "amsc_awos"; } @@ -36,14 +52,17 @@ function RunwayCityCard({ }: { detail?: CityDetail | null; isEn: boolean; - label: (typeof CHINA_RUNWAY_CITIES)[number]; + label: (typeof RUNWAY_OBSERVATION_CITIES)[number]; }) { const rows = getRunwayRows(detail); + const pairRows = getRunwayPairRows(detail); const tempSymbol = detail?.temp_symbol || "°C"; const range = detail?.amos?.runway_temp_range; const obsLocal = 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 (
@@ -55,41 +74,65 @@ function RunwayCityCard({
- AMSC AWOS + {label.sourceLabel} {range ? `${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 : "--"}
- {hasAmsc ? ( + {hasRunwayData ? ( <>
-
- {isEn ? "Runway" : "跑道"} - TDZ / MID / END -
- {rows.map((row) => ( -
- {row.runway || "--"} - - {formatTemp(row.tdz_temp, tempSymbol)} /{" "} - {formatTemp(row.mid_temp, tempSymbol)} /{" "} - {formatTemp(row.end_temp, tempSymbol)} - + {hasPointRows ? ( + <> +
+ {isEn ? "Runway" : "跑道"} + TDZ / MID / END +
+ {rows.map((row) => ( +
+ {row.runway || "--"} + + {formatTemp(row.tdz_temp, tempSymbol)} /{" "} + {formatTemp(row.mid_temp, tempSymbol)} /{" "} + {formatTemp(row.end_temp, tempSymbol)} + +
+ ))} + + ) : ( +
+ {isEn ? "Runway" : "跑道"} + {isEn ? "Temp / Dew" : "温度 / 露点"}
- ))} + )} + {hasPairRows && + pairRows.map((row) => ( +
+ {row.label} + + {formatTemp(row.temp, tempSymbol)} / {formatTemp(row.dew, tempSymbol)} + +
+ ))} ) : (
{isEn - ? "No AMSC runway observation loaded yet." - : "暂无 AMSC 跑道观测。"} + ? `No ${label.sourceLabel} runway observation loaded yet.` + : `暂无 ${label.sourceLabel} 跑道观测。`}
)}
@@ -104,7 +147,7 @@ export function RunwayObservationsPanel() { const loadAll = useCallback( async () => { await Promise.allSettled( - CHINA_RUNWAY_CITIES.map((city) => + RUNWAY_OBSERVATION_CITIES.map((city) => ensureCityDetail(city.key, false, "panel"), ), ); @@ -118,7 +161,7 @@ export function RunwayObservationsPanel() { void loadAll(); intervalRef.current = setInterval(() => { void Promise.allSettled( - CHINA_RUNWAY_CITIES.map((city) => + RUNWAY_OBSERVATION_CITIES.map((city) => ensureCityDetail(city.key, true, "panel"), ), ); @@ -130,7 +173,7 @@ export function RunwayObservationsPanel() { const cards = useMemo( () => - CHINA_RUNWAY_CITIES.map((city) => ({ + RUNWAY_OBSERVATION_CITIES.map((city) => ({ ...city, detail: cityDetailsByName[city.key], })), diff --git a/frontend/components/dashboard/scan-terminal/__tests__/aiCityReadQueuePerformance.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/aiCityReadQueuePerformance.test.ts new file mode 100644 index 00000000..11515a7d --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/aiCityReadQueuePerformance.test.ts @@ -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", + ); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/aiPinnedHydrationPerformance.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/aiPinnedHydrationPerformance.test.ts new file mode 100644 index 00000000..661e2f00 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/aiPinnedHydrationPerformance.test.ts @@ -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", + ); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/mobileAnnouncement.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/mobileAnnouncement.test.ts new file mode 100644 index 00000000..f2ff88fa --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/mobileAnnouncement.test.ts @@ -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", + ); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/mobileMapCitySelect.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/mobileMapCitySelect.test.ts new file mode 100644 index 00000000..c35b83b1 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/mobileMapCitySelect.test.ts @@ -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", + ); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/runwayObservationTab.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/runwayObservationTab.test.ts index 569be241..f6c33b6b 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/runwayObservationTab.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/runwayObservationTab.test.ts @@ -27,6 +27,13 @@ export function runTests() { "scan-terminal", "RunwayObservationsPanel.tsx", ); + const monitorPath = path.join( + projectRoot, + "components", + "dashboard", + "monitoring", + "MonitorPanel.tsx", + ); const shellPartsSource = fs.readFileSync(shellPartsPath, "utf8"); const dashboardSource = fs.readFileSync(dashboardPath, "utf8"); @@ -50,4 +57,23 @@ export function runTests() { panelSource.includes("END"), "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", + ); } diff --git a/frontend/components/dashboard/scan-terminal/city-detail-utils.ts b/frontend/components/dashboard/scan-terminal/city-detail-utils.ts index 02f090b9..89a389dd 100644 --- a/frontend/components/dashboard/scan-terminal/city-detail-utils.ts +++ b/frontend/components/dashboard/scan-terminal/city-detail-utils.ts @@ -52,6 +52,3 @@ export function isFullEnoughForDeepAnalysis(detail?: CityDetail | null) { ); } -export function waitForDeepAnalysisQueue(ms: number) { - return new Promise((resolve) => window.setTimeout(resolve, ms)); -} diff --git a/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts b/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts index e058bc27..4ff11739 100644 --- a/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts +++ b/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts @@ -76,7 +76,7 @@ type AiCityReadOptions = { signal?: AbortSignal; }; -const AI_CITY_READ_MAX_CONCURRENT_STREAMS = 2; +const AI_CITY_READ_MAX_CONCURRENT_STREAMS = 4; const pendingAiCityReadRequests = new Map>(); const queuedAiCityReadTasks: Array<() => void> = []; let activeAiCityReadStreams = 0; @@ -329,7 +329,7 @@ function runQueuedAiCityReadTask(task: () => Promise, onQueued?: () => voi .then(resolve, reject) .finally(() => { activeAiCityReadStreams = Math.max(0, activeAiCityReadStreams - 1); - const next = queuedAiCityReadTasks.shift(); + const next = queuedAiCityReadTasks.pop(); if (next) next(); }); }; @@ -337,7 +337,7 @@ function runQueuedAiCityReadTask(task: () => Promise, onQueued?: () => voi run(); } else { onQueued?.(); - queuedAiCityReadTasks.push(run); + queuedAiCityReadTasks.unshift(run); } }); } diff --git a/frontend/components/dashboard/scan-terminal/use-ai-pinned-city-workspace.ts b/frontend/components/dashboard/scan-terminal/use-ai-pinned-city-workspace.ts index 8cbd9c07..57c8b98e 100644 --- a/frontend/components/dashboard/scan-terminal/use-ai-pinned-city-workspace.ts +++ b/frontend/components/dashboard/scan-terminal/use-ai-pinned-city-workspace.ts @@ -7,7 +7,6 @@ import type { useDashboardStore } from "@/hooks/useDashboardStore"; import { findDetailForCity, isFullEnoughForDeepAnalysis, - waitForDeepAnalysisQueue, } from "@/components/dashboard/scan-terminal/city-detail-utils"; import { findRowForCity, @@ -44,7 +43,7 @@ export function useAiPinnedCityWorkspace({ try { const detail = await store.ensureCityDetail( nextCity, - Boolean(existingDetail) && !isFullEnoughForDeepAnalysis(existingDetail), + false, "full", ); if (!isFullEnoughForDeepAnalysis(detail)) { @@ -53,7 +52,6 @@ export function useAiPinnedCityWorkspace({ } catch { aiFullHydrationRef.current.delete(key); } - await waitForDeepAnalysisQueue(1200); } } finally { aiHydrationRunningRef.current = false; diff --git a/frontend/hooks/useLeafletMap.ts b/frontend/hooks/useLeafletMap.ts index 02b49c30..17dbf21a 100644 --- a/frontend/hooks/useLeafletMap.ts +++ b/frontend/hooks/useLeafletMap.ts @@ -464,6 +464,7 @@ export function useLeafletMap({ const handlingAutoNearbyRef = useRef(false); const lastMovedCityRef = useRef(null); const lastCameraSelectionRef = useRef(null); + const lastTouchMarkerSelectAtRef = useRef(0); const lastUserCameraInteractionAtRef = useRef(0); const suspendMotionRef = useRef(suspendMotion); const hasFittedInitialBoundsRef = useRef(false); @@ -672,6 +673,56 @@ export function useLeafletMap({ const existing = currentMarkers[city.name]; const signature = getMarkerSignature(city, snapshot); 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.city.lat !== city.lat || existing.city.lon !== city.lon) { @@ -680,6 +731,7 @@ export function useLeafletMap({ if (previousSignature !== signature) { existing.marker.setIcon(createMarkerIcon(city, snapshot)); } + bindMarkerTouchSelect(existing.marker); currentMarkers[city.name] = { city, marker: existing.marker }; lastCityDataRef.current[city.name] = signature; return; @@ -692,20 +744,12 @@ export function useLeafletMap({ }).addTo(map); marker.on("click", () => { - const currentMap = mapRef.current; - currentMap?.stop(); - 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; + if (Date.now() - lastTouchMarkerSelectAtRef.current < 650) { + return; } - onSelectCityRef.current(city.name); + selectCityFromMarker(); }); + bindMarkerTouchSelect(marker); currentMarkers[city.name] = { city, marker }; lastCityDataRef.current[city.name] = signature;