From d4859eb361e3cfe0a92f04654bc90dfe6593daf7 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 13:03:05 +0900 Subject: [PATCH] fix(globe): stabilize deck draw; billboard ships --- apps/web/src/widgets/map3d/Map3D.tsx | 324 +++++++++++++++++- .../widgets/map3d/MaplibreDeckCustomLayer.ts | 7 + 2 files changed, 316 insertions(+), 15 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 8d9de7e..47497e6 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -82,6 +82,15 @@ const LEGACY_CODE_COLORS: Record = { FC: [245, 158, 11], // #f59e0b }; +const LEGACY_CODE_HEX: Record = { + PT: "#1e40af", + "PT-S": "#ea580c", + GN: "#10b981", + OT: "#8b5cf6", + PS: "#ef4444", + FC: "#f59e0b", +}; + const DEPTH_DISABLED_PARAMS = { // In MapLibre+Deck (interleaved), the map often leaves a depth buffer populated. // For 2D overlays like zones/icons/halos we want stable painter's-order rendering @@ -466,6 +475,7 @@ export function Map3D({ const mapRef = useRef(null); const overlayRef = useRef(null); const globeDeckLayerRef = useRef(null); + const prevGlobeSelectedRef = useRef(null); const showSeamarkRef = useRef(settings.showSeamark); const baseMapRef = useRef(baseMap); const projectionRef = useRef(projection); @@ -894,6 +904,289 @@ export function Map3D({ }; }, [zones, overlays.zones]); + // 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. + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const imgId = "ship-globe-icon"; + const srcId = "ships-globe-src"; + const haloId = "ships-globe-halo"; + const outlineId = "ships-globe-outline"; + const symbolId = "ships-globe"; + + const remove = () => { + for (const id of [symbolId, outlineId, haloId]) { + try { + if (map.getLayer(id)) map.removeLayer(id); + } catch { + // ignore + } + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + prevGlobeSelectedRef.current = null; + }; + + const ensureImage = () => { + if (map.hasImage(imgId)) return; + const size = 96; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Simple top-down ship silhouette, pointing north. + ctx.clearRect(0, 0, size, size); + ctx.fillStyle = "rgba(255,255,255,1)"; + ctx.beginPath(); + ctx.moveTo(size / 2, 6); + ctx.lineTo(size / 2 - 14, 24); + ctx.lineTo(size / 2 - 18, 58); + ctx.lineTo(size / 2 - 10, 88); + ctx.lineTo(size / 2 + 10, 88); + ctx.lineTo(size / 2 + 18, 58); + ctx.lineTo(size / 2 + 14, 24); + ctx.closePath(); + ctx.fill(); + + ctx.fillRect(size / 2 - 8, 34, 16, 18); + + const img = ctx.getImageData(0, 0, size, size); + map.addImage(imgId, img, { pixelRatio: 2 }); + }; + + const speedColorExpr: unknown[] = [ + "case", + [">=", ["to-number", ["get", "sog"]], 10], + "#3b82f6", + [">=", ["to-number", ["get", "sog"]], 1], + "#22c55e", + "#64748b", + ]; + + const codeColorExpr: unknown[] = ["match", ["get", "code"]]; + for (const [k, hex] of Object.entries(LEGACY_CODE_HEX)) codeColorExpr.push(k, hex); + codeColorExpr.push(speedColorExpr); + + const ensure = () => { + if (!map.isStyleLoaded()) return; + + if (projection !== "globe" || !settings.showShips) { + remove(); + return; + } + + try { + ensureImage(); + } catch (e) { + console.warn("Ship icon image setup failed:", e); + } + + const globeShipData = targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon)); + const geojson: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: globeShipData.map((t) => { + const legacy = legacyHits?.get(t.mmsi) ?? null; + return { + type: "Feature", + id: t.mmsi, + geometry: { type: "Point", coordinates: [t.lon, t.lat] }, + properties: { + mmsi: t.mmsi, + name: t.name || "", + cog: isFiniteNumber(t.cog) ? t.cog : 0, + sog: isFiniteNumber(t.sog) ? t.sog : 0, + permitted: !!legacy, + code: legacy?.shipCode || "", + }, + }; + }), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(geojson); + else map.addSource(srcId, { type: "geojson", data: geojson } as GeoJSONSourceSpecification); + } catch (e) { + console.warn("Ship source setup failed:", e); + return; + } + + const visibility = settings.showShips ? "visible" : "none"; + const circleRadius = [ + "case", + ["boolean", ["feature-state", "selected"], false], + ["interpolate", ["linear"], ["zoom"], 3, 5, 7, 8, 10, 10, 14, 14], + ["interpolate", ["linear"], ["zoom"], 3, 4, 7, 6, 10, 8, 14, 11], + ] as unknown as number[]; + + // Put ships at the top so they're always visible (especially important under globe projection). + const before = undefined; + + if (!map.getLayer(haloId)) { + try { + map.addLayer( + { + id: haloId, + type: "circle", + source: srcId, + layout: { visibility }, + paint: { + "circle-radius": circleRadius as never, + "circle-color": codeColorExpr as never, + "circle-opacity": 0.22, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("Ship halo layer add failed:", e); + } + } else { + try { + map.setLayoutProperty(haloId, "visibility", visibility); + } catch { + // ignore + } + } + + if (!map.getLayer(outlineId)) { + try { + map.addLayer( + { + id: outlineId, + type: "circle", + source: srcId, + layout: { visibility }, + paint: { + "circle-radius": circleRadius as never, + "circle-color": "rgba(0,0,0,0)", + "circle-stroke-color": codeColorExpr as never, + "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], + ] as unknown as number[], + "circle-stroke-opacity": 0.8, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("Ship outline layer add failed:", e); + } + } else { + try { + map.setLayoutProperty(outlineId, "visibility", visibility); + } catch { + // ignore + } + } + + if (!map.getLayer(symbolId)) { + try { + map.addLayer( + { + id: symbolId, + type: "symbol", + source: srcId, + layout: { + visibility, + "icon-image": imgId, + "icon-size": ["interpolate", ["linear"], ["zoom"], 3, 0.32, 7, 0.42, 10, 0.52, 14, 0.72], + "icon-allow-overlap": true, + "icon-ignore-placement": true, + "icon-anchor": "center", + "icon-rotate": ["get", "cog"], + // Keep rotation relative to the map (true-north), but billboard to camera so it + // doesn't look like it's pointing into the sky/ground on globe. + "icon-rotation-alignment": "map", + "icon-pitch-alignment": "viewport", + }, + paint: { + "icon-opacity": ["case", ["boolean", ["feature-state", "selected"], false], 1.0, 0.92], + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("Ship symbol layer add failed:", e); + } + } else { + try { + map.setLayoutProperty(symbolId, "visibility", visibility); + } catch { + // ignore + } + } + + // 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; + }; + + ensure(); + map.on("style.load", ensure); + return () => { + try { + map.off("style.load", ensure); + } catch { + // ignore + } + }; + }, [projection, settings.showShips, targets, legacyHits, selectedMmsi]); + + // Globe ship click selection (MapLibre-native ships layer) + useEffect(() => { + const map = mapRef.current; + if (!map) return; + if (projection !== "globe" || !settings.showShips) return; + + const symbolId = "ships-globe"; + + const onClick = (e: maplibregl.MapMouseEvent) => { + try { + const feats = map.queryRenderedFeatures(e.point, { layers: [symbolId] }); + const f = feats?.[0]; + const props = (f?.properties || {}) as Record; + const mmsi = Number(props.mmsi); + if (Number.isFinite(mmsi)) { + onSelectMmsi(mmsi); + return; + } + } catch { + // ignore + } + onSelectMmsi(null); + }; + + map.on("click", onClick); + return () => { + try { + map.off("click", onClick); + } catch { + // ignore + } + }; + }, [projection, settings.showShips, onSelectMmsi]); + const shipData = useMemo(() => { return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon)); }, [targets]); @@ -1037,13 +1330,13 @@ export function Map3D({ ); } - if (settings.showShips && legacyTargets.length > 0) { + if (settings.showShips && projection !== "globe" && legacyTargets.length > 0) { layers.push( new ScatterplotLayer({ id: "legacy-halo", data: legacyTargets, pickable: false, - billboard: projection === "globe", + billboard: false, // This ring is most prone to z-fighting, so force it into pure painter's-order rendering. parameters: overlayParams, filled: false, @@ -1059,9 +1352,7 @@ export function Map3D({ return [rgb[0], rgb[1], rgb[2], 200]; }, getPosition: (d) => - projection === "globe" - ? (globePosByMmsi?.get(d.mmsi) ?? lngLatToUnitSphere(d.lon, d.lat, 12)) - : ([d.lon, d.lat] as [number, number]), + [d.lon, d.lat] as [number, number], updateTriggers: { getRadius: [selectedMmsi], getLineColor: [legacyHits], @@ -1070,23 +1361,20 @@ export function Map3D({ ); } - if (settings.showShips) { + if (settings.showShips && projection !== "globe") { layers.push( new IconLayer({ id: "ships", data: shipData, pickable: true, - // Mercator: keep icons horizontal on the sea surface when view is pitched/rotated. - // Globe: billboard to keep the icon visible and glued to the globe. - billboard: projection === "globe", - parameters: projection === "globe" ? ({ ...overlayParams, cullMode: "none" } as const) : overlayParams, + // Keep icons horizontal on the sea surface when view is pitched/rotated. + billboard: false, + parameters: overlayParams, iconAtlas: "/assets/ship.svg", iconMapping: SHIP_ICON_MAPPING, getIcon: () => "ship", getPosition: (d) => - projection === "globe" - ? (globePosByMmsi?.get(d.mmsi) ?? lngLatToUnitSphere(d.lon, d.lat, 12)) - : ([d.lon, d.lat] as [number, number]), + [d.lon, d.lat] as [number, number], getAngle: (d) => (isFiniteNumber(d.cog) ? d.cog : 0), sizeUnits: "pixels", getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 34 : 22), @@ -1102,7 +1390,10 @@ export function Map3D({ const deckProps = { layers, - getTooltip: (info: PickingInfo) => { + getTooltip: + projection === "globe" + ? undefined + : (info: PickingInfo) => { if (!info.object) return null; if (info.layer && info.layer.id === "density") { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1139,7 +1430,10 @@ export function Map3D({ if (label) return { text: label }; return null; }, - onClick: (info: PickingInfo) => { + onClick: + projection === "globe" + ? undefined + : (info: PickingInfo) => { if (!info.object) { onSelectMmsi(null); return; diff --git a/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts b/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts index 0fae915..d27e703 100644 --- a/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts +++ b/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts @@ -152,7 +152,14 @@ export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface render(_gl: WebGLRenderingContext | WebGL2RenderingContext, options: maplibregl.CustomRenderMethodInput): void { const deck = this._deck; + if (!this._map) return; if (!deck || !deck.isInitialized) return; + // Deck reports `isInitialized` once `viewManager` exists, but we still see rare cases during + // style/projection transitions where internal managers are temporarily null (or tearing down). + // Guard before calling the internal `_drawLayers` to avoid crashing the whole map render. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const internal = deck as any; + if (!internal.layerManager || !internal.viewManager) return; // MapLibre gives us a world->clip matrix for the current projection (mercator/globe). // For globe, this matrix expects unit-sphere world coordinates (see MapLibre's globe transform).