Merge branch 'develop' into feature/mpa-develop
This commit is contained in:
커밋
2d6827c0a9
@ -5,7 +5,30 @@
|
|||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"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": [
|
"deny": [
|
||||||
"Bash(git push --force*)",
|
"Bash(git push --force*)",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"applied_global_version": "1.6.1",
|
"applied_global_version": "1.6.1",
|
||||||
"applied_date": "2026-03-31",
|
"applied_date": "2026-04-14",
|
||||||
"project_type": "react-ts",
|
"project_type": "react-ts",
|
||||||
"gitea_url": "https://gitea.gc-si.dev",
|
"gitea_url": "https://gitea.gc-si.dev",
|
||||||
"custom_pre_commit": true
|
"custom_pre_commit": true
|
||||||
|
|||||||
17
.github/java-upgrade/hooks/scripts/recordToolUse.ps1
vendored
Normal file
17
.github/java-upgrade/hooks/scripts/recordToolUse.ps1
vendored
Normal file
@ -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))
|
||||||
27
.github/java-upgrade/hooks/scripts/recordToolUse.sh
vendored
Normal file
27
.github/java-upgrade/hooks/scripts/recordToolUse.sh
vendored
Normal file
@ -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"
|
||||||
20
backend/src/gsc/gscAccidentsRouter.ts
Normal file
20
backend/src/gsc/gscAccidentsRouter.ts
Normal file
@ -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;
|
||||||
65
backend/src/gsc/gscAccidentsService.ts
Normal file
65
backend/src/gsc/gscAccidentsService.ts
Normal file
@ -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<GscAccidentListItem[]> {
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@ -19,12 +19,15 @@ import hnsRouter from './hns/hnsRouter.js'
|
|||||||
import reportsRouter from './reports/reportsRouter.js'
|
import reportsRouter from './reports/reportsRouter.js'
|
||||||
import assetsRouter from './assets/assetsRouter.js'
|
import assetsRouter from './assets/assetsRouter.js'
|
||||||
import incidentsRouter from './incidents/incidentsRouter.js'
|
import incidentsRouter from './incidents/incidentsRouter.js'
|
||||||
|
import gscAccidentsRouter from './gsc/gscAccidentsRouter.js'
|
||||||
import scatRouter from './scat/scatRouter.js'
|
import scatRouter from './scat/scatRouter.js'
|
||||||
import predictionRouter from './prediction/predictionRouter.js'
|
import predictionRouter from './prediction/predictionRouter.js'
|
||||||
import aerialRouter from './aerial/aerialRouter.js'
|
import aerialRouter from './aerial/aerialRouter.js'
|
||||||
import rescueRouter from './rescue/rescueRouter.js'
|
import rescueRouter from './rescue/rescueRouter.js'
|
||||||
import mapBaseRouter from './map-base/mapBaseRouter.js'
|
import mapBaseRouter from './map-base/mapBaseRouter.js'
|
||||||
import monitorRouter from './monitor/monitorRouter.js'
|
import monitorRouter from './monitor/monitorRouter.js'
|
||||||
|
import vesselRouter from './vessels/vesselRouter.js'
|
||||||
|
import { startVesselScheduler } from './vessels/vesselScheduler.js'
|
||||||
import {
|
import {
|
||||||
sanitizeBody,
|
sanitizeBody,
|
||||||
sanitizeQuery,
|
sanitizeQuery,
|
||||||
@ -168,6 +171,7 @@ app.use('/api/hns', hnsRouter)
|
|||||||
app.use('/api/reports', reportsRouter)
|
app.use('/api/reports', reportsRouter)
|
||||||
app.use('/api/assets', assetsRouter)
|
app.use('/api/assets', assetsRouter)
|
||||||
app.use('/api/incidents', incidentsRouter)
|
app.use('/api/incidents', incidentsRouter)
|
||||||
|
app.use('/api/gsc/accidents', gscAccidentsRouter)
|
||||||
app.use('/api/scat', scatRouter)
|
app.use('/api/scat', scatRouter)
|
||||||
app.use('/api/prediction', predictionRouter)
|
app.use('/api/prediction', predictionRouter)
|
||||||
app.use('/api/aerial', aerialRouter)
|
app.use('/api/aerial', aerialRouter)
|
||||||
@ -175,6 +179,7 @@ app.use('/api/rescue', rescueRouter)
|
|||||||
app.use('/api/map-base', mapBaseRouter)
|
app.use('/api/map-base', mapBaseRouter)
|
||||||
app.use('/api/monitor', monitorRouter)
|
app.use('/api/monitor', monitorRouter)
|
||||||
app.use('/api/tiles', tilesRouter)
|
app.use('/api/tiles', tilesRouter)
|
||||||
|
app.use('/api/vessels', vesselRouter)
|
||||||
|
|
||||||
// 헬스 체크
|
// 헬스 체크
|
||||||
app.get('/health', (_req, res) => {
|
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 () => {
|
app.listen(PORT, async () => {
|
||||||
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
|
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
|
||||||
|
|
||||||
|
// 선박 신호 스케줄러 시작 (한국 전 해역 1분 폴링)
|
||||||
|
startVesselScheduler()
|
||||||
|
|
||||||
// wing DB 연결 확인 (wing + auth 스키마 통합)
|
// wing DB 연결 확인 (wing + auth 스키마 통합)
|
||||||
const connected = await testWingDbConnection()
|
const connected = await testWingDbConnection()
|
||||||
if (connected) {
|
if (connected) {
|
||||||
|
|||||||
33
backend/src/vessels/vesselRouter.ts
Normal file
33
backend/src/vessels/vesselRouter.ts
Normal file
@ -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;
|
||||||
96
backend/src/vessels/vesselScheduler.ts
Normal file
96
backend/src/vessels/vesselScheduler.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { updateVesselCache } from './vesselService.js';
|
||||||
|
import type { VesselPosition } from './vesselTypes.js';
|
||||||
|
|
||||||
|
const VESSEL_TRACK_API_URL =
|
||||||
|
process.env.VESSEL_TRACK_API_URL ?? 'https://guide.gc-si.dev/signal-batch';
|
||||||
|
const POLL_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
|
// 개별 쿠키 환경변수를 조합하여 Cookie 헤더 문자열 생성
|
||||||
|
function buildVesselCookie(): string {
|
||||||
|
const entries: [string, string | undefined][] = [
|
||||||
|
['apt.uid', process.env.VESSEL_COOKIE_APT_UID],
|
||||||
|
['g_state', process.env.VESSEL_COOKIE_G_STATE],
|
||||||
|
['gc_proxy_auth', process.env.VESSEL_COOKIE_GC_PROXY_AUTH],
|
||||||
|
['GC_SESSION', process.env.VESSEL_COOKIE_GC_SESSION],
|
||||||
|
// 기존 단일 쿠키 변수 폴백 (레거시 지원)
|
||||||
|
];
|
||||||
|
const parts = entries
|
||||||
|
.filter(([, v]) => v)
|
||||||
|
.map(([k, v]) => `${k}=${v}`);
|
||||||
|
|
||||||
|
// 기존 VESSEL_TRACK_COOKIE 폴백 (단일 문자열로 설정된 경우)
|
||||||
|
if (parts.length === 0 && process.env.VESSEL_TRACK_COOKIE) {
|
||||||
|
return process.env.VESSEL_TRACK_COOKIE;
|
||||||
|
}
|
||||||
|
return parts.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 한국 전 해역 고정 폴리곤 (124~132°E, 32~38°N)
|
||||||
|
const KOREA_WATERS_POLYGON = [
|
||||||
|
[120, 31],
|
||||||
|
[132, 31],
|
||||||
|
[132, 41],
|
||||||
|
[120, 41],
|
||||||
|
[120, 31],
|
||||||
|
];
|
||||||
|
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
async function pollVesselSignals(): Promise<void> {
|
||||||
|
const url = `${VESSEL_TRACK_API_URL}/api/v1/vessels/recent-positions-detail`;
|
||||||
|
const body = {
|
||||||
|
minutes: 5,
|
||||||
|
coordinates: KOREA_WATERS_POLYGON,
|
||||||
|
polygonFilter: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cookie = buildVesselCookie();
|
||||||
|
const requestHeaders: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(cookie ? { Cookie: cookie } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: requestHeaders,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
console.error(`[vesselScheduler] 선박 신호 API 오류: ${res.status}`, text.substring(0, 200));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = res.headers.get('content-type') ?? '';
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
console.error('[vesselScheduler] 선박 신호 API가 JSON이 아닌 응답 반환:', text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as VesselPosition[];
|
||||||
|
updateVesselCache(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[vesselScheduler] 선박 신호 폴링 실패:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startVesselScheduler(): void {
|
||||||
|
if (intervalId !== null) return;
|
||||||
|
|
||||||
|
// 서버 시작 시 즉시 1회 실행 후 주기적 폴링
|
||||||
|
pollVesselSignals();
|
||||||
|
intervalId = setInterval(pollVesselSignals, POLL_INTERVAL_MS);
|
||||||
|
console.log('[vesselScheduler] 선박 신호 스케줄러 시작 (1분 간격)');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopVesselScheduler(): void {
|
||||||
|
if (intervalId !== null) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
console.log('[vesselScheduler] 선박 신호 스케줄러 중지');
|
||||||
|
}
|
||||||
|
}
|
||||||
55
backend/src/vessels/vesselService.ts
Normal file
55
backend/src/vessels/vesselService.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import type { VesselPosition, BoundingBox } from './vesselTypes.js';
|
||||||
|
|
||||||
|
const VESSEL_TTL_MS = 10 * 60 * 1000; // 10분
|
||||||
|
|
||||||
|
const cachedVessels = new Map<string, VesselPosition>();
|
||||||
|
let lastUpdated: Date | null = null;
|
||||||
|
|
||||||
|
// lastUpdate가 TTL을 초과한 선박을 캐시에서 제거.
|
||||||
|
// lastUpdate 파싱이 불가능한 경우 보수적으로 유지한다.
|
||||||
|
function evictStale(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [mmsi, vessel] of cachedVessels) {
|
||||||
|
const ts = Date.parse(vessel.lastUpdate);
|
||||||
|
if (Number.isNaN(ts)) continue;
|
||||||
|
if (now - ts > VESSEL_TTL_MS) {
|
||||||
|
cachedVessels.delete(mmsi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateVesselCache(vessels: VesselPosition[]): void {
|
||||||
|
for (const vessel of vessels) {
|
||||||
|
if (!vessel.mmsi) continue;
|
||||||
|
cachedVessels.set(vessel.mmsi, vessel);
|
||||||
|
}
|
||||||
|
evictStale();
|
||||||
|
lastUpdated = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVesselsInBounds(bounds: BoundingBox): VesselPosition[] {
|
||||||
|
const result: VesselPosition[] = [];
|
||||||
|
for (const v of cachedVessels.values()) {
|
||||||
|
if (
|
||||||
|
v.lon >= bounds.minLon &&
|
||||||
|
v.lon <= bounds.maxLon &&
|
||||||
|
v.lat >= bounds.minLat &&
|
||||||
|
v.lat <= bounds.maxLat
|
||||||
|
) {
|
||||||
|
result.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCacheStatus(): {
|
||||||
|
count: number;
|
||||||
|
bangjeCount: number;
|
||||||
|
lastUpdated: Date | null;
|
||||||
|
} {
|
||||||
|
let bangjeCount = 0;
|
||||||
|
for (const v of cachedVessels.values()) {
|
||||||
|
if (v.shipNm && v.shipNm.toUpperCase().includes('BANGJE')) bangjeCount++;
|
||||||
|
}
|
||||||
|
return { count: cachedVessels.size, bangjeCount, lastUpdated };
|
||||||
|
}
|
||||||
26
backend/src/vessels/vesselTypes.ts
Normal file
26
backend/src/vessels/vesselTypes.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -4,8 +4,15 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2026-04-15]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 확산예측·HNS 대기확산·긴급구난: GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (사고명·발생시각·위경도 자동 입력 + 지도 이동)
|
||||||
|
- 실시간 선박 신호 지도 표출: 한국 해역 1분 주기 폴링 스케줄러, 호버 툴팁·클릭 팝업·상세 모달 제공 (확산예측·HNS·긴급구난·사건사고 탭 연동)
|
||||||
|
|
||||||
### 변경
|
### 변경
|
||||||
- MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용
|
- MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용
|
||||||
|
- aerial 이미지 분석 API 기본 URL 변경
|
||||||
|
|
||||||
## [2026-04-14]
|
## [2026-04-14]
|
||||||
|
|
||||||
|
|||||||
42
frontend/src/common/components/map/MapBoundsTracker.tsx
Normal file
42
frontend/src/common/components/map/MapBoundsTracker.tsx
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -32,6 +32,15 @@ import { DeckGLOverlay } from './DeckGLOverlay';
|
|||||||
import { FlyToController } from './FlyToController';
|
import { FlyToController } from './FlyToController';
|
||||||
import { useMapStore } from '@common/store/mapStore';
|
import { useMapStore } from '@common/store/mapStore';
|
||||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
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';
|
const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
@ -166,10 +175,16 @@ interface MapViewProps {
|
|||||||
analysisCircleRadiusM?: number;
|
analysisCircleRadiusM?: number;
|
||||||
/** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */
|
/** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */
|
||||||
showOverlays?: boolean;
|
showOverlays?: boolean;
|
||||||
|
/** 선박 신호 목록 (실시간 표출) */
|
||||||
|
vessels?: VesselPosition[];
|
||||||
|
/** 지도 뷰포트 bounds 변경 콜백 (선박 신호 필터링에 사용) */
|
||||||
|
onBoundsChange?: (bounds: MapBounds) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeckGLOverlay, FlyToController → @common/components/map/DeckGLOverlay, FlyToController 에서 import
|
// DeckGLOverlay, FlyToController → @common/components/map/DeckGLOverlay, FlyToController 에서 import
|
||||||
|
|
||||||
|
// MapBoundsTracker는 './MapBoundsTracker' 모듈로 추출됨 (IncidentsView 등 BaseMap 직접 사용처에서도 재사용)
|
||||||
|
|
||||||
// fitBounds 트리거 컴포넌트 (Map 내부에서 useMap() 사용)
|
// fitBounds 트리거 컴포넌트 (Map 내부에서 useMap() 사용)
|
||||||
function FitBoundsController({
|
function FitBoundsController({
|
||||||
fitBoundsTarget,
|
fitBoundsTarget,
|
||||||
@ -348,6 +363,8 @@ export function MapView({
|
|||||||
analysisCircleCenter,
|
analysisCircleCenter,
|
||||||
analysisCircleRadiusM = 0,
|
analysisCircleRadiusM = 0,
|
||||||
showOverlays = true,
|
showOverlays = true,
|
||||||
|
vessels = [],
|
||||||
|
onBoundsChange,
|
||||||
}: MapViewProps) {
|
}: MapViewProps) {
|
||||||
const lightMode = true;
|
const lightMode = true;
|
||||||
const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore();
|
const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore();
|
||||||
@ -366,6 +383,10 @@ export function MapView({
|
|||||||
const persistentPopupRef = useRef(false);
|
const persistentPopupRef = useRef(false);
|
||||||
// 현재 호버 중인 민감자원 feature properties (handleMapClick에서 팝업 생성에 사용)
|
// 현재 호버 중인 민감자원 feature properties (handleMapClick에서 팝업 생성에 사용)
|
||||||
const hoveredSensitiveRef = useRef<Record<string, unknown> | null>(null);
|
const hoveredSensitiveRef = useRef<Record<string, unknown> | null>(null);
|
||||||
|
// 선박 호버/클릭 상호작용 상태
|
||||||
|
const [vesselHover, setVesselHover] = useState<VesselHoverInfo | null>(null);
|
||||||
|
const [selectedVessel, setSelectedVessel] = useState<VesselPosition | null>(null);
|
||||||
|
const [detailVessel, setDetailVessel] = useState<VesselPosition | null>(null);
|
||||||
const currentTime = isControlled ? externalCurrentTime : internalCurrentTime;
|
const currentTime = isControlled ? externalCurrentTime : internalCurrentTime;
|
||||||
|
|
||||||
const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => {
|
const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => {
|
||||||
@ -1217,6 +1238,23 @@ export function MapView({
|
|||||||
// 거리/면적 측정 레이어
|
// 거리/면적 측정 레이어
|
||||||
result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements));
|
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);
|
return result.filter(Boolean);
|
||||||
}, [
|
}, [
|
||||||
oilTrajectory,
|
oilTrajectory,
|
||||||
@ -1242,6 +1280,8 @@ export function MapView({
|
|||||||
analysisCircleCenter,
|
analysisCircleCenter,
|
||||||
analysisCircleRadiusM,
|
analysisCircleRadiusM,
|
||||||
lightMode,
|
lightMode,
|
||||||
|
vessels,
|
||||||
|
mapZoom,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 3D 모드 / 테마에 따른 지도 스타일 전환
|
// 3D 모드 / 테마에 따른 지도 스타일 전환
|
||||||
@ -1279,6 +1319,8 @@ export function MapView({
|
|||||||
<FlyToController target={flyToTarget} duration={1200} />
|
<FlyToController target={flyToTarget} duration={1200} />
|
||||||
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
|
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
|
||||||
<FitBoundsController fitBoundsTarget={fitBoundsTarget} />
|
<FitBoundsController fitBoundsTarget={fitBoundsTarget} />
|
||||||
|
{/* 선박 신호 뷰포트 bounds 추적 */}
|
||||||
|
<MapBoundsTracker onBoundsChange={onBoundsChange} />
|
||||||
|
|
||||||
{/* S-57 전자해도 오버레이 (공식 style.json 기반) */}
|
{/* S-57 전자해도 오버레이 (공식 style.json 기반) */}
|
||||||
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||||
@ -1430,6 +1472,26 @@ export function MapView({
|
|||||||
ships={backtrackReplay.ships}
|
ships={backtrackReplay.ships}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 선박 호버 툴팁 */}
|
||||||
|
{vesselHover && !selectedVessel && <VesselHoverTooltip hover={vesselHover} />}
|
||||||
|
|
||||||
|
{/* 선박 클릭 팝업 */}
|
||||||
|
{selectedVessel && !detailVessel && (
|
||||||
|
<VesselPopupPanel
|
||||||
|
vessel={selectedVessel}
|
||||||
|
onClose={() => setSelectedVessel(null)}
|
||||||
|
onDetail={() => {
|
||||||
|
setDetailVessel(selectedVessel);
|
||||||
|
setSelectedVessel(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 선박 상세 모달 */}
|
||||||
|
{detailVessel && (
|
||||||
|
<VesselDetailModal vessel={detailVessel} onClose={() => setDetailVessel(null)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
647
frontend/src/common/components/map/VesselInteraction.tsx
Normal file
647
frontend/src/common/components/map/VesselInteraction.tsx
Normal file
@ -0,0 +1,647 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { VesselPosition } from '@common/types/vessel';
|
||||||
|
import { getShipKindLabel } from './VesselLayer';
|
||||||
|
|
||||||
|
export interface VesselHoverInfo {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vessel: VesselPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '-';
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayVal(v: unknown): string {
|
||||||
|
if (v === undefined || v === null || v === '') return '-';
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VesselHoverTooltip({ hover }: { hover: VesselHoverInfo }) {
|
||||||
|
const v = hover.vessel;
|
||||||
|
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '- kn';
|
||||||
|
const heading = v.heading ?? v.cog;
|
||||||
|
const headingText = heading !== undefined ? `HDG ${Math.round(heading)}°` : 'HDG -';
|
||||||
|
const typeText = [getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-', v.nationalCode]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute z-[1000] pointer-events-none rounded-md"
|
||||||
|
style={{
|
||||||
|
left: hover.x + 12,
|
||||||
|
top: hover.y - 12,
|
||||||
|
background: 'var(--bg-elevated)',
|
||||||
|
border: '1px solid var(--stroke-default)',
|
||||||
|
padding: '8px 12px',
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||||
|
minWidth: 150,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-label-2 font-bold text-fg" style={{ marginBottom: 3 }}>
|
||||||
|
{v.shipNm ?? '(이름 없음)'}
|
||||||
|
</div>
|
||||||
|
<div className="text-caption text-fg-disabled" style={{ marginBottom: 4 }}>
|
||||||
|
{typeText}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-caption">
|
||||||
|
<span className="text-color-accent font-semibold">{speed}</span>
|
||||||
|
<span className="text-fg-disabled">{headingText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VesselPopupPanel({
|
||||||
|
vessel: v,
|
||||||
|
onClose,
|
||||||
|
onDetail,
|
||||||
|
}: {
|
||||||
|
vessel: VesselPosition;
|
||||||
|
onClose: () => void;
|
||||||
|
onDetail: () => void;
|
||||||
|
}) {
|
||||||
|
const statusText = v.status ?? '-';
|
||||||
|
const isAccident = (v.status ?? '').includes('사고');
|
||||||
|
const statusColor = isAccident ? 'var(--color-danger)' : 'var(--color-success)';
|
||||||
|
const statusBg = isAccident
|
||||||
|
? 'color-mix(in srgb, var(--color-danger) 15%, transparent)'
|
||||||
|
: 'color-mix(in srgb, var(--color-success) 10%, transparent)';
|
||||||
|
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-';
|
||||||
|
const heading = v.heading ?? v.cog;
|
||||||
|
const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-';
|
||||||
|
const receivedAt = v.lastUpdate ? formatDateTime(v.lastUpdate) : '-';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%,-50%)',
|
||||||
|
zIndex: 9995,
|
||||||
|
width: 300,
|
||||||
|
background: 'rgba(13,17,23,0.97)',
|
||||||
|
border: '1px solid rgba(48,54,61,0.8)',
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: '0 16px 48px rgba(0,0,0,0.7)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: 'rgba(22,27,34,0.97)',
|
||||||
|
borderBottom: '1px solid rgba(48,54,61,0.8)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center text-title-2"
|
||||||
|
style={{ width: 28, height: 20 }}
|
||||||
|
>
|
||||||
|
{v.nationalCode ?? '🚢'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className="text-label-1 font-[800] whitespace-nowrap overflow-hidden text-ellipsis"
|
||||||
|
style={{ color: '#e6edf3' }}
|
||||||
|
>
|
||||||
|
{v.shipNm ?? '(이름 없음)'}
|
||||||
|
</div>
|
||||||
|
<div className="text-caption font-mono" style={{ color: '#8b949e' }}>
|
||||||
|
MMSI: {v.mmsi}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-title-3 cursor-pointer p-[2px]"
|
||||||
|
style={{ color: '#8b949e' }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="w-full flex items-center justify-center text-[40px]"
|
||||||
|
style={{
|
||||||
|
height: 120,
|
||||||
|
background: 'rgba(22,27,34,0.97)',
|
||||||
|
borderBottom: '1px solid rgba(48,54,61,0.6)',
|
||||||
|
color: '#484f58',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🚢
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex gap-2"
|
||||||
|
style={{ padding: '6px 14px', borderBottom: '1px solid rgba(48,54,61,0.6)' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-caption font-bold rounded text-color-info"
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
background: 'color-mix(in srgb, var(--color-info) 12%, transparent)',
|
||||||
|
border: '1px solid color-mix(in srgb, var(--color-info) 25%, transparent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-'}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-caption font-bold rounded"
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
background: statusBg,
|
||||||
|
border: `1px solid color-mix(in srgb, ${statusColor} 40%, transparent)`,
|
||||||
|
color: statusColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '4px 0' }}>
|
||||||
|
<PopupRow label="속도/항로" value={`${speed} / ${headingText}`} accent />
|
||||||
|
<PopupRow label="흘수" value={v.draught !== undefined ? `${v.draught.toFixed(2)} m` : '-'} />
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-1"
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderBottom: '1px solid rgba(48,54,61,0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-caption" style={{ color: '#8b949e' }}>
|
||||||
|
출항지
|
||||||
|
</span>
|
||||||
|
<span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}>
|
||||||
|
-
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-caption" style={{ color: '#8b949e' }}>
|
||||||
|
입항지
|
||||||
|
</span>
|
||||||
|
<span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}>
|
||||||
|
{v.destination ?? '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PopupRow label="데이터 수신" value={receivedAt} muted />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1.5" style={{ padding: '10px 14px' }}>
|
||||||
|
<button
|
||||||
|
onClick={onDetail}
|
||||||
|
className="flex-1 text-caption font-bold cursor-pointer text-center rounded-sm"
|
||||||
|
style={{
|
||||||
|
padding: 6,
|
||||||
|
color: '#58a6ff',
|
||||||
|
background: 'rgba(88,166,255,0.12)',
|
||||||
|
border: '1px solid rgba(88,166,255,0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📋 상세정보
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex-1 text-caption font-bold cursor-pointer text-center rounded-sm"
|
||||||
|
style={{
|
||||||
|
padding: 6,
|
||||||
|
color: '#a5d6ff',
|
||||||
|
background: 'rgba(165,214,255,0.1)',
|
||||||
|
border: '1px solid rgba(165,214,255,0.25)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔍 항적조회
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex-1 text-caption font-bold cursor-pointer text-center rounded-sm"
|
||||||
|
style={{
|
||||||
|
padding: 6,
|
||||||
|
color: '#22d3ee',
|
||||||
|
background: 'rgba(34,211,238,0.1)',
|
||||||
|
border: '1px solid rgba(34,211,238,0.25)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📐 항로예측
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopupRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
accent,
|
||||||
|
muted,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
accent?: boolean;
|
||||||
|
muted?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex justify-between text-caption"
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderBottom: '1px solid rgba(48,54,61,0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#8b949e' }}>{label}</span>
|
||||||
|
<span
|
||||||
|
className="font-semibold font-mono"
|
||||||
|
style={{
|
||||||
|
color: muted ? '#8b949e' : accent ? '#22d3ee' : '#c9d1d9',
|
||||||
|
fontSize: muted ? 9 : 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DetTab = 'info' | 'nav' | 'spec' | 'ins' | 'dg';
|
||||||
|
const TAB_LABELS: { key: DetTab; label: string }[] = [
|
||||||
|
{ key: 'info', label: '상세정보' },
|
||||||
|
{ key: 'nav', label: '항해정보' },
|
||||||
|
{ key: 'spec', label: '선박제원' },
|
||||||
|
{ key: 'ins', label: '보험정보' },
|
||||||
|
{ key: 'dg', label: '위험물정보' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function VesselDetailModal({
|
||||||
|
vessel: v,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
vessel: VesselPosition;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState<DetTab>('info');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
className="fixed inset-0 z-[10000] flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(0,0,0,0.65)',
|
||||||
|
backdropFilter: 'blur(6px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex flex-col overflow-hidden bg-bg-surface border border-stroke"
|
||||||
|
style={{
|
||||||
|
width: 560,
|
||||||
|
height: '85vh',
|
||||||
|
borderRadius: 14,
|
||||||
|
boxShadow: '0 24px 64px rgba(0,0,0,0.7)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="shrink-0 flex items-center justify-between bg-bg-surface border-b border-stroke"
|
||||||
|
style={{ padding: '14px 18px' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-[10px]">
|
||||||
|
<span className="text-lg">{v.nationalCode ?? '🚢'}</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-title-3 font-[800] text-fg">{v.shipNm ?? '(이름 없음)'}</div>
|
||||||
|
<div className="text-caption text-fg-disabled font-mono">
|
||||||
|
MMSI: {v.mmsi} · IMO: {displayVal(v.imo)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span onClick={onClose} className="text-title-2 cursor-pointer text-fg-disabled">
|
||||||
|
✕
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="shrink-0 flex gap-0.5 overflow-x-auto bg-bg-base border-b border-stroke"
|
||||||
|
style={{ padding: '0 18px' }}
|
||||||
|
>
|
||||||
|
{TAB_LABELS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className="text-label-2 cursor-pointer whitespace-nowrap"
|
||||||
|
style={{
|
||||||
|
padding: '8px 11px',
|
||||||
|
fontWeight: tab === t.key ? 600 : 400,
|
||||||
|
color: tab === t.key ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||||
|
borderBottom:
|
||||||
|
tab === t.key ? '2px solid var(--color-accent)' : '2px solid transparent',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
transition: '0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex-1 overflow-y-auto flex flex-col"
|
||||||
|
style={{
|
||||||
|
padding: '16px 18px',
|
||||||
|
gap: 14,
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
scrollbarColor: 'var(--stroke-default) transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab === 'info' && <TabInfo v={v} />}
|
||||||
|
{tab === 'nav' && <TabNav />}
|
||||||
|
{tab === 'spec' && <TabSpec v={v} />}
|
||||||
|
{tab === 'ins' && <TabInsurance />}
|
||||||
|
{tab === 'dg' && <TabDangerous />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sec({
|
||||||
|
title,
|
||||||
|
borderColor,
|
||||||
|
bgColor,
|
||||||
|
badge,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
borderColor?: string;
|
||||||
|
bgColor?: string;
|
||||||
|
badge?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${borderColor || 'var(--stroke-default)'}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="text-label-2 font-bold text-fg-sub flex items-center justify-between"
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: bgColor || 'var(--bg-base)',
|
||||||
|
borderBottom: `1px solid ${borderColor || 'var(--stroke-default)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{title}</span>
|
||||||
|
{badge}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Grid({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className="grid grid-cols-2">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Cell({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
span,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
span?: boolean;
|
||||||
|
color?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderBottom: '1px solid color-mix(in srgb, var(--stroke-default) 60%, transparent)',
|
||||||
|
borderRight: span
|
||||||
|
? 'none'
|
||||||
|
: '1px solid color-mix(in srgb, var(--stroke-default) 60%, transparent)',
|
||||||
|
gridColumn: span ? '1 / -1' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-caption text-fg-disabled" style={{ marginBottom: 2 }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-label-2 font-semibold font-mono"
|
||||||
|
style={{ color: color || 'var(--fg)' }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ label, color }: { label: string; color: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="text-caption font-bold"
|
||||||
|
style={{
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginLeft: 'auto',
|
||||||
|
background: `color-mix(in srgb, ${color} 25%, transparent)`,
|
||||||
|
color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabInfo({ v }: { v: VesselPosition }) {
|
||||||
|
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-';
|
||||||
|
const heading = v.heading ?? v.cog;
|
||||||
|
const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-';
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="w-full rounded-lg overflow-hidden flex items-center justify-center text-[60px] text-fg-disabled bg-bg-base"
|
||||||
|
style={{ height: 160 }}
|
||||||
|
>
|
||||||
|
🚢
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Sec title="📡 실시간 현황">
|
||||||
|
<Grid>
|
||||||
|
<Cell label="선박상태" value={displayVal(v.status)} />
|
||||||
|
<Cell
|
||||||
|
label="속도 / 항로"
|
||||||
|
value={`${speed} / ${headingText}`}
|
||||||
|
color="var(--color-accent)"
|
||||||
|
/>
|
||||||
|
<Cell label="위도" value={`${v.lat.toFixed(4)}°N`} />
|
||||||
|
<Cell label="경도" value={`${v.lon.toFixed(4)}°E`} />
|
||||||
|
<Cell label="흘수" value={v.draught !== undefined ? `${v.draught} m` : '-'} />
|
||||||
|
<Cell label="수신시간" value={v.lastUpdate ? formatDateTime(v.lastUpdate) : '-'} />
|
||||||
|
</Grid>
|
||||||
|
</Sec>
|
||||||
|
|
||||||
|
<Sec title="🚢 항해 일정">
|
||||||
|
<Grid>
|
||||||
|
<Cell label="출항지" value="-" />
|
||||||
|
<Cell label="입항지" value={displayVal(v.destination)} />
|
||||||
|
<Cell label="출항일시" value="-" />
|
||||||
|
<Cell label="입항일시(ETA)" value="-" />
|
||||||
|
</Grid>
|
||||||
|
</Sec>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabNav() {
|
||||||
|
const hours = ['08', '09', '10', '11', '12', '13', '14'];
|
||||||
|
const heights = [45, 60, 78, 82, 70, 85, 75];
|
||||||
|
const colors = [
|
||||||
|
'color-mix(in srgb, var(--color-success) 30%, transparent)',
|
||||||
|
'color-mix(in srgb, var(--color-success) 40%, transparent)',
|
||||||
|
'color-mix(in srgb, var(--color-info) 40%, transparent)',
|
||||||
|
'color-mix(in srgb, var(--color-info) 50%, transparent)',
|
||||||
|
'color-mix(in srgb, var(--color-info) 50%, transparent)',
|
||||||
|
'color-mix(in srgb, var(--color-info) 60%, transparent)',
|
||||||
|
'color-mix(in srgb, var(--color-accent) 50%, transparent)',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Sec title="🗺 최근 항적 (24h)">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center relative overflow-hidden bg-bg-base"
|
||||||
|
style={{ height: 180 }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
viewBox="0 0 400 180"
|
||||||
|
style={{ position: 'absolute', inset: 0 }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M50,150 C80,140 120,100 160,95 S240,70 280,50 S340,30 370,20"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-accent)"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="6,3"
|
||||||
|
opacity=".6"
|
||||||
|
/>
|
||||||
|
<circle cx="50" cy="150" r="4" fill="var(--fg-disabled)" />
|
||||||
|
<circle cx="160" cy="95" r="3" fill="var(--color-accent)" opacity=".5" />
|
||||||
|
<circle cx="280" cy="50" r="3" fill="var(--color-accent)" opacity=".5" />
|
||||||
|
<circle cx="370" cy="20" r="5" fill="var(--color-accent)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</Sec>
|
||||||
|
|
||||||
|
<Sec title="📊 속도 이력">
|
||||||
|
<div className="p-3 bg-bg-base">
|
||||||
|
<div className="flex items-end gap-1.5" style={{ height: 80 }}>
|
||||||
|
{hours.map((h, i) => (
|
||||||
|
<div key={h} className="flex-1 flex flex-col items-center gap-0.5">
|
||||||
|
<div
|
||||||
|
className="w-full"
|
||||||
|
style={{
|
||||||
|
background: colors[i],
|
||||||
|
borderRadius: '2px 2px 0 0',
|
||||||
|
height: `${heights[i]}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-caption text-fg-disabled">{h}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-caption text-fg-disabled" style={{ marginTop: 6 }}>
|
||||||
|
평균: <b className="text-color-info">8.4 kn</b> · 최대:{' '}
|
||||||
|
<b className="text-color-accent">11.2 kn</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Sec>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabSpec({ v }: { v: VesselPosition }) {
|
||||||
|
const loa = v.length !== undefined ? `${v.length} m` : '-';
|
||||||
|
const beam = v.width !== undefined ? `${v.width} m` : '-';
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Sec title="📐 선체 제원">
|
||||||
|
<Grid>
|
||||||
|
<Cell label="선종" value={displayVal(getShipKindLabel(v.shipKindCode) ?? v.shipTy)} />
|
||||||
|
<Cell label="선적국" value={displayVal(v.nationalCode)} />
|
||||||
|
<Cell label="총톤수 (GT)" value="-" />
|
||||||
|
<Cell label="재화중량 (DWT)" value="-" />
|
||||||
|
<Cell label="전장 (LOA)" value={loa} />
|
||||||
|
<Cell label="선폭" value={beam} />
|
||||||
|
<Cell label="건조년도" value="-" />
|
||||||
|
<Cell label="건조 조선소" value="-" />
|
||||||
|
</Grid>
|
||||||
|
</Sec>
|
||||||
|
|
||||||
|
<Sec title="📡 통신 / 식별">
|
||||||
|
<Grid>
|
||||||
|
<Cell label="MMSI" value={String(v.mmsi)} />
|
||||||
|
<Cell label="IMO" value={displayVal(v.imo)} />
|
||||||
|
<Cell label="호출부호" value="-" />
|
||||||
|
<Cell label="선급" value="-" />
|
||||||
|
</Grid>
|
||||||
|
</Sec>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabInsurance() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Sec title="🏢 선주 / 운항사">
|
||||||
|
<Grid>
|
||||||
|
<Cell label="선주" value="-" />
|
||||||
|
<Cell label="운항사" value="-" />
|
||||||
|
<Cell label="P&I Club" value="-" span />
|
||||||
|
</Grid>
|
||||||
|
</Sec>
|
||||||
|
|
||||||
|
<Sec
|
||||||
|
title="🚢 선체보험 (H&M)"
|
||||||
|
borderColor="color-mix(in srgb, var(--color-accent) 20%, transparent)"
|
||||||
|
bgColor="color-mix(in srgb, var(--color-accent) 6%, transparent)"
|
||||||
|
badge={<StatusBadge label="-" color="var(--color-success)" />}
|
||||||
|
>
|
||||||
|
<Grid>
|
||||||
|
<Cell label="보험사" value="-" />
|
||||||
|
<Cell label="보험가액" value="-" color="var(--color-accent)" />
|
||||||
|
<Cell label="보험기간" value="-" color="var(--color-success)" />
|
||||||
|
<Cell label="면책금" value="-" />
|
||||||
|
</Grid>
|
||||||
|
</Sec>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabDangerous() {
|
||||||
|
return (
|
||||||
|
<Sec
|
||||||
|
title="⚠ 위험물 화물 신고정보"
|
||||||
|
bgColor="color-mix(in srgb, var(--color-warning) 6%, transparent)"
|
||||||
|
>
|
||||||
|
<Grid>
|
||||||
|
<Cell label="화물명" value="-" color="var(--color-warning)" />
|
||||||
|
<Cell label="컨테이너갯수/총량" value="-" />
|
||||||
|
<Cell label="하역업체코드" value="-" />
|
||||||
|
<Cell label="하역기간" value="-" />
|
||||||
|
</Grid>
|
||||||
|
</Sec>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
frontend/src/common/components/map/VesselLayer.ts
Normal file
133
frontend/src/common/components/map/VesselLayer.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { IconLayer, TextLayer } from '@deck.gl/layers';
|
||||||
|
import type { VesselPosition } from '@common/types/vessel';
|
||||||
|
|
||||||
|
export interface VesselLegendItem {
|
||||||
|
code: string;
|
||||||
|
type: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VESSEL_LEGEND: VesselLegendItem[] = [
|
||||||
|
{ code: '000020', type: '어선', color: '#f97316' },
|
||||||
|
{ code: '000021', type: '함정', color: '#64748b' },
|
||||||
|
{ code: '000022', type: '여객선', color: '#a855f7' },
|
||||||
|
{ code: '000023', type: '화물선', color: '#22c55e' },
|
||||||
|
{ code: '000024', type: '유조선', color: '#ef4444' },
|
||||||
|
{ code: '000025', type: '관공선', color: '#3b82f6' },
|
||||||
|
{ code: '000027', type: '기타', color: '#94a3b8' },
|
||||||
|
{ code: '000028', type: '부이', color: '#eab308' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_VESSEL_COLOR = '#94a3b8';
|
||||||
|
|
||||||
|
const SHIP_KIND_COLORS: Record<string, string> = VESSEL_LEGEND.reduce(
|
||||||
|
(acc, { code, color }) => {
|
||||||
|
acc[code] = color;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const SHIP_KIND_LABELS: Record<string, string> = VESSEL_LEGEND.reduce(
|
||||||
|
(acc, { code, type }) => {
|
||||||
|
acc[code] = type;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getShipKindColor(shipKindCode?: string): string {
|
||||||
|
if (!shipKindCode) return DEFAULT_VESSEL_COLOR;
|
||||||
|
return SHIP_KIND_COLORS[shipKindCode] ?? DEFAULT_VESSEL_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShipKindLabel(shipKindCode?: string): string | undefined {
|
||||||
|
if (!shipKindCode) return undefined;
|
||||||
|
return SHIP_KIND_LABELS[shipKindCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTriangleSvg(color: string, isAccident: boolean): string {
|
||||||
|
const opacity = isAccident ? '1' : '0.85';
|
||||||
|
const glowOpacity = isAccident ? '0.9' : '0.75';
|
||||||
|
const svgStr = [
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="20" viewBox="0 0 16 20">',
|
||||||
|
'<defs><filter id="g" x="-50%" y="-50%" width="200%" height="200%">',
|
||||||
|
'<feGaussianBlur stdDeviation="1.2"/></filter></defs>',
|
||||||
|
`<polygon points="8,0 15,20 1,20" fill="${color}" opacity="${glowOpacity}" filter="url(#g)"/>`,
|
||||||
|
`<polygon points="8,1 14,19 2,19" fill="${color}" opacity="${opacity}" stroke="${color}" stroke-width="0.5"/>`,
|
||||||
|
'</svg>',
|
||||||
|
].join('');
|
||||||
|
return `data:image/svg+xml;base64,${btoa(svgStr)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VesselLayerHandlers {
|
||||||
|
onClick?: (vessel: VesselPosition, coordinate: [number, number]) => void;
|
||||||
|
onHover?: (vessel: VesselPosition | null, x: number, y: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildVesselLayers(
|
||||||
|
vessels: VesselPosition[],
|
||||||
|
handlers: VesselLayerHandlers = {},
|
||||||
|
zoom?: number,
|
||||||
|
) {
|
||||||
|
if (!vessels.length) return [];
|
||||||
|
const showLabels = zoom === undefined || zoom > 9;
|
||||||
|
|
||||||
|
const iconLayer = new IconLayer({
|
||||||
|
id: 'vessel-icons',
|
||||||
|
data: vessels,
|
||||||
|
getPosition: (d: VesselPosition) => [d.lon, d.lat],
|
||||||
|
getIcon: (d: VesselPosition) => {
|
||||||
|
const color = getShipKindColor(d.shipKindCode);
|
||||||
|
const isAccident = (d.status ?? '').includes('사고');
|
||||||
|
return {
|
||||||
|
url: makeTriangleSvg(color, isAccident),
|
||||||
|
width: 16,
|
||||||
|
height: 20,
|
||||||
|
anchorX: 8,
|
||||||
|
anchorY: 10,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getSize: 16,
|
||||||
|
getAngle: (d: VesselPosition) => -(d.heading ?? d.cog ?? 0),
|
||||||
|
sizeUnits: 'pixels',
|
||||||
|
sizeScale: 1,
|
||||||
|
pickable: true,
|
||||||
|
onClick: (info: { object?: VesselPosition; coordinate?: number[] }) => {
|
||||||
|
if (info.object && info.coordinate && handlers.onClick) {
|
||||||
|
handlers.onClick(info.object, [info.coordinate[0], info.coordinate[1]]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onHover: (info: { object?: VesselPosition; x?: number; y?: number }) => {
|
||||||
|
if (!handlers.onHover) return;
|
||||||
|
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||||
|
handlers.onHover(info.object, info.x, info.y);
|
||||||
|
} else {
|
||||||
|
handlers.onHover(null, 0, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateTriggers: {
|
||||||
|
getIcon: [vessels],
|
||||||
|
getAngle: [vessels],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const labelLayer = new TextLayer({
|
||||||
|
id: 'vessel-labels',
|
||||||
|
data: vessels.filter((v) => v.shipNm),
|
||||||
|
visible: showLabels,
|
||||||
|
getPosition: (d: VesselPosition) => [d.lon, d.lat],
|
||||||
|
getText: (d: VesselPosition) => d.shipNm ?? '',
|
||||||
|
getSize: 11,
|
||||||
|
getColor: [255, 255, 255, 240],
|
||||||
|
getPixelOffset: [0, -14],
|
||||||
|
billboard: true,
|
||||||
|
sizeUnits: 'pixels' as const,
|
||||||
|
characterSet: 'auto',
|
||||||
|
fontSettings: { sdf: true },
|
||||||
|
outlineColor: [0, 0, 0, 230],
|
||||||
|
outlineWidth: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
return [iconLayer, labelLayer];
|
||||||
|
}
|
||||||
79
frontend/src/common/hooks/useVesselSignals.ts
Normal file
79
frontend/src/common/hooks/useVesselSignals.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
createVesselSignalClient,
|
||||||
|
type VesselSignalClient,
|
||||||
|
} from '@common/services/vesselSignalClient';
|
||||||
|
import {
|
||||||
|
getInitialVesselSnapshot,
|
||||||
|
isVesselInitEnabled,
|
||||||
|
} from '@common/services/vesselApi';
|
||||||
|
import type { VesselPosition, MapBounds } from '@common/types/vessel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선박 신호 실시간 수신 훅
|
||||||
|
*
|
||||||
|
* 개발환경(VITE_VESSEL_SIGNAL_MODE=polling):
|
||||||
|
* - 60초마다 백엔드 REST API(/api/vessels/in-area)를 현재 뷰포트 bbox로 호출
|
||||||
|
*
|
||||||
|
* 운영환경(VITE_VESSEL_SIGNAL_MODE=websocket):
|
||||||
|
* - 운영 WebSocket 서버(VITE_VESSEL_WS_URL)에 직접 연결하여 실시간 수신
|
||||||
|
* - 수신된 전체 데이터를 현재 뷰포트 bbox로 프론트에서 필터링
|
||||||
|
*
|
||||||
|
* @param mapBounds MapView의 onBoundsChange로 전달받은 현재 뷰포트 bbox
|
||||||
|
* @returns 현재 뷰포트 내 선박 목록
|
||||||
|
*/
|
||||||
|
export function useVesselSignals(mapBounds: MapBounds | null): VesselPosition[] {
|
||||||
|
const [vessels, setVessels] = useState<VesselPosition[]>([]);
|
||||||
|
const boundsRef = useRef<MapBounds | null>(mapBounds);
|
||||||
|
const clientRef = useRef<VesselSignalClient | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
boundsRef.current = mapBounds;
|
||||||
|
}, [mapBounds]);
|
||||||
|
|
||||||
|
const getViewportBounds = useCallback(() => boundsRef.current, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const client = createVesselSignalClient();
|
||||||
|
clientRef.current = client;
|
||||||
|
|
||||||
|
// 운영 환경: 로그인/새로고침 직후 최근 10분치 스냅샷을 먼저 1회 로드.
|
||||||
|
// 이후 WebSocket 수신이 시작되면 최신 신호로 갱신된다.
|
||||||
|
// VITE_VESSEL_INIT_ENABLED=true 일 때만 활성화(기본 비활성).
|
||||||
|
if (isVesselInitEnabled()) {
|
||||||
|
getInitialVesselSnapshot()
|
||||||
|
.then((initial) => {
|
||||||
|
const bounds = boundsRef.current;
|
||||||
|
const filtered = bounds
|
||||||
|
? initial.filter(
|
||||||
|
(v) =>
|
||||||
|
v.lon >= bounds.minLon &&
|
||||||
|
v.lon <= bounds.maxLon &&
|
||||||
|
v.lat >= bounds.minLat &&
|
||||||
|
v.lat <= bounds.maxLat,
|
||||||
|
)
|
||||||
|
: initial;
|
||||||
|
// WS 첫 메시지가 먼저 도착해 이미 채워졌다면 덮어쓰지 않음
|
||||||
|
setVessels((prev) => (prev.length === 0 ? filtered : prev));
|
||||||
|
})
|
||||||
|
.catch((e) => console.warn('[useVesselSignals] 초기 스냅샷 실패', e));
|
||||||
|
}
|
||||||
|
|
||||||
|
client.start(setVessels, getViewportBounds);
|
||||||
|
return () => {
|
||||||
|
client.stop();
|
||||||
|
clientRef.current = null;
|
||||||
|
};
|
||||||
|
}, [getViewportBounds]);
|
||||||
|
|
||||||
|
// mapBounds가 바뀔 때마다(최초 채워질 때 + 이후 뷰포트 이동/줌마다) 즉시 1회 새로고침.
|
||||||
|
// MapView의 onBoundsChange는 moveend/zoomend에서만 호출되므로 드래그 중 스팸은 없다.
|
||||||
|
// 이후에도 60초 인터벌 폴링은 백그라운드에서 계속 동작.
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapBounds && clientRef.current) {
|
||||||
|
clientRef.current.refresh();
|
||||||
|
}
|
||||||
|
}, [mapBounds]);
|
||||||
|
|
||||||
|
return vessels;
|
||||||
|
}
|
||||||
@ -1,613 +1,4 @@
|
|||||||
export interface Vessel {
|
// Deprecated: Mock 선박 데이터는 제거되었습니다.
|
||||||
mmsi: number;
|
// 실제 선박 신호는 @common/hooks/useVesselSignals + @common/components/map/VesselLayer 를 사용합니다.
|
||||||
imo: string;
|
// 범례는 @common/components/map/VesselLayer 의 VESSEL_LEGEND 를 import 하세요.
|
||||||
name: string;
|
export {};
|
||||||
typS: string;
|
|
||||||
flag: string;
|
|
||||||
status: string;
|
|
||||||
speed: number;
|
|
||||||
heading: number;
|
|
||||||
lat: number;
|
|
||||||
lng: number;
|
|
||||||
draft: number;
|
|
||||||
depart: string;
|
|
||||||
arrive: string;
|
|
||||||
etd: string;
|
|
||||||
eta: string;
|
|
||||||
gt: string;
|
|
||||||
dwt: string;
|
|
||||||
loa: string;
|
|
||||||
beam: string;
|
|
||||||
built: string;
|
|
||||||
yard: string;
|
|
||||||
callSign: string;
|
|
||||||
cls: string;
|
|
||||||
cargo: string;
|
|
||||||
color: string;
|
|
||||||
markerType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VESSEL_TYPE_COLORS: Record<string, string> = {
|
|
||||||
Tanker: '#ef4444',
|
|
||||||
Chemical: '#ef4444',
|
|
||||||
Cargo: '#22c55e',
|
|
||||||
Bulk: '#22c55e',
|
|
||||||
Container: '#3b82f6',
|
|
||||||
Passenger: '#a855f7',
|
|
||||||
Fishing: '#f97316',
|
|
||||||
Tug: '#06b6d4',
|
|
||||||
Navy: '#6b7280',
|
|
||||||
Sailing: '#fbbf24',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VESSEL_LEGEND = [
|
|
||||||
{ type: 'Tanker', color: '#ef4444' },
|
|
||||||
{ type: 'Cargo', color: '#22c55e' },
|
|
||||||
{ type: 'Container', color: '#3b82f6' },
|
|
||||||
{ type: 'Fishing', color: '#f97316' },
|
|
||||||
{ type: 'Passenger', color: '#a855f7' },
|
|
||||||
{ type: 'Tug', color: '#06b6d4' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const mockVessels: Vessel[] = [
|
|
||||||
{
|
|
||||||
mmsi: 440123456,
|
|
||||||
imo: '9812345',
|
|
||||||
name: 'HANKUK CHEMI',
|
|
||||||
typS: 'Tanker',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 8.2,
|
|
||||||
heading: 330,
|
|
||||||
lat: 34.6,
|
|
||||||
lng: 127.5,
|
|
||||||
draft: 5.8,
|
|
||||||
depart: '여수항',
|
|
||||||
arrive: '부산항',
|
|
||||||
etd: '2026-02-25 08:00',
|
|
||||||
eta: '2026-02-25 18:30',
|
|
||||||
gt: '29,246',
|
|
||||||
dwt: '49,999',
|
|
||||||
loa: '183.0m',
|
|
||||||
beam: '32.2m',
|
|
||||||
built: '2018',
|
|
||||||
yard: '현대미포조선',
|
|
||||||
callSign: 'HLKC',
|
|
||||||
cls: '한국선급(KR)',
|
|
||||||
cargo: 'BUNKER-C · 1,200kL · IMO Class 3',
|
|
||||||
color: '#ef4444',
|
|
||||||
markerType: 'tanker',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440234567,
|
|
||||||
imo: '9823456',
|
|
||||||
name: 'DONG-A GLAUCOS',
|
|
||||||
typS: 'Cargo',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 11.4,
|
|
||||||
heading: 245,
|
|
||||||
lat: 34.78,
|
|
||||||
lng: 127.8,
|
|
||||||
draft: 7.2,
|
|
||||||
depart: '울산항',
|
|
||||||
arrive: '광양항',
|
|
||||||
etd: '2026-02-25 06:30',
|
|
||||||
eta: '2026-02-25 16:00',
|
|
||||||
gt: '12,450',
|
|
||||||
dwt: '18,800',
|
|
||||||
loa: '144.0m',
|
|
||||||
beam: '22.6m',
|
|
||||||
built: '2015',
|
|
||||||
yard: 'STX조선',
|
|
||||||
callSign: 'HLDG',
|
|
||||||
cls: '한국선급(KR)',
|
|
||||||
cargo: '철강재 · 4,500t',
|
|
||||||
color: '#22c55e',
|
|
||||||
markerType: 'cargo',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440345678,
|
|
||||||
imo: '9834567',
|
|
||||||
name: 'HMM ALGECIRAS',
|
|
||||||
typS: 'Container',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 18.5,
|
|
||||||
heading: 195,
|
|
||||||
lat: 35.0,
|
|
||||||
lng: 128.8,
|
|
||||||
draft: 14.5,
|
|
||||||
depart: '부산항',
|
|
||||||
arrive: '싱가포르',
|
|
||||||
etd: '2026-02-25 04:00',
|
|
||||||
eta: '2026-03-02 08:00',
|
|
||||||
gt: '228,283',
|
|
||||||
dwt: '223,092',
|
|
||||||
loa: '399.9m',
|
|
||||||
beam: '61.0m',
|
|
||||||
built: '2020',
|
|
||||||
yard: '대우조선해양',
|
|
||||||
callSign: 'HLHM',
|
|
||||||
cls: "Lloyd's Register",
|
|
||||||
cargo: '컨테이너 · 16,420 TEU',
|
|
||||||
color: '#3b82f6',
|
|
||||||
markerType: 'container',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 355678901,
|
|
||||||
imo: '9756789',
|
|
||||||
name: 'STELLAR DAISY',
|
|
||||||
typS: 'Tanker',
|
|
||||||
flag: '🇵🇦',
|
|
||||||
status: '⚠ 사고(좌초)',
|
|
||||||
speed: 0.0,
|
|
||||||
heading: 0,
|
|
||||||
lat: 34.72,
|
|
||||||
lng: 127.72,
|
|
||||||
draft: 8.1,
|
|
||||||
depart: '여수항',
|
|
||||||
arrive: '—',
|
|
||||||
etd: '2026-01-18 12:00',
|
|
||||||
eta: '—',
|
|
||||||
gt: '35,120',
|
|
||||||
dwt: '58,000',
|
|
||||||
loa: '190.0m',
|
|
||||||
beam: '34.0m',
|
|
||||||
built: '2012',
|
|
||||||
yard: 'CSBC Taiwan',
|
|
||||||
callSign: '3FZA7',
|
|
||||||
cls: 'NK',
|
|
||||||
cargo: 'BUNKER-C · 150kL 유출 · ⚠ 사고선박',
|
|
||||||
color: '#ef4444',
|
|
||||||
markerType: 'tanker',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440456789,
|
|
||||||
imo: '—',
|
|
||||||
name: '제72 금양호',
|
|
||||||
typS: 'Fishing',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '조업중',
|
|
||||||
speed: 4.1,
|
|
||||||
heading: 120,
|
|
||||||
lat: 34.55,
|
|
||||||
lng: 127.35,
|
|
||||||
draft: 2.1,
|
|
||||||
depart: '여수 국동항',
|
|
||||||
arrive: '여수 국동항',
|
|
||||||
etd: '2026-02-25 04:30',
|
|
||||||
eta: '2026-02-25 18:00',
|
|
||||||
gt: '78',
|
|
||||||
dwt: '—',
|
|
||||||
loa: '24.5m',
|
|
||||||
beam: '6.2m',
|
|
||||||
built: '2008',
|
|
||||||
yard: '통영조선',
|
|
||||||
callSign: '—',
|
|
||||||
cls: '한국선급',
|
|
||||||
cargo: '어획물',
|
|
||||||
color: '#f97316',
|
|
||||||
markerType: 'fishing',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440567890,
|
|
||||||
imo: '9867890',
|
|
||||||
name: 'PAN OCEAN GLORY',
|
|
||||||
typS: 'Bulk',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 12.8,
|
|
||||||
heading: 170,
|
|
||||||
lat: 35.6,
|
|
||||||
lng: 126.4,
|
|
||||||
draft: 10.3,
|
|
||||||
depart: '군산항',
|
|
||||||
arrive: '포항항',
|
|
||||||
etd: '2026-02-25 07:00',
|
|
||||||
eta: '2026-02-26 04:00',
|
|
||||||
gt: '43,800',
|
|
||||||
dwt: '76,500',
|
|
||||||
loa: '229.0m',
|
|
||||||
beam: '32.3m',
|
|
||||||
built: '2019',
|
|
||||||
yard: '현대삼호중공업',
|
|
||||||
callSign: 'HLPO',
|
|
||||||
cls: '한국선급(KR)',
|
|
||||||
cargo: '석탄 · 65,000t',
|
|
||||||
color: '#22c55e',
|
|
||||||
markerType: 'cargo',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440678901,
|
|
||||||
imo: '—',
|
|
||||||
name: '여수예인1호',
|
|
||||||
typS: 'Tug',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '방제지원',
|
|
||||||
speed: 6.3,
|
|
||||||
heading: 355,
|
|
||||||
lat: 34.68,
|
|
||||||
lng: 127.6,
|
|
||||||
draft: 3.2,
|
|
||||||
depart: '여수항',
|
|
||||||
arrive: '사고현장',
|
|
||||||
etd: '2026-01-18 16:30',
|
|
||||||
eta: '—',
|
|
||||||
gt: '280',
|
|
||||||
dwt: '—',
|
|
||||||
loa: '32.0m',
|
|
||||||
beam: '9.5m',
|
|
||||||
built: '2016',
|
|
||||||
yard: '삼성중공업',
|
|
||||||
callSign: 'HLYT',
|
|
||||||
cls: '한국선급',
|
|
||||||
cargo: '방제장비 · 오일붐 500m',
|
|
||||||
color: '#06b6d4',
|
|
||||||
markerType: 'tug',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 235012345,
|
|
||||||
imo: '9456789',
|
|
||||||
name: 'QUEEN MARY',
|
|
||||||
typS: 'Passenger',
|
|
||||||
flag: '🇬🇧',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 15.2,
|
|
||||||
heading: 10,
|
|
||||||
lat: 33.8,
|
|
||||||
lng: 127.0,
|
|
||||||
draft: 8.5,
|
|
||||||
depart: '상하이',
|
|
||||||
arrive: '부산항',
|
|
||||||
etd: '2026-02-24 18:00',
|
|
||||||
eta: '2026-02-26 06:00',
|
|
||||||
gt: '148,528',
|
|
||||||
dwt: '18,000',
|
|
||||||
loa: '345.0m',
|
|
||||||
beam: '41.0m',
|
|
||||||
built: '2004',
|
|
||||||
yard: "Chantiers de l'Atlantique",
|
|
||||||
callSign: 'GBQM2',
|
|
||||||
cls: "Lloyd's Register",
|
|
||||||
cargo: '승객 2,620명',
|
|
||||||
color: '#a855f7',
|
|
||||||
markerType: 'passenger',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 353012345,
|
|
||||||
imo: '9811000',
|
|
||||||
name: 'EVER GIVEN',
|
|
||||||
typS: 'Container',
|
|
||||||
flag: '🇹🇼',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 14.7,
|
|
||||||
heading: 220,
|
|
||||||
lat: 35.2,
|
|
||||||
lng: 129.2,
|
|
||||||
draft: 15.7,
|
|
||||||
depart: '부산항',
|
|
||||||
arrive: '카오슝',
|
|
||||||
etd: '2026-02-25 02:00',
|
|
||||||
eta: '2026-02-28 14:00',
|
|
||||||
gt: '220,940',
|
|
||||||
dwt: '199,629',
|
|
||||||
loa: '400.0m',
|
|
||||||
beam: '59.0m',
|
|
||||||
built: '2018',
|
|
||||||
yard: '今治造船',
|
|
||||||
callSign: 'BIXE9',
|
|
||||||
cls: 'ABS',
|
|
||||||
cargo: '컨테이너 · 14,800 TEU',
|
|
||||||
color: '#3b82f6',
|
|
||||||
markerType: 'container',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440789012,
|
|
||||||
imo: '—',
|
|
||||||
name: '제85 대성호',
|
|
||||||
typS: 'Fishing',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '조업중',
|
|
||||||
speed: 3.8,
|
|
||||||
heading: 85,
|
|
||||||
lat: 34.4,
|
|
||||||
lng: 126.3,
|
|
||||||
draft: 1.8,
|
|
||||||
depart: '목포항',
|
|
||||||
arrive: '목포항',
|
|
||||||
etd: '2026-02-25 03:00',
|
|
||||||
eta: '2026-02-25 17:00',
|
|
||||||
gt: '65',
|
|
||||||
dwt: '—',
|
|
||||||
loa: '22.0m',
|
|
||||||
beam: '5.8m',
|
|
||||||
built: '2010',
|
|
||||||
yard: '목포조선',
|
|
||||||
callSign: '—',
|
|
||||||
cls: '한국선급',
|
|
||||||
cargo: '어획물',
|
|
||||||
color: '#f97316',
|
|
||||||
markerType: 'fishing',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440890123,
|
|
||||||
imo: '9878901',
|
|
||||||
name: 'SK INNOVATION',
|
|
||||||
typS: 'Chemical',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 9.6,
|
|
||||||
heading: 340,
|
|
||||||
lat: 35.8,
|
|
||||||
lng: 126.6,
|
|
||||||
draft: 6.5,
|
|
||||||
depart: '대산항',
|
|
||||||
arrive: '여수항',
|
|
||||||
etd: '2026-02-25 10:00',
|
|
||||||
eta: '2026-02-26 02:00',
|
|
||||||
gt: '11,200',
|
|
||||||
dwt: '16,800',
|
|
||||||
loa: '132.0m',
|
|
||||||
beam: '20.4m',
|
|
||||||
built: '2020',
|
|
||||||
yard: '현대미포조선',
|
|
||||||
callSign: 'HLSK',
|
|
||||||
cls: '한국선급(KR)',
|
|
||||||
cargo: '톨루엔 · 8,500kL · IMO Class 3',
|
|
||||||
color: '#ef4444',
|
|
||||||
markerType: 'tanker',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440901234,
|
|
||||||
imo: '9889012',
|
|
||||||
name: 'KOREA EXPRESS',
|
|
||||||
typS: 'Cargo',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 10.1,
|
|
||||||
heading: 190,
|
|
||||||
lat: 36.2,
|
|
||||||
lng: 128.5,
|
|
||||||
draft: 6.8,
|
|
||||||
depart: '동해항',
|
|
||||||
arrive: '포항항',
|
|
||||||
etd: '2026-02-25 09:00',
|
|
||||||
eta: '2026-02-25 15:00',
|
|
||||||
gt: '8,500',
|
|
||||||
dwt: '12,000',
|
|
||||||
loa: '118.0m',
|
|
||||||
beam: '18.2m',
|
|
||||||
built: '2014',
|
|
||||||
yard: '대한조선',
|
|
||||||
callSign: 'HLKE',
|
|
||||||
cls: '한국선급',
|
|
||||||
cargo: '일반화물',
|
|
||||||
color: '#22c55e',
|
|
||||||
markerType: 'cargo',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440012345,
|
|
||||||
imo: '—',
|
|
||||||
name: 'ROKS SEJONG',
|
|
||||||
typS: 'Navy',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '작전중',
|
|
||||||
speed: 16.0,
|
|
||||||
heading: 270,
|
|
||||||
lat: 35.3,
|
|
||||||
lng: 129.5,
|
|
||||||
draft: 6.3,
|
|
||||||
depart: '부산 해군기지',
|
|
||||||
arrive: '—',
|
|
||||||
etd: '—',
|
|
||||||
eta: '—',
|
|
||||||
gt: '7,600',
|
|
||||||
dwt: '—',
|
|
||||||
loa: '165.9m',
|
|
||||||
beam: '21.4m',
|
|
||||||
built: '2008',
|
|
||||||
yard: '현대중공업',
|
|
||||||
callSign: 'HLNS',
|
|
||||||
cls: '군용',
|
|
||||||
cargo: '군사작전',
|
|
||||||
color: '#6b7280',
|
|
||||||
markerType: 'military',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440023456,
|
|
||||||
imo: '—',
|
|
||||||
name: '군산예인3호',
|
|
||||||
typS: 'Tug',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '대기중',
|
|
||||||
speed: 5.5,
|
|
||||||
heading: 140,
|
|
||||||
lat: 35.9,
|
|
||||||
lng: 126.9,
|
|
||||||
draft: 2.8,
|
|
||||||
depart: '군산항',
|
|
||||||
arrive: '군산항',
|
|
||||||
etd: '—',
|
|
||||||
eta: '—',
|
|
||||||
gt: '180',
|
|
||||||
dwt: '—',
|
|
||||||
loa: '28.0m',
|
|
||||||
beam: '8.2m',
|
|
||||||
built: '2019',
|
|
||||||
yard: '통영조선',
|
|
||||||
callSign: 'HLGS',
|
|
||||||
cls: '한국선급',
|
|
||||||
cargo: '—',
|
|
||||||
color: '#06b6d4',
|
|
||||||
markerType: 'tug',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440034567,
|
|
||||||
imo: '—',
|
|
||||||
name: 'JEJU WIND',
|
|
||||||
typS: 'Sailing',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 6.8,
|
|
||||||
heading: 290,
|
|
||||||
lat: 33.35,
|
|
||||||
lng: 126.65,
|
|
||||||
draft: 2.5,
|
|
||||||
depart: '제주항',
|
|
||||||
arrive: '제주항',
|
|
||||||
etd: '2026-02-25 10:00',
|
|
||||||
eta: '2026-02-25 16:00',
|
|
||||||
gt: '45',
|
|
||||||
dwt: '—',
|
|
||||||
loa: '18.0m',
|
|
||||||
beam: '5.0m',
|
|
||||||
built: '2022',
|
|
||||||
yard: '제주요트',
|
|
||||||
callSign: '—',
|
|
||||||
cls: '—',
|
|
||||||
cargo: '—',
|
|
||||||
color: '#fbbf24',
|
|
||||||
markerType: 'sail',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440045678,
|
|
||||||
imo: '—',
|
|
||||||
name: '제33 삼양호',
|
|
||||||
typS: 'Fishing',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '조업중',
|
|
||||||
speed: 2.4,
|
|
||||||
heading: 55,
|
|
||||||
lat: 35.1,
|
|
||||||
lng: 127.4,
|
|
||||||
draft: 1.6,
|
|
||||||
depart: '통영항',
|
|
||||||
arrive: '통영항',
|
|
||||||
etd: '2026-02-25 05:00',
|
|
||||||
eta: '2026-02-25 19:00',
|
|
||||||
gt: '52',
|
|
||||||
dwt: '—',
|
|
||||||
loa: '20.0m',
|
|
||||||
beam: '5.4m',
|
|
||||||
built: '2006',
|
|
||||||
yard: '거제조선',
|
|
||||||
callSign: '—',
|
|
||||||
cls: '한국선급',
|
|
||||||
cargo: '어획물',
|
|
||||||
color: '#f97316',
|
|
||||||
markerType: 'fishing',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 255012345,
|
|
||||||
imo: '9703291',
|
|
||||||
name: 'MSC OSCAR',
|
|
||||||
typS: 'Container',
|
|
||||||
flag: '🇨🇭',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 17.3,
|
|
||||||
heading: 355,
|
|
||||||
lat: 34.1,
|
|
||||||
lng: 128.1,
|
|
||||||
draft: 14.0,
|
|
||||||
depart: '카오슝',
|
|
||||||
arrive: '부산항',
|
|
||||||
etd: '2026-02-23 08:00',
|
|
||||||
eta: '2026-02-25 22:00',
|
|
||||||
gt: '197,362',
|
|
||||||
dwt: '199,272',
|
|
||||||
loa: '395.4m',
|
|
||||||
beam: '59.0m',
|
|
||||||
built: '2015',
|
|
||||||
yard: '대우조선해양',
|
|
||||||
callSign: '9HA4713',
|
|
||||||
cls: 'DNV',
|
|
||||||
cargo: '컨테이너 · 18,200 TEU',
|
|
||||||
color: '#3b82f6',
|
|
||||||
markerType: 'container',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440056789,
|
|
||||||
imo: '9890567',
|
|
||||||
name: 'SAEHAN PIONEER',
|
|
||||||
typS: 'Tanker',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 7.9,
|
|
||||||
heading: 310,
|
|
||||||
lat: 34.9,
|
|
||||||
lng: 127.1,
|
|
||||||
draft: 5.2,
|
|
||||||
depart: '여수항',
|
|
||||||
arrive: '대산항',
|
|
||||||
etd: '2026-02-25 11:00',
|
|
||||||
eta: '2026-02-26 08:00',
|
|
||||||
gt: '8,900',
|
|
||||||
dwt: '14,200',
|
|
||||||
loa: '120.0m',
|
|
||||||
beam: '18.0m',
|
|
||||||
built: '2017',
|
|
||||||
yard: '현대미포조선',
|
|
||||||
callSign: 'HLSP',
|
|
||||||
cls: '한국선급(KR)',
|
|
||||||
cargo: '경유 · 10,000kL',
|
|
||||||
color: '#ef4444',
|
|
||||||
markerType: 'tanker',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440067890,
|
|
||||||
imo: '9891678',
|
|
||||||
name: 'DONGHAE STAR',
|
|
||||||
typS: 'Cargo',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '항해중',
|
|
||||||
speed: 11.0,
|
|
||||||
heading: 155,
|
|
||||||
lat: 37.55,
|
|
||||||
lng: 129.3,
|
|
||||||
draft: 6.0,
|
|
||||||
depart: '속초항',
|
|
||||||
arrive: '동해항',
|
|
||||||
etd: '2026-02-25 12:00',
|
|
||||||
eta: '2026-02-25 16:30',
|
|
||||||
gt: '6,200',
|
|
||||||
dwt: '8,500',
|
|
||||||
loa: '105.0m',
|
|
||||||
beam: '16.5m',
|
|
||||||
built: '2013',
|
|
||||||
yard: '대한조선',
|
|
||||||
callSign: 'HLDS',
|
|
||||||
cls: '한국선급',
|
|
||||||
cargo: '일반화물 · 목재',
|
|
||||||
color: '#22c55e',
|
|
||||||
markerType: 'cargo',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mmsi: 440078901,
|
|
||||||
imo: '—',
|
|
||||||
name: '제18 한라호',
|
|
||||||
typS: 'Fishing',
|
|
||||||
flag: '🇰🇷',
|
|
||||||
status: '귀항중',
|
|
||||||
speed: 3.2,
|
|
||||||
heading: 70,
|
|
||||||
lat: 33.3,
|
|
||||||
lng: 126.3,
|
|
||||||
draft: 1.9,
|
|
||||||
depart: '서귀포항',
|
|
||||||
arrive: '서귀포항',
|
|
||||||
etd: '2026-02-25 04:00',
|
|
||||||
eta: '2026-02-25 15:00',
|
|
||||||
gt: '58',
|
|
||||||
dwt: '—',
|
|
||||||
loa: '21.0m',
|
|
||||||
beam: '5.6m',
|
|
||||||
built: '2011',
|
|
||||||
yard: '제주조선',
|
|
||||||
callSign: '—',
|
|
||||||
cls: '한국선급',
|
|
||||||
cargo: '어획물 · 갈치/고등어',
|
|
||||||
color: '#f97316',
|
|
||||||
markerType: 'fishing',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|||||||
35
frontend/src/common/services/vesselApi.ts
Normal file
35
frontend/src/common/services/vesselApi.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
import type { VesselPosition, MapBounds } from '@common/types/vessel';
|
||||||
|
|
||||||
|
export async function getVesselsInArea(bounds: MapBounds): Promise<VesselPosition[]> {
|
||||||
|
const res = await api.post<VesselPosition[]>('/vessels/in-area', { bounds });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인/새로고침 직후 1회 호출하는 초기 스냅샷 API.
|
||||||
|
* 운영 환경의 별도 REST 서버가 현재 시각 기준 최근 10분치 선박 신호를 반환한다.
|
||||||
|
* URL은 VITE_VESSEL_INIT_API_URL 로 주입(운영에서 실제 URL로 교체).
|
||||||
|
*/
|
||||||
|
export async function getInitialVesselSnapshot(): Promise<VesselPosition[]> {
|
||||||
|
const url = import.meta.env.VITE_VESSEL_INIT_API_URL as string | undefined;
|
||||||
|
if (!url) return [];
|
||||||
|
const res = await fetch(url, { method: 'GET' });
|
||||||
|
if (!res.ok) throw new Error(`vessel init snapshot ${res.status}`);
|
||||||
|
return (await res.json()) as VesselPosition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVesselInitEnabled(): boolean {
|
||||||
|
return import.meta.env.VITE_VESSEL_INIT_ENABLED === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VesselCacheStatus {
|
||||||
|
count: number;
|
||||||
|
bangjeCount: number;
|
||||||
|
lastUpdated: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVesselCacheStatus(): Promise<VesselCacheStatus> {
|
||||||
|
const res = await api.get<VesselCacheStatus>('/vessels/status');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
125
frontend/src/common/services/vesselSignalClient.ts
Normal file
125
frontend/src/common/services/vesselSignalClient.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import type { VesselPosition, MapBounds } from '@common/types/vessel';
|
||||||
|
import { getVesselsInArea } from './vesselApi';
|
||||||
|
|
||||||
|
export interface VesselSignalClient {
|
||||||
|
start(
|
||||||
|
onVessels: (vessels: VesselPosition[]) => void,
|
||||||
|
getViewportBounds: () => MapBounds | null,
|
||||||
|
): void;
|
||||||
|
stop(): void;
|
||||||
|
/**
|
||||||
|
* 즉시 1회 새로고침. 폴링 모드에선 현재 bbox로 REST 호출,
|
||||||
|
* WebSocket 모드에선 no-op(서버 push에 의존).
|
||||||
|
*/
|
||||||
|
refresh(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 개발환경: setInterval(60s) → 백엔드 REST API 호출
|
||||||
|
class PollingVesselClient implements VesselSignalClient {
|
||||||
|
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private onVessels: ((vessels: VesselPosition[]) => void) | null = null;
|
||||||
|
private getViewportBounds: (() => MapBounds | null) | null = null;
|
||||||
|
|
||||||
|
private async poll(): Promise<void> {
|
||||||
|
const bounds = this.getViewportBounds?.();
|
||||||
|
if (!bounds || !this.onVessels) return;
|
||||||
|
try {
|
||||||
|
const vessels = await getVesselsInArea(bounds);
|
||||||
|
this.onVessels(vessels);
|
||||||
|
} catch {
|
||||||
|
// 폴링 실패 시 무시 (다음 인터벌에 재시도)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start(
|
||||||
|
onVessels: (vessels: VesselPosition[]) => void,
|
||||||
|
getViewportBounds: () => MapBounds | null,
|
||||||
|
): void {
|
||||||
|
this.onVessels = onVessels;
|
||||||
|
this.getViewportBounds = getViewportBounds;
|
||||||
|
|
||||||
|
// 즉시 1회 실행 후 60초 간격으로 반복
|
||||||
|
this.poll();
|
||||||
|
this.intervalId = setInterval(() => this.poll(), 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.intervalId !== null) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
this.onVessels = null;
|
||||||
|
this.getViewportBounds = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(): void {
|
||||||
|
this.poll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 운영환경: 실시간 WebSocket 서버에 직접 연결
|
||||||
|
class DirectWebSocketVesselClient implements VesselSignalClient {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private readonly wsUrl: string;
|
||||||
|
|
||||||
|
constructor(wsUrl: string) {
|
||||||
|
this.wsUrl = wsUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(
|
||||||
|
onVessels: (vessels: VesselPosition[]) => void,
|
||||||
|
getViewportBounds: () => MapBounds | null,
|
||||||
|
): void {
|
||||||
|
this.ws = new WebSocket(this.wsUrl);
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const allVessels = JSON.parse(event.data as string) as VesselPosition[];
|
||||||
|
const bounds = getViewportBounds();
|
||||||
|
|
||||||
|
if (!bounds) {
|
||||||
|
onVessels(allVessels);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = allVessels.filter(
|
||||||
|
(v) =>
|
||||||
|
v.lon >= bounds.minLon &&
|
||||||
|
v.lon <= bounds.maxLon &&
|
||||||
|
v.lat >= bounds.minLat &&
|
||||||
|
v.lat <= bounds.maxLat,
|
||||||
|
);
|
||||||
|
onVessels(filtered);
|
||||||
|
} catch {
|
||||||
|
// 파싱 실패 무시
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
console.error('[vesselSignalClient] WebSocket 연결 오류');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
console.warn('[vesselSignalClient] WebSocket 연결 종료');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(): void {
|
||||||
|
// 운영 WS 모드에선 서버 push에 의존하므로 별도 새로고침 동작 없음
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVesselSignalClient(): VesselSignalClient {
|
||||||
|
if (import.meta.env.VITE_VESSEL_SIGNAL_MODE === 'websocket') {
|
||||||
|
const wsUrl = import.meta.env.VITE_VESSEL_WS_URL as string;
|
||||||
|
return new DirectWebSocketVesselClient(wsUrl);
|
||||||
|
}
|
||||||
|
return new PollingVesselClient();
|
||||||
|
}
|
||||||
26
frontend/src/common/types/vessel.ts
Normal file
26
frontend/src/common/types/vessel.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -3,8 +3,8 @@ import { ComboBox } from '@common/components/ui/ComboBox';
|
|||||||
import { useWeatherFetch } from '../hooks/useWeatherFetch';
|
import { useWeatherFetch } from '../hooks/useWeatherFetch';
|
||||||
import { getSubstanceToxicity } from '../utils/toxicityData';
|
import { getSubstanceToxicity } from '../utils/toxicityData';
|
||||||
import type { WeatherFetchResult, ReleaseType } from '../utils/dispersionTypes';
|
import type { WeatherFetchResult, ReleaseType } from '../utils/dispersionTypes';
|
||||||
import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi';
|
import { fetchGscAccidents } from '@tabs/prediction/services/predictionApi';
|
||||||
import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi';
|
import type { GscAccidentListItem } from '@tabs/prediction/services/predictionApi';
|
||||||
|
|
||||||
/** HNS 분석 입력 파라미터 (부모에 전달) */
|
/** HNS 분석 입력 파라미터 (부모에 전달) */
|
||||||
export interface HNSInputParams {
|
export interface HNSInputParams {
|
||||||
@ -44,6 +44,7 @@ interface HNSLeftPanelProps {
|
|||||||
onParamsChange?: (params: HNSInputParams) => void;
|
onParamsChange?: (params: HNSInputParams) => void;
|
||||||
onReset?: () => void;
|
onReset?: () => void;
|
||||||
loadedParams?: Partial<HNSInputParams> | null;
|
loadedParams?: Partial<HNSInputParams> | null;
|
||||||
|
onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 십진 좌표 → 도분초 변환 */
|
/** 십진 좌표 → 도분초 변환 */
|
||||||
@ -67,8 +68,9 @@ export function HNSLeftPanel({
|
|||||||
onParamsChange,
|
onParamsChange,
|
||||||
onReset,
|
onReset,
|
||||||
loadedParams,
|
loadedParams,
|
||||||
|
onFlyToCoord,
|
||||||
}: HNSLeftPanelProps) {
|
}: HNSLeftPanelProps) {
|
||||||
const [incidents, setIncidents] = useState<IncidentListItem[]>([]);
|
const [incidents, setIncidents] = useState<GscAccidentListItem[]>([]);
|
||||||
const [selectedIncidentSn, setSelectedIncidentSn] = useState('');
|
const [selectedIncidentSn, setSelectedIncidentSn] = useState('');
|
||||||
const [expandedSections, setExpandedSections] = useState({ accident: true, params: true });
|
const [expandedSections, setExpandedSections] = useState({ accident: true, params: true });
|
||||||
const toggleSection = (key: 'accident' | 'params') =>
|
const toggleSection = (key: 'accident' | 'params') =>
|
||||||
@ -138,21 +140,26 @@ export function HNSLeftPanel({
|
|||||||
// 사고 목록 불러오기 (마운트 시 1회, ref 초기화 패턴)
|
// 사고 목록 불러오기 (마운트 시 1회, ref 초기화 패턴)
|
||||||
const incidentsPromiseRef = useRef<Promise<void> | null>(null);
|
const incidentsPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
if (incidentsPromiseRef.current == null) {
|
if (incidentsPromiseRef.current == null) {
|
||||||
incidentsPromiseRef.current = fetchIncidentsRaw()
|
incidentsPromiseRef.current = fetchGscAccidents()
|
||||||
.then((data) => setIncidents(data))
|
.then((data) => setIncidents(data))
|
||||||
.catch(() => setIncidents([]));
|
.catch(() => setIncidents([]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사고 선택 시 필드 자동 채움
|
// 사고 선택 시 필드 자동 채움
|
||||||
const handleSelectIncident = (snStr: string) => {
|
const handleSelectIncident = (mngNo: string) => {
|
||||||
setSelectedIncidentSn(snStr);
|
setSelectedIncidentSn(mngNo);
|
||||||
const sn = parseInt(snStr);
|
const incident = incidents.find((i) => i.acdntMngNo === mngNo);
|
||||||
const incident = incidents.find((i) => i.acdntSn === sn);
|
|
||||||
if (!incident) return;
|
if (!incident) return;
|
||||||
|
|
||||||
setAccidentName(incident.acdntNm);
|
setAccidentName(incident.pollNm);
|
||||||
if (incident.lat && incident.lng) {
|
if (incident.pollDate) {
|
||||||
onCoordChange({ lat: incident.lat, lon: incident.lng });
|
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}
|
onChange={handleSelectIncident}
|
||||||
placeholder="또는 사고 리스트에서 선택"
|
placeholder="또는 사고 리스트에서 선택"
|
||||||
options={incidents.map((inc) => ({
|
options={incidents.map((inc) => ({
|
||||||
value: String(inc.acdntSn),
|
value: inc.acdntMngNo,
|
||||||
label: `${inc.acdntNm} (${new Date(inc.occrnDtm).toLocaleDateString('ko-KR')})`,
|
label: `${inc.pollNm} (${inc.pollDate ? inc.pollDate.replace('T', ' ') : '-'})`,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
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 { HNSLeftPanel } from './HNSLeftPanel';
|
||||||
import type { HNSInputParams } from './HNSLeftPanel';
|
import type { HNSInputParams } from './HNSLeftPanel';
|
||||||
import { HNSRightPanel } from './HNSRightPanel';
|
import { HNSRightPanel } from './HNSRightPanel';
|
||||||
@ -202,7 +204,10 @@ export function HNSView() {
|
|||||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||||
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(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 [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
||||||
|
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
||||||
|
const vessels = useVesselSignals(mapBounds);
|
||||||
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
|
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const [dispersionResult, setDispersionResult] = useState<any>(null);
|
const [dispersionResult, setDispersionResult] = useState<any>(null);
|
||||||
@ -865,6 +870,7 @@ export function HNSView() {
|
|||||||
onParamsChange={handleParamsChange}
|
onParamsChange={handleParamsChange}
|
||||||
onReset={handleReset}
|
onReset={handleReset}
|
||||||
loadedParams={loadedParams}
|
loadedParams={loadedParams}
|
||||||
|
onFlyToCoord={(c) => setFlyToCoord({ lat: c.lat, lon: c.lon })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -924,6 +930,8 @@ export function HNSView() {
|
|||||||
<>
|
<>
|
||||||
<MapView
|
<MapView
|
||||||
incidentCoord={incidentCoord ?? undefined}
|
incidentCoord={incidentCoord ?? undefined}
|
||||||
|
flyToIncident={flyToCoord}
|
||||||
|
onIncidentFlyEnd={() => setFlyToCoord(undefined)}
|
||||||
isSelectingLocation={isSelectingLocation}
|
isSelectingLocation={isSelectingLocation}
|
||||||
onMapClick={handleMapClick}
|
onMapClick={handleMapClick}
|
||||||
oilTrajectory={[]}
|
oilTrajectory={[]}
|
||||||
@ -931,6 +939,8 @@ export function HNSView() {
|
|||||||
dispersionResult={dispersionResult}
|
dispersionResult={dispersionResult}
|
||||||
dispersionHeatmap={heatmapData}
|
dispersionHeatmap={heatmapData}
|
||||||
mapCaptureRef={mapCaptureRef}
|
mapCaptureRef={mapCaptureRef}
|
||||||
|
vessels={vessels}
|
||||||
|
onBoundsChange={setMapBounds}
|
||||||
/>
|
/>
|
||||||
{/* 시간 슬라이더 (puff/dense_gas 모델용) */}
|
{/* 시간 슬라이더 (puff/dense_gas 모델용) */}
|
||||||
{allTimeFrames.length > 1 && (
|
{allTimeFrames.length > 1 && (
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { Popup, useMap } from '@vis.gl/react-maplibre';
|
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 { PathStyleExtension } from '@deck.gl/extensions';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import { BaseMap } from '@common/components/map/BaseMap';
|
import { BaseMap } from '@common/components/map/BaseMap';
|
||||||
import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay';
|
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 { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel';
|
||||||
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel';
|
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel';
|
||||||
import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData';
|
|
||||||
import { fetchIncidents } from '../services/incidentsApi';
|
import { fetchIncidents } from '../services/incidentsApi';
|
||||||
import type { IncidentCompat } from '../services/incidentsApi';
|
import type { IncidentCompat } from '../services/incidentsApi';
|
||||||
import { fetchAnalysisTrajectory } from '@tabs/prediction/services/predictionApi';
|
import { fetchAnalysisTrajectory } from '@tabs/prediction/services/predictionApi';
|
||||||
@ -86,16 +90,11 @@ function getMarkerStroke(s: string): [number, number, number, number] {
|
|||||||
const getStatusLabel = (s: string) =>
|
const getStatusLabel = (s: string) =>
|
||||||
s === 'active' ? '대응중' : s === 'investigating' ? '조사중' : s === 'closed' ? '종료' : '';
|
s === 'active' ? '대응중' : s === 'investigating' ? '조사중' : s === 'closed' ? '종료' : '';
|
||||||
|
|
||||||
// ── 선박 아이콘 SVG (삼각형) ────────────────────────────
|
|
||||||
// deck.gl IconLayer용 atlas 없이 SVGOverlay 방식 대신
|
|
||||||
// ScatterplotLayer로 단순화하여 선박을 원으로 표현 (heading 정보 별도 레이어)
|
|
||||||
// → 원 마커 + 방향 지시선을 deck.gl ScatterplotLayer로 표현
|
|
||||||
|
|
||||||
// 팝업 정보
|
// 팝업 정보
|
||||||
interface VesselPopupInfo {
|
interface VesselPopupInfo {
|
||||||
longitude: number;
|
longitude: number;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
vessel: Vessel;
|
vessel: VesselPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IncidentPopupInfo {
|
interface IncidentPopupInfo {
|
||||||
@ -108,7 +107,7 @@ interface IncidentPopupInfo {
|
|||||||
interface HoverInfo {
|
interface HoverInfo {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
object: Vessel | IncidentCompat;
|
object: VesselPosition | IncidentCompat;
|
||||||
type: 'vessel' | 'incident';
|
type: 'vessel' | 'incident';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,12 +118,35 @@ export function IncidentsView() {
|
|||||||
const [incidents, setIncidents] = useState<IncidentCompat[]>([]);
|
const [incidents, setIncidents] = useState<IncidentCompat[]>([]);
|
||||||
const [filteredIncidents, setFilteredIncidents] = useState<IncidentCompat[]>([]);
|
const [filteredIncidents, setFilteredIncidents] = useState<IncidentCompat[]>([]);
|
||||||
const [selectedIncidentId, setSelectedIncidentId] = useState<string | null>(null);
|
const [selectedIncidentId, setSelectedIncidentId] = useState<string | null>(null);
|
||||||
const [selectedVessel, setSelectedVessel] = useState<Vessel | null>(null);
|
const [selectedVessel, setSelectedVessel] = useState<VesselPosition | null>(null);
|
||||||
const [detailVessel, setDetailVessel] = useState<Vessel | null>(null);
|
const [detailVessel, setDetailVessel] = useState<VesselPosition | null>(null);
|
||||||
const [vesselPopup, setVesselPopup] = useState<VesselPopupInfo | null>(null);
|
const [vesselPopup, setVesselPopup] = useState<VesselPopupInfo | null>(null);
|
||||||
const [incidentPopup, setIncidentPopup] = useState<IncidentPopupInfo | null>(null);
|
const [incidentPopup, setIncidentPopup] = useState<IncidentPopupInfo | null>(null);
|
||||||
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null);
|
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null);
|
||||||
|
|
||||||
|
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
||||||
|
const [mapZoom, setMapZoom] = useState<number>(10);
|
||||||
|
const realVessels = useVesselSignals(mapBounds);
|
||||||
|
|
||||||
|
const [vesselStatus, setVesselStatus] = useState<VesselCacheStatus | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const status = await getVesselCacheStatus();
|
||||||
|
if (!cancelled) setVesselStatus(status);
|
||||||
|
} catch {
|
||||||
|
// 무시 — 다음 폴링에서 재시도
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const id = setInterval(load, 30_000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [dischargeMode, setDischargeMode] = useState(false);
|
const [dischargeMode, setDischargeMode] = useState(false);
|
||||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||||
@ -283,60 +305,6 @@ export function IncidentsView() {
|
|||||||
[filteredIncidents, selectedIncidentId],
|
[filteredIncidents, selectedIncidentId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── 선박 방향 지시 아이콘 (삼각형 SVG → IconLayer) ──────
|
|
||||||
// 글로우 효과 + 드롭쉐도우가 있는 개선된 SVG 삼각형
|
|
||||||
const vesselIconLayer = useMemo(() => {
|
|
||||||
const makeTriangleSvg = (color: string, isAccident: boolean) => {
|
|
||||||
const opacity = isAccident ? '1' : '0.85';
|
|
||||||
const glowOpacity = isAccident ? '0.9' : '0.75';
|
|
||||||
const svgStr = [
|
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="20" viewBox="0 0 16 20">',
|
|
||||||
'<defs><filter id="g" x="-50%" y="-50%" width="200%" height="200%">',
|
|
||||||
'<feGaussianBlur stdDeviation="1.2"/></filter></defs>',
|
|
||||||
`<polygon points="8,0 15,20 1,20" fill="${color}" opacity="${glowOpacity}" filter="url(#g)"/>`,
|
|
||||||
`<polygon points="8,1 14,19 2,19" fill="${color}" opacity="${opacity}" stroke="${color}" stroke-width="0.5"/>`,
|
|
||||||
'</svg>',
|
|
||||||
].join('');
|
|
||||||
return `data:image/svg+xml;base64,${btoa(svgStr)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return new IconLayer({
|
|
||||||
id: 'vessel-icons',
|
|
||||||
data: mockVessels,
|
|
||||||
getPosition: (d: Vessel) => [d.lng, d.lat],
|
|
||||||
getIcon: (d: Vessel) => ({
|
|
||||||
url: makeTriangleSvg(d.color, d.status.includes('사고')),
|
|
||||||
width: 16,
|
|
||||||
height: 20,
|
|
||||||
anchorX: 8,
|
|
||||||
anchorY: 10,
|
|
||||||
}),
|
|
||||||
getSize: 16,
|
|
||||||
getAngle: (d: Vessel) => -d.heading,
|
|
||||||
sizeUnits: 'pixels',
|
|
||||||
sizeScale: 1,
|
|
||||||
pickable: true,
|
|
||||||
onClick: (info: { object?: Vessel; coordinate?: number[] }) => {
|
|
||||||
if (info.object && info.coordinate) {
|
|
||||||
setSelectedVessel(info.object);
|
|
||||||
setVesselPopup({
|
|
||||||
longitude: info.coordinate[0],
|
|
||||||
latitude: info.coordinate[1],
|
|
||||||
vessel: info.object,
|
|
||||||
});
|
|
||||||
setIncidentPopup(null);
|
|
||||||
setDetailVessel(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onHover: (info: { object?: Vessel; x?: number; y?: number }) => {
|
|
||||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
|
||||||
setHoverInfo({ x: info.x, y: info.y, object: info.object, type: 'vessel' });
|
|
||||||
} else {
|
|
||||||
setHoverInfo((h) => (h?.type === 'vessel' ? null : h));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── 배출 구역 경계선 레이어 ──
|
// ── 배출 구역 경계선 레이어 ──
|
||||||
const dischargeZoneLayers = useMemo(() => {
|
const dischargeZoneLayers = useMemo(() => {
|
||||||
@ -535,16 +503,44 @@ export function IncidentsView() {
|
|||||||
});
|
});
|
||||||
}, [sensitiveGeojson, sensCheckedCategories, sensColorMap]);
|
}, [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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const deckLayers: any[] = useMemo(
|
const deckLayers: any[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
incidentLayer,
|
incidentLayer,
|
||||||
vesselIconLayer,
|
...realVesselLayers,
|
||||||
...dischargeZoneLayers,
|
...dischargeZoneLayers,
|
||||||
...trajectoryLayers,
|
...trajectoryLayers,
|
||||||
...(sensLayer ? [sensLayer] : []),
|
...(sensLayer ? [sensLayer] : []),
|
||||||
],
|
],
|
||||||
[incidentLayer, vesselIconLayer, dischargeZoneLayers, trajectoryLayers, sensLayer],
|
[incidentLayer, realVesselLayers, dischargeZoneLayers, trajectoryLayers, sensLayer],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -692,6 +688,7 @@ export function IncidentsView() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DeckGLOverlay layers={deckLayers} />
|
<DeckGLOverlay layers={deckLayers} />
|
||||||
|
<MapBoundsTracker onBoundsChange={setMapBounds} onZoomChange={setMapZoom} />
|
||||||
<FlyToController incident={selectedIncident} />
|
<FlyToController incident={selectedIncident} />
|
||||||
|
|
||||||
{/* 사고 팝업 */}
|
{/* 사고 팝업 */}
|
||||||
@ -729,7 +726,7 @@ export function IncidentsView() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hoverInfo.type === 'vessel' ? (
|
{hoverInfo.type === 'vessel' ? (
|
||||||
<VesselTooltipContent vessel={hoverInfo.object as Vessel} />
|
<VesselTooltipContent vessel={hoverInfo.object as VesselPosition} />
|
||||||
) : (
|
) : (
|
||||||
<IncidentTooltipContent incident={hoverInfo.object as IncidentCompat} />
|
<IncidentTooltipContent incident={hoverInfo.object as IncidentCompat} />
|
||||||
)}
|
)}
|
||||||
@ -859,12 +856,11 @@ export function IncidentsView() {
|
|||||||
}}
|
}}
|
||||||
/> */}
|
/> */}
|
||||||
<span className="text-caption">AIS Live</span>
|
<span className="text-caption">AIS Live</span>
|
||||||
<span className="text-caption text-fg-disabled font-mono">MarineTraffic</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2.5 text-caption font-mono">
|
<div className="flex gap-2.5 text-caption font-mono">
|
||||||
<div className="text-fg-sub">선박 20</div>
|
<div className="text-fg-sub">선박 {vesselStatus?.count ?? 0}</div>
|
||||||
<div className="text-fg-sub">사고 6</div>
|
<div className="text-fg-sub">사고 {filteredIncidents.length}</div>
|
||||||
<div className="text-fg-sub">방제선 2</div>
|
<div className="text-fg-sub">방제선 {vesselStatus?.bangjeCount ?? 0}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1108,7 +1104,15 @@ export function IncidentsView() {
|
|||||||
onCloseAnalysis={handleCloseAnalysis}
|
onCloseAnalysis={handleCloseAnalysis}
|
||||||
onCheckedPredsChange={handleCheckedPredsChange}
|
onCheckedPredsChange={handleCheckedPredsChange}
|
||||||
onSensitiveDataChange={handleSensitiveDataChange}
|
onSensitiveDataChange={handleSensitiveDataChange}
|
||||||
selectedVessel={selectedVessel}
|
selectedVessel={
|
||||||
|
selectedVessel
|
||||||
|
? {
|
||||||
|
lat: selectedVessel.lat,
|
||||||
|
lng: selectedVessel.lon,
|
||||||
|
name: selectedVessel.shipNm,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1253,21 +1257,40 @@ function SplitPanelContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════
|
||||||
VesselPopupPanel
|
VesselPopupPanel / VesselDetailModal 공용 유틸
|
||||||
════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════ */
|
||||||
|
function formatDateTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '-';
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayVal(v: unknown): string {
|
||||||
|
if (v === undefined || v === null || v === '') return '-';
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function VesselPopupPanel({
|
function VesselPopupPanel({
|
||||||
vessel: v,
|
vessel: v,
|
||||||
onClose,
|
onClose,
|
||||||
onDetail,
|
onDetail,
|
||||||
}: {
|
}: {
|
||||||
vessel: Vessel;
|
vessel: VesselPosition;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDetail: () => void;
|
onDetail: () => void;
|
||||||
}) {
|
}) {
|
||||||
const statusColor = v.status.includes('사고') ? 'var(--color-danger)' : 'var(--color-success)';
|
const statusText = v.status ?? '-';
|
||||||
const statusBg = v.status.includes('사고')
|
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-danger) 15%, transparent)'
|
||||||
: 'color-mix(in srgb, var(--color-success) 10%, 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -1300,14 +1323,14 @@ function VesselPopupPanel({
|
|||||||
className="flex items-center justify-center text-title-2"
|
className="flex items-center justify-center text-title-2"
|
||||||
style={{ width: 28, height: 20 }}
|
style={{ width: 28, height: 20 }}
|
||||||
>
|
>
|
||||||
{v.flag}
|
{v.nationalCode ?? '🚢'}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div
|
<div
|
||||||
className="text-label-1 font-[800] whitespace-nowrap overflow-hidden text-ellipsis"
|
className="text-label-1 font-[800] whitespace-nowrap overflow-hidden text-ellipsis"
|
||||||
style={{ color: '#e6edf3' }}
|
style={{ color: '#e6edf3' }}
|
||||||
>
|
>
|
||||||
{v.name}
|
{v.shipNm ?? '(이름 없음)'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-caption font-mono" style={{ color: '#8b949e' }}>
|
<div className="text-caption font-mono" style={{ color: '#8b949e' }}>
|
||||||
MMSI: {v.mmsi}
|
MMSI: {v.mmsi}
|
||||||
@ -1348,7 +1371,7 @@ function VesselPopupPanel({
|
|||||||
border: '1px solid color-mix(in srgb, var(--color-info) 25%, transparent)',
|
border: '1px solid color-mix(in srgb, var(--color-info) 25%, transparent)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{v.typS}
|
{getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-'}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="text-caption font-bold rounded"
|
className="text-caption font-bold rounded"
|
||||||
@ -1359,14 +1382,14 @@ function VesselPopupPanel({
|
|||||||
color: statusColor,
|
color: statusColor,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{v.status}
|
{statusText}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Data rows */}
|
{/* Data rows */}
|
||||||
<div style={{ padding: '4px 0' }}>
|
<div style={{ padding: '4px 0' }}>
|
||||||
<PopupRow label="속도/항로" value={`${v.speed} kn / ${v.heading}°`} accent />
|
<PopupRow label="속도/항로" value={`${speed} / ${headingText}`} accent />
|
||||||
<PopupRow label="흘수" value={`${v.draft}m`} />
|
<PopupRow label="흘수" value={v.draught !== undefined ? `${v.draught.toFixed(2)} m` : '-'} />
|
||||||
<div
|
<div
|
||||||
className="flex flex-col gap-1"
|
className="flex flex-col gap-1"
|
||||||
style={{
|
style={{
|
||||||
@ -1379,7 +1402,7 @@ function VesselPopupPanel({
|
|||||||
출항지
|
출항지
|
||||||
</span>
|
</span>
|
||||||
<span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}>
|
<span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}>
|
||||||
{v.depart}
|
-
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
@ -1387,11 +1410,11 @@ function VesselPopupPanel({
|
|||||||
입항지
|
입항지
|
||||||
</span>
|
</span>
|
||||||
<span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}>
|
<span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}>
|
||||||
{v.arrive}
|
{v.destination ?? '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<PopupRow label="데이터 수신" value="2026-02-25 14:32:00" muted />
|
<PopupRow label="데이터 수신" value={receivedAt} muted />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Buttons */}
|
{/* Buttons */}
|
||||||
@ -1636,7 +1659,13 @@ const TAB_LABELS: { key: DetTab; label: string }[] = [
|
|||||||
{ key: 'dg', label: '위험물정보' },
|
{ key: 'dg', label: '위험물정보' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function VesselDetailModal({ vessel: v, onClose }: { vessel: Vessel; onClose: () => void }) {
|
function VesselDetailModal({
|
||||||
|
vessel: v,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
vessel: VesselPosition;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
const [tab, setTab] = useState<DetTab>('info');
|
const [tab, setTab] = useState<DetTab>('info');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -1665,11 +1694,13 @@ function VesselDetailModal({ vessel: v, onClose }: { vessel: Vessel; onClose: ()
|
|||||||
style={{ padding: '14px 18px' }}
|
style={{ padding: '14px 18px' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-[10px]">
|
<div className="flex items-center gap-[10px]">
|
||||||
<span className="text-lg">{v.flag}</span>
|
<span className="text-lg">{v.nationalCode ?? '🚢'}</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-title-3 font-[800] text-fg">{v.name}</div>
|
<div className="text-title-3 font-[800] text-fg">
|
||||||
|
{v.shipNm ?? '(이름 없음)'}
|
||||||
|
</div>
|
||||||
<div className="text-caption text-fg-disabled font-mono">
|
<div className="text-caption text-fg-disabled font-mono">
|
||||||
MMSI: {v.mmsi} · IMO: {v.imo}
|
MMSI: {v.mmsi} · IMO: {displayVal(v.imo)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1817,7 +1848,10 @@ function StatusBadge({ label, color }: { label: string; color: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tab 0: 상세정보 ─────────────────────────────── */
|
/* ── 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@ -1829,25 +1863,25 @@ function TabInfo({ v }: { v: Vessel }) {
|
|||||||
|
|
||||||
<Sec title="📡 실시간 현황">
|
<Sec title="📡 실시간 현황">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Cell label="선박상태" value={v.status} />
|
<Cell label="선박상태" value={displayVal(v.status)} />
|
||||||
<Cell
|
<Cell
|
||||||
label="속도 / 항로"
|
label="속도 / 항로"
|
||||||
value={`${v.speed} kn / ${v.heading}°`}
|
value={`${speed} / ${headingText}`}
|
||||||
color="var(--color-accent)"
|
color="var(--color-accent)"
|
||||||
/>
|
/>
|
||||||
<Cell label="위도" value={`${v.lat.toFixed(4)}°N`} />
|
<Cell label="위도" value={`${v.lat.toFixed(4)}°N`} />
|
||||||
<Cell label="경도" value={`${v.lng.toFixed(4)}°E`} />
|
<Cell label="경도" value={`${v.lon.toFixed(4)}°E`} />
|
||||||
<Cell label="흘수" value={`${v.draft}m`} />
|
<Cell label="흘수" value={v.draught !== undefined ? `${v.draught} m` : '-'} />
|
||||||
<Cell label="수신시간" value="2026-02-25 14:30" />
|
<Cell label="수신시간" value={v.lastUpdate ? formatDateTime(v.lastUpdate) : '-'} />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Sec>
|
</Sec>
|
||||||
|
|
||||||
<Sec title="🚢 항해 일정">
|
<Sec title="🚢 항해 일정">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Cell label="출항지" value={v.depart} />
|
<Cell label="출항지" value="-" />
|
||||||
<Cell label="입항지" value={v.arrive} />
|
<Cell label="입항지" value={displayVal(v.destination)} />
|
||||||
<Cell label="출항일시" value={v.etd || '—'} />
|
<Cell label="출항일시" value="-" />
|
||||||
<Cell label="입항일시(ETA)" value={v.eta || '—'} />
|
<Cell label="입항일시(ETA)" value="-" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Sec>
|
</Sec>
|
||||||
</>
|
</>
|
||||||
@ -1856,7 +1890,7 @@ function TabInfo({ v }: { v: Vessel }) {
|
|||||||
|
|
||||||
/* ── Tab 1: 항해정보 ─────────────────────────────── */
|
/* ── Tab 1: 항해정보 ─────────────────────────────── */
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// 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 hours = ['08', '09', '10', '11', '12', '13', '14'];
|
||||||
const heights = [45, 60, 78, 82, 70, 85, 75];
|
const heights = [45, 60, 78, 82, 70, 85, 75];
|
||||||
const colors = [
|
const colors = [
|
||||||
@ -1979,28 +2013,30 @@ function TabNav(_props: { v: Vessel }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tab 2: 선박제원 ─────────────────────────────── */
|
/* ── 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sec title="📐 선체 제원">
|
<Sec title="📐 선체 제원">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Cell label="선종" value={v.typS} />
|
<Cell label="선종" value={displayVal(getShipKindLabel(v.shipKindCode) ?? v.shipTy)} />
|
||||||
<Cell label="선적국" value={`${v.flag}`} />
|
<Cell label="선적국" value={displayVal(v.nationalCode)} />
|
||||||
<Cell label="총톤수 (GT)" value={v.gt} />
|
<Cell label="총톤수 (GT)" value="-" />
|
||||||
<Cell label="재화중량 (DWT)" value={v.dwt} />
|
<Cell label="재화중량 (DWT)" value="-" />
|
||||||
<Cell label="전장 (LOA)" value={v.loa} />
|
<Cell label="전장 (LOA)" value={loa} />
|
||||||
<Cell label="선폭" value={v.beam} />
|
<Cell label="선폭" value={beam} />
|
||||||
<Cell label="건조년도" value={v.built} />
|
<Cell label="건조년도" value="-" />
|
||||||
<Cell label="건조 조선소" value={v.yard} />
|
<Cell label="건조 조선소" value="-" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Sec>
|
</Sec>
|
||||||
|
|
||||||
<Sec title="📡 통신 / 식별">
|
<Sec title="📡 통신 / 식별">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Cell label="MMSI" value={String(v.mmsi)} />
|
<Cell label="MMSI" value={String(v.mmsi)} />
|
||||||
<Cell label="IMO" value={v.imo} />
|
<Cell label="IMO" value={displayVal(v.imo)} />
|
||||||
<Cell label="호출부호" value={v.callSign} />
|
<Cell label="호출부호" value="-" />
|
||||||
<Cell label="선급" value={v.cls} />
|
<Cell label="선급" value="-" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Sec>
|
</Sec>
|
||||||
|
|
||||||
@ -2016,23 +2052,9 @@ function TabSpec({ v }: { v: Vessel }) {
|
|||||||
>
|
>
|
||||||
<span className="text-label-1">🛢</span>
|
<span className="text-label-1">🛢</span>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-caption font-semibold text-fg">
|
<div className="text-caption font-semibold text-fg">-</div>
|
||||||
{v.cargo.split('·')[0].trim()}
|
<div className="text-caption text-fg-disabled">정보 없음</div>
|
||||||
</div>
|
|
||||||
<div className="text-caption text-fg-disabled">{v.cargo}</div>
|
|
||||||
</div>
|
</div>
|
||||||
{v.cargo.includes('IMO') && (
|
|
||||||
<span
|
|
||||||
className="text-caption font-bold text-color-danger"
|
|
||||||
style={{
|
|
||||||
padding: '2px 6px',
|
|
||||||
background: 'color-mix(in srgb, var(--color-danger) 15%, transparent)',
|
|
||||||
borderRadius: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
위험
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Sec>
|
</Sec>
|
||||||
@ -2042,7 +2064,7 @@ function TabSpec({ v }: { v: Vessel }) {
|
|||||||
|
|
||||||
/* ── Tab 3: 보험정보 ─────────────────────────────── */
|
/* ── Tab 3: 보험정보 ─────────────────────────────── */
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
function TabInsurance(_props: { v: Vessel }) {
|
function TabInsurance(_props: { v: VesselPosition }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sec title="🏢 선주 / 운항사">
|
<Sec title="🏢 선주 / 운항사">
|
||||||
@ -2114,7 +2136,8 @@ function TabInsurance(_props: { v: Vessel }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tab 4: 위험물정보 ───────────────────────────── */
|
/* ── Tab 4: 위험물정보 ───────────────────────────── */
|
||||||
function TabDangerous({ v }: { v: Vessel }) {
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
function TabDangerous(_props: { v: VesselPosition }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sec
|
<Sec
|
||||||
@ -2134,11 +2157,7 @@ function TabDangerous({ v }: { v: Vessel }) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Grid>
|
<Grid>
|
||||||
<Cell
|
<Cell label="화물명" value="-" color="var(--color-warning)" />
|
||||||
label="화물명"
|
|
||||||
value={v.cargo.split('·')[0].trim() || '—'}
|
|
||||||
color="var(--color-warning)"
|
|
||||||
/>
|
|
||||||
<Cell label="컨테이너갯수/총량" value="— / 72,850 톤" />
|
<Cell label="컨테이너갯수/총량" value="— / 72,850 톤" />
|
||||||
<Cell label="하역업체코드" value="KRY-2847" />
|
<Cell label="하역업체코드" value="KRY-2847" />
|
||||||
<Cell label="하역기간" value="02-26 ~ 02-28" />
|
<Cell label="하역기간" value="02-26 ~ 02-28" />
|
||||||
@ -2308,18 +2327,22 @@ function ActionBtn({
|
|||||||
/* ════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════
|
||||||
호버 툴팁 컴포넌트
|
호버 툴팁 컴포넌트
|
||||||
════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════ */
|
||||||
function VesselTooltipContent({ vessel: v }: { vessel: Vessel }) {
|
function VesselTooltipContent({ vessel: v }: { vessel: VesselPosition }) {
|
||||||
|
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '- kn';
|
||||||
|
const heading = v.heading ?? v.cog;
|
||||||
|
const headingText = heading !== undefined ? `HDG ${Math.round(heading)}°` : 'HDG -';
|
||||||
|
const typeText = [getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-', v.nationalCode].filter(Boolean).join(' · ');
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="text-label-2 font-bold text-fg" style={{ marginBottom: 3 }}>
|
<div className="text-label-2 font-bold text-fg" style={{ marginBottom: 3 }}>
|
||||||
{v.name}
|
{v.shipNm ?? '(이름 없음)'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-caption text-fg-disabled" style={{ marginBottom: 4 }}>
|
<div className="text-caption text-fg-disabled" style={{ marginBottom: 4 }}>
|
||||||
{v.typS} · {v.flag}
|
{typeText}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-caption">
|
<div className="flex justify-between text-caption">
|
||||||
<span className="text-color-accent font-semibold">{v.speed} kn</span>
|
<span className="text-color-accent font-semibold">{speed}</span>
|
||||||
<span className="text-fg-disabled">HDG {v.heading}°</span>
|
<span className="text-fg-disabled">{headingText}</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -114,6 +114,7 @@ export function LeftPanel({
|
|||||||
onLayerColorChange,
|
onLayerColorChange,
|
||||||
sensitiveResources = [],
|
sensitiveResources = [],
|
||||||
onImageAnalysisResult,
|
onImageAnalysisResult,
|
||||||
|
onFlyToCoord,
|
||||||
validationErrors,
|
validationErrors,
|
||||||
}: LeftPanelProps) {
|
}: LeftPanelProps) {
|
||||||
const [expandedSections, setExpandedSections] = useState<ExpandedSections>({
|
const [expandedSections, setExpandedSections] = useState<ExpandedSections>({
|
||||||
@ -168,6 +169,7 @@ export function LeftPanel({
|
|||||||
spillUnit={spillUnit}
|
spillUnit={spillUnit}
|
||||||
onSpillUnitChange={onSpillUnitChange}
|
onSpillUnitChange={onSpillUnitChange}
|
||||||
onImageAnalysisResult={onImageAnalysisResult}
|
onImageAnalysisResult={onImageAnalysisResult}
|
||||||
|
onFlyToCoord={onFlyToCoord}
|
||||||
validationErrors={validationErrors}
|
validationErrors={validationErrors}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
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 { LeftPanel } from './LeftPanel';
|
||||||
import { RightPanel } from './RightPanel';
|
import { RightPanel } from './RightPanel';
|
||||||
import { MapView } from '@common/components/map/MapView';
|
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 [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
|
||||||
const flyToTarget = null;
|
const flyToTarget = null;
|
||||||
const fitBoundsTarget = null;
|
const fitBoundsTarget = null;
|
||||||
|
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
||||||
|
const vessels = useVesselSignals(mapBounds);
|
||||||
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
||||||
const [oilTrajectory, setOilTrajectory] = useState<OilParticle[]>([]);
|
const [oilTrajectory, setOilTrajectory] = useState<OilParticle[]>([]);
|
||||||
const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([]);
|
const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([]);
|
||||||
@ -1211,6 +1215,9 @@ export function OilSpillView() {
|
|||||||
onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))}
|
onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))}
|
||||||
sensitiveResources={sensitiveResourceCategories}
|
sensitiveResources={sensitiveResourceCategories}
|
||||||
onImageAnalysisResult={handleImageAnalysisResult}
|
onImageAnalysisResult={handleImageAnalysisResult}
|
||||||
|
onFlyToCoord={(c: { lon: number; lat: number }) =>
|
||||||
|
setFlyToCoord({ lat: c.lat, lon: c.lon })
|
||||||
|
}
|
||||||
validationErrors={validationErrors}
|
validationErrors={validationErrors}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -1325,6 +1332,8 @@ export function OilSpillView() {
|
|||||||
showBeached={displayControls.showBeached}
|
showBeached={displayControls.showBeached}
|
||||||
showTimeLabel={displayControls.showTimeLabel}
|
showTimeLabel={displayControls.showTimeLabel}
|
||||||
simulationStartTime={accidentTime || undefined}
|
simulationStartTime={accidentTime || undefined}
|
||||||
|
vessels={vessels}
|
||||||
|
onBoundsChange={setMapBounds}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 타임라인 플레이어 (리플레이 비활성 시) */}
|
{/* 타임라인 플레이어 (리플레이 비활성 시) */}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { ComboBox } from '@common/components/ui/ComboBox';
|
import { ComboBox } from '@common/components/ui/ComboBox';
|
||||||
import type { PredictionModel } from './OilSpillView';
|
import type { PredictionModel } from './OilSpillView';
|
||||||
import { analyzeImage } from '../services/predictionApi';
|
import { analyzeImage, fetchGscAccidents } from '../services/predictionApi';
|
||||||
import type { ImageAnalyzeResult } from '../services/predictionApi';
|
import type { ImageAnalyzeResult, GscAccidentListItem } from '../services/predictionApi';
|
||||||
|
|
||||||
interface PredictionInputSectionProps {
|
interface PredictionInputSectionProps {
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
@ -33,6 +33,7 @@ interface PredictionInputSectionProps {
|
|||||||
spillUnit: string;
|
spillUnit: string;
|
||||||
onSpillUnitChange: (unit: string) => void;
|
onSpillUnitChange: (unit: string) => void;
|
||||||
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void;
|
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void;
|
||||||
|
onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
|
||||||
validationErrors?: Set<string>;
|
validationErrors?: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,6 +65,7 @@ const PredictionInputSection = ({
|
|||||||
spillUnit,
|
spillUnit,
|
||||||
onSpillUnitChange,
|
onSpillUnitChange,
|
||||||
onImageAnalysisResult,
|
onImageAnalysisResult,
|
||||||
|
onFlyToCoord,
|
||||||
validationErrors,
|
validationErrors,
|
||||||
}: PredictionInputSectionProps) => {
|
}: PredictionInputSectionProps) => {
|
||||||
const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct');
|
const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct');
|
||||||
@ -71,8 +73,41 @@ const PredictionInputSection = ({
|
|||||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
const [analyzeError, setAnalyzeError] = useState<string | null>(null);
|
const [analyzeError, setAnalyzeError] = useState<string | null>(null);
|
||||||
const [analyzeResult, setAnalyzeResult] = useState<ImageAnalyzeResult | null>(null);
|
const [analyzeResult, setAnalyzeResult] = useState<ImageAnalyzeResult | null>(null);
|
||||||
|
const [gscAccidents, setGscAccidents] = useState<GscAccidentListItem[]>([]);
|
||||||
|
const [selectedGscMngNo, setSelectedGscMngNo] = useState<string>('');
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0] ?? null;
|
const file = e.target.files?.[0] ?? null;
|
||||||
setUploadedFile(file);
|
setUploadedFile(file);
|
||||||
@ -161,7 +196,13 @@ const PredictionInputSection = ({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" readOnly />
|
<ComboBox
|
||||||
|
className="prd-i"
|
||||||
|
value={selectedGscMngNo}
|
||||||
|
onChange={handleGscAccidentSelect}
|
||||||
|
options={gscOptions}
|
||||||
|
placeholder="또는 사고 리스트에서 선택"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Image Upload Mode */}
|
{/* Image Upload Mode */}
|
||||||
{inputMode === 'upload' && (
|
{inputMode === 'upload' && (
|
||||||
|
|||||||
@ -62,6 +62,8 @@ export interface LeftPanelProps {
|
|||||||
sensitiveResources?: SensitiveResourceCategory[];
|
sensitiveResources?: SensitiveResourceCategory[];
|
||||||
// 이미지 분석 결과 콜백
|
// 이미지 분석 결과 콜백
|
||||||
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void;
|
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void;
|
||||||
|
// 사고 리스트 선택 시 지도 이동 콜백
|
||||||
|
onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
|
||||||
// 유효성 검증 에러 필드
|
// 유효성 검증 에러 필드
|
||||||
validationErrors?: Set<string>;
|
validationErrors?: Set<string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -321,3 +321,20 @@ export const analyzeImage = async (file: File, acdntNm?: string): Promise<ImageA
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GSC 외부 수집 사고 목록 (확산 예측 입력 셀렉트용)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface GscAccidentListItem {
|
||||||
|
acdntMngNo: string;
|
||||||
|
pollNm: string;
|
||||||
|
pollDate: string | null;
|
||||||
|
lat: number | null;
|
||||||
|
lon: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchGscAccidents = async (): Promise<GscAccidentListItem[]> => {
|
||||||
|
const response = await api.get<GscAccidentListItem[]>('/gsc/accidents');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import { Fragment, useState, useEffect, useCallback } from 'react';
|
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 { useSubMenu } from '@common/hooks/useSubMenu';
|
||||||
import { MapView } from '@common/components/map/MapView';
|
import { MapView } from '@common/components/map/MapView';
|
||||||
import { RescueTheoryView } from './RescueTheoryView';
|
import { RescueTheoryView } from './RescueTheoryView';
|
||||||
import { RescueScenarioView } from './RescueScenarioView';
|
import { RescueScenarioView } from './RescueScenarioView';
|
||||||
import { fetchRescueOps } from '../services/rescueApi';
|
import { fetchRescueOps } from '../services/rescueApi';
|
||||||
import type { RescueOpsItem } from '../services/rescueApi';
|
import type { RescueOpsItem } from '../services/rescueApi';
|
||||||
import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi';
|
import { fetchGscAccidents } from '@tabs/prediction/services/predictionApi';
|
||||||
import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi';
|
import type { GscAccidentListItem } from '@tabs/prediction/services/predictionApi';
|
||||||
|
|
||||||
/* ─── Types ─── */
|
/* ─── Types ─── */
|
||||||
type AccidentType =
|
type AccidentType =
|
||||||
@ -230,9 +232,9 @@ function LeftPanel({
|
|||||||
}: {
|
}: {
|
||||||
activeType: AccidentType;
|
activeType: AccidentType;
|
||||||
onTypeChange: (t: AccidentType) => void;
|
onTypeChange: (t: AccidentType) => void;
|
||||||
incidents: IncidentListItem[];
|
incidents: GscAccidentListItem[];
|
||||||
selectedAcdnt: IncidentListItem | null;
|
selectedAcdnt: GscAccidentListItem | null;
|
||||||
onSelectAcdnt: (item: IncidentListItem | null) => void;
|
onSelectAcdnt: (item: GscAccidentListItem | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const [acdntName, setAcdntName] = useState('');
|
const [acdntName, setAcdntName] = useState('');
|
||||||
const [acdntDate, setAcdntDate] = useState('');
|
const [acdntDate, setAcdntDate] = useState('');
|
||||||
@ -242,18 +244,25 @@ function LeftPanel({
|
|||||||
const [showList, setShowList] = useState(false);
|
const [showList, setShowList] = useState(false);
|
||||||
|
|
||||||
// 사고 선택 시 필드 자동 채움
|
// 사고 선택 시 필드 자동 채움
|
||||||
const handlePickIncident = (item: IncidentListItem) => {
|
const handlePickIncident = (item: GscAccidentListItem) => {
|
||||||
onSelectAcdnt(item);
|
onSelectAcdnt(item);
|
||||||
setAcdntName(item.acdntNm);
|
setAcdntName(item.pollNm);
|
||||||
const dt = new Date(item.occrnDtm);
|
if (item.pollDate) {
|
||||||
setAcdntDate(
|
const [d, t] = item.pollDate.split('T');
|
||||||
`${dt.getFullYear()}. ${String(dt.getMonth() + 1).padStart(2, '0')}. ${String(dt.getDate()).padStart(2, '0')}.`,
|
if (d) {
|
||||||
);
|
const [y, m, day] = d.split('-');
|
||||||
setAcdntTime(
|
setAcdntDate(`${y}. ${m}. ${day}.`);
|
||||||
`${dt.getHours() >= 12 ? '오후' : '오전'} ${String(dt.getHours() % 12 || 12).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`,
|
}
|
||||||
);
|
if (t) {
|
||||||
setAcdntLat(String(item.lat));
|
const [hhStr, mmStr] = t.split(':');
|
||||||
setAcdntLon(String(item.lng));
|
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);
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<span className={selectedAcdnt ? 'text-fg' : 'text-fg-disabled/50'}>
|
<span className={selectedAcdnt ? 'text-fg' : 'text-fg-disabled/50'}>
|
||||||
{selectedAcdnt ? selectedAcdnt.acdntCd : '또는 사고 리스트에서 선택'}
|
{selectedAcdnt ? selectedAcdnt.pollNm : '또는 사고 리스트에서 선택'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-fg-disabled text-[10px]">{showList ? '▲' : '▼'}</span>
|
<span className="text-fg-disabled text-[10px]">{showList ? '▲' : '▼'}</span>
|
||||||
</button>
|
</button>
|
||||||
@ -296,13 +305,13 @@ function LeftPanel({
|
|||||||
)}
|
)}
|
||||||
{incidents.map((item) => (
|
{incidents.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.acdntSn}
|
key={item.acdntMngNo}
|
||||||
onClick={() => handlePickIncident(item)}
|
onClick={() => handlePickIncident(item)}
|
||||||
className="w-full text-left px-2 py-1.5 text-caption font-korean hover:bg-bg-surface cursor-pointer border-b border-stroke last:border-b-0"
|
className="w-full text-left px-2 py-1.5 text-caption font-korean hover:bg-bg-surface cursor-pointer border-b border-stroke last:border-b-0"
|
||||||
>
|
>
|
||||||
<div className="text-fg font-semibold truncate">{item.acdntNm}</div>
|
<div className="text-fg font-semibold truncate">{item.pollNm}</div>
|
||||||
<div className="text-fg-disabled text-[10px]">
|
<div className="text-fg-disabled text-[10px]">
|
||||||
{item.acdntCd} · {item.regionNm}
|
{item.pollDate ? item.pollDate.replace('T', ' ') : '-'}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@ -1523,13 +1532,16 @@ export function RescueView() {
|
|||||||
const { activeSubTab } = useSubMenu('rescue');
|
const { activeSubTab } = useSubMenu('rescue');
|
||||||
const [activeType, setActiveType] = useState<AccidentType>('collision');
|
const [activeType, setActiveType] = useState<AccidentType>('collision');
|
||||||
const [activeAnalysis, setActiveAnalysis] = useState<AnalysisTab>('rescue');
|
const [activeAnalysis, setActiveAnalysis] = useState<AnalysisTab>('rescue');
|
||||||
const [incidents, setIncidents] = useState<IncidentListItem[]>([]);
|
const [incidents, setIncidents] = useState<GscAccidentListItem[]>([]);
|
||||||
const [selectedAcdnt, setSelectedAcdnt] = useState<IncidentListItem | null>(null);
|
const [selectedAcdnt, setSelectedAcdnt] = useState<GscAccidentListItem | null>(null);
|
||||||
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(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 [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
||||||
|
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
||||||
|
const vessels = useVesselSignals(mapBounds);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchIncidentsRaw()
|
fetchGscAccidents()
|
||||||
.then((items) => setIncidents(items))
|
.then((items) => setIncidents(items))
|
||||||
.catch(() => setIncidents([]));
|
.catch(() => setIncidents([]));
|
||||||
}, []);
|
}, []);
|
||||||
@ -1540,24 +1552,17 @@ export function RescueView() {
|
|||||||
setIsSelectingLocation(false);
|
setIsSelectingLocation(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 사고 선택 시 사고유형 자동 매핑
|
// 사고 선택 시 좌표 자동 반영 + 지도 이동
|
||||||
const handleSelectAcdnt = useCallback((item: IncidentListItem | null) => {
|
const handleSelectAcdnt = useCallback(
|
||||||
setSelectedAcdnt(item);
|
(item: GscAccidentListItem | null) => {
|
||||||
if (item) {
|
setSelectedAcdnt(item);
|
||||||
const typeMap: Record<string, AccidentType> = {
|
if (item && item.lat != null && item.lon != null) {
|
||||||
collision: 'collision',
|
setIncidentCoord({ lon: item.lon, lat: item.lat });
|
||||||
grounding: 'grounding',
|
setFlyToCoord({ lon: item.lon, lat: item.lat });
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (activeSubTab === 'list') {
|
if (activeSubTab === 'list') {
|
||||||
return (
|
return (
|
||||||
@ -1592,11 +1597,15 @@ export function RescueView() {
|
|||||||
<div className="flex-1 relative overflow-hidden">
|
<div className="flex-1 relative overflow-hidden">
|
||||||
<MapView
|
<MapView
|
||||||
incidentCoord={incidentCoord ?? undefined}
|
incidentCoord={incidentCoord ?? undefined}
|
||||||
|
flyToIncident={flyToCoord}
|
||||||
|
onIncidentFlyEnd={() => setFlyToCoord(undefined)}
|
||||||
isSelectingLocation={isSelectingLocation}
|
isSelectingLocation={isSelectingLocation}
|
||||||
onMapClick={handleMapClick}
|
onMapClick={handleMapClick}
|
||||||
oilTrajectory={[]}
|
oilTrajectory={[]}
|
||||||
enabledLayers={new Set()}
|
enabledLayers={new Set()}
|
||||||
showOverlays={false}
|
showOverlays={false}
|
||||||
|
vessels={vessels}
|
||||||
|
onBoundsChange={setMapBounds}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<RightPanel
|
<RightPanel
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user