Compare commits
14 커밋
20d5c08bc7
...
988cc47e9f
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| 988cc47e9f | |||
| 0daae3c807 | |||
| fa5c7f518f | |||
| 72ead1140f | |||
| 938665e323 | |||
| 29c5293ce7 | |||
| ae0a17990b | |||
| 6b19d34e5b | |||
| 679649ab8c | |||
| 279dcbc0e1 | |||
| 2fe9deeabe | |||
| 388116aa88 | |||
| 3eb66e2e54 | |||
| 15ca946a00 |
@ -5,7 +5,30 @@
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(*)"
|
||||
"Bash(*)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(npm test *)",
|
||||
"Bash(npx *)",
|
||||
"Bash(node *)",
|
||||
"Bash(git status)",
|
||||
"Bash(git diff *)",
|
||||
"Bash(git log *)",
|
||||
"Bash(git branch *)",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(git pull *)",
|
||||
"Bash(git fetch *)",
|
||||
"Bash(git merge *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(git remote *)",
|
||||
"Bash(git config *)",
|
||||
"Bash(git rev-parse *)",
|
||||
"Bash(git show *)",
|
||||
"Bash(git tag *)",
|
||||
"Bash(curl -s *)",
|
||||
"Bash(fnm *)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push --force*)",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"applied_global_version": "1.6.1",
|
||||
"applied_date": "2026-03-31",
|
||||
"applied_date": "2026-04-14",
|
||||
"project_type": "react-ts",
|
||||
"gitea_url": "https://gitea.gc-si.dev",
|
||||
"custom_pre_commit": true
|
||||
|
||||
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"
|
||||
@ -368,7 +368,7 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis
|
||||
// OIL INFERENCE (GPU 서버 프록시)
|
||||
// ============================================================
|
||||
|
||||
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001';
|
||||
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://211.208.115.83:5001';
|
||||
const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090';
|
||||
const INFERENCE_TIMEOUT_MS = 10_000;
|
||||
|
||||
|
||||
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 assetsRouter from './assets/assetsRouter.js'
|
||||
import incidentsRouter from './incidents/incidentsRouter.js'
|
||||
import gscAccidentsRouter from './gsc/gscAccidentsRouter.js'
|
||||
import scatRouter from './scat/scatRouter.js'
|
||||
import predictionRouter from './prediction/predictionRouter.js'
|
||||
import aerialRouter from './aerial/aerialRouter.js'
|
||||
import rescueRouter from './rescue/rescueRouter.js'
|
||||
import mapBaseRouter from './map-base/mapBaseRouter.js'
|
||||
import monitorRouter from './monitor/monitorRouter.js'
|
||||
import vesselRouter from './vessels/vesselRouter.js'
|
||||
import { startVesselScheduler } from './vessels/vesselScheduler.js'
|
||||
import {
|
||||
sanitizeBody,
|
||||
sanitizeQuery,
|
||||
@ -168,6 +171,7 @@ app.use('/api/hns', hnsRouter)
|
||||
app.use('/api/reports', reportsRouter)
|
||||
app.use('/api/assets', assetsRouter)
|
||||
app.use('/api/incidents', incidentsRouter)
|
||||
app.use('/api/gsc/accidents', gscAccidentsRouter)
|
||||
app.use('/api/scat', scatRouter)
|
||||
app.use('/api/prediction', predictionRouter)
|
||||
app.use('/api/aerial', aerialRouter)
|
||||
@ -175,6 +179,7 @@ app.use('/api/rescue', rescueRouter)
|
||||
app.use('/api/map-base', mapBaseRouter)
|
||||
app.use('/api/monitor', monitorRouter)
|
||||
app.use('/api/tiles', tilesRouter)
|
||||
app.use('/api/vessels', vesselRouter)
|
||||
|
||||
// 헬스 체크
|
||||
app.get('/health', (_req, res) => {
|
||||
@ -210,6 +215,9 @@ app.use((err: Error, _req: express.Request, res: express.Response, _next: expres
|
||||
app.listen(PORT, async () => {
|
||||
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
|
||||
|
||||
// 선박 신호 스케줄러 시작 (한국 전 해역 1분 폴링)
|
||||
startVesselScheduler()
|
||||
|
||||
// wing DB 연결 확인 (wing + auth 스키마 통합)
|
||||
const connected = await testWingDbConnection()
|
||||
if (connected) {
|
||||
|
||||
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,6 +4,16 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-04-15]
|
||||
|
||||
### 추가
|
||||
- 확산예측·HNS 대기확산·긴급구난: GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (사고명·발생시각·위경도 자동 입력 + 지도 이동)
|
||||
- 실시간 선박 신호 지도 표출: 한국 해역 1분 주기 폴링 스케줄러, 호버 툴팁·클릭 팝업·상세 모달 제공 (확산예측·HNS·긴급구난·사건사고 탭 연동)
|
||||
|
||||
### 변경
|
||||
- MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용
|
||||
- aerial 이미지 분석 API 기본 URL 변경
|
||||
|
||||
## [2026-04-14]
|
||||
|
||||
### 추가
|
||||
|
||||
311
frontend/src/common/components/map/BaseMap.tsx
Normal file
311
frontend/src/common/components/map/BaseMap.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
import {
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { Map, useMap } from '@vis.gl/react-maplibre';
|
||||
import type { MapLayerMouseEvent } from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||||
import { useMapStore } from '@common/store/mapStore';
|
||||
import { useMeasureTool } from '@common/hooks/useMeasureTool';
|
||||
import { S57EncOverlay } from './S57EncOverlay';
|
||||
import { MeasureOverlay } from './MeasureOverlay';
|
||||
import { DeckGLOverlay } from './DeckGLOverlay';
|
||||
import { buildMeasureLayers } from './measureLayers';
|
||||
|
||||
const DEFAULT_CENTER: [number, number] = [37.39, 126.64];
|
||||
const DEFAULT_ZOOM = 10;
|
||||
|
||||
export interface BaseMapProps {
|
||||
/** 초기 중심 좌표 [lat, lng]. 기본: 인천 송도 */
|
||||
center?: [number, number];
|
||||
/** 초기 줌 레벨. 기본: 10 */
|
||||
zoom?: number;
|
||||
/** 지도 클릭 핸들러 (측정 모드 중에는 호출되지 않음) */
|
||||
onMapClick?: (lon: number, lat: number) => void;
|
||||
/** 줌 변경 핸들러. ScatMap 등 줌 기반 레이어 스케일 조정에 사용 */
|
||||
onZoom?: (zoom: number) => void;
|
||||
/** 커서 스타일 (예: 'crosshair'). 기본: 'grab' */
|
||||
cursor?: string;
|
||||
/** false 시 컨트롤 UI·좌표 표시를 숨김 (캡처 전용 모드). 기본: true */
|
||||
showOverlays?: boolean;
|
||||
/** 지도 캡처 함수 ref (Reports 탭 전용) */
|
||||
mapCaptureRef?: MutableRefObject<(() => Promise<string | null>) | null>;
|
||||
/** 탭별 고유 오버레이·마커·팝업 등 */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
// ─── 3D 모드 pitch/bearing 제어 ────────────────────────────────────────────
|
||||
function MapPitchController({ threeD }: { threeD: boolean }) {
|
||||
const { current: map } = useMap();
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
map.easeTo(
|
||||
threeD ? { pitch: 45, bearing: -17, duration: 800 } : { pitch: 0, bearing: 0, duration: 800 },
|
||||
);
|
||||
}, [threeD, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── 지도 캡처 지원 ────────────────────────────────────────────────────────
|
||||
function MapCaptureSetup({
|
||||
captureRef,
|
||||
}: {
|
||||
captureRef: MutableRefObject<(() => Promise<string | null>) | null>;
|
||||
}) {
|
||||
const { current: map } = useMap();
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
captureRef.current = () =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
map.once('render', () => {
|
||||
try {
|
||||
const src = map.getCanvas();
|
||||
const maxW = 1200;
|
||||
const scale = src.width > maxW ? maxW / src.width : 1;
|
||||
const composite = document.createElement('canvas');
|
||||
composite.width = Math.round(src.width * scale);
|
||||
composite.height = Math.round(src.height * scale);
|
||||
const ctx = composite.getContext('2d')!;
|
||||
ctx.fillStyle = '#0f1117';
|
||||
ctx.fillRect(0, 0, composite.width, composite.height);
|
||||
ctx.drawImage(src, 0, 0, composite.width, composite.height);
|
||||
resolve(composite.toDataURL('image/jpeg', 0.82));
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
map.triggerRepaint();
|
||||
});
|
||||
}, [map, captureRef]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── 공통 컨트롤 UI + 좌표 표시 ───────────────────────────────────────────
|
||||
// Map 내부에 렌더링되어 useMap()으로 인스턴스에 접근함.
|
||||
// 줌 버튼·지도 타입·측정 도구·좌표 표시를 하나의 컴포넌트로 통합.
|
||||
function MapOverlayControls({
|
||||
initialCenter,
|
||||
initialZoom,
|
||||
}: {
|
||||
initialCenter: [number, number];
|
||||
initialZoom: number;
|
||||
}) {
|
||||
const { current: map } = useMap();
|
||||
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||
const toggleMap = useMapStore((s) => s.toggleMap);
|
||||
const measureMode = useMapStore((s) => s.measureMode);
|
||||
const setMeasureMode = useMapStore((s) => s.setMeasureMode);
|
||||
|
||||
const [pos, setPos] = useState({
|
||||
lat: initialCenter[0],
|
||||
lng: initialCenter[1],
|
||||
zoom: initialZoom,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
const update = () => {
|
||||
const c = map.getCenter();
|
||||
setPos({ lat: c.lat, lng: c.lng, zoom: map.getZoom() });
|
||||
};
|
||||
update();
|
||||
map.on('move', update);
|
||||
map.on('zoom', update);
|
||||
return () => {
|
||||
map.off('move', update);
|
||||
map.off('zoom', update);
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
const btn =
|
||||
'w-[28px] h-[28px] bg-[color-mix(in_srgb,var(--bg-elevated)_85%,transparent)] ' +
|
||||
'backdrop-blur-sm border border-stroke rounded-sm text-fg-sub flex items-center justify-center ' +
|
||||
'hover:bg-bg-surface-hover hover:text-fg transition-all text-caption select-none cursor-pointer';
|
||||
const btnOn = 'text-color-accent border-color-accent bg-[rgba(6,182,212,0.08)]';
|
||||
|
||||
// 좌표·축척 계산
|
||||
const { lat, lng, zoom } = pos;
|
||||
const latDir = lat >= 0 ? 'N' : 'S';
|
||||
const lngDir = lng >= 0 ? 'E' : 'W';
|
||||
const mpp = (40075016.686 * Math.cos((lat * Math.PI) / 180)) / (256 * Math.pow(2, zoom));
|
||||
const sr = Math.round(mpp * (96 / 0.0254));
|
||||
const scaleLabel =
|
||||
sr >= 1_000_000 ? `1:${(sr / 1_000_000).toFixed(1)}M` : `1:${sr.toLocaleString()}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 좌측 컨트롤 컬럼 */}
|
||||
<div className="absolute top-[80px] left-[10px] z-10 flex flex-col gap-1">
|
||||
{/* 줌 */}
|
||||
<button title="줌 인" onClick={() => map?.zoomIn()} className={btn}>
|
||||
+
|
||||
</button>
|
||||
<button title="줌 아웃" onClick={() => map?.zoomOut()} className={btn}>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
title="초기 위치로"
|
||||
onClick={() =>
|
||||
map?.flyTo({
|
||||
center: [initialCenter[1], initialCenter[0]],
|
||||
zoom: initialZoom,
|
||||
duration: 1000,
|
||||
})
|
||||
}
|
||||
className={btn}
|
||||
style={{ fontSize: '10px' }}
|
||||
>
|
||||
⊙
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-stroke my-0.5" />
|
||||
|
||||
{/* 지도 타입 */}
|
||||
<button
|
||||
title="3D 위성 모드"
|
||||
onClick={() => toggleMap('threeD')}
|
||||
className={`${btn} ${mapToggles.threeD ? btnOn : ''}`}
|
||||
style={{ fontSize: '9px', fontWeight: 600, letterSpacing: '-0.3px' }}
|
||||
>
|
||||
3D
|
||||
</button>
|
||||
<button
|
||||
title="ENC 전자해도"
|
||||
onClick={() => toggleMap('s57')}
|
||||
className={`${btn} ${mapToggles.s57 ? btnOn : ''}`}
|
||||
style={{ fontSize: '8px', fontWeight: 600 }}
|
||||
>
|
||||
ENC
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-stroke my-0.5" />
|
||||
|
||||
{/* 측정 도구 */}
|
||||
<button
|
||||
title="거리 측정"
|
||||
onClick={() => setMeasureMode(measureMode === 'distance' ? null : 'distance')}
|
||||
className={`${btn} ${measureMode === 'distance' ? btnOn : ''}`}
|
||||
style={{ fontSize: '12px' }}
|
||||
>
|
||||
📏
|
||||
</button>
|
||||
<button
|
||||
title="면적 측정"
|
||||
onClick={() => setMeasureMode(measureMode === 'area' ? null : 'area')}
|
||||
className={`${btn} ${measureMode === 'area' ? btnOn : ''}`}
|
||||
style={{ fontSize: '11px' }}
|
||||
>
|
||||
⬜
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 좌표 표시 (좌하단) */}
|
||||
<div className="cod">
|
||||
<span>
|
||||
위도{' '}
|
||||
<span className="cov">
|
||||
{Math.abs(lat).toFixed(4)}°{latDir}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
경도{' '}
|
||||
<span className="cov">
|
||||
{Math.abs(lng).toFixed(4)}°{lngDir}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
축척 <span className="cov">{scaleLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── BaseMap ───────────────────────────────────────────────────────────────
|
||||
export function BaseMap({
|
||||
center = DEFAULT_CENTER,
|
||||
zoom = DEFAULT_ZOOM,
|
||||
onMapClick,
|
||||
onZoom,
|
||||
cursor,
|
||||
showOverlays = true,
|
||||
mapCaptureRef,
|
||||
children,
|
||||
}: BaseMapProps) {
|
||||
const mapStyle = useBaseMapStyle();
|
||||
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||
const measureMode = useMapStore((s) => s.measureMode);
|
||||
const measureInProgress = useMapStore((s) => s.measureInProgress);
|
||||
const measurements = useMapStore((s) => s.measurements);
|
||||
const { handleMeasureClick } = useMeasureTool();
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: MapLayerMouseEvent) => {
|
||||
const { lng, lat } = e.lngLat;
|
||||
if (measureMode !== null) {
|
||||
handleMeasureClick(lng, lat);
|
||||
return;
|
||||
}
|
||||
onMapClick?.(lng, lat);
|
||||
},
|
||||
[measureMode, handleMeasureClick, onMapClick],
|
||||
);
|
||||
|
||||
const handleZoom = useCallback(
|
||||
(e: { viewState: { zoom: number } }) => {
|
||||
onZoom?.(e.viewState.zoom);
|
||||
},
|
||||
[onZoom],
|
||||
);
|
||||
|
||||
const measureDeckLayers = useMemo(
|
||||
() => buildMeasureLayers(measureInProgress, measureMode, measurements),
|
||||
[measureInProgress, measureMode, measurements],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
<Map
|
||||
initialViewState={{ longitude: center[1], latitude: center[0], zoom }}
|
||||
mapStyle={mapStyle}
|
||||
className="w-full h-full"
|
||||
onClick={handleClick}
|
||||
onZoom={handleZoom}
|
||||
style={{ cursor: cursor ?? 'grab' }}
|
||||
attributionControl={false}
|
||||
preserveDrawingBuffer={true}
|
||||
>
|
||||
{/* 공통 오버레이 */}
|
||||
<S57EncOverlay visible={mapToggles.s57 ?? false} />
|
||||
<MapPitchController threeD={mapToggles.threeD ?? false} />
|
||||
<DeckGLOverlay layers={measureDeckLayers} />
|
||||
<MeasureOverlay />
|
||||
{mapCaptureRef && <MapCaptureSetup captureRef={mapCaptureRef} />}
|
||||
|
||||
{/* 공통 컨트롤 UI (줌·지도 타입·측정·좌표) */}
|
||||
{showOverlays && <MapOverlayControls initialCenter={center} initialZoom={zoom} />}
|
||||
|
||||
{/* 탭별 주입 */}
|
||||
{children}
|
||||
</Map>
|
||||
|
||||
{/* 측정 모드 힌트 */}
|
||||
{showOverlays && measureMode === 'distance' && (
|
||||
<div className="boom-drawing-indicator">
|
||||
거리 재기 — {measureInProgress.length === 0 ? '시작점을 클릭하세요' : '끝점을 클릭하세요'}
|
||||
</div>
|
||||
)}
|
||||
{showOverlays && measureMode === 'area' && (
|
||||
<div className="boom-drawing-indicator">
|
||||
면적 재기 — 꼭짓점을 클릭하세요 ({measureInProgress.length}개)
|
||||
{measureInProgress.length >= 3 && ' → 첫 번째 점 근처를 클릭하면 닫힙니다'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
frontend/src/common/components/map/DeckGLOverlay.tsx
Normal file
16
frontend/src/common/components/map/DeckGLOverlay.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { useControl } from '@vis.gl/react-maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
|
||||
interface DeckGLOverlayProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
layers: Layer<any>[];
|
||||
}
|
||||
|
||||
/** deck.gl 레이어를 MapLibre에 interleaved 방식으로 통합하는 공통 컴포넌트.
|
||||
* 반드시 <Map> 자식으로 사용해야 한다. */
|
||||
export function DeckGLOverlay({ layers }: DeckGLOverlayProps) {
|
||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
||||
overlay.setProps({ layers });
|
||||
return null;
|
||||
}
|
||||
26
frontend/src/common/components/map/FlyToController.tsx
Normal file
26
frontend/src/common/components/map/FlyToController.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useMap } from '@vis.gl/react-maplibre';
|
||||
|
||||
interface FlyToControllerProps {
|
||||
target?: { lng: number; lat: number; zoom?: number } | null;
|
||||
/** 이동 애니메이션 시간(ms). 기본: 1000 */
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/** 지도 특정 좌표로 flyTo 트리거 컴포넌트.
|
||||
* target이 바뀔 때마다 flyTo를 실행한다.
|
||||
* 반드시 <Map> 자식으로 사용해야 한다. */
|
||||
export function FlyToController({ target, duration = 1000 }: FlyToControllerProps) {
|
||||
const { current: map } = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !target) return;
|
||||
map.flyTo({
|
||||
center: [target.lng, target.lat],
|
||||
zoom: target.zoom ?? 10,
|
||||
duration,
|
||||
});
|
||||
}, [target, map, duration]);
|
||||
|
||||
return null;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { Map, Marker, Popup, Source, Layer, useMap } from '@vis.gl/react-maplibre';
|
||||
import {
|
||||
ScatterplotLayer,
|
||||
PathLayer,
|
||||
@ -28,8 +27,19 @@ import { useMeasureTool } from '@common/hooks/useMeasureTool';
|
||||
import { hexToRgba } from './mapUtils';
|
||||
import { S57EncOverlay } from './S57EncOverlay';
|
||||
import { SrOverlay } from './SrOverlay';
|
||||
import { DeckGLOverlay } from './DeckGLOverlay';
|
||||
import { FlyToController } from './FlyToController';
|
||||
import { useMapStore } from '@common/store/mapStore';
|
||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||||
import { buildVesselLayers } from './VesselLayer';
|
||||
import { MapBoundsTracker } from './MapBoundsTracker';
|
||||
import {
|
||||
VesselHoverTooltip,
|
||||
VesselPopupPanel,
|
||||
VesselDetailModal,
|
||||
type VesselHoverInfo,
|
||||
} from './VesselInteraction';
|
||||
import type { VesselPosition, MapBounds } from '@common/types/vessel';
|
||||
|
||||
const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080';
|
||||
|
||||
@ -126,6 +136,7 @@ interface MapViewProps {
|
||||
dispersionResult?: DispersionResult | null;
|
||||
dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>;
|
||||
boomLines?: BoomLine[];
|
||||
showBoomLines?: boolean;
|
||||
isDrawingBoom?: boolean;
|
||||
drawingPoints?: BoomLineCoord[];
|
||||
layerOpacity?: number;
|
||||
@ -163,33 +174,15 @@ interface MapViewProps {
|
||||
analysisCircleRadiusM?: number;
|
||||
/** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */
|
||||
showOverlays?: boolean;
|
||||
/** 선박 신호 목록 (실시간 표출) */
|
||||
vessels?: VesselPosition[];
|
||||
/** 지도 뷰포트 bounds 변경 콜백 (선박 신호 필터링에 사용) */
|
||||
onBoundsChange?: (bounds: MapBounds) => void;
|
||||
}
|
||||
|
||||
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
||||
overlay.setProps({ layers });
|
||||
return null;
|
||||
}
|
||||
// DeckGLOverlay, FlyToController → @common/components/map/DeckGLOverlay, FlyToController 에서 import
|
||||
|
||||
// flyTo 트리거 컴포넌트 (Map 내부에서 useMap() 사용)
|
||||
function FlyToController({
|
||||
flyToTarget,
|
||||
}: {
|
||||
flyToTarget?: { lng: number; lat: number; zoom?: number } | null;
|
||||
}) {
|
||||
const { current: map } = useMap();
|
||||
useEffect(() => {
|
||||
if (!map || !flyToTarget) return;
|
||||
map.flyTo({
|
||||
center: [flyToTarget.lng, flyToTarget.lat],
|
||||
zoom: flyToTarget.zoom ?? 10,
|
||||
duration: 1200,
|
||||
});
|
||||
}, [flyToTarget, map]);
|
||||
return null;
|
||||
}
|
||||
// MapBoundsTracker는 './MapBoundsTracker' 모듈로 추출됨 (IncidentsView 등 BaseMap 직접 사용처에서도 재사용)
|
||||
|
||||
// fitBounds 트리거 컴포넌트 (Map 내부에서 useMap() 사용)
|
||||
function FitBoundsController({
|
||||
@ -341,6 +334,7 @@ export function MapView({
|
||||
dispersionResult = null,
|
||||
dispersionHeatmap = [],
|
||||
boomLines = [],
|
||||
showBoomLines = true,
|
||||
isDrawingBoom = false,
|
||||
drawingPoints = [],
|
||||
layerOpacity = 50,
|
||||
@ -368,6 +362,8 @@ export function MapView({
|
||||
analysisCircleCenter,
|
||||
analysisCircleRadiusM = 0,
|
||||
showOverlays = true,
|
||||
vessels = [],
|
||||
onBoundsChange,
|
||||
}: MapViewProps) {
|
||||
const lightMode = true;
|
||||
const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore();
|
||||
@ -386,6 +382,10 @@ export function MapView({
|
||||
const persistentPopupRef = useRef(false);
|
||||
// 현재 호버 중인 민감자원 feature properties (handleMapClick에서 팝업 생성에 사용)
|
||||
const hoveredSensitiveRef = useRef<Record<string, unknown> | null>(null);
|
||||
// 선박 호버/클릭 상호작용 상태
|
||||
const [vesselHover, setVesselHover] = useState<VesselHoverInfo | null>(null);
|
||||
const [selectedVessel, setSelectedVessel] = useState<VesselPosition | null>(null);
|
||||
const [detailVessel, setDetailVessel] = useState<VesselPosition | null>(null);
|
||||
const currentTime = isControlled ? externalCurrentTime : internalCurrentTime;
|
||||
|
||||
const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => {
|
||||
@ -587,7 +587,7 @@ export function MapView({
|
||||
}
|
||||
|
||||
// --- 오일펜스 라인 (PathLayer) ---
|
||||
if (boomLines.length > 0) {
|
||||
if (showBoomLines && boomLines.length > 0) {
|
||||
result.push(
|
||||
new PathLayer({
|
||||
id: 'boom-lines',
|
||||
@ -1237,12 +1237,30 @@ export function MapView({
|
||||
// 거리/면적 측정 레이어
|
||||
result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements));
|
||||
|
||||
// 선박 신호 레이어
|
||||
result.push(
|
||||
...buildVesselLayers(
|
||||
vessels,
|
||||
{
|
||||
onClick: (vessel) => {
|
||||
setSelectedVessel(vessel);
|
||||
setDetailVessel(null);
|
||||
},
|
||||
onHover: (vessel, x, y) => {
|
||||
setVesselHover(vessel ? { x, y, vessel } : null);
|
||||
},
|
||||
},
|
||||
mapZoom,
|
||||
),
|
||||
);
|
||||
|
||||
return result.filter(Boolean);
|
||||
}, [
|
||||
oilTrajectory,
|
||||
currentTime,
|
||||
selectedModels,
|
||||
boomLines,
|
||||
showBoomLines,
|
||||
isDrawingBoom,
|
||||
drawingPoints,
|
||||
dispersionResult,
|
||||
@ -1261,6 +1279,8 @@ export function MapView({
|
||||
analysisCircleCenter,
|
||||
analysisCircleRadiusM,
|
||||
lightMode,
|
||||
vessels,
|
||||
mapZoom,
|
||||
]);
|
||||
|
||||
// 3D 모드 / 테마에 따른 지도 스타일 전환
|
||||
@ -1295,9 +1315,11 @@ export function MapView({
|
||||
{/* 사고 지점 변경 시 지도 이동 */}
|
||||
<MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} />
|
||||
{/* 외부에서 flyTo 트리거 */}
|
||||
<FlyToController flyToTarget={flyToTarget} />
|
||||
<FlyToController target={flyToTarget} duration={1200} />
|
||||
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
|
||||
<FitBoundsController fitBoundsTarget={fitBoundsTarget} />
|
||||
{/* 선박 신호 뷰포트 bounds 추적 */}
|
||||
<MapBoundsTracker onBoundsChange={onBoundsChange} />
|
||||
|
||||
{/* S-57 전자해도 오버레이 (공식 style.json 기반) */}
|
||||
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||
@ -1448,6 +1470,26 @@ export function MapView({
|
||||
ships={backtrackReplay.ships}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 선박 호버 툴팁 */}
|
||||
{vesselHover && !selectedVessel && <VesselHoverTooltip hover={vesselHover} />}
|
||||
|
||||
{/* 선박 클릭 팝업 */}
|
||||
{selectedVessel && !detailVessel && (
|
||||
<VesselPopupPanel
|
||||
vessel={selectedVessel}
|
||||
onClose={() => setSelectedVessel(null)}
|
||||
onDetail={() => {
|
||||
setDetailVessel(selectedVessel);
|
||||
setSelectedVessel(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 선박 상세 모달 */}
|
||||
{detailVessel && (
|
||||
<VesselDetailModal vessel={detailVessel} onClose={() => setDetailVessel(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
mmsi: number;
|
||||
imo: string;
|
||||
name: string;
|
||||
typS: string;
|
||||
flag: string;
|
||||
status: string;
|
||||
speed: number;
|
||||
heading: number;
|
||||
lat: number;
|
||||
lng: number;
|
||||
draft: number;
|
||||
depart: string;
|
||||
arrive: string;
|
||||
etd: string;
|
||||
eta: string;
|
||||
gt: string;
|
||||
dwt: string;
|
||||
loa: string;
|
||||
beam: string;
|
||||
built: string;
|
||||
yard: string;
|
||||
callSign: string;
|
||||
cls: string;
|
||||
cargo: string;
|
||||
color: string;
|
||||
markerType: string;
|
||||
}
|
||||
|
||||
export const VESSEL_TYPE_COLORS: Record<string, string> = {
|
||||
Tanker: '#ef4444',
|
||||
Chemical: '#ef4444',
|
||||
Cargo: '#22c55e',
|
||||
Bulk: '#22c55e',
|
||||
Container: '#3b82f6',
|
||||
Passenger: '#a855f7',
|
||||
Fishing: '#f97316',
|
||||
Tug: '#06b6d4',
|
||||
Navy: '#6b7280',
|
||||
Sailing: '#fbbf24',
|
||||
};
|
||||
|
||||
export const VESSEL_LEGEND = [
|
||||
{ type: 'Tanker', color: '#ef4444' },
|
||||
{ type: 'Cargo', color: '#22c55e' },
|
||||
{ type: 'Container', color: '#3b82f6' },
|
||||
{ type: 'Fishing', color: '#f97316' },
|
||||
{ type: 'Passenger', color: '#a855f7' },
|
||||
{ type: 'Tug', color: '#06b6d4' },
|
||||
];
|
||||
|
||||
export const mockVessels: Vessel[] = [
|
||||
{
|
||||
mmsi: 440123456,
|
||||
imo: '9812345',
|
||||
name: 'HANKUK CHEMI',
|
||||
typS: 'Tanker',
|
||||
flag: '🇰🇷',
|
||||
status: '항해중',
|
||||
speed: 8.2,
|
||||
heading: 330,
|
||||
lat: 34.6,
|
||||
lng: 127.5,
|
||||
draft: 5.8,
|
||||
depart: '여수항',
|
||||
arrive: '부산항',
|
||||
etd: '2026-02-25 08:00',
|
||||
eta: '2026-02-25 18:30',
|
||||
gt: '29,246',
|
||||
dwt: '49,999',
|
||||
loa: '183.0m',
|
||||
beam: '32.2m',
|
||||
built: '2018',
|
||||
yard: '현대미포조선',
|
||||
callSign: 'HLKC',
|
||||
cls: '한국선급(KR)',
|
||||
cargo: 'BUNKER-C · 1,200kL · IMO Class 3',
|
||||
color: '#ef4444',
|
||||
markerType: 'tanker',
|
||||
},
|
||||
{
|
||||
mmsi: 440234567,
|
||||
imo: '9823456',
|
||||
name: 'DONG-A GLAUCOS',
|
||||
typS: 'Cargo',
|
||||
flag: '🇰🇷',
|
||||
status: '항해중',
|
||||
speed: 11.4,
|
||||
heading: 245,
|
||||
lat: 34.78,
|
||||
lng: 127.8,
|
||||
draft: 7.2,
|
||||
depart: '울산항',
|
||||
arrive: '광양항',
|
||||
etd: '2026-02-25 06:30',
|
||||
eta: '2026-02-25 16:00',
|
||||
gt: '12,450',
|
||||
dwt: '18,800',
|
||||
loa: '144.0m',
|
||||
beam: '22.6m',
|
||||
built: '2015',
|
||||
yard: 'STX조선',
|
||||
callSign: 'HLDG',
|
||||
cls: '한국선급(KR)',
|
||||
cargo: '철강재 · 4,500t',
|
||||
color: '#22c55e',
|
||||
markerType: 'cargo',
|
||||
},
|
||||
{
|
||||
mmsi: 440345678,
|
||||
imo: '9834567',
|
||||
name: 'HMM ALGECIRAS',
|
||||
typS: 'Container',
|
||||
flag: '🇰🇷',
|
||||
status: '항해중',
|
||||
speed: 18.5,
|
||||
heading: 195,
|
||||
lat: 35.0,
|
||||
lng: 128.8,
|
||||
draft: 14.5,
|
||||
depart: '부산항',
|
||||
arrive: '싱가포르',
|
||||
etd: '2026-02-25 04:00',
|
||||
eta: '2026-03-02 08:00',
|
||||
gt: '228,283',
|
||||
dwt: '223,092',
|
||||
loa: '399.9m',
|
||||
beam: '61.0m',
|
||||
built: '2020',
|
||||
yard: '대우조선해양',
|
||||
callSign: 'HLHM',
|
||||
cls: "Lloyd's Register",
|
||||
cargo: '컨테이너 · 16,420 TEU',
|
||||
color: '#3b82f6',
|
||||
markerType: 'container',
|
||||
},
|
||||
{
|
||||
mmsi: 355678901,
|
||||
imo: '9756789',
|
||||
name: 'STELLAR DAISY',
|
||||
typS: 'Tanker',
|
||||
flag: '🇵🇦',
|
||||
status: '⚠ 사고(좌초)',
|
||||
speed: 0.0,
|
||||
heading: 0,
|
||||
lat: 34.72,
|
||||
lng: 127.72,
|
||||
draft: 8.1,
|
||||
depart: '여수항',
|
||||
arrive: '—',
|
||||
etd: '2026-01-18 12:00',
|
||||
eta: '—',
|
||||
gt: '35,120',
|
||||
dwt: '58,000',
|
||||
loa: '190.0m',
|
||||
beam: '34.0m',
|
||||
built: '2012',
|
||||
yard: 'CSBC Taiwan',
|
||||
callSign: '3FZA7',
|
||||
cls: 'NK',
|
||||
cargo: 'BUNKER-C · 150kL 유출 · ⚠ 사고선박',
|
||||
color: '#ef4444',
|
||||
markerType: 'tanker',
|
||||
},
|
||||
{
|
||||
mmsi: 440456789,
|
||||
imo: '—',
|
||||
name: '제72 금양호',
|
||||
typS: 'Fishing',
|
||||
flag: '🇰🇷',
|
||||
status: '조업중',
|
||||
speed: 4.1,
|
||||
heading: 120,
|
||||
lat: 34.55,
|
||||
lng: 127.35,
|
||||
draft: 2.1,
|
||||
depart: '여수 국동항',
|
||||
arrive: '여수 국동항',
|
||||
etd: '2026-02-25 04:30',
|
||||
eta: '2026-02-25 18:00',
|
||||
gt: '78',
|
||||
dwt: '—',
|
||||
loa: '24.5m',
|
||||
beam: '6.2m',
|
||||
built: '2008',
|
||||
yard: '통영조선',
|
||||
callSign: '—',
|
||||
cls: '한국선급',
|
||||
cargo: '어획물',
|
||||
color: '#f97316',
|
||||
markerType: 'fishing',
|
||||
},
|
||||
{
|
||||
mmsi: 440567890,
|
||||
imo: '9867890',
|
||||
name: 'PAN OCEAN GLORY',
|
||||
typS: 'Bulk',
|
||||
flag: '🇰🇷',
|
||||
status: '항해중',
|
||||
speed: 12.8,
|
||||
heading: 170,
|
||||
lat: 35.6,
|
||||
lng: 126.4,
|
||||
draft: 10.3,
|
||||
depart: '군산항',
|
||||
arrive: '포항항',
|
||||
etd: '2026-02-25 07:00',
|
||||
eta: '2026-02-26 04:00',
|
||||
gt: '43,800',
|
||||
dwt: '76,500',
|
||||
loa: '229.0m',
|
||||
beam: '32.3m',
|
||||
built: '2019',
|
||||
yard: '현대삼호중공업',
|
||||
callSign: 'HLPO',
|
||||
cls: '한국선급(KR)',
|
||||
cargo: '석탄 · 65,000t',
|
||||
color: '#22c55e',
|
||||
markerType: 'cargo',
|
||||
},
|
||||
{
|
||||
mmsi: 440678901,
|
||||
imo: '—',
|
||||
name: '여수예인1호',
|
||||
typS: 'Tug',
|
||||
flag: '🇰🇷',
|
||||
status: '방제지원',
|
||||
speed: 6.3,
|
||||
heading: 355,
|
||||
lat: 34.68,
|
||||
lng: 127.6,
|
||||
draft: 3.2,
|
||||
depart: '여수항',
|
||||
arrive: '사고현장',
|
||||
etd: '2026-01-18 16:30',
|
||||
eta: '—',
|
||||
gt: '280',
|
||||
dwt: '—',
|
||||
loa: '32.0m',
|
||||
beam: '9.5m',
|
||||
built: '2016',
|
||||
yard: '삼성중공업',
|
||||
callSign: 'HLYT',
|
||||
cls: '한국선급',
|
||||
cargo: '방제장비 · 오일붐 500m',
|
||||
color: '#06b6d4',
|
||||
markerType: 'tug',
|
||||
},
|
||||
{
|
||||
mmsi: 235012345,
|
||||
imo: '9456789',
|
||||
name: 'QUEEN MARY',
|
||||
typS: 'Passenger',
|
||||
flag: '🇬🇧',
|
||||
status: '항해중',
|
||||
speed: 15.2,
|
||||
heading: 10,
|
||||
lat: 33.8,
|
||||
lng: 127.0,
|
||||
draft: 8.5,
|
||||
depart: '상하이',
|
||||
arrive: '부산항',
|
||||
etd: '2026-02-24 18:00',
|
||||
eta: '2026-02-26 06:00',
|
||||
gt: '148,528',
|
||||
dwt: '18,000',
|
||||
loa: '345.0m',
|
||||
beam: '41.0m',
|
||||
built: '2004',
|
||||
yard: "Chantiers de l'Atlantique",
|
||||
callSign: 'GBQM2',
|
||||
cls: "Lloyd's Register",
|
||||
cargo: '승객 2,620명',
|
||||
color: '#a855f7',
|
||||
markerType: 'passenger',
|
||||
},
|
||||
{
|
||||
mmsi: 353012345,
|
||||
imo: '9811000',
|
||||
name: 'EVER GIVEN',
|
||||
typS: 'Container',
|
||||
flag: '🇹🇼',
|
||||
status: '항해중',
|
||||
speed: 14.7,
|
||||
heading: 220,
|
||||
lat: 35.2,
|
||||
lng: 129.2,
|
||||
draft: 15.7,
|
||||
depart: '부산항',
|
||||
arrive: '카오슝',
|
||||
etd: '2026-02-25 02:00',
|
||||
eta: '2026-02-28 14:00',
|
||||
gt: '220,940',
|
||||
dwt: '199,629',
|
||||
loa: '400.0m',
|
||||
beam: '59.0m',
|
||||
built: '2018',
|
||||
yard: '今治造船',
|
||||
callSign: 'BIXE9',
|
||||
cls: 'ABS',
|
||||
cargo: '컨테이너 · 14,800 TEU',
|
||||
color: '#3b82f6',
|
||||
markerType: 'container',
|
||||
},
|
||||
{
|
||||
mmsi: 440789012,
|
||||
imo: '—',
|
||||
name: '제85 대성호',
|
||||
typS: 'Fishing',
|
||||
flag: '🇰🇷',
|
||||
status: '조업중',
|
||||
speed: 3.8,
|
||||
heading: 85,
|
||||
lat: 34.4,
|
||||
lng: 126.3,
|
||||
draft: 1.8,
|
||||
depart: '목포항',
|
||||
arrive: '목포항',
|
||||
etd: '2026-02-25 03:00',
|
||||
eta: '2026-02-25 17:00',
|
||||
gt: '65',
|
||||
dwt: '—',
|
||||
loa: '22.0m',
|
||||
beam: '5.8m',
|
||||
built: '2010',
|
||||
yard: '목포조선',
|
||||
callSign: '—',
|
||||
cls: '한국선급',
|
||||
cargo: '어획물',
|
||||
color: '#f97316',
|
||||
markerType: 'fishing',
|
||||
},
|
||||
{
|
||||
mmsi: 440890123,
|
||||
imo: '9878901',
|
||||
name: 'SK INNOVATION',
|
||||
typS: 'Chemical',
|
||||
flag: '🇰🇷',
|
||||
status: '항해중',
|
||||
speed: 9.6,
|
||||
heading: 340,
|
||||
lat: 35.8,
|
||||
lng: 126.6,
|
||||
draft: 6.5,
|
||||
depart: '대산항',
|
||||
arrive: '여수항',
|
||||
etd: '2026-02-25 10:00',
|
||||
eta: '2026-02-26 02:00',
|
||||
gt: '11,200',
|
||||
dwt: '16,800',
|
||||
loa: '132.0m',
|
||||
beam: '20.4m',
|
||||
built: '2020',
|
||||
yard: '현대미포조선',
|
||||
callSign: 'HLSK',
|
||||
cls: '한국선급(KR)',
|
||||
cargo: '톨루엔 · 8,500kL · IMO Class 3',
|
||||
color: '#ef4444',
|
||||
markerType: 'tanker',
|
||||
},
|
||||
{
|
||||
mmsi: 440901234,
|
||||
imo: '9889012',
|
||||
name: 'KOREA EXPRESS',
|
||||
typS: 'Cargo',
|
||||
flag: '🇰🇷',
|
||||
status: '항해중',
|
||||
speed: 10.1,
|
||||
heading: 190,
|
||||
lat: 36.2,
|
||||
lng: 128.5,
|
||||
draft: 6.8,
|
||||
depart: '동해항',
|
||||
arrive: '포항항',
|
||||
etd: '2026-02-25 09:00',
|
||||
eta: '2026-02-25 15:00',
|
||||
gt: '8,500',
|
||||
dwt: '12,000',
|
||||
loa: '118.0m',
|
||||
beam: '18.2m',
|
||||
built: '2014',
|
||||
yard: '대한조선',
|
||||
callSign: 'HLKE',
|
||||
cls: '한국선급',
|
||||
cargo: '일반화물',
|
||||
color: '#22c55e',
|
||||
markerType: 'cargo',
|
||||
},
|
||||
{
|
||||
mmsi: 440012345,
|
||||
imo: '—',
|
||||
name: 'ROKS SEJONG',
|
||||
typS: 'Navy',
|
||||
flag: '🇰🇷',
|
||||
status: '작전중',
|
||||
speed: 16.0,
|
||||
heading: 270,
|
||||
lat: 35.3,
|
||||
lng: 129.5,
|
||||
draft: 6.3,
|
||||
depart: '부산 해군기지',
|
||||
arrive: '—',
|
||||
etd: '—',
|
||||
eta: '—',
|
||||
gt: '7,600',
|
||||
dwt: '—',
|
||||
loa: '165.9m',
|
||||
beam: '21.4m',
|
||||
built: '2008',
|
||||
yard: '현대중공업',
|
||||
callSign: 'HLNS',
|
||||
cls: '군용',
|
||||
cargo: '군사작전',
|
||||
color: '#6b7280',
|
||||
markerType: 'military',
|
||||
},
|
||||
{
|
||||
mmsi: 440023456,
|
||||
imo: '—',
|
||||
name: '군산예인3호',
|
||||
typS: 'Tug',
|
||||
flag: '🇰🇷',
|
||||
status: '대기중',
|
||||
speed: 5.5,
|
||||
heading: 140,
|
||||
lat: 35.9,
|
||||
lng: 126.9,
|
||||
draft: 2.8,
|
||||
depart: '군산항',
|
||||
arrive: '군산항',
|
||||
etd: '—',
|
||||
eta: '—',
|
||||
gt: '180',
|
||||
dwt: '—',
|
||||
loa: '28.0m',
|
||||
beam: '8.2m',
|
||||
built: '2019',
|
||||
yard: '통영조선',
|
||||
callSign: 'HLGS',
|
||||
cls: '한국선급',
|
||||
cargo: '—',
|
||||
color: '#06b6d4',
|
||||
markerType: 'tug',
|
||||
},
|
||||
{
|
||||
mmsi: 440034567,
|
||||
imo: '—',
|
||||
name: 'JEJU WIND',
|
||||
typS: 'Sailing',
|
||||
flag: '🇰🇷',
|
||||
status: '항해중',
|
||||
speed: 6.8,
|
||||
heading: 290,
|
||||
lat: 33.35,
|
||||
lng: 126.65,
|
||||
draft: 2.5,
|
||||
depart: '제주항',
|
||||
arrive: '제주항',
|
||||
etd: '2026-02-25 10:00',
|
||||
eta: '2026-02-25 16:00',
|
||||
gt: '45',
|
||||
dwt: '—',
|
||||
loa: '18.0m',
|
||||
beam: '5.0m',
|
||||
built: '2022',
|
||||
yard: '제주요트',
|
||||
callSign: '—',
|
||||
cls: '—',
|
||||
cargo: '—',
|
||||
color: '#fbbf24',
|
||||
markerType: 'sail',
|
||||
},
|
||||
{
|
||||
mmsi: 440045678,
|
||||
imo: '—',
|
||||
name: '제33 삼양호',
|
||||
typS: 'Fishing',
|
||||
flag: '🇰🇷',
|
||||
status: '조업중',
|
||||
speed: 2.4,
|
||||
heading: 55,
|
||||
lat: 35.1,
|
||||
lng: 127.4,
|
||||
draft: 1.6,
|
||||
depart: '통영항',
|
||||
arrive: '통영항',
|
||||
etd: '2026-02-25 05:00',
|
||||
eta: '2026-02-25 19:00',
|
||||
gt: '52',
|
||||
dwt: '—',
|
||||
loa: '20.0m',
|
||||
beam: '5.4m',
|
||||
built: '2006',
|
||||
yard: '거제조선',
|
||||
callSign: '—',
|
||||
cls: '한국선급',
|
||||
cargo: '어획물',
|
||||
color: '#f97316',
|
||||
markerType: 'fishing',
|
||||
},
|
||||
{
|
||||
mmsi: 255012345,
|
||||
imo: '9703291',
|
||||
name: 'MSC OSCAR',
|
||||
typS: 'Container',
|
||||
flag: '🇨🇭',
|
||||
status: '항해중',
|
||||
speed: 17.3,
|
||||
heading: 355,
|
||||
lat: 34.1,
|
||||
lng: 128.1,
|
||||
draft: 14.0,
|
||||
depart: '카오슝',
|
||||
arrive: '부산항',
|
||||
etd: '2026-02-23 08:00',
|
||||
eta: '2026-02-25 22:00',
|
||||
gt: '197,362',
|
||||
dwt: '199,272',
|
||||
loa: '395.4m',
|
||||
beam: '59.0m',
|
||||
built: '2015',
|
||||
yard: '대우조선해양',
|
||||
callSign: '9HA4713',
|
||||
cls: 'DNV',
|
||||
cargo: '컨테이너 · 18,200 TEU',
|
||||
color: '#3b82f6',
|
||||
markerType: 'container',
|
||||
},
|
||||
{
|
||||
mmsi: 440056789,
|
||||
imo: '9890567',
|
||||
name: 'SAEHAN PIONEER',
|
||||
typS: 'Tanker',
|
||||
flag: '🇰🇷',
|
||||
status: '항해중',
|
||||
speed: 7.9,
|
||||
heading: 310,
|
||||
lat: 34.9,
|
||||
lng: 127.1,
|
||||
draft: 5.2,
|
||||
depart: '여수항',
|
||||
arrive: '대산항',
|
||||
etd: '2026-02-25 11:00',
|
||||
eta: '2026-02-26 08:00',
|
||||
gt: '8,900',
|
||||
dwt: '14,200',
|
||||
loa: '120.0m',
|
||||
beam: '18.0m',
|
||||
built: '2017',
|
||||
yard: '현대미포조선',
|
||||
callSign: 'HLSP',
|
||||
cls: '한국선급(KR)',
|
||||
cargo: '경유 · 10,000kL',
|
||||
color: '#ef4444',
|
||||
markerType: 'tanker',
|
||||
},
|
||||
{
|
||||
mmsi: 440067890,
|
||||
imo: '9891678',
|
||||
name: 'DONGHAE STAR',
|
||||
typS: 'Cargo',
|
||||
flag: '🇰🇷',
|
||||
status: '항해중',
|
||||
speed: 11.0,
|
||||
heading: 155,
|
||||
lat: 37.55,
|
||||
lng: 129.3,
|
||||
draft: 6.0,
|
||||
depart: '속초항',
|
||||
arrive: '동해항',
|
||||
etd: '2026-02-25 12:00',
|
||||
eta: '2026-02-25 16:30',
|
||||
gt: '6,200',
|
||||
dwt: '8,500',
|
||||
loa: '105.0m',
|
||||
beam: '16.5m',
|
||||
built: '2013',
|
||||
yard: '대한조선',
|
||||
callSign: 'HLDS',
|
||||
cls: '한국선급',
|
||||
cargo: '일반화물 · 목재',
|
||||
color: '#22c55e',
|
||||
markerType: 'cargo',
|
||||
},
|
||||
{
|
||||
mmsi: 440078901,
|
||||
imo: '—',
|
||||
name: '제18 한라호',
|
||||
typS: 'Fishing',
|
||||
flag: '🇰🇷',
|
||||
status: '귀항중',
|
||||
speed: 3.2,
|
||||
heading: 70,
|
||||
lat: 33.3,
|
||||
lng: 126.3,
|
||||
draft: 1.9,
|
||||
depart: '서귀포항',
|
||||
arrive: '서귀포항',
|
||||
etd: '2026-02-25 04:00',
|
||||
eta: '2026-02-25 15:00',
|
||||
gt: '58',
|
||||
dwt: '—',
|
||||
loa: '21.0m',
|
||||
beam: '5.6m',
|
||||
built: '2011',
|
||||
yard: '제주조선',
|
||||
callSign: '—',
|
||||
cls: '한국선급',
|
||||
cargo: '어획물 · 갈치/고등어',
|
||||
color: '#f97316',
|
||||
markerType: 'fishing',
|
||||
},
|
||||
];
|
||||
// Deprecated: Mock 선박 데이터는 제거되었습니다.
|
||||
// 실제 선박 신호는 @common/hooks/useVesselSignals + @common/components/map/VesselLayer 를 사용합니다.
|
||||
// 범례는 @common/components/map/VesselLayer 의 VESSEL_LEGEND 를 import 하세요.
|
||||
export {};
|
||||
|
||||
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();
|
||||
}
|
||||
@ -903,10 +903,10 @@
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--stroke-default);
|
||||
border-radius: 4px;
|
||||
color: var(--color-accent);
|
||||
color: var(--color-default);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-weight: 400;
|
||||
text-align: right;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
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;
|
||||
}
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -36,14 +36,7 @@ interface DeidentifyTask {
|
||||
type SourceType = 'db' | 'file' | 'api';
|
||||
type ProcessMode = 'immediate' | 'scheduled' | 'oneshot';
|
||||
type RepeatType = 'daily' | 'weekly' | 'monthly';
|
||||
type DeidentifyTechnique =
|
||||
| '마스킹'
|
||||
| '삭제'
|
||||
| '범주화'
|
||||
| '암호화'
|
||||
| '샘플링'
|
||||
| '가명처리'
|
||||
| '유지';
|
||||
type DeidentifyTechnique = '마스킹' | '삭제' | '범주화' | '암호화' | '샘플링' | '가명처리' | '유지';
|
||||
|
||||
interface FieldConfig {
|
||||
name: string;
|
||||
@ -97,24 +90,102 @@ interface WizardState {
|
||||
// ─── Mock 데이터 ────────────────────────────────────────────
|
||||
|
||||
const MOCK_TASKS: DeidentifyTask[] = [
|
||||
{ id: '001', name: 'customer_2024', target: '선박/운항 - 선장·선원 성명', status: '완료', startTime: '2026-04-10 14:30', progress: 100, createdBy: '관리자' },
|
||||
{ id: '002', name: 'transaction_04', target: '사고 현장 - 현장사진, 영상내 인물', status: '진행중', startTime: '2026-04-10 14:15', progress: 82, createdBy: '김담당' },
|
||||
{ id: '003', name: 'employee_info', target: '인사정보 - 계정, 로그인 정보', status: '대기', startTime: '2026-04-10 22:00', progress: 0, createdBy: '이담당' },
|
||||
{ id: '004', name: 'vendor_data', target: 'HNS 대응 - 화학물질 취급자, 방제업체 연락처', status: '오류', startTime: '2026-04-09 13:45', progress: 45, createdBy: '관리자' },
|
||||
{ id: '005', name: 'partner_contacts', target: '시스템 운영 - 관리자, 운영자 접속로그', status: '완료', startTime: '2026-04-08 09:00', progress: 100, createdBy: '박담당' },
|
||||
{
|
||||
id: '001',
|
||||
name: 'customer_2024',
|
||||
target: '선박/운항 - 선장·선원 성명',
|
||||
status: '완료',
|
||||
startTime: '2026-04-10 14:30',
|
||||
progress: 100,
|
||||
createdBy: '관리자',
|
||||
},
|
||||
{
|
||||
id: '002',
|
||||
name: 'transaction_04',
|
||||
target: '사고 현장 - 현장사진, 영상내 인물',
|
||||
status: '진행중',
|
||||
startTime: '2026-04-10 14:15',
|
||||
progress: 82,
|
||||
createdBy: '김담당',
|
||||
},
|
||||
{
|
||||
id: '003',
|
||||
name: 'employee_info',
|
||||
target: '인사정보 - 계정, 로그인 정보',
|
||||
status: '대기',
|
||||
startTime: '2026-04-10 22:00',
|
||||
progress: 0,
|
||||
createdBy: '이담당',
|
||||
},
|
||||
{
|
||||
id: '004',
|
||||
name: 'vendor_data',
|
||||
target: 'HNS 대응 - 화학물질 취급자, 방제업체 연락처',
|
||||
status: '오류',
|
||||
startTime: '2026-04-09 13:45',
|
||||
progress: 45,
|
||||
createdBy: '관리자',
|
||||
},
|
||||
{
|
||||
id: '005',
|
||||
name: 'partner_contacts',
|
||||
target: '시스템 운영 - 관리자, 운영자 접속로그',
|
||||
status: '완료',
|
||||
startTime: '2026-04-08 09:00',
|
||||
progress: 100,
|
||||
createdBy: '박담당',
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_FIELDS: FieldConfig[] = [
|
||||
{ name: '고객ID', dataType: '문자열', technique: '삭제', configValue: '-', selected: true },
|
||||
{ name: '이름', dataType: '문자열', technique: '마스킹', configValue: '*로 치환', selected: true },
|
||||
{ name: '휴대폰', dataType: '문자열', technique: '마스킹', configValue: '010-****-****', selected: true },
|
||||
{ name: '주소', dataType: '문자열', technique: '범주화', configValue: '시/도만 표시', selected: true },
|
||||
{ name: '이메일', dataType: '문자열', technique: '가명처리', configValue: '키: random_001', selected: true },
|
||||
{ name: '생년월일', dataType: '날짜', technique: '범주화', configValue: '연도만 표시', selected: true },
|
||||
{
|
||||
name: '이름',
|
||||
dataType: '문자열',
|
||||
technique: '마스킹',
|
||||
configValue: '*로 치환',
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
name: '휴대폰',
|
||||
dataType: '문자열',
|
||||
technique: '마스킹',
|
||||
configValue: '010-****-****',
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
name: '주소',
|
||||
dataType: '문자열',
|
||||
technique: '범주화',
|
||||
configValue: '시/도만 표시',
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
name: '이메일',
|
||||
dataType: '문자열',
|
||||
technique: '가명처리',
|
||||
configValue: '키: random_001',
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
name: '생년월일',
|
||||
dataType: '날짜',
|
||||
technique: '범주화',
|
||||
configValue: '연도만 표시',
|
||||
selected: true,
|
||||
},
|
||||
{ name: '회사', dataType: '문자열', technique: '유지', configValue: '변경 없음', selected: true },
|
||||
];
|
||||
|
||||
const TECHNIQUES: DeidentifyTechnique[] = ['마스킹', '삭제', '범주화', '암호화', '샘플링', '가명처리', '유지'];
|
||||
const TECHNIQUES: DeidentifyTechnique[] = [
|
||||
'마스킹',
|
||||
'삭제',
|
||||
'범주화',
|
||||
'암호화',
|
||||
'샘플링',
|
||||
'가명처리',
|
||||
'유지',
|
||||
];
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`);
|
||||
|
||||
@ -124,23 +195,161 @@ const TEMPLATES = ['기본 개인정보', '금융데이터', '의료데이터'];
|
||||
|
||||
const MOCK_AUDIT_LOGS: Record<string, AuditLogEntry[]> = {
|
||||
'001': [
|
||||
{ id: 'LOG_20260410_001', time: '2026-04-10 14:30:45', operator: '김철수', operatorId: 'user_12345', action: '처리완료', targetData: 'customer_2024', result: '성공 (100%)', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 15240, rulesApplied: '마스킹 3, 범주화 2, 삭제 2', processedCount: 15240, errorCount: 0 } },
|
||||
{ id: 'LOG_20260410_002', time: '2026-04-10 14:15:10', operator: '김철수', operatorId: 'user_12345', action: '처리시작', targetData: 'customer_2024', result: '성공', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 15240, rulesApplied: '마스킹 3, 범주화 2, 삭제 2', processedCount: 0, errorCount: 0 } },
|
||||
{ id: 'LOG_20260410_003', time: '2026-04-10 14:10:30', operator: '김철수', operatorId: 'user_12345', action: '규칙설정', targetData: 'customer_2024', result: '성공', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 15240, rulesApplied: '7개 규칙 적용', processedCount: 0, errorCount: 0 } },
|
||||
{
|
||||
id: 'LOG_20260410_001',
|
||||
time: '2026-04-10 14:30:45',
|
||||
operator: '김철수',
|
||||
operatorId: 'user_12345',
|
||||
action: '처리완료',
|
||||
targetData: 'customer_2024',
|
||||
result: '성공 (100%)',
|
||||
resultType: '성공',
|
||||
ip: '192.168.1.100',
|
||||
browser: 'Chrome 123.0',
|
||||
detail: {
|
||||
dataCount: 15240,
|
||||
rulesApplied: '마스킹 3, 범주화 2, 삭제 2',
|
||||
processedCount: 15240,
|
||||
errorCount: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'LOG_20260410_002',
|
||||
time: '2026-04-10 14:15:10',
|
||||
operator: '김철수',
|
||||
operatorId: 'user_12345',
|
||||
action: '처리시작',
|
||||
targetData: 'customer_2024',
|
||||
result: '성공',
|
||||
resultType: '성공',
|
||||
ip: '192.168.1.100',
|
||||
browser: 'Chrome 123.0',
|
||||
detail: {
|
||||
dataCount: 15240,
|
||||
rulesApplied: '마스킹 3, 범주화 2, 삭제 2',
|
||||
processedCount: 0,
|
||||
errorCount: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'LOG_20260410_003',
|
||||
time: '2026-04-10 14:10:30',
|
||||
operator: '김철수',
|
||||
operatorId: 'user_12345',
|
||||
action: '규칙설정',
|
||||
targetData: 'customer_2024',
|
||||
result: '성공',
|
||||
resultType: '성공',
|
||||
ip: '192.168.1.100',
|
||||
browser: 'Chrome 123.0',
|
||||
detail: { dataCount: 15240, rulesApplied: '7개 규칙 적용', processedCount: 0, errorCount: 0 },
|
||||
},
|
||||
],
|
||||
'002': [
|
||||
{ id: 'LOG_20260410_004', time: '2026-04-10 14:15:22', operator: '이영희', operatorId: 'user_23456', action: '처리시작', targetData: 'transaction_04', result: '진행중 (82%)', resultType: '진행중', ip: '192.168.1.101', browser: 'Firefox 124.0', detail: { dataCount: 8920, rulesApplied: '마스킹 2, 암호화 1, 삭제 3', processedCount: 7314, errorCount: 0 } },
|
||||
{
|
||||
id: 'LOG_20260410_004',
|
||||
time: '2026-04-10 14:15:22',
|
||||
operator: '이영희',
|
||||
operatorId: 'user_23456',
|
||||
action: '처리시작',
|
||||
targetData: 'transaction_04',
|
||||
result: '진행중 (82%)',
|
||||
resultType: '진행중',
|
||||
ip: '192.168.1.101',
|
||||
browser: 'Firefox 124.0',
|
||||
detail: {
|
||||
dataCount: 8920,
|
||||
rulesApplied: '마스킹 2, 암호화 1, 삭제 3',
|
||||
processedCount: 7314,
|
||||
errorCount: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
'003': [
|
||||
{ id: 'LOG_20260410_005', time: '2026-04-10 13:45:30', operator: '박민준', operatorId: 'user_34567', action: '규칙수정', targetData: 'employee_info', result: '성공', resultType: '성공', ip: '192.168.1.102', browser: 'Chrome 123.0', detail: { dataCount: 3200, rulesApplied: '마스킹 4, 가명처리 1', processedCount: 0, errorCount: 0 } },
|
||||
{
|
||||
id: 'LOG_20260410_005',
|
||||
time: '2026-04-10 13:45:30',
|
||||
operator: '박민준',
|
||||
operatorId: 'user_34567',
|
||||
action: '규칙수정',
|
||||
targetData: 'employee_info',
|
||||
result: '성공',
|
||||
resultType: '성공',
|
||||
ip: '192.168.1.102',
|
||||
browser: 'Chrome 123.0',
|
||||
detail: {
|
||||
dataCount: 3200,
|
||||
rulesApplied: '마스킹 4, 가명처리 1',
|
||||
processedCount: 0,
|
||||
errorCount: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
'004': [
|
||||
{ id: 'LOG_20260409_001', time: '2026-04-09 13:45:30', operator: '관리자', operatorId: 'user_admin', action: '처리오류', targetData: 'vendor_data', result: '오류 (45%)', resultType: '실패', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 5100, rulesApplied: '마스킹 2, 범주화 1, 삭제 1', processedCount: 2295, errorCount: 12 } },
|
||||
{ id: 'LOG_20260409_002', time: '2026-04-09 13:40:15', operator: '김철수', operatorId: 'user_12345', action: '규칙조회', targetData: 'vendor_data', result: '성공', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 5100, rulesApplied: '4개 규칙', processedCount: 0, errorCount: 0 } },
|
||||
{ id: 'LOG_20260409_003', time: '2026-04-09 09:25:00', operator: '이영희', operatorId: 'user_23456', action: '삭제시도', targetData: 'vendor_data', result: '거부 (권한부족)', resultType: '거부', ip: '192.168.1.101', browser: 'Firefox 124.0', detail: { dataCount: 5100, rulesApplied: '-', processedCount: 0, errorCount: 0 } },
|
||||
{
|
||||
id: 'LOG_20260409_001',
|
||||
time: '2026-04-09 13:45:30',
|
||||
operator: '관리자',
|
||||
operatorId: 'user_admin',
|
||||
action: '처리오류',
|
||||
targetData: 'vendor_data',
|
||||
result: '오류 (45%)',
|
||||
resultType: '실패',
|
||||
ip: '192.168.1.100',
|
||||
browser: 'Chrome 123.0',
|
||||
detail: {
|
||||
dataCount: 5100,
|
||||
rulesApplied: '마스킹 2, 범주화 1, 삭제 1',
|
||||
processedCount: 2295,
|
||||
errorCount: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'LOG_20260409_002',
|
||||
time: '2026-04-09 13:40:15',
|
||||
operator: '김철수',
|
||||
operatorId: 'user_12345',
|
||||
action: '규칙조회',
|
||||
targetData: 'vendor_data',
|
||||
result: '성공',
|
||||
resultType: '성공',
|
||||
ip: '192.168.1.100',
|
||||
browser: 'Chrome 123.0',
|
||||
detail: { dataCount: 5100, rulesApplied: '4개 규칙', processedCount: 0, errorCount: 0 },
|
||||
},
|
||||
{
|
||||
id: 'LOG_20260409_003',
|
||||
time: '2026-04-09 09:25:00',
|
||||
operator: '이영희',
|
||||
operatorId: 'user_23456',
|
||||
action: '삭제시도',
|
||||
targetData: 'vendor_data',
|
||||
result: '거부 (권한부족)',
|
||||
resultType: '거부',
|
||||
ip: '192.168.1.101',
|
||||
browser: 'Firefox 124.0',
|
||||
detail: { dataCount: 5100, rulesApplied: '-', processedCount: 0, errorCount: 0 },
|
||||
},
|
||||
],
|
||||
'005': [
|
||||
{ id: 'LOG_20260408_001', time: '2026-04-08 09:15:00', operator: '박담당', operatorId: 'user_45678', action: '처리완료', targetData: 'partner_contacts', result: '성공 (100%)', resultType: '성공', ip: '192.168.1.103', browser: 'Edge 122.0', detail: { dataCount: 1850, rulesApplied: '마스킹 2, 유지 3', processedCount: 1850, errorCount: 0 } },
|
||||
{
|
||||
id: 'LOG_20260408_001',
|
||||
time: '2026-04-08 09:15:00',
|
||||
operator: '박담당',
|
||||
operatorId: 'user_45678',
|
||||
action: '처리완료',
|
||||
targetData: 'partner_contacts',
|
||||
result: '성공 (100%)',
|
||||
resultType: '성공',
|
||||
ip: '192.168.1.103',
|
||||
browser: 'Edge 122.0',
|
||||
detail: {
|
||||
dataCount: 1850,
|
||||
rulesApplied: '마스킹 2, 유지 3',
|
||||
processedCount: 1850,
|
||||
errorCount: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@ -154,10 +363,14 @@ function fetchTasks(): Promise<DeidentifyTask[]> {
|
||||
|
||||
function getStatusBadgeClass(status: TaskStatus): string {
|
||||
switch (status) {
|
||||
case '완료': return 'text-emerald-400 bg-emerald-500/10';
|
||||
case '진행중': return 'text-cyan-400 bg-cyan-500/10';
|
||||
case '대기': return 'text-yellow-400 bg-yellow-500/10';
|
||||
case '오류': return 'text-red-400 bg-red-500/10';
|
||||
case '완료':
|
||||
return 'text-emerald-400 bg-emerald-500/10';
|
||||
case '진행중':
|
||||
return 'text-cyan-400 bg-cyan-500/10';
|
||||
case '대기':
|
||||
return 'text-yellow-400 bg-yellow-500/10';
|
||||
case '오류':
|
||||
return 'text-red-400 bg-red-500/10';
|
||||
}
|
||||
}
|
||||
|
||||
@ -169,7 +382,10 @@ function ProgressBar({ value }: { value: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-bg-elevated rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all ${colorClass}`} style={{ width: `${value}%` }} />
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${colorClass}`}
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-t3 w-8 text-right">{value}%</span>
|
||||
</div>
|
||||
@ -217,9 +433,16 @@ function TaskTable({ rows, loading, onAction }: TaskTableProps) {
|
||||
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||
<td className="px-3 py-2 text-t3 font-mono">{row.id}</td>
|
||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.name}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap max-w-[240px] truncate" title={row.target}>{row.target}</td>
|
||||
<td
|
||||
className="px-3 py-2 text-t2 whitespace-nowrap max-w-[240px] truncate"
|
||||
title={row.target}
|
||||
>
|
||||
{row.target}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-label-2 font-medium ${getStatusBadgeClass(row.status)}`}>
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-label-2 font-medium ${getStatusBadgeClass(row.status)}`}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
</td>
|
||||
@ -289,13 +512,18 @@ function StepIndicator({ current }: { current: number }) {
|
||||
isDone
|
||||
? 'bg-emerald-500 text-white'
|
||||
: isActive
|
||||
? 'bg-cyan-500 text-white'
|
||||
: 'bg-bg-elevated text-t3'
|
||||
? 'bg-cyan-500 text-white'
|
||||
: 'bg-bg-elevated text-t3'
|
||||
}`}
|
||||
>
|
||||
{isDone ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
stepNum
|
||||
@ -352,11 +580,13 @@ function Step1({ wizard, onChange }: Step1Props) {
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-t2 mb-2">소스 유형 *</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{([
|
||||
['db', '데이터베이스 연결'],
|
||||
['file', '파일 업로드'],
|
||||
['api', 'API 호출'],
|
||||
] as [SourceType, string][]).map(([val, label]) => (
|
||||
{(
|
||||
[
|
||||
['db', '데이터베이스 연결'],
|
||||
['file', '파일 업로드'],
|
||||
['api', 'API 호출'],
|
||||
] as [SourceType, string][]
|
||||
).map(([val, label]) => (
|
||||
<label key={val} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
@ -399,7 +629,12 @@ function Step1({ wizard, onChange }: Step1Props) {
|
||||
{wizard.sourceType === 'file' && (
|
||||
<div className="p-8 rounded border-2 border-dashed border-stroke-1 bg-bg-surface flex flex-col items-center gap-2 text-center">
|
||||
<svg className="w-8 h-8 text-t3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-xs text-t2">파일을 드래그하거나 클릭하여 업로드</p>
|
||||
<p className="text-label-2 text-t3">CSV, XLSX, JSON 지원 (최대 500MB)</p>
|
||||
@ -444,9 +679,7 @@ interface Step2Props {
|
||||
|
||||
function Step2({ wizard, onChange }: Step2Props) {
|
||||
const toggleField = (idx: number) => {
|
||||
const updated = wizard.fields.map((f, i) =>
|
||||
i === idx ? { ...f, selected: !f.selected } : f,
|
||||
);
|
||||
const updated = wizard.fields.map((f, i) => (i === idx ? { ...f, selected: !f.selected } : f));
|
||||
onChange({ fields: updated });
|
||||
};
|
||||
|
||||
@ -476,13 +709,17 @@ function Step2({ wizard, onChange }: Step2Props) {
|
||||
type="checkbox"
|
||||
checked={wizard.fields.every((f) => f.selected)}
|
||||
onChange={(e) =>
|
||||
onChange({ fields: wizard.fields.map((f) => ({ ...f, selected: e.target.checked })) })
|
||||
onChange({
|
||||
fields: wizard.fields.map((f) => ({ ...f, selected: e.target.checked })),
|
||||
})
|
||||
}
|
||||
className="accent-cyan-500"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">필드명</th>
|
||||
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">데이터 타입</th>
|
||||
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">
|
||||
데이터 타입
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -520,9 +757,7 @@ interface Step3Props {
|
||||
|
||||
function Step3({ wizard, onChange }: Step3Props) {
|
||||
const updateField = (idx: number, key: keyof FieldConfig, value: string | boolean) => {
|
||||
const updated = wizard.fields.map((f, i) =>
|
||||
i === idx ? { ...f, [key]: value } : f,
|
||||
);
|
||||
const updated = wizard.fields.map((f, i) => (i === idx ? { ...f, [key]: value } : f));
|
||||
onChange({ fields: updated });
|
||||
};
|
||||
|
||||
@ -535,8 +770,12 @@ function Step3({ wizard, onChange }: Step3Props) {
|
||||
<thead>
|
||||
<tr className="bg-bg-elevated text-t3">
|
||||
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">필드명</th>
|
||||
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">데이터타입</th>
|
||||
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">선택된 기법</th>
|
||||
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">
|
||||
데이터타입
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">
|
||||
선택된 기법
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">설정값</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -554,7 +793,9 @@ function Step3({ wizard, onChange }: Step3Props) {
|
||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
|
||||
>
|
||||
{TECHNIQUES.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
@ -593,7 +834,9 @@ function Step3({ wizard, onChange }: Step3Props) {
|
||||
>
|
||||
<option value="">선택 안 함</option>
|
||||
{TEMPLATES.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@ -652,7 +895,11 @@ function Step4({ wizard, onChange }: Step4Props) {
|
||||
onChange={(e) => handleScheduleChange('hour', e.target.value)}
|
||||
className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
|
||||
>
|
||||
{HOURS.map((h) => <option key={h} value={h}>{h}</option>)}
|
||||
{HOURS.map((h) => (
|
||||
<option key={h} value={h}>
|
||||
{h}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
@ -681,7 +928,11 @@ function Step4({ wizard, onChange }: Step4Props) {
|
||||
onChange={(e) => handleScheduleChange('weekday', e.target.value)}
|
||||
className="ml-1 px-2 py-0.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
|
||||
>
|
||||
{WEEKDAYS.map((d) => <option key={d} value={d}>{d}요일</option>)}
|
||||
{WEEKDAYS.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}요일
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
@ -738,7 +989,11 @@ function Step4({ wizard, onChange }: Step4Props) {
|
||||
onChange={(e) => handleOneshotChange('hour', e.target.value)}
|
||||
className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
|
||||
>
|
||||
{HOURS.map((h) => <option key={h} value={h}>{h}</option>)}
|
||||
{HOURS.map((h) => (
|
||||
<option key={h} value={h}>
|
||||
{h}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@ -769,7 +1024,15 @@ function Step5({ wizard, onChange }: Step5Props) {
|
||||
|
||||
const summaryRows = [
|
||||
{ label: '작업명', value: wizard.taskName || '(미입력)' },
|
||||
{ label: '소스', value: wizard.sourceType === 'db' ? `DB: ${wizard.dbConfig.database}.${wizard.dbConfig.tableName}` : wizard.sourceType === 'file' ? '파일 업로드' : `API: ${wizard.apiConfig.url}` },
|
||||
{
|
||||
label: '소스',
|
||||
value:
|
||||
wizard.sourceType === 'db'
|
||||
? `DB: ${wizard.dbConfig.database}.${wizard.dbConfig.tableName}`
|
||||
: wizard.sourceType === 'file'
|
||||
? '파일 업로드'
|
||||
: `API: ${wizard.apiConfig.url}`,
|
||||
},
|
||||
{ label: '데이터 건수', value: '15,240건' },
|
||||
{ label: '선택 필드 수', value: `${selectedCount}개` },
|
||||
{ label: '비식별화 규칙 수', value: `${ruleCount}개` },
|
||||
@ -833,10 +1096,14 @@ const INITIAL_WIZARD: WizardState = {
|
||||
|
||||
function getAuditResultClass(type: AuditLogEntry['resultType']): string {
|
||||
switch (type) {
|
||||
case '성공': return 'text-emerald-400 bg-emerald-500/10';
|
||||
case '진행중': return 'text-cyan-400 bg-cyan-500/10';
|
||||
case '실패': return 'text-red-400 bg-red-500/10';
|
||||
case '거부': return 'text-yellow-400 bg-yellow-500/10';
|
||||
case '성공':
|
||||
return 'text-emerald-400 bg-emerald-500/10';
|
||||
case '진행중':
|
||||
return 'text-cyan-400 bg-cyan-500/10';
|
||||
case '실패':
|
||||
return 'text-red-400 bg-red-500/10';
|
||||
case '거부':
|
||||
return 'text-yellow-400 bg-yellow-500/10';
|
||||
}
|
||||
}
|
||||
|
||||
@ -863,10 +1130,11 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
|
||||
<div className="bg-bg-card border border-stroke-1 rounded-lg shadow-2xl w-full max-w-5xl max-h-[85vh] flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
|
||||
<h3 className="text-sm font-semibold text-t1">
|
||||
감시 감독 (감사로그) — {task.name}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-t3 hover:text-t1 transition-colors text-lg leading-none">
|
||||
<h3 className="text-sm font-semibold text-t1">감시 감독 (감사로그) — {task.name}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-t3 hover:text-t1 transition-colors text-lg leading-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
@ -894,7 +1162,9 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
|
||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
|
||||
>
|
||||
{operators.map((op) => (
|
||||
<option key={op} value={op}>{op}</option>
|
||||
<option key={op} value={op}>
|
||||
{op}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@ -905,7 +1175,10 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
|
||||
<thead>
|
||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||
{['시간', '작업자', '작업', '대상 데이터', '결과', '상세'].map((h) => (
|
||||
<th key={h} className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap">
|
||||
<th
|
||||
key={h}
|
||||
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
@ -925,18 +1198,27 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
|
||||
className={`border-b border-stroke-1 hover:bg-bg-surface/50 cursor-pointer ${selectedLog?.id === log.id ? 'bg-bg-surface/70' : ''}`}
|
||||
onClick={() => setSelectedLog(log)}
|
||||
>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{log.time.split(' ')[1]}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
||||
{log.time.split(' ')[1]}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-t1 whitespace-nowrap">{log.operator}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap">{log.action}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{log.targetData}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
||||
{log.targetData}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-label-2 font-medium ${getAuditResultClass(log.resultType)}`}>
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-label-2 font-medium ${getAuditResultClass(log.resultType)}`}
|
||||
>
|
||||
{log.result}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedLog(log); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedLog(log);
|
||||
}}
|
||||
className="px-2 py-0.5 text-label-2 rounded bg-bg-elevated hover:bg-bg-card text-cyan-400 transition-colors whitespace-nowrap"
|
||||
>
|
||||
보기
|
||||
@ -954,15 +1236,49 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
|
||||
<div className="px-5 py-3 border-t border-stroke-1 shrink-0 bg-bg-base">
|
||||
<h4 className="text-xs font-semibold text-t1 mb-2">로그 상세 정보</h4>
|
||||
<div className="bg-bg-elevated border border-stroke-1 rounded p-3 text-xs grid grid-cols-2 gap-x-6 gap-y-1.5">
|
||||
<div><span className="text-t3">로그ID:</span> <span className="text-t1 font-mono">{selectedLog.id}</span></div>
|
||||
<div><span className="text-t3">타임스탬프:</span> <span className="text-t1 font-mono">{selectedLog.time}</span></div>
|
||||
<div><span className="text-t3">작업자:</span> <span className="text-t1">{selectedLog.operator} ({selectedLog.operatorId})</span></div>
|
||||
<div><span className="text-t3">작업 유형:</span> <span className="text-t1">{selectedLog.action}</span></div>
|
||||
<div><span className="text-t3">대상:</span> <span className="text-t1">{selectedLog.targetData} ({selectedLog.detail.dataCount.toLocaleString()}건)</span></div>
|
||||
<div><span className="text-t3">적용 규칙:</span> <span className="text-t1">{selectedLog.detail.rulesApplied}</span></div>
|
||||
<div><span className="text-t3">결과:</span> <span className="text-t1">{selectedLog.result} (처리: {selectedLog.detail.processedCount.toLocaleString()}, 오류: {selectedLog.detail.errorCount})</span></div>
|
||||
<div><span className="text-t3">IP 주소:</span> <span className="text-t1 font-mono">{selectedLog.ip}</span></div>
|
||||
<div><span className="text-t3">브라우저:</span> <span className="text-t1">{selectedLog.browser}</span></div>
|
||||
<div>
|
||||
<span className="text-t3">로그ID:</span>{' '}
|
||||
<span className="text-t1 font-mono">{selectedLog.id}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-t3">타임스탬프:</span>{' '}
|
||||
<span className="text-t1 font-mono">{selectedLog.time}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-t3">작업자:</span>{' '}
|
||||
<span className="text-t1">
|
||||
{selectedLog.operator} ({selectedLog.operatorId})
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-t3">작업 유형:</span>{' '}
|
||||
<span className="text-t1">{selectedLog.action}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-t3">대상:</span>{' '}
|
||||
<span className="text-t1">
|
||||
{selectedLog.targetData} ({selectedLog.detail.dataCount.toLocaleString()}건)
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-t3">적용 규칙:</span>{' '}
|
||||
<span className="text-t1">{selectedLog.detail.rulesApplied}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-t3">결과:</span>{' '}
|
||||
<span className="text-t1">
|
||||
{selectedLog.result} (처리: {selectedLog.detail.processedCount.toLocaleString()},
|
||||
오류: {selectedLog.detail.errorCount})
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-t3">IP 주소:</span>{' '}
|
||||
<span className="text-t1 font-mono">{selectedLog.ip}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-t3">브라우저:</span>{' '}
|
||||
<span className="text-t1">{selectedLog.browser}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -1030,7 +1346,12 @@ function WizardModal({ onClose, onSubmit }: WizardModalProps) {
|
||||
className="p-1 rounded text-t3 hover:text-t1 hover:bg-bg-elevated transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@ -1128,22 +1449,32 @@ export default function DeidentifyPanel() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleWizardSubmit = useCallback((wizard: WizardState) => {
|
||||
const selectedFields = wizard.fields.filter((f) => f.selected).map((f) => f.name);
|
||||
const newTask: DeidentifyTask = {
|
||||
id: String(tasks.length + 1).padStart(3, '0'),
|
||||
name: wizard.taskName,
|
||||
target: selectedFields.join(', ') || '-',
|
||||
status: wizard.processMode === 'immediate' ? '진행중' : '대기',
|
||||
startTime: new Date().toLocaleString('ko-KR', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).replace(/\. /g, '-').replace('.', ''),
|
||||
progress: 0,
|
||||
createdBy: '관리자',
|
||||
};
|
||||
setTasks((prev) => [newTask, ...prev]);
|
||||
}, [tasks.length]);
|
||||
const handleWizardSubmit = useCallback(
|
||||
(wizard: WizardState) => {
|
||||
const selectedFields = wizard.fields.filter((f) => f.selected).map((f) => f.name);
|
||||
const newTask: DeidentifyTask = {
|
||||
id: String(tasks.length + 1).padStart(3, '0'),
|
||||
name: wizard.taskName,
|
||||
target: selectedFields.join(', ') || '-',
|
||||
status: wizard.processMode === 'immediate' ? '진행중' : '대기',
|
||||
startTime: new Date()
|
||||
.toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
.replace(/\. /g, '-')
|
||||
.replace('.', ''),
|
||||
progress: 0,
|
||||
createdBy: '관리자',
|
||||
};
|
||||
setTasks((prev) => [newTask, ...prev]);
|
||||
},
|
||||
[tasks.length],
|
||||
);
|
||||
|
||||
const filteredTasks = tasks.filter((t) => {
|
||||
if (searchName && !t.name.includes(searchName)) return false;
|
||||
@ -1205,7 +1536,9 @@ export default function DeidentifyPanel() {
|
||||
className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
|
||||
>
|
||||
{(['모두', '완료', '진행중', '대기', '오류'] as FilterStatus[]).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
@ -1225,16 +1558,11 @@ export default function DeidentifyPanel() {
|
||||
</div>
|
||||
|
||||
{/* 감사로그 모달 */}
|
||||
{auditTask && (
|
||||
<AuditLogModal task={auditTask} onClose={() => setAuditTask(null)} />
|
||||
)}
|
||||
{auditTask && <AuditLogModal task={auditTask} onClose={() => setAuditTask(null)} />}
|
||||
|
||||
{/* 마법사 모달 */}
|
||||
{showWizard && (
|
||||
<WizardModal
|
||||
onClose={() => setShowWizard(false)}
|
||||
onSubmit={handleWizardSubmit}
|
||||
/>
|
||||
<WizardModal onClose={() => setShowWizard(false)} onSubmit={handleWizardSubmit} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -328,9 +328,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
|
||||
{nodes.map((node, idx) => (
|
||||
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<PipelineCard node={node} />
|
||||
{idx < nodes.length - 1 && (
|
||||
<span className="text-t3 text-sm shrink-0 px-0.5">→</span>
|
||||
)}
|
||||
{idx < nodes.length - 1 && <span className="text-t3 text-sm shrink-0 px-0.5">→</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -395,9 +393,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
|
||||
))
|
||||
: rows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
||||
{row.timestamp}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.timestamp}</td>
|
||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
||||
@ -550,8 +546,7 @@ export default function RndHnsAtmosPanel() {
|
||||
{/* 요약 통계 바 */}
|
||||
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
|
||||
<span>
|
||||
정상 수신:{' '}
|
||||
<span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
||||
정상 수신: <span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
@ -563,8 +558,7 @@ export default function RndHnsAtmosPanel() {
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
금일 예측 완료:{' '}
|
||||
<span className="text-cyan-400 font-medium">2 / 4회</span>
|
||||
금일 예측 완료: <span className="text-cyan-400 font-medium">2 / 4회</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -627,9 +621,7 @@ export default function RndHnsAtmosPanel() {
|
||||
|
||||
{/* 알림 현황 */}
|
||||
<section className="px-5 pt-4 pb-5">
|
||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
|
||||
알림 현황
|
||||
</h3>
|
||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">알림 현황</h3>
|
||||
<AlertList alerts={alerts} loading={loading} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@ -328,9 +328,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
|
||||
{nodes.map((node, idx) => (
|
||||
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<PipelineCard node={node} />
|
||||
{idx < nodes.length - 1 && (
|
||||
<span className="text-t3 text-sm shrink-0 px-0.5">→</span>
|
||||
)}
|
||||
{idx < nodes.length - 1 && <span className="text-t3 text-sm shrink-0 px-0.5">→</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -395,9 +393,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
|
||||
))
|
||||
: rows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
||||
{row.timestamp}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.timestamp}</td>
|
||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
||||
@ -550,8 +546,7 @@ export default function RndKospsPanel() {
|
||||
{/* 요약 통계 바 */}
|
||||
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
|
||||
<span>
|
||||
정상 수신:{' '}
|
||||
<span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
||||
정상 수신: <span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
@ -563,8 +558,7 @@ export default function RndKospsPanel() {
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
금일 예측 완료:{' '}
|
||||
<span className="text-cyan-400 font-medium">3 / 6회</span>
|
||||
금일 예측 완료: <span className="text-cyan-400 font-medium">3 / 6회</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -627,9 +621,7 @@ export default function RndKospsPanel() {
|
||||
|
||||
{/* 알림 현황 */}
|
||||
<section className="px-5 pt-4 pb-5">
|
||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
|
||||
알림 현황
|
||||
</h3>
|
||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">알림 현황</h3>
|
||||
<AlertList alerts={alerts} loading={loading} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@ -355,9 +355,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
|
||||
{nodes.map((node, idx) => (
|
||||
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<PipelineCard node={node} />
|
||||
{idx < nodes.length - 1 && (
|
||||
<span className="text-t3 text-sm shrink-0 px-0.5">→</span>
|
||||
)}
|
||||
{idx < nodes.length - 1 && <span className="text-t3 text-sm shrink-0 px-0.5">→</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -422,9 +420,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
|
||||
))
|
||||
: rows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
||||
{row.timestamp}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.timestamp}</td>
|
||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
||||
@ -577,8 +573,7 @@ export default function RndPoseidonPanel() {
|
||||
{/* 요약 통계 바 */}
|
||||
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
|
||||
<span>
|
||||
정상 수신:{' '}
|
||||
<span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
||||
정상 수신: <span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
@ -590,8 +585,7 @@ export default function RndPoseidonPanel() {
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
금일 예측 완료:{' '}
|
||||
<span className="text-cyan-400 font-medium">4 / 8회</span>
|
||||
금일 예측 완료: <span className="text-cyan-400 font-medium">4 / 8회</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -654,9 +648,7 @@ export default function RndPoseidonPanel() {
|
||||
|
||||
{/* 알림 현황 */}
|
||||
<section className="px-5 pt-4 pb-5">
|
||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
|
||||
알림 현황
|
||||
</h3>
|
||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">알림 현황</h3>
|
||||
<AlertList alerts={alerts} loading={loading} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@ -328,9 +328,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
|
||||
{nodes.map((node, idx) => (
|
||||
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<PipelineCard node={node} />
|
||||
{idx < nodes.length - 1 && (
|
||||
<span className="text-t3 text-sm shrink-0 px-0.5">→</span>
|
||||
)}
|
||||
{idx < nodes.length - 1 && <span className="text-t3 text-sm shrink-0 px-0.5">→</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -395,9 +393,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
|
||||
))
|
||||
: rows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
||||
{row.timestamp}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.timestamp}</td>
|
||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
||||
@ -550,8 +546,7 @@ export default function RndRescuePanel() {
|
||||
{/* 요약 통계 바 */}
|
||||
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
|
||||
<span>
|
||||
정상 수신:{' '}
|
||||
<span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
||||
정상 수신: <span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
@ -563,8 +558,7 @@ export default function RndRescuePanel() {
|
||||
</span>
|
||||
<span className="text-stroke-1">|</span>
|
||||
<span>
|
||||
금일 분석 완료:{' '}
|
||||
<span className="text-cyan-400 font-medium">5 / 6회</span>
|
||||
금일 분석 완료: <span className="text-cyan-400 font-medium">5 / 6회</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -627,9 +621,7 @@ export default function RndRescuePanel() {
|
||||
|
||||
{/* 알림 현황 */}
|
||||
<section className="px-5 pt-4 pb-5">
|
||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
|
||||
알림 현황
|
||||
</h3>
|
||||
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">알림 현황</h3>
|
||||
<AlertList alerts={alerts} loading={loading} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@ -300,8 +300,7 @@ function FrameworkTab() {
|
||||
{[
|
||||
{
|
||||
title: 'HTTP 정책',
|
||||
content:
|
||||
'GET/POST만 사용 (PUT/DELETE/PATCH 금지) — 한국 보안취약점 점검 가이드 준수',
|
||||
content: 'GET/POST만 사용 (PUT/DELETE/PATCH 금지) — 한국 보안취약점 점검 가이드 준수',
|
||||
},
|
||||
{
|
||||
title: '코드 표준',
|
||||
@ -463,14 +462,7 @@ function TargetArchTab() {
|
||||
// ─── 탭 3: 시스템 인터페이스 연계 ────────────────────────────────────────────────
|
||||
|
||||
function InterfaceTab() {
|
||||
const dataFlowSteps = [
|
||||
'수집',
|
||||
'전처리',
|
||||
'저장',
|
||||
'분석/예측',
|
||||
'시각화',
|
||||
'의사결정지원',
|
||||
];
|
||||
const dataFlowSteps = ['수집', '전처리', '저장', '분석/예측', '시각화', '의사결정지원'];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-5">
|
||||
@ -630,7 +622,6 @@ function InterfaceTab() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ─── 이기종시스템 연계 데이터 ─────────────────────────────────────────────────────
|
||||
|
||||
interface HeterogeneousSystemRow {
|
||||
@ -730,7 +721,7 @@ const INTEGRATION_PLANS: IntegrationPlanItem[] = [
|
||||
{
|
||||
title: '해양공간 데이터 연계',
|
||||
description:
|
||||
'해경 해양공간 데이터 구축사업(해양공간정보 활용체계 구축 및 빅데이터 관련 사업)의 \'데이터통합저장소\' 시스템과 연계하여 현장탐색 전자지도 자동표출 기술 등에 영상 및 사진자료를 연계하여 구축',
|
||||
"해경 해양공간 데이터 구축사업(해양공간정보 활용체계 구축 및 빅데이터 관련 사업)의 '데이터통합저장소' 시스템과 연계하여 현장탐색 전자지도 자동표출 기술 등에 영상 및 사진자료를 연계하여 구축",
|
||||
},
|
||||
{
|
||||
title: 'DB 통합설계 기반 맞춤형 인터페이스',
|
||||
@ -752,8 +743,7 @@ const INTEGRATION_PLANS: IntegrationPlanItem[] = [
|
||||
},
|
||||
{
|
||||
title: '기타 시스템 연계',
|
||||
description:
|
||||
'그 밖에 시스템 구축 중 효율적인 사고대응을 위한 타 시스템 연계할 수 있음',
|
||||
description: '그 밖에 시스템 구축 중 효율적인 사고대응을 위한 타 시스템 연계할 수 있음',
|
||||
},
|
||||
];
|
||||
|
||||
@ -1180,128 +1170,348 @@ const FEATURE_MATRIX: FeatureMatrixRow[] = [
|
||||
feature: '사용자 인증 (JWT)',
|
||||
category: '공통기능',
|
||||
integrated: true,
|
||||
systems: { '확산예측': true, 'HNS분석': true, '구조시나리오': true, '항공방제': true, '해양기상': true, '사건/사고': true, '자산관리': true, 'SCAT조사': true, '게시판': true, '관리자': true },
|
||||
systems: {
|
||||
확산예측: true,
|
||||
HNS분석: true,
|
||||
구조시나리오: true,
|
||||
항공방제: true,
|
||||
해양기상: true,
|
||||
'사건/사고': true,
|
||||
자산관리: true,
|
||||
SCAT조사: true,
|
||||
게시판: true,
|
||||
관리자: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: 'RBAC 권한 제어',
|
||||
category: '공통기능',
|
||||
integrated: true,
|
||||
systems: { '확산예측': true, 'HNS분석': true, '구조시나리오': true, '항공방제': true, '해양기상': true, '사건/사고': true, '자산관리': true, 'SCAT조사': true, '게시판': true, '관리자': true },
|
||||
systems: {
|
||||
확산예측: true,
|
||||
HNS분석: true,
|
||||
구조시나리오: true,
|
||||
항공방제: true,
|
||||
해양기상: true,
|
||||
'사건/사고': true,
|
||||
자산관리: true,
|
||||
SCAT조사: true,
|
||||
게시판: true,
|
||||
관리자: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: '감사 로그',
|
||||
category: '공통기능',
|
||||
integrated: true,
|
||||
systems: { '확산예측': true, 'HNS분석': true, '구조시나리오': true, '항공방제': true, '해양기상': true, '사건/사고': true, '자산관리': true, 'SCAT조사': true, '게시판': true, '관리자': true },
|
||||
systems: {
|
||||
확산예측: true,
|
||||
HNS분석: true,
|
||||
구조시나리오: true,
|
||||
항공방제: true,
|
||||
해양기상: true,
|
||||
'사건/사고': true,
|
||||
자산관리: true,
|
||||
SCAT조사: true,
|
||||
게시판: true,
|
||||
관리자: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: 'API 통신 (Axios)',
|
||||
category: '공통기능',
|
||||
integrated: true,
|
||||
systems: { '확산예측': true, 'HNS분석': true, '구조시나리오': true, '항공방제': true, '해양기상': true, '사건/사고': true, '자산관리': true, 'SCAT조사': true, '게시판': true, '관리자': true },
|
||||
systems: {
|
||||
확산예측: true,
|
||||
HNS분석: true,
|
||||
구조시나리오: true,
|
||||
항공방제: true,
|
||||
해양기상: true,
|
||||
'사건/사고': true,
|
||||
자산관리: true,
|
||||
SCAT조사: true,
|
||||
게시판: true,
|
||||
관리자: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: '입력 살균/보안',
|
||||
category: '공통기능',
|
||||
integrated: true,
|
||||
systems: { '확산예측': true, 'HNS분석': true, '구조시나리오': true, '항공방제': true, '해양기상': true, '사건/사고': true, '자산관리': true, 'SCAT조사': true, '게시판': true, '관리자': true },
|
||||
systems: {
|
||||
확산예측: true,
|
||||
HNS분석: true,
|
||||
구조시나리오: true,
|
||||
항공방제: true,
|
||||
해양기상: true,
|
||||
'사건/사고': true,
|
||||
자산관리: true,
|
||||
SCAT조사: true,
|
||||
게시판: true,
|
||||
관리자: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: '사용자 관리',
|
||||
category: '기본정보관리',
|
||||
integrated: true,
|
||||
systems: { '확산예측': false, 'HNS분석': false, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': true },
|
||||
systems: {
|
||||
확산예측: false,
|
||||
HNS분석: false,
|
||||
구조시나리오: false,
|
||||
항공방제: false,
|
||||
해양기상: false,
|
||||
'사건/사고': false,
|
||||
자산관리: false,
|
||||
SCAT조사: false,
|
||||
게시판: false,
|
||||
관리자: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: '지도 엔진 (MapLibre)',
|
||||
category: '기본정보관리',
|
||||
integrated: true,
|
||||
systems: { '확산예측': true, 'HNS분석': true, '구조시나리오': true, '항공방제': true, '해양기상': true, '사건/사고': true, '자산관리': false, 'SCAT조사': true, '게시판': false, '관리자': false },
|
||||
systems: {
|
||||
확산예측: true,
|
||||
HNS분석: true,
|
||||
구조시나리오: true,
|
||||
항공방제: true,
|
||||
해양기상: true,
|
||||
'사건/사고': true,
|
||||
자산관리: false,
|
||||
SCAT조사: true,
|
||||
게시판: false,
|
||||
관리자: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: '레이어 관리',
|
||||
category: '기본정보관리',
|
||||
integrated: true,
|
||||
systems: { '확산예측': true, 'HNS분석': true, '구조시나리오': true, '항공방제': true, '해양기상': true, '사건/사고': true, '자산관리': false, 'SCAT조사': true, '게시판': false, '관리자': true },
|
||||
systems: {
|
||||
확산예측: true,
|
||||
HNS분석: true,
|
||||
구조시나리오: true,
|
||||
항공방제: true,
|
||||
해양기상: true,
|
||||
'사건/사고': true,
|
||||
자산관리: false,
|
||||
SCAT조사: true,
|
||||
게시판: false,
|
||||
관리자: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: '메뉴 관리',
|
||||
category: '기본정보관리',
|
||||
integrated: true,
|
||||
systems: { '확산예측': false, 'HNS분석': false, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': true },
|
||||
systems: {
|
||||
확산예측: false,
|
||||
HNS분석: false,
|
||||
구조시나리오: false,
|
||||
항공방제: false,
|
||||
해양기상: false,
|
||||
'사건/사고': false,
|
||||
자산관리: false,
|
||||
SCAT조사: false,
|
||||
게시판: false,
|
||||
관리자: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: '시스템 설정',
|
||||
category: '기본정보관리',
|
||||
integrated: true,
|
||||
systems: { '확산예측': false, 'HNS분석': false, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': true },
|
||||
systems: {
|
||||
확산예측: false,
|
||||
HNS분석: false,
|
||||
구조시나리오: false,
|
||||
항공방제: false,
|
||||
해양기상: false,
|
||||
'사건/사고': false,
|
||||
자산관리: false,
|
||||
SCAT조사: false,
|
||||
게시판: false,
|
||||
관리자: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: '확산 시뮬레이션',
|
||||
category: '업무기능',
|
||||
integrated: false,
|
||||
systems: { '확산예측': true, 'HNS분석': false, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': false },
|
||||
systems: {
|
||||
확산예측: true,
|
||||
HNS분석: false,
|
||||
구조시나리오: false,
|
||||
항공방제: false,
|
||||
해양기상: false,
|
||||
'사건/사고': false,
|
||||
자산관리: false,
|
||||
SCAT조사: false,
|
||||
게시판: false,
|
||||
관리자: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: 'HNS 대기확산',
|
||||
category: '업무기능',
|
||||
integrated: false,
|
||||
systems: { '확산예측': false, 'HNS분석': true, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': false },
|
||||
systems: {
|
||||
확산예측: false,
|
||||
HNS분석: true,
|
||||
구조시나리오: false,
|
||||
항공방제: false,
|
||||
해양기상: false,
|
||||
'사건/사고': false,
|
||||
자산관리: false,
|
||||
SCAT조사: false,
|
||||
게시판: false,
|
||||
관리자: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: '표류 예측',
|
||||
category: '업무기능',
|
||||
integrated: false,
|
||||
systems: { '확산예측': false, 'HNS분석': false, '구조시나리오': true, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': false },
|
||||
systems: {
|
||||
확산예측: false,
|
||||
HNS분석: false,
|
||||
구조시나리오: true,
|
||||
항공방제: false,
|
||||
해양기상: false,
|
||||
'사건/사고': false,
|
||||
자산관리: false,
|
||||
SCAT조사: false,
|
||||
게시판: false,
|
||||
관리자: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: '위성/드론 영상',
|
||||
category: '업무기능',
|
||||
integrated: false,
|
||||
systems: { '확산예측': false, 'HNS분석': false, '구조시나리오': false, '항공방제': true, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': false },
|
||||
systems: {
|
||||
확산예측: false,
|
||||
HNS분석: false,
|
||||
구조시나리오: false,
|
||||
항공방제: true,
|
||||
해양기상: false,
|
||||
'사건/사고': false,
|
||||
자산관리: false,
|
||||
SCAT조사: false,
|
||||
게시판: false,
|
||||
관리자: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: '기상/해상 정보',
|
||||
category: '업무기능',
|
||||
integrated: false,
|
||||
systems: { '확산예측': true, 'HNS분석': true, '구조시나리오': true, '항공방제': false, '해양기상': true, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': false },
|
||||
systems: {
|
||||
확산예측: true,
|
||||
HNS분석: true,
|
||||
구조시나리오: true,
|
||||
항공방제: false,
|
||||
해양기상: true,
|
||||
'사건/사고': false,
|
||||
자산관리: false,
|
||||
SCAT조사: false,
|
||||
게시판: false,
|
||||
관리자: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: '역추적 분석',
|
||||
category: '업무기능',
|
||||
integrated: false,
|
||||
systems: { '확산예측': true, 'HNS분석': false, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': false },
|
||||
systems: {
|
||||
확산예측: true,
|
||||
HNS분석: false,
|
||||
구조시나리오: false,
|
||||
항공방제: false,
|
||||
해양기상: false,
|
||||
'사건/사고': false,
|
||||
자산관리: false,
|
||||
SCAT조사: false,
|
||||
게시판: false,
|
||||
관리자: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: '사고 등록/이력',
|
||||
category: '업무기능',
|
||||
integrated: false,
|
||||
systems: { '확산예측': false, 'HNS분석': false, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': true, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': false },
|
||||
systems: {
|
||||
확산예측: false,
|
||||
HNS분석: false,
|
||||
구조시나리오: false,
|
||||
항공방제: false,
|
||||
해양기상: false,
|
||||
'사건/사고': true,
|
||||
자산관리: false,
|
||||
SCAT조사: false,
|
||||
게시판: false,
|
||||
관리자: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: '장비/선박 관리',
|
||||
category: '업무기능',
|
||||
integrated: false,
|
||||
systems: { '확산예측': false, 'HNS분석': false, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': true, 'SCAT조사': false, '게시판': false, '관리자': false },
|
||||
systems: {
|
||||
확산예측: false,
|
||||
HNS분석: false,
|
||||
구조시나리오: false,
|
||||
항공방제: false,
|
||||
해양기상: false,
|
||||
'사건/사고': false,
|
||||
자산관리: true,
|
||||
SCAT조사: false,
|
||||
게시판: false,
|
||||
관리자: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: '해안 조사',
|
||||
category: '업무기능',
|
||||
integrated: false,
|
||||
systems: { '확산예측': false, 'HNS분석': false, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': true, '게시판': false, '관리자': false },
|
||||
systems: {
|
||||
확산예측: false,
|
||||
HNS분석: false,
|
||||
구조시나리오: false,
|
||||
항공방제: false,
|
||||
해양기상: false,
|
||||
'사건/사고': false,
|
||||
자산관리: false,
|
||||
SCAT조사: true,
|
||||
게시판: false,
|
||||
관리자: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: '게시판 CRUD',
|
||||
category: '업무기능',
|
||||
integrated: false,
|
||||
systems: { '확산예측': false, 'HNS분석': false, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': true, '관리자': false },
|
||||
systems: {
|
||||
확산예측: false,
|
||||
HNS분석: false,
|
||||
구조시나리오: false,
|
||||
항공방제: false,
|
||||
해양기상: false,
|
||||
'사건/사고': false,
|
||||
자산관리: false,
|
||||
SCAT조사: false,
|
||||
게시판: true,
|
||||
관리자: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const CATEGORY_STYLES: Record<string, string> = {
|
||||
'공통기능': 'bg-cyan-600/20 text-cyan-300',
|
||||
'기본정보관리': 'bg-emerald-600/20 text-emerald-300',
|
||||
'업무기능': 'bg-bg-elevated text-t3',
|
||||
공통기능: 'bg-cyan-600/20 text-cyan-300',
|
||||
기본정보관리: 'bg-emerald-600/20 text-emerald-300',
|
||||
업무기능: 'bg-bg-elevated text-t3',
|
||||
};
|
||||
|
||||
// ─── 탭 5: 공통기능 ─────────────────────────────────────────────────────────────
|
||||
@ -1313,8 +1523,8 @@ function CommonFeaturesTab() {
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-t1 mb-3">1. 방제대응 프로세스</h3>
|
||||
<p className="text-xs text-t2 leading-relaxed mb-4">
|
||||
해양오염 사고 발생 시 사고 접수부터 상황 종료까지의 단계별 대응 프로세스이며,
|
||||
각 단계에서 활용하는 시스템 모듈을 표시한다.
|
||||
해양오염 사고 발생 시 사고 접수부터 상황 종료까지의 단계별 대응 프로세스이며, 각 단계에서
|
||||
활용하는 시스템 모듈을 표시한다.
|
||||
</p>
|
||||
{/* 프로세스 흐름도 */}
|
||||
<div className="flex items-start gap-1 flex-wrap mb-4">
|
||||
@ -1324,7 +1534,9 @@ function CommonFeaturesTab() {
|
||||
<p className="text-xs font-semibold text-t1 mb-1">{step.phase}</p>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{step.modules.map((mod) => (
|
||||
<span key={mod} className="text-[10px] text-cyan-400">{mod}</span>
|
||||
<span key={mod} className="text-[10px] text-cyan-400">
|
||||
{mod}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@ -1337,7 +1549,10 @@ function CommonFeaturesTab() {
|
||||
{/* 프로세스 상세 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{RESPONSE_PROCESS.map((step, idx) => (
|
||||
<div key={step.phase} className="bg-bg-card border border-stroke-1 rounded p-3 flex items-start gap-3">
|
||||
<div
|
||||
key={step.phase}
|
||||
className="bg-bg-card border border-stroke-1 rounded p-3 flex items-start gap-3"
|
||||
>
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded bg-cyan-600 text-white text-xs font-semibold shrink-0 mt-0.5">
|
||||
{idx + 1}
|
||||
</span>
|
||||
@ -1347,7 +1562,10 @@ function CommonFeaturesTab() {
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{step.modules.map((mod) => (
|
||||
<span key={mod} className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-cyan-600/20 text-cyan-300">
|
||||
<span
|
||||
key={mod}
|
||||
className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-cyan-600/20 text-cyan-300"
|
||||
>
|
||||
{mod}
|
||||
</span>
|
||||
))}
|
||||
@ -1362,8 +1580,9 @@ function CommonFeaturesTab() {
|
||||
<h3 className="text-sm font-semibold text-t1 mb-3">2. 시스템별 기능 유무 매트릭스</h3>
|
||||
<p className="text-xs text-t2 leading-relaxed mb-4">
|
||||
각 시스템(업무 모듈)별 기능의 유무를 파악하여 공통기능, 기본정보 관리(사용자, 지도 등) 등
|
||||
통합할 수 있는 기능을 표시한다. <span className="text-cyan-400 font-medium">통합 대상</span> 기능은
|
||||
공통 모듈로 일원화하여 중복 개발을 방지한다.
|
||||
통합할 수 있는 기능을 표시한다.{' '}
|
||||
<span className="text-cyan-400 font-medium">통합 대상</span> 기능은 공통 모듈로 일원화하여
|
||||
중복 개발을 방지한다.
|
||||
</p>
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
@ -1379,7 +1598,10 @@ function CommonFeaturesTab() {
|
||||
통합
|
||||
</th>
|
||||
{SYSTEM_MODULES.map((mod) => (
|
||||
<th key={mod} className="px-1.5 py-2 text-center font-medium border-b border-stroke-1 whitespace-nowrap">
|
||||
<th
|
||||
key={mod}
|
||||
className="px-1.5 py-2 text-center font-medium border-b border-stroke-1 whitespace-nowrap"
|
||||
>
|
||||
<span className="writing-mode-vertical text-[10px]">{mod}</span>
|
||||
</th>
|
||||
))}
|
||||
@ -1392,7 +1614,9 @@ function CommonFeaturesTab() {
|
||||
{row.feature}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${CATEGORY_STYLES[row.category]}`}>
|
||||
<span
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${CATEGORY_STYLES[row.category]}`}
|
||||
>
|
||||
{row.category}
|
||||
</span>
|
||||
</td>
|
||||
@ -1420,15 +1644,21 @@ function CommonFeaturesTab() {
|
||||
{/* 범례 */}
|
||||
<div className="flex gap-4 mt-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-cyan-600/20 text-cyan-300">공통기능</span>
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-cyan-600/20 text-cyan-300">
|
||||
공통기능
|
||||
</span>
|
||||
<span className="text-xs text-t3">전 모듈 공통 적용</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-600/20 text-emerald-300">기본정보관리</span>
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-600/20 text-emerald-300">
|
||||
기본정보관리
|
||||
</span>
|
||||
<span className="text-xs text-t3">사용자·지도·메뉴·설정 통합 관리</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-bg-elevated text-t3">업무기능</span>
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-bg-elevated text-t3">
|
||||
업무기능
|
||||
</span>
|
||||
<span className="text-xs text-t3">모듈별 고유 기능</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -1478,18 +1708,48 @@ function CommonFeaturesTab() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ dir: 'common/components/', role: '공통 UI 컴포넌트', files: 'auth/, layout/, map/, ui/, layer/' },
|
||||
{ dir: 'common/hooks/', role: '공통 커스텀 훅', files: 'useLayers, useSubMenu, useFeatureTracking' },
|
||||
{ dir: 'common/services/', role: 'API 통신 모듈', files: 'api.ts, authApi.ts, layerService.ts' },
|
||||
{ dir: 'common/store/', role: '전역 상태 스토어', files: 'authStore.ts, menuStore.ts' },
|
||||
{ dir: 'common/styles/', role: 'CSS @layer 스타일', files: 'base.css, components.css, wing.css' },
|
||||
{ dir: 'common/types/', role: '공통 타입 정의', files: 'backtrack, hns, navigation 등' },
|
||||
{ dir: 'common/utils/', role: '유틸리티 함수', files: 'coordinates, geo, sanitize, cn.ts' },
|
||||
{
|
||||
dir: 'common/components/',
|
||||
role: '공통 UI 컴포넌트',
|
||||
files: 'auth/, layout/, map/, ui/, layer/',
|
||||
},
|
||||
{
|
||||
dir: 'common/hooks/',
|
||||
role: '공통 커스텀 훅',
|
||||
files: 'useLayers, useSubMenu, useFeatureTracking',
|
||||
},
|
||||
{
|
||||
dir: 'common/services/',
|
||||
role: 'API 통신 모듈',
|
||||
files: 'api.ts, authApi.ts, layerService.ts',
|
||||
},
|
||||
{
|
||||
dir: 'common/store/',
|
||||
role: '전역 상태 스토어',
|
||||
files: 'authStore.ts, menuStore.ts',
|
||||
},
|
||||
{
|
||||
dir: 'common/styles/',
|
||||
role: 'CSS @layer 스타일',
|
||||
files: 'base.css, components.css, wing.css',
|
||||
},
|
||||
{
|
||||
dir: 'common/types/',
|
||||
role: '공통 타입 정의',
|
||||
files: 'backtrack, hns, navigation 등',
|
||||
},
|
||||
{
|
||||
dir: 'common/utils/',
|
||||
role: '유틸리티 함수',
|
||||
files: 'coordinates, geo, sanitize, cn.ts',
|
||||
},
|
||||
{ dir: 'common/constants/', role: '상수 정의', files: 'featureIds.ts' },
|
||||
{ dir: 'common/data/', role: 'UI 데이터', files: 'layerData.ts (레이어 트리)' },
|
||||
].map((row) => (
|
||||
<tr key={row.dir} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap font-mono">{row.dir}</td>
|
||||
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap font-mono">
|
||||
{row.dir}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-t2">{row.role}</td>
|
||||
<td className="px-3 py-2 text-t3 font-mono">{row.files}</td>
|
||||
</tr>
|
||||
|
||||
@ -5,6 +5,7 @@ import { fetchCctvCameras } from '../services/aerialApi';
|
||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||||
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
|
||||
import { useMapStore } from '@common/store/mapStore';
|
||||
import { BaseMap } from '@common/components/map/BaseMap';
|
||||
import type { CctvCameraItem } from '../services/aerialApi';
|
||||
import { CCTVPlayer } from './CCTVPlayer';
|
||||
import type { CCTVPlayerHandle } from './CCTVPlayer';
|
||||
@ -1055,13 +1056,7 @@ export function CctvView() {
|
||||
</div>
|
||||
) : showMap ? (
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<Map
|
||||
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
|
||||
mapStyle={currentMapStyle}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
attributionControl={false}
|
||||
>
|
||||
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||
<BaseMap center={[35.5, 127.8]} zoom={6.2}>
|
||||
{filtered
|
||||
.filter((c) => c.lon && c.lat)
|
||||
.map((cam) => (
|
||||
@ -1221,7 +1216,7 @@ export function CctvView() {
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</Map>
|
||||
</BaseMap>
|
||||
{/* 지도 위 안내 배지 */}
|
||||
<div
|
||||
className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full text-label-1 font-bold font-korean z-10"
|
||||
|
||||
@ -1,39 +1,12 @@
|
||||
import { useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { Map, useControl, useMap } from '@vis.gl/react-maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import { ScatterplotLayer } from '@deck.gl/layers';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||||
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
|
||||
import { useMapStore } from '@common/store/mapStore';
|
||||
import { BaseMap } from '@common/components/map/BaseMap';
|
||||
import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay';
|
||||
import { FlyToController } from '@common/components/map/FlyToController';
|
||||
import type { AssetOrgCompat } from '../services/assetsApi';
|
||||
import { typeColor } from './assetTypes';
|
||||
import { hexToRgba } from '@common/components/map/mapUtils';
|
||||
|
||||
// ── DeckGLOverlay ──────────────────────────────────────
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
||||
overlay.setProps({ layers });
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── FlyTo Controller ────────────────────────────────────
|
||||
function FlyToController({ selectedOrg }: { selectedOrg: AssetOrgCompat }) {
|
||||
const { current: map } = useMap();
|
||||
const prevIdRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedOrg.id) {
|
||||
map.flyTo({ center: [selectedOrg.lng, selectedOrg.lat], zoom: 10, duration: 800 });
|
||||
}
|
||||
prevIdRef.current = selectedOrg.id;
|
||||
}, [map, selectedOrg]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface AssetMapProps {
|
||||
organizations: AssetOrgCompat[];
|
||||
selectedOrg: AssetOrgCompat;
|
||||
@ -49,8 +22,16 @@ function AssetMap({
|
||||
regionFilter,
|
||||
onRegionFilterChange,
|
||||
}: AssetMapProps) {
|
||||
const currentMapStyle = useBaseMapStyle();
|
||||
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||
// 선택 항목이 실제로 바뀔 때만 flyTo (첫 렌더에서는 이동하지 않음)
|
||||
// 첫 렌더 ID를 useState lazy init으로 동결 → 그 외엔 useMemo로 target 파생
|
||||
const [initialId] = useState(selectedOrg.id);
|
||||
const flyTarget = useMemo(
|
||||
() =>
|
||||
selectedOrg.id === initialId
|
||||
? null
|
||||
: { lng: selectedOrg.lng, lat: selectedOrg.lat, zoom: 10 },
|
||||
[selectedOrg, initialId],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(org: AssetOrgCompat) => {
|
||||
@ -59,58 +40,54 @@ function AssetMap({
|
||||
[onSelectOrg],
|
||||
);
|
||||
|
||||
const markerLayer = useMemo(() => {
|
||||
return new ScatterplotLayer({
|
||||
id: 'asset-orgs',
|
||||
data: orgs,
|
||||
getPosition: (d: AssetOrgCompat) => [d.lng, d.lat],
|
||||
getRadius: (d: AssetOrgCompat) => {
|
||||
const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7;
|
||||
const isSelected = selectedOrg.id === d.id;
|
||||
return isSelected ? baseRadius + 4 : baseRadius;
|
||||
},
|
||||
getFillColor: (d: AssetOrgCompat) => {
|
||||
const tc = typeColor(d.type);
|
||||
const isSelected = selectedOrg.id === d.id;
|
||||
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 229 : 178);
|
||||
},
|
||||
getLineColor: (d: AssetOrgCompat) => {
|
||||
const tc = typeColor(d.type);
|
||||
const isSelected = selectedOrg.id === d.id;
|
||||
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 255 : 200);
|
||||
},
|
||||
getLineWidth: (d: AssetOrgCompat) => (selectedOrg.id === d.id ? 3 : 2),
|
||||
stroked: true,
|
||||
radiusMinPixels: 4,
|
||||
radiusMaxPixels: 20,
|
||||
radiusUnits: 'pixels',
|
||||
pickable: true,
|
||||
onClick: (info: { object?: AssetOrgCompat }) => {
|
||||
if (info.object) handleClick(info.object);
|
||||
},
|
||||
updateTriggers: {
|
||||
getRadius: [selectedOrg.id],
|
||||
getFillColor: [selectedOrg.id],
|
||||
getLineColor: [selectedOrg.id],
|
||||
getLineWidth: [selectedOrg.id],
|
||||
},
|
||||
});
|
||||
}, [orgs, selectedOrg, handleClick]);
|
||||
const markerLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer({
|
||||
id: 'asset-orgs',
|
||||
data: orgs,
|
||||
getPosition: (d: AssetOrgCompat) => [d.lng, d.lat],
|
||||
getRadius: (d: AssetOrgCompat) => {
|
||||
const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7;
|
||||
const isSelected = selectedOrg.id === d.id;
|
||||
return isSelected ? baseRadius + 4 : baseRadius;
|
||||
},
|
||||
getFillColor: (d: AssetOrgCompat) => {
|
||||
const tc = typeColor(d.type);
|
||||
const isSelected = selectedOrg.id === d.id;
|
||||
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 229 : 178);
|
||||
},
|
||||
getLineColor: (d: AssetOrgCompat) => {
|
||||
const tc = typeColor(d.type);
|
||||
const isSelected = selectedOrg.id === d.id;
|
||||
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 255 : 200);
|
||||
},
|
||||
getLineWidth: (d: AssetOrgCompat) => (selectedOrg.id === d.id ? 3 : 2),
|
||||
stroked: true,
|
||||
radiusMinPixels: 4,
|
||||
radiusMaxPixels: 20,
|
||||
radiusUnits: 'pixels',
|
||||
pickable: true,
|
||||
onClick: (info: { object?: AssetOrgCompat }) => {
|
||||
if (info.object) handleClick(info.object);
|
||||
},
|
||||
updateTriggers: {
|
||||
getRadius: [selectedOrg.id],
|
||||
getFillColor: [selectedOrg.id],
|
||||
getLineColor: [selectedOrg.id],
|
||||
getLineWidth: [selectedOrg.id],
|
||||
},
|
||||
}),
|
||||
[orgs, selectedOrg, handleClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
<Map
|
||||
initialViewState={{ longitude: 127.8, latitude: 35.9, zoom: 7 }}
|
||||
mapStyle={currentMapStyle}
|
||||
className="w-full h-full"
|
||||
attributionControl={false}
|
||||
>
|
||||
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||
<BaseMap center={[35.9, 127.8]} zoom={7}>
|
||||
<DeckGLOverlay layers={[markerLayer]} />
|
||||
<FlyToController selectedOrg={selectedOrg} />
|
||||
</Map>
|
||||
<FlyToController target={flyTarget} duration={800} />
|
||||
</BaseMap>
|
||||
|
||||
{/* Region filter overlay */}
|
||||
{/* 지역 필터 */}
|
||||
<div className="absolute top-3 left-3 z-[1000] flex gap-1">
|
||||
{[
|
||||
{ value: 'all', label: '전체' },
|
||||
@ -134,7 +111,7 @@ function AssetMap({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend overlay */}
|
||||
{/* 범례 */}
|
||||
<div className="absolute bottom-3 left-3 z-[1000] bg-bg-base/90 border border-stroke rounded-sm p-2.5 backdrop-blur-sm">
|
||||
<div className="text-caption text-fg-disabled font-bold mb-1.5 font-korean">범례</div>
|
||||
{[
|
||||
|
||||
@ -3,8 +3,8 @@ import { ComboBox } from '@common/components/ui/ComboBox';
|
||||
import { useWeatherFetch } from '../hooks/useWeatherFetch';
|
||||
import { getSubstanceToxicity } from '../utils/toxicityData';
|
||||
import type { WeatherFetchResult, ReleaseType } from '../utils/dispersionTypes';
|
||||
import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi';
|
||||
import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi';
|
||||
import { fetchGscAccidents } from '@tabs/prediction/services/predictionApi';
|
||||
import type { GscAccidentListItem } from '@tabs/prediction/services/predictionApi';
|
||||
|
||||
/** HNS 분석 입력 파라미터 (부모에 전달) */
|
||||
export interface HNSInputParams {
|
||||
@ -44,6 +44,7 @@ interface HNSLeftPanelProps {
|
||||
onParamsChange?: (params: HNSInputParams) => void;
|
||||
onReset?: () => void;
|
||||
loadedParams?: Partial<HNSInputParams> | null;
|
||||
onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
|
||||
}
|
||||
|
||||
/** 십진 좌표 → 도분초 변환 */
|
||||
@ -67,8 +68,9 @@ export function HNSLeftPanel({
|
||||
onParamsChange,
|
||||
onReset,
|
||||
loadedParams,
|
||||
onFlyToCoord,
|
||||
}: HNSLeftPanelProps) {
|
||||
const [incidents, setIncidents] = useState<IncidentListItem[]>([]);
|
||||
const [incidents, setIncidents] = useState<GscAccidentListItem[]>([]);
|
||||
const [selectedIncidentSn, setSelectedIncidentSn] = useState('');
|
||||
const [expandedSections, setExpandedSections] = useState({ accident: true, params: true });
|
||||
const toggleSection = (key: 'accident' | 'params') =>
|
||||
@ -138,21 +140,26 @@ export function HNSLeftPanel({
|
||||
// 사고 목록 불러오기 (마운트 시 1회, ref 초기화 패턴)
|
||||
const incidentsPromiseRef = useRef<Promise<void> | null>(null);
|
||||
if (incidentsPromiseRef.current == null) {
|
||||
incidentsPromiseRef.current = fetchIncidentsRaw()
|
||||
incidentsPromiseRef.current = fetchGscAccidents()
|
||||
.then((data) => setIncidents(data))
|
||||
.catch(() => setIncidents([]));
|
||||
}
|
||||
|
||||
// 사고 선택 시 필드 자동 채움
|
||||
const handleSelectIncident = (snStr: string) => {
|
||||
setSelectedIncidentSn(snStr);
|
||||
const sn = parseInt(snStr);
|
||||
const incident = incidents.find((i) => i.acdntSn === sn);
|
||||
const handleSelectIncident = (mngNo: string) => {
|
||||
setSelectedIncidentSn(mngNo);
|
||||
const incident = incidents.find((i) => i.acdntMngNo === mngNo);
|
||||
if (!incident) return;
|
||||
|
||||
setAccidentName(incident.acdntNm);
|
||||
if (incident.lat && incident.lng) {
|
||||
onCoordChange({ lat: incident.lat, lon: incident.lng });
|
||||
setAccidentName(incident.pollNm);
|
||||
if (incident.pollDate) {
|
||||
const [d, t] = incident.pollDate.split('T');
|
||||
if (d) setAccidentDate(d);
|
||||
if (t) setAccidentTime(t);
|
||||
}
|
||||
if (incident.lat != null && incident.lon != null) {
|
||||
onCoordChange({ lat: incident.lat, lon: incident.lon });
|
||||
onFlyToCoord?.({ lat: incident.lat, lon: incident.lon });
|
||||
}
|
||||
};
|
||||
|
||||
@ -266,8 +273,8 @@ export function HNSLeftPanel({
|
||||
onChange={handleSelectIncident}
|
||||
placeholder="또는 사고 리스트에서 선택"
|
||||
options={incidents.map((inc) => ({
|
||||
value: String(inc.acdntSn),
|
||||
label: `${inc.acdntNm} (${new Date(inc.occrnDtm).toLocaleDateString('ko-KR')})`,
|
||||
value: inc.acdntMngNo,
|
||||
label: `${inc.pollNm} (${inc.pollDate ? inc.pollDate.replace('T', ' ') : '-'})`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useVesselSignals } from '@common/hooks/useVesselSignals';
|
||||
import type { MapBounds } from '@common/types/vessel';
|
||||
import { HNSLeftPanel } from './HNSLeftPanel';
|
||||
import type { HNSInputParams } from './HNSLeftPanel';
|
||||
import { HNSRightPanel } from './HNSRightPanel';
|
||||
@ -265,7 +267,10 @@ export function HNSView() {
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
|
||||
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
|
||||
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
||||
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
||||
const vessels = useVesselSignals(mapBounds);
|
||||
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [dispersionResult, setDispersionResult] = useState<any>(null);
|
||||
@ -904,6 +909,7 @@ export function HNSView() {
|
||||
onParamsChange={handleParamsChange}
|
||||
onReset={handleReset}
|
||||
loadedParams={loadedParams}
|
||||
onFlyToCoord={(c) => setFlyToCoord({ lat: c.lat, lon: c.lon })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -963,6 +969,8 @@ export function HNSView() {
|
||||
<>
|
||||
<MapView
|
||||
incidentCoord={incidentCoord ?? undefined}
|
||||
flyToIncident={flyToCoord}
|
||||
onIncidentFlyEnd={() => setFlyToCoord(undefined)}
|
||||
isSelectingLocation={isSelectingLocation}
|
||||
onMapClick={handleMapClick}
|
||||
oilTrajectory={[]}
|
||||
@ -970,6 +978,8 @@ export function HNSView() {
|
||||
dispersionResult={dispersionResult}
|
||||
dispersionHeatmap={heatmapData}
|
||||
mapCaptureRef={mapCaptureRef}
|
||||
vessels={vessels}
|
||||
onBoundsChange={setMapBounds}
|
||||
/>
|
||||
{/* 시간 슬라이더 (puff/dense_gas 모델용) */}
|
||||
{allTimeFrames.length > 1 && (
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { Map as MapLibre, Popup, useControl, useMap } from '@vis.gl/react-maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { ScatterplotLayer, IconLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers';
|
||||
import { Popup, useMap } from '@vis.gl/react-maplibre';
|
||||
import { ScatterplotLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers';
|
||||
import { PathStyleExtension } from '@deck.gl/extensions';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||||
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
|
||||
import { BaseMap } from '@common/components/map/BaseMap';
|
||||
import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay';
|
||||
import { MapBoundsTracker } from '@common/components/map/MapBoundsTracker';
|
||||
import { buildVesselLayers, VESSEL_LEGEND, getShipKindLabel } from '@common/components/map/VesselLayer';
|
||||
import { useVesselSignals } from '@common/hooks/useVesselSignals';
|
||||
import type { MapBounds, VesselPosition } from '@common/types/vessel';
|
||||
import { getVesselCacheStatus, type VesselCacheStatus } from '@common/services/vesselApi';
|
||||
import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel';
|
||||
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel';
|
||||
import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData';
|
||||
import { fetchIncidents } from '../services/incidentsApi';
|
||||
import type { IncidentCompat } from '../services/incidentsApi';
|
||||
import { fetchAnalysisTrajectory } from '@tabs/prediction/services/predictionApi';
|
||||
@ -27,9 +30,6 @@ import {
|
||||
getCachedZones,
|
||||
} from '../utils/dischargeZoneData';
|
||||
import { useMapStore } from '@common/store/mapStore';
|
||||
import { useMeasureTool } from '@common/hooks/useMeasureTool';
|
||||
import { buildMeasureLayers } from '@common/components/map/measureLayers';
|
||||
import { MeasureOverlay } from '@common/components/map/MeasureOverlay';
|
||||
|
||||
// ── 민감자원 카테고리별 색상 (목록 순서 인덱스 기반 — 중복 없음) ────────────
|
||||
const CATEGORY_PALETTE: [number, number, number][] = [
|
||||
@ -55,14 +55,6 @@ function getCategoryColor(index: number): [number, number, number] {
|
||||
return CATEGORY_PALETTE[index % CATEGORY_PALETTE.length];
|
||||
}
|
||||
|
||||
// ── DeckGLOverlay ──────────────────────────────────────
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
||||
overlay.setProps({ layers });
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── FlyToController: 사고 선택 시 지도 이동 ──────────
|
||||
function FlyToController({ incident }: { incident: IncidentCompat | null }) {
|
||||
const { current: map } = useMap();
|
||||
@ -98,16 +90,11 @@ function getMarkerStroke(s: string): [number, number, number, number] {
|
||||
const getStatusLabel = (s: string) =>
|
||||
s === 'active' ? '대응중' : s === 'investigating' ? '조사중' : s === 'closed' ? '종료' : '';
|
||||
|
||||
// ── 선박 아이콘 SVG (삼각형) ────────────────────────────
|
||||
// deck.gl IconLayer용 atlas 없이 SVGOverlay 방식 대신
|
||||
// ScatterplotLayer로 단순화하여 선박을 원으로 표현 (heading 정보 별도 레이어)
|
||||
// → 원 마커 + 방향 지시선을 deck.gl ScatterplotLayer로 표현
|
||||
|
||||
// 팝업 정보
|
||||
interface VesselPopupInfo {
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
vessel: Vessel;
|
||||
vessel: VesselPosition;
|
||||
}
|
||||
|
||||
interface IncidentPopupInfo {
|
||||
@ -120,7 +107,7 @@ interface IncidentPopupInfo {
|
||||
interface HoverInfo {
|
||||
x: number;
|
||||
y: number;
|
||||
object: Vessel | IncidentCompat;
|
||||
object: VesselPosition | IncidentCompat;
|
||||
type: 'vessel' | 'incident';
|
||||
}
|
||||
|
||||
@ -131,12 +118,35 @@ export function IncidentsView() {
|
||||
const [incidents, setIncidents] = useState<IncidentCompat[]>([]);
|
||||
const [filteredIncidents, setFilteredIncidents] = useState<IncidentCompat[]>([]);
|
||||
const [selectedIncidentId, setSelectedIncidentId] = useState<string | null>(null);
|
||||
const [selectedVessel, setSelectedVessel] = useState<Vessel | null>(null);
|
||||
const [detailVessel, setDetailVessel] = useState<Vessel | null>(null);
|
||||
const [selectedVessel, setSelectedVessel] = useState<VesselPosition | null>(null);
|
||||
const [detailVessel, setDetailVessel] = useState<VesselPosition | null>(null);
|
||||
const [vesselPopup, setVesselPopup] = useState<VesselPopupInfo | null>(null);
|
||||
const [incidentPopup, setIncidentPopup] = useState<IncidentPopupInfo | null>(null);
|
||||
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null);
|
||||
|
||||
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
||||
const [mapZoom, setMapZoom] = useState<number>(10);
|
||||
const realVessels = useVesselSignals(mapBounds);
|
||||
|
||||
const [vesselStatus, setVesselStatus] = useState<VesselCacheStatus | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const status = await getVesselCacheStatus();
|
||||
if (!cancelled) setVesselStatus(status);
|
||||
} catch {
|
||||
// 무시 — 다음 폴링에서 재시도
|
||||
}
|
||||
};
|
||||
load();
|
||||
const id = setInterval(load, 30_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [dischargeMode, setDischargeMode] = useState(false);
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||
@ -150,14 +160,8 @@ export function IncidentsView() {
|
||||
() => getCachedBaseline() !== null && getCachedZones() !== null,
|
||||
);
|
||||
|
||||
// Map style & toggles
|
||||
const currentMapStyle = useBaseMapStyle();
|
||||
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||
|
||||
// Measure tool
|
||||
const { handleMeasureClick, measureMode } = useMeasureTool();
|
||||
const measureInProgress = useMapStore((s) => s.measureInProgress);
|
||||
const measurements = useMapStore((s) => s.measurements);
|
||||
// Measure mode (cursor 결정용 — 측정 클릭/레이어는 BaseMap이 처리)
|
||||
const measureMode = useMapStore((s) => s.measureMode);
|
||||
|
||||
// Analysis view mode
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('overlay');
|
||||
@ -301,60 +305,6 @@ export function IncidentsView() {
|
||||
[filteredIncidents, selectedIncidentId],
|
||||
);
|
||||
|
||||
// ── 선박 방향 지시 아이콘 (삼각형 SVG → IconLayer) ──────
|
||||
// 글로우 효과 + 드롭쉐도우가 있는 개선된 SVG 삼각형
|
||||
const vesselIconLayer = useMemo(() => {
|
||||
const makeTriangleSvg = (color: string, isAccident: boolean) => {
|
||||
const opacity = isAccident ? '1' : '0.85';
|
||||
const glowOpacity = isAccident ? '0.9' : '0.75';
|
||||
const svgStr = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="20" viewBox="0 0 16 20">',
|
||||
'<defs><filter id="g" x="-50%" y="-50%" width="200%" height="200%">',
|
||||
'<feGaussianBlur stdDeviation="1.2"/></filter></defs>',
|
||||
`<polygon points="8,0 15,20 1,20" fill="${color}" opacity="${glowOpacity}" filter="url(#g)"/>`,
|
||||
`<polygon points="8,1 14,19 2,19" fill="${color}" opacity="${opacity}" stroke="${color}" stroke-width="0.5"/>`,
|
||||
'</svg>',
|
||||
].join('');
|
||||
return `data:image/svg+xml;base64,${btoa(svgStr)}`;
|
||||
};
|
||||
|
||||
return new IconLayer({
|
||||
id: 'vessel-icons',
|
||||
data: mockVessels,
|
||||
getPosition: (d: Vessel) => [d.lng, d.lat],
|
||||
getIcon: (d: Vessel) => ({
|
||||
url: makeTriangleSvg(d.color, d.status.includes('사고')),
|
||||
width: 16,
|
||||
height: 20,
|
||||
anchorX: 8,
|
||||
anchorY: 10,
|
||||
}),
|
||||
getSize: 16,
|
||||
getAngle: (d: Vessel) => -d.heading,
|
||||
sizeUnits: 'pixels',
|
||||
sizeScale: 1,
|
||||
pickable: true,
|
||||
onClick: (info: { object?: Vessel; coordinate?: number[] }) => {
|
||||
if (info.object && info.coordinate) {
|
||||
setSelectedVessel(info.object);
|
||||
setVesselPopup({
|
||||
longitude: info.coordinate[0],
|
||||
latitude: info.coordinate[1],
|
||||
vessel: info.object,
|
||||
});
|
||||
setIncidentPopup(null);
|
||||
setDetailVessel(null);
|
||||
}
|
||||
},
|
||||
onHover: (info: { object?: Vessel; x?: number; y?: number }) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setHoverInfo({ x: info.x, y: info.y, object: info.object, type: 'vessel' });
|
||||
} else {
|
||||
setHoverInfo((h) => (h?.type === 'vessel' ? null : h));
|
||||
}
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── 배출 구역 경계선 레이어 ──
|
||||
const dischargeZoneLayers = useMemo(() => {
|
||||
@ -377,11 +327,6 @@ export function IncidentsView() {
|
||||
);
|
||||
}, [dischargeMode, baselineLoaded]);
|
||||
|
||||
const measureDeckLayers = useMemo(
|
||||
() => buildMeasureLayers(measureInProgress, measureMode, measurements),
|
||||
[measureInProgress, measureMode, measurements],
|
||||
);
|
||||
|
||||
// ── 예측 결과 레이어 (입자 클라우드, 중심점 경로, 시간 라벨, 해안 부착 입자) ──────
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const trajectoryLayers: any[] = useMemo(() => {
|
||||
@ -558,24 +503,44 @@ export function IncidentsView() {
|
||||
});
|
||||
}, [sensitiveGeojson, sensCheckedCategories, sensColorMap]);
|
||||
|
||||
const realVesselLayers = useMemo(
|
||||
() =>
|
||||
buildVesselLayers(
|
||||
realVessels,
|
||||
{
|
||||
onClick: (vessel, coordinate) => {
|
||||
setSelectedVessel(vessel);
|
||||
setVesselPopup({
|
||||
longitude: coordinate[0],
|
||||
latitude: coordinate[1],
|
||||
vessel,
|
||||
});
|
||||
setIncidentPopup(null);
|
||||
setDetailVessel(null);
|
||||
},
|
||||
onHover: (vessel, x, y) => {
|
||||
if (vessel) {
|
||||
setHoverInfo({ x, y, object: vessel, type: 'vessel' });
|
||||
} else {
|
||||
setHoverInfo((h) => (h?.type === 'vessel' ? null : h));
|
||||
}
|
||||
},
|
||||
},
|
||||
mapZoom,
|
||||
),
|
||||
[realVessels, mapZoom],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const deckLayers: any[] = useMemo(
|
||||
() => [
|
||||
incidentLayer,
|
||||
vesselIconLayer,
|
||||
...realVesselLayers,
|
||||
...dischargeZoneLayers,
|
||||
...measureDeckLayers,
|
||||
...trajectoryLayers,
|
||||
...(sensLayer ? [sensLayer] : []),
|
||||
],
|
||||
[
|
||||
incidentLayer,
|
||||
vesselIconLayer,
|
||||
dischargeZoneLayers,
|
||||
measureDeckLayers,
|
||||
trajectoryLayers,
|
||||
sensLayer,
|
||||
],
|
||||
[incidentLayer, realVesselLayers, dischargeZoneLayers, trajectoryLayers, sensLayer],
|
||||
);
|
||||
|
||||
return (
|
||||
@ -710,30 +675,21 @@ export function IncidentsView() {
|
||||
{/* Default Map (visible when not in analysis or in overlay mode) */}
|
||||
{(!analysisActive || viewMode === 'overlay') && (
|
||||
<div className="absolute inset-0">
|
||||
<MapLibre
|
||||
initialViewState={{ longitude: 127.8, latitude: 35.0, zoom: 7 }}
|
||||
mapStyle={currentMapStyle}
|
||||
style={{ width: '100%', height: '100%', background: '#f0f0f0' }}
|
||||
attributionControl={false}
|
||||
onClick={(e) => {
|
||||
if (measureMode !== null && e.lngLat) {
|
||||
handleMeasureClick(e.lngLat.lng, e.lngLat.lat);
|
||||
return;
|
||||
}
|
||||
if (dischargeMode && e.lngLat) {
|
||||
const lat = e.lngLat.lat;
|
||||
const lon = e.lngLat.lng;
|
||||
<BaseMap
|
||||
center={[35.0, 127.8]}
|
||||
zoom={7}
|
||||
cursor={measureMode !== null || dischargeMode ? 'crosshair' : undefined}
|
||||
onMapClick={(lon, lat) => {
|
||||
if (dischargeMode) {
|
||||
const distanceNm = estimateDistanceFromCoast(lat, lon);
|
||||
const zoneIndex = determineZone(lat, lon);
|
||||
setDischargeInfo({ lat, lon, distanceNm, zoneIndex });
|
||||
}
|
||||
}}
|
||||
cursor={measureMode !== null || dischargeMode ? 'crosshair' : undefined}
|
||||
>
|
||||
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||
<DeckGLOverlay layers={deckLayers} />
|
||||
<MapBoundsTracker onBoundsChange={setMapBounds} onZoomChange={setMapZoom} />
|
||||
<FlyToController incident={selectedIncident} />
|
||||
<MeasureOverlay />
|
||||
|
||||
{/* 사고 팝업 */}
|
||||
{incidentPopup && (
|
||||
@ -753,7 +709,7 @@ export function IncidentsView() {
|
||||
/>
|
||||
</Popup>
|
||||
)}
|
||||
</MapLibre>
|
||||
</BaseMap>
|
||||
|
||||
{/* 호버 툴팁 */}
|
||||
{hoverInfo && (
|
||||
@ -770,7 +726,7 @@ export function IncidentsView() {
|
||||
}}
|
||||
>
|
||||
{hoverInfo.type === 'vessel' ? (
|
||||
<VesselTooltipContent vessel={hoverInfo.object as Vessel} />
|
||||
<VesselTooltipContent vessel={hoverInfo.object as VesselPosition} />
|
||||
) : (
|
||||
<IncidentTooltipContent incident={hoverInfo.object as IncidentCompat} />
|
||||
)}
|
||||
@ -900,12 +856,11 @@ export function IncidentsView() {
|
||||
}}
|
||||
/> */}
|
||||
<span className="text-caption">AIS Live</span>
|
||||
<span className="text-caption text-fg-disabled font-mono">MarineTraffic</span>
|
||||
</div>
|
||||
<div className="flex gap-2.5 text-caption font-mono">
|
||||
<div className="text-fg-sub">선박 20</div>
|
||||
<div className="text-fg-sub">사고 6</div>
|
||||
<div className="text-fg-sub">방제선 2</div>
|
||||
<div className="text-fg-sub">선박 {vesselStatus?.count ?? 0}</div>
|
||||
<div className="text-fg-sub">사고 {filteredIncidents.length}</div>
|
||||
<div className="text-fg-sub">방제선 {vesselStatus?.bangjeCount ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1149,7 +1104,15 @@ export function IncidentsView() {
|
||||
onCloseAnalysis={handleCloseAnalysis}
|
||||
onCheckedPredsChange={handleCheckedPredsChange}
|
||||
onSensitiveDataChange={handleSensitiveDataChange}
|
||||
selectedVessel={selectedVessel}
|
||||
selectedVessel={
|
||||
selectedVessel
|
||||
? {
|
||||
lat: selectedVessel.lat,
|
||||
lng: selectedVessel.lon,
|
||||
name: selectedVessel.shipNm,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -1294,21 +1257,40 @@ function SplitPanelContent({
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════
|
||||
VesselPopupPanel
|
||||
VesselPopupPanel / VesselDetailModal 공용 유틸
|
||||
════════════════════════════════════════════════════ */
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '-';
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function displayVal(v: unknown): string {
|
||||
if (v === undefined || v === null || v === '') return '-';
|
||||
return String(v);
|
||||
}
|
||||
|
||||
|
||||
function VesselPopupPanel({
|
||||
vessel: v,
|
||||
onClose,
|
||||
onDetail,
|
||||
}: {
|
||||
vessel: Vessel;
|
||||
vessel: VesselPosition;
|
||||
onClose: () => void;
|
||||
onDetail: () => void;
|
||||
}) {
|
||||
const statusColor = v.status.includes('사고') ? 'var(--color-danger)' : 'var(--color-success)';
|
||||
const statusBg = v.status.includes('사고')
|
||||
const statusText = v.status ?? '-';
|
||||
const isAccident = (v.status ?? '').includes('사고');
|
||||
const statusColor = isAccident ? 'var(--color-danger)' : 'var(--color-success)';
|
||||
const statusBg = isAccident
|
||||
? 'color-mix(in srgb, var(--color-danger) 15%, transparent)'
|
||||
: 'color-mix(in srgb, var(--color-success) 10%, transparent)';
|
||||
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-';
|
||||
const heading = v.heading ?? v.cog;
|
||||
const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-';
|
||||
const receivedAt = v.lastUpdate ? formatDateTime(v.lastUpdate) : '-';
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -1341,14 +1323,14 @@ function VesselPopupPanel({
|
||||
className="flex items-center justify-center text-title-2"
|
||||
style={{ width: 28, height: 20 }}
|
||||
>
|
||||
{v.flag}
|
||||
{v.nationalCode ?? '🚢'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-label-1 font-[800] whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
style={{ color: '#e6edf3' }}
|
||||
>
|
||||
{v.name}
|
||||
{v.shipNm ?? '(이름 없음)'}
|
||||
</div>
|
||||
<div className="text-caption font-mono" style={{ color: '#8b949e' }}>
|
||||
MMSI: {v.mmsi}
|
||||
@ -1389,7 +1371,7 @@ function VesselPopupPanel({
|
||||
border: '1px solid color-mix(in srgb, var(--color-info) 25%, transparent)',
|
||||
}}
|
||||
>
|
||||
{v.typS}
|
||||
{getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-'}
|
||||
</span>
|
||||
<span
|
||||
className="text-caption font-bold rounded"
|
||||
@ -1400,14 +1382,14 @@ function VesselPopupPanel({
|
||||
color: statusColor,
|
||||
}}
|
||||
>
|
||||
{v.status}
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Data rows */}
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
<PopupRow label="속도/항로" value={`${v.speed} kn / ${v.heading}°`} accent />
|
||||
<PopupRow label="흘수" value={`${v.draft}m`} />
|
||||
<PopupRow label="속도/항로" value={`${speed} / ${headingText}`} accent />
|
||||
<PopupRow label="흘수" value={v.draught !== undefined ? `${v.draught.toFixed(2)} m` : '-'} />
|
||||
<div
|
||||
className="flex flex-col gap-1"
|
||||
style={{
|
||||
@ -1420,7 +1402,7 @@ function VesselPopupPanel({
|
||||
출항지
|
||||
</span>
|
||||
<span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}>
|
||||
{v.depart}
|
||||
-
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
@ -1428,11 +1410,11 @@ function VesselPopupPanel({
|
||||
입항지
|
||||
</span>
|
||||
<span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}>
|
||||
{v.arrive}
|
||||
{v.destination ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<PopupRow label="데이터 수신" value="2026-02-25 14:32:00" muted />
|
||||
<PopupRow label="데이터 수신" value={receivedAt} muted />
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
@ -1677,7 +1659,13 @@ const TAB_LABELS: { key: DetTab; label: string }[] = [
|
||||
{ key: 'dg', label: '위험물정보' },
|
||||
];
|
||||
|
||||
function VesselDetailModal({ vessel: v, onClose }: { vessel: Vessel; onClose: () => void }) {
|
||||
function VesselDetailModal({
|
||||
vessel: v,
|
||||
onClose,
|
||||
}: {
|
||||
vessel: VesselPosition;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<DetTab>('info');
|
||||
|
||||
return (
|
||||
@ -1706,11 +1694,13 @@ function VesselDetailModal({ vessel: v, onClose }: { vessel: Vessel; onClose: ()
|
||||
style={{ padding: '14px 18px' }}
|
||||
>
|
||||
<div className="flex items-center gap-[10px]">
|
||||
<span className="text-lg">{v.flag}</span>
|
||||
<span className="text-lg">{v.nationalCode ?? '🚢'}</span>
|
||||
<div>
|
||||
<div className="text-title-3 font-[800] text-fg">{v.name}</div>
|
||||
<div className="text-title-3 font-[800] text-fg">
|
||||
{v.shipNm ?? '(이름 없음)'}
|
||||
</div>
|
||||
<div className="text-caption text-fg-disabled font-mono">
|
||||
MMSI: {v.mmsi} · IMO: {v.imo}
|
||||
MMSI: {v.mmsi} · IMO: {displayVal(v.imo)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1858,7 +1848,10 @@ function StatusBadge({ label, color }: { label: string; color: string }) {
|
||||
}
|
||||
|
||||
/* ── Tab 0: 상세정보 ─────────────────────────────── */
|
||||
function TabInfo({ v }: { v: Vessel }) {
|
||||
function TabInfo({ v }: { v: VesselPosition }) {
|
||||
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-';
|
||||
const heading = v.heading ?? v.cog;
|
||||
const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-';
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@ -1870,25 +1863,25 @@ function TabInfo({ v }: { v: Vessel }) {
|
||||
|
||||
<Sec title="📡 실시간 현황">
|
||||
<Grid>
|
||||
<Cell label="선박상태" value={v.status} />
|
||||
<Cell label="선박상태" value={displayVal(v.status)} />
|
||||
<Cell
|
||||
label="속도 / 항로"
|
||||
value={`${v.speed} kn / ${v.heading}°`}
|
||||
value={`${speed} / ${headingText}`}
|
||||
color="var(--color-accent)"
|
||||
/>
|
||||
<Cell label="위도" value={`${v.lat.toFixed(4)}°N`} />
|
||||
<Cell label="경도" value={`${v.lng.toFixed(4)}°E`} />
|
||||
<Cell label="흘수" value={`${v.draft}m`} />
|
||||
<Cell label="수신시간" value="2026-02-25 14:30" />
|
||||
<Cell label="경도" value={`${v.lon.toFixed(4)}°E`} />
|
||||
<Cell label="흘수" value={v.draught !== undefined ? `${v.draught} m` : '-'} />
|
||||
<Cell label="수신시간" value={v.lastUpdate ? formatDateTime(v.lastUpdate) : '-'} />
|
||||
</Grid>
|
||||
</Sec>
|
||||
|
||||
<Sec title="🚢 항해 일정">
|
||||
<Grid>
|
||||
<Cell label="출항지" value={v.depart} />
|
||||
<Cell label="입항지" value={v.arrive} />
|
||||
<Cell label="출항일시" value={v.etd || '—'} />
|
||||
<Cell label="입항일시(ETA)" value={v.eta || '—'} />
|
||||
<Cell label="출항지" value="-" />
|
||||
<Cell label="입항지" value={displayVal(v.destination)} />
|
||||
<Cell label="출항일시" value="-" />
|
||||
<Cell label="입항일시(ETA)" value="-" />
|
||||
</Grid>
|
||||
</Sec>
|
||||
</>
|
||||
@ -1897,7 +1890,7 @@ function TabInfo({ v }: { v: Vessel }) {
|
||||
|
||||
/* ── Tab 1: 항해정보 ─────────────────────────────── */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function TabNav(_props: { v: Vessel }) {
|
||||
function TabNav(_props: { v: VesselPosition }) {
|
||||
const hours = ['08', '09', '10', '11', '12', '13', '14'];
|
||||
const heights = [45, 60, 78, 82, 70, 85, 75];
|
||||
const colors = [
|
||||
@ -2020,28 +2013,30 @@ function TabNav(_props: { v: Vessel }) {
|
||||
}
|
||||
|
||||
/* ── Tab 2: 선박제원 ─────────────────────────────── */
|
||||
function TabSpec({ v }: { v: Vessel }) {
|
||||
function TabSpec({ v }: { v: VesselPosition }) {
|
||||
const loa = v.length !== undefined ? `${v.length} m` : '-';
|
||||
const beam = v.width !== undefined ? `${v.width} m` : '-';
|
||||
return (
|
||||
<>
|
||||
<Sec title="📐 선체 제원">
|
||||
<Grid>
|
||||
<Cell label="선종" value={v.typS} />
|
||||
<Cell label="선적국" value={`${v.flag}`} />
|
||||
<Cell label="총톤수 (GT)" value={v.gt} />
|
||||
<Cell label="재화중량 (DWT)" value={v.dwt} />
|
||||
<Cell label="전장 (LOA)" value={v.loa} />
|
||||
<Cell label="선폭" value={v.beam} />
|
||||
<Cell label="건조년도" value={v.built} />
|
||||
<Cell label="건조 조선소" value={v.yard} />
|
||||
<Cell label="선종" value={displayVal(getShipKindLabel(v.shipKindCode) ?? v.shipTy)} />
|
||||
<Cell label="선적국" value={displayVal(v.nationalCode)} />
|
||||
<Cell label="총톤수 (GT)" value="-" />
|
||||
<Cell label="재화중량 (DWT)" value="-" />
|
||||
<Cell label="전장 (LOA)" value={loa} />
|
||||
<Cell label="선폭" value={beam} />
|
||||
<Cell label="건조년도" value="-" />
|
||||
<Cell label="건조 조선소" value="-" />
|
||||
</Grid>
|
||||
</Sec>
|
||||
|
||||
<Sec title="📡 통신 / 식별">
|
||||
<Grid>
|
||||
<Cell label="MMSI" value={String(v.mmsi)} />
|
||||
<Cell label="IMO" value={v.imo} />
|
||||
<Cell label="호출부호" value={v.callSign} />
|
||||
<Cell label="선급" value={v.cls} />
|
||||
<Cell label="IMO" value={displayVal(v.imo)} />
|
||||
<Cell label="호출부호" value="-" />
|
||||
<Cell label="선급" value="-" />
|
||||
</Grid>
|
||||
</Sec>
|
||||
|
||||
@ -2057,23 +2052,9 @@ function TabSpec({ v }: { v: Vessel }) {
|
||||
>
|
||||
<span className="text-label-1">🛢</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-caption font-semibold text-fg">
|
||||
{v.cargo.split('·')[0].trim()}
|
||||
</div>
|
||||
<div className="text-caption text-fg-disabled">{v.cargo}</div>
|
||||
<div className="text-caption font-semibold text-fg">-</div>
|
||||
<div className="text-caption text-fg-disabled">정보 없음</div>
|
||||
</div>
|
||||
{v.cargo.includes('IMO') && (
|
||||
<span
|
||||
className="text-caption font-bold text-color-danger"
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
background: 'color-mix(in srgb, var(--color-danger) 15%, transparent)',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
위험
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Sec>
|
||||
@ -2083,7 +2064,7 @@ function TabSpec({ v }: { v: Vessel }) {
|
||||
|
||||
/* ── Tab 3: 보험정보 ─────────────────────────────── */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function TabInsurance(_props: { v: Vessel }) {
|
||||
function TabInsurance(_props: { v: VesselPosition }) {
|
||||
return (
|
||||
<>
|
||||
<Sec title="🏢 선주 / 운항사">
|
||||
@ -2155,7 +2136,8 @@ function TabInsurance(_props: { v: Vessel }) {
|
||||
}
|
||||
|
||||
/* ── Tab 4: 위험물정보 ───────────────────────────── */
|
||||
function TabDangerous({ v }: { v: Vessel }) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function TabDangerous(_props: { v: VesselPosition }) {
|
||||
return (
|
||||
<>
|
||||
<Sec
|
||||
@ -2175,11 +2157,7 @@ function TabDangerous({ v }: { v: Vessel }) {
|
||||
}
|
||||
>
|
||||
<Grid>
|
||||
<Cell
|
||||
label="화물명"
|
||||
value={v.cargo.split('·')[0].trim() || '—'}
|
||||
color="var(--color-warning)"
|
||||
/>
|
||||
<Cell label="화물명" value="-" color="var(--color-warning)" />
|
||||
<Cell label="컨테이너갯수/총량" value="— / 72,850 톤" />
|
||||
<Cell label="하역업체코드" value="KRY-2847" />
|
||||
<Cell label="하역기간" value="02-26 ~ 02-28" />
|
||||
@ -2349,18 +2327,22 @@ function ActionBtn({
|
||||
/* ════════════════════════════════════════════════════
|
||||
호버 툴팁 컴포넌트
|
||||
════════════════════════════════════════════════════ */
|
||||
function VesselTooltipContent({ vessel: v }: { vessel: Vessel }) {
|
||||
function VesselTooltipContent({ vessel: v }: { vessel: VesselPosition }) {
|
||||
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '- kn';
|
||||
const heading = v.heading ?? v.cog;
|
||||
const headingText = heading !== undefined ? `HDG ${Math.round(heading)}°` : 'HDG -';
|
||||
const typeText = [getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-', v.nationalCode].filter(Boolean).join(' · ');
|
||||
return (
|
||||
<>
|
||||
<div className="text-label-2 font-bold text-fg" style={{ marginBottom: 3 }}>
|
||||
{v.name}
|
||||
{v.shipNm ?? '(이름 없음)'}
|
||||
</div>
|
||||
<div className="text-caption text-fg-disabled" style={{ marginBottom: 4 }}>
|
||||
{v.typS} · {v.flag}
|
||||
{typeText}
|
||||
</div>
|
||||
<div className="flex justify-between text-caption">
|
||||
<span className="text-color-accent font-semibold">{v.speed} kn</span>
|
||||
<span className="text-fg-disabled">HDG {v.heading}°</span>
|
||||
<span className="text-color-accent font-semibold">{speed}</span>
|
||||
<span className="text-fg-disabled">{headingText}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Incident } from './IncidentsLeftPanel';
|
||||
import { fetchIncidentMedia, fetchIncidentAerialMedia, getMediaImageUrl } from '../services/incidentsApi';
|
||||
import {
|
||||
fetchIncidentMedia,
|
||||
fetchIncidentAerialMedia,
|
||||
getMediaImageUrl,
|
||||
} from '../services/incidentsApi';
|
||||
import type { MediaInfo, AerialMediaItem } from '../services/incidentsApi';
|
||||
|
||||
type MediaTab = 'all' | 'photo' | 'video' | 'satellite' | 'cctv';
|
||||
@ -78,7 +82,12 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
);
|
||||
}
|
||||
|
||||
const total = (media.photoCnt ?? 0) + (media.videoCnt ?? 0) + (media.satCnt ?? 0) + (media.cctvCnt ?? 0) + aerialImages.length;
|
||||
const total =
|
||||
(media.photoCnt ?? 0) +
|
||||
(media.videoCnt ?? 0) +
|
||||
(media.satCnt ?? 0) +
|
||||
(media.cctvCnt ?? 0) +
|
||||
aerialImages.length;
|
||||
|
||||
const showPhoto = activeTab === 'all' || activeTab === 'photo';
|
||||
const showVideo = activeTab === 'all' || activeTab === 'video';
|
||||
@ -236,14 +245,25 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<span className="text-label-1">📷</span>
|
||||
<span className="text-label-1 font-bold text-fg">
|
||||
현장사진 — {aerialImages.length > 0 ? `${aerialImages.length}장` : str(media.photoMeta, 'title', '현장 사진')}
|
||||
현장사진 —{' '}
|
||||
{aerialImages.length > 0
|
||||
? `${aerialImages.length}장`
|
||||
: str(media.photoMeta, 'title', '현장 사진')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-[4px]">
|
||||
{aerialImages.length > 1 && (
|
||||
<>
|
||||
<NavBtn label="◀" onClick={() => setSelectedImageIdx((p) => Math.max(0, p - 1))} />
|
||||
<NavBtn label="▶" onClick={() => setSelectedImageIdx((p) => Math.min(aerialImages.length - 1, p + 1))} />
|
||||
<NavBtn
|
||||
label="◀"
|
||||
onClick={() => setSelectedImageIdx((p) => Math.max(0, p - 1))}
|
||||
/>
|
||||
<NavBtn
|
||||
label="▶"
|
||||
onClick={() =>
|
||||
setSelectedImageIdx((p) => Math.min(aerialImages.length - 1, p + 1))
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<NavBtn label="↗" />
|
||||
@ -259,12 +279,18 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
|
||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove(
|
||||
'hidden',
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="hidden flex-col items-center gap-2">
|
||||
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>📷</div>
|
||||
<div className="text-label-1 text-fg-disabled">이미지를 불러올 수 없습니다</div>
|
||||
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>
|
||||
📷
|
||||
</div>
|
||||
<div className="text-label-1 text-fg-disabled">
|
||||
이미지를 불러올 수 없습니다
|
||||
</div>
|
||||
</div>
|
||||
{aerialImages.length > 1 && (
|
||||
<>
|
||||
@ -272,7 +298,8 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
onClick={() => setSelectedImageIdx((prev) => Math.max(0, prev - 1))}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 flex items-center justify-center text-fg-disabled cursor-pointer rounded"
|
||||
style={{
|
||||
width: 28, height: 28,
|
||||
width: 28,
|
||||
height: 28,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
opacity: selectedImageIdx === 0 ? 0.3 : 1,
|
||||
@ -282,10 +309,15 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
◀
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedImageIdx((prev) => Math.min(aerialImages.length - 1, prev + 1))}
|
||||
onClick={() =>
|
||||
setSelectedImageIdx((prev) =>
|
||||
Math.min(aerialImages.length - 1, prev + 1),
|
||||
)
|
||||
}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center text-fg-disabled cursor-pointer rounded"
|
||||
style={{
|
||||
width: 28, height: 28,
|
||||
width: 28,
|
||||
height: 28,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
opacity: selectedImageIdx === aerialImages.length - 1 ? 0.3 : 1,
|
||||
@ -309,9 +341,12 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>📷</div>
|
||||
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>
|
||||
📷
|
||||
</div>
|
||||
<div className="text-label-1 text-fg-sub font-semibold">
|
||||
{incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')} 해상 사진
|
||||
{incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')} 해상
|
||||
사진
|
||||
</div>
|
||||
<div className="text-caption text-fg-disabled font-mono">
|
||||
{str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')}
|
||||
@ -335,10 +370,14 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
width: 48,
|
||||
height: 40,
|
||||
borderRadius: 4,
|
||||
background: i === selectedImageIdx ? 'rgba(6,182,212,0.15)' : 'var(--bg-elevated)',
|
||||
border: i === selectedImageIdx
|
||||
? '2px solid rgba(6,182,212,0.5)'
|
||||
: '1px solid var(--stroke-default)',
|
||||
background:
|
||||
i === selectedImageIdx
|
||||
? 'rgba(6,182,212,0.15)'
|
||||
: 'var(--bg-elevated)',
|
||||
border:
|
||||
i === selectedImageIdx
|
||||
? '2px solid rgba(6,182,212,0.5)'
|
||||
: '1px solid var(--stroke-default)',
|
||||
}}
|
||||
onClick={() => setSelectedImageIdx(i)}
|
||||
>
|
||||
@ -393,7 +432,8 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-caption text-fg-disabled">
|
||||
📷 사진 {num(media.photoMeta, 'thumbCount')}장 · {str(media.photoMeta, 'stage')}
|
||||
📷 사진 {num(media.photoMeta, 'thumbCount')}장 ·{' '}
|
||||
{str(media.photoMeta, 'stage')}
|
||||
</span>
|
||||
<span className="text-caption text-color-tertiary cursor-pointer">
|
||||
🔗 R&D 연계
|
||||
@ -673,7 +713,10 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
>
|
||||
<div className="flex gap-4 text-caption font-mono text-fg-disabled">
|
||||
<span>
|
||||
📷 사진 <b className="text-fg">{aerialImages.length > 0 ? aerialImages.length : (media.photoCnt ?? 0)}</b>
|
||||
📷 사진{' '}
|
||||
<b className="text-fg">
|
||||
{aerialImages.length > 0 ? aerialImages.length : (media.photoCnt ?? 0)}
|
||||
</b>
|
||||
</span>
|
||||
<span>
|
||||
🎬 영상 <b className="text-fg">{media.videoCnt ?? 0}</b>
|
||||
|
||||
@ -48,7 +48,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
||||
);
|
||||
case 'pending':
|
||||
return (
|
||||
<span className="px-2 py-1 text-label-2 font-medium rounded-md bg-[rgba(138,150,168,0.15)] text-fg-disabled">
|
||||
<span className="px-2 py-1 text-label-2 font-medium rounded-md bg-[rgba(138,150,168,0.15)] text-fg-default">
|
||||
대기
|
||||
</span>
|
||||
);
|
||||
@ -103,7 +103,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
||||
return pages.map((page, index) => {
|
||||
if (page === '...') {
|
||||
return (
|
||||
<span key={`ellipsis-${index}`} className="px-3 py-1 text-fg-disabled">
|
||||
<span key={`ellipsis-${index}`} className="px-3 py-1 text-fg-default">
|
||||
...
|
||||
</span>
|
||||
);
|
||||
@ -128,7 +128,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-stroke">
|
||||
<div>
|
||||
<h1 className="text-heading-3 text-fg">유출유 확산 예측 목록</h1>
|
||||
<p className="text-body-2 text-fg-disabled mt-1">총 {analyses.length}건</p>
|
||||
<p className="text-body-2 text-fg-default mt-1">총 {analyses.length}건</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
@ -156,48 +156,48 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="text-center py-20 text-fg-disabled text-body-2">로딩 중...</div>
|
||||
<div className="text-center py-20 text-fg-default text-body-2">로딩 중...</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="sticky top-0 bg-bg-surface border-b border-stroke z-10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
|
||||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||||
번호
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
|
||||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||||
사고명
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
|
||||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||||
사고일시
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
|
||||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||||
예측 실행
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
|
||||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||||
예측시간
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
|
||||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||||
유종
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-label-1 font-bold text-fg-disabled uppercase tracking-label">
|
||||
<th className="px-4 py-3 text-right text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||||
유출량
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-disabled uppercase tracking-label">
|
||||
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||||
KOSPS
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-disabled uppercase tracking-label">
|
||||
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||||
POSEIDON
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-disabled uppercase tracking-label">
|
||||
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||||
OpenDrift
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-disabled uppercase tracking-label">
|
||||
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||||
역추적
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
|
||||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||||
담당자
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
|
||||
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
|
||||
소속
|
||||
</th>
|
||||
</tr>
|
||||
@ -277,7 +277,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
||||
)}
|
||||
|
||||
{!loading && analyses.length === 0 && (
|
||||
<div className="text-center py-20 text-fg-disabled text-body-2">
|
||||
<div className="text-center py-20 text-fg-default text-body-2">
|
||||
분석 데이터가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -115,7 +115,7 @@ export function BacktrackModal({
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-base font-bold m-0">유출유 역추적 분석</h2>
|
||||
<div className="text-label-2 text-fg-disabled mt-[2px]">
|
||||
<div className="text-label-2 text-fg-default mt-[2px]">
|
||||
AIS 항적 기반 유출 선박 추정
|
||||
</div>
|
||||
</div>
|
||||
@ -128,7 +128,7 @@ export function BacktrackModal({
|
||||
background: 'var(--bg-card)',
|
||||
fontSize: 'var(--font-size-body-2)',
|
||||
}}
|
||||
className="border border-stroke text-fg-disabled cursor-pointer flex items-center justify-center"
|
||||
className="border border-stroke text-fg-default cursor-pointer flex items-center justify-center"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@ -160,7 +160,7 @@ export function BacktrackModal({
|
||||
}}
|
||||
className="border border-stroke"
|
||||
>
|
||||
<div className="text-caption text-fg-disabled mb-1">유출 추정 시각</div>
|
||||
<div className="text-caption text-fg-default mb-1">유출 추정 시각</div>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={inputTime}
|
||||
@ -179,7 +179,7 @@ export function BacktrackModal({
|
||||
}}
|
||||
className="border border-stroke"
|
||||
>
|
||||
<div className="text-caption text-fg-disabled mb-1">분석 범위</div>
|
||||
<div className="text-caption text-fg-default mb-1">분석 범위</div>
|
||||
<select
|
||||
value={inputRange}
|
||||
onChange={(e) => setInputRange(e.target.value)}
|
||||
@ -201,7 +201,7 @@ export function BacktrackModal({
|
||||
}}
|
||||
className="border border-stroke"
|
||||
>
|
||||
<div className="text-caption text-fg-disabled mb-1">탐색 반경</div>
|
||||
<div className="text-caption text-fg-default mb-1">탐색 반경</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
@ -213,7 +213,7 @@ export function BacktrackModal({
|
||||
step={0.5}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<span className="text-caption text-fg-disabled shrink-0">NM</span>
|
||||
<span className="text-caption text-fg-default shrink-0">NM</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -226,7 +226,7 @@ export function BacktrackModal({
|
||||
}}
|
||||
className="border border-stroke"
|
||||
>
|
||||
<div className="text-caption text-fg-disabled mb-1">유출 위치</div>
|
||||
<div className="text-caption text-fg-default mb-1">유출 위치</div>
|
||||
<div className="text-label-1 font-semibold font-mono">
|
||||
{conditions.spillLocation.lat.toFixed(4)}°N,{' '}
|
||||
{conditions.spillLocation.lon.toFixed(4)}°E
|
||||
@ -243,10 +243,10 @@ export function BacktrackModal({
|
||||
gridColumn: '1 / -1',
|
||||
}}
|
||||
>
|
||||
<div className="text-caption text-fg-disabled mb-1">분석 대상 선박</div>
|
||||
<div className="text-caption text-fg-default mb-1">분석 대상 선박</div>
|
||||
<div className="text-body-2 font-bold text-color-tertiary font-mono">
|
||||
{conditions.totalVessels}척{' '}
|
||||
<span className="text-caption font-medium text-fg-disabled">(AIS 수신)</span>
|
||||
<span className="text-caption font-medium text-fg-default">(AIS 수신)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -380,7 +380,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-title-4 font-bold font-mono">{vessel.name}</div>
|
||||
<div className="text-caption text-fg-disabled font-mono mt-[2px]">
|
||||
<div className="text-caption text-fg-default font-mono mt-[2px]">
|
||||
IMO: {vessel.imo} · {vessel.type} · {vessel.flag}
|
||||
</div>
|
||||
</div>
|
||||
@ -391,7 +391,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
|
||||
>
|
||||
{vessel.probability}%
|
||||
</div>
|
||||
<div className="text-caption text-fg-disabled">유출 확률</div>
|
||||
<div className="text-caption text-fg-default">유출 확률</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -429,7 +429,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
|
||||
: '1px solid var(--stroke-default)',
|
||||
}}
|
||||
>
|
||||
<div className="text-caption text-fg-disabled mb-[2px]">{s.label}</div>
|
||||
<div className="text-caption text-fg-default mb-[2px]">{s.label}</div>
|
||||
<div
|
||||
style={{
|
||||
color: s.highlight ? 'var(--color-danger)' : 'var(--fg-default)',
|
||||
|
||||
@ -27,7 +27,7 @@ export function BoomDeploymentTheoryView() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-title-2 font-bold">오일펜스 배치 최적화 알고리즘 이론</div>
|
||||
<div className="text-label-2 mt-0.5 text-fg-disabled">
|
||||
<div className="text-label-2 mt-0.5 text-fg-default">
|
||||
Oil Boom Deployment Optimization · 유출유 확산예측 연동 · 방제효율 최대화
|
||||
</div>
|
||||
</div>
|
||||
@ -54,7 +54,7 @@ export function BoomDeploymentTheoryView() {
|
||||
className={`flex-1 py-2 px-1 text-label-1 font-medium rounded-md transition-all duration-150 cursor-pointer border ${
|
||||
activePanel === tab.id
|
||||
? 'border-stroke-light bg-bg-elevated text-fg'
|
||||
: 'border-transparent bg-bg-card text-fg-disabled hover:text-fg-sub'
|
||||
: 'border-transparent bg-bg-card text-fg-default hover:text-fg-sub'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
@ -207,11 +207,11 @@ function OverviewPanel() {
|
||||
<div className="font-bold" style={{ color: step.color }}>
|
||||
{step.label}
|
||||
</div>
|
||||
<div className="text-fg-disabled" style={{ whiteSpace: 'pre-line' }}>
|
||||
<div className="text-fg-default" style={{ whiteSpace: 'pre-line' }}>
|
||||
{step.sub}
|
||||
</div>
|
||||
</div>
|
||||
{i < 5 && <div className="px-1.5 text-fg-disabled text-title-3">▶</div>}
|
||||
{i < 5 && <div className="px-1.5 text-fg-default text-title-3">▶</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -369,10 +369,10 @@ function DeploymentTheoryPanel() {
|
||||
F<sub>loss</sub>(U<sub>n</sub>)
|
||||
</span>
|
||||
<br />U<sub>n</sub> = U · sin(θ){' '}
|
||||
<span className="text-caption text-fg-disabled">(법선방향 유속)</span>
|
||||
<span className="text-caption text-fg-default">(법선방향 유속)</span>
|
||||
<br />E = 1 (U<sub>n</sub> ≤ U<sub>c</sub>)<br />E = max(0, 1 − (U<sub>n</sub>/U
|
||||
<sub>c</sub>)²) (U<sub>n</sub> > U<sub>c</sub>)<br />
|
||||
<span className="text-caption text-fg-disabled">
|
||||
<span className="text-caption text-fg-default">
|
||||
U<sub>c</sub>: 임계유속(약 0.35m/s = 0.7 knot)
|
||||
</span>
|
||||
</div>
|
||||
@ -391,12 +391,12 @@ function DeploymentTheoryPanel() {
|
||||
style={{ border: '1px solid rgba(6,182,212,.2)' }}
|
||||
>
|
||||
θ* = arcsin(U<sub>c</sub> / U){' '}
|
||||
<span className="text-caption text-fg-disabled">(임계조건)</span>
|
||||
<span className="text-caption text-fg-default">(임계조건)</span>
|
||||
<br />θ<sub>opt</sub> = argmax [A<sub>block</sub>(θ) · E(θ,U)]
|
||||
<br />
|
||||
실용범위: 15° ≤ θ ≤ 60°
|
||||
<br />
|
||||
<span className="text-caption text-fg-disabled">
|
||||
<span className="text-caption text-fg-default">
|
||||
단, θ < arcsin(U<sub>c</sub>/U) 이면 기름 통과 발생
|
||||
</span>
|
||||
</div>
|
||||
@ -483,7 +483,7 @@ function DeploymentTheoryPanel() {
|
||||
>
|
||||
A<sub>V</sub> = L²·sin(2α)/2
|
||||
<br />
|
||||
<span className="text-fg-disabled">α: 반개각, L: 편측 길이</span>
|
||||
<span className="text-fg-default">α: 반개각, L: 편측 길이</span>
|
||||
<br />
|
||||
최적 α = 30°~45°
|
||||
</div>
|
||||
@ -554,7 +554,7 @@ function DeploymentTheoryPanel() {
|
||||
>
|
||||
A<sub>U</sub> = π·r²/2 + 2r·h
|
||||
<br />
|
||||
<span className="text-fg-disabled">r: 반경, h: 직선부 길이</span>
|
||||
<span className="text-fg-default">r: 반경, h: 직선부 길이</span>
|
||||
<br />
|
||||
전제: U < 0.5 knot
|
||||
</div>
|
||||
@ -625,7 +625,7 @@ function DeploymentTheoryPanel() {
|
||||
style={{ background: 'rgba(6,182,212,.05)' }}
|
||||
>
|
||||
θ<sub>J</sub> = arcsin(U<sub>c</sub>/U) + δ<br />
|
||||
<span className="text-fg-disabled">δ: 안전여유각(5°~10°)</span>
|
||||
<span className="text-fg-default">δ: 안전여유각(5°~10°)</span>
|
||||
<br />
|
||||
활용: U > 0.7 knot
|
||||
</div>
|
||||
@ -642,7 +642,7 @@ function DeploymentTheoryPanel() {
|
||||
n개 직렬 배치 시 누적 차단 효율:
|
||||
<div className="mt-2 rounded-[5px] p-[9px] leading-[2] bg-bg-base font-mono">
|
||||
E<sub>total</sub> = 1 − ∏(1−E<sub>i</sub>)<br />
|
||||
<span className="text-caption text-fg-disabled">
|
||||
<span className="text-caption text-fg-default">
|
||||
E<sub>i</sub>: i번째 오일펜스 단독 차단효율
|
||||
</span>
|
||||
</div>
|
||||
@ -728,18 +728,18 @@ function OptimizationPanel() {
|
||||
<b className="text-color-accent">최대화:</b>
|
||||
<br />
|
||||
f₁(x) = Σ A<sub>blocked,i</sub> · w<sub>ESI,i</sub>{' '}
|
||||
<span className="text-caption text-fg-disabled">(가중 차단면적)</span>
|
||||
<span className="text-caption text-fg-default">(가중 차단면적)</span>
|
||||
<br />
|
||||
f₂(x) = T<sub>deadline</sub> − T<sub>deploy</sub>{' '}
|
||||
<span className="text-caption text-fg-disabled">(여유시간)</span>
|
||||
<span className="text-caption text-fg-default">(여유시간)</span>
|
||||
<br />
|
||||
<b className="text-color-info">최소화:</b>
|
||||
<br />
|
||||
f₃(x) = Σ L<sub>boom,j</sub>{' '}
|
||||
<span className="text-caption text-fg-disabled">(총 오일펜스 사용량)</span>
|
||||
<span className="text-caption text-fg-default">(총 오일펜스 사용량)</span>
|
||||
<br />
|
||||
f₄(x) = Σ D<sub>vessel,k</sub>{' '}
|
||||
<span className="text-caption text-fg-disabled">(방제정 총 이동거리)</span>
|
||||
<span className="text-caption text-fg-default">(방제정 총 이동거리)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@ -752,19 +752,19 @@ function OptimizationPanel() {
|
||||
style={{ background: 'rgba(59,130,246,.04)' }}
|
||||
>
|
||||
g₁: U·sin(θ<sub>i</sub>) ≤ U<sub>c</sub> ∀i{' '}
|
||||
<span className="text-caption text-fg-disabled">(임계유속)</span>
|
||||
<span className="text-caption text-fg-default">(임계유속)</span>
|
||||
<br />
|
||||
g₂: Σ L<sub>j</sub> ≤ L<sub>max</sub>{' '}
|
||||
<span className="text-caption text-fg-disabled">(자원 한계)</span>
|
||||
<span className="text-caption text-fg-default">(자원 한계)</span>
|
||||
<br />
|
||||
g₃: T<sub>deploy,i</sub> ≤ T<sub>arrive,i</sub>{' '}
|
||||
<span className="text-caption text-fg-disabled">(시간 제약)</span>
|
||||
<span className="text-caption text-fg-default">(시간 제약)</span>
|
||||
<br />
|
||||
g₄: d(p<sub>i</sub>, shore) ≥ d<sub>min</sub>{' '}
|
||||
<span className="text-caption text-fg-disabled">(연안 이격)</span>
|
||||
<span className="text-caption text-fg-default">(연안 이격)</span>
|
||||
<br />
|
||||
g₅: h(p<sub>i</sub>) ≥ h<sub>min</sub>{' '}
|
||||
<span className="text-caption text-fg-disabled">(수심 조건)</span>
|
||||
<span className="text-caption text-fg-default">(수심 조건)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -824,7 +824,7 @@ function OptimizationPanel() {
|
||||
<div className="font-bold" style={{ color: esi.color }}>
|
||||
{esi.grade}
|
||||
</div>
|
||||
<div className="text-fg-disabled">{esi.desc}</div>
|
||||
<div className="text-fg-default">{esi.desc}</div>
|
||||
<div className="font-bold">{esi.w}</div>
|
||||
</div>
|
||||
))}
|
||||
@ -933,7 +933,7 @@ function OptimizationPanel() {
|
||||
{['알고리즘', '유형', '장점', '단점', 'WING 활용'].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="py-[7px] px-2.5 font-semibold text-fg-disabled"
|
||||
className="py-[7px] px-2.5 font-semibold text-fg-default"
|
||||
style={{ textAlign: h === '알고리즘' ? 'left' : 'center' }}
|
||||
>
|
||||
{h}
|
||||
@ -1031,11 +1031,11 @@ function FluidDynamicsPanel() {
|
||||
F<sub>D</sub> = ½ · ρ · C<sub>D</sub> · A · U<sub>n</sub>²<br />T = F<sub>D</sub> · L
|
||||
/ (2·sin(α))
|
||||
<br />
|
||||
<span className="text-caption text-fg-disabled">
|
||||
<span className="text-caption text-fg-default">
|
||||
C<sub>D</sub>: 항력계수(≈1.2), A: 수중 투영면적
|
||||
</span>
|
||||
<br />
|
||||
<span className="text-caption text-fg-disabled">T: 연결부 장력, α: 체인각도</span>
|
||||
<span className="text-caption text-fg-default">T: 연결부 장력, α: 체인각도</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@ -1054,11 +1054,11 @@ function FluidDynamicsPanel() {
|
||||
<br />
|
||||
Splash-over: Fr > 0.5~0.6
|
||||
<br />
|
||||
<span className="text-caption text-fg-disabled">
|
||||
<span className="text-caption text-fg-default">
|
||||
Fr: 수정 Froude수, h: 오일펜스 수중깊이
|
||||
</span>
|
||||
<br />
|
||||
<span className="text-caption text-fg-disabled">Δρ/ρ: 기름-해수 밀도비 (~0.15)</span>
|
||||
<span className="text-caption text-fg-default">Δρ/ρ: 기름-해수 밀도비 (~0.15)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1075,7 +1075,7 @@ function FluidDynamicsPanel() {
|
||||
<div className="mt-2 rounded-[5px] p-[9px] leading-[2] bg-bg-base font-mono">
|
||||
y(x) = a·cosh(x/a) − a<br />L<sub>arc</sub> = 2a·sinh(L<sub>span</sub>/(2a))
|
||||
<br />L<sub>eff</sub> = L<sub>span</sub> · cos(φ<sub>max</sub>)<br />
|
||||
<span className="text-caption text-fg-disabled">
|
||||
<span className="text-caption text-fg-default">
|
||||
a: catenary 파라미터, φ: 최대 편향각
|
||||
</span>
|
||||
</div>
|
||||
@ -1392,7 +1392,7 @@ function ReferencesPanel() {
|
||||
return (
|
||||
<>
|
||||
<div className="text-label-1 font-bold mb-1">📚 오일펜스 배치 최적화 이론 근거 문헌</div>
|
||||
<div className="text-label-2 mb-3.5 text-fg-disabled">총 12편 · 4개 카테고리</div>
|
||||
<div className="text-label-2 mb-3.5 text-fg-default">총 12편 · 4개 카테고리</div>
|
||||
|
||||
{categories.map((cat, ci) => (
|
||||
<div key={ci} className="mb-4">
|
||||
@ -1430,7 +1430,7 @@ function ReferencesPanel() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold mb-0.5">{ref.title}</div>
|
||||
<div className="leading-[1.6] text-fg-disabled">{ref.author}</div>
|
||||
<div className="leading-[1.6] text-fg-default">{ref.author}</div>
|
||||
<div className="mt-0.5 text-fg-sub">{ref.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -38,7 +38,7 @@ const InfoLayerSection = ({
|
||||
<div className="flex items-center justify-between p-4 hover:bg-[rgba(255,255,255,0.02)]">
|
||||
<h3
|
||||
onClick={onToggle}
|
||||
className="text-title-4 font-bold text-fg-sub font-korean cursor-pointer"
|
||||
className="text-title-4 font-bold text-fg-default font-korean cursor-pointer"
|
||||
>
|
||||
정보 레이어
|
||||
</h3>
|
||||
@ -117,7 +117,7 @@ const InfoLayerSection = ({
|
||||
>
|
||||
전체 끄기
|
||||
</button>
|
||||
<span onClick={onToggle} className="text-label-2 text-fg-disabled cursor-pointer">
|
||||
<span onClick={onToggle} className="text-label-2 text-fg-default cursor-pointer">
|
||||
{expanded ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
@ -126,9 +126,9 @@ const InfoLayerSection = ({
|
||||
{expanded && (
|
||||
<div className="px-4 pb-2">
|
||||
{isLoading && effectiveLayers.length === 0 ? (
|
||||
<p className="text-label-2 text-fg-disabled py-2">레이어 로딩 중...</p>
|
||||
<p className="text-label-2 text-fg-default py-2">레이어 로딩 중...</p>
|
||||
) : effectiveLayers.length === 0 ? (
|
||||
<p className="text-label-2 text-fg-disabled py-2">레이어 데이터가 없습니다.</p>
|
||||
<p className="text-label-2 text-fg-default py-2">레이어 데이터가 없습니다.</p>
|
||||
) : (
|
||||
<LayerTree
|
||||
layers={effectiveLayers}
|
||||
|
||||
@ -95,6 +95,8 @@ export function LeftPanel({
|
||||
onSpillUnitChange,
|
||||
boomLines,
|
||||
onBoomLinesChange,
|
||||
showBoomLines,
|
||||
onShowBoomLinesChange,
|
||||
oilTrajectory,
|
||||
algorithmSettings,
|
||||
onAlgorithmSettingsChange,
|
||||
@ -112,6 +114,7 @@ export function LeftPanel({
|
||||
onLayerColorChange,
|
||||
sensitiveResources = [],
|
||||
onImageAnalysisResult,
|
||||
onFlyToCoord,
|
||||
validationErrors,
|
||||
}: LeftPanelProps) {
|
||||
const [expandedSections, setExpandedSections] = useState<ExpandedSections>({
|
||||
@ -166,6 +169,7 @@ export function LeftPanel({
|
||||
spillUnit={spillUnit}
|
||||
onSpillUnitChange={onSpillUnitChange}
|
||||
onImageAnalysisResult={onImageAnalysisResult}
|
||||
onFlyToCoord={onFlyToCoord}
|
||||
validationErrors={validationErrors}
|
||||
/>
|
||||
|
||||
@ -175,8 +179,8 @@ export function LeftPanel({
|
||||
onClick={() => toggleSection('incident')}
|
||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]"
|
||||
>
|
||||
<h3 className="text-title-4 font-bold text-fg-sub font-korean">사고정보</h3>
|
||||
<span className="text-label-2 text-fg-disabled">
|
||||
<h3 className="text-title-4 font-bold text-fg-default font-korean">사고정보</h3>
|
||||
<span className="text-label-2 text-fg-default">
|
||||
{expandedSections.incident ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
@ -202,7 +206,7 @@ export function LeftPanel({
|
||||
CLOSED: {
|
||||
label: '종료',
|
||||
style:
|
||||
'bg-[rgba(100,116,139,0.15)] text-fg-disabled border border-[rgba(100,116,139,0.3)]',
|
||||
'bg-[rgba(100,116,139,0.15)] text-fg-default border border-[rgba(100,116,139,0.3)]',
|
||||
dot: 'bg-fg-disabled',
|
||||
},
|
||||
};
|
||||
@ -220,7 +224,7 @@ export function LeftPanel({
|
||||
{/* Info Grid */}
|
||||
<div className="grid gap-1">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-label-2 text-fg-disabled min-w-[52px] font-korean">
|
||||
<span className="text-label-2 text-fg-default min-w-[52px] font-korean">
|
||||
사고코드
|
||||
</span>
|
||||
<span className="text-label-2 text-fg font-medium font-mono">
|
||||
@ -228,7 +232,7 @@ export function LeftPanel({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-label-2 text-fg-disabled min-w-[52px] font-korean">
|
||||
<span className="text-label-2 text-fg-default min-w-[52px] font-korean">
|
||||
사고명
|
||||
</span>
|
||||
<span className="text-label-2 text-fg font-medium font-korean">
|
||||
@ -236,7 +240,7 @@ export function LeftPanel({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-label-2 text-fg-disabled min-w-[52px] font-korean">
|
||||
<span className="text-label-2 text-fg-default min-w-[52px] font-korean">
|
||||
사고일시
|
||||
</span>
|
||||
<span className="text-label-2 text-fg font-medium font-mono">
|
||||
@ -246,7 +250,7 @@ export function LeftPanel({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-label-2 text-fg-disabled min-w-[52px] font-korean">
|
||||
<span className="text-label-2 text-fg-default min-w-[52px] font-korean">
|
||||
유종
|
||||
</span>
|
||||
<span className="text-label-2 text-fg font-medium font-korean">
|
||||
@ -254,7 +258,7 @@ export function LeftPanel({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-label-2 text-fg-disabled min-w-[52px] font-korean">
|
||||
<span className="text-label-2 text-fg-default min-w-[52px] font-korean">
|
||||
유출량
|
||||
</span>
|
||||
<span className="text-label-2 text-fg font-medium font-mono">
|
||||
@ -264,7 +268,7 @@ export function LeftPanel({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-label-2 text-fg-disabled min-w-[52px] font-korean">
|
||||
<span className="text-label-2 text-fg-default min-w-[52px] font-korean">
|
||||
담당자
|
||||
</span>
|
||||
<span className="text-label-2 text-fg font-medium font-korean">
|
||||
@ -272,7 +276,7 @@ export function LeftPanel({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-label-2 text-fg-disabled min-w-[52px] font-korean">
|
||||
<span className="text-label-2 text-fg-default min-w-[52px] font-korean">
|
||||
위치
|
||||
</span>
|
||||
<span className="text-label-2 text-color-warning font-medium font-korean">
|
||||
@ -283,7 +287,7 @@ export function LeftPanel({
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-4">
|
||||
<p className="text-label-1 text-fg-disabled font-korean text-center py-2">
|
||||
<p className="text-label-1 text-fg-default font-korean text-center py-2">
|
||||
선택된 사고정보가 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
@ -296,8 +300,8 @@ export function LeftPanel({
|
||||
onClick={() => toggleSection('impactResources')}
|
||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]"
|
||||
>
|
||||
<h3 className="text-title-4 font-bold text-fg-sub font-korean">영향 민감자원</h3>
|
||||
<span className="text-label-2 text-fg-disabled">
|
||||
<h3 className="text-title-4 font-bold text-fg-default font-korean">영향 민감자원</h3>
|
||||
<span className="text-label-2 text-fg-default">
|
||||
{expandedSections.impactResources ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
@ -305,7 +309,7 @@ export function LeftPanel({
|
||||
{expandedSections.impactResources && (
|
||||
<div className="px-4 pb-4">
|
||||
{sensitiveResources.length === 0 ? (
|
||||
<p className="text-label-1 text-fg-disabled text-center font-korean">
|
||||
<p className="text-label-1 text-fg-default text-center font-korean">
|
||||
영향받는 민감자원 목록
|
||||
</p>
|
||||
) : (
|
||||
@ -357,6 +361,8 @@ export function LeftPanel({
|
||||
onToggle={() => toggleSection('oilBoom')}
|
||||
boomLines={boomLines}
|
||||
onBoomLinesChange={onBoomLinesChange}
|
||||
showBoomLines={showBoomLines}
|
||||
onShowBoomLinesChange={onShowBoomLinesChange}
|
||||
oilTrajectory={oilTrajectory}
|
||||
incidentCoord={incidentCoord ?? { lat: 0, lon: 0 }}
|
||||
algorithmSettings={algorithmSettings}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import type {
|
||||
BoomLine,
|
||||
BoomLineCoord,
|
||||
@ -22,6 +23,8 @@ interface OilBoomSectionProps {
|
||||
onDrawingPointsChange: (points: BoomLineCoord[]) => void;
|
||||
containmentResult: ContainmentResult | null;
|
||||
onContainmentResultChange: (result: ContainmentResult | null) => void;
|
||||
showBoomLines: boolean;
|
||||
onShowBoomLinesChange: (show: boolean) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AlgorithmSettings = {
|
||||
@ -44,6 +47,8 @@ const OilBoomSection = ({
|
||||
onDrawingPointsChange,
|
||||
containmentResult,
|
||||
onContainmentResultChange,
|
||||
showBoomLines,
|
||||
onShowBoomLinesChange,
|
||||
}: OilBoomSectionProps) => {
|
||||
const [boomPlacementTab, setBoomPlacementTab] = useState<'ai' | 'simulation'>('simulation');
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
@ -81,8 +86,22 @@ const OilBoomSection = ({
|
||||
onClick={onToggle}
|
||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]"
|
||||
>
|
||||
<h3 className="text-title-4 font-bold text-fg-sub font-korean">오일펜스 배치 가이드</h3>
|
||||
<span className="text-label-2 text-fg-disabled">{expanded ? '▼' : '▶'}</span>
|
||||
<h3 className="text-title-4 font-bold text-fg-default font-korean">오일펜스 배치 가이드</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShowBoomLinesChange(!showBoomLines);
|
||||
}}
|
||||
disabled={boomLines.length === 0}
|
||||
title={showBoomLines ? '지도에서 숨기기' : '지도에 표시'}
|
||||
className="p-1 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
style={{ color: showBoomLines ? 'var(--color-accent)' : 'var(--fg-disabled)' }}
|
||||
>
|
||||
{showBoomLines ? <Eye size={14} /> : <EyeOff size={14} />}
|
||||
</button>
|
||||
<span className="text-label-2 text-fg-default">{expanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
@ -127,7 +146,7 @@ const OilBoomSection = ({
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
background: 'var(--bg-base)',
|
||||
color: hasData ? 'var(--color-danger)' : 'var(--fg-disabled)',
|
||||
color: 'var(--fg-disabled)',
|
||||
cursor: hasData ? 'pointer' : 'not-allowed',
|
||||
transition: '0.15s',
|
||||
}}
|
||||
@ -150,7 +169,7 @@ const OilBoomSection = ({
|
||||
<div className="text-label-2 font-bold text-fg font-korean mb-2">
|
||||
⚠ 오일펜스 배치 가이드를 초기화 합니다
|
||||
</div>
|
||||
<div className="text-caption text-fg-disabled font-korean mb-3">
|
||||
<div className="text-caption text-fg-default font-korean mb-3">
|
||||
배치된 오일펜스 라인과 시뮬레이션 결과가 삭제됩니다. 확산 예측 결과는 유지됩니다.
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@ -218,12 +237,12 @@ const OilBoomSection = ({
|
||||
className="border border-stroke"
|
||||
>
|
||||
<div
|
||||
style={{ color: metric.color }}
|
||||
className="text-title-1 font-bold font-mono mb-[2px]"
|
||||
// style={{ color: metric.color }}
|
||||
className="text-title-1 font-semibold font-mono mb-[2px]"
|
||||
>
|
||||
{metric.value}
|
||||
</div>
|
||||
<div className="text-caption text-fg-disabled">{metric.label}</div>
|
||||
<div className="text-caption text-fg-default">{metric.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -242,16 +261,10 @@ const OilBoomSection = ({
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background:
|
||||
oilTrajectory.length > 0 ? 'var(--color-success)' : 'var(--color-danger)',
|
||||
background: 'var(--fg-default)',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
oilTrajectory.length > 0 ? 'var(--color-success)' : 'var(--fg-disabled)',
|
||||
}}
|
||||
>
|
||||
<span className="text-fg-default">
|
||||
확산 궤적 데이터{' '}
|
||||
{oilTrajectory.length > 0 ? `(${oilTrajectory.length}개 입자)` : '없음'}
|
||||
</span>
|
||||
@ -261,7 +274,7 @@ const OilBoomSection = ({
|
||||
{/* 알고리즘 설정 */}
|
||||
<div>
|
||||
<h4
|
||||
className="text-label-2 font-bold text-fg-sub mb-2"
|
||||
className="text-label-2 font-bold text-fg-default mb-2"
|
||||
style={{ letterSpacing: 'var(--letter-spacing-label)' }}
|
||||
>
|
||||
📊 V자형 배치 알고리즘 설정
|
||||
@ -301,7 +314,7 @@ const OilBoomSection = ({
|
||||
}}
|
||||
className="flex items-center justify-between px-2.5 py-1.5 border border-stroke"
|
||||
>
|
||||
<span className="flex-1 text-caption text-fg-disabled truncate">
|
||||
<span className="flex-1 text-caption text-fg-default truncate">
|
||||
● {setting.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 shrink-0 w-[80px] justify-end">
|
||||
@ -315,7 +328,7 @@ const OilBoomSection = ({
|
||||
className="boom-setting-input"
|
||||
step={setting.key === 'waveHeightCorrectionFactor' ? 0.1 : 1}
|
||||
/>
|
||||
<span className="text-caption text-fg-disabled w-[14px]">
|
||||
<span className="text-caption text-fg-default w-[14px]">
|
||||
{setting.unit}
|
||||
</span>
|
||||
</div>
|
||||
@ -342,7 +355,7 @@ const OilBoomSection = ({
|
||||
V자형 오일펜스 배치 + 시뮬레이션 실행
|
||||
</button>
|
||||
|
||||
<p className="text-caption text-fg-disabled leading-relaxed font-korean">
|
||||
<p className="text-caption text-fg-default leading-relaxed font-korean">
|
||||
확산 궤적을 분석하여 해류 직교 방향 1차 방어선(V형), U형 포위 2차 방어선, 연안 보호
|
||||
3차 방어선을 자동 배치하고 차단 시뮬레이션을 실행합니다.
|
||||
</p>
|
||||
@ -363,7 +376,7 @@ const OilBoomSection = ({
|
||||
<div className="text-heading-2 font-bold text-color-accent font-mono">
|
||||
{containmentResult.overallEfficiency}%
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled mt-[2px]">전체 차단 효율</div>
|
||||
<div className="text-label-2 text-fg-default mt-[2px]">전체 차단 효율</div>
|
||||
</div>
|
||||
|
||||
{/* 차단/통과 카운트 */}
|
||||
@ -380,7 +393,7 @@ const OilBoomSection = ({
|
||||
<div className="text-title-2 font-bold text-color-success font-mono">
|
||||
{containmentResult.blockedParticles}
|
||||
</div>
|
||||
<div className="text-caption text-fg-disabled">차단 입자</div>
|
||||
<div className="text-caption text-fg-default">차단 입자</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@ -394,7 +407,7 @@ const OilBoomSection = ({
|
||||
<div className="text-title-2 font-bold text-color-danger font-mono">
|
||||
{containmentResult.passedParticles}
|
||||
</div>
|
||||
<div className="text-caption text-fg-disabled">통과 입자</div>
|
||||
<div className="text-caption text-fg-default">통과 입자</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -485,13 +498,13 @@ const OilBoomSection = ({
|
||||
className="mb-1.5"
|
||||
>
|
||||
<div>
|
||||
<span className="text-caption text-fg-disabled">길이</span>
|
||||
<span className="text-caption text-fg-default">길이</span>
|
||||
<div className="text-title-3 font-bold font-mono text-fg">
|
||||
{line.length.toFixed(0)}m
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-caption text-fg-disabled">각도</span>
|
||||
<span className="text-caption text-fg-default">각도</span>
|
||||
<div className="text-title-3 font-bold font-mono text-fg">
|
||||
{line.angle.toFixed(0)}°
|
||||
</div>
|
||||
|
||||
@ -85,7 +85,7 @@ ${styles}
|
||||
<span className="text-label-2 font-medium text-fg-sub">🔴 POSEIDON</span>
|
||||
<span className="text-label-2 font-medium text-fg-sub">🔵 OpenDrift</span>
|
||||
<span className="text-label-2 font-medium text-fg-sub">⚡ 앙상블</span>
|
||||
<span className="text-label-2 text-fg-disabled">라그랑지안 입자추적 이론 기반</span>
|
||||
<span className="text-label-2 text-fg-default">라그랑지안 입자추적 이론 기반</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -111,7 +111,7 @@ ${styles}
|
||||
className={`flex-1 py-2 px-1 text-label-1 font-medium rounded-md transition-all duration-150 cursor-pointer border ${
|
||||
activePanel === tab.id
|
||||
? 'border-stroke-light bg-bg-elevated text-fg'
|
||||
: 'border-transparent bg-bg-card text-fg-disabled hover:text-fg-sub'
|
||||
: 'border-transparent bg-bg-card text-fg-default hover:text-fg-sub'
|
||||
}`}
|
||||
>
|
||||
{tab.icon} {tab.name}
|
||||
@ -232,7 +232,7 @@ function SystemOverviewPanel() {
|
||||
<div className={`${card} ${cardBg}`}>
|
||||
<div className="flex items-center justify-between mb-3.5">
|
||||
<div style={labelStyle('var(--fg-default)')}>🤖 WING 탑재 유출유 확산 모델 비교</div>
|
||||
<span className="text-label-2 text-fg-disabled">3종 앙상블 운용 · 불확실성 정량화</span>
|
||||
<span className="text-label-2 text-fg-default">3종 앙상블 운용 · 불확실성 정량화</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2.5 mb-3.5">
|
||||
{[
|
||||
@ -289,7 +289,7 @@ function SystemOverviewPanel() {
|
||||
<div className="text-label-1 font-bold" style={{ color: m.color }}>
|
||||
{m.name}
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled">{m.sub}</div>
|
||||
<div className="text-label-2 text-fg-default">{m.sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-label-2 mb-2 text-fg-sub leading-[1.7]">{m.desc}</div>
|
||||
@ -337,7 +337,7 @@ function SystemOverviewPanel() {
|
||||
}}
|
||||
>
|
||||
<th
|
||||
className="py-2 px-3 text-left text-fg-disabled font-medium"
|
||||
className="py-2 px-3 text-left text-fg-default font-medium"
|
||||
style={{ width: '15%' }}
|
||||
>
|
||||
구분
|
||||
@ -469,7 +469,7 @@ function SystemOverviewPanel() {
|
||||
}}
|
||||
>
|
||||
<td
|
||||
className="py-[7px] px-3 text-fg-disabled"
|
||||
className="py-[7px] px-3 text-fg-default"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(row.label.replace(/\n/g, '<br>')),
|
||||
}}
|
||||
@ -538,7 +538,7 @@ function KospsPanel() {
|
||||
<div className="text-title-2 font-bold text-fg">
|
||||
KOSPS (Korea Oil Spill Prediction System)
|
||||
</div>
|
||||
<div className="text-label-2 mt-0.5 text-fg-disabled">
|
||||
<div className="text-label-2 mt-0.5 text-fg-default">
|
||||
한국해양연구원(KORDI) 개발 · 한국 해역 특화 유출유 확산 예측 상시 운용 시스템
|
||||
</div>
|
||||
</div>
|
||||
@ -584,9 +584,9 @@ function KospsPanel() {
|
||||
{/* 특허 1 */}
|
||||
<div className="rounded-lg p-3 bg-bg-base border border-stroke flex gap-3 items-start">
|
||||
<div className="px-2.5 py-1.5 rounded-md text-center whitespace-nowrap bg-bg-elevated border border-stroke font-mono shrink-0">
|
||||
<div className="text-label-2 text-fg-disabled">등록번호</div>
|
||||
<div className="text-label-2 text-fg-default">등록번호</div>
|
||||
<div className="text-label-2 font-bold text-fg">10-1567431</div>
|
||||
<div className="text-label-2 mt-0.5 text-fg-disabled">2015.11.03</div>
|
||||
<div className="text-label-2 mt-0.5 text-fg-default">2015.11.03</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 text-label-2 min-w-0">
|
||||
<div className="font-bold text-fg">
|
||||
@ -611,7 +611,7 @@ function KospsPanel() {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-fg-disabled">
|
||||
<div className="text-fg-default">
|
||||
국가R&D: ① 3차원 유출유 확산예측 기반 방제 지원기술 개발 (기여율 65%) ② HNS 유출
|
||||
거동예측 및 대응정보 지원기술 개발 (기여율 35%) | 해양수산부
|
||||
</div>
|
||||
@ -632,7 +632,7 @@ function KospsPanel() {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div className={codeBox}>
|
||||
<span className="text-fg-disabled text-label-2">/* 변조조석 수식 */</span>
|
||||
<span className="text-fg-default text-label-2">/* 변조조석 수식 */</span>
|
||||
<br />
|
||||
ζ(t) = A(t) cos[σt − θ(t)]
|
||||
<br />
|
||||
@ -712,7 +712,7 @@ function KospsPanel() {
|
||||
<span className="font-medium">
|
||||
{d.icon} {d.label}
|
||||
</span>
|
||||
<span className="text-label-2 text-fg-disabled">{d.detail}</span>
|
||||
<span className="text-label-2 text-fg-default">{d.detail}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -725,14 +725,14 @@ function KospsPanel() {
|
||||
style={{ background: 'rgba(6,182,212,.04)', border: '1px solid rgba(6,182,212,.12)' }}
|
||||
>
|
||||
<div className="font-medium mb-0.5">📍 수심·해안선</div>
|
||||
<div className="text-fg-disabled">전자해도(ENC) → 500m 격자 보간</div>
|
||||
<div className="text-fg-default">전자해도(ENC) → 500m 격자 보간</div>
|
||||
</div>
|
||||
<div
|
||||
className="px-2.5 py-1.5 rounded-md"
|
||||
style={{ background: 'rgba(6,182,212,.04)', border: '1px solid rgba(6,182,212,.12)' }}
|
||||
>
|
||||
<div className="font-medium mb-0.5">🗺️ 격자 구성</div>
|
||||
<div className="text-fg-disabled">좌표변환 → 영역추출 → 격자보간 표준화</div>
|
||||
<div className="text-fg-default">좌표변환 → 영역추출 → 격자보간 표준화</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -744,12 +744,12 @@ function KospsPanel() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className={`${codeBox} mb-2`}>
|
||||
<span className="text-fg-disabled text-label-2">/* 취송류 유속 (이·강, 2000) */</span>
|
||||
<span className="text-fg-default text-label-2">/* 취송류 유속 (이·강, 2000) */</span>
|
||||
<br />
|
||||
V_WDC = <span className="text-color-accent">0.029</span> × V_wind
|
||||
</div>
|
||||
<div className={codeBox}>
|
||||
<span className="text-fg-disabled text-label-2">/* 취송류 유향 */</span>
|
||||
<span className="text-fg-default text-label-2">/* 취송류 유향 */</span>
|
||||
<br />
|
||||
θ_WDC = θ_wind + <span className="text-color-accent">18.6°</span>
|
||||
</div>
|
||||
@ -810,7 +810,7 @@ function KospsPanel() {
|
||||
<div className="font-bold" style={{ color: node.color }}>
|
||||
{node.label}
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled">{node.sub}</div>
|
||||
<div className="text-label-2 text-fg-default">{node.sub}</div>
|
||||
</div>
|
||||
{i < 5 && (
|
||||
<div className="w-[30px] h-px" style={{ background: 'var(--stroke-light)' }} />
|
||||
@ -818,7 +818,7 @@ function KospsPanel() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-label-2 text-center mt-1 text-fg-disabled">
|
||||
<div className="text-label-2 text-center mt-1 text-fg-default">
|
||||
FTP 자동 갱신 → DB 정규화 → 격자 재구성 → 모델 구동 → 결과 표출
|
||||
</div>
|
||||
<div
|
||||
@ -848,7 +848,7 @@ function KospsPanel() {
|
||||
<div className="text-title-4 font-bold text-fg">
|
||||
이문진 박사 특허 기반 핵심 기술 (등록특허 10-1567431)
|
||||
</div>
|
||||
<div className="text-label-2 mt-0.5 text-fg-disabled">
|
||||
<div className="text-label-2 mt-0.5 text-fg-default">
|
||||
해양 유류오염사고 발생시 효율적인 방제방안 수립을 위한 유출유 확산 예측 방법 ·
|
||||
한국해양과학기술원 · 2015년 등록
|
||||
</div>
|
||||
@ -1058,7 +1058,7 @@ function KospsPanel() {
|
||||
</div>
|
||||
<div className="mt-1.5 p-1.5 rounded bg-bg-base font-mono text-label-1 leading-loose">
|
||||
z(x,y) = Σ Σ qᵢⱼ xⁱ yʲ{' '}
|
||||
<span className="text-label-2 text-fg-disabled">(i≤5, i+j≤5)</span>
|
||||
<span className="text-label-2 text-fg-default">(i≤5, i+j≤5)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg p-3" className="bg-bg-card border border-stroke">
|
||||
@ -1104,7 +1104,7 @@ function KospsPanel() {
|
||||
<div className="text-fg-sub leading-[1.6]">
|
||||
3차원 유출유 확산예측 기반 해양유류오염 방제 지원기술 개발
|
||||
<br />
|
||||
<span className="text-fg-disabled">해양수산부 | 2013.01~2013.12</span>
|
||||
<span className="text-fg-default">해양수산부 | 2013.01~2013.12</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@ -1118,7 +1118,7 @@ function KospsPanel() {
|
||||
<div className="text-fg-sub leading-[1.6]">
|
||||
주요 위험유해물질(HNS) 유출 거동예측 및 대응정보 지원기술 개발
|
||||
<br />
|
||||
<span className="text-fg-disabled">해양수산부 | 2013.01~2013.12</span>
|
||||
<span className="text-fg-default">해양수산부 | 2013.01~2013.12</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1163,7 +1163,7 @@ function KospsPanel() {
|
||||
▼
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-label-2 mt-0.5 text-fg-disabled">
|
||||
<div className="text-label-2 mt-0.5 text-fg-default">
|
||||
KOSPS 개발 · 허베이스피리트 검증 · 3D 확산예측 시스템 · 방제효과 모델링 —
|
||||
한국해양환경·에너지학회
|
||||
</div>
|
||||
@ -1246,10 +1246,10 @@ function KospsPanel() {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-label-2 text-fg-disabled">{paper.year}</span>
|
||||
<span className="text-label-2 text-fg-default">{paper.year}</span>
|
||||
</div>
|
||||
<div className="text-label-2 font-bold mb-1">{paper.title}</div>
|
||||
<div className="text-label-2 mb-1.5 text-fg-disabled">{paper.authors}</div>
|
||||
<div className="text-label-2 mb-1.5 text-fg-default">{paper.authors}</div>
|
||||
<div className="text-label-2 text-fg-sub leading-[1.7]">{paper.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
@ -1405,7 +1405,7 @@ function KospsPanel() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold mb-0.5">{paper.title}</div>
|
||||
<div className="text-fg-disabled leading-[1.6]">
|
||||
<div className="text-fg-default leading-[1.6]">
|
||||
{paper.authors} | <b style={{ color: paper.color }}>{paper.journal}</b>{' '}
|
||||
{paper.detail}
|
||||
</div>
|
||||
@ -1542,7 +1542,7 @@ function KospsPanel() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold mb-0.5">{paper.title}</div>
|
||||
<div className="text-fg-disabled leading-[1.6]">
|
||||
<div className="text-fg-default leading-[1.6]">
|
||||
{paper.authors} | <b style={{ color: paper.color }}>{paper.journal}</b>{' '}
|
||||
{paper.detail}
|
||||
</div>
|
||||
@ -1570,7 +1570,7 @@ function PoseidonPanel() {
|
||||
<div className="text-title-2 font-bold text-fg">
|
||||
POSEIDON (입자추적 최적화 예측 시스템)
|
||||
</div>
|
||||
<div className="text-label-2 mt-0.5 text-fg-disabled">
|
||||
<div className="text-label-2 mt-0.5 text-fg-default">
|
||||
한국환경연구원 · (주)아라종합기술 · 한국해양기상기술 공동개발 · MOHID 해양순환모델
|
||||
기반 · 뜰개 관측 매개변수 자동 최적화
|
||||
</div>
|
||||
@ -1631,9 +1631,9 @@ function PoseidonPanel() {
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
<div className="text-label-2 text-fg-disabled">등록번호</div>
|
||||
<div className="text-label-2 text-fg-default">등록번호</div>
|
||||
<div className="text-label-2 font-bold text-fg">10-1868791</div>
|
||||
<div className="text-label-2 mt-0.5 text-fg-disabled">2018 등록</div>
|
||||
<div className="text-label-2 mt-0.5 text-fg-default">2018 등록</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-label-2 font-bold mb-1">
|
||||
@ -1724,7 +1724,7 @@ function PoseidonPanel() {
|
||||
<div style={labelStyle('var(--color-info)')}>POSEIDON 입자추적 핵심 수식</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-label-2 font-medium mb-1.5 text-fg-disabled">
|
||||
<div className="text-label-2 font-medium mb-1.5 text-fg-default">
|
||||
제1 입자추적 모델 (기본)
|
||||
</div>
|
||||
<div className={codeBox}>
|
||||
@ -1732,12 +1732,12 @@ function PoseidonPanel() {
|
||||
<br />
|
||||
Model_y = Δt × current_v + Δt × c × wind_v
|
||||
</div>
|
||||
<div className="text-label-2 mt-1.5 text-fg-disabled">
|
||||
<div className="text-label-2 mt-1.5 text-fg-default">
|
||||
c : 풍속 가중치 (예: c=0.3 → 바람의 30% 반영)
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-label-2 font-medium mb-1.5 text-fg-disabled">
|
||||
<div className="text-label-2 font-medium mb-1.5 text-fg-default">
|
||||
제2 입자추적 모델 (최적화 후)
|
||||
</div>
|
||||
<div className={codeBox}>
|
||||
@ -1749,7 +1749,7 @@ function PoseidonPanel() {
|
||||
+ a5·Model_x +
|
||||
a6·Model_y + a7
|
||||
</div>
|
||||
<div className="text-label-2 mt-1.5 text-fg-disabled">
|
||||
<div className="text-label-2 mt-1.5 text-fg-default">
|
||||
a1~a7 : GA·DE·PSO로 최적화된 매개변수
|
||||
</div>
|
||||
</div>
|
||||
@ -1760,7 +1760,7 @@ function PoseidonPanel() {
|
||||
<div style={labelStyle('var(--fg-default)')}>🔄 POSEIDON_V2 상시 운용 체계</div>
|
||||
|
||||
{/* 외부 입력 자료 */}
|
||||
<div className="text-label-2 font-bold mb-1.5 mt-1 text-fg-disabled">외부 입력 자료</div>
|
||||
<div className="text-label-2 font-bold mb-1.5 mt-1 text-fg-default">외부 입력 자료</div>
|
||||
<div className="flex items-center justify-center gap-0 mb-3">
|
||||
{[
|
||||
{
|
||||
@ -1818,12 +1818,12 @@ function PoseidonPanel() {
|
||||
<div className="border-t border-stroke my-3" />
|
||||
|
||||
{/* 중앙 화살표 */}
|
||||
<div className="text-center text-label-2 mb-2 text-fg-disabled">
|
||||
<div className="text-center text-label-2 mb-2 text-fg-default">
|
||||
▼ DATA → PREP → 격자 보간/좌표 변환 ▼
|
||||
</div>
|
||||
|
||||
{/* 4대 도메인 실행 모듈 */}
|
||||
<div className="text-label-2 font-bold mb-1.5 text-fg-disabled">
|
||||
<div className="text-label-2 font-bold mb-1.5 text-fg-default">
|
||||
POSEIDON 4대 실행 모듈 (EA012 대격자 → KO108 연안 상세격자)
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 mb-3">
|
||||
@ -1887,7 +1887,7 @@ function PoseidonPanel() {
|
||||
</div>
|
||||
|
||||
{/* 화살표 + 최적화 */}
|
||||
<div className="text-center text-label-2 mb-2 text-fg-disabled">
|
||||
<div className="text-center text-label-2 mb-2 text-fg-default">
|
||||
▼ HYDR + WAVE + TIDE → OILS 강제력 입력 ▼ 뜰개 관측 → GA/DE/PSO 매개변수 자동 최적화 ▼
|
||||
</div>
|
||||
|
||||
@ -1914,7 +1914,7 @@ function PoseidonPanel() {
|
||||
<div className="font-bold" style={{ color: node.color }}>
|
||||
{node.label}
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled">{node.sub}</div>
|
||||
<div className="text-label-2 text-fg-default">{node.sub}</div>
|
||||
</div>
|
||||
{i < 2 && (
|
||||
<div className="w-[30px] h-px" style={{ background: 'var(--stroke-light)' }} />
|
||||
@ -1961,7 +1961,7 @@ function PoseidonPanel() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-label-1 font-bold">POSEIDON관련 유출유 확산예측 논문</div>
|
||||
<div className="text-label-2 mt-0.5 text-fg-disabled">
|
||||
<div className="text-label-2 mt-0.5 text-fg-default">
|
||||
포세이돈 시스템 소개·활용 · 최적 방제전략 · 원격탐사 연동 · MOHID 검증 —
|
||||
한국해양환경·에너지학회 외
|
||||
</div>
|
||||
@ -2040,10 +2040,10 @@ function PoseidonPanel() {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-label-2 text-fg-disabled">{paper.year}</span>
|
||||
<span className="text-label-2 text-fg-default">{paper.year}</span>
|
||||
</div>
|
||||
<div className="text-label-2 font-bold mb-1">{paper.title}</div>
|
||||
<div className="text-label-2 mb-1.5 text-fg-disabled">{paper.authors}</div>
|
||||
<div className="text-label-2 mb-1.5 text-fg-default">{paper.authors}</div>
|
||||
<div className="text-label-2 text-fg-sub leading-[1.7]">{paper.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
@ -2066,7 +2066,7 @@ function OpenDriftPanel() {
|
||||
<div className="text-title-2 font-bold text-fg">
|
||||
OpenDrift (오픈소스 라그랑지안 확산 프레임워크)
|
||||
</div>
|
||||
<div className="text-label-2 mt-0.5 text-fg-disabled">
|
||||
<div className="text-label-2 mt-0.5 text-fg-default">
|
||||
노르웨이 MET Norway · OpenOil 공개 프레임워크 · Python 기반 · IMO/IPIECA 검증
|
||||
</div>
|
||||
</div>
|
||||
@ -2210,7 +2210,7 @@ function OpenDriftPanel() {
|
||||
<div className="text-label-2 font-medium" style={{ color: w.color }}>
|
||||
{w.title}
|
||||
</div>
|
||||
<div className="text-label-2 mt-1 text-fg-disabled leading-normal">{w.desc}</div>
|
||||
<div className="text-label-2 mt-1 text-fg-default leading-normal">{w.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -2244,7 +2244,7 @@ function OpenDriftPanel() {
|
||||
<div className="font-bold" style={{ color: node.color }}>
|
||||
{node.label}
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled">{node.sub}</div>
|
||||
<div className="text-label-2 text-fg-default">{node.sub}</div>
|
||||
</div>
|
||||
{i < 6 && (
|
||||
<div className="w-[24px] h-px" style={{ background: 'var(--stroke-light)' }} />
|
||||
@ -2252,7 +2252,7 @@ function OpenDriftPanel() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-label-2 text-center mt-1 text-fg-disabled">
|
||||
<div className="text-label-2 text-center mt-1 text-fg-default">
|
||||
해양모델(NEMO·ROMS·HYCOM) + 기상자료(ECMWF·GFS) → NOAA Oil Library 유종 매칭 →
|
||||
OpenDrift/OpenOil 모듈 구동 → NetCDF 결과 출력·시각화
|
||||
</div>
|
||||
@ -2271,7 +2271,7 @@ function OpenDriftPanel() {
|
||||
<div className="text-label-1 font-bold">
|
||||
OpenDrift / OpenOil 국내 해역 적용 연구 논문
|
||||
</div>
|
||||
<div className="text-label-2 mt-0.5 text-fg-disabled">
|
||||
<div className="text-label-2 mt-0.5 text-fg-default">
|
||||
한국 연안 유출유 확산 수치모의 관련 핵심 논문 3편 — WING 모델 이론 근거
|
||||
</div>
|
||||
</div>
|
||||
@ -2309,13 +2309,13 @@ function OpenDriftPanel() {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-label-2 whitespace-nowrap text-fg-disabled">2024</span>
|
||||
<span className="text-label-2 whitespace-nowrap text-fg-default">2024</span>
|
||||
</div>
|
||||
<div className="text-label-2 font-bold mb-1 leading-normal">
|
||||
Numerical Model Test of Spilled Oil Transport Near the Korean Coasts Using Various
|
||||
Input Parametric Models
|
||||
</div>
|
||||
<div className="text-label-2 mb-2 text-fg-disabled">
|
||||
<div className="text-label-2 mb-2 text-fg-default">
|
||||
Hai Van Dang, Suchan Joo, Junhyeok Lim, Jinhwan Hur, Sungwon Shin | Hanyang University
|
||||
ERICA | Journal of Ocean Engineering and Technology, 2024
|
||||
</div>
|
||||
@ -2417,13 +2417,13 @@ function OpenDriftPanel() {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-label-2 whitespace-nowrap text-fg-disabled">1998</span>
|
||||
<span className="text-label-2 whitespace-nowrap text-fg-default">1998</span>
|
||||
</div>
|
||||
<div className="text-label-2 font-bold mb-1 leading-normal">
|
||||
한국 동남해역에서의 유출유 확산예측모델 (Oil Spill Behavior Forecasting Model in
|
||||
South-eastern Coastal Area of Korea)
|
||||
</div>
|
||||
<div className="text-label-2 mb-2 text-fg-disabled">
|
||||
<div className="text-label-2 mb-2 text-fg-default">
|
||||
류청로, 김종규, 설동관, 강동욱 | 부경대학교 해양공학과 | 한국해양환경공학회지 Vol.1
|
||||
No.2, pp.52–59, 1998
|
||||
</div>
|
||||
@ -2520,13 +2520,13 @@ function OpenDriftPanel() {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-label-2 whitespace-nowrap text-fg-disabled">2008</span>
|
||||
<span className="text-label-2 whitespace-nowrap text-fg-default">2008</span>
|
||||
</div>
|
||||
<div className="text-label-2 font-bold mb-1 leading-normal">
|
||||
태안 기름유출사고의 유출유 확산특성 분석 (Analysis of Oil Spill Dispersion in Taean
|
||||
Coastal Zone)
|
||||
</div>
|
||||
<div className="text-label-2 mb-2 text-fg-disabled">
|
||||
<div className="text-label-2 mb-2 text-fg-default">
|
||||
정태성, 조형진 | 한남대학교 토목환경공학과 | 한국해안·해양공학회 학술발표논문집 제17권
|
||||
pp.60–63, 2008
|
||||
</div>
|
||||
@ -2593,7 +2593,7 @@ function OpenDriftPanel() {
|
||||
}}
|
||||
>
|
||||
<div className="font-bold text-color-info">α = 3%</div>
|
||||
<div className="text-fg-disabled">과대 확산</div>
|
||||
<div className="text-fg-default">과대 확산</div>
|
||||
</div>
|
||||
<div
|
||||
className="px-2 py-1 rounded text-center"
|
||||
@ -2603,7 +2603,7 @@ function OpenDriftPanel() {
|
||||
}}
|
||||
>
|
||||
<div className="font-bold text-color-caution">α = 2.5%</div>
|
||||
<div className="text-fg-disabled">다소 빠름</div>
|
||||
<div className="text-fg-default">다소 빠름</div>
|
||||
</div>
|
||||
<div
|
||||
className="px-2 py-1 rounded text-center"
|
||||
@ -2613,7 +2613,7 @@ function OpenDriftPanel() {
|
||||
}}
|
||||
>
|
||||
<div className="font-bold text-color-info">α = 2% ✓</div>
|
||||
<div className="text-fg-disabled">최적 일치</div>
|
||||
<div className="text-fg-default">최적 일치</div>
|
||||
</div>
|
||||
<div
|
||||
className="px-2 py-1 rounded text-center"
|
||||
@ -2623,7 +2623,7 @@ function OpenDriftPanel() {
|
||||
}}
|
||||
>
|
||||
<div className="font-bold text-color-accent">θ = 20° ✓</div>
|
||||
<div className="text-fg-disabled">최적 편향각</div>
|
||||
<div className="text-fg-default">최적 편향각</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -2733,14 +2733,14 @@ function LagrangianPanel() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className={`${codeBox} mb-2`}>
|
||||
<span className="text-fg-disabled text-label-2">/* 중력-관성 체제 (초기) */</span>
|
||||
<span className="text-fg-default text-label-2">/* 중력-관성 체제 (초기) */</span>
|
||||
<br />
|
||||
R(t) = <span className="text-color-accent">K₁</span> · (
|
||||
<span className="text-color-accent">ΔρgV²</span> /{' '}
|
||||
<span className="text-color-info">ρw</span>)<sup>¼</sup> · t<sup>½</sup>
|
||||
</div>
|
||||
<div className={codeBox}>
|
||||
<span className="text-fg-disabled text-label-2">/* 중력-점성 체제 (후기) */</span>
|
||||
<span className="text-fg-default text-label-2">/* 중력-점성 체제 (후기) */</span>
|
||||
<br />
|
||||
R(t) = <span className="text-color-accent">K₂</span> · (
|
||||
<span className="text-color-accent">ΔρgV²</span> /{' '}
|
||||
@ -2832,7 +2832,7 @@ function WeatheringPanel() {
|
||||
<div style={labelStyle(w.color)}>{w.title}</div>
|
||||
<div className={`${bodyText} mb-2`}>{w.desc}</div>
|
||||
<div className={codeBox}>{w.formula}</div>
|
||||
<div className="mt-2 text-label-2 text-fg-disabled">{w.note}</div>
|
||||
<div className="mt-2 text-label-2 text-fg-default">{w.note}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -2883,7 +2883,7 @@ function WeatheringPanel() {
|
||||
{s.time}
|
||||
</div>
|
||||
<div className="text-label-2 font-medium mb-1">{s.title}</div>
|
||||
<div className="text-label-2 whitespace-pre-line text-fg-disabled leading-normal">
|
||||
<div className="text-label-2 whitespace-pre-line text-fg-default leading-normal">
|
||||
{s.desc}
|
||||
</div>
|
||||
</div>
|
||||
@ -2934,7 +2934,7 @@ function OceanInputPanel() {
|
||||
}}
|
||||
>
|
||||
<div className="font-medium mb-0.5">{t.label}</div>
|
||||
<div className="text-fg-disabled">{t.desc}</div>
|
||||
<div className="text-fg-default">{t.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -2957,7 +2957,7 @@ function OceanInputPanel() {
|
||||
}}
|
||||
>
|
||||
<div className="font-medium mb-0.5">{t.label}</div>
|
||||
<div className="text-fg-disabled">{t.desc}</div>
|
||||
<div className="text-fg-default">{t.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -3013,7 +3013,7 @@ function VerificationPanel() {
|
||||
>
|
||||
{s.value}
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled">{s.label}</div>
|
||||
<div className="text-label-2 text-fg-default">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -3202,7 +3202,7 @@ function VerificationPanel() {
|
||||
{paper.system}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-fg-disabled leading-[1.6]">
|
||||
<div className="text-fg-default leading-[1.6]">
|
||||
{paper.authors} | <b style={{ color: paper.color }}>{paper.journal}</b>{' '}
|
||||
{paper.detail}
|
||||
</div>
|
||||
@ -3314,7 +3314,7 @@ function RoadmapPanel() {
|
||||
}}
|
||||
>
|
||||
<div className="font-medium mb-0.5">{r.title}</div>
|
||||
<div className="text-label-2 text-fg-disabled">{r.desc}</div>
|
||||
<div className="text-label-2 text-fg-default">{r.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -3367,7 +3367,7 @@ function RoadmapPanel() {
|
||||
{s.phase}
|
||||
</div>
|
||||
<div className="text-label-2 font-medium mb-1">{s.title}</div>
|
||||
<div className="text-label-2 whitespace-pre-line text-fg-disabled leading-normal">
|
||||
<div className="text-label-2 whitespace-pre-line text-fg-default leading-normal">
|
||||
{s.desc}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useVesselSignals } from '@common/hooks/useVesselSignals';
|
||||
import type { MapBounds } from '@common/types/vessel';
|
||||
import { LeftPanel } from './LeftPanel';
|
||||
import { RightPanel } from './RightPanel';
|
||||
import { MapView } from '@common/components/map/MapView';
|
||||
@ -173,6 +175,8 @@ export function OilSpillView() {
|
||||
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
|
||||
const flyToTarget = null;
|
||||
const fitBoundsTarget = null;
|
||||
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
||||
const vessels = useVesselSignals(mapBounds);
|
||||
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
||||
const [oilTrajectory, setOilTrajectory] = useState<OilParticle[]>([]);
|
||||
const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([]);
|
||||
@ -208,6 +212,7 @@ export function OilSpillView() {
|
||||
|
||||
// 오일펜스 배치 상태
|
||||
const [boomLines, setBoomLines] = useState<BoomLine[]>([]);
|
||||
const [showBoomLines, setShowBoomLines] = useState(true);
|
||||
const [algorithmSettings, setAlgorithmSettings] = useState<AlgorithmSettings>({
|
||||
currentOrthogonalCorrection: 15,
|
||||
safetyMarginMinutes: 60,
|
||||
@ -1191,6 +1196,8 @@ export function OilSpillView() {
|
||||
onSpillUnitChange={setSpillUnit}
|
||||
boomLines={boomLines}
|
||||
onBoomLinesChange={setBoomLines}
|
||||
showBoomLines={showBoomLines}
|
||||
onShowBoomLinesChange={setShowBoomLines}
|
||||
oilTrajectory={oilTrajectory}
|
||||
algorithmSettings={algorithmSettings}
|
||||
onAlgorithmSettingsChange={setAlgorithmSettings}
|
||||
@ -1208,6 +1215,9 @@ export function OilSpillView() {
|
||||
onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))}
|
||||
sensitiveResources={sensitiveResourceCategories}
|
||||
onImageAnalysisResult={handleImageAnalysisResult}
|
||||
onFlyToCoord={(c: { lon: number; lat: number }) =>
|
||||
setFlyToCoord({ lat: c.lat, lon: c.lon })
|
||||
}
|
||||
validationErrors={validationErrors}
|
||||
/>
|
||||
</div>
|
||||
@ -1281,6 +1291,7 @@ export function OilSpillView() {
|
||||
)}
|
||||
selectedModels={selectedModels}
|
||||
boomLines={boomLines}
|
||||
showBoomLines={showBoomLines}
|
||||
isDrawingBoom={isDrawingBoom}
|
||||
drawingPoints={drawingPoints}
|
||||
layerOpacity={layerOpacity}
|
||||
@ -1321,6 +1332,8 @@ export function OilSpillView() {
|
||||
showBeached={displayControls.showBeached}
|
||||
showTimeLabel={displayControls.showTimeLabel}
|
||||
simulationStartTime={accidentTime || undefined}
|
||||
vessels={vessels}
|
||||
onBoundsChange={setMapBounds}
|
||||
/>
|
||||
|
||||
{/* 타임라인 플레이어 (리플레이 비활성 시) */}
|
||||
@ -1659,7 +1672,7 @@ export function OilSpillView() {
|
||||
fontSize: 'var(--font-size-caption)',
|
||||
}}
|
||||
>
|
||||
<span className="text-fg-disabled">{s.label}</span>
|
||||
<span className="text-fg-default">{s.label}</span>
|
||||
<span
|
||||
style={{
|
||||
color: s.color,
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { ComboBox } from '@common/components/ui/ComboBox';
|
||||
import type { PredictionModel } from './OilSpillView';
|
||||
import { analyzeImage } from '../services/predictionApi';
|
||||
import type { ImageAnalyzeResult } from '../services/predictionApi';
|
||||
import { analyzeImage, fetchGscAccidents } from '../services/predictionApi';
|
||||
import type { ImageAnalyzeResult, GscAccidentListItem } from '../services/predictionApi';
|
||||
|
||||
interface PredictionInputSectionProps {
|
||||
expanded: boolean;
|
||||
@ -33,6 +33,7 @@ interface PredictionInputSectionProps {
|
||||
spillUnit: string;
|
||||
onSpillUnitChange: (unit: string) => void;
|
||||
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void;
|
||||
onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
|
||||
validationErrors?: Set<string>;
|
||||
}
|
||||
|
||||
@ -64,6 +65,7 @@ const PredictionInputSection = ({
|
||||
spillUnit,
|
||||
onSpillUnitChange,
|
||||
onImageAnalysisResult,
|
||||
onFlyToCoord,
|
||||
validationErrors,
|
||||
}: PredictionInputSectionProps) => {
|
||||
const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct');
|
||||
@ -71,8 +73,41 @@ const PredictionInputSection = ({
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [analyzeError, setAnalyzeError] = useState<string | null>(null);
|
||||
const [analyzeResult, setAnalyzeResult] = useState<ImageAnalyzeResult | null>(null);
|
||||
const [gscAccidents, setGscAccidents] = useState<GscAccidentListItem[]>([]);
|
||||
const [selectedGscMngNo, setSelectedGscMngNo] = useState<string>('');
|
||||
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 file = e.target.files?.[0] ?? null;
|
||||
setUploadedFile(file);
|
||||
@ -119,8 +154,8 @@ const PredictionInputSection = ({
|
||||
onClick={onToggle}
|
||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]"
|
||||
>
|
||||
<h3 className="text-title-4 font-bold text-fg-sub font-korean">예측정보 입력</h3>
|
||||
<span className="text-label-2 text-fg-disabled">{expanded ? '▼' : '▶'}</span>
|
||||
<h3 className="text-title-4 font-bold text-fg-default font-korean">예측정보 입력</h3>
|
||||
<span className="text-label-2 text-fg-default">{expanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
@ -161,7 +196,13 @@ const PredictionInputSection = ({
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" readOnly />
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
value={selectedGscMngNo}
|
||||
onChange={handleGscAccidentSelect}
|
||||
options={gscOptions}
|
||||
placeholder="또는 사고 리스트에서 선택"
|
||||
/>
|
||||
|
||||
{/* Image Upload Mode */}
|
||||
{inputMode === 'upload' && (
|
||||
@ -169,7 +210,7 @@ const PredictionInputSection = ({
|
||||
{/* 파일 선택 영역 */}
|
||||
{!uploadedFile ? (
|
||||
<label
|
||||
className="flex items-center justify-center text-label-2 text-fg-disabled cursor-pointer"
|
||||
className="flex items-center justify-center text-label-2 text-fg-default cursor-pointer"
|
||||
style={{
|
||||
padding: '20px',
|
||||
background: 'var(--bg-base)',
|
||||
@ -203,7 +244,7 @@ const PredictionInputSection = ({
|
||||
<span className="text-fg-sub">📄 {uploadedFile.name}</span>
|
||||
<button
|
||||
onClick={handleRemoveFile}
|
||||
className="text-label-2 text-fg-disabled bg-transparent border-none cursor-pointer"
|
||||
className="text-label-2 text-fg-default bg-transparent border-none cursor-pointer"
|
||||
style={{ padding: '2px 6px', transition: '0.15s' }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--color-danger)';
|
||||
@ -258,7 +299,7 @@ const PredictionInputSection = ({
|
||||
>
|
||||
✓ 분석 완료
|
||||
<br />
|
||||
<span className="font-normal text-fg-disabled">
|
||||
<span className="font-normal text-fg-default">
|
||||
위도 {analyzeResult.lat.toFixed(4)} / 경도 {analyzeResult.lon.toFixed(4)}
|
||||
<br />
|
||||
유종: {analyzeResult.oilType} / 면적: {analyzeResult.area.toFixed(1)} m²
|
||||
@ -270,9 +311,7 @@ const PredictionInputSection = ({
|
||||
|
||||
{/* 사고 발생 시각 */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-label-2 text-fg-disabled font-korean">
|
||||
사고 발생 시각 (KST)
|
||||
</label>
|
||||
<label className="text-label-2 text-fg-default font-korean">사고 발생 시각 (KST)</label>
|
||||
<DateTimeInput
|
||||
value={accidentTime}
|
||||
onChange={onAccidentTimeChange}
|
||||
@ -551,7 +590,7 @@ function DateTimeInput({
|
||||
|
||||
{/* 시 */}
|
||||
<TimeDropdown value={hh} max={24} onChange={(v) => updateTime(v, mm)} />
|
||||
<span className="text-label-2 text-fg-disabled font-bold">:</span>
|
||||
<span className="text-label-2 text-fg-default font-bold">:</span>
|
||||
{/* 분 */}
|
||||
<TimeDropdown value={mm} max={60} onChange={(v) => updateTime(hh, v)} />
|
||||
|
||||
@ -577,7 +616,7 @@ function DateTimeInput({
|
||||
<button
|
||||
type="button"
|
||||
onClick={prevMonth}
|
||||
className="text-label-2 text-fg-disabled cursor-pointer px-1 hover:text-fg"
|
||||
className="text-label-2 text-fg-default cursor-pointer px-1 hover:text-fg"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
@ -587,7 +626,7 @@ function DateTimeInput({
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextMonth}
|
||||
className="text-label-2 text-fg-disabled cursor-pointer px-1 hover:text-fg"
|
||||
className="text-label-2 text-fg-default cursor-pointer px-1 hover:text-fg"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
@ -597,7 +636,7 @@ function DateTimeInput({
|
||||
{['일', '월', '화', '수', '목', '금', '토'].map((d) => (
|
||||
<span
|
||||
key={d}
|
||||
className="text-label-2 text-fg-disabled font-korean"
|
||||
className="text-label-2 text-fg-default font-korean"
|
||||
style={{ padding: '2px 0' }}
|
||||
>
|
||||
{d}
|
||||
@ -779,7 +818,7 @@ function DmsCoordInput({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-caption text-fg-disabled font-korean">{label}</span>
|
||||
<span className="text-caption text-fg-default font-korean">{label}</span>
|
||||
<div
|
||||
className="flex items-center gap-0.5"
|
||||
style={
|
||||
@ -822,7 +861,7 @@ function DmsCoordInput({
|
||||
onChange={(e) => update(parseInt(e.target.value) || 0, m, s, dir)}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
<span className="text-caption text-fg-disabled">°</span>
|
||||
<span className="text-caption text-fg-default">°</span>
|
||||
<input
|
||||
className="prd-i text-center flex-1"
|
||||
type="number"
|
||||
@ -832,7 +871,7 @@ function DmsCoordInput({
|
||||
onChange={(e) => update(d, parseInt(e.target.value) || 0, s, dir)}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
<span className="text-caption text-fg-disabled">'</span>
|
||||
<span className="text-caption text-fg-default">'</span>
|
||||
<input
|
||||
className="prd-i text-center flex-1"
|
||||
type="number"
|
||||
@ -843,7 +882,7 @@ function DmsCoordInput({
|
||||
onChange={(e) => update(d, m, parseFloat(e.target.value) || 0, dir)}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
<span className="text-caption text-fg-disabled">"</span>
|
||||
<span className="text-caption text-fg-default">"</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -167,7 +167,7 @@ export function RecalcModal({
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-subtitle font-bold m-0">확산예측 재계산</h2>
|
||||
<div className="text-caption text-fg-disabled mt-[2px]">
|
||||
<div className="text-caption text-fg-default mt-[2px]">
|
||||
유출유·유출량 등 파라미터를 수정하여 재실행
|
||||
</div>
|
||||
</div>
|
||||
@ -180,7 +180,7 @@ export function RecalcModal({
|
||||
background: 'var(--bg-card)',
|
||||
fontSize: 'var(--font-size-caption)',
|
||||
}}
|
||||
className="border border-stroke text-fg-disabled cursor-pointer flex items-center justify-center"
|
||||
className="border border-stroke text-fg-default cursor-pointer flex items-center justify-center"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@ -281,7 +281,7 @@ export function RecalcModal({
|
||||
<FieldGroup label="유출 위치 (좌표)">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="flex-1">
|
||||
<div className="text-caption text-fg-disabled mb-[3px]">위도 (N)</div>
|
||||
<div className="text-caption text-fg-default mb-[3px]">위도 (N)</div>
|
||||
<input
|
||||
type="number"
|
||||
className="prd-i font-mono"
|
||||
@ -291,7 +291,7 @@ export function RecalcModal({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-caption text-fg-disabled mb-[3px]">경도 (E)</div>
|
||||
<div className="text-caption text-fg-default mb-[3px]">경도 (E)</div>
|
||||
<input
|
||||
type="number"
|
||||
className="prd-i font-mono"
|
||||
@ -404,7 +404,7 @@ function FieldGroup({ label, children }: { label: string; children: React.ReactN
|
||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between py-[2px]">
|
||||
<span className="text-fg-disabled">{label}</span>
|
||||
<span className="text-fg-default">{label}</span>
|
||||
<span className="font-semibold font-mono">{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -165,7 +165,7 @@ export function RightPanel({
|
||||
</div>
|
||||
{windHydrModelOptions.length > 1 && (
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className="text-label-2 text-fg-disabled font-korean whitespace-nowrap">
|
||||
<span className="text-label-2 text-fg-default font-korean whitespace-nowrap">
|
||||
데이터 모델
|
||||
</span>
|
||||
<select
|
||||
@ -197,7 +197,7 @@ export function RightPanel({
|
||||
className={`flex-1 py-1.5 px-1 rounded text-label-2 font-medium font-korean border transition-colors ${
|
||||
analysisTab === tab
|
||||
? 'border-color-accent bg-[rgba(6,182,212,0.08)] text-color-accent'
|
||||
: 'border-stroke bg-bg-card text-fg-disabled hover:text-fg-sub'
|
||||
: 'border-stroke bg-bg-card text-fg-default hover:text-fg-sub'
|
||||
}`}
|
||||
>
|
||||
{tab === 'polygon' ? '다각형 분석' : '원 분석'}
|
||||
@ -208,7 +208,7 @@ export function RightPanel({
|
||||
{/* 다각형 패널 */}
|
||||
{analysisTab === 'polygon' && (
|
||||
<div>
|
||||
<p className="text-label-2 text-fg-disabled font-korean mb-2 leading-relaxed">
|
||||
<p className="text-label-2 text-fg-default font-korean mb-2 leading-relaxed">
|
||||
지도에서 다각형 영역을 지정하여 해당 범위 내 오염도를 분석합니다.
|
||||
</p>
|
||||
{!drawAnalysisMode && !analysisResult && (
|
||||
@ -229,7 +229,7 @@ export function RightPanel({
|
||||
<div className="text-label-2 text-purple-400 font-korean bg-[rgba(168,85,247,0.08)] rounded px-2 py-1.5 leading-relaxed">
|
||||
지도를 클릭하여 꼭짓점을 추가하세요
|
||||
<br />
|
||||
<span className="text-fg-disabled">
|
||||
<span className="text-fg-default">
|
||||
현재 {analysisPolygonPoints.length}개 선택됨
|
||||
</span>
|
||||
</div>
|
||||
@ -247,7 +247,7 @@ export function RightPanel({
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelAnalysis}
|
||||
className="py-1.5 px-2 rounded text-label-2 font-medium font-korean border border-stroke text-fg-disabled hover:text-fg-sub transition-colors"
|
||||
className="py-1.5 px-2 rounded text-label-2 font-medium font-korean border border-stroke text-fg-default hover:text-fg-sub transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
@ -268,7 +268,7 @@ export function RightPanel({
|
||||
{/* 원 분석 패널 */}
|
||||
{analysisTab === 'circle' && (
|
||||
<div>
|
||||
<p className="text-label-2 text-fg-disabled font-korean mb-2 leading-relaxed">
|
||||
<p className="text-label-2 text-fg-default font-korean mb-2 leading-relaxed">
|
||||
반경(NM)을 지정하면 사고지점 기준 원형 영역의 오염도를 분석합니다.
|
||||
</p>
|
||||
<div className="text-label-2 font-medium text-fg-sub font-korean mb-1.5">
|
||||
@ -282,7 +282,7 @@ export function RightPanel({
|
||||
className={`w-8 h-7 rounded text-label-2 font-medium font-mono border transition-all ${
|
||||
circleRadiusNm === nm
|
||||
? 'border-color-accent bg-[rgba(6,182,212,0.1)] text-color-accent'
|
||||
: 'border-stroke bg-bg-base text-fg-disabled hover:text-fg-sub'
|
||||
: 'border-stroke bg-bg-base text-fg-default hover:text-fg-sub'
|
||||
}`}
|
||||
>
|
||||
{nm}
|
||||
@ -290,7 +290,7 @@ export function RightPanel({
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mb-2.5">
|
||||
<span className="text-label-2 text-fg-disabled font-korean whitespace-nowrap">
|
||||
<span className="text-label-2 text-fg-default font-korean whitespace-nowrap">
|
||||
직접 입력
|
||||
</span>
|
||||
<input
|
||||
@ -303,7 +303,7 @@ export function RightPanel({
|
||||
className="w-14 text-center py-1 px-1 bg-bg-base border border-stroke rounded text-label-2 font-mono text-fg outline-none focus:border-color-accent"
|
||||
style={{ colorScheme: 'dark' }}
|
||||
/>
|
||||
<span className="text-label-2 text-fg-disabled font-korean">NM</span>
|
||||
<span className="text-label-2 text-fg-default font-korean">NM</span>
|
||||
<button
|
||||
onClick={onRunCircleAnalysis}
|
||||
className="ml-auto py-1 px-3 rounded-sm text-label-2 font-bold font-korean transition-colors hover:bg-[rgba(6,182,212,0.08)]"
|
||||
@ -462,7 +462,7 @@ export function RightPanel({
|
||||
<div className="text-label-2 font-bold text-fg font-korean">
|
||||
{vessel?.vesselNm || '—'}
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled font-mono">
|
||||
<div className="text-label-2 text-fg-default font-mono">
|
||||
IMO {vessel?.imoNo || '—'} · {vessel?.vesselTp || '—'}
|
||||
</div>
|
||||
</div>
|
||||
@ -511,7 +511,7 @@ export function RightPanel({
|
||||
<div className="text-label-2 font-bold text-color-warning font-korean mb-1">
|
||||
⚠ 충돌 상대: {vessel2.vesselNm}
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean leading-relaxed">
|
||||
<div className="text-label-2 text-fg-default font-korean leading-relaxed">
|
||||
{vessel2.flagCd} {vessel2.vesselTp}{' '}
|
||||
{vessel2.gt ? `${vessel2.gt.toLocaleString()}GT` : ''}
|
||||
</div>
|
||||
@ -570,7 +570,7 @@ export function RightPanel({
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-label-2 text-fg-disabled font-korean text-center py-4">
|
||||
<div className="text-label-2 text-fg-default font-korean text-center py-4">
|
||||
보험 정보가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
@ -647,12 +647,9 @@ function getSpreadSeverity(
|
||||
// Helper Components
|
||||
const BADGE_STYLES: Record<string, string> = {
|
||||
red: 'bg-[rgba(239,68,68,0.08)] text-color-danger border border-[rgba(239,68,68,0.25)]',
|
||||
orange:
|
||||
'bg-[rgba(249,115,22,0.08)] text-color-warning border border-[rgba(249,115,22,0.25)]',
|
||||
yellow:
|
||||
'bg-[rgba(234,179,8,0.08)] text-color-caution border border-[rgba(234,179,8,0.25)]',
|
||||
green:
|
||||
'bg-[rgba(34,197,94,0.08)] text-color-success border border-[rgba(34,197,94,0.25)]',
|
||||
orange: 'bg-[rgba(249,115,22,0.08)] text-color-warning border border-[rgba(249,115,22,0.25)]',
|
||||
yellow: 'bg-[rgba(234,179,8,0.08)] text-color-caution border border-[rgba(234,179,8,0.25)]',
|
||||
green: 'bg-[rgba(34,197,94,0.08)] text-color-success border border-[rgba(34,197,94,0.25)]',
|
||||
};
|
||||
|
||||
function Section({
|
||||
@ -699,7 +696,7 @@ function ControlledCheckbox({
|
||||
return (
|
||||
<label
|
||||
className={`flex items-center gap-1.5 text-label-2 font-korean cursor-pointer ${
|
||||
disabled ? 'text-fg-disabled cursor-not-allowed opacity-40' : 'text-fg-sub'
|
||||
disabled ? 'text-fg-default cursor-not-allowed opacity-40' : 'text-fg-sub'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
@ -727,9 +724,9 @@ function StatBox({
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-between px-2 py-1 bg-bg-base border border-stroke rounded-[3px]">
|
||||
<span className="text-fg-disabled font-korean">{label}</span>
|
||||
<span className="text-fg-default font-korean">{label}</span>
|
||||
<span style={{ fontWeight: 700, color, fontFamily: 'var(--font-mono)' }}>
|
||||
{value} <small className="font-normal text-fg-disabled">{unit}</small>
|
||||
{value} <small className="font-normal text-fg-default">{unit}</small>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@ -738,7 +735,7 @@ function StatBox({
|
||||
function PredictionCard({ value, label, color }: { value: string; label: string; color: string }) {
|
||||
return (
|
||||
<div className="flex justify-between px-2 py-1 bg-bg-base border border-stroke rounded-[3px] text-label-2">
|
||||
<span className="text-fg-disabled font-korean">{label}</span>
|
||||
<span className="text-fg-default font-korean">{label}</span>
|
||||
<span style={{ fontWeight: 700, color, fontFamily: 'var(--font-mono)' }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
@ -747,7 +744,7 @@ function PredictionCard({ value, label, color }: { value: string; label: string;
|
||||
function ProgressBar({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-fg-disabled font-korean" style={{ minWidth: '38px' }}>
|
||||
<span className="text-fg-default font-korean" style={{ minWidth: '38px' }}>
|
||||
{label}
|
||||
</span>
|
||||
<div
|
||||
@ -783,7 +780,7 @@ function CollapsibleSection({
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-3.5 mb-2.5">
|
||||
<div className="flex items-center justify-between cursor-pointer mb-2" onClick={onToggle}>
|
||||
<h4 className="text-label-1 font-medium text-fg-sub font-korean">{title}</h4>
|
||||
<span className="text-label-2 text-fg-disabled">{expanded ? '▾' : '▸'}</span>
|
||||
<span className="text-label-2 text-fg-default">{expanded ? '▾' : '▸'}</span>
|
||||
</div>
|
||||
{expanded && children}
|
||||
</div>
|
||||
@ -796,7 +793,7 @@ function SpecCard({ value, label, color }: { value: string; label: string; color
|
||||
<div style={{ color }} className="text-label-1 font-bold font-mono">
|
||||
{value}
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean">{label}</div>
|
||||
<div className="text-label-2 text-fg-default font-korean">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -814,7 +811,7 @@ function InfoRow({
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-between py-[3px] px-[6px] bg-bg-base rounded-[3px]">
|
||||
<span className="text-fg-disabled">{label}</span>
|
||||
<span className="text-fg-default">{label}</span>
|
||||
<span
|
||||
style={{ color: valueColor || 'var(--fg-default)' }}
|
||||
className={`font-medium${mono ? ' font-mono' : ''}`}
|
||||
@ -869,7 +866,7 @@ function InsuranceCard({
|
||||
<div className="space-y-0.5 text-label-2 font-korean">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex justify-between py-0.5 px-1">
|
||||
<span className="text-fg-disabled">{item.label}</span>
|
||||
<span className="text-fg-default">{item.label}</span>
|
||||
<span
|
||||
style={{ color: item.valueColor || 'var(--fg-default)' }}
|
||||
className={`font-medium${item.mono ? ' font-mono' : ''}`}
|
||||
@ -928,7 +925,7 @@ function PollResult({
|
||||
>
|
||||
{result.area.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean mt-0.5">분석면적(km²)</div>
|
||||
<div className="text-label-2 text-fg-default font-korean mt-0.5">분석면적(km²)</div>
|
||||
</div>
|
||||
<div className="text-center py-1.5 px-1 bg-bg-card rounded">
|
||||
<div
|
||||
@ -937,7 +934,7 @@ function PollResult({
|
||||
>
|
||||
{result.particlePercent}%
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean mt-0.5">오염비율</div>
|
||||
<div className="text-label-2 text-fg-default font-korean mt-0.5">오염비율</div>
|
||||
</div>
|
||||
<div className="text-center py-1.5 px-1 bg-bg-card rounded">
|
||||
<div
|
||||
@ -946,13 +943,13 @@ function PollResult({
|
||||
>
|
||||
{pollutedArea}
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean mt-0.5">오염면적(km²)</div>
|
||||
<div className="text-label-2 text-fg-default font-korean mt-0.5">오염면적(km²)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 text-label-2 font-korean">
|
||||
{summary && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-fg-disabled">해상잔존량</span>
|
||||
<span className="text-fg-default">해상잔존량</span>
|
||||
<span className="font-medium font-mono" style={{ color: 'var(--color-info)' }}>
|
||||
{summary.remainingVolume.toFixed(2)} m³
|
||||
</span>
|
||||
@ -960,14 +957,14 @@ function PollResult({
|
||||
)}
|
||||
{summary && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-fg-disabled">연안부착량</span>
|
||||
<span className="text-fg-default">연안부착량</span>
|
||||
<span className="font-medium font-mono" style={{ color: 'var(--color-danger)' }}>
|
||||
{summary.beachedVolume.toFixed(2)} m³
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-fg-disabled">민감자원 포함</span>
|
||||
<span className="text-fg-default">민감자원 포함</span>
|
||||
<span className="font-medium font-mono" style={{ color: 'var(--color-warning)' }}>
|
||||
{result.sensitiveCount}개소
|
||||
</span>
|
||||
@ -976,7 +973,7 @@ function PollResult({
|
||||
<div className="flex gap-1.5 mt-2">
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="flex-1 py-1.5 rounded text-label-2 font-medium font-korean border border-stroke text-fg-disabled hover:text-fg-sub transition-colors"
|
||||
className="flex-1 py-1.5 rounded text-label-2 font-medium font-korean border border-stroke text-fg-default hover:text-fg-sub transition-colors"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
|
||||
@ -40,6 +40,8 @@ export interface LeftPanelProps {
|
||||
// 오일펜스 배치 관련
|
||||
boomLines: BoomLine[];
|
||||
onBoomLinesChange: (lines: BoomLine[]) => void;
|
||||
showBoomLines: boolean;
|
||||
onShowBoomLinesChange: (show: boolean) => void;
|
||||
oilTrajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>;
|
||||
algorithmSettings: AlgorithmSettings;
|
||||
onAlgorithmSettingsChange: (settings: AlgorithmSettings) => void;
|
||||
@ -60,6 +62,8 @@ export interface LeftPanelProps {
|
||||
sensitiveResources?: SensitiveResourceCategory[];
|
||||
// 이미지 분석 결과 콜백
|
||||
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void;
|
||||
// 사고 리스트 선택 시 지도 이동 콜백
|
||||
onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
|
||||
// 유효성 검증 에러 필드
|
||||
validationErrors?: Set<string>;
|
||||
}
|
||||
|
||||
@ -321,3 +321,20 @@ export const analyzeImage = async (file: File, acdntNm?: string): Promise<ImageA
|
||||
});
|
||||
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;
|
||||
};
|
||||
|
||||
@ -120,10 +120,19 @@ const SCENARIO_MGMT_GUIDELINES = [
|
||||
/* ─── Mock 시나리오 (API 미연결 시 폴백) — 긴급구난 모델 이론 기반 10개 ─── */
|
||||
const MOCK_SCENARIOS: RescueScenarioItem[] = [
|
||||
{
|
||||
scenarioSn: 1, rescueOpsSn: 1, timeStep: 'T+0h',
|
||||
scenarioDtm: '2024-10-27T01:30:00.000Z', svrtCd: 'CRITICAL',
|
||||
gmM: 0.8, listDeg: 15.0, trimM: 2.5, buoyancyPct: 30.0, oilRateLpm: 100.0, bmRatioPct: 92.0,
|
||||
description: '좌현 35° 충돌로 No.1P 화물탱크 파공. 벙커C유 유출 개시. 손상복원성 분석: 초기 GM 0.8m으로 IMO 기준(1.0m) 미달, 복원력 위험 판정.',
|
||||
scenarioSn: 1,
|
||||
rescueOpsSn: 1,
|
||||
timeStep: 'T+0h',
|
||||
scenarioDtm: '2024-10-27T01:30:00.000Z',
|
||||
svrtCd: 'CRITICAL',
|
||||
gmM: 0.8,
|
||||
listDeg: 15.0,
|
||||
trimM: 2.5,
|
||||
buoyancyPct: 30.0,
|
||||
oilRateLpm: 100.0,
|
||||
bmRatioPct: 92.0,
|
||||
description:
|
||||
'좌현 35° 충돌로 No.1P 화물탱크 파공. 벙커C유 유출 개시. 손상복원성 분석: 초기 GM 0.8m으로 IMO 기준(1.0m) 미달, 복원력 위험 판정.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' },
|
||||
@ -146,10 +155,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [
|
||||
sortOrd: 1,
|
||||
},
|
||||
{
|
||||
scenarioSn: 2, rescueOpsSn: 1, timeStep: 'T+30m',
|
||||
scenarioDtm: '2024-10-27T02:00:00.000Z', svrtCd: 'CRITICAL',
|
||||
gmM: 0.7, listDeg: 17.0, trimM: 2.8, buoyancyPct: 28.0, oilRateLpm: 120.0, bmRatioPct: 90.0,
|
||||
description: '잠수사 수중 조사 결과 좌현 No.1P 파공 크기 1.2m×0.8m 확인. Bernoulli 유입률 모델 적용: 수두차 4.5m 기준 유입률 약 2.1㎥/min.',
|
||||
scenarioSn: 2,
|
||||
rescueOpsSn: 1,
|
||||
timeStep: 'T+30m',
|
||||
scenarioDtm: '2024-10-27T02:00:00.000Z',
|
||||
svrtCd: 'CRITICAL',
|
||||
gmM: 0.7,
|
||||
listDeg: 17.0,
|
||||
trimM: 2.8,
|
||||
buoyancyPct: 28.0,
|
||||
oilRateLpm: 120.0,
|
||||
bmRatioPct: 90.0,
|
||||
description:
|
||||
'잠수사 수중 조사 결과 좌현 No.1P 파공 크기 1.2m×0.8m 확인. Bernoulli 유입률 모델 적용: 수두차 4.5m 기준 유입률 약 2.1㎥/min.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' },
|
||||
@ -172,10 +190,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [
|
||||
sortOrd: 2,
|
||||
},
|
||||
{
|
||||
scenarioSn: 3, rescueOpsSn: 1, timeStep: 'T+1h',
|
||||
scenarioDtm: '2024-10-27T02:30:00.000Z', svrtCd: 'CRITICAL',
|
||||
gmM: 0.65, listDeg: 18.5, trimM: 3.0, buoyancyPct: 26.0, oilRateLpm: 135.0, bmRatioPct: 89.0,
|
||||
description: '해경 3009함 현장 도착, SAR 작전 개시. Leeway 표류 예측 모델: 풍속 8m/s, 해류 2.5kn NE — 실종자 표류 반경 1.2nm. GZ 최대 복원력 각도 25°로 감소.',
|
||||
scenarioSn: 3,
|
||||
rescueOpsSn: 1,
|
||||
timeStep: 'T+1h',
|
||||
scenarioDtm: '2024-10-27T02:30:00.000Z',
|
||||
svrtCd: 'CRITICAL',
|
||||
gmM: 0.65,
|
||||
listDeg: 18.5,
|
||||
trimM: 3.0,
|
||||
buoyancyPct: 26.0,
|
||||
oilRateLpm: 135.0,
|
||||
bmRatioPct: 89.0,
|
||||
description:
|
||||
'해경 3009함 현장 도착, SAR 작전 개시. Leeway 표류 예측 모델: 풍속 8m/s, 해류 2.5kn NE — 실종자 표류 반경 1.2nm. GZ 최대 복원력 각도 25°로 감소.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
@ -198,10 +225,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [
|
||||
sortOrd: 3,
|
||||
},
|
||||
{
|
||||
scenarioSn: 4, rescueOpsSn: 1, timeStep: 'T+2h',
|
||||
scenarioDtm: '2024-10-27T03:30:00.000Z', svrtCd: 'CRITICAL',
|
||||
gmM: 0.5, listDeg: 20.0, trimM: 3.5, buoyancyPct: 22.0, oilRateLpm: 160.0, bmRatioPct: 86.0,
|
||||
description: '격벽 관통으로 #2 Port Tank 침수 확대. 자유표면효과(FSE) 보정: GM_fluid = 0.5m. 종강도: Sagging 모멘트 86%. 침몰 위험 단계 진입.',
|
||||
scenarioSn: 4,
|
||||
rescueOpsSn: 1,
|
||||
timeStep: 'T+2h',
|
||||
scenarioDtm: '2024-10-27T03:30:00.000Z',
|
||||
svrtCd: 'CRITICAL',
|
||||
gmM: 0.5,
|
||||
listDeg: 20.0,
|
||||
trimM: 3.5,
|
||||
buoyancyPct: 22.0,
|
||||
oilRateLpm: 160.0,
|
||||
bmRatioPct: 86.0,
|
||||
description:
|
||||
'격벽 관통으로 #2 Port Tank 침수 확대. 자유표면효과(FSE) 보정: GM_fluid = 0.5m. 종강도: Sagging 모멘트 86%. 침몰 위험 단계 진입.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
@ -224,10 +260,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [
|
||||
sortOrd: 4,
|
||||
},
|
||||
{
|
||||
scenarioSn: 5, rescueOpsSn: 1, timeStep: 'T+3h',
|
||||
scenarioDtm: '2024-10-27T04:30:00.000Z', svrtCd: 'HIGH',
|
||||
gmM: 0.55, listDeg: 16.0, trimM: 3.2, buoyancyPct: 25.0, oilRateLpm: 140.0, bmRatioPct: 87.0,
|
||||
description: 'Counter-Flooding 실시: #3 Stbd Tank에 평형수 280톤 주입, 횡경사 20°→16° 교정. 종강도: 중량 재배분으로 BM 87% 유지.',
|
||||
scenarioSn: 5,
|
||||
rescueOpsSn: 1,
|
||||
timeStep: 'T+3h',
|
||||
scenarioDtm: '2024-10-27T04:30:00.000Z',
|
||||
svrtCd: 'HIGH',
|
||||
gmM: 0.55,
|
||||
listDeg: 16.0,
|
||||
trimM: 3.2,
|
||||
buoyancyPct: 25.0,
|
||||
oilRateLpm: 140.0,
|
||||
bmRatioPct: 87.0,
|
||||
description:
|
||||
'Counter-Flooding 실시: #3 Stbd Tank에 평형수 280톤 주입, 횡경사 20°→16° 교정. 종강도: 중량 재배분으로 BM 87% 유지.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
@ -250,10 +295,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [
|
||||
sortOrd: 5,
|
||||
},
|
||||
{
|
||||
scenarioSn: 6, rescueOpsSn: 1, timeStep: 'T+6h',
|
||||
scenarioDtm: '2024-10-27T07:30:00.000Z', svrtCd: 'HIGH',
|
||||
gmM: 0.7, listDeg: 12.0, trimM: 2.5, buoyancyPct: 32.0, oilRateLpm: 80.0, bmRatioPct: 90.0,
|
||||
description: '수중패치 설치, 유입률 감소. GM 0.7m 회복. Trim/Stability Booklet 기준 예인 가능 최소 조건(GM≥0.5m, List≤15°) 충족.',
|
||||
scenarioSn: 6,
|
||||
rescueOpsSn: 1,
|
||||
timeStep: 'T+6h',
|
||||
scenarioDtm: '2024-10-27T07:30:00.000Z',
|
||||
svrtCd: 'HIGH',
|
||||
gmM: 0.7,
|
||||
listDeg: 12.0,
|
||||
trimM: 2.5,
|
||||
buoyancyPct: 32.0,
|
||||
oilRateLpm: 80.0,
|
||||
bmRatioPct: 90.0,
|
||||
description:
|
||||
'수중패치 설치, 유입률 감소. GM 0.7m 회복. Trim/Stability Booklet 기준 예인 가능 최소 조건(GM≥0.5m, List≤15°) 충족.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
|
||||
@ -276,10 +330,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [
|
||||
sortOrd: 6,
|
||||
},
|
||||
{
|
||||
scenarioSn: 7, rescueOpsSn: 1, timeStep: 'T+8h',
|
||||
scenarioDtm: '2024-10-27T09:30:00.000Z', svrtCd: 'MEDIUM',
|
||||
gmM: 0.8, listDeg: 10.0, trimM: 2.0, buoyancyPct: 38.0, oilRateLpm: 55.0, bmRatioPct: 91.0,
|
||||
description: '오일붐 2중 전개, 유회수기 3대 가동. GNOME 확산 모델: 12시간 후 확산 면적 2.3km² 예측. 기계적 회수율 35%.',
|
||||
scenarioSn: 7,
|
||||
rescueOpsSn: 1,
|
||||
timeStep: 'T+8h',
|
||||
scenarioDtm: '2024-10-27T09:30:00.000Z',
|
||||
svrtCd: 'MEDIUM',
|
||||
gmM: 0.8,
|
||||
listDeg: 10.0,
|
||||
trimM: 2.0,
|
||||
buoyancyPct: 38.0,
|
||||
oilRateLpm: 55.0,
|
||||
bmRatioPct: 91.0,
|
||||
description:
|
||||
'오일붐 2중 전개, 유회수기 3대 가동. GNOME 확산 모델: 12시간 후 확산 면적 2.3km² 예측. 기계적 회수율 35%.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
|
||||
@ -302,10 +365,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [
|
||||
sortOrd: 7,
|
||||
},
|
||||
{
|
||||
scenarioSn: 8, rescueOpsSn: 1, timeStep: 'T+12h',
|
||||
scenarioDtm: '2024-10-27T13:30:00.000Z', svrtCd: 'MEDIUM',
|
||||
gmM: 0.9, listDeg: 8.0, trimM: 1.5, buoyancyPct: 45.0, oilRateLpm: 30.0, bmRatioPct: 94.0,
|
||||
description: '예인 개시. 예인 저항 Rt=1/2·ρ·Cd·A·V² 기반 4,000HP급 배정. 목포항 42nm, 예인 속도 3kn, ETA 14h.',
|
||||
scenarioSn: 8,
|
||||
rescueOpsSn: 1,
|
||||
timeStep: 'T+12h',
|
||||
scenarioDtm: '2024-10-27T13:30:00.000Z',
|
||||
svrtCd: 'MEDIUM',
|
||||
gmM: 0.9,
|
||||
listDeg: 8.0,
|
||||
trimM: 1.5,
|
||||
buoyancyPct: 45.0,
|
||||
oilRateLpm: 30.0,
|
||||
bmRatioPct: 94.0,
|
||||
description:
|
||||
'예인 개시. 예인 저항 Rt=1/2·ρ·Cd·A·V² 기반 4,000HP급 배정. 목포항 42nm, 예인 속도 3kn, ETA 14h.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
|
||||
@ -328,10 +400,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [
|
||||
sortOrd: 8,
|
||||
},
|
||||
{
|
||||
scenarioSn: 9, rescueOpsSn: 1, timeStep: 'T+18h',
|
||||
scenarioDtm: '2024-10-27T19:30:00.000Z', svrtCd: 'MEDIUM',
|
||||
gmM: 1.0, listDeg: 5.0, trimM: 1.0, buoyancyPct: 55.0, oilRateLpm: 15.0, bmRatioPct: 96.0,
|
||||
description: '예인 진행률 65%. 파랑 응답 분석(RAO): 유의파고 1.2m, 주기 6s — 횡동요 ±3° 안전 범위. 잔류 유출률 15 L/min.',
|
||||
scenarioSn: 9,
|
||||
rescueOpsSn: 1,
|
||||
timeStep: 'T+18h',
|
||||
scenarioDtm: '2024-10-27T19:30:00.000Z',
|
||||
svrtCd: 'MEDIUM',
|
||||
gmM: 1.0,
|
||||
listDeg: 5.0,
|
||||
trimM: 1.0,
|
||||
buoyancyPct: 55.0,
|
||||
oilRateLpm: 15.0,
|
||||
bmRatioPct: 96.0,
|
||||
description:
|
||||
'예인 진행률 65%. 파랑 응답 분석(RAO): 유의파고 1.2m, 주기 6s — 횡동요 ±3° 안전 범위. 잔류 유출률 15 L/min.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
|
||||
@ -354,10 +435,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [
|
||||
sortOrd: 9,
|
||||
},
|
||||
{
|
||||
scenarioSn: 10, rescueOpsSn: 1, timeStep: 'T+24h',
|
||||
scenarioDtm: '2024-10-28T01:30:00.000Z', svrtCd: 'RESOLVED',
|
||||
gmM: 1.2, listDeg: 3.0, trimM: 0.5, buoyancyPct: 75.0, oilRateLpm: 5.0, bmRatioPct: 98.0,
|
||||
description: '목포항 접안 완료. 잔류유 전량 이적(120kL). 최종 GM 1.2m IMO 충족, BM 98% 정상. 방제 총 회수량 85kL (회수율 71%). 상황 종료.',
|
||||
scenarioSn: 10,
|
||||
rescueOpsSn: 1,
|
||||
timeStep: 'T+24h',
|
||||
scenarioDtm: '2024-10-28T01:30:00.000Z',
|
||||
svrtCd: 'RESOLVED',
|
||||
gmM: 1.2,
|
||||
listDeg: 3.0,
|
||||
trimM: 0.5,
|
||||
buoyancyPct: 75.0,
|
||||
oilRateLpm: 5.0,
|
||||
bmRatioPct: 98.0,
|
||||
description:
|
||||
'목포항 접안 완료. 잔류유 전량 이적(120kL). 최종 GM 1.2m IMO 충족, BM 98% 정상. 방제 총 회수량 85kL (회수율 71%). 상황 종료.',
|
||||
compartments: [
|
||||
{ name: '#1 FP Tank', status: 'SEALED', color: 'var(--orange)' },
|
||||
{ name: '#1 Port Tank', status: 'SEALED', color: 'var(--orange)' },
|
||||
@ -383,15 +473,30 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [
|
||||
|
||||
const MOCK_OPS: RescueOpsItem[] = [
|
||||
{
|
||||
rescueOpsSn: 1, acdntSn: 1, opsCd: 'RSC-2026-001', acdntTpCd: 'collision',
|
||||
vesselNm: 'M/V SEA GUARDIAN', commanderNm: null,
|
||||
lon: 126.25, lat: 37.467, locDc: '37°28\'N, 126°15\'E',
|
||||
depthM: 25.0, currentDc: '2.5kn NE',
|
||||
gmM: 0.8, listDeg: 15.0, trimM: 2.5, buoyancyPct: 30.0,
|
||||
oilRateLpm: 100.0, bmRatioPct: 92.0,
|
||||
totalCrew: 20, survivors: 15, missing: 5,
|
||||
hydroData: null, gmdssData: null,
|
||||
sttsCd: 'ACTIVE', regDtm: '2024-10-27T01:30:00.000Z',
|
||||
rescueOpsSn: 1,
|
||||
acdntSn: 1,
|
||||
opsCd: 'RSC-2026-001',
|
||||
acdntTpCd: 'collision',
|
||||
vesselNm: 'M/V SEA GUARDIAN',
|
||||
commanderNm: null,
|
||||
lon: 126.25,
|
||||
lat: 37.467,
|
||||
locDc: "37°28'N, 126°15'E",
|
||||
depthM: 25.0,
|
||||
currentDc: '2.5kn NE',
|
||||
gmM: 0.8,
|
||||
listDeg: 15.0,
|
||||
trimM: 2.5,
|
||||
buoyancyPct: 30.0,
|
||||
oilRateLpm: 100.0,
|
||||
bmRatioPct: 92.0,
|
||||
totalCrew: 20,
|
||||
survivors: 15,
|
||||
missing: 5,
|
||||
hydroData: null,
|
||||
gmdssData: null,
|
||||
sttsCd: 'ACTIVE',
|
||||
regDtm: '2024-10-27T01:30:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
@ -698,7 +803,9 @@ export function RescueScenarioView() {
|
||||
</div>
|
||||
|
||||
{/* View content */}
|
||||
<div className={`flex-1 ${detailView === 2 ? 'flex flex-col overflow-hidden' : 'overflow-y-auto scrollbar-thin'}`}>
|
||||
<div
|
||||
className={`flex-1 ${detailView === 2 ? 'flex flex-col overflow-hidden' : 'overflow-y-auto scrollbar-thin'}`}
|
||||
>
|
||||
{/* ─── VIEW 0: 시나리오 상세 ─── */}
|
||||
{detailView === 0 && selected && (
|
||||
<div className="p-5">
|
||||
@ -1039,9 +1146,23 @@ function ScenarioMapOverlay({
|
||||
maxWidth="320px"
|
||||
className="rescue-map-popup"
|
||||
>
|
||||
<div style={{ padding: '8px 4px', minWidth: 260, background: 'var(--bg-card)', color: 'var(--fg)' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 4px',
|
||||
minWidth: 260,
|
||||
background: 'var(--bg-card)',
|
||||
color: 'var(--fg)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||||
<span style={{ fontWeight: 800, fontFamily: 'monospace', color: sev.color, fontSize: 13 }}>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 800,
|
||||
fontFamily: 'monospace',
|
||||
color: sev.color,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{sc.id}
|
||||
</span>
|
||||
<span style={{ fontWeight: 700, fontSize: 12 }}>{sc.timeStep}</span>
|
||||
@ -1059,16 +1180,38 @@ function ScenarioMapOverlay({
|
||||
{sev.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--fg-sub)', lineHeight: 1.5, marginBottom: 6 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--fg-sub)',
|
||||
lineHeight: 1.5,
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
{sc.description}
|
||||
</div>
|
||||
{/* KPI */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4, marginBottom: 6 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr 1fr',
|
||||
gap: 4,
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: 'GM', value: `${sc.gm}m`, color: gmColor(parseFloat(sc.gm)) },
|
||||
{ label: '횡경사', value: `${sc.list}°`, color: listColor(parseFloat(sc.list)) },
|
||||
{
|
||||
label: '횡경사',
|
||||
value: `${sc.list}°`,
|
||||
color: listColor(parseFloat(sc.list)),
|
||||
},
|
||||
{ label: '부력', value: `${sc.buoyancy}%`, color: buoyColor(sc.buoyancy) },
|
||||
{ label: '유출', value: sc.oilRate.split(' ')[0], color: oilColor(parseFloat(sc.oilRate)) },
|
||||
{
|
||||
label: '유출',
|
||||
value: sc.oilRate.split(' ')[0],
|
||||
color: oilColor(parseFloat(sc.oilRate)),
|
||||
},
|
||||
].map((m) => (
|
||||
<div
|
||||
key={m.label}
|
||||
@ -1081,14 +1224,23 @@ function ScenarioMapOverlay({
|
||||
}}
|
||||
>
|
||||
<div style={{ color: 'var(--fg-disabled)' }}>{m.label}</div>
|
||||
<div style={{ fontWeight: 700, fontFamily: 'monospace', color: m.color }}>{m.value}</div>
|
||||
<div style={{ fontWeight: 700, fontFamily: 'monospace', color: m.color }}>
|
||||
{m.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 구획 상태 */}
|
||||
{sc.compartments.length > 0 && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--fg-disabled)', marginBottom: 3 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
color: 'var(--fg-disabled)',
|
||||
marginBottom: 3,
|
||||
}}
|
||||
>
|
||||
구획 상태
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
||||
@ -1122,11 +1274,16 @@ function ScenarioMapOverlay({
|
||||
style={{ background: 'rgba(15,23,42,0.92)', width: 280, backdropFilter: 'blur(8px)' }}
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-stroke flex items-center gap-2">
|
||||
<span className="font-bold font-mono text-color-accent text-label-2">{selected.id}</span>
|
||||
<span className="font-bold font-mono text-color-accent text-label-2">
|
||||
{selected.id}
|
||||
</span>
|
||||
<span className="text-caption font-bold">{selected.timeStep}</span>
|
||||
<span
|
||||
className="ml-auto px-1.5 py-0.5 rounded-md text-caption font-bold"
|
||||
style={{ background: SEV_STYLE[selected.severity].bg, color: SEV_STYLE[selected.severity].color }}
|
||||
style={{
|
||||
background: SEV_STYLE[selected.severity].bg,
|
||||
color: SEV_STYLE[selected.severity].color,
|
||||
}}
|
||||
>
|
||||
{SEV_STYLE[selected.severity].label}
|
||||
</span>
|
||||
@ -1134,14 +1291,34 @@ function ScenarioMapOverlay({
|
||||
<div className="px-3 py-2">
|
||||
<div className="grid grid-cols-4 gap-1 font-mono text-caption mb-2">
|
||||
{[
|
||||
{ label: 'GM', value: `${selected.gm}m`, color: gmColor(parseFloat(selected.gm)) },
|
||||
{ label: '횡경사', value: `${selected.list}°`, color: listColor(parseFloat(selected.list)) },
|
||||
{ label: '부력', value: `${selected.buoyancy}%`, color: buoyColor(selected.buoyancy) },
|
||||
{ label: '유출', value: selected.oilRate.split(' ')[0], color: oilColor(parseFloat(selected.oilRate)) },
|
||||
{
|
||||
label: 'GM',
|
||||
value: `${selected.gm}m`,
|
||||
color: gmColor(parseFloat(selected.gm)),
|
||||
},
|
||||
{
|
||||
label: '횡경사',
|
||||
value: `${selected.list}°`,
|
||||
color: listColor(parseFloat(selected.list)),
|
||||
},
|
||||
{
|
||||
label: '부력',
|
||||
value: `${selected.buoyancy}%`,
|
||||
color: buoyColor(selected.buoyancy),
|
||||
},
|
||||
{
|
||||
label: '유출',
|
||||
value: selected.oilRate.split(' ')[0],
|
||||
color: oilColor(parseFloat(selected.oilRate)),
|
||||
},
|
||||
].map((m) => (
|
||||
<div key={m.label} className="text-center p-1 bg-bg-base rounded">
|
||||
<div className="text-fg-disabled" style={{ fontSize: 9 }}>{m.label}</div>
|
||||
<div className="font-bold" style={{ color: m.color, fontSize: 11 }}>{m.value}</div>
|
||||
<div className="text-fg-disabled" style={{ fontSize: 9 }}>
|
||||
{m.label}
|
||||
</div>
|
||||
<div className="font-bold" style={{ color: m.color, fontSize: 11 }}>
|
||||
{m.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -1171,7 +1348,12 @@ function ScenarioMapOverlay({
|
||||
<div className="flex items-center gap-1.5 mt-1 pt-1 border-t border-stroke">
|
||||
<span
|
||||
className="inline-block rounded-full"
|
||||
style={{ width: 8, height: 8, background: 'var(--color-danger)', border: '1px solid var(--color-danger)' }}
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
background: 'var(--color-danger)',
|
||||
border: '1px solid var(--color-danger)',
|
||||
}}
|
||||
/>
|
||||
<span className="text-caption text-fg-sub">사고 위치</span>
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import { Fragment, useState, useEffect, useCallback } from 'react';
|
||||
import { useVesselSignals } from '@common/hooks/useVesselSignals';
|
||||
import type { MapBounds } from '@common/types/vessel';
|
||||
import { useSubMenu } from '@common/hooks/useSubMenu';
|
||||
import { MapView } from '@common/components/map/MapView';
|
||||
import { RescueTheoryView } from './RescueTheoryView';
|
||||
import { RescueScenarioView } from './RescueScenarioView';
|
||||
import { fetchRescueOps } from '../services/rescueApi';
|
||||
import type { RescueOpsItem } from '../services/rescueApi';
|
||||
import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi';
|
||||
import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi';
|
||||
import { fetchGscAccidents } from '@tabs/prediction/services/predictionApi';
|
||||
import type { GscAccidentListItem } from '@tabs/prediction/services/predictionApi';
|
||||
|
||||
/* ─── Types ─── */
|
||||
type AccidentType =
|
||||
@ -230,9 +232,9 @@ function LeftPanel({
|
||||
}: {
|
||||
activeType: AccidentType;
|
||||
onTypeChange: (t: AccidentType) => void;
|
||||
incidents: IncidentListItem[];
|
||||
selectedAcdnt: IncidentListItem | null;
|
||||
onSelectAcdnt: (item: IncidentListItem | null) => void;
|
||||
incidents: GscAccidentListItem[];
|
||||
selectedAcdnt: GscAccidentListItem | null;
|
||||
onSelectAcdnt: (item: GscAccidentListItem | null) => void;
|
||||
}) {
|
||||
const [acdntName, setAcdntName] = useState('');
|
||||
const [acdntDate, setAcdntDate] = useState('');
|
||||
@ -242,18 +244,25 @@ function LeftPanel({
|
||||
const [showList, setShowList] = useState(false);
|
||||
|
||||
// 사고 선택 시 필드 자동 채움
|
||||
const handlePickIncident = (item: IncidentListItem) => {
|
||||
const handlePickIncident = (item: GscAccidentListItem) => {
|
||||
onSelectAcdnt(item);
|
||||
setAcdntName(item.acdntNm);
|
||||
const dt = new Date(item.occrnDtm);
|
||||
setAcdntDate(
|
||||
`${dt.getFullYear()}. ${String(dt.getMonth() + 1).padStart(2, '0')}. ${String(dt.getDate()).padStart(2, '0')}.`,
|
||||
);
|
||||
setAcdntTime(
|
||||
`${dt.getHours() >= 12 ? '오후' : '오전'} ${String(dt.getHours() % 12 || 12).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`,
|
||||
);
|
||||
setAcdntLat(String(item.lat));
|
||||
setAcdntLon(String(item.lng));
|
||||
setAcdntName(item.pollNm);
|
||||
if (item.pollDate) {
|
||||
const [d, t] = item.pollDate.split('T');
|
||||
if (d) {
|
||||
const [y, m, day] = d.split('-');
|
||||
setAcdntDate(`${y}. ${m}. ${day}.`);
|
||||
}
|
||||
if (t) {
|
||||
const [hhStr, mmStr] = t.split(':');
|
||||
const hh = parseInt(hhStr, 10);
|
||||
const ampm = hh >= 12 ? '오후' : '오전';
|
||||
const hh12 = String(hh % 12 || 12).padStart(2, '0');
|
||||
setAcdntTime(`${ampm} ${hh12}:${mmStr}`);
|
||||
}
|
||||
}
|
||||
if (item.lat != null) setAcdntLat(String(item.lat));
|
||||
if (item.lon != null) setAcdntLon(String(item.lon));
|
||||
setShowList(false);
|
||||
};
|
||||
|
||||
@ -283,7 +292,7 @@ function LeftPanel({
|
||||
className="w-full px-2 py-1.5 text-caption bg-bg-card border border-stroke rounded font-korean text-left cursor-pointer hover:border-[var(--stroke-light)] flex items-center justify-between"
|
||||
>
|
||||
<span className={selectedAcdnt ? 'text-fg' : 'text-fg-disabled/50'}>
|
||||
{selectedAcdnt ? selectedAcdnt.acdntCd : '또는 사고 리스트에서 선택'}
|
||||
{selectedAcdnt ? selectedAcdnt.pollNm : '또는 사고 리스트에서 선택'}
|
||||
</span>
|
||||
<span className="text-fg-disabled text-[10px]">{showList ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
@ -296,13 +305,13 @@ function LeftPanel({
|
||||
)}
|
||||
{incidents.map((item) => (
|
||||
<button
|
||||
key={item.acdntSn}
|
||||
key={item.acdntMngNo}
|
||||
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"
|
||||
>
|
||||
<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]">
|
||||
{item.acdntCd} · {item.regionNm}
|
||||
{item.pollDate ? item.pollDate.replace('T', ' ') : '-'}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@ -1523,13 +1532,16 @@ export function RescueView() {
|
||||
const { activeSubTab } = useSubMenu('rescue');
|
||||
const [activeType, setActiveType] = useState<AccidentType>('collision');
|
||||
const [activeAnalysis, setActiveAnalysis] = useState<AnalysisTab>('rescue');
|
||||
const [incidents, setIncidents] = useState<IncidentListItem[]>([]);
|
||||
const [selectedAcdnt, setSelectedAcdnt] = useState<IncidentListItem | null>(null);
|
||||
const [incidents, setIncidents] = useState<GscAccidentListItem[]>([]);
|
||||
const [selectedAcdnt, setSelectedAcdnt] = useState<GscAccidentListItem | 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 [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
||||
const vessels = useVesselSignals(mapBounds);
|
||||
|
||||
useEffect(() => {
|
||||
fetchIncidentsRaw()
|
||||
fetchGscAccidents()
|
||||
.then((items) => setIncidents(items))
|
||||
.catch(() => setIncidents([]));
|
||||
}, []);
|
||||
@ -1540,23 +1552,13 @@ export function RescueView() {
|
||||
setIsSelectingLocation(false);
|
||||
}, []);
|
||||
|
||||
// 사고 선택 시 사고유형 자동 매핑
|
||||
// 사고 선택 시 좌표 자동 반영 + 지도 이동
|
||||
const handleSelectAcdnt = useCallback(
|
||||
(item: IncidentListItem | null) => {
|
||||
(item: GscAccidentListItem | null) => {
|
||||
setSelectedAcdnt(item);
|
||||
if (item) {
|
||||
const typeMap: Record<string, AccidentType> = {
|
||||
collision: 'collision',
|
||||
grounding: 'grounding',
|
||||
turning: 'turning',
|
||||
capsizing: 'capsizing',
|
||||
sharpTurn: 'sharpTurn',
|
||||
flooding: 'flooding',
|
||||
sinking: 'sinking',
|
||||
};
|
||||
const mapped = typeMap[item.acdntTpCd];
|
||||
if (mapped) setActiveType(mapped);
|
||||
setIncidentCoord({ lon: item.lng, lat: item.lat });
|
||||
if (item && item.lat != null && item.lon != null) {
|
||||
setIncidentCoord({ lon: item.lon, lat: item.lat });
|
||||
setFlyToCoord({ lon: item.lon, lat: item.lat });
|
||||
}
|
||||
},
|
||||
[],
|
||||
@ -1595,11 +1597,15 @@ export function RescueView() {
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<MapView
|
||||
incidentCoord={incidentCoord ?? undefined}
|
||||
flyToIncident={flyToCoord}
|
||||
onIncidentFlyEnd={() => setFlyToCoord(undefined)}
|
||||
isSelectingLocation={isSelectingLocation}
|
||||
onMapClick={handleMapClick}
|
||||
oilTrajectory={[]}
|
||||
enabledLayers={new Set()}
|
||||
showOverlays={false}
|
||||
vessels={vessels}
|
||||
onBoundsChange={setMapBounds}
|
||||
/>
|
||||
</div>
|
||||
<RightPanel
|
||||
|
||||
@ -2,7 +2,7 @@ import { useState, useEffect, useRef, type CSSProperties, type ReactElement } fr
|
||||
import { List } from 'react-window';
|
||||
import type { ScatSegment } from './scatTypes';
|
||||
import type { ApiZoneItem } from '../services/scatApi';
|
||||
import { esiColor, sensColor, statusColor, esiLevel } from './scatConstants';
|
||||
import { esiLevel } from './scatConstants';
|
||||
|
||||
interface ScatLeftPanelProps {
|
||||
segments: ScatSegment[];
|
||||
@ -71,8 +71,8 @@ function SegRow(
|
||||
📍 {seg.code} {seg.area}
|
||||
</span>
|
||||
<span
|
||||
className="text-caption font-bold px-1.5 py-0.5 rounded-lg text-white"
|
||||
style={{ background: esiColor(seg.esiNum) }}
|
||||
className="text-caption font-semibold px-1.5 py-0.5 rounded-lg text-color-accent border border-stroke-light"
|
||||
// style={{ background: esiColor(seg.esiNum) }}
|
||||
>
|
||||
ESI {seg.esi}
|
||||
</span>
|
||||
@ -89,8 +89,8 @@ function SegRow(
|
||||
<div className="flex justify-between text-label-2">
|
||||
<span className="text-fg-sub font-korean">민감</span>
|
||||
<span
|
||||
className="font-medium font-mono text-label-2"
|
||||
style={{ color: sensColor[seg.sensitivity] }}
|
||||
className="font-medium font-mono text-label-2 text-fg-default"
|
||||
// style={{ color: sensColor[seg.sensitivity] }}
|
||||
>
|
||||
{seg.sensitivity}
|
||||
</span>
|
||||
@ -98,8 +98,9 @@ function SegRow(
|
||||
<div className="flex justify-between text-label-2">
|
||||
<span className="text-fg-sub font-korean">현황</span>
|
||||
<span
|
||||
className="font-medium font-mono text-label-2"
|
||||
style={{ color: statusColor[seg.status] }}
|
||||
className={`font-medium font-mono text-label-2 ${
|
||||
seg.status === '미조사' ? 'text-fg-disabled' : 'text-fg-default'
|
||||
}`}
|
||||
>
|
||||
{seg.status}
|
||||
</span>
|
||||
@ -160,7 +161,7 @@ function ScatLeftPanel({
|
||||
{/* Filters */}
|
||||
<div className="p-3.5 border-b border-stroke">
|
||||
<div className="flex items-center gap-1.5 text-caption font-bold uppercase tracking-wider text-fg mb-3">
|
||||
<span className="w-[3px] h-2.5 bg-color-success rounded-sm" />
|
||||
<span className="w-[3px] h-2.5 bg-color-accent rounded-sm" />
|
||||
해안 조사 구역
|
||||
</div>
|
||||
|
||||
@ -257,12 +258,10 @@ function ScatLeftPanel({
|
||||
<div className="flex-1 flex flex-col overflow-hidden p-3.5 pt-2">
|
||||
<div className="flex items-center justify-between text-caption font-bold uppercase tracking-wider text-fg mb-2.5">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-[3px] h-2.5 bg-color-success rounded-sm" />
|
||||
<span className="w-[3px] h-2.5 bg-color-accent rounded-sm" />
|
||||
해안 구간 목록
|
||||
</span>
|
||||
<span className="text-color-accent font-mono text-caption">
|
||||
총 {filtered.length}개 구간
|
||||
</span>
|
||||
<span className="font-mono text-caption">총 {filtered.length}개 구간</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden" ref={listContainerRef}>
|
||||
<List<SegRowData>
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { Map, useControl, useMap } from '@vis.gl/react-maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { useMap } from '@vis.gl/react-maplibre';
|
||||
import { PathLayer, ScatterplotLayer } from '@deck.gl/layers';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||||
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
|
||||
import { useMapStore } from '@common/store/mapStore';
|
||||
import { BaseMap } from '@common/components/map/BaseMap';
|
||||
import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay';
|
||||
import type { ScatSegment } from './scatTypes';
|
||||
import type { ApiZoneItem } from '../services/scatApi';
|
||||
import { esiColor } from './scatConstants';
|
||||
@ -20,16 +17,9 @@ interface ScatMapProps {
|
||||
onOpenPopup: (idx: number) => void;
|
||||
}
|
||||
|
||||
// ── DeckGLOverlay ──────────────────────────────────────
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
||||
overlay.setProps({ layers });
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── FlyTo Controller: 선택 구간 변경 시 맵 이동 ─────────
|
||||
function FlyToController({
|
||||
// ── FlyTo: 선택 구간·관할해경 변경 시 맵 이동 ──────────────
|
||||
// 두 가지 트리거를 독립적으로 처리해 공통 FlyToController로 통합 불가
|
||||
function ScatFlyToController({
|
||||
selectedSeg,
|
||||
zones,
|
||||
}: {
|
||||
@ -40,7 +30,7 @@ function FlyToController({
|
||||
const prevIdRef = useRef<number | undefined>(undefined);
|
||||
const prevZonesLenRef = useRef<number>(0);
|
||||
|
||||
// 선택 구간 변경 시
|
||||
// 선택 구간 변경 시 이동 (첫 렌더 제외)
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedSeg.id) {
|
||||
@ -49,7 +39,7 @@ function FlyToController({
|
||||
prevIdRef.current = selectedSeg.id;
|
||||
}, [map, selectedSeg]);
|
||||
|
||||
// 관할해경(zones) 변경 시 지도 중심 이동
|
||||
// 관할해경(zones) 변경 시 중심 이동
|
||||
useEffect(() => {
|
||||
if (!map || zones.length === 0) return;
|
||||
if (prevZonesLenRef.current === zones.length) return;
|
||||
@ -72,13 +62,11 @@ function getZoomScale(zoom: number) {
|
||||
selPolyWidth: 2 + zScale * 5,
|
||||
glowWidth: 4 + zScale * 14,
|
||||
halfLenScale: 0.15 + zScale * 0.85,
|
||||
markerRadius: Math.round(6 + zScale * 16),
|
||||
showStatusMarker: zoom >= 11,
|
||||
dotRadius: Math.round(4 + zScale * 10),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 세그먼트 폴리라인 좌표 생성 (lng, lat 순서) ──────────
|
||||
// 인접 구간 좌표로 해안선 방향을 동적 계산
|
||||
function buildSegCoords(
|
||||
seg: ScatSegment,
|
||||
halfLenScale: number,
|
||||
@ -100,7 +88,6 @@ function buildSegCoords(
|
||||
];
|
||||
}
|
||||
|
||||
// ── 툴팁 상태 ───────────────────────────────────────────
|
||||
interface TooltipState {
|
||||
x: number;
|
||||
y: number;
|
||||
@ -116,12 +103,19 @@ function ScatMap({
|
||||
onSelectSeg,
|
||||
onOpenPopup,
|
||||
}: ScatMapProps) {
|
||||
const currentMapStyle = useBaseMapStyle();
|
||||
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||
|
||||
const [zoom, setZoom] = useState(10);
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
|
||||
// zones 첫 렌더 기준으로 초기 중심 좌표 결정 (이후 불변)
|
||||
const [initialCenter] = useState<[number, number]>(() =>
|
||||
zones.length > 0
|
||||
? [
|
||||
zones.reduce((a, z) => a + z.latCenter, 0) / zones.length,
|
||||
zones.reduce((a, z) => a + z.lngCenter, 0) / zones.length,
|
||||
]
|
||||
: [33.38, 126.55],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(seg: ScatSegment) => {
|
||||
onSelectSeg(seg);
|
||||
@ -132,23 +126,6 @@ function ScatMap({
|
||||
|
||||
const zs = useMemo(() => getZoomScale(zoom), [zoom]);
|
||||
|
||||
// 제주도 해안선 레퍼런스 라인 — 하드코딩 제거, 추후 DB 기반 해안선으로 대체 예정
|
||||
// const coastlineLayer = useMemo(
|
||||
// () =>
|
||||
// new PathLayer({
|
||||
// id: 'jeju-coastline',
|
||||
// data: [{ path: jejuCoastCoords.map(([lat, lng]) => [lng, lat] as [number, number]) }],
|
||||
// getPath: (d: { path: [number, number][] }) => d.path,
|
||||
// getColor: [6, 182, 212, 46],
|
||||
// getWidth: 1.5,
|
||||
// getDashArray: [8, 6],
|
||||
// dashJustified: true,
|
||||
// widthMinPixels: 1,
|
||||
// }),
|
||||
// [],
|
||||
// )
|
||||
|
||||
// 선택된 구간 글로우 레이어
|
||||
const glowLayer = useMemo(
|
||||
() =>
|
||||
new PathLayer({
|
||||
@ -168,7 +145,6 @@ function ScatMap({
|
||||
[selectedSeg, segments, zs.glowWidth, zs.halfLenScale],
|
||||
);
|
||||
|
||||
// ESI 색상 세그먼트 폴리라인
|
||||
const segPathLayer = useMemo(
|
||||
() =>
|
||||
new PathLayer({
|
||||
@ -183,14 +159,11 @@ function ScatMap({
|
||||
getWidth: (d: ScatSegment) => (selectedSeg.id === d.id ? zs.selPolyWidth : zs.polyWidth),
|
||||
capRounded: true,
|
||||
jointRounded: true,
|
||||
widthMinPixels: 1,
|
||||
widthMinPixels: 2,
|
||||
pickable: true,
|
||||
onHover: (info: { object?: ScatSegment; x: number; y: number }) => {
|
||||
if (info.object) {
|
||||
setTooltip({ x: info.x, y: info.y, seg: info.object });
|
||||
} else {
|
||||
setTooltip(null);
|
||||
}
|
||||
if (info.object) setTooltip({ x: info.x, y: info.y, seg: info.object });
|
||||
else setTooltip(null);
|
||||
},
|
||||
onClick: (info: { object?: ScatSegment }) => {
|
||||
if (info.object) handleClick(info.object);
|
||||
@ -204,46 +177,58 @@ function ScatMap({
|
||||
[segments, selectedSeg, zs, handleClick],
|
||||
);
|
||||
|
||||
// 조사 상태 마커 (줌 >= 11 시 표시)
|
||||
const markerLayer = useMemo(() => {
|
||||
if (!zs.showStatusMarker) return null;
|
||||
return new ScatterplotLayer({
|
||||
id: 'scat-status-markers',
|
||||
data: segments,
|
||||
getPosition: (d: ScatSegment) => [d.lng, d.lat],
|
||||
getRadius: zs.markerRadius,
|
||||
getFillColor: (d: ScatSegment) => {
|
||||
if (d.status === '완료') return [34, 197, 94, 51];
|
||||
if (d.status === '진행중') return [234, 179, 8, 51];
|
||||
return [100, 116, 139, 51];
|
||||
},
|
||||
getLineColor: (d: ScatSegment) => {
|
||||
if (d.status === '완료') return [34, 197, 94, 200];
|
||||
if (d.status === '진행중') return [234, 179, 8, 200];
|
||||
return [100, 116, 139, 200];
|
||||
},
|
||||
getLineWidth: 1,
|
||||
stroked: true,
|
||||
radiusMinPixels: 4,
|
||||
radiusMaxPixels: 22,
|
||||
radiusUnits: 'pixels',
|
||||
pickable: true,
|
||||
onClick: (info: { object?: ScatSegment }) => {
|
||||
if (info.object) handleClick(info.object);
|
||||
},
|
||||
updateTriggers: {
|
||||
getRadius: [zs.markerRadius],
|
||||
},
|
||||
});
|
||||
}, [segments, zs.showStatusMarker, zs.markerRadius, handleClick]);
|
||||
const shadowDotLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<ScatSegment>({
|
||||
id: 'scat-shadow-dots',
|
||||
data: segments,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: zs.dotRadius + 2,
|
||||
getFillColor: [0, 0, 0, 70],
|
||||
stroked: false,
|
||||
radiusUnits: 'pixels',
|
||||
radiusMinPixels: 7,
|
||||
radiusMaxPixels: 18,
|
||||
pickable: false,
|
||||
updateTriggers: { getRadius: [zs.dotRadius] },
|
||||
}),
|
||||
[segments, zs.dotRadius],
|
||||
);
|
||||
|
||||
const dotLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<ScatSegment>({
|
||||
id: 'scat-dots',
|
||||
data: segments,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: zs.dotRadius,
|
||||
getFillColor: (d) => {
|
||||
if (d.status === '완료') return [34, 197, 94, 210];
|
||||
if (d.status === '진행중') return [234, 179, 8, 210];
|
||||
return [148, 163, 184, 200];
|
||||
},
|
||||
stroked: false,
|
||||
radiusUnits: 'pixels',
|
||||
radiusMinPixels: 5,
|
||||
radiusMaxPixels: 16,
|
||||
pickable: true,
|
||||
onHover: (info: { object?: ScatSegment; x: number; y: number }) => {
|
||||
if (info.object) setTooltip({ x: info.x, y: info.y, seg: info.object });
|
||||
else setTooltip(null);
|
||||
},
|
||||
onClick: (info: { object?: ScatSegment }) => {
|
||||
if (info.object) handleClick(info.object);
|
||||
},
|
||||
updateTriggers: { getRadius: [zs.dotRadius] },
|
||||
}),
|
||||
[segments, zs.dotRadius, handleClick],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const deckLayers: any[] = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const layers: any[] = [glowLayer, segPathLayer];
|
||||
if (markerLayer) layers.push(markerLayer);
|
||||
return layers;
|
||||
}, [glowLayer, segPathLayer, markerLayer]);
|
||||
const deckLayers: any[] = useMemo(
|
||||
() => [glowLayer, segPathLayer, shadowDotLayer, dotLayer],
|
||||
[glowLayer, segPathLayer, shadowDotLayer, dotLayer],
|
||||
);
|
||||
|
||||
const totalLen = segments.reduce((a, s) => a + s.lengthM, 0);
|
||||
const doneLen = segments.filter((s) => s.status === '완료').reduce((a, s) => a + s.lengthM, 0);
|
||||
@ -253,24 +238,10 @@ function ScatMap({
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<Map
|
||||
initialViewState={(() => {
|
||||
if (zones.length > 0) {
|
||||
const avgLng = zones.reduce((a, z) => a + z.lngCenter, 0) / zones.length;
|
||||
const avgLat = zones.reduce((a, z) => a + z.latCenter, 0) / zones.length;
|
||||
return { longitude: avgLng, latitude: avgLat, zoom: 10 };
|
||||
}
|
||||
return { longitude: 126.55, latitude: 33.38, zoom: 10 };
|
||||
})()}
|
||||
mapStyle={currentMapStyle}
|
||||
className="w-full h-full"
|
||||
attributionControl={false}
|
||||
onZoom={(e) => setZoom(e.viewState.zoom)}
|
||||
>
|
||||
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||
<BaseMap center={initialCenter} zoom={10} onZoom={setZoom}>
|
||||
<DeckGLOverlay layers={deckLayers} />
|
||||
<FlyToController selectedSeg={selectedSeg} zones={zones} />
|
||||
</Map>
|
||||
<ScatFlyToController selectedSeg={selectedSeg} zones={zones} />
|
||||
</BaseMap>
|
||||
|
||||
{/* 호버 툴팁 */}
|
||||
{tooltip && (
|
||||
@ -287,11 +258,9 @@ function ScatMap({
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<div className="font-bold">
|
||||
{tooltip.seg.code} {tooltip.seg.area}
|
||||
</div>
|
||||
<div className="font-bold font-korean">{tooltip.seg.name}</div>
|
||||
<div className="text-caption opacity-70">
|
||||
ESI {tooltip.seg.esi} · {tooltip.seg.length} ·{' '}
|
||||
{tooltip.seg.code} · ESI {tooltip.seg.esi} ·{' '}
|
||||
{tooltip.seg.status === '완료' ? '✓' : tooltip.seg.status === '진행중' ? '⏳' : '—'}{' '}
|
||||
{tooltip.seg.status}
|
||||
</div>
|
||||
@ -301,7 +270,7 @@ function ScatMap({
|
||||
{/* Status chips */}
|
||||
<div className="absolute top-3.5 left-3.5 flex gap-2 z-[1000]">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-full text-label-2 text-fg-sub font-korean">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-color-success shadow-[0_0_6px_var(--color-success)]" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-color-accent shadow-[0_0_6px_var(--color-accent)]" />
|
||||
Pre-SCAT 사전조사
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-full text-label-2 text-fg-sub font-korean">
|
||||
@ -342,25 +311,6 @@ function ScatMap({
|
||||
<div className="text-caption font-bold uppercase tracking-wider text-fg-disabled mb-2.5">
|
||||
조사 정보
|
||||
</div>
|
||||
{/* <div className="flex gap-0.5 h-3 rounded overflow-hidden mb-1">
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{ width: `${donePct}%`, background: 'var(--color-success)' }}
|
||||
/>
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{ width: `${progPct}%`, background: 'var(--color-warning)' }}
|
||||
/>
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{ width: `${notPct}%`, background: 'var(--stroke-default)' }}
|
||||
/>
|
||||
</div> */}
|
||||
{/* <div className="flex justify-between mt-1">
|
||||
<span className="text-caption font-mono text-color-success">완료 {donePct}%</span>
|
||||
<span className="text-caption font-mono text-color-warning">진행 {progPct}%</span>
|
||||
<span className="text-caption font-mono text-fg-disabled">미조사 {notPct}%</span>
|
||||
</div> */}
|
||||
<div className="mt-2.5">
|
||||
{[
|
||||
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
|
||||
@ -388,19 +338,6 @@ function ScatMap({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coordinates */}
|
||||
{/* <div className="absolute bottom-3 left-3 z-[1000] bg-[rgba(18,25,41,0.85)] backdrop-blur-xl border border-stroke rounded-sm px-3 py-1.5 font-mono text-label-2 text-fg-sub flex gap-3.5">
|
||||
<span>
|
||||
위도 <span className="text-color-success font-medium">{selectedSeg.lat.toFixed(4)}°N</span>
|
||||
</span>
|
||||
<span>
|
||||
경도 <span className="text-color-success font-medium">{selectedSeg.lng.toFixed(4)}°E</span>
|
||||
</span>
|
||||
<span>
|
||||
축척 <span className="text-color-success font-medium">1:25,000</span>
|
||||
</span>
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -70,7 +70,8 @@ export default function ScatRightPanel({
|
||||
: 'text-fg-disabled hover:text-fg-sub'
|
||||
}`}
|
||||
>
|
||||
{tab.icon} {tab.label}
|
||||
{/* {tab.icon} */}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Map, Marker, useControl } from '@vis.gl/react-maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import type { MapLayerMouseEvent } from 'maplibre-gl';
|
||||
import { Marker } from '@vis.gl/react-maplibre';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||||
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
|
||||
import { useMapStore } from '@common/store/mapStore';
|
||||
import { BaseMap } from '@common/components/map/BaseMap';
|
||||
import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay';
|
||||
import { WeatherRightPanel } from './WeatherRightPanel';
|
||||
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay';
|
||||
// import { OceanForecastOverlay } from './OceanForecastOverlay'
|
||||
@ -16,7 +12,6 @@ import { WindParticleLayer } from './WindParticleLayer';
|
||||
import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer';
|
||||
import { useWeatherData } from '../hooks/useWeatherData';
|
||||
// import { useOceanForecast } from '../hooks/useOceanForecast'
|
||||
import { WeatherMapControls } from './WeatherMapControls';
|
||||
import { degreesToCardinal } from '../services/weatherUtils';
|
||||
|
||||
type TimeOffset = '0' | '3' | '6' | '9';
|
||||
@ -89,13 +84,6 @@ const generateForecast = (timeOffset: TimeOffset): WeatherForecast[] => {
|
||||
const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5]; // [lng, lat]
|
||||
const WEATHER_MAP_ZOOM = 7;
|
||||
|
||||
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록)
|
||||
function DeckGLOverlay({ layers }: { layers: Layer[] }) {
|
||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
||||
overlay.setProps({ layers });
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* WeatherMapInner — Map 컴포넌트 내부 (useMap / useControl 사용 가능 영역)
|
||||
*/
|
||||
@ -104,8 +92,6 @@ interface WeatherMapInnerProps {
|
||||
enabledLayers: Set<string>;
|
||||
selectedStationId: string | null;
|
||||
onStationClick: (station: WeatherStation) => void;
|
||||
mapCenter: [number, number];
|
||||
mapZoom: number;
|
||||
clickedLocation: { lat: number; lon: number } | null;
|
||||
}
|
||||
|
||||
@ -114,8 +100,6 @@ function WeatherMapInner({
|
||||
enabledLayers,
|
||||
selectedStationId,
|
||||
onStationClick,
|
||||
mapCenter,
|
||||
mapZoom,
|
||||
clickedLocation,
|
||||
}: WeatherMapInnerProps) {
|
||||
// deck.gl layers 조합
|
||||
@ -183,17 +167,12 @@ function WeatherMapInner({
|
||||
</div>
|
||||
</Marker>
|
||||
)}
|
||||
|
||||
{/* 줌 컨트롤 */}
|
||||
<WeatherMapControls center={mapCenter} zoom={mapZoom} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function WeatherView() {
|
||||
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS);
|
||||
const currentMapStyle = useBaseMapStyle();
|
||||
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||
|
||||
// const {
|
||||
// selectedForecast,
|
||||
@ -220,8 +199,7 @@ export function WeatherView() {
|
||||
}, []);
|
||||
|
||||
const handleMapClick = useCallback(
|
||||
(e: MapLayerMouseEvent) => {
|
||||
const { lat, lng } = e.lngLat;
|
||||
(lng: number, lat: number) => {
|
||||
if (weatherStations.length === 0) return;
|
||||
|
||||
// 가장 가까운 관측소 선택
|
||||
@ -331,28 +309,19 @@ export function WeatherView() {
|
||||
|
||||
{/* Map */}
|
||||
<div className="flex-1 relative">
|
||||
<Map
|
||||
initialViewState={{
|
||||
longitude: WEATHER_MAP_CENTER[0],
|
||||
latitude: WEATHER_MAP_CENTER[1],
|
||||
zoom: WEATHER_MAP_ZOOM,
|
||||
}}
|
||||
mapStyle={currentMapStyle}
|
||||
className="w-full h-full"
|
||||
onClick={handleMapClick}
|
||||
attributionControl={false}
|
||||
<BaseMap
|
||||
center={[WEATHER_MAP_CENTER[1], WEATHER_MAP_CENTER[0]]}
|
||||
zoom={WEATHER_MAP_ZOOM}
|
||||
onMapClick={handleMapClick}
|
||||
>
|
||||
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||
<WeatherMapInner
|
||||
weatherStations={weatherStations}
|
||||
enabledLayers={enabledLayers}
|
||||
selectedStationId={selectedStation?.id || null}
|
||||
onStationClick={handleStationClick}
|
||||
mapCenter={WEATHER_MAP_CENTER}
|
||||
mapZoom={WEATHER_MAP_ZOOM}
|
||||
clickedLocation={selectedLocation}
|
||||
/>
|
||||
</Map>
|
||||
</BaseMap>
|
||||
|
||||
{/* 레이어 컨트롤 */}
|
||||
<div className="absolute top-4 left-4 bg-bg-surface border border-stroke rounded-md shadow-md z-10 px-2.5 py-1.5">
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user