diff --git a/.claude/settings.json b/.claude/settings.json index 441dc35..c8c5d77 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,7 +5,30 @@ }, "permissions": { "allow": [ - "Bash(*)" + "Bash(*)", + "Bash(npm run *)", + "Bash(npm install *)", + "Bash(npm test *)", + "Bash(npx *)", + "Bash(node *)", + "Bash(git status)", + "Bash(git diff *)", + "Bash(git log *)", + "Bash(git branch *)", + "Bash(git checkout *)", + "Bash(git add *)", + "Bash(git commit *)", + "Bash(git pull *)", + "Bash(git fetch *)", + "Bash(git merge *)", + "Bash(git stash *)", + "Bash(git remote *)", + "Bash(git config *)", + "Bash(git rev-parse *)", + "Bash(git show *)", + "Bash(git tag *)", + "Bash(curl -s *)", + "Bash(fnm *)" ], "deny": [ "Bash(git push --force*)", diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index 03381a9..ffa771a 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,6 +1,6 @@ { "applied_global_version": "1.6.1", - "applied_date": "2026-03-31", + "applied_date": "2026-04-14", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true 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/gsc/gscAccidentsRouter.ts b/backend/src/gsc/gscAccidentsRouter.ts new file mode 100644 index 0000000..4ba3cbb --- /dev/null +++ b/backend/src/gsc/gscAccidentsRouter.ts @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import { requireAuth } from '../auth/authMiddleware.js'; +import { listGscAccidents } from './gscAccidentsService.js'; + +const router = Router(); + +// ============================================================ +// GET /api/gsc/accidents — 외부 수집 사고 목록 (최신 20건) +// ============================================================ +router.get('/', requireAuth, async (_req, res) => { + try { + const accidents = await listGscAccidents(20); + res.json(accidents); + } catch (err) { + console.error('[gsc] 사고 목록 조회 오류:', err); + res.status(500).json({ error: '사고 목록 조회 중 오류가 발생했습니다.' }); + } +}); + +export default router; diff --git a/backend/src/gsc/gscAccidentsService.ts b/backend/src/gsc/gscAccidentsService.ts new file mode 100644 index 0000000..69fbff0 --- /dev/null +++ b/backend/src/gsc/gscAccidentsService.ts @@ -0,0 +1,65 @@ +import { wingPool } from '../db/wingDb.js'; + +export interface GscAccidentListItem { + acdntMngNo: string; + pollNm: string; + pollDate: string | null; + lat: number | null; + lon: number | null; +} + +const ACDNT_ASORT_CODES = [ + '055001001', + '055001002', + '055001003', + '055001004', + '055001005', + '055001006', + '055003001', + '055003002', + '055003003', + '055003004', + '055003005', + '055004003', +]; + +export async function listGscAccidents(limit = 20): Promise { + const sql = ` + SELECT DISTINCT ON (a.acdnt_mng_no) + a.acdnt_mng_no AS "acdntMngNo", + a.acdnt_title AS "pollNm", + to_char(a.rcept_dt, 'YYYY-MM-DD"T"HH24:MI') AS "pollDate", + a.rcept_dt AS "rceptDt", + b.la AS "lat", + b.lo AS "lon" + FROM gsc.tgs_acdnt_info AS a + LEFT JOIN gsc.tgs_acdnt_lc AS b + ON a.acdnt_mng_no = b.acdnt_mng_no + WHERE a.acdnt_asort_code = ANY($1::varchar[]) + AND a.acdnt_title IS NOT NULL + ORDER BY a.acdnt_mng_no, b.acdnt_lc_sn ASC + `; + + const orderedSql = ` + SELECT "acdntMngNo", "pollNm", "pollDate", "lat", "lon" + FROM (${sql}) t + ORDER BY t."rceptDt" DESC NULLS LAST + LIMIT $2 + `; + + const result = await wingPool.query<{ + acdntMngNo: string; + pollNm: string; + pollDate: string | null; + lat: string | null; + lon: string | null; + }>(orderedSql, [ACDNT_ASORT_CODES, limit]); + + return result.rows.map((row) => ({ + acdntMngNo: row.acdntMngNo, + pollNm: row.pollNm, + pollDate: row.pollDate, + lat: row.lat != null ? Number(row.lat) : null, + lon: row.lon != null ? Number(row.lon) : null, + })); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index a577c9d..22378ae 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -19,12 +19,15 @@ import hnsRouter from './hns/hnsRouter.js' import reportsRouter from './reports/reportsRouter.js' import assetsRouter from './assets/assetsRouter.js' import incidentsRouter from './incidents/incidentsRouter.js' +import gscAccidentsRouter from './gsc/gscAccidentsRouter.js' import scatRouter from './scat/scatRouter.js' import predictionRouter from './prediction/predictionRouter.js' 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, @@ -168,6 +171,7 @@ app.use('/api/hns', hnsRouter) app.use('/api/reports', reportsRouter) app.use('/api/assets', assetsRouter) app.use('/api/incidents', incidentsRouter) +app.use('/api/gsc/accidents', gscAccidentsRouter) app.use('/api/scat', scatRouter) app.use('/api/prediction', predictionRouter) app.use('/api/aerial', aerialRouter) @@ -175,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) => { @@ -210,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/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 8dd07de..d22c636 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,8 +4,15 @@ ## [Unreleased] +## [2026-04-15] + +### 추가 +- 확산예측·HNS 대기확산·긴급구난: GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (사고명·발생시각·위경도 자동 입력 + 지도 이동) +- 실시간 선박 신호 지도 표출: 한국 해역 1분 주기 폴링 스케줄러, 호버 툴팁·클릭 팝업·상세 모달 제공 (확산예측·HNS·긴급구난·사건사고 탭 연동) + ### 변경 - MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용 +- aerial 이미지 분석 API 기본 URL 변경 ## [2026-04-14] 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 81b6983..6ca025f 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -32,6 +32,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'; @@ -166,10 +175,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, @@ -348,6 +363,8 @@ export function MapView({ analysisCircleCenter, analysisCircleRadiusM = 0, showOverlays = true, + vessels = [], + onBoundsChange, }: MapViewProps) { const lightMode = true; const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore(); @@ -366,6 +383,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) => { @@ -1217,6 +1238,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, @@ -1242,6 +1280,8 @@ export function MapView({ analysisCircleCenter, analysisCircleRadiusM, lightMode, + vessels, + mapZoom, ]); // 3D 모드 / 테마에 따른 지도 스타일 전환 @@ -1279,6 +1319,8 @@ export function MapView({ {/* 예측 완료 시 궤적 전체 범위로 fitBounds */} + {/* 선박 신호 뷰포트 bounds 추적 */} + {/* S-57 전자해도 오버레이 (공식 style.json 기반) */} @@ -1430,6 +1472,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/HNSLeftPanel.tsx b/frontend/src/tabs/hns/components/HNSLeftPanel.tsx index ed939ac..7822dd8 100755 --- a/frontend/src/tabs/hns/components/HNSLeftPanel.tsx +++ b/frontend/src/tabs/hns/components/HNSLeftPanel.tsx @@ -3,8 +3,8 @@ import { ComboBox } from '@common/components/ui/ComboBox'; import { useWeatherFetch } from '../hooks/useWeatherFetch'; import { getSubstanceToxicity } from '../utils/toxicityData'; import type { WeatherFetchResult, ReleaseType } from '../utils/dispersionTypes'; -import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi'; -import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi'; +import { fetchGscAccidents } from '@tabs/prediction/services/predictionApi'; +import type { GscAccidentListItem } from '@tabs/prediction/services/predictionApi'; /** HNS 분석 입력 파라미터 (부모에 전달) */ export interface HNSInputParams { @@ -44,6 +44,7 @@ interface HNSLeftPanelProps { onParamsChange?: (params: HNSInputParams) => void; onReset?: () => void; loadedParams?: Partial | null; + onFlyToCoord?: (coord: { lon: number; lat: number }) => void; } /** 십진 좌표 → 도분초 변환 */ @@ -67,8 +68,9 @@ export function HNSLeftPanel({ onParamsChange, onReset, loadedParams, + onFlyToCoord, }: HNSLeftPanelProps) { - const [incidents, setIncidents] = useState([]); + const [incidents, setIncidents] = useState([]); const [selectedIncidentSn, setSelectedIncidentSn] = useState(''); const [expandedSections, setExpandedSections] = useState({ accident: true, params: true }); const toggleSection = (key: 'accident' | 'params') => @@ -138,21 +140,26 @@ export function HNSLeftPanel({ // 사고 목록 불러오기 (마운트 시 1회, ref 초기화 패턴) const incidentsPromiseRef = useRef | null>(null); if (incidentsPromiseRef.current == null) { - incidentsPromiseRef.current = fetchIncidentsRaw() + incidentsPromiseRef.current = fetchGscAccidents() .then((data) => setIncidents(data)) .catch(() => setIncidents([])); } // 사고 선택 시 필드 자동 채움 - const handleSelectIncident = (snStr: string) => { - setSelectedIncidentSn(snStr); - const sn = parseInt(snStr); - const incident = incidents.find((i) => i.acdntSn === sn); + const handleSelectIncident = (mngNo: string) => { + setSelectedIncidentSn(mngNo); + const incident = incidents.find((i) => i.acdntMngNo === mngNo); if (!incident) return; - setAccidentName(incident.acdntNm); - if (incident.lat && incident.lng) { - onCoordChange({ lat: incident.lat, lon: incident.lng }); + setAccidentName(incident.pollNm); + if (incident.pollDate) { + const [d, t] = incident.pollDate.split('T'); + if (d) setAccidentDate(d); + if (t) setAccidentTime(t); + } + if (incident.lat != null && incident.lon != null) { + onCoordChange({ lat: incident.lat, lon: incident.lon }); + onFlyToCoord?.({ lat: incident.lat, lon: incident.lon }); } }; @@ -266,8 +273,8 @@ export function HNSLeftPanel({ onChange={handleSelectIncident} placeholder="또는 사고 리스트에서 선택" options={incidents.map((inc) => ({ - value: String(inc.acdntSn), - label: `${inc.acdntNm} (${new Date(inc.occrnDtm).toLocaleDateString('ko-KR')})`, + value: inc.acdntMngNo, + label: `${inc.pollNm} (${inc.pollDate ? inc.pollDate.replace('T', ' ') : '-'})`, }))} /> diff --git a/frontend/src/tabs/hns/components/HNSView.tsx b/frontend/src/tabs/hns/components/HNSView.tsx index 171be6b..662e9e5 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'; @@ -202,7 +204,10 @@ export function HNSView() { const [leftCollapsed, setLeftCollapsed] = useState(false); const [rightCollapsed, setRightCollapsed] = useState(false); 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); @@ -865,6 +870,7 @@ export function HNSView() { onParamsChange={handleParamsChange} onReset={handleReset} loadedParams={loadedParams} + onFlyToCoord={(c) => setFlyToCoord({ lat: c.lat, lon: c.lon })} />
)} @@ -924,6 +930,8 @@ export function HNSView() { <> setFlyToCoord(undefined)} isSelectingLocation={isSelectingLocation} onMapClick={handleMapClick} oilTrajectory={[]} @@ -931,6 +939,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 e48b457..bc7830b 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/LeftPanel.tsx b/frontend/src/tabs/prediction/components/LeftPanel.tsx index 7ffe552..ba5c114 100755 --- a/frontend/src/tabs/prediction/components/LeftPanel.tsx +++ b/frontend/src/tabs/prediction/components/LeftPanel.tsx @@ -114,6 +114,7 @@ export function LeftPanel({ onLayerColorChange, sensitiveResources = [], onImageAnalysisResult, + onFlyToCoord, validationErrors, }: LeftPanelProps) { const [expandedSections, setExpandedSections] = useState({ @@ -168,6 +169,7 @@ export function LeftPanel({ spillUnit={spillUnit} onSpillUnitChange={onSpillUnitChange} onImageAnalysisResult={onImageAnalysisResult} + onFlyToCoord={onFlyToCoord} validationErrors={validationErrors} /> diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index d6f23b4..e432d25 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([]); @@ -1211,6 +1215,9 @@ export function OilSpillView() { onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))} sensitiveResources={sensitiveResourceCategories} onImageAnalysisResult={handleImageAnalysisResult} + onFlyToCoord={(c: { lon: number; lat: number }) => + setFlyToCoord({ lat: c.lat, lon: c.lon }) + } validationErrors={validationErrors} /> @@ -1325,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/prediction/components/PredictionInputSection.tsx b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx index 9f636c9..079a892 100644 --- a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx +++ b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx @@ -1,8 +1,8 @@ import { useState, useRef, useEffect } from 'react'; import { ComboBox } from '@common/components/ui/ComboBox'; import type { PredictionModel } from './OilSpillView'; -import { analyzeImage } from '../services/predictionApi'; -import type { ImageAnalyzeResult } from '../services/predictionApi'; +import { analyzeImage, fetchGscAccidents } from '../services/predictionApi'; +import type { ImageAnalyzeResult, GscAccidentListItem } from '../services/predictionApi'; interface PredictionInputSectionProps { expanded: boolean; @@ -33,6 +33,7 @@ interface PredictionInputSectionProps { spillUnit: string; onSpillUnitChange: (unit: string) => void; onImageAnalysisResult?: (result: ImageAnalyzeResult) => void; + onFlyToCoord?: (coord: { lon: number; lat: number }) => void; validationErrors?: Set; } @@ -64,6 +65,7 @@ const PredictionInputSection = ({ spillUnit, onSpillUnitChange, onImageAnalysisResult, + onFlyToCoord, validationErrors, }: PredictionInputSectionProps) => { const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct'); @@ -71,8 +73,41 @@ const PredictionInputSection = ({ const [isAnalyzing, setIsAnalyzing] = useState(false); const [analyzeError, setAnalyzeError] = useState(null); const [analyzeResult, setAnalyzeResult] = useState(null); + const [gscAccidents, setGscAccidents] = useState([]); + const [selectedGscMngNo, setSelectedGscMngNo] = useState(''); const fileInputRef = useRef(null); + useEffect(() => { + let cancelled = false; + fetchGscAccidents() + .then((list) => { + if (!cancelled) setGscAccidents(list); + }) + .catch((err) => { + console.error('[prediction] GSC 사고 목록 조회 실패:', err); + }); + return () => { + cancelled = true; + }; + }, []); + + const handleGscAccidentSelect = (mngNo: string) => { + setSelectedGscMngNo(mngNo); + const item = gscAccidents.find((a) => a.acdntMngNo === mngNo); + if (!item) return; + onIncidentNameChange(item.pollNm); + if (item.pollDate) onAccidentTimeChange(item.pollDate); + if (item.lat != null && item.lon != null) { + onCoordChange({ lat: item.lat, lon: item.lon }); + onFlyToCoord?.({ lat: item.lat, lon: item.lon }); + } + }; + + const gscOptions = gscAccidents.map((a) => ({ + value: a.acdntMngNo, + label: `${a.pollNm} (${a.pollDate ? a.pollDate.replace('T', ' ') : '-'})`, + })); + const handleFileSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0] ?? null; setUploadedFile(file); @@ -161,7 +196,13 @@ const PredictionInputSection = ({ : undefined } /> - + {/* Image Upload Mode */} {inputMode === 'upload' && ( diff --git a/frontend/src/tabs/prediction/components/leftPanelTypes.ts b/frontend/src/tabs/prediction/components/leftPanelTypes.ts index ce0c561..bd3a3c8 100644 --- a/frontend/src/tabs/prediction/components/leftPanelTypes.ts +++ b/frontend/src/tabs/prediction/components/leftPanelTypes.ts @@ -62,6 +62,8 @@ export interface LeftPanelProps { sensitiveResources?: SensitiveResourceCategory[]; // 이미지 분석 결과 콜백 onImageAnalysisResult?: (result: ImageAnalyzeResult) => void; + // 사고 리스트 선택 시 지도 이동 콜백 + onFlyToCoord?: (coord: { lon: number; lat: number }) => void; // 유효성 검증 에러 필드 validationErrors?: Set; } diff --git a/frontend/src/tabs/prediction/services/predictionApi.ts b/frontend/src/tabs/prediction/services/predictionApi.ts index 64c446f..e2783eb 100644 --- a/frontend/src/tabs/prediction/services/predictionApi.ts +++ b/frontend/src/tabs/prediction/services/predictionApi.ts @@ -321,3 +321,20 @@ export const analyzeImage = async (file: File, acdntNm?: string): Promise => { + const response = await api.get('/gsc/accidents'); + return response.data; +}; diff --git a/frontend/src/tabs/rescue/components/RescueView.tsx b/frontend/src/tabs/rescue/components/RescueView.tsx index 114e10c..fde68d9 100755 --- a/frontend/src/tabs/rescue/components/RescueView.tsx +++ b/frontend/src/tabs/rescue/components/RescueView.tsx @@ -1,12 +1,14 @@ 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'; import { RescueScenarioView } from './RescueScenarioView'; import { fetchRescueOps } from '../services/rescueApi'; import type { RescueOpsItem } from '../services/rescueApi'; -import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi'; -import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi'; +import { fetchGscAccidents } from '@tabs/prediction/services/predictionApi'; +import type { GscAccidentListItem } from '@tabs/prediction/services/predictionApi'; /* ─── Types ─── */ type AccidentType = @@ -230,9 +232,9 @@ function LeftPanel({ }: { activeType: AccidentType; onTypeChange: (t: AccidentType) => void; - incidents: IncidentListItem[]; - selectedAcdnt: IncidentListItem | null; - onSelectAcdnt: (item: IncidentListItem | null) => void; + incidents: GscAccidentListItem[]; + selectedAcdnt: GscAccidentListItem | null; + onSelectAcdnt: (item: GscAccidentListItem | null) => void; }) { const [acdntName, setAcdntName] = useState(''); const [acdntDate, setAcdntDate] = useState(''); @@ -242,18 +244,25 @@ function LeftPanel({ const [showList, setShowList] = useState(false); // 사고 선택 시 필드 자동 채움 - const handlePickIncident = (item: IncidentListItem) => { + const handlePickIncident = (item: GscAccidentListItem) => { onSelectAcdnt(item); - setAcdntName(item.acdntNm); - const dt = new Date(item.occrnDtm); - setAcdntDate( - `${dt.getFullYear()}. ${String(dt.getMonth() + 1).padStart(2, '0')}. ${String(dt.getDate()).padStart(2, '0')}.`, - ); - setAcdntTime( - `${dt.getHours() >= 12 ? '오후' : '오전'} ${String(dt.getHours() % 12 || 12).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`, - ); - setAcdntLat(String(item.lat)); - setAcdntLon(String(item.lng)); + setAcdntName(item.pollNm); + if (item.pollDate) { + const [d, t] = item.pollDate.split('T'); + if (d) { + const [y, m, day] = d.split('-'); + setAcdntDate(`${y}. ${m}. ${day}.`); + } + if (t) { + const [hhStr, mmStr] = t.split(':'); + const hh = parseInt(hhStr, 10); + const ampm = hh >= 12 ? '오후' : '오전'; + const hh12 = String(hh % 12 || 12).padStart(2, '0'); + setAcdntTime(`${ampm} ${hh12}:${mmStr}`); + } + } + if (item.lat != null) setAcdntLat(String(item.lat)); + if (item.lon != null) setAcdntLon(String(item.lon)); setShowList(false); }; @@ -283,7 +292,7 @@ function LeftPanel({ className="w-full px-2 py-1.5 text-caption bg-bg-card border border-stroke rounded font-korean text-left cursor-pointer hover:border-[var(--stroke-light)] flex items-center justify-between" > - {selectedAcdnt ? selectedAcdnt.acdntCd : '또는 사고 리스트에서 선택'} + {selectedAcdnt ? selectedAcdnt.pollNm : '또는 사고 리스트에서 선택'} {showList ? '▲' : '▼'} @@ -296,13 +305,13 @@ function LeftPanel({ )} {incidents.map((item) => ( ))} @@ -1523,13 +1532,16 @@ export function RescueView() { const { activeSubTab } = useSubMenu('rescue'); const [activeType, setActiveType] = useState('collision'); const [activeAnalysis, setActiveAnalysis] = useState('rescue'); - const [incidents, setIncidents] = useState([]); - const [selectedAcdnt, setSelectedAcdnt] = useState(null); + const [incidents, setIncidents] = useState([]); + const [selectedAcdnt, setSelectedAcdnt] = useState(null); 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(() => { - fetchIncidentsRaw() + fetchGscAccidents() .then((items) => setIncidents(items)) .catch(() => setIncidents([])); }, []); @@ -1540,24 +1552,17 @@ export function RescueView() { setIsSelectingLocation(false); }, []); - // 사고 선택 시 사고유형 자동 매핑 - const handleSelectAcdnt = useCallback((item: IncidentListItem | null) => { - setSelectedAcdnt(item); - if (item) { - const typeMap: Record = { - collision: 'collision', - grounding: 'grounding', - turning: 'turning', - capsizing: 'capsizing', - sharpTurn: 'sharpTurn', - flooding: 'flooding', - sinking: 'sinking', - }; - const mapped = typeMap[item.acdntTpCd]; - if (mapped) setActiveType(mapped); - setIncidentCoord({ lon: item.lng, lat: item.lat }); - } - }, []); + // 사고 선택 시 좌표 자동 반영 + 지도 이동 + const handleSelectAcdnt = useCallback( + (item: GscAccidentListItem | null) => { + setSelectedAcdnt(item); + if (item && item.lat != null && item.lon != null) { + setIncidentCoord({ lon: item.lon, lat: item.lat }); + setFlyToCoord({ lon: item.lon, lat: item.lat }); + } + }, + [], + ); if (activeSubTab === 'list') { return ( @@ -1592,11 +1597,15 @@ export function RescueView() {
setFlyToCoord(undefined)} isSelectingLocation={isSelectingLocation} onMapClick={handleMapClick} oilTrajectory={[]} enabledLayers={new Set()} showOverlays={false} + vessels={vessels} + onBoundsChange={setMapBounds} />