fix(globe): force repaints; maplibre overlays; disable bathy raster

This commit is contained in:
htlee 2026-02-15 13:46:01 +09:00
부모 dc0729fc5f
커밋 7f72ab651d

파일 보기

@ -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<GeoJSON.LineString> = {
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<GeoJSON.LineString> = {
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<GeoJSON.LineString> = {
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<GeoJSON.LineString> = {
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<PairLink>({
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<DashSeg>({
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",