refactor(map): MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용 #172

병합
dnlee feature/mpa-develop 에서 develop 로 2 commits 를 머지했습니다 2026-04-14 17:33:31 +09:00
35개의 변경된 파일2100개의 추가작업 그리고 2195개의 파일을 삭제

파일 보기

@ -4,6 +4,9 @@
## [Unreleased]
### 변경
- MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용
## [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;
}

파일 보기

@ -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,6 +27,8 @@ 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';
@ -126,6 +127,7 @@ interface MapViewProps {
dispersionResult?: DispersionResult | null;
dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>;
boomLines?: BoomLine[];
showBoomLines?: boolean;
isDrawingBoom?: boolean;
drawingPoints?: BoomLineCoord[];
layerOpacity?: number;
@ -165,31 +167,7 @@ interface MapViewProps {
showOverlays?: boolean;
}
// 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;
}
// 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;
}
// DeckGLOverlay, FlyToController → @common/components/map/DeckGLOverlay, FlyToController 에서 import
// fitBounds 트리거 컴포넌트 (Map 내부에서 useMap() 사용)
function FitBoundsController({
@ -341,6 +319,7 @@ export function MapView({
dispersionResult = null,
dispersionHeatmap = [],
boomLines = [],
showBoomLines = true,
isDrawingBoom = false,
drawingPoints = [],
layerOpacity = 50,
@ -587,7 +566,7 @@ export function MapView({
}
// --- 오일펜스 라인 (PathLayer) ---
if (boomLines.length > 0) {
if (showBoomLines && boomLines.length > 0) {
result.push(
new PathLayer({
id: 'boom-lines',
@ -1243,6 +1222,7 @@ export function MapView({
currentTime,
selectedModels,
boomLines,
showBoomLines,
isDrawingBoom,
drawingPoints,
dispersionResult,
@ -1295,7 +1275,7 @@ export function MapView({
{/* 사고 지점 변경 시 지도 이동 */}
<MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} />
{/* 외부에서 flyTo 트리거 */}
<FlyToController flyToTarget={flyToTarget} />
<FlyToController target={flyToTarget} duration={1200} />
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
<FitBoundsController fitBoundsTarget={fitBoundsTarget} />

파일 보기

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

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. 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>
@ -295,7 +518,12 @@ function StepIndicator({ current }: { current: number }) {
>
{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]) => (
] 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 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('.', ''),
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]);
},
[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,8 +40,9 @@ function AssetMap({
[onSelectOrg],
);
const markerLayer = useMemo(() => {
return new ScatterplotLayer({
const markerLayer = useMemo(
() =>
new ScatterplotLayer({
id: 'asset-orgs',
data: orgs,
getPosition: (d: AssetOrgCompat) => [d.lng, d.lat],
@ -94,23 +76,18 @@ function AssetMap({
getLineColor: [selectedOrg.id],
getLineWidth: [selectedOrg.id],
},
});
}, [orgs, selectedOrg, handleClick]);
}),
[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>
{[

파일 보기

@ -1,11 +1,10 @@
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 { Popup, useMap } from '@vis.gl/react-maplibre';
import { ScatterplotLayer, IconLayer, 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 { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel';
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel';
import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData';
@ -27,9 +26,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 +51,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();
@ -150,14 +138,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');
@ -377,11 +359,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(() => {
@ -564,18 +541,10 @@ export function IncidentsView() {
incidentLayer,
vesselIconLayer,
...dischargeZoneLayers,
...measureDeckLayers,
...trajectoryLayers,
...(sensLayer ? [sensLayer] : []),
],
[
incidentLayer,
vesselIconLayer,
dischargeZoneLayers,
measureDeckLayers,
trajectoryLayers,
sensLayer,
],
[incidentLayer, vesselIconLayer, dischargeZoneLayers, trajectoryLayers, sensLayer],
);
return (
@ -710,30 +679,20 @@ 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} />
<FlyToController incident={selectedIncident} />
<MeasureOverlay />
{/* 사고 팝업 */}
{incidentPopup && (
@ -753,7 +712,7 @@ export function IncidentsView() {
/>
</Popup>
)}
</MapLibre>
</BaseMap>
{/* 호버 툴팁 */}
{hoverInfo && (

파일 보기

@ -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,8 +370,12 @@ 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
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)',
}}
@ -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,
@ -175,8 +177,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 +204,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 +222,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 +230,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 +238,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 +248,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 +256,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 +266,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 +274,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 +285,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 +298,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 +307,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 +359,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>

파일 보기

@ -208,6 +208,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 +1192,8 @@ export function OilSpillView() {
onSpillUnitChange={setSpillUnit}
boomLines={boomLines}
onBoomLinesChange={setBoomLines}
showBoomLines={showBoomLines}
onShowBoomLinesChange={setShowBoomLines}
oilTrajectory={oilTrajectory}
algorithmSettings={algorithmSettings}
onAlgorithmSettingsChange={setAlgorithmSettings}
@ -1281,6 +1284,7 @@ export function OilSpillView() {
)}
selectedModels={selectedModels}
boomLines={boomLines}
showBoomLines={showBoomLines}
isDrawingBoom={isDrawingBoom}
drawingPoints={drawingPoints}
layerOpacity={layerOpacity}
@ -1659,7 +1663,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,

파일 보기

@ -119,8 +119,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 && (
@ -169,7 +169,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 +203,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 +258,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 +270,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 +549,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 +575,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 +585,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 +595,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 +777,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 +820,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 +830,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 +841,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;

파일 보기

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

파일 보기

@ -1541,8 +1541,7 @@ export function RescueView() {
}, []);
// 사고 선택 시 사고유형 자동 매핑
const handleSelectAcdnt = useCallback(
(item: IncidentListItem | null) => {
const handleSelectAcdnt = useCallback((item: IncidentListItem | null) => {
setSelectedAcdnt(item);
if (item) {
const typeMap: Record<string, AccidentType> = {
@ -1558,9 +1557,7 @@ export function RescueView() {
if (mapped) setActiveType(mapped);
setIncidentCoord({ lon: item.lng, lat: item.lat });
}
},
[],
);
}, []);
if (activeSubTab === 'list') {
return (

파일 보기

@ -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',
const shadowDotLayer = useMemo(
() =>
new ScatterplotLayer<ScatSegment>({
id: 'scat-shadow-dots',
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,
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.markerRadius],
},
});
}, [segments, zs.showStatusMarker, zs.markerRadius, handleClick]);
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">