fix(globe): stabilize deck draw; billboard ships

This commit is contained in:
htlee 2026-02-15 13:03:05 +09:00
부모 0172ed6134
커밋 d4859eb361
2개의 변경된 파일316개의 추가작업 그리고 15개의 파일을 삭제

파일 보기

@ -82,6 +82,15 @@ const LEGACY_CODE_COLORS: Record<string, [number, number, number]> = {
FC: [245, 158, 11], // #f59e0b
};
const LEGACY_CODE_HEX: Record<string, string> = {
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<maplibregl.Map | null>(null);
const overlayRef = useRef<MapboxOverlay | null>(null);
const globeDeckLayerRef = useRef<MaplibreDeckCustomLayer | null>(null);
const prevGlobeSelectedRef = useRef<number | null>(null);
const showSeamarkRef = useRef(settings.showSeamark);
const baseMapRef = useRef<BaseMapId>(baseMap);
const projectionRef = useRef<MapProjectionId>(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<GeoJSON.Point> = {
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<string, unknown>;
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<AisTarget>({
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<AisTarget>({
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;

파일 보기

@ -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).