From bcd4a77f475f12c7f7eda8b1fcce7841127d945b Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 13:58:07 +0900 Subject: [PATCH] fix(api): add center/radius AIS query and stabilize globe ship icon render --- .../aisTarget/api/searchAisTargets.ts | 12 ++++ .../aisPolling/useAisTargetPolling.ts | 19 +++++- .../web/src/pages/dashboard/DashboardPage.tsx | 8 +++ apps/web/src/widgets/map3d/Map3D.tsx | 60 ++++++++++++++++--- 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/apps/web/src/entities/aisTarget/api/searchAisTargets.ts b/apps/web/src/entities/aisTarget/api/searchAisTargets.ts index b963ebc..b6bcdb3 100644 --- a/apps/web/src/entities/aisTarget/api/searchAisTargets.ts +++ b/apps/web/src/entities/aisTarget/api/searchAisTargets.ts @@ -3,6 +3,9 @@ import type { AisTargetSearchResponse } from "../model/types"; export type SearchAisTargetsParams = { minutes: number; bbox?: string; + centerLon?: number; + centerLat?: number; + radiusMeters?: number; }; export async function searchAisTargets(params: SearchAisTargetsParams, signal?: AbortSignal) { @@ -13,6 +16,15 @@ export async function searchAisTargets(params: SearchAisTargetsParams, signal?: const u = new URL(`${base}/api/ais-target/search`, window.location.origin); u.searchParams.set("minutes", String(params.minutes)); if (params.bbox) u.searchParams.set("bbox", params.bbox); + if (typeof params.centerLon === "number" && Number.isFinite(params.centerLon)) { + u.searchParams.set("centerLon", String(params.centerLon)); + } + if (typeof params.centerLat === "number" && Number.isFinite(params.centerLat)) { + u.searchParams.set("centerLat", String(params.centerLat)); + } + if (typeof params.radiusMeters === "number" && Number.isFinite(params.radiusMeters)) { + u.searchParams.set("radiusMeters", String(params.radiusMeters)); + } const res = await fetch(u, { signal, headers: { accept: "application/json" } }); const txt = await res.text(); diff --git a/apps/web/src/features/aisPolling/useAisTargetPolling.ts b/apps/web/src/features/aisPolling/useAisTargetPolling.ts index b7bc959..eec745e 100644 --- a/apps/web/src/features/aisPolling/useAisTargetPolling.ts +++ b/apps/web/src/features/aisPolling/useAisTargetPolling.ts @@ -22,6 +22,9 @@ export type AisPollingOptions = { intervalMs?: number; retentionMinutes?: number; bbox?: string; + centerLon?: number; + centerLat?: number; + radiusMeters?: number; enabled?: boolean; }; @@ -114,6 +117,9 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { const retentionMinutes = opts.retentionMinutes ?? initialMinutes; const enabled = opts.enabled ?? true; const bbox = opts.bbox; + const centerLon = opts.centerLon; + const centerLat = opts.centerLat; + const radiusMeters = opts.radiusMeters; const storeRef = useRef>(new Map()); const inFlightRef = useRef(false); @@ -143,7 +149,16 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { try { setSnapshot((s) => ({ ...s, status: s.status === "idle" ? "loading" : s.status, error: null })); - const res = await searchAisTargets({ minutes, bbox }, controller.signal); + const res = await searchAisTargets( + { + minutes, + bbox, + centerLon, + centerLat, + radiusMeters, + }, + controller.signal, + ); if (cancelled) return; const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data); @@ -198,7 +213,7 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { controller.abort(); window.clearInterval(id); }; - }, [initialMinutes, incrementalMinutes, intervalMs, retentionMinutes, bbox, enabled]); + }, [initialMinutes, 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. diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 4736b2c..f0533fd 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -33,6 +33,11 @@ import { import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta"; const AIS_API_BASE = (import.meta.env.VITE_API_URL || "/snp-api").replace(/\/$/, ""); +const AIS_CENTER = { + lon: 126.95, + lat: 35.95, + radiusMeters: 2_000_000, +}; function fmtLocal(iso: string | null) { if (!iso) return "-"; @@ -75,6 +80,9 @@ export function DashboardPage() { intervalMs: 60_000, retentionMinutes: 90, bbox: useApiBbox ? apiBbox : undefined, + centerLon: useApiBbox ? undefined : AIS_CENTER.lon, + centerLat: useApiBbox ? undefined : AIS_CENTER.lat, + radiusMeters: useApiBbox ? undefined : AIS_CENTER.radiusMeters, }); const [selectedMmsi, setSelectedMmsi] = useState(null); diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 7088fb1..e3c7101 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -90,6 +90,10 @@ function kickRepaint(map: maplibregl.Map | null) { const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` const DEG2RAD = Math.PI / 180; +function clampExpr(inputExpr: unknown, minValue: number, maxValue: number): unknown[] { + return ["min", ["max", inputExpr, minValue], maxValue]; +} + function lngLatToUnitSphere(lon: number, lat: number, altitudeMeters = 0): [number, number, number] { const lambda = lon * DEG2RAD; const phi = lat * DEG2RAD; @@ -299,14 +303,20 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str } as unknown as LayerSpecification; const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500]; + const bathyMajorDepthFilter: unknown[] = [ + "match", + ["to-number", ["get", "depth"]], + ...majorDepths.map((v) => [v, true]).flat(), + false, + ] as unknown[]; + const bathyLinesMajor: LayerSpecification = { id: "bathymetry-lines-major", type: "line", source: oceanSourceId, "source-layer": "contour_line", minzoom: 8, - // Use legacy filter syntax here (not expression "in"), so we can pass multiple values. - filter: ["in", "depth", ...majorDepths] as unknown as unknown[], + filter: bathyMajorDepthFilter as unknown as unknown[], paint: { "line-color": "rgba(255,255,255,0.16)", "line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0.22, 10, 0.28, 12, 0.34], @@ -321,8 +331,7 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str source: oceanSourceId, "source-layer": "contour", minzoom: 4, - // Use legacy filter syntax here (not expression "in"), so we can pass multiple values. - filter: ["in", "depth", ...majorDepths] as unknown as unknown[], + filter: bathyMajorDepthFilter as unknown as unknown[], paint: { "line-color": "rgba(255,255,255,0.14)", "line-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.14, 8, 0.2, 12, 0.26], @@ -337,8 +346,7 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str source: oceanSourceId, "source-layer": "contour_line", minzoom: 10, - // Use legacy filter syntax here (not expression "in"), so we can pass multiple values. - filter: ["in", "depth", ...majorDepths] as unknown as unknown[], + filter: bathyMajorDepthFilter as unknown as unknown[], layout: { "symbol-placement": "line", "text-field": depthLabel, @@ -732,6 +740,11 @@ 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); + try { + map.resize(); + } catch { + // ignore + } }; if (map.isStyleLoaded()) syncProjectionAndDeck(); @@ -762,7 +775,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", () => kickRepaint(map)); + map.once("style.load", () => { + kickRepaint(map); + requestAnimationFrame(() => kickRepaint(map)); + }); } catch (e) { if (cancelled) return; console.warn("Base map switch failed:", e); @@ -785,6 +801,9 @@ export function Map3D({ if (!map.isStyleLoaded()) return; const disableBathyHeavy = projection === "globe" && baseMap === "enhanced"; const visHeavy = disableBathyHeavy ? "none" : "visible"; + const disableBaseMapSea = projection === "globe" && baseMap === "enhanced"; + const seaVisibility = disableBaseMapSea ? "none" : "visible"; + const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i; // Globe + our injected bathymetry fill polygons can exceed MapLibre's per-segment vertex limit // (65535), causing broken ocean rendering. Keep globe mode stable by disabling the heavy fill. @@ -802,6 +821,27 @@ export function Map3D({ // ignore } } + + // Vector basemap water-style layers can flicker on globe with dense symbols/fills in this stack. + // Hide them only in globe/enhanced mode and restore on return. + try { + for (const layer of map.getStyle().layers || []) { + const id = String(layer.id ?? ""); + if (!id) continue; + const sourceLayer = String((layer as Record)["source-layer"] ?? "").toLowerCase(); + const source = String((layer as { source?: unknown }).source ?? "").toLowerCase(); + const isSea = seaRegex.test(id) || seaRegex.test(sourceLayer) || seaRegex.test(source); + if (!isSea) continue; + if (!map.getLayer(id)) continue; + try { + map.setLayoutProperty(id, "visibility", seaVisibility); + } catch { + // ignore + } + } + } catch { + // ignore + } }; if (map.isStyleLoaded()) apply(); @@ -1151,7 +1191,7 @@ export function Map3D({ 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 hullExpr: unknown[] = clampExpr(["+", lengthExpr, ["*", 3, widthExpr]], 0, 420); const sizeFactor: unknown[] = [ "interpolate", ["linear"], @@ -1254,6 +1294,10 @@ export function Map3D({ const onClick = (e: maplibregl.MapMouseEvent) => { try { + if (!map.getLayer(symbolId)) { + onSelectMmsi(null); + return; + } const feats = map.queryRenderedFeatures(e.point, { layers: [symbolId] }); const f = feats?.[0]; const props = (f?.properties || {}) as Record;