chore: develop 머지 충돌 해결

This commit is contained in:
jeonghyo.k 2026-04-15 08:13:12 +09:00
커밋 279dcbc0e1
34개의 변경된 파일2083개의 추가작업 그리고 2175개의 파일을 삭제

파일 보기

@ -4,6 +4,9 @@
## [Unreleased] ## [Unreleased]
### 변경
- MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용
## [2026-04-14] ## [2026-04-14]
### 추가 ### 추가

파일 보기

@ -0,0 +1,311 @@
import {
useState,
useMemo,
useCallback,
useEffect,
type MutableRefObject,
type ReactNode,
} from 'react';
import { Map, useMap } from '@vis.gl/react-maplibre';
import type { MapLayerMouseEvent } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { useMapStore } from '@common/store/mapStore';
import { useMeasureTool } from '@common/hooks/useMeasureTool';
import { S57EncOverlay } from './S57EncOverlay';
import { MeasureOverlay } from './MeasureOverlay';
import { DeckGLOverlay } from './DeckGLOverlay';
import { buildMeasureLayers } from './measureLayers';
const DEFAULT_CENTER: [number, number] = [37.39, 126.64];
const DEFAULT_ZOOM = 10;
export interface BaseMapProps {
/** 초기 중심 좌표 [lat, lng]. 기본: 인천 송도 */
center?: [number, number];
/** 초기 줌 레벨. 기본: 10 */
zoom?: number;
/** 지도 클릭 핸들러 (측정 모드 중에는 호출되지 않음) */
onMapClick?: (lon: number, lat: number) => void;
/** 줌 변경 핸들러. ScatMap 등 줌 기반 레이어 스케일 조정에 사용 */
onZoom?: (zoom: number) => void;
/** 커서 스타일 (예: 'crosshair'). 기본: 'grab' */
cursor?: string;
/** false 시 컨트롤 UI·좌표 표시를 숨김 (캡처 전용 모드). 기본: true */
showOverlays?: boolean;
/** 지도 캡처 함수 ref (Reports 탭 전용) */
mapCaptureRef?: MutableRefObject<(() => Promise<string | null>) | null>;
/** 탭별 고유 오버레이·마커·팝업 등 */
children?: ReactNode;
}
// ─── 3D 모드 pitch/bearing 제어 ────────────────────────────────────────────
function MapPitchController({ threeD }: { threeD: boolean }) {
const { current: map } = useMap();
useEffect(() => {
if (!map) return;
map.easeTo(
threeD ? { pitch: 45, bearing: -17, duration: 800 } : { pitch: 0, bearing: 0, duration: 800 },
);
}, [threeD, map]);
return null;
}
// ─── 지도 캡처 지원 ────────────────────────────────────────────────────────
function MapCaptureSetup({
captureRef,
}: {
captureRef: MutableRefObject<(() => Promise<string | null>) | null>;
}) {
const { current: map } = useMap();
useEffect(() => {
if (!map) return;
captureRef.current = () =>
new Promise<string | null>((resolve) => {
map.once('render', () => {
try {
const src = map.getCanvas();
const maxW = 1200;
const scale = src.width > maxW ? maxW / src.width : 1;
const composite = document.createElement('canvas');
composite.width = Math.round(src.width * scale);
composite.height = Math.round(src.height * scale);
const ctx = composite.getContext('2d')!;
ctx.fillStyle = '#0f1117';
ctx.fillRect(0, 0, composite.width, composite.height);
ctx.drawImage(src, 0, 0, composite.width, composite.height);
resolve(composite.toDataURL('image/jpeg', 0.82));
} catch {
resolve(null);
}
});
map.triggerRepaint();
});
}, [map, captureRef]);
return null;
}
// ─── 공통 컨트롤 UI + 좌표 표시 ───────────────────────────────────────────
// Map 내부에 렌더링되어 useMap()으로 인스턴스에 접근함.
// 줌 버튼·지도 타입·측정 도구·좌표 표시를 하나의 컴포넌트로 통합.
function MapOverlayControls({
initialCenter,
initialZoom,
}: {
initialCenter: [number, number];
initialZoom: number;
}) {
const { current: map } = useMap();
const mapToggles = useMapStore((s) => s.mapToggles);
const toggleMap = useMapStore((s) => s.toggleMap);
const measureMode = useMapStore((s) => s.measureMode);
const setMeasureMode = useMapStore((s) => s.setMeasureMode);
const [pos, setPos] = useState({
lat: initialCenter[0],
lng: initialCenter[1],
zoom: initialZoom,
});
useEffect(() => {
if (!map) return;
const update = () => {
const c = map.getCenter();
setPos({ lat: c.lat, lng: c.lng, zoom: map.getZoom() });
};
update();
map.on('move', update);
map.on('zoom', update);
return () => {
map.off('move', update);
map.off('zoom', update);
};
}, [map]);
const btn =
'w-[28px] h-[28px] bg-[color-mix(in_srgb,var(--bg-elevated)_85%,transparent)] ' +
'backdrop-blur-sm border border-stroke rounded-sm text-fg-sub flex items-center justify-center ' +
'hover:bg-bg-surface-hover hover:text-fg transition-all text-caption select-none cursor-pointer';
const btnOn = 'text-color-accent border-color-accent bg-[rgba(6,182,212,0.08)]';
// 좌표·축척 계산
const { lat, lng, zoom } = pos;
const latDir = lat >= 0 ? 'N' : 'S';
const lngDir = lng >= 0 ? 'E' : 'W';
const mpp = (40075016.686 * Math.cos((lat * Math.PI) / 180)) / (256 * Math.pow(2, zoom));
const sr = Math.round(mpp * (96 / 0.0254));
const scaleLabel =
sr >= 1_000_000 ? `1:${(sr / 1_000_000).toFixed(1)}M` : `1:${sr.toLocaleString()}`;
return (
<>
{/* 좌측 컨트롤 컬럼 */}
<div className="absolute top-[80px] left-[10px] z-10 flex flex-col gap-1">
{/* 줌 */}
<button title="줌 인" onClick={() => map?.zoomIn()} className={btn}>
+
</button>
<button title="줌 아웃" onClick={() => map?.zoomOut()} className={btn}>
</button>
<button
title="초기 위치로"
onClick={() =>
map?.flyTo({
center: [initialCenter[1], initialCenter[0]],
zoom: initialZoom,
duration: 1000,
})
}
className={btn}
style={{ fontSize: '10px' }}
>
</button>
<div className="h-px bg-stroke my-0.5" />
{/* 지도 타입 */}
<button
title="3D 위성 모드"
onClick={() => toggleMap('threeD')}
className={`${btn} ${mapToggles.threeD ? btnOn : ''}`}
style={{ fontSize: '9px', fontWeight: 600, letterSpacing: '-0.3px' }}
>
3D
</button>
<button
title="ENC 전자해도"
onClick={() => toggleMap('s57')}
className={`${btn} ${mapToggles.s57 ? btnOn : ''}`}
style={{ fontSize: '8px', fontWeight: 600 }}
>
ENC
</button>
<div className="h-px bg-stroke my-0.5" />
{/* 측정 도구 */}
<button
title="거리 측정"
onClick={() => setMeasureMode(measureMode === 'distance' ? null : 'distance')}
className={`${btn} ${measureMode === 'distance' ? btnOn : ''}`}
style={{ fontSize: '12px' }}
>
📏
</button>
<button
title="면적 측정"
onClick={() => setMeasureMode(measureMode === 'area' ? null : 'area')}
className={`${btn} ${measureMode === 'area' ? btnOn : ''}`}
style={{ fontSize: '11px' }}
>
</button>
</div>
{/* 좌표 표시 (좌하단) */}
<div className="cod">
<span>
{' '}
<span className="cov">
{Math.abs(lat).toFixed(4)}°{latDir}
</span>
</span>
<span>
{' '}
<span className="cov">
{Math.abs(lng).toFixed(4)}°{lngDir}
</span>
</span>
<span>
<span className="cov">{scaleLabel}</span>
</span>
</div>
</>
);
}
// ─── BaseMap ───────────────────────────────────────────────────────────────
export function BaseMap({
center = DEFAULT_CENTER,
zoom = DEFAULT_ZOOM,
onMapClick,
onZoom,
cursor,
showOverlays = true,
mapCaptureRef,
children,
}: BaseMapProps) {
const mapStyle = useBaseMapStyle();
const mapToggles = useMapStore((s) => s.mapToggles);
const measureMode = useMapStore((s) => s.measureMode);
const measureInProgress = useMapStore((s) => s.measureInProgress);
const measurements = useMapStore((s) => s.measurements);
const { handleMeasureClick } = useMeasureTool();
const handleClick = useCallback(
(e: MapLayerMouseEvent) => {
const { lng, lat } = e.lngLat;
if (measureMode !== null) {
handleMeasureClick(lng, lat);
return;
}
onMapClick?.(lng, lat);
},
[measureMode, handleMeasureClick, onMapClick],
);
const handleZoom = useCallback(
(e: { viewState: { zoom: number } }) => {
onZoom?.(e.viewState.zoom);
},
[onZoom],
);
const measureDeckLayers = useMemo(
() => buildMeasureLayers(measureInProgress, measureMode, measurements),
[measureInProgress, measureMode, measurements],
);
return (
<div className="w-full h-full relative">
<Map
initialViewState={{ longitude: center[1], latitude: center[0], zoom }}
mapStyle={mapStyle}
className="w-full h-full"
onClick={handleClick}
onZoom={handleZoom}
style={{ cursor: cursor ?? 'grab' }}
attributionControl={false}
preserveDrawingBuffer={true}
>
{/* 공통 오버레이 */}
<S57EncOverlay visible={mapToggles.s57 ?? false} />
<MapPitchController threeD={mapToggles.threeD ?? false} />
<DeckGLOverlay layers={measureDeckLayers} />
<MeasureOverlay />
{mapCaptureRef && <MapCaptureSetup captureRef={mapCaptureRef} />}
{/* 공통 컨트롤 UI (줌·지도 타입·측정·좌표) */}
{showOverlays && <MapOverlayControls initialCenter={center} initialZoom={zoom} />}
{/* 탭별 주입 */}
{children}
</Map>
{/* 측정 모드 힌트 */}
{showOverlays && measureMode === 'distance' && (
<div className="boom-drawing-indicator">
{measureInProgress.length === 0 ? '시작점을 클릭하세요' : '끝점을 클릭하세요'}
</div>
)}
{showOverlays && measureMode === 'area' && (
<div className="boom-drawing-indicator">
({measureInProgress.length})
{measureInProgress.length >= 3 && ' → 첫 번째 점 근처를 클릭하면 닫힙니다'}
</div>
)}
</div>
);
}

파일 보기

@ -0,0 +1,16 @@
import { useControl } from '@vis.gl/react-maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import type { Layer } from '@deck.gl/core';
interface DeckGLOverlayProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
layers: Layer<any>[];
}
/** deck.gl MapLibre interleaved .
* <Map> . */
export function DeckGLOverlay({ layers }: DeckGLOverlayProps) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers });
return null;
}

파일 보기

@ -0,0 +1,26 @@
import { useEffect } from 'react';
import { useMap } from '@vis.gl/react-maplibre';
interface FlyToControllerProps {
target?: { lng: number; lat: number; zoom?: number } | null;
/** 이동 애니메이션 시간(ms). 기본: 1000 */
duration?: number;
}
/** flyTo .
* target이 flyTo를 .
* <Map> . */
export function FlyToController({ target, duration = 1000 }: FlyToControllerProps) {
const { current: map } = useMap();
useEffect(() => {
if (!map || !target) return;
map.flyTo({
center: [target.lng, target.lat],
zoom: target.zoom ?? 10,
duration,
});
}, [target, map, duration]);
return null;
}

파일 보기

@ -1,6 +1,5 @@
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'; import { Map, Marker, Popup, Source, Layer, useMap } from '@vis.gl/react-maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { import {
ScatterplotLayer, ScatterplotLayer,
PathLayer, PathLayer,
@ -28,6 +27,8 @@ import { useMeasureTool } from '@common/hooks/useMeasureTool';
import { hexToRgba } from './mapUtils'; import { hexToRgba } from './mapUtils';
import { S57EncOverlay } from './S57EncOverlay'; import { S57EncOverlay } from './S57EncOverlay';
import { SrOverlay } from './SrOverlay'; import { SrOverlay } from './SrOverlay';
import { DeckGLOverlay } from './DeckGLOverlay';
import { FlyToController } from './FlyToController';
import { useMapStore } from '@common/store/mapStore'; import { useMapStore } from '@common/store/mapStore';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
@ -126,6 +127,7 @@ interface MapViewProps {
dispersionResult?: DispersionResult | null; dispersionResult?: DispersionResult | null;
dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>; dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>;
boomLines?: BoomLine[]; boomLines?: BoomLine[];
showBoomLines?: boolean;
isDrawingBoom?: boolean; isDrawingBoom?: boolean;
drawingPoints?: BoomLineCoord[]; drawingPoints?: BoomLineCoord[];
layerOpacity?: number; layerOpacity?: number;
@ -165,31 +167,7 @@ interface MapViewProps {
showOverlays?: boolean; showOverlays?: boolean;
} }
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved) // DeckGLOverlay, FlyToController → @common/components/map/DeckGLOverlay, FlyToController 에서 import
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: any[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers });
return null;
}
// flyTo 트리거 컴포넌트 (Map 내부에서 useMap() 사용)
function FlyToController({
flyToTarget,
}: {
flyToTarget?: { lng: number; lat: number; zoom?: number } | null;
}) {
const { current: map } = useMap();
useEffect(() => {
if (!map || !flyToTarget) return;
map.flyTo({
center: [flyToTarget.lng, flyToTarget.lat],
zoom: flyToTarget.zoom ?? 10,
duration: 1200,
});
}, [flyToTarget, map]);
return null;
}
// fitBounds 트리거 컴포넌트 (Map 내부에서 useMap() 사용) // fitBounds 트리거 컴포넌트 (Map 내부에서 useMap() 사용)
function FitBoundsController({ function FitBoundsController({
@ -341,6 +319,7 @@ export function MapView({
dispersionResult = null, dispersionResult = null,
dispersionHeatmap = [], dispersionHeatmap = [],
boomLines = [], boomLines = [],
showBoomLines = true,
isDrawingBoom = false, isDrawingBoom = false,
drawingPoints = [], drawingPoints = [],
layerOpacity = 50, layerOpacity = 50,
@ -587,7 +566,7 @@ export function MapView({
} }
// --- 오일펜스 라인 (PathLayer) --- // --- 오일펜스 라인 (PathLayer) ---
if (boomLines.length > 0) { if (showBoomLines && boomLines.length > 0) {
result.push( result.push(
new PathLayer({ new PathLayer({
id: 'boom-lines', id: 'boom-lines',
@ -1243,6 +1222,7 @@ export function MapView({
currentTime, currentTime,
selectedModels, selectedModels,
boomLines, boomLines,
showBoomLines,
isDrawingBoom, isDrawingBoom,
drawingPoints, drawingPoints,
dispersionResult, dispersionResult,
@ -1295,7 +1275,7 @@ export function MapView({
{/* 사고 지점 변경 시 지도 이동 */} {/* 사고 지점 변경 시 지도 이동 */}
<MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} /> <MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} />
{/* 외부에서 flyTo 트리거 */} {/* 외부에서 flyTo 트리거 */}
<FlyToController flyToTarget={flyToTarget} /> <FlyToController target={flyToTarget} duration={1200} />
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */} {/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
<FitBoundsController fitBoundsTarget={fitBoundsTarget} /> <FitBoundsController fitBoundsTarget={fitBoundsTarget} />

파일 보기

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

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -1,11 +1,10 @@
import { useState, useEffect, useMemo, useRef } from 'react'; import { useState, useEffect, useMemo, useRef } from 'react';
import { Map as MapLibre, Popup, useControl, useMap } from '@vis.gl/react-maplibre'; import { Popup, useMap } from '@vis.gl/react-maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { ScatterplotLayer, IconLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers'; import { ScatterplotLayer, IconLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers';
import { PathStyleExtension } from '@deck.gl/extensions'; import { PathStyleExtension } from '@deck.gl/extensions';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; import { BaseMap } from '@common/components/map/BaseMap';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'; import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay';
import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel'; import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel';
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel'; import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel';
import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData'; import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData';
@ -27,9 +26,6 @@ import {
getCachedZones, getCachedZones,
} from '../utils/dischargeZoneData'; } from '../utils/dischargeZoneData';
import { useMapStore } from '@common/store/mapStore'; import { useMapStore } from '@common/store/mapStore';
import { useMeasureTool } from '@common/hooks/useMeasureTool';
import { buildMeasureLayers } from '@common/components/map/measureLayers';
import { MeasureOverlay } from '@common/components/map/MeasureOverlay';
// ── 민감자원 카테고리별 색상 (목록 순서 인덱스 기반 — 중복 없음) ──────────── // ── 민감자원 카테고리별 색상 (목록 순서 인덱스 기반 — 중복 없음) ────────────
const CATEGORY_PALETTE: [number, number, number][] = [ const CATEGORY_PALETTE: [number, number, number][] = [
@ -55,14 +51,6 @@ function getCategoryColor(index: number): [number, number, number] {
return CATEGORY_PALETTE[index % CATEGORY_PALETTE.length]; return CATEGORY_PALETTE[index % CATEGORY_PALETTE.length];
} }
// ── DeckGLOverlay ──────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: any[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers });
return null;
}
// ── FlyToController: 사고 선택 시 지도 이동 ────────── // ── FlyToController: 사고 선택 시 지도 이동 ──────────
function FlyToController({ incident }: { incident: IncidentCompat | null }) { function FlyToController({ incident }: { incident: IncidentCompat | null }) {
const { current: map } = useMap(); const { current: map } = useMap();
@ -150,14 +138,8 @@ export function IncidentsView() {
() => getCachedBaseline() !== null && getCachedZones() !== null, () => getCachedBaseline() !== null && getCachedZones() !== null,
); );
// Map style & toggles // Measure mode (cursor 결정용 — 측정 클릭/레이어는 BaseMap이 처리)
const currentMapStyle = useBaseMapStyle(); const measureMode = useMapStore((s) => s.measureMode);
const mapToggles = useMapStore((s) => s.mapToggles);
// Measure tool
const { handleMeasureClick, measureMode } = useMeasureTool();
const measureInProgress = useMapStore((s) => s.measureInProgress);
const measurements = useMapStore((s) => s.measurements);
// Analysis view mode // Analysis view mode
const [viewMode, setViewMode] = useState<ViewMode>('overlay'); const [viewMode, setViewMode] = useState<ViewMode>('overlay');
@ -377,11 +359,6 @@ export function IncidentsView() {
); );
}, [dischargeMode, baselineLoaded]); }, [dischargeMode, baselineLoaded]);
const measureDeckLayers = useMemo(
() => buildMeasureLayers(measureInProgress, measureMode, measurements),
[measureInProgress, measureMode, measurements],
);
// ── 예측 결과 레이어 (입자 클라우드, 중심점 경로, 시간 라벨, 해안 부착 입자) ────── // ── 예측 결과 레이어 (입자 클라우드, 중심점 경로, 시간 라벨, 해안 부착 입자) ──────
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const trajectoryLayers: any[] = useMemo(() => { const trajectoryLayers: any[] = useMemo(() => {
@ -564,18 +541,10 @@ export function IncidentsView() {
incidentLayer, incidentLayer,
vesselIconLayer, vesselIconLayer,
...dischargeZoneLayers, ...dischargeZoneLayers,
...measureDeckLayers,
...trajectoryLayers, ...trajectoryLayers,
...(sensLayer ? [sensLayer] : []), ...(sensLayer ? [sensLayer] : []),
], ],
[ [incidentLayer, vesselIconLayer, dischargeZoneLayers, trajectoryLayers, sensLayer],
incidentLayer,
vesselIconLayer,
dischargeZoneLayers,
measureDeckLayers,
trajectoryLayers,
sensLayer,
],
); );
return ( return (
@ -710,30 +679,20 @@ export function IncidentsView() {
{/* Default Map (visible when not in analysis or in overlay mode) */} {/* Default Map (visible when not in analysis or in overlay mode) */}
{(!analysisActive || viewMode === 'overlay') && ( {(!analysisActive || viewMode === 'overlay') && (
<div className="absolute inset-0"> <div className="absolute inset-0">
<MapLibre <BaseMap
initialViewState={{ longitude: 127.8, latitude: 35.0, zoom: 7 }} center={[35.0, 127.8]}
mapStyle={currentMapStyle} zoom={7}
style={{ width: '100%', height: '100%', background: '#f0f0f0' }} cursor={measureMode !== null || dischargeMode ? 'crosshair' : undefined}
attributionControl={false} onMapClick={(lon, lat) => {
onClick={(e) => { if (dischargeMode) {
if (measureMode !== null && e.lngLat) {
handleMeasureClick(e.lngLat.lng, e.lngLat.lat);
return;
}
if (dischargeMode && e.lngLat) {
const lat = e.lngLat.lat;
const lon = e.lngLat.lng;
const distanceNm = estimateDistanceFromCoast(lat, lon); const distanceNm = estimateDistanceFromCoast(lat, lon);
const zoneIndex = determineZone(lat, lon); const zoneIndex = determineZone(lat, lon);
setDischargeInfo({ lat, lon, distanceNm, zoneIndex }); setDischargeInfo({ lat, lon, distanceNm, zoneIndex });
} }
}} }}
cursor={measureMode !== null || dischargeMode ? 'crosshair' : undefined}
> >
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<DeckGLOverlay layers={deckLayers} /> <DeckGLOverlay layers={deckLayers} />
<FlyToController incident={selectedIncident} /> <FlyToController incident={selectedIncident} />
<MeasureOverlay />
{/* 사고 팝업 */} {/* 사고 팝업 */}
{incidentPopup && ( {incidentPopup && (
@ -753,7 +712,7 @@ export function IncidentsView() {
/> />
</Popup> </Popup>
)} )}
</MapLibre> </BaseMap>
{/* 호버 툴팁 */} {/* 호버 툴팁 */}
{hoverInfo && ( {hoverInfo && (

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -95,6 +95,8 @@ export function LeftPanel({
onSpillUnitChange, onSpillUnitChange,
boomLines, boomLines,
onBoomLinesChange, onBoomLinesChange,
showBoomLines,
onShowBoomLinesChange,
oilTrajectory, oilTrajectory,
algorithmSettings, algorithmSettings,
onAlgorithmSettingsChange, onAlgorithmSettingsChange,
@ -177,8 +179,8 @@ export function LeftPanel({
onClick={() => toggleSection('incident')} onClick={() => toggleSection('incident')}
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]"
> >
<h3 className="text-title-4 font-bold text-fg-sub font-korean"></h3> <h3 className="text-title-4 font-bold text-fg-default font-korean"></h3>
<span className="text-label-2 text-fg-disabled"> <span className="text-label-2 text-fg-default">
{expandedSections.incident ? '▼' : '▶'} {expandedSections.incident ? '▼' : '▶'}
</span> </span>
</div> </div>
@ -204,7 +206,7 @@ export function LeftPanel({
CLOSED: { CLOSED: {
label: '종료', label: '종료',
style: style:
'bg-[rgba(100,116,139,0.15)] text-fg-disabled border border-[rgba(100,116,139,0.3)]', 'bg-[rgba(100,116,139,0.15)] text-fg-default border border-[rgba(100,116,139,0.3)]',
dot: 'bg-fg-disabled', dot: 'bg-fg-disabled',
}, },
}; };
@ -222,7 +224,7 @@ export function LeftPanel({
{/* Info Grid */} {/* Info Grid */}
<div className="grid gap-1"> <div className="grid gap-1">
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<span className="text-label-2 text-fg-disabled min-w-[52px] font-korean"> <span className="text-label-2 text-fg-default min-w-[52px] font-korean">
</span> </span>
<span className="text-label-2 text-fg font-medium font-mono"> <span className="text-label-2 text-fg font-medium font-mono">
@ -230,7 +232,7 @@ export function LeftPanel({
</span> </span>
</div> </div>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<span className="text-label-2 text-fg-disabled min-w-[52px] font-korean"> <span className="text-label-2 text-fg-default min-w-[52px] font-korean">
</span> </span>
<span className="text-label-2 text-fg font-medium font-korean"> <span className="text-label-2 text-fg font-medium font-korean">
@ -238,7 +240,7 @@ export function LeftPanel({
</span> </span>
</div> </div>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<span className="text-label-2 text-fg-disabled min-w-[52px] font-korean"> <span className="text-label-2 text-fg-default min-w-[52px] font-korean">
</span> </span>
<span className="text-label-2 text-fg font-medium font-mono"> <span className="text-label-2 text-fg font-medium font-mono">
@ -248,7 +250,7 @@ export function LeftPanel({
</span> </span>
</div> </div>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<span className="text-label-2 text-fg-disabled min-w-[52px] font-korean"> <span className="text-label-2 text-fg-default min-w-[52px] font-korean">
</span> </span>
<span className="text-label-2 text-fg font-medium font-korean"> <span className="text-label-2 text-fg font-medium font-korean">
@ -256,7 +258,7 @@ export function LeftPanel({
</span> </span>
</div> </div>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<span className="text-label-2 text-fg-disabled min-w-[52px] font-korean"> <span className="text-label-2 text-fg-default min-w-[52px] font-korean">
</span> </span>
<span className="text-label-2 text-fg font-medium font-mono"> <span className="text-label-2 text-fg font-medium font-mono">
@ -266,7 +268,7 @@ export function LeftPanel({
</span> </span>
</div> </div>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<span className="text-label-2 text-fg-disabled min-w-[52px] font-korean"> <span className="text-label-2 text-fg-default min-w-[52px] font-korean">
</span> </span>
<span className="text-label-2 text-fg font-medium font-korean"> <span className="text-label-2 text-fg font-medium font-korean">
@ -274,7 +276,7 @@ export function LeftPanel({
</span> </span>
</div> </div>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<span className="text-label-2 text-fg-disabled min-w-[52px] font-korean"> <span className="text-label-2 text-fg-default min-w-[52px] font-korean">
</span> </span>
<span className="text-label-2 text-color-warning font-medium font-korean"> <span className="text-label-2 text-color-warning font-medium font-korean">
@ -285,7 +287,7 @@ export function LeftPanel({
</div> </div>
) : ( ) : (
<div className="px-4 pb-4"> <div className="px-4 pb-4">
<p className="text-label-1 text-fg-disabled font-korean text-center py-2"> <p className="text-label-1 text-fg-default font-korean text-center py-2">
. .
</p> </p>
</div> </div>
@ -298,8 +300,8 @@ export function LeftPanel({
onClick={() => toggleSection('impactResources')} onClick={() => toggleSection('impactResources')}
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]"
> >
<h3 className="text-title-4 font-bold text-fg-sub font-korean"> </h3> <h3 className="text-title-4 font-bold text-fg-default font-korean"> </h3>
<span className="text-label-2 text-fg-disabled"> <span className="text-label-2 text-fg-default">
{expandedSections.impactResources ? '▼' : '▶'} {expandedSections.impactResources ? '▼' : '▶'}
</span> </span>
</div> </div>
@ -307,7 +309,7 @@ export function LeftPanel({
{expandedSections.impactResources && ( {expandedSections.impactResources && (
<div className="px-4 pb-4"> <div className="px-4 pb-4">
{sensitiveResources.length === 0 ? ( {sensitiveResources.length === 0 ? (
<p className="text-label-1 text-fg-disabled text-center font-korean"> <p className="text-label-1 text-fg-default text-center font-korean">
</p> </p>
) : ( ) : (
@ -359,6 +361,8 @@ export function LeftPanel({
onToggle={() => toggleSection('oilBoom')} onToggle={() => toggleSection('oilBoom')}
boomLines={boomLines} boomLines={boomLines}
onBoomLinesChange={onBoomLinesChange} onBoomLinesChange={onBoomLinesChange}
showBoomLines={showBoomLines}
onShowBoomLinesChange={onShowBoomLinesChange}
oilTrajectory={oilTrajectory} oilTrajectory={oilTrajectory}
incidentCoord={incidentCoord ?? { lat: 0, lon: 0 }} incidentCoord={incidentCoord ?? { lat: 0, lon: 0 }}
algorithmSettings={algorithmSettings} algorithmSettings={algorithmSettings}

파일 보기

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

파일 보기

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

파일 보기

@ -208,6 +208,7 @@ export function OilSpillView() {
// 오일펜스 배치 상태 // 오일펜스 배치 상태
const [boomLines, setBoomLines] = useState<BoomLine[]>([]); const [boomLines, setBoomLines] = useState<BoomLine[]>([]);
const [showBoomLines, setShowBoomLines] = useState(true);
const [algorithmSettings, setAlgorithmSettings] = useState<AlgorithmSettings>({ const [algorithmSettings, setAlgorithmSettings] = useState<AlgorithmSettings>({
currentOrthogonalCorrection: 15, currentOrthogonalCorrection: 15,
safetyMarginMinutes: 60, safetyMarginMinutes: 60,
@ -1191,6 +1192,8 @@ export function OilSpillView() {
onSpillUnitChange={setSpillUnit} onSpillUnitChange={setSpillUnit}
boomLines={boomLines} boomLines={boomLines}
onBoomLinesChange={setBoomLines} onBoomLinesChange={setBoomLines}
showBoomLines={showBoomLines}
onShowBoomLinesChange={setShowBoomLines}
oilTrajectory={oilTrajectory} oilTrajectory={oilTrajectory}
algorithmSettings={algorithmSettings} algorithmSettings={algorithmSettings}
onAlgorithmSettingsChange={setAlgorithmSettings} onAlgorithmSettingsChange={setAlgorithmSettings}
@ -1284,6 +1287,7 @@ export function OilSpillView() {
)} )}
selectedModels={selectedModels} selectedModels={selectedModels}
boomLines={boomLines} boomLines={boomLines}
showBoomLines={showBoomLines}
isDrawingBoom={isDrawingBoom} isDrawingBoom={isDrawingBoom}
drawingPoints={drawingPoints} drawingPoints={drawingPoints}
layerOpacity={layerOpacity} layerOpacity={layerOpacity}
@ -1662,7 +1666,7 @@ export function OilSpillView() {
fontSize: 'var(--font-size-caption)', fontSize: 'var(--font-size-caption)',
}} }}
> >
<span className="text-fg-disabled">{s.label}</span> <span className="text-fg-default">{s.label}</span>
<span <span
style={{ style={{
color: s.color, color: s.color,

파일 보기

@ -154,8 +154,8 @@ const PredictionInputSection = ({
onClick={onToggle} onClick={onToggle}
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]"
> >
<h3 className="text-title-4 font-bold text-fg-sub font-korean"> </h3> <h3 className="text-title-4 font-bold text-fg-default font-korean"> </h3>
<span className="text-label-2 text-fg-disabled">{expanded ? '▼' : '▶'}</span> <span className="text-label-2 text-fg-default">{expanded ? '▼' : '▶'}</span>
</div> </div>
{expanded && ( {expanded && (
@ -210,7 +210,7 @@ const PredictionInputSection = ({
{/* 파일 선택 영역 */} {/* 파일 선택 영역 */}
{!uploadedFile ? ( {!uploadedFile ? (
<label <label
className="flex items-center justify-center text-label-2 text-fg-disabled cursor-pointer" className="flex items-center justify-center text-label-2 text-fg-default cursor-pointer"
style={{ style={{
padding: '20px', padding: '20px',
background: 'var(--bg-base)', background: 'var(--bg-base)',
@ -244,7 +244,7 @@ const PredictionInputSection = ({
<span className="text-fg-sub">📄 {uploadedFile.name}</span> <span className="text-fg-sub">📄 {uploadedFile.name}</span>
<button <button
onClick={handleRemoveFile} onClick={handleRemoveFile}
className="text-label-2 text-fg-disabled bg-transparent border-none cursor-pointer" className="text-label-2 text-fg-default bg-transparent border-none cursor-pointer"
style={{ padding: '2px 6px', transition: '0.15s' }} style={{ padding: '2px 6px', transition: '0.15s' }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--color-danger)'; e.currentTarget.style.color = 'var(--color-danger)';
@ -299,7 +299,7 @@ const PredictionInputSection = ({
> >
<br /> <br />
<span className="font-normal text-fg-disabled"> <span className="font-normal text-fg-default">
{analyzeResult.lat.toFixed(4)} / {analyzeResult.lon.toFixed(4)} {analyzeResult.lat.toFixed(4)} / {analyzeResult.lon.toFixed(4)}
<br /> <br />
: {analyzeResult.oilType} / : {analyzeResult.area.toFixed(1)} m² : {analyzeResult.oilType} / : {analyzeResult.area.toFixed(1)} m²
@ -311,9 +311,7 @@ const PredictionInputSection = ({
{/* 사고 발생 시각 */} {/* 사고 발생 시각 */}
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<label className="text-label-2 text-fg-disabled font-korean"> <label className="text-label-2 text-fg-default font-korean"> (KST)</label>
(KST)
</label>
<DateTimeInput <DateTimeInput
value={accidentTime} value={accidentTime}
onChange={onAccidentTimeChange} onChange={onAccidentTimeChange}
@ -592,7 +590,7 @@ function DateTimeInput({
{/* 시 */} {/* 시 */}
<TimeDropdown value={hh} max={24} onChange={(v) => updateTime(v, mm)} /> <TimeDropdown value={hh} max={24} onChange={(v) => updateTime(v, mm)} />
<span className="text-label-2 text-fg-disabled font-bold">:</span> <span className="text-label-2 text-fg-default font-bold">:</span>
{/* 분 */} {/* 분 */}
<TimeDropdown value={mm} max={60} onChange={(v) => updateTime(hh, v)} /> <TimeDropdown value={mm} max={60} onChange={(v) => updateTime(hh, v)} />
@ -618,7 +616,7 @@ function DateTimeInput({
<button <button
type="button" type="button"
onClick={prevMonth} onClick={prevMonth}
className="text-label-2 text-fg-disabled cursor-pointer px-1 hover:text-fg" className="text-label-2 text-fg-default cursor-pointer px-1 hover:text-fg"
> >
</button> </button>
@ -628,7 +626,7 @@ function DateTimeInput({
<button <button
type="button" type="button"
onClick={nextMonth} onClick={nextMonth}
className="text-label-2 text-fg-disabled cursor-pointer px-1 hover:text-fg" className="text-label-2 text-fg-default cursor-pointer px-1 hover:text-fg"
> >
</button> </button>
@ -638,7 +636,7 @@ function DateTimeInput({
{['일', '월', '화', '수', '목', '금', '토'].map((d) => ( {['일', '월', '화', '수', '목', '금', '토'].map((d) => (
<span <span
key={d} key={d}
className="text-label-2 text-fg-disabled font-korean" className="text-label-2 text-fg-default font-korean"
style={{ padding: '2px 0' }} style={{ padding: '2px 0' }}
> >
{d} {d}
@ -820,7 +818,7 @@ function DmsCoordInput({
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="text-caption text-fg-disabled font-korean">{label}</span> <span className="text-caption text-fg-default font-korean">{label}</span>
<div <div
className="flex items-center gap-0.5" className="flex items-center gap-0.5"
style={ style={
@ -863,7 +861,7 @@ function DmsCoordInput({
onChange={(e) => update(parseInt(e.target.value) || 0, m, s, dir)} onChange={(e) => update(parseInt(e.target.value) || 0, m, s, dir)}
style={fieldStyle} style={fieldStyle}
/> />
<span className="text-caption text-fg-disabled">°</span> <span className="text-caption text-fg-default">°</span>
<input <input
className="prd-i text-center flex-1" className="prd-i text-center flex-1"
type="number" type="number"
@ -873,7 +871,7 @@ function DmsCoordInput({
onChange={(e) => update(d, parseInt(e.target.value) || 0, s, dir)} onChange={(e) => update(d, parseInt(e.target.value) || 0, s, dir)}
style={fieldStyle} style={fieldStyle}
/> />
<span className="text-caption text-fg-disabled">'</span> <span className="text-caption text-fg-default">'</span>
<input <input
className="prd-i text-center flex-1" className="prd-i text-center flex-1"
type="number" type="number"
@ -884,7 +882,7 @@ function DmsCoordInput({
onChange={(e) => update(d, m, parseFloat(e.target.value) || 0, dir)} onChange={(e) => update(d, m, parseFloat(e.target.value) || 0, dir)}
style={fieldStyle} style={fieldStyle}
/> />
<span className="text-caption text-fg-disabled">"</span> <span className="text-caption text-fg-default">"</span>
</div> </div>
</div> </div>
); );

파일 보기

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

파일 보기

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

파일 보기

@ -40,6 +40,8 @@ export interface LeftPanelProps {
// 오일펜스 배치 관련 // 오일펜스 배치 관련
boomLines: BoomLine[]; boomLines: BoomLine[];
onBoomLinesChange: (lines: BoomLine[]) => void; onBoomLinesChange: (lines: BoomLine[]) => void;
showBoomLines: boolean;
onShowBoomLinesChange: (show: boolean) => void;
oilTrajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>; oilTrajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>;
algorithmSettings: AlgorithmSettings; algorithmSettings: AlgorithmSettings;
onAlgorithmSettingsChange: (settings: AlgorithmSettings) => void; onAlgorithmSettingsChange: (settings: AlgorithmSettings) => void;

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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