Merge pull request 'feat(vessels): 실시간 선박 신호 지도 표출 및 폴링 스케줄러 추가' (#174) from feature/ship-signal-map into develop

This commit is contained in:
jhkang 2026-04-15 14:44:43 +09:00
커밋 72ead1140f
22개의 변경된 파일1598개의 추가작업 그리고 755개의 파일을 삭제

파일 보기

@ -0,0 +1,17 @@
# Records run_in_terminal and appmod-* tool calls as JSONL for the extension to process.
$raw = [Console]::In.ReadToEnd()
if ($raw -notmatch '"tool_name"\s*:\s*"([^"]+)"') { exit 0 }
$toolName = $Matches[1]
if ($toolName -ne 'run_in_terminal' -and $toolName -notlike 'appmod-*') { exit 0 }
if ($raw -notmatch '"session_id"\s*:\s*"([^"]+)"') { exit 0 }
$sessionId = $Matches[1]
$hooksDir = '.github\java-upgrade\hooks'
if (-not (Test-Path $hooksDir)) { New-Item -ItemType Directory -Path $hooksDir -Force | Out-Null }
$line = ($raw -replace '[\r\n]+', ' ').Trim() + "`n"
[System.IO.File]::AppendAllText("$hooksDir\$sessionId.json", $line, [System.Text.UTF8Encoding]::new($false))

파일 보기

@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Records run_in_terminal and appmod-* tool calls as JSONL for the extension to process.
INPUT=$(cat)
TOOL_NAME="${INPUT#*\"tool_name\":\"}"
TOOL_NAME="${TOOL_NAME%%\"*}"
case "$TOOL_NAME" in
run_in_terminal|appmod-*) ;;
*) exit 0 ;;
esac
case "$INPUT" in
*'"session_id":"'*) ;;
*) exit 0 ;;
esac
SESSION_ID="${INPUT#*\"session_id\":\"}"
SESSION_ID="${SESSION_ID%%\"*}"
[ -z "$SESSION_ID" ] && exit 0
HOOKS_DIR=".github/java-upgrade/hooks"
mkdir -p "$HOOKS_DIR"
LINE=$(printf '%s' "$INPUT" | tr -d '\r\n')
printf '%s\n' "$LINE" >> "$HOOKS_DIR/${SESSION_ID}.json"

파일 보기

@ -368,7 +368,7 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis
// OIL INFERENCE (GPU 서버 프록시)
// ============================================================
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001';
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://211.208.115.83:5001';
const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090';
const INFERENCE_TIMEOUT_MS = 10_000;

파일 보기

@ -26,6 +26,8 @@ import aerialRouter from './aerial/aerialRouter.js'
import rescueRouter from './rescue/rescueRouter.js'
import mapBaseRouter from './map-base/mapBaseRouter.js'
import monitorRouter from './monitor/monitorRouter.js'
import vesselRouter from './vessels/vesselRouter.js'
import { startVesselScheduler } from './vessels/vesselScheduler.js'
import {
sanitizeBody,
sanitizeQuery,
@ -177,6 +179,7 @@ app.use('/api/rescue', rescueRouter)
app.use('/api/map-base', mapBaseRouter)
app.use('/api/monitor', monitorRouter)
app.use('/api/tiles', tilesRouter)
app.use('/api/vessels', vesselRouter)
// 헬스 체크
app.get('/health', (_req, res) => {
@ -212,6 +215,9 @@ app.use((err: Error, _req: express.Request, res: express.Response, _next: expres
app.listen(PORT, async () => {
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
// 선박 신호 스케줄러 시작 (한국 전 해역 1분 폴링)
startVesselScheduler()
// wing DB 연결 확인 (wing + auth 스키마 통합)
const connected = await testWingDbConnection()
if (connected) {

파일 보기

@ -0,0 +1,33 @@
import { Router } from 'express';
import { getVesselsInBounds, getCacheStatus } from './vesselService.js';
import type { BoundingBox } from './vesselTypes.js';
const vesselRouter = Router();
// POST /api/vessels/in-area
// 현재 뷰포트 bbox 안의 선박 목록 반환 (메모리 캐시에서 필터링)
vesselRouter.post('/in-area', (req, res) => {
const { bounds } = req.body as { bounds?: BoundingBox };
if (
!bounds ||
typeof bounds.minLon !== 'number' ||
typeof bounds.minLat !== 'number' ||
typeof bounds.maxLon !== 'number' ||
typeof bounds.maxLat !== 'number'
) {
res.status(400).json({ error: '유효한 bounds 정보가 필요합니다.' });
return;
}
const vessels = getVesselsInBounds(bounds);
res.json(vessels);
});
// GET /api/vessels/status — 캐시 상태 확인 (디버그용)
vesselRouter.get('/status', (_req, res) => {
const status = getCacheStatus();
res.json(status);
});
export default vesselRouter;

파일 보기

@ -0,0 +1,96 @@
import { updateVesselCache } from './vesselService.js';
import type { VesselPosition } from './vesselTypes.js';
const VESSEL_TRACK_API_URL =
process.env.VESSEL_TRACK_API_URL ?? 'https://guide.gc-si.dev/signal-batch';
const POLL_INTERVAL_MS = 60_000;
// 개별 쿠키 환경변수를 조합하여 Cookie 헤더 문자열 생성
function buildVesselCookie(): string {
const entries: [string, string | undefined][] = [
['apt.uid', process.env.VESSEL_COOKIE_APT_UID],
['g_state', process.env.VESSEL_COOKIE_G_STATE],
['gc_proxy_auth', process.env.VESSEL_COOKIE_GC_PROXY_AUTH],
['GC_SESSION', process.env.VESSEL_COOKIE_GC_SESSION],
// 기존 단일 쿠키 변수 폴백 (레거시 지원)
];
const parts = entries
.filter(([, v]) => v)
.map(([k, v]) => `${k}=${v}`);
// 기존 VESSEL_TRACK_COOKIE 폴백 (단일 문자열로 설정된 경우)
if (parts.length === 0 && process.env.VESSEL_TRACK_COOKIE) {
return process.env.VESSEL_TRACK_COOKIE;
}
return parts.join('; ');
}
// 한국 전 해역 고정 폴리곤 (124~132°E, 32~38°N)
const KOREA_WATERS_POLYGON = [
[120, 31],
[132, 31],
[132, 41],
[120, 41],
[120, 31],
];
let intervalId: ReturnType<typeof setInterval> | null = null;
async function pollVesselSignals(): Promise<void> {
const url = `${VESSEL_TRACK_API_URL}/api/v1/vessels/recent-positions-detail`;
const body = {
minutes: 5,
coordinates: KOREA_WATERS_POLYGON,
polygonFilter: true,
};
const cookie = buildVesselCookie();
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...(cookie ? { Cookie: cookie } : {}),
};
try {
const res = await fetch(url, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
console.error(`[vesselScheduler] 선박 신호 API 오류: ${res.status}`, text.substring(0, 200));
return;
}
const contentType = res.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
const text = await res.text().catch(() => '');
console.error('[vesselScheduler] 선박 신호 API가 JSON이 아닌 응답 반환:', text);
return;
}
const data = (await res.json()) as VesselPosition[];
updateVesselCache(data);
} catch (err) {
console.error('[vesselScheduler] 선박 신호 폴링 실패:', err);
}
}
export function startVesselScheduler(): void {
if (intervalId !== null) return;
// 서버 시작 시 즉시 1회 실행 후 주기적 폴링
pollVesselSignals();
intervalId = setInterval(pollVesselSignals, POLL_INTERVAL_MS);
console.log('[vesselScheduler] 선박 신호 스케줄러 시작 (1분 간격)');
}
export function stopVesselScheduler(): void {
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
console.log('[vesselScheduler] 선박 신호 스케줄러 중지');
}
}

파일 보기

@ -0,0 +1,55 @@
import type { VesselPosition, BoundingBox } from './vesselTypes.js';
const VESSEL_TTL_MS = 10 * 60 * 1000; // 10분
const cachedVessels = new Map<string, VesselPosition>();
let lastUpdated: Date | null = null;
// lastUpdate가 TTL을 초과한 선박을 캐시에서 제거.
// lastUpdate 파싱이 불가능한 경우 보수적으로 유지한다.
function evictStale(): void {
const now = Date.now();
for (const [mmsi, vessel] of cachedVessels) {
const ts = Date.parse(vessel.lastUpdate);
if (Number.isNaN(ts)) continue;
if (now - ts > VESSEL_TTL_MS) {
cachedVessels.delete(mmsi);
}
}
}
export function updateVesselCache(vessels: VesselPosition[]): void {
for (const vessel of vessels) {
if (!vessel.mmsi) continue;
cachedVessels.set(vessel.mmsi, vessel);
}
evictStale();
lastUpdated = new Date();
}
export function getVesselsInBounds(bounds: BoundingBox): VesselPosition[] {
const result: VesselPosition[] = [];
for (const v of cachedVessels.values()) {
if (
v.lon >= bounds.minLon &&
v.lon <= bounds.maxLon &&
v.lat >= bounds.minLat &&
v.lat <= bounds.maxLat
) {
result.push(v);
}
}
return result;
}
export function getCacheStatus(): {
count: number;
bangjeCount: number;
lastUpdated: Date | null;
} {
let bangjeCount = 0;
for (const v of cachedVessels.values()) {
if (v.shipNm && v.shipNm.toUpperCase().includes('BANGJE')) bangjeCount++;
}
return { count: cachedVessels.size, bangjeCount, lastUpdated };
}

파일 보기

@ -0,0 +1,26 @@
export interface VesselPosition {
mmsi: string;
imo?: number;
lon: number;
lat: number;
sog?: number;
cog?: number;
heading?: number;
shipNm?: string;
shipTy?: string;
shipKindCode?: string;
nationalCode?: string;
lastUpdate: string;
status?: string;
destination?: string;
length?: number;
width?: number;
draught?: number;
}
export interface BoundingBox {
minLon: number;
minLat: number;
maxLon: number;
maxLat: number;
}

파일 보기

@ -6,9 +6,11 @@
### 추가
- 확산예측·HNS 대기확산·긴급구난: GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (사고명·발생시각·위경도 자동 입력 + 지도 이동)
- 실시간 선박 신호 지도 표출: 한국 해역 1분 주기 폴링 스케줄러, 호버 툴팁·클릭 팝업·상세 모달 제공 (확산예측·HNS·긴급구난·사건사고 탭 연동)
### 변경
- MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용
- aerial 이미지 분석 API 기본 URL 변경
## [2026-04-14]

파일 보기

@ -0,0 +1,42 @@
import { useEffect } from 'react';
import { useMap } from '@vis.gl/react-maplibre';
import type { MapBounds } from '@common/types/vessel';
interface MapBoundsTrackerProps {
onBoundsChange?: (bounds: MapBounds) => void;
onZoomChange?: (zoom: number) => void;
}
export function MapBoundsTracker({ onBoundsChange, onZoomChange }: MapBoundsTrackerProps) {
const { current: map } = useMap();
useEffect(() => {
if (!map) return;
const update = () => {
if (onBoundsChange) {
const b = map.getBounds();
onBoundsChange({
minLon: b.getWest(),
minLat: b.getSouth(),
maxLon: b.getEast(),
maxLat: b.getNorth(),
});
}
if (onZoomChange) {
onZoomChange(map.getZoom());
}
};
update();
map.on('moveend', update);
map.on('zoomend', update);
return () => {
map.off('moveend', update);
map.off('zoomend', update);
};
}, [map, onBoundsChange, onZoomChange]);
return null;
}

파일 보기

@ -31,6 +31,15 @@ import { DeckGLOverlay } from './DeckGLOverlay';
import { FlyToController } from './FlyToController';
import { useMapStore } from '@common/store/mapStore';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { buildVesselLayers } from './VesselLayer';
import { MapBoundsTracker } from './MapBoundsTracker';
import {
VesselHoverTooltip,
VesselPopupPanel,
VesselDetailModal,
type VesselHoverInfo,
} from './VesselInteraction';
import type { VesselPosition, MapBounds } from '@common/types/vessel';
const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080';
@ -165,10 +174,16 @@ interface MapViewProps {
analysisCircleRadiusM?: number;
/** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */
showOverlays?: boolean;
/** 선박 신호 목록 (실시간 표출) */
vessels?: VesselPosition[];
/** 지도 뷰포트 bounds 변경 콜백 (선박 신호 필터링에 사용) */
onBoundsChange?: (bounds: MapBounds) => void;
}
// DeckGLOverlay, FlyToController → @common/components/map/DeckGLOverlay, FlyToController 에서 import
// MapBoundsTracker는 './MapBoundsTracker' 모듈로 추출됨 (IncidentsView 등 BaseMap 직접 사용처에서도 재사용)
// fitBounds 트리거 컴포넌트 (Map 내부에서 useMap() 사용)
function FitBoundsController({
fitBoundsTarget,
@ -347,6 +362,8 @@ export function MapView({
analysisCircleCenter,
analysisCircleRadiusM = 0,
showOverlays = true,
vessels = [],
onBoundsChange,
}: MapViewProps) {
const lightMode = true;
const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore();
@ -365,6 +382,10 @@ export function MapView({
const persistentPopupRef = useRef(false);
// 현재 호버 중인 민감자원 feature properties (handleMapClick에서 팝업 생성에 사용)
const hoveredSensitiveRef = useRef<Record<string, unknown> | null>(null);
// 선박 호버/클릭 상호작용 상태
const [vesselHover, setVesselHover] = useState<VesselHoverInfo | null>(null);
const [selectedVessel, setSelectedVessel] = useState<VesselPosition | null>(null);
const [detailVessel, setDetailVessel] = useState<VesselPosition | null>(null);
const currentTime = isControlled ? externalCurrentTime : internalCurrentTime;
const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => {
@ -1216,6 +1237,23 @@ export function MapView({
// 거리/면적 측정 레이어
result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements));
// 선박 신호 레이어
result.push(
...buildVesselLayers(
vessels,
{
onClick: (vessel) => {
setSelectedVessel(vessel);
setDetailVessel(null);
},
onHover: (vessel, x, y) => {
setVesselHover(vessel ? { x, y, vessel } : null);
},
},
mapZoom,
),
);
return result.filter(Boolean);
}, [
oilTrajectory,
@ -1241,6 +1279,8 @@ export function MapView({
analysisCircleCenter,
analysisCircleRadiusM,
lightMode,
vessels,
mapZoom,
]);
// 3D 모드 / 테마에 따른 지도 스타일 전환
@ -1278,6 +1318,8 @@ export function MapView({
<FlyToController target={flyToTarget} duration={1200} />
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
<FitBoundsController fitBoundsTarget={fitBoundsTarget} />
{/* 선박 신호 뷰포트 bounds 추적 */}
<MapBoundsTracker onBoundsChange={onBoundsChange} />
{/* S-57 전자해도 오버레이 (공식 style.json 기반) */}
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
@ -1428,6 +1470,26 @@ export function MapView({
ships={backtrackReplay.ships}
/>
)}
{/* 선박 호버 툴팁 */}
{vesselHover && !selectedVessel && <VesselHoverTooltip hover={vesselHover} />}
{/* 선박 클릭 팝업 */}
{selectedVessel && !detailVessel && (
<VesselPopupPanel
vessel={selectedVessel}
onClose={() => setSelectedVessel(null)}
onDetail={() => {
setDetailVessel(selectedVessel);
setSelectedVessel(null);
}}
/>
)}
{/* 선박 상세 모달 */}
{detailVessel && (
<VesselDetailModal vessel={detailVessel} onClose={() => setDetailVessel(null)} />
)}
</div>
);
}

파일 보기

@ -0,0 +1,647 @@
import { useState } from 'react';
import type { VesselPosition } from '@common/types/vessel';
import { getShipKindLabel } from './VesselLayer';
export interface VesselHoverInfo {
x: number;
y: number;
vessel: VesselPosition;
}
function formatDateTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '-';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function displayVal(v: unknown): string {
if (v === undefined || v === null || v === '') return '-';
return String(v);
}
export function VesselHoverTooltip({ hover }: { hover: VesselHoverInfo }) {
const v = hover.vessel;
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '- kn';
const heading = v.heading ?? v.cog;
const headingText = heading !== undefined ? `HDG ${Math.round(heading)}°` : 'HDG -';
const typeText = [getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-', v.nationalCode]
.filter(Boolean)
.join(' · ');
return (
<div
className="absolute z-[1000] pointer-events-none rounded-md"
style={{
left: hover.x + 12,
top: hover.y - 12,
background: 'var(--bg-elevated)',
border: '1px solid var(--stroke-default)',
padding: '8px 12px',
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
minWidth: 150,
}}
>
<div className="text-label-2 font-bold text-fg" style={{ marginBottom: 3 }}>
{v.shipNm ?? '(이름 없음)'}
</div>
<div className="text-caption text-fg-disabled" style={{ marginBottom: 4 }}>
{typeText}
</div>
<div className="flex justify-between text-caption">
<span className="text-color-accent font-semibold">{speed}</span>
<span className="text-fg-disabled">{headingText}</span>
</div>
</div>
);
}
export function VesselPopupPanel({
vessel: v,
onClose,
onDetail,
}: {
vessel: VesselPosition;
onClose: () => void;
onDetail: () => void;
}) {
const statusText = v.status ?? '-';
const isAccident = (v.status ?? '').includes('사고');
const statusColor = isAccident ? 'var(--color-danger)' : 'var(--color-success)';
const statusBg = isAccident
? 'color-mix(in srgb, var(--color-danger) 15%, transparent)'
: 'color-mix(in srgb, var(--color-success) 10%, transparent)';
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-';
const heading = v.heading ?? v.cog;
const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-';
const receivedAt = v.lastUpdate ? formatDateTime(v.lastUpdate) : '-';
return (
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%,-50%)',
zIndex: 9995,
width: 300,
background: 'rgba(13,17,23,0.97)',
border: '1px solid rgba(48,54,61,0.8)',
borderRadius: 12,
boxShadow: '0 16px 48px rgba(0,0,0,0.7)',
overflow: 'hidden',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '10px 14px',
background: 'rgba(22,27,34,0.97)',
borderBottom: '1px solid rgba(48,54,61,0.8)',
}}
>
<div
className="flex items-center justify-center text-title-2"
style={{ width: 28, height: 20 }}
>
{v.nationalCode ?? '🚢'}
</div>
<div className="flex-1 min-w-0">
<div
className="text-label-1 font-[800] whitespace-nowrap overflow-hidden text-ellipsis"
style={{ color: '#e6edf3' }}
>
{v.shipNm ?? '(이름 없음)'}
</div>
<div className="text-caption font-mono" style={{ color: '#8b949e' }}>
MMSI: {v.mmsi}
</div>
</div>
<span
onClick={onClose}
className="text-title-3 cursor-pointer p-[2px]"
style={{ color: '#8b949e' }}
>
</span>
</div>
<div
className="w-full flex items-center justify-center text-[40px]"
style={{
height: 120,
background: 'rgba(22,27,34,0.97)',
borderBottom: '1px solid rgba(48,54,61,0.6)',
color: '#484f58',
}}
>
🚢
</div>
<div
className="flex gap-2"
style={{ padding: '6px 14px', borderBottom: '1px solid rgba(48,54,61,0.6)' }}
>
<span
className="text-caption font-bold rounded text-color-info"
style={{
padding: '2px 8px',
background: 'color-mix(in srgb, var(--color-info) 12%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-info) 25%, transparent)',
}}
>
{getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-'}
</span>
<span
className="text-caption font-bold rounded"
style={{
padding: '2px 8px',
background: statusBg,
border: `1px solid color-mix(in srgb, ${statusColor} 40%, transparent)`,
color: statusColor,
}}
>
{statusText}
</span>
</div>
<div style={{ padding: '4px 0' }}>
<PopupRow label="속도/항로" value={`${speed} / ${headingText}`} accent />
<PopupRow label="흘수" value={v.draught !== undefined ? `${v.draught.toFixed(2)} m` : '-'} />
<div
className="flex flex-col gap-1"
style={{
padding: '6px 14px',
borderBottom: '1px solid rgba(48,54,61,0.4)',
}}
>
<div className="flex justify-between">
<span className="text-caption" style={{ color: '#8b949e' }}>
</span>
<span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}>
-
</span>
</div>
<div className="flex justify-between">
<span className="text-caption" style={{ color: '#8b949e' }}>
</span>
<span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}>
{v.destination ?? '-'}
</span>
</div>
</div>
<PopupRow label="데이터 수신" value={receivedAt} muted />
</div>
<div className="flex gap-1.5" style={{ padding: '10px 14px' }}>
<button
onClick={onDetail}
className="flex-1 text-caption font-bold cursor-pointer text-center rounded-sm"
style={{
padding: 6,
color: '#58a6ff',
background: 'rgba(88,166,255,0.12)',
border: '1px solid rgba(88,166,255,0.3)',
}}
>
📋
</button>
<button
className="flex-1 text-caption font-bold cursor-pointer text-center rounded-sm"
style={{
padding: 6,
color: '#a5d6ff',
background: 'rgba(165,214,255,0.1)',
border: '1px solid rgba(165,214,255,0.25)',
}}
>
🔍
</button>
<button
className="flex-1 text-caption font-bold cursor-pointer text-center rounded-sm"
style={{
padding: 6,
color: '#22d3ee',
background: 'rgba(34,211,238,0.1)',
border: '1px solid rgba(34,211,238,0.25)',
}}
>
📐
</button>
</div>
</div>
);
}
function PopupRow({
label,
value,
accent,
muted,
}: {
label: string;
value: string;
accent?: boolean;
muted?: boolean;
}) {
return (
<div
className="flex justify-between text-caption"
style={{
padding: '6px 14px',
borderBottom: '1px solid rgba(48,54,61,0.4)',
}}
>
<span style={{ color: '#8b949e' }}>{label}</span>
<span
className="font-semibold font-mono"
style={{
color: muted ? '#8b949e' : accent ? '#22d3ee' : '#c9d1d9',
fontSize: muted ? 9 : 10,
}}
>
{value}
</span>
</div>
);
}
type DetTab = 'info' | 'nav' | 'spec' | 'ins' | 'dg';
const TAB_LABELS: { key: DetTab; label: string }[] = [
{ key: 'info', label: '상세정보' },
{ key: 'nav', label: '항해정보' },
{ key: 'spec', label: '선박제원' },
{ key: 'ins', label: '보험정보' },
{ key: 'dg', label: '위험물정보' },
];
export function VesselDetailModal({
vessel: v,
onClose,
}: {
vessel: VesselPosition;
onClose: () => void;
}) {
const [tab, setTab] = useState<DetTab>('info');
return (
<div
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
className="fixed inset-0 z-[10000] flex items-center justify-center"
style={{
background: 'rgba(0,0,0,0.65)',
backdropFilter: 'blur(6px)',
}}
>
<div
className="flex flex-col overflow-hidden bg-bg-surface border border-stroke"
style={{
width: 560,
height: '85vh',
borderRadius: 14,
boxShadow: '0 24px 64px rgba(0,0,0,0.7)',
}}
>
<div
className="shrink-0 flex items-center justify-between bg-bg-surface border-b border-stroke"
style={{ padding: '14px 18px' }}
>
<div className="flex items-center gap-[10px]">
<span className="text-lg">{v.nationalCode ?? '🚢'}</span>
<div>
<div className="text-title-3 font-[800] text-fg">{v.shipNm ?? '(이름 없음)'}</div>
<div className="text-caption text-fg-disabled font-mono">
MMSI: {v.mmsi} · IMO: {displayVal(v.imo)}
</div>
</div>
</div>
<span onClick={onClose} className="text-title-2 cursor-pointer text-fg-disabled">
</span>
</div>
<div
className="shrink-0 flex gap-0.5 overflow-x-auto bg-bg-base border-b border-stroke"
style={{ padding: '0 18px' }}
>
{TAB_LABELS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className="text-label-2 cursor-pointer whitespace-nowrap"
style={{
padding: '8px 11px',
fontWeight: tab === t.key ? 600 : 400,
color: tab === t.key ? 'var(--color-accent)' : 'var(--fg-disabled)',
borderBottom:
tab === t.key ? '2px solid var(--color-accent)' : '2px solid transparent',
background: 'none',
border: 'none',
transition: '0.15s',
}}
>
{t.label}
</button>
))}
</div>
<div
className="flex-1 overflow-y-auto flex flex-col"
style={{
padding: '16px 18px',
gap: 14,
scrollbarWidth: 'thin',
scrollbarColor: 'var(--stroke-default) transparent',
}}
>
{tab === 'info' && <TabInfo v={v} />}
{tab === 'nav' && <TabNav />}
{tab === 'spec' && <TabSpec v={v} />}
{tab === 'ins' && <TabInsurance />}
{tab === 'dg' && <TabDangerous />}
</div>
</div>
</div>
);
}
function Sec({
title,
borderColor,
bgColor,
badge,
children,
}: {
title: string;
borderColor?: string;
bgColor?: string;
badge?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div
style={{
border: `1px solid ${borderColor || 'var(--stroke-default)'}`,
borderRadius: 8,
overflow: 'hidden',
}}
>
<div
className="text-label-2 font-bold text-fg-sub flex items-center justify-between"
style={{
padding: '8px 12px',
background: bgColor || 'var(--bg-base)',
borderBottom: `1px solid ${borderColor || 'var(--stroke-default)'}`,
}}
>
<span>{title}</span>
{badge}
</div>
{children}
</div>
);
}
function Grid({ children }: { children: React.ReactNode }) {
return <div className="grid grid-cols-2">{children}</div>;
}
function Cell({
label,
value,
span,
color,
}: {
label: string;
value: string;
span?: boolean;
color?: string;
}) {
return (
<div
style={{
padding: '8px 12px',
borderBottom: '1px solid color-mix(in srgb, var(--stroke-default) 60%, transparent)',
borderRight: span
? 'none'
: '1px solid color-mix(in srgb, var(--stroke-default) 60%, transparent)',
gridColumn: span ? '1 / -1' : undefined,
}}
>
<div className="text-caption text-fg-disabled" style={{ marginBottom: 2 }}>
{label}
</div>
<div
className="text-label-2 font-semibold font-mono"
style={{ color: color || 'var(--fg)' }}
>
{value}
</div>
</div>
);
}
function StatusBadge({ label, color }: { label: string; color: string }) {
return (
<span
className="text-caption font-bold"
style={{
padding: '2px 6px',
borderRadius: 8,
marginLeft: 'auto',
background: `color-mix(in srgb, ${color} 25%, transparent)`,
color,
}}
>
{label}
</span>
);
}
function TabInfo({ v }: { v: VesselPosition }) {
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-';
const heading = v.heading ?? v.cog;
const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-';
return (
<>
<div
className="w-full rounded-lg overflow-hidden flex items-center justify-center text-[60px] text-fg-disabled bg-bg-base"
style={{ height: 160 }}
>
🚢
</div>
<Sec title="📡 실시간 현황">
<Grid>
<Cell label="선박상태" value={displayVal(v.status)} />
<Cell
label="속도 / 항로"
value={`${speed} / ${headingText}`}
color="var(--color-accent)"
/>
<Cell label="위도" value={`${v.lat.toFixed(4)}°N`} />
<Cell label="경도" value={`${v.lon.toFixed(4)}°E`} />
<Cell label="흘수" value={v.draught !== undefined ? `${v.draught} m` : '-'} />
<Cell label="수신시간" value={v.lastUpdate ? formatDateTime(v.lastUpdate) : '-'} />
</Grid>
</Sec>
<Sec title="🚢 항해 일정">
<Grid>
<Cell label="출항지" value="-" />
<Cell label="입항지" value={displayVal(v.destination)} />
<Cell label="출항일시" value="-" />
<Cell label="입항일시(ETA)" value="-" />
</Grid>
</Sec>
</>
);
}
function TabNav() {
const hours = ['08', '09', '10', '11', '12', '13', '14'];
const heights = [45, 60, 78, 82, 70, 85, 75];
const colors = [
'color-mix(in srgb, var(--color-success) 30%, transparent)',
'color-mix(in srgb, var(--color-success) 40%, transparent)',
'color-mix(in srgb, var(--color-info) 40%, transparent)',
'color-mix(in srgb, var(--color-info) 50%, transparent)',
'color-mix(in srgb, var(--color-info) 50%, transparent)',
'color-mix(in srgb, var(--color-info) 60%, transparent)',
'color-mix(in srgb, var(--color-accent) 50%, transparent)',
];
return (
<>
<Sec title="🗺 최근 항적 (24h)">
<div
className="flex items-center justify-center relative overflow-hidden bg-bg-base"
style={{ height: 180 }}
>
<svg
width="100%"
height="100%"
viewBox="0 0 400 180"
style={{ position: 'absolute', inset: 0 }}
>
<path
d="M50,150 C80,140 120,100 160,95 S240,70 280,50 S340,30 370,20"
fill="none"
stroke="var(--color-accent)"
strokeWidth="2"
strokeDasharray="6,3"
opacity=".6"
/>
<circle cx="50" cy="150" r="4" fill="var(--fg-disabled)" />
<circle cx="160" cy="95" r="3" fill="var(--color-accent)" opacity=".5" />
<circle cx="280" cy="50" r="3" fill="var(--color-accent)" opacity=".5" />
<circle cx="370" cy="20" r="5" fill="var(--color-accent)" />
</svg>
</div>
</Sec>
<Sec title="📊 속도 이력">
<div className="p-3 bg-bg-base">
<div className="flex items-end gap-1.5" style={{ height: 80 }}>
{hours.map((h, i) => (
<div key={h} className="flex-1 flex flex-col items-center gap-0.5">
<div
className="w-full"
style={{
background: colors[i],
borderRadius: '2px 2px 0 0',
height: `${heights[i]}%`,
}}
/>
<span className="text-caption text-fg-disabled">{h}</span>
</div>
))}
</div>
<div className="text-center text-caption text-fg-disabled" style={{ marginTop: 6 }}>
: <b className="text-color-info">8.4 kn</b> · :{' '}
<b className="text-color-accent">11.2 kn</b>
</div>
</div>
</Sec>
</>
);
}
function TabSpec({ v }: { v: VesselPosition }) {
const loa = v.length !== undefined ? `${v.length} m` : '-';
const beam = v.width !== undefined ? `${v.width} m` : '-';
return (
<>
<Sec title="📐 선체 제원">
<Grid>
<Cell label="선종" value={displayVal(getShipKindLabel(v.shipKindCode) ?? v.shipTy)} />
<Cell label="선적국" value={displayVal(v.nationalCode)} />
<Cell label="총톤수 (GT)" value="-" />
<Cell label="재화중량 (DWT)" value="-" />
<Cell label="전장 (LOA)" value={loa} />
<Cell label="선폭" value={beam} />
<Cell label="건조년도" value="-" />
<Cell label="건조 조선소" value="-" />
</Grid>
</Sec>
<Sec title="📡 통신 / 식별">
<Grid>
<Cell label="MMSI" value={String(v.mmsi)} />
<Cell label="IMO" value={displayVal(v.imo)} />
<Cell label="호출부호" value="-" />
<Cell label="선급" value="-" />
</Grid>
</Sec>
</>
);
}
function TabInsurance() {
return (
<>
<Sec title="🏢 선주 / 운항사">
<Grid>
<Cell label="선주" value="-" />
<Cell label="운항사" value="-" />
<Cell label="P&I Club" value="-" span />
</Grid>
</Sec>
<Sec
title="🚢 선체보험 (H&M)"
borderColor="color-mix(in srgb, var(--color-accent) 20%, transparent)"
bgColor="color-mix(in srgb, var(--color-accent) 6%, transparent)"
badge={<StatusBadge label="-" color="var(--color-success)" />}
>
<Grid>
<Cell label="보험사" value="-" />
<Cell label="보험가액" value="-" color="var(--color-accent)" />
<Cell label="보험기간" value="-" color="var(--color-success)" />
<Cell label="면책금" value="-" />
</Grid>
</Sec>
</>
);
}
function TabDangerous() {
return (
<Sec
title="⚠ 위험물 화물 신고정보"
bgColor="color-mix(in srgb, var(--color-warning) 6%, transparent)"
>
<Grid>
<Cell label="화물명" value="-" color="var(--color-warning)" />
<Cell label="컨테이너갯수/총량" value="-" />
<Cell label="하역업체코드" value="-" />
<Cell label="하역기간" value="-" />
</Grid>
</Sec>
);
}

파일 보기

@ -0,0 +1,133 @@
import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { VesselPosition } from '@common/types/vessel';
export interface VesselLegendItem {
code: string;
type: string;
color: string;
}
export const VESSEL_LEGEND: VesselLegendItem[] = [
{ code: '000020', type: '어선', color: '#f97316' },
{ code: '000021', type: '함정', color: '#64748b' },
{ code: '000022', type: '여객선', color: '#a855f7' },
{ code: '000023', type: '화물선', color: '#22c55e' },
{ code: '000024', type: '유조선', color: '#ef4444' },
{ code: '000025', type: '관공선', color: '#3b82f6' },
{ code: '000027', type: '기타', color: '#94a3b8' },
{ code: '000028', type: '부이', color: '#eab308' },
];
const DEFAULT_VESSEL_COLOR = '#94a3b8';
const SHIP_KIND_COLORS: Record<string, string> = VESSEL_LEGEND.reduce(
(acc, { code, color }) => {
acc[code] = color;
return acc;
},
{} as Record<string, string>,
);
const SHIP_KIND_LABELS: Record<string, string> = VESSEL_LEGEND.reduce(
(acc, { code, type }) => {
acc[code] = type;
return acc;
},
{} as Record<string, string>,
);
export function getShipKindColor(shipKindCode?: string): string {
if (!shipKindCode) return DEFAULT_VESSEL_COLOR;
return SHIP_KIND_COLORS[shipKindCode] ?? DEFAULT_VESSEL_COLOR;
}
export function getShipKindLabel(shipKindCode?: string): string | undefined {
if (!shipKindCode) return undefined;
return SHIP_KIND_LABELS[shipKindCode];
}
function makeTriangleSvg(color: string, isAccident: boolean): string {
const opacity = isAccident ? '1' : '0.85';
const glowOpacity = isAccident ? '0.9' : '0.75';
const svgStr = [
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="20" viewBox="0 0 16 20">',
'<defs><filter id="g" x="-50%" y="-50%" width="200%" height="200%">',
'<feGaussianBlur stdDeviation="1.2"/></filter></defs>',
`<polygon points="8,0 15,20 1,20" fill="${color}" opacity="${glowOpacity}" filter="url(#g)"/>`,
`<polygon points="8,1 14,19 2,19" fill="${color}" opacity="${opacity}" stroke="${color}" stroke-width="0.5"/>`,
'</svg>',
].join('');
return `data:image/svg+xml;base64,${btoa(svgStr)}`;
}
export interface VesselLayerHandlers {
onClick?: (vessel: VesselPosition, coordinate: [number, number]) => void;
onHover?: (vessel: VesselPosition | null, x: number, y: number) => void;
}
export function buildVesselLayers(
vessels: VesselPosition[],
handlers: VesselLayerHandlers = {},
zoom?: number,
) {
if (!vessels.length) return [];
const showLabels = zoom === undefined || zoom > 9;
const iconLayer = new IconLayer({
id: 'vessel-icons',
data: vessels,
getPosition: (d: VesselPosition) => [d.lon, d.lat],
getIcon: (d: VesselPosition) => {
const color = getShipKindColor(d.shipKindCode);
const isAccident = (d.status ?? '').includes('사고');
return {
url: makeTriangleSvg(color, isAccident),
width: 16,
height: 20,
anchorX: 8,
anchorY: 10,
};
},
getSize: 16,
getAngle: (d: VesselPosition) => -(d.heading ?? d.cog ?? 0),
sizeUnits: 'pixels',
sizeScale: 1,
pickable: true,
onClick: (info: { object?: VesselPosition; coordinate?: number[] }) => {
if (info.object && info.coordinate && handlers.onClick) {
handlers.onClick(info.object, [info.coordinate[0], info.coordinate[1]]);
}
},
onHover: (info: { object?: VesselPosition; x?: number; y?: number }) => {
if (!handlers.onHover) return;
if (info.object && info.x !== undefined && info.y !== undefined) {
handlers.onHover(info.object, info.x, info.y);
} else {
handlers.onHover(null, 0, 0);
}
},
updateTriggers: {
getIcon: [vessels],
getAngle: [vessels],
},
});
const labelLayer = new TextLayer({
id: 'vessel-labels',
data: vessels.filter((v) => v.shipNm),
visible: showLabels,
getPosition: (d: VesselPosition) => [d.lon, d.lat],
getText: (d: VesselPosition) => d.shipNm ?? '',
getSize: 11,
getColor: [255, 255, 255, 240],
getPixelOffset: [0, -14],
billboard: true,
sizeUnits: 'pixels' as const,
characterSet: 'auto',
fontSettings: { sdf: true },
outlineColor: [0, 0, 0, 230],
outlineWidth: 2,
});
return [iconLayer, labelLayer];
}

파일 보기

@ -0,0 +1,79 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
createVesselSignalClient,
type VesselSignalClient,
} from '@common/services/vesselSignalClient';
import {
getInitialVesselSnapshot,
isVesselInitEnabled,
} from '@common/services/vesselApi';
import type { VesselPosition, MapBounds } from '@common/types/vessel';
/**
*
*
* (VITE_VESSEL_SIGNAL_MODE=polling):
* - 60 REST API(/api/vessels/in-area) bbox
*
* (VITE_VESSEL_SIGNAL_MODE=websocket):
* - WebSocket (VITE_VESSEL_WS_URL)
* - bbox로
*
* @param mapBounds MapView의 onBoundsChange로 bbox
* @returns
*/
export function useVesselSignals(mapBounds: MapBounds | null): VesselPosition[] {
const [vessels, setVessels] = useState<VesselPosition[]>([]);
const boundsRef = useRef<MapBounds | null>(mapBounds);
const clientRef = useRef<VesselSignalClient | null>(null);
useEffect(() => {
boundsRef.current = mapBounds;
}, [mapBounds]);
const getViewportBounds = useCallback(() => boundsRef.current, []);
useEffect(() => {
const client = createVesselSignalClient();
clientRef.current = client;
// 운영 환경: 로그인/새로고침 직후 최근 10분치 스냅샷을 먼저 1회 로드.
// 이후 WebSocket 수신이 시작되면 최신 신호로 갱신된다.
// VITE_VESSEL_INIT_ENABLED=true 일 때만 활성화(기본 비활성).
if (isVesselInitEnabled()) {
getInitialVesselSnapshot()
.then((initial) => {
const bounds = boundsRef.current;
const filtered = bounds
? initial.filter(
(v) =>
v.lon >= bounds.minLon &&
v.lon <= bounds.maxLon &&
v.lat >= bounds.minLat &&
v.lat <= bounds.maxLat,
)
: initial;
// WS 첫 메시지가 먼저 도착해 이미 채워졌다면 덮어쓰지 않음
setVessels((prev) => (prev.length === 0 ? filtered : prev));
})
.catch((e) => console.warn('[useVesselSignals] 초기 스냅샷 실패', e));
}
client.start(setVessels, getViewportBounds);
return () => {
client.stop();
clientRef.current = null;
};
}, [getViewportBounds]);
// mapBounds가 바뀔 때마다(최초 채워질 때 + 이후 뷰포트 이동/줌마다) 즉시 1회 새로고침.
// MapView의 onBoundsChange는 moveend/zoomend에서만 호출되므로 드래그 중 스팸은 없다.
// 이후에도 60초 인터벌 폴링은 백그라운드에서 계속 동작.
useEffect(() => {
if (mapBounds && clientRef.current) {
clientRef.current.refresh();
}
}, [mapBounds]);
return vessels;
}

파일 보기

@ -1,613 +1,4 @@
export interface Vessel {
mmsi: number;
imo: string;
name: string;
typS: string;
flag: string;
status: string;
speed: number;
heading: number;
lat: number;
lng: number;
draft: number;
depart: string;
arrive: string;
etd: string;
eta: string;
gt: string;
dwt: string;
loa: string;
beam: string;
built: string;
yard: string;
callSign: string;
cls: string;
cargo: string;
color: string;
markerType: string;
}
export const VESSEL_TYPE_COLORS: Record<string, string> = {
Tanker: '#ef4444',
Chemical: '#ef4444',
Cargo: '#22c55e',
Bulk: '#22c55e',
Container: '#3b82f6',
Passenger: '#a855f7',
Fishing: '#f97316',
Tug: '#06b6d4',
Navy: '#6b7280',
Sailing: '#fbbf24',
};
export const VESSEL_LEGEND = [
{ type: 'Tanker', color: '#ef4444' },
{ type: 'Cargo', color: '#22c55e' },
{ type: 'Container', color: '#3b82f6' },
{ type: 'Fishing', color: '#f97316' },
{ type: 'Passenger', color: '#a855f7' },
{ type: 'Tug', color: '#06b6d4' },
];
export const mockVessels: Vessel[] = [
{
mmsi: 440123456,
imo: '9812345',
name: 'HANKUK CHEMI',
typS: 'Tanker',
flag: '🇰🇷',
status: '항해중',
speed: 8.2,
heading: 330,
lat: 34.6,
lng: 127.5,
draft: 5.8,
depart: '여수항',
arrive: '부산항',
etd: '2026-02-25 08:00',
eta: '2026-02-25 18:30',
gt: '29,246',
dwt: '49,999',
loa: '183.0m',
beam: '32.2m',
built: '2018',
yard: '현대미포조선',
callSign: 'HLKC',
cls: '한국선급(KR)',
cargo: 'BUNKER-C · 1,200kL · IMO Class 3',
color: '#ef4444',
markerType: 'tanker',
},
{
mmsi: 440234567,
imo: '9823456',
name: 'DONG-A GLAUCOS',
typS: 'Cargo',
flag: '🇰🇷',
status: '항해중',
speed: 11.4,
heading: 245,
lat: 34.78,
lng: 127.8,
draft: 7.2,
depart: '울산항',
arrive: '광양항',
etd: '2026-02-25 06:30',
eta: '2026-02-25 16:00',
gt: '12,450',
dwt: '18,800',
loa: '144.0m',
beam: '22.6m',
built: '2015',
yard: 'STX조선',
callSign: 'HLDG',
cls: '한국선급(KR)',
cargo: '철강재 · 4,500t',
color: '#22c55e',
markerType: 'cargo',
},
{
mmsi: 440345678,
imo: '9834567',
name: 'HMM ALGECIRAS',
typS: 'Container',
flag: '🇰🇷',
status: '항해중',
speed: 18.5,
heading: 195,
lat: 35.0,
lng: 128.8,
draft: 14.5,
depart: '부산항',
arrive: '싱가포르',
etd: '2026-02-25 04:00',
eta: '2026-03-02 08:00',
gt: '228,283',
dwt: '223,092',
loa: '399.9m',
beam: '61.0m',
built: '2020',
yard: '대우조선해양',
callSign: 'HLHM',
cls: "Lloyd's Register",
cargo: '컨테이너 · 16,420 TEU',
color: '#3b82f6',
markerType: 'container',
},
{
mmsi: 355678901,
imo: '9756789',
name: 'STELLAR DAISY',
typS: 'Tanker',
flag: '🇵🇦',
status: '⚠ 사고(좌초)',
speed: 0.0,
heading: 0,
lat: 34.72,
lng: 127.72,
draft: 8.1,
depart: '여수항',
arrive: '—',
etd: '2026-01-18 12:00',
eta: '—',
gt: '35,120',
dwt: '58,000',
loa: '190.0m',
beam: '34.0m',
built: '2012',
yard: 'CSBC Taiwan',
callSign: '3FZA7',
cls: 'NK',
cargo: 'BUNKER-C · 150kL 유출 · ⚠ 사고선박',
color: '#ef4444',
markerType: 'tanker',
},
{
mmsi: 440456789,
imo: '—',
name: '제72 금양호',
typS: 'Fishing',
flag: '🇰🇷',
status: '조업중',
speed: 4.1,
heading: 120,
lat: 34.55,
lng: 127.35,
draft: 2.1,
depart: '여수 국동항',
arrive: '여수 국동항',
etd: '2026-02-25 04:30',
eta: '2026-02-25 18:00',
gt: '78',
dwt: '—',
loa: '24.5m',
beam: '6.2m',
built: '2008',
yard: '통영조선',
callSign: '—',
cls: '한국선급',
cargo: '어획물',
color: '#f97316',
markerType: 'fishing',
},
{
mmsi: 440567890,
imo: '9867890',
name: 'PAN OCEAN GLORY',
typS: 'Bulk',
flag: '🇰🇷',
status: '항해중',
speed: 12.8,
heading: 170,
lat: 35.6,
lng: 126.4,
draft: 10.3,
depart: '군산항',
arrive: '포항항',
etd: '2026-02-25 07:00',
eta: '2026-02-26 04:00',
gt: '43,800',
dwt: '76,500',
loa: '229.0m',
beam: '32.3m',
built: '2019',
yard: '현대삼호중공업',
callSign: 'HLPO',
cls: '한국선급(KR)',
cargo: '석탄 · 65,000t',
color: '#22c55e',
markerType: 'cargo',
},
{
mmsi: 440678901,
imo: '—',
name: '여수예인1호',
typS: 'Tug',
flag: '🇰🇷',
status: '방제지원',
speed: 6.3,
heading: 355,
lat: 34.68,
lng: 127.6,
draft: 3.2,
depart: '여수항',
arrive: '사고현장',
etd: '2026-01-18 16:30',
eta: '—',
gt: '280',
dwt: '—',
loa: '32.0m',
beam: '9.5m',
built: '2016',
yard: '삼성중공업',
callSign: 'HLYT',
cls: '한국선급',
cargo: '방제장비 · 오일붐 500m',
color: '#06b6d4',
markerType: 'tug',
},
{
mmsi: 235012345,
imo: '9456789',
name: 'QUEEN MARY',
typS: 'Passenger',
flag: '🇬🇧',
status: '항해중',
speed: 15.2,
heading: 10,
lat: 33.8,
lng: 127.0,
draft: 8.5,
depart: '상하이',
arrive: '부산항',
etd: '2026-02-24 18:00',
eta: '2026-02-26 06:00',
gt: '148,528',
dwt: '18,000',
loa: '345.0m',
beam: '41.0m',
built: '2004',
yard: "Chantiers de l'Atlantique",
callSign: 'GBQM2',
cls: "Lloyd's Register",
cargo: '승객 2,620명',
color: '#a855f7',
markerType: 'passenger',
},
{
mmsi: 353012345,
imo: '9811000',
name: 'EVER GIVEN',
typS: 'Container',
flag: '🇹🇼',
status: '항해중',
speed: 14.7,
heading: 220,
lat: 35.2,
lng: 129.2,
draft: 15.7,
depart: '부산항',
arrive: '카오슝',
etd: '2026-02-25 02:00',
eta: '2026-02-28 14:00',
gt: '220,940',
dwt: '199,629',
loa: '400.0m',
beam: '59.0m',
built: '2018',
yard: '今治造船',
callSign: 'BIXE9',
cls: 'ABS',
cargo: '컨테이너 · 14,800 TEU',
color: '#3b82f6',
markerType: 'container',
},
{
mmsi: 440789012,
imo: '—',
name: '제85 대성호',
typS: 'Fishing',
flag: '🇰🇷',
status: '조업중',
speed: 3.8,
heading: 85,
lat: 34.4,
lng: 126.3,
draft: 1.8,
depart: '목포항',
arrive: '목포항',
etd: '2026-02-25 03:00',
eta: '2026-02-25 17:00',
gt: '65',
dwt: '—',
loa: '22.0m',
beam: '5.8m',
built: '2010',
yard: '목포조선',
callSign: '—',
cls: '한국선급',
cargo: '어획물',
color: '#f97316',
markerType: 'fishing',
},
{
mmsi: 440890123,
imo: '9878901',
name: 'SK INNOVATION',
typS: 'Chemical',
flag: '🇰🇷',
status: '항해중',
speed: 9.6,
heading: 340,
lat: 35.8,
lng: 126.6,
draft: 6.5,
depart: '대산항',
arrive: '여수항',
etd: '2026-02-25 10:00',
eta: '2026-02-26 02:00',
gt: '11,200',
dwt: '16,800',
loa: '132.0m',
beam: '20.4m',
built: '2020',
yard: '현대미포조선',
callSign: 'HLSK',
cls: '한국선급(KR)',
cargo: '톨루엔 · 8,500kL · IMO Class 3',
color: '#ef4444',
markerType: 'tanker',
},
{
mmsi: 440901234,
imo: '9889012',
name: 'KOREA EXPRESS',
typS: 'Cargo',
flag: '🇰🇷',
status: '항해중',
speed: 10.1,
heading: 190,
lat: 36.2,
lng: 128.5,
draft: 6.8,
depart: '동해항',
arrive: '포항항',
etd: '2026-02-25 09:00',
eta: '2026-02-25 15:00',
gt: '8,500',
dwt: '12,000',
loa: '118.0m',
beam: '18.2m',
built: '2014',
yard: '대한조선',
callSign: 'HLKE',
cls: '한국선급',
cargo: '일반화물',
color: '#22c55e',
markerType: 'cargo',
},
{
mmsi: 440012345,
imo: '—',
name: 'ROKS SEJONG',
typS: 'Navy',
flag: '🇰🇷',
status: '작전중',
speed: 16.0,
heading: 270,
lat: 35.3,
lng: 129.5,
draft: 6.3,
depart: '부산 해군기지',
arrive: '—',
etd: '—',
eta: '—',
gt: '7,600',
dwt: '—',
loa: '165.9m',
beam: '21.4m',
built: '2008',
yard: '현대중공업',
callSign: 'HLNS',
cls: '군용',
cargo: '군사작전',
color: '#6b7280',
markerType: 'military',
},
{
mmsi: 440023456,
imo: '—',
name: '군산예인3호',
typS: 'Tug',
flag: '🇰🇷',
status: '대기중',
speed: 5.5,
heading: 140,
lat: 35.9,
lng: 126.9,
draft: 2.8,
depart: '군산항',
arrive: '군산항',
etd: '—',
eta: '—',
gt: '180',
dwt: '—',
loa: '28.0m',
beam: '8.2m',
built: '2019',
yard: '통영조선',
callSign: 'HLGS',
cls: '한국선급',
cargo: '—',
color: '#06b6d4',
markerType: 'tug',
},
{
mmsi: 440034567,
imo: '—',
name: 'JEJU WIND',
typS: 'Sailing',
flag: '🇰🇷',
status: '항해중',
speed: 6.8,
heading: 290,
lat: 33.35,
lng: 126.65,
draft: 2.5,
depart: '제주항',
arrive: '제주항',
etd: '2026-02-25 10:00',
eta: '2026-02-25 16:00',
gt: '45',
dwt: '—',
loa: '18.0m',
beam: '5.0m',
built: '2022',
yard: '제주요트',
callSign: '—',
cls: '—',
cargo: '—',
color: '#fbbf24',
markerType: 'sail',
},
{
mmsi: 440045678,
imo: '—',
name: '제33 삼양호',
typS: 'Fishing',
flag: '🇰🇷',
status: '조업중',
speed: 2.4,
heading: 55,
lat: 35.1,
lng: 127.4,
draft: 1.6,
depart: '통영항',
arrive: '통영항',
etd: '2026-02-25 05:00',
eta: '2026-02-25 19:00',
gt: '52',
dwt: '—',
loa: '20.0m',
beam: '5.4m',
built: '2006',
yard: '거제조선',
callSign: '—',
cls: '한국선급',
cargo: '어획물',
color: '#f97316',
markerType: 'fishing',
},
{
mmsi: 255012345,
imo: '9703291',
name: 'MSC OSCAR',
typS: 'Container',
flag: '🇨🇭',
status: '항해중',
speed: 17.3,
heading: 355,
lat: 34.1,
lng: 128.1,
draft: 14.0,
depart: '카오슝',
arrive: '부산항',
etd: '2026-02-23 08:00',
eta: '2026-02-25 22:00',
gt: '197,362',
dwt: '199,272',
loa: '395.4m',
beam: '59.0m',
built: '2015',
yard: '대우조선해양',
callSign: '9HA4713',
cls: 'DNV',
cargo: '컨테이너 · 18,200 TEU',
color: '#3b82f6',
markerType: 'container',
},
{
mmsi: 440056789,
imo: '9890567',
name: 'SAEHAN PIONEER',
typS: 'Tanker',
flag: '🇰🇷',
status: '항해중',
speed: 7.9,
heading: 310,
lat: 34.9,
lng: 127.1,
draft: 5.2,
depart: '여수항',
arrive: '대산항',
etd: '2026-02-25 11:00',
eta: '2026-02-26 08:00',
gt: '8,900',
dwt: '14,200',
loa: '120.0m',
beam: '18.0m',
built: '2017',
yard: '현대미포조선',
callSign: 'HLSP',
cls: '한국선급(KR)',
cargo: '경유 · 10,000kL',
color: '#ef4444',
markerType: 'tanker',
},
{
mmsi: 440067890,
imo: '9891678',
name: 'DONGHAE STAR',
typS: 'Cargo',
flag: '🇰🇷',
status: '항해중',
speed: 11.0,
heading: 155,
lat: 37.55,
lng: 129.3,
draft: 6.0,
depart: '속초항',
arrive: '동해항',
etd: '2026-02-25 12:00',
eta: '2026-02-25 16:30',
gt: '6,200',
dwt: '8,500',
loa: '105.0m',
beam: '16.5m',
built: '2013',
yard: '대한조선',
callSign: 'HLDS',
cls: '한국선급',
cargo: '일반화물 · 목재',
color: '#22c55e',
markerType: 'cargo',
},
{
mmsi: 440078901,
imo: '—',
name: '제18 한라호',
typS: 'Fishing',
flag: '🇰🇷',
status: '귀항중',
speed: 3.2,
heading: 70,
lat: 33.3,
lng: 126.3,
draft: 1.9,
depart: '서귀포항',
arrive: '서귀포항',
etd: '2026-02-25 04:00',
eta: '2026-02-25 15:00',
gt: '58',
dwt: '—',
loa: '21.0m',
beam: '5.6m',
built: '2011',
yard: '제주조선',
callSign: '—',
cls: '한국선급',
cargo: '어획물 · 갈치/고등어',
color: '#f97316',
markerType: 'fishing',
},
];
// Deprecated: Mock 선박 데이터는 제거되었습니다.
// 실제 선박 신호는 @common/hooks/useVesselSignals + @common/components/map/VesselLayer 를 사용합니다.
// 범례는 @common/components/map/VesselLayer 의 VESSEL_LEGEND 를 import 하세요.
export {};

파일 보기

@ -0,0 +1,35 @@
import { api } from './api';
import type { VesselPosition, MapBounds } from '@common/types/vessel';
export async function getVesselsInArea(bounds: MapBounds): Promise<VesselPosition[]> {
const res = await api.post<VesselPosition[]>('/vessels/in-area', { bounds });
return res.data;
}
/**
* / 1 API.
* REST 10 .
* URL은 VITE_VESSEL_INIT_API_URL ( URL로 ).
*/
export async function getInitialVesselSnapshot(): Promise<VesselPosition[]> {
const url = import.meta.env.VITE_VESSEL_INIT_API_URL as string | undefined;
if (!url) return [];
const res = await fetch(url, { method: 'GET' });
if (!res.ok) throw new Error(`vessel init snapshot ${res.status}`);
return (await res.json()) as VesselPosition[];
}
export function isVesselInitEnabled(): boolean {
return import.meta.env.VITE_VESSEL_INIT_ENABLED === 'true';
}
export interface VesselCacheStatus {
count: number;
bangjeCount: number;
lastUpdated: string | null;
}
export async function getVesselCacheStatus(): Promise<VesselCacheStatus> {
const res = await api.get<VesselCacheStatus>('/vessels/status');
return res.data;
}

파일 보기

@ -0,0 +1,125 @@
import type { VesselPosition, MapBounds } from '@common/types/vessel';
import { getVesselsInArea } from './vesselApi';
export interface VesselSignalClient {
start(
onVessels: (vessels: VesselPosition[]) => void,
getViewportBounds: () => MapBounds | null,
): void;
stop(): void;
/**
* 1 . bbox로 REST ,
* WebSocket no-op( push에 ).
*/
refresh(): void;
}
// 개발환경: setInterval(60s) → 백엔드 REST API 호출
class PollingVesselClient implements VesselSignalClient {
private intervalId: ReturnType<typeof setInterval> | null = null;
private onVessels: ((vessels: VesselPosition[]) => void) | null = null;
private getViewportBounds: (() => MapBounds | null) | null = null;
private async poll(): Promise<void> {
const bounds = this.getViewportBounds?.();
if (!bounds || !this.onVessels) return;
try {
const vessels = await getVesselsInArea(bounds);
this.onVessels(vessels);
} catch {
// 폴링 실패 시 무시 (다음 인터벌에 재시도)
}
}
start(
onVessels: (vessels: VesselPosition[]) => void,
getViewportBounds: () => MapBounds | null,
): void {
this.onVessels = onVessels;
this.getViewportBounds = getViewportBounds;
// 즉시 1회 실행 후 60초 간격으로 반복
this.poll();
this.intervalId = setInterval(() => this.poll(), 60_000);
}
stop(): void {
if (this.intervalId !== null) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.onVessels = null;
this.getViewportBounds = null;
}
refresh(): void {
this.poll();
}
}
// 운영환경: 실시간 WebSocket 서버에 직접 연결
class DirectWebSocketVesselClient implements VesselSignalClient {
private ws: WebSocket | null = null;
private readonly wsUrl: string;
constructor(wsUrl: string) {
this.wsUrl = wsUrl;
}
start(
onVessels: (vessels: VesselPosition[]) => void,
getViewportBounds: () => MapBounds | null,
): void {
this.ws = new WebSocket(this.wsUrl);
this.ws.onmessage = (event) => {
try {
const allVessels = JSON.parse(event.data as string) as VesselPosition[];
const bounds = getViewportBounds();
if (!bounds) {
onVessels(allVessels);
return;
}
const filtered = allVessels.filter(
(v) =>
v.lon >= bounds.minLon &&
v.lon <= bounds.maxLon &&
v.lat >= bounds.minLat &&
v.lat <= bounds.maxLat,
);
onVessels(filtered);
} catch {
// 파싱 실패 무시
}
};
this.ws.onerror = () => {
console.error('[vesselSignalClient] WebSocket 연결 오류');
};
this.ws.onclose = () => {
console.warn('[vesselSignalClient] WebSocket 연결 종료');
};
}
stop(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
refresh(): void {
// 운영 WS 모드에선 서버 push에 의존하므로 별도 새로고침 동작 없음
}
}
export function createVesselSignalClient(): VesselSignalClient {
if (import.meta.env.VITE_VESSEL_SIGNAL_MODE === 'websocket') {
const wsUrl = import.meta.env.VITE_VESSEL_WS_URL as string;
return new DirectWebSocketVesselClient(wsUrl);
}
return new PollingVesselClient();
}

파일 보기

@ -0,0 +1,26 @@
export interface VesselPosition {
mmsi: string;
imo?: number;
lon: number;
lat: number;
sog?: number;
cog?: number;
heading?: number;
shipNm?: string;
shipTy?: string;
shipKindCode?: string;
nationalCode?: string;
lastUpdate: string;
status?: string;
destination?: string;
length?: number;
width?: number;
draught?: number;
}
export interface MapBounds {
minLon: number;
minLat: number;
maxLon: number;
maxLat: number;
}

파일 보기

@ -1,4 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useVesselSignals } from '@common/hooks/useVesselSignals';
import type { MapBounds } from '@common/types/vessel';
import { HNSLeftPanel } from './HNSLeftPanel';
import type { HNSInputParams } from './HNSLeftPanel';
import { HNSRightPanel } from './HNSRightPanel';
@ -267,6 +269,8 @@ export function HNSView() {
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const vessels = useVesselSignals(mapBounds);
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [dispersionResult, setDispersionResult] = useState<any>(null);
@ -974,6 +978,8 @@ export function HNSView() {
dispersionResult={dispersionResult}
dispersionHeatmap={heatmapData}
mapCaptureRef={mapCaptureRef}
vessels={vessels}
onBoundsChange={setMapBounds}
/>
{/* 시간 슬라이더 (puff/dense_gas 모델용) */}
{allTimeFrames.length > 1 && (

파일 보기

@ -1,13 +1,17 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { Popup, useMap } from '@vis.gl/react-maplibre';
import { ScatterplotLayer, IconLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers';
import { ScatterplotLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers';
import { PathStyleExtension } from '@deck.gl/extensions';
import 'maplibre-gl/dist/maplibre-gl.css';
import { BaseMap } from '@common/components/map/BaseMap';
import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay';
import { MapBoundsTracker } from '@common/components/map/MapBoundsTracker';
import { buildVesselLayers, VESSEL_LEGEND, getShipKindLabel } from '@common/components/map/VesselLayer';
import { useVesselSignals } from '@common/hooks/useVesselSignals';
import type { MapBounds, VesselPosition } from '@common/types/vessel';
import { getVesselCacheStatus, type VesselCacheStatus } from '@common/services/vesselApi';
import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel';
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel';
import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData';
import { fetchIncidents } from '../services/incidentsApi';
import type { IncidentCompat } from '../services/incidentsApi';
import { fetchAnalysisTrajectory } from '@tabs/prediction/services/predictionApi';
@ -86,16 +90,11 @@ function getMarkerStroke(s: string): [number, number, number, number] {
const getStatusLabel = (s: string) =>
s === 'active' ? '대응중' : s === 'investigating' ? '조사중' : s === 'closed' ? '종료' : '';
// ── 선박 아이콘 SVG (삼각형) ────────────────────────────
// deck.gl IconLayer용 atlas 없이 SVGOverlay 방식 대신
// ScatterplotLayer로 단순화하여 선박을 원으로 표현 (heading 정보 별도 레이어)
// → 원 마커 + 방향 지시선을 deck.gl ScatterplotLayer로 표현
// 팝업 정보
interface VesselPopupInfo {
longitude: number;
latitude: number;
vessel: Vessel;
vessel: VesselPosition;
}
interface IncidentPopupInfo {
@ -108,7 +107,7 @@ interface IncidentPopupInfo {
interface HoverInfo {
x: number;
y: number;
object: Vessel | IncidentCompat;
object: VesselPosition | IncidentCompat;
type: 'vessel' | 'incident';
}
@ -119,12 +118,35 @@ export function IncidentsView() {
const [incidents, setIncidents] = useState<IncidentCompat[]>([]);
const [filteredIncidents, setFilteredIncidents] = useState<IncidentCompat[]>([]);
const [selectedIncidentId, setSelectedIncidentId] = useState<string | null>(null);
const [selectedVessel, setSelectedVessel] = useState<Vessel | null>(null);
const [detailVessel, setDetailVessel] = useState<Vessel | null>(null);
const [selectedVessel, setSelectedVessel] = useState<VesselPosition | null>(null);
const [detailVessel, setDetailVessel] = useState<VesselPosition | null>(null);
const [vesselPopup, setVesselPopup] = useState<VesselPopupInfo | null>(null);
const [incidentPopup, setIncidentPopup] = useState<IncidentPopupInfo | null>(null);
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null);
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const [mapZoom, setMapZoom] = useState<number>(10);
const realVessels = useVesselSignals(mapBounds);
const [vesselStatus, setVesselStatus] = useState<VesselCacheStatus | null>(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const status = await getVesselCacheStatus();
if (!cancelled) setVesselStatus(status);
} catch {
// 무시 — 다음 폴링에서 재시도
}
};
load();
const id = setInterval(load, 30_000);
return () => {
cancelled = true;
clearInterval(id);
};
}, []);
const [dischargeMode, setDischargeMode] = useState(false);
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
@ -283,60 +305,6 @@ export function IncidentsView() {
[filteredIncidents, selectedIncidentId],
);
// ── 선박 방향 지시 아이콘 (삼각형 SVG → IconLayer) ──────
// 글로우 효과 + 드롭쉐도우가 있는 개선된 SVG 삼각형
const vesselIconLayer = useMemo(() => {
const makeTriangleSvg = (color: string, isAccident: boolean) => {
const opacity = isAccident ? '1' : '0.85';
const glowOpacity = isAccident ? '0.9' : '0.75';
const svgStr = [
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="20" viewBox="0 0 16 20">',
'<defs><filter id="g" x="-50%" y="-50%" width="200%" height="200%">',
'<feGaussianBlur stdDeviation="1.2"/></filter></defs>',
`<polygon points="8,0 15,20 1,20" fill="${color}" opacity="${glowOpacity}" filter="url(#g)"/>`,
`<polygon points="8,1 14,19 2,19" fill="${color}" opacity="${opacity}" stroke="${color}" stroke-width="0.5"/>`,
'</svg>',
].join('');
return `data:image/svg+xml;base64,${btoa(svgStr)}`;
};
return new IconLayer({
id: 'vessel-icons',
data: mockVessels,
getPosition: (d: Vessel) => [d.lng, d.lat],
getIcon: (d: Vessel) => ({
url: makeTriangleSvg(d.color, d.status.includes('사고')),
width: 16,
height: 20,
anchorX: 8,
anchorY: 10,
}),
getSize: 16,
getAngle: (d: Vessel) => -d.heading,
sizeUnits: 'pixels',
sizeScale: 1,
pickable: true,
onClick: (info: { object?: Vessel; coordinate?: number[] }) => {
if (info.object && info.coordinate) {
setSelectedVessel(info.object);
setVesselPopup({
longitude: info.coordinate[0],
latitude: info.coordinate[1],
vessel: info.object,
});
setIncidentPopup(null);
setDetailVessel(null);
}
},
onHover: (info: { object?: Vessel; x?: number; y?: number }) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setHoverInfo({ x: info.x, y: info.y, object: info.object, type: 'vessel' });
} else {
setHoverInfo((h) => (h?.type === 'vessel' ? null : h));
}
},
});
}, []);
// ── 배출 구역 경계선 레이어 ──
const dischargeZoneLayers = useMemo(() => {
@ -535,16 +503,44 @@ export function IncidentsView() {
});
}, [sensitiveGeojson, sensCheckedCategories, sensColorMap]);
const realVesselLayers = useMemo(
() =>
buildVesselLayers(
realVessels,
{
onClick: (vessel, coordinate) => {
setSelectedVessel(vessel);
setVesselPopup({
longitude: coordinate[0],
latitude: coordinate[1],
vessel,
});
setIncidentPopup(null);
setDetailVessel(null);
},
onHover: (vessel, x, y) => {
if (vessel) {
setHoverInfo({ x, y, object: vessel, type: 'vessel' });
} else {
setHoverInfo((h) => (h?.type === 'vessel' ? null : h));
}
},
},
mapZoom,
),
[realVessels, mapZoom],
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deckLayers: any[] = useMemo(
() => [
incidentLayer,
vesselIconLayer,
...realVesselLayers,
...dischargeZoneLayers,
...trajectoryLayers,
...(sensLayer ? [sensLayer] : []),
],
[incidentLayer, vesselIconLayer, dischargeZoneLayers, trajectoryLayers, sensLayer],
[incidentLayer, realVesselLayers, dischargeZoneLayers, trajectoryLayers, sensLayer],
);
return (
@ -692,6 +688,7 @@ export function IncidentsView() {
}}
>
<DeckGLOverlay layers={deckLayers} />
<MapBoundsTracker onBoundsChange={setMapBounds} onZoomChange={setMapZoom} />
<FlyToController incident={selectedIncident} />
{/* 사고 팝업 */}
@ -729,7 +726,7 @@ export function IncidentsView() {
}}
>
{hoverInfo.type === 'vessel' ? (
<VesselTooltipContent vessel={hoverInfo.object as Vessel} />
<VesselTooltipContent vessel={hoverInfo.object as VesselPosition} />
) : (
<IncidentTooltipContent incident={hoverInfo.object as IncidentCompat} />
)}
@ -859,12 +856,11 @@ export function IncidentsView() {
}}
/> */}
<span className="text-caption">AIS Live</span>
<span className="text-caption text-fg-disabled font-mono">MarineTraffic</span>
</div>
<div className="flex gap-2.5 text-caption font-mono">
<div className="text-fg-sub"> 20</div>
<div className="text-fg-sub"> 6</div>
<div className="text-fg-sub"> 2</div>
<div className="text-fg-sub"> {vesselStatus?.count ?? 0}</div>
<div className="text-fg-sub"> {filteredIncidents.length}</div>
<div className="text-fg-sub"> {vesselStatus?.bangjeCount ?? 0}</div>
</div>
</div>
@ -1108,7 +1104,15 @@ export function IncidentsView() {
onCloseAnalysis={handleCloseAnalysis}
onCheckedPredsChange={handleCheckedPredsChange}
onSensitiveDataChange={handleSensitiveDataChange}
selectedVessel={selectedVessel}
selectedVessel={
selectedVessel
? {
lat: selectedVessel.lat,
lng: selectedVessel.lon,
name: selectedVessel.shipNm,
}
: null
}
/>
</div>
</div>
@ -1253,21 +1257,40 @@ function SplitPanelContent({
}
/*
VesselPopupPanel
VesselPopupPanel / VesselDetailModal
*/
function formatDateTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '-';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function displayVal(v: unknown): string {
if (v === undefined || v === null || v === '') return '-';
return String(v);
}
function VesselPopupPanel({
vessel: v,
onClose,
onDetail,
}: {
vessel: Vessel;
vessel: VesselPosition;
onClose: () => void;
onDetail: () => void;
}) {
const statusColor = v.status.includes('사고') ? 'var(--color-danger)' : 'var(--color-success)';
const statusBg = v.status.includes('사고')
const statusText = v.status ?? '-';
const isAccident = (v.status ?? '').includes('사고');
const statusColor = isAccident ? 'var(--color-danger)' : 'var(--color-success)';
const statusBg = isAccident
? 'color-mix(in srgb, var(--color-danger) 15%, transparent)'
: 'color-mix(in srgb, var(--color-success) 10%, transparent)';
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-';
const heading = v.heading ?? v.cog;
const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-';
const receivedAt = v.lastUpdate ? formatDateTime(v.lastUpdate) : '-';
return (
<div
@ -1300,14 +1323,14 @@ function VesselPopupPanel({
className="flex items-center justify-center text-title-2"
style={{ width: 28, height: 20 }}
>
{v.flag}
{v.nationalCode ?? '🚢'}
</div>
<div className="flex-1 min-w-0">
<div
className="text-label-1 font-[800] whitespace-nowrap overflow-hidden text-ellipsis"
style={{ color: '#e6edf3' }}
>
{v.name}
{v.shipNm ?? '(이름 없음)'}
</div>
<div className="text-caption font-mono" style={{ color: '#8b949e' }}>
MMSI: {v.mmsi}
@ -1348,7 +1371,7 @@ function VesselPopupPanel({
border: '1px solid color-mix(in srgb, var(--color-info) 25%, transparent)',
}}
>
{v.typS}
{getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-'}
</span>
<span
className="text-caption font-bold rounded"
@ -1359,14 +1382,14 @@ function VesselPopupPanel({
color: statusColor,
}}
>
{v.status}
{statusText}
</span>
</div>
{/* Data rows */}
<div style={{ padding: '4px 0' }}>
<PopupRow label="속도/항로" value={`${v.speed} kn / ${v.heading}°`} accent />
<PopupRow label="흘수" value={`${v.draft}m`} />
<PopupRow label="속도/항로" value={`${speed} / ${headingText}`} accent />
<PopupRow label="흘수" value={v.draught !== undefined ? `${v.draught.toFixed(2)} m` : '-'} />
<div
className="flex flex-col gap-1"
style={{
@ -1379,7 +1402,7 @@ function VesselPopupPanel({
</span>
<span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}>
{v.depart}
-
</span>
</div>
<div className="flex justify-between">
@ -1387,11 +1410,11 @@ function VesselPopupPanel({
</span>
<span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}>
{v.arrive}
{v.destination ?? '-'}
</span>
</div>
</div>
<PopupRow label="데이터 수신" value="2026-02-25 14:32:00" muted />
<PopupRow label="데이터 수신" value={receivedAt} muted />
</div>
{/* Buttons */}
@ -1636,7 +1659,13 @@ const TAB_LABELS: { key: DetTab; label: string }[] = [
{ key: 'dg', label: '위험물정보' },
];
function VesselDetailModal({ vessel: v, onClose }: { vessel: Vessel; onClose: () => void }) {
function VesselDetailModal({
vessel: v,
onClose,
}: {
vessel: VesselPosition;
onClose: () => void;
}) {
const [tab, setTab] = useState<DetTab>('info');
return (
@ -1665,11 +1694,13 @@ function VesselDetailModal({ vessel: v, onClose }: { vessel: Vessel; onClose: ()
style={{ padding: '14px 18px' }}
>
<div className="flex items-center gap-[10px]">
<span className="text-lg">{v.flag}</span>
<span className="text-lg">{v.nationalCode ?? '🚢'}</span>
<div>
<div className="text-title-3 font-[800] text-fg">{v.name}</div>
<div className="text-title-3 font-[800] text-fg">
{v.shipNm ?? '(이름 없음)'}
</div>
<div className="text-caption text-fg-disabled font-mono">
MMSI: {v.mmsi} · IMO: {v.imo}
MMSI: {v.mmsi} · IMO: {displayVal(v.imo)}
</div>
</div>
</div>
@ -1817,7 +1848,10 @@ function StatusBadge({ label, color }: { label: string; color: string }) {
}
/* ── Tab 0: 상세정보 ─────────────────────────────── */
function TabInfo({ v }: { v: Vessel }) {
function TabInfo({ v }: { v: VesselPosition }) {
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-';
const heading = v.heading ?? v.cog;
const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-';
return (
<>
<div
@ -1829,25 +1863,25 @@ function TabInfo({ v }: { v: Vessel }) {
<Sec title="📡 실시간 현황">
<Grid>
<Cell label="선박상태" value={v.status} />
<Cell label="선박상태" value={displayVal(v.status)} />
<Cell
label="속도 / 항로"
value={`${v.speed} kn / ${v.heading}°`}
value={`${speed} / ${headingText}`}
color="var(--color-accent)"
/>
<Cell label="위도" value={`${v.lat.toFixed(4)}°N`} />
<Cell label="경도" value={`${v.lng.toFixed(4)}°E`} />
<Cell label="흘수" value={`${v.draft}m`} />
<Cell label="수신시간" value="2026-02-25 14:30" />
<Cell label="경도" value={`${v.lon.toFixed(4)}°E`} />
<Cell label="흘수" value={v.draught !== undefined ? `${v.draught} m` : '-'} />
<Cell label="수신시간" value={v.lastUpdate ? formatDateTime(v.lastUpdate) : '-'} />
</Grid>
</Sec>
<Sec title="🚢 항해 일정">
<Grid>
<Cell label="출항지" value={v.depart} />
<Cell label="입항지" value={v.arrive} />
<Cell label="출항일시" value={v.etd || '—'} />
<Cell label="입항일시(ETA)" value={v.eta || '—'} />
<Cell label="출항지" value="-" />
<Cell label="입항지" value={displayVal(v.destination)} />
<Cell label="출항일시" value="-" />
<Cell label="입항일시(ETA)" value="-" />
</Grid>
</Sec>
</>
@ -1856,7 +1890,7 @@ function TabInfo({ v }: { v: Vessel }) {
/* ── Tab 1: 항해정보 ─────────────────────────────── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function TabNav(_props: { v: Vessel }) {
function TabNav(_props: { v: VesselPosition }) {
const hours = ['08', '09', '10', '11', '12', '13', '14'];
const heights = [45, 60, 78, 82, 70, 85, 75];
const colors = [
@ -1979,28 +2013,30 @@ function TabNav(_props: { v: Vessel }) {
}
/* ── Tab 2: 선박제원 ─────────────────────────────── */
function TabSpec({ v }: { v: Vessel }) {
function TabSpec({ v }: { v: VesselPosition }) {
const loa = v.length !== undefined ? `${v.length} m` : '-';
const beam = v.width !== undefined ? `${v.width} m` : '-';
return (
<>
<Sec title="📐 선체 제원">
<Grid>
<Cell label="선종" value={v.typS} />
<Cell label="선적국" value={`${v.flag}`} />
<Cell label="총톤수 (GT)" value={v.gt} />
<Cell label="재화중량 (DWT)" value={v.dwt} />
<Cell label="전장 (LOA)" value={v.loa} />
<Cell label="선폭" value={v.beam} />
<Cell label="건조년도" value={v.built} />
<Cell label="건조 조선소" value={v.yard} />
<Cell label="선종" value={displayVal(getShipKindLabel(v.shipKindCode) ?? v.shipTy)} />
<Cell label="선적국" value={displayVal(v.nationalCode)} />
<Cell label="총톤수 (GT)" value="-" />
<Cell label="재화중량 (DWT)" value="-" />
<Cell label="전장 (LOA)" value={loa} />
<Cell label="선폭" value={beam} />
<Cell label="건조년도" value="-" />
<Cell label="건조 조선소" value="-" />
</Grid>
</Sec>
<Sec title="📡 통신 / 식별">
<Grid>
<Cell label="MMSI" value={String(v.mmsi)} />
<Cell label="IMO" value={v.imo} />
<Cell label="호출부호" value={v.callSign} />
<Cell label="선급" value={v.cls} />
<Cell label="IMO" value={displayVal(v.imo)} />
<Cell label="호출부호" value="-" />
<Cell label="선급" value="-" />
</Grid>
</Sec>
@ -2016,23 +2052,9 @@ function TabSpec({ v }: { v: Vessel }) {
>
<span className="text-label-1">🛢</span>
<div className="flex-1">
<div className="text-caption font-semibold text-fg">
{v.cargo.split('·')[0].trim()}
<div className="text-caption font-semibold text-fg">-</div>
<div className="text-caption text-fg-disabled"> </div>
</div>
<div className="text-caption text-fg-disabled">{v.cargo}</div>
</div>
{v.cargo.includes('IMO') && (
<span
className="text-caption font-bold text-color-danger"
style={{
padding: '2px 6px',
background: 'color-mix(in srgb, var(--color-danger) 15%, transparent)',
borderRadius: 3,
}}
>
</span>
)}
</div>
</div>
</Sec>
@ -2042,7 +2064,7 @@ function TabSpec({ v }: { v: Vessel }) {
/* ── Tab 3: 보험정보 ─────────────────────────────── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function TabInsurance(_props: { v: Vessel }) {
function TabInsurance(_props: { v: VesselPosition }) {
return (
<>
<Sec title="🏢 선주 / 운항사">
@ -2114,7 +2136,8 @@ function TabInsurance(_props: { v: Vessel }) {
}
/* ── Tab 4: 위험물정보 ───────────────────────────── */
function TabDangerous({ v }: { v: Vessel }) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function TabDangerous(_props: { v: VesselPosition }) {
return (
<>
<Sec
@ -2134,11 +2157,7 @@ function TabDangerous({ v }: { v: Vessel }) {
}
>
<Grid>
<Cell
label="화물명"
value={v.cargo.split('·')[0].trim() || '—'}
color="var(--color-warning)"
/>
<Cell label="화물명" value="-" color="var(--color-warning)" />
<Cell label="컨테이너갯수/총량" value="— / 72,850 톤" />
<Cell label="하역업체코드" value="KRY-2847" />
<Cell label="하역기간" value="02-26 ~ 02-28" />
@ -2308,18 +2327,22 @@ function ActionBtn({
/*
*/
function VesselTooltipContent({ vessel: v }: { vessel: Vessel }) {
function VesselTooltipContent({ vessel: v }: { vessel: VesselPosition }) {
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '- kn';
const heading = v.heading ?? v.cog;
const headingText = heading !== undefined ? `HDG ${Math.round(heading)}°` : 'HDG -';
const typeText = [getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-', v.nationalCode].filter(Boolean).join(' · ');
return (
<>
<div className="text-label-2 font-bold text-fg" style={{ marginBottom: 3 }}>
{v.name}
{v.shipNm ?? '(이름 없음)'}
</div>
<div className="text-caption text-fg-disabled" style={{ marginBottom: 4 }}>
{v.typS} · {v.flag}
{typeText}
</div>
<div className="flex justify-between text-caption">
<span className="text-color-accent font-semibold">{v.speed} kn</span>
<span className="text-fg-disabled">HDG {v.heading}°</span>
<span className="text-color-accent font-semibold">{speed}</span>
<span className="text-fg-disabled">{headingText}</span>
</div>
</>
);

파일 보기

@ -1,4 +1,6 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useVesselSignals } from '@common/hooks/useVesselSignals';
import type { MapBounds } from '@common/types/vessel';
import { LeftPanel } from './LeftPanel';
import { RightPanel } from './RightPanel';
import { MapView } from '@common/components/map/MapView';
@ -173,6 +175,8 @@ export function OilSpillView() {
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
const flyToTarget = null;
const fitBoundsTarget = null;
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const vessels = useVesselSignals(mapBounds);
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
const [oilTrajectory, setOilTrajectory] = useState<OilParticle[]>([]);
const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([]);
@ -1328,6 +1332,8 @@ export function OilSpillView() {
showBeached={displayControls.showBeached}
showTimeLabel={displayControls.showTimeLabel}
simulationStartTime={accidentTime || undefined}
vessels={vessels}
onBoundsChange={setMapBounds}
/>
{/* 타임라인 플레이어 (리플레이 비활성 시) */}

파일 보기

@ -1,4 +1,6 @@
import { Fragment, useState, useEffect, useCallback } from 'react';
import { useVesselSignals } from '@common/hooks/useVesselSignals';
import type { MapBounds } from '@common/types/vessel';
import { useSubMenu } from '@common/hooks/useSubMenu';
import { MapView } from '@common/components/map/MapView';
import { RescueTheoryView } from './RescueTheoryView';
@ -1535,6 +1537,8 @@ export function RescueView() {
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const vessels = useVesselSignals(mapBounds);
useEffect(() => {
fetchGscAccidents()
@ -1600,6 +1604,8 @@ export function RescueView() {
oilTrajectory={[]}
enabledLayers={new Set()}
showOverlays={false}
vessels={vessels}
onBoundsChange={setMapBounds}
/>
</div>
<RightPanel