Compare commits

...

14 커밋

작성자 SHA1 메시지 날짜
988cc47e9f 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
2026-04-15 14:49:19 +09:00
0daae3c807 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-15)' (#175) from release/2026-04-15-notes into develop 2026-04-15 14:48:12 +09:00
fa5c7f518f docs: 릴리즈 노트 정리 (2026-04-15) 2026-04-15 14:47:13 +09:00
72ead1140f Merge pull request 'feat(vessels): 실시간 선박 신호 지도 표출 및 폴링 스케줄러 추가' (#174) from feature/ship-signal-map into develop 2026-04-15 14:44:43 +09:00
938665e323 docs: 릴리즈 노트 업데이트 2026-04-15 14:43:28 +09:00
29c5293ce7 feat(vessels): 실시간 선박 신호 지도 표출 및 폴링 스케줄러 추가
- 백엔드 vessels 라우터/서비스/스케줄러 추가 (1분 주기 한국 해역 폴링)
- 공통 useVesselSignals 훅 + vesselApi/vesselSignalClient 서비스 추가
- MapView에 VesselLayer/VesselInteraction/MapBoundsTracker 통합 (호버·팝업·상세 모달)
- OilSpill/HNS/Rescue/Incidents 뷰에 선박 신호 연동
- vesselMockData 정리, aerial IMAGE_API_URL 기본값 변경
2026-04-15 14:40:28 +09:00
ae0a17990b Merge pull request 'feat(prediction): GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (prediction/hns/rescue)' (#173) from feature/prediction-gsc-accident-select into develop 2026-04-15 08:25:35 +09:00
6b19d34e5b chore: 팀 워크플로우 설정 업데이트
- settings.json Bash 권한 항목 세분화
- workflow-version.json 적용일 갱신 (2026-04-14)
2026-04-15 08:22:57 +09:00
679649ab8c docs: 릴리즈 노트 업데이트 2026-04-15 08:16:09 +09:00
279dcbc0e1 chore: develop 머지 충돌 해결 2026-04-15 08:13:12 +09:00
2fe9deeabe Merge pull request 'refactor(map): MapView ������Ʈ �и� �� ��ü �� ������ �ý��� ��ū ����' (#172) from feature/mpa-develop into develop 2026-04-14 17:33:31 +09:00
388116aa88 docs: 릴리즈 노트 업데이트 2026-04-14 17:32:08 +09:00
3eb66e2e54 refactor(map): MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용 2026-04-14 17:20:01 +09:00
15ca946a00 feat(prediction): GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (prediction/hns/rescue) 2026-04-14 17:11:38 +09:00
58개의 변경된 파일3926개의 추가작업 그리고 2984개의 파일을 삭제

파일 보기

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

파일 보기

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

파일 보기

@ -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 서버 프록시)
// ============================================================
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001';
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://211.208.115.83:5001';
const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090';
const INFERENCE_TIMEOUT_MS = 10_000;

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -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);
border: 1px solid var(--stroke-default);
border-radius: 4px;
color: var(--color-accent);
color: var(--color-default);
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 600;
font-weight: 400;
text-align: right;
outline: none;
transition: border-color 0.2s;

파일 보기

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

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -36,14 +36,7 @@ interface DeidentifyTask {
type SourceType = 'db' | 'file' | 'api';
type ProcessMode = 'immediate' | 'scheduled' | 'oneshot';
type RepeatType = 'daily' | 'weekly' | 'monthly';
type DeidentifyTechnique =
| '마스킹'
| '삭제'
| '범주화'
| '암호화'
| '샘플링'
| '가명처리'
| '유지';
type DeidentifyTechnique = '마스킹' | '삭제' | '범주화' | '암호화' | '샘플링' | '가명처리' | '유지';
interface FieldConfig {
name: string;
@ -97,24 +90,102 @@ interface WizardState {
// ─── Mock 데이터 ────────────────────────────────────────────
const MOCK_TASKS: DeidentifyTask[] = [
{ id: '001', name: 'customer_2024', target: '선박/운항 - 선장·선원 성명', status: '완료', startTime: '2026-04-10 14:30', progress: 100, createdBy: '관리자' },
{ id: '002', name: 'transaction_04', target: '사고 현장 - 현장사진, 영상내 인물', status: '진행중', startTime: '2026-04-10 14:15', progress: 82, createdBy: '김담당' },
{ id: '003', name: 'employee_info', target: '인사정보 - 계정, 로그인 정보', status: '대기', startTime: '2026-04-10 22:00', progress: 0, createdBy: '이담당' },
{ id: '004', name: 'vendor_data', target: 'HNS 대응 - 화학물질 취급자, 방제업체 연락처', status: '오류', startTime: '2026-04-09 13:45', progress: 45, createdBy: '관리자' },
{ id: '005', name: 'partner_contacts', target: '시스템 운영 - 관리자, 운영자 접속로그', status: '완료', startTime: '2026-04-08 09:00', progress: 100, createdBy: '박담당' },
{
id: '001',
name: 'customer_2024',
target: '선박/운항 - 선장·선원 성명',
status: '완료',
startTime: '2026-04-10 14:30',
progress: 100,
createdBy: '관리자',
},
{
id: '002',
name: 'transaction_04',
target: '사고 현장 - 현장사진, 영상내 인물',
status: '진행중',
startTime: '2026-04-10 14:15',
progress: 82,
createdBy: '김담당',
},
{
id: '003',
name: 'employee_info',
target: '인사정보 - 계정, 로그인 정보',
status: '대기',
startTime: '2026-04-10 22:00',
progress: 0,
createdBy: '이담당',
},
{
id: '004',
name: 'vendor_data',
target: 'HNS 대응 - 화학물질 취급자, 방제업체 연락처',
status: '오류',
startTime: '2026-04-09 13:45',
progress: 45,
createdBy: '관리자',
},
{
id: '005',
name: 'partner_contacts',
target: '시스템 운영 - 관리자, 운영자 접속로그',
status: '완료',
startTime: '2026-04-08 09:00',
progress: 100,
createdBy: '박담당',
},
];
const DEFAULT_FIELDS: FieldConfig[] = [
{ name: '고객ID', dataType: '문자열', technique: '삭제', configValue: '-', selected: true },
{ name: '이름', dataType: '문자열', technique: '마스킹', configValue: '*로 치환', selected: true },
{ name: '휴대폰', dataType: '문자열', technique: '마스킹', configValue: '010-****-****', selected: true },
{ name: '주소', dataType: '문자열', technique: '범주화', configValue: '시/도만 표시', selected: true },
{ name: '이메일', dataType: '문자열', technique: '가명처리', configValue: '키: random_001', selected: true },
{ name: '생년월일', dataType: '날짜', technique: '범주화', configValue: '연도만 표시', selected: true },
{
name: '이름',
dataType: '문자열',
technique: '마스킹',
configValue: '*로 치환',
selected: true,
},
{
name: '휴대폰',
dataType: '문자열',
technique: '마스킹',
configValue: '010-****-****',
selected: true,
},
{
name: '주소',
dataType: '문자열',
technique: '범주화',
configValue: '시/도만 표시',
selected: true,
},
{
name: '이메일',
dataType: '문자열',
technique: '가명처리',
configValue: '키: random_001',
selected: true,
},
{
name: '생년월일',
dataType: '날짜',
technique: '범주화',
configValue: '연도만 표시',
selected: true,
},
{ name: '회사', dataType: '문자열', technique: '유지', configValue: '변경 없음', selected: true },
];
const TECHNIQUES: DeidentifyTechnique[] = ['마스킹', '삭제', '범주화', '암호화', '샘플링', '가명처리', '유지'];
const TECHNIQUES: DeidentifyTechnique[] = [
'마스킹',
'삭제',
'범주화',
'암호화',
'샘플링',
'가명처리',
'유지',
];
const HOURS = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`);
@ -124,23 +195,161 @@ const TEMPLATES = ['기본 개인정보', '금융데이터', '의료데이터'];
const MOCK_AUDIT_LOGS: Record<string, AuditLogEntry[]> = {
'001': [
{ id: 'LOG_20260410_001', time: '2026-04-10 14:30:45', operator: '김철수', operatorId: 'user_12345', action: '처리완료', targetData: 'customer_2024', result: '성공 (100%)', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 15240, rulesApplied: '마스킹 3, 범주화 2, 삭제 2', processedCount: 15240, errorCount: 0 } },
{ id: 'LOG_20260410_002', time: '2026-04-10 14:15:10', operator: '김철수', operatorId: 'user_12345', action: '처리시작', targetData: 'customer_2024', result: '성공', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 15240, rulesApplied: '마스킹 3, 범주화 2, 삭제 2', processedCount: 0, errorCount: 0 } },
{ id: 'LOG_20260410_003', time: '2026-04-10 14:10:30', operator: '김철수', operatorId: 'user_12345', action: '규칙설정', targetData: 'customer_2024', result: '성공', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 15240, rulesApplied: '7개 규칙 적용', processedCount: 0, errorCount: 0 } },
{
id: 'LOG_20260410_001',
time: '2026-04-10 14:30:45',
operator: '김철수',
operatorId: 'user_12345',
action: '처리완료',
targetData: 'customer_2024',
result: '성공 (100%)',
resultType: '성공',
ip: '192.168.1.100',
browser: 'Chrome 123.0',
detail: {
dataCount: 15240,
rulesApplied: '마스킹 3, 범주화 2, 삭제 2',
processedCount: 15240,
errorCount: 0,
},
},
{
id: 'LOG_20260410_002',
time: '2026-04-10 14:15:10',
operator: '김철수',
operatorId: 'user_12345',
action: '처리시작',
targetData: 'customer_2024',
result: '성공',
resultType: '성공',
ip: '192.168.1.100',
browser: 'Chrome 123.0',
detail: {
dataCount: 15240,
rulesApplied: '마스킹 3, 범주화 2, 삭제 2',
processedCount: 0,
errorCount: 0,
},
},
{
id: 'LOG_20260410_003',
time: '2026-04-10 14:10:30',
operator: '김철수',
operatorId: 'user_12345',
action: '규칙설정',
targetData: 'customer_2024',
result: '성공',
resultType: '성공',
ip: '192.168.1.100',
browser: 'Chrome 123.0',
detail: { dataCount: 15240, rulesApplied: '7개 규칙 적용', processedCount: 0, errorCount: 0 },
},
],
'002': [
{ id: 'LOG_20260410_004', time: '2026-04-10 14:15:22', operator: '이영희', operatorId: 'user_23456', action: '처리시작', targetData: 'transaction_04', result: '진행중 (82%)', resultType: '진행중', ip: '192.168.1.101', browser: 'Firefox 124.0', detail: { dataCount: 8920, rulesApplied: '마스킹 2, 암호화 1, 삭제 3', processedCount: 7314, errorCount: 0 } },
{
id: 'LOG_20260410_004',
time: '2026-04-10 14:15:22',
operator: '이영희',
operatorId: 'user_23456',
action: '처리시작',
targetData: 'transaction_04',
result: '진행중 (82%)',
resultType: '진행중',
ip: '192.168.1.101',
browser: 'Firefox 124.0',
detail: {
dataCount: 8920,
rulesApplied: '마스킹 2, 암호화 1, 삭제 3',
processedCount: 7314,
errorCount: 0,
},
},
],
'003': [
{ id: 'LOG_20260410_005', time: '2026-04-10 13:45:30', operator: '박민준', operatorId: 'user_34567', action: '규칙수정', targetData: 'employee_info', result: '성공', resultType: '성공', ip: '192.168.1.102', browser: 'Chrome 123.0', detail: { dataCount: 3200, rulesApplied: '마스킹 4, 가명처리 1', processedCount: 0, errorCount: 0 } },
{
id: 'LOG_20260410_005',
time: '2026-04-10 13:45:30',
operator: '박민준',
operatorId: 'user_34567',
action: '규칙수정',
targetData: 'employee_info',
result: '성공',
resultType: '성공',
ip: '192.168.1.102',
browser: 'Chrome 123.0',
detail: {
dataCount: 3200,
rulesApplied: '마스킹 4, 가명처리 1',
processedCount: 0,
errorCount: 0,
},
},
],
'004': [
{ id: 'LOG_20260409_001', time: '2026-04-09 13:45:30', operator: '관리자', operatorId: 'user_admin', action: '처리오류', targetData: 'vendor_data', result: '오류 (45%)', resultType: '실패', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 5100, rulesApplied: '마스킹 2, 범주화 1, 삭제 1', processedCount: 2295, errorCount: 12 } },
{ id: 'LOG_20260409_002', time: '2026-04-09 13:40:15', operator: '김철수', operatorId: 'user_12345', action: '규칙조회', targetData: 'vendor_data', result: '성공', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 5100, rulesApplied: '4개 규칙', processedCount: 0, errorCount: 0 } },
{ id: 'LOG_20260409_003', time: '2026-04-09 09:25:00', operator: '이영희', operatorId: 'user_23456', action: '삭제시도', targetData: 'vendor_data', result: '거부 (권한부족)', resultType: '거부', ip: '192.168.1.101', browser: 'Firefox 124.0', detail: { dataCount: 5100, rulesApplied: '-', processedCount: 0, errorCount: 0 } },
{
id: 'LOG_20260409_001',
time: '2026-04-09 13:45:30',
operator: '관리자',
operatorId: 'user_admin',
action: '처리오류',
targetData: 'vendor_data',
result: '오류 (45%)',
resultType: '실패',
ip: '192.168.1.100',
browser: 'Chrome 123.0',
detail: {
dataCount: 5100,
rulesApplied: '마스킹 2, 범주화 1, 삭제 1',
processedCount: 2295,
errorCount: 12,
},
},
{
id: 'LOG_20260409_002',
time: '2026-04-09 13:40:15',
operator: '김철수',
operatorId: 'user_12345',
action: '규칙조회',
targetData: 'vendor_data',
result: '성공',
resultType: '성공',
ip: '192.168.1.100',
browser: 'Chrome 123.0',
detail: { dataCount: 5100, rulesApplied: '4개 규칙', processedCount: 0, errorCount: 0 },
},
{
id: 'LOG_20260409_003',
time: '2026-04-09 09:25:00',
operator: '이영희',
operatorId: 'user_23456',
action: '삭제시도',
targetData: 'vendor_data',
result: '거부 (권한부족)',
resultType: '거부',
ip: '192.168.1.101',
browser: 'Firefox 124.0',
detail: { dataCount: 5100, rulesApplied: '-', processedCount: 0, errorCount: 0 },
},
],
'005': [
{ id: 'LOG_20260408_001', time: '2026-04-08 09:15:00', operator: '박담당', operatorId: 'user_45678', action: '처리완료', targetData: 'partner_contacts', result: '성공 (100%)', resultType: '성공', ip: '192.168.1.103', browser: 'Edge 122.0', detail: { dataCount: 1850, rulesApplied: '마스킹 2, 유지 3', processedCount: 1850, errorCount: 0 } },
{
id: 'LOG_20260408_001',
time: '2026-04-08 09:15:00',
operator: '박담당',
operatorId: 'user_45678',
action: '처리완료',
targetData: 'partner_contacts',
result: '성공 (100%)',
resultType: '성공',
ip: '192.168.1.103',
browser: 'Edge 122.0',
detail: {
dataCount: 1850,
rulesApplied: '마스킹 2, 유지 3',
processedCount: 1850,
errorCount: 0,
},
},
],
};
@ -154,10 +363,14 @@ function fetchTasks(): Promise<DeidentifyTask[]> {
function getStatusBadgeClass(status: TaskStatus): string {
switch (status) {
case '완료': return 'text-emerald-400 bg-emerald-500/10';
case '진행중': return 'text-cyan-400 bg-cyan-500/10';
case '대기': return 'text-yellow-400 bg-yellow-500/10';
case '오류': return 'text-red-400 bg-red-500/10';
case '완료':
return 'text-emerald-400 bg-emerald-500/10';
case '진행중':
return 'text-cyan-400 bg-cyan-500/10';
case '대기':
return 'text-yellow-400 bg-yellow-500/10';
case '오류':
return 'text-red-400 bg-red-500/10';
}
}
@ -169,7 +382,10 @@ function ProgressBar({ value }: { value: number }) {
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-bg-elevated rounded-full overflow-hidden">
<div className={`h-full rounded-full transition-all ${colorClass}`} style={{ width: `${value}%` }} />
<div
className={`h-full rounded-full transition-all ${colorClass}`}
style={{ width: `${value}%` }}
/>
</div>
<span className="text-t3 w-8 text-right">{value}%</span>
</div>
@ -217,9 +433,16 @@ function TaskTable({ rows, loading, onAction }: TaskTableProps) {
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<td className="px-3 py-2 text-t3 font-mono">{row.id}</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.name}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap max-w-[240px] truncate" title={row.target}>{row.target}</td>
<td
className="px-3 py-2 text-t2 whitespace-nowrap max-w-[240px] truncate"
title={row.target}
>
{row.target}
</td>
<td className="px-3 py-2">
<span className={`inline-block px-2 py-0.5 rounded text-label-2 font-medium ${getStatusBadgeClass(row.status)}`}>
<span
className={`inline-block px-2 py-0.5 rounded text-label-2 font-medium ${getStatusBadgeClass(row.status)}`}
>
{row.status}
</span>
</td>
@ -289,13 +512,18 @@ function StepIndicator({ current }: { current: number }) {
isDone
? 'bg-emerald-500 text-white'
: isActive
? 'bg-cyan-500 text-white'
: 'bg-bg-elevated text-t3'
? 'bg-cyan-500 text-white'
: 'bg-bg-elevated text-t3'
}`}
>
{isDone ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M5 13l4 4L19 7"
/>
</svg>
) : (
stepNum
@ -352,11 +580,13 @@ function Step1({ wizard, onChange }: Step1Props) {
<div>
<label className="block text-xs font-medium text-t2 mb-2"> *</label>
<div className="flex flex-col gap-2">
{([
['db', '데이터베이스 연결'],
['file', '파일 업로드'],
['api', 'API 호출'],
] as [SourceType, string][]).map(([val, label]) => (
{(
[
['db', '데이터베이스 연결'],
['file', '파일 업로드'],
['api', 'API 호출'],
] as [SourceType, string][]
).map(([val, label]) => (
<label key={val} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
@ -399,7 +629,12 @@ function Step1({ wizard, onChange }: Step1Props) {
{wizard.sourceType === 'file' && (
<div className="p-8 rounded border-2 border-dashed border-stroke-1 bg-bg-surface flex flex-col items-center gap-2 text-center">
<svg className="w-8 h-8 text-t3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-xs text-t2"> </p>
<p className="text-label-2 text-t3">CSV, XLSX, JSON ( 500MB)</p>
@ -444,9 +679,7 @@ interface Step2Props {
function Step2({ wizard, onChange }: Step2Props) {
const toggleField = (idx: number) => {
const updated = wizard.fields.map((f, i) =>
i === idx ? { ...f, selected: !f.selected } : f,
);
const updated = wizard.fields.map((f, i) => (i === idx ? { ...f, selected: !f.selected } : f));
onChange({ fields: updated });
};
@ -476,13 +709,17 @@ function Step2({ wizard, onChange }: Step2Props) {
type="checkbox"
checked={wizard.fields.every((f) => f.selected)}
onChange={(e) =>
onChange({ fields: wizard.fields.map((f) => ({ ...f, selected: e.target.checked })) })
onChange({
fields: wizard.fields.map((f) => ({ ...f, selected: e.target.checked })),
})
}
className="accent-cyan-500"
/>
</th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1"></th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1"> </th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">
</th>
</tr>
</thead>
<tbody>
@ -520,9 +757,7 @@ interface Step3Props {
function Step3({ wizard, onChange }: Step3Props) {
const updateField = (idx: number, key: keyof FieldConfig, value: string | boolean) => {
const updated = wizard.fields.map((f, i) =>
i === idx ? { ...f, [key]: value } : f,
);
const updated = wizard.fields.map((f, i) => (i === idx ? { ...f, [key]: value } : f));
onChange({ fields: updated });
};
@ -535,8 +770,12 @@ function Step3({ wizard, onChange }: Step3Props) {
<thead>
<tr className="bg-bg-elevated text-t3">
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1"></th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1"></th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1"> </th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">
</th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">
</th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1"></th>
</tr>
</thead>
@ -554,7 +793,9 @@ function Step3({ wizard, onChange }: Step3Props) {
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
>
{TECHNIQUES.map((t) => (
<option key={t} value={t}>{t}</option>
<option key={t} value={t}>
{t}
</option>
))}
</select>
</td>
@ -593,7 +834,9 @@ function Step3({ wizard, onChange }: Step3Props) {
>
<option value=""> </option>
{TEMPLATES.map((t) => (
<option key={t} value={t}>{t}</option>
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
@ -652,7 +895,11 @@ function Step4({ wizard, onChange }: Step4Props) {
onChange={(e) => handleScheduleChange('hour', e.target.value)}
className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
>
{HOURS.map((h) => <option key={h} value={h}>{h}</option>)}
{HOURS.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
</div>
<div className="flex items-start gap-3">
@ -681,7 +928,11 @@ function Step4({ wizard, onChange }: Step4Props) {
onChange={(e) => handleScheduleChange('weekday', e.target.value)}
className="ml-1 px-2 py-0.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
>
{WEEKDAYS.map((d) => <option key={d} value={d}>{d}</option>)}
{WEEKDAYS.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
)}
</div>
@ -738,7 +989,11 @@ function Step4({ wizard, onChange }: Step4Props) {
onChange={(e) => handleOneshotChange('hour', e.target.value)}
className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
>
{HOURS.map((h) => <option key={h} value={h}>{h}</option>)}
{HOURS.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
</div>
</div>
@ -769,7 +1024,15 @@ function Step5({ wizard, onChange }: Step5Props) {
const summaryRows = [
{ label: '작업명', value: wizard.taskName || '(미입력)' },
{ label: '소스', value: wizard.sourceType === 'db' ? `DB: ${wizard.dbConfig.database}.${wizard.dbConfig.tableName}` : wizard.sourceType === 'file' ? '파일 업로드' : `API: ${wizard.apiConfig.url}` },
{
label: '소스',
value:
wizard.sourceType === 'db'
? `DB: ${wizard.dbConfig.database}.${wizard.dbConfig.tableName}`
: wizard.sourceType === 'file'
? '파일 업로드'
: `API: ${wizard.apiConfig.url}`,
},
{ label: '데이터 건수', value: '15,240건' },
{ label: '선택 필드 수', value: `${selectedCount}` },
{ label: '비식별화 규칙 수', value: `${ruleCount}` },
@ -833,10 +1096,14 @@ const INITIAL_WIZARD: WizardState = {
function getAuditResultClass(type: AuditLogEntry['resultType']): string {
switch (type) {
case '성공': return 'text-emerald-400 bg-emerald-500/10';
case '진행중': return 'text-cyan-400 bg-cyan-500/10';
case '실패': return 'text-red-400 bg-red-500/10';
case '거부': return 'text-yellow-400 bg-yellow-500/10';
case '성공':
return 'text-emerald-400 bg-emerald-500/10';
case '진행중':
return 'text-cyan-400 bg-cyan-500/10';
case '실패':
return 'text-red-400 bg-red-500/10';
case '거부':
return 'text-yellow-400 bg-yellow-500/10';
}
}
@ -863,10 +1130,11 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
<div className="bg-bg-card border border-stroke-1 rounded-lg shadow-2xl w-full max-w-5xl max-h-[85vh] flex flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
<h3 className="text-sm font-semibold text-t1">
() {task.name}
</h3>
<button onClick={onClose} className="text-t3 hover:text-t1 transition-colors text-lg leading-none">
<h3 className="text-sm font-semibold text-t1"> () {task.name}</h3>
<button
onClick={onClose}
className="text-t3 hover:text-t1 transition-colors text-lg leading-none"
>
</button>
</div>
@ -894,7 +1162,9 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
>
{operators.map((op) => (
<option key={op} value={op}>{op}</option>
<option key={op} value={op}>
{op}
</option>
))}
</select>
</div>
@ -905,7 +1175,10 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{['시간', '작업자', '작업', '대상 데이터', '결과', '상세'].map((h) => (
<th key={h} className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap">
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
>
{h}
</th>
))}
@ -925,18 +1198,27 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
className={`border-b border-stroke-1 hover:bg-bg-surface/50 cursor-pointer ${selectedLog?.id === log.id ? 'bg-bg-surface/70' : ''}`}
onClick={() => setSelectedLog(log)}
>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{log.time.split(' ')[1]}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
{log.time.split(' ')[1]}
</td>
<td className="px-3 py-2 text-t1 whitespace-nowrap">{log.operator}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{log.action}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{log.targetData}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
{log.targetData}
</td>
<td className="px-3 py-2">
<span className={`inline-block px-2 py-0.5 rounded text-label-2 font-medium ${getAuditResultClass(log.resultType)}`}>
<span
className={`inline-block px-2 py-0.5 rounded text-label-2 font-medium ${getAuditResultClass(log.resultType)}`}
>
{log.result}
</span>
</td>
<td className="px-3 py-2">
<button
onClick={(e) => { e.stopPropagation(); setSelectedLog(log); }}
onClick={(e) => {
e.stopPropagation();
setSelectedLog(log);
}}
className="px-2 py-0.5 text-label-2 rounded bg-bg-elevated hover:bg-bg-card text-cyan-400 transition-colors whitespace-nowrap"
>
@ -954,15 +1236,49 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
<div className="px-5 py-3 border-t border-stroke-1 shrink-0 bg-bg-base">
<h4 className="text-xs font-semibold text-t1 mb-2"> </h4>
<div className="bg-bg-elevated border border-stroke-1 rounded p-3 text-xs grid grid-cols-2 gap-x-6 gap-y-1.5">
<div><span className="text-t3">ID:</span> <span className="text-t1 font-mono">{selectedLog.id}</span></div>
<div><span className="text-t3">:</span> <span className="text-t1 font-mono">{selectedLog.time}</span></div>
<div><span className="text-t3">:</span> <span className="text-t1">{selectedLog.operator} ({selectedLog.operatorId})</span></div>
<div><span className="text-t3"> :</span> <span className="text-t1">{selectedLog.action}</span></div>
<div><span className="text-t3">:</span> <span className="text-t1">{selectedLog.targetData} ({selectedLog.detail.dataCount.toLocaleString()})</span></div>
<div><span className="text-t3"> :</span> <span className="text-t1">{selectedLog.detail.rulesApplied}</span></div>
<div><span className="text-t3">:</span> <span className="text-t1">{selectedLog.result} (: {selectedLog.detail.processedCount.toLocaleString()}, : {selectedLog.detail.errorCount})</span></div>
<div><span className="text-t3">IP :</span> <span className="text-t1 font-mono">{selectedLog.ip}</span></div>
<div><span className="text-t3">:</span> <span className="text-t1">{selectedLog.browser}</span></div>
<div>
<span className="text-t3">ID:</span>{' '}
<span className="text-t1 font-mono">{selectedLog.id}</span>
</div>
<div>
<span className="text-t3">:</span>{' '}
<span className="text-t1 font-mono">{selectedLog.time}</span>
</div>
<div>
<span className="text-t3">:</span>{' '}
<span className="text-t1">
{selectedLog.operator} ({selectedLog.operatorId})
</span>
</div>
<div>
<span className="text-t3"> :</span>{' '}
<span className="text-t1">{selectedLog.action}</span>
</div>
<div>
<span className="text-t3">:</span>{' '}
<span className="text-t1">
{selectedLog.targetData} ({selectedLog.detail.dataCount.toLocaleString()})
</span>
</div>
<div>
<span className="text-t3"> :</span>{' '}
<span className="text-t1">{selectedLog.detail.rulesApplied}</span>
</div>
<div>
<span className="text-t3">:</span>{' '}
<span className="text-t1">
{selectedLog.result} (: {selectedLog.detail.processedCount.toLocaleString()},
: {selectedLog.detail.errorCount})
</span>
</div>
<div>
<span className="text-t3">IP :</span>{' '}
<span className="text-t1 font-mono">{selectedLog.ip}</span>
</div>
<div>
<span className="text-t3">:</span>{' '}
<span className="text-t1">{selectedLog.browser}</span>
</div>
</div>
</div>
)}
@ -1030,7 +1346,12 @@ function WizardModal({ onClose, onSubmit }: WizardModalProps) {
className="p-1 rounded text-t3 hover:text-t1 hover:bg-bg-elevated transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
@ -1128,22 +1449,32 @@ export default function DeidentifyPanel() {
}
}, []);
const handleWizardSubmit = useCallback((wizard: WizardState) => {
const selectedFields = wizard.fields.filter((f) => f.selected).map((f) => f.name);
const newTask: DeidentifyTask = {
id: String(tasks.length + 1).padStart(3, '0'),
name: wizard.taskName,
target: selectedFields.join(', ') || '-',
status: wizard.processMode === 'immediate' ? '진행중' : '대기',
startTime: new Date().toLocaleString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).replace(/\. /g, '-').replace('.', ''),
progress: 0,
createdBy: '관리자',
};
setTasks((prev) => [newTask, ...prev]);
}, [tasks.length]);
const handleWizardSubmit = useCallback(
(wizard: WizardState) => {
const selectedFields = wizard.fields.filter((f) => f.selected).map((f) => f.name);
const newTask: DeidentifyTask = {
id: String(tasks.length + 1).padStart(3, '0'),
name: wizard.taskName,
target: selectedFields.join(', ') || '-',
status: wizard.processMode === 'immediate' ? '진행중' : '대기',
startTime: new Date()
.toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
.replace(/\. /g, '-')
.replace('.', ''),
progress: 0,
createdBy: '관리자',
};
setTasks((prev) => [newTask, ...prev]);
},
[tasks.length],
);
const filteredTasks = tasks.filter((t) => {
if (searchName && !t.name.includes(searchName)) return false;
@ -1205,7 +1536,9 @@ export default function DeidentifyPanel() {
className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
>
{(['모두', '완료', '진행중', '대기', '오류'] as FilterStatus[]).map((s) => (
<option key={s} value={s}>{s}</option>
<option key={s} value={s}>
{s}
</option>
))}
</select>
<select
@ -1225,16 +1558,11 @@ export default function DeidentifyPanel() {
</div>
{/* 감사로그 모달 */}
{auditTask && (
<AuditLogModal task={auditTask} onClose={() => setAuditTask(null)} />
)}
{auditTask && <AuditLogModal task={auditTask} onClose={() => setAuditTask(null)} />}
{/* 마법사 모달 */}
{showWizard && (
<WizardModal
onClose={() => setShowWizard(false)}
onSubmit={handleWizardSubmit}
/>
<WizardModal onClose={() => setShowWizard(false)} onSubmit={handleWizardSubmit} />
)}
</div>
);

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -5,6 +5,7 @@ import { fetchCctvCameras } from '../services/aerialApi';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
import { useMapStore } from '@common/store/mapStore';
import { BaseMap } from '@common/components/map/BaseMap';
import type { CctvCameraItem } from '../services/aerialApi';
import { CCTVPlayer } from './CCTVPlayer';
import type { CCTVPlayerHandle } from './CCTVPlayer';
@ -1055,13 +1056,7 @@ export function CctvView() {
</div>
) : showMap ? (
<div className="flex-1 overflow-hidden relative">
<Map
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
mapStyle={currentMapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<BaseMap center={[35.5, 127.8]} zoom={6.2}>
{filtered
.filter((c) => c.lon && c.lat)
.map((cam) => (
@ -1221,7 +1216,7 @@ export function CctvView() {
</div>
</Popup>
)}
</Map>
</BaseMap>
{/* 지도 위 안내 배지 */}
<div
className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full text-label-1 font-bold font-korean z-10"

파일 보기

@ -1,39 +1,12 @@
import { useMemo, useCallback, useEffect, useRef } from 'react';
import { Map, useControl, useMap } from '@vis.gl/react-maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { useMemo, useCallback, useState } from 'react';
import { ScatterplotLayer } from '@deck.gl/layers';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
import { useMapStore } from '@common/store/mapStore';
import { BaseMap } from '@common/components/map/BaseMap';
import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay';
import { FlyToController } from '@common/components/map/FlyToController';
import type { AssetOrgCompat } from '../services/assetsApi';
import { typeColor } from './assetTypes';
import { hexToRgba } from '@common/components/map/mapUtils';
// ── DeckGLOverlay ──────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: any[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers });
return null;
}
// ── FlyTo Controller ────────────────────────────────────
function FlyToController({ selectedOrg }: { selectedOrg: AssetOrgCompat }) {
const { current: map } = useMap();
const prevIdRef = useRef<number | undefined>(undefined);
useEffect(() => {
if (!map) return;
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedOrg.id) {
map.flyTo({ center: [selectedOrg.lng, selectedOrg.lat], zoom: 10, duration: 800 });
}
prevIdRef.current = selectedOrg.id;
}, [map, selectedOrg]);
return null;
}
interface AssetMapProps {
organizations: AssetOrgCompat[];
selectedOrg: AssetOrgCompat;
@ -49,8 +22,16 @@ function AssetMap({
regionFilter,
onRegionFilterChange,
}: AssetMapProps) {
const currentMapStyle = useBaseMapStyle();
const mapToggles = useMapStore((s) => s.mapToggles);
// 선택 항목이 실제로 바뀔 때만 flyTo (첫 렌더에서는 이동하지 않음)
// 첫 렌더 ID를 useState lazy init으로 동결 → 그 외엔 useMemo로 target 파생
const [initialId] = useState(selectedOrg.id);
const flyTarget = useMemo(
() =>
selectedOrg.id === initialId
? null
: { lng: selectedOrg.lng, lat: selectedOrg.lat, zoom: 10 },
[selectedOrg, initialId],
);
const handleClick = useCallback(
(org: AssetOrgCompat) => {
@ -59,58 +40,54 @@ function AssetMap({
[onSelectOrg],
);
const markerLayer = useMemo(() => {
return new ScatterplotLayer({
id: 'asset-orgs',
data: orgs,
getPosition: (d: AssetOrgCompat) => [d.lng, d.lat],
getRadius: (d: AssetOrgCompat) => {
const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7;
const isSelected = selectedOrg.id === d.id;
return isSelected ? baseRadius + 4 : baseRadius;
},
getFillColor: (d: AssetOrgCompat) => {
const tc = typeColor(d.type);
const isSelected = selectedOrg.id === d.id;
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 229 : 178);
},
getLineColor: (d: AssetOrgCompat) => {
const tc = typeColor(d.type);
const isSelected = selectedOrg.id === d.id;
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 255 : 200);
},
getLineWidth: (d: AssetOrgCompat) => (selectedOrg.id === d.id ? 3 : 2),
stroked: true,
radiusMinPixels: 4,
radiusMaxPixels: 20,
radiusUnits: 'pixels',
pickable: true,
onClick: (info: { object?: AssetOrgCompat }) => {
if (info.object) handleClick(info.object);
},
updateTriggers: {
getRadius: [selectedOrg.id],
getFillColor: [selectedOrg.id],
getLineColor: [selectedOrg.id],
getLineWidth: [selectedOrg.id],
},
});
}, [orgs, selectedOrg, handleClick]);
const markerLayer = useMemo(
() =>
new ScatterplotLayer({
id: 'asset-orgs',
data: orgs,
getPosition: (d: AssetOrgCompat) => [d.lng, d.lat],
getRadius: (d: AssetOrgCompat) => {
const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7;
const isSelected = selectedOrg.id === d.id;
return isSelected ? baseRadius + 4 : baseRadius;
},
getFillColor: (d: AssetOrgCompat) => {
const tc = typeColor(d.type);
const isSelected = selectedOrg.id === d.id;
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 229 : 178);
},
getLineColor: (d: AssetOrgCompat) => {
const tc = typeColor(d.type);
const isSelected = selectedOrg.id === d.id;
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 255 : 200);
},
getLineWidth: (d: AssetOrgCompat) => (selectedOrg.id === d.id ? 3 : 2),
stroked: true,
radiusMinPixels: 4,
radiusMaxPixels: 20,
radiusUnits: 'pixels',
pickable: true,
onClick: (info: { object?: AssetOrgCompat }) => {
if (info.object) handleClick(info.object);
},
updateTriggers: {
getRadius: [selectedOrg.id],
getFillColor: [selectedOrg.id],
getLineColor: [selectedOrg.id],
getLineWidth: [selectedOrg.id],
},
}),
[orgs, selectedOrg, handleClick],
);
return (
<div className="w-full h-full relative">
<Map
initialViewState={{ longitude: 127.8, latitude: 35.9, zoom: 7 }}
mapStyle={currentMapStyle}
className="w-full h-full"
attributionControl={false}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<BaseMap center={[35.9, 127.8]} zoom={7}>
<DeckGLOverlay layers={[markerLayer]} />
<FlyToController selectedOrg={selectedOrg} />
</Map>
<FlyToController target={flyTarget} duration={800} />
</BaseMap>
{/* Region filter overlay */}
{/* 지역 필터 */}
<div className="absolute top-3 left-3 z-[1000] flex gap-1">
{[
{ value: 'all', label: '전체' },
@ -134,7 +111,7 @@ function AssetMap({
))}
</div>
{/* Legend overlay */}
{/* 범례 */}
<div className="absolute bottom-3 left-3 z-[1000] bg-bg-base/90 border border-stroke rounded-sm p-2.5 backdrop-blur-sm">
<div className="text-caption text-fg-disabled font-bold mb-1.5 font-korean"></div>
{[

파일 보기

@ -3,8 +3,8 @@ import { ComboBox } from '@common/components/ui/ComboBox';
import { useWeatherFetch } from '../hooks/useWeatherFetch';
import { getSubstanceToxicity } from '../utils/toxicityData';
import type { WeatherFetchResult, ReleaseType } from '../utils/dispersionTypes';
import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi';
import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi';
import { fetchGscAccidents } from '@tabs/prediction/services/predictionApi';
import type { GscAccidentListItem } from '@tabs/prediction/services/predictionApi';
/** HNS 분석 입력 파라미터 (부모에 전달) */
export interface HNSInputParams {
@ -44,6 +44,7 @@ interface HNSLeftPanelProps {
onParamsChange?: (params: HNSInputParams) => void;
onReset?: () => void;
loadedParams?: Partial<HNSInputParams> | null;
onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
}
/** 십진 좌표 → 도분초 변환 */
@ -67,8 +68,9 @@ export function HNSLeftPanel({
onParamsChange,
onReset,
loadedParams,
onFlyToCoord,
}: HNSLeftPanelProps) {
const [incidents, setIncidents] = useState<IncidentListItem[]>([]);
const [incidents, setIncidents] = useState<GscAccidentListItem[]>([]);
const [selectedIncidentSn, setSelectedIncidentSn] = useState('');
const [expandedSections, setExpandedSections] = useState({ accident: true, params: true });
const toggleSection = (key: 'accident' | 'params') =>
@ -138,21 +140,26 @@ export function HNSLeftPanel({
// 사고 목록 불러오기 (마운트 시 1회, ref 초기화 패턴)
const incidentsPromiseRef = useRef<Promise<void> | null>(null);
if (incidentsPromiseRef.current == null) {
incidentsPromiseRef.current = fetchIncidentsRaw()
incidentsPromiseRef.current = fetchGscAccidents()
.then((data) => setIncidents(data))
.catch(() => setIncidents([]));
}
// 사고 선택 시 필드 자동 채움
const handleSelectIncident = (snStr: string) => {
setSelectedIncidentSn(snStr);
const sn = parseInt(snStr);
const incident = incidents.find((i) => i.acdntSn === sn);
const handleSelectIncident = (mngNo: string) => {
setSelectedIncidentSn(mngNo);
const incident = incidents.find((i) => i.acdntMngNo === mngNo);
if (!incident) return;
setAccidentName(incident.acdntNm);
if (incident.lat && incident.lng) {
onCoordChange({ lat: incident.lat, lon: incident.lng });
setAccidentName(incident.pollNm);
if (incident.pollDate) {
const [d, t] = incident.pollDate.split('T');
if (d) setAccidentDate(d);
if (t) setAccidentTime(t);
}
if (incident.lat != null && incident.lon != null) {
onCoordChange({ lat: incident.lat, lon: incident.lon });
onFlyToCoord?.({ lat: incident.lat, lon: incident.lon });
}
};
@ -266,8 +273,8 @@ export function HNSLeftPanel({
onChange={handleSelectIncident}
placeholder="또는 사고 리스트에서 선택"
options={incidents.map((inc) => ({
value: String(inc.acdntSn),
label: `${inc.acdntNm} (${new Date(inc.occrnDtm).toLocaleDateString('ko-KR')})`,
value: inc.acdntMngNo,
label: `${inc.pollNm} (${inc.pollDate ? inc.pollDate.replace('T', ' ') : '-'})`,
}))}
/>

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -48,7 +48,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
);
case 'pending':
return (
<span className="px-2 py-1 text-label-2 font-medium rounded-md bg-[rgba(138,150,168,0.15)] text-fg-disabled">
<span className="px-2 py-1 text-label-2 font-medium rounded-md bg-[rgba(138,150,168,0.15)] text-fg-default">
</span>
);
@ -103,7 +103,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
return pages.map((page, index) => {
if (page === '...') {
return (
<span key={`ellipsis-${index}`} className="px-3 py-1 text-fg-disabled">
<span key={`ellipsis-${index}`} className="px-3 py-1 text-fg-default">
...
</span>
);
@ -128,7 +128,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
<div className="flex items-center justify-between px-5 py-4 border-b border-stroke">
<div>
<h1 className="text-heading-3 text-fg"> </h1>
<p className="text-body-2 text-fg-disabled mt-1"> {analyses.length}</p>
<p className="text-body-2 text-fg-default mt-1"> {analyses.length}</p>
</div>
<div className="flex items-center gap-3">
<div className="relative">
@ -156,48 +156,48 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
{/* 테이블 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="text-center py-20 text-fg-disabled text-body-2"> ...</div>
<div className="text-center py-20 text-fg-default text-body-2"> ...</div>
) : (
<table className="w-full">
<thead className="sticky top-0 bg-bg-surface border-b border-stroke z-10">
<tr>
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
</th>
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
</th>
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
</th>
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
</th>
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
</th>
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
</th>
<th className="px-4 py-3 text-right text-label-1 font-bold text-fg-disabled uppercase tracking-label">
<th className="px-4 py-3 text-right text-label-1 font-bold text-fg-default uppercase tracking-label">
</th>
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-disabled uppercase tracking-label">
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-default uppercase tracking-label">
KOSPS
</th>
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-disabled uppercase tracking-label">
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-default uppercase tracking-label">
POSEIDON
</th>
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-disabled uppercase tracking-label">
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-default uppercase tracking-label">
OpenDrift
</th>
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-disabled uppercase tracking-label">
<th className="px-4 py-3 text-center text-label-1 font-bold text-fg-default uppercase tracking-label">
</th>
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
</th>
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-disabled uppercase tracking-label">
<th className="px-4 py-3 text-left text-label-1 font-bold text-fg-default uppercase tracking-label">
</th>
</tr>
@ -277,7 +277,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
)}
{!loading && analyses.length === 0 && (
<div className="text-center py-20 text-fg-disabled text-body-2">
<div className="text-center py-20 text-fg-default text-body-2">
.
</div>
)}

파일 보기

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

파일 보기

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

파일 보기

@ -38,7 +38,7 @@ const InfoLayerSection = ({
<div className="flex items-center justify-between p-4 hover:bg-[rgba(255,255,255,0.02)]">
<h3
onClick={onToggle}
className="text-title-4 font-bold text-fg-sub font-korean cursor-pointer"
className="text-title-4 font-bold text-fg-default font-korean cursor-pointer"
>
</h3>
@ -117,7 +117,7 @@ const InfoLayerSection = ({
>
</button>
<span onClick={onToggle} className="text-label-2 text-fg-disabled cursor-pointer">
<span onClick={onToggle} className="text-label-2 text-fg-default cursor-pointer">
{expanded ? '▼' : '▶'}
</span>
</div>
@ -126,9 +126,9 @@ const InfoLayerSection = ({
{expanded && (
<div className="px-4 pb-2">
{isLoading && effectiveLayers.length === 0 ? (
<p className="text-label-2 text-fg-disabled py-2"> ...</p>
<p className="text-label-2 text-fg-default py-2"> ...</p>
) : effectiveLayers.length === 0 ? (
<p className="text-label-2 text-fg-disabled py-2"> .</p>
<p className="text-label-2 text-fg-default py-2"> .</p>
) : (
<LayerTree
layers={effectiveLayers}

파일 보기

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

파일 보기

@ -1,4 +1,5 @@
import { useState } from 'react';
import { Eye, EyeOff } from 'lucide-react';
import type {
BoomLine,
BoomLineCoord,
@ -22,6 +23,8 @@ interface OilBoomSectionProps {
onDrawingPointsChange: (points: BoomLineCoord[]) => void;
containmentResult: ContainmentResult | null;
onContainmentResultChange: (result: ContainmentResult | null) => void;
showBoomLines: boolean;
onShowBoomLinesChange: (show: boolean) => void;
}
const DEFAULT_SETTINGS: AlgorithmSettings = {
@ -44,6 +47,8 @@ const OilBoomSection = ({
onDrawingPointsChange,
containmentResult,
onContainmentResultChange,
showBoomLines,
onShowBoomLinesChange,
}: OilBoomSectionProps) => {
const [boomPlacementTab, setBoomPlacementTab] = useState<'ai' | 'simulation'>('simulation');
const [showResetConfirm, setShowResetConfirm] = useState(false);
@ -81,8 +86,22 @@ const OilBoomSection = ({
onClick={onToggle}
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]"
>
<h3 className="text-title-4 font-bold text-fg-sub font-korean"> </h3>
<span className="text-label-2 text-fg-disabled">{expanded ? '▼' : '▶'}</span>
<h3 className="text-title-4 font-bold text-fg-default font-korean"> </h3>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
onShowBoomLinesChange(!showBoomLines);
}}
disabled={boomLines.length === 0}
title={showBoomLines ? '지도에서 숨기기' : '지도에 표시'}
className="p-1 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
style={{ color: showBoomLines ? 'var(--color-accent)' : 'var(--fg-disabled)' }}
>
{showBoomLines ? <Eye size={14} /> : <EyeOff size={14} />}
</button>
<span className="text-label-2 text-fg-default">{expanded ? '▼' : '▶'}</span>
</div>
</div>
{expanded && (
@ -127,7 +146,7 @@ const OilBoomSection = ({
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--stroke-default)',
background: 'var(--bg-base)',
color: hasData ? 'var(--color-danger)' : 'var(--fg-disabled)',
color: 'var(--fg-disabled)',
cursor: hasData ? 'pointer' : 'not-allowed',
transition: '0.15s',
}}
@ -150,7 +169,7 @@ const OilBoomSection = ({
<div className="text-label-2 font-bold text-fg font-korean mb-2">
</div>
<div className="text-caption text-fg-disabled font-korean mb-3">
<div className="text-caption text-fg-default font-korean mb-3">
. .
</div>
<div className="flex gap-2">
@ -218,12 +237,12 @@ const OilBoomSection = ({
className="border border-stroke"
>
<div
style={{ color: metric.color }}
className="text-title-1 font-bold font-mono mb-[2px]"
// style={{ color: metric.color }}
className="text-title-1 font-semibold font-mono mb-[2px]"
>
{metric.value}
</div>
<div className="text-caption text-fg-disabled">{metric.label}</div>
<div className="text-caption text-fg-default">{metric.label}</div>
</div>
))}
</div>
@ -242,16 +261,10 @@ const OilBoomSection = ({
width: '8px',
height: '8px',
borderRadius: '50%',
background:
oilTrajectory.length > 0 ? 'var(--color-success)' : 'var(--color-danger)',
background: 'var(--fg-default)',
}}
/>
<span
style={{
color:
oilTrajectory.length > 0 ? 'var(--color-success)' : 'var(--fg-disabled)',
}}
>
<span className="text-fg-default">
{' '}
{oilTrajectory.length > 0 ? `(${oilTrajectory.length}개 입자)` : '없음'}
</span>
@ -261,7 +274,7 @@ const OilBoomSection = ({
{/* 알고리즘 설정 */}
<div>
<h4
className="text-label-2 font-bold text-fg-sub mb-2"
className="text-label-2 font-bold text-fg-default mb-2"
style={{ letterSpacing: 'var(--letter-spacing-label)' }}
>
📊 V자형
@ -301,7 +314,7 @@ const OilBoomSection = ({
}}
className="flex items-center justify-between px-2.5 py-1.5 border border-stroke"
>
<span className="flex-1 text-caption text-fg-disabled truncate">
<span className="flex-1 text-caption text-fg-default truncate">
{setting.label}
</span>
<div className="flex items-center gap-1 shrink-0 w-[80px] justify-end">
@ -315,7 +328,7 @@ const OilBoomSection = ({
className="boom-setting-input"
step={setting.key === 'waveHeightCorrectionFactor' ? 0.1 : 1}
/>
<span className="text-caption text-fg-disabled w-[14px]">
<span className="text-caption text-fg-default w-[14px]">
{setting.unit}
</span>
</div>
@ -342,7 +355,7 @@ const OilBoomSection = ({
V자형 +
</button>
<p className="text-caption text-fg-disabled leading-relaxed font-korean">
<p className="text-caption text-fg-default leading-relaxed font-korean">
1 (V형), U형 2 ,
3 .
</p>
@ -363,7 +376,7 @@ const OilBoomSection = ({
<div className="text-heading-2 font-bold text-color-accent font-mono">
{containmentResult.overallEfficiency}%
</div>
<div className="text-label-2 text-fg-disabled mt-[2px]"> </div>
<div className="text-label-2 text-fg-default mt-[2px]"> </div>
</div>
{/* 차단/통과 카운트 */}
@ -380,7 +393,7 @@ const OilBoomSection = ({
<div className="text-title-2 font-bold text-color-success font-mono">
{containmentResult.blockedParticles}
</div>
<div className="text-caption text-fg-disabled"> </div>
<div className="text-caption text-fg-default"> </div>
</div>
<div
style={{
@ -394,7 +407,7 @@ const OilBoomSection = ({
<div className="text-title-2 font-bold text-color-danger font-mono">
{containmentResult.passedParticles}
</div>
<div className="text-caption text-fg-disabled"> </div>
<div className="text-caption text-fg-default"> </div>
</div>
</div>
@ -485,13 +498,13 @@ const OilBoomSection = ({
className="mb-1.5"
>
<div>
<span className="text-caption text-fg-disabled"></span>
<span className="text-caption text-fg-default"></span>
<div className="text-title-3 font-bold font-mono text-fg">
{line.length.toFixed(0)}m
</div>
</div>
<div>
<span className="text-caption text-fg-disabled"></span>
<span className="text-caption text-fg-default"></span>
<div className="text-title-3 font-bold font-mono text-fg">
{line.angle.toFixed(0)}°
</div>

파일 보기

@ -85,7 +85,7 @@ ${styles}
<span className="text-label-2 font-medium text-fg-sub">🔴 POSEIDON</span>
<span className="text-label-2 font-medium text-fg-sub">🔵 OpenDrift</span>
<span className="text-label-2 font-medium text-fg-sub"> </span>
<span className="text-label-2 text-fg-disabled"> </span>
<span className="text-label-2 text-fg-default"> </span>
</div>
</div>
</div>
@ -111,7 +111,7 @@ ${styles}
className={`flex-1 py-2 px-1 text-label-1 font-medium rounded-md transition-all duration-150 cursor-pointer border ${
activePanel === tab.id
? 'border-stroke-light bg-bg-elevated text-fg'
: 'border-transparent bg-bg-card text-fg-disabled hover:text-fg-sub'
: 'border-transparent bg-bg-card text-fg-default hover:text-fg-sub'
}`}
>
{tab.icon} {tab.name}
@ -232,7 +232,7 @@ function SystemOverviewPanel() {
<div className={`${card} ${cardBg}`}>
<div className="flex items-center justify-between mb-3.5">
<div style={labelStyle('var(--fg-default)')}>🤖 WING </div>
<span className="text-label-2 text-fg-disabled">3 · </span>
<span className="text-label-2 text-fg-default">3 · </span>
</div>
<div className="grid grid-cols-3 gap-2.5 mb-3.5">
{[
@ -289,7 +289,7 @@ function SystemOverviewPanel() {
<div className="text-label-1 font-bold" style={{ color: m.color }}>
{m.name}
</div>
<div className="text-label-2 text-fg-disabled">{m.sub}</div>
<div className="text-label-2 text-fg-default">{m.sub}</div>
</div>
</div>
<div className="text-label-2 mb-2 text-fg-sub leading-[1.7]">{m.desc}</div>
@ -337,7 +337,7 @@ function SystemOverviewPanel() {
}}
>
<th
className="py-2 px-3 text-left text-fg-disabled font-medium"
className="py-2 px-3 text-left text-fg-default font-medium"
style={{ width: '15%' }}
>
@ -469,7 +469,7 @@ function SystemOverviewPanel() {
}}
>
<td
className="py-[7px] px-3 text-fg-disabled"
className="py-[7px] px-3 text-fg-default"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(row.label.replace(/\n/g, '<br>')),
}}
@ -538,7 +538,7 @@ function KospsPanel() {
<div className="text-title-2 font-bold text-fg">
KOSPS (Korea Oil Spill Prediction System)
</div>
<div className="text-label-2 mt-0.5 text-fg-disabled">
<div className="text-label-2 mt-0.5 text-fg-default">
(KORDI) ·
</div>
</div>
@ -584,9 +584,9 @@ function KospsPanel() {
{/* 특허 1 */}
<div className="rounded-lg p-3 bg-bg-base border border-stroke flex gap-3 items-start">
<div className="px-2.5 py-1.5 rounded-md text-center whitespace-nowrap bg-bg-elevated border border-stroke font-mono shrink-0">
<div className="text-label-2 text-fg-disabled"></div>
<div className="text-label-2 text-fg-default"></div>
<div className="text-label-2 font-bold text-fg">10-1567431</div>
<div className="text-label-2 mt-0.5 text-fg-disabled">2015.11.03</div>
<div className="text-label-2 mt-0.5 text-fg-default">2015.11.03</div>
</div>
<div className="flex flex-col gap-1.5 text-label-2 min-w-0">
<div className="font-bold text-fg">
@ -611,7 +611,7 @@ function KospsPanel() {
</span>
))}
</div>
<div className="text-fg-disabled">
<div className="text-fg-default">
R&amp;D: 3 ( 65%) HNS
( 35%) |
</div>
@ -632,7 +632,7 @@ function KospsPanel() {
</div>
<div className="grid grid-cols-2 gap-2.5">
<div className={codeBox}>
<span className="text-fg-disabled text-label-2">/* 변조조석 수식 */</span>
<span className="text-fg-default text-label-2">/* 변조조석 수식 */</span>
<br />
ζ(t) = A(t) cos[σt θ(t)]
<br />
@ -712,7 +712,7 @@ function KospsPanel() {
<span className="font-medium">
{d.icon} {d.label}
</span>
<span className="text-label-2 text-fg-disabled">{d.detail}</span>
<span className="text-label-2 text-fg-default">{d.detail}</span>
</div>
))}
</div>
@ -725,14 +725,14 @@ function KospsPanel() {
style={{ background: 'rgba(6,182,212,.04)', border: '1px solid rgba(6,182,212,.12)' }}
>
<div className="font-medium mb-0.5">📍 ·</div>
<div className="text-fg-disabled">(ENC) 500m </div>
<div className="text-fg-default">(ENC) 500m </div>
</div>
<div
className="px-2.5 py-1.5 rounded-md"
style={{ background: 'rgba(6,182,212,.04)', border: '1px solid rgba(6,182,212,.12)' }}
>
<div className="font-medium mb-0.5">🗺 </div>
<div className="text-fg-disabled"> </div>
<div className="text-fg-default"> </div>
</div>
</div>
</div>
@ -744,12 +744,12 @@ function KospsPanel() {
<div className="grid grid-cols-2 gap-3">
<div>
<div className={`${codeBox} mb-2`}>
<span className="text-fg-disabled text-label-2">/* 취송류 유속 (이·강, 2000) */</span>
<span className="text-fg-default text-label-2">/* 취송류 유속 (이·강, 2000) */</span>
<br />
V_WDC = <span className="text-color-accent">0.029</span> × V_wind
</div>
<div className={codeBox}>
<span className="text-fg-disabled text-label-2">/* 취송류 유향 */</span>
<span className="text-fg-default text-label-2">/* 취송류 유향 */</span>
<br />
θ_WDC = θ_wind + <span className="text-color-accent">18.6°</span>
</div>
@ -810,7 +810,7 @@ function KospsPanel() {
<div className="font-bold" style={{ color: node.color }}>
{node.label}
</div>
<div className="text-label-2 text-fg-disabled">{node.sub}</div>
<div className="text-label-2 text-fg-default">{node.sub}</div>
</div>
{i < 5 && (
<div className="w-[30px] h-px" style={{ background: 'var(--stroke-light)' }} />
@ -818,7 +818,7 @@ function KospsPanel() {
</div>
))}
</div>
<div className="text-label-2 text-center mt-1 text-fg-disabled">
<div className="text-label-2 text-center mt-1 text-fg-default">
FTP DB
</div>
<div
@ -848,7 +848,7 @@ function KospsPanel() {
<div className="text-title-4 font-bold text-fg">
( 10-1567431)
</div>
<div className="text-label-2 mt-0.5 text-fg-disabled">
<div className="text-label-2 mt-0.5 text-fg-default">
·
· 2015
</div>
@ -1058,7 +1058,7 @@ function KospsPanel() {
</div>
<div className="mt-1.5 p-1.5 rounded bg-bg-base font-mono text-label-1 leading-loose">
z(x,y) = Σ Σ qᵢⱼ xⁱ {' '}
<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 className="rounded-lg p-3" className="bg-bg-card border border-stroke">
@ -1104,7 +1104,7 @@ function KospsPanel() {
<div className="text-fg-sub leading-[1.6]">
3
<br />
<span className="text-fg-disabled"> | 2013.01~2013.12</span>
<span className="text-fg-default"> | 2013.01~2013.12</span>
</div>
</div>
<div
@ -1118,7 +1118,7 @@ function KospsPanel() {
<div className="text-fg-sub leading-[1.6]">
(HNS)
<br />
<span className="text-fg-disabled"> | 2013.01~2013.12</span>
<span className="text-fg-default"> | 2013.01~2013.12</span>
</div>
</div>
</div>
@ -1163,7 +1163,7 @@ function KospsPanel() {
</span>
</div>
<div className="text-label-2 mt-0.5 text-fg-disabled">
<div className="text-label-2 mt-0.5 text-fg-default">
KOSPS · · 3D ·
·
</div>
@ -1246,10 +1246,10 @@ function KospsPanel() {
</span>
))}
</div>
<span className="text-label-2 text-fg-disabled">{paper.year}</span>
<span className="text-label-2 text-fg-default">{paper.year}</span>
</div>
<div className="text-label-2 font-bold mb-1">{paper.title}</div>
<div className="text-label-2 mb-1.5 text-fg-disabled">{paper.authors}</div>
<div className="text-label-2 mb-1.5 text-fg-default">{paper.authors}</div>
<div className="text-label-2 text-fg-sub leading-[1.7]">{paper.desc}</div>
</div>
))}
@ -1405,7 +1405,7 @@ function KospsPanel() {
</div>
<div>
<div className="font-bold mb-0.5">{paper.title}</div>
<div className="text-fg-disabled leading-[1.6]">
<div className="text-fg-default leading-[1.6]">
{paper.authors} | <b style={{ color: paper.color }}>{paper.journal}</b>{' '}
{paper.detail}
</div>
@ -1542,7 +1542,7 @@ function KospsPanel() {
</div>
<div>
<div className="font-bold mb-0.5">{paper.title}</div>
<div className="text-fg-disabled leading-[1.6]">
<div className="text-fg-default leading-[1.6]">
{paper.authors} | <b style={{ color: paper.color }}>{paper.journal}</b>{' '}
{paper.detail}
</div>
@ -1570,7 +1570,7 @@ function PoseidonPanel() {
<div className="text-title-2 font-bold text-fg">
POSEIDON ( )
</div>
<div className="text-label-2 mt-0.5 text-fg-disabled">
<div className="text-label-2 mt-0.5 text-fg-default">
· () · · MOHID
·
</div>
@ -1631,9 +1631,9 @@ function PoseidonPanel() {
fontFamily: 'var(--font-mono)',
}}
>
<div className="text-label-2 text-fg-disabled"></div>
<div className="text-label-2 text-fg-default"></div>
<div className="text-label-2 font-bold text-fg">10-1868791</div>
<div className="text-label-2 mt-0.5 text-fg-disabled">2018 </div>
<div className="text-label-2 mt-0.5 text-fg-default">2018 </div>
</div>
<div>
<div className="text-label-2 font-bold mb-1">
@ -1724,7 +1724,7 @@ function PoseidonPanel() {
<div style={labelStyle('var(--color-info)')}>POSEIDON </div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-label-2 font-medium mb-1.5 text-fg-disabled">
<div className="text-label-2 font-medium mb-1.5 text-fg-default">
1 ()
</div>
<div className={codeBox}>
@ -1732,12 +1732,12 @@ function PoseidonPanel() {
<br />
Model_y = Δt × current_v + Δt × c × wind_v
</div>
<div className="text-label-2 mt-1.5 text-fg-disabled">
<div className="text-label-2 mt-1.5 text-fg-default">
c : 풍속 (: c=0.3 30% )
</div>
</div>
<div>
<div className="text-label-2 font-medium mb-1.5 text-fg-disabled">
<div className="text-label-2 font-medium mb-1.5 text-fg-default">
2 ( )
</div>
<div className={codeBox}>
@ -1749,7 +1749,7 @@ function PoseidonPanel() {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;+ a5·Model_x +
a6·Model_y + a7
</div>
<div className="text-label-2 mt-1.5 text-fg-disabled">
<div className="text-label-2 mt-1.5 text-fg-default">
a1~a7 : GA·DE·PSO로
</div>
</div>
@ -1760,7 +1760,7 @@ function PoseidonPanel() {
<div style={labelStyle('var(--fg-default)')}>🔄 POSEIDON_V2 </div>
{/* 외부 입력 자료 */}
<div className="text-label-2 font-bold mb-1.5 mt-1 text-fg-disabled"> </div>
<div className="text-label-2 font-bold mb-1.5 mt-1 text-fg-default"> </div>
<div className="flex items-center justify-center gap-0 mb-3">
{[
{
@ -1818,12 +1818,12 @@ function PoseidonPanel() {
<div className="border-t border-stroke my-3" />
{/* 중앙 화살표 */}
<div className="text-center text-label-2 mb-2 text-fg-disabled">
<div className="text-center text-label-2 mb-2 text-fg-default">
DATA PREP /
</div>
{/* 4대 도메인 실행 모듈 */}
<div className="text-label-2 font-bold mb-1.5 text-fg-disabled">
<div className="text-label-2 font-bold mb-1.5 text-fg-default">
POSEIDON 4 (EA012 KO108 )
</div>
<div className="grid grid-cols-4 gap-2 mb-3">
@ -1887,7 +1887,7 @@ function PoseidonPanel() {
</div>
{/* 화살표 + 최적화 */}
<div className="text-center text-label-2 mb-2 text-fg-disabled">
<div className="text-center text-label-2 mb-2 text-fg-default">
HYDR + WAVE + TIDE OILS GA/DE/PSO
</div>
@ -1914,7 +1914,7 @@ function PoseidonPanel() {
<div className="font-bold" style={{ color: node.color }}>
{node.label}
</div>
<div className="text-label-2 text-fg-disabled">{node.sub}</div>
<div className="text-label-2 text-fg-default">{node.sub}</div>
</div>
{i < 2 && (
<div className="w-[30px] h-px" style={{ background: 'var(--stroke-light)' }} />
@ -1961,7 +1961,7 @@ function PoseidonPanel() {
</div>
<div>
<div className="text-label-1 font-bold">POSEIDON관련 </div>
<div className="text-label-2 mt-0.5 text-fg-disabled">
<div className="text-label-2 mt-0.5 text-fg-default">
· · · · MOHID
·
</div>
@ -2040,10 +2040,10 @@ function PoseidonPanel() {
</span>
))}
</div>
<span className="text-label-2 text-fg-disabled">{paper.year}</span>
<span className="text-label-2 text-fg-default">{paper.year}</span>
</div>
<div className="text-label-2 font-bold mb-1">{paper.title}</div>
<div className="text-label-2 mb-1.5 text-fg-disabled">{paper.authors}</div>
<div className="text-label-2 mb-1.5 text-fg-default">{paper.authors}</div>
<div className="text-label-2 text-fg-sub leading-[1.7]">{paper.desc}</div>
</div>
))}
@ -2066,7 +2066,7 @@ function OpenDriftPanel() {
<div className="text-title-2 font-bold text-fg">
OpenDrift ( )
</div>
<div className="text-label-2 mt-0.5 text-fg-disabled">
<div className="text-label-2 mt-0.5 text-fg-default">
MET Norway · OpenOil · Python · IMO/IPIECA
</div>
</div>
@ -2210,7 +2210,7 @@ function OpenDriftPanel() {
<div className="text-label-2 font-medium" style={{ color: w.color }}>
{w.title}
</div>
<div className="text-label-2 mt-1 text-fg-disabled leading-normal">{w.desc}</div>
<div className="text-label-2 mt-1 text-fg-default leading-normal">{w.desc}</div>
</div>
))}
</div>
@ -2244,7 +2244,7 @@ function OpenDriftPanel() {
<div className="font-bold" style={{ color: node.color }}>
{node.label}
</div>
<div className="text-label-2 text-fg-disabled">{node.sub}</div>
<div className="text-label-2 text-fg-default">{node.sub}</div>
</div>
{i < 6 && (
<div className="w-[24px] h-px" style={{ background: 'var(--stroke-light)' }} />
@ -2252,7 +2252,7 @@ function OpenDriftPanel() {
</div>
))}
</div>
<div className="text-label-2 text-center mt-1 text-fg-disabled">
<div className="text-label-2 text-center mt-1 text-fg-default">
(NEMO·ROMS·HYCOM) + (ECMWF·GFS) NOAA Oil Library
OpenDrift/OpenOil NetCDF ·
</div>
@ -2271,7 +2271,7 @@ function OpenDriftPanel() {
<div className="text-label-1 font-bold">
OpenDrift / OpenOil
</div>
<div className="text-label-2 mt-0.5 text-fg-disabled">
<div className="text-label-2 mt-0.5 text-fg-default">
3 WING
</div>
</div>
@ -2309,13 +2309,13 @@ function OpenDriftPanel() {
</span>
))}
</div>
<span className="text-label-2 whitespace-nowrap text-fg-disabled">2024</span>
<span className="text-label-2 whitespace-nowrap text-fg-default">2024</span>
</div>
<div className="text-label-2 font-bold mb-1 leading-normal">
Numerical Model Test of Spilled Oil Transport Near the Korean Coasts Using Various
Input Parametric Models
</div>
<div className="text-label-2 mb-2 text-fg-disabled">
<div className="text-label-2 mb-2 text-fg-default">
Hai Van Dang, Suchan Joo, Junhyeok Lim, Jinhwan Hur, Sungwon Shin | Hanyang University
ERICA | Journal of Ocean Engineering and Technology, 2024
</div>
@ -2417,13 +2417,13 @@ function OpenDriftPanel() {
</span>
))}
</div>
<span className="text-label-2 whitespace-nowrap text-fg-disabled">1998</span>
<span className="text-label-2 whitespace-nowrap text-fg-default">1998</span>
</div>
<div className="text-label-2 font-bold mb-1 leading-normal">
(Oil Spill Behavior Forecasting Model in
South-eastern Coastal Area of Korea)
</div>
<div className="text-label-2 mb-2 text-fg-disabled">
<div className="text-label-2 mb-2 text-fg-default">
, , , | | Vol.1
No.2, pp.5259, 1998
</div>
@ -2520,13 +2520,13 @@ function OpenDriftPanel() {
</span>
))}
</div>
<span className="text-label-2 whitespace-nowrap text-fg-disabled">2008</span>
<span className="text-label-2 whitespace-nowrap text-fg-default">2008</span>
</div>
<div className="text-label-2 font-bold mb-1 leading-normal">
(Analysis of Oil Spill Dispersion in Taean
Coastal Zone)
</div>
<div className="text-label-2 mb-2 text-fg-disabled">
<div className="text-label-2 mb-2 text-fg-default">
, | | · 17
pp.6063, 2008
</div>
@ -2593,7 +2593,7 @@ function OpenDriftPanel() {
}}
>
<div className="font-bold text-color-info">α = 3%</div>
<div className="text-fg-disabled"> </div>
<div className="text-fg-default"> </div>
</div>
<div
className="px-2 py-1 rounded text-center"
@ -2603,7 +2603,7 @@ function OpenDriftPanel() {
}}
>
<div className="font-bold text-color-caution">α = 2.5%</div>
<div className="text-fg-disabled"> </div>
<div className="text-fg-default"> </div>
</div>
<div
className="px-2 py-1 rounded text-center"
@ -2613,7 +2613,7 @@ function OpenDriftPanel() {
}}
>
<div className="font-bold text-color-info">α = 2% </div>
<div className="text-fg-disabled"> </div>
<div className="text-fg-default"> </div>
</div>
<div
className="px-2 py-1 rounded text-center"
@ -2623,7 +2623,7 @@ function OpenDriftPanel() {
}}
>
<div className="font-bold text-color-accent">θ = 20° </div>
<div className="text-fg-disabled"> </div>
<div className="text-fg-default"> </div>
</div>
</div>
</div>
@ -2733,14 +2733,14 @@ function LagrangianPanel() {
<div className="grid grid-cols-2 gap-3">
<div>
<div className={`${codeBox} mb-2`}>
<span className="text-fg-disabled text-label-2">/* 중력-관성 체제 (초기) */</span>
<span className="text-fg-default text-label-2">/* 중력-관성 체제 (초기) */</span>
<br />
R(t) = <span className="text-color-accent">K</span> · (
<span className="text-color-accent">ΔρgV²</span> /{' '}
<span className="text-color-info">ρw</span>)<sup>¼</sup> · t<sup>½</sup>
</div>
<div className={codeBox}>
<span className="text-fg-disabled text-label-2">/* 중력-점성 체제 (후기) */</span>
<span className="text-fg-default text-label-2">/* 중력-점성 체제 (후기) */</span>
<br />
R(t) = <span className="text-color-accent">K</span> · (
<span className="text-color-accent">ΔρgV²</span> /{' '}
@ -2832,7 +2832,7 @@ function WeatheringPanel() {
<div style={labelStyle(w.color)}>{w.title}</div>
<div className={`${bodyText} mb-2`}>{w.desc}</div>
<div className={codeBox}>{w.formula}</div>
<div className="mt-2 text-label-2 text-fg-disabled">{w.note}</div>
<div className="mt-2 text-label-2 text-fg-default">{w.note}</div>
</div>
))}
</div>
@ -2883,7 +2883,7 @@ function WeatheringPanel() {
{s.time}
</div>
<div className="text-label-2 font-medium mb-1">{s.title}</div>
<div className="text-label-2 whitespace-pre-line text-fg-disabled leading-normal">
<div className="text-label-2 whitespace-pre-line text-fg-default leading-normal">
{s.desc}
</div>
</div>
@ -2934,7 +2934,7 @@ function OceanInputPanel() {
}}
>
<div className="font-medium mb-0.5">{t.label}</div>
<div className="text-fg-disabled">{t.desc}</div>
<div className="text-fg-default">{t.desc}</div>
</div>
))}
</div>
@ -2957,7 +2957,7 @@ function OceanInputPanel() {
}}
>
<div className="font-medium mb-0.5">{t.label}</div>
<div className="text-fg-disabled">{t.desc}</div>
<div className="text-fg-default">{t.desc}</div>
</div>
))}
</div>
@ -3013,7 +3013,7 @@ function VerificationPanel() {
>
{s.value}
</div>
<div className="text-label-2 text-fg-disabled">{s.label}</div>
<div className="text-label-2 text-fg-default">{s.label}</div>
</div>
))}
</div>
@ -3202,7 +3202,7 @@ function VerificationPanel() {
{paper.system}
</span>
</div>
<div className="text-fg-disabled leading-[1.6]">
<div className="text-fg-default leading-[1.6]">
{paper.authors} | <b style={{ color: paper.color }}>{paper.journal}</b>{' '}
{paper.detail}
</div>
@ -3314,7 +3314,7 @@ function RoadmapPanel() {
}}
>
<div className="font-medium mb-0.5">{r.title}</div>
<div className="text-label-2 text-fg-disabled">{r.desc}</div>
<div className="text-label-2 text-fg-default">{r.desc}</div>
</div>
))}
</div>
@ -3367,7 +3367,7 @@ function RoadmapPanel() {
{s.phase}
</div>
<div className="text-label-2 font-medium mb-1">{s.title}</div>
<div className="text-label-2 whitespace-pre-line text-fg-disabled leading-normal">
<div className="text-label-2 whitespace-pre-line text-fg-default leading-normal">
{s.desc}
</div>
</div>

파일 보기

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

파일 보기

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

파일 보기

@ -167,7 +167,7 @@ export function RecalcModal({
</div>
<div className="flex-1">
<h2 className="text-subtitle font-bold m-0"> </h2>
<div className="text-caption text-fg-disabled mt-[2px]">
<div className="text-caption text-fg-default mt-[2px]">
·
</div>
</div>
@ -180,7 +180,7 @@ export function RecalcModal({
background: 'var(--bg-card)',
fontSize: 'var(--font-size-caption)',
}}
className="border border-stroke text-fg-disabled cursor-pointer flex items-center justify-center"
className="border border-stroke text-fg-default cursor-pointer flex items-center justify-center"
>
</button>
@ -281,7 +281,7 @@ export function RecalcModal({
<FieldGroup label="유출 위치 (좌표)">
<div className="flex gap-1.5">
<div className="flex-1">
<div className="text-caption text-fg-disabled mb-[3px]"> (N)</div>
<div className="text-caption text-fg-default mb-[3px]"> (N)</div>
<input
type="number"
className="prd-i font-mono"
@ -291,7 +291,7 @@ export function RecalcModal({
/>
</div>
<div className="flex-1">
<div className="text-caption text-fg-disabled mb-[3px]"> (E)</div>
<div className="text-caption text-fg-default mb-[3px]"> (E)</div>
<input
type="number"
className="prd-i font-mono"
@ -404,7 +404,7 @@ function FieldGroup({ label, children }: { label: string; children: React.ReactN
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between py-[2px]">
<span className="text-fg-disabled">{label}</span>
<span className="text-fg-default">{label}</span>
<span className="font-semibold font-mono">{value}</span>
</div>
);

파일 보기

@ -165,7 +165,7 @@ export function RightPanel({
</div>
{windHydrModelOptions.length > 1 && (
<div className="flex items-center gap-2 mt-1.5">
<span className="text-label-2 text-fg-disabled font-korean whitespace-nowrap">
<span className="text-label-2 text-fg-default font-korean whitespace-nowrap">
</span>
<select
@ -197,7 +197,7 @@ export function RightPanel({
className={`flex-1 py-1.5 px-1 rounded text-label-2 font-medium font-korean border transition-colors ${
analysisTab === tab
? 'border-color-accent bg-[rgba(6,182,212,0.08)] text-color-accent'
: 'border-stroke bg-bg-card text-fg-disabled hover:text-fg-sub'
: 'border-stroke bg-bg-card text-fg-default hover:text-fg-sub'
}`}
>
{tab === 'polygon' ? '다각형 분석' : '원 분석'}
@ -208,7 +208,7 @@ export function RightPanel({
{/* 다각형 패널 */}
{analysisTab === 'polygon' && (
<div>
<p className="text-label-2 text-fg-disabled font-korean mb-2 leading-relaxed">
<p className="text-label-2 text-fg-default font-korean mb-2 leading-relaxed">
.
</p>
{!drawAnalysisMode && !analysisResult && (
@ -229,7 +229,7 @@ export function RightPanel({
<div className="text-label-2 text-purple-400 font-korean bg-[rgba(168,85,247,0.08)] rounded px-2 py-1.5 leading-relaxed">
<br />
<span className="text-fg-disabled">
<span className="text-fg-default">
{analysisPolygonPoints.length}
</span>
</div>
@ -247,7 +247,7 @@ export function RightPanel({
</button>
<button
onClick={onCancelAnalysis}
className="py-1.5 px-2 rounded text-label-2 font-medium font-korean border border-stroke text-fg-disabled hover:text-fg-sub transition-colors"
className="py-1.5 px-2 rounded text-label-2 font-medium font-korean border border-stroke text-fg-default hover:text-fg-sub transition-colors"
>
</button>
@ -268,7 +268,7 @@ export function RightPanel({
{/* 원 분석 패널 */}
{analysisTab === 'circle' && (
<div>
<p className="text-label-2 text-fg-disabled font-korean mb-2 leading-relaxed">
<p className="text-label-2 text-fg-default font-korean mb-2 leading-relaxed">
(NM) .
</p>
<div className="text-label-2 font-medium text-fg-sub font-korean mb-1.5">
@ -282,7 +282,7 @@ export function RightPanel({
className={`w-8 h-7 rounded text-label-2 font-medium font-mono border transition-all ${
circleRadiusNm === nm
? 'border-color-accent bg-[rgba(6,182,212,0.1)] text-color-accent'
: 'border-stroke bg-bg-base text-fg-disabled hover:text-fg-sub'
: 'border-stroke bg-bg-base text-fg-default hover:text-fg-sub'
}`}
>
{nm}
@ -290,7 +290,7 @@ export function RightPanel({
))}
</div>
<div className="flex items-center gap-1.5 mb-2.5">
<span className="text-label-2 text-fg-disabled font-korean whitespace-nowrap">
<span className="text-label-2 text-fg-default font-korean whitespace-nowrap">
</span>
<input
@ -303,7 +303,7 @@ export function RightPanel({
className="w-14 text-center py-1 px-1 bg-bg-base border border-stroke rounded text-label-2 font-mono text-fg outline-none focus:border-color-accent"
style={{ colorScheme: 'dark' }}
/>
<span className="text-label-2 text-fg-disabled font-korean">NM</span>
<span className="text-label-2 text-fg-default font-korean">NM</span>
<button
onClick={onRunCircleAnalysis}
className="ml-auto py-1 px-3 rounded-sm text-label-2 font-bold font-korean transition-colors hover:bg-[rgba(6,182,212,0.08)]"
@ -462,7 +462,7 @@ export function RightPanel({
<div className="text-label-2 font-bold text-fg font-korean">
{vessel?.vesselNm || '—'}
</div>
<div className="text-label-2 text-fg-disabled font-mono">
<div className="text-label-2 text-fg-default font-mono">
IMO {vessel?.imoNo || '—'} · {vessel?.vesselTp || '—'}
</div>
</div>
@ -511,7 +511,7 @@ export function RightPanel({
<div className="text-label-2 font-bold text-color-warning font-korean mb-1">
: {vessel2.vesselNm}
</div>
<div className="text-label-2 text-fg-disabled font-korean leading-relaxed">
<div className="text-label-2 text-fg-default font-korean leading-relaxed">
{vessel2.flagCd} {vessel2.vesselTp}{' '}
{vessel2.gt ? `${vessel2.gt.toLocaleString()}GT` : ''}
</div>
@ -570,7 +570,7 @@ export function RightPanel({
))}
</>
) : (
<div className="text-label-2 text-fg-disabled font-korean text-center py-4">
<div className="text-label-2 text-fg-default font-korean text-center py-4">
.
</div>
)}
@ -647,12 +647,9 @@ function getSpreadSeverity(
// Helper Components
const BADGE_STYLES: Record<string, string> = {
red: 'bg-[rgba(239,68,68,0.08)] text-color-danger border border-[rgba(239,68,68,0.25)]',
orange:
'bg-[rgba(249,115,22,0.08)] text-color-warning border border-[rgba(249,115,22,0.25)]',
yellow:
'bg-[rgba(234,179,8,0.08)] text-color-caution border border-[rgba(234,179,8,0.25)]',
green:
'bg-[rgba(34,197,94,0.08)] text-color-success border border-[rgba(34,197,94,0.25)]',
orange: 'bg-[rgba(249,115,22,0.08)] text-color-warning border border-[rgba(249,115,22,0.25)]',
yellow: 'bg-[rgba(234,179,8,0.08)] text-color-caution border border-[rgba(234,179,8,0.25)]',
green: 'bg-[rgba(34,197,94,0.08)] text-color-success border border-[rgba(34,197,94,0.25)]',
};
function Section({
@ -699,7 +696,7 @@ function ControlledCheckbox({
return (
<label
className={`flex items-center gap-1.5 text-label-2 font-korean cursor-pointer ${
disabled ? 'text-fg-disabled cursor-not-allowed opacity-40' : 'text-fg-sub'
disabled ? 'text-fg-default cursor-not-allowed opacity-40' : 'text-fg-sub'
}`}
>
<input
@ -727,9 +724,9 @@ function StatBox({
}) {
return (
<div className="flex justify-between px-2 py-1 bg-bg-base border border-stroke rounded-[3px]">
<span className="text-fg-disabled font-korean">{label}</span>
<span className="text-fg-default font-korean">{label}</span>
<span style={{ fontWeight: 700, color, fontFamily: 'var(--font-mono)' }}>
{value} <small className="font-normal text-fg-disabled">{unit}</small>
{value} <small className="font-normal text-fg-default">{unit}</small>
</span>
</div>
);
@ -738,7 +735,7 @@ function StatBox({
function PredictionCard({ value, label, color }: { value: string; label: string; color: string }) {
return (
<div className="flex justify-between px-2 py-1 bg-bg-base border border-stroke rounded-[3px] text-label-2">
<span className="text-fg-disabled font-korean">{label}</span>
<span className="text-fg-default font-korean">{label}</span>
<span style={{ fontWeight: 700, color, fontFamily: 'var(--font-mono)' }}>{value}</span>
</div>
);
@ -747,7 +744,7 @@ function PredictionCard({ value, label, color }: { value: string; label: string;
function ProgressBar({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="flex items-center gap-1">
<span className="text-fg-disabled font-korean" style={{ minWidth: '38px' }}>
<span className="text-fg-default font-korean" style={{ minWidth: '38px' }}>
{label}
</span>
<div
@ -783,7 +780,7 @@ function CollapsibleSection({
<div className="bg-bg-card border border-stroke rounded-md p-3.5 mb-2.5">
<div className="flex items-center justify-between cursor-pointer mb-2" onClick={onToggle}>
<h4 className="text-label-1 font-medium text-fg-sub font-korean">{title}</h4>
<span className="text-label-2 text-fg-disabled">{expanded ? '▾' : '▸'}</span>
<span className="text-label-2 text-fg-default">{expanded ? '▾' : '▸'}</span>
</div>
{expanded && children}
</div>
@ -796,7 +793,7 @@ function SpecCard({ value, label, color }: { value: string; label: string; color
<div style={{ color }} className="text-label-1 font-bold font-mono">
{value}
</div>
<div className="text-label-2 text-fg-disabled font-korean">{label}</div>
<div className="text-label-2 text-fg-default font-korean">{label}</div>
</div>
);
}
@ -814,7 +811,7 @@ function InfoRow({
}) {
return (
<div className="flex justify-between py-[3px] px-[6px] bg-bg-base rounded-[3px]">
<span className="text-fg-disabled">{label}</span>
<span className="text-fg-default">{label}</span>
<span
style={{ color: valueColor || 'var(--fg-default)' }}
className={`font-medium${mono ? ' font-mono' : ''}`}
@ -869,7 +866,7 @@ function InsuranceCard({
<div className="space-y-0.5 text-label-2 font-korean">
{items.map((item, i) => (
<div key={i} className="flex justify-between py-0.5 px-1">
<span className="text-fg-disabled">{item.label}</span>
<span className="text-fg-default">{item.label}</span>
<span
style={{ color: item.valueColor || 'var(--fg-default)' }}
className={`font-medium${item.mono ? ' font-mono' : ''}`}
@ -928,7 +925,7 @@ function PollResult({
>
{result.area.toFixed(2)}
</div>
<div className="text-label-2 text-fg-disabled font-korean mt-0.5">(km²)</div>
<div className="text-label-2 text-fg-default font-korean mt-0.5">(km²)</div>
</div>
<div className="text-center py-1.5 px-1 bg-bg-card rounded">
<div
@ -937,7 +934,7 @@ function PollResult({
>
{result.particlePercent}%
</div>
<div className="text-label-2 text-fg-disabled font-korean mt-0.5"></div>
<div className="text-label-2 text-fg-default font-korean mt-0.5"></div>
</div>
<div className="text-center py-1.5 px-1 bg-bg-card rounded">
<div
@ -946,13 +943,13 @@ function PollResult({
>
{pollutedArea}
</div>
<div className="text-label-2 text-fg-disabled font-korean mt-0.5">(km²)</div>
<div className="text-label-2 text-fg-default font-korean mt-0.5">(km²)</div>
</div>
</div>
<div className="space-y-1 text-label-2 font-korean">
{summary && (
<div className="flex justify-between">
<span className="text-fg-disabled"></span>
<span className="text-fg-default"></span>
<span className="font-medium font-mono" style={{ color: 'var(--color-info)' }}>
{summary.remainingVolume.toFixed(2)} m³
</span>
@ -960,14 +957,14 @@ function PollResult({
)}
{summary && (
<div className="flex justify-between">
<span className="text-fg-disabled"></span>
<span className="text-fg-default"></span>
<span className="font-medium font-mono" style={{ color: 'var(--color-danger)' }}>
{summary.beachedVolume.toFixed(2)} m³
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-fg-disabled"> </span>
<span className="text-fg-default"> </span>
<span className="font-medium font-mono" style={{ color: 'var(--color-warning)' }}>
{result.sensitiveCount}
</span>
@ -976,7 +973,7 @@ function PollResult({
<div className="flex gap-1.5 mt-2">
<button
onClick={onClear}
className="flex-1 py-1.5 rounded text-label-2 font-medium font-korean border border-stroke text-fg-disabled hover:text-fg-sub transition-colors"
className="flex-1 py-1.5 rounded text-label-2 font-medium font-korean border border-stroke text-fg-default hover:text-fg-sub transition-colors"
>
</button>

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -1,11 +1,8 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { Map, useControl, useMap } from '@vis.gl/react-maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { useMap } from '@vis.gl/react-maplibre';
import { PathLayer, ScatterplotLayer } from '@deck.gl/layers';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
import { useMapStore } from '@common/store/mapStore';
import { BaseMap } from '@common/components/map/BaseMap';
import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay';
import type { ScatSegment } from './scatTypes';
import type { ApiZoneItem } from '../services/scatApi';
import { esiColor } from './scatConstants';
@ -20,16 +17,9 @@ interface ScatMapProps {
onOpenPopup: (idx: number) => void;
}
// ── DeckGLOverlay ──────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: any[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers });
return null;
}
// ── FlyTo Controller: 선택 구간 변경 시 맵 이동 ─────────
function FlyToController({
// ── FlyTo: 선택 구간·관할해경 변경 시 맵 이동 ──────────────
// 두 가지 트리거를 독립적으로 처리해 공통 FlyToController로 통합 불가
function ScatFlyToController({
selectedSeg,
zones,
}: {
@ -40,7 +30,7 @@ function FlyToController({
const prevIdRef = useRef<number | undefined>(undefined);
const prevZonesLenRef = useRef<number>(0);
// 선택 구간 변경 시
// 선택 구간 변경 시 이동 (첫 렌더 제외)
useEffect(() => {
if (!map) return;
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedSeg.id) {
@ -49,7 +39,7 @@ function FlyToController({
prevIdRef.current = selectedSeg.id;
}, [map, selectedSeg]);
// 관할해경(zones) 변경 시 지도 중심 이동
// 관할해경(zones) 변경 시 중심 이동
useEffect(() => {
if (!map || zones.length === 0) return;
if (prevZonesLenRef.current === zones.length) return;
@ -72,13 +62,11 @@ function getZoomScale(zoom: number) {
selPolyWidth: 2 + zScale * 5,
glowWidth: 4 + zScale * 14,
halfLenScale: 0.15 + zScale * 0.85,
markerRadius: Math.round(6 + zScale * 16),
showStatusMarker: zoom >= 11,
dotRadius: Math.round(4 + zScale * 10),
};
}
// ── 세그먼트 폴리라인 좌표 생성 (lng, lat 순서) ──────────
// 인접 구간 좌표로 해안선 방향을 동적 계산
function buildSegCoords(
seg: ScatSegment,
halfLenScale: number,
@ -100,7 +88,6 @@ function buildSegCoords(
];
}
// ── 툴팁 상태 ───────────────────────────────────────────
interface TooltipState {
x: number;
y: number;
@ -116,12 +103,19 @@ function ScatMap({
onSelectSeg,
onOpenPopup,
}: ScatMapProps) {
const currentMapStyle = useBaseMapStyle();
const mapToggles = useMapStore((s) => s.mapToggles);
const [zoom, setZoom] = useState(10);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
// zones 첫 렌더 기준으로 초기 중심 좌표 결정 (이후 불변)
const [initialCenter] = useState<[number, number]>(() =>
zones.length > 0
? [
zones.reduce((a, z) => a + z.latCenter, 0) / zones.length,
zones.reduce((a, z) => a + z.lngCenter, 0) / zones.length,
]
: [33.38, 126.55],
);
const handleClick = useCallback(
(seg: ScatSegment) => {
onSelectSeg(seg);
@ -132,23 +126,6 @@ function ScatMap({
const zs = useMemo(() => getZoomScale(zoom), [zoom]);
// 제주도 해안선 레퍼런스 라인 — 하드코딩 제거, 추후 DB 기반 해안선으로 대체 예정
// const coastlineLayer = useMemo(
// () =>
// new PathLayer({
// id: 'jeju-coastline',
// data: [{ path: jejuCoastCoords.map(([lat, lng]) => [lng, lat] as [number, number]) }],
// getPath: (d: { path: [number, number][] }) => d.path,
// getColor: [6, 182, 212, 46],
// getWidth: 1.5,
// getDashArray: [8, 6],
// dashJustified: true,
// widthMinPixels: 1,
// }),
// [],
// )
// 선택된 구간 글로우 레이어
const glowLayer = useMemo(
() =>
new PathLayer({
@ -168,7 +145,6 @@ function ScatMap({
[selectedSeg, segments, zs.glowWidth, zs.halfLenScale],
);
// ESI 색상 세그먼트 폴리라인
const segPathLayer = useMemo(
() =>
new PathLayer({
@ -183,14 +159,11 @@ function ScatMap({
getWidth: (d: ScatSegment) => (selectedSeg.id === d.id ? zs.selPolyWidth : zs.polyWidth),
capRounded: true,
jointRounded: true,
widthMinPixels: 1,
widthMinPixels: 2,
pickable: true,
onHover: (info: { object?: ScatSegment; x: number; y: number }) => {
if (info.object) {
setTooltip({ x: info.x, y: info.y, seg: info.object });
} else {
setTooltip(null);
}
if (info.object) setTooltip({ x: info.x, y: info.y, seg: info.object });
else setTooltip(null);
},
onClick: (info: { object?: ScatSegment }) => {
if (info.object) handleClick(info.object);
@ -204,46 +177,58 @@ function ScatMap({
[segments, selectedSeg, zs, handleClick],
);
// 조사 상태 마커 (줌 >= 11 시 표시)
const markerLayer = useMemo(() => {
if (!zs.showStatusMarker) return null;
return new ScatterplotLayer({
id: 'scat-status-markers',
data: segments,
getPosition: (d: ScatSegment) => [d.lng, d.lat],
getRadius: zs.markerRadius,
getFillColor: (d: ScatSegment) => {
if (d.status === '완료') return [34, 197, 94, 51];
if (d.status === '진행중') return [234, 179, 8, 51];
return [100, 116, 139, 51];
},
getLineColor: (d: ScatSegment) => {
if (d.status === '완료') return [34, 197, 94, 200];
if (d.status === '진행중') return [234, 179, 8, 200];
return [100, 116, 139, 200];
},
getLineWidth: 1,
stroked: true,
radiusMinPixels: 4,
radiusMaxPixels: 22,
radiusUnits: 'pixels',
pickable: true,
onClick: (info: { object?: ScatSegment }) => {
if (info.object) handleClick(info.object);
},
updateTriggers: {
getRadius: [zs.markerRadius],
},
});
}, [segments, zs.showStatusMarker, zs.markerRadius, handleClick]);
const shadowDotLayer = useMemo(
() =>
new ScatterplotLayer<ScatSegment>({
id: 'scat-shadow-dots',
data: segments,
getPosition: (d) => [d.lng, d.lat],
getRadius: zs.dotRadius + 2,
getFillColor: [0, 0, 0, 70],
stroked: false,
radiusUnits: 'pixels',
radiusMinPixels: 7,
radiusMaxPixels: 18,
pickable: false,
updateTriggers: { getRadius: [zs.dotRadius] },
}),
[segments, zs.dotRadius],
);
const dotLayer = useMemo(
() =>
new ScatterplotLayer<ScatSegment>({
id: 'scat-dots',
data: segments,
getPosition: (d) => [d.lng, d.lat],
getRadius: zs.dotRadius,
getFillColor: (d) => {
if (d.status === '완료') return [34, 197, 94, 210];
if (d.status === '진행중') return [234, 179, 8, 210];
return [148, 163, 184, 200];
},
stroked: false,
radiusUnits: 'pixels',
radiusMinPixels: 5,
radiusMaxPixels: 16,
pickable: true,
onHover: (info: { object?: ScatSegment; x: number; y: number }) => {
if (info.object) setTooltip({ x: info.x, y: info.y, seg: info.object });
else setTooltip(null);
},
onClick: (info: { object?: ScatSegment }) => {
if (info.object) handleClick(info.object);
},
updateTriggers: { getRadius: [zs.dotRadius] },
}),
[segments, zs.dotRadius, handleClick],
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deckLayers: any[] = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const layers: any[] = [glowLayer, segPathLayer];
if (markerLayer) layers.push(markerLayer);
return layers;
}, [glowLayer, segPathLayer, markerLayer]);
const deckLayers: any[] = useMemo(
() => [glowLayer, segPathLayer, shadowDotLayer, dotLayer],
[glowLayer, segPathLayer, shadowDotLayer, dotLayer],
);
const totalLen = segments.reduce((a, s) => a + s.lengthM, 0);
const doneLen = segments.filter((s) => s.status === '완료').reduce((a, s) => a + s.lengthM, 0);
@ -253,24 +238,10 @@ function ScatMap({
return (
<div className="absolute inset-0 overflow-hidden">
<Map
initialViewState={(() => {
if (zones.length > 0) {
const avgLng = zones.reduce((a, z) => a + z.lngCenter, 0) / zones.length;
const avgLat = zones.reduce((a, z) => a + z.latCenter, 0) / zones.length;
return { longitude: avgLng, latitude: avgLat, zoom: 10 };
}
return { longitude: 126.55, latitude: 33.38, zoom: 10 };
})()}
mapStyle={currentMapStyle}
className="w-full h-full"
attributionControl={false}
onZoom={(e) => setZoom(e.viewState.zoom)}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<BaseMap center={initialCenter} zoom={10} onZoom={setZoom}>
<DeckGLOverlay layers={deckLayers} />
<FlyToController selectedSeg={selectedSeg} zones={zones} />
</Map>
<ScatFlyToController selectedSeg={selectedSeg} zones={zones} />
</BaseMap>
{/* 호버 툴팁 */}
{tooltip && (
@ -287,11 +258,9 @@ function ScatMap({
whiteSpace: 'nowrap',
}}
>
<div className="font-bold">
{tooltip.seg.code} {tooltip.seg.area}
</div>
<div className="font-bold font-korean">{tooltip.seg.name}</div>
<div className="text-caption opacity-70">
ESI {tooltip.seg.esi} · {tooltip.seg.length} ·{' '}
{tooltip.seg.code} · ESI {tooltip.seg.esi} ·{' '}
{tooltip.seg.status === '완료' ? '✓' : tooltip.seg.status === '진행중' ? '⏳' : '—'}{' '}
{tooltip.seg.status}
</div>
@ -301,7 +270,7 @@ function ScatMap({
{/* Status chips */}
<div className="absolute top-3.5 left-3.5 flex gap-2 z-[1000]">
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-full text-label-2 text-fg-sub font-korean">
<span className="w-1.5 h-1.5 rounded-full bg-color-success shadow-[0_0_6px_var(--color-success)]" />
<span className="w-1.5 h-1.5 rounded-full bg-color-accent shadow-[0_0_6px_var(--color-accent)]" />
Pre-SCAT
</div>
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-full text-label-2 text-fg-sub font-korean">
@ -342,25 +311,6 @@ function ScatMap({
<div className="text-caption font-bold uppercase tracking-wider text-fg-disabled mb-2.5">
</div>
{/* <div className="flex gap-0.5 h-3 rounded overflow-hidden mb-1">
<div
className="h-full transition-all duration-500"
style={{ width: `${donePct}%`, background: 'var(--color-success)' }}
/>
<div
className="h-full transition-all duration-500"
style={{ width: `${progPct}%`, background: 'var(--color-warning)' }}
/>
<div
className="h-full transition-all duration-500"
style={{ width: `${notPct}%`, background: 'var(--stroke-default)' }}
/>
</div> */}
{/* <div className="flex justify-between mt-1">
<span className="text-caption font-mono text-color-success"> {donePct}%</span>
<span className="text-caption font-mono text-color-warning"> {progPct}%</span>
<span className="text-caption font-mono text-fg-disabled"> {notPct}%</span>
</div> */}
<div className="mt-2.5">
{[
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
@ -388,19 +338,6 @@ function ScatMap({
</div>
</div>
</div>
{/* Coordinates */}
{/* <div className="absolute bottom-3 left-3 z-[1000] bg-[rgba(18,25,41,0.85)] backdrop-blur-xl border border-stroke rounded-sm px-3 py-1.5 font-mono text-label-2 text-fg-sub flex gap-3.5">
<span>
<span className="text-color-success font-medium">{selectedSeg.lat.toFixed(4)}°N</span>
</span>
<span>
<span className="text-color-success font-medium">{selectedSeg.lng.toFixed(4)}°E</span>
</span>
<span>
<span className="text-color-success font-medium">1:25,000</span>
</span>
</div> */}
</div>
);
}

파일 보기

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

파일 보기

@ -1,12 +1,8 @@
import { useState, useMemo, useCallback } from 'react';
import { Map, Marker, useControl } from '@vis.gl/react-maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import type { Layer } from '@deck.gl/core';
import type { MapLayerMouseEvent } from 'maplibre-gl';
import { Marker } from '@vis.gl/react-maplibre';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
import { useMapStore } from '@common/store/mapStore';
import { BaseMap } from '@common/components/map/BaseMap';
import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay';
import { WeatherRightPanel } from './WeatherRightPanel';
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay';
// import { OceanForecastOverlay } from './OceanForecastOverlay'
@ -16,7 +12,6 @@ import { WindParticleLayer } from './WindParticleLayer';
import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer';
import { useWeatherData } from '../hooks/useWeatherData';
// import { useOceanForecast } from '../hooks/useOceanForecast'
import { WeatherMapControls } from './WeatherMapControls';
import { degreesToCardinal } from '../services/weatherUtils';
type TimeOffset = '0' | '3' | '6' | '9';
@ -89,13 +84,6 @@ const generateForecast = (timeOffset: TimeOffset): WeatherForecast[] => {
const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5]; // [lng, lat]
const WEATHER_MAP_ZOOM = 7;
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록)
function DeckGLOverlay({ layers }: { layers: Layer[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers });
return null;
}
/**
* WeatherMapInner Map (useMap / useControl )
*/
@ -104,8 +92,6 @@ interface WeatherMapInnerProps {
enabledLayers: Set<string>;
selectedStationId: string | null;
onStationClick: (station: WeatherStation) => void;
mapCenter: [number, number];
mapZoom: number;
clickedLocation: { lat: number; lon: number } | null;
}
@ -114,8 +100,6 @@ function WeatherMapInner({
enabledLayers,
selectedStationId,
onStationClick,
mapCenter,
mapZoom,
clickedLocation,
}: WeatherMapInnerProps) {
// deck.gl layers 조합
@ -183,17 +167,12 @@ function WeatherMapInner({
</div>
</Marker>
)}
{/* 줌 컨트롤 */}
<WeatherMapControls center={mapCenter} zoom={mapZoom} />
</>
);
}
export function WeatherView() {
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS);
const currentMapStyle = useBaseMapStyle();
const mapToggles = useMapStore((s) => s.mapToggles);
// const {
// selectedForecast,
@ -220,8 +199,7 @@ export function WeatherView() {
}, []);
const handleMapClick = useCallback(
(e: MapLayerMouseEvent) => {
const { lat, lng } = e.lngLat;
(lng: number, lat: number) => {
if (weatherStations.length === 0) return;
// 가장 가까운 관측소 선택
@ -331,28 +309,19 @@ export function WeatherView() {
{/* Map */}
<div className="flex-1 relative">
<Map
initialViewState={{
longitude: WEATHER_MAP_CENTER[0],
latitude: WEATHER_MAP_CENTER[1],
zoom: WEATHER_MAP_ZOOM,
}}
mapStyle={currentMapStyle}
className="w-full h-full"
onClick={handleMapClick}
attributionControl={false}
<BaseMap
center={[WEATHER_MAP_CENTER[1], WEATHER_MAP_CENTER[0]]}
zoom={WEATHER_MAP_ZOOM}
onMapClick={handleMapClick}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<WeatherMapInner
weatherStations={weatherStations}
enabledLayers={enabledLayers}
selectedStationId={selectedStation?.id || null}
onStationClick={handleStationClick}
mapCenter={WEATHER_MAP_CENTER}
mapZoom={WEATHER_MAP_ZOOM}
clickedLocation={selectedLocation}
/>
</Map>
</BaseMap>
{/* 레이어 컨트롤 */}
<div className="absolute top-4 left-4 bg-bg-surface border border-stroke rounded-md shadow-md z-10 px-2.5 py-1.5">