diff --git a/.github/java-upgrade/hooks/scripts/recordToolUse.ps1 b/.github/java-upgrade/hooks/scripts/recordToolUse.ps1 new file mode 100644 index 0000000..2d24329 --- /dev/null +++ b/.github/java-upgrade/hooks/scripts/recordToolUse.ps1 @@ -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)) diff --git a/.github/java-upgrade/hooks/scripts/recordToolUse.sh b/.github/java-upgrade/hooks/scripts/recordToolUse.sh new file mode 100644 index 0000000..36b2043 --- /dev/null +++ b/.github/java-upgrade/hooks/scripts/recordToolUse.sh @@ -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" diff --git a/backend/src/aerial/aerialService.ts b/backend/src/aerial/aerialService.ts index c3ec980..f8c907c 100644 --- a/backend/src/aerial/aerialService.ts +++ b/backend/src/aerial/aerialService.ts @@ -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; diff --git a/backend/src/server.ts b/backend/src/server.ts index 09999dc..22378ae 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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) { diff --git a/backend/src/vessels/vesselRouter.ts b/backend/src/vessels/vesselRouter.ts new file mode 100644 index 0000000..601c970 --- /dev/null +++ b/backend/src/vessels/vesselRouter.ts @@ -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; diff --git a/backend/src/vessels/vesselScheduler.ts b/backend/src/vessels/vesselScheduler.ts new file mode 100644 index 0000000..fbcd2c1 --- /dev/null +++ b/backend/src/vessels/vesselScheduler.ts @@ -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 | null = null; + +async function pollVesselSignals(): Promise { + 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 = { + '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] 선박 신호 스케줄러 중지'); + } +} diff --git a/backend/src/vessels/vesselService.ts b/backend/src/vessels/vesselService.ts new file mode 100644 index 0000000..5b2640c --- /dev/null +++ b/backend/src/vessels/vesselService.ts @@ -0,0 +1,55 @@ +import type { VesselPosition, BoundingBox } from './vesselTypes.js'; + +const VESSEL_TTL_MS = 10 * 60 * 1000; // 10분 + +const cachedVessels = new Map(); +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 }; +} diff --git a/backend/src/vessels/vesselTypes.ts b/backend/src/vessels/vesselTypes.ts new file mode 100644 index 0000000..8b5ed00 --- /dev/null +++ b/backend/src/vessels/vesselTypes.ts @@ -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; +} diff --git a/frontend/src/common/components/map/MapBoundsTracker.tsx b/frontend/src/common/components/map/MapBoundsTracker.tsx new file mode 100644 index 0000000..722bd4d --- /dev/null +++ b/frontend/src/common/components/map/MapBoundsTracker.tsx @@ -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; +} diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 98fa5d1..7475d65 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -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 | null>(null); + // 선박 호버/클릭 상호작용 상태 + const [vesselHover, setVesselHover] = useState(null); + const [selectedVessel, setSelectedVessel] = useState(null); + const [detailVessel, setDetailVessel] = useState(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({ {/* 예측 완료 시 궤적 전체 범위로 fitBounds */} + {/* 선박 신호 뷰포트 bounds 추적 */} + {/* S-57 전자해도 오버레이 (공식 style.json 기반) */} @@ -1428,6 +1470,26 @@ export function MapView({ ships={backtrackReplay.ships} /> )} + + {/* 선박 호버 툴팁 */} + {vesselHover && !selectedVessel && } + + {/* 선박 클릭 팝업 */} + {selectedVessel && !detailVessel && ( + setSelectedVessel(null)} + onDetail={() => { + setDetailVessel(selectedVessel); + setSelectedVessel(null); + }} + /> + )} + + {/* 선박 상세 모달 */} + {detailVessel && ( + setDetailVessel(null)} /> + )} ); } diff --git a/frontend/src/common/components/map/VesselInteraction.tsx b/frontend/src/common/components/map/VesselInteraction.tsx new file mode 100644 index 0000000..c350fbf --- /dev/null +++ b/frontend/src/common/components/map/VesselInteraction.tsx @@ -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 ( +
+
+ {v.shipNm ?? '(이름 없음)'} +
+
+ {typeText} +
+
+ {speed} + {headingText} +
+
+ ); +} + +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 ( +
+
+
+ {v.nationalCode ?? '🚢'} +
+
+
+ {v.shipNm ?? '(이름 없음)'} +
+
+ MMSI: {v.mmsi} +
+
+ + ✕ + +
+ +
+ 🚢 +
+ +
+ + {getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-'} + + + {statusText} + +
+ +
+ + +
+
+ + 출항지 + + + - + +
+
+ + 입항지 + + + {v.destination ?? '-'} + +
+
+ +
+ +
+ + + +
+
+ ); +} + +function PopupRow({ + label, + value, + accent, + muted, +}: { + label: string; + value: string; + accent?: boolean; + muted?: boolean; +}) { + return ( +
+ {label} + + {value} + +
+ ); +} + +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('info'); + + return ( +
{ + 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)', + }} + > +
+
+
+ {v.nationalCode ?? '🚢'} +
+
{v.shipNm ?? '(이름 없음)'}
+
+ MMSI: {v.mmsi} · IMO: {displayVal(v.imo)} +
+
+
+ + ✕ + +
+ +
+ {TAB_LABELS.map((t) => ( + + ))} +
+ +
+ {tab === 'info' && } + {tab === 'nav' && } + {tab === 'spec' && } + {tab === 'ins' && } + {tab === 'dg' && } +
+
+
+ ); +} + +function Sec({ + title, + borderColor, + bgColor, + badge, + children, +}: { + title: string; + borderColor?: string; + bgColor?: string; + badge?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+ {title} + {badge} +
+ {children} +
+ ); +} + +function Grid({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function Cell({ + label, + value, + span, + color, +}: { + label: string; + value: string; + span?: boolean; + color?: string; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +function StatusBadge({ label, color }: { label: string; color: string }) { + return ( + + {label} + + ); +} + +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 ( + <> +
+ 🚢 +
+ + + + + + + + + + + + + + + + + + + + + + ); +} + +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 ( + <> + +
+ + + + + + + +
+
+ + +
+
+ {hours.map((h, i) => ( +
+
+ {h} +
+ ))} +
+
+ 평균: 8.4 kn · 최대:{' '} + 11.2 kn +
+
+ + + ); +} + +function TabSpec({ v }: { v: VesselPosition }) { + const loa = v.length !== undefined ? `${v.length} m` : '-'; + const beam = v.width !== undefined ? `${v.width} m` : '-'; + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function TabInsurance() { + return ( + <> + + + + + + + + + } + > + + + + + + + + + ); +} + +function TabDangerous() { + return ( + + + + + + + + + ); +} diff --git a/frontend/src/common/components/map/VesselLayer.ts b/frontend/src/common/components/map/VesselLayer.ts new file mode 100644 index 0000000..8337729 --- /dev/null +++ b/frontend/src/common/components/map/VesselLayer.ts @@ -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 = VESSEL_LEGEND.reduce( + (acc, { code, color }) => { + acc[code] = color; + return acc; + }, + {} as Record, +); + +const SHIP_KIND_LABELS: Record = VESSEL_LEGEND.reduce( + (acc, { code, type }) => { + acc[code] = type; + return acc; + }, + {} as Record, +); + +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 = [ + '', + '', + '', + ``, + ``, + '', + ].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]; +} diff --git a/frontend/src/common/hooks/useVesselSignals.ts b/frontend/src/common/hooks/useVesselSignals.ts new file mode 100644 index 0000000..734c35f --- /dev/null +++ b/frontend/src/common/hooks/useVesselSignals.ts @@ -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([]); + const boundsRef = useRef(mapBounds); + const clientRef = useRef(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; +} diff --git a/frontend/src/common/mock/vesselMockData.ts b/frontend/src/common/mock/vesselMockData.ts index 48d5604..d49e83d 100755 --- a/frontend/src/common/mock/vesselMockData.ts +++ b/frontend/src/common/mock/vesselMockData.ts @@ -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 = { - 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 {}; diff --git a/frontend/src/common/services/vesselApi.ts b/frontend/src/common/services/vesselApi.ts new file mode 100644 index 0000000..de3a2f2 --- /dev/null +++ b/frontend/src/common/services/vesselApi.ts @@ -0,0 +1,35 @@ +import { api } from './api'; +import type { VesselPosition, MapBounds } from '@common/types/vessel'; + +export async function getVesselsInArea(bounds: MapBounds): Promise { + const res = await api.post('/vessels/in-area', { bounds }); + return res.data; +} + +/** + * 로그인/새로고침 직후 1회 호출하는 초기 스냅샷 API. + * 운영 환경의 별도 REST 서버가 현재 시각 기준 최근 10분치 선박 신호를 반환한다. + * URL은 VITE_VESSEL_INIT_API_URL 로 주입(운영에서 실제 URL로 교체). + */ +export async function getInitialVesselSnapshot(): Promise { + 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 { + const res = await api.get('/vessels/status'); + return res.data; +} diff --git a/frontend/src/common/services/vesselSignalClient.ts b/frontend/src/common/services/vesselSignalClient.ts new file mode 100644 index 0000000..201b4bc --- /dev/null +++ b/frontend/src/common/services/vesselSignalClient.ts @@ -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 | null = null; + private onVessels: ((vessels: VesselPosition[]) => void) | null = null; + private getViewportBounds: (() => MapBounds | null) | null = null; + + private async poll(): Promise { + 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(); +} diff --git a/frontend/src/common/types/vessel.ts b/frontend/src/common/types/vessel.ts new file mode 100644 index 0000000..4291ca1 --- /dev/null +++ b/frontend/src/common/types/vessel.ts @@ -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; +} diff --git a/frontend/src/tabs/hns/components/HNSView.tsx b/frontend/src/tabs/hns/components/HNSView.tsx index 4fc86e4..625abf8 100755 --- a/frontend/src/tabs/hns/components/HNSView.tsx +++ b/frontend/src/tabs/hns/components/HNSView.tsx @@ -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(null); + const vessels = useVesselSignals(mapBounds); const [isRunningPrediction, setIsRunningPrediction] = useState(false); // eslint-disable-next-line @typescript-eslint/no-explicit-any const [dispersionResult, setDispersionResult] = useState(null); @@ -974,6 +978,8 @@ export function HNSView() { dispersionResult={dispersionResult} dispersionHeatmap={heatmapData} mapCaptureRef={mapCaptureRef} + vessels={vessels} + onBoundsChange={setMapBounds} /> {/* 시간 슬라이더 (puff/dense_gas 모델용) */} {allTimeFrames.length > 1 && ( diff --git a/frontend/src/tabs/incidents/components/IncidentsView.tsx b/frontend/src/tabs/incidents/components/IncidentsView.tsx index 91b89b0..52dc6e5 100755 --- a/frontend/src/tabs/incidents/components/IncidentsView.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsView.tsx @@ -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([]); const [filteredIncidents, setFilteredIncidents] = useState([]); const [selectedIncidentId, setSelectedIncidentId] = useState(null); - const [selectedVessel, setSelectedVessel] = useState(null); - const [detailVessel, setDetailVessel] = useState(null); + const [selectedVessel, setSelectedVessel] = useState(null); + const [detailVessel, setDetailVessel] = useState(null); const [vesselPopup, setVesselPopup] = useState(null); const [incidentPopup, setIncidentPopup] = useState(null); const [hoverInfo, setHoverInfo] = useState(null); + const [mapBounds, setMapBounds] = useState(null); + const [mapZoom, setMapZoom] = useState(10); + const realVessels = useVesselSignals(mapBounds); + + const [vesselStatus, setVesselStatus] = useState(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 = [ - '', - '', - '', - ``, - ``, - '', - ].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() { }} > + {/* 사고 팝업 */} @@ -729,7 +726,7 @@ export function IncidentsView() { }} > {hoverInfo.type === 'vessel' ? ( - + ) : ( )} @@ -859,12 +856,11 @@ export function IncidentsView() { }} /> */} AIS Live - MarineTraffic
-
선박 20
-
사고 6
-
방제선 2
+
선박 {vesselStatus?.count ?? 0}
+
사고 {filteredIncidents.length}
+
방제선 {vesselStatus?.bangjeCount ?? 0}
@@ -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 + } /> @@ -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 (
- {v.flag} + {v.nationalCode ?? '🚢'}
- {v.name} + {v.shipNm ?? '(이름 없음)'}
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 ?? '-'} - {v.status} + {statusText}
{/* Data rows */}
- - + +
- {v.depart} + -
@@ -1387,11 +1410,11 @@ function VesselPopupPanel({ 입항지 - {v.arrive} + {v.destination ?? '-'}
- +
{/* 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('info'); return ( @@ -1665,11 +1694,13 @@ function VesselDetailModal({ vessel: v, onClose }: { vessel: Vessel; onClose: () style={{ padding: '14px 18px' }} >
- {v.flag} + {v.nationalCode ?? '🚢'}
-
{v.name}
+
+ {v.shipNm ?? '(이름 없음)'} +
- MMSI: {v.mmsi} · IMO: {v.imo} + MMSI: {v.mmsi} · IMO: {displayVal(v.imo)}
@@ -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 ( <>
- + - - - + + + - - - - + + + + @@ -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 ( <> - - - - - - - - + + + + + + + + - - - + + + @@ -2016,23 +2052,9 @@ function TabSpec({ v }: { v: Vessel }) { > 🛢
-
- {v.cargo.split('·')[0].trim()} -
-
{v.cargo}
+
-
+
정보 없음
- {v.cargo.includes('IMO') && ( - - 위험 - - )}
@@ -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 ( <> @@ -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 ( <> - + @@ -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 ( <>
- {v.name} + {v.shipNm ?? '(이름 없음)'}
- {v.typS} · {v.flag} + {typeText}
- {v.speed} kn - HDG {v.heading}° + {speed} + {headingText}
); diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index b3f1c14..8b69fc6 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -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(null); + const vessels = useVesselSignals(mapBounds); const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [oilTrajectory, setOilTrajectory] = useState([]); const [centerPoints, setCenterPoints] = useState([]); @@ -1328,6 +1332,8 @@ export function OilSpillView() { showBeached={displayControls.showBeached} showTimeLabel={displayControls.showTimeLabel} simulationStartTime={accidentTime || undefined} + vessels={vessels} + onBoundsChange={setMapBounds} /> {/* 타임라인 플레이어 (리플레이 비활성 시) */} diff --git a/frontend/src/tabs/rescue/components/RescueView.tsx b/frontend/src/tabs/rescue/components/RescueView.tsx index 1244f38..fde68d9 100755 --- a/frontend/src/tabs/rescue/components/RescueView.tsx +++ b/frontend/src/tabs/rescue/components/RescueView.tsx @@ -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(null); + const vessels = useVesselSignals(mapBounds); useEffect(() => { fetchGscAccidents() @@ -1600,6 +1604,8 @@ export function RescueView() { oilTrajectory={[]} enabledLayers={new Set()} showOverlays={false} + vessels={vessels} + onBoundsChange={setMapBounds} />