From 2514591703b593765667ac91021f53e336937060 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 14:38:25 +0900 Subject: [PATCH] fix(map3d): align globe ship icon rendering and heading --- apps/web/src/widgets/map3d/Map3D.tsx | 123 +++++++++++++++++++++------ 1 file changed, 96 insertions(+), 27 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 0afa504..b5f7de9 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -137,9 +137,29 @@ function extractProjectionType(map: maplibregl.Map): MapProjectionId | undefined } const DEG2RAD = Math.PI / 180; +const GLOBE_ICON_HEADING_OFFSET_DEG = -90; const clampNumber = (value: number, minValue: number, maxValue: number) => Math.max(minValue, Math.min(maxValue, value)); +function normalizeAngleDeg(value: number, offset = 0): number { + const v = value + offset; + return ((v % 360) + 360) % 360; +} + +function getDisplayHeading({ + cog, + heading, + offset = 0, +}: { + cog: number | null | undefined; + heading: number | null | undefined; + offset?: number; +}) { + const raw = + isFiniteNumber(heading) && heading >= 0 && heading <= 360 && heading !== 511 ? heading : isFiniteNumber(cog) ? cog : 0; + return normalizeAngleDeg(raw, offset); +} + function rgbToHex(rgb: [number, number, number]) { const toHex = (v: number) => { const clamped = Math.max(0, Math.min(255, Math.round(v))); @@ -182,6 +202,7 @@ const LEGACY_CODE_COLORS: Record = { OT: [139, 92, 246], // #8b5cf6 PS: [239, 68, 68], // #ef4444 FC: [245, 158, 11], // #f59e0b + C21: [236, 72, 153], // #ec4899 }; const DEPTH_DISABLED_PARAMS = { @@ -558,6 +579,7 @@ export function Map3D({ const showSeamarkRef = useRef(settings.showSeamark); const baseMapRef = useRef(baseMap); const projectionRef = useRef(projection); + const globeShipIconLoadingRef = useRef(false); const [mapSyncEpoch, setMapSyncEpoch] = useState(0); const pulseMapSync = () => { @@ -1176,32 +1198,71 @@ export function Map3D({ }; const ensureImage = () => { + const addFallbackImage = () => { + 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, sdf: true }); + kickRepaint(map); + }; + 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; + if (globeShipIconLoadingRef.current) 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(); + try { + globeShipIconLoadingRef.current = true; + void map + .loadImage("/assets/ship.svg") + .then((response) => { + globeShipIconLoadingRef.current = false; + if (map.hasImage(imgId)) return; - ctx.fillRect(size / 2 - 8, 34, 16, 18); + const loadedImage = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data; + if (!loadedImage) { + addFallbackImage(); + return; + } - const img = ctx.getImageData(0, 0, size, size); - map.addImage(imgId, img, { pixelRatio: 2, sdf: true }); + try { + map.addImage(imgId, loadedImage, { pixelRatio: 2, sdf: true }); + kickRepaint(map); + } catch (e) { + console.warn("Ship icon image add failed:", e); + } + }) + .catch(() => { + globeShipIconLoadingRef.current = false; + addFallbackImage(); + }); + } catch (e) { + globeShipIconLoadingRef.current = false; + try { + addFallbackImage(); + } catch (fallbackError) { + console.warn("Ship icon image setup failed:", e, fallbackError); + } + } }; const ensure = () => { @@ -1228,8 +1289,11 @@ 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 heading = getDisplayHeading({ + cog: t.cog, + heading: t.heading, + offset: GLOBE_ICON_HEADING_OFFSET_DEG, + }); 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; @@ -1245,7 +1309,8 @@ export function Map3D({ properties: { mmsi: t.mmsi, name: t.name || "", - cog: cogNorm, + cog: heading, + heading, sog: isFiniteNumber(t.sog) ? t.sog : 0, shipColor: getGlobeShipColor({ selected, @@ -1379,7 +1444,7 @@ export function Map3D({ "icon-allow-overlap": true, "icon-ignore-placement": true, "icon-anchor": "center", - "icon-rotate": ["to-number", ["get", "cog"], 0], + "icon-rotate": ["to-number", ["get", "heading"], 0], // Keep the icon on the sea surface. "icon-rotation-alignment": "map", "icon-pitch-alignment": "map", @@ -1927,7 +1992,11 @@ export function Map3D({ getIcon: () => "ship", getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => (isFiniteNumber(d.cog) ? d.cog : 0), + getAngle: (d) => + getDisplayHeading({ + cog: d.cog, + heading: d.heading, + }), sizeUnits: "pixels", getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? FLAT_SHIP_ICON_SIZE_SELECTED : FLAT_SHIP_ICON_SIZE), getColor: (d) => getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null),