fix(globe): stabilize deck draw; billboard ships
This commit is contained in:
부모
0172ed6134
커밋
d4859eb361
@ -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).
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user