fix(ais,map): 2-stage bootstrap and globe overlay refresh

This commit is contained in:
htlee 2026-02-15 14:17:27 +09:00
부모 b8ccef23ca
커밋 c31d26124c
3개의 변경된 파일174개의 추가작업 그리고 110개의 파일을 삭제

파일 보기

@ -18,6 +18,7 @@ export type AisPollingSnapshot = {
export type AisPollingOptions = { export type AisPollingOptions = {
initialMinutes?: number; initialMinutes?: number;
bootstrapMinutes?: number;
incrementalMinutes?: number; incrementalMinutes?: number;
intervalMs?: number; intervalMs?: number;
retentionMinutes?: number; retentionMinutes?: number;
@ -43,10 +44,10 @@ function upsertByMmsi(store: Map<number, AisTarget>, rows: AisTarget[]) {
continue; continue;
} }
// Prefer newer records if the upstream ever returns stale items. // Keep newer rows only. If backend returns same/older timestamp, skip.
const prevTs = prev.messageTimestamp ?? ""; const prevTs = Date.parse(prev.messageTimestamp || "");
const nextTs = r.messageTimestamp ?? ""; const nextTs = Date.parse(r.messageTimestamp || "");
if (nextTs && prevTs && nextTs < prevTs) continue; if (Number.isFinite(prevTs) && Number.isFinite(nextTs) && nextTs <= prevTs) continue;
store.set(r.mmsi, r); store.set(r.mmsi, r);
upserted += 1; upserted += 1;
@ -112,6 +113,7 @@ function pruneStore(store: Map<number, AisTarget>, retentionMinutes: number, bbo
export function useAisTargetPolling(opts: AisPollingOptions = {}) { export function useAisTargetPolling(opts: AisPollingOptions = {}) {
const initialMinutes = opts.initialMinutes ?? 60; const initialMinutes = opts.initialMinutes ?? 60;
const bootstrapMinutes = opts.bootstrapMinutes ?? initialMinutes;
const incrementalMinutes = opts.incrementalMinutes ?? 1; const incrementalMinutes = opts.incrementalMinutes ?? 1;
const intervalMs = opts.intervalMs ?? 60_000; const intervalMs = opts.intervalMs ?? 60_000;
const retentionMinutes = opts.retentionMinutes ?? initialMinutes; const retentionMinutes = opts.retentionMinutes ?? initialMinutes;
@ -122,7 +124,7 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
const radiusMeters = opts.radiusMeters; const radiusMeters = opts.radiusMeters;
const storeRef = useRef<Map<number, AisTarget>>(new Map()); const storeRef = useRef<Map<number, AisTarget>>(new Map());
const inFlightRef = useRef(false); const generationRef = useRef(0);
const [rev, setRev] = useState(0); const [rev, setRev] = useState(0);
const [snapshot, setSnapshot] = useState<AisPollingSnapshot>({ const [snapshot, setSnapshot] = useState<AisPollingSnapshot>({
@ -142,10 +144,9 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
let cancelled = false; let cancelled = false;
const controller = new AbortController(); const controller = new AbortController();
const generation = ++generationRef.current;
async function run(minutes: number) { async function run(minutes: number, context: "bootstrap" | "initial" | "incremental") {
if (inFlightRef.current) return;
inFlightRef.current = true;
try { try {
setSnapshot((s) => ({ ...s, status: s.status === "idle" ? "loading" : s.status, error: null })); setSnapshot((s) => ({ ...s, status: s.status === "idle" ? "loading" : s.status, error: null }));
@ -159,7 +160,7 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
}, },
controller.signal, controller.signal,
); );
if (cancelled) return; if (cancelled || generation !== generationRef.current) return;
const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data); const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data);
const deleted = pruneStore(storeRef.current, retentionMinutes, bbox); const deleted = pruneStore(storeRef.current, retentionMinutes, bbox);
@ -179,14 +180,12 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
}); });
setRev((r) => r + 1); setRev((r) => r + 1);
} catch (e) { } catch (e) {
if (cancelled) return; if (cancelled || generation !== generationRef.current) return;
setSnapshot((s) => ({ setSnapshot((s) => ({
...s, ...s,
status: "error", status: context === "incremental" ? s.status : "error",
error: e instanceof Error ? e.message : String(e), error: e instanceof Error ? e.message : String(e),
})); }));
} finally {
inFlightRef.current = false;
} }
} }
@ -205,15 +204,30 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
}); });
setRev((r) => r + 1); setRev((r) => r + 1);
void run(initialMinutes); void run(bootstrapMinutes, "bootstrap");
const id = window.setInterval(() => void run(incrementalMinutes), intervalMs); if (bootstrapMinutes !== initialMinutes) {
void run(initialMinutes, "initial");
}
const id = window.setInterval(() => void run(incrementalMinutes, "incremental"), intervalMs);
return () => { return () => {
cancelled = true; cancelled = true;
controller.abort(); controller.abort();
window.clearInterval(id); window.clearInterval(id);
}; };
}, [initialMinutes, incrementalMinutes, intervalMs, retentionMinutes, bbox, centerLon, centerLat, radiusMeters, enabled]); }, [
initialMinutes,
bootstrapMinutes,
incrementalMinutes,
intervalMs,
retentionMinutes,
bbox,
centerLon,
centerLat,
radiusMeters,
enabled,
]);
const targets = useMemo(() => { const targets = useMemo(() => {
// `rev` is a version counter so we recompute the array snapshot when the store changes. // `rev` is a version counter so we recompute the array snapshot when the store changes.

파일 보기

@ -76,6 +76,7 @@ export function DashboardPage() {
const { targets, snapshot } = useAisTargetPolling({ const { targets, snapshot } = useAisTargetPolling({
initialMinutes: 60, initialMinutes: 60,
bootstrapMinutes: 10,
incrementalMinutes: 2, incrementalMinutes: 2,
intervalMs: 60_000, intervalMs: 60_000,
retentionMinutes: 90, retentionMinutes: 90,

파일 보기

@ -87,10 +87,84 @@ function kickRepaint(map: maplibregl.Map | null) {
} }
} }
function onMapStyleReady(map: maplibregl.Map, callback: () => void) {
if (map.isStyleLoaded()) {
callback();
return () => {
// noop
};
}
let fired = false;
const runOnce = () => {
if (fired || !map.isStyleLoaded()) return;
fired = true;
callback();
try {
map.off("style.load", runOnce);
map.off("styledata", runOnce);
map.off("idle", runOnce);
} catch {
// ignore
}
};
map.on("style.load", runOnce);
map.on("styledata", runOnce);
map.on("idle", runOnce);
return () => {
if (fired) return;
fired = true;
try {
map.off("style.load", runOnce);
map.off("styledata", runOnce);
map.off("idle", runOnce);
} catch {
// ignore
}
};
}
const DEG2RAD = Math.PI / 180; const DEG2RAD = Math.PI / 180;
const clampNumber = (value: number, minValue: number, maxValue: number) => Math.max(minValue, Math.min(maxValue, value)); const clampNumber = (value: number, minValue: number, maxValue: number) => Math.max(minValue, Math.min(maxValue, value));
function rgbToHex(rgb: [number, number, number]) {
const toHex = (v: number) => {
const clamped = Math.max(0, Math.min(255, Math.round(v)));
return clamped.toString(16).padStart(2, "0");
};
return `#${toHex(rgb[0])}${toHex(rgb[1])}${toHex(rgb[2])}`;
}
function lightenColor(rgb: [number, number, number], ratio = 0.32) {
const out = rgb.map((v) => Math.round(v + (255 - v) * ratio) as number) as [number, number, number];
return out;
}
function getGlobeShipColor({
selected,
legacy,
sog,
}: {
selected: boolean;
legacy: string | null;
sog: number | null;
}) {
if (selected) return "rgba(255,255,255,0.98)";
if (legacy) {
const rgb = LEGACY_CODE_COLORS[legacy];
if (rgb) return rgbToHex(lightenColor(rgb, 0.38));
}
if (!isFiniteNumber(sog)) return "rgba(100,116,139,0.75)";
if (sog >= 10) return "#3b82f6";
if (sog >= 1) return "#22c55e";
return "rgba(100,116,139,0.75)";
}
const LEGACY_CODE_COLORS: Record<string, [number, number, number]> = { const LEGACY_CODE_COLORS: Record<string, [number, number, number]> = {
PT: [30, 64, 175], // #1e40af PT: [30, 64, 175], // #1e40af
"PT-S": [234, 88, 12], // #ea580c "PT-S": [234, 88, 12], // #ea580c
@ -100,15 +174,6 @@ const LEGACY_CODE_COLORS: Record<string, [number, number, number]> = {
FC: [245, 158, 11], // #f59e0b 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 = { const DEPTH_DISABLED_PARAMS = {
// In MapLibre+Deck (interleaved), the map often leaves a depth buffer populated. // 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 // For 2D overlays like zones/icons/halos we want stable painter's-order rendering
@ -480,6 +545,14 @@ export function Map3D({
const projectionRef = useRef<MapProjectionId>(projection); const projectionRef = useRef<MapProjectionId>(projection);
const [mapSyncEpoch, setMapSyncEpoch] = useState(0); const [mapSyncEpoch, setMapSyncEpoch] = useState(0);
const pulseMapSync = () => {
setMapSyncEpoch((prev) => prev + 1);
requestAnimationFrame(() => {
kickRepaint(mapRef.current);
setMapSyncEpoch((prev) => prev + 1);
});
};
useEffect(() => { useEffect(() => {
showSeamarkRef.current = settings.showSeamark; showSeamarkRef.current = settings.showSeamark;
}, [settings.showSeamark]); }, [settings.showSeamark]);
@ -561,7 +634,7 @@ export function Map3D({
} }
// Ensure the seamark raster overlay exists even when using MapTiler vector styles. // Ensure the seamark raster overlay exists even when using MapTiler vector styles.
map.on("style.load", () => { onMapStyleReady(map, () => {
applyProjection(); applyProjection();
// Globe deck layer lives inside the style and must be re-added after any style swap. // Globe deck layer lives inside the style and must be re-added after any style swap.
if (projectionRef.current === "globe" && globeDeckLayerRef.current && !map!.getLayer(globeDeckLayerRef.current.id)) { if (projectionRef.current === "globe" && globeDeckLayerRef.current && !map!.getLayer(globeDeckLayerRef.current.id)) {
@ -599,16 +672,16 @@ export function Map3D({
} }
} }
map.once("load", () => { map.once("load", () => {
if (showSeamarkRef.current) { if (showSeamarkRef.current) {
try { try {
ensureSeamarkOverlay(map!, "bathymetry-lines"); ensureSeamarkOverlay(map!, "bathymetry-lines");
} catch { } catch {
// ignore // ignore
}
applySeamarkOpacity();
} }
}); applySeamarkOpacity();
}
});
})(); })();
return () => { return () => {
@ -658,7 +731,7 @@ export function Map3D({
} }
const next = projection; const next = projection;
try { try {
map.setProjection({ type: next }); map.setProjection({ type: next });
map.setRenderWorldCopies(next !== "globe"); map.setRenderWorldCopies(next !== "globe");
} catch (e) { } catch (e) {
@ -740,20 +813,20 @@ export function Map3D({
} catch { } catch {
// ignore // ignore
} }
pulseMapSync();
setMapSyncEpoch((prev) => prev + 1);
}; };
if (map.isStyleLoaded()) syncProjectionAndDeck(); if (map.isStyleLoaded()) syncProjectionAndDeck();
else map.once("style.load", syncProjectionAndDeck); else {
const stop = onMapStyleReady(map, syncProjectionAndDeck);
return () => {
cancelled = true;
stop();
};
}
return () => { return () => {
cancelled = true; cancelled = true;
try {
map.off("style.load", syncProjectionAndDeck);
} catch {
// ignore
}
}; };
}, [projection]); }, [projection]);
@ -764,6 +837,7 @@ export function Map3D({
let cancelled = false; let cancelled = false;
const controller = new AbortController(); const controller = new AbortController();
let stop: (() => void) | null = null;
(async () => { (async () => {
try { try {
@ -772,10 +846,10 @@ export function Map3D({
// Disable style diff to avoid warnings with custom layers (Deck MapboxOverlay) and // Disable style diff to avoid warnings with custom layers (Deck MapboxOverlay) and
// to ensure a clean rebuild when switching between very different styles. // to ensure a clean rebuild when switching between very different styles.
map.setStyle(style, { diff: false }); map.setStyle(style, { diff: false });
map.once("style.load", () => { stop = onMapStyleReady(map, () => {
kickRepaint(map); kickRepaint(map);
requestAnimationFrame(() => kickRepaint(map)); requestAnimationFrame(() => kickRepaint(map));
setMapSyncEpoch((prev) => prev + 1); pulseMapSync();
}); });
} catch (e) { } catch (e) {
if (cancelled) return; if (cancelled) return;
@ -786,6 +860,7 @@ export function Map3D({
return () => { return () => {
cancelled = true; cancelled = true;
controller.abort(); controller.abort();
stop?.();
}; };
}, [baseMap]); }, [baseMap]);
@ -845,14 +920,9 @@ export function Map3D({
} }
}; };
if (map.isStyleLoaded()) apply(); const stop = onMapStyleReady(map, apply);
map.on("style.load", apply);
return () => { return () => {
try { stop();
map.off("style.load", apply);
} catch {
// ignore
}
}; };
}, [projection, baseMap, mapSyncEpoch]); }, [projection, baseMap, mapSyncEpoch]);
@ -975,14 +1045,9 @@ export function Map3D({
} }
}; };
if (map.isStyleLoaded()) ensure(); const stop = onMapStyleReady(map, ensure);
map.on("style.load", ensure);
return () => { return () => {
try { stop();
map.off("style.load", ensure);
} catch {
// ignore
}
}; };
}, [zones, overlays.zones, projection, baseMap, mapSyncEpoch]); }, [zones, overlays.zones, projection, baseMap, mapSyncEpoch]);
@ -1040,22 +1105,9 @@ export function Map3D({
ctx.fillRect(size / 2 - 8, 34, 16, 18); ctx.fillRect(size / 2 - 8, 34, 16, 18);
const img = ctx.getImageData(0, 0, size, size); const img = ctx.getImageData(0, 0, size, size);
map.addImage(imgId, img, { pixelRatio: 2 }); map.addImage(imgId, img, { pixelRatio: 2, sdf: true });
}; };
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 = () => { const ensure = () => {
if (!map.isStyleLoaded()) return; if (!map.isStyleLoaded()) return;
@ -1099,6 +1151,11 @@ export function Map3D({
name: t.name || "", name: t.name || "",
cog: cogNorm, cog: cogNorm,
sog: isFiniteNumber(t.sog) ? t.sog : 0, sog: isFiniteNumber(t.sog) ? t.sog : 0,
shipColor: getGlobeShipColor({
selected,
legacy: legacy?.shipCode || null,
sog: isFiniteNumber(t.sog) ? t.sog : null,
}),
iconSize3, iconSize3,
iconSize7, iconSize7,
iconSize10, iconSize10,
@ -1149,7 +1206,7 @@ export function Map3D({
layout: { visibility }, layout: { visibility },
paint: { paint: {
"circle-radius": circleRadius as never, "circle-radius": circleRadius as never,
"circle-color": codeColorExpr as never, "circle-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never,
"circle-opacity": 0.22, "circle-opacity": 0.22,
}, },
} as unknown as LayerSpecification, } as unknown as LayerSpecification,
@ -1177,7 +1234,7 @@ export function Map3D({
paint: { paint: {
"circle-radius": circleRadius as never, "circle-radius": circleRadius as never,
"circle-color": "rgba(0,0,0,0)", "circle-color": "rgba(0,0,0,0)",
"circle-stroke-color": codeColorExpr as never, "circle-stroke-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never,
"circle-stroke-width": [ "circle-stroke-width": [
"case", "case",
["boolean", ["get", "permitted"], false], ["boolean", ["get", "permitted"], false],
@ -1229,10 +1286,13 @@ export function Map3D({
"icon-rotate": ["to-number", ["get", "cog"], 0], "icon-rotate": ["to-number", ["get", "cog"], 0],
// Keep the icon on the sea surface. // Keep the icon on the sea surface.
"icon-rotation-alignment": "map", "icon-rotation-alignment": "map",
"icon-pitch-alignment": "map", "icon-pitch-alignment": "viewport",
}, },
paint: { paint: {
"icon-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never,
"icon-opacity": ["case", ["==", ["get", "selected"], 1], 1.0, 0.92], "icon-opacity": ["case", ["==", ["get", "selected"], 1], 1.0, 0.92],
"icon-halo-color": "rgba(15,23,42,0.25)",
"icon-halo-width": 1,
}, },
} as unknown as LayerSpecification, } as unknown as LayerSpecification,
before, before,
@ -1252,14 +1312,9 @@ export function Map3D({
kickRepaint(map); kickRepaint(map);
}; };
ensure(); const stop = onMapStyleReady(map, ensure);
map.on("style.load", ensure);
return () => { return () => {
try { stop();
map.off("style.load", ensure);
} catch {
// ignore
}
}; };
}, [projection, settings.showShips, targets, legacyHits, selectedMmsi, mapSyncEpoch]); }, [projection, settings.showShips, targets, legacyHits, selectedMmsi, mapSyncEpoch]);
@ -1277,9 +1332,19 @@ export function Map3D({
const onClick = (e: maplibregl.MapMouseEvent) => { const onClick = (e: maplibregl.MapMouseEvent) => {
try { try {
const layerIds = [symbolId, haloId, outlineId].filter((id) => map.getLayer(id)); const layerIds = [symbolId, haloId, outlineId].filter((id) => map.getLayer(id));
const feats = layerIds.length > 0 ? map.queryRenderedFeatures(e.point, { layers: layerIds }) : []; let feats: unknown[] = [];
if (layerIds.length > 0) {
try {
feats = map.queryRenderedFeatures(e.point, { layers: layerIds }) as unknown[];
} catch {
feats = [];
}
}
const f = feats?.[0]; const f = feats?.[0];
const props = (f?.properties || {}) as Record<string, unknown>; const props = ((f as { properties?: Record<string, unknown> } | undefined)?.properties || {}) as Record<
string,
unknown
>;
const mmsi = Number(props.mmsi); const mmsi = Number(props.mmsi);
if (Number.isFinite(mmsi)) { if (Number.isFinite(mmsi)) {
onSelectMmsi(mmsi); onSelectMmsi(mmsi);
@ -1399,14 +1464,10 @@ export function Map3D({
kickRepaint(map); kickRepaint(map);
}; };
const stop = onMapStyleReady(map, ensure);
ensure(); ensure();
map.on("style.load", ensure);
return () => { return () => {
try { stop();
map.off("style.load", ensure);
} catch {
// ignore
}
remove(); remove();
}; };
}, [projection, overlays.pairLines, pairLinks, mapSyncEpoch]); }, [projection, overlays.pairLines, pairLinks, mapSyncEpoch]);
@ -1495,14 +1556,10 @@ export function Map3D({
kickRepaint(map); kickRepaint(map);
}; };
const stop = onMapStyleReady(map, ensure);
ensure(); ensure();
map.on("style.load", ensure);
return () => { return () => {
try { stop();
map.off("style.load", ensure);
} catch {
// ignore
}
remove(); remove();
}; };
}, [projection, overlays.fcLines, fcLinks, mapSyncEpoch]); }, [projection, overlays.fcLines, fcLinks, mapSyncEpoch]);
@ -1582,14 +1639,10 @@ export function Map3D({
kickRepaint(map); kickRepaint(map);
}; };
const stop = onMapStyleReady(map, ensure);
ensure(); ensure();
map.on("style.load", ensure);
return () => { return () => {
try { stop();
map.off("style.load", ensure);
} catch {
// ignore
}
remove(); remove();
}; };
}, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch]); }, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch]);
@ -1684,14 +1737,10 @@ export function Map3D({
kickRepaint(map); kickRepaint(map);
}; };
const stop = onMapStyleReady(map, ensure);
ensure(); ensure();
map.on("style.load", ensure);
return () => { return () => {
try { stop();
map.off("style.load", ensure);
} catch {
// ignore
}
remove(); remove();
}; };
}, [projection, overlays.pairRange, pairLinks, mapSyncEpoch]); }, [projection, overlays.pairRange, pairLinks, mapSyncEpoch]);