From 1ee8408b1fc888fcd803aa59d09cff85cf9b24bb Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 26 Apr 2026 10:13:44 +0800 Subject: [PATCH] feat: implement Leaflet map hook and AI-assisted city card forecast view for dashboard --- .../components/dashboard/Dashboard.module.css | 57 +++- .../scan-terminal/AiPinnedForecastView.tsx | 90 ++++- .../scan-terminal/city-card-decision-utils.ts | 28 +- .../scan-terminal/use-ai-city-card-data.ts | 319 +++--------------- frontend/hooks/useLeafletMap.ts | 20 +- 5 files changed, 226 insertions(+), 288 deletions(-) diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 532d6681..b0371da5 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -86,7 +86,7 @@ --border-subtle: rgba(226, 232, 240, 0.74); --text-primary: #0F172A; --text-secondary: #475569; - --text-muted: #94A3B8; + --text-muted: #64748B; background: #F7F9FC; color: #0F172A; background-image: @@ -13826,7 +13826,60 @@ } .root :global(.scan-terminal.light .scan-map-shell) { - background: rgba(255, 255, 255, 0.72); + background: + radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.12), transparent 34%), + linear-gradient(180deg, #f8fbff, #eaf3ff); + border-color: rgba(37, 99, 235, 0.16); + box-shadow: 0 16px 34px rgba(40, 70, 110, 0.12); +} + +.root :global(.scan-terminal.light .scan-map-shell .map), +.root :global(.scan-terminal.light .scan-map-shell .leaflet-container) { + background: #eaf3ff !important; +} + +.root :global(.scan-terminal.light .scan-map-shell .leaflet-tile) { + filter: saturate(1.06) contrast(0.98) brightness(1.04) !important; +} + +.root :global(.scan-terminal.light .scan-map-caption) { + color: #475569; +} + +.root :global(.scan-terminal.light .scan-list-tabs button), +.root :global(.scan-terminal.light .scan-mode-tab-sub), +.root :global(.scan-terminal.light .scan-opportunity-models em), +.root :global(.scan-terminal.light .scan-opportunity-stat small), +.root :global(.scan-terminal.light .panel-meta-chip), +.root :global(.scan-terminal.light .panel-weather-chip), +.root :global(.scan-terminal.light .scan-distribution-card), +.root :global(.scan-terminal.light .scan-chart-legend), +.root :global(.scan-terminal.light .scan-kpi-note), +.root :global(.scan-terminal.light .scan-calendar-subtitle), +.root :global(.scan-terminal.light .scan-calendar-action span), +.root :global(.scan-terminal.light .scan-calendar-countdown small), +.root :global(.scan-terminal.light .scan-opportunity-ai small), +.root :global(.scan-terminal.light .scan-ai-log-head span), +.root :global(.scan-terminal.light .scan-ai-log-time), +.root :global(.scan-terminal.light .scan-ai-log-empty), +.root :global(.scan-terminal.light .scan-ai-log-item small), +.root :global(.scan-terminal.light .scan-ai-summary-card p), +.root :global(.scan-terminal.light .scan-ai-city-head p), +.root :global(.scan-terminal.light .scan-ai-contract p), +.root :global(.scan-terminal.light .scan-ai-contract small), +.root :global(.scan-terminal.light .scan-v4-analysis p), +.root :global(.scan-terminal.light .scan-v4-analysis ul), +.root :global(.scan-terminal.light .scan-v4-evidence > div > span), +.root :global(.scan-terminal.light .scan-v4-model-sources em), +.root :global(.scan-terminal.light .scan-ai-raw-metar), +.root :global(.scan-terminal.light .scan-opportunity-hero p), +.root :global(.scan-terminal.light .scan-opportunity-summary span), +.root :global(.scan-terminal.light .scan-opportunity-lane-head p), +.root :global(.scan-terminal.light .scan-opportunity-decision-card p), +.root :global(.scan-terminal.light .scan-opportunity-decision-head span), +.root :global(.scan-terminal.light .scan-opportunity-decision-primary span), +.root :global(.scan-terminal.light .scan-opportunity-decision-foot small) { + color: #475569; } @media (max-width: 1480px) { diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx index d5c694ac..3ba51c13 100644 --- a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx +++ b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx @@ -23,6 +23,62 @@ function toFiniteDecisionNumber(value: unknown) { return Number.isFinite(numeric) ? numeric : null; } +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 AiPinnedCityCard({ item, detail, @@ -82,6 +138,7 @@ function AiPinnedCityCard({ ? "Waiting for intraday observations to compare against the DEB path." : "等待更多日内实测,用来对照 DEB 预测路径。"); const report = detail?.current?.raw_metar || detail?.airport_current?.raw_metar || ""; + const metarReportTimeDisplay = formatMetarReportTime(detail, report, isEn); const airportStation = detail?.risk?.icao || detail?.current?.station_code || @@ -104,16 +161,31 @@ function AiPinnedCityCard({ }); const aiCityForecast = aiForecast.payload?.city_forecast || null; - const localizedFinalJudgment = + const localizedFinalJudgmentRaw = (isEn ? aiCityForecast?.final_judgment_en : aiCityForecast?.final_judgment_zh) || (isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) || ""; - const localizedMetarRead = + const localizedMetarReadRaw = (isEn ? aiCityForecast?.metar_read_en : aiCityForecast?.metar_read_zh) || ""; - const localizedReasoning = + const localizedReasoningRaw = (isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) || ""; + const localizedFinalJudgment = normalizeMetarReadTime( + localizedFinalJudgmentRaw, + metarReportTimeDisplay, + isEn, + ); + const localizedMetarRead = normalizeMetarReadTime( + localizedMetarReadRaw, + metarReportTimeDisplay, + isEn, + ); + const localizedReasoning = normalizeMetarReadTime( + localizedReasoningRaw, + metarReportTimeDisplay, + isEn, + ); const localizedModelNote = (isEn ? aiCityForecast?.model_cluster_note_en @@ -349,13 +421,11 @@ function AiPinnedCityCard({ ? "Deepseek V4 pro is reading the latest airport bulletin..." : "Deepseek V4 pro 正在解读最新机场报文...")}

- {aiForecast.streamText ? ( -

- {isEn - ? "Streaming airport read; final forecast is still being completed..." - : "机场报文解读正在流式输出,最终预报结论仍在补全..."} -

- ) : null} +

+ {isEn + ? "Non-streaming mode is enabled to avoid truncated airport reads." + : "已停用流式输出,改用非流式 JSON 请求,避免报文解读被截断。"} +

) : aiForecast.status === "ready" && aiCityForecast ? ( <> diff --git a/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts b/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts index 03bb287f..ceb351d1 100644 --- a/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts +++ b/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts @@ -96,6 +96,16 @@ function getBucketAnchor(bucket: MarketTopBucket) { return toFiniteMarketNumber(bucket.temp ?? bucket.value ?? bucket.lower); } +function getRoundedWeatherBucketValue( + expectedHigh: number | null, + tempSymbol: string, + bucket: MarketTopBucket, +) { + const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, bucket); + if (comparable == null || !Number.isFinite(comparable)) return null; + return Math.round(comparable); +} + function getBucketModelProbability(bucket?: MarketTopBucket | null) { const model = normalizeMarketProbability(bucket?.model_probability); const probability = normalizeMarketProbability(bucket?.probability); @@ -164,24 +174,33 @@ export function pickMarketBucketForWeatherCenter( return isReasonableFallback(selectedBucket) ? selectedBucket : null; } + let roundedMatch: MarketTopBucket | null = null; let nearest: MarketTopBucket | null = null; let nearestDelta = Number.POSITIVE_INFINITY; for (const bucket of buckets) { const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, bucket); if (comparable == null) continue; + const roundedTarget = getRoundedWeatherBucketValue(expectedHigh, tempSymbol, bucket); const lower = bucket.lower != null ? Number(bucket.lower) : null; const upper = bucket.upper != null ? Number(bucket.upper) : null; + const anchor = getBucketAnchor(bucket); + if (anchor != null && roundedTarget != null && Math.round(anchor) === roundedTarget) { + roundedMatch = bucket; + break; + } if ( + roundedMatch == null && lower != null && upper != null && Number.isFinite(lower) && Number.isFinite(upper) && - comparable >= lower - 0.01 && - comparable <= upper + 0.01 + roundedTarget != null && + roundedTarget >= lower - 0.01 && + roundedTarget <= upper + 0.01 ) { - return bucket; + roundedMatch = bucket; + break; } - const anchor = getBucketAnchor(bucket); if (anchor == null) continue; const delta = Math.abs(anchor - comparable); if (delta < nearestDelta) { @@ -189,6 +208,7 @@ export function pickMarketBucketForWeatherCenter( nearestDelta = delta; } } + if (roundedMatch) return roundedMatch; if (!nearest) return isReasonableFallback(selectedBucket) ? selectedBucket : null; const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, nearest); const unit = String(nearest.unit || "").toUpperCase(); diff --git a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts index 31d7d538..be0a3d98 100644 --- a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts +++ b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts @@ -1,11 +1,6 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { - enqueueAiCityFetch, - extractStreamingAirportRead, - parseSseBlock, -} from "@/components/dashboard/scan-terminal/ai-city-stream"; import type { AiCityForecastPayload, AiCityForecastState, @@ -14,9 +9,9 @@ import { useDashboardStore } from "@/hooks/useDashboardStore"; import type { CityDetail, MarketScan } from "@/lib/dashboard-types"; import { normalizeCityKey } from "./decision-utils"; -const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v1"; +const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v2"; const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000; -const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v1"; +const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v2"; const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000; function getStorage() { @@ -62,56 +57,6 @@ function writeCachedPayload(key: string, payload: T) { } } -function buildPartialAiStreamPayload({ - fallbackText, - isEn, - tempSymbol, -}: { - fallbackText?: string | null; - isEn: boolean; - tempSymbol?: string | null; -}): AiCityForecastPayload { - const preservedText = - String(fallbackText || "").trim() || - (isEn - ? "The AI airport read stream was interrupted after partial output." - : "AI 机场报文解读已输出部分内容,但最终载荷未返回。"); - const retryHint = isEn - ? "The streaming connection ended before the final structured payload. The partial airport read above is preserved; refresh once if you need the full JSON-backed conclusion." - : "流式连接在最终结构化载荷返回前结束。上方已保留已输出的机场报文解读;如需完整 JSON 结论可刷新一次。"; - - return { - city_forecast: { - confidence: "low", - final_judgment_en: isEn - ? preservedText - : "Partial AI airport read was preserved after the stream ended early.", - final_judgment_zh: isEn - ? "AI 机场报文解读已保留部分输出,但流式连接提前结束。" - : preservedText, - metar_read_en: isEn ? preservedText : "", - metar_read_zh: isEn ? "" : preservedText, - model_cluster_note_en: "", - model_cluster_note_zh: "", - predicted_max: null, - range_high: null, - range_low: null, - reasoning_en: retryHint, - reasoning_zh: retryHint, - risks_en: isEn ? [retryHint] : [], - risks_zh: isEn ? [] : [retryHint], - unit: tempSymbol || "°C", - }, - raw_reason: "partial_ai_stream_without_final_payload", - reason: retryHint, - reason_en: isEn - ? retryHint - : "AI stream ended before the final payload; partial text was preserved.", - reason_zh: isEn ? "AI 流在最终载荷前结束;已保留部分文本。" : retryHint, - status: "partial_stream", - }; -} - export function useAiCityForecast({ detail, detailCityName, @@ -138,8 +83,6 @@ export function useAiCityForecast({ : "", [detail, detailCityName, locale, report], ); - const aiTempSymbol = detail?.temp_symbol || "°C"; - useEffect(() => { if (!enabled || !aiForecastKey) { setAiForecast({ status: "idle" }); @@ -162,199 +105,55 @@ export function useAiCityForecast({ controller.abort(); }; } - setAiForecast({ status: "loading", streamText: null, streamRaw: "" }); - enqueueAiCityFetch( - () => - fetch("/api/scan/terminal/ai-city/stream", { - method: "POST", - headers: { - Accept: "text/event-stream", - "Content-Type": "application/json", - }, - cache: "no-store", - signal: controller.signal, - body: JSON.stringify({ - city: detailCityName, - force_refresh: aiRefreshToken > 0, - locale, - }), - }).then(async (response) => { - if (!response.ok) { - let detailMessage = ""; - try { - const errorPayload = await response.json(); - const message = String(errorPayload?.error || "").trim(); - const rawDetail = String(errorPayload?.detail || "").trim(); - const elapsed = Number(errorPayload?.elapsed_ms); - const timeout = Number(errorPayload?.timeout_ms); - detailMessage = [ - message, - rawDetail, - Number.isFinite(elapsed) && Number.isFinite(timeout) - ? `elapsed ${Math.round(elapsed / 1000)}s / timeout ${Math.round(timeout / 1000)}s` - : "", - ] - .filter(Boolean) - .join(" · "); - } catch { - detailMessage = ""; - } - throw new Error( - detailMessage - ? `HTTP ${response.status} · ${detailMessage}` - : `HTTP ${response.status}`, - ); - } - const contentType = response.headers.get("content-type") || ""; - if (!response.body || !contentType.includes("text/event-stream")) { - return response.json() as Promise; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let rawStream = ""; - let finalPayload: AiCityForecastPayload | null = null; - let latestReadableText = ""; - const rememberReadableText = (value?: string | null) => { - const text = String(value || "").trim(); - if (text) { - latestReadableText = text; - } - }; - const handleBlock = (block: string) => { - const message = parseSseBlock(block); - if (!message || !message.data || typeof message.data !== "object") { - return; - } - const data = message.data as Record; - if (message.event === "progress") { - const progressText = - String( - locale === "en-US" ? data.message_en || "" : data.message_zh || "", - ).trim() || String(data.message || "").trim(); - if (progressText && !cancelled) { - rememberReadableText(progressText); - setAiForecast((current) => - current.status === "loading" - ? { ...current, streamText: current.streamText || progressText } - : current, - ); - } - } else if (message.event === "preview") { - const previewText = - String( - locale === "en-US" - ? data.metar_read_en || "" - : data.metar_read_zh || "", - ).trim() || - String(data.metar_read_zh || data.metar_read_en || "").trim() || - String( - locale === "en-US" - ? data.final_judgment_en || "" - : data.final_judgment_zh || "", - ).trim() || - String(data.final_judgment_zh || data.final_judgment_en || "").trim(); - if (previewText && !cancelled) { - rememberReadableText(previewText); - setAiForecast((current) => - current.status === "loading" - ? { - ...current, - streamText: previewText, - } - : current, - ); - } - } else if (message.event === "delta") { - const content = String(data.content || ""); - if (!content) return; - rawStream += content; - const airportRead = extractStreamingAirportRead(rawStream, locale); - const streamingText = - airportRead || - (rawStream.trim() - ? isEn - ? "AI has started streaming; parsing the METAR read field…" - : "AI 已开始流式输出,正在解析机场报文字段…" - : ""); - rememberReadableText(airportRead || streamingText); - if (!cancelled) { - setAiForecast((current) => - current.status === "loading" - ? { - ...current, - streamRaw: rawStream, - streamText: streamingText || current.streamText || null, - } - : current, - ); - } - } else if (message.event === "final") { - finalPayload = data as AiCityForecastPayload; - } - }; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const blocks = buffer.split(/\n\n|\r\n\r\n/); - buffer = blocks.pop() || ""; - for (const block of blocks) { - handleBlock(block); - } - } - buffer += decoder.decode(); - if (buffer.trim()) { - handleBlock(buffer); - } - if (!finalPayload) { - const fallbackText = - extractStreamingAirportRead(rawStream, locale) || - latestReadableText; - const partialPayload = buildPartialAiStreamPayload({ - fallbackText, - isEn, - tempSymbol: aiTempSymbol, - }); - writeCachedPayload(cacheKey, partialPayload); - return partialPayload; - } - return finalPayload; - }), - controller.signal, - { - onQueued: () => { - if (cancelled) return; - setAiForecast((current) => - current.status === "loading" - ? { - ...current, - streamText: isEn - ? "Waiting for the AI airport read queue..." - : "正在等待 AI 机场报文解读队列...", - } - : current, - ); - }, - onStart: () => { - if (cancelled) return; - setAiForecast((current) => - current.status === "loading" - ? { - ...current, - streamText: current.streamRaw - ? current.streamText - : isEn - ? "Connecting to DeepSeek V4-Pro for airport bulletin streaming..." - : "正在连接 DeepSeek V4-Pro,准备流式解读机场报文...", - } - : current, - ); - }, + setAiForecast({ + status: "loading", + streamText: isEn + ? "DeepSeek V4-Pro is reading the latest airport bulletin..." + : "DeepSeek V4-Pro 正在解读最新机场报文...", + }); + fetch("/api/scan/terminal/ai-city", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", }, - ) + cache: "no-store", + signal: controller.signal, + body: JSON.stringify({ + city: detailCityName, + force_refresh: aiRefreshToken > 0, + locale, + }), + }) + .then(async (response) => { + if (!response.ok) { + let detailMessage = ""; + try { + const errorPayload = await response.json(); + const message = String(errorPayload?.error || "").trim(); + const rawDetail = String(errorPayload?.detail || "").trim(); + const elapsed = Number(errorPayload?.elapsed_ms); + const timeout = Number(errorPayload?.timeout_ms); + detailMessage = [ + message, + rawDetail, + Number.isFinite(elapsed) && Number.isFinite(timeout) + ? `elapsed ${Math.round(elapsed / 1000)}s / timeout ${Math.round(timeout / 1000)}s` + : "", + ] + .filter(Boolean) + .join(" · "); + } catch { + detailMessage = ""; + } + throw new Error( + detailMessage + ? `HTTP ${response.status} · ${detailMessage}` + : `HTTP ${response.status}`, + ); + } + return response.json() as Promise; + }) .then((payload) => { if (!cancelled) { writeCachedPayload(cacheKey, payload); @@ -364,22 +163,6 @@ export function useAiCityForecast({ .catch((error) => { if (controller.signal.aborted) return; if (!cancelled) { - const message = String(error); - if (message.includes("AI stream ended before final payload")) { - setAiForecast((current) => { - const partialPayload = buildPartialAiStreamPayload({ - fallbackText: current.streamText, - isEn, - tempSymbol: aiTempSymbol, - }); - writeCachedPayload(cacheKey, partialPayload); - return { - payload: partialPayload, - status: "ready", - }; - }); - return; - } setAiForecast({ error: String(error), status: "failed" }); } }); @@ -387,7 +170,7 @@ export function useAiCityForecast({ cancelled = true; controller.abort(); }; - }, [aiForecastKey, aiRefreshToken, aiTempSymbol, detailCityName, enabled, isEn, locale]); + }, [aiForecastKey, aiRefreshToken, detailCityName, enabled, isEn, locale]); const refreshAiForecast = useCallback(() => { setAiRefreshToken((current) => current + 1); diff --git a/frontend/hooks/useLeafletMap.ts b/frontend/hooks/useLeafletMap.ts index 8fc7b681..ebbedaf3 100644 --- a/frontend/hooks/useLeafletMap.ts +++ b/frontend/hooks/useLeafletMap.ts @@ -58,10 +58,15 @@ function getMarkerDisplayOffset(cityName: string) { } function getMapTileUrl() { - if ( - typeof document !== "undefined" && - document.documentElement.classList.contains("light") - ) { + if (typeof document === "undefined") { + return MAP_TILE_URLS.dark; + } + + const lightMode = + document.documentElement.classList.contains("light") || + document.body.classList.contains("light") || + Boolean(document.querySelector(".scan-terminal.light")); + if (lightMode) { return MAP_TILE_URLS.light; } return MAP_TILE_URLS.dark; @@ -603,6 +608,13 @@ export function useLeafletMap({ attributeFilter: ["class"], attributes: true, }); + if (document.body) { + observer.observe(document.body, { + attributeFilter: ["class"], + attributes: true, + subtree: true, + }); + } return () => observer.disconnect(); }, []);