Merge pull request 'feat(map): 항적조회 기능 구현' (#14) from feature/vessel-track into develop

Reviewed-on: #14
This commit is contained in:
htlee 2026-02-16 18:33:18 +09:00
커밋 c05ec159ce
19개의 변경된 파일1976개의 추가작업 그리고 83개의 파일을 삭제

파일 보기

@ -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",

파일 보기

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

파일 보기

@ -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 };
}

파일 보기

@ -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}
/>
)}
</>
);
}

파일 보기

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

파일 보기

@ -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) {

파일 보기

@ -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

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff