From b8ccef23cae174f0779a66ed4ea00b6ba99f07b2 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 14:04:37 +0900 Subject: [PATCH] fix(globe): stabilize ship symbols and deck rendering --- apps/web/src/widgets/map3d/Map3D.tsx | 265 +++++++++--------- .../widgets/map3d/MaplibreDeckCustomLayer.ts | 17 +- 2 files changed, 140 insertions(+), 142 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index e3c7101..fae9006 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -9,7 +9,7 @@ import maplibregl, { type StyleSpecification, type VectorSourceSpecification, } from "maplibre-gl"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { AisTarget } from "../../entities/aisTarget/model/types"; import type { LegacyVesselInfo } from "../../entities/legacyVessel/model/types"; import type { ZonesGeoJson } from "../../entities/zone/api/useZones"; @@ -87,21 +87,9 @@ function kickRepaint(map: maplibregl.Map | null) { } } -const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` const DEG2RAD = Math.PI / 180; -function clampExpr(inputExpr: unknown, minValue: number, maxValue: number): unknown[] { - return ["min", ["max", inputExpr, minValue], maxValue]; -} - -function lngLatToUnitSphere(lon: number, lat: number, altitudeMeters = 0): [number, number, number] { - const lambda = lon * DEG2RAD; - const phi = lat * DEG2RAD; - const cosPhi = Math.cos(phi); - const s = 1 + altitudeMeters / EARTH_RADIUS_M; - // MapLibre globe space: x = east, y = north, z = lon=0 at equator. - return [Math.sin(lambda) * cosPhi * s, Math.sin(phi) * s, Math.cos(lambda) * cosPhi * s]; -} +const clampNumber = (value: number, minValue: number, maxValue: number) => Math.max(minValue, Math.min(maxValue, value)); const LEGACY_CODE_COLORS: Record = { PT: [30, 64, 175], // #1e40af @@ -228,7 +216,8 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str // Very low zoom tiles can contain extremely complex polygons (coastline/detail), // which may exceed MapLibre's per-segment 16-bit vertex limit and render incorrectly. // We keep the fill starting at a more reasonable zoom. - minzoom: 4, + minzoom: 6, + maxzoom: 12, paint: { // Dark-mode friendly palette (shallow = slightly brighter; deep = near-black). "fill-color": bathyFillColor, @@ -236,38 +225,15 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str }, } as unknown as LayerSpecification; - const bathyExtrusion: LayerSpecification = { - id: "bathymetry-extrusion", - type: "fill-extrusion", - source: oceanSourceId, - "source-layer": "contour", - minzoom: 6, - paint: { - "fill-extrusion-color": bathyFillColor, - // MapLibre fill-extrusion cannot go below 0m, so we exaggerate the "relative seabed height" - // (shallow areas higher, deep areas lower) to create a stepped relief. - "fill-extrusion-base": 0, - // NOTE: `zoom` can only appear as the input to a top-level `step`/`interpolate`. - "fill-extrusion-height": [ - "interpolate", - ["linear"], - ["zoom"], - 6, - ["*", ["+", depth, 12000], 0.002], // depth is negative; -> range [0..12000] - 10, - ["*", ["+", depth, 12000], 0.01], - ], - "fill-extrusion-opacity": ["interpolate", ["linear"], ["zoom"], 6, 0.0, 7, 0.25, 10, 0.55], - "fill-extrusion-vertical-gradient": true, - }, - } as unknown as LayerSpecification; + const bathyBandBorders: LayerSpecification = { id: "bathymetry-borders", type: "line", source: oceanSourceId, "source-layer": "contour", - minzoom: 4, + minzoom: 6, + maxzoom: 14, paint: { "line-color": "rgba(255,255,255,0.06)", "line-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.12, 8, 0.18, 12, 0.22], @@ -304,10 +270,9 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500]; const bathyMajorDepthFilter: unknown[] = [ - "match", + "in", ["to-number", ["get", "depth"]], - ...majorDepths.map((v) => [v, true]).flat(), - false, + ["literal", majorDepths], ] as unknown[]; const bathyLinesMajor: LayerSpecification = { @@ -316,6 +281,7 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str source: oceanSourceId, "source-layer": "contour_line", minzoom: 8, + maxzoom: 14, filter: bathyMajorDepthFilter as unknown as unknown[], paint: { "line-color": "rgba(255,255,255,0.16)", @@ -331,6 +297,7 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str source: oceanSourceId, "source-layer": "contour", minzoom: 4, + maxzoom: 14, filter: bathyMajorDepthFilter as unknown as unknown[], paint: { "line-color": "rgba(255,255,255,0.14)", @@ -394,7 +361,6 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str const toInsert = [ bathyFill, - bathyExtrusion, bathyBandBorders, bathyBandBordersMajor, bathyLinesMinor, @@ -486,6 +452,7 @@ type PairRangeCircle = { warn: boolean; }; +const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` const DECK_VIEW_ID = "mapbox"; export function Map3D({ @@ -507,10 +474,11 @@ export function Map3D({ const mapRef = useRef(null); const overlayRef = useRef(null); const globeDeckLayerRef = useRef(null); - const prevGlobeSelectedRef = useRef(null); + const globeShipsEpochRef = useRef(-1); const showSeamarkRef = useRef(settings.showSeamark); const baseMapRef = useRef(baseMap); const projectionRef = useRef(projection); + const [mapSyncEpoch, setMapSyncEpoch] = useState(0); useEffect(() => { showSeamarkRef.current = settings.showSeamark; @@ -675,27 +643,54 @@ export function Map3D({ const map = mapRef.current; if (!map) return; let cancelled = false; + let retries = 0; + const maxRetries = 6; const syncProjectionAndDeck = () => { if (cancelled) return; - try { - map.setProjection({ type: projection }); - map.setRenderWorldCopies(projection !== "globe"); + + if (!map.isStyleLoaded()) { + if (!cancelled && retries < maxRetries) { + retries += 1; + window.requestAnimationFrame(() => syncProjectionAndDeck()); + } + return; + } + + const next = projection; + try { + map.setProjection({ type: next }); + map.setRenderWorldCopies(next !== "globe"); } catch (e) { + if (!cancelled && retries < maxRetries) { + retries += 1; + window.requestAnimationFrame(() => syncProjectionAndDeck()); + return; + } console.warn("Projection switch failed:", e); } + const oldOverlay = overlayRef.current; + if (projection === "globe" && oldOverlay) { + // Globe mode uses custom MapLibre deck layers and should fully replace Mercator overlays. + try { + oldOverlay.finalize(); + } catch { + // ignore + } + overlayRef.current = null; + } + if (projection === "globe") { - // Tear down MapboxOverlay (mercator) and use a MapLibre custom layer that renders Deck - // with MapLibre's globe MVP matrix. This avoids the Deck <-> MapLibre globe mismatch. - const old = overlayRef.current; - if (old) { + // Ensure any stale layer from old mode is dropped then re-added on this projection. + if (globeDeckLayerRef.current) { try { - old.finalize(); + if (map.getLayer(globeDeckLayerRef.current.id)) { + map.removeLayer(globeDeckLayerRef.current.id); + } } catch { // ignore } - overlayRef.current = null; } if (!globeDeckLayerRef.current) { @@ -745,6 +740,8 @@ export function Map3D({ } catch { // ignore } + + setMapSyncEpoch((prev) => prev + 1); }; if (map.isStyleLoaded()) syncProjectionAndDeck(); @@ -778,6 +775,7 @@ export function Map3D({ map.once("style.load", () => { kickRepaint(map); requestAnimationFrame(() => kickRepaint(map)); + setMapSyncEpoch((prev) => prev + 1); }); } catch (e) { if (cancelled) return; @@ -801,7 +799,7 @@ export function Map3D({ if (!map.isStyleLoaded()) return; const disableBathyHeavy = projection === "globe" && baseMap === "enhanced"; const visHeavy = disableBathyHeavy ? "none" : "visible"; - const disableBaseMapSea = projection === "globe" && baseMap === "enhanced"; + const disableBaseMapSea = projection === "globe"; const seaVisibility = disableBaseMapSea ? "none" : "visible"; const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i; @@ -822,17 +820,20 @@ export function Map3D({ } } - // Vector basemap water-style layers can flicker on globe with dense symbols/fills in this stack. - // Hide them only in globe/enhanced mode and restore on return. + // Vector basemap water/raster layers can flicker on globe with dense symbols/fills in this stack. + // Hide them only in globe mode and restore on return. try { for (const layer of map.getStyle().layers || []) { const id = String(layer.id ?? ""); if (!id) continue; const sourceLayer = String((layer as Record)["source-layer"] ?? "").toLowerCase(); const source = String((layer as { source?: unknown }).source ?? "").toLowerCase(); + const type = String((layer as { type?: unknown }).type ?? "").toLowerCase(); const isSea = seaRegex.test(id) || seaRegex.test(sourceLayer) || seaRegex.test(source); - if (!isSea) continue; + const isRaster = type === "raster"; + if (!isSea && !isRaster) continue; if (!map.getLayer(id)) continue; + if (isRaster && id === "seamark") continue; try { map.setLayoutProperty(id, "visibility", seaVisibility); } catch { @@ -853,7 +854,7 @@ export function Map3D({ // ignore } }; - }, [projection, baseMap]); + }, [projection, baseMap, mapSyncEpoch]); // seamark toggle useEffect(() => { @@ -983,7 +984,7 @@ export function Map3D({ // ignore } }; - }, [zones, overlays.zones, projection, baseMap]); + }, [zones, overlays.zones, projection, baseMap, mapSyncEpoch]); // Ships in globe mode: render with MapLibre symbol layers so icons can be aligned to the globe surface. // Deck IconLayer billboards or uses a fixed plane, which looks like ships are pointing into the sky/ground on globe. @@ -1010,7 +1011,6 @@ export function Map3D({ } catch { // ignore } - prevGlobeSelectedRef.current = null; kickRepaint(map); }; @@ -1064,6 +1064,11 @@ export function Map3D({ return; } + if (globeShipsEpochRef.current !== mapSyncEpoch) { + remove(); + globeShipsEpochRef.current = mapSyncEpoch; + } + try { ensureImage(); } catch (e) { @@ -1077,7 +1082,14 @@ export function Map3D({ const legacy = legacyHits?.get(t.mmsi) ?? null; const cog = isFiniteNumber(t.cog) ? t.cog : 0; const cogNorm = ((cog % 360) + 360) % 360; - const cog4 = (Math.round(cogNorm / 90) % 4) * 90; + const hull = clampNumber((isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, 50, 420); + const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); + const selected = t.mmsi === selectedMmsi; + const selectedScale = selected ? 1.08 : 1; + const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3); + const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45); + const iconSize10 = clampNumber(0.56 * sizeScale * selectedScale, 0.35, 1.7); + const iconSize14 = clampNumber(0.72 * sizeScale * selectedScale, 0.45, 2.1); return { type: "Feature", id: t.mmsi, @@ -1085,11 +1097,14 @@ export function Map3D({ properties: { mmsi: t.mmsi, name: t.name || "", - cog, - cog4, + cog: cogNorm, sog: isFiniteNumber(t.sog) ? t.sog : 0, - length: isFiniteNumber(t.length) ? t.length : 0, - width: isFiniteNumber(t.width) ? t.width : 0, + iconSize3, + iconSize7, + iconSize10, + iconSize14, + sizeScale, + selected: selected ? 1 : 0, permitted: !!legacy, code: legacy?.shipCode || "", }, @@ -1107,20 +1122,18 @@ export function Map3D({ } const visibility = settings.showShips ? "visible" : "none"; - const isSelected = ["boolean", ["feature-state", "selected"], false] as const; - // Style-spec restriction: only one zoom-based step/interpolate is allowed in an expression. const circleRadius = [ "interpolate", ["linear"], ["zoom"], 3, - ["case", isSelected, 5, 4], + 4, 7, - ["case", isSelected, 8, 6], + 6, 10, - ["case", isSelected, 10, 8], + 8, 14, - ["case", isSelected, 14, 11], + 11, ] as const; // Put ships at the top so they're always visible (especially important under globe projection). @@ -1168,8 +1181,8 @@ export function Map3D({ "circle-stroke-width": [ "case", ["boolean", ["get", "permitted"], false], - ["case", ["boolean", ["feature-state", "selected"], false], 2.5, 1.6], - ["case", ["boolean", ["feature-state", "selected"], false], 2.0, 0.0], + ["case", ["==", ["get", "selected"], 1], 2.5, 1.6], + ["case", ["==", ["get", "selected"], 1], 2.0, 0.0], ] as unknown as number[], "circle-stroke-opacity": 0.8, }, @@ -1189,27 +1202,6 @@ export function Map3D({ if (!map.getLayer(symbolId)) { try { - const lengthExpr: unknown[] = ["to-number", ["get", "length"], 0]; - const widthExpr: unknown[] = ["to-number", ["get", "width"], 0]; - const hullExpr: unknown[] = clampExpr(["+", lengthExpr, ["*", 3, widthExpr]], 0, 420); - const sizeFactor: unknown[] = [ - "interpolate", - ["linear"], - hullExpr, - 0, - 0.85, - 40, - 0.95, - 80, - 1.0, - 160, - 1.25, - 260, - 1.55, - 350, - 1.85, - ]; - map.addLayer( { id: symbolId, @@ -1223,25 +1215,24 @@ export function Map3D({ ["linear"], ["zoom"], 3, - ["*", 0.32, sizeFactor], + ["to-number", ["get", "iconSize3"], 0.35], 7, - ["*", 0.42, sizeFactor], + ["to-number", ["get", "iconSize7"], 0.45], 10, - ["*", 0.52, sizeFactor], + ["to-number", ["get", "iconSize10"], 0.56], 14, - ["*", 0.72, sizeFactor], + ["to-number", ["get", "iconSize14"], 0.72], ] as unknown as number[], "icon-allow-overlap": true, "icon-ignore-placement": true, "icon-anchor": "center", - // Debug-friendly: quantize heading to N/E/S/W while we validate globe alignment. - "icon-rotate": ["get", "cog4"], + "icon-rotate": ["to-number", ["get", "cog"], 0], // Keep the icon on the sea surface. "icon-rotation-alignment": "map", "icon-pitch-alignment": "map", }, paint: { - "icon-opacity": ["case", ["boolean", ["feature-state", "selected"], false], 1.0, 0.92], + "icon-opacity": ["case", ["==", ["get", "selected"], 1], 1.0, 0.92], }, } as unknown as LayerSpecification, before, @@ -1257,19 +1248,7 @@ export function Map3D({ } } - // Apply selection state for highlight. - try { - const prev = prevGlobeSelectedRef.current; - if (prev && prev !== selectedMmsi) map.setFeatureState({ source: srcId, id: prev }, { selected: false }); - } catch { - // ignore - } - try { - if (selectedMmsi) map.setFeatureState({ source: srcId, id: selectedMmsi }, { selected: true }); - } catch { - // ignore - } - prevGlobeSelectedRef.current = selectedMmsi; + // Selection is now source-data driven (`selected` property), no per-feature state update needed. kickRepaint(map); }; @@ -1282,7 +1261,7 @@ export function Map3D({ // ignore } }; - }, [projection, settings.showShips, targets, legacyHits, selectedMmsi]); + }, [projection, settings.showShips, targets, legacyHits, selectedMmsi, mapSyncEpoch]); // Globe ship click selection (MapLibre-native ships layer) useEffect(() => { @@ -1291,14 +1270,14 @@ export function Map3D({ if (projection !== "globe" || !settings.showShips) return; const symbolId = "ships-globe"; + const haloId = "ships-globe-halo"; + const outlineId = "ships-globe-outline"; + const clickedRadiusDeg2 = Math.pow(0.08, 2); const onClick = (e: maplibregl.MapMouseEvent) => { try { - if (!map.getLayer(symbolId)) { - onSelectMmsi(null); - return; - } - const feats = map.queryRenderedFeatures(e.point, { layers: [symbolId] }); + const layerIds = [symbolId, haloId, outlineId].filter((id) => map.getLayer(id)); + const feats = layerIds.length > 0 ? map.queryRenderedFeatures(e.point, { layers: layerIds }) : []; const f = feats?.[0]; const props = (f?.properties || {}) as Record; const mmsi = Number(props.mmsi); @@ -1306,6 +1285,25 @@ export function Map3D({ onSelectMmsi(mmsi); return; } + + const clicked = { lat: e.lngLat.lat, lon: e.lngLat.lng }; + const cosLat = Math.cos(clicked.lat * DEG2RAD); + let bestMmsi: number | null = null; + let bestD2 = Number.POSITIVE_INFINITY; + for (const t of targets) { + if (!isFiniteNumber(t.lat) || !isFiniteNumber(t.lon)) continue; + const dLon = (clicked.lon - t.lon) * cosLat; + const dLat = clicked.lat - t.lat; + const d2 = dLon * dLon + dLat * dLat; + if (d2 <= clickedRadiusDeg2 && d2 < bestD2) { + bestD2 = d2; + bestMmsi = t.mmsi; + } + } + if (bestMmsi != null) { + onSelectMmsi(bestMmsi); + return; + } } catch { // ignore } @@ -1320,7 +1318,7 @@ export function Map3D({ // ignore } }; - }, [projection, settings.showShips, onSelectMmsi]); + }, [projection, settings.showShips, onSelectMmsi, mapSyncEpoch, targets]); // Globe overlays (pair links / FC links / ranges) rendered as MapLibre GeoJSON layers. // Deck custom layers are more fragile under globe projection; MapLibre-native rendering stays aligned like zones. @@ -1411,7 +1409,7 @@ export function Map3D({ } remove(); }; - }, [projection, overlays.pairLines, pairLinks]); + }, [projection, overlays.pairLines, pairLinks, mapSyncEpoch]); useEffect(() => { const map = mapRef.current; @@ -1507,7 +1505,7 @@ export function Map3D({ } remove(); }; - }, [projection, overlays.fcLines, fcLinks]); + }, [projection, overlays.fcLines, fcLinks, mapSyncEpoch]); useEffect(() => { const map = mapRef.current; @@ -1594,7 +1592,7 @@ export function Map3D({ } remove(); }; - }, [projection, overlays.fleetCircles, fleetCircles]); + }, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch]); useEffect(() => { const map = mapRef.current; @@ -1696,22 +1694,12 @@ export function Map3D({ } remove(); }; - }, [projection, overlays.pairRange, pairLinks]); + }, [projection, overlays.pairRange, pairLinks, mapSyncEpoch]); const shipData = useMemo(() => { return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon)); }, [targets]); - const globePosByMmsi = useMemo(() => { - if (projection !== "globe") return null; - const m = new Map(); - for (const t of shipData) { - // Slightly above the sea surface to keep the icon readable and avoid depth-fighting. - m.set(t.mmsi, lngLatToUnitSphere(t.lon, t.lat, 12)); - } - return m; - }, [projection, shipData]); - const legacyTargets = useMemo(() => { if (!legacyHits) return []; return shipData.filter((t) => legacyHits.has(t.mmsi)); @@ -1965,6 +1953,7 @@ export function Map3D({ }, [ projection, shipData, + baseMap, zones, selectedMmsi, overlays.zones, @@ -1981,7 +1970,7 @@ export function Map3D({ pairRanges, fcDashed, fleetCircles, - globePosByMmsi, + mapSyncEpoch, ]); return
; diff --git a/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts b/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts index d27e703..f3bc99b 100644 --- a/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts +++ b/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts @@ -169,9 +169,18 @@ export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface deck.setProps({ viewState: { [this._viewId]: { projectionMatrix, viewMatrix: IDENTITY_4x4 } } }); } - deck._drawLayers("maplibre-custom", { - clearCanvas: false, - clearStack: true, - }); + try { + deck._drawLayers("maplibre-custom", { + clearCanvas: false, + clearStack: true, + }); + } catch (e) { + // Rendering can fail transiently during style/projection transitions. + // Keep the map responsive and request a clean pass on next frame. + console.warn("Deck render sync failed, skipping frame:", e); + requestAnimationFrame(() => { + this._map?.triggerRepaint(); + }); + } } }