fix(api): add center/radius AIS query and stabilize globe ship icon render

This commit is contained in:
htlee 2026-02-15 13:58:07 +09:00
부모 7f72ab651d
커밋 bcd4a77f47
4개의 변경된 파일89개의 추가작업 그리고 10개의 파일을 삭제

파일 보기

@ -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();

파일 보기

@ -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<Map<number, AisTarget>>(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.

파일 보기

@ -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<number | null>(null);

파일 보기

@ -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<string, unknown>)["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<string, unknown>;