diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index e4cebbf..7088fb1 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -6,7 +6,6 @@ import maplibregl, { type GeoJSONSource, type GeoJSONSourceSpecification, type LayerSpecification, - type RasterDEMSourceSpecification, type StyleSpecification, type VectorSourceSpecification, } from "maplibre-gl"; @@ -61,6 +60,33 @@ function isFiniteNumber(x: unknown): x is number { return typeof x === "number" && Number.isFinite(x); } +function kickRepaint(map: maplibregl.Map | null) { + if (!map) return; + try { + map.triggerRepaint(); + } catch { + // ignore + } + try { + requestAnimationFrame(() => { + try { + map.triggerRepaint(); + } catch { + // ignore + } + }); + requestAnimationFrame(() => { + try { + map.triggerRepaint(); + } catch { + // ignore + } + }); + } catch { + // ignore (e.g., non-browser env) + } +} + const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` const DEG2RAD = Math.PI / 180; @@ -142,8 +168,10 @@ function ensureSeamarkOverlay(map: maplibregl.Map, beforeLayerId?: string) { } function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) { + // NOTE: Vector-only bathymetry injection. + // Raster/DEM hillshade was intentionally removed for now because it caused ocean flicker + // and extra PNG tile traffic under globe projection in our setup. const oceanSourceId = "maptiler-ocean"; - const terrainSourceId = "maptiler-terrain"; if (!style.sources) style.sources = {} as StyleSpecification["sources"]; if (!style.layers) style.layers = []; @@ -155,15 +183,6 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str } satisfies VectorSourceSpecification as unknown as StyleSpecification["sources"][string]; } - if (!style.sources[terrainSourceId]) { - style.sources[terrainSourceId] = { - type: "raster-dem", - url: `https://api.maptiler.com/tiles/terrain-rgb/tiles.json?key=${encodeURIComponent(maptilerKey)}`, - tileSize: 512, - encoding: "mapbox", - } satisfies RasterDEMSourceSpecification as unknown as StyleSpecification["sources"][string]; - } - const depth = ["to-number", ["get", "depth"]] as unknown as number[]; const depthLabel = ["concat", ["to-string", ["*", depth, -1]], "m"] as unknown as string[]; @@ -197,22 +216,6 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str "#0b3a53", ] as const; - const bathyHillshade: LayerSpecification = { - id: "bathymetry-hillshade", - type: "hillshade", - source: terrainSourceId, - paint: { - "hillshade-illumination-anchor": "viewport", - "hillshade-illumination-direction": 315, - "hillshade-illumination-altitude": 45, - "hillshade-exaggeration": ["interpolate", ["linear"], ["zoom"], 0, 0.15, 6, 0.25, 10, 0.32], - // Dark-mode tuned shading. Alpha is baked into the colors. - "hillshade-shadow-color": "rgba(0,0,0,0.45)", - "hillshade-highlight-color": "rgba(255,255,255,0.18)", - "hillshade-accent-color": "rgba(255,255,255,0.06)", - }, - } as unknown as LayerSpecification; - const bathyFill: LayerSpecification = { id: "bathymetry-fill", type: "fill", @@ -383,7 +386,6 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str const toInsert = [ bathyFill, - bathyHillshade, bathyExtrusion, bathyBandBorders, bathyBandBordersMajor, @@ -452,6 +454,24 @@ function dashifyLine(from: [number, number], to: [number, number], suspicious: b return segs; } +function circleRingLngLat(center: [number, number], radiusMeters: number, steps = 72): [number, number][] { + const [lon0, lat0] = center; + const latRad = lat0 * DEG2RAD; + const cosLat = Math.max(1e-6, Math.cos(latRad)); + const r = Math.max(0, radiusMeters); + + const ring: [number, number][] = []; + for (let i = 0; i <= steps; i++) { + const a = (i / steps) * Math.PI * 2; + const dy = r * Math.sin(a); + const dx = r * Math.cos(a); + const dLat = (dy / EARTH_RADIUS_M) / DEG2RAD; + const dLon = (dx / (EARTH_RADIUS_M * cosLat)) / DEG2RAD; + ring.push([lon0 + dLon, lat0 + dLat]); + } + return ring; +} + type PairRangeCircle = { center: [number, number]; // [lon, lat] radiusNm: number; @@ -708,6 +728,10 @@ export function Map3D({ } } } + + // MapLibre may not schedule a frame immediately after projection swaps if the map is idle. + // Kick a few repaints so overlay sources (ships/zones) appear instantly. + kickRepaint(map); }; if (map.isStyleLoaded()) syncProjectionAndDeck(); @@ -738,6 +762,7 @@ export function Map3D({ // Disable style diff to avoid warnings with custom layers (Deck MapboxOverlay) and // to ensure a clean rebuild when switching between very different styles. map.setStyle(style, { diff: false }); + map.once("style.load", () => kickRepaint(map)); } catch (e) { if (cancelled) return; console.warn("Base map switch failed:", e); @@ -904,6 +929,8 @@ export function Map3D({ } } catch (e) { console.warn("Zones layer setup failed:", e); + } finally { + kickRepaint(map); } }; @@ -916,7 +943,7 @@ export function Map3D({ // ignore } }; - }, [zones, overlays.zones]); + }, [zones, overlays.zones, projection, baseMap]); // 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. @@ -944,6 +971,7 @@ export function Map3D({ // ignore } prevGlobeSelectedRef.current = null; + kickRepaint(map); }; const ensureImage = () => { @@ -1007,6 +1035,9 @@ export function Map3D({ type: "FeatureCollection", features: globeShipData.map((t) => { 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; return { type: "Feature", id: t.mmsi, @@ -1014,8 +1045,11 @@ export function Map3D({ properties: { mmsi: t.mmsi, name: t.name || "", - cog: isFiniteNumber(t.cog) ? t.cog : 0, + cog, + cog4, sog: isFiniteNumber(t.sog) ? t.sog : 0, + length: isFiniteNumber(t.length) ? t.length : 0, + width: isFiniteNumber(t.width) ? t.width : 0, permitted: !!legacy, code: legacy?.shipCode || "", }, @@ -1115,6 +1149,27 @@ 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[] = ["clamp", ["+", 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, @@ -1123,15 +1178,27 @@ export function Map3D({ layout: { visibility, "icon-image": imgId, - "icon-size": ["interpolate", ["linear"], ["zoom"], 3, 0.32, 7, 0.42, 10, 0.52, 14, 0.72], + "icon-size": [ + "interpolate", + ["linear"], + ["zoom"], + 3, + ["*", 0.32, sizeFactor], + 7, + ["*", 0.42, sizeFactor], + 10, + ["*", 0.52, sizeFactor], + 14, + ["*", 0.72, sizeFactor], + ] as unknown as number[], "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. + // Debug-friendly: quantize heading to N/E/S/W while we validate globe alignment. + "icon-rotate": ["get", "cog4"], + // Keep the icon on the sea surface. "icon-rotation-alignment": "map", - "icon-pitch-alignment": "viewport", + "icon-pitch-alignment": "map", }, paint: { "icon-opacity": ["case", ["boolean", ["feature-state", "selected"], false], 1.0, 0.92], @@ -1163,6 +1230,7 @@ export function Map3D({ // ignore } prevGlobeSelectedRef.current = selectedMmsi; + kickRepaint(map); }; ensure(); @@ -1210,6 +1278,382 @@ export function Map3D({ }; }, [projection, settings.showShips, onSelectMmsi]); + // 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. + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = "pair-lines-ml-src"; + const layerId = "pair-lines-ml"; + + const remove = () => { + try { + if (map.getLayer(layerId)) map.removeLayer(layerId); + } catch { + // ignore + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + }; + + const ensure = () => { + if (!map.isStyleLoaded()) return; + if (projection !== "globe" || !overlays.pairLines || (pairLinks?.length ?? 0) === 0) { + remove(); + return; + } + + const fc: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: (pairLinks || []).map((p, idx) => ({ + type: "Feature", + id: `${p.aMmsi}-${p.bMmsi}-${idx}`, + geometry: { type: "LineString", coordinates: [p.from, p.to] }, + properties: { warn: p.warn }, + })), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: "geojson", data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn("Pair lines source setup failed:", e); + return; + } + + const before = map.getLayer("ships-globe-halo") ? "ships-globe-halo" : undefined; + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: "line", + source: srcId, + layout: { "line-cap": "round", "line-join": "round", visibility: "visible" }, + paint: { + "line-color": [ + "case", + ["boolean", ["get", "warn"], false], + "rgba(245,158,11,0.95)", + "rgba(59,130,246,0.55)", + ] as never, + "line-width": ["case", ["boolean", ["get", "warn"], false], 2.2, 1.4] as never, + "line-opacity": 0.9, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("Pair lines layer add failed:", e); + } + } + + kickRepaint(map); + }; + + ensure(); + map.on("style.load", ensure); + return () => { + try { + map.off("style.load", ensure); + } catch { + // ignore + } + remove(); + }; + }, [projection, overlays.pairLines, pairLinks]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = "fc-lines-ml-src"; + const layerId = "fc-lines-ml"; + + const remove = () => { + try { + if (map.getLayer(layerId)) map.removeLayer(layerId); + } catch { + // ignore + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + }; + + const ensure = () => { + if (!map.isStyleLoaded()) return; + if (projection !== "globe" || !overlays.fcLines) { + remove(); + return; + } + + const segs: DashSeg[] = []; + for (const l of fcLinks || []) segs.push(...dashifyLine(l.from, l.to, l.suspicious)); + if (segs.length === 0) { + remove(); + return; + } + + const fc: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: segs.map((s, idx) => ({ + type: "Feature", + id: `fc-${idx}`, + geometry: { type: "LineString", coordinates: [s.from, s.to] }, + properties: { suspicious: s.suspicious }, + })), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: "geojson", data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn("FC lines source setup failed:", e); + return; + } + + const before = map.getLayer("ships-globe-halo") ? "ships-globe-halo" : undefined; + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: "line", + source: srcId, + layout: { "line-cap": "round", "line-join": "round", visibility: "visible" }, + paint: { + "line-color": [ + "case", + ["boolean", ["get", "suspicious"], false], + "rgba(239,68,68,0.95)", + "rgba(217,119,6,0.92)", + ] as never, + "line-width": 1.3, + "line-opacity": 0.9, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("FC lines layer add failed:", e); + } + } + + kickRepaint(map); + }; + + ensure(); + map.on("style.load", ensure); + return () => { + try { + map.off("style.load", ensure); + } catch { + // ignore + } + remove(); + }; + }, [projection, overlays.fcLines, fcLinks]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = "fleet-circles-ml-src"; + const layerId = "fleet-circles-ml"; + + const remove = () => { + try { + if (map.getLayer(layerId)) map.removeLayer(layerId); + } catch { + // ignore + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + }; + + const ensure = () => { + if (!map.isStyleLoaded()) return; + if (projection !== "globe" || !overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) { + remove(); + return; + } + + const fc: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: (fleetCircles || []).map((c, idx) => { + const ring = circleRingLngLat(c.center, c.radiusNm * 1852); + return { + type: "Feature", + id: `fleet-${c.ownerKey}-${idx}`, + geometry: { type: "LineString", coordinates: ring }, + properties: { count: c.count }, + }; + }), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: "geojson", data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn("Fleet circles source setup failed:", e); + return; + } + + const before = map.getLayer("ships-globe-halo") ? "ships-globe-halo" : undefined; + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: "line", + source: srcId, + layout: { "line-cap": "round", "line-join": "round", visibility: "visible" }, + paint: { + "line-color": "rgba(245,158,11,0.65)", + "line-width": 1.1, + "line-opacity": 0.85, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("Fleet circles layer add failed:", e); + } + } + + kickRepaint(map); + }; + + ensure(); + map.on("style.load", ensure); + return () => { + try { + map.off("style.load", ensure); + } catch { + // ignore + } + remove(); + }; + }, [projection, overlays.fleetCircles, fleetCircles]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = "pair-range-ml-src"; + const layerId = "pair-range-ml"; + + const remove = () => { + try { + if (map.getLayer(layerId)) map.removeLayer(layerId); + } catch { + // ignore + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + }; + + const ensure = () => { + if (!map.isStyleLoaded()) return; + if (projection !== "globe" || !overlays.pairRange) { + remove(); + return; + } + + const ranges: PairRangeCircle[] = []; + for (const p of pairLinks || []) { + const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; + ranges.push({ center, radiusNm: Math.max(0.05, p.distanceNm / 2), warn: p.warn }); + } + if (ranges.length === 0) { + remove(); + return; + } + + const fc: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: ranges.map((c, idx) => { + const ring = circleRingLngLat(c.center, c.radiusNm * 1852); + return { + type: "Feature", + id: `pair-range-${idx}`, + geometry: { type: "LineString", coordinates: ring }, + properties: { warn: c.warn }, + }; + }), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: "geojson", data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn("Pair range source setup failed:", e); + return; + } + + const before = map.getLayer("ships-globe-halo") ? "ships-globe-halo" : undefined; + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: "line", + source: srcId, + layout: { "line-cap": "round", "line-join": "round", visibility: "visible" }, + paint: { + "line-color": [ + "case", + ["boolean", ["get", "warn"], false], + "rgba(245,158,11,0.75)", + "rgba(59,130,246,0.45)", + ] as never, + "line-width": 1.0, + "line-opacity": 0.85, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("Pair range layer add failed:", e); + } + } + + kickRepaint(map); + }; + + ensure(); + map.on("style.load", ensure); + return () => { + try { + map.off("style.load", ensure); + } catch { + // ignore + } + remove(); + }; + }, [projection, overlays.pairRange, pairLinks]); + const shipData = useMemo(() => { return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon)); }, [targets]); @@ -1321,15 +1765,15 @@ export function Map3D({ ); } - if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) { + if (overlays.pairLines && projection !== "globe" && (pairLinks?.length ?? 0) > 0) { layers.push( new LineLayer({ id: "pair-lines", data: pairLinks, pickable: false, parameters: overlayParams, - getSourcePosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.from[0], d.from[1]) : d.from), - getTargetPosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.to[0], d.to[1]) : d.to), + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? [245, 158, 11, 220] : [59, 130, 246, 85]), getWidth: (d) => (d.warn ? 2.2 : 1.4), widthUnits: "pixels", @@ -1337,15 +1781,15 @@ export function Map3D({ ); } - if (overlays.fcLines && fcDashed.length > 0) { + if (overlays.fcLines && projection !== "globe" && fcDashed.length > 0) { layers.push( new LineLayer({ id: "fc-lines", data: fcDashed, pickable: false, parameters: overlayParams, - getSourcePosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.from[0], d.from[1]) : d.from), - getTargetPosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.to[0], d.to[1]) : d.to), + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? [239, 68, 68, 220] : [217, 119, 6, 200]), getWidth: () => 1.3, widthUnits: "pixels",