fix(ais,map): 2-stage bootstrap and globe overlay refresh
This commit is contained in:
부모
b8ccef23ca
커밋
c31d26124c
@ -18,6 +18,7 @@ export type AisPollingSnapshot = {
|
||||
|
||||
export type AisPollingOptions = {
|
||||
initialMinutes?: number;
|
||||
bootstrapMinutes?: number;
|
||||
incrementalMinutes?: number;
|
||||
intervalMs?: number;
|
||||
retentionMinutes?: number;
|
||||
@ -43,10 +44,10 @@ function upsertByMmsi(store: Map<number, AisTarget>, rows: AisTarget[]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prefer newer records if the upstream ever returns stale items.
|
||||
const prevTs = prev.messageTimestamp ?? "";
|
||||
const nextTs = r.messageTimestamp ?? "";
|
||||
if (nextTs && prevTs && nextTs < prevTs) continue;
|
||||
// Keep newer rows only. If backend returns same/older timestamp, skip.
|
||||
const prevTs = Date.parse(prev.messageTimestamp || "");
|
||||
const nextTs = Date.parse(r.messageTimestamp || "");
|
||||
if (Number.isFinite(prevTs) && Number.isFinite(nextTs) && nextTs <= prevTs) continue;
|
||||
|
||||
store.set(r.mmsi, r);
|
||||
upserted += 1;
|
||||
@ -112,6 +113,7 @@ function pruneStore(store: Map<number, AisTarget>, retentionMinutes: number, bbo
|
||||
|
||||
export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
||||
const initialMinutes = opts.initialMinutes ?? 60;
|
||||
const bootstrapMinutes = opts.bootstrapMinutes ?? initialMinutes;
|
||||
const incrementalMinutes = opts.incrementalMinutes ?? 1;
|
||||
const intervalMs = opts.intervalMs ?? 60_000;
|
||||
const retentionMinutes = opts.retentionMinutes ?? initialMinutes;
|
||||
@ -122,7 +124,7 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
||||
const radiusMeters = opts.radiusMeters;
|
||||
|
||||
const storeRef = useRef<Map<number, AisTarget>>(new Map());
|
||||
const inFlightRef = useRef(false);
|
||||
const generationRef = useRef(0);
|
||||
|
||||
const [rev, setRev] = useState(0);
|
||||
const [snapshot, setSnapshot] = useState<AisPollingSnapshot>({
|
||||
@ -142,10 +144,9 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
const generation = ++generationRef.current;
|
||||
|
||||
async function run(minutes: number) {
|
||||
if (inFlightRef.current) return;
|
||||
inFlightRef.current = true;
|
||||
async function run(minutes: number, context: "bootstrap" | "initial" | "incremental") {
|
||||
try {
|
||||
setSnapshot((s) => ({ ...s, status: s.status === "idle" ? "loading" : s.status, error: null }));
|
||||
|
||||
@ -159,7 +160,7 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (cancelled || generation !== generationRef.current) return;
|
||||
|
||||
const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data);
|
||||
const deleted = pruneStore(storeRef.current, retentionMinutes, bbox);
|
||||
@ -179,14 +180,12 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
||||
});
|
||||
setRev((r) => r + 1);
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
if (cancelled || generation !== generationRef.current) return;
|
||||
setSnapshot((s) => ({
|
||||
...s,
|
||||
status: "error",
|
||||
status: context === "incremental" ? s.status : "error",
|
||||
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);
|
||||
|
||||
void run(initialMinutes);
|
||||
const id = window.setInterval(() => void run(incrementalMinutes), intervalMs);
|
||||
void run(bootstrapMinutes, "bootstrap");
|
||||
if (bootstrapMinutes !== initialMinutes) {
|
||||
void run(initialMinutes, "initial");
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => void run(incrementalMinutes, "incremental"), intervalMs);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
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(() => {
|
||||
// `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({
|
||||
initialMinutes: 60,
|
||||
bootstrapMinutes: 10,
|
||||
incrementalMinutes: 2,
|
||||
intervalMs: 60_000,
|
||||
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 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]> = {
|
||||
PT: [30, 64, 175], // #1e40af
|
||||
"PT-S": [234, 88, 12], // #ea580c
|
||||
@ -100,15 +174,6 @@ 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
|
||||
@ -480,6 +545,14 @@ export function Map3D({
|
||||
const projectionRef = useRef<MapProjectionId>(projection);
|
||||
const [mapSyncEpoch, setMapSyncEpoch] = useState(0);
|
||||
|
||||
const pulseMapSync = () => {
|
||||
setMapSyncEpoch((prev) => prev + 1);
|
||||
requestAnimationFrame(() => {
|
||||
kickRepaint(mapRef.current);
|
||||
setMapSyncEpoch((prev) => prev + 1);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
showSeamarkRef.current = settings.showSeamark;
|
||||
}, [settings.showSeamark]);
|
||||
@ -561,7 +634,7 @@ export function Map3D({
|
||||
}
|
||||
|
||||
// Ensure the seamark raster overlay exists even when using MapTiler vector styles.
|
||||
map.on("style.load", () => {
|
||||
onMapStyleReady(map, () => {
|
||||
applyProjection();
|
||||
// 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)) {
|
||||
@ -740,20 +813,20 @@ export function Map3D({
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
setMapSyncEpoch((prev) => prev + 1);
|
||||
pulseMapSync();
|
||||
};
|
||||
|
||||
if (map.isStyleLoaded()) syncProjectionAndDeck();
|
||||
else map.once("style.load", syncProjectionAndDeck);
|
||||
else {
|
||||
const stop = onMapStyleReady(map, syncProjectionAndDeck);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stop();
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
try {
|
||||
map.off("style.load", syncProjectionAndDeck);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}, [projection]);
|
||||
|
||||
@ -764,6 +837,7 @@ export function Map3D({
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
let stop: (() => void) | null = null;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
@ -772,10 +846,10 @@ 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", () => {
|
||||
stop = onMapStyleReady(map, () => {
|
||||
kickRepaint(map);
|
||||
requestAnimationFrame(() => kickRepaint(map));
|
||||
setMapSyncEpoch((prev) => prev + 1);
|
||||
pulseMapSync();
|
||||
});
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
@ -786,6 +860,7 @@ export function Map3D({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
stop?.();
|
||||
};
|
||||
}, [baseMap]);
|
||||
|
||||
@ -845,14 +920,9 @@ export function Map3D({
|
||||
}
|
||||
};
|
||||
|
||||
if (map.isStyleLoaded()) apply();
|
||||
map.on("style.load", apply);
|
||||
const stop = onMapStyleReady(map, apply);
|
||||
return () => {
|
||||
try {
|
||||
map.off("style.load", apply);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
stop();
|
||||
};
|
||||
}, [projection, baseMap, mapSyncEpoch]);
|
||||
|
||||
@ -975,14 +1045,9 @@ export function Map3D({
|
||||
}
|
||||
};
|
||||
|
||||
if (map.isStyleLoaded()) ensure();
|
||||
map.on("style.load", ensure);
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
return () => {
|
||||
try {
|
||||
map.off("style.load", ensure);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
stop();
|
||||
};
|
||||
}, [zones, overlays.zones, projection, baseMap, mapSyncEpoch]);
|
||||
|
||||
@ -1040,22 +1105,9 @@ export function Map3D({
|
||||
ctx.fillRect(size / 2 - 8, 34, 16, 18);
|
||||
|
||||
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 = () => {
|
||||
if (!map.isStyleLoaded()) return;
|
||||
|
||||
@ -1099,6 +1151,11 @@ export function Map3D({
|
||||
name: t.name || "",
|
||||
cog: cogNorm,
|
||||
sog: isFiniteNumber(t.sog) ? t.sog : 0,
|
||||
shipColor: getGlobeShipColor({
|
||||
selected,
|
||||
legacy: legacy?.shipCode || null,
|
||||
sog: isFiniteNumber(t.sog) ? t.sog : null,
|
||||
}),
|
||||
iconSize3,
|
||||
iconSize7,
|
||||
iconSize10,
|
||||
@ -1149,7 +1206,7 @@ export function Map3D({
|
||||
layout: { visibility },
|
||||
paint: {
|
||||
"circle-radius": circleRadius as never,
|
||||
"circle-color": codeColorExpr as never,
|
||||
"circle-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never,
|
||||
"circle-opacity": 0.22,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
@ -1177,7 +1234,7 @@ export function Map3D({
|
||||
paint: {
|
||||
"circle-radius": circleRadius as never,
|
||||
"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": [
|
||||
"case",
|
||||
["boolean", ["get", "permitted"], false],
|
||||
@ -1229,10 +1286,13 @@ export function Map3D({
|
||||
"icon-rotate": ["to-number", ["get", "cog"], 0],
|
||||
// Keep the icon on the sea surface.
|
||||
"icon-rotation-alignment": "map",
|
||||
"icon-pitch-alignment": "map",
|
||||
"icon-pitch-alignment": "viewport",
|
||||
},
|
||||
paint: {
|
||||
"icon-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never,
|
||||
"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,
|
||||
before,
|
||||
@ -1252,14 +1312,9 @@ export function Map3D({
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
ensure();
|
||||
map.on("style.load", ensure);
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
return () => {
|
||||
try {
|
||||
map.off("style.load", ensure);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
stop();
|
||||
};
|
||||
}, [projection, settings.showShips, targets, legacyHits, selectedMmsi, mapSyncEpoch]);
|
||||
|
||||
@ -1277,9 +1332,19 @@ export function Map3D({
|
||||
const onClick = (e: maplibregl.MapMouseEvent) => {
|
||||
try {
|
||||
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 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);
|
||||
if (Number.isFinite(mmsi)) {
|
||||
onSelectMmsi(mmsi);
|
||||
@ -1399,14 +1464,10 @@ export function Map3D({
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
map.on("style.load", ensure);
|
||||
return () => {
|
||||
try {
|
||||
map.off("style.load", ensure);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
stop();
|
||||
remove();
|
||||
};
|
||||
}, [projection, overlays.pairLines, pairLinks, mapSyncEpoch]);
|
||||
@ -1495,14 +1556,10 @@ export function Map3D({
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
map.on("style.load", ensure);
|
||||
return () => {
|
||||
try {
|
||||
map.off("style.load", ensure);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
stop();
|
||||
remove();
|
||||
};
|
||||
}, [projection, overlays.fcLines, fcLinks, mapSyncEpoch]);
|
||||
@ -1582,14 +1639,10 @@ export function Map3D({
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
map.on("style.load", ensure);
|
||||
return () => {
|
||||
try {
|
||||
map.off("style.load", ensure);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
stop();
|
||||
remove();
|
||||
};
|
||||
}, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch]);
|
||||
@ -1684,14 +1737,10 @@ export function Map3D({
|
||||
kickRepaint(map);
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
ensure();
|
||||
map.on("style.load", ensure);
|
||||
return () => {
|
||||
try {
|
||||
map.off("style.load", ensure);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
stop();
|
||||
remove();
|
||||
};
|
||||
}, [projection, overlays.pairRange, pairLinks, mapSyncEpoch]);
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user