Merge pull request 'release: 2026-04-15' (#176) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 38s

This commit is contained in:
jhkang 2026-04-15 14:49:19 +09:00
커밋 988cc47e9f
58개의 변경된 파일3926개의 추가작업 그리고 2984개의 파일을 삭제

파일 보기

@ -5,7 +5,30 @@
}, },
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(*)" "Bash(*)",
"Bash(npm run *)",
"Bash(npm install *)",
"Bash(npm test *)",
"Bash(npx *)",
"Bash(node *)",
"Bash(git status)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(git branch *)",
"Bash(git checkout *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git pull *)",
"Bash(git fetch *)",
"Bash(git merge *)",
"Bash(git stash *)",
"Bash(git remote *)",
"Bash(git config *)",
"Bash(git rev-parse *)",
"Bash(git show *)",
"Bash(git tag *)",
"Bash(curl -s *)",
"Bash(fnm *)"
], ],
"deny": [ "deny": [
"Bash(git push --force*)", "Bash(git push --force*)",

파일 보기

@ -1,6 +1,6 @@
{ {
"applied_global_version": "1.6.1", "applied_global_version": "1.6.1",
"applied_date": "2026-03-31", "applied_date": "2026-04-14",
"project_type": "react-ts", "project_type": "react-ts",
"gitea_url": "https://gitea.gc-si.dev", "gitea_url": "https://gitea.gc-si.dev",
"custom_pre_commit": true "custom_pre_commit": true

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -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;

파일 보기

@ -0,0 +1,65 @@
import { wingPool } from '../db/wingDb.js';
export interface GscAccidentListItem {
acdntMngNo: string;
pollNm: string;
pollDate: string | null;
lat: number | null;
lon: number | null;
}
const ACDNT_ASORT_CODES = [
'055001001',
'055001002',
'055001003',
'055001004',
'055001005',
'055001006',
'055003001',
'055003002',
'055003003',
'055003004',
'055003005',
'055004003',
];
export async function listGscAccidents(limit = 20): Promise<GscAccidentListItem[]> {
const sql = `
SELECT DISTINCT ON (a.acdnt_mng_no)
a.acdnt_mng_no AS "acdntMngNo",
a.acdnt_title AS "pollNm",
to_char(a.rcept_dt, 'YYYY-MM-DD"T"HH24:MI') AS "pollDate",
a.rcept_dt AS "rceptDt",
b.la AS "lat",
b.lo AS "lon"
FROM gsc.tgs_acdnt_info AS a
LEFT JOIN gsc.tgs_acdnt_lc AS b
ON a.acdnt_mng_no = b.acdnt_mng_no
WHERE a.acdnt_asort_code = ANY($1::varchar[])
AND a.acdnt_title IS NOT NULL
ORDER BY a.acdnt_mng_no, b.acdnt_lc_sn ASC
`;
const orderedSql = `
SELECT "acdntMngNo", "pollNm", "pollDate", "lat", "lon"
FROM (${sql}) t
ORDER BY t."rceptDt" DESC NULLS LAST
LIMIT $2
`;
const result = await wingPool.query<{
acdntMngNo: string;
pollNm: string;
pollDate: string | null;
lat: string | null;
lon: string | null;
}>(orderedSql, [ACDNT_ASORT_CODES, limit]);
return result.rows.map((row) => ({
acdntMngNo: row.acdntMngNo,
pollNm: row.pollNm,
pollDate: row.pollDate,
lat: row.lat != null ? Number(row.lat) : null,
lon: row.lon != null ? Number(row.lon) : null,
}));
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -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>
);
}

파일 보기

@ -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;
}

파일 보기

@ -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;
}

파일 보기

@ -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 { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'; import { Map, Marker, Popup, Source, Layer, useMap } from '@vis.gl/react-maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { import {
ScatterplotLayer, ScatterplotLayer,
PathLayer, PathLayer,
@ -28,8 +27,19 @@ import { useMeasureTool } from '@common/hooks/useMeasureTool';
import { hexToRgba } from './mapUtils'; import { hexToRgba } from './mapUtils';
import { S57EncOverlay } from './S57EncOverlay'; import { S57EncOverlay } from './S57EncOverlay';
import { SrOverlay } from './SrOverlay'; import { SrOverlay } from './SrOverlay';
import { DeckGLOverlay } from './DeckGLOverlay';
import { FlyToController } from './FlyToController';
import { useMapStore } from '@common/store/mapStore'; import { useMapStore } from '@common/store/mapStore';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { buildVesselLayers } from './VesselLayer';
import { MapBoundsTracker } from './MapBoundsTracker';
import {
VesselHoverTooltip,
VesselPopupPanel,
VesselDetailModal,
type VesselHoverInfo,
} from './VesselInteraction';
import type { VesselPosition, MapBounds } from '@common/types/vessel';
const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080'; const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080';
@ -126,6 +136,7 @@ interface MapViewProps {
dispersionResult?: DispersionResult | null; dispersionResult?: DispersionResult | null;
dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>; dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>;
boomLines?: BoomLine[]; boomLines?: BoomLine[];
showBoomLines?: boolean;
isDrawingBoom?: boolean; isDrawingBoom?: boolean;
drawingPoints?: BoomLineCoord[]; drawingPoints?: BoomLineCoord[];
layerOpacity?: number; layerOpacity?: number;
@ -163,33 +174,15 @@ interface MapViewProps {
analysisCircleRadiusM?: number; analysisCircleRadiusM?: number;
/** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */ /** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */
showOverlays?: boolean; showOverlays?: boolean;
/** 선박 신호 목록 (실시간 표출) */
vessels?: VesselPosition[];
/** 지도 뷰포트 bounds 변경 콜백 (선박 신호 필터링에 사용) */
onBoundsChange?: (bounds: MapBounds) => void;
} }
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved) // DeckGLOverlay, FlyToController → @common/components/map/DeckGLOverlay, FlyToController 에서 import
// 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 트리거 컴포넌트 (Map 내부에서 useMap() 사용) // MapBoundsTracker는 './MapBoundsTracker' 모듈로 추출됨 (IncidentsView 등 BaseMap 직접 사용처에서도 재사용)
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;
}
// fitBounds 트리거 컴포넌트 (Map 내부에서 useMap() 사용) // fitBounds 트리거 컴포넌트 (Map 내부에서 useMap() 사용)
function FitBoundsController({ function FitBoundsController({
@ -341,6 +334,7 @@ export function MapView({
dispersionResult = null, dispersionResult = null,
dispersionHeatmap = [], dispersionHeatmap = [],
boomLines = [], boomLines = [],
showBoomLines = true,
isDrawingBoom = false, isDrawingBoom = false,
drawingPoints = [], drawingPoints = [],
layerOpacity = 50, layerOpacity = 50,
@ -368,6 +362,8 @@ export function MapView({
analysisCircleCenter, analysisCircleCenter,
analysisCircleRadiusM = 0, analysisCircleRadiusM = 0,
showOverlays = true, showOverlays = true,
vessels = [],
onBoundsChange,
}: MapViewProps) { }: MapViewProps) {
const lightMode = true; const lightMode = true;
const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore(); const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore();
@ -386,6 +382,10 @@ export function MapView({
const persistentPopupRef = useRef(false); const persistentPopupRef = useRef(false);
// 현재 호버 중인 민감자원 feature properties (handleMapClick에서 팝업 생성에 사용) // 현재 호버 중인 민감자원 feature properties (handleMapClick에서 팝업 생성에 사용)
const hoveredSensitiveRef = useRef<Record<string, unknown> | null>(null); const hoveredSensitiveRef = useRef<Record<string, unknown> | null>(null);
// 선박 호버/클릭 상호작용 상태
const [vesselHover, setVesselHover] = useState<VesselHoverInfo | null>(null);
const [selectedVessel, setSelectedVessel] = useState<VesselPosition | null>(null);
const [detailVessel, setDetailVessel] = useState<VesselPosition | null>(null);
const currentTime = isControlled ? externalCurrentTime : internalCurrentTime; const currentTime = isControlled ? externalCurrentTime : internalCurrentTime;
const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => { const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => {
@ -587,7 +587,7 @@ export function MapView({
} }
// --- 오일펜스 라인 (PathLayer) --- // --- 오일펜스 라인 (PathLayer) ---
if (boomLines.length > 0) { if (showBoomLines && boomLines.length > 0) {
result.push( result.push(
new PathLayer({ new PathLayer({
id: 'boom-lines', id: 'boom-lines',
@ -1237,12 +1237,30 @@ export function MapView({
// 거리/면적 측정 레이어 // 거리/면적 측정 레이어
result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements)); result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements));
// 선박 신호 레이어
result.push(
...buildVesselLayers(
vessels,
{
onClick: (vessel) => {
setSelectedVessel(vessel);
setDetailVessel(null);
},
onHover: (vessel, x, y) => {
setVesselHover(vessel ? { x, y, vessel } : null);
},
},
mapZoom,
),
);
return result.filter(Boolean); return result.filter(Boolean);
}, [ }, [
oilTrajectory, oilTrajectory,
currentTime, currentTime,
selectedModels, selectedModels,
boomLines, boomLines,
showBoomLines,
isDrawingBoom, isDrawingBoom,
drawingPoints, drawingPoints,
dispersionResult, dispersionResult,
@ -1261,6 +1279,8 @@ export function MapView({
analysisCircleCenter, analysisCircleCenter,
analysisCircleRadiusM, analysisCircleRadiusM,
lightMode, lightMode,
vessels,
mapZoom,
]); ]);
// 3D 모드 / 테마에 따른 지도 스타일 전환 // 3D 모드 / 테마에 따른 지도 스타일 전환
@ -1295,9 +1315,11 @@ export function MapView({
{/* 사고 지점 변경 시 지도 이동 */} {/* 사고 지점 변경 시 지도 이동 */}
<MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} /> <MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} />
{/* 외부에서 flyTo 트리거 */} {/* 외부에서 flyTo 트리거 */}
<FlyToController flyToTarget={flyToTarget} /> <FlyToController target={flyToTarget} duration={1200} />
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */} {/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
<FitBoundsController fitBoundsTarget={fitBoundsTarget} /> <FitBoundsController fitBoundsTarget={fitBoundsTarget} />
{/* 선박 신호 뷰포트 bounds 추적 */}
<MapBoundsTracker onBoundsChange={onBoundsChange} />
{/* S-57 전자해도 오버레이 (공식 style.json 기반) */} {/* S-57 전자해도 오버레이 (공식 style.json 기반) */}
<S57EncOverlay visible={mapToggles['s57'] ?? false} /> <S57EncOverlay visible={mapToggles['s57'] ?? false} />
@ -1448,6 +1470,26 @@ export function MapView({
ships={backtrackReplay.ships} ships={backtrackReplay.ships}
/> />
)} )}
{/* 선박 호버 툴팁 */}
{vesselHover && !selectedVessel && <VesselHoverTooltip hover={vesselHover} />}
{/* 선박 클릭 팝업 */}
{selectedVessel && !detailVessel && (
<VesselPopupPanel
vessel={selectedVessel}
onClose={() => setSelectedVessel(null)}
onDetail={() => {
setDetailVessel(selectedVessel);
setSelectedVessel(null);
}}
/>
)}
{/* 선박 상세 모달 */}
{detailVessel && (
<VesselDetailModal vessel={detailVessel} onClose={() => setDetailVessel(null)} />
)}
</div> </div>
); );
} }

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -903,10 +903,10 @@
background: var(--bg-base); background: var(--bg-base);
border: 1px solid var(--stroke-default); border: 1px solid var(--stroke-default);
border-radius: 4px; border-radius: 4px;
color: var(--color-accent); color: var(--color-default);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 400;
text-align: right; text-align: right;
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s;

파일 보기

@ -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 SourceType = 'db' | 'file' | 'api';
type ProcessMode = 'immediate' | 'scheduled' | 'oneshot'; type ProcessMode = 'immediate' | 'scheduled' | 'oneshot';
type RepeatType = 'daily' | 'weekly' | 'monthly'; type RepeatType = 'daily' | 'weekly' | 'monthly';
type DeidentifyTechnique = type DeidentifyTechnique = '마스킹' | '삭제' | '범주화' | '암호화' | '샘플링' | '가명처리' | '유지';
| '마스킹'
| '삭제'
| '범주화'
| '암호화'
| '샘플링'
| '가명처리'
| '유지';
interface FieldConfig { interface FieldConfig {
name: string; name: string;
@ -97,24 +90,102 @@ interface WizardState {
// ─── Mock 데이터 ──────────────────────────────────────────── // ─── Mock 데이터 ────────────────────────────────────────────
const MOCK_TASKS: DeidentifyTask[] = [ 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: '001',
{ id: '003', name: 'employee_info', target: '인사정보 - 계정, 로그인 정보', status: '대기', startTime: '2026-04-10 22:00', progress: 0, createdBy: '이담당' }, name: 'customer_2024',
{ id: '004', name: 'vendor_data', target: 'HNS 대응 - 화학물질 취급자, 방제업체 연락처', status: '오류', startTime: '2026-04-09 13:45', progress: 45, createdBy: '관리자' }, target: '선박/운항 - 선장·선원 성명',
{ id: '005', name: 'partner_contacts', target: '시스템 운영 - 관리자, 운영자 접속로그', status: '완료', startTime: '2026-04-08 09:00', progress: 100, createdBy: '박담당' }, 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[] = [ const DEFAULT_FIELDS: FieldConfig[] = [
{ name: '고객ID', dataType: '문자열', technique: '삭제', configValue: '-', selected: true }, { name: '고객ID', dataType: '문자열', technique: '삭제', configValue: '-', selected: true },
{ name: '이름', dataType: '문자열', technique: '마스킹', configValue: '*로 치환', selected: true }, {
{ name: '휴대폰', dataType: '문자열', technique: '마스킹', configValue: '010-****-****', selected: true }, name: '이름',
{ name: '주소', dataType: '문자열', technique: '범주화', configValue: '시/도만 표시', selected: true }, dataType: '문자열',
{ name: '이메일', dataType: '문자열', technique: '가명처리', configValue: '키: random_001', selected: true }, technique: '마스킹',
{ name: '생년월일', dataType: '날짜', technique: '범주화', configValue: '연도만 표시', selected: true }, 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: '변경 없음', selected: true },
]; ];
const TECHNIQUES: DeidentifyTechnique[] = ['마스킹', '삭제', '범주화', '암호화', '샘플링', '가명처리', '유지']; const TECHNIQUES: DeidentifyTechnique[] = [
'마스킹',
'삭제',
'범주화',
'암호화',
'샘플링',
'가명처리',
'유지',
];
const HOURS = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`); 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[]> = { const MOCK_AUDIT_LOGS: Record<string, AuditLogEntry[]> = {
'001': [ '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_001',
{ 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 } }, 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': [ '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': [ '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': [ '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_001',
{ 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 } }, 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': [ '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 { function getStatusBadgeClass(status: TaskStatus): string {
switch (status) { switch (status) {
case '완료': return 'text-emerald-400 bg-emerald-500/10'; case '완료':
case '진행중': return 'text-cyan-400 bg-cyan-500/10'; return 'text-emerald-400 bg-emerald-500/10';
case '대기': return 'text-yellow-400 bg-yellow-500/10'; case '진행중':
case '오류': return 'text-red-400 bg-red-500/10'; 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 ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-bg-elevated rounded-full overflow-hidden"> <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> </div>
<span className="text-t3 w-8 text-right">{value}%</span> <span className="text-t3 w-8 text-right">{value}%</span>
</div> </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"> <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 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 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"> <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} {row.status}
</span> </span>
</td> </td>
@ -289,13 +512,18 @@ function StepIndicator({ current }: { current: number }) {
isDone isDone
? 'bg-emerald-500 text-white' ? 'bg-emerald-500 text-white'
: isActive : isActive
? 'bg-cyan-500 text-white' ? 'bg-cyan-500 text-white'
: 'bg-bg-elevated text-t3' : 'bg-bg-elevated text-t3'
}`} }`}
> >
{isDone ? ( {isDone ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <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> </svg>
) : ( ) : (
stepNum stepNum
@ -352,11 +580,13 @@ function Step1({ wizard, onChange }: Step1Props) {
<div> <div>
<label className="block text-xs font-medium text-t2 mb-2"> *</label> <label className="block text-xs font-medium text-t2 mb-2"> *</label>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{([ {(
['db', '데이터베이스 연결'], [
['file', '파일 업로드'], ['db', '데이터베이스 연결'],
['api', 'API 호출'], ['file', '파일 업로드'],
] as [SourceType, string][]).map(([val, label]) => ( ['api', 'API 호출'],
] as [SourceType, string][]
).map(([val, label]) => (
<label key={val} className="flex items-center gap-2 cursor-pointer"> <label key={val} className="flex items-center gap-2 cursor-pointer">
<input <input
type="radio" type="radio"
@ -399,7 +629,12 @@ function Step1({ wizard, onChange }: Step1Props) {
{wizard.sourceType === 'file' && ( {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"> <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"> <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> </svg>
<p className="text-xs text-t2"> </p> <p className="text-xs text-t2"> </p>
<p className="text-label-2 text-t3">CSV, XLSX, JSON ( 500MB)</p> <p className="text-label-2 text-t3">CSV, XLSX, JSON ( 500MB)</p>
@ -444,9 +679,7 @@ interface Step2Props {
function Step2({ wizard, onChange }: Step2Props) { function Step2({ wizard, onChange }: Step2Props) {
const toggleField = (idx: number) => { const toggleField = (idx: number) => {
const updated = wizard.fields.map((f, i) => const updated = wizard.fields.map((f, i) => (i === idx ? { ...f, selected: !f.selected } : f));
i === idx ? { ...f, selected: !f.selected } : f,
);
onChange({ fields: updated }); onChange({ fields: updated });
}; };
@ -476,13 +709,17 @@ function Step2({ wizard, onChange }: Step2Props) {
type="checkbox" type="checkbox"
checked={wizard.fields.every((f) => f.selected)} checked={wizard.fields.every((f) => f.selected)}
onChange={(e) => 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" className="accent-cyan-500"
/> />
</th> </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> </tr>
</thead> </thead>
<tbody> <tbody>
@ -520,9 +757,7 @@ interface Step3Props {
function Step3({ wizard, onChange }: Step3Props) { function Step3({ wizard, onChange }: Step3Props) {
const updateField = (idx: number, key: keyof FieldConfig, value: string | boolean) => { const updateField = (idx: number, key: keyof FieldConfig, value: string | boolean) => {
const updated = wizard.fields.map((f, i) => const updated = wizard.fields.map((f, i) => (i === idx ? { ...f, [key]: value } : f));
i === idx ? { ...f, [key]: value } : f,
);
onChange({ fields: updated }); onChange({ fields: updated });
}; };
@ -535,8 +770,12 @@ function Step3({ wizard, onChange }: Step3Props) {
<thead> <thead>
<tr className="bg-bg-elevated text-t3"> <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 className="px-3 py-2 text-left font-medium border-b border-stroke-1"> </th>
</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> </tr>
</thead> </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" 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) => ( {TECHNIQUES.map((t) => (
<option key={t} value={t}>{t}</option> <option key={t} value={t}>
{t}
</option>
))} ))}
</select> </select>
</td> </td>
@ -593,7 +834,9 @@ function Step3({ wizard, onChange }: Step3Props) {
> >
<option value=""> </option> <option value=""> </option>
{TEMPLATES.map((t) => ( {TEMPLATES.map((t) => (
<option key={t} value={t}>{t}</option> <option key={t} value={t}>
{t}
</option>
))} ))}
</select> </select>
</div> </div>
@ -652,7 +895,11 @@ function Step4({ wizard, onChange }: Step4Props) {
onChange={(e) => handleScheduleChange('hour', e.target.value)} 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" 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> </select>
</div> </div>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@ -681,7 +928,11 @@ function Step4({ wizard, onChange }: Step4Props) {
onChange={(e) => handleScheduleChange('weekday', e.target.value)} 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" 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> </select>
)} )}
</div> </div>
@ -738,7 +989,11 @@ function Step4({ wizard, onChange }: Step4Props) {
onChange={(e) => handleOneshotChange('hour', e.target.value)} 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" 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> </select>
</div> </div>
</div> </div>
@ -769,7 +1024,15 @@ function Step5({ wizard, onChange }: Step5Props) {
const summaryRows = [ const summaryRows = [
{ label: '작업명', value: wizard.taskName || '(미입력)' }, { 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: '15,240건' },
{ label: '선택 필드 수', value: `${selectedCount}` }, { label: '선택 필드 수', value: `${selectedCount}` },
{ label: '비식별화 규칙 수', value: `${ruleCount}` }, { label: '비식별화 규칙 수', value: `${ruleCount}` },
@ -833,10 +1096,14 @@ const INITIAL_WIZARD: WizardState = {
function getAuditResultClass(type: AuditLogEntry['resultType']): string { function getAuditResultClass(type: AuditLogEntry['resultType']): string {
switch (type) { switch (type) {
case '성공': return 'text-emerald-400 bg-emerald-500/10'; case '성공':
case '진행중': return 'text-cyan-400 bg-cyan-500/10'; return 'text-emerald-400 bg-emerald-500/10';
case '실패': return 'text-red-400 bg-red-500/10'; case '진행중':
case '거부': return 'text-yellow-400 bg-yellow-500/10'; 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="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"> <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"> <h3 className="text-sm font-semibold text-t1"> () {task.name}</h3>
() {task.name} <button
</h3> onClick={onClose}
<button onClick={onClose} className="text-t3 hover:text-t1 transition-colors text-lg leading-none"> className="text-t3 hover:text-t1 transition-colors text-lg leading-none"
>
</button> </button>
</div> </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" 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) => ( {operators.map((op) => (
<option key={op} value={op}>{op}</option> <option key={op} value={op}>
{op}
</option>
))} ))}
</select> </select>
</div> </div>
@ -905,7 +1175,10 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
<thead> <thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide"> <tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{['시간', '작업자', '작업', '대상 데이터', '결과', '상세'].map((h) => ( {['시간', '작업자', '작업', '대상 데이터', '결과', '상세'].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} {h}
</th> </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' : ''}`} className={`border-b border-stroke-1 hover:bg-bg-surface/50 cursor-pointer ${selectedLog?.id === log.id ? 'bg-bg-surface/70' : ''}`}
onClick={() => setSelectedLog(log)} 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-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">{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"> <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} {log.result}
</span> </span>
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<button <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" 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"> <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> <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 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>
<div><span className="text-t3">:</span> <span className="text-t1 font-mono">{selectedLog.time}</span></div> <span className="text-t3">ID:</span>{' '}
<div><span className="text-t3">:</span> <span className="text-t1">{selectedLog.operator} ({selectedLog.operatorId})</span></div> <span className="text-t1 font-mono">{selectedLog.id}</span>
<div><span className="text-t3"> :</span> <span className="text-t1">{selectedLog.action}</span></div> </div>
<div><span className="text-t3">:</span> <span className="text-t1">{selectedLog.targetData} ({selectedLog.detail.dataCount.toLocaleString()})</span></div> <div>
<div><span className="text-t3"> :</span> <span className="text-t1">{selectedLog.detail.rulesApplied}</span></div> <span className="text-t3">:</span>{' '}
<div><span className="text-t3">:</span> <span className="text-t1">{selectedLog.result} (: {selectedLog.detail.processedCount.toLocaleString()}, : {selectedLog.detail.errorCount})</span></div> <span className="text-t1 font-mono">{selectedLog.time}</span>
<div><span className="text-t3">IP :</span> <span className="text-t1 font-mono">{selectedLog.ip}</span></div> </div>
<div><span className="text-t3">:</span> <span className="text-t1">{selectedLog.browser}</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>
</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" 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"> <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> </svg>
</button> </button>
</div> </div>
@ -1128,22 +1449,32 @@ export default function DeidentifyPanel() {
} }
}, []); }, []);
const handleWizardSubmit = useCallback((wizard: WizardState) => { const handleWizardSubmit = useCallback(
const selectedFields = wizard.fields.filter((f) => f.selected).map((f) => f.name); (wizard: WizardState) => {
const newTask: DeidentifyTask = { const selectedFields = wizard.fields.filter((f) => f.selected).map((f) => f.name);
id: String(tasks.length + 1).padStart(3, '0'), const newTask: DeidentifyTask = {
name: wizard.taskName, id: String(tasks.length + 1).padStart(3, '0'),
target: selectedFields.join(', ') || '-', name: wizard.taskName,
status: wizard.processMode === 'immediate' ? '진행중' : '대기', target: selectedFields.join(', ') || '-',
startTime: new Date().toLocaleString('ko-KR', { status: wizard.processMode === 'immediate' ? '진행중' : '대기',
year: 'numeric', month: '2-digit', day: '2-digit', startTime: new Date()
hour: '2-digit', minute: '2-digit', hour12: false, .toLocaleString('ko-KR', {
}).replace(/\. /g, '-').replace('.', ''), year: 'numeric',
progress: 0, month: '2-digit',
createdBy: '관리자', day: '2-digit',
}; hour: '2-digit',
setTasks((prev) => [newTask, ...prev]); minute: '2-digit',
}, [tasks.length]); hour12: false,
})
.replace(/\. /g, '-')
.replace('.', ''),
progress: 0,
createdBy: '관리자',
};
setTasks((prev) => [newTask, ...prev]);
},
[tasks.length],
);
const filteredTasks = tasks.filter((t) => { const filteredTasks = tasks.filter((t) => {
if (searchName && !t.name.includes(searchName)) return false; 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" 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) => ( {(['모두', '완료', '진행중', '대기', '오류'] as FilterStatus[]).map((s) => (
<option key={s} value={s}>{s}</option> <option key={s} value={s}>
{s}
</option>
))} ))}
</select> </select>
<select <select
@ -1225,16 +1558,11 @@ export default function DeidentifyPanel() {
</div> </div>
{/* 감사로그 모달 */} {/* 감사로그 모달 */}
{auditTask && ( {auditTask && <AuditLogModal task={auditTask} onClose={() => setAuditTask(null)} />}
<AuditLogModal task={auditTask} onClose={() => setAuditTask(null)} />
)}
{/* 마법사 모달 */} {/* 마법사 모달 */}
{showWizard && ( {showWizard && (
<WizardModal <WizardModal onClose={() => setShowWizard(false)} onSubmit={handleWizardSubmit} />
onClose={() => setShowWizard(false)}
onSubmit={handleWizardSubmit}
/>
)} )}
</div> </div>
); );

파일 보기

@ -328,9 +328,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{nodes.map((node, idx) => ( {nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0"> <div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} /> <PipelineCard node={node} />
{idx < nodes.length - 1 && ( {idx < nodes.length - 1 && <span className="text-t3 text-sm shrink-0 px-0.5"></span>}
<span className="text-t3 text-sm shrink-0 px-0.5"></span>
)}
</div> </div>
))} ))}
</div> </div>
@ -395,9 +393,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
)) ))
: rows.map((row) => ( : rows.map((row) => (
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50"> <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"> <td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.timestamp}</td>
{row.timestamp}
</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</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">{row.dataType}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</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"> <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>
:{' '} : <span className="text-emerald-400 font-medium">{totalReceived}</span>
<span className="text-emerald-400 font-medium">{totalReceived}</span>
</span> </span>
<span className="text-stroke-1">|</span> <span className="text-stroke-1">|</span>
<span> <span>
@ -563,8 +558,7 @@ export default function RndHnsAtmosPanel() {
</span> </span>
<span className="text-stroke-1">|</span> <span className="text-stroke-1">|</span>
<span> <span>
:{' '} : <span className="text-cyan-400 font-medium">2 / 4</span>
<span className="text-cyan-400 font-medium">2 / 4</span>
</span> </span>
</div> </div>
</div> </div>
@ -627,9 +621,7 @@ export default function RndHnsAtmosPanel() {
{/* 알림 현황 */} {/* 알림 현황 */}
<section className="px-5 pt-4 pb-5"> <section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3"> <h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
</h3>
<AlertList alerts={alerts} loading={loading} /> <AlertList alerts={alerts} loading={loading} />
</section> </section>
</div> </div>

파일 보기

@ -328,9 +328,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{nodes.map((node, idx) => ( {nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0"> <div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} /> <PipelineCard node={node} />
{idx < nodes.length - 1 && ( {idx < nodes.length - 1 && <span className="text-t3 text-sm shrink-0 px-0.5"></span>}
<span className="text-t3 text-sm shrink-0 px-0.5"></span>
)}
</div> </div>
))} ))}
</div> </div>
@ -395,9 +393,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
)) ))
: rows.map((row) => ( : rows.map((row) => (
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50"> <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"> <td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.timestamp}</td>
{row.timestamp}
</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</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">{row.dataType}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</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"> <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>
:{' '} : <span className="text-emerald-400 font-medium">{totalReceived}</span>
<span className="text-emerald-400 font-medium">{totalReceived}</span>
</span> </span>
<span className="text-stroke-1">|</span> <span className="text-stroke-1">|</span>
<span> <span>
@ -563,8 +558,7 @@ export default function RndKospsPanel() {
</span> </span>
<span className="text-stroke-1">|</span> <span className="text-stroke-1">|</span>
<span> <span>
:{' '} : <span className="text-cyan-400 font-medium">3 / 6</span>
<span className="text-cyan-400 font-medium">3 / 6</span>
</span> </span>
</div> </div>
</div> </div>
@ -627,9 +621,7 @@ export default function RndKospsPanel() {
{/* 알림 현황 */} {/* 알림 현황 */}
<section className="px-5 pt-4 pb-5"> <section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3"> <h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
</h3>
<AlertList alerts={alerts} loading={loading} /> <AlertList alerts={alerts} loading={loading} />
</section> </section>
</div> </div>

파일 보기

@ -355,9 +355,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{nodes.map((node, idx) => ( {nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0"> <div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} /> <PipelineCard node={node} />
{idx < nodes.length - 1 && ( {idx < nodes.length - 1 && <span className="text-t3 text-sm shrink-0 px-0.5"></span>}
<span className="text-t3 text-sm shrink-0 px-0.5"></span>
)}
</div> </div>
))} ))}
</div> </div>
@ -422,9 +420,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
)) ))
: rows.map((row) => ( : rows.map((row) => (
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50"> <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"> <td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.timestamp}</td>
{row.timestamp}
</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</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">{row.dataType}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</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"> <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>
:{' '} : <span className="text-emerald-400 font-medium">{totalReceived}</span>
<span className="text-emerald-400 font-medium">{totalReceived}</span>
</span> </span>
<span className="text-stroke-1">|</span> <span className="text-stroke-1">|</span>
<span> <span>
@ -590,8 +585,7 @@ export default function RndPoseidonPanel() {
</span> </span>
<span className="text-stroke-1">|</span> <span className="text-stroke-1">|</span>
<span> <span>
:{' '} : <span className="text-cyan-400 font-medium">4 / 8</span>
<span className="text-cyan-400 font-medium">4 / 8</span>
</span> </span>
</div> </div>
</div> </div>
@ -654,9 +648,7 @@ export default function RndPoseidonPanel() {
{/* 알림 현황 */} {/* 알림 현황 */}
<section className="px-5 pt-4 pb-5"> <section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3"> <h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
</h3>
<AlertList alerts={alerts} loading={loading} /> <AlertList alerts={alerts} loading={loading} />
</section> </section>
</div> </div>

파일 보기

@ -328,9 +328,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{nodes.map((node, idx) => ( {nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0"> <div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} /> <PipelineCard node={node} />
{idx < nodes.length - 1 && ( {idx < nodes.length - 1 && <span className="text-t3 text-sm shrink-0 px-0.5"></span>}
<span className="text-t3 text-sm shrink-0 px-0.5"></span>
)}
</div> </div>
))} ))}
</div> </div>
@ -395,9 +393,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
)) ))
: rows.map((row) => ( : rows.map((row) => (
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50"> <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"> <td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.timestamp}</td>
{row.timestamp}
</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</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">{row.dataType}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</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"> <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>
:{' '} : <span className="text-emerald-400 font-medium">{totalReceived}</span>
<span className="text-emerald-400 font-medium">{totalReceived}</span>
</span> </span>
<span className="text-stroke-1">|</span> <span className="text-stroke-1">|</span>
<span> <span>
@ -563,8 +558,7 @@ export default function RndRescuePanel() {
</span> </span>
<span className="text-stroke-1">|</span> <span className="text-stroke-1">|</span>
<span> <span>
:{' '} : <span className="text-cyan-400 font-medium">5 / 6</span>
<span className="text-cyan-400 font-medium">5 / 6</span>
</span> </span>
</div> </div>
</div> </div>
@ -627,9 +621,7 @@ export default function RndRescuePanel() {
{/* 알림 현황 */} {/* 알림 현황 */}
<section className="px-5 pt-4 pb-5"> <section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3"> <h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
</h3>
<AlertList alerts={alerts} loading={loading} /> <AlertList alerts={alerts} loading={loading} />
</section> </section>
</div> </div>

파일 보기

@ -300,8 +300,7 @@ function FrameworkTab() {
{[ {[
{ {
title: 'HTTP 정책', title: 'HTTP 정책',
content: content: 'GET/POST만 사용 (PUT/DELETE/PATCH 금지) — 한국 보안취약점 점검 가이드 준수',
'GET/POST만 사용 (PUT/DELETE/PATCH 금지) — 한국 보안취약점 점검 가이드 준수',
}, },
{ {
title: '코드 표준', title: '코드 표준',
@ -463,14 +462,7 @@ function TargetArchTab() {
// ─── 탭 3: 시스템 인터페이스 연계 ──────────────────────────────────────────────── // ─── 탭 3: 시스템 인터페이스 연계 ────────────────────────────────────────────────
function InterfaceTab() { function InterfaceTab() {
const dataFlowSteps = [ const dataFlowSteps = ['수집', '전처리', '저장', '분석/예측', '시각화', '의사결정지원'];
'수집',
'전처리',
'저장',
'분석/예측',
'시각화',
'의사결정지원',
];
return ( return (
<div className="flex flex-col gap-6 p-5"> <div className="flex flex-col gap-6 p-5">
@ -630,7 +622,6 @@ function InterfaceTab() {
); );
} }
// ─── 이기종시스템 연계 데이터 ───────────────────────────────────────────────────── // ─── 이기종시스템 연계 데이터 ─────────────────────────────────────────────────────
interface HeterogeneousSystemRow { interface HeterogeneousSystemRow {
@ -730,7 +721,7 @@ const INTEGRATION_PLANS: IntegrationPlanItem[] = [
{ {
title: '해양공간 데이터 연계', title: '해양공간 데이터 연계',
description: description:
'해경 해양공간 데이터 구축사업(해양공간정보 활용체계 구축 및 빅데이터 관련 사업)의 \'데이터통합저장소\' 시스템과 연계하여 현장탐색 전자지도 자동표출 기술 등에 영상 및 사진자료를 연계하여 구축', "해경 해양공간 데이터 구축사업(해양공간정보 활용체계 구축 및 빅데이터 관련 사업)의 '데이터통합저장소' 시스템과 연계하여 현장탐색 전자지도 자동표출 기술 등에 영상 및 사진자료를 연계하여 구축",
}, },
{ {
title: 'DB 통합설계 기반 맞춤형 인터페이스', title: 'DB 통합설계 기반 맞춤형 인터페이스',
@ -752,8 +743,7 @@ const INTEGRATION_PLANS: IntegrationPlanItem[] = [
}, },
{ {
title: '기타 시스템 연계', title: '기타 시스템 연계',
description: description: '그 밖에 시스템 구축 중 효율적인 사고대응을 위한 타 시스템 연계할 수 있음',
'그 밖에 시스템 구축 중 효율적인 사고대응을 위한 타 시스템 연계할 수 있음',
}, },
]; ];
@ -1180,128 +1170,348 @@ const FEATURE_MATRIX: FeatureMatrixRow[] = [
feature: '사용자 인증 (JWT)', feature: '사용자 인증 (JWT)',
category: '공통기능', category: '공통기능',
integrated: true, 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 권한 제어', feature: 'RBAC 권한 제어',
category: '공통기능', category: '공통기능',
integrated: true, 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: '감사 로그', feature: '감사 로그',
category: '공통기능', category: '공통기능',
integrated: true, 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)', feature: 'API 통신 (Axios)',
category: '공통기능', category: '공통기능',
integrated: true, 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: '입력 살균/보안', feature: '입력 살균/보안',
category: '공통기능', category: '공통기능',
integrated: true, 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: '사용자 관리', feature: '사용자 관리',
category: '기본정보관리', category: '기본정보관리',
integrated: true, 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)', feature: '지도 엔진 (MapLibre)',
category: '기본정보관리', category: '기본정보관리',
integrated: true, 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: '레이어 관리', feature: '레이어 관리',
category: '기본정보관리', category: '기본정보관리',
integrated: true, 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: '메뉴 관리', feature: '메뉴 관리',
category: '기본정보관리', category: '기본정보관리',
integrated: true, 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: '시스템 설정', feature: '시스템 설정',
category: '기본정보관리', category: '기본정보관리',
integrated: true, 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: '확산 시뮬레이션', feature: '확산 시뮬레이션',
category: '업무기능', category: '업무기능',
integrated: false, 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 대기확산', feature: 'HNS 대기확산',
category: '업무기능', category: '업무기능',
integrated: false, 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: '표류 예측', feature: '표류 예측',
category: '업무기능', category: '업무기능',
integrated: false, 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: '위성/드론 영상', feature: '위성/드론 영상',
category: '업무기능', category: '업무기능',
integrated: false, 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: '기상/해상 정보', feature: '기상/해상 정보',
category: '업무기능', category: '업무기능',
integrated: false, 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: '역추적 분석', feature: '역추적 분석',
category: '업무기능', category: '업무기능',
integrated: false, 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: '사고 등록/이력', feature: '사고 등록/이력',
category: '업무기능', category: '업무기능',
integrated: false, 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: '장비/선박 관리', feature: '장비/선박 관리',
category: '업무기능', category: '업무기능',
integrated: false, 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: '해안 조사', feature: '해안 조사',
category: '업무기능', category: '업무기능',
integrated: false, 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', feature: '게시판 CRUD',
category: '업무기능', category: '업무기능',
integrated: false, 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> = { const CATEGORY_STYLES: Record<string, string> = {
'공통기능': 'bg-cyan-600/20 text-cyan-300', : 'bg-cyan-600/20 text-cyan-300',
'기본정보관리': 'bg-emerald-600/20 text-emerald-300', : 'bg-emerald-600/20 text-emerald-300',
'업무기능': 'bg-bg-elevated text-t3', : 'bg-bg-elevated text-t3',
}; };
// ─── 탭 5: 공통기능 ───────────────────────────────────────────────────────────── // ─── 탭 5: 공통기능 ─────────────────────────────────────────────────────────────
@ -1313,8 +1523,8 @@ function CommonFeaturesTab() {
<section> <section>
<h3 className="text-sm font-semibold text-t1 mb-3">1. </h3> <h3 className="text-sm font-semibold text-t1 mb-3">1. </h3>
<p className="text-xs text-t2 leading-relaxed mb-4"> <p className="text-xs text-t2 leading-relaxed mb-4">
, ,
. .
</p> </p>
{/* 프로세스 흐름도 */} {/* 프로세스 흐름도 */}
<div className="flex items-start gap-1 flex-wrap mb-4"> <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> <p className="text-xs font-semibold text-t1 mb-1">{step.phase}</p>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
{step.modules.map((mod) => ( {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>
</div> </div>
@ -1337,7 +1549,10 @@ function CommonFeaturesTab() {
{/* 프로세스 상세 */} {/* 프로세스 상세 */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{RESPONSE_PROCESS.map((step, idx) => ( {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"> <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} {idx + 1}
</span> </span>
@ -1347,7 +1562,10 @@ function CommonFeaturesTab() {
</div> </div>
<div className="flex gap-1 shrink-0"> <div className="flex gap-1 shrink-0">
{step.modules.map((mod) => ( {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} {mod}
</span> </span>
))} ))}
@ -1362,8 +1580,9 @@ function CommonFeaturesTab() {
<h3 className="text-sm font-semibold text-t1 mb-3">2. </h3> <h3 className="text-sm font-semibold text-t1 mb-3">2. </h3>
<p className="text-xs text-t2 leading-relaxed mb-4"> <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> </p>
<div className="overflow-auto"> <div className="overflow-auto">
<table className="w-full text-xs border-collapse"> <table className="w-full text-xs border-collapse">
@ -1379,7 +1598,10 @@ function CommonFeaturesTab() {
</th> </th>
{SYSTEM_MODULES.map((mod) => ( {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> <span className="writing-mode-vertical text-[10px]">{mod}</span>
</th> </th>
))} ))}
@ -1392,7 +1614,9 @@ function CommonFeaturesTab() {
{row.feature} {row.feature}
</td> </td>
<td className="px-2 py-1.5 text-center"> <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} {row.category}
</span> </span>
</td> </td>
@ -1420,15 +1644,21 @@ function CommonFeaturesTab() {
{/* 범례 */} {/* 범례 */}
<div className="flex gap-4 mt-3"> <div className="flex gap-4 mt-3">
<div className="flex items-center gap-1.5"> <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> <span className="text-xs text-t3"> </span>
</div> </div>
<div className="flex items-center gap-1.5"> <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> <span className="text-xs text-t3">··· </span>
</div> </div>
<div className="flex items-center gap-1.5"> <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> <span className="text-xs text-t3"> </span>
</div> </div>
</div> </div>
@ -1478,18 +1708,48 @@ function CommonFeaturesTab() {
</thead> </thead>
<tbody> <tbody>
{[ {[
{ dir: 'common/components/', role: '공통 UI 컴포넌트', files: 'auth/, layout/, map/, ui/, layer/' }, {
{ dir: 'common/hooks/', role: '공통 커스텀 훅', files: 'useLayers, useSubMenu, useFeatureTracking' }, dir: 'common/components/',
{ dir: 'common/services/', role: 'API 통신 모듈', files: 'api.ts, authApi.ts, layerService.ts' }, role: '공통 UI 컴포넌트',
{ dir: 'common/store/', role: '전역 상태 스토어', files: 'authStore.ts, menuStore.ts' }, files: 'auth/, layout/, map/, ui/, layer/',
{ 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/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/constants/', role: '상수 정의', files: 'featureIds.ts' },
{ dir: 'common/data/', role: 'UI 데이터', files: 'layerData.ts (레이어 트리)' }, { dir: 'common/data/', role: 'UI 데이터', files: 'layerData.ts (레이어 트리)' },
].map((row) => ( ].map((row) => (
<tr key={row.dir} className="border-b border-stroke-1 hover:bg-bg-surface/50"> <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-t2">{row.role}</td>
<td className="px-3 py-2 text-t3 font-mono">{row.files}</td> <td className="px-3 py-2 text-t3 font-mono">{row.files}</td>
</tr> </tr>

파일 보기

@ -5,6 +5,7 @@ import { fetchCctvCameras } from '../services/aerialApi';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'; import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
import { useMapStore } from '@common/store/mapStore'; import { useMapStore } from '@common/store/mapStore';
import { BaseMap } from '@common/components/map/BaseMap';
import type { CctvCameraItem } from '../services/aerialApi'; import type { CctvCameraItem } from '../services/aerialApi';
import { CCTVPlayer } from './CCTVPlayer'; import { CCTVPlayer } from './CCTVPlayer';
import type { CCTVPlayerHandle } from './CCTVPlayer'; import type { CCTVPlayerHandle } from './CCTVPlayer';
@ -1055,13 +1056,7 @@ export function CctvView() {
</div> </div>
) : showMap ? ( ) : showMap ? (
<div className="flex-1 overflow-hidden relative"> <div className="flex-1 overflow-hidden relative">
<Map <BaseMap center={[35.5, 127.8]} zoom={6.2}>
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
mapStyle={currentMapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
{filtered {filtered
.filter((c) => c.lon && c.lat) .filter((c) => c.lon && c.lat)
.map((cam) => ( .map((cam) => (
@ -1221,7 +1216,7 @@ export function CctvView() {
</div> </div>
</Popup> </Popup>
)} )}
</Map> </BaseMap>
{/* 지도 위 안내 배지 */} {/* 지도 위 안내 배지 */}
<div <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" 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 { useMemo, useCallback, useState } from 'react';
import { Map, useControl, useMap } from '@vis.gl/react-maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { ScatterplotLayer } from '@deck.gl/layers'; import { ScatterplotLayer } from '@deck.gl/layers';
import 'maplibre-gl/dist/maplibre-gl.css'; import { BaseMap } from '@common/components/map/BaseMap';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'; import { FlyToController } from '@common/components/map/FlyToController';
import { useMapStore } from '@common/store/mapStore';
import type { AssetOrgCompat } from '../services/assetsApi'; import type { AssetOrgCompat } from '../services/assetsApi';
import { typeColor } from './assetTypes'; import { typeColor } from './assetTypes';
import { hexToRgba } from '@common/components/map/mapUtils'; 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 { interface AssetMapProps {
organizations: AssetOrgCompat[]; organizations: AssetOrgCompat[];
selectedOrg: AssetOrgCompat; selectedOrg: AssetOrgCompat;
@ -49,8 +22,16 @@ function AssetMap({
regionFilter, regionFilter,
onRegionFilterChange, onRegionFilterChange,
}: AssetMapProps) { }: AssetMapProps) {
const currentMapStyle = useBaseMapStyle(); // 선택 항목이 실제로 바뀔 때만 flyTo (첫 렌더에서는 이동하지 않음)
const mapToggles = useMapStore((s) => s.mapToggles); // 첫 렌더 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( const handleClick = useCallback(
(org: AssetOrgCompat) => { (org: AssetOrgCompat) => {
@ -59,58 +40,54 @@ function AssetMap({
[onSelectOrg], [onSelectOrg],
); );
const markerLayer = useMemo(() => { const markerLayer = useMemo(
return new ScatterplotLayer({ () =>
id: 'asset-orgs', new ScatterplotLayer({
data: orgs, id: 'asset-orgs',
getPosition: (d: AssetOrgCompat) => [d.lng, d.lat], data: orgs,
getRadius: (d: AssetOrgCompat) => { getPosition: (d: AssetOrgCompat) => [d.lng, d.lat],
const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7; getRadius: (d: AssetOrgCompat) => {
const isSelected = selectedOrg.id === d.id; const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7;
return isSelected ? baseRadius + 4 : baseRadius; const isSelected = selectedOrg.id === d.id;
}, return isSelected ? baseRadius + 4 : baseRadius;
getFillColor: (d: AssetOrgCompat) => { },
const tc = typeColor(d.type); getFillColor: (d: AssetOrgCompat) => {
const isSelected = selectedOrg.id === d.id; const tc = typeColor(d.type);
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 229 : 178); const isSelected = selectedOrg.id === d.id;
}, return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 229 : 178);
getLineColor: (d: AssetOrgCompat) => { },
const tc = typeColor(d.type); getLineColor: (d: AssetOrgCompat) => {
const isSelected = selectedOrg.id === d.id; const tc = typeColor(d.type);
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 255 : 200); 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, getLineWidth: (d: AssetOrgCompat) => (selectedOrg.id === d.id ? 3 : 2),
radiusMinPixels: 4, stroked: true,
radiusMaxPixels: 20, radiusMinPixels: 4,
radiusUnits: 'pixels', radiusMaxPixels: 20,
pickable: true, radiusUnits: 'pixels',
onClick: (info: { object?: AssetOrgCompat }) => { pickable: true,
if (info.object) handleClick(info.object); onClick: (info: { object?: AssetOrgCompat }) => {
}, if (info.object) handleClick(info.object);
updateTriggers: { },
getRadius: [selectedOrg.id], updateTriggers: {
getFillColor: [selectedOrg.id], getRadius: [selectedOrg.id],
getLineColor: [selectedOrg.id], getFillColor: [selectedOrg.id],
getLineWidth: [selectedOrg.id], getLineColor: [selectedOrg.id],
}, getLineWidth: [selectedOrg.id],
}); },
}, [orgs, selectedOrg, handleClick]); }),
[orgs, selectedOrg, handleClick],
);
return ( return (
<div className="w-full h-full relative"> <div className="w-full h-full relative">
<Map <BaseMap center={[35.9, 127.8]} zoom={7}>
initialViewState={{ longitude: 127.8, latitude: 35.9, zoom: 7 }}
mapStyle={currentMapStyle}
className="w-full h-full"
attributionControl={false}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<DeckGLOverlay layers={[markerLayer]} /> <DeckGLOverlay layers={[markerLayer]} />
<FlyToController selectedOrg={selectedOrg} /> <FlyToController target={flyTarget} duration={800} />
</Map> </BaseMap>
{/* Region filter overlay */} {/* 지역 필터 */}
<div className="absolute top-3 left-3 z-[1000] flex gap-1"> <div className="absolute top-3 left-3 z-[1000] flex gap-1">
{[ {[
{ value: 'all', label: '전체' }, { value: 'all', label: '전체' },
@ -134,7 +111,7 @@ function AssetMap({
))} ))}
</div> </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="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> <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 { useWeatherFetch } from '../hooks/useWeatherFetch';
import { getSubstanceToxicity } from '../utils/toxicityData'; import { getSubstanceToxicity } from '../utils/toxicityData';
import type { WeatherFetchResult, ReleaseType } from '../utils/dispersionTypes'; import type { WeatherFetchResult, ReleaseType } from '../utils/dispersionTypes';
import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi'; import { fetchGscAccidents } from '@tabs/prediction/services/predictionApi';
import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi'; import type { GscAccidentListItem } from '@tabs/prediction/services/predictionApi';
/** HNS 분석 입력 파라미터 (부모에 전달) */ /** HNS 분석 입력 파라미터 (부모에 전달) */
export interface HNSInputParams { export interface HNSInputParams {
@ -44,6 +44,7 @@ interface HNSLeftPanelProps {
onParamsChange?: (params: HNSInputParams) => void; onParamsChange?: (params: HNSInputParams) => void;
onReset?: () => void; onReset?: () => void;
loadedParams?: Partial<HNSInputParams> | null; loadedParams?: Partial<HNSInputParams> | null;
onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
} }
/** 십진 좌표 → 도분초 변환 */ /** 십진 좌표 → 도분초 변환 */
@ -67,8 +68,9 @@ export function HNSLeftPanel({
onParamsChange, onParamsChange,
onReset, onReset,
loadedParams, loadedParams,
onFlyToCoord,
}: HNSLeftPanelProps) { }: HNSLeftPanelProps) {
const [incidents, setIncidents] = useState<IncidentListItem[]>([]); const [incidents, setIncidents] = useState<GscAccidentListItem[]>([]);
const [selectedIncidentSn, setSelectedIncidentSn] = useState(''); const [selectedIncidentSn, setSelectedIncidentSn] = useState('');
const [expandedSections, setExpandedSections] = useState({ accident: true, params: true }); const [expandedSections, setExpandedSections] = useState({ accident: true, params: true });
const toggleSection = (key: 'accident' | 'params') => const toggleSection = (key: 'accident' | 'params') =>
@ -138,21 +140,26 @@ export function HNSLeftPanel({
// 사고 목록 불러오기 (마운트 시 1회, ref 초기화 패턴) // 사고 목록 불러오기 (마운트 시 1회, ref 초기화 패턴)
const incidentsPromiseRef = useRef<Promise<void> | null>(null); const incidentsPromiseRef = useRef<Promise<void> | null>(null);
if (incidentsPromiseRef.current == null) { if (incidentsPromiseRef.current == null) {
incidentsPromiseRef.current = fetchIncidentsRaw() incidentsPromiseRef.current = fetchGscAccidents()
.then((data) => setIncidents(data)) .then((data) => setIncidents(data))
.catch(() => setIncidents([])); .catch(() => setIncidents([]));
} }
// 사고 선택 시 필드 자동 채움 // 사고 선택 시 필드 자동 채움
const handleSelectIncident = (snStr: string) => { const handleSelectIncident = (mngNo: string) => {
setSelectedIncidentSn(snStr); setSelectedIncidentSn(mngNo);
const sn = parseInt(snStr); const incident = incidents.find((i) => i.acdntMngNo === mngNo);
const incident = incidents.find((i) => i.acdntSn === sn);
if (!incident) return; if (!incident) return;
setAccidentName(incident.acdntNm); setAccidentName(incident.pollNm);
if (incident.lat && incident.lng) { if (incident.pollDate) {
onCoordChange({ lat: incident.lat, lon: incident.lng }); const [d, t] = incident.pollDate.split('T');
if (d) setAccidentDate(d);
if (t) setAccidentTime(t);
}
if (incident.lat != null && incident.lon != null) {
onCoordChange({ lat: incident.lat, lon: incident.lon });
onFlyToCoord?.({ lat: incident.lat, lon: incident.lon });
} }
}; };
@ -266,8 +273,8 @@ export function HNSLeftPanel({
onChange={handleSelectIncident} onChange={handleSelectIncident}
placeholder="또는 사고 리스트에서 선택" placeholder="또는 사고 리스트에서 선택"
options={incidents.map((inc) => ({ options={incidents.map((inc) => ({
value: String(inc.acdntSn), value: inc.acdntMngNo,
label: `${inc.acdntNm} (${new Date(inc.occrnDtm).toLocaleDateString('ko-KR')})`, label: `${inc.pollNm} (${inc.pollDate ? inc.pollDate.replace('T', ' ') : '-'})`,
}))} }))}
/> />

파일 보기

@ -1,4 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { useVesselSignals } from '@common/hooks/useVesselSignals';
import type { MapBounds } from '@common/types/vessel';
import { HNSLeftPanel } from './HNSLeftPanel'; import { HNSLeftPanel } from './HNSLeftPanel';
import type { HNSInputParams } from './HNSLeftPanel'; import type { HNSInputParams } from './HNSLeftPanel';
import { HNSRightPanel } from './HNSRightPanel'; import { HNSRightPanel } from './HNSRightPanel';
@ -265,7 +267,10 @@ export function HNSView() {
const [leftCollapsed, setLeftCollapsed] = useState(false); const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false); const [rightCollapsed, setRightCollapsed] = useState(false);
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null); const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [isSelectingLocation, setIsSelectingLocation] = useState(false);
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const vessels = useVesselSignals(mapBounds);
const [isRunningPrediction, setIsRunningPrediction] = useState(false); const [isRunningPrediction, setIsRunningPrediction] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const [dispersionResult, setDispersionResult] = useState<any>(null); const [dispersionResult, setDispersionResult] = useState<any>(null);
@ -904,6 +909,7 @@ export function HNSView() {
onParamsChange={handleParamsChange} onParamsChange={handleParamsChange}
onReset={handleReset} onReset={handleReset}
loadedParams={loadedParams} loadedParams={loadedParams}
onFlyToCoord={(c) => setFlyToCoord({ lat: c.lat, lon: c.lon })}
/> />
</div> </div>
)} )}
@ -963,6 +969,8 @@ export function HNSView() {
<> <>
<MapView <MapView
incidentCoord={incidentCoord ?? undefined} incidentCoord={incidentCoord ?? undefined}
flyToIncident={flyToCoord}
onIncidentFlyEnd={() => setFlyToCoord(undefined)}
isSelectingLocation={isSelectingLocation} isSelectingLocation={isSelectingLocation}
onMapClick={handleMapClick} onMapClick={handleMapClick}
oilTrajectory={[]} oilTrajectory={[]}
@ -970,6 +978,8 @@ export function HNSView() {
dispersionResult={dispersionResult} dispersionResult={dispersionResult}
dispersionHeatmap={heatmapData} dispersionHeatmap={heatmapData}
mapCaptureRef={mapCaptureRef} mapCaptureRef={mapCaptureRef}
vessels={vessels}
onBoundsChange={setMapBounds}
/> />
{/* 시간 슬라이더 (puff/dense_gas 모델용) */} {/* 시간 슬라이더 (puff/dense_gas 모델용) */}
{allTimeFrames.length > 1 && ( {allTimeFrames.length > 1 && (

파일 보기

@ -1,14 +1,17 @@
import { useState, useEffect, useMemo, useRef } from 'react'; import { useState, useEffect, useMemo, useRef } from 'react';
import { Map as MapLibre, Popup, useControl, useMap } from '@vis.gl/react-maplibre'; import { Popup, useMap } from '@vis.gl/react-maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox'; import { ScatterplotLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers';
import { ScatterplotLayer, IconLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers';
import { PathStyleExtension } from '@deck.gl/extensions'; import { PathStyleExtension } from '@deck.gl/extensions';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; import { BaseMap } from '@common/components/map/BaseMap';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'; import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay';
import { MapBoundsTracker } from '@common/components/map/MapBoundsTracker';
import { buildVesselLayers, VESSEL_LEGEND, getShipKindLabel } from '@common/components/map/VesselLayer';
import { useVesselSignals } from '@common/hooks/useVesselSignals';
import type { MapBounds, VesselPosition } from '@common/types/vessel';
import { getVesselCacheStatus, type VesselCacheStatus } from '@common/services/vesselApi';
import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel'; import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel';
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel'; import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel';
import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData';
import { fetchIncidents } from '../services/incidentsApi'; import { fetchIncidents } from '../services/incidentsApi';
import type { IncidentCompat } from '../services/incidentsApi'; import type { IncidentCompat } from '../services/incidentsApi';
import { fetchAnalysisTrajectory } from '@tabs/prediction/services/predictionApi'; import { fetchAnalysisTrajectory } from '@tabs/prediction/services/predictionApi';
@ -27,9 +30,6 @@ import {
getCachedZones, getCachedZones,
} from '../utils/dischargeZoneData'; } from '../utils/dischargeZoneData';
import { useMapStore } from '@common/store/mapStore'; 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][] = [ const CATEGORY_PALETTE: [number, number, number][] = [
@ -55,14 +55,6 @@ function getCategoryColor(index: number): [number, number, number] {
return CATEGORY_PALETTE[index % CATEGORY_PALETTE.length]; 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: 사고 선택 시 지도 이동 ────────── // ── FlyToController: 사고 선택 시 지도 이동 ──────────
function FlyToController({ incident }: { incident: IncidentCompat | null }) { function FlyToController({ incident }: { incident: IncidentCompat | null }) {
const { current: map } = useMap(); const { current: map } = useMap();
@ -98,16 +90,11 @@ function getMarkerStroke(s: string): [number, number, number, number] {
const getStatusLabel = (s: string) => const getStatusLabel = (s: string) =>
s === 'active' ? '대응중' : s === 'investigating' ? '조사중' : s === 'closed' ? '종료' : ''; s === 'active' ? '대응중' : s === 'investigating' ? '조사중' : s === 'closed' ? '종료' : '';
// ── 선박 아이콘 SVG (삼각형) ────────────────────────────
// deck.gl IconLayer용 atlas 없이 SVGOverlay 방식 대신
// ScatterplotLayer로 단순화하여 선박을 원으로 표현 (heading 정보 별도 레이어)
// → 원 마커 + 방향 지시선을 deck.gl ScatterplotLayer로 표현
// 팝업 정보 // 팝업 정보
interface VesselPopupInfo { interface VesselPopupInfo {
longitude: number; longitude: number;
latitude: number; latitude: number;
vessel: Vessel; vessel: VesselPosition;
} }
interface IncidentPopupInfo { interface IncidentPopupInfo {
@ -120,7 +107,7 @@ interface IncidentPopupInfo {
interface HoverInfo { interface HoverInfo {
x: number; x: number;
y: number; y: number;
object: Vessel | IncidentCompat; object: VesselPosition | IncidentCompat;
type: 'vessel' | 'incident'; type: 'vessel' | 'incident';
} }
@ -131,12 +118,35 @@ export function IncidentsView() {
const [incidents, setIncidents] = useState<IncidentCompat[]>([]); const [incidents, setIncidents] = useState<IncidentCompat[]>([]);
const [filteredIncidents, setFilteredIncidents] = useState<IncidentCompat[]>([]); const [filteredIncidents, setFilteredIncidents] = useState<IncidentCompat[]>([]);
const [selectedIncidentId, setSelectedIncidentId] = useState<string | null>(null); const [selectedIncidentId, setSelectedIncidentId] = useState<string | null>(null);
const [selectedVessel, setSelectedVessel] = useState<Vessel | null>(null); const [selectedVessel, setSelectedVessel] = useState<VesselPosition | null>(null);
const [detailVessel, setDetailVessel] = useState<Vessel | null>(null); const [detailVessel, setDetailVessel] = useState<VesselPosition | null>(null);
const [vesselPopup, setVesselPopup] = useState<VesselPopupInfo | null>(null); const [vesselPopup, setVesselPopup] = useState<VesselPopupInfo | null>(null);
const [incidentPopup, setIncidentPopup] = useState<IncidentPopupInfo | null>(null); const [incidentPopup, setIncidentPopup] = useState<IncidentPopupInfo | null>(null);
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null); const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null);
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const [mapZoom, setMapZoom] = useState<number>(10);
const realVessels = useVesselSignals(mapBounds);
const [vesselStatus, setVesselStatus] = useState<VesselCacheStatus | null>(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const status = await getVesselCacheStatus();
if (!cancelled) setVesselStatus(status);
} catch {
// 무시 — 다음 폴링에서 재시도
}
};
load();
const id = setInterval(load, 30_000);
return () => {
cancelled = true;
clearInterval(id);
};
}, []);
const [dischargeMode, setDischargeMode] = useState(false); const [dischargeMode, setDischargeMode] = useState(false);
const [leftCollapsed, setLeftCollapsed] = useState(false); const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false); const [rightCollapsed, setRightCollapsed] = useState(false);
@ -150,14 +160,8 @@ export function IncidentsView() {
() => getCachedBaseline() !== null && getCachedZones() !== null, () => getCachedBaseline() !== null && getCachedZones() !== null,
); );
// Map style & toggles // Measure mode (cursor 결정용 — 측정 클릭/레이어는 BaseMap이 처리)
const currentMapStyle = useBaseMapStyle(); const measureMode = useMapStore((s) => s.measureMode);
const mapToggles = useMapStore((s) => s.mapToggles);
// Measure tool
const { handleMeasureClick, measureMode } = useMeasureTool();
const measureInProgress = useMapStore((s) => s.measureInProgress);
const measurements = useMapStore((s) => s.measurements);
// Analysis view mode // Analysis view mode
const [viewMode, setViewMode] = useState<ViewMode>('overlay'); const [viewMode, setViewMode] = useState<ViewMode>('overlay');
@ -301,60 +305,6 @@ export function IncidentsView() {
[filteredIncidents, selectedIncidentId], [filteredIncidents, selectedIncidentId],
); );
// ── 선박 방향 지시 아이콘 (삼각형 SVG → IconLayer) ──────
// 글로우 효과 + 드롭쉐도우가 있는 개선된 SVG 삼각형
const vesselIconLayer = useMemo(() => {
const makeTriangleSvg = (color: string, isAccident: boolean) => {
const opacity = isAccident ? '1' : '0.85';
const glowOpacity = isAccident ? '0.9' : '0.75';
const svgStr = [
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="20" viewBox="0 0 16 20">',
'<defs><filter id="g" x="-50%" y="-50%" width="200%" height="200%">',
'<feGaussianBlur stdDeviation="1.2"/></filter></defs>',
`<polygon points="8,0 15,20 1,20" fill="${color}" opacity="${glowOpacity}" filter="url(#g)"/>`,
`<polygon points="8,1 14,19 2,19" fill="${color}" opacity="${opacity}" stroke="${color}" stroke-width="0.5"/>`,
'</svg>',
].join('');
return `data:image/svg+xml;base64,${btoa(svgStr)}`;
};
return new IconLayer({
id: 'vessel-icons',
data: mockVessels,
getPosition: (d: Vessel) => [d.lng, d.lat],
getIcon: (d: Vessel) => ({
url: makeTriangleSvg(d.color, d.status.includes('사고')),
width: 16,
height: 20,
anchorX: 8,
anchorY: 10,
}),
getSize: 16,
getAngle: (d: Vessel) => -d.heading,
sizeUnits: 'pixels',
sizeScale: 1,
pickable: true,
onClick: (info: { object?: Vessel; coordinate?: number[] }) => {
if (info.object && info.coordinate) {
setSelectedVessel(info.object);
setVesselPopup({
longitude: info.coordinate[0],
latitude: info.coordinate[1],
vessel: info.object,
});
setIncidentPopup(null);
setDetailVessel(null);
}
},
onHover: (info: { object?: Vessel; x?: number; y?: number }) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setHoverInfo({ x: info.x, y: info.y, object: info.object, type: 'vessel' });
} else {
setHoverInfo((h) => (h?.type === 'vessel' ? null : h));
}
},
});
}, []);
// ── 배출 구역 경계선 레이어 ── // ── 배출 구역 경계선 레이어 ──
const dischargeZoneLayers = useMemo(() => { const dischargeZoneLayers = useMemo(() => {
@ -377,11 +327,6 @@ export function IncidentsView() {
); );
}, [dischargeMode, baselineLoaded]); }, [dischargeMode, baselineLoaded]);
const measureDeckLayers = useMemo(
() => buildMeasureLayers(measureInProgress, measureMode, measurements),
[measureInProgress, measureMode, measurements],
);
// ── 예측 결과 레이어 (입자 클라우드, 중심점 경로, 시간 라벨, 해안 부착 입자) ────── // ── 예측 결과 레이어 (입자 클라우드, 중심점 경로, 시간 라벨, 해안 부착 입자) ──────
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const trajectoryLayers: any[] = useMemo(() => { const trajectoryLayers: any[] = useMemo(() => {
@ -558,24 +503,44 @@ export function IncidentsView() {
}); });
}, [sensitiveGeojson, sensCheckedCategories, sensColorMap]); }, [sensitiveGeojson, sensCheckedCategories, sensColorMap]);
const realVesselLayers = useMemo(
() =>
buildVesselLayers(
realVessels,
{
onClick: (vessel, coordinate) => {
setSelectedVessel(vessel);
setVesselPopup({
longitude: coordinate[0],
latitude: coordinate[1],
vessel,
});
setIncidentPopup(null);
setDetailVessel(null);
},
onHover: (vessel, x, y) => {
if (vessel) {
setHoverInfo({ x, y, object: vessel, type: 'vessel' });
} else {
setHoverInfo((h) => (h?.type === 'vessel' ? null : h));
}
},
},
mapZoom,
),
[realVessels, mapZoom],
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const deckLayers: any[] = useMemo( const deckLayers: any[] = useMemo(
() => [ () => [
incidentLayer, incidentLayer,
vesselIconLayer, ...realVesselLayers,
...dischargeZoneLayers, ...dischargeZoneLayers,
...measureDeckLayers,
...trajectoryLayers, ...trajectoryLayers,
...(sensLayer ? [sensLayer] : []), ...(sensLayer ? [sensLayer] : []),
], ],
[ [incidentLayer, realVesselLayers, dischargeZoneLayers, trajectoryLayers, sensLayer],
incidentLayer,
vesselIconLayer,
dischargeZoneLayers,
measureDeckLayers,
trajectoryLayers,
sensLayer,
],
); );
return ( return (
@ -710,30 +675,21 @@ export function IncidentsView() {
{/* Default Map (visible when not in analysis or in overlay mode) */} {/* Default Map (visible when not in analysis or in overlay mode) */}
{(!analysisActive || viewMode === 'overlay') && ( {(!analysisActive || viewMode === 'overlay') && (
<div className="absolute inset-0"> <div className="absolute inset-0">
<MapLibre <BaseMap
initialViewState={{ longitude: 127.8, latitude: 35.0, zoom: 7 }} center={[35.0, 127.8]}
mapStyle={currentMapStyle} zoom={7}
style={{ width: '100%', height: '100%', background: '#f0f0f0' }} cursor={measureMode !== null || dischargeMode ? 'crosshair' : undefined}
attributionControl={false} onMapClick={(lon, lat) => {
onClick={(e) => { if (dischargeMode) {
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;
const distanceNm = estimateDistanceFromCoast(lat, lon); const distanceNm = estimateDistanceFromCoast(lat, lon);
const zoneIndex = determineZone(lat, lon); const zoneIndex = determineZone(lat, lon);
setDischargeInfo({ lat, lon, distanceNm, zoneIndex }); setDischargeInfo({ lat, lon, distanceNm, zoneIndex });
} }
}} }}
cursor={measureMode !== null || dischargeMode ? 'crosshair' : undefined}
> >
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<DeckGLOverlay layers={deckLayers} /> <DeckGLOverlay layers={deckLayers} />
<MapBoundsTracker onBoundsChange={setMapBounds} onZoomChange={setMapZoom} />
<FlyToController incident={selectedIncident} /> <FlyToController incident={selectedIncident} />
<MeasureOverlay />
{/* 사고 팝업 */} {/* 사고 팝업 */}
{incidentPopup && ( {incidentPopup && (
@ -753,7 +709,7 @@ export function IncidentsView() {
/> />
</Popup> </Popup>
)} )}
</MapLibre> </BaseMap>
{/* 호버 툴팁 */} {/* 호버 툴팁 */}
{hoverInfo && ( {hoverInfo && (
@ -770,7 +726,7 @@ export function IncidentsView() {
}} }}
> >
{hoverInfo.type === 'vessel' ? ( {hoverInfo.type === 'vessel' ? (
<VesselTooltipContent vessel={hoverInfo.object as Vessel} /> <VesselTooltipContent vessel={hoverInfo.object as VesselPosition} />
) : ( ) : (
<IncidentTooltipContent incident={hoverInfo.object as IncidentCompat} /> <IncidentTooltipContent incident={hoverInfo.object as IncidentCompat} />
)} )}
@ -900,12 +856,11 @@ export function IncidentsView() {
}} }}
/> */} /> */}
<span className="text-caption">AIS Live</span> <span className="text-caption">AIS Live</span>
<span className="text-caption text-fg-disabled font-mono">MarineTraffic</span>
</div> </div>
<div className="flex gap-2.5 text-caption font-mono"> <div className="flex gap-2.5 text-caption font-mono">
<div className="text-fg-sub"> 20</div> <div className="text-fg-sub"> {vesselStatus?.count ?? 0}</div>
<div className="text-fg-sub"> 6</div> <div className="text-fg-sub"> {filteredIncidents.length}</div>
<div className="text-fg-sub"> 2</div> <div className="text-fg-sub"> {vesselStatus?.bangjeCount ?? 0}</div>
</div> </div>
</div> </div>
@ -1149,7 +1104,15 @@ export function IncidentsView() {
onCloseAnalysis={handleCloseAnalysis} onCloseAnalysis={handleCloseAnalysis}
onCheckedPredsChange={handleCheckedPredsChange} onCheckedPredsChange={handleCheckedPredsChange}
onSensitiveDataChange={handleSensitiveDataChange} onSensitiveDataChange={handleSensitiveDataChange}
selectedVessel={selectedVessel} selectedVessel={
selectedVessel
? {
lat: selectedVessel.lat,
lng: selectedVessel.lon,
name: selectedVessel.shipNm,
}
: null
}
/> />
</div> </div>
</div> </div>
@ -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({ function VesselPopupPanel({
vessel: v, vessel: v,
onClose, onClose,
onDetail, onDetail,
}: { }: {
vessel: Vessel; vessel: VesselPosition;
onClose: () => void; onClose: () => void;
onDetail: () => void; onDetail: () => void;
}) { }) {
const statusColor = v.status.includes('사고') ? 'var(--color-danger)' : 'var(--color-success)'; const statusText = v.status ?? '-';
const statusBg = v.status.includes('사고') const isAccident = (v.status ?? '').includes('사고');
const statusColor = isAccident ? 'var(--color-danger)' : 'var(--color-success)';
const statusBg = isAccident
? 'color-mix(in srgb, var(--color-danger) 15%, transparent)' ? 'color-mix(in srgb, var(--color-danger) 15%, transparent)'
: 'color-mix(in srgb, var(--color-success) 10%, transparent)'; : 'color-mix(in srgb, var(--color-success) 10%, transparent)';
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-';
const heading = v.heading ?? v.cog;
const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-';
const receivedAt = v.lastUpdate ? formatDateTime(v.lastUpdate) : '-';
return ( return (
<div <div
@ -1341,14 +1323,14 @@ function VesselPopupPanel({
className="flex items-center justify-center text-title-2" className="flex items-center justify-center text-title-2"
style={{ width: 28, height: 20 }} style={{ width: 28, height: 20 }}
> >
{v.flag} {v.nationalCode ?? '🚢'}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div <div
className="text-label-1 font-[800] whitespace-nowrap overflow-hidden text-ellipsis" className="text-label-1 font-[800] whitespace-nowrap overflow-hidden text-ellipsis"
style={{ color: '#e6edf3' }} style={{ color: '#e6edf3' }}
> >
{v.name} {v.shipNm ?? '(이름 없음)'}
</div> </div>
<div className="text-caption font-mono" style={{ color: '#8b949e' }}> <div className="text-caption font-mono" style={{ color: '#8b949e' }}>
MMSI: {v.mmsi} MMSI: {v.mmsi}
@ -1389,7 +1371,7 @@ function VesselPopupPanel({
border: '1px solid color-mix(in srgb, var(--color-info) 25%, transparent)', border: '1px solid color-mix(in srgb, var(--color-info) 25%, transparent)',
}} }}
> >
{v.typS} {getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-'}
</span> </span>
<span <span
className="text-caption font-bold rounded" className="text-caption font-bold rounded"
@ -1400,14 +1382,14 @@ function VesselPopupPanel({
color: statusColor, color: statusColor,
}} }}
> >
{v.status} {statusText}
</span> </span>
</div> </div>
{/* Data rows */} {/* Data rows */}
<div style={{ padding: '4px 0' }}> <div style={{ padding: '4px 0' }}>
<PopupRow label="속도/항로" value={`${v.speed} kn / ${v.heading}°`} accent /> <PopupRow label="속도/항로" value={`${speed} / ${headingText}`} accent />
<PopupRow label="흘수" value={`${v.draft}m`} /> <PopupRow label="흘수" value={v.draught !== undefined ? `${v.draught.toFixed(2)} m` : '-'} />
<div <div
className="flex flex-col gap-1" className="flex flex-col gap-1"
style={{ style={{
@ -1420,7 +1402,7 @@ function VesselPopupPanel({
</span> </span>
<span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}> <span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}>
{v.depart} -
</span> </span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
@ -1428,11 +1410,11 @@ function VesselPopupPanel({
</span> </span>
<span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}> <span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}>
{v.arrive} {v.destination ?? '-'}
</span> </span>
</div> </div>
</div> </div>
<PopupRow label="데이터 수신" value="2026-02-25 14:32:00" muted /> <PopupRow label="데이터 수신" value={receivedAt} muted />
</div> </div>
{/* Buttons */} {/* Buttons */}
@ -1677,7 +1659,13 @@ const TAB_LABELS: { key: DetTab; label: string }[] = [
{ key: 'dg', label: '위험물정보' }, { key: 'dg', label: '위험물정보' },
]; ];
function VesselDetailModal({ vessel: v, onClose }: { vessel: Vessel; onClose: () => void }) { function VesselDetailModal({
vessel: v,
onClose,
}: {
vessel: VesselPosition;
onClose: () => void;
}) {
const [tab, setTab] = useState<DetTab>('info'); const [tab, setTab] = useState<DetTab>('info');
return ( return (
@ -1706,11 +1694,13 @@ function VesselDetailModal({ vessel: v, onClose }: { vessel: Vessel; onClose: ()
style={{ padding: '14px 18px' }} style={{ padding: '14px 18px' }}
> >
<div className="flex items-center gap-[10px]"> <div className="flex items-center gap-[10px]">
<span className="text-lg">{v.flag}</span> <span className="text-lg">{v.nationalCode ?? '🚢'}</span>
<div> <div>
<div className="text-title-3 font-[800] text-fg">{v.name}</div> <div className="text-title-3 font-[800] text-fg">
{v.shipNm ?? '(이름 없음)'}
</div>
<div className="text-caption text-fg-disabled font-mono"> <div className="text-caption text-fg-disabled font-mono">
MMSI: {v.mmsi} · IMO: {v.imo} MMSI: {v.mmsi} · IMO: {displayVal(v.imo)}
</div> </div>
</div> </div>
</div> </div>
@ -1858,7 +1848,10 @@ function StatusBadge({ label, color }: { label: string; color: string }) {
} }
/* ── Tab 0: 상세정보 ─────────────────────────────── */ /* ── Tab 0: 상세정보 ─────────────────────────────── */
function TabInfo({ v }: { v: Vessel }) { function TabInfo({ v }: { v: VesselPosition }) {
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-';
const heading = v.heading ?? v.cog;
const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-';
return ( return (
<> <>
<div <div
@ -1870,25 +1863,25 @@ function TabInfo({ v }: { v: Vessel }) {
<Sec title="📡 실시간 현황"> <Sec title="📡 실시간 현황">
<Grid> <Grid>
<Cell label="선박상태" value={v.status} /> <Cell label="선박상태" value={displayVal(v.status)} />
<Cell <Cell
label="속도 / 항로" label="속도 / 항로"
value={`${v.speed} kn / ${v.heading}°`} value={`${speed} / ${headingText}`}
color="var(--color-accent)" color="var(--color-accent)"
/> />
<Cell label="위도" value={`${v.lat.toFixed(4)}°N`} /> <Cell label="위도" value={`${v.lat.toFixed(4)}°N`} />
<Cell label="경도" value={`${v.lng.toFixed(4)}°E`} /> <Cell label="경도" value={`${v.lon.toFixed(4)}°E`} />
<Cell label="흘수" value={`${v.draft}m`} /> <Cell label="흘수" value={v.draught !== undefined ? `${v.draught} m` : '-'} />
<Cell label="수신시간" value="2026-02-25 14:30" /> <Cell label="수신시간" value={v.lastUpdate ? formatDateTime(v.lastUpdate) : '-'} />
</Grid> </Grid>
</Sec> </Sec>
<Sec title="🚢 항해 일정"> <Sec title="🚢 항해 일정">
<Grid> <Grid>
<Cell label="출항지" value={v.depart} /> <Cell label="출항지" value="-" />
<Cell label="입항지" value={v.arrive} /> <Cell label="입항지" value={displayVal(v.destination)} />
<Cell label="출항일시" value={v.etd || '—'} /> <Cell label="출항일시" value="-" />
<Cell label="입항일시(ETA)" value={v.eta || '—'} /> <Cell label="입항일시(ETA)" value="-" />
</Grid> </Grid>
</Sec> </Sec>
</> </>
@ -1897,7 +1890,7 @@ function TabInfo({ v }: { v: Vessel }) {
/* ── Tab 1: 항해정보 ─────────────────────────────── */ /* ── Tab 1: 항해정보 ─────────────────────────────── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
function TabNav(_props: { v: Vessel }) { function TabNav(_props: { v: VesselPosition }) {
const hours = ['08', '09', '10', '11', '12', '13', '14']; const hours = ['08', '09', '10', '11', '12', '13', '14'];
const heights = [45, 60, 78, 82, 70, 85, 75]; const heights = [45, 60, 78, 82, 70, 85, 75];
const colors = [ const colors = [
@ -2020,28 +2013,30 @@ function TabNav(_props: { v: Vessel }) {
} }
/* ── Tab 2: 선박제원 ─────────────────────────────── */ /* ── Tab 2: 선박제원 ─────────────────────────────── */
function TabSpec({ v }: { v: Vessel }) { function TabSpec({ v }: { v: VesselPosition }) {
const loa = v.length !== undefined ? `${v.length} m` : '-';
const beam = v.width !== undefined ? `${v.width} m` : '-';
return ( return (
<> <>
<Sec title="📐 선체 제원"> <Sec title="📐 선체 제원">
<Grid> <Grid>
<Cell label="선종" value={v.typS} /> <Cell label="선종" value={displayVal(getShipKindLabel(v.shipKindCode) ?? v.shipTy)} />
<Cell label="선적국" value={`${v.flag}`} /> <Cell label="선적국" value={displayVal(v.nationalCode)} />
<Cell label="총톤수 (GT)" value={v.gt} /> <Cell label="총톤수 (GT)" value="-" />
<Cell label="재화중량 (DWT)" value={v.dwt} /> <Cell label="재화중량 (DWT)" value="-" />
<Cell label="전장 (LOA)" value={v.loa} /> <Cell label="전장 (LOA)" value={loa} />
<Cell label="선폭" value={v.beam} /> <Cell label="선폭" value={beam} />
<Cell label="건조년도" value={v.built} /> <Cell label="건조년도" value="-" />
<Cell label="건조 조선소" value={v.yard} /> <Cell label="건조 조선소" value="-" />
</Grid> </Grid>
</Sec> </Sec>
<Sec title="📡 통신 / 식별"> <Sec title="📡 통신 / 식별">
<Grid> <Grid>
<Cell label="MMSI" value={String(v.mmsi)} /> <Cell label="MMSI" value={String(v.mmsi)} />
<Cell label="IMO" value={v.imo} /> <Cell label="IMO" value={displayVal(v.imo)} />
<Cell label="호출부호" value={v.callSign} /> <Cell label="호출부호" value="-" />
<Cell label="선급" value={v.cls} /> <Cell label="선급" value="-" />
</Grid> </Grid>
</Sec> </Sec>
@ -2057,23 +2052,9 @@ function TabSpec({ v }: { v: Vessel }) {
> >
<span className="text-label-1">🛢</span> <span className="text-label-1">🛢</span>
<div className="flex-1"> <div className="flex-1">
<div className="text-caption font-semibold text-fg"> <div className="text-caption font-semibold text-fg">-</div>
{v.cargo.split('·')[0].trim()} <div className="text-caption text-fg-disabled"> </div>
</div>
<div className="text-caption text-fg-disabled">{v.cargo}</div>
</div> </div>
{v.cargo.includes('IMO') && (
<span
className="text-caption font-bold text-color-danger"
style={{
padding: '2px 6px',
background: 'color-mix(in srgb, var(--color-danger) 15%, transparent)',
borderRadius: 3,
}}
>
</span>
)}
</div> </div>
</div> </div>
</Sec> </Sec>
@ -2083,7 +2064,7 @@ function TabSpec({ v }: { v: Vessel }) {
/* ── Tab 3: 보험정보 ─────────────────────────────── */ /* ── Tab 3: 보험정보 ─────────────────────────────── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
function TabInsurance(_props: { v: Vessel }) { function TabInsurance(_props: { v: VesselPosition }) {
return ( return (
<> <>
<Sec title="🏢 선주 / 운항사"> <Sec title="🏢 선주 / 운항사">
@ -2155,7 +2136,8 @@ function TabInsurance(_props: { v: Vessel }) {
} }
/* ── Tab 4: 위험물정보 ───────────────────────────── */ /* ── Tab 4: 위험물정보 ───────────────────────────── */
function TabDangerous({ v }: { v: Vessel }) { // eslint-disable-next-line @typescript-eslint/no-unused-vars
function TabDangerous(_props: { v: VesselPosition }) {
return ( return (
<> <>
<Sec <Sec
@ -2175,11 +2157,7 @@ function TabDangerous({ v }: { v: Vessel }) {
} }
> >
<Grid> <Grid>
<Cell <Cell label="화물명" value="-" color="var(--color-warning)" />
label="화물명"
value={v.cargo.split('·')[0].trim() || '—'}
color="var(--color-warning)"
/>
<Cell label="컨테이너갯수/총량" value="— / 72,850 톤" /> <Cell label="컨테이너갯수/총량" value="— / 72,850 톤" />
<Cell label="하역업체코드" value="KRY-2847" /> <Cell label="하역업체코드" value="KRY-2847" />
<Cell label="하역기간" value="02-26 ~ 02-28" /> <Cell label="하역기간" value="02-26 ~ 02-28" />
@ -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 ( return (
<> <>
<div className="text-label-2 font-bold text-fg" style={{ marginBottom: 3 }}> <div className="text-label-2 font-bold text-fg" style={{ marginBottom: 3 }}>
{v.name} {v.shipNm ?? '(이름 없음)'}
</div> </div>
<div className="text-caption text-fg-disabled" style={{ marginBottom: 4 }}> <div className="text-caption text-fg-disabled" style={{ marginBottom: 4 }}>
{v.typS} · {v.flag} {typeText}
</div> </div>
<div className="flex justify-between text-caption"> <div className="flex justify-between text-caption">
<span className="text-color-accent font-semibold">{v.speed} kn</span> <span className="text-color-accent font-semibold">{speed}</span>
<span className="text-fg-disabled">HDG {v.heading}°</span> <span className="text-fg-disabled">{headingText}</span>
</div> </div>
</> </>
); );

파일 보기

@ -1,6 +1,10 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import type { Incident } from './IncidentsLeftPanel'; 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'; import type { MediaInfo, AerialMediaItem } from '../services/incidentsApi';
type MediaTab = 'all' | 'photo' | 'video' | 'satellite' | 'cctv'; 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 showPhoto = activeTab === 'all' || activeTab === 'photo';
const showVideo = activeTab === 'all' || activeTab === 'video'; 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]"> <div className="flex items-center gap-[6px]">
<span className="text-label-1">📷</span> <span className="text-label-1">📷</span>
<span className="text-label-1 font-bold text-fg"> <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> </span>
</div> </div>
<div className="flex gap-[4px]"> <div className="flex gap-[4px]">
{aerialImages.length > 1 && ( {aerialImages.length > 1 && (
<> <>
<NavBtn label="◀" onClick={() => setSelectedImageIdx((p) => Math.max(0, p - 1))} /> <NavBtn
<NavBtn label="▶" onClick={() => setSelectedImageIdx((p) => Math.min(aerialImages.length - 1, p + 1))} /> label="◀"
onClick={() => setSelectedImageIdx((p) => Math.max(0, p - 1))}
/>
<NavBtn
label="▶"
onClick={() =>
setSelectedImageIdx((p) => Math.min(aerialImages.length - 1, p + 1))
}
/>
</> </>
)} )}
<NavBtn label="↗" /> <NavBtn label="↗" />
@ -259,12 +279,18 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
style={{ width: '100%', height: '100%', objectFit: 'contain' }} style={{ width: '100%', height: '100%', objectFit: 'contain' }}
onError={(e) => { onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'; (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="hidden 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 className="text-label-1 text-fg-disabled"> </div> 📷
</div>
<div className="text-label-1 text-fg-disabled">
</div>
</div> </div>
{aerialImages.length > 1 && ( {aerialImages.length > 1 && (
<> <>
@ -272,7 +298,8 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
onClick={() => setSelectedImageIdx((prev) => Math.max(0, prev - 1))} 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" className="absolute left-2 top-1/2 -translate-y-1/2 flex items-center justify-center text-fg-disabled cursor-pointer rounded"
style={{ style={{
width: 28, height: 28, width: 28,
height: 28,
background: 'rgba(0,0,0,0.5)', background: 'rgba(0,0,0,0.5)',
border: '1px solid var(--stroke-default)', border: '1px solid var(--stroke-default)',
opacity: selectedImageIdx === 0 ? 0.3 : 1, opacity: selectedImageIdx === 0 ? 0.3 : 1,
@ -282,10 +309,15 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
</button> </button>
<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" className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center text-fg-disabled cursor-pointer rounded"
style={{ style={{
width: 28, height: 28, width: 28,
height: 28,
background: 'rgba(0,0,0,0.5)', background: 'rgba(0,0,0,0.5)',
border: '1px solid var(--stroke-default)', border: '1px solid var(--stroke-default)',
opacity: selectedImageIdx === aerialImages.length - 1 ? 0.3 : 1, 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="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"> <div className="text-label-1 text-fg-sub font-semibold">
{incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')} {incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')}
</div> </div>
<div className="text-caption text-fg-disabled font-mono"> <div className="text-caption text-fg-disabled font-mono">
{str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')} {str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')}
@ -335,10 +370,14 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
width: 48, width: 48,
height: 40, height: 40,
borderRadius: 4, borderRadius: 4,
background: i === selectedImageIdx ? 'rgba(6,182,212,0.15)' : 'var(--bg-elevated)', background:
border: i === selectedImageIdx i === selectedImageIdx
? '2px solid rgba(6,182,212,0.5)' ? 'rgba(6,182,212,0.15)'
: '1px solid var(--stroke-default)', : 'var(--bg-elevated)',
border:
i === selectedImageIdx
? '2px solid rgba(6,182,212,0.5)'
: '1px solid var(--stroke-default)',
}} }}
onClick={() => setSelectedImageIdx(i)} onClick={() => setSelectedImageIdx(i)}
> >
@ -393,7 +432,8 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-caption text-fg-disabled"> <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>
<span className="text-caption text-color-tertiary cursor-pointer"> <span className="text-caption text-color-tertiary cursor-pointer">
🔗 R&D 🔗 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"> <div className="flex gap-4 text-caption font-mono text-fg-disabled">
<span> <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>
<span> <span>
🎬 <b className="text-fg">{media.videoCnt ?? 0}</b> 🎬 <b className="text-fg">{media.videoCnt ?? 0}</b>

파일 보기

@ -48,7 +48,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
); );
case 'pending': case 'pending':
return ( 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> </span>
); );
@ -103,7 +103,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
return pages.map((page, index) => { return pages.map((page, index) => {
if (page === '...') { if (page === '...') {
return ( 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> </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 className="flex items-center justify-between px-5 py-4 border-b border-stroke">
<div> <div>
<h1 className="text-heading-3 text-fg"> </h1> <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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="relative"> <div className="relative">
@ -156,48 +156,48 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
{/* 테이블 */} {/* 테이블 */}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{loading ? ( {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"> <table className="w-full">
<thead className="sticky top-0 bg-bg-surface border-b border-stroke z-10"> <thead className="sticky top-0 bg-bg-surface border-b border-stroke z-10">
<tr> <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>
<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>
<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>
<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>
<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>
<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>
<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>
<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 KOSPS
</th> </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 POSEIDON
</th> </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 OpenDrift
</th> </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>
<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>
<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>
</tr> </tr>
@ -277,7 +277,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
)} )}
{!loading && analyses.length === 0 && ( {!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> </div>
)} )}

파일 보기

@ -115,7 +115,7 @@ export function BacktrackModal({
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h2 className="text-base font-bold m-0"> </h2> <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 AIS
</div> </div>
</div> </div>
@ -128,7 +128,7 @@ export function BacktrackModal({
background: 'var(--bg-card)', background: 'var(--bg-card)',
fontSize: 'var(--font-size-body-2)', 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> </button>
@ -160,7 +160,7 @@ export function BacktrackModal({
}} }}
className="border border-stroke" 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 <input
type="datetime-local" type="datetime-local"
value={inputTime} value={inputTime}
@ -179,7 +179,7 @@ export function BacktrackModal({
}} }}
className="border border-stroke" 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 <select
value={inputRange} value={inputRange}
onChange={(e) => setInputRange(e.target.value)} onChange={(e) => setInputRange(e.target.value)}
@ -201,7 +201,7 @@ export function BacktrackModal({
}} }}
className="border border-stroke" 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"> <div className="flex items-center gap-1">
<input <input
type="number" type="number"
@ -213,7 +213,7 @@ export function BacktrackModal({
step={0.5} step={0.5}
style={{ ...inputStyle, flex: 1 }} 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>
</div> </div>
@ -226,7 +226,7 @@ export function BacktrackModal({
}} }}
className="border border-stroke" 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"> <div className="text-label-1 font-semibold font-mono">
{conditions.spillLocation.lat.toFixed(4)}°N,{' '} {conditions.spillLocation.lat.toFixed(4)}°N,{' '}
{conditions.spillLocation.lon.toFixed(4)}°E {conditions.spillLocation.lon.toFixed(4)}°E
@ -243,10 +243,10 @@ export function BacktrackModal({
gridColumn: '1 / -1', 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"> <div className="text-body-2 font-bold text-color-tertiary font-mono">
{conditions.totalVessels}{' '} {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> </div>
</div> </div>
@ -380,7 +380,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="text-title-4 font-bold font-mono">{vessel.name}</div> <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} IMO: {vessel.imo} · {vessel.type} · {vessel.flag}
</div> </div>
</div> </div>
@ -391,7 +391,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
> >
{vessel.probability}% {vessel.probability}%
</div> </div>
<div className="text-caption text-fg-disabled"> </div> <div className="text-caption text-fg-default"> </div>
</div> </div>
</div> </div>
@ -429,7 +429,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
: '1px solid var(--stroke-default)', : '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 <div
style={{ style={{
color: s.highlight ? 'var(--color-danger)' : 'var(--fg-default)', color: s.highlight ? 'var(--color-danger)' : 'var(--fg-default)',

파일 보기

@ -27,7 +27,7 @@ export function BoomDeploymentTheoryView() {
</div> </div>
<div> <div>
<div className="text-title-2 font-bold"> </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 · · Oil Boom Deployment Optimization · ·
</div> </div>
</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 ${ className={`flex-1 py-2 px-1 text-label-1 font-medium rounded-md transition-all duration-150 cursor-pointer border ${
activePanel === tab.id activePanel === tab.id
? 'border-stroke-light bg-bg-elevated text-fg' ? '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} {tab.label}
@ -207,11 +207,11 @@ function OverviewPanel() {
<div className="font-bold" style={{ color: step.color }}> <div className="font-bold" style={{ color: step.color }}>
{step.label} {step.label}
</div> </div>
<div className="text-fg-disabled" style={{ whiteSpace: 'pre-line' }}> <div className="text-fg-default" style={{ whiteSpace: 'pre-line' }}>
{step.sub} {step.sub}
</div> </div>
</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>
))} ))}
</div> </div>
@ -369,10 +369,10 @@ function DeploymentTheoryPanel() {
F<sub>loss</sub>(U<sub>n</sub>) F<sub>loss</sub>(U<sub>n</sub>)
</span> </span>
<br />U<sub>n</sub> = U · sin(θ){' '} <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 <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> &gt; U<sub>c</sub>)<br /> <sub>c</sub>)²) (U<sub>n</sub> &gt; 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) U<sub>c</sub>: ( 0.35m/s = 0.7 knot)
</span> </span>
</div> </div>
@ -391,12 +391,12 @@ function DeploymentTheoryPanel() {
style={{ border: '1px solid rgba(6,182,212,.2)' }} style={{ border: '1px solid rgba(6,182,212,.2)' }}
> >
θ* = arcsin(U<sub>c</sub> / U){' '} θ* = 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 />θ<sub>opt</sub> = argmax [A<sub>block</sub>(θ) · E(θ,U)]
<br /> <br />
실용범위: 15° θ 60° 실용범위: 15° θ 60°
<br /> <br />
<span className="text-caption text-fg-disabled"> <span className="text-caption text-fg-default">
, θ &lt; arcsin(U<sub>c</sub>/U) , θ &lt; arcsin(U<sub>c</sub>/U)
</span> </span>
</div> </div>
@ -483,7 +483,7 @@ function DeploymentTheoryPanel() {
> >
A<sub>V</sub> = L²·sin(2α)/2 A<sub>V</sub> = L²·sin(2α)/2
<br /> <br />
<span className="text-fg-disabled">α: 반개각, L: 편측 </span> <span className="text-fg-default">α: 반개각, L: 편측 </span>
<br /> <br />
α = 30°~45° α = 30°~45°
</div> </div>
@ -554,7 +554,7 @@ function DeploymentTheoryPanel() {
> >
A<sub>U</sub> = π·r²/2 + 2r·h A<sub>U</sub> = π·r²/2 + 2r·h
<br /> <br />
<span className="text-fg-disabled">r: 반경, h: 직선부 </span> <span className="text-fg-default">r: 반경, h: 직선부 </span>
<br /> <br />
전제: U &lt; 0.5 knot 전제: U &lt; 0.5 knot
</div> </div>
@ -625,7 +625,7 @@ function DeploymentTheoryPanel() {
style={{ background: 'rgba(6,182,212,.05)' }} style={{ background: 'rgba(6,182,212,.05)' }}
> >
θ<sub>J</sub> = arcsin(U<sub>c</sub>/U) + δ<br /> θ<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 /> <br />
활용: U &gt; 0.7 knot 활용: U &gt; 0.7 knot
</div> </div>
@ -642,7 +642,7 @@ function DeploymentTheoryPanel() {
n개 : n개 :
<div className="mt-2 rounded-[5px] p-[9px] leading-[2] bg-bg-base font-mono"> <div className="mt-2 rounded-[5px] p-[9px] leading-[2] bg-bg-base font-mono">
E<sub>total</sub> = 1 (1E<sub>i</sub>)<br /> E<sub>total</sub> = 1 (1E<sub>i</sub>)<br />
<span className="text-caption text-fg-disabled"> <span className="text-caption text-fg-default">
E<sub>i</sub>: i번째 E<sub>i</sub>: i번째
</span> </span>
</div> </div>
@ -728,18 +728,18 @@ function OptimizationPanel() {
<b className="text-color-accent">:</b> <b className="text-color-accent">:</b>
<br /> <br />
f(x) = Σ A<sub>blocked,i</sub> · w<sub>ESI,i</sub>{' '} 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 /> <br />
f(x) = T<sub>deadline</sub> T<sub>deploy</sub>{' '} 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 /> <br />
<b className="text-color-info">:</b> <b className="text-color-info">:</b>
<br /> <br />
f(x) = Σ L<sub>boom,j</sub>{' '} f(x) = Σ L<sub>boom,j</sub>{' '}
<span className="text-caption text-fg-disabled">( )</span> <span className="text-caption text-fg-default">( )</span>
<br /> <br />
f(x) = Σ D<sub>vessel,k</sub>{' '} 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> </div>
<div <div
@ -752,19 +752,19 @@ function OptimizationPanel() {
style={{ background: 'rgba(59,130,246,.04)' }} style={{ background: 'rgba(59,130,246,.04)' }}
> >
g: U·sin(θ<sub>i</sub>) U<sub>c</sub> i{' '} 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 /> <br />
g: Σ L<sub>j</sub> L<sub>max</sub>{' '} 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 /> <br />
g: T<sub>deploy,i</sub> T<sub>arrive,i</sub>{' '} 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 /> <br />
g: d(p<sub>i</sub>, shore) d<sub>min</sub>{' '} 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 /> <br />
g: h(p<sub>i</sub>) h<sub>min</sub>{' '} 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> </div>
</div> </div>
@ -824,7 +824,7 @@ function OptimizationPanel() {
<div className="font-bold" style={{ color: esi.color }}> <div className="font-bold" style={{ color: esi.color }}>
{esi.grade} {esi.grade}
</div> </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 className="font-bold">{esi.w}</div>
</div> </div>
))} ))}
@ -933,7 +933,7 @@ function OptimizationPanel() {
{['알고리즘', '유형', '장점', '단점', 'WING 활용'].map((h) => ( {['알고리즘', '유형', '장점', '단점', 'WING 활용'].map((h) => (
<th <th
key={h} 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' }} style={{ textAlign: h === '알고리즘' ? 'left' : 'center' }}
> >
{h} {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 F<sub>D</sub> = ½ · ρ · C<sub>D</sub> · A · U<sub>n</sub>²<br />T = F<sub>D</sub> · L
/ (2·sin(α)) / (2·sin(α))
<br /> <br />
<span className="text-caption text-fg-disabled"> <span className="text-caption text-fg-default">
C<sub>D</sub>: (1.2), A: 수중 C<sub>D</sub>: (1.2), A: 수중
</span> </span>
<br /> <br />
<span className="text-caption text-fg-disabled">T: 연결부 , α: 체인각도</span> <span className="text-caption text-fg-default">T: 연결부 , α: 체인각도</span>
</div> </div>
</div> </div>
<div> <div>
@ -1054,11 +1054,11 @@ function FluidDynamicsPanel() {
<br /> <br />
Splash-over: Fr &gt; 0.5~0.6 Splash-over: Fr &gt; 0.5~0.6
<br /> <br />
<span className="text-caption text-fg-disabled"> <span className="text-caption text-fg-default">
Fr: 수정 Froude수, h: 오일펜스 Fr: 수정 Froude수, h: 오일펜스
</span> </span>
<br /> <br />
<span className="text-caption text-fg-disabled">Δρ/ρ: 기름- (~0.15)</span> <span className="text-caption text-fg-default">Δρ/ρ: 기름- (~0.15)</span>
</div> </div>
</div> </div>
</div> </div>
@ -1075,7 +1075,7 @@ function FluidDynamicsPanel() {
<div className="mt-2 rounded-[5px] p-[9px] leading-[2] bg-bg-base font-mono"> <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)) 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 /> <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 , φ: 최대 a: catenary , φ: 최대
</span> </span>
</div> </div>
@ -1392,7 +1392,7 @@ function ReferencesPanel() {
return ( return (
<> <>
<div className="text-label-1 font-bold mb-1">📚 </div> <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) => ( {categories.map((cat, ci) => (
<div key={ci} className="mb-4"> <div key={ci} className="mb-4">
@ -1430,7 +1430,7 @@ function ReferencesPanel() {
</div> </div>
<div> <div>
<div className="font-bold mb-0.5">{ref.title}</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 className="mt-0.5 text-fg-sub">{ref.desc}</div>
</div> </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)]"> <div className="flex items-center justify-between p-4 hover:bg-[rgba(255,255,255,0.02)]">
<h3 <h3
onClick={onToggle} 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> </h3>
@ -117,7 +117,7 @@ const InfoLayerSection = ({
> >
</button> </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 ? '▼' : '▶'} {expanded ? '▼' : '▶'}
</span> </span>
</div> </div>
@ -126,9 +126,9 @@ const InfoLayerSection = ({
{expanded && ( {expanded && (
<div className="px-4 pb-2"> <div className="px-4 pb-2">
{isLoading && effectiveLayers.length === 0 ? ( {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 ? ( ) : 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 <LayerTree
layers={effectiveLayers} layers={effectiveLayers}

파일 보기

@ -95,6 +95,8 @@ export function LeftPanel({
onSpillUnitChange, onSpillUnitChange,
boomLines, boomLines,
onBoomLinesChange, onBoomLinesChange,
showBoomLines,
onShowBoomLinesChange,
oilTrajectory, oilTrajectory,
algorithmSettings, algorithmSettings,
onAlgorithmSettingsChange, onAlgorithmSettingsChange,
@ -112,6 +114,7 @@ export function LeftPanel({
onLayerColorChange, onLayerColorChange,
sensitiveResources = [], sensitiveResources = [],
onImageAnalysisResult, onImageAnalysisResult,
onFlyToCoord,
validationErrors, validationErrors,
}: LeftPanelProps) { }: LeftPanelProps) {
const [expandedSections, setExpandedSections] = useState<ExpandedSections>({ const [expandedSections, setExpandedSections] = useState<ExpandedSections>({
@ -166,6 +169,7 @@ export function LeftPanel({
spillUnit={spillUnit} spillUnit={spillUnit}
onSpillUnitChange={onSpillUnitChange} onSpillUnitChange={onSpillUnitChange}
onImageAnalysisResult={onImageAnalysisResult} onImageAnalysisResult={onImageAnalysisResult}
onFlyToCoord={onFlyToCoord}
validationErrors={validationErrors} validationErrors={validationErrors}
/> />
@ -175,8 +179,8 @@ export function LeftPanel({
onClick={() => toggleSection('incident')} onClick={() => toggleSection('incident')}
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" 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> <h3 className="text-title-4 font-bold text-fg-default font-korean"></h3>
<span className="text-label-2 text-fg-disabled"> <span className="text-label-2 text-fg-default">
{expandedSections.incident ? '▼' : '▶'} {expandedSections.incident ? '▼' : '▶'}
</span> </span>
</div> </div>
@ -202,7 +206,7 @@ export function LeftPanel({
CLOSED: { CLOSED: {
label: '종료', label: '종료',
style: 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', dot: 'bg-fg-disabled',
}, },
}; };
@ -220,7 +224,7 @@ export function LeftPanel({
{/* Info Grid */} {/* Info Grid */}
<div className="grid gap-1"> <div className="grid gap-1">
<div className="flex items-baseline gap-1.5"> <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>
<span className="text-label-2 text-fg font-medium font-mono"> <span className="text-label-2 text-fg font-medium font-mono">
@ -228,7 +232,7 @@ export function LeftPanel({
</span> </span>
</div> </div>
<div className="flex items-baseline gap-1.5"> <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>
<span className="text-label-2 text-fg font-medium font-korean"> <span className="text-label-2 text-fg font-medium font-korean">
@ -236,7 +240,7 @@ export function LeftPanel({
</span> </span>
</div> </div>
<div className="flex items-baseline gap-1.5"> <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>
<span className="text-label-2 text-fg font-medium font-mono"> <span className="text-label-2 text-fg font-medium font-mono">
@ -246,7 +250,7 @@ export function LeftPanel({
</span> </span>
</div> </div>
<div className="flex items-baseline gap-1.5"> <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>
<span className="text-label-2 text-fg font-medium font-korean"> <span className="text-label-2 text-fg font-medium font-korean">
@ -254,7 +258,7 @@ export function LeftPanel({
</span> </span>
</div> </div>
<div className="flex items-baseline gap-1.5"> <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>
<span className="text-label-2 text-fg font-medium font-mono"> <span className="text-label-2 text-fg font-medium font-mono">
@ -264,7 +268,7 @@ export function LeftPanel({
</span> </span>
</div> </div>
<div className="flex items-baseline gap-1.5"> <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>
<span className="text-label-2 text-fg font-medium font-korean"> <span className="text-label-2 text-fg font-medium font-korean">
@ -272,7 +276,7 @@ export function LeftPanel({
</span> </span>
</div> </div>
<div className="flex items-baseline gap-1.5"> <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>
<span className="text-label-2 text-color-warning font-medium font-korean"> <span className="text-label-2 text-color-warning font-medium font-korean">
@ -283,7 +287,7 @@ export function LeftPanel({
</div> </div>
) : ( ) : (
<div className="px-4 pb-4"> <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> </p>
</div> </div>
@ -296,8 +300,8 @@ export function LeftPanel({
onClick={() => toggleSection('impactResources')} onClick={() => toggleSection('impactResources')}
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" 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> <h3 className="text-title-4 font-bold text-fg-default font-korean"> </h3>
<span className="text-label-2 text-fg-disabled"> <span className="text-label-2 text-fg-default">
{expandedSections.impactResources ? '▼' : '▶'} {expandedSections.impactResources ? '▼' : '▶'}
</span> </span>
</div> </div>
@ -305,7 +309,7 @@ export function LeftPanel({
{expandedSections.impactResources && ( {expandedSections.impactResources && (
<div className="px-4 pb-4"> <div className="px-4 pb-4">
{sensitiveResources.length === 0 ? ( {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> </p>
) : ( ) : (
@ -357,6 +361,8 @@ export function LeftPanel({
onToggle={() => toggleSection('oilBoom')} onToggle={() => toggleSection('oilBoom')}
boomLines={boomLines} boomLines={boomLines}
onBoomLinesChange={onBoomLinesChange} onBoomLinesChange={onBoomLinesChange}
showBoomLines={showBoomLines}
onShowBoomLinesChange={onShowBoomLinesChange}
oilTrajectory={oilTrajectory} oilTrajectory={oilTrajectory}
incidentCoord={incidentCoord ?? { lat: 0, lon: 0 }} incidentCoord={incidentCoord ?? { lat: 0, lon: 0 }}
algorithmSettings={algorithmSettings} algorithmSettings={algorithmSettings}

파일 보기

@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Eye, EyeOff } from 'lucide-react';
import type { import type {
BoomLine, BoomLine,
BoomLineCoord, BoomLineCoord,
@ -22,6 +23,8 @@ interface OilBoomSectionProps {
onDrawingPointsChange: (points: BoomLineCoord[]) => void; onDrawingPointsChange: (points: BoomLineCoord[]) => void;
containmentResult: ContainmentResult | null; containmentResult: ContainmentResult | null;
onContainmentResultChange: (result: ContainmentResult | null) => void; onContainmentResultChange: (result: ContainmentResult | null) => void;
showBoomLines: boolean;
onShowBoomLinesChange: (show: boolean) => void;
} }
const DEFAULT_SETTINGS: AlgorithmSettings = { const DEFAULT_SETTINGS: AlgorithmSettings = {
@ -44,6 +47,8 @@ const OilBoomSection = ({
onDrawingPointsChange, onDrawingPointsChange,
containmentResult, containmentResult,
onContainmentResultChange, onContainmentResultChange,
showBoomLines,
onShowBoomLinesChange,
}: OilBoomSectionProps) => { }: OilBoomSectionProps) => {
const [boomPlacementTab, setBoomPlacementTab] = useState<'ai' | 'simulation'>('simulation'); const [boomPlacementTab, setBoomPlacementTab] = useState<'ai' | 'simulation'>('simulation');
const [showResetConfirm, setShowResetConfirm] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false);
@ -81,8 +86,22 @@ const OilBoomSection = ({
onClick={onToggle} onClick={onToggle}
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" 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> <h3 className="text-title-4 font-bold text-fg-default font-korean"> </h3>
<span className="text-label-2 text-fg-disabled">{expanded ? '▼' : '▶'}</span> <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> </div>
{expanded && ( {expanded && (
@ -127,7 +146,7 @@ const OilBoomSection = ({
borderRadius: 'var(--radius-sm)', borderRadius: 'var(--radius-sm)',
border: '1px solid var(--stroke-default)', border: '1px solid var(--stroke-default)',
background: 'var(--bg-base)', background: 'var(--bg-base)',
color: hasData ? 'var(--color-danger)' : 'var(--fg-disabled)', color: 'var(--fg-disabled)',
cursor: hasData ? 'pointer' : 'not-allowed', cursor: hasData ? 'pointer' : 'not-allowed',
transition: '0.15s', transition: '0.15s',
}} }}
@ -150,7 +169,7 @@ const OilBoomSection = ({
<div className="text-label-2 font-bold text-fg font-korean mb-2"> <div className="text-label-2 font-bold text-fg font-korean mb-2">
</div> </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>
<div className="flex gap-2"> <div className="flex gap-2">
@ -218,12 +237,12 @@ const OilBoomSection = ({
className="border border-stroke" className="border border-stroke"
> >
<div <div
style={{ color: metric.color }} // style={{ color: metric.color }}
className="text-title-1 font-bold font-mono mb-[2px]" className="text-title-1 font-semibold font-mono mb-[2px]"
> >
{metric.value} {metric.value}
</div> </div>
<div className="text-caption text-fg-disabled">{metric.label}</div> <div className="text-caption text-fg-default">{metric.label}</div>
</div> </div>
))} ))}
</div> </div>
@ -242,16 +261,10 @@ const OilBoomSection = ({
width: '8px', width: '8px',
height: '8px', height: '8px',
borderRadius: '50%', borderRadius: '50%',
background: background: 'var(--fg-default)',
oilTrajectory.length > 0 ? 'var(--color-success)' : 'var(--color-danger)',
}} }}
/> />
<span <span className="text-fg-default">
style={{
color:
oilTrajectory.length > 0 ? 'var(--color-success)' : 'var(--fg-disabled)',
}}
>
{' '} {' '}
{oilTrajectory.length > 0 ? `(${oilTrajectory.length}개 입자)` : '없음'} {oilTrajectory.length > 0 ? `(${oilTrajectory.length}개 입자)` : '없음'}
</span> </span>
@ -261,7 +274,7 @@ const OilBoomSection = ({
{/* 알고리즘 설정 */} {/* 알고리즘 설정 */}
<div> <div>
<h4 <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)' }} style={{ letterSpacing: 'var(--letter-spacing-label)' }}
> >
📊 V자형 📊 V자형
@ -301,7 +314,7 @@ const OilBoomSection = ({
}} }}
className="flex items-center justify-between px-2.5 py-1.5 border border-stroke" 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} {setting.label}
</span> </span>
<div className="flex items-center gap-1 shrink-0 w-[80px] justify-end"> <div className="flex items-center gap-1 shrink-0 w-[80px] justify-end">
@ -315,7 +328,7 @@ const OilBoomSection = ({
className="boom-setting-input" className="boom-setting-input"
step={setting.key === 'waveHeightCorrectionFactor' ? 0.1 : 1} 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} {setting.unit}
</span> </span>
</div> </div>
@ -342,7 +355,7 @@ const OilBoomSection = ({
V자형 + V자형 +
</button> </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 , 1 (V형), U형 2 ,
3 . 3 .
</p> </p>
@ -363,7 +376,7 @@ const OilBoomSection = ({
<div className="text-heading-2 font-bold text-color-accent font-mono"> <div className="text-heading-2 font-bold text-color-accent font-mono">
{containmentResult.overallEfficiency}% {containmentResult.overallEfficiency}%
</div> </div>
<div className="text-label-2 text-fg-disabled mt-[2px]"> </div> <div className="text-label-2 text-fg-default mt-[2px]"> </div>
</div> </div>
{/* 차단/통과 카운트 */} {/* 차단/통과 카운트 */}
@ -380,7 +393,7 @@ const OilBoomSection = ({
<div className="text-title-2 font-bold text-color-success font-mono"> <div className="text-title-2 font-bold text-color-success font-mono">
{containmentResult.blockedParticles} {containmentResult.blockedParticles}
</div> </div>
<div className="text-caption text-fg-disabled"> </div> <div className="text-caption text-fg-default"> </div>
</div> </div>
<div <div
style={{ style={{
@ -394,7 +407,7 @@ const OilBoomSection = ({
<div className="text-title-2 font-bold text-color-danger font-mono"> <div className="text-title-2 font-bold text-color-danger font-mono">
{containmentResult.passedParticles} {containmentResult.passedParticles}
</div> </div>
<div className="text-caption text-fg-disabled"> </div> <div className="text-caption text-fg-default"> </div>
</div> </div>
</div> </div>
@ -485,13 +498,13 @@ const OilBoomSection = ({
className="mb-1.5" className="mb-1.5"
> >
<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"> <div className="text-title-3 font-bold font-mono text-fg">
{line.length.toFixed(0)}m {line.length.toFixed(0)}m
</div> </div>
</div> </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"> <div className="text-title-3 font-bold font-mono text-fg">
{line.angle.toFixed(0)}° {line.angle.toFixed(0)}°
</div> </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">🔴 POSEIDON</span>
<span className="text-label-2 font-medium text-fg-sub">🔵 OpenDrift</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 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> </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 ${ className={`flex-1 py-2 px-1 text-label-1 font-medium rounded-md transition-all duration-150 cursor-pointer border ${
activePanel === tab.id activePanel === tab.id
? 'border-stroke-light bg-bg-elevated text-fg' ? '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} {tab.icon} {tab.name}
@ -232,7 +232,7 @@ function SystemOverviewPanel() {
<div className={`${card} ${cardBg}`}> <div className={`${card} ${cardBg}`}>
<div className="flex items-center justify-between mb-3.5"> <div className="flex items-center justify-between mb-3.5">
<div style={labelStyle('var(--fg-default)')}>🤖 WING </div> <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>
<div className="grid grid-cols-3 gap-2.5 mb-3.5"> <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 }}> <div className="text-label-1 font-bold" style={{ color: m.color }}>
{m.name} {m.name}
</div> </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> </div>
<div className="text-label-2 mb-2 text-fg-sub leading-[1.7]">{m.desc}</div> <div className="text-label-2 mb-2 text-fg-sub leading-[1.7]">{m.desc}</div>
@ -337,7 +337,7 @@ function SystemOverviewPanel() {
}} }}
> >
<th <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%' }} style={{ width: '15%' }}
> >
@ -469,7 +469,7 @@ function SystemOverviewPanel() {
}} }}
> >
<td <td
className="py-[7px] px-3 text-fg-disabled" className="py-[7px] px-3 text-fg-default"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: sanitizeHtml(row.label.replace(/\n/g, '<br>')), __html: sanitizeHtml(row.label.replace(/\n/g, '<br>')),
}} }}
@ -538,7 +538,7 @@ function KospsPanel() {
<div className="text-title-2 font-bold text-fg"> <div className="text-title-2 font-bold text-fg">
KOSPS (Korea Oil Spill Prediction System) KOSPS (Korea Oil Spill Prediction System)
</div> </div>
<div className="text-label-2 mt-0.5 text-fg-disabled"> <div className="text-label-2 mt-0.5 text-fg-default">
(KORDI) · (KORDI) ·
</div> </div>
</div> </div>
@ -584,9 +584,9 @@ function KospsPanel() {
{/* 특허 1 */} {/* 특허 1 */}
<div className="rounded-lg p-3 bg-bg-base border border-stroke flex gap-3 items-start"> <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="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 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>
<div className="flex flex-col gap-1.5 text-label-2 min-w-0"> <div className="flex flex-col gap-1.5 text-label-2 min-w-0">
<div className="font-bold text-fg"> <div className="font-bold text-fg">
@ -611,7 +611,7 @@ function KospsPanel() {
</span> </span>
))} ))}
</div> </div>
<div className="text-fg-disabled"> <div className="text-fg-default">
R&amp;D: 3 ( 65%) HNS R&amp;D: 3 ( 65%) HNS
( 35%) | ( 35%) |
</div> </div>
@ -632,7 +632,7 @@ function KospsPanel() {
</div> </div>
<div className="grid grid-cols-2 gap-2.5"> <div className="grid grid-cols-2 gap-2.5">
<div className={codeBox}> <div className={codeBox}>
<span className="text-fg-disabled text-label-2">/* 변조조석 수식 */</span> <span className="text-fg-default text-label-2">/* 변조조석 수식 */</span>
<br /> <br />
ζ(t) = A(t) cos[σt θ(t)] ζ(t) = A(t) cos[σt θ(t)]
<br /> <br />
@ -712,7 +712,7 @@ function KospsPanel() {
<span className="font-medium"> <span className="font-medium">
{d.icon} {d.label} {d.icon} {d.label}
</span> </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>
))} ))}
</div> </div>
@ -725,14 +725,14 @@ function KospsPanel() {
style={{ background: 'rgba(6,182,212,.04)', border: '1px solid rgba(6,182,212,.12)' }} 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="font-medium mb-0.5">📍 ·</div>
<div className="text-fg-disabled">(ENC) 500m </div> <div className="text-fg-default">(ENC) 500m </div>
</div> </div>
<div <div
className="px-2.5 py-1.5 rounded-md" className="px-2.5 py-1.5 rounded-md"
style={{ background: 'rgba(6,182,212,.04)', border: '1px solid rgba(6,182,212,.12)' }} 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="font-medium mb-0.5">🗺 </div>
<div className="text-fg-disabled"> </div> <div className="text-fg-default"> </div>
</div> </div>
</div> </div>
</div> </div>
@ -744,12 +744,12 @@ function KospsPanel() {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<div className={`${codeBox} mb-2`}> <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 /> <br />
V_WDC = <span className="text-color-accent">0.029</span> × V_wind V_WDC = <span className="text-color-accent">0.029</span> × V_wind
</div> </div>
<div className={codeBox}> <div className={codeBox}>
<span className="text-fg-disabled text-label-2">/* 취송류 유향 */</span> <span className="text-fg-default text-label-2">/* 취송류 유향 */</span>
<br /> <br />
θ_WDC = θ_wind + <span className="text-color-accent">18.6°</span> θ_WDC = θ_wind + <span className="text-color-accent">18.6°</span>
</div> </div>
@ -810,7 +810,7 @@ function KospsPanel() {
<div className="font-bold" style={{ color: node.color }}> <div className="font-bold" style={{ color: node.color }}>
{node.label} {node.label}
</div> </div>
<div className="text-label-2 text-fg-disabled">{node.sub}</div> <div className="text-label-2 text-fg-default">{node.sub}</div>
</div> </div>
{i < 5 && ( {i < 5 && (
<div className="w-[30px] h-px" style={{ background: 'var(--stroke-light)' }} /> <div className="w-[30px] h-px" style={{ background: 'var(--stroke-light)' }} />
@ -818,7 +818,7 @@ function KospsPanel() {
</div> </div>
))} ))}
</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 FTP DB
</div> </div>
<div <div
@ -848,7 +848,7 @@ function KospsPanel() {
<div className="text-title-4 font-bold text-fg"> <div className="text-title-4 font-bold text-fg">
( 10-1567431) ( 10-1567431)
</div> </div>
<div className="text-label-2 mt-0.5 text-fg-disabled"> <div className="text-label-2 mt-0.5 text-fg-default">
· ·
· 2015 · 2015
</div> </div>
@ -1058,7 +1058,7 @@ function KospsPanel() {
</div> </div>
<div className="mt-1.5 p-1.5 rounded bg-bg-base font-mono text-label-1 leading-loose"> <div className="mt-1.5 p-1.5 rounded bg-bg-base font-mono text-label-1 leading-loose">
z(x,y) = Σ Σ qᵢⱼ xⁱ {' '} z(x,y) = Σ Σ qᵢⱼ xⁱ {' '}
<span className="text-label-2 text-fg-disabled">(i5, i+j5)</span> <span className="text-label-2 text-fg-default">(i5, i+j5)</span>
</div> </div>
</div> </div>
<div className="rounded-lg p-3" className="bg-bg-card border border-stroke"> <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]"> <div className="text-fg-sub leading-[1.6]">
3 3
<br /> <br />
<span className="text-fg-disabled"> | 2013.01~2013.12</span> <span className="text-fg-default"> | 2013.01~2013.12</span>
</div> </div>
</div> </div>
<div <div
@ -1118,7 +1118,7 @@ function KospsPanel() {
<div className="text-fg-sub leading-[1.6]"> <div className="text-fg-sub leading-[1.6]">
(HNS) (HNS)
<br /> <br />
<span className="text-fg-disabled"> | 2013.01~2013.12</span> <span className="text-fg-default"> | 2013.01~2013.12</span>
</div> </div>
</div> </div>
</div> </div>
@ -1163,7 +1163,7 @@ function KospsPanel() {
</span> </span>
</div> </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 · KOSPS · · 3D ·
· ·
</div> </div>
@ -1246,10 +1246,10 @@ function KospsPanel() {
</span> </span>
))} ))}
</div> </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>
<div className="text-label-2 font-bold mb-1">{paper.title}</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 className="text-label-2 text-fg-sub leading-[1.7]">{paper.desc}</div>
</div> </div>
))} ))}
@ -1405,7 +1405,7 @@ function KospsPanel() {
</div> </div>
<div> <div>
<div className="font-bold mb-0.5">{paper.title}</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.authors} | <b style={{ color: paper.color }}>{paper.journal}</b>{' '}
{paper.detail} {paper.detail}
</div> </div>
@ -1542,7 +1542,7 @@ function KospsPanel() {
</div> </div>
<div> <div>
<div className="font-bold mb-0.5">{paper.title}</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.authors} | <b style={{ color: paper.color }}>{paper.journal}</b>{' '}
{paper.detail} {paper.detail}
</div> </div>
@ -1570,7 +1570,7 @@ function PoseidonPanel() {
<div className="text-title-2 font-bold text-fg"> <div className="text-title-2 font-bold text-fg">
POSEIDON ( ) POSEIDON ( )
</div> </div>
<div className="text-label-2 mt-0.5 text-fg-disabled"> <div className="text-label-2 mt-0.5 text-fg-default">
· () · · MOHID · () · · MOHID
· ·
</div> </div>
@ -1631,9 +1631,9 @@ function PoseidonPanel() {
fontFamily: 'var(--font-mono)', 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 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> <div>
<div className="text-label-2 font-bold mb-1"> <div className="text-label-2 font-bold mb-1">
@ -1724,7 +1724,7 @@ function PoseidonPanel() {
<div style={labelStyle('var(--color-info)')}>POSEIDON </div> <div style={labelStyle('var(--color-info)')}>POSEIDON </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<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">
1 () 1 ()
</div> </div>
<div className={codeBox}> <div className={codeBox}>
@ -1732,12 +1732,12 @@ function PoseidonPanel() {
<br /> <br />
Model_y = Δt × current_v + Δt × c × wind_v Model_y = Δt × current_v + Δt × c × wind_v
</div> </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% ) c : 풍속 (: c=0.3 30% )
</div> </div>
</div> </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 ( ) 2 ( )
</div> </div>
<div className={codeBox}> <div className={codeBox}>
@ -1749,7 +1749,7 @@ function PoseidonPanel() {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;+ a5·Model_x + &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;+ a5·Model_x +
a6·Model_y + a7 a6·Model_y + a7
</div> </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로 a1~a7 : GA·DE·PSO로
</div> </div>
</div> </div>
@ -1760,7 +1760,7 @@ function PoseidonPanel() {
<div style={labelStyle('var(--fg-default)')}>🔄 POSEIDON_V2 </div> <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"> <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="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 / DATA PREP /
</div> </div>
{/* 4대 도메인 실행 모듈 */} {/* 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 ) POSEIDON 4 (EA012 KO108 )
</div> </div>
<div className="grid grid-cols-4 gap-2 mb-3"> <div className="grid grid-cols-4 gap-2 mb-3">
@ -1887,7 +1887,7 @@ function PoseidonPanel() {
</div> </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 HYDR + WAVE + TIDE OILS GA/DE/PSO
</div> </div>
@ -1914,7 +1914,7 @@ function PoseidonPanel() {
<div className="font-bold" style={{ color: node.color }}> <div className="font-bold" style={{ color: node.color }}>
{node.label} {node.label}
</div> </div>
<div className="text-label-2 text-fg-disabled">{node.sub}</div> <div className="text-label-2 text-fg-default">{node.sub}</div>
</div> </div>
{i < 2 && ( {i < 2 && (
<div className="w-[30px] h-px" style={{ background: 'var(--stroke-light)' }} /> <div className="w-[30px] h-px" style={{ background: 'var(--stroke-light)' }} />
@ -1961,7 +1961,7 @@ function PoseidonPanel() {
</div> </div>
<div> <div>
<div className="text-label-1 font-bold">POSEIDON관련 </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 · · · · MOHID
· ·
</div> </div>
@ -2040,10 +2040,10 @@ function PoseidonPanel() {
</span> </span>
))} ))}
</div> </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>
<div className="text-label-2 font-bold mb-1">{paper.title}</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 className="text-label-2 text-fg-sub leading-[1.7]">{paper.desc}</div>
</div> </div>
))} ))}
@ -2066,7 +2066,7 @@ function OpenDriftPanel() {
<div className="text-title-2 font-bold text-fg"> <div className="text-title-2 font-bold text-fg">
OpenDrift ( ) OpenDrift ( )
</div> </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 MET Norway · OpenOil · Python · IMO/IPIECA
</div> </div>
</div> </div>
@ -2210,7 +2210,7 @@ function OpenDriftPanel() {
<div className="text-label-2 font-medium" style={{ color: w.color }}> <div className="text-label-2 font-medium" style={{ color: w.color }}>
{w.title} {w.title}
</div> </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>
))} ))}
</div> </div>
@ -2244,7 +2244,7 @@ function OpenDriftPanel() {
<div className="font-bold" style={{ color: node.color }}> <div className="font-bold" style={{ color: node.color }}>
{node.label} {node.label}
</div> </div>
<div className="text-label-2 text-fg-disabled">{node.sub}</div> <div className="text-label-2 text-fg-default">{node.sub}</div>
</div> </div>
{i < 6 && ( {i < 6 && (
<div className="w-[24px] h-px" style={{ background: 'var(--stroke-light)' }} /> <div className="w-[24px] h-px" style={{ background: 'var(--stroke-light)' }} />
@ -2252,7 +2252,7 @@ function OpenDriftPanel() {
</div> </div>
))} ))}
</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 (NEMO·ROMS·HYCOM) + (ECMWF·GFS) NOAA Oil Library
OpenDrift/OpenOil NetCDF · OpenDrift/OpenOil NetCDF ·
</div> </div>
@ -2271,7 +2271,7 @@ function OpenDriftPanel() {
<div className="text-label-1 font-bold"> <div className="text-label-1 font-bold">
OpenDrift / OpenOil OpenDrift / OpenOil
</div> </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 3 WING
</div> </div>
</div> </div>
@ -2309,13 +2309,13 @@ function OpenDriftPanel() {
</span> </span>
))} ))}
</div> </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>
<div className="text-label-2 font-bold mb-1 leading-normal"> <div className="text-label-2 font-bold mb-1 leading-normal">
Numerical Model Test of Spilled Oil Transport Near the Korean Coasts Using Various Numerical Model Test of Spilled Oil Transport Near the Korean Coasts Using Various
Input Parametric Models Input Parametric Models
</div> </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 Hai Van Dang, Suchan Joo, Junhyeok Lim, Jinhwan Hur, Sungwon Shin | Hanyang University
ERICA | Journal of Ocean Engineering and Technology, 2024 ERICA | Journal of Ocean Engineering and Technology, 2024
</div> </div>
@ -2417,13 +2417,13 @@ function OpenDriftPanel() {
</span> </span>
))} ))}
</div> </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>
<div className="text-label-2 font-bold mb-1 leading-normal"> <div className="text-label-2 font-bold mb-1 leading-normal">
(Oil Spill Behavior Forecasting Model in (Oil Spill Behavior Forecasting Model in
South-eastern Coastal Area of Korea) South-eastern Coastal Area of Korea)
</div> </div>
<div className="text-label-2 mb-2 text-fg-disabled"> <div className="text-label-2 mb-2 text-fg-default">
, , , | | Vol.1 , , , | | Vol.1
No.2, pp.5259, 1998 No.2, pp.5259, 1998
</div> </div>
@ -2520,13 +2520,13 @@ function OpenDriftPanel() {
</span> </span>
))} ))}
</div> </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>
<div className="text-label-2 font-bold mb-1 leading-normal"> <div className="text-label-2 font-bold mb-1 leading-normal">
(Analysis of Oil Spill Dispersion in Taean (Analysis of Oil Spill Dispersion in Taean
Coastal Zone) Coastal Zone)
</div> </div>
<div className="text-label-2 mb-2 text-fg-disabled"> <div className="text-label-2 mb-2 text-fg-default">
, | | · 17 , | | · 17
pp.6063, 2008 pp.6063, 2008
</div> </div>
@ -2593,7 +2593,7 @@ function OpenDriftPanel() {
}} }}
> >
<div className="font-bold text-color-info">α = 3%</div> <div className="font-bold text-color-info">α = 3%</div>
<div className="text-fg-disabled"> </div> <div className="text-fg-default"> </div>
</div> </div>
<div <div
className="px-2 py-1 rounded text-center" 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="font-bold text-color-caution">α = 2.5%</div>
<div className="text-fg-disabled"> </div> <div className="text-fg-default"> </div>
</div> </div>
<div <div
className="px-2 py-1 rounded text-center" 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="font-bold text-color-info">α = 2% </div>
<div className="text-fg-disabled"> </div> <div className="text-fg-default"> </div>
</div> </div>
<div <div
className="px-2 py-1 rounded text-center" 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="font-bold text-color-accent">θ = 20° </div>
<div className="text-fg-disabled"> </div> <div className="text-fg-default"> </div>
</div> </div>
</div> </div>
</div> </div>
@ -2733,14 +2733,14 @@ function LagrangianPanel() {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<div className={`${codeBox} mb-2`}> <div className={`${codeBox} mb-2`}>
<span className="text-fg-disabled text-label-2">/* 중력-관성 체제 (초기) */</span> <span className="text-fg-default text-label-2">/* 중력-관성 체제 (초기) */</span>
<br /> <br />
R(t) = <span className="text-color-accent">K</span> · ( R(t) = <span className="text-color-accent">K</span> · (
<span className="text-color-accent">ΔρgV²</span> /{' '} <span className="text-color-accent">ΔρgV²</span> /{' '}
<span className="text-color-info">ρw</span>)<sup>¼</sup> · t<sup>½</sup> <span className="text-color-info">ρw</span>)<sup>¼</sup> · t<sup>½</sup>
</div> </div>
<div className={codeBox}> <div className={codeBox}>
<span className="text-fg-disabled text-label-2">/* 중력-점성 체제 (후기) */</span> <span className="text-fg-default text-label-2">/* 중력-점성 체제 (후기) */</span>
<br /> <br />
R(t) = <span className="text-color-accent">K</span> · ( R(t) = <span className="text-color-accent">K</span> · (
<span className="text-color-accent">ΔρgV²</span> /{' '} <span className="text-color-accent">ΔρgV²</span> /{' '}
@ -2832,7 +2832,7 @@ function WeatheringPanel() {
<div style={labelStyle(w.color)}>{w.title}</div> <div style={labelStyle(w.color)}>{w.title}</div>
<div className={`${bodyText} mb-2`}>{w.desc}</div> <div className={`${bodyText} mb-2`}>{w.desc}</div>
<div className={codeBox}>{w.formula}</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>
))} ))}
</div> </div>
@ -2883,7 +2883,7 @@ function WeatheringPanel() {
{s.time} {s.time}
</div> </div>
<div className="text-label-2 font-medium mb-1">{s.title}</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} {s.desc}
</div> </div>
</div> </div>
@ -2934,7 +2934,7 @@ function OceanInputPanel() {
}} }}
> >
<div className="font-medium mb-0.5">{t.label}</div> <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>
))} ))}
</div> </div>
@ -2957,7 +2957,7 @@ function OceanInputPanel() {
}} }}
> >
<div className="font-medium mb-0.5">{t.label}</div> <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>
))} ))}
</div> </div>
@ -3013,7 +3013,7 @@ function VerificationPanel() {
> >
{s.value} {s.value}
</div> </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>
))} ))}
</div> </div>
@ -3202,7 +3202,7 @@ function VerificationPanel() {
{paper.system} {paper.system}
</span> </span>
</div> </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.authors} | <b style={{ color: paper.color }}>{paper.journal}</b>{' '}
{paper.detail} {paper.detail}
</div> </div>
@ -3314,7 +3314,7 @@ function RoadmapPanel() {
}} }}
> >
<div className="font-medium mb-0.5">{r.title}</div> <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>
))} ))}
</div> </div>
@ -3367,7 +3367,7 @@ function RoadmapPanel() {
{s.phase} {s.phase}
</div> </div>
<div className="text-label-2 font-medium mb-1">{s.title}</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} {s.desc}
</div> </div>
</div> </div>

파일 보기

@ -1,4 +1,6 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useVesselSignals } from '@common/hooks/useVesselSignals';
import type { MapBounds } from '@common/types/vessel';
import { LeftPanel } from './LeftPanel'; import { LeftPanel } from './LeftPanel';
import { RightPanel } from './RightPanel'; import { RightPanel } from './RightPanel';
import { MapView } from '@common/components/map/MapView'; import { MapView } from '@common/components/map/MapView';
@ -173,6 +175,8 @@ export function OilSpillView() {
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined); const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
const flyToTarget = null; const flyToTarget = null;
const fitBoundsTarget = null; const fitBoundsTarget = null;
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const vessels = useVesselSignals(mapBounds);
const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [isSelectingLocation, setIsSelectingLocation] = useState(false);
const [oilTrajectory, setOilTrajectory] = useState<OilParticle[]>([]); const [oilTrajectory, setOilTrajectory] = useState<OilParticle[]>([]);
const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([]); const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([]);
@ -208,6 +212,7 @@ export function OilSpillView() {
// 오일펜스 배치 상태 // 오일펜스 배치 상태
const [boomLines, setBoomLines] = useState<BoomLine[]>([]); const [boomLines, setBoomLines] = useState<BoomLine[]>([]);
const [showBoomLines, setShowBoomLines] = useState(true);
const [algorithmSettings, setAlgorithmSettings] = useState<AlgorithmSettings>({ const [algorithmSettings, setAlgorithmSettings] = useState<AlgorithmSettings>({
currentOrthogonalCorrection: 15, currentOrthogonalCorrection: 15,
safetyMarginMinutes: 60, safetyMarginMinutes: 60,
@ -1191,6 +1196,8 @@ export function OilSpillView() {
onSpillUnitChange={setSpillUnit} onSpillUnitChange={setSpillUnit}
boomLines={boomLines} boomLines={boomLines}
onBoomLinesChange={setBoomLines} onBoomLinesChange={setBoomLines}
showBoomLines={showBoomLines}
onShowBoomLinesChange={setShowBoomLines}
oilTrajectory={oilTrajectory} oilTrajectory={oilTrajectory}
algorithmSettings={algorithmSettings} algorithmSettings={algorithmSettings}
onAlgorithmSettingsChange={setAlgorithmSettings} onAlgorithmSettingsChange={setAlgorithmSettings}
@ -1208,6 +1215,9 @@ export function OilSpillView() {
onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))} onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))}
sensitiveResources={sensitiveResourceCategories} sensitiveResources={sensitiveResourceCategories}
onImageAnalysisResult={handleImageAnalysisResult} onImageAnalysisResult={handleImageAnalysisResult}
onFlyToCoord={(c: { lon: number; lat: number }) =>
setFlyToCoord({ lat: c.lat, lon: c.lon })
}
validationErrors={validationErrors} validationErrors={validationErrors}
/> />
</div> </div>
@ -1281,6 +1291,7 @@ export function OilSpillView() {
)} )}
selectedModels={selectedModels} selectedModels={selectedModels}
boomLines={boomLines} boomLines={boomLines}
showBoomLines={showBoomLines}
isDrawingBoom={isDrawingBoom} isDrawingBoom={isDrawingBoom}
drawingPoints={drawingPoints} drawingPoints={drawingPoints}
layerOpacity={layerOpacity} layerOpacity={layerOpacity}
@ -1321,6 +1332,8 @@ export function OilSpillView() {
showBeached={displayControls.showBeached} showBeached={displayControls.showBeached}
showTimeLabel={displayControls.showTimeLabel} showTimeLabel={displayControls.showTimeLabel}
simulationStartTime={accidentTime || undefined} simulationStartTime={accidentTime || undefined}
vessels={vessels}
onBoundsChange={setMapBounds}
/> />
{/* 타임라인 플레이어 (리플레이 비활성 시) */} {/* 타임라인 플레이어 (리플레이 비활성 시) */}
@ -1659,7 +1672,7 @@ export function OilSpillView() {
fontSize: 'var(--font-size-caption)', fontSize: 'var(--font-size-caption)',
}} }}
> >
<span className="text-fg-disabled">{s.label}</span> <span className="text-fg-default">{s.label}</span>
<span <span
style={{ style={{
color: s.color, color: s.color,

파일 보기

@ -1,8 +1,8 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { ComboBox } from '@common/components/ui/ComboBox'; import { ComboBox } from '@common/components/ui/ComboBox';
import type { PredictionModel } from './OilSpillView'; import type { PredictionModel } from './OilSpillView';
import { analyzeImage } from '../services/predictionApi'; import { analyzeImage, fetchGscAccidents } from '../services/predictionApi';
import type { ImageAnalyzeResult } from '../services/predictionApi'; import type { ImageAnalyzeResult, GscAccidentListItem } from '../services/predictionApi';
interface PredictionInputSectionProps { interface PredictionInputSectionProps {
expanded: boolean; expanded: boolean;
@ -33,6 +33,7 @@ interface PredictionInputSectionProps {
spillUnit: string; spillUnit: string;
onSpillUnitChange: (unit: string) => void; onSpillUnitChange: (unit: string) => void;
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void; onImageAnalysisResult?: (result: ImageAnalyzeResult) => void;
onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
validationErrors?: Set<string>; validationErrors?: Set<string>;
} }
@ -64,6 +65,7 @@ const PredictionInputSection = ({
spillUnit, spillUnit,
onSpillUnitChange, onSpillUnitChange,
onImageAnalysisResult, onImageAnalysisResult,
onFlyToCoord,
validationErrors, validationErrors,
}: PredictionInputSectionProps) => { }: PredictionInputSectionProps) => {
const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct'); const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct');
@ -71,8 +73,41 @@ const PredictionInputSection = ({
const [isAnalyzing, setIsAnalyzing] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analyzeError, setAnalyzeError] = useState<string | null>(null); const [analyzeError, setAnalyzeError] = useState<string | null>(null);
const [analyzeResult, setAnalyzeResult] = useState<ImageAnalyzeResult | null>(null); const [analyzeResult, setAnalyzeResult] = useState<ImageAnalyzeResult | null>(null);
const [gscAccidents, setGscAccidents] = useState<GscAccidentListItem[]>([]);
const [selectedGscMngNo, setSelectedGscMngNo] = useState<string>('');
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let cancelled = false;
fetchGscAccidents()
.then((list) => {
if (!cancelled) setGscAccidents(list);
})
.catch((err) => {
console.error('[prediction] GSC 사고 목록 조회 실패:', err);
});
return () => {
cancelled = true;
};
}, []);
const handleGscAccidentSelect = (mngNo: string) => {
setSelectedGscMngNo(mngNo);
const item = gscAccidents.find((a) => a.acdntMngNo === mngNo);
if (!item) return;
onIncidentNameChange(item.pollNm);
if (item.pollDate) onAccidentTimeChange(item.pollDate);
if (item.lat != null && item.lon != null) {
onCoordChange({ lat: item.lat, lon: item.lon });
onFlyToCoord?.({ lat: item.lat, lon: item.lon });
}
};
const gscOptions = gscAccidents.map((a) => ({
value: a.acdntMngNo,
label: `${a.pollNm} (${a.pollDate ? a.pollDate.replace('T', ' ') : '-'})`,
}));
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null; const file = e.target.files?.[0] ?? null;
setUploadedFile(file); setUploadedFile(file);
@ -119,8 +154,8 @@ const PredictionInputSection = ({
onClick={onToggle} onClick={onToggle}
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" 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> <h3 className="text-title-4 font-bold text-fg-default font-korean"> </h3>
<span className="text-label-2 text-fg-disabled">{expanded ? '▼' : '▶'}</span> <span className="text-label-2 text-fg-default">{expanded ? '▼' : '▶'}</span>
</div> </div>
{expanded && ( {expanded && (
@ -161,7 +196,13 @@ const PredictionInputSection = ({
: undefined : undefined
} }
/> />
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" readOnly /> <ComboBox
className="prd-i"
value={selectedGscMngNo}
onChange={handleGscAccidentSelect}
options={gscOptions}
placeholder="또는 사고 리스트에서 선택"
/>
{/* Image Upload Mode */} {/* Image Upload Mode */}
{inputMode === 'upload' && ( {inputMode === 'upload' && (
@ -169,7 +210,7 @@ const PredictionInputSection = ({
{/* 파일 선택 영역 */} {/* 파일 선택 영역 */}
{!uploadedFile ? ( {!uploadedFile ? (
<label <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={{ style={{
padding: '20px', padding: '20px',
background: 'var(--bg-base)', background: 'var(--bg-base)',
@ -203,7 +244,7 @@ const PredictionInputSection = ({
<span className="text-fg-sub">📄 {uploadedFile.name}</span> <span className="text-fg-sub">📄 {uploadedFile.name}</span>
<button <button
onClick={handleRemoveFile} 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' }} style={{ padding: '2px 6px', transition: '0.15s' }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--color-danger)'; e.currentTarget.style.color = 'var(--color-danger)';
@ -258,7 +299,7 @@ const PredictionInputSection = ({
> >
<br /> <br />
<span className="font-normal text-fg-disabled"> <span className="font-normal text-fg-default">
{analyzeResult.lat.toFixed(4)} / {analyzeResult.lon.toFixed(4)} {analyzeResult.lat.toFixed(4)} / {analyzeResult.lon.toFixed(4)}
<br /> <br />
: {analyzeResult.oilType} / : {analyzeResult.area.toFixed(1)} m² : {analyzeResult.oilType} / : {analyzeResult.area.toFixed(1)} m²
@ -270,9 +311,7 @@ const PredictionInputSection = ({
{/* 사고 발생 시각 */} {/* 사고 발생 시각 */}
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<label className="text-label-2 text-fg-disabled font-korean"> <label className="text-label-2 text-fg-default font-korean"> (KST)</label>
(KST)
</label>
<DateTimeInput <DateTimeInput
value={accidentTime} value={accidentTime}
onChange={onAccidentTimeChange} onChange={onAccidentTimeChange}
@ -551,7 +590,7 @@ function DateTimeInput({
{/* 시 */} {/* 시 */}
<TimeDropdown value={hh} max={24} onChange={(v) => updateTime(v, mm)} /> <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)} /> <TimeDropdown value={mm} max={60} onChange={(v) => updateTime(hh, v)} />
@ -577,7 +616,7 @@ function DateTimeInput({
<button <button
type="button" type="button"
onClick={prevMonth} 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> </button>
@ -587,7 +626,7 @@ function DateTimeInput({
<button <button
type="button" type="button"
onClick={nextMonth} 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> </button>
@ -597,7 +636,7 @@ function DateTimeInput({
{['일', '월', '화', '수', '목', '금', '토'].map((d) => ( {['일', '월', '화', '수', '목', '금', '토'].map((d) => (
<span <span
key={d} key={d}
className="text-label-2 text-fg-disabled font-korean" className="text-label-2 text-fg-default font-korean"
style={{ padding: '2px 0' }} style={{ padding: '2px 0' }}
> >
{d} {d}
@ -779,7 +818,7 @@ function DmsCoordInput({
return ( return (
<div className="flex flex-col gap-0.5"> <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 <div
className="flex items-center gap-0.5" className="flex items-center gap-0.5"
style={ style={
@ -822,7 +861,7 @@ function DmsCoordInput({
onChange={(e) => update(parseInt(e.target.value) || 0, m, s, dir)} onChange={(e) => update(parseInt(e.target.value) || 0, m, s, dir)}
style={fieldStyle} style={fieldStyle}
/> />
<span className="text-caption text-fg-disabled">°</span> <span className="text-caption text-fg-default">°</span>
<input <input
className="prd-i text-center flex-1" className="prd-i text-center flex-1"
type="number" type="number"
@ -832,7 +871,7 @@ function DmsCoordInput({
onChange={(e) => update(d, parseInt(e.target.value) || 0, s, dir)} onChange={(e) => update(d, parseInt(e.target.value) || 0, s, dir)}
style={fieldStyle} style={fieldStyle}
/> />
<span className="text-caption text-fg-disabled">'</span> <span className="text-caption text-fg-default">'</span>
<input <input
className="prd-i text-center flex-1" className="prd-i text-center flex-1"
type="number" type="number"
@ -843,7 +882,7 @@ function DmsCoordInput({
onChange={(e) => update(d, m, parseFloat(e.target.value) || 0, dir)} onChange={(e) => update(d, m, parseFloat(e.target.value) || 0, dir)}
style={fieldStyle} style={fieldStyle}
/> />
<span className="text-caption text-fg-disabled">"</span> <span className="text-caption text-fg-default">"</span>
</div> </div>
</div> </div>
); );

파일 보기

@ -167,7 +167,7 @@ export function RecalcModal({
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h2 className="text-subtitle font-bold m-0"> </h2> <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>
</div> </div>
@ -180,7 +180,7 @@ export function RecalcModal({
background: 'var(--bg-card)', background: 'var(--bg-card)',
fontSize: 'var(--font-size-caption)', 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> </button>
@ -281,7 +281,7 @@ export function RecalcModal({
<FieldGroup label="유출 위치 (좌표)"> <FieldGroup label="유출 위치 (좌표)">
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<div className="flex-1"> <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 <input
type="number" type="number"
className="prd-i font-mono" className="prd-i font-mono"
@ -291,7 +291,7 @@ export function RecalcModal({
/> />
</div> </div>
<div className="flex-1"> <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 <input
type="number" type="number"
className="prd-i font-mono" 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 }) { function InfoItem({ label, value }: { label: string; value: string }) {
return ( return (
<div className="flex justify-between py-[2px]"> <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> <span className="font-semibold font-mono">{value}</span>
</div> </div>
); );

파일 보기

@ -165,7 +165,7 @@ export function RightPanel({
</div> </div>
{windHydrModelOptions.length > 1 && ( {windHydrModelOptions.length > 1 && (
<div className="flex items-center gap-2 mt-1.5"> <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> </span>
<select <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 ${ className={`flex-1 py-1.5 px-1 rounded text-label-2 font-medium font-korean border transition-colors ${
analysisTab === tab analysisTab === tab
? 'border-color-accent bg-[rgba(6,182,212,0.08)] text-color-accent' ? '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' ? '다각형 분석' : '원 분석'} {tab === 'polygon' ? '다각형 분석' : '원 분석'}
@ -208,7 +208,7 @@ export function RightPanel({
{/* 다각형 패널 */} {/* 다각형 패널 */}
{analysisTab === 'polygon' && ( {analysisTab === 'polygon' && (
<div> <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> </p>
{!drawAnalysisMode && !analysisResult && ( {!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"> <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 /> <br />
<span className="text-fg-disabled"> <span className="text-fg-default">
{analysisPolygonPoints.length} {analysisPolygonPoints.length}
</span> </span>
</div> </div>
@ -247,7 +247,7 @@ export function RightPanel({
</button> </button>
<button <button
onClick={onCancelAnalysis} 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> </button>
@ -268,7 +268,7 @@ export function RightPanel({
{/* 원 분석 패널 */} {/* 원 분석 패널 */}
{analysisTab === 'circle' && ( {analysisTab === 'circle' && (
<div> <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) . (NM) .
</p> </p>
<div className="text-label-2 font-medium text-fg-sub font-korean mb-1.5"> <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 ${ className={`w-8 h-7 rounded text-label-2 font-medium font-mono border transition-all ${
circleRadiusNm === nm circleRadiusNm === nm
? 'border-color-accent bg-[rgba(6,182,212,0.1)] text-color-accent' ? '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} {nm}
@ -290,7 +290,7 @@ export function RightPanel({
))} ))}
</div> </div>
<div className="flex items-center gap-1.5 mb-2.5"> <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> </span>
<input <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" 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' }} 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 <button
onClick={onRunCircleAnalysis} 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)]" 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"> <div className="text-label-2 font-bold text-fg font-korean">
{vessel?.vesselNm || '—'} {vessel?.vesselNm || '—'}
</div> </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 || '—'} IMO {vessel?.imoNo || '—'} · {vessel?.vesselTp || '—'}
</div> </div>
</div> </div>
@ -511,7 +511,7 @@ export function RightPanel({
<div className="text-label-2 font-bold text-color-warning font-korean mb-1"> <div className="text-label-2 font-bold text-color-warning font-korean mb-1">
: {vessel2.vesselNm} : {vessel2.vesselNm}
</div> </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.flagCd} {vessel2.vesselTp}{' '}
{vessel2.gt ? `${vessel2.gt.toLocaleString()}GT` : ''} {vessel2.gt ? `${vessel2.gt.toLocaleString()}GT` : ''}
</div> </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> </div>
)} )}
@ -647,12 +647,9 @@ function getSpreadSeverity(
// Helper Components // Helper Components
const BADGE_STYLES: Record<string, string> = { 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)]', red: 'bg-[rgba(239,68,68,0.08)] text-color-danger border border-[rgba(239,68,68,0.25)]',
orange: orange: 'bg-[rgba(249,115,22,0.08)] text-color-warning border border-[rgba(249,115,22,0.25)]',
'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)]',
yellow: green: 'bg-[rgba(34,197,94,0.08)] text-color-success border border-[rgba(34,197,94,0.25)]',
'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({ function Section({
@ -699,7 +696,7 @@ function ControlledCheckbox({
return ( return (
<label <label
className={`flex items-center gap-1.5 text-label-2 font-korean cursor-pointer ${ 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 <input
@ -727,9 +724,9 @@ function StatBox({
}) { }) {
return ( return (
<div className="flex justify-between px-2 py-1 bg-bg-base border border-stroke rounded-[3px]"> <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)' }}> <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> </span>
</div> </div>
); );
@ -738,7 +735,7 @@ function StatBox({
function PredictionCard({ value, label, color }: { value: string; label: string; color: string }) { function PredictionCard({ value, label, color }: { value: string; label: string; color: string }) {
return ( return (
<div className="flex justify-between px-2 py-1 bg-bg-base border border-stroke rounded-[3px] text-label-2"> <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> <span style={{ fontWeight: 700, color, fontFamily: 'var(--font-mono)' }}>{value}</span>
</div> </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 }) { function ProgressBar({ label, value, color }: { label: string; value: number; color: string }) {
return ( return (
<div className="flex items-center gap-1"> <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} {label}
</span> </span>
<div <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="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}> <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> <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> </div>
{expanded && children} {expanded && children}
</div> </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"> <div style={{ color }} className="text-label-1 font-bold font-mono">
{value} {value}
</div> </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> </div>
); );
} }
@ -814,7 +811,7 @@ function InfoRow({
}) { }) {
return ( return (
<div className="flex justify-between py-[3px] px-[6px] bg-bg-base rounded-[3px]"> <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 <span
style={{ color: valueColor || 'var(--fg-default)' }} style={{ color: valueColor || 'var(--fg-default)' }}
className={`font-medium${mono ? ' font-mono' : ''}`} className={`font-medium${mono ? ' font-mono' : ''}`}
@ -869,7 +866,7 @@ function InsuranceCard({
<div className="space-y-0.5 text-label-2 font-korean"> <div className="space-y-0.5 text-label-2 font-korean">
{items.map((item, i) => ( {items.map((item, i) => (
<div key={i} className="flex justify-between py-0.5 px-1"> <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 <span
style={{ color: item.valueColor || 'var(--fg-default)' }} style={{ color: item.valueColor || 'var(--fg-default)' }}
className={`font-medium${item.mono ? ' font-mono' : ''}`} className={`font-medium${item.mono ? ' font-mono' : ''}`}
@ -928,7 +925,7 @@ function PollResult({
> >
{result.area.toFixed(2)} {result.area.toFixed(2)}
</div> </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="text-center py-1.5 px-1 bg-bg-card rounded"> <div className="text-center py-1.5 px-1 bg-bg-card rounded">
<div <div
@ -937,7 +934,7 @@ function PollResult({
> >
{result.particlePercent}% {result.particlePercent}%
</div> </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>
<div className="text-center py-1.5 px-1 bg-bg-card rounded"> <div className="text-center py-1.5 px-1 bg-bg-card rounded">
<div <div
@ -946,13 +943,13 @@ function PollResult({
> >
{pollutedArea} {pollutedArea}
</div> </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> </div>
<div className="space-y-1 text-label-2 font-korean"> <div className="space-y-1 text-label-2 font-korean">
{summary && ( {summary && (
<div className="flex justify-between"> <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)' }}> <span className="font-medium font-mono" style={{ color: 'var(--color-info)' }}>
{summary.remainingVolume.toFixed(2)} m³ {summary.remainingVolume.toFixed(2)} m³
</span> </span>
@ -960,14 +957,14 @@ function PollResult({
)} )}
{summary && ( {summary && (
<div className="flex justify-between"> <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)' }}> <span className="font-medium font-mono" style={{ color: 'var(--color-danger)' }}>
{summary.beachedVolume.toFixed(2)} m³ {summary.beachedVolume.toFixed(2)} m³
</span> </span>
</div> </div>
)} )}
<div className="flex justify-between"> <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)' }}> <span className="font-medium font-mono" style={{ color: 'var(--color-warning)' }}>
{result.sensitiveCount} {result.sensitiveCount}
</span> </span>
@ -976,7 +973,7 @@ function PollResult({
<div className="flex gap-1.5 mt-2"> <div className="flex gap-1.5 mt-2">
<button <button
onClick={onClear} 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> </button>

파일 보기

@ -40,6 +40,8 @@ export interface LeftPanelProps {
// 오일펜스 배치 관련 // 오일펜스 배치 관련
boomLines: BoomLine[]; boomLines: BoomLine[];
onBoomLinesChange: (lines: BoomLine[]) => void; onBoomLinesChange: (lines: BoomLine[]) => void;
showBoomLines: boolean;
onShowBoomLinesChange: (show: boolean) => void;
oilTrajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>; oilTrajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>;
algorithmSettings: AlgorithmSettings; algorithmSettings: AlgorithmSettings;
onAlgorithmSettingsChange: (settings: AlgorithmSettings) => void; onAlgorithmSettingsChange: (settings: AlgorithmSettings) => void;
@ -60,6 +62,8 @@ export interface LeftPanelProps {
sensitiveResources?: SensitiveResourceCategory[]; sensitiveResources?: SensitiveResourceCategory[];
// 이미지 분석 결과 콜백 // 이미지 분석 결과 콜백
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void; onImageAnalysisResult?: (result: ImageAnalyzeResult) => void;
// 사고 리스트 선택 시 지도 이동 콜백
onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
// 유효성 검증 에러 필드 // 유효성 검증 에러 필드
validationErrors?: Set<string>; validationErrors?: Set<string>;
} }

파일 보기

@ -321,3 +321,20 @@ export const analyzeImage = async (file: File, acdntNm?: string): Promise<ImageA
}); });
return response.data; return response.data;
}; };
// ============================================================
// GSC 외부 수집 사고 목록 (확산 예측 입력 셀렉트용)
// ============================================================
export interface GscAccidentListItem {
acdntMngNo: string;
pollNm: string;
pollDate: string | null;
lat: number | null;
lon: number | null;
}
export const fetchGscAccidents = async (): Promise<GscAccidentListItem[]> => {
const response = await api.get<GscAccidentListItem[]>('/gsc/accidents');
return response.data;
};

파일 보기

@ -120,10 +120,19 @@ const SCENARIO_MGMT_GUIDELINES = [
/* ─── Mock 시나리오 (API 미연결 시 폴백) — 긴급구난 모델 이론 기반 10개 ─── */ /* ─── Mock 시나리오 (API 미연결 시 폴백) — 긴급구난 모델 이론 기반 10개 ─── */
const MOCK_SCENARIOS: RescueScenarioItem[] = [ const MOCK_SCENARIOS: RescueScenarioItem[] = [
{ {
scenarioSn: 1, rescueOpsSn: 1, timeStep: 'T+0h', scenarioSn: 1,
scenarioDtm: '2024-10-27T01:30:00.000Z', svrtCd: 'CRITICAL', rescueOpsSn: 1,
gmM: 0.8, listDeg: 15.0, trimM: 2.5, buoyancyPct: 30.0, oilRateLpm: 100.0, bmRatioPct: 92.0, timeStep: 'T+0h',
description: '좌현 35° 충돌로 No.1P 화물탱크 파공. 벙커C유 유출 개시. 손상복원성 분석: 초기 GM 0.8m으로 IMO 기준(1.0m) 미달, 복원력 위험 판정.', 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: [ compartments: [
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' },
@ -146,10 +155,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [
sortOrd: 1, sortOrd: 1,
}, },
{ {
scenarioSn: 2, rescueOpsSn: 1, timeStep: 'T+30m', scenarioSn: 2,
scenarioDtm: '2024-10-27T02:00:00.000Z', svrtCd: 'CRITICAL', rescueOpsSn: 1,
gmM: 0.7, listDeg: 17.0, trimM: 2.8, buoyancyPct: 28.0, oilRateLpm: 120.0, bmRatioPct: 90.0, timeStep: 'T+30m',
description: '잠수사 수중 조사 결과 좌현 No.1P 파공 크기 1.2m×0.8m 확인. Bernoulli 유입률 모델 적용: 수두차 4.5m 기준 유입률 약 2.1㎥/min.', 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: [ compartments: [
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' },
@ -172,10 +190,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [
sortOrd: 2, sortOrd: 2,
}, },
{ {
scenarioSn: 3, rescueOpsSn: 1, timeStep: 'T+1h', scenarioSn: 3,
scenarioDtm: '2024-10-27T02:30:00.000Z', svrtCd: 'CRITICAL', rescueOpsSn: 1,
gmM: 0.65, listDeg: 18.5, trimM: 3.0, buoyancyPct: 26.0, oilRateLpm: 135.0, bmRatioPct: 89.0, timeStep: 'T+1h',
description: '해경 3009함 현장 도착, SAR 작전 개시. Leeway 표류 예측 모델: 풍속 8m/s, 해류 2.5kn NE — 실종자 표류 반경 1.2nm. GZ 최대 복원력 각도 25°로 감소.', 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: [ compartments: [
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#1 Port 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, sortOrd: 3,
}, },
{ {
scenarioSn: 4, rescueOpsSn: 1, timeStep: 'T+2h', scenarioSn: 4,
scenarioDtm: '2024-10-27T03:30:00.000Z', svrtCd: 'CRITICAL', rescueOpsSn: 1,
gmM: 0.5, listDeg: 20.0, trimM: 3.5, buoyancyPct: 22.0, oilRateLpm: 160.0, bmRatioPct: 86.0, timeStep: 'T+2h',
description: '격벽 관통으로 #2 Port Tank 침수 확대. 자유표면효과(FSE) 보정: GM_fluid = 0.5m. 종강도: Sagging 모멘트 86%. 침몰 위험 단계 진입.', 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: [ compartments: [
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#1 Port 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, sortOrd: 4,
}, },
{ {
scenarioSn: 5, rescueOpsSn: 1, timeStep: 'T+3h', scenarioSn: 5,
scenarioDtm: '2024-10-27T04:30:00.000Z', svrtCd: 'HIGH', rescueOpsSn: 1,
gmM: 0.55, listDeg: 16.0, trimM: 3.2, buoyancyPct: 25.0, oilRateLpm: 140.0, bmRatioPct: 87.0, timeStep: 'T+3h',
description: 'Counter-Flooding 실시: #3 Stbd Tank에 평형수 280톤 주입, 횡경사 20°→16° 교정. 종강도: 중량 재배분으로 BM 87% 유지.', 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: [ compartments: [
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#1 Port 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, sortOrd: 5,
}, },
{ {
scenarioSn: 6, rescueOpsSn: 1, timeStep: 'T+6h', scenarioSn: 6,
scenarioDtm: '2024-10-27T07:30:00.000Z', svrtCd: 'HIGH', rescueOpsSn: 1,
gmM: 0.7, listDeg: 12.0, trimM: 2.5, buoyancyPct: 32.0, oilRateLpm: 80.0, bmRatioPct: 90.0, timeStep: 'T+6h',
description: '수중패치 설치, 유입률 감소. GM 0.7m 회복. Trim/Stability Booklet 기준 예인 가능 최소 조건(GM≥0.5m, List≤15°) 충족.', 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: [ compartments: [
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
@ -276,10 +330,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [
sortOrd: 6, sortOrd: 6,
}, },
{ {
scenarioSn: 7, rescueOpsSn: 1, timeStep: 'T+8h', scenarioSn: 7,
scenarioDtm: '2024-10-27T09:30:00.000Z', svrtCd: 'MEDIUM', rescueOpsSn: 1,
gmM: 0.8, listDeg: 10.0, trimM: 2.0, buoyancyPct: 38.0, oilRateLpm: 55.0, bmRatioPct: 91.0, timeStep: 'T+8h',
description: '오일붐 2중 전개, 유회수기 3대 가동. GNOME 확산 모델: 12시간 후 확산 면적 2.3km² 예측. 기계적 회수율 35%.', 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: [ compartments: [
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
@ -302,10 +365,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [
sortOrd: 7, sortOrd: 7,
}, },
{ {
scenarioSn: 8, rescueOpsSn: 1, timeStep: 'T+12h', scenarioSn: 8,
scenarioDtm: '2024-10-27T13:30:00.000Z', svrtCd: 'MEDIUM', rescueOpsSn: 1,
gmM: 0.9, listDeg: 8.0, trimM: 1.5, buoyancyPct: 45.0, oilRateLpm: 30.0, bmRatioPct: 94.0, timeStep: 'T+12h',
description: '예인 개시. 예인 저항 Rt=1/2·ρ·Cd·A·V² 기반 4,000HP급 배정. 목포항 42nm, 예인 속도 3kn, ETA 14h.', 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: [ compartments: [
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
@ -328,10 +400,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [
sortOrd: 8, sortOrd: 8,
}, },
{ {
scenarioSn: 9, rescueOpsSn: 1, timeStep: 'T+18h', scenarioSn: 9,
scenarioDtm: '2024-10-27T19:30:00.000Z', svrtCd: 'MEDIUM', rescueOpsSn: 1,
gmM: 1.0, listDeg: 5.0, trimM: 1.0, buoyancyPct: 55.0, oilRateLpm: 15.0, bmRatioPct: 96.0, timeStep: 'T+18h',
description: '예인 진행률 65%. 파랑 응답 분석(RAO): 유의파고 1.2m, 주기 6s — 횡동요 ±3° 안전 범위. 잔류 유출률 15 L/min.', 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: [ compartments: [
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
@ -354,10 +435,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [
sortOrd: 9, sortOrd: 9,
}, },
{ {
scenarioSn: 10, rescueOpsSn: 1, timeStep: 'T+24h', scenarioSn: 10,
scenarioDtm: '2024-10-28T01:30:00.000Z', svrtCd: 'RESOLVED', rescueOpsSn: 1,
gmM: 1.2, listDeg: 3.0, trimM: 0.5, buoyancyPct: 75.0, oilRateLpm: 5.0, bmRatioPct: 98.0, timeStep: 'T+24h',
description: '목포항 접안 완료. 잔류유 전량 이적(120kL). 최종 GM 1.2m IMO 충족, BM 98% 정상. 방제 총 회수량 85kL (회수율 71%). 상황 종료.', 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: [ compartments: [
{ name: '#1 FP Tank', status: 'SEALED', color: 'var(--orange)' }, { name: '#1 FP Tank', status: 'SEALED', color: 'var(--orange)' },
{ name: '#1 Port 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[] = [ const MOCK_OPS: RescueOpsItem[] = [
{ {
rescueOpsSn: 1, acdntSn: 1, opsCd: 'RSC-2026-001', acdntTpCd: 'collision', rescueOpsSn: 1,
vesselNm: 'M/V SEA GUARDIAN', commanderNm: null, acdntSn: 1,
lon: 126.25, lat: 37.467, locDc: '37°28\'N, 126°15\'E', opsCd: 'RSC-2026-001',
depthM: 25.0, currentDc: '2.5kn NE', acdntTpCd: 'collision',
gmM: 0.8, listDeg: 15.0, trimM: 2.5, buoyancyPct: 30.0, vesselNm: 'M/V SEA GUARDIAN',
oilRateLpm: 100.0, bmRatioPct: 92.0, commanderNm: null,
totalCrew: 20, survivors: 15, missing: 5, lon: 126.25,
hydroData: null, gmdssData: null, lat: 37.467,
sttsCd: 'ACTIVE', regDtm: '2024-10-27T01:30:00.000Z', 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> </div>
{/* View content */} {/* 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: 시나리오 상세 ─── */} {/* ─── VIEW 0: 시나리오 상세 ─── */}
{detailView === 0 && selected && ( {detailView === 0 && selected && (
<div className="p-5"> <div className="p-5">
@ -1039,9 +1146,23 @@ function ScenarioMapOverlay({
maxWidth="320px" maxWidth="320px"
className="rescue-map-popup" 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 }}> <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} {sc.id}
</span> </span>
<span style={{ fontWeight: 700, fontSize: 12 }}>{sc.timeStep}</span> <span style={{ fontWeight: 700, fontSize: 12 }}>{sc.timeStep}</span>
@ -1059,16 +1180,38 @@ function ScenarioMapOverlay({
{sev.label} {sev.label}
</span> </span>
</div> </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} {sc.description}
</div> </div>
{/* KPI */} {/* 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: '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.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) => ( ].map((m) => (
<div <div
key={m.label} key={m.label}
@ -1081,14 +1224,23 @@ function ScenarioMapOverlay({
}} }}
> >
<div style={{ color: 'var(--fg-disabled)' }}>{m.label}</div> <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>
))} ))}
</div> </div>
{/* 구획 상태 */} {/* 구획 상태 */}
{sc.compartments.length > 0 && ( {sc.compartments.length > 0 && (
<div style={{ marginBottom: 4 }}> <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>
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}> <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)' }} 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"> <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="text-caption font-bold">{selected.timeStep}</span>
<span <span
className="ml-auto px-1.5 py-0.5 rounded-md text-caption font-bold" 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} {SEV_STYLE[selected.severity].label}
</span> </span>
@ -1134,14 +1291,34 @@ function ScenarioMapOverlay({
<div className="px-3 py-2"> <div className="px-3 py-2">
<div className="grid grid-cols-4 gap-1 font-mono text-caption mb-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: 'GM',
{ label: '부력', value: `${selected.buoyancy}%`, color: buoyColor(selected.buoyancy) }, value: `${selected.gm}m`,
{ label: '유출', value: selected.oilRate.split(' ')[0], color: oilColor(parseFloat(selected.oilRate)) }, 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) => ( ].map((m) => (
<div key={m.label} className="text-center p-1 bg-bg-base rounded"> <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="text-fg-disabled" style={{ fontSize: 9 }}>
<div className="font-bold" style={{ color: m.color, fontSize: 11 }}>{m.value}</div> {m.label}
</div>
<div className="font-bold" style={{ color: m.color, fontSize: 11 }}>
{m.value}
</div>
</div> </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"> <div className="flex items-center gap-1.5 mt-1 pt-1 border-t border-stroke">
<span <span
className="inline-block rounded-full" 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> <span className="text-caption text-fg-sub"> </span>
</div> </div>

파일 보기

@ -1,12 +1,14 @@
import { Fragment, useState, useEffect, useCallback } from 'react'; import { Fragment, useState, useEffect, useCallback } from 'react';
import { useVesselSignals } from '@common/hooks/useVesselSignals';
import type { MapBounds } from '@common/types/vessel';
import { useSubMenu } from '@common/hooks/useSubMenu'; import { useSubMenu } from '@common/hooks/useSubMenu';
import { MapView } from '@common/components/map/MapView'; import { MapView } from '@common/components/map/MapView';
import { RescueTheoryView } from './RescueTheoryView'; import { RescueTheoryView } from './RescueTheoryView';
import { RescueScenarioView } from './RescueScenarioView'; import { RescueScenarioView } from './RescueScenarioView';
import { fetchRescueOps } from '../services/rescueApi'; import { fetchRescueOps } from '../services/rescueApi';
import type { RescueOpsItem } from '../services/rescueApi'; import type { RescueOpsItem } from '../services/rescueApi';
import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi'; import { fetchGscAccidents } from '@tabs/prediction/services/predictionApi';
import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi'; import type { GscAccidentListItem } from '@tabs/prediction/services/predictionApi';
/* ─── Types ─── */ /* ─── Types ─── */
type AccidentType = type AccidentType =
@ -230,9 +232,9 @@ function LeftPanel({
}: { }: {
activeType: AccidentType; activeType: AccidentType;
onTypeChange: (t: AccidentType) => void; onTypeChange: (t: AccidentType) => void;
incidents: IncidentListItem[]; incidents: GscAccidentListItem[];
selectedAcdnt: IncidentListItem | null; selectedAcdnt: GscAccidentListItem | null;
onSelectAcdnt: (item: IncidentListItem | null) => void; onSelectAcdnt: (item: GscAccidentListItem | null) => void;
}) { }) {
const [acdntName, setAcdntName] = useState(''); const [acdntName, setAcdntName] = useState('');
const [acdntDate, setAcdntDate] = useState(''); const [acdntDate, setAcdntDate] = useState('');
@ -242,18 +244,25 @@ function LeftPanel({
const [showList, setShowList] = useState(false); const [showList, setShowList] = useState(false);
// 사고 선택 시 필드 자동 채움 // 사고 선택 시 필드 자동 채움
const handlePickIncident = (item: IncidentListItem) => { const handlePickIncident = (item: GscAccidentListItem) => {
onSelectAcdnt(item); onSelectAcdnt(item);
setAcdntName(item.acdntNm); setAcdntName(item.pollNm);
const dt = new Date(item.occrnDtm); if (item.pollDate) {
setAcdntDate( const [d, t] = item.pollDate.split('T');
`${dt.getFullYear()}. ${String(dt.getMonth() + 1).padStart(2, '0')}. ${String(dt.getDate()).padStart(2, '0')}.`, if (d) {
); const [y, m, day] = d.split('-');
setAcdntTime( setAcdntDate(`${y}. ${m}. ${day}.`);
`${dt.getHours() >= 12 ? '오후' : '오전'} ${String(dt.getHours() % 12 || 12).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`, }
); if (t) {
setAcdntLat(String(item.lat)); const [hhStr, mmStr] = t.split(':');
setAcdntLon(String(item.lng)); const hh = parseInt(hhStr, 10);
const ampm = hh >= 12 ? '오후' : '오전';
const hh12 = String(hh % 12 || 12).padStart(2, '0');
setAcdntTime(`${ampm} ${hh12}:${mmStr}`);
}
}
if (item.lat != null) setAcdntLat(String(item.lat));
if (item.lon != null) setAcdntLon(String(item.lon));
setShowList(false); setShowList(false);
}; };
@ -283,7 +292,7 @@ function LeftPanel({
className="w-full px-2 py-1.5 text-caption bg-bg-card border border-stroke rounded font-korean text-left cursor-pointer hover:border-[var(--stroke-light)] flex items-center justify-between" className="w-full px-2 py-1.5 text-caption bg-bg-card border border-stroke rounded font-korean text-left cursor-pointer hover:border-[var(--stroke-light)] flex items-center justify-between"
> >
<span className={selectedAcdnt ? 'text-fg' : 'text-fg-disabled/50'}> <span className={selectedAcdnt ? 'text-fg' : 'text-fg-disabled/50'}>
{selectedAcdnt ? selectedAcdnt.acdntCd : '또는 사고 리스트에서 선택'} {selectedAcdnt ? selectedAcdnt.pollNm : '또는 사고 리스트에서 선택'}
</span> </span>
<span className="text-fg-disabled text-[10px]">{showList ? '▲' : '▼'}</span> <span className="text-fg-disabled text-[10px]">{showList ? '▲' : '▼'}</span>
</button> </button>
@ -296,13 +305,13 @@ function LeftPanel({
)} )}
{incidents.map((item) => ( {incidents.map((item) => (
<button <button
key={item.acdntSn} key={item.acdntMngNo}
onClick={() => handlePickIncident(item)} onClick={() => handlePickIncident(item)}
className="w-full text-left px-2 py-1.5 text-caption font-korean hover:bg-bg-surface cursor-pointer border-b border-stroke last:border-b-0" className="w-full text-left px-2 py-1.5 text-caption font-korean hover:bg-bg-surface cursor-pointer border-b border-stroke last:border-b-0"
> >
<div className="text-fg font-semibold truncate">{item.acdntNm}</div> <div className="text-fg font-semibold truncate">{item.pollNm}</div>
<div className="text-fg-disabled text-[10px]"> <div className="text-fg-disabled text-[10px]">
{item.acdntCd} · {item.regionNm} {item.pollDate ? item.pollDate.replace('T', ' ') : '-'}
</div> </div>
</button> </button>
))} ))}
@ -1523,13 +1532,16 @@ export function RescueView() {
const { activeSubTab } = useSubMenu('rescue'); const { activeSubTab } = useSubMenu('rescue');
const [activeType, setActiveType] = useState<AccidentType>('collision'); const [activeType, setActiveType] = useState<AccidentType>('collision');
const [activeAnalysis, setActiveAnalysis] = useState<AnalysisTab>('rescue'); const [activeAnalysis, setActiveAnalysis] = useState<AnalysisTab>('rescue');
const [incidents, setIncidents] = useState<IncidentListItem[]>([]); const [incidents, setIncidents] = useState<GscAccidentListItem[]>([]);
const [selectedAcdnt, setSelectedAcdnt] = useState<IncidentListItem | null>(null); const [selectedAcdnt, setSelectedAcdnt] = useState<GscAccidentListItem | null>(null);
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null); const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [isSelectingLocation, setIsSelectingLocation] = useState(false);
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const vessels = useVesselSignals(mapBounds);
useEffect(() => { useEffect(() => {
fetchIncidentsRaw() fetchGscAccidents()
.then((items) => setIncidents(items)) .then((items) => setIncidents(items))
.catch(() => setIncidents([])); .catch(() => setIncidents([]));
}, []); }, []);
@ -1540,23 +1552,13 @@ export function RescueView() {
setIsSelectingLocation(false); setIsSelectingLocation(false);
}, []); }, []);
// 사고 선택 시 사고유형 자동 매핑 // 사고 선택 시 좌표 자동 반영 + 지도 이동
const handleSelectAcdnt = useCallback( const handleSelectAcdnt = useCallback(
(item: IncidentListItem | null) => { (item: GscAccidentListItem | null) => {
setSelectedAcdnt(item); setSelectedAcdnt(item);
if (item) { if (item && item.lat != null && item.lon != null) {
const typeMap: Record<string, AccidentType> = { setIncidentCoord({ lon: item.lon, lat: item.lat });
collision: 'collision', setFlyToCoord({ lon: item.lon, lat: item.lat });
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 });
} }
}, },
[], [],
@ -1595,11 +1597,15 @@ export function RescueView() {
<div className="flex-1 relative overflow-hidden"> <div className="flex-1 relative overflow-hidden">
<MapView <MapView
incidentCoord={incidentCoord ?? undefined} incidentCoord={incidentCoord ?? undefined}
flyToIncident={flyToCoord}
onIncidentFlyEnd={() => setFlyToCoord(undefined)}
isSelectingLocation={isSelectingLocation} isSelectingLocation={isSelectingLocation}
onMapClick={handleMapClick} onMapClick={handleMapClick}
oilTrajectory={[]} oilTrajectory={[]}
enabledLayers={new Set()} enabledLayers={new Set()}
showOverlays={false} showOverlays={false}
vessels={vessels}
onBoundsChange={setMapBounds}
/> />
</div> </div>
<RightPanel <RightPanel

파일 보기

@ -2,7 +2,7 @@ import { useState, useEffect, useRef, type CSSProperties, type ReactElement } fr
import { List } from 'react-window'; import { List } from 'react-window';
import type { ScatSegment } from './scatTypes'; import type { ScatSegment } from './scatTypes';
import type { ApiZoneItem } from '../services/scatApi'; import type { ApiZoneItem } from '../services/scatApi';
import { esiColor, sensColor, statusColor, esiLevel } from './scatConstants'; import { esiLevel } from './scatConstants';
interface ScatLeftPanelProps { interface ScatLeftPanelProps {
segments: ScatSegment[]; segments: ScatSegment[];
@ -71,8 +71,8 @@ function SegRow(
📍 {seg.code} {seg.area} 📍 {seg.code} {seg.area}
</span> </span>
<span <span
className="text-caption font-bold px-1.5 py-0.5 rounded-lg text-white" 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) }} // style={{ background: esiColor(seg.esiNum) }}
> >
ESI {seg.esi} ESI {seg.esi}
</span> </span>
@ -89,8 +89,8 @@ function SegRow(
<div className="flex justify-between text-label-2"> <div className="flex justify-between text-label-2">
<span className="text-fg-sub font-korean"></span> <span className="text-fg-sub font-korean"></span>
<span <span
className="font-medium font-mono text-label-2" className="font-medium font-mono text-label-2 text-fg-default"
style={{ color: sensColor[seg.sensitivity] }} // style={{ color: sensColor[seg.sensitivity] }}
> >
{seg.sensitivity} {seg.sensitivity}
</span> </span>
@ -98,8 +98,9 @@ function SegRow(
<div className="flex justify-between text-label-2"> <div className="flex justify-between text-label-2">
<span className="text-fg-sub font-korean"></span> <span className="text-fg-sub font-korean"></span>
<span <span
className="font-medium font-mono text-label-2" className={`font-medium font-mono text-label-2 ${
style={{ color: statusColor[seg.status] }} seg.status === '미조사' ? 'text-fg-disabled' : 'text-fg-default'
}`}
> >
{seg.status} {seg.status}
</span> </span>
@ -160,7 +161,7 @@ function ScatLeftPanel({
{/* Filters */} {/* Filters */}
<div className="p-3.5 border-b border-stroke"> <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"> <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> </div>
@ -257,12 +258,10 @@ function ScatLeftPanel({
<div className="flex-1 flex flex-col overflow-hidden p-3.5 pt-2"> <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"> <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="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>
<span className="text-color-accent font-mono text-caption"> <span className="font-mono text-caption"> {filtered.length} </span>
{filtered.length}
</span>
</div> </div>
<div className="flex-1 overflow-hidden" ref={listContainerRef}> <div className="flex-1 overflow-hidden" ref={listContainerRef}>
<List<SegRowData> <List<SegRowData>

파일 보기

@ -1,11 +1,8 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'; import { useMap } from '@vis.gl/react-maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { PathLayer, ScatterplotLayer } from '@deck.gl/layers'; import { PathLayer, ScatterplotLayer } from '@deck.gl/layers';
import 'maplibre-gl/dist/maplibre-gl.css'; import { BaseMap } from '@common/components/map/BaseMap';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
import { useMapStore } from '@common/store/mapStore';
import type { ScatSegment } from './scatTypes'; import type { ScatSegment } from './scatTypes';
import type { ApiZoneItem } from '../services/scatApi'; import type { ApiZoneItem } from '../services/scatApi';
import { esiColor } from './scatConstants'; import { esiColor } from './scatConstants';
@ -20,16 +17,9 @@ interface ScatMapProps {
onOpenPopup: (idx: number) => void; onOpenPopup: (idx: number) => void;
} }
// ── DeckGLOverlay ────────────────────────────────────── // ── FlyTo: 선택 구간·관할해경 변경 시 맵 이동 ──────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 두 가지 트리거를 독립적으로 처리해 공통 FlyToController로 통합 불가
function DeckGLOverlay({ layers }: { layers: any[] }) { function ScatFlyToController({
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers });
return null;
}
// ── FlyTo Controller: 선택 구간 변경 시 맵 이동 ─────────
function FlyToController({
selectedSeg, selectedSeg,
zones, zones,
}: { }: {
@ -40,7 +30,7 @@ function FlyToController({
const prevIdRef = useRef<number | undefined>(undefined); const prevIdRef = useRef<number | undefined>(undefined);
const prevZonesLenRef = useRef<number>(0); const prevZonesLenRef = useRef<number>(0);
// 선택 구간 변경 시 // 선택 구간 변경 시 이동 (첫 렌더 제외)
useEffect(() => { useEffect(() => {
if (!map) return; if (!map) return;
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedSeg.id) { if (prevIdRef.current !== undefined && prevIdRef.current !== selectedSeg.id) {
@ -49,7 +39,7 @@ function FlyToController({
prevIdRef.current = selectedSeg.id; prevIdRef.current = selectedSeg.id;
}, [map, selectedSeg]); }, [map, selectedSeg]);
// 관할해경(zones) 변경 시 지도 중심 이동 // 관할해경(zones) 변경 시 중심 이동
useEffect(() => { useEffect(() => {
if (!map || zones.length === 0) return; if (!map || zones.length === 0) return;
if (prevZonesLenRef.current === zones.length) return; if (prevZonesLenRef.current === zones.length) return;
@ -72,13 +62,11 @@ function getZoomScale(zoom: number) {
selPolyWidth: 2 + zScale * 5, selPolyWidth: 2 + zScale * 5,
glowWidth: 4 + zScale * 14, glowWidth: 4 + zScale * 14,
halfLenScale: 0.15 + zScale * 0.85, halfLenScale: 0.15 + zScale * 0.85,
markerRadius: Math.round(6 + zScale * 16), dotRadius: Math.round(4 + zScale * 10),
showStatusMarker: zoom >= 11,
}; };
} }
// ── 세그먼트 폴리라인 좌표 생성 (lng, lat 순서) ────────── // ── 세그먼트 폴리라인 좌표 생성 (lng, lat 순서) ──────────
// 인접 구간 좌표로 해안선 방향을 동적 계산
function buildSegCoords( function buildSegCoords(
seg: ScatSegment, seg: ScatSegment,
halfLenScale: number, halfLenScale: number,
@ -100,7 +88,6 @@ function buildSegCoords(
]; ];
} }
// ── 툴팁 상태 ───────────────────────────────────────────
interface TooltipState { interface TooltipState {
x: number; x: number;
y: number; y: number;
@ -116,12 +103,19 @@ function ScatMap({
onSelectSeg, onSelectSeg,
onOpenPopup, onOpenPopup,
}: ScatMapProps) { }: ScatMapProps) {
const currentMapStyle = useBaseMapStyle();
const mapToggles = useMapStore((s) => s.mapToggles);
const [zoom, setZoom] = useState(10); const [zoom, setZoom] = useState(10);
const [tooltip, setTooltip] = useState<TooltipState | null>(null); 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( const handleClick = useCallback(
(seg: ScatSegment) => { (seg: ScatSegment) => {
onSelectSeg(seg); onSelectSeg(seg);
@ -132,23 +126,6 @@ function ScatMap({
const zs = useMemo(() => getZoomScale(zoom), [zoom]); 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( const glowLayer = useMemo(
() => () =>
new PathLayer({ new PathLayer({
@ -168,7 +145,6 @@ function ScatMap({
[selectedSeg, segments, zs.glowWidth, zs.halfLenScale], [selectedSeg, segments, zs.glowWidth, zs.halfLenScale],
); );
// ESI 색상 세그먼트 폴리라인
const segPathLayer = useMemo( const segPathLayer = useMemo(
() => () =>
new PathLayer({ new PathLayer({
@ -183,14 +159,11 @@ function ScatMap({
getWidth: (d: ScatSegment) => (selectedSeg.id === d.id ? zs.selPolyWidth : zs.polyWidth), getWidth: (d: ScatSegment) => (selectedSeg.id === d.id ? zs.selPolyWidth : zs.polyWidth),
capRounded: true, capRounded: true,
jointRounded: true, jointRounded: true,
widthMinPixels: 1, widthMinPixels: 2,
pickable: true, pickable: true,
onHover: (info: { object?: ScatSegment; x: number; y: number }) => { onHover: (info: { object?: ScatSegment; x: number; y: number }) => {
if (info.object) { if (info.object) setTooltip({ x: info.x, y: info.y, seg: info.object });
setTooltip({ x: info.x, y: info.y, seg: info.object }); else setTooltip(null);
} else {
setTooltip(null);
}
}, },
onClick: (info: { object?: ScatSegment }) => { onClick: (info: { object?: ScatSegment }) => {
if (info.object) handleClick(info.object); if (info.object) handleClick(info.object);
@ -204,46 +177,58 @@ function ScatMap({
[segments, selectedSeg, zs, handleClick], [segments, selectedSeg, zs, handleClick],
); );
// 조사 상태 마커 (줌 >= 11 시 표시) const shadowDotLayer = useMemo(
const markerLayer = useMemo(() => { () =>
if (!zs.showStatusMarker) return null; new ScatterplotLayer<ScatSegment>({
return new ScatterplotLayer({ id: 'scat-shadow-dots',
id: 'scat-status-markers', data: segments,
data: segments, getPosition: (d) => [d.lng, d.lat],
getPosition: (d: ScatSegment) => [d.lng, d.lat], getRadius: zs.dotRadius + 2,
getRadius: zs.markerRadius, getFillColor: [0, 0, 0, 70],
getFillColor: (d: ScatSegment) => { stroked: false,
if (d.status === '완료') return [34, 197, 94, 51]; radiusUnits: 'pixels',
if (d.status === '진행중') return [234, 179, 8, 51]; radiusMinPixels: 7,
return [100, 116, 139, 51]; radiusMaxPixels: 18,
}, pickable: false,
getLineColor: (d: ScatSegment) => { updateTriggers: { getRadius: [zs.dotRadius] },
if (d.status === '완료') return [34, 197, 94, 200]; }),
if (d.status === '진행중') return [234, 179, 8, 200]; [segments, zs.dotRadius],
return [100, 116, 139, 200]; );
},
getLineWidth: 1, const dotLayer = useMemo(
stroked: true, () =>
radiusMinPixels: 4, new ScatterplotLayer<ScatSegment>({
radiusMaxPixels: 22, id: 'scat-dots',
radiusUnits: 'pixels', data: segments,
pickable: true, getPosition: (d) => [d.lng, d.lat],
onClick: (info: { object?: ScatSegment }) => { getRadius: zs.dotRadius,
if (info.object) handleClick(info.object); getFillColor: (d) => {
}, if (d.status === '완료') return [34, 197, 94, 210];
updateTriggers: { if (d.status === '진행중') return [234, 179, 8, 210];
getRadius: [zs.markerRadius], return [148, 163, 184, 200];
}, },
}); stroked: false,
}, [segments, zs.showStatusMarker, zs.markerRadius, handleClick]); 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const deckLayers: any[] = useMemo(() => { const deckLayers: any[] = useMemo(
// eslint-disable-next-line @typescript-eslint/no-explicit-any () => [glowLayer, segPathLayer, shadowDotLayer, dotLayer],
const layers: any[] = [glowLayer, segPathLayer]; [glowLayer, segPathLayer, shadowDotLayer, dotLayer],
if (markerLayer) layers.push(markerLayer); );
return layers;
}, [glowLayer, segPathLayer, markerLayer]);
const totalLen = segments.reduce((a, s) => a + s.lengthM, 0); const totalLen = segments.reduce((a, s) => a + s.lengthM, 0);
const doneLen = segments.filter((s) => s.status === '완료').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 ( return (
<div className="absolute inset-0 overflow-hidden"> <div className="absolute inset-0 overflow-hidden">
<Map <BaseMap center={initialCenter} zoom={10} onZoom={setZoom}>
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} />
<DeckGLOverlay layers={deckLayers} /> <DeckGLOverlay layers={deckLayers} />
<FlyToController selectedSeg={selectedSeg} zones={zones} /> <ScatFlyToController selectedSeg={selectedSeg} zones={zones} />
</Map> </BaseMap>
{/* 호버 툴팁 */} {/* 호버 툴팁 */}
{tooltip && ( {tooltip && (
@ -287,11 +258,9 @@ function ScatMap({
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
> >
<div className="font-bold"> <div className="font-bold font-korean">{tooltip.seg.name}</div>
{tooltip.seg.code} {tooltip.seg.area}
</div>
<div className="text-caption opacity-70"> <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 === '완료' ? '✓' : tooltip.seg.status === '진행중' ? '⏳' : '—'}{' '}
{tooltip.seg.status} {tooltip.seg.status}
</div> </div>
@ -301,7 +270,7 @@ function ScatMap({
{/* Status chips */} {/* Status chips */}
<div className="absolute top-3.5 left-3.5 flex gap-2 z-[1000]"> <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"> <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 Pre-SCAT
</div> </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"> <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 className="text-caption font-bold uppercase tracking-wider text-fg-disabled mb-2.5">
</div> </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"> <div className="mt-2.5">
{[ {[
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''], ['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
@ -388,19 +338,6 @@ function ScatMap({
</div> </div>
</div> </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> </div>
); );
} }

파일 보기

@ -70,7 +70,8 @@ export default function ScatRightPanel({
: 'text-fg-disabled hover:text-fg-sub' : 'text-fg-disabled hover:text-fg-sub'
}`} }`}
> >
{tab.icon} {tab.label} {/* {tab.icon} */}
{tab.label}
</button> </button>
))} ))}
</div> </div>

파일 보기

@ -1,12 +1,8 @@
import { useState, useMemo, useCallback } from 'react'; import { useState, useMemo, useCallback } from 'react';
import { Map, Marker, useControl } from '@vis.gl/react-maplibre'; import { Marker } 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 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; import { BaseMap } from '@common/components/map/BaseMap';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'; import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay';
import { useMapStore } from '@common/store/mapStore';
import { WeatherRightPanel } from './WeatherRightPanel'; import { WeatherRightPanel } from './WeatherRightPanel';
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay'; import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay';
// import { OceanForecastOverlay } from './OceanForecastOverlay' // import { OceanForecastOverlay } from './OceanForecastOverlay'
@ -16,7 +12,6 @@ import { WindParticleLayer } from './WindParticleLayer';
import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer'; import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer';
import { useWeatherData } from '../hooks/useWeatherData'; import { useWeatherData } from '../hooks/useWeatherData';
// import { useOceanForecast } from '../hooks/useOceanForecast' // import { useOceanForecast } from '../hooks/useOceanForecast'
import { WeatherMapControls } from './WeatherMapControls';
import { degreesToCardinal } from '../services/weatherUtils'; import { degreesToCardinal } from '../services/weatherUtils';
type TimeOffset = '0' | '3' | '6' | '9'; 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_CENTER: [number, number] = [127.8, 36.5]; // [lng, lat]
const WEATHER_MAP_ZOOM = 7; 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 ) * WeatherMapInner Map (useMap / useControl )
*/ */
@ -104,8 +92,6 @@ interface WeatherMapInnerProps {
enabledLayers: Set<string>; enabledLayers: Set<string>;
selectedStationId: string | null; selectedStationId: string | null;
onStationClick: (station: WeatherStation) => void; onStationClick: (station: WeatherStation) => void;
mapCenter: [number, number];
mapZoom: number;
clickedLocation: { lat: number; lon: number } | null; clickedLocation: { lat: number; lon: number } | null;
} }
@ -114,8 +100,6 @@ function WeatherMapInner({
enabledLayers, enabledLayers,
selectedStationId, selectedStationId,
onStationClick, onStationClick,
mapCenter,
mapZoom,
clickedLocation, clickedLocation,
}: WeatherMapInnerProps) { }: WeatherMapInnerProps) {
// deck.gl layers 조합 // deck.gl layers 조합
@ -183,17 +167,12 @@ function WeatherMapInner({
</div> </div>
</Marker> </Marker>
)} )}
{/* 줌 컨트롤 */}
<WeatherMapControls center={mapCenter} zoom={mapZoom} />
</> </>
); );
} }
export function WeatherView() { export function WeatherView() {
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS); const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS);
const currentMapStyle = useBaseMapStyle();
const mapToggles = useMapStore((s) => s.mapToggles);
// const { // const {
// selectedForecast, // selectedForecast,
@ -220,8 +199,7 @@ export function WeatherView() {
}, []); }, []);
const handleMapClick = useCallback( const handleMapClick = useCallback(
(e: MapLayerMouseEvent) => { (lng: number, lat: number) => {
const { lat, lng } = e.lngLat;
if (weatherStations.length === 0) return; if (weatherStations.length === 0) return;
// 가장 가까운 관측소 선택 // 가장 가까운 관측소 선택
@ -331,28 +309,19 @@ export function WeatherView() {
{/* Map */} {/* Map */}
<div className="flex-1 relative"> <div className="flex-1 relative">
<Map <BaseMap
initialViewState={{ center={[WEATHER_MAP_CENTER[1], WEATHER_MAP_CENTER[0]]}
longitude: WEATHER_MAP_CENTER[0], zoom={WEATHER_MAP_ZOOM}
latitude: WEATHER_MAP_CENTER[1], onMapClick={handleMapClick}
zoom: WEATHER_MAP_ZOOM,
}}
mapStyle={currentMapStyle}
className="w-full h-full"
onClick={handleMapClick}
attributionControl={false}
> >
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<WeatherMapInner <WeatherMapInner
weatherStations={weatherStations} weatherStations={weatherStations}
enabledLayers={enabledLayers} enabledLayers={enabledLayers}
selectedStationId={selectedStation?.id || null} selectedStationId={selectedStation?.id || null}
onStationClick={handleStationClick} onStationClick={handleStationClick}
mapCenter={WEATHER_MAP_CENTER}
mapZoom={WEATHER_MAP_ZOOM}
clickedLocation={selectedLocation} 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"> <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">