Merge pull request 'feat(map): 항적조회 기능 구현' (#14) from feature/vessel-track into develop
Reviewed-on: #14
This commit is contained in:
커밋
c05ec159ce
@ -131,6 +131,52 @@ function parseBbox(raw: string | undefined) {
|
|||||||
return { lonMin, latMin, lonMax, latMax };
|
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) => {
|
app.get("/zones", async (_req, reply) => {
|
||||||
const zonesPath = path.resolve(
|
const zonesPath = path.resolve(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deck.gl/aggregation-layers": "^9.2.7",
|
"@deck.gl/aggregation-layers": "^9.2.7",
|
||||||
"@deck.gl/core": "^9.2.7",
|
"@deck.gl/core": "^9.2.7",
|
||||||
|
"@deck.gl/geo-layers": "^9.2.7",
|
||||||
"@deck.gl/layers": "^9.2.7",
|
"@deck.gl/layers": "^9.2.7",
|
||||||
"@deck.gl/mapbox": "^9.2.7",
|
"@deck.gl/mapbox": "^9.2.7",
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@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 { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
|
||||||
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
|
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
|
||||||
import { VesselList } from "../../widgets/vesselList/VesselList";
|
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 { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
|
||||||
import { DepthLegend } from "../../widgets/legend/DepthLegend";
|
import { DepthLegend } from "../../widgets/legend/DepthLegend";
|
||||||
import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types";
|
import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types";
|
||||||
@ -99,7 +101,7 @@ export function DashboardPage() {
|
|||||||
const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]);
|
const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]);
|
||||||
const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]);
|
const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]);
|
||||||
const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null);
|
const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null);
|
||||||
const uid = null;
|
const uid = user?.id ?? null;
|
||||||
const [typeEnabled, setTypeEnabled] = usePersistedState<Record<VesselTypeCode, boolean>>(
|
const [typeEnabled, setTypeEnabled] = usePersistedState<Record<VesselTypeCode, boolean>>(
|
||||||
uid, 'typeEnabled', { PT: true, "PT-S": true, GN: true, OT: true, PS: true, FC: true },
|
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 [hoveredCableId, setHoveredCableId] = useState<string | null>(null);
|
||||||
const [selectedCableId, setSelectedCableId] = 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', {
|
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
|
||||||
showShips: true, showDensity: false, showSeamark: false,
|
showShips: true, showDensity: false, showSeamark: false,
|
||||||
});
|
});
|
||||||
@ -729,6 +754,11 @@ export function DashboardPage() {
|
|||||||
mapStyleSettings={mapStyleSettings}
|
mapStyleSettings={mapStyleSettings}
|
||||||
initialView={mapView}
|
initialView={mapView}
|
||||||
onViewStateChange={setMapView}
|
onViewStateChange={setMapView}
|
||||||
|
activeTrack={activeTrack}
|
||||||
|
trackContextMenu={trackContextMenu}
|
||||||
|
onRequestTrack={handleRequestTrack}
|
||||||
|
onCloseTrackMenu={handleCloseTrackMenu}
|
||||||
|
onOpenTrackMenu={handleOpenTrackMenu}
|
||||||
/>
|
/>
|
||||||
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
|
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
|
||||||
<DepthLegend depthStops={mapStyleSettings.depthStops} />
|
<DepthLegend depthStops={mapStyleSettings.depthStops} />
|
||||||
|
|||||||
@ -26,7 +26,9 @@ import { useGlobeOverlays } from './hooks/useGlobeOverlays';
|
|||||||
import { useGlobeInteraction } from './hooks/useGlobeInteraction';
|
import { useGlobeInteraction } from './hooks/useGlobeInteraction';
|
||||||
import { useDeckLayers } from './hooks/useDeckLayers';
|
import { useDeckLayers } from './hooks/useDeckLayers';
|
||||||
import { useSubcablesLayer } from './hooks/useSubcablesLayer';
|
import { useSubcablesLayer } from './hooks/useSubcablesLayer';
|
||||||
|
import { useVesselTrackLayer } from './hooks/useVesselTrackLayer';
|
||||||
import { useMapStyleSettings } from './hooks/useMapStyleSettings';
|
import { useMapStyleSettings } from './hooks/useMapStyleSettings';
|
||||||
|
import { VesselContextMenu } from './components/VesselContextMenu';
|
||||||
|
|
||||||
export type { Map3DSettings, BaseMapId, MapProjectionId } from './types';
|
export type { Map3DSettings, BaseMapId, MapProjectionId } from './types';
|
||||||
|
|
||||||
@ -69,6 +71,11 @@ export function Map3D({
|
|||||||
initialView,
|
initialView,
|
||||||
onViewStateChange,
|
onViewStateChange,
|
||||||
onGlobeShipsReady,
|
onGlobeShipsReady,
|
||||||
|
activeTrack = null,
|
||||||
|
trackContextMenu = null,
|
||||||
|
onRequestTrack,
|
||||||
|
onCloseTrackMenu,
|
||||||
|
onOpenTrackMenu,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
void onHoverFleet;
|
void onHoverFleet;
|
||||||
void onClearFleetHover;
|
void onClearFleetHover;
|
||||||
@ -94,6 +101,7 @@ export function Map3D({
|
|||||||
|
|
||||||
// ── Hover state ──────────────────────────────────────────────────────
|
// ── Hover state ──────────────────────────────────────────────────────
|
||||||
const {
|
const {
|
||||||
|
hoveredDeckMmsiSet: hoveredDeckMmsiArr,
|
||||||
setHoveredDeckMmsiSet,
|
setHoveredDeckMmsiSet,
|
||||||
setHoveredDeckPairMmsiSet,
|
setHoveredDeckPairMmsiSet,
|
||||||
setHoveredDeckFleetOwnerKey,
|
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(
|
useFlyTo(
|
||||||
mapRef, projectionRef,
|
mapRef, projectionRef,
|
||||||
{ selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom },
|
{ 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,
|
getFleetCircleTooltipHtml,
|
||||||
} from '../lib/tooltips';
|
} from '../lib/tooltips';
|
||||||
import { sanitizeDeckLayerList } from '../lib/mapCore';
|
import { sanitizeDeckLayerList } from '../lib/mapCore';
|
||||||
|
import { getCachedShipIcon } from '../lib/shipIconCache';
|
||||||
|
|
||||||
// NOTE:
|
// NOTE:
|
||||||
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
|
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
|
||||||
@ -380,7 +381,7 @@ export function useDeckLayers(
|
|||||||
pickable: true,
|
pickable: true,
|
||||||
billboard: false,
|
billboard: false,
|
||||||
parameters: overlayParams,
|
parameters: overlayParams,
|
||||||
iconAtlas: '/assets/ship.svg',
|
iconAtlas: getCachedShipIcon(),
|
||||||
iconMapping: SHIP_ICON_MAPPING,
|
iconMapping: SHIP_ICON_MAPPING,
|
||||||
getIcon: () => 'ship',
|
getIcon: () => 'ship',
|
||||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
@ -403,7 +404,7 @@ export function useDeckLayers(
|
|||||||
pickable: false,
|
pickable: false,
|
||||||
billboard: false,
|
billboard: false,
|
||||||
parameters: overlayParams,
|
parameters: overlayParams,
|
||||||
iconAtlas: '/assets/ship.svg',
|
iconAtlas: getCachedShipIcon(),
|
||||||
iconMapping: SHIP_ICON_MAPPING,
|
iconMapping: SHIP_ICON_MAPPING,
|
||||||
getIcon: () => 'ship',
|
getIcon: () => 'ship',
|
||||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
@ -448,7 +449,7 @@ export function useDeckLayers(
|
|||||||
pickable: true,
|
pickable: true,
|
||||||
billboard: false,
|
billboard: false,
|
||||||
parameters: overlayParams,
|
parameters: overlayParams,
|
||||||
iconAtlas: '/assets/ship.svg',
|
iconAtlas: getCachedShipIcon(),
|
||||||
iconMapping: SHIP_ICON_MAPPING,
|
iconMapping: SHIP_ICON_MAPPING,
|
||||||
getIcon: () => 'ship',
|
getIcon: () => 'ship',
|
||||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
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) {
|
if (settings.showShips && shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)).length > 0) {
|
||||||
const shipOverlayTargetData2 = shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi));
|
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);
|
const normalizedLayers = sanitizeDeckLayerList(layers);
|
||||||
|
|||||||
@ -63,6 +63,7 @@ function applyLandColor(map: maplibregl.Map, color: string) {
|
|||||||
if (id.startsWith('fc-')) continue;
|
if (id.startsWith('fc-')) continue;
|
||||||
if (id.startsWith('fleet-')) continue;
|
if (id.startsWith('fleet-')) continue;
|
||||||
if (id.startsWith('predict-')) continue;
|
if (id.startsWith('predict-')) continue;
|
||||||
|
if (id.startsWith('vessel-track-')) continue;
|
||||||
if (id === 'deck-globe') continue;
|
if (id === 'deck-globe') continue;
|
||||||
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '');
|
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '');
|
||||||
const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer);
|
const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer);
|
||||||
|
|||||||
@ -94,6 +94,11 @@ export function useProjectionToggle(
|
|||||||
'subcables-glow',
|
'subcables-glow',
|
||||||
'subcables-points',
|
'subcables-points',
|
||||||
'subcables-label',
|
'subcables-label',
|
||||||
|
'vessel-track-line',
|
||||||
|
'vessel-track-line-hitarea',
|
||||||
|
'vessel-track-arrow',
|
||||||
|
'vessel-track-pts',
|
||||||
|
'vessel-track-pts-highlight',
|
||||||
'zones-fill',
|
'zones-fill',
|
||||||
'zones-line',
|
'zones-line',
|
||||||
'zones-label',
|
'zones-label',
|
||||||
|
|||||||
@ -16,6 +16,7 @@ const LINE_ID = 'subcables-line';
|
|||||||
const GLOW_ID = 'subcables-glow';
|
const GLOW_ID = 'subcables-glow';
|
||||||
const POINTS_ID = 'subcables-points';
|
const POINTS_ID = 'subcables-points';
|
||||||
const LABEL_ID = 'subcables-label';
|
const LABEL_ID = 'subcables-label';
|
||||||
|
const HOVER_LABEL_ID = 'subcables-hover-label';
|
||||||
|
|
||||||
/* ── Paint defaults (used for layer creation + hover reset) ──────── */
|
/* ── Paint defaults (used for layer creation + hover reset) ──────── */
|
||||||
const LINE_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.7, 6, 0.82, 10, 0.92];
|
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',
|
type: 'line',
|
||||||
sourceId: SRC_ID,
|
sourceId: SRC_ID,
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': ['get', 'color'],
|
'line-color': '#ffffff',
|
||||||
'line-opacity': 0,
|
'line-opacity': 0,
|
||||||
'line-width': ['interpolate', ['linear'], ['zoom'], 2, 5, 6, 8, 10, 12],
|
'line-width': ['interpolate', ['linear'], ['zoom'], 2, 10, 6, 16, 10, 24],
|
||||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 5, 10, 7],
|
'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 4, 6, 6, 10, 8],
|
||||||
},
|
},
|
||||||
filter: ['==', ['get', 'id'], ''],
|
filter: ['==', ['get', 'id'], ''],
|
||||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
@ -107,6 +108,29 @@ const LAYER_SPECS: NativeLayerSpec[] = [
|
|||||||
},
|
},
|
||||||
minzoom: 4,
|
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(
|
export function useSubcablesLayer(
|
||||||
@ -250,42 +274,27 @@ export function useSubcablesLayer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Hover highlight helper (paint-only mutations) ────────────────── */
|
/* ── Hover highlight helper (paint-only mutations) ────────────────── */
|
||||||
|
// 기본 레이어는 항상 기본값 유지, glow 레이어(filter 기반)로만 호버 강조
|
||||||
function applyHoverHighlight(map: maplibregl.Map, hoveredId: string | null) {
|
function applyHoverHighlight(map: maplibregl.Map, hoveredId: string | null) {
|
||||||
|
const noMatch = ['==', ['get', 'id'], ''] as never;
|
||||||
if (hoveredId) {
|
if (hoveredId) {
|
||||||
const matchExpr = ['==', ['get', 'id'], 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)) {
|
if (map.getLayer(GLOW_ID)) {
|
||||||
map.setFilter(GLOW_ID, matchExpr as never);
|
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)) {
|
if (map.getLayer(HOVER_LABEL_ID)) {
|
||||||
map.setPaintProperty(POINTS_ID, 'circle-opacity', ['case', matchExpr, 1.0, 0.15] as never);
|
map.setFilter(HOVER_LABEL_ID, matchExpr as never);
|
||||||
map.setPaintProperty(POINTS_ID, 'circle-radius', ['case', matchExpr, 4, 1.5] as never);
|
map.setPaintProperty(HOVER_LABEL_ID, 'text-opacity', 1.0);
|
||||||
}
|
}
|
||||||
} else {
|
} 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)) {
|
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);
|
map.setPaintProperty(GLOW_ID, 'line-opacity', 0);
|
||||||
}
|
}
|
||||||
if (map.getLayer(POINTS_ID)) {
|
if (map.getLayer(HOVER_LABEL_ID)) {
|
||||||
map.setPaintProperty(POINTS_ID, 'circle-opacity', POINTS_OPACITY_DEFAULT as never);
|
map.setFilter(HOVER_LABEL_ID, noMatch);
|
||||||
map.setPaintProperty(POINTS_ID, 'circle-radius', POINTS_RADIUS_DEFAULT as never);
|
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-glow',
|
||||||
'subcables-points',
|
'subcables-points',
|
||||||
'subcables-label',
|
'subcables-label',
|
||||||
|
'vessel-track-line',
|
||||||
|
'vessel-track-line-hitarea',
|
||||||
|
'vessel-track-arrow',
|
||||||
|
'vessel-track-pts',
|
||||||
|
'vessel-track-pts-highlight',
|
||||||
'deck-globe',
|
'deck-globe',
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -46,6 +51,8 @@ const GLOBE_NATIVE_SOURCE_IDS = [
|
|||||||
'pair-range-ml-src',
|
'pair-range-ml-src',
|
||||||
'subcables-src',
|
'subcables-src',
|
||||||
'subcables-pts-src',
|
'subcables-pts-src',
|
||||||
|
'vessel-track-line-src',
|
||||||
|
'vessel-track-pts-src',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function clearGlobeNativeLayers(map: maplibregl.Map) {
|
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>`,
|
</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 { AisTarget } from '../../entities/aisTarget/model/types';
|
||||||
import type { LegacyVesselInfo } from '../../entities/legacyVessel/model/types';
|
import type { LegacyVesselInfo } from '../../entities/legacyVessel/model/types';
|
||||||
import type { SubcableGeoJson } from '../../entities/subcable/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 { ZonesGeoJson } from '../../entities/zone/api/useZones';
|
||||||
import type { MapToggleState } from '../../features/mapToggles/MapToggles';
|
import type { MapToggleState } from '../../features/mapToggles/MapToggles';
|
||||||
import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types';
|
import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types';
|
||||||
@ -62,6 +63,11 @@ export interface Map3DProps {
|
|||||||
initialView?: MapViewState | null;
|
initialView?: MapViewState | null;
|
||||||
onViewStateChange?: (view: MapViewState) => void;
|
onViewStateChange?: (view: MapViewState) => void;
|
||||||
onGlobeShipsReady?: (ready: boolean) => 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 = {
|
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))];
|
const countries = [...new Set(detail.landing_points.map((lp) => lp.country).filter(Boolean))];
|
||||||
|
|
||||||
return (
|
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 className="close-btn" onClick={onClose} aria-label="close">
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div style={{ marginBottom: 8 }}>
|
{/* ── Header ── */}
|
||||||
|
<div style={{ marginBottom: 10, paddingRight: 20 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
{color && (
|
{color && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 12,
|
width: 14,
|
||||||
height: 12,
|
height: 14,
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
flexShrink: 0,
|
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>
|
||||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>
|
{detail.is_planned && (
|
||||||
Submarine Cable{detail.is_planned ? ' (Planned)' : ''}
|
<span
|
||||||
</div>
|
style={{
|
||||||
</div>
|
display: 'inline-block',
|
||||||
|
marginTop: 4,
|
||||||
<div className="ir">
|
fontSize: 9,
|
||||||
<span className="il">길이</span>
|
fontWeight: 700,
|
||||||
<span className="iv">{detail.length || '-'}</span>
|
color: '#F59E0B',
|
||||||
</div>
|
background: 'rgba(245,158,11,0.12)',
|
||||||
<div className="ir">
|
padding: '1px 6px',
|
||||||
<span className="il">개통</span>
|
borderRadius: 3,
|
||||||
<span className="iv">{detail.rfs || '-'}</span>
|
letterSpacing: 0.5,
|
||||||
</div>
|
textTransform: 'uppercase',
|
||||||
{detail.owners && (
|
}}
|
||||||
<div className="ir" style={{ alignItems: 'flex-start' }}>
|
>
|
||||||
<span className="il">운영사</span>
|
Planned
|
||||||
<span className="iv" style={{ wordBreak: 'break-word' }}>
|
|
||||||
{detail.owners}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
{detail.suppliers && (
|
|
||||||
<div className="ir">
|
|
||||||
<span className="il">공급사</span>
|
|
||||||
<span className="iv">{detail.suppliers}</span>
|
|
||||||
</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 && (
|
{landingCount > 0 && (
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 10 }}>
|
||||||
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--muted)', marginBottom: 4 }}>
|
<div
|
||||||
Landing Points ({landingCount}) · {countries.length} countries
|
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>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
maxHeight: 140,
|
maxHeight: 160,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
fontSize: 10,
|
display: 'flex',
|
||||||
lineHeight: 1.6,
|
flexDirection: 'column',
|
||||||
color: 'var(--text)',
|
gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{detail.landing_points.map((lp) => (
|
{detail.landing_points.map((lp) => (
|
||||||
<div key={lp.id}>
|
<div
|
||||||
<span style={{ color: 'var(--muted)' }}>{lp.country}</span>{' '}
|
key={lp.id}
|
||||||
<b>{lp.name}</b>
|
style={{
|
||||||
{lp.is_tbd && <span style={{ color: '#F59E0B', marginLeft: 4 }}>TBD</span>}
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Notes ── */}
|
||||||
{detail.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}
|
{detail.notes}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Link ── */}
|
||||||
{detail.url && (
|
{detail.url && (
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 10 }}>
|
||||||
<a
|
<a
|
||||||
href={detail.url}
|
href={detail.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{ fontSize: 10, color: 'var(--accent)' }}
|
style={{ fontSize: 10, color: 'var(--accent)', textDecoration: 'none' }}
|
||||||
>
|
>
|
||||||
Official website ↗
|
상세정보 ↗
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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