diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index d3da34b..13ca7ae 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -997,14 +997,20 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str } as unknown as LayerSpecification; // Insert before the first symbol layer (keep labels on top), otherwise append. - const rawLayers = Array.isArray(style.layers) ? style.layers : []; - const layers = rawLayers.filter((layer): layer is LayerSpecification => { - if (!layer || typeof layer !== "object") return false; - return typeof (layer as { id?: unknown }).id === "string"; - }); - const symbolIndex = layers.findIndex((l) => l.type === "symbol"); + const layers = Array.isArray(style.layers) ? (style.layers as unknown as LayerSpecification[]) : []; + if (!Array.isArray(style.layers)) { + style.layers = layers as unknown as StyleSpecification["layers"]; + } + + const symbolIndex = layers.findIndex((l) => (l as { type?: unknown } | null)?.type === "symbol"); const insertAt = symbolIndex >= 0 ? symbolIndex : layers.length; + const existingIds = new Set(); + for (const layer of layers) { + const id = getLayerId(layer); + if (id) existingIds.add(id); + } + const toInsert = [ bathyFill, bathyBandBorders, @@ -1013,12 +1019,44 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str bathyLinesMajor, bathyLabels, landformLabels, - ].filter( - (l) => !layers.some((x) => x.id === l.id), - ); + ].filter((l) => !existingIds.has(l.id)); if (toInsert.length > 0) layers.splice(insertAt, 0, ...toInsert); } +type BathyZoomRange = { + id: string; + mercator: [number, number]; + globe: [number, number]; +}; + +const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ + { id: "bathymetry-fill", mercator: [6, 12], globe: [8, 12] }, + { id: "bathymetry-borders", mercator: [6, 14], globe: [8, 14] }, + { id: "bathymetry-borders-major", mercator: [4, 14], globe: [8, 14] }, +]; + +function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMapId, projection: MapProjectionId) { + if (!map || !map.isStyleLoaded()) return; + if (baseMap !== "enhanced") return; + const isGlobe = projection === "globe"; + + for (const range of BATHY_ZOOM_RANGES) { + if (!map.getLayer(range.id)) continue; + const [minzoom, maxzoom] = isGlobe ? range.globe : range.mercator; + try { + // Safety: ensure heavy layers aren't stuck hidden from a previous session. + map.setLayoutProperty(range.id, "visibility", "visible"); + } catch { + // ignore + } + try { + map.setLayerZoomRange(range.id, minzoom, maxzoom); + } catch { + // ignore + } + } +} + async function resolveInitialMapStyle(signal: AbortSignal): Promise { const key = getMapTilerKey(); if (!key) return "/map/styles/osm-seamark.json"; @@ -1223,6 +1261,7 @@ export function Map3D({ const projectionBusyTokenRef = useRef(0); const projectionBusyTimerRef = useRef | null>(null); const projectionPrevRef = useRef(projection); + const bathyZoomProfileKeyRef = useRef(""); const mapTooltipRef = useRef(null); const deckHoverRafRef = useRef(null); const deckHoverHasHitRef = useRef(false); @@ -2081,33 +2120,25 @@ export function Map3D({ }, [baseMap]); // Globe rendering + bathymetry tuning. - // Some terrain/hillshade/extrusion effects look unstable under globe and can occlude Deck overlays. + // Under globe projection, low-zoom bathymetry polygons can exceed MapLibre's per-segment 16-bit vertex + // limit (65535) due to projection subdivision. Keep globe stable by gating heavy bathymetry fills/borders + // to higher zoom levels rather than toggling them on every frame. useEffect(() => { const map = mapRef.current; if (!map) return; const apply = () => { if (!map.isStyleLoaded()) return; - const disableBathyHeavy = projection === "globe" && baseMap === "enhanced"; - const visHeavy = disableBathyHeavy ? "none" : "visible"; const seaVisibility = "visible" as const; const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i; - // Globe + our injected bathymetry fill polygons can exceed MapLibre's per-segment vertex limit - // (65535), causing broken ocean rendering. Keep globe mode stable by disabling the heavy fill. - const heavyIds = [ - "bathymetry-fill", - "bathymetry-borders", - "bathymetry-borders-major", - "bathymetry-extrusion", - "bathymetry-hillshade", - ]; - for (const id of heavyIds) { - try { - if (map.getLayer(id)) map.setLayoutProperty(id, "visibility", visHeavy); - } catch { - // ignore - } + // Apply zoom gating for heavy bathymetry layers once per (baseMap, projection) combination. + // This avoids repeatedly mutating layer zoom ranges on hover/mapSyncEpoch pulses. + const nextProfileKey = `bathyZoomV1|${baseMap}|${projection}`; + if (bathyZoomProfileKeyRef.current !== nextProfileKey) { + applyBathymetryZoomProfile(map, baseMap, projection); + bathyZoomProfileKeyRef.current = nextProfileKey; + kickRepaint(map); } // Vector basemap water layers can be tuned per-style. Keep visible by default,