feat(map): 항적조회 기능 구현 #14
@ -131,6 +131,52 @@ function parseBbox(raw: string | undefined) {
|
||||
return { lonMin, latMin, lonMax, latMax };
|
||||
}
|
||||
|
||||
app.get<{
|
||||
Params: { mmsi: string };
|
||||
Querystring: { minutes?: string };
|
||||
}>("/api/ais-target/:mmsi/track", async (req, reply) => {
|
||||
const mmsiRaw = req.params.mmsi;
|
||||
const mmsi = Number(mmsiRaw);
|
||||
if (!Number.isFinite(mmsi) || mmsi <= 0 || !Number.isInteger(mmsi)) {
|
||||
return reply.code(400).send({ success: false, message: "invalid mmsi", data: [], errorCode: "BAD_REQUEST" });
|
||||
}
|
||||
|
||||
const minutesRaw = req.query.minutes ?? "360";
|
||||
const minutes = Number(minutesRaw);
|
||||
if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 7200) {
|
||||
return reply.code(400).send({ success: false, message: "invalid minutes (1-7200)", data: [], errorCode: "BAD_REQUEST" });
|
||||
}
|
||||
|
||||
const u = new URL(`/snp-api/api/ais-target/${mmsi}/track`, AIS_UPSTREAM_BASE);
|
||||
u.searchParams.set("minutes", String(minutes));
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = 20_000;
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(u, { signal: controller.signal, headers: { accept: "application/json" } });
|
||||
const txt = await res.text();
|
||||
if (!res.ok) {
|
||||
req.log.warn({ status: res.status, body: txt.slice(0, 2000) }, "Track upstream error");
|
||||
return reply.code(502).send({ success: false, message: "upstream error", data: [], errorCode: "UPSTREAM" });
|
||||
}
|
||||
|
||||
reply.type("application/json").send(txt);
|
||||
} catch (e) {
|
||||
const name = e instanceof Error ? e.name : "";
|
||||
const isTimeout = name === "AbortError";
|
||||
req.log.warn({ err: e, url: u.toString() }, "Track proxy request failed");
|
||||
return reply.code(isTimeout ? 504 : 502).send({
|
||||
success: false,
|
||||
message: isTimeout ? `upstream timeout (${timeoutMs}ms)` : "upstream fetch failed",
|
||||
data: [],
|
||||
errorCode: isTimeout ? "UPSTREAM_TIMEOUT" : "UPSTREAM_FETCH_FAILED",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/zones", async (_req, reply) => {
|
||||
const zonesPath = path.resolve(
|
||||
process.cwd(),
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@deck.gl/aggregation-layers": "^9.2.7",
|
||||
"@deck.gl/core": "^9.2.7",
|
||||
"@deck.gl/geo-layers": "^9.2.7",
|
||||
"@deck.gl/layers": "^9.2.7",
|
||||
"@deck.gl/mapbox": "^9.2.7",
|
||||
"@react-oauth/google": "^0.13.4",
|
||||
|
||||
32
apps/web/src/entities/vesselTrack/api/fetchTrack.ts
Normal file
32
apps/web/src/entities/vesselTrack/api/fetchTrack.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { TrackResponse } from '../model/types';
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_URL || '/snp-api').replace(/\/$/, '');
|
||||
|
||||
export async function fetchVesselTrack(
|
||||
mmsi: number,
|
||||
minutes: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TrackResponse> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15_000);
|
||||
|
||||
const combinedSignal = signal ?? controller.signal;
|
||||
|
||||
try {
|
||||
const url = `${API_BASE}/api/ais-target/${mmsi}/track?minutes=${minutes}`;
|
||||
const res = await fetch(url, {
|
||||
signal: combinedSignal,
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`Track API error ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as TrackResponse;
|
||||
return json;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
115
apps/web/src/entities/vesselTrack/lib/buildTrackGeoJson.ts
Normal file
115
apps/web/src/entities/vesselTrack/lib/buildTrackGeoJson.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { haversineNm } from '../../../shared/lib/geo/haversineNm';
|
||||
import type { ActiveTrack, NormalizedTrip } from '../model/types';
|
||||
|
||||
/** 시간순 정렬 후 TripsLayer용 정규화 데이터 생성 */
|
||||
export function normalizeTrip(
|
||||
track: ActiveTrack,
|
||||
color: [number, number, number],
|
||||
): NormalizedTrip {
|
||||
const sorted = [...track.points].sort(
|
||||
(a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(),
|
||||
);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return { path: [], timestamps: [], mmsi: track.mmsi, name: '', color };
|
||||
}
|
||||
|
||||
const baseEpoch = new Date(sorted[0].messageTimestamp).getTime();
|
||||
const path: [number, number][] = [];
|
||||
const timestamps: number[] = [];
|
||||
|
||||
for (const pt of sorted) {
|
||||
path.push([pt.lon, pt.lat]);
|
||||
// 32-bit float 정밀도를 보장하기 위해 첫 포인트 기준 초 단위 오프셋
|
||||
timestamps.push((new Date(pt.messageTimestamp).getTime() - baseEpoch) / 1000);
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
timestamps,
|
||||
mmsi: track.mmsi,
|
||||
name: sorted[0].name || `MMSI ${track.mmsi}`,
|
||||
color,
|
||||
};
|
||||
}
|
||||
|
||||
/** Globe 전용 — LineString GeoJSON */
|
||||
export function buildTrackLineGeoJson(
|
||||
track: ActiveTrack,
|
||||
): GeoJSON.FeatureCollection<GeoJSON.LineString> {
|
||||
const sorted = [...track.points].sort(
|
||||
(a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(),
|
||||
);
|
||||
|
||||
if (sorted.length < 2) {
|
||||
return { type: 'FeatureCollection', features: [] };
|
||||
}
|
||||
|
||||
let totalDistanceNm = 0;
|
||||
const coordinates: [number, number][] = [];
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const pt = sorted[i];
|
||||
coordinates.push([pt.lon, pt.lat]);
|
||||
if (i > 0) {
|
||||
const prev = sorted[i - 1];
|
||||
totalDistanceNm += haversineNm(prev.lat, prev.lon, pt.lat, pt.lon);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
mmsi: track.mmsi,
|
||||
name: sorted[0].name || `MMSI ${track.mmsi}`,
|
||||
pointCount: sorted.length,
|
||||
minutes: track.minutes,
|
||||
totalDistanceNm: Math.round(totalDistanceNm * 100) / 100,
|
||||
},
|
||||
geometry: { type: 'LineString', coordinates },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Globe+Mercator 공용 — Point GeoJSON */
|
||||
export function buildTrackPointsGeoJson(
|
||||
track: ActiveTrack,
|
||||
): GeoJSON.FeatureCollection<GeoJSON.Point> {
|
||||
const sorted = [...track.points].sort(
|
||||
(a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(),
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: sorted.map((pt, index) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
mmsi: pt.mmsi,
|
||||
name: pt.name,
|
||||
sog: pt.sog,
|
||||
cog: pt.cog,
|
||||
heading: pt.heading,
|
||||
status: pt.status,
|
||||
messageTimestamp: pt.messageTimestamp,
|
||||
index,
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [pt.lon, pt.lat] },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function getTrackTimeRange(trip: NormalizedTrip): {
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
durationSec: number;
|
||||
} {
|
||||
if (trip.timestamps.length === 0) {
|
||||
return { minTime: 0, maxTime: 0, durationSec: 0 };
|
||||
}
|
||||
const minTime = trip.timestamps[0];
|
||||
const maxTime = trip.timestamps[trip.timestamps.length - 1];
|
||||
return { minTime, maxTime, durationSec: maxTime - minTime };
|
||||
}
|
||||
39
apps/web/src/entities/vesselTrack/model/types.ts
Normal file
39
apps/web/src/entities/vesselTrack/model/types.ts
Normal file
@ -0,0 +1,39 @@
|
||||
export interface TrackPoint {
|
||||
mmsi: number;
|
||||
name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
heading: number;
|
||||
sog: number;
|
||||
cog: number;
|
||||
rot: number;
|
||||
length: number;
|
||||
width: number;
|
||||
draught: number;
|
||||
status: string;
|
||||
messageTimestamp: string;
|
||||
receivedDate: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface TrackResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: TrackPoint[];
|
||||
}
|
||||
|
||||
export interface ActiveTrack {
|
||||
mmsi: number;
|
||||
minutes: number;
|
||||
points: TrackPoint[];
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
/** TripsLayer용 정규화 데이터 */
|
||||
export interface NormalizedTrip {
|
||||
path: [number, number][];
|
||||
timestamps: number[];
|
||||
mmsi: number;
|
||||
name: string;
|
||||
color: [number, number, number];
|
||||
}
|
||||
@ -27,6 +27,8 @@ import { Topbar } from "../../widgets/topbar/Topbar";
|
||||
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
|
||||
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
|
||||
import { VesselList } from "../../widgets/vesselList/VesselList";
|
||||
import type { ActiveTrack } from "../../entities/vesselTrack/model/types";
|
||||
import { fetchVesselTrack } from "../../entities/vesselTrack/api/fetchTrack";
|
||||
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
|
||||
import { DepthLegend } from "../../widgets/legend/DepthLegend";
|
||||
import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types";
|
||||
@ -99,7 +101,7 @@ export function DashboardPage() {
|
||||
const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]);
|
||||
const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null);
|
||||
const uid = null;
|
||||
const uid = user?.id ?? null;
|
||||
const [typeEnabled, setTypeEnabled] = usePersistedState<Record<VesselTypeCode, boolean>>(
|
||||
uid, 'typeEnabled', { PT: true, "PT-S": true, GN: true, OT: true, PS: true, FC: true },
|
||||
);
|
||||
@ -129,6 +131,29 @@ export function DashboardPage() {
|
||||
const [hoveredCableId, setHoveredCableId] = useState<string | null>(null);
|
||||
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
|
||||
|
||||
// 항적 (vessel track)
|
||||
const [activeTrack, setActiveTrack] = useState<ActiveTrack | null>(null);
|
||||
const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(null);
|
||||
const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => {
|
||||
setTrackContextMenu(info);
|
||||
}, []);
|
||||
const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []);
|
||||
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
|
||||
try {
|
||||
const res = await fetchVesselTrack(mmsi, minutes);
|
||||
if (res.success && res.data.length > 0) {
|
||||
const sorted = [...res.data].sort(
|
||||
(a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(),
|
||||
);
|
||||
setActiveTrack({ mmsi, minutes, points: sorted, fetchedAt: Date.now() });
|
||||
} else {
|
||||
console.warn('Track: no data', res.message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Track fetch failed:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
|
||||
showShips: true, showDensity: false, showSeamark: false,
|
||||
});
|
||||
@ -729,6 +754,11 @@ export function DashboardPage() {
|
||||
mapStyleSettings={mapStyleSettings}
|
||||
initialView={mapView}
|
||||
onViewStateChange={setMapView}
|
||||
activeTrack={activeTrack}
|
||||
trackContextMenu={trackContextMenu}
|
||||
onRequestTrack={handleRequestTrack}
|
||||
onCloseTrackMenu={handleCloseTrackMenu}
|
||||
onOpenTrackMenu={handleOpenTrackMenu}
|
||||
/>
|
||||
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
|
||||
<DepthLegend depthStops={mapStyleSettings.depthStops} />
|
||||
|
||||
@ -26,7 +26,9 @@ import { useGlobeOverlays } from './hooks/useGlobeOverlays';
|
||||
import { useGlobeInteraction } from './hooks/useGlobeInteraction';
|
||||
import { useDeckLayers } from './hooks/useDeckLayers';
|
||||
import { useSubcablesLayer } from './hooks/useSubcablesLayer';
|
||||
import { useVesselTrackLayer } from './hooks/useVesselTrackLayer';
|
||||
import { useMapStyleSettings } from './hooks/useMapStyleSettings';
|
||||
import { VesselContextMenu } from './components/VesselContextMenu';
|
||||
|
||||
export type { Map3DSettings, BaseMapId, MapProjectionId } from './types';
|
||||
|
||||
@ -69,6 +71,11 @@ export function Map3D({
|
||||
initialView,
|
||||
onViewStateChange,
|
||||
onGlobeShipsReady,
|
||||
activeTrack = null,
|
||||
trackContextMenu = null,
|
||||
onRequestTrack,
|
||||
onCloseTrackMenu,
|
||||
onOpenTrackMenu,
|
||||
}: Props) {
|
||||
void onHoverFleet;
|
||||
void onClearFleetHover;
|
||||
@ -94,6 +101,7 @@ export function Map3D({
|
||||
|
||||
// ── Hover state ──────────────────────────────────────────────────────
|
||||
const {
|
||||
hoveredDeckMmsiSet: hoveredDeckMmsiArr,
|
||||
setHoveredDeckMmsiSet,
|
||||
setHoveredDeckPairMmsiSet,
|
||||
setHoveredDeckFleetOwnerKey,
|
||||
@ -527,10 +535,80 @@ export function Map3D({
|
||||
},
|
||||
);
|
||||
|
||||
useVesselTrackLayer(
|
||||
mapRef, overlayRef, projectionBusyRef, reorderGlobeFeatureLayers,
|
||||
{ activeTrack, projection, mapSyncEpoch },
|
||||
);
|
||||
|
||||
// 우클릭 컨텍스트 메뉴 — 대상선박(legacyHits)만 허용
|
||||
// Mercator: Deck.gl hover 상태에서 MMSI 참조, Globe: queryRenderedFeatures
|
||||
const hoveredDeckMmsiRef = useRef(hoveredDeckMmsiArr);
|
||||
useEffect(() => { hoveredDeckMmsiRef.current = hoveredDeckMmsiArr; }, [hoveredDeckMmsiArr]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const onContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (!onOpenTrackMenu) return;
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded() || projectionBusyRef.current) return;
|
||||
|
||||
let mmsi: number | null = null;
|
||||
|
||||
if (projectionRef.current === 'globe') {
|
||||
// Globe: MapLibre 네이티브 레이어에서 쿼리
|
||||
const point: [number, number] = [e.offsetX, e.offsetY];
|
||||
const shipLayerIds = [
|
||||
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
|
||||
].filter((id) => map.getLayer(id));
|
||||
|
||||
let features: maplibregl.MapGeoJSONFeature[] = [];
|
||||
try {
|
||||
if (shipLayerIds.length > 0) {
|
||||
features = map.queryRenderedFeatures(point, { layers: shipLayerIds });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (features.length > 0) {
|
||||
const props = features[0].properties || {};
|
||||
const raw = typeof props.mmsi === 'number' ? props.mmsi : Number(props.mmsi);
|
||||
if (Number.isFinite(raw) && raw > 0) mmsi = raw;
|
||||
}
|
||||
} else {
|
||||
// Mercator: Deck.gl hover 상태에서 현재 호버된 MMSI 사용
|
||||
const hovered = hoveredDeckMmsiRef.current;
|
||||
if (hovered.length > 0) mmsi = hovered[0];
|
||||
}
|
||||
|
||||
if (mmsi == null || !legacyHits?.has(mmsi)) return;
|
||||
|
||||
const target = shipByMmsi.get(mmsi);
|
||||
const vesselName = (target?.name || '').trim() || `MMSI ${mmsi}`;
|
||||
onOpenTrackMenu({ x: e.clientX, y: e.clientY, mmsi, vesselName });
|
||||
};
|
||||
container.addEventListener('contextmenu', onContextMenu);
|
||||
return () => container.removeEventListener('contextmenu', onContextMenu);
|
||||
}, [onOpenTrackMenu, legacyHits, shipByMmsi]);
|
||||
|
||||
useFlyTo(
|
||||
mapRef, projectionRef,
|
||||
{ selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom },
|
||||
);
|
||||
|
||||
return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />;
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||||
{trackContextMenu && onRequestTrack && onCloseTrackMenu && (
|
||||
<VesselContextMenu
|
||||
x={trackContextMenu.x}
|
||||
y={trackContextMenu.y}
|
||||
mmsi={trackContextMenu.mmsi}
|
||||
vesselName={trackContextMenu.vesselName}
|
||||
onRequestTrack={onRequestTrack}
|
||||
onClose={onCloseTrackMenu}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
135
apps/web/src/widgets/map3d/components/VesselContextMenu.tsx
Normal file
135
apps/web/src/widgets/map3d/components/VesselContextMenu.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Props {
|
||||
x: number;
|
||||
y: number;
|
||||
mmsi: number;
|
||||
vesselName: string;
|
||||
onRequestTrack: (mmsi: number, minutes: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TRACK_OPTIONS = [
|
||||
{ label: '6시간', minutes: 360 },
|
||||
{ label: '12시간', minutes: 720 },
|
||||
{ label: '1일', minutes: 1440 },
|
||||
{ label: '3일', minutes: 4320 },
|
||||
{ label: '5일', minutes: 7200 },
|
||||
] as const;
|
||||
|
||||
const MENU_WIDTH = 180;
|
||||
const MENU_PAD = 8;
|
||||
|
||||
export function VesselContextMenu({ x, y, mmsi, vesselName, onRequestTrack, onClose }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 화면 밖 보정
|
||||
const left = Math.min(x, window.innerWidth - MENU_WIDTH - MENU_PAD);
|
||||
const maxTop = window.innerHeight - (TRACK_OPTIONS.length * 30 + 56) - MENU_PAD;
|
||||
const top = Math.min(y, maxTop);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onScroll = () => onClose();
|
||||
|
||||
window.addEventListener('keydown', onKey);
|
||||
window.addEventListener('mousedown', onClick, true);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('mousedown', onClick, true);
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const handleSelect = (minutes: number) => {
|
||||
onRequestTrack(mmsi, minutes);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left,
|
||||
top,
|
||||
zIndex: 9999,
|
||||
minWidth: MENU_WIDTH,
|
||||
background: 'rgba(24, 24, 32, 0.96)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
padding: '4px 0',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: 12,
|
||||
color: '#e2e2e2',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '6px 12px 4px',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: 'rgba(255,255,255,0.45)',
|
||||
letterSpacing: 0.3,
|
||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||
marginBottom: 2,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: MENU_WIDTH - 24,
|
||||
}}
|
||||
title={`${vesselName} (${mmsi})`}
|
||||
>
|
||||
{vesselName}
|
||||
</div>
|
||||
|
||||
{/* 항적조회 항목 */}
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 12px 2px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
}}
|
||||
>
|
||||
항적조회
|
||||
</div>
|
||||
|
||||
{TRACK_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.minutes}
|
||||
onClick={() => handleSelect(opt.minutes)}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '5px 12px 5px 24px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#e2e2e2',
|
||||
fontSize: 12,
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.target as HTMLElement).style.background = 'rgba(59,130,246,0.18)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.target as HTMLElement).style.background = 'none';
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -50,6 +50,7 @@ import {
|
||||
getFleetCircleTooltipHtml,
|
||||
} from '../lib/tooltips';
|
||||
import { sanitizeDeckLayerList } from '../lib/mapCore';
|
||||
import { getCachedShipIcon } from '../lib/shipIconCache';
|
||||
|
||||
// NOTE:
|
||||
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
|
||||
@ -380,7 +381,7 @@ export function useDeckLayers(
|
||||
pickable: true,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
iconAtlas: '/assets/ship.svg',
|
||||
iconAtlas: getCachedShipIcon(),
|
||||
iconMapping: SHIP_ICON_MAPPING,
|
||||
getIcon: () => 'ship',
|
||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||
@ -403,7 +404,7 @@ export function useDeckLayers(
|
||||
pickable: false,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
iconAtlas: '/assets/ship.svg',
|
||||
iconAtlas: getCachedShipIcon(),
|
||||
iconMapping: SHIP_ICON_MAPPING,
|
||||
getIcon: () => 'ship',
|
||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||
@ -448,7 +449,7 @@ export function useDeckLayers(
|
||||
pickable: true,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
iconAtlas: '/assets/ship.svg',
|
||||
iconAtlas: getCachedShipIcon(),
|
||||
iconMapping: SHIP_ICON_MAPPING,
|
||||
getIcon: () => 'ship',
|
||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||
@ -484,7 +485,7 @@ export function useDeckLayers(
|
||||
|
||||
if (settings.showShips && shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)).length > 0) {
|
||||
const shipOverlayTargetData2 = shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi));
|
||||
layers.push(new IconLayer<AisTarget>({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: '/assets/ship.svg', iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } }));
|
||||
layers.push(new IconLayer<AisTarget>({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } }));
|
||||
}
|
||||
|
||||
const normalizedLayers = sanitizeDeckLayerList(layers);
|
||||
|
||||
@ -63,6 +63,7 @@ function applyLandColor(map: maplibregl.Map, color: string) {
|
||||
if (id.startsWith('fc-')) continue;
|
||||
if (id.startsWith('fleet-')) continue;
|
||||
if (id.startsWith('predict-')) continue;
|
||||
if (id.startsWith('vessel-track-')) continue;
|
||||
if (id === 'deck-globe') continue;
|
||||
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '');
|
||||
const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer);
|
||||
|
||||
@ -94,6 +94,11 @@ export function useProjectionToggle(
|
||||
'subcables-glow',
|
||||
'subcables-points',
|
||||
'subcables-label',
|
||||
'vessel-track-line',
|
||||
'vessel-track-line-hitarea',
|
||||
'vessel-track-arrow',
|
||||
'vessel-track-pts',
|
||||
'vessel-track-pts-highlight',
|
||||
'zones-fill',
|
||||
'zones-line',
|
||||
'zones-label',
|
||||
|
||||
@ -16,6 +16,7 @@ const LINE_ID = 'subcables-line';
|
||||
const GLOW_ID = 'subcables-glow';
|
||||
const POINTS_ID = 'subcables-points';
|
||||
const LABEL_ID = 'subcables-label';
|
||||
const HOVER_LABEL_ID = 'subcables-hover-label';
|
||||
|
||||
/* ── Paint defaults (used for layer creation + hover reset) ──────── */
|
||||
const LINE_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.7, 6, 0.82, 10, 0.92];
|
||||
@ -63,10 +64,10 @@ const LAYER_SPECS: NativeLayerSpec[] = [
|
||||
type: 'line',
|
||||
sourceId: SRC_ID,
|
||||
paint: {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-color': '#ffffff',
|
||||
'line-opacity': 0,
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 2, 5, 6, 8, 10, 12],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 5, 10, 7],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 2, 10, 6, 16, 10, 24],
|
||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 4, 6, 6, 10, 8],
|
||||
},
|
||||
filter: ['==', ['get', 'id'], ''],
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
@ -107,6 +108,29 @@ const LAYER_SPECS: NativeLayerSpec[] = [
|
||||
},
|
||||
minzoom: 4,
|
||||
},
|
||||
{
|
||||
id: HOVER_LABEL_ID,
|
||||
type: 'symbol',
|
||||
sourceId: SRC_ID,
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': 'rgba(0,0,0,0.85)',
|
||||
'text-halo-width': 2,
|
||||
'text-halo-blur': 0.5,
|
||||
'text-opacity': 0,
|
||||
},
|
||||
layout: {
|
||||
'symbol-placement': 'line',
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 2, 14, 6, 17, 10, 20],
|
||||
'text-font': ['Noto Sans Bold', 'Open Sans Bold'],
|
||||
'text-allow-overlap': true,
|
||||
'text-padding': 2,
|
||||
'text-rotation-alignment': 'map',
|
||||
},
|
||||
filter: ['==', ['get', 'id'], ''],
|
||||
minzoom: 2,
|
||||
},
|
||||
];
|
||||
|
||||
export function useSubcablesLayer(
|
||||
@ -250,42 +274,27 @@ export function useSubcablesLayer(
|
||||
}
|
||||
|
||||
/* ── Hover highlight helper (paint-only mutations) ────────────────── */
|
||||
// 기본 레이어는 항상 기본값 유지, glow 레이어(filter 기반)로만 호버 강조
|
||||
function applyHoverHighlight(map: maplibregl.Map, hoveredId: string | null) {
|
||||
const noMatch = ['==', ['get', 'id'], ''] as never;
|
||||
if (hoveredId) {
|
||||
const matchExpr = ['==', ['get', 'id'], hoveredId];
|
||||
|
||||
if (map.getLayer(LINE_ID)) {
|
||||
map.setPaintProperty(LINE_ID, 'line-opacity', ['case', matchExpr, 1.0, 0.2] as never);
|
||||
map.setPaintProperty(LINE_ID, 'line-width', ['case', matchExpr, 4.5, 1.2] as never);
|
||||
}
|
||||
if (map.getLayer(CASING_ID)) {
|
||||
map.setPaintProperty(CASING_ID, 'line-opacity', ['case', matchExpr, 0.7, 0.12] as never);
|
||||
map.setPaintProperty(CASING_ID, 'line-width', ['case', matchExpr, 6.5, 2.0] as never);
|
||||
}
|
||||
if (map.getLayer(GLOW_ID)) {
|
||||
map.setFilter(GLOW_ID, matchExpr as never);
|
||||
map.setPaintProperty(GLOW_ID, 'line-opacity', 0.35);
|
||||
map.setPaintProperty(GLOW_ID, 'line-opacity', 0.55);
|
||||
}
|
||||
if (map.getLayer(POINTS_ID)) {
|
||||
map.setPaintProperty(POINTS_ID, 'circle-opacity', ['case', matchExpr, 1.0, 0.15] as never);
|
||||
map.setPaintProperty(POINTS_ID, 'circle-radius', ['case', matchExpr, 4, 1.5] as never);
|
||||
if (map.getLayer(HOVER_LABEL_ID)) {
|
||||
map.setFilter(HOVER_LABEL_ID, matchExpr as never);
|
||||
map.setPaintProperty(HOVER_LABEL_ID, 'text-opacity', 1.0);
|
||||
}
|
||||
} else {
|
||||
if (map.getLayer(LINE_ID)) {
|
||||
map.setPaintProperty(LINE_ID, 'line-opacity', LINE_OPACITY_DEFAULT as never);
|
||||
map.setPaintProperty(LINE_ID, 'line-width', LINE_WIDTH_DEFAULT as never);
|
||||
}
|
||||
if (map.getLayer(CASING_ID)) {
|
||||
map.setPaintProperty(CASING_ID, 'line-opacity', CASING_OPACITY_DEFAULT as never);
|
||||
map.setPaintProperty(CASING_ID, 'line-width', CASING_WIDTH_DEFAULT as never);
|
||||
}
|
||||
if (map.getLayer(GLOW_ID)) {
|
||||
map.setFilter(GLOW_ID, ['==', ['get', 'id'], ''] as never);
|
||||
map.setFilter(GLOW_ID, noMatch);
|
||||
map.setPaintProperty(GLOW_ID, 'line-opacity', 0);
|
||||
}
|
||||
if (map.getLayer(POINTS_ID)) {
|
||||
map.setPaintProperty(POINTS_ID, 'circle-opacity', POINTS_OPACITY_DEFAULT as never);
|
||||
map.setPaintProperty(POINTS_ID, 'circle-radius', POINTS_RADIUS_DEFAULT as never);
|
||||
if (map.getLayer(HOVER_LABEL_ID)) {
|
||||
map.setFilter(HOVER_LABEL_ID, noMatch);
|
||||
map.setPaintProperty(HOVER_LABEL_ID, 'text-opacity', 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
395
apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts
Normal file
395
apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts
Normal file
@ -0,0 +1,395 @@
|
||||
/**
|
||||
* useVesselTrackLayer — 항적(Track) 렌더링 hook
|
||||
*
|
||||
* Mercator: TripsLayer 애니메이션 + ScatterplotLayer 포인트
|
||||
* Globe: MapLibre 네이티브 line + circle + symbol(arrow)
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, type MutableRefObject } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { TripsLayer } from '@deck.gl/geo-layers';
|
||||
import { PathLayer, ScatterplotLayer } from '@deck.gl/layers';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import type { ActiveTrack, NormalizedTrip, TrackPoint } from '../../../entities/vesselTrack/model/types';
|
||||
import {
|
||||
normalizeTrip,
|
||||
buildTrackLineGeoJson,
|
||||
buildTrackPointsGeoJson,
|
||||
getTrackTimeRange,
|
||||
} from '../../../entities/vesselTrack/lib/buildTrackGeoJson';
|
||||
import { getTrackLineTooltipHtml, getTrackPointTooltipHtml } from '../lib/tooltips';
|
||||
import { useNativeMapLayers, type NativeLayerSpec, type NativeSourceConfig } from './useNativeMapLayers';
|
||||
import type { MapProjectionId } from '../types';
|
||||
|
||||
/* ── Constants ──────────────────────────────────────────────────────── */
|
||||
const TRACK_COLOR: [number, number, number] = [0, 224, 255]; // cyan
|
||||
const TRACK_COLOR_CSS = `rgb(${TRACK_COLOR.join(',')})`;
|
||||
|
||||
// Globe 네이티브 레이어/소스 ID
|
||||
const LINE_SRC = 'vessel-track-line-src';
|
||||
const PTS_SRC = 'vessel-track-pts-src';
|
||||
const LINE_ID = 'vessel-track-line';
|
||||
const ARROW_ID = 'vessel-track-arrow';
|
||||
const HITAREA_ID = 'vessel-track-line-hitarea';
|
||||
const PTS_ID = 'vessel-track-pts';
|
||||
const PTS_HL_ID = 'vessel-track-pts-highlight';
|
||||
|
||||
// Mercator Deck.gl 레이어 ID
|
||||
const DECK_PATH_ID = 'vessel-track-path';
|
||||
const DECK_TRIPS_ID = 'vessel-track-trips';
|
||||
const DECK_POINTS_ID = 'vessel-track-deck-pts';
|
||||
|
||||
/* ── Globe 네이티브 레이어 스펙 ────────────────────────────────────── */
|
||||
const GLOBE_LAYERS: NativeLayerSpec[] = [
|
||||
{
|
||||
id: LINE_ID,
|
||||
type: 'line',
|
||||
sourceId: LINE_SRC,
|
||||
paint: {
|
||||
'line-color': TRACK_COLOR_CSS,
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 2, 2, 6, 3, 10, 4],
|
||||
'line-opacity': 0.8,
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
},
|
||||
{
|
||||
id: HITAREA_ID,
|
||||
type: 'line',
|
||||
sourceId: LINE_SRC,
|
||||
paint: { 'line-color': 'rgba(0,0,0,0)', 'line-width': 14, 'line-opacity': 0 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
},
|
||||
{
|
||||
id: ARROW_ID,
|
||||
type: 'symbol',
|
||||
sourceId: LINE_SRC,
|
||||
paint: {
|
||||
'text-color': TRACK_COLOR_CSS,
|
||||
'text-opacity': 0.7,
|
||||
},
|
||||
layout: {
|
||||
'symbol-placement': 'line',
|
||||
'text-field': '▶',
|
||||
'text-size': 10,
|
||||
'symbol-spacing': 80,
|
||||
'text-rotation-alignment': 'map',
|
||||
'text-allow-overlap': true,
|
||||
'text-ignore-placement': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: PTS_ID,
|
||||
type: 'circle',
|
||||
sourceId: PTS_SRC,
|
||||
paint: {
|
||||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 2, 6, 3, 10, 5],
|
||||
'circle-color': TRACK_COLOR_CSS,
|
||||
'circle-stroke-width': 1,
|
||||
'circle-stroke-color': 'rgba(0,0,0,0.5)',
|
||||
'circle-opacity': 0.85,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: PTS_HL_ID,
|
||||
type: 'circle',
|
||||
sourceId: PTS_SRC,
|
||||
paint: {
|
||||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 6, 6, 8, 10, 12],
|
||||
'circle-color': '#ffffff',
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': TRACK_COLOR_CSS,
|
||||
'circle-opacity': 0,
|
||||
},
|
||||
filter: ['==', ['get', 'index'], -1],
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Animation speed: 전체 궤적을 ~20초에 재생 ────────────────────── */
|
||||
const ANIM_CYCLE_SEC = 20;
|
||||
|
||||
/* ── Hook ──────────────────────────────────────────────────────────── */
|
||||
export function useVesselTrackLayer(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
overlayRef: MutableRefObject<MapboxOverlay | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
reorderGlobeFeatureLayers: () => void,
|
||||
opts: {
|
||||
activeTrack: ActiveTrack | null;
|
||||
projection: MapProjectionId;
|
||||
mapSyncEpoch: number;
|
||||
},
|
||||
) {
|
||||
const { activeTrack, projection, mapSyncEpoch } = opts;
|
||||
|
||||
/* ── 정규화 데이터 ── */
|
||||
const normalizedTrip = useMemo<NormalizedTrip | null>(() => {
|
||||
if (!activeTrack || activeTrack.points.length < 2) return null;
|
||||
return normalizeTrip(activeTrack, TRACK_COLOR);
|
||||
}, [activeTrack]);
|
||||
|
||||
const timeRange = useMemo(() => {
|
||||
if (!normalizedTrip) return null;
|
||||
return getTrackTimeRange(normalizedTrip);
|
||||
}, [normalizedTrip]);
|
||||
|
||||
/* ── Globe 네이티브 GeoJSON ── */
|
||||
const lineGeoJson = useMemo(() => {
|
||||
if (!activeTrack || activeTrack.points.length < 2) return null;
|
||||
return buildTrackLineGeoJson(activeTrack);
|
||||
}, [activeTrack]);
|
||||
|
||||
const pointsGeoJson = useMemo(() => {
|
||||
if (!activeTrack || activeTrack.points.length === 0) return null;
|
||||
return buildTrackPointsGeoJson(activeTrack);
|
||||
}, [activeTrack]);
|
||||
|
||||
/* ── Globe 네이티브 레이어 (useNativeMapLayers) ── */
|
||||
const globeSources = useMemo<NativeSourceConfig[]>(() => [
|
||||
{ id: LINE_SRC, data: lineGeoJson, options: { lineMetrics: true } },
|
||||
{ id: PTS_SRC, data: pointsGeoJson },
|
||||
], [lineGeoJson, pointsGeoJson]);
|
||||
|
||||
const isGlobeVisible = projection === 'globe' && activeTrack != null && activeTrack.points.length >= 2;
|
||||
|
||||
useNativeMapLayers(
|
||||
mapRef,
|
||||
projectionBusyRef,
|
||||
reorderGlobeFeatureLayers,
|
||||
{
|
||||
sources: globeSources,
|
||||
layers: GLOBE_LAYERS,
|
||||
visible: isGlobeVisible,
|
||||
beforeLayer: ['zones-fill', 'zones-line'],
|
||||
},
|
||||
[lineGeoJson, pointsGeoJson, isGlobeVisible, projection, mapSyncEpoch],
|
||||
);
|
||||
|
||||
/* ── Globe 호버 툴팁 ── */
|
||||
const tooltipRef = useRef<maplibregl.Popup | null>(null);
|
||||
|
||||
const clearTooltip = useCallback(() => {
|
||||
try { tooltipRef.current?.remove(); } catch { /* ignore */ }
|
||||
tooltipRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || projection !== 'globe' || !activeTrack) {
|
||||
clearTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
const onMove = (e: maplibregl.MapMouseEvent) => {
|
||||
if (projectionBusyRef.current || !map.isStyleLoaded()) {
|
||||
clearTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
const layers = [PTS_ID, HITAREA_ID].filter((id) => map.getLayer(id));
|
||||
if (layers.length === 0) { clearTooltip(); return; }
|
||||
|
||||
let features: maplibregl.MapGeoJSONFeature[] = [];
|
||||
try {
|
||||
features = map.queryRenderedFeatures(e.point, { layers });
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (features.length === 0) {
|
||||
clearTooltip();
|
||||
// 하이라이트 리셋
|
||||
try {
|
||||
if (map.getLayer(PTS_HL_ID)) {
|
||||
map.setFilter(PTS_HL_ID, ['==', ['get', 'index'], -1] as never);
|
||||
map.setPaintProperty(PTS_HL_ID, 'circle-opacity', 0);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
const feat = features[0];
|
||||
const props = feat.properties || {};
|
||||
const layerId = feat.layer?.id;
|
||||
let tooltipHtml = '';
|
||||
|
||||
if (layerId === PTS_ID && props.index != null) {
|
||||
tooltipHtml = getTrackPointTooltipHtml({
|
||||
name: String(props.name ?? ''),
|
||||
sog: Number(props.sog),
|
||||
cog: Number(props.cog),
|
||||
heading: Number(props.heading),
|
||||
status: String(props.status ?? ''),
|
||||
messageTimestamp: String(props.messageTimestamp ?? ''),
|
||||
}).html;
|
||||
// 하이라이트
|
||||
try {
|
||||
if (map.getLayer(PTS_HL_ID)) {
|
||||
map.setFilter(PTS_HL_ID, ['==', ['get', 'index'], Number(props.index)] as never);
|
||||
map.setPaintProperty(PTS_HL_ID, 'circle-opacity', 0.8);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} else if (layerId === HITAREA_ID) {
|
||||
tooltipHtml = getTrackLineTooltipHtml({
|
||||
name: String(props.name ?? ''),
|
||||
pointCount: Number(props.pointCount ?? 0),
|
||||
minutes: Number(props.minutes ?? 0),
|
||||
totalDistanceNm: Number(props.totalDistanceNm ?? 0),
|
||||
}).html;
|
||||
}
|
||||
|
||||
if (!tooltipHtml) { clearTooltip(); return; }
|
||||
|
||||
if (!tooltipRef.current) {
|
||||
tooltipRef.current = new maplibregl.Popup({
|
||||
closeButton: false, closeOnClick: false,
|
||||
maxWidth: '360px', className: 'maplibre-tooltip-popup',
|
||||
});
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
container.className = 'maplibre-tooltip-popup__content';
|
||||
container.innerHTML = tooltipHtml;
|
||||
tooltipRef.current.setLngLat(e.lngLat).setDOMContent(container).addTo(map);
|
||||
};
|
||||
|
||||
const onOut = () => {
|
||||
clearTooltip();
|
||||
try {
|
||||
if (map.getLayer(PTS_HL_ID)) {
|
||||
map.setFilter(PTS_HL_ID, ['==', ['get', 'index'], -1] as never);
|
||||
map.setPaintProperty(PTS_HL_ID, 'circle-opacity', 0);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
map.on('mousemove', onMove);
|
||||
map.on('mouseout', onOut);
|
||||
return () => {
|
||||
map.off('mousemove', onMove);
|
||||
map.off('mouseout', onOut);
|
||||
clearTooltip();
|
||||
};
|
||||
}, [projection, activeTrack, clearTooltip]);
|
||||
|
||||
/* ── Mercator: 정적 레이어 1회 생성 + rAF 애니메이션 (React state 미사용) ── */
|
||||
const animRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const overlay = overlayRef.current;
|
||||
if (!overlay || projection !== 'mercator') {
|
||||
cancelAnimationFrame(animRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
const isTrackLayer = (id?: string) =>
|
||||
id === DECK_PATH_ID || id === DECK_TRIPS_ID || id === DECK_POINTS_ID;
|
||||
|
||||
if (!normalizedTrip || !activeTrack || activeTrack.points.length < 2 || !timeRange || timeRange.durationSec === 0) {
|
||||
cancelAnimationFrame(animRef.current);
|
||||
try {
|
||||
const existing = (overlay as unknown as { props?: { layers?: unknown[] } }).props?.layers ?? [];
|
||||
const filtered = (existing as { id?: string }[]).filter((l) => !isTrackLayer(l.id));
|
||||
if (filtered.length !== (existing as unknown[]).length) {
|
||||
overlay.setProps({ layers: filtered } as never);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
// 정적 레이어: activeTrack 변경 시 1회만 생성, rAF 루프에서 재사용
|
||||
const pathLayer = new PathLayer<NormalizedTrip>({
|
||||
id: DECK_PATH_ID,
|
||||
data: [normalizedTrip],
|
||||
getPath: (d) => d.path,
|
||||
getColor: [...TRACK_COLOR, 90] as [number, number, number, number],
|
||||
getWidth: 2,
|
||||
widthMinPixels: 2,
|
||||
widthUnits: 'pixels' as const,
|
||||
capRounded: true,
|
||||
jointRounded: true,
|
||||
pickable: false,
|
||||
});
|
||||
|
||||
const sorted = [...activeTrack.points].sort(
|
||||
(a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(),
|
||||
);
|
||||
|
||||
const pointsLayer = new ScatterplotLayer<TrackPoint>({
|
||||
id: DECK_POINTS_ID,
|
||||
data: sorted,
|
||||
getPosition: (d) => [d.lon, d.lat],
|
||||
getRadius: 4,
|
||||
radiusUnits: 'pixels' as const,
|
||||
getFillColor: TRACK_COLOR,
|
||||
getLineColor: [0, 0, 0, 128],
|
||||
lineWidthMinPixels: 1,
|
||||
stroked: true,
|
||||
pickable: true,
|
||||
});
|
||||
|
||||
// rAF 루프: TripsLayer만 매 프레임 갱신 (React 재렌더링 없음)
|
||||
const { minTime, maxTime, durationSec } = timeRange;
|
||||
const speed = durationSec / ANIM_CYCLE_SEC;
|
||||
let current = minTime;
|
||||
|
||||
const loop = () => {
|
||||
current += speed / 60;
|
||||
if (current > maxTime) current = minTime;
|
||||
|
||||
const tripsLayer = new TripsLayer({
|
||||
id: DECK_TRIPS_ID,
|
||||
data: [normalizedTrip],
|
||||
getPath: (d: NormalizedTrip) => d.path,
|
||||
getTimestamps: (d: NormalizedTrip) => d.timestamps,
|
||||
getColor: (d: NormalizedTrip) => d.color,
|
||||
currentTime: current,
|
||||
trailLength: durationSec * 0.15,
|
||||
fadeTrail: true,
|
||||
widthMinPixels: 4,
|
||||
capRounded: true,
|
||||
jointRounded: true,
|
||||
pickable: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const existing = (overlay as unknown as { props?: { layers?: unknown[] } }).props?.layers ?? [];
|
||||
const filtered = (existing as { id?: string }[]).filter((l) => !isTrackLayer(l.id));
|
||||
overlay.setProps({ layers: [...filtered, pathLayer, tripsLayer, pointsLayer] } as never);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
animRef.current = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
animRef.current = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(animRef.current);
|
||||
}, [projection, normalizedTrip, activeTrack, timeRange]);
|
||||
|
||||
/* ── 항적 조회 시 자동 fitBounds ── */
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !activeTrack || activeTrack.points.length < 2) return;
|
||||
if (projectionBusyRef.current) return;
|
||||
|
||||
let minLon = Infinity;
|
||||
let minLat = Infinity;
|
||||
let maxLon = -Infinity;
|
||||
let maxLat = -Infinity;
|
||||
for (const pt of activeTrack.points) {
|
||||
if (pt.lon < minLon) minLon = pt.lon;
|
||||
if (pt.lat < minLat) minLat = pt.lat;
|
||||
if (pt.lon > maxLon) maxLon = pt.lon;
|
||||
if (pt.lat > maxLat) maxLat = pt.lat;
|
||||
}
|
||||
|
||||
const fitOpts = { padding: 80, duration: 1000, maxZoom: 14 };
|
||||
const apply = () => {
|
||||
try {
|
||||
map.fitBounds([[minLon, minLat], [maxLon, maxLat]], fitOpts);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
if (map.isStyleLoaded()) {
|
||||
apply();
|
||||
} else {
|
||||
const onLoad = () => { apply(); map.off('styledata', onLoad); };
|
||||
map.on('styledata', onLoad);
|
||||
return () => { map.off('styledata', onLoad); };
|
||||
}
|
||||
}, [activeTrack]);
|
||||
}
|
||||
@ -36,6 +36,11 @@ const GLOBE_NATIVE_LAYER_IDS = [
|
||||
'subcables-glow',
|
||||
'subcables-points',
|
||||
'subcables-label',
|
||||
'vessel-track-line',
|
||||
'vessel-track-line-hitarea',
|
||||
'vessel-track-arrow',
|
||||
'vessel-track-pts',
|
||||
'vessel-track-pts-highlight',
|
||||
'deck-globe',
|
||||
];
|
||||
|
||||
@ -46,6 +51,8 @@ const GLOBE_NATIVE_SOURCE_IDS = [
|
||||
'pair-range-ml-src',
|
||||
'subcables-src',
|
||||
'subcables-pts-src',
|
||||
'vessel-track-line-src',
|
||||
'vessel-track-pts-src',
|
||||
];
|
||||
|
||||
export function clearGlobeNativeLayers(map: maplibregl.Map) {
|
||||
|
||||
30
apps/web/src/widgets/map3d/lib/shipIconCache.ts
Normal file
30
apps/web/src/widgets/map3d/lib/shipIconCache.ts
Normal file
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Ship SVG 아이콘을 미리 fetch하여 data URL로 캐시.
|
||||
* Deck.gl IconLayer가 매번 iconAtlas URL을 fetch하지 않도록
|
||||
* 인라인 data URL을 전달한다.
|
||||
*/
|
||||
const SHIP_SVG_URL = '/assets/ship.svg';
|
||||
|
||||
let _cachedDataUrl: string | null = null;
|
||||
let _promise: Promise<string> | null = null;
|
||||
|
||||
function preloadShipIcon(): Promise<string> {
|
||||
if (_cachedDataUrl) return Promise.resolve(_cachedDataUrl);
|
||||
if (_promise) return _promise;
|
||||
_promise = fetch(SHIP_SVG_URL)
|
||||
.then((res) => res.text())
|
||||
.then((svg) => {
|
||||
_cachedDataUrl = `data:image/svg+xml;base64,${btoa(svg)}`;
|
||||
return _cachedDataUrl;
|
||||
})
|
||||
.catch(() => SHIP_SVG_URL);
|
||||
return _promise;
|
||||
}
|
||||
|
||||
/** 캐시된 data URL 또는 폴백 URL 반환 */
|
||||
export function getCachedShipIcon(): string {
|
||||
return _cachedDataUrl ?? SHIP_SVG_URL;
|
||||
}
|
||||
|
||||
// 모듈 임포트 시 즉시 로드 시작
|
||||
preloadShipIcon();
|
||||
@ -168,3 +168,54 @@ export function getFleetCircleTooltipHtml({
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
function fmtMinutesKr(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes}분`;
|
||||
if (minutes < 1440) return `${Math.round(minutes / 60)}시간`;
|
||||
return `${Math.round(minutes / 1440)}일`;
|
||||
}
|
||||
|
||||
export function getTrackLineTooltipHtml({
|
||||
name,
|
||||
pointCount,
|
||||
minutes,
|
||||
totalDistanceNm,
|
||||
}: {
|
||||
name: string;
|
||||
pointCount: number;
|
||||
minutes: number;
|
||||
totalDistanceNm: number;
|
||||
}) {
|
||||
return {
|
||||
html: `<div style="font-family: system-ui; font-size: 12px;">
|
||||
<div style="font-weight: 700; margin-bottom: 4px;">항적 · ${name}</div>
|
||||
<div>기간: <b>${fmtMinutesKr(minutes)}</b> · 포인트: <b>${pointCount}</b></div>
|
||||
<div>총 거리: <b>${totalDistanceNm.toFixed(1)} NM</b></div>
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getTrackPointTooltipHtml({
|
||||
name,
|
||||
sog,
|
||||
cog,
|
||||
heading,
|
||||
status,
|
||||
messageTimestamp,
|
||||
}: {
|
||||
name: string;
|
||||
sog: number;
|
||||
cog: number;
|
||||
heading: number;
|
||||
status: string;
|
||||
messageTimestamp: string;
|
||||
}) {
|
||||
return {
|
||||
html: `<div style="font-family: system-ui; font-size: 12px; white-space: nowrap;">
|
||||
<div style="font-weight: 700; margin-bottom: 4px;">${name}</div>
|
||||
<div>SOG: <b>${isFiniteNumber(sog) ? sog : '?'}</b> kt · COG: <b>${isFiniteNumber(cog) ? cog : '?'}</b>°</div>
|
||||
<div>Heading: <b>${isFiniteNumber(heading) ? heading : '?'}</b>° · 상태: ${status || '-'}</div>
|
||||
<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${fmtIsoFull(messageTimestamp)}</div>
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { AisTarget } from '../../entities/aisTarget/model/types';
|
||||
import type { LegacyVesselInfo } from '../../entities/legacyVessel/model/types';
|
||||
import type { SubcableGeoJson } from '../../entities/subcable/model/types';
|
||||
import type { ActiveTrack } from '../../entities/vesselTrack/model/types';
|
||||
import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
|
||||
import type { MapToggleState } from '../../features/mapToggles/MapToggles';
|
||||
import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types';
|
||||
@ -62,6 +63,11 @@ export interface Map3DProps {
|
||||
initialView?: MapViewState | null;
|
||||
onViewStateChange?: (view: MapViewState) => void;
|
||||
onGlobeShipsReady?: (ready: boolean) => void;
|
||||
activeTrack?: ActiveTrack | null;
|
||||
trackContextMenu?: { x: number; y: number; mmsi: number; vesselName: string } | null;
|
||||
onRequestTrack?: (mmsi: number, minutes: number) => void;
|
||||
onCloseTrackMenu?: () => void;
|
||||
onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string }) => void;
|
||||
}
|
||||
|
||||
export type DashSeg = {
|
||||
|
||||
@ -11,97 +11,161 @@ export function SubcableInfoPanel({ detail, color, onClose }: Props) {
|
||||
const countries = [...new Set(detail.landing_points.map((lp) => lp.country).filter(Boolean))];
|
||||
|
||||
return (
|
||||
<div className="map-info" style={{ maxWidth: 340 }}>
|
||||
<div className="map-info" style={{ width: 320 }}>
|
||||
<button className="close-btn" onClick={onClose} aria-label="close">
|
||||
✕
|
||||
</button>
|
||||
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
{/* ── Header ── */}
|
||||
<div style={{ marginBottom: 10, paddingRight: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{color && (
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 3,
|
||||
backgroundColor: color,
|
||||
flexShrink: 0,
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div style={{ fontSize: 16, fontWeight: 900, color: 'var(--accent)' }}>{detail.name}</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 800, color: 'var(--accent)', lineHeight: 1.3 }}>
|
||||
{detail.name}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>
|
||||
Submarine Cable{detail.is_planned ? ' (Planned)' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ir">
|
||||
<span className="il">길이</span>
|
||||
<span className="iv">{detail.length || '-'}</span>
|
||||
</div>
|
||||
<div className="ir">
|
||||
<span className="il">개통</span>
|
||||
<span className="iv">{detail.rfs || '-'}</span>
|
||||
</div>
|
||||
{detail.owners && (
|
||||
<div className="ir" style={{ alignItems: 'flex-start' }}>
|
||||
<span className="il">운영사</span>
|
||||
<span className="iv" style={{ wordBreak: 'break-word' }}>
|
||||
{detail.owners}
|
||||
{detail.is_planned && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
marginTop: 4,
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: '#F59E0B',
|
||||
background: 'rgba(245,158,11,0.12)',
|
||||
padding: '1px 6px',
|
||||
borderRadius: 3,
|
||||
letterSpacing: 0.5,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Planned
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{detail.suppliers && (
|
||||
<div className="ir">
|
||||
<span className="il">공급사</span>
|
||||
<span className="iv">{detail.suppliers}</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Info rows ── */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<InfoRow label="길이" value={detail.length} />
|
||||
<InfoRow label="개통" value={detail.rfs} />
|
||||
{detail.owners && <InfoRow label="운영사" value={detail.owners} wrap />}
|
||||
{detail.suppliers && <InfoRow label="공급사" value={detail.suppliers} wrap />}
|
||||
</div>
|
||||
|
||||
{/* ── Landing Points ── */}
|
||||
{landingCount > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--muted)', marginBottom: 4 }}>
|
||||
Landing Points ({landingCount}) · {countries.length} countries
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: 'var(--muted)',
|
||||
marginBottom: 6,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<span>Landing Points</span>
|
||||
<span style={{ fontWeight: 400 }}>
|
||||
{landingCount}곳 · {countries.length}개국
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 140,
|
||||
maxHeight: 160,
|
||||
overflowY: 'auto',
|
||||
fontSize: 10,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--text)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{detail.landing_points.map((lp) => (
|
||||
<div key={lp.id}>
|
||||
<span style={{ color: 'var(--muted)' }}>{lp.country}</span>{' '}
|
||||
<b>{lp.name}</b>
|
||||
{lp.is_tbd && <span style={{ color: '#F59E0B', marginLeft: 4 }}>TBD</span>}
|
||||
<div
|
||||
key={lp.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 6,
|
||||
fontSize: 10,
|
||||
lineHeight: 1.5,
|
||||
padding: '1px 0',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--muted)',
|
||||
fontSize: 9,
|
||||
flexShrink: 0,
|
||||
minWidth: 28,
|
||||
}}
|
||||
>
|
||||
{lp.country}
|
||||
</span>
|
||||
<span style={{ fontWeight: 600, color: 'var(--text)' }}>{lp.name}</span>
|
||||
{lp.is_tbd && (
|
||||
<span style={{ color: '#F59E0B', fontSize: 8, fontWeight: 700, flexShrink: 0 }}>
|
||||
TBD
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Notes ── */}
|
||||
{detail.notes && (
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--muted)', fontStyle: 'italic' }}>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
fontSize: 10,
|
||||
color: 'var(--muted)',
|
||||
fontStyle: 'italic',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{detail.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Link ── */}
|
||||
{detail.url && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<a
|
||||
href={detail.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: 10, color: 'var(--accent)' }}
|
||||
style={{ fontSize: 10, color: 'var(--accent)', textDecoration: 'none' }}
|
||||
>
|
||||
Official website ↗
|
||||
상세정보 ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value, wrap }: { label: string; value: string | null; wrap?: boolean }) {
|
||||
return (
|
||||
<div className="ir" style={wrap ? { alignItems: 'flex-start' } : undefined}>
|
||||
<span className="il">{label}</span>
|
||||
<span
|
||||
className="iv"
|
||||
style={wrap ? { textAlign: 'right', wordBreak: 'break-word', maxWidth: '65%' } : undefined}
|
||||
>
|
||||
{value || '-'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
854
package-lock.json
generated
854
package-lock.json
generated
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
불러오는 중...
Reference in New Issue
Block a user