fix(map3d): align globe ship icon rendering and heading

This commit is contained in:
htlee 2026-02-15 14:38:25 +09:00
부모 1225d5c54c
커밋 2514591703

파일 보기

@ -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<string, [number, number, number]> = {
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<BaseMapId>(baseMap);
const projectionRef = useRef<MapProjectionId>(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),