From 3eb66e2e54a21c9fdfa9a0ad252a11b6e94c9a91 Mon Sep 17 00:00:00 2001 From: leedano Date: Tue, 14 Apr 2026 17:20:01 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor(map):=20MapView=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=ED=83=AD=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=ED=86=A0=ED=81=B0=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/common/components/map/BaseMap.tsx | 311 ++++ .../common/components/map/DeckGLOverlay.tsx | 16 + .../common/components/map/FlyToController.tsx | 26 + .../src/common/components/map/MapView.tsx | 38 +- frontend/src/common/styles/components.css | 4 +- frontend/src/pages/design/LayoutContent.tsx | 1494 +++-------------- .../tabs/admin/components/DeidentifyPanel.tsx | 544 ++++-- .../admin/components/RndHnsAtmosPanel.tsx | 18 +- .../tabs/admin/components/RndKospsPanel.tsx | 18 +- .../admin/components/RndPoseidonPanel.tsx | 18 +- .../tabs/admin/components/RndRescuePanel.tsx | 18 +- .../tabs/admin/components/SystemArchPanel.tsx | 374 ++++- .../src/tabs/aerial/components/CctvView.tsx | 11 +- .../src/tabs/assets/components/AssetMap.tsx | 139 +- .../incidents/components/IncidentsView.tsx | 67 +- .../tabs/incidents/components/MediaModal.tsx | 81 +- .../components/AnalysisListTable.tsx | 36 +- .../prediction/components/BacktrackModal.tsx | 24 +- .../components/BoomDeploymentTheoryView.tsx | 60 +- .../components/InfoLayerSection.tsx | 8 +- .../tabs/prediction/components/LeftPanel.tsx | 32 +- .../prediction/components/OilBoomSection.tsx | 61 +- .../components/OilSpillTheoryView.tsx | 134 +- .../prediction/components/OilSpillView.tsx | 6 +- .../components/PredictionInputSection.tsx | 30 +- .../prediction/components/RecalcModal.tsx | 10 +- .../tabs/prediction/components/RightPanel.tsx | 65 +- .../prediction/components/leftPanelTypes.ts | 2 + .../rescue/components/RescueScenarioView.tsx | 316 +++- .../src/tabs/rescue/components/RescueView.tsx | 37 +- .../tabs/scat/components/ScatLeftPanel.tsx | 23 +- frontend/src/tabs/scat/components/ScatMap.tsx | 219 +-- .../tabs/scat/components/ScatRightPanel.tsx | 3 +- .../tabs/weather/components/WeatherView.tsx | 49 +- 34 files changed, 2097 insertions(+), 2195 deletions(-) create mode 100644 frontend/src/common/components/map/BaseMap.tsx create mode 100644 frontend/src/common/components/map/DeckGLOverlay.tsx create mode 100644 frontend/src/common/components/map/FlyToController.tsx diff --git a/frontend/src/common/components/map/BaseMap.tsx b/frontend/src/common/components/map/BaseMap.tsx new file mode 100644 index 0000000..b140a9b --- /dev/null +++ b/frontend/src/common/components/map/BaseMap.tsx @@ -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) | 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) | null>; +}) { + const { current: map } = useMap(); + useEffect(() => { + if (!map) return; + captureRef.current = () => + new Promise((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 ( + <> + {/* 좌측 컨트롤 컬럼 */} +
+ {/* 줌 */} + + + + +
+ + {/* 지도 타입 */} + + + +
+ + {/* 측정 도구 */} + + +
+ + {/* 좌표 표시 (좌하단) */} +
+ + 위도{' '} + + {Math.abs(lat).toFixed(4)}°{latDir} + + + + 경도{' '} + + {Math.abs(lng).toFixed(4)}°{lngDir} + + + + 축척 {scaleLabel} + +
+ + ); +} + +// ─── 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 ( +
+ + {/* 공통 오버레이 */} + + + + + {mapCaptureRef && } + + {/* 공통 컨트롤 UI (줌·지도 타입·측정·좌표) */} + {showOverlays && } + + {/* 탭별 주입 */} + {children} + + + {/* 측정 모드 힌트 */} + {showOverlays && measureMode === 'distance' && ( +
+ 거리 재기 — {measureInProgress.length === 0 ? '시작점을 클릭하세요' : '끝점을 클릭하세요'} +
+ )} + {showOverlays && measureMode === 'area' && ( +
+ 면적 재기 — 꼭짓점을 클릭하세요 ({measureInProgress.length}개) + {measureInProgress.length >= 3 && ' → 첫 번째 점 근처를 클릭하면 닫힙니다'} +
+ )} +
+ ); +} diff --git a/frontend/src/common/components/map/DeckGLOverlay.tsx b/frontend/src/common/components/map/DeckGLOverlay.tsx new file mode 100644 index 0000000..72f86eb --- /dev/null +++ b/frontend/src/common/components/map/DeckGLOverlay.tsx @@ -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[]; +} + +/** deck.gl 레이어를 MapLibre에 interleaved 방식으로 통합하는 공통 컴포넌트. + * 반드시 자식으로 사용해야 한다. */ +export function DeckGLOverlay({ layers }: DeckGLOverlayProps) { + const overlay = useControl(() => new MapboxOverlay({ interleaved: true })); + overlay.setProps({ layers }); + return null; +} diff --git a/frontend/src/common/components/map/FlyToController.tsx b/frontend/src/common/components/map/FlyToController.tsx new file mode 100644 index 0000000..3c1bd78 --- /dev/null +++ b/frontend/src/common/components/map/FlyToController.tsx @@ -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를 실행한다. + * 반드시 자식으로 사용해야 한다. */ +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; +} diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 02619bf..98fa5d1 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -1,6 +1,5 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react'; -import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'; -import { MapboxOverlay } from '@deck.gl/mapbox'; +import { Map, Marker, Popup, Source, Layer, useMap } from '@vis.gl/react-maplibre'; import { ScatterplotLayer, PathLayer, @@ -28,6 +27,8 @@ import { useMeasureTool } from '@common/hooks/useMeasureTool'; import { hexToRgba } from './mapUtils'; import { S57EncOverlay } from './S57EncOverlay'; import { SrOverlay } from './SrOverlay'; +import { DeckGLOverlay } from './DeckGLOverlay'; +import { FlyToController } from './FlyToController'; import { useMapStore } from '@common/store/mapStore'; import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; @@ -126,6 +127,7 @@ interface MapViewProps { dispersionResult?: DispersionResult | null; dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>; boomLines?: BoomLine[]; + showBoomLines?: boolean; isDrawingBoom?: boolean; drawingPoints?: BoomLineCoord[]; layerOpacity?: number; @@ -165,31 +167,7 @@ interface MapViewProps { showOverlays?: boolean; } -// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved) -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function DeckGLOverlay({ layers }: { layers: any[] }) { - const overlay = useControl(() => new MapboxOverlay({ interleaved: true })); - overlay.setProps({ layers }); - return null; -} - -// flyTo 트리거 컴포넌트 (Map 내부에서 useMap() 사용) -function FlyToController({ - flyToTarget, -}: { - flyToTarget?: { lng: number; lat: number; zoom?: number } | null; -}) { - const { current: map } = useMap(); - useEffect(() => { - if (!map || !flyToTarget) return; - map.flyTo({ - center: [flyToTarget.lng, flyToTarget.lat], - zoom: flyToTarget.zoom ?? 10, - duration: 1200, - }); - }, [flyToTarget, map]); - return null; -} +// DeckGLOverlay, FlyToController → @common/components/map/DeckGLOverlay, FlyToController 에서 import // fitBounds 트리거 컴포넌트 (Map 내부에서 useMap() 사용) function FitBoundsController({ @@ -341,6 +319,7 @@ export function MapView({ dispersionResult = null, dispersionHeatmap = [], boomLines = [], + showBoomLines = true, isDrawingBoom = false, drawingPoints = [], layerOpacity = 50, @@ -587,7 +566,7 @@ export function MapView({ } // --- 오일펜스 라인 (PathLayer) --- - if (boomLines.length > 0) { + if (showBoomLines && boomLines.length > 0) { result.push( new PathLayer({ id: 'boom-lines', @@ -1243,6 +1222,7 @@ export function MapView({ currentTime, selectedModels, boomLines, + showBoomLines, isDrawingBoom, drawingPoints, dispersionResult, @@ -1295,7 +1275,7 @@ export function MapView({ {/* 사고 지점 변경 시 지도 이동 */} {/* 외부에서 flyTo 트리거 */} - + {/* 예측 완료 시 궤적 전체 범위로 fitBounds */} diff --git a/frontend/src/common/styles/components.css b/frontend/src/common/styles/components.css index 15aabeb..3d2a173 100644 --- a/frontend/src/common/styles/components.css +++ b/frontend/src/common/styles/components.css @@ -903,10 +903,10 @@ background: var(--bg-base); border: 1px solid var(--stroke-default); border-radius: 4px; - color: var(--color-accent); + color: var(--color-default); font-family: var(--font-mono); font-size: 0.75rem; - font-weight: 600; + font-weight: 400; text-align: right; outline: none; transition: border-color 0.2s; diff --git a/frontend/src/pages/design/LayoutContent.tsx b/frontend/src/pages/design/LayoutContent.tsx index 0a180f1..1282eeb 100644 --- a/frontend/src/pages/design/LayoutContent.tsx +++ b/frontend/src/pages/design/LayoutContent.tsx @@ -1,1361 +1,297 @@ -// LayoutContent.tsx — WING-OPS Layout 카탈로그 (KT 디자인시스템 시각화 영감) +// LayoutContent.tsx — WING-OPS Layout 카탈로그 (신한 UX 벤치마킹) import type { DesignTheme } from './designTheme'; -// ---------- 데이터 타입 ---------- - -interface Breakpoint { - name: string; - krdsName: string; - prefix: string; - minWidth: number; - maxWidth: number | null; - columns: number; - inUse: boolean; - note: string; -} - -interface DeviceSpec { - device: string; - prefix: string; - width: string; - columns: number; - gutter: string; - margin: string; - supported: boolean; -} - -interface SpacingToken { - className: string; - rem: string; - px: number; - usage: string; -} - -interface ZLayer { - name: string; - zIndex: number; - description: string; - color: string; -} - -interface ShellClass { - className: string; - role: string; - styles: string; -} - -interface GridRule { - name: string; - krds: string; - wingOps: string; - note: string; -} - -interface SubPageMapping { - krdsRegion: string; - wingOpsComponent: string; - implementation: string; - color: string; -} - -interface MultiplierAnnotation { - position: 'top' | 'left' | 'right' | 'bottom'; - multiplier: string; - px: number; -} - -// ---------- Breakpoints ---------- - -const BREAKPOINTS: Breakpoint[] = [ - { - name: '-', - krdsName: 'xsmall', - prefix: '-', - minWidth: 0, - maxWidth: 360, - columns: 4, - inUse: false, - note: '미지원', - }, - { - name: '-', - krdsName: 'small', - prefix: '-', - minWidth: 360, - maxWidth: 768, - columns: 4, - inUse: false, - note: '미지원 (모바일)', - }, - { - name: 'md', - krdsName: 'medium', - prefix: 'md:', - minWidth: 768, - maxWidth: 1024, - columns: 8, - inUse: false, - note: '미지원 (태블릿)', - }, - { - name: 'lg', - krdsName: 'large', - prefix: 'lg:', - minWidth: 1024, - maxWidth: 1280, - columns: 12, - inUse: false, - note: '미지원', - }, - { - name: 'xl', - krdsName: 'xlarge', - prefix: 'xl:', - minWidth: 1280, - maxWidth: 1440, - columns: 12, - inUse: true, - note: 'WING-OPS 최소 지원', - }, - { - name: '2xl', - krdsName: 'xxlarge', - prefix: '2xl:', - minWidth: 1440, - maxWidth: null, - columns: 12, - inUse: true, - note: 'WING-OPS 주 사용 해상도', - }, -]; - -// 타임라인 정규화 (0 ~ 2000px 범위) -const TIMELINE_MAX = 2000; -const toPercent = (px: number) => Math.min(100, (px / TIMELINE_MAX) * 100); -const TIMELINE_MARKERS = [360, 768, 1024, 1280, 1440, 1920]; - -// ---------- Device Specs (xl/2xl만 활성) ---------- - -const DEVICE_SPECS: DeviceSpec[] = [ - { - device: 'Desktop xl', - prefix: 'xl', - width: '1280px – 1439px', - columns: 12, - gutter: '24px (gap-6)', - margin: '24px (px-6)', - supported: true, - }, - { - device: 'Desktop 2xl', - prefix: '2xl', - width: '≥ 1440px', - columns: 12, - gutter: '24px (gap-6)', - margin: '32px (px-8)', - supported: true, - }, -]; - -// ---------- Spacing Scale ---------- - -const SPACING_TOKENS: SpacingToken[] = [ - { className: '0.5', rem: '0.125rem', px: 2, usage: '미세 간격' }, - { className: '1', rem: '0.25rem', px: 4, usage: '최소 간격 (gap-1)' }, - { className: '1.5', rem: '0.375rem', px: 6, usage: '컴팩트 간격 (gap-1.5)' }, - { className: '2', rem: '0.5rem', px: 8, usage: '기본 간격 (gap-2, p-2)' }, - { className: '2.5', rem: '0.625rem', px: 10, usage: '중간 간격' }, - { className: '3', rem: '0.75rem', px: 12, usage: '표준 간격 (gap-3, p-3)' }, - { className: '4', rem: '1rem', px: 16, usage: '넓은 간격 (p-4, gap-4)' }, - { className: '5', rem: '1.25rem', px: 20, usage: '패널 패딩 (px-5, py-5)' }, - { className: '6', rem: '1.5rem', px: 24, usage: '섹션 간격 (gap-6, p-6)' }, - { className: '8', rem: '2rem', px: 32, usage: '큰 간격 (px-8, gap-8)' }, - { className: '16', rem: '4rem', px: 64, usage: '최대 간격' }, -]; - -const SPACING_MAX_PX = Math.max(...SPACING_TOKENS.map((s) => s.px)); - -// ---------- Z-Index Layers (논리적 계층 — 디자인 시스템 진실 소스) ---------- - -const Z_LAYERS: ZLayer[] = [ - { name: 'Tooltip', zIndex: 60, description: '툴팁, 드롭다운 메뉴', color: '#a855f7' }, - { name: 'Popup', zIndex: 50, description: '팝업, 지도 오버레이', color: '#f97316' }, - { name: 'Modal', zIndex: 40, description: '모달 다이얼로그, 백드롭', color: '#ef4444' }, - { name: 'TopBar', zIndex: 30, description: '상단 네비게이션 바', color: '#3b82f6' }, - { name: 'Sidebar', zIndex: 20, description: '사이드바, 패널', color: '#06b6d4' }, - { name: 'Content', zIndex: 10, description: '메인 콘텐츠 영역', color: '#22c55e' }, - { name: 'Base', zIndex: 0, description: '기본 레이어, 배경', color: '#9ba3b8' }, -]; - -// ---------- App Shell Classes ---------- - -const SHELL_CLASSES: ShellClass[] = [ - { - className: '.wing-panel', - role: '탭 콘텐츠 패널', - styles: 'flex flex-col h-full overflow-hidden', - }, - { - className: '.wing-panel-scroll', - role: '패널 내 스크롤 영역', - styles: 'flex-1 overflow-y-auto', - }, - { - className: '.wing-header-bar', - role: '패널 헤더', - styles: 'flex items-center justify-between shrink-0 px-5 border-b', - }, - { className: '.wing-sidebar', role: '사이드바', styles: 'flex flex-col border-r border-stroke' }, -]; - -// ---------- KRDS Grid Rules ---------- - -const GRID_RULES: GridRule[] = [ - { - name: 'Grid base', - krds: '8pt grid', - wingOps: 'Tailwind 4px base + 8pt 권장', - note: 'gap-2=8px, p-4=16px, gap-6=24px 등 8pt 배수 우선', - }, - { - name: 'Max container', - krds: '1200px fixed', - wingOps: 'max-w-[1440px]', - note: '데스크톱 전용 풀스크린 앱 특성 반영', - }, - { - name: 'Screen margin', - krds: '24px (≥medium)', - wingOps: 'px-5 ~ px-8 (20–32px)', - note: 'KRDS medium+ 기준 충족', - }, - { - name: 'Column gutter', - krds: '16–24px (medium+)', - wingOps: 'gap-2 ~ gap-6 (8–24px)', - note: 'KRDS 권장 범위 내 사용', - }, - { - name: 'Sub-page 구조', - krds: 'Header → Left → Main → Right → Footer', - wingOps: 'TopBar → Sidebar → Content', - note: 'Footer 없음 — 풀스크린 앱', - }, -]; - -// ---------- KRDS Sub-page Mappings ---------- - -const SUB_PAGE_MAPPINGS: SubPageMapping[] = [ - { - krdsRegion: 'Header', - wingOpsComponent: 'TopBar', - implementation: 'h-[52px] / shrink-0 / z-30', - color: '#3b82f6', - }, - { - krdsRegion: 'Sub Navigation', - wingOpsComponent: 'SubMenuBar', - implementation: 'shrink-0 / 조건부 렌더', - color: '#06b6d4', - }, - { - krdsRegion: 'Left Menu', - wingOpsComponent: 'Sidebar', - implementation: '가변 너비 / flex-col / border-r', - color: '#a855f7', - }, - { - krdsRegion: 'Main Contents', - wingOpsComponent: 'Content', - implementation: 'flex-1 / overflow-y-auto', - color: '#22c55e', - }, - { - krdsRegion: 'Right Menu', - wingOpsComponent: 'Right Panel', - implementation: '조건부 / 탭 콘텐츠 내부', - color: '#f97316', - }, - { - krdsRegion: 'Footer', - wingOpsComponent: '없음', - implementation: '풀스크린 앱 — 미사용', - color: '#9ba3b8', - }, -]; - -// ---------- Multiplier 어노테이션 (4pt Grid 데모용) ---------- - -const CARD_ANNOTATIONS: MultiplierAnnotation[] = [ - { position: 'top', multiplier: 'x4', px: 16 }, - { position: 'left', multiplier: 'x5', px: 20 }, - { position: 'right', multiplier: 'x5', px: 20 }, - { position: 'bottom', multiplier: 'x4', px: 16 }, -]; - // ---------- Props ---------- interface LayoutContentProps { theme: DesignTheme; } +// ---------- 데이터 타입 ---------- + +interface WebResolution { + label: string; + gridLabel: string; + gutter: string; + margin: string; +} + +const WEB_RESOLUTIONS: WebResolution[] = [ + { label: 'WEB - 1280', gridLabel: '12 Grid (1280)', gutter: '24px', margin: '24px (px-6)' }, + { label: 'WEB - 1440', gridLabel: '12 Grid (1440)', gutter: '24px', margin: '32px (px-8)' }, + { label: 'WEB - 1600', gridLabel: '12 Grid (1600)', gutter: '24px', margin: '32px (px-8)' }, + { label: 'WEB - 1920', gridLabel: '12 Grid (1920)', gutter: '24px', margin: '32px (px-8)' }, +]; + // ---------- 컴포넌트 ---------- export const LayoutContent = ({ theme }: LayoutContentProps) => { const t = theme; const isDark = t.mode === 'dark'; - - // 시각화 색상 - const accentTint = isDark ? 'rgba(76,215,246,0.18)' : 'rgba(6,182,212,0.14)'; - const accentTintLight = isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)'; - const dimBg = isDark ? 'rgba(140,144,159,0.12)' : 'rgba(148,163,184,0.10)'; - const dimText = isDark ? '#8c909f' : '#94a3b8'; - const cardSurface = isDark ? '#1b1f2c' : '#ffffff'; + const previewBg = isDark ? '#1a1f30' : '#f1f5f9'; + const innerBg = isDark ? '#0a0e1a' : '#ffffff'; + const colCyan = 'rgba(6,182,212,0.18)'; + const gutterCyan = 'rgba(6,182,212,0.45)'; + const marginCyan = 'rgba(6,182,212,0.5)'; return (
- {/* ── 섹션 1: 헤더 + 개요 ── */} -
-
-

- Layout -

-

- WING-OPS는 데스크톱 전용 고정 뷰포트 애플리케이션입니다. 화면 전체를 채우는 고정 - 레이아웃(100vh)으로, flex 기반의 패널 구조를 사용합니다. KRDS 가이드라인을 기반으로 - xlarge / xxlarge 구간에 최적화되어 있습니다. -

-
-
- {[ - { label: 'Viewport', value: '100vh fixed', desc: 'overflow: hidden' }, - { label: 'Layout', value: 'flex 기반', desc: 'grid 보조' }, - { label: 'Min Width', value: '1280px', desc: 'xl: 이상' }, - ].map((item) => ( -
- - {item.label} - - - {item.value} - - - {item.desc} - -
- ))} -
-
- - {/* ── 섹션 2: Breakpoint 타임라인 ── */} + {/* ── 섹션 1: Layout grid ── */}

- Breakpoint + Layout grid

- 화면 크기에 따라 반응형 레이아웃을 사용하여 환경에 최적화된 구조로 표시됩니다. WING-OPS - 사용 구간(xl, 2xl)은 cyan으로 강조되어 있습니다. -

-
- - {/* 타임라인 다이어그램 */} -
- {/* 가로축 마커 (분기점 라벨) */} -
- {TIMELINE_MARKERS.map((px) => ( -
- - {px} - -
- ))} -
- - {/* 분기점 점선 + tier 막대 컨테이너 */} -
- {/* 수직 점선 */} - {TIMELINE_MARKERS.map((px) => ( -
- ))} - - {/* tier 막대들 */} -
- {BREAKPOINTS.map((bp) => { - const startPct = toPercent(bp.minWidth); - const endPct = bp.maxWidth ? toPercent(bp.maxWidth) : 100; - const widthPct = endPct - startPct; - const barColor = bp.inUse ? accentTint : dimBg; - const labelColor = bp.inUse ? t.textAccent : dimText; - - return ( -
- {/* 컬럼 그리드 미니 */} -
- {Array.from({ length: bp.columns }).map((_, i) => ( -
- ))} -
- - {bp.krdsName} - - {bp.inUse && ( - - in use - - )} - - {bp.columns} cols - -
- ); - })} -
-
- - {/* 범례 */} -
-
-
- - WING-OPS 사용 중 - -
-
-
- - 미지원 (1280px 미만) - -
-
-
-
- - {/* ── 섹션 3: Grid 시각화 카드 ── */} -
-
-

- Grid -

-

- 컬럼, 마진, 거터로 구성된 그리드 시스템입니다. 데스크톱 전용으로 xl / 2xl 두 구간만 - 지원합니다. -

-
- -
- {DEVICE_SPECS.map((spec) => ( -
- {/* 카드 헤더 */} -
-
- - Breakpoint - - - {spec.prefix} - -
-
- - Width - - - {spec.width} - -
-
- - {/* 시각화 영역 */} -
- {/* 컬럼 위 어노테이션 (margin / gutter) */} -
- {Array.from({ length: spec.columns + 1 }).map((_, idx) => { - const isEdge = idx === 0 || idx === spec.columns; - return ( - - {isEdge ? (spec.prefix === '2xl' ? '32' : '24') : '24'} - - ); - })} -
- - {/* 컬럼 막대 시각화 */} -
- {Array.from({ length: spec.columns }).map((_, i) => ( -
- ))} -
- - {/* 가짜 TopBar mockup */} -
- - WING-OPS - -
- {['예측', 'HNS', '구조', '관리'].map((label) => ( - - {label} - - ))} -
-
-
- - {/* 카드 푸터 — Grid 정보 */} -
- {[ - { label: 'Columns', value: `${spec.columns}` }, - { label: 'Gutter', value: spec.gutter }, - { label: 'Margin', value: spec.margin }, - ].map((info) => ( -
- - {info.label} - - - {info.value} - -
- ))} -
-
- ))} -
- - {/* 미지원 안내 */} -
- - ⚠ - - - 1280px 미만 미지원 — Mobile / Tablet - 구간(xs / s / md / lg)은 데스크톱 전용 운영 정책에 따라 지원하지 않습니다. - -
-
- - {/* ── 섹션 4: App Shell + KRDS Region 매핑 ── */} -
-
-

- App Shell -

-

- WING-OPS 애플리케이션의 기본 레이아웃 구조와 KRDS Sub-page 영역 매핑입니다. + 그리드 시스템은 columns, gutters, margins 세 가지 요소로 구성됩니다.

{/* 다이어그램 */}
- {/* TopBar */} -
-
- - TopBar - - - KRDS · Header - -
- - h-[52px] / shrink-0 + {/* Gutters 어노테이션 상단 */} +
+ + ⌐ Gutters ¬
- {/* SubMenuBar */} -
-
- - SubMenuBar - - - KRDS · Sub Navigation - + {/* 그리드 시각화 */} +
+ {/* Margin 좌 */} +
+ {/* 내부 컬럼 4개 그룹 */} +
+ {[0, 1, 2, 3].map((g) => ( +
+ ))}
- - shrink-0 / 조건부 - + {/* Margin 우 */} +
- {/* Content Area */} -
- {/* Sidebar */} -
- - Sidebar - - - KRDS · Left Menu - + {/* 어노테이션 하단 레이블 */} +
+
+
- flex-col / border-r + Margin
- - {/* Main Content */} -
- - Content - - - KRDS · Main Contents - +
+
- flex-1 / overflow-y-auto + Columns
- - {/* Right Panel (조건부) */} -
- - Right Panel - - - KRDS · Right Menu - +
+
- 조건부 렌더 + Gutters
+
+
- {/* Footer 안내 */} -
- - KRDS · Footer - - - 풀스크린 앱 — 미사용 - + {/* ── 섹션 2: 컬럼 ── */} +
+
+

+ 컬럼 (Column) +

+
+

+ 컬럼 단위를 사용해 콘텐츠의 크기를 조정합니다. +

+

+ 콘텐츠 영역을 동일 너비의 균일한 컬럼으로 나눠 1개 이상의 컬럼을 조합해 콘텐츠의 + 크기를 결정합니다. +

+

+ WING-OPS는 데스크톱 전용 앱으로 12컬럼 그리드를 사용하며, 거터는 24px(gap-6)입니다. +

- {/* 매핑 표 (다이어그램 부속) */} -
-
- {(['KRDS Region', 'WING-OPS Component', 'Implementation'] as const).map((col) => ( -
- + {WEB_RESOLUTIONS.map((res) => ( +
+
+

- {col} - + {res.label} +

+

+ 해상도 {res.label.replace('WEB - ', '')}은 12컬럼 {res.gutter}거터 사용을 + 권장합니다. +

- ))} -
- {SUB_PAGE_MAPPINGS.map((row, idx) => ( -
-
-
- - {row.krdsRegion} - -
-
- - {row.wingOpsComponent} - -
-
+ + {/* 프리뷰 박스 */} +
- {row.implementation} + {res.gridLabel} +
+
+ {Array.from({ length: 12 }).map((_, colIdx) => ( +
+ {/* 이미지 영역 */} +
+ {/* 텍스트 라인 3개 */} +
+
+
+
+ ))} +
+
))}
- {/* ── 섹션 5: Spacing Scale 막대 시각화 ── */} + {/* ── 섹션 3: 거터 ── */}

- Spacing + 거터 (Gutters)

-

- UI 요소 간의 간격과 여백을 정의하여 콘텐츠의 위계와 가독성을 조율합니다. Tailwind - spacing 토큰과 직결되며, 막대 길이는 실제 px 비율입니다. -

+
+

+ 거터는 컬럼간의 사이 공간으로 콘텐츠 간의 간격입니다. 거터의 너비는 고정값으로 + 정의됩니다. +

+

+ 상황에 따라 화면의 너비에 비례하는 거터값을 사용할 수 있습니다. +

+

+ 거터값은 4의 배수를 기본으로 합니다. WING-OPS 기본 거터: 24px (gap-6) +

+
+ {/* 거터 다이어그램 */}
- {SPACING_TOKENS.map((token) => { - const widthPct = (token.px / SPACING_MAX_PX) * 100; - return ( -
- {/* 토큰 배지 */} - - {token.className} - - {/* px 값 */} - - {token.px}px - - {/* 막대 시각화 */} -
+ {/* Gutters 어노테이션 */} +
+ + [ Gutters ] + +
+ + {/* 3컬럼 거터 하이라이트 */} +
+
+ {[0, 1, 2].map((colIdx) => ( +
+ {/* 컬럼 본체 */}
-
- {/* 사용처 */} - - {token.usage} - -
- ); - })} -
-
- - {/* ── 섹션 6: 4pt Grid 원칙 + Multiplier ── */} -
-
-

- 4pt Grid -

-

- 모든 여백과 간격을 4point 단위로 설정해 규칙성을 확보합니다. 컴팩트한 컴포넌트의 경우, - 2의 배수 단위를 제한적으로 사용합니다. -

-
- -
- {/* Part A — 원칙 카드 */} -
- - Principles - -
- {[ - { - label: '기본', - text: '4px base — 모든 spacing은 4의 배수', - example: '4 / 8 / 12 / 16 / 20 / 24 / 32', - }, - { - label: '권장', - text: '8pt 배수 우선 — gap-2, p-4, gap-6', - example: '8 / 16 / 24 / 32 / 64', - }, - { - label: '예외', - text: '컴팩트 컴포넌트만 4px / 12px 사용', - example: 'gap-1, gap-3, p-1.5', - }, - ].map((item) => ( -
-
- - {item.label} - - - {item.text} - -
- - {item.example} - + {/* 거터 (마지막 컬럼 제외) */} + {colIdx < 2 && ( +
+ )}
))}
- {/* Part B — Multiplier 어노테이션 시각화 */} -
- - Multiplier Annotation + {/* 거터 레이블 */} +
+
+ + Gutter — 24px (gap-6) -
- {/* 어노테이션 — top */} -
- - {CARD_ANNOTATIONS[0].multiplier}={CARD_ANNOTATIONS[0].px} - -
- {/* 어노테이션 — left */} -
- - {CARD_ANNOTATIONS[1].multiplier}={CARD_ANNOTATIONS[1].px} - -
- {/* 어노테이션 — right */} -
- - {CARD_ANNOTATIONS[2].multiplier}={CARD_ANNOTATIONS[2].px} - -
- {/* 어노테이션 — bottom */} -
- - {CARD_ANNOTATIONS[3].multiplier}={CARD_ANNOTATIONS[3].px} - -
- - {/* 실제 카드 mockup */} -
- - 카드 타이틀 - - - 본문 내용 예시 - -
- - Action - -
-
-
-

- 어노테이션은 spacing 값의 4px 배수 multiplier를 표시합니다. -

-
-
-
- - {/* ── 섹션 7: Z-Index Layers ── */} -
-
-
-

- Z-Index Layers -

- - 디자인 시스템 진실 소스 - -
-

- UI 요소의 레이어 스택 순서입니다. 높은 z-index가 위에 표시되며, 이 값은 디자인 시스템의 - 이상적 설계 값으로 실제 코드는 이 값에 맞춰 정정되어야 합니다. -

-
- -
- {Z_LAYERS.map((layer, idx) => ( -
-
- - {layer.zIndex} - -
-
-
- - {layer.name} - - - {layer.description} - -
-
- ))} -
-
- - {/* ── 섹션 8: CSS 클래스 + KRDS Grid Rules 통합 ── */} -
-
-

- Reference -

-

- App Shell CSS 클래스와 KRDS Grid 규칙 비교 — 코드 작성 시 참조용입니다. -

-
- -
- {/* CSS 클래스 */} -
-
- - CSS Classes - -
- {SHELL_CLASSES.map((cls, idx) => ( -
-
- - {cls.className} - - - {cls.role} - -
- - {cls.styles} - -
- ))} -
- - {/* KRDS Grid Rules */} -
-
- - KRDS vs WING-OPS - -
- {GRID_RULES.map((rule, idx) => ( -
- - {rule.name} - -
- - KRDS: - - - {rule.krds} - -
-
- - WING: - - - {rule.wingOps} - -
- - {rule.note} - -
- ))}
); }; - -export default LayoutContent; diff --git a/frontend/src/tabs/admin/components/DeidentifyPanel.tsx b/frontend/src/tabs/admin/components/DeidentifyPanel.tsx index e5a579f..561727b 100644 --- a/frontend/src/tabs/admin/components/DeidentifyPanel.tsx +++ b/frontend/src/tabs/admin/components/DeidentifyPanel.tsx @@ -36,14 +36,7 @@ interface DeidentifyTask { type SourceType = 'db' | 'file' | 'api'; type ProcessMode = 'immediate' | 'scheduled' | 'oneshot'; type RepeatType = 'daily' | 'weekly' | 'monthly'; -type DeidentifyTechnique = - | '마스킹' - | '삭제' - | '범주화' - | '암호화' - | '샘플링' - | '가명처리' - | '유지'; +type DeidentifyTechnique = '마스킹' | '삭제' | '범주화' | '암호화' | '샘플링' | '가명처리' | '유지'; interface FieldConfig { name: string; @@ -97,24 +90,102 @@ interface WizardState { // ─── Mock 데이터 ──────────────────────────────────────────── const MOCK_TASKS: DeidentifyTask[] = [ - { id: '001', name: 'customer_2024', target: '선박/운항 - 선장·선원 성명', status: '완료', startTime: '2026-04-10 14:30', progress: 100, createdBy: '관리자' }, - { id: '002', name: 'transaction_04', target: '사고 현장 - 현장사진, 영상내 인물', status: '진행중', startTime: '2026-04-10 14:15', progress: 82, createdBy: '김담당' }, - { id: '003', name: 'employee_info', target: '인사정보 - 계정, 로그인 정보', status: '대기', startTime: '2026-04-10 22:00', progress: 0, createdBy: '이담당' }, - { id: '004', name: 'vendor_data', target: 'HNS 대응 - 화학물질 취급자, 방제업체 연락처', status: '오류', startTime: '2026-04-09 13:45', progress: 45, createdBy: '관리자' }, - { id: '005', name: 'partner_contacts', target: '시스템 운영 - 관리자, 운영자 접속로그', status: '완료', startTime: '2026-04-08 09:00', progress: 100, createdBy: '박담당' }, + { + id: '001', + name: 'customer_2024', + target: '선박/운항 - 선장·선원 성명', + status: '완료', + startTime: '2026-04-10 14:30', + progress: 100, + createdBy: '관리자', + }, + { + id: '002', + name: 'transaction_04', + target: '사고 현장 - 현장사진, 영상내 인물', + status: '진행중', + startTime: '2026-04-10 14:15', + progress: 82, + createdBy: '김담당', + }, + { + id: '003', + name: 'employee_info', + target: '인사정보 - 계정, 로그인 정보', + status: '대기', + startTime: '2026-04-10 22:00', + progress: 0, + createdBy: '이담당', + }, + { + id: '004', + name: 'vendor_data', + target: 'HNS 대응 - 화학물질 취급자, 방제업체 연락처', + status: '오류', + startTime: '2026-04-09 13:45', + progress: 45, + createdBy: '관리자', + }, + { + id: '005', + name: 'partner_contacts', + target: '시스템 운영 - 관리자, 운영자 접속로그', + status: '완료', + startTime: '2026-04-08 09:00', + progress: 100, + createdBy: '박담당', + }, ]; const DEFAULT_FIELDS: FieldConfig[] = [ { name: '고객ID', dataType: '문자열', technique: '삭제', configValue: '-', selected: true }, - { name: '이름', dataType: '문자열', technique: '마스킹', configValue: '*로 치환', selected: true }, - { name: '휴대폰', dataType: '문자열', technique: '마스킹', configValue: '010-****-****', selected: true }, - { name: '주소', dataType: '문자열', technique: '범주화', configValue: '시/도만 표시', selected: true }, - { name: '이메일', dataType: '문자열', technique: '가명처리', configValue: '키: random_001', selected: true }, - { name: '생년월일', dataType: '날짜', technique: '범주화', configValue: '연도만 표시', selected: true }, + { + name: '이름', + dataType: '문자열', + technique: '마스킹', + configValue: '*로 치환', + selected: true, + }, + { + name: '휴대폰', + dataType: '문자열', + technique: '마스킹', + configValue: '010-****-****', + selected: true, + }, + { + name: '주소', + dataType: '문자열', + technique: '범주화', + configValue: '시/도만 표시', + selected: true, + }, + { + name: '이메일', + dataType: '문자열', + technique: '가명처리', + configValue: '키: random_001', + selected: true, + }, + { + name: '생년월일', + dataType: '날짜', + technique: '범주화', + configValue: '연도만 표시', + selected: true, + }, { name: '회사', dataType: '문자열', technique: '유지', configValue: '변경 없음', selected: true }, ]; -const TECHNIQUES: DeidentifyTechnique[] = ['마스킹', '삭제', '범주화', '암호화', '샘플링', '가명처리', '유지']; +const TECHNIQUES: DeidentifyTechnique[] = [ + '마스킹', + '삭제', + '범주화', + '암호화', + '샘플링', + '가명처리', + '유지', +]; const HOURS = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`); @@ -124,23 +195,161 @@ const TEMPLATES = ['기본 개인정보', '금융데이터', '의료데이터']; const MOCK_AUDIT_LOGS: Record = { '001': [ - { id: 'LOG_20260410_001', time: '2026-04-10 14:30:45', operator: '김철수', operatorId: 'user_12345', action: '처리완료', targetData: 'customer_2024', result: '성공 (100%)', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 15240, rulesApplied: '마스킹 3, 범주화 2, 삭제 2', processedCount: 15240, errorCount: 0 } }, - { id: 'LOG_20260410_002', time: '2026-04-10 14:15:10', operator: '김철수', operatorId: 'user_12345', action: '처리시작', targetData: 'customer_2024', result: '성공', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 15240, rulesApplied: '마스킹 3, 범주화 2, 삭제 2', processedCount: 0, errorCount: 0 } }, - { id: 'LOG_20260410_003', time: '2026-04-10 14:10:30', operator: '김철수', operatorId: 'user_12345', action: '규칙설정', targetData: 'customer_2024', result: '성공', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 15240, rulesApplied: '7개 규칙 적용', processedCount: 0, errorCount: 0 } }, + { + id: 'LOG_20260410_001', + time: '2026-04-10 14:30:45', + operator: '김철수', + operatorId: 'user_12345', + action: '처리완료', + targetData: 'customer_2024', + result: '성공 (100%)', + resultType: '성공', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { + dataCount: 15240, + rulesApplied: '마스킹 3, 범주화 2, 삭제 2', + processedCount: 15240, + errorCount: 0, + }, + }, + { + id: 'LOG_20260410_002', + time: '2026-04-10 14:15:10', + operator: '김철수', + operatorId: 'user_12345', + action: '처리시작', + targetData: 'customer_2024', + result: '성공', + resultType: '성공', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { + dataCount: 15240, + rulesApplied: '마스킹 3, 범주화 2, 삭제 2', + processedCount: 0, + errorCount: 0, + }, + }, + { + id: 'LOG_20260410_003', + time: '2026-04-10 14:10:30', + operator: '김철수', + operatorId: 'user_12345', + action: '규칙설정', + targetData: 'customer_2024', + result: '성공', + resultType: '성공', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { dataCount: 15240, rulesApplied: '7개 규칙 적용', processedCount: 0, errorCount: 0 }, + }, ], '002': [ - { id: 'LOG_20260410_004', time: '2026-04-10 14:15:22', operator: '이영희', operatorId: 'user_23456', action: '처리시작', targetData: 'transaction_04', result: '진행중 (82%)', resultType: '진행중', ip: '192.168.1.101', browser: 'Firefox 124.0', detail: { dataCount: 8920, rulesApplied: '마스킹 2, 암호화 1, 삭제 3', processedCount: 7314, errorCount: 0 } }, + { + id: 'LOG_20260410_004', + time: '2026-04-10 14:15:22', + operator: '이영희', + operatorId: 'user_23456', + action: '처리시작', + targetData: 'transaction_04', + result: '진행중 (82%)', + resultType: '진행중', + ip: '192.168.1.101', + browser: 'Firefox 124.0', + detail: { + dataCount: 8920, + rulesApplied: '마스킹 2, 암호화 1, 삭제 3', + processedCount: 7314, + errorCount: 0, + }, + }, ], '003': [ - { id: 'LOG_20260410_005', time: '2026-04-10 13:45:30', operator: '박민준', operatorId: 'user_34567', action: '규칙수정', targetData: 'employee_info', result: '성공', resultType: '성공', ip: '192.168.1.102', browser: 'Chrome 123.0', detail: { dataCount: 3200, rulesApplied: '마스킹 4, 가명처리 1', processedCount: 0, errorCount: 0 } }, + { + id: 'LOG_20260410_005', + time: '2026-04-10 13:45:30', + operator: '박민준', + operatorId: 'user_34567', + action: '규칙수정', + targetData: 'employee_info', + result: '성공', + resultType: '성공', + ip: '192.168.1.102', + browser: 'Chrome 123.0', + detail: { + dataCount: 3200, + rulesApplied: '마스킹 4, 가명처리 1', + processedCount: 0, + errorCount: 0, + }, + }, ], '004': [ - { id: 'LOG_20260409_001', time: '2026-04-09 13:45:30', operator: '관리자', operatorId: 'user_admin', action: '처리오류', targetData: 'vendor_data', result: '오류 (45%)', resultType: '실패', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 5100, rulesApplied: '마스킹 2, 범주화 1, 삭제 1', processedCount: 2295, errorCount: 12 } }, - { id: 'LOG_20260409_002', time: '2026-04-09 13:40:15', operator: '김철수', operatorId: 'user_12345', action: '규칙조회', targetData: 'vendor_data', result: '성공', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 5100, rulesApplied: '4개 규칙', processedCount: 0, errorCount: 0 } }, - { id: 'LOG_20260409_003', time: '2026-04-09 09:25:00', operator: '이영희', operatorId: 'user_23456', action: '삭제시도', targetData: 'vendor_data', result: '거부 (권한부족)', resultType: '거부', ip: '192.168.1.101', browser: 'Firefox 124.0', detail: { dataCount: 5100, rulesApplied: '-', processedCount: 0, errorCount: 0 } }, + { + id: 'LOG_20260409_001', + time: '2026-04-09 13:45:30', + operator: '관리자', + operatorId: 'user_admin', + action: '처리오류', + targetData: 'vendor_data', + result: '오류 (45%)', + resultType: '실패', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { + dataCount: 5100, + rulesApplied: '마스킹 2, 범주화 1, 삭제 1', + processedCount: 2295, + errorCount: 12, + }, + }, + { + id: 'LOG_20260409_002', + time: '2026-04-09 13:40:15', + operator: '김철수', + operatorId: 'user_12345', + action: '규칙조회', + targetData: 'vendor_data', + result: '성공', + resultType: '성공', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { dataCount: 5100, rulesApplied: '4개 규칙', processedCount: 0, errorCount: 0 }, + }, + { + id: 'LOG_20260409_003', + time: '2026-04-09 09:25:00', + operator: '이영희', + operatorId: 'user_23456', + action: '삭제시도', + targetData: 'vendor_data', + result: '거부 (권한부족)', + resultType: '거부', + ip: '192.168.1.101', + browser: 'Firefox 124.0', + detail: { dataCount: 5100, rulesApplied: '-', processedCount: 0, errorCount: 0 }, + }, ], '005': [ - { id: 'LOG_20260408_001', time: '2026-04-08 09:15:00', operator: '박담당', operatorId: 'user_45678', action: '처리완료', targetData: 'partner_contacts', result: '성공 (100%)', resultType: '성공', ip: '192.168.1.103', browser: 'Edge 122.0', detail: { dataCount: 1850, rulesApplied: '마스킹 2, 유지 3', processedCount: 1850, errorCount: 0 } }, + { + id: 'LOG_20260408_001', + time: '2026-04-08 09:15:00', + operator: '박담당', + operatorId: 'user_45678', + action: '처리완료', + targetData: 'partner_contacts', + result: '성공 (100%)', + resultType: '성공', + ip: '192.168.1.103', + browser: 'Edge 122.0', + detail: { + dataCount: 1850, + rulesApplied: '마스킹 2, 유지 3', + processedCount: 1850, + errorCount: 0, + }, + }, ], }; @@ -154,10 +363,14 @@ function fetchTasks(): Promise { function getStatusBadgeClass(status: TaskStatus): string { switch (status) { - case '완료': return 'text-emerald-400 bg-emerald-500/10'; - case '진행중': return 'text-cyan-400 bg-cyan-500/10'; - case '대기': return 'text-yellow-400 bg-yellow-500/10'; - case '오류': return 'text-red-400 bg-red-500/10'; + case '완료': + return 'text-emerald-400 bg-emerald-500/10'; + case '진행중': + return 'text-cyan-400 bg-cyan-500/10'; + case '대기': + return 'text-yellow-400 bg-yellow-500/10'; + case '오류': + return 'text-red-400 bg-red-500/10'; } } @@ -169,7 +382,10 @@ function ProgressBar({ value }: { value: number }) { return (
-
+
{value}%
@@ -217,9 +433,16 @@ function TaskTable({ rows, loading, onAction }: TaskTableProps) { {row.id} {row.name} - {row.target} + + {row.target} + - + {row.status} @@ -289,13 +512,18 @@ function StepIndicator({ current }: { current: number }) { isDone ? 'bg-emerald-500 text-white' : isActive - ? 'bg-cyan-500 text-white' - : 'bg-bg-elevated text-t3' + ? 'bg-cyan-500 text-white' + : 'bg-bg-elevated text-t3' }`} > {isDone ? ( - + ) : ( stepNum @@ -352,11 +580,13 @@ function Step1({ wizard, onChange }: Step1Props) {
- {([ - ['db', '데이터베이스 연결'], - ['file', '파일 업로드'], - ['api', 'API 호출'], - ] as [SourceType, string][]).map(([val, label]) => ( + {( + [ + ['db', '데이터베이스 연결'], + ['file', '파일 업로드'], + ['api', 'API 호출'], + ] as [SourceType, string][] + ).map(([val, label]) => (
@@ -652,7 +895,11 @@ function Step4({ wizard, onChange }: Step4Props) { onChange={(e) => handleScheduleChange('hour', e.target.value)} className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500" > - {HOURS.map((h) => )} + {HOURS.map((h) => ( + + ))}
@@ -681,7 +928,11 @@ function Step4({ wizard, onChange }: Step4Props) { onChange={(e) => handleScheduleChange('weekday', e.target.value)} className="ml-1 px-2 py-0.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500" > - {WEEKDAYS.map((d) => )} + {WEEKDAYS.map((d) => ( + + ))} )}
@@ -738,7 +989,11 @@ function Step4({ wizard, onChange }: Step4Props) { onChange={(e) => handleOneshotChange('hour', e.target.value)} className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500" > - {HOURS.map((h) => )} + {HOURS.map((h) => ( + + ))}
@@ -769,7 +1024,15 @@ function Step5({ wizard, onChange }: Step5Props) { const summaryRows = [ { label: '작업명', value: wizard.taskName || '(미입력)' }, - { label: '소스', value: wizard.sourceType === 'db' ? `DB: ${wizard.dbConfig.database}.${wizard.dbConfig.tableName}` : wizard.sourceType === 'file' ? '파일 업로드' : `API: ${wizard.apiConfig.url}` }, + { + label: '소스', + value: + wizard.sourceType === 'db' + ? `DB: ${wizard.dbConfig.database}.${wizard.dbConfig.tableName}` + : wizard.sourceType === 'file' + ? '파일 업로드' + : `API: ${wizard.apiConfig.url}`, + }, { label: '데이터 건수', value: '15,240건' }, { label: '선택 필드 수', value: `${selectedCount}개` }, { label: '비식별화 규칙 수', value: `${ruleCount}개` }, @@ -833,10 +1096,14 @@ const INITIAL_WIZARD: WizardState = { function getAuditResultClass(type: AuditLogEntry['resultType']): string { switch (type) { - case '성공': return 'text-emerald-400 bg-emerald-500/10'; - case '진행중': return 'text-cyan-400 bg-cyan-500/10'; - case '실패': return 'text-red-400 bg-red-500/10'; - case '거부': return 'text-yellow-400 bg-yellow-500/10'; + case '성공': + return 'text-emerald-400 bg-emerald-500/10'; + case '진행중': + return 'text-cyan-400 bg-cyan-500/10'; + case '실패': + return 'text-red-400 bg-red-500/10'; + case '거부': + return 'text-yellow-400 bg-yellow-500/10'; } } @@ -863,10 +1130,11 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
{/* 헤더 */}
-

- 감시 감독 (감사로그) — {task.name} -

-
@@ -894,7 +1162,9 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) { className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500" > {operators.map((op) => ( - + ))}
@@ -905,7 +1175,10 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) { {['시간', '작업자', '작업', '대상 데이터', '결과', '상세'].map((h) => ( - + {h} ))} @@ -925,18 +1198,27 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) { className={`border-b border-stroke-1 hover:bg-bg-surface/50 cursor-pointer ${selectedLog?.id === log.id ? 'bg-bg-surface/70' : ''}`} onClick={() => setSelectedLog(log)} > - {log.time.split(' ')[1]} + + {log.time.split(' ')[1]} + {log.operator} {log.action} - {log.targetData} + + {log.targetData} + - + {log.result}
@@ -1128,22 +1449,32 @@ export default function DeidentifyPanel() { } }, []); - const handleWizardSubmit = useCallback((wizard: WizardState) => { - const selectedFields = wizard.fields.filter((f) => f.selected).map((f) => f.name); - const newTask: DeidentifyTask = { - id: String(tasks.length + 1).padStart(3, '0'), - name: wizard.taskName, - target: selectedFields.join(', ') || '-', - status: wizard.processMode === 'immediate' ? '진행중' : '대기', - startTime: new Date().toLocaleString('ko-KR', { - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', hour12: false, - }).replace(/\. /g, '-').replace('.', ''), - progress: 0, - createdBy: '관리자', - }; - setTasks((prev) => [newTask, ...prev]); - }, [tasks.length]); + const handleWizardSubmit = useCallback( + (wizard: WizardState) => { + const selectedFields = wizard.fields.filter((f) => f.selected).map((f) => f.name); + const newTask: DeidentifyTask = { + id: String(tasks.length + 1).padStart(3, '0'), + name: wizard.taskName, + target: selectedFields.join(', ') || '-', + status: wizard.processMode === 'immediate' ? '진행중' : '대기', + startTime: new Date() + .toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) + .replace(/\. /g, '-') + .replace('.', ''), + progress: 0, + createdBy: '관리자', + }; + setTasks((prev) => [newTask, ...prev]); + }, + [tasks.length], + ); const filteredTasks = tasks.filter((t) => { if (searchName && !t.name.includes(searchName)) return false; @@ -1205,7 +1536,9 @@ export default function DeidentifyPanel() { className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500" > {(['모두', '완료', '진행중', '대기', '오류'] as FilterStatus[]).map((s) => ( - + ))} -
분석 범위
+
분석 범위
- NM + NM
@@ -226,7 +226,7 @@ export function BacktrackModal({ }} className="border border-stroke" > -
유출 위치
+
유출 위치
{conditions.spillLocation.lat.toFixed(4)}°N,{' '} {conditions.spillLocation.lon.toFixed(4)}°E @@ -243,10 +243,10 @@ export function BacktrackModal({ gridColumn: '1 / -1', }} > -
분석 대상 선박
+
분석 대상 선박
{conditions.totalVessels}척{' '} - (AIS 수신) + (AIS 수신)
@@ -380,7 +380,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
{vessel.name}
-
+
IMO: {vessel.imo} · {vessel.type} · {vessel.flag}
@@ -391,7 +391,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) { > {vessel.probability}%
-
유출 확률
+
유출 확률
@@ -429,7 +429,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) { : '1px solid var(--stroke-default)', }} > -
{s.label}
+
{s.label}
오일펜스 배치 최적화 알고리즘 이론
-
+
Oil Boom Deployment Optimization · 유출유 확산예측 연동 · 방제효율 최대화
@@ -54,7 +54,7 @@ export function BoomDeploymentTheoryView() { className={`flex-1 py-2 px-1 text-label-1 font-medium rounded-md transition-all duration-150 cursor-pointer border ${ activePanel === tab.id ? 'border-stroke-light bg-bg-elevated text-fg' - : 'border-transparent bg-bg-card text-fg-disabled hover:text-fg-sub' + : 'border-transparent bg-bg-card text-fg-default hover:text-fg-sub' }`} > {tab.label} @@ -207,11 +207,11 @@ function OverviewPanel() {
{step.label}
-
+
{step.sub}
- {i < 5 &&
} + {i < 5 &&
}
))}
@@ -369,10 +369,10 @@ function DeploymentTheoryPanel() { Floss(Un)
Un = U · sin(θ){' '} - (법선방향 유속) + (법선방향 유속)
E = 1 (Un ≤ Uc)
E = max(0, 1 − (Un/U c)²) (Un > Uc)
- + Uc: 임계유속(약 0.35m/s = 0.7 knot)
@@ -391,12 +391,12 @@ function DeploymentTheoryPanel() { style={{ border: '1px solid rgba(6,182,212,.2)' }} > θ* = arcsin(Uc / U){' '} - (임계조건) + (임계조건)
θopt = argmax [Ablock(θ) · E(θ,U)]
실용범위: 15° ≤ θ ≤ 60°
- + 단, θ < arcsin(Uc/U) 이면 기름 통과 발생
@@ -483,7 +483,7 @@ function DeploymentTheoryPanel() { > AV = L²·sin(2α)/2
- α: 반개각, L: 편측 길이 + α: 반개각, L: 편측 길이
최적 α = 30°~45°
@@ -554,7 +554,7 @@ function DeploymentTheoryPanel() { > AU = π·r²/2 + 2r·h
- r: 반경, h: 직선부 길이 + r: 반경, h: 직선부 길이
전제: U < 0.5 knot
@@ -625,7 +625,7 @@ function DeploymentTheoryPanel() { style={{ background: 'rgba(6,182,212,.05)' }} > θJ = arcsin(Uc/U) + δ
- δ: 안전여유각(5°~10°) + δ: 안전여유각(5°~10°)
활용: U > 0.7 knot
@@ -642,7 +642,7 @@ function DeploymentTheoryPanel() { n개 직렬 배치 시 누적 차단 효율:
Etotal = 1 − ∏(1−Ei)
- + Ei: i번째 오일펜스 단독 차단효율
@@ -728,18 +728,18 @@ function OptimizationPanel() { 최대화:
f₁(x) = Σ Ablocked,i · wESI,i{' '} - (가중 차단면적) + (가중 차단면적)
f₂(x) = Tdeadline − Tdeploy{' '} - (여유시간) + (여유시간)
최소화:
f₃(x) = Σ Lboom,j{' '} - (총 오일펜스 사용량) + (총 오일펜스 사용량)
f₄(x) = Σ Dvessel,k{' '} - (방제정 총 이동거리) + (방제정 총 이동거리)
g₁: U·sin(θi) ≤ Uc ∀i{' '} - (임계유속) + (임계유속)
g₂: Σ Lj ≤ Lmax{' '} - (자원 한계) + (자원 한계)
g₃: Tdeploy,i ≤ Tarrive,i{' '} - (시간 제약) + (시간 제약)
g₄: d(pi, shore) ≥ dmin{' '} - (연안 이격) + (연안 이격)
g₅: h(pi) ≥ hmin{' '} - (수심 조건) + (수심 조건)
@@ -824,7 +824,7 @@ function OptimizationPanel() {
{esi.grade}
-
{esi.desc}
+
{esi.desc}
{esi.w}
))} @@ -933,7 +933,7 @@ function OptimizationPanel() { {['알고리즘', '유형', '장점', '단점', 'WING 활용'].map((h) => ( {h} @@ -1031,11 +1031,11 @@ function FluidDynamicsPanel() { FD = ½ · ρ · CD · A · Un²
T = FD · L / (2·sin(α))
- + CD: 항력계수(≈1.2), A: 수중 투영면적
- T: 연결부 장력, α: 체인각도 + T: 연결부 장력, α: 체인각도
@@ -1054,11 +1054,11 @@ function FluidDynamicsPanel() {
Splash-over: Fr > 0.5~0.6
- + Fr: 수정 Froude수, h: 오일펜스 수중깊이
- Δρ/ρ: 기름-해수 밀도비 (~0.15) + Δρ/ρ: 기름-해수 밀도비 (~0.15)
@@ -1075,7 +1075,7 @@ function FluidDynamicsPanel() {
y(x) = a·cosh(x/a) − a
Larc = 2a·sinh(Lspan/(2a))
Leff = Lspan · cos(φmax)
- + a: catenary 파라미터, φ: 최대 편향각
@@ -1392,7 +1392,7 @@ function ReferencesPanel() { return ( <>
📚 오일펜스 배치 최적화 이론 근거 문헌
-
총 12편 · 4개 카테고리
+
총 12편 · 4개 카테고리
{categories.map((cat, ci) => (
@@ -1430,7 +1430,7 @@ function ReferencesPanel() {
{ref.title}
-
{ref.author}
+
{ref.author}
{ref.desc}
diff --git a/frontend/src/tabs/prediction/components/InfoLayerSection.tsx b/frontend/src/tabs/prediction/components/InfoLayerSection.tsx index 33985f9..b75a28c 100644 --- a/frontend/src/tabs/prediction/components/InfoLayerSection.tsx +++ b/frontend/src/tabs/prediction/components/InfoLayerSection.tsx @@ -38,7 +38,7 @@ const InfoLayerSection = ({

정보 레이어

@@ -117,7 +117,7 @@ const InfoLayerSection = ({ > 전체 끄기 - + {expanded ? '▼' : '▶'}
@@ -126,9 +126,9 @@ const InfoLayerSection = ({ {expanded && (
{isLoading && effectiveLayers.length === 0 ? ( -

레이어 로딩 중...

+

레이어 로딩 중...

) : effectiveLayers.length === 0 ? ( -

레이어 데이터가 없습니다.

+

레이어 데이터가 없습니다.

) : ( toggleSection('incident')} className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" > -

사고정보

- +

사고정보

+ {expandedSections.incident ? '▼' : '▶'}
@@ -202,7 +204,7 @@ export function LeftPanel({ CLOSED: { label: '종료', style: - 'bg-[rgba(100,116,139,0.15)] text-fg-disabled border border-[rgba(100,116,139,0.3)]', + 'bg-[rgba(100,116,139,0.15)] text-fg-default border border-[rgba(100,116,139,0.3)]', dot: 'bg-fg-disabled', }, }; @@ -220,7 +222,7 @@ export function LeftPanel({ {/* Info Grid */}
- + 사고코드 @@ -228,7 +230,7 @@ export function LeftPanel({
- + 사고명 @@ -236,7 +238,7 @@ export function LeftPanel({
- + 사고일시 @@ -246,7 +248,7 @@ export function LeftPanel({
- + 유종 @@ -254,7 +256,7 @@ export function LeftPanel({
- + 유출량 @@ -264,7 +266,7 @@ export function LeftPanel({
- + 담당자 @@ -272,7 +274,7 @@ export function LeftPanel({
- + 위치 @@ -283,7 +285,7 @@ export function LeftPanel({
) : (
-

+

선택된 사고정보가 없습니다.

@@ -296,8 +298,8 @@ export function LeftPanel({ onClick={() => toggleSection('impactResources')} className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" > -

영향 민감자원

- +

영향 민감자원

+ {expandedSections.impactResources ? '▼' : '▶'}
@@ -305,7 +307,7 @@ export function LeftPanel({ {expandedSections.impactResources && (
{sensitiveResources.length === 0 ? ( -

+

영향받는 민감자원 목록

) : ( @@ -357,6 +359,8 @@ export function LeftPanel({ onToggle={() => toggleSection('oilBoom')} boomLines={boomLines} onBoomLinesChange={onBoomLinesChange} + showBoomLines={showBoomLines} + onShowBoomLinesChange={onShowBoomLinesChange} oilTrajectory={oilTrajectory} incidentCoord={incidentCoord ?? { lat: 0, lon: 0 }} algorithmSettings={algorithmSettings} diff --git a/frontend/src/tabs/prediction/components/OilBoomSection.tsx b/frontend/src/tabs/prediction/components/OilBoomSection.tsx index 2a708fb..a3ce490 100644 --- a/frontend/src/tabs/prediction/components/OilBoomSection.tsx +++ b/frontend/src/tabs/prediction/components/OilBoomSection.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { Eye, EyeOff } from 'lucide-react'; import type { BoomLine, BoomLineCoord, @@ -22,6 +23,8 @@ interface OilBoomSectionProps { onDrawingPointsChange: (points: BoomLineCoord[]) => void; containmentResult: ContainmentResult | null; onContainmentResultChange: (result: ContainmentResult | null) => void; + showBoomLines: boolean; + onShowBoomLinesChange: (show: boolean) => void; } const DEFAULT_SETTINGS: AlgorithmSettings = { @@ -44,6 +47,8 @@ const OilBoomSection = ({ onDrawingPointsChange, containmentResult, onContainmentResultChange, + showBoomLines, + onShowBoomLinesChange, }: OilBoomSectionProps) => { const [boomPlacementTab, setBoomPlacementTab] = useState<'ai' | 'simulation'>('simulation'); const [showResetConfirm, setShowResetConfirm] = useState(false); @@ -81,8 +86,22 @@ const OilBoomSection = ({ onClick={onToggle} className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" > -

오일펜스 배치 가이드

- {expanded ? '▼' : '▶'} +

오일펜스 배치 가이드

+
+ + {expanded ? '▼' : '▶'} +
{expanded && ( @@ -127,7 +146,7 @@ const OilBoomSection = ({ borderRadius: 'var(--radius-sm)', border: '1px solid var(--stroke-default)', background: 'var(--bg-base)', - color: hasData ? 'var(--color-danger)' : 'var(--fg-disabled)', + color: 'var(--fg-disabled)', cursor: hasData ? 'pointer' : 'not-allowed', transition: '0.15s', }} @@ -150,7 +169,7 @@ const OilBoomSection = ({
⚠ 오일펜스 배치 가이드를 초기화 합니다
-
+
배치된 오일펜스 라인과 시뮬레이션 결과가 삭제됩니다. 확산 예측 결과는 유지됩니다.
@@ -218,12 +237,12 @@ const OilBoomSection = ({ className="border border-stroke" >
{metric.value}
-
{metric.label}
+
{metric.label}
))}
@@ -242,16 +261,10 @@ const OilBoomSection = ({ width: '8px', height: '8px', borderRadius: '50%', - background: - oilTrajectory.length > 0 ? 'var(--color-success)' : 'var(--color-danger)', + background: 'var(--fg-default)', }} /> - 0 ? 'var(--color-success)' : 'var(--fg-disabled)', - }} - > + 확산 궤적 데이터{' '} {oilTrajectory.length > 0 ? `(${oilTrajectory.length}개 입자)` : '없음'} @@ -261,7 +274,7 @@ const OilBoomSection = ({ {/* 알고리즘 설정 */}

📊 V자형 배치 알고리즘 설정 @@ -301,7 +314,7 @@ const OilBoomSection = ({ }} className="flex items-center justify-between px-2.5 py-1.5 border border-stroke" > - + ● {setting.label}
@@ -315,7 +328,7 @@ const OilBoomSection = ({ className="boom-setting-input" step={setting.key === 'waveHeightCorrectionFactor' ? 0.1 : 1} /> - + {setting.unit}
@@ -342,7 +355,7 @@ const OilBoomSection = ({ V자형 오일펜스 배치 + 시뮬레이션 실행 -

+

확산 궤적을 분석하여 해류 직교 방향 1차 방어선(V형), U형 포위 2차 방어선, 연안 보호 3차 방어선을 자동 배치하고 차단 시뮬레이션을 실행합니다.

@@ -363,7 +376,7 @@ const OilBoomSection = ({
{containmentResult.overallEfficiency}%
-
전체 차단 효율
+
전체 차단 효율

{/* 차단/통과 카운트 */} @@ -380,7 +393,7 @@ const OilBoomSection = ({
{containmentResult.blockedParticles}
-
차단 입자
+
차단 입자
{containmentResult.passedParticles}
-
통과 입자
+
통과 입자
@@ -485,13 +498,13 @@ const OilBoomSection = ({ className="mb-1.5" >
- 길이 + 길이
{line.length.toFixed(0)}m
- 각도 + 각도
{line.angle.toFixed(0)}°
diff --git a/frontend/src/tabs/prediction/components/OilSpillTheoryView.tsx b/frontend/src/tabs/prediction/components/OilSpillTheoryView.tsx index ca75b77..7ac156f 100755 --- a/frontend/src/tabs/prediction/components/OilSpillTheoryView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillTheoryView.tsx @@ -85,7 +85,7 @@ ${styles} 🔴 POSEIDON 🔵 OpenDrift ⚡ 앙상블 - 라그랑지안 입자추적 이론 기반 + 라그랑지안 입자추적 이론 기반
@@ -111,7 +111,7 @@ ${styles} className={`flex-1 py-2 px-1 text-label-1 font-medium rounded-md transition-all duration-150 cursor-pointer border ${ activePanel === tab.id ? 'border-stroke-light bg-bg-elevated text-fg' - : 'border-transparent bg-bg-card text-fg-disabled hover:text-fg-sub' + : 'border-transparent bg-bg-card text-fg-default hover:text-fg-sub' }`} > {tab.icon} {tab.name} @@ -232,7 +232,7 @@ function SystemOverviewPanel() {
🤖 WING 탑재 유출유 확산 모델 비교
- 3종 앙상블 운용 · 불확실성 정량화 + 3종 앙상블 운용 · 불확실성 정량화
{[ @@ -289,7 +289,7 @@ function SystemOverviewPanel() {
{m.name}
-
{m.sub}
+
{m.sub}
{m.desc}
@@ -337,7 +337,7 @@ function SystemOverviewPanel() { }} > 구분 @@ -469,7 +469,7 @@ function SystemOverviewPanel() { }} > ')), }} @@ -538,7 +538,7 @@ function KospsPanel() {
KOSPS (Korea Oil Spill Prediction System)
-
+
한국해양연구원(KORDI) 개발 · 한국 해역 특화 유출유 확산 예측 상시 운용 시스템
@@ -584,9 +584,9 @@ function KospsPanel() { {/* 특허 1 */}
-
등록번호
+
등록번호
10-1567431
-
2015.11.03
+
2015.11.03
@@ -611,7 +611,7 @@ function KospsPanel() { ))}
-
+
국가R&D: ① 3차원 유출유 확산예측 기반 방제 지원기술 개발 (기여율 65%) ② HNS 유출 거동예측 및 대응정보 지원기술 개발 (기여율 35%) | 해양수산부
@@ -632,7 +632,7 @@ function KospsPanel() {
- /* 변조조석 수식 */ + /* 변조조석 수식 */
ζ(t) = A(t) cos[σt − θ(t)]
@@ -712,7 +712,7 @@ function KospsPanel() { {d.icon} {d.label} - {d.detail} + {d.detail}
))}
@@ -725,14 +725,14 @@ function KospsPanel() { style={{ background: 'rgba(6,182,212,.04)', border: '1px solid rgba(6,182,212,.12)' }} >
📍 수심·해안선
-
전자해도(ENC) → 500m 격자 보간
+
전자해도(ENC) → 500m 격자 보간
🗺️ 격자 구성
-
좌표변환 → 영역추출 → 격자보간 표준화
+
좌표변환 → 영역추출 → 격자보간 표준화
@@ -744,12 +744,12 @@ function KospsPanel() {
- /* 취송류 유속 (이·강, 2000) */ + /* 취송류 유속 (이·강, 2000) */
V_WDC = 0.029 × V_wind
- /* 취송류 유향 */ + /* 취송류 유향 */
θ_WDC = θ_wind + 18.6°
@@ -810,7 +810,7 @@ function KospsPanel() {
{node.label}
-
{node.sub}
+
{node.sub}
{i < 5 && (
@@ -818,7 +818,7 @@ function KospsPanel() {
))}
-
+
FTP 자동 갱신 → DB 정규화 → 격자 재구성 → 모델 구동 → 결과 표출
이문진 박사 특허 기반 핵심 기술 (등록특허 10-1567431)
-
+
해양 유류오염사고 발생시 효율적인 방제방안 수립을 위한 유출유 확산 예측 방법 · 한국해양과학기술원 · 2015년 등록
@@ -1058,7 +1058,7 @@ function KospsPanel() {
z(x,y) = Σ Σ qᵢⱼ xⁱ yʲ{' '} - (i≤5, i+j≤5) + (i≤5, i+j≤5)
@@ -1104,7 +1104,7 @@ function KospsPanel() {
3차원 유출유 확산예측 기반 해양유류오염 방제 지원기술 개발
- 해양수산부 | 2013.01~2013.12 + 해양수산부 | 2013.01~2013.12
주요 위험유해물질(HNS) 유출 거동예측 및 대응정보 지원기술 개발
- 해양수산부 | 2013.01~2013.12 + 해양수산부 | 2013.01~2013.12
@@ -1163,7 +1163,7 @@ function KospsPanel() { ▼
-
+
KOSPS 개발 · 허베이스피리트 검증 · 3D 확산예측 시스템 · 방제효과 모델링 — 한국해양환경·에너지학회
@@ -1246,10 +1246,10 @@ function KospsPanel() { ))}
- {paper.year} + {paper.year}
{paper.title}
-
{paper.authors}
+
{paper.authors}
{paper.desc}
))} @@ -1405,7 +1405,7 @@ function KospsPanel() {
{paper.title}
-
+
{paper.authors} | {paper.journal}{' '} {paper.detail}
@@ -1542,7 +1542,7 @@ function KospsPanel() {
{paper.title}
-
+
{paper.authors} | {paper.journal}{' '} {paper.detail}
@@ -1570,7 +1570,7 @@ function PoseidonPanel() {
POSEIDON (입자추적 최적화 예측 시스템)
-
+
한국환경연구원 · (주)아라종합기술 · 한국해양기상기술 공동개발 · MOHID 해양순환모델 기반 · 뜰개 관측 매개변수 자동 최적화
@@ -1631,9 +1631,9 @@ function PoseidonPanel() { fontFamily: 'var(--font-mono)', }} > -
등록번호
+
등록번호
10-1868791
-
2018 등록
+
2018 등록
@@ -1724,7 +1724,7 @@ function PoseidonPanel() {
POSEIDON 입자추적 핵심 수식
-
+
제1 입자추적 모델 (기본)
@@ -1732,12 +1732,12 @@ function PoseidonPanel() {
Model_y = Δt × current_v + Δt × c × wind_v
-
+
c : 풍속 가중치 (예: c=0.3 → 바람의 30% 반영)
-
+
제2 입자추적 모델 (최적화 후)
@@ -1749,7 +1749,7 @@ function PoseidonPanel() {            + a5·Model_x + a6·Model_y + a7
-
+
a1~a7 : GA·DE·PSO로 최적화된 매개변수
@@ -1760,7 +1760,7 @@ function PoseidonPanel() {
🔄 POSEIDON_V2 상시 운용 체계
{/* 외부 입력 자료 */} -
외부 입력 자료
+
외부 입력 자료
{[ { @@ -1818,12 +1818,12 @@ function PoseidonPanel() {
{/* 중앙 화살표 */} -
+
▼ DATA → PREP → 격자 보간/좌표 변환 ▼
{/* 4대 도메인 실행 모듈 */} -
+
POSEIDON 4대 실행 모듈 (EA012 대격자 → KO108 연안 상세격자)
@@ -1887,7 +1887,7 @@ function PoseidonPanel() {
{/* 화살표 + 최적화 */} -
+
▼ HYDR + WAVE + TIDE → OILS 강제력 입력 ▼ 뜰개 관측 → GA/DE/PSO 매개변수 자동 최적화 ▼
@@ -1914,7 +1914,7 @@ function PoseidonPanel() {
{node.label}
-
{node.sub}
+
{node.sub}
{i < 2 && (
@@ -1961,7 +1961,7 @@ function PoseidonPanel() {
POSEIDON관련 유출유 확산예측 논문
-
+
포세이돈 시스템 소개·활용 · 최적 방제전략 · 원격탐사 연동 · MOHID 검증 — 한국해양환경·에너지학회 외
@@ -2040,10 +2040,10 @@ function PoseidonPanel() { ))}
- {paper.year} + {paper.year}
{paper.title}
-
{paper.authors}
+
{paper.authors}
{paper.desc}
))} @@ -2066,7 +2066,7 @@ function OpenDriftPanel() {
OpenDrift (오픈소스 라그랑지안 확산 프레임워크)
-
+
노르웨이 MET Norway · OpenOil 공개 프레임워크 · Python 기반 · IMO/IPIECA 검증
@@ -2210,7 +2210,7 @@ function OpenDriftPanel() {
{w.title}
-
{w.desc}
+
{w.desc}
))}
@@ -2244,7 +2244,7 @@ function OpenDriftPanel() {
{node.label}
-
{node.sub}
+
{node.sub}
{i < 6 && (
@@ -2252,7 +2252,7 @@ function OpenDriftPanel() {
))}
-
+
해양모델(NEMO·ROMS·HYCOM) + 기상자료(ECMWF·GFS) → NOAA Oil Library 유종 매칭 → OpenDrift/OpenOil 모듈 구동 → NetCDF 결과 출력·시각화
@@ -2271,7 +2271,7 @@ function OpenDriftPanel() {
OpenDrift / OpenOil 국내 해역 적용 연구 논문
-
+
한국 연안 유출유 확산 수치모의 관련 핵심 논문 3편 — WING 모델 이론 근거
@@ -2309,13 +2309,13 @@ function OpenDriftPanel() { ))}
- 2024 + 2024
Numerical Model Test of Spilled Oil Transport Near the Korean Coasts Using Various Input Parametric Models
-
+
Hai Van Dang, Suchan Joo, Junhyeok Lim, Jinhwan Hur, Sungwon Shin | Hanyang University ERICA | Journal of Ocean Engineering and Technology, 2024
@@ -2417,13 +2417,13 @@ function OpenDriftPanel() { ))}
- 1998 + 1998
한국 동남해역에서의 유출유 확산예측모델 (Oil Spill Behavior Forecasting Model in South-eastern Coastal Area of Korea)
-
+
류청로, 김종규, 설동관, 강동욱 | 부경대학교 해양공학과 | 한국해양환경공학회지 Vol.1 No.2, pp.52–59, 1998
@@ -2520,13 +2520,13 @@ function OpenDriftPanel() { ))}
- 2008 + 2008
태안 기름유출사고의 유출유 확산특성 분석 (Analysis of Oil Spill Dispersion in Taean Coastal Zone)
-
+
정태성, 조형진 | 한남대학교 토목환경공학과 | 한국해안·해양공학회 학술발표논문집 제17권 pp.60–63, 2008
@@ -2593,7 +2593,7 @@ function OpenDriftPanel() { }} >
α = 3%
-
과대 확산
+
과대 확산
α = 2.5%
-
다소 빠름
+
다소 빠름
α = 2% ✓
-
최적 일치
+
최적 일치
θ = 20° ✓
-
최적 편향각
+
최적 편향각
@@ -2733,14 +2733,14 @@ function LagrangianPanel() {
- /* 중력-관성 체제 (초기) */ + /* 중력-관성 체제 (초기) */
R(t) = K₁ · ( ΔρgV² /{' '} ρw)¼ · t½
- /* 중력-점성 체제 (후기) */ + /* 중력-점성 체제 (후기) */
R(t) = K₂ · ( ΔρgV² /{' '} @@ -2832,7 +2832,7 @@ function WeatheringPanel() {
{w.title}
{w.desc}
{w.formula}
-
{w.note}
+
{w.note}
))}
@@ -2883,7 +2883,7 @@ function WeatheringPanel() { {s.time}
{s.title}
-
+
{s.desc}
@@ -2934,7 +2934,7 @@ function OceanInputPanel() { }} >
{t.label}
-
{t.desc}
+
{t.desc}
))}
@@ -2957,7 +2957,7 @@ function OceanInputPanel() { }} >
{t.label}
-
{t.desc}
+
{t.desc}
))}
@@ -3013,7 +3013,7 @@ function VerificationPanel() { > {s.value}
-
{s.label}
+
{s.label}
))}
@@ -3202,7 +3202,7 @@ function VerificationPanel() { {paper.system} -
+
{paper.authors} | {paper.journal}{' '} {paper.detail}
@@ -3314,7 +3314,7 @@ function RoadmapPanel() { }} >
{r.title}
-
{r.desc}
+
{r.desc}
))} @@ -3367,7 +3367,7 @@ function RoadmapPanel() { {s.phase}
{s.title}
-
+
{s.desc}
diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index f276a0b..d64567e 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -208,6 +208,7 @@ export function OilSpillView() { // 오일펜스 배치 상태 const [boomLines, setBoomLines] = useState([]); + const [showBoomLines, setShowBoomLines] = useState(true); const [algorithmSettings, setAlgorithmSettings] = useState({ currentOrthogonalCorrection: 15, safetyMarginMinutes: 60, @@ -1191,6 +1192,8 @@ export function OilSpillView() { onSpillUnitChange={setSpillUnit} boomLines={boomLines} onBoomLinesChange={setBoomLines} + showBoomLines={showBoomLines} + onShowBoomLinesChange={setShowBoomLines} oilTrajectory={oilTrajectory} algorithmSettings={algorithmSettings} onAlgorithmSettingsChange={setAlgorithmSettings} @@ -1281,6 +1284,7 @@ export function OilSpillView() { )} selectedModels={selectedModels} boomLines={boomLines} + showBoomLines={showBoomLines} isDrawingBoom={isDrawingBoom} drawingPoints={drawingPoints} layerOpacity={layerOpacity} @@ -1659,7 +1663,7 @@ export function OilSpillView() { fontSize: 'var(--font-size-caption)', }} > - {s.label} + {s.label} -

예측정보 입력

- {expanded ? '▼' : '▶'} +

예측정보 입력

+ {expanded ? '▼' : '▶'} {expanded && ( @@ -169,7 +169,7 @@ const PredictionInputSection = ({ {/* 파일 선택 영역 */} {!uploadedFile ? (
@@ -587,7 +585,7 @@ function DateTimeInput({ @@ -597,7 +595,7 @@ function DateTimeInput({ {['일', '월', '화', '수', '목', '금', '토'].map((d) => ( {d} @@ -779,7 +777,7 @@ function DmsCoordInput({ return (
- {label} + {label}
update(parseInt(e.target.value) || 0, m, s, dir)} style={fieldStyle} /> - ° + ° update(d, parseInt(e.target.value) || 0, s, dir)} style={fieldStyle} /> - ' + ' update(d, m, parseFloat(e.target.value) || 0, dir)} style={fieldStyle} /> - " + "
); diff --git a/frontend/src/tabs/prediction/components/RecalcModal.tsx b/frontend/src/tabs/prediction/components/RecalcModal.tsx index 0956154..2ab79a3 100755 --- a/frontend/src/tabs/prediction/components/RecalcModal.tsx +++ b/frontend/src/tabs/prediction/components/RecalcModal.tsx @@ -167,7 +167,7 @@ export function RecalcModal({

확산예측 재계산

-
+
유출유·유출량 등 파라미터를 수정하여 재실행
@@ -180,7 +180,7 @@ export function RecalcModal({ background: 'var(--bg-card)', fontSize: 'var(--font-size-caption)', }} - className="border border-stroke text-fg-disabled cursor-pointer flex items-center justify-center" + className="border border-stroke text-fg-default cursor-pointer flex items-center justify-center" > ✕ @@ -281,7 +281,7 @@ export function RecalcModal({
-
위도 (N)
+
위도 (N)
-
경도 (E)
+
경도 (E)
- {label} + {label} {value}
); diff --git a/frontend/src/tabs/prediction/components/RightPanel.tsx b/frontend/src/tabs/prediction/components/RightPanel.tsx index a584c70..4edadf7 100755 --- a/frontend/src/tabs/prediction/components/RightPanel.tsx +++ b/frontend/src/tabs/prediction/components/RightPanel.tsx @@ -165,7 +165,7 @@ export function RightPanel({
{windHydrModelOptions.length > 1 && (
- + 데이터 모델 - NM + NM
-
+
IMO {vessel?.imoNo || '—'} · {vessel?.vesselTp || '—'}
@@ -511,7 +511,7 @@ export function RightPanel({
⚠ 충돌 상대: {vessel2.vesselNm}
-
+
{vessel2.flagCd} {vessel2.vesselTp}{' '} {vessel2.gt ? `${vessel2.gt.toLocaleString()}GT` : ''}
@@ -570,7 +570,7 @@ export function RightPanel({ ))} ) : ( -
+
보험 정보가 없습니다.
)} @@ -647,12 +647,9 @@ function getSpreadSeverity( // Helper Components const BADGE_STYLES: Record = { red: 'bg-[rgba(239,68,68,0.08)] text-color-danger border border-[rgba(239,68,68,0.25)]', - orange: - 'bg-[rgba(249,115,22,0.08)] text-color-warning border border-[rgba(249,115,22,0.25)]', - yellow: - 'bg-[rgba(234,179,8,0.08)] text-color-caution border border-[rgba(234,179,8,0.25)]', - green: - 'bg-[rgba(34,197,94,0.08)] text-color-success border border-[rgba(34,197,94,0.25)]', + orange: 'bg-[rgba(249,115,22,0.08)] text-color-warning border border-[rgba(249,115,22,0.25)]', + yellow: 'bg-[rgba(234,179,8,0.08)] text-color-caution border border-[rgba(234,179,8,0.25)]', + green: 'bg-[rgba(34,197,94,0.08)] text-color-success border border-[rgba(34,197,94,0.25)]', }; function Section({ @@ -699,7 +696,7 @@ function ControlledCheckbox({ return (
); @@ -738,7 +735,7 @@ function StatBox({ function PredictionCard({ value, label, color }: { value: string; label: string; color: string }) { return (
- {label} + {label} {value}
); @@ -747,7 +744,7 @@ function PredictionCard({ value, label, color }: { value: string; label: string; function ProgressBar({ label, value, color }: { label: string; value: number; color: string }) { return (
- + {label}

{title}

- {expanded ? '▾' : '▸'} + {expanded ? '▾' : '▸'}
{expanded && children}
@@ -796,7 +793,7 @@ function SpecCard({ value, label, color }: { value: string; label: string; color
{value}
-
{label}
+
{label}
); } @@ -814,7 +811,7 @@ function InfoRow({ }) { return (
- {label} + {label} {items.map((item, i) => (
- {item.label} + {item.label} {result.area.toFixed(2)}
-
분석면적(km²)
+
분석면적(km²)
{result.particlePercent}%
-
오염비율
+
오염비율
{pollutedArea}
-
오염면적(km²)
+
오염면적(km²)
{summary && (
- 해상잔존량 + 해상잔존량 {summary.remainingVolume.toFixed(2)} m³ @@ -960,14 +957,14 @@ function PollResult({ )} {summary && (
- 연안부착량 + 연안부착량 {summary.beachedVolume.toFixed(2)} m³
)}
- 민감자원 포함 + 민감자원 포함 {result.sensitiveCount}개소 @@ -976,7 +973,7 @@ function PollResult({
diff --git a/frontend/src/tabs/prediction/components/leftPanelTypes.ts b/frontend/src/tabs/prediction/components/leftPanelTypes.ts index 4ade4ad..ce0c561 100644 --- a/frontend/src/tabs/prediction/components/leftPanelTypes.ts +++ b/frontend/src/tabs/prediction/components/leftPanelTypes.ts @@ -40,6 +40,8 @@ export interface LeftPanelProps { // 오일펜스 배치 관련 boomLines: BoomLine[]; onBoomLinesChange: (lines: BoomLine[]) => void; + showBoomLines: boolean; + onShowBoomLinesChange: (show: boolean) => void; oilTrajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>; algorithmSettings: AlgorithmSettings; onAlgorithmSettingsChange: (settings: AlgorithmSettings) => void; diff --git a/frontend/src/tabs/rescue/components/RescueScenarioView.tsx b/frontend/src/tabs/rescue/components/RescueScenarioView.tsx index 9072508..e504353 100755 --- a/frontend/src/tabs/rescue/components/RescueScenarioView.tsx +++ b/frontend/src/tabs/rescue/components/RescueScenarioView.tsx @@ -120,10 +120,19 @@ const SCENARIO_MGMT_GUIDELINES = [ /* ─── Mock 시나리오 (API 미연결 시 폴백) — 긴급구난 모델 이론 기반 10개 ─── */ const MOCK_SCENARIOS: RescueScenarioItem[] = [ { - scenarioSn: 1, rescueOpsSn: 1, timeStep: 'T+0h', - scenarioDtm: '2024-10-27T01:30:00.000Z', svrtCd: 'CRITICAL', - gmM: 0.8, listDeg: 15.0, trimM: 2.5, buoyancyPct: 30.0, oilRateLpm: 100.0, bmRatioPct: 92.0, - description: '좌현 35° 충돌로 No.1P 화물탱크 파공. 벙커C유 유출 개시. 손상복원성 분석: 초기 GM 0.8m으로 IMO 기준(1.0m) 미달, 복원력 위험 판정.', + scenarioSn: 1, + rescueOpsSn: 1, + timeStep: 'T+0h', + scenarioDtm: '2024-10-27T01:30:00.000Z', + svrtCd: 'CRITICAL', + gmM: 0.8, + listDeg: 15.0, + trimM: 2.5, + buoyancyPct: 30.0, + oilRateLpm: 100.0, + bmRatioPct: 92.0, + description: + '좌현 35° 충돌로 No.1P 화물탱크 파공. 벙커C유 유출 개시. 손상복원성 분석: 초기 GM 0.8m으로 IMO 기준(1.0m) 미달, 복원력 위험 판정.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' }, @@ -146,10 +155,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ sortOrd: 1, }, { - scenarioSn: 2, rescueOpsSn: 1, timeStep: 'T+30m', - scenarioDtm: '2024-10-27T02:00:00.000Z', svrtCd: 'CRITICAL', - gmM: 0.7, listDeg: 17.0, trimM: 2.8, buoyancyPct: 28.0, oilRateLpm: 120.0, bmRatioPct: 90.0, - description: '잠수사 수중 조사 결과 좌현 No.1P 파공 크기 1.2m×0.8m 확인. Bernoulli 유입률 모델 적용: 수두차 4.5m 기준 유입률 약 2.1㎥/min.', + scenarioSn: 2, + rescueOpsSn: 1, + timeStep: 'T+30m', + scenarioDtm: '2024-10-27T02:00:00.000Z', + svrtCd: 'CRITICAL', + gmM: 0.7, + listDeg: 17.0, + trimM: 2.8, + buoyancyPct: 28.0, + oilRateLpm: 120.0, + bmRatioPct: 90.0, + description: + '잠수사 수중 조사 결과 좌현 No.1P 파공 크기 1.2m×0.8m 확인. Bernoulli 유입률 모델 적용: 수두차 4.5m 기준 유입률 약 2.1㎥/min.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' }, @@ -172,10 +190,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ sortOrd: 2, }, { - scenarioSn: 3, rescueOpsSn: 1, timeStep: 'T+1h', - scenarioDtm: '2024-10-27T02:30:00.000Z', svrtCd: 'CRITICAL', - gmM: 0.65, listDeg: 18.5, trimM: 3.0, buoyancyPct: 26.0, oilRateLpm: 135.0, bmRatioPct: 89.0, - description: '해경 3009함 현장 도착, SAR 작전 개시. Leeway 표류 예측 모델: 풍속 8m/s, 해류 2.5kn NE — 실종자 표류 반경 1.2nm. GZ 최대 복원력 각도 25°로 감소.', + scenarioSn: 3, + rescueOpsSn: 1, + timeStep: 'T+1h', + scenarioDtm: '2024-10-27T02:30:00.000Z', + svrtCd: 'CRITICAL', + gmM: 0.65, + listDeg: 18.5, + trimM: 3.0, + buoyancyPct: 26.0, + oilRateLpm: 135.0, + bmRatioPct: 89.0, + description: + '해경 3009함 현장 도착, SAR 작전 개시. Leeway 표류 예측 모델: 풍속 8m/s, 해류 2.5kn NE — 실종자 표류 반경 1.2nm. GZ 최대 복원력 각도 25°로 감소.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' }, @@ -198,10 +225,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ sortOrd: 3, }, { - scenarioSn: 4, rescueOpsSn: 1, timeStep: 'T+2h', - scenarioDtm: '2024-10-27T03:30:00.000Z', svrtCd: 'CRITICAL', - gmM: 0.5, listDeg: 20.0, trimM: 3.5, buoyancyPct: 22.0, oilRateLpm: 160.0, bmRatioPct: 86.0, - description: '격벽 관통으로 #2 Port Tank 침수 확대. 자유표면효과(FSE) 보정: GM_fluid = 0.5m. 종강도: Sagging 모멘트 86%. 침몰 위험 단계 진입.', + scenarioSn: 4, + rescueOpsSn: 1, + timeStep: 'T+2h', + scenarioDtm: '2024-10-27T03:30:00.000Z', + svrtCd: 'CRITICAL', + gmM: 0.5, + listDeg: 20.0, + trimM: 3.5, + buoyancyPct: 22.0, + oilRateLpm: 160.0, + bmRatioPct: 86.0, + description: + '격벽 관통으로 #2 Port Tank 침수 확대. 자유표면효과(FSE) 보정: GM_fluid = 0.5m. 종강도: Sagging 모멘트 86%. 침몰 위험 단계 진입.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' }, @@ -224,10 +260,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ sortOrd: 4, }, { - scenarioSn: 5, rescueOpsSn: 1, timeStep: 'T+3h', - scenarioDtm: '2024-10-27T04:30:00.000Z', svrtCd: 'HIGH', - gmM: 0.55, listDeg: 16.0, trimM: 3.2, buoyancyPct: 25.0, oilRateLpm: 140.0, bmRatioPct: 87.0, - description: 'Counter-Flooding 실시: #3 Stbd Tank에 평형수 280톤 주입, 횡경사 20°→16° 교정. 종강도: 중량 재배분으로 BM 87% 유지.', + scenarioSn: 5, + rescueOpsSn: 1, + timeStep: 'T+3h', + scenarioDtm: '2024-10-27T04:30:00.000Z', + svrtCd: 'HIGH', + gmM: 0.55, + listDeg: 16.0, + trimM: 3.2, + buoyancyPct: 25.0, + oilRateLpm: 140.0, + bmRatioPct: 87.0, + description: + 'Counter-Flooding 실시: #3 Stbd Tank에 평형수 280톤 주입, 횡경사 20°→16° 교정. 종강도: 중량 재배분으로 BM 87% 유지.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' }, @@ -250,10 +295,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ sortOrd: 5, }, { - scenarioSn: 6, rescueOpsSn: 1, timeStep: 'T+6h', - scenarioDtm: '2024-10-27T07:30:00.000Z', svrtCd: 'HIGH', - gmM: 0.7, listDeg: 12.0, trimM: 2.5, buoyancyPct: 32.0, oilRateLpm: 80.0, bmRatioPct: 90.0, - description: '수중패치 설치, 유입률 감소. GM 0.7m 회복. Trim/Stability Booklet 기준 예인 가능 최소 조건(GM≥0.5m, List≤15°) 충족.', + scenarioSn: 6, + rescueOpsSn: 1, + timeStep: 'T+6h', + scenarioDtm: '2024-10-27T07:30:00.000Z', + svrtCd: 'HIGH', + gmM: 0.7, + listDeg: 12.0, + trimM: 2.5, + buoyancyPct: 32.0, + oilRateLpm: 80.0, + bmRatioPct: 90.0, + description: + '수중패치 설치, 유입률 감소. GM 0.7m 회복. Trim/Stability Booklet 기준 예인 가능 최소 조건(GM≥0.5m, List≤15°) 충족.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, @@ -276,10 +330,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ sortOrd: 6, }, { - scenarioSn: 7, rescueOpsSn: 1, timeStep: 'T+8h', - scenarioDtm: '2024-10-27T09:30:00.000Z', svrtCd: 'MEDIUM', - gmM: 0.8, listDeg: 10.0, trimM: 2.0, buoyancyPct: 38.0, oilRateLpm: 55.0, bmRatioPct: 91.0, - description: '오일붐 2중 전개, 유회수기 3대 가동. GNOME 확산 모델: 12시간 후 확산 면적 2.3km² 예측. 기계적 회수율 35%.', + scenarioSn: 7, + rescueOpsSn: 1, + timeStep: 'T+8h', + scenarioDtm: '2024-10-27T09:30:00.000Z', + svrtCd: 'MEDIUM', + gmM: 0.8, + listDeg: 10.0, + trimM: 2.0, + buoyancyPct: 38.0, + oilRateLpm: 55.0, + bmRatioPct: 91.0, + description: + '오일붐 2중 전개, 유회수기 3대 가동. GNOME 확산 모델: 12시간 후 확산 면적 2.3km² 예측. 기계적 회수율 35%.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, @@ -302,10 +365,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ sortOrd: 7, }, { - scenarioSn: 8, rescueOpsSn: 1, timeStep: 'T+12h', - scenarioDtm: '2024-10-27T13:30:00.000Z', svrtCd: 'MEDIUM', - gmM: 0.9, listDeg: 8.0, trimM: 1.5, buoyancyPct: 45.0, oilRateLpm: 30.0, bmRatioPct: 94.0, - description: '예인 개시. 예인 저항 Rt=1/2·ρ·Cd·A·V² 기반 4,000HP급 배정. 목포항 42nm, 예인 속도 3kn, ETA 14h.', + scenarioSn: 8, + rescueOpsSn: 1, + timeStep: 'T+12h', + scenarioDtm: '2024-10-27T13:30:00.000Z', + svrtCd: 'MEDIUM', + gmM: 0.9, + listDeg: 8.0, + trimM: 1.5, + buoyancyPct: 45.0, + oilRateLpm: 30.0, + bmRatioPct: 94.0, + description: + '예인 개시. 예인 저항 Rt=1/2·ρ·Cd·A·V² 기반 4,000HP급 배정. 목포항 42nm, 예인 속도 3kn, ETA 14h.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, @@ -328,10 +400,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ sortOrd: 8, }, { - scenarioSn: 9, rescueOpsSn: 1, timeStep: 'T+18h', - scenarioDtm: '2024-10-27T19:30:00.000Z', svrtCd: 'MEDIUM', - gmM: 1.0, listDeg: 5.0, trimM: 1.0, buoyancyPct: 55.0, oilRateLpm: 15.0, bmRatioPct: 96.0, - description: '예인 진행률 65%. 파랑 응답 분석(RAO): 유의파고 1.2m, 주기 6s — 횡동요 ±3° 안전 범위. 잔류 유출률 15 L/min.', + scenarioSn: 9, + rescueOpsSn: 1, + timeStep: 'T+18h', + scenarioDtm: '2024-10-27T19:30:00.000Z', + svrtCd: 'MEDIUM', + gmM: 1.0, + listDeg: 5.0, + trimM: 1.0, + buoyancyPct: 55.0, + oilRateLpm: 15.0, + bmRatioPct: 96.0, + description: + '예인 진행률 65%. 파랑 응답 분석(RAO): 유의파고 1.2m, 주기 6s — 횡동요 ±3° 안전 범위. 잔류 유출률 15 L/min.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, @@ -354,10 +435,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ sortOrd: 9, }, { - scenarioSn: 10, rescueOpsSn: 1, timeStep: 'T+24h', - scenarioDtm: '2024-10-28T01:30:00.000Z', svrtCd: 'RESOLVED', - gmM: 1.2, listDeg: 3.0, trimM: 0.5, buoyancyPct: 75.0, oilRateLpm: 5.0, bmRatioPct: 98.0, - description: '목포항 접안 완료. 잔류유 전량 이적(120kL). 최종 GM 1.2m IMO 충족, BM 98% 정상. 방제 총 회수량 85kL (회수율 71%). 상황 종료.', + scenarioSn: 10, + rescueOpsSn: 1, + timeStep: 'T+24h', + scenarioDtm: '2024-10-28T01:30:00.000Z', + svrtCd: 'RESOLVED', + gmM: 1.2, + listDeg: 3.0, + trimM: 0.5, + buoyancyPct: 75.0, + oilRateLpm: 5.0, + bmRatioPct: 98.0, + description: + '목포항 접안 완료. 잔류유 전량 이적(120kL). 최종 GM 1.2m IMO 충족, BM 98% 정상. 방제 총 회수량 85kL (회수율 71%). 상황 종료.', compartments: [ { name: '#1 FP Tank', status: 'SEALED', color: 'var(--orange)' }, { name: '#1 Port Tank', status: 'SEALED', color: 'var(--orange)' }, @@ -383,15 +473,30 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ const MOCK_OPS: RescueOpsItem[] = [ { - rescueOpsSn: 1, acdntSn: 1, opsCd: 'RSC-2026-001', acdntTpCd: 'collision', - vesselNm: 'M/V SEA GUARDIAN', commanderNm: null, - lon: 126.25, lat: 37.467, locDc: '37°28\'N, 126°15\'E', - depthM: 25.0, currentDc: '2.5kn NE', - gmM: 0.8, listDeg: 15.0, trimM: 2.5, buoyancyPct: 30.0, - oilRateLpm: 100.0, bmRatioPct: 92.0, - totalCrew: 20, survivors: 15, missing: 5, - hydroData: null, gmdssData: null, - sttsCd: 'ACTIVE', regDtm: '2024-10-27T01:30:00.000Z', + rescueOpsSn: 1, + acdntSn: 1, + opsCd: 'RSC-2026-001', + acdntTpCd: 'collision', + vesselNm: 'M/V SEA GUARDIAN', + commanderNm: null, + lon: 126.25, + lat: 37.467, + locDc: "37°28'N, 126°15'E", + depthM: 25.0, + currentDc: '2.5kn NE', + gmM: 0.8, + listDeg: 15.0, + trimM: 2.5, + buoyancyPct: 30.0, + oilRateLpm: 100.0, + bmRatioPct: 92.0, + totalCrew: 20, + survivors: 15, + missing: 5, + hydroData: null, + gmdssData: null, + sttsCd: 'ACTIVE', + regDtm: '2024-10-27T01:30:00.000Z', }, ]; @@ -698,7 +803,9 @@ export function RescueScenarioView() {
{/* View content */} -
+
{/* ─── VIEW 0: 시나리오 상세 ─── */} {detailView === 0 && selected && (
@@ -1039,9 +1146,23 @@ function ScenarioMapOverlay({ maxWidth="320px" className="rescue-map-popup" > -
+
- + {sc.id} {sc.timeStep} @@ -1059,16 +1180,38 @@ function ScenarioMapOverlay({ {sev.label}
-
+
{sc.description}
{/* KPI */} -
+
{[ { label: 'GM', value: `${sc.gm}m`, color: gmColor(parseFloat(sc.gm)) }, - { label: '횡경사', value: `${sc.list}°`, color: listColor(parseFloat(sc.list)) }, + { + label: '횡경사', + value: `${sc.list}°`, + color: listColor(parseFloat(sc.list)), + }, { label: '부력', value: `${sc.buoyancy}%`, color: buoyColor(sc.buoyancy) }, - { label: '유출', value: sc.oilRate.split(' ')[0], color: oilColor(parseFloat(sc.oilRate)) }, + { + label: '유출', + value: sc.oilRate.split(' ')[0], + color: oilColor(parseFloat(sc.oilRate)), + }, ].map((m) => (
{m.label}
-
{m.value}
+
+ {m.value} +
))}
{/* 구획 상태 */} {sc.compartments.length > 0 && (
-
+
구획 상태
@@ -1122,11 +1274,16 @@ function ScenarioMapOverlay({ style={{ background: 'rgba(15,23,42,0.92)', width: 280, backdropFilter: 'blur(8px)' }} >
- {selected.id} + + {selected.id} + {selected.timeStep} {SEV_STYLE[selected.severity].label} @@ -1134,14 +1291,34 @@ function ScenarioMapOverlay({
{[ - { label: 'GM', value: `${selected.gm}m`, color: gmColor(parseFloat(selected.gm)) }, - { label: '횡경사', value: `${selected.list}°`, color: listColor(parseFloat(selected.list)) }, - { label: '부력', value: `${selected.buoyancy}%`, color: buoyColor(selected.buoyancy) }, - { label: '유출', value: selected.oilRate.split(' ')[0], color: oilColor(parseFloat(selected.oilRate)) }, + { + label: 'GM', + value: `${selected.gm}m`, + color: gmColor(parseFloat(selected.gm)), + }, + { + label: '횡경사', + value: `${selected.list}°`, + color: listColor(parseFloat(selected.list)), + }, + { + label: '부력', + value: `${selected.buoyancy}%`, + color: buoyColor(selected.buoyancy), + }, + { + label: '유출', + value: selected.oilRate.split(' ')[0], + color: oilColor(parseFloat(selected.oilRate)), + }, ].map((m) => (
-
{m.label}
-
{m.value}
+
+ {m.label} +
+
+ {m.value} +
))}
@@ -1171,7 +1348,12 @@ function ScenarioMapOverlay({
사고 위치
diff --git a/frontend/src/tabs/rescue/components/RescueView.tsx b/frontend/src/tabs/rescue/components/RescueView.tsx index 59d27d7..114e10c 100755 --- a/frontend/src/tabs/rescue/components/RescueView.tsx +++ b/frontend/src/tabs/rescue/components/RescueView.tsx @@ -1541,26 +1541,23 @@ export function RescueView() { }, []); // 사고 선택 시 사고유형 자동 매핑 - const handleSelectAcdnt = useCallback( - (item: IncidentListItem | null) => { - setSelectedAcdnt(item); - if (item) { - const typeMap: Record = { - collision: 'collision', - grounding: 'grounding', - turning: 'turning', - capsizing: 'capsizing', - sharpTurn: 'sharpTurn', - flooding: 'flooding', - sinking: 'sinking', - }; - const mapped = typeMap[item.acdntTpCd]; - if (mapped) setActiveType(mapped); - setIncidentCoord({ lon: item.lng, lat: item.lat }); - } - }, - [], - ); + const handleSelectAcdnt = useCallback((item: IncidentListItem | null) => { + setSelectedAcdnt(item); + if (item) { + const typeMap: Record = { + collision: 'collision', + grounding: 'grounding', + turning: 'turning', + capsizing: 'capsizing', + sharpTurn: 'sharpTurn', + flooding: 'flooding', + sinking: 'sinking', + }; + const mapped = typeMap[item.acdntTpCd]; + if (mapped) setActiveType(mapped); + setIncidentCoord({ lon: item.lng, lat: item.lat }); + } + }, []); if (activeSubTab === 'list') { return ( diff --git a/frontend/src/tabs/scat/components/ScatLeftPanel.tsx b/frontend/src/tabs/scat/components/ScatLeftPanel.tsx index 0e8b725..992e18f 100644 --- a/frontend/src/tabs/scat/components/ScatLeftPanel.tsx +++ b/frontend/src/tabs/scat/components/ScatLeftPanel.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, type CSSProperties, type ReactElement } fr import { List } from 'react-window'; import type { ScatSegment } from './scatTypes'; import type { ApiZoneItem } from '../services/scatApi'; -import { esiColor, sensColor, statusColor, esiLevel } from './scatConstants'; +import { esiLevel } from './scatConstants'; interface ScatLeftPanelProps { segments: ScatSegment[]; @@ -71,8 +71,8 @@ function SegRow( 📍 {seg.code} {seg.area} ESI {seg.esi} @@ -89,8 +89,8 @@ function SegRow(
민감 {seg.sensitivity} @@ -98,8 +98,9 @@ function SegRow(
현황 {seg.status} @@ -160,7 +161,7 @@ function ScatLeftPanel({ {/* Filters */}
- + 해안 조사 구역
@@ -257,12 +258,10 @@ function ScatLeftPanel({
- + 해안 구간 목록 - - 총 {filtered.length}개 구간 - + 총 {filtered.length}개 구간
diff --git a/frontend/src/tabs/scat/components/ScatMap.tsx b/frontend/src/tabs/scat/components/ScatMap.tsx index 9fb5227..8dd2f4b 100644 --- a/frontend/src/tabs/scat/components/ScatMap.tsx +++ b/frontend/src/tabs/scat/components/ScatMap.tsx @@ -1,11 +1,8 @@ import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; -import { Map, useControl, useMap } from '@vis.gl/react-maplibre'; -import { MapboxOverlay } from '@deck.gl/mapbox'; +import { useMap } from '@vis.gl/react-maplibre'; import { PathLayer, ScatterplotLayer } from '@deck.gl/layers'; -import 'maplibre-gl/dist/maplibre-gl.css'; -import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; -import { S57EncOverlay } from '@common/components/map/S57EncOverlay'; -import { useMapStore } from '@common/store/mapStore'; +import { BaseMap } from '@common/components/map/BaseMap'; +import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay'; import type { ScatSegment } from './scatTypes'; import type { ApiZoneItem } from '../services/scatApi'; import { esiColor } from './scatConstants'; @@ -20,16 +17,9 @@ interface ScatMapProps { onOpenPopup: (idx: number) => void; } -// ── DeckGLOverlay ────────────────────────────────────── -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function DeckGLOverlay({ layers }: { layers: any[] }) { - const overlay = useControl(() => new MapboxOverlay({ interleaved: true })); - overlay.setProps({ layers }); - return null; -} - -// ── FlyTo Controller: 선택 구간 변경 시 맵 이동 ───────── -function FlyToController({ +// ── FlyTo: 선택 구간·관할해경 변경 시 맵 이동 ────────────── +// 두 가지 트리거를 독립적으로 처리해 공통 FlyToController로 통합 불가 +function ScatFlyToController({ selectedSeg, zones, }: { @@ -40,7 +30,7 @@ function FlyToController({ const prevIdRef = useRef(undefined); const prevZonesLenRef = useRef(0); - // 선택 구간 변경 시 + // 선택 구간 변경 시 이동 (첫 렌더 제외) useEffect(() => { if (!map) return; if (prevIdRef.current !== undefined && prevIdRef.current !== selectedSeg.id) { @@ -49,7 +39,7 @@ function FlyToController({ prevIdRef.current = selectedSeg.id; }, [map, selectedSeg]); - // 관할해경(zones) 변경 시 지도 중심 이동 + // 관할해경(zones) 변경 시 중심 이동 useEffect(() => { if (!map || zones.length === 0) return; if (prevZonesLenRef.current === zones.length) return; @@ -72,13 +62,11 @@ function getZoomScale(zoom: number) { selPolyWidth: 2 + zScale * 5, glowWidth: 4 + zScale * 14, halfLenScale: 0.15 + zScale * 0.85, - markerRadius: Math.round(6 + zScale * 16), - showStatusMarker: zoom >= 11, + dotRadius: Math.round(4 + zScale * 10), }; } // ── 세그먼트 폴리라인 좌표 생성 (lng, lat 순서) ────────── -// 인접 구간 좌표로 해안선 방향을 동적 계산 function buildSegCoords( seg: ScatSegment, halfLenScale: number, @@ -100,7 +88,6 @@ function buildSegCoords( ]; } -// ── 툴팁 상태 ─────────────────────────────────────────── interface TooltipState { x: number; y: number; @@ -116,12 +103,19 @@ function ScatMap({ onSelectSeg, onOpenPopup, }: ScatMapProps) { - const currentMapStyle = useBaseMapStyle(); - const mapToggles = useMapStore((s) => s.mapToggles); - const [zoom, setZoom] = useState(10); const [tooltip, setTooltip] = useState(null); + // zones 첫 렌더 기준으로 초기 중심 좌표 결정 (이후 불변) + const [initialCenter] = useState<[number, number]>(() => + zones.length > 0 + ? [ + zones.reduce((a, z) => a + z.latCenter, 0) / zones.length, + zones.reduce((a, z) => a + z.lngCenter, 0) / zones.length, + ] + : [33.38, 126.55], + ); + const handleClick = useCallback( (seg: ScatSegment) => { onSelectSeg(seg); @@ -132,23 +126,6 @@ function ScatMap({ const zs = useMemo(() => getZoomScale(zoom), [zoom]); - // 제주도 해안선 레퍼런스 라인 — 하드코딩 제거, 추후 DB 기반 해안선으로 대체 예정 - // const coastlineLayer = useMemo( - // () => - // new PathLayer({ - // id: 'jeju-coastline', - // data: [{ path: jejuCoastCoords.map(([lat, lng]) => [lng, lat] as [number, number]) }], - // getPath: (d: { path: [number, number][] }) => d.path, - // getColor: [6, 182, 212, 46], - // getWidth: 1.5, - // getDashArray: [8, 6], - // dashJustified: true, - // widthMinPixels: 1, - // }), - // [], - // ) - - // 선택된 구간 글로우 레이어 const glowLayer = useMemo( () => new PathLayer({ @@ -168,7 +145,6 @@ function ScatMap({ [selectedSeg, segments, zs.glowWidth, zs.halfLenScale], ); - // ESI 색상 세그먼트 폴리라인 const segPathLayer = useMemo( () => new PathLayer({ @@ -183,14 +159,11 @@ function ScatMap({ getWidth: (d: ScatSegment) => (selectedSeg.id === d.id ? zs.selPolyWidth : zs.polyWidth), capRounded: true, jointRounded: true, - widthMinPixels: 1, + widthMinPixels: 2, pickable: true, onHover: (info: { object?: ScatSegment; x: number; y: number }) => { - if (info.object) { - setTooltip({ x: info.x, y: info.y, seg: info.object }); - } else { - setTooltip(null); - } + if (info.object) setTooltip({ x: info.x, y: info.y, seg: info.object }); + else setTooltip(null); }, onClick: (info: { object?: ScatSegment }) => { if (info.object) handleClick(info.object); @@ -204,46 +177,58 @@ function ScatMap({ [segments, selectedSeg, zs, handleClick], ); - // 조사 상태 마커 (줌 >= 11 시 표시) - const markerLayer = useMemo(() => { - if (!zs.showStatusMarker) return null; - return new ScatterplotLayer({ - id: 'scat-status-markers', - data: segments, - getPosition: (d: ScatSegment) => [d.lng, d.lat], - getRadius: zs.markerRadius, - getFillColor: (d: ScatSegment) => { - if (d.status === '완료') return [34, 197, 94, 51]; - if (d.status === '진행중') return [234, 179, 8, 51]; - return [100, 116, 139, 51]; - }, - getLineColor: (d: ScatSegment) => { - if (d.status === '완료') return [34, 197, 94, 200]; - if (d.status === '진행중') return [234, 179, 8, 200]; - return [100, 116, 139, 200]; - }, - getLineWidth: 1, - stroked: true, - radiusMinPixels: 4, - radiusMaxPixels: 22, - radiusUnits: 'pixels', - pickable: true, - onClick: (info: { object?: ScatSegment }) => { - if (info.object) handleClick(info.object); - }, - updateTriggers: { - getRadius: [zs.markerRadius], - }, - }); - }, [segments, zs.showStatusMarker, zs.markerRadius, handleClick]); + const shadowDotLayer = useMemo( + () => + new ScatterplotLayer({ + id: 'scat-shadow-dots', + data: segments, + getPosition: (d) => [d.lng, d.lat], + getRadius: zs.dotRadius + 2, + getFillColor: [0, 0, 0, 70], + stroked: false, + radiusUnits: 'pixels', + radiusMinPixels: 7, + radiusMaxPixels: 18, + pickable: false, + updateTriggers: { getRadius: [zs.dotRadius] }, + }), + [segments, zs.dotRadius], + ); + + const dotLayer = useMemo( + () => + new ScatterplotLayer({ + id: 'scat-dots', + data: segments, + getPosition: (d) => [d.lng, d.lat], + getRadius: zs.dotRadius, + getFillColor: (d) => { + if (d.status === '완료') return [34, 197, 94, 210]; + if (d.status === '진행중') return [234, 179, 8, 210]; + return [148, 163, 184, 200]; + }, + stroked: false, + radiusUnits: 'pixels', + radiusMinPixels: 5, + radiusMaxPixels: 16, + pickable: true, + onHover: (info: { object?: ScatSegment; x: number; y: number }) => { + if (info.object) setTooltip({ x: info.x, y: info.y, seg: info.object }); + else setTooltip(null); + }, + onClick: (info: { object?: ScatSegment }) => { + if (info.object) handleClick(info.object); + }, + updateTriggers: { getRadius: [zs.dotRadius] }, + }), + [segments, zs.dotRadius, handleClick], + ); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const deckLayers: any[] = useMemo(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const layers: any[] = [glowLayer, segPathLayer]; - if (markerLayer) layers.push(markerLayer); - return layers; - }, [glowLayer, segPathLayer, markerLayer]); + const deckLayers: any[] = useMemo( + () => [glowLayer, segPathLayer, shadowDotLayer, dotLayer], + [glowLayer, segPathLayer, shadowDotLayer, dotLayer], + ); const totalLen = segments.reduce((a, s) => a + s.lengthM, 0); const doneLen = segments.filter((s) => s.status === '완료').reduce((a, s) => a + s.lengthM, 0); @@ -253,24 +238,10 @@ function ScatMap({ return (
- { - 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)} - > - + - - + + {/* 호버 툴팁 */} {tooltip && ( @@ -287,11 +258,9 @@ function ScatMap({ whiteSpace: 'nowrap', }} > -
- {tooltip.seg.code} {tooltip.seg.area} -
+
{tooltip.seg.name}
- ESI {tooltip.seg.esi} · {tooltip.seg.length} ·{' '} + {tooltip.seg.code} · ESI {tooltip.seg.esi} ·{' '} {tooltip.seg.status === '완료' ? '✓' : tooltip.seg.status === '진행중' ? '⏳' : '—'}{' '} {tooltip.seg.status}
@@ -301,7 +270,7 @@ function ScatMap({ {/* Status chips */}
- + Pre-SCAT 사전조사
@@ -342,25 +311,6 @@ function ScatMap({
조사 정보
- {/*
-
-
-
-
*/} - {/*
- 완료 {donePct}% - 진행 {progPct}% - 미조사 {notPct}% -
*/}
{[ ['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''], @@ -388,19 +338,6 @@ function ScatMap({
- - {/* Coordinates */} - {/*
- - 위도 {selectedSeg.lat.toFixed(4)}°N - - - 경도 {selectedSeg.lng.toFixed(4)}°E - - - 축척 1:25,000 - -
*/}
); } diff --git a/frontend/src/tabs/scat/components/ScatRightPanel.tsx b/frontend/src/tabs/scat/components/ScatRightPanel.tsx index f43a8d8..8b9af91 100644 --- a/frontend/src/tabs/scat/components/ScatRightPanel.tsx +++ b/frontend/src/tabs/scat/components/ScatRightPanel.tsx @@ -70,7 +70,8 @@ export default function ScatRightPanel({ : 'text-fg-disabled hover:text-fg-sub' }`} > - {tab.icon} {tab.label} + {/* {tab.icon} */} + {tab.label} ))}
diff --git a/frontend/src/tabs/weather/components/WeatherView.tsx b/frontend/src/tabs/weather/components/WeatherView.tsx index 783c0ee..b2edfd2 100755 --- a/frontend/src/tabs/weather/components/WeatherView.tsx +++ b/frontend/src/tabs/weather/components/WeatherView.tsx @@ -1,12 +1,8 @@ import { useState, useMemo, useCallback } from 'react'; -import { Map, Marker, useControl } from '@vis.gl/react-maplibre'; -import { MapboxOverlay } from '@deck.gl/mapbox'; -import type { Layer } from '@deck.gl/core'; -import type { MapLayerMouseEvent } from 'maplibre-gl'; +import { Marker } from '@vis.gl/react-maplibre'; import 'maplibre-gl/dist/maplibre-gl.css'; -import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; -import { S57EncOverlay } from '@common/components/map/S57EncOverlay'; -import { useMapStore } from '@common/store/mapStore'; +import { BaseMap } from '@common/components/map/BaseMap'; +import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay'; import { WeatherRightPanel } from './WeatherRightPanel'; import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay'; // import { OceanForecastOverlay } from './OceanForecastOverlay' @@ -16,7 +12,6 @@ import { WindParticleLayer } from './WindParticleLayer'; import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer'; import { useWeatherData } from '../hooks/useWeatherData'; // import { useOceanForecast } from '../hooks/useOceanForecast' -import { WeatherMapControls } from './WeatherMapControls'; import { degreesToCardinal } from '../services/weatherUtils'; type TimeOffset = '0' | '3' | '6' | '9'; @@ -89,13 +84,6 @@ const generateForecast = (timeOffset: TimeOffset): WeatherForecast[] => { const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5]; // [lng, lat] const WEATHER_MAP_ZOOM = 7; -// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록) -function DeckGLOverlay({ layers }: { layers: Layer[] }) { - const overlay = useControl(() => new MapboxOverlay({ interleaved: true })); - overlay.setProps({ layers }); - return null; -} - /** * WeatherMapInner — Map 컴포넌트 내부 (useMap / useControl 사용 가능 영역) */ @@ -104,8 +92,6 @@ interface WeatherMapInnerProps { enabledLayers: Set; selectedStationId: string | null; onStationClick: (station: WeatherStation) => void; - mapCenter: [number, number]; - mapZoom: number; clickedLocation: { lat: number; lon: number } | null; } @@ -114,8 +100,6 @@ function WeatherMapInner({ enabledLayers, selectedStationId, onStationClick, - mapCenter, - mapZoom, clickedLocation, }: WeatherMapInnerProps) { // deck.gl layers 조합 @@ -183,17 +167,12 @@ function WeatherMapInner({
)} - - {/* 줌 컨트롤 */} - ); } export function WeatherView() { const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS); - const currentMapStyle = useBaseMapStyle(); - const mapToggles = useMapStore((s) => s.mapToggles); // const { // selectedForecast, @@ -220,8 +199,7 @@ export function WeatherView() { }, []); const handleMapClick = useCallback( - (e: MapLayerMouseEvent) => { - const { lat, lng } = e.lngLat; + (lng: number, lat: number) => { if (weatherStations.length === 0) return; // 가장 가까운 관측소 선택 @@ -331,28 +309,19 @@ export function WeatherView() { {/* Map */}
- - - + {/* 레이어 컨트롤 */}
From 388116aa889422a106f14223e91ba98abd86579d Mon Sep 17 00:00:00 2001 From: leedano Date: Tue, 14 Apr 2026 17:32:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 7415bfa..8dd07de 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,9 @@ ## [Unreleased] +### 변경 +- MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용 + ## [2026-04-14] ### 추가