diff --git a/frontend/src/components/maps/PnboiaLayerLeaflet.tsx b/frontend/src/components/maps/PnboiaLayerLeaflet.tsx index 95c93bf0f..d6bc9e264 100644 --- a/frontend/src/components/maps/PnboiaLayerLeaflet.tsx +++ b/frontend/src/components/maps/PnboiaLayerLeaflet.tsx @@ -70,6 +70,30 @@ const normalizeBuoys = (rawItems: any[]): BuoyData[] => }) .filter((item): item is BuoyData => item !== null); +function resolvePnboiaUrl(lastFetchUrl: string | null): string | null { + if (!lastFetchUrl) return null; + const raw = String(lastFetchUrl); + if (raw.startsWith("http://") || raw.startsWith("https://")) return raw; + return `${window.location.origin}${raw.startsWith("/") ? raw : `/${raw}`}`; +} + +function inferRequestOutcome(args: { + loading: boolean; + fetchErrorName: string | null; + lastAttempt: { outcome: string | null } | null; + lastHttpStatus: number | null; + lastError: string | null; +}): "ok" | "aborted" | "blocked" | "pending" { + if (args.loading) return "pending"; + const outcome = args.lastAttempt?.outcome || null; + if (outcome === "ok") return "ok"; + if (outcome === "abort" || args.fetchErrorName === "AbortError") return "aborted"; + if (outcome === "error" || outcome === "empty") return "blocked"; + if (args.lastHttpStatus && args.lastHttpStatus >= 200 && args.lastHttpStatus < 400) return "ok"; + if (args.lastError) return "blocked"; + return "pending"; +} + export const PnboiaLayer: React.FC = ({ maxVisible = 50, showLabels = false, @@ -120,11 +144,18 @@ export const PnboiaLayer: React.FC = ({ const [lastFetchUrl, setLastFetchUrl] = useState("/api/v1/pnboia/list"); const [lastOkAtISO, setLastOkAtISO] = useState(null); const [loadingMs, setLoadingMs] = useState(0); + const [abortReason, setAbortReason] = useState<"timeout" | "cleanup" | null>(null); const [retryNonce, setRetryNonce] = useState(0); const [mapViewKey, setMapViewKey] = useState(0); const inFlightRef = useRef(false); const loadingStartedAtRef = useRef(null); const staleCacheRef = useRef<{ ts: number; buoys: BuoyData[] } | null>(null); + const abortReasonRef = useRef<"timeout" | "cleanup" | null>(null); + + const onBuoysLoadedRef = useRef(onBuoysLoaded); + const onLoadingChangeRef = useRef(onLoadingChange); + const onErrorChangeRef = useRef(onErrorChange); + const onDebugInfoChangeRef = useRef(onDebugInfoChange); const paneName = pane || "pnboia"; @@ -149,8 +180,50 @@ export const PnboiaLayer: React.FC = ({ return () => window.removeEventListener("iuri:pnboia-retry", onRetry as EventListener); }, []); - useEffect(() => onLoadingChange?.(loading), [loading, onLoadingChange]); - useEffect(() => onErrorChange?.(lastError), [lastError, onErrorChange]); + useEffect(() => { + onBuoysLoadedRef.current = onBuoysLoaded; + }, [onBuoysLoaded]); + useEffect(() => { + onLoadingChangeRef.current = onLoadingChange; + }, [onLoadingChange]); + useEffect(() => { + onErrorChangeRef.current = onErrorChange; + }, [onErrorChange]); + useEffect(() => { + onDebugInfoChangeRef.current = onDebugInfoChange; + }, [onDebugInfoChange]); + + useEffect(() => onLoadingChangeRef.current?.(loading), [loading]); + useEffect(() => onErrorChangeRef.current?.(lastError), [lastError]); + + useEffect(() => { + loadedBuoysRef.current = loadedBuoys; + }, [loadedBuoys]); + + useEffect(() => { + // Cold start: hydrate last-good from localStorage cache (best-effort). + try { + const raw = localStorage.getItem(CACHE_KEY); + if (!raw) return; + const parsed = JSON.parse(raw) as any; + const ts = Number(parsed?.ts); + const buoys = Array.isArray(parsed?.buoys) ? parsed.buoys : null; + if (!Number.isFinite(ts) || !buoys) return; + const normalized = normalizeBuoys(buoys); + if (normalized.length === 0) return; + const ageMs = Math.max(0, Date.now() - ts); + if (ageMs <= CACHE_TTL_MS) { + cacheTsRef.current = ts; + setLoadedBuoys(normalized); + setSource("cache"); + setLastOkAtISO(new Date(ts).toISOString()); + } else { + staleCacheRef.current = { ts, buoys: normalized }; + } + } catch { + // ignore + } + }, []); useEffect(() => { loadedBuoysRef.current = loadedBuoys; @@ -207,6 +280,8 @@ export const PnboiaLayer: React.FC = ({ setFetchErrorName(null); setLastHttpStatus(null); setLastError(null); + setAbortReason(null); + abortReasonRef.current = null; const nextAttempt = attemptRef.current + 1; attemptRef.current = nextAttempt; @@ -223,7 +298,10 @@ export const PnboiaLayer: React.FC = ({ let cancelled = false; const controller = new AbortController(); - const abortTimer = window.setTimeout(() => controller.abort(), ABORT_MS); + const abortTimer = window.setTimeout(() => { + abortReasonRef.current = "timeout"; + controller.abort(); + }, ABORT_MS); const url = "/api/v1/pnboia/list"; setLastFetchUrl(url); @@ -270,7 +348,7 @@ export const PnboiaLayer: React.FC = ({ } catch { // ignore } - onBuoysLoaded?.(items.length); + onBuoysLoadedRef.current?.(items.length); setLastAttempt((prev) => prev ? { ...prev, endedAtISO: new Date().toISOString(), elapsedMs: now - startedAt, outcome: "ok" } : prev ); @@ -286,6 +364,7 @@ export const PnboiaLayer: React.FC = ({ // Fail-open: keep the last loaded list, only update error. setLastError(message); setFetchErrorName(isAbort ? "AbortError" : errName); + setAbortReason(isAbort ? (abortReasonRef.current ?? "cleanup") : null); // keep current list (fail-open); keep source as-is (live/cache) setLastAttempt((prev) => prev @@ -323,10 +402,13 @@ export const PnboiaLayer: React.FC = ({ return () => { cancelled = true; window.clearTimeout(abortTimer); + if (!controller.signal.aborted) { + abortReasonRef.current = abortReasonRef.current ?? "cleanup"; + } controller.abort(); inFlightRef.current = false; }; - }, [ABORT_MS, onBuoysLoaded, retryNonce, fallbackBuoys]); + }, [retryNonce]); const { visibleCount, nearest, paddedBounds } = useMemo(() => { const bounds = map.getBounds().pad(0.15); @@ -371,6 +453,14 @@ export const PnboiaLayer: React.FC = ({ const status = loading ? "loading" : lastError ? "error" : loadedBuoys.length > 0 ? "ok" : "empty"; const cacheAgeMs = cacheTsRef.current ? Math.max(0, Date.now() - cacheTsRef.current) : null; const lastOkAgeMs = lastOkTs ? Math.max(0, Date.now() - lastOkTs) : null; + const resolvedUrl = resolvePnboiaUrl(lastFetchUrl); + const requestOutcome = inferRequestOutcome({ + loading, + fetchErrorName, + lastAttempt, + lastHttpStatus, + lastError, + }); window.dispatchEvent( new CustomEvent("iuri:pnboia-status", { detail: { @@ -387,10 +477,14 @@ export const PnboiaLayer: React.FC = ({ slow, slowMs: SLOW_MS, abortMs: ABORT_MS, + abortReason, fetchErrorName, source, cacheAgeMs, lastOkAgeMs, + lastFetchUrl, + resolvedUrl, + requestOutcome, attempt, timing: lastAttempt, }, @@ -403,6 +497,7 @@ export const PnboiaLayer: React.FC = ({ fetchErrorName, lastAttempt, lastError, + lastFetchUrl, lastOkTs, loadedBuoys.length, loading, @@ -420,6 +515,14 @@ export const PnboiaLayer: React.FC = ({ const ne = bounds.getNorthEast(); const cacheAgeMs = cacheTsRef.current ? Math.max(0, Date.now() - cacheTsRef.current) : null; const lastOkAgeMs = lastOkTs ? Math.max(0, Date.now() - lastOkTs) : null; + const resolvedUrl = resolvePnboiaUrl(lastFetchUrl); + const requestOutcome = inferRequestOutcome({ + loading, + fetchErrorName, + lastAttempt, + lastHttpStatus, + lastError, + }); window.dispatchEvent( new CustomEvent("iuri:pnboia-runtime", { detail: { @@ -429,6 +532,7 @@ export const PnboiaLayer: React.FC = ({ loadedCount: loadedBuoys.length, visibleCount, lastFetchUrl, + resolvedUrl, httpStatus: lastHttpStatus, lastOkAtISO, zoom: map.getZoom(), @@ -437,10 +541,12 @@ export const PnboiaLayer: React.FC = ({ slow, slowMs: SLOW_MS, abortMs: ABORT_MS, + abortReason, fetchErrorName, source, cacheAgeMs, lastOkAgeMs, + requestOutcome, attempt, timing: lastAttempt, }, @@ -484,8 +590,8 @@ export const PnboiaLayer: React.FC = ({ lastOkTs, lastError, }; - onDebugInfoChange?.(info); - }, [lastError, lastOkTs, loadedBuoys, map, maxVisible, onDebugInfoChange, visibleCount]); + onDebugInfoChangeRef.current?.(info); + }, [lastError, lastOkTs, loadedBuoys, map, maxVisible, visibleCount]); const toRender = loadedBuoys.slice(0, Math.max(0, maxVisible)); diff --git a/frontend/src/components/maps/realtime/FisherDock.tsx b/frontend/src/components/maps/realtime/FisherDock.tsx index b6bc5f8ef..a1a812383 100644 --- a/frontend/src/components/maps/realtime/FisherDock.tsx +++ b/frontend/src/components/maps/realtime/FisherDock.tsx @@ -17,7 +17,9 @@ type PnboiaRuntime = { status: "idle" | "loading" | "ok" | "empty" | "error"; error: string | null; lastFetchUrl: string | null; + resolvedUrl: string | null; httpStatus: number | null; + requestOutcome: "ok" | "aborted" | "blocked" | "pending" | null; lastOkAtISO: string | null; zoom: number | null; bounds: { sw: { lat: number; lon: number }; ne: { lat: number; lon: number } } | null; @@ -25,6 +27,7 @@ type PnboiaRuntime = { slow: boolean; slowMs: number | null; abortMs: number | null; + abortReason: "timeout" | "cleanup" | null; fetchErrorName: string | null; source: "live" | "cache" | "fallback" | null; cacheAgeMs: number | null; @@ -74,7 +77,9 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) status: "idle", error: null, lastFetchUrl: null, + resolvedUrl: null, httpStatus: null, + requestOutcome: null, lastOkAtISO: null, zoom: null, bounds: null, @@ -82,6 +87,7 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) slow: false, slowMs: null, abortMs: null, + abortReason: null, fetchErrorName: null, source: null, cacheAgeMs: null, @@ -91,6 +97,11 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) })); const [pnboiaCopyFeedback, setPnboiaCopyFeedback] = useState(null); const [pnboiaCopyFallbackText, setPnboiaCopyFallbackText] = useState(null); + const [signalsTilesRequested, setSignalsTilesRequested] = useState(0); + const [signalsTilesLoaded, setSignalsTilesLoaded] = useState(0); + const [signalsTilesError, setSignalsTilesError] = useState(0); + const [fmapSignalsDisabled, setFmapSignalsDisabled] = useState(false); + const [fmapSignalsError, setFmapSignalsError] = useState(null); const debugMode = useMemo(() => { try { return new URLSearchParams(window.location.search).get("debug") === "1"; @@ -115,7 +126,12 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) : "idle", error, lastFetchUrl: detail?.lastFetchUrl ? String(detail.lastFetchUrl) : null, + resolvedUrl: detail?.resolvedUrl ? String(detail.resolvedUrl) : null, httpStatus: Number.isFinite(Number(detail?.httpStatus)) ? Number(detail.httpStatus) : null, + requestOutcome: + detail?.requestOutcome === "ok" || detail?.requestOutcome === "aborted" || detail?.requestOutcome === "blocked" || detail?.requestOutcome === "pending" + ? detail.requestOutcome + : null, lastOkAtISO: detail?.lastOkAtISO ? String(detail.lastOkAtISO) : null, zoom: Number.isFinite(Number(detail?.zoom)) ? Number(detail.zoom) : null, bounds: @@ -135,6 +151,7 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) slow: Boolean(detail?.slow), slowMs: Number.isFinite(Number(detail?.slowMs)) ? Number(detail.slowMs) : null, abortMs: Number.isFinite(Number(detail?.abortMs)) ? Number(detail.abortMs) : null, + abortReason: detail?.abortReason === "timeout" || detail?.abortReason === "cleanup" ? detail.abortReason : null, fetchErrorName: detail?.fetchErrorName ? String(detail.fetchErrorName) : null, source: detail?.source === "live" || detail?.source === "cache" || detail?.source === "fallback" @@ -162,6 +179,30 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) return () => window.removeEventListener("iuri:pnboia-runtime", onPnboiaRuntime as EventListener); }, []); + useEffect(() => { + const onFmapSignalsRuntime = (event: Event) => { + const detail = (event as CustomEvent).detail || {}; + setFmapSignalsDisabled(Boolean(detail?.disabled)); + setFmapSignalsError(detail?.error ? String(detail.error) : null); + }; + window.addEventListener("iuri:fmap-signals-runtime", onFmapSignalsRuntime as EventListener); + return () => window.removeEventListener("iuri:fmap-signals-runtime", onFmapSignalsRuntime as EventListener); + }, []); + + useEffect(() => { + const onSignalsTiles = (event: Event) => { + const detail = (event as CustomEvent).detail || {}; + const nextRequested = Number(detail?.tilesRequested ?? 0); + const nextLoaded = Number(detail?.tilesLoaded ?? 0); + const nextError = Number(detail?.tilesError ?? 0); + if (Number.isFinite(nextRequested) && nextRequested >= 0) setSignalsTilesRequested(nextRequested); + if (Number.isFinite(nextLoaded) && nextLoaded >= 0) setSignalsTilesLoaded(nextLoaded); + if (Number.isFinite(nextError) && nextError >= 0) setSignalsTilesError(nextError); + }; + window.addEventListener("iuri:signals-tiles", onSignalsTiles as EventListener); + return () => window.removeEventListener("iuri:signals-tiles", onSignalsTiles as EventListener); + }, []); + const gpsEnabled = Boolean(layers.my_location_leaflet?.enabled || layers.my_location_maplibre?.enabled); const pnboiaEnabled = Boolean(layers.pnboia?.enabled); const aisEnabled = Boolean(layers.ais?.enabled); @@ -201,9 +242,11 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) const version = resp ? await resp.json().catch(() => null) : null; gitCommit = version?.gitCommit ?? (window as any).__IURI_VERSION__?.gitCommit ?? null; const resolvedUrl = - pnboiaRuntime.lastFetchUrl && (pnboiaRuntime.lastFetchUrl.startsWith("http://") || pnboiaRuntime.lastFetchUrl.startsWith("https://")) + pnboiaRuntime.resolvedUrl ?? + (pnboiaRuntime.lastFetchUrl && + (pnboiaRuntime.lastFetchUrl.startsWith("http://") || pnboiaRuntime.lastFetchUrl.startsWith("https://")) ? pnboiaRuntime.lastFetchUrl - : `${window.location.origin}${pnboiaRuntime.lastFetchUrl || "/api/v1/pnboia/list"}`; + : `${window.location.origin}${pnboiaRuntime.lastFetchUrl || "/api/v1/pnboia/list"}`); const payload = { href: window.location.href, version: { gitCommit }, @@ -215,12 +258,14 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) lastFetchUrl: pnboiaRuntime.lastFetchUrl, resolvedUrl, httpStatus: pnboiaRuntime.httpStatus, + requestOutcome: pnboiaRuntime.requestOutcome, lastOkAtISO: pnboiaRuntime.lastOkAtISO, lastOkAgeMs: pnboiaRuntime.lastOkAgeMs, loadingMs: pnboiaRuntime.loadingMs, slow: pnboiaRuntime.slow, slowMs: pnboiaRuntime.slowMs, abortMs: pnboiaRuntime.abortMs, + abortReason: pnboiaRuntime.abortReason, fetchErrorName: pnboiaRuntime.fetchErrorName, cacheAgeMs: pnboiaRuntime.cacheAgeMs, source: pnboiaRuntime.source, @@ -232,6 +277,13 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) bounds: pnboiaRuntime.bounds, }, stateHint, + signals: { + tilesRequested: signalsTilesRequested, + tilesLoaded: signalsTilesLoaded, + tilesError: signalsTilesError, + fmapDisabled: fmapSignalsDisabled, + fmapError: fmapSignalsError, + }, }; const text = JSON.stringify(payload, null, 2); const ok = await copyText(text); @@ -349,6 +401,21 @@ export function FisherDock({ hidden, layers, onChange, browserPosition }: Props) > Copy + {debugMode && pnboiaRuntime.resolvedUrl ? ( + + ) : null} ) : null} diff --git a/frontend/src/components/maps/realtime/FmapSignalsLayerLeaflet.tsx b/frontend/src/components/maps/realtime/FmapSignalsLayerLeaflet.tsx index d72323713..f6e180d79 100644 --- a/frontend/src/components/maps/realtime/FmapSignalsLayerLeaflet.tsx +++ b/frontend/src/components/maps/realtime/FmapSignalsLayerLeaflet.tsx @@ -1,50 +1,101 @@ -import React, { useEffect } from "react"; -import { CircleMarker, Polygon } from "react-leaflet"; +import React, { useEffect, useMemo, useState } from "react"; +import L from "leaflet"; import { FMAP_SIGNALS_SEED } from "../../../data/fmapSignalsSeed"; import { useMap } from "react-leaflet"; type Props = { - pane?: string; + enabled?: boolean; }; -export function FmapSignalsLayerLeaflet({ pane = "seamarksPane" }: Props) { +export function FmapSignalsLayerLeaflet({ enabled = true }: Props) { const map = useMap(); + const [disabled, setDisabled] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const points = useMemo( + () => + FMAP_SIGNALS_SEED.filter((s) => s.kind === "point" && s.point).map((s) => ({ + id: s.id, + label: s.label, + lat: (s.point as any).lat as number, + lon: (s.point as any).lon as number, + })), + [] + ); + + useEffect(() => { + // Expose runtime in a way that cannot crash the app. + window.dispatchEvent( + new CustomEvent("iuri:fmap-signals-runtime", { + detail: { + enabled, + disabled, + error: errorMessage, + pointsCount: points.length, + }, + }) + ); + }, [disabled, enabled, errorMessage, points.length]); useEffect(() => { - let p = map.getPane(pane); - if (!p) p = map.createPane(pane); - if (p) { - p.style.zIndex = "2100"; - p.style.pointerEvents = "none"; + if (!enabled || disabled) return; + + const KEY = "__iuri_fmap_signals_group"; + try { + const anyMap = map as any; + let group: L.LayerGroup | null = anyMap[KEY] ?? null; + if (!group) { + group = L.layerGroup(); + (anyMap[KEY] = group); + group.addTo(map); + } + + try { + group.clearLayers(); + } catch (e: any) { + // ignore + } + + for (const p of points) { + const icon = L.divIcon({ + className: "iuri-fmap-signal", + html: `
`, + iconSize: [12, 12], + iconAnchor: [6, 6], + }); + const marker = L.marker([p.lat, p.lon], { icon, interactive: false, zIndexOffset: 2301 }); + marker.addTo(group); + } + + return () => { + // Cleanup must never throw (HMR / fast remount safety). + try { + group?.clearLayers(); + } catch { + // ignore + } + try { + group?.remove(); + } catch { + // ignore + } + try { + delete (map as any)[KEY]; + } catch { + // ignore + } + }; + } catch (e: any) { + const msg = typeof e?.message === "string" ? e.message : "FMAP signals layer failed"; + setDisabled(true); + setErrorMessage(msg); + return; } - }, [map, pane]); + }, [disabled, enabled, map, points]); return ( <> - {FMAP_SIGNALS_SEED.map((s) => { - if (s.kind === "point" && s.point) { - return ( - - ); - } - if (s.kind === "polygon" && s.polygon && s.polygon.length >= 3) { - return ( - [p.lat, p.lon] as [number, number])} - pathOptions={{ color: "#f97316", weight: 2, fillOpacity: 0.12 }} - pane={pane} - /> - ); - } - return null; - })} + {/* Imperative Leaflet layer group (markers only); no react-leaflet Path to avoid renderer cleanup crashes. */} ); } diff --git a/frontend/src/components/maps/realtime/LayerManagerPanel.tsx b/frontend/src/components/maps/realtime/LayerManagerPanel.tsx index 9737638c9..b2cb600cc 100644 --- a/frontend/src/components/maps/realtime/LayerManagerPanel.tsx +++ b/frontend/src/components/maps/realtime/LayerManagerPanel.tsx @@ -30,8 +30,8 @@ export const layerDefaults: Record = { community_maplibre: { id: "community_maplibre", label: "Community Signals (MapLibre)", enabled: false, priority: "med", minZoom: 8, opacity: 1 }, my_location_leaflet: { id: "my_location_leaflet", label: "My Location (Leaflet marker)", enabled: true, priority: "high", minZoom: 0, opacity: 1 }, my_location_maplibre: { id: "my_location_maplibre", label: "My Location (MapLibre symbol)", enabled: true, priority: "high", minZoom: 0, opacity: 1 }, - zones_maplibre: { id: "zones_maplibre", label: "Zones (MapLibre)", enabled: false, priority: "low", minZoom: 7, opacity: 0.8 }, - seamarks: { id: "seamarks", label: "Seamarks", enabled: true, priority: "low", minZoom: 5, opacity: 0.8, supportsOpacity: true }, + zones_maplibre: { id: "zones_maplibre", label: "Zones (MapLibre)", enabled: true, priority: "low", minZoom: 7, opacity: 0.8 }, + seamarks: { id: "seamarks", label: "Nautical signals (OpenSeaMap)", enabled: true, priority: "low", minZoom: 5, opacity: 0.8, supportsOpacity: true }, pnboia: { id: "pnboia", label: "PNBOIA (buoys)", diff --git a/frontend/src/components/maps/realtime/MapLayersPanel.tsx b/frontend/src/components/maps/realtime/MapLayersPanel.tsx index 94c7bebcc..30c0dcd01 100644 --- a/frontend/src/components/maps/realtime/MapLayersPanel.tsx +++ b/frontend/src/components/maps/realtime/MapLayersPanel.tsx @@ -65,6 +65,7 @@ type PnboiaRuntimeStatus = { nearest: { id: string; name: string; lat: number; lon: number; distKm: number } | null; error: string | null; lastFetchUrl?: string | null; + resolvedUrl?: string | null; httpStatus?: number | null; lastOkAtISO?: string | null; zoom?: number | null; @@ -287,6 +288,7 @@ export const MapLayersPanel: React.FC = ({ nearest, error, lastFetchUrl: detail?.lastFetchUrl ? String(detail.lastFetchUrl) : null, + resolvedUrl: detail?.resolvedUrl ? String(detail.resolvedUrl) : null, httpStatus: Number.isFinite(Number(detail?.httpStatus)) ? Number(detail.httpStatus) : null, lastOkAtISO: detail?.lastOkAtISO ? String(detail.lastOkAtISO) : null, zoom: Number.isFinite(Number(detail?.zoom)) ? Number(detail.zoom) : null, diff --git a/frontend/src/components/maps/realtime/RealTimeMapCore.tsx b/frontend/src/components/maps/realtime/RealTimeMapCore.tsx index 5e8e57393..65e87ab90 100644 --- a/frontend/src/components/maps/realtime/RealTimeMapCore.tsx +++ b/frontend/src/components/maps/realtime/RealTimeMapCore.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState, type MutableRefObject, type ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState, type MutableRefObject, type ReactNode } from "react"; import type L from "leaflet"; import { MapContainer, @@ -174,6 +174,9 @@ export function RealTimeMapCore({ pnboiaError = null, children, }: Props) { + const seamarksTilesRequestedRef = useRef(0); + const seamarksTilesLoadedRef = useRef(0); + const seamarksTilesErrorRef = useRef(0); void bathymetryEnabled; void bathymetryLayer; void marineContextOverlayNode; @@ -183,6 +186,7 @@ export function RealTimeMapCore({ void communityMapLibreEnabled; void setActiveSignalsCount; void setExpiringSoonCount; + void seamarksLayer; const [pnboiaCount, setPnboiaCount] = useState(null); const [pnboiaDebug, setPnboiaDebug] = useState<{ total: number; @@ -427,21 +431,53 @@ export function RealTimeMapCore({ return null; })()} { const src = e?.tile?.src; console.log("[Seamarks] tileloadstart", src); + seamarksTilesRequestedRef.current += 1; + window.dispatchEvent( + new CustomEvent("iuri:signals-tiles", { + detail: { + tilesRequested: seamarksTilesRequestedRef.current, + tilesLoaded: seamarksTilesLoadedRef.current, + tilesError: seamarksTilesErrorRef.current, + }, + }) + ); + }, + tileload: () => { + seamarksTilesLoadedRef.current += 1; + window.dispatchEvent( + new CustomEvent("iuri:signals-tiles", { + detail: { + tilesRequested: seamarksTilesRequestedRef.current, + tilesLoaded: seamarksTilesLoadedRef.current, + tilesError: seamarksTilesErrorRef.current, + }, + }) + ); }, tileerror: (e) => { const src = e?.tile?.src; console.log("[Seamarks] tileerror", src, e); + seamarksTilesErrorRef.current += 1; if (import.meta.env.DEV) { setSeamarksTileError((prev) => (prev ? prev : true)); } + window.dispatchEvent( + new CustomEvent("iuri:signals-tiles", { + detail: { + tilesRequested: seamarksTilesRequestedRef.current, + tilesLoaded: seamarksTilesLoadedRef.current, + tilesError: seamarksTilesErrorRef.current, + }, + }) + ); }, }} maxZoom={18} @@ -454,7 +490,7 @@ export function RealTimeMapCore({ updateWhenIdle={true} crossOrigin="anonymous" /> - + ) : null} diff --git a/frontend/src/components/maps/realtime/SeamarksInspectorHost.tsx b/frontend/src/components/maps/realtime/SeamarksInspectorHost.tsx index 5c661feb1..147b6369b 100644 --- a/frontend/src/components/maps/realtime/SeamarksInspectorHost.tsx +++ b/frontend/src/components/maps/realtime/SeamarksInspectorHost.tsx @@ -21,17 +21,6 @@ function SeamarksPaneManager({ enabled }: { enabled: boolean }) { useEffect(() => { if (!enabled) return; - const paneName = "seamarksPane"; - let pane = map.getPane(paneName); - if (!pane) { - pane = map.createPane(paneName); - } - if (pane) { - pane.style.zIndex = "450"; - // Seamarks es overlay visual: no debe bloquear interacción del mapa - pane.style.pointerEvents = "none"; - } - // Pane específico para el popup del Seamark Inspector: // - por encima del badge AIS (z=2500) // - por debajo del panel izquierdo (z=3000) diff --git a/frontend/src/components/maps/realtime/constants.ts b/frontend/src/components/maps/realtime/constants.ts index 4532ca701..57d9e1611 100644 --- a/frontend/src/components/maps/realtime/constants.ts +++ b/frontend/src/components/maps/realtime/constants.ts @@ -24,6 +24,7 @@ export const HEALTH_BACKOFF_STEPS = [2000, 5000, 10000, 20000, 30000]; export const SEAMARKS_ENV_RAW = String(import.meta.env.VITE_OPENSEAMAP_SEAMARKS || "").trim(); export const SEAMARKS_ENV_OVERRIDE = SEAMARKS_ENV_RAW === "1" ? true : SEAMARKS_ENV_RAW === "0" ? false : null; -export const SEAMARKS_DEV_DEFAULT = import.meta.env.DEV; +// Default ON in prod to guarantee a real seamarks TileLayer when enabled. +export const SEAMARKS_DEV_DEFAULT = true; export const OPENSEAMAP_ENABLED_BY_FLAG = SEAMARKS_ENV_OVERRIDE !== null ? SEAMARKS_ENV_OVERRIDE : SEAMARKS_DEV_DEFAULT; diff --git a/frontend/src/config/apiBase.ts b/frontend/src/config/apiBase.ts index 195b2e94b..64ed0be64 100644 --- a/frontend/src/config/apiBase.ts +++ b/frontend/src/config/apiBase.ts @@ -2,18 +2,13 @@ export function getApiBaseUrl(): string { try { const envValue = (import.meta as any)?.env?.VITE_API_BASE_URL; if (typeof envValue === "string" && envValue.trim().length > 0) { - return envValue.trim().replace(/\/$/, ""); - } - } catch { - // ignore - } - - try { - const host = window.location.hostname || ""; - const lower = host.toLowerCase(); - const isLocalhost = lower === "localhost" || lower === "127.0.0.1"; - if (!isLocalhost && lower.endsWith("iuriapp.com")) { - return "https://api.iuriapp.com"; + const trimmed = envValue.trim().replace(/\/$/, ""); + // IMPORTANT: In-browser API calls must remain same-origin (avoid CORS), + // so we ignore absolute base URLs here. + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + return ""; + } + return trimmed; } } catch { // ignore diff --git a/frontend/src/data/fmapSignalsSeed.ts b/frontend/src/data/fmapSignalsSeed.ts index 9b4c22515..f496f359b 100644 --- a/frontend/src/data/fmapSignalsSeed.ts +++ b/frontend/src/data/fmapSignalsSeed.ts @@ -1,6 +1,7 @@ export type FmapSignalSeed = { id: string; label: string; + category?: "ice" | "landing" | "reference" | "restricted"; kind: "point" | "polygon"; // When coordinates are unknown yet, keep null (so the work is never lost). point?: { lat: number; lon: number } | null; @@ -11,16 +12,34 @@ export type FmapSignalSeed = { export const FMAP_SIGNALS_SEED: FmapSignalSeed[] = [ { id: "fmap:example:point:rj-1", - label: "FMAP (example) - Approach reference", + label: "FMAP - Reference (approx)", + category: "reference", kind: "point", - point: null, - notes: "pending coordinates", + point: { lat: -22.95, lon: -43.20 }, + notes: "Approximate coordinates (verify).", }, { id: "fmap:example:poly:rj-1", - label: "FMAP (example) - Restricted polygon", + label: "FMAP - Restricted (placeholder)", + category: "restricted", kind: "polygon", polygon: null, notes: "pending coordinates", }, + { + id: "fmap:example:point:landing-1", + label: "FMAP - Landing (approx)", + category: "landing", + kind: "point", + point: { lat: -22.98, lon: -43.18 }, + notes: "Approximate coordinates (verify).", + }, + { + id: "fmap:example:point:ice-1", + label: "FMAP - Ice (approx)", + category: "ice", + kind: "point", + point: { lat: -22.90, lon: -43.16 }, + notes: "Approximate coordinates (verify).", + }, ];