fix(api): add center/radius AIS query and stabilize globe ship icon render
This commit is contained in:
부모
7f72ab651d
커밋
bcd4a77f47
@ -3,6 +3,9 @@ import type { AisTargetSearchResponse } from "../model/types";
|
|||||||
export type SearchAisTargetsParams = {
|
export type SearchAisTargetsParams = {
|
||||||
minutes: number;
|
minutes: number;
|
||||||
bbox?: string;
|
bbox?: string;
|
||||||
|
centerLon?: number;
|
||||||
|
centerLat?: number;
|
||||||
|
radiusMeters?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function searchAisTargets(params: SearchAisTargetsParams, signal?: AbortSignal) {
|
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);
|
const u = new URL(`${base}/api/ais-target/search`, window.location.origin);
|
||||||
u.searchParams.set("minutes", String(params.minutes));
|
u.searchParams.set("minutes", String(params.minutes));
|
||||||
if (params.bbox) u.searchParams.set("bbox", params.bbox);
|
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 res = await fetch(u, { signal, headers: { accept: "application/json" } });
|
||||||
const txt = await res.text();
|
const txt = await res.text();
|
||||||
|
|||||||
@ -22,6 +22,9 @@ export type AisPollingOptions = {
|
|||||||
intervalMs?: number;
|
intervalMs?: number;
|
||||||
retentionMinutes?: number;
|
retentionMinutes?: number;
|
||||||
bbox?: string;
|
bbox?: string;
|
||||||
|
centerLon?: number;
|
||||||
|
centerLat?: number;
|
||||||
|
radiusMeters?: number;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -114,6 +117,9 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
|||||||
const retentionMinutes = opts.retentionMinutes ?? initialMinutes;
|
const retentionMinutes = opts.retentionMinutes ?? initialMinutes;
|
||||||
const enabled = opts.enabled ?? true;
|
const enabled = opts.enabled ?? true;
|
||||||
const bbox = opts.bbox;
|
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 storeRef = useRef<Map<number, AisTarget>>(new Map());
|
||||||
const inFlightRef = useRef(false);
|
const inFlightRef = useRef(false);
|
||||||
@ -143,7 +149,16 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
|||||||
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 }));
|
||||||
|
|
||||||
const res = await searchAisTargets({ minutes, bbox }, controller.signal);
|
const res = await searchAisTargets(
|
||||||
|
{
|
||||||
|
minutes,
|
||||||
|
bbox,
|
||||||
|
centerLon,
|
||||||
|
centerLat,
|
||||||
|
radiusMeters,
|
||||||
|
},
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data);
|
const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data);
|
||||||
@ -198,7 +213,7 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
window.clearInterval(id);
|
window.clearInterval(id);
|
||||||
};
|
};
|
||||||
}, [initialMinutes, incrementalMinutes, intervalMs, retentionMinutes, bbox, enabled]);
|
}, [initialMinutes, 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.
|
||||||
|
|||||||
@ -33,6 +33,11 @@ import {
|
|||||||
import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta";
|
import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta";
|
||||||
|
|
||||||
const AIS_API_BASE = (import.meta.env.VITE_API_URL || "/snp-api").replace(/\/$/, "");
|
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) {
|
function fmtLocal(iso: string | null) {
|
||||||
if (!iso) return "-";
|
if (!iso) return "-";
|
||||||
@ -75,6 +80,9 @@ export function DashboardPage() {
|
|||||||
intervalMs: 60_000,
|
intervalMs: 60_000,
|
||||||
retentionMinutes: 90,
|
retentionMinutes: 90,
|
||||||
bbox: useApiBbox ? apiBbox : undefined,
|
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);
|
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 EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius`
|
||||||
const DEG2RAD = Math.PI / 180;
|
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] {
|
function lngLatToUnitSphere(lon: number, lat: number, altitudeMeters = 0): [number, number, number] {
|
||||||
const lambda = lon * DEG2RAD;
|
const lambda = lon * DEG2RAD;
|
||||||
const phi = lat * DEG2RAD;
|
const phi = lat * DEG2RAD;
|
||||||
@ -299,14 +303,20 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str
|
|||||||
} as unknown as LayerSpecification;
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500];
|
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 = {
|
const bathyLinesMajor: LayerSpecification = {
|
||||||
id: "bathymetry-lines-major",
|
id: "bathymetry-lines-major",
|
||||||
type: "line",
|
type: "line",
|
||||||
source: oceanSourceId,
|
source: oceanSourceId,
|
||||||
"source-layer": "contour_line",
|
"source-layer": "contour_line",
|
||||||
minzoom: 8,
|
minzoom: 8,
|
||||||
// Use legacy filter syntax here (not expression "in"), so we can pass multiple values.
|
filter: bathyMajorDepthFilter as unknown as unknown[],
|
||||||
filter: ["in", "depth", ...majorDepths] as unknown as unknown[],
|
|
||||||
paint: {
|
paint: {
|
||||||
"line-color": "rgba(255,255,255,0.16)",
|
"line-color": "rgba(255,255,255,0.16)",
|
||||||
"line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0.22, 10, 0.28, 12, 0.34],
|
"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: oceanSourceId,
|
||||||
"source-layer": "contour",
|
"source-layer": "contour",
|
||||||
minzoom: 4,
|
minzoom: 4,
|
||||||
// Use legacy filter syntax here (not expression "in"), so we can pass multiple values.
|
filter: bathyMajorDepthFilter as unknown as unknown[],
|
||||||
filter: ["in", "depth", ...majorDepths] as unknown as unknown[],
|
|
||||||
paint: {
|
paint: {
|
||||||
"line-color": "rgba(255,255,255,0.14)",
|
"line-color": "rgba(255,255,255,0.14)",
|
||||||
"line-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.14, 8, 0.2, 12, 0.26],
|
"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: oceanSourceId,
|
||||||
"source-layer": "contour_line",
|
"source-layer": "contour_line",
|
||||||
minzoom: 10,
|
minzoom: 10,
|
||||||
// Use legacy filter syntax here (not expression "in"), so we can pass multiple values.
|
filter: bathyMajorDepthFilter as unknown as unknown[],
|
||||||
filter: ["in", "depth", ...majorDepths] as unknown as unknown[],
|
|
||||||
layout: {
|
layout: {
|
||||||
"symbol-placement": "line",
|
"symbol-placement": "line",
|
||||||
"text-field": depthLabel,
|
"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.
|
// 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.
|
// Kick a few repaints so overlay sources (ships/zones) appear instantly.
|
||||||
kickRepaint(map);
|
kickRepaint(map);
|
||||||
|
try {
|
||||||
|
map.resize();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (map.isStyleLoaded()) syncProjectionAndDeck();
|
if (map.isStyleLoaded()) syncProjectionAndDeck();
|
||||||
@ -762,7 +775,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", () => kickRepaint(map));
|
map.once("style.load", () => {
|
||||||
|
kickRepaint(map);
|
||||||
|
requestAnimationFrame(() => kickRepaint(map));
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
console.warn("Base map switch failed:", e);
|
console.warn("Base map switch failed:", e);
|
||||||
@ -785,6 +801,9 @@ export function Map3D({
|
|||||||
if (!map.isStyleLoaded()) return;
|
if (!map.isStyleLoaded()) return;
|
||||||
const disableBathyHeavy = projection === "globe" && baseMap === "enhanced";
|
const disableBathyHeavy = projection === "globe" && baseMap === "enhanced";
|
||||||
const visHeavy = disableBathyHeavy ? "none" : "visible";
|
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
|
// 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.
|
// (65535), causing broken ocean rendering. Keep globe mode stable by disabling the heavy fill.
|
||||||
@ -802,6 +821,27 @@ export function Map3D({
|
|||||||
// ignore
|
// 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();
|
if (map.isStyleLoaded()) apply();
|
||||||
@ -1151,7 +1191,7 @@ export function Map3D({
|
|||||||
try {
|
try {
|
||||||
const lengthExpr: unknown[] = ["to-number", ["get", "length"], 0];
|
const lengthExpr: unknown[] = ["to-number", ["get", "length"], 0];
|
||||||
const widthExpr: unknown[] = ["to-number", ["get", "width"], 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[] = [
|
const sizeFactor: unknown[] = [
|
||||||
"interpolate",
|
"interpolate",
|
||||||
["linear"],
|
["linear"],
|
||||||
@ -1254,6 +1294,10 @@ export function Map3D({
|
|||||||
|
|
||||||
const onClick = (e: maplibregl.MapMouseEvent) => {
|
const onClick = (e: maplibregl.MapMouseEvent) => {
|
||||||
try {
|
try {
|
||||||
|
if (!map.getLayer(symbolId)) {
|
||||||
|
onSelectMmsi(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const feats = map.queryRenderedFeatures(e.point, { layers: [symbolId] });
|
const feats = map.queryRenderedFeatures(e.point, { layers: [symbolId] });
|
||||||
const f = feats?.[0];
|
const f = feats?.[0];
|
||||||
const props = (f?.properties || {}) as Record<string, unknown>;
|
const props = (f?.properties || {}) as Record<string, unknown>;
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user