From d8d236c624228c482ab95559f70cef4757a23694 Mon Sep 17 00:00:00 2001 From: leedano Date: Thu, 9 Apr 2026 14:55:53 +0900 Subject: [PATCH 1/9] =?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=A0=95=EB=A6=AC=20(2026-04-09)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 5bb6047..d88decc 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-04-09] + ### 추가 - 디자인 시스템 Float 카탈로그 추가 (Modal / Dropdown / Overlay / Toast) - 디자인 시스템 폰트/색상 토큰을 전 탭 컴포넌트에 전면 적용 (admin, aerial, assets, board, hns, incidents, prediction, reports, rescue, scat, weather) From 7e0da5ea7642730bf9dedeae7518f2f1edef22eb Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Thu, 9 Apr 2026 16:52:14 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(hns):=20=ED=8C=8C=ED=8B=B0=ED=81=B4=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=84=B1=EB=8A=A5=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94=20=EB=B0=8F=20=EC=9C=84=ED=97=98=EB=8F=84=20?= =?UTF-8?q?=EB=B1=83=EC=A7=80=20=EB=8F=99=EC=A0=81=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../components/map/HydrParticleOverlay.tsx | 218 ++++++++++-------- .../tabs/prediction/components/RightPanel.tsx | 66 +++++- 2 files changed, 182 insertions(+), 102 deletions(-) diff --git a/frontend/src/common/components/map/HydrParticleOverlay.tsx b/frontend/src/common/components/map/HydrParticleOverlay.tsx index 7f57f8a..a825242 100644 --- a/frontend/src/common/components/map/HydrParticleOverlay.tsx +++ b/frontend/src/common/components/map/HydrParticleOverlay.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef } from 'react'; import { useMap } from '@vis.gl/react-maplibre'; import type { HydrDataStep } from '@tabs/prediction/services/predictionApi'; -import { useThemeStore } from '@common/store/themeStore'; interface HydrParticleOverlayProps { hydrStep: HydrDataStep | null; @@ -9,24 +8,13 @@ interface HydrParticleOverlayProps { const PARTICLE_COUNT = 3000; const MAX_AGE = 300; -const SPEED_SCALE = 0.1; +const SPEED_SCALE = 0.15; const DT = 600; -const TRAIL_LENGTH = 30; // 파티클당 저장할 화면 좌표 수 -const NUM_ALPHA_BANDS = 4; // stroke 배치 단위 - -interface TrailPoint { - x: number; - y: number; -} -interface Particle { - lon: number; - lat: number; - trail: TrailPoint[]; - age: number; -} +const DEG_TO_RAD = Math.PI / 180; +const PI_4 = Math.PI / 4; +const FADE_ALPHA = 0.02; // 프레임당 페이드량 (낮을수록 긴 꼬리) export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayProps) { - const lightMode = useThemeStore((s) => s.theme) === 'light'; const { current: map } = useMap(); const animRef = useRef(); @@ -52,21 +40,21 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro const lats: number[] = [boundLonLat.bottom]; for (const d of latInterval) lats.push(lats[lats.length - 1] + d); + function bisect(arr: number[], val: number): number { + let lo = 0, + hi = arr.length - 2; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (val < arr[mid]) hi = mid - 1; + else if (val >= arr[mid + 1]) lo = mid + 1; + else return mid; + } + return -1; + } + function getUV(lon: number, lat: number): [number, number] { - let col = -1, - row = -1; - for (let i = 0; i < lons.length - 1; i++) { - if (lon >= lons[i] && lon < lons[i + 1]) { - col = i; - break; - } - } - for (let i = 0; i < lats.length - 1; i++) { - if (lat >= lats[i] && lat < lats[i + 1]) { - row = i; - break; - } - } + const col = bisect(lons, lon); + const row = bisect(lats, lat); if (col < 0 || row < 0) return [0, 0]; const fx = (lon - lons[col]) / (lons[col + 1] - lons[col]); const fy = (lat - lats[row]) / (lats[row + 1] - lats[row]); @@ -78,96 +66,134 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro v01 = v2d[row]?.[col + 1] ?? v00; const v10 = v2d[row + 1]?.[col] ?? v00, v11 = v2d[row + 1]?.[col + 1] ?? v00; - const u = - u00 * (1 - fx) * (1 - fy) + u01 * fx * (1 - fy) + u10 * (1 - fx) * fy + u11 * fx * fy; - const v = - v00 * (1 - fx) * (1 - fy) + v01 * fx * (1 - fy) + v10 * (1 - fx) * fy + v11 * fx * fy; - return [u, v]; + const _1fx = 1 - fx, + _1fy = 1 - fy; + return [ + u00 * _1fx * _1fy + u01 * fx * _1fy + u10 * _1fx * fy + u11 * fx * fy, + v00 * _1fx * _1fy + v01 * fx * _1fy + v10 * _1fx * fy + v11 * fx * fy, + ]; } const bbox = boundLonLat; - const particles: Particle[] = Array.from({ length: PARTICLE_COUNT }, () => ({ - lon: bbox.left + Math.random() * (bbox.right - bbox.left), - lat: bbox.bottom + Math.random() * (bbox.top - bbox.bottom), - trail: [], - age: Math.floor(Math.random() * MAX_AGE), - })); + const bboxW = bbox.right - bbox.left; + const bboxH = bbox.top - bbox.bottom; - function resetParticle(p: Particle) { - p.lon = bbox.left + Math.random() * (bbox.right - bbox.left); - p.lat = bbox.bottom + Math.random() * (bbox.top - bbox.bottom); - p.trail = []; - p.age = 0; + // 파티클: 위치 + 이전 화면좌표 (선분 1개만 그리면 됨) + const pLon = new Float64Array(PARTICLE_COUNT); + const pLat = new Float64Array(PARTICLE_COUNT); + const pAge = new Int32Array(PARTICLE_COUNT); + const pPrevX = new Float32Array(PARTICLE_COUNT); // 이전 프레임 화면 X + const pPrevY = new Float32Array(PARTICLE_COUNT); // 이전 프레임 화면 Y + const pHasPrev = new Uint8Array(PARTICLE_COUNT); // 이전 좌표 유효 여부 + + for (let i = 0; i < PARTICLE_COUNT; i++) { + pLon[i] = bbox.left + Math.random() * bboxW; + pLat[i] = bbox.bottom + Math.random() * bboxH; + pAge[i] = Math.floor(Math.random() * MAX_AGE); + pHasPrev[i] = 0; } - // 지도 이동/줌 시 화면 좌표가 틀어지므로 trail 초기화 + function resetParticle(i: number) { + pLon[i] = bbox.left + Math.random() * bboxW; + pLat[i] = bbox.bottom + Math.random() * bboxH; + pAge[i] = 0; + pHasPrev[i] = 0; + } + + // Mercator 수동 투영 + function lngToMercX(lng: number, worldSize: number): number { + return ((lng + 180) / 360) * worldSize; + } + function latToMercY(lat: number, worldSize: number): number { + return ((1 - Math.log(Math.tan(PI_4 + (lat * DEG_TO_RAD) / 2)) / Math.PI) / 2) * worldSize; + } + + // 지도 이동 시 캔버스 초기화 + 이전 좌표 무효화 const onMove = () => { - for (const p of particles) p.trail = []; + ctx.clearRect(0, 0, canvas.width, canvas.height); + for (let i = 0; i < PARTICLE_COUNT; i++) pHasPrev[i] = 0; }; map.on('move', onMove); function animate() { - // 매 프레임 완전 초기화 → 잔상 없음 - ctx.clearRect(0, 0, canvas.width, canvas.height); + const w = canvas.width; + const h = canvas.height; - // alpha band별 세그먼트 버퍼 (드로우 콜 최소화) - const bands: [number, number, number, number][][] = Array.from( - { length: NUM_ALPHA_BANDS }, - () => [], - ); + // ── 페이드: 기존 내용을 서서히 지움 (destination-out) ── + ctx.globalCompositeOperation = 'destination-out'; + ctx.fillStyle = `rgba(0, 0, 0, ${FADE_ALPHA})`; + ctx.fillRect(0, 0, w, h); + ctx.globalCompositeOperation = 'source-over'; - for (const p of particles) { - const [u, v] = getUV(p.lon, p.lat); - const speed = Math.sqrt(u * u + v * v); - if (speed < 0.001) { - resetParticle(p); + // 뷰포트 transform (프레임당 1회) + const zoom = map.getZoom(); + const center = map.getCenter(); + const bearing = map.getBearing(); + const worldSize = 512 * Math.pow(2, zoom); + const cx = lngToMercX(center.lng, worldSize); + const cy = latToMercY(center.lat, worldSize); + const halfW = w / 2; + const halfH = h / 2; + const bearingRad = -bearing * DEG_TO_RAD; + const cosB = Math.cos(bearingRad); + const sinB = Math.sin(bearingRad); + const hasBearing = Math.abs(bearing) > 0.01; + + // ── 파티클당 선분 1개만 그리기 (3000 선분) ── + ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + + for (let i = 0; i < PARTICLE_COUNT; i++) { + const lon = pLon[i], + lat = pLat[i]; + const [u, v] = getUV(lon, lat); + const speed2 = u * u + v * v; + if (speed2 < 0.000001) { + resetParticle(i); continue; } - const cosLat = Math.cos((p.lat * Math.PI) / 180); - p.lon += (u * SPEED_SCALE * DT) / (cosLat * 111320); - p.lat += (v * SPEED_SCALE * DT) / 111320; - p.age++; + const cosLat = Math.cos(lat * DEG_TO_RAD); + pLon[i] = lon + (u * SPEED_SCALE * DT) / (cosLat * 111320); + pLat[i] = lat + (v * SPEED_SCALE * DT) / 111320; + pAge[i]++; if ( - p.lon < bbox.left || - p.lon > bbox.right || - p.lat < bbox.bottom || - p.lat > bbox.top || - p.age > MAX_AGE + pLon[i] < bbox.left || + pLon[i] > bbox.right || + pLat[i] < bbox.bottom || + pLat[i] > bbox.top || + pAge[i] > MAX_AGE ) { - resetParticle(p); + resetParticle(i); continue; } - const curr = map.project([p.lon, p.lat]); - if (!curr) continue; - - p.trail.push({ x: curr.x, y: curr.y }); - if (p.trail.length > TRAIL_LENGTH) p.trail.shift(); - if (p.trail.length < 2) continue; - - for (let i = 1; i < p.trail.length; i++) { - const t = i / p.trail.length; // 0=oldest, 1=newest - const band = Math.min(NUM_ALPHA_BANDS - 1, Math.floor(t * NUM_ALPHA_BANDS)); - const a = p.trail[i - 1], - b = p.trail[i]; - bands[band].push([a.x, a.y, b.x, b.y]); + // 수동 Mercator 투영 + let dx = lngToMercX(pLon[i], worldSize) - cx; + let dy = latToMercY(pLat[i], worldSize) - cy; + if (hasBearing) { + const rx = dx * cosB - dy * sinB; + const ry = dx * sinB + dy * cosB; + dx = rx; + dy = ry; } + const sx = dx + halfW; + const sy = dy + halfH; + + // 이전 좌표가 있으면 선분 1개 추가 + if (pHasPrev[i]) { + ctx.moveTo(pPrevX[i], pPrevY[i]); + ctx.lineTo(sx, sy); + } + + pPrevX[i] = sx; + pPrevY[i] = sy; + pHasPrev[i] = 1; } - // alpha band별 일괄 렌더링 - ctx.lineWidth = 0.8; - for (let b = 0; b < NUM_ALPHA_BANDS; b++) { - const [pr, pg, pb] = lightMode ? [30, 90, 180] : [180, 210, 255]; - ctx.strokeStyle = `rgba(${pr}, ${pg}, ${pb}, ${((b + 1) / NUM_ALPHA_BANDS) * 0.75})`; - ctx.beginPath(); - for (const [x1, y1, x2, y2] of bands[b]) { - ctx.moveTo(x1, y1); - ctx.lineTo(x2, y2); - } - ctx.stroke(); - } + ctx.stroke(); animRef.current = requestAnimationFrame(animate); } @@ -186,7 +212,7 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro map.off('move', onMove); canvas.remove(); }; - }, [map, hydrStep, lightMode]); + }, [map, hydrStep]); return null; } diff --git a/frontend/src/tabs/prediction/components/RightPanel.tsx b/frontend/src/tabs/prediction/components/RightPanel.tsx index 483c862..a31e6d2 100755 --- a/frontend/src/tabs/prediction/components/RightPanel.tsx +++ b/frontend/src/tabs/prediction/components/RightPanel.tsx @@ -329,7 +329,11 @@ export function RightPanel({ {/* 오염 종합 상황 */} -
+
{/* 확산 예측 요약 */} -
+
= 500) return SEVERITY_LEVELS[0]; // 심각 (중앙방제대책본부) + if (volumeKl >= 50) return SEVERITY_LEVELS[1]; // 경계 (광역방제대책본부) + if (volumeKl >= 10) return SEVERITY_LEVELS[2]; // 주의 (지역방제대책본부) + return SEVERITY_LEVELS[3]; // 관심 +} + +/** 확산 예측 요약 — 확산거리(km) + 속도(m/s) 중 높은 등급 */ +function getSpreadSeverity( + distanceKm: number | null | undefined, + speedMs: number | null | undefined, +): SeverityLevel | null { + if (distanceKm == null && speedMs == null) return null; + + const distLevel = + distanceKm == null ? 3 : distanceKm >= 15 ? 0 : distanceKm >= 5 ? 1 : distanceKm >= 1 ? 2 : 3; + const speedLevel = + speedMs == null ? 3 : speedMs >= 0.3 ? 0 : speedMs >= 0.15 ? 1 : speedMs >= 0.05 ? 2 : 3; + + return SEVERITY_LEVELS[Math.min(distLevel, speedLevel)]; +} + // 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)]', +}; + function Section({ title, badge, @@ -607,7 +663,7 @@ function Section({ }: { title: string; badge?: string; - badgeColor?: 'red' | 'green'; + badgeColor?: 'red' | 'orange' | 'yellow' | 'green'; children: React.ReactNode; }) { return ( @@ -617,9 +673,7 @@ function Section({ {badge && ( {badge} From 0d53f850b233e0b1a001ae5dec9c313c3216eac0 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Thu, 9 Apr 2026 16:54:31 +0900 Subject: [PATCH 3/9] =?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 Co-Authored-By: Claude Opus 4.6 --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 5bb6047..32fccc3 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -5,6 +5,8 @@ ## [Unreleased] ### 추가 +- HNS 확산 파티클 렌더링 성능 최적화 (TypedArray + 수동 Mercator 투영 + 페이드 트레일) +- 오염 종합 상황/확산 예측 요약 위험도 뱃지 동적 표시 (심각/경계/주의/관심 4단계) - 디자인 시스템 Float 카탈로그 추가 (Modal / Dropdown / Overlay / Toast) - 디자인 시스템 폰트/색상 토큰을 전 탭 컴포넌트에 전면 적용 (admin, aerial, assets, board, hns, incidents, prediction, reports, rescue, scat, weather) - SR 민감자원 벡터타일 오버레이 컴포넌트 및 백엔드 프록시 엔드포인트 추가 From 1142e0cc46d3b72138857b5f4a55a071944c55f9 Mon Sep 17 00:00:00 2001 From: Nan Kyung Lee Date: Sat, 11 Apr 2026 07:04:20 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat(admin):=20=EB=B9=84=EC=8B=9D=EB=B3=84?= =?UTF-8?q?=ED=99=94=EC=A1=B0=EC=B9=98=20=EB=A9=94=EB=89=B4=20=EB=B0=8F=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 연계관리 하위에 비식별화조치 메뉴를 추가하고, 작업 관리 그리드·5단계 마법사·감사로그 모달을 구현 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/tabs/admin/components/AdminView.tsx | 6 +- .../tabs/admin/components/DeidentifyPanel.tsx | 1241 +++++++++++++++++ .../tabs/admin/components/adminMenuConfig.ts | 1 + 3 files changed, 1246 insertions(+), 2 deletions(-) create mode 100644 frontend/src/tabs/admin/components/DeidentifyPanel.tsx diff --git a/frontend/src/tabs/admin/components/AdminView.tsx b/frontend/src/tabs/admin/components/AdminView.tsx index 420b648..9708651 100755 --- a/frontend/src/tabs/admin/components/AdminView.tsx +++ b/frontend/src/tabs/admin/components/AdminView.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import React, { useState } from 'react'; import AdminSidebar from './AdminSidebar'; import AdminPlaceholder from './AdminPlaceholder'; import { findMenuLabel } from './adminMenuConfig'; @@ -19,9 +19,10 @@ import MonitorVesselPanel from './MonitorVesselPanel'; import CollectHrPanel from './CollectHrPanel'; import MonitorForecastPanel from './MonitorForecastPanel'; import VesselMaterialsPanel from './VesselMaterialsPanel'; +import DeidentifyPanel from './DeidentifyPanel'; /** 기존 패널이 있는 메뉴 ID 매핑 */ -const PANEL_MAP: Record JSX.Element> = { +const PANEL_MAP: Record React.JSX.Element> = { users: () => , permissions: () => , menus: () => , @@ -42,6 +43,7 @@ const PANEL_MAP: Record JSX.Element> = { 'monitor-vessel': () => , 'collect-hr': () => , 'monitor-forecast': () => , + deidentify: () => , }; export function AdminView() { diff --git a/frontend/src/tabs/admin/components/DeidentifyPanel.tsx b/frontend/src/tabs/admin/components/DeidentifyPanel.tsx new file mode 100644 index 0000000..e5a579f --- /dev/null +++ b/frontend/src/tabs/admin/components/DeidentifyPanel.tsx @@ -0,0 +1,1241 @@ +import { useState, useEffect, useCallback } from 'react'; + +// ─── 타입 ────────────────────────────────────────────────── + +type TaskStatus = '완료' | '진행중' | '대기' | '오류'; + +interface AuditLogEntry { + id: string; + time: string; + operator: string; + operatorId: string; + action: string; + targetData: string; + result: string; + resultType: '성공' | '실패' | '거부' | '진행중'; + ip: string; + browser: string; + detail: { + dataCount: number; + rulesApplied: string; + processedCount: number; + errorCount: number; + }; +} + +interface DeidentifyTask { + id: string; + name: string; + target: string; + status: TaskStatus; + startTime: string; + progress: number; + createdBy: string; +} + +type SourceType = 'db' | 'file' | 'api'; +type ProcessMode = 'immediate' | 'scheduled' | 'oneshot'; +type RepeatType = 'daily' | 'weekly' | 'monthly'; +type DeidentifyTechnique = + | '마스킹' + | '삭제' + | '범주화' + | '암호화' + | '샘플링' + | '가명처리' + | '유지'; + +interface FieldConfig { + name: string; + dataType: string; + technique: DeidentifyTechnique; + configValue: string; + selected: boolean; +} + +interface DbConfig { + host: string; + port: string; + database: string; + tableName: string; +} + +interface ApiConfig { + url: string; + method: 'GET' | 'POST'; +} + +interface ScheduleConfig { + hour: string; + repeatType: RepeatType; + weekday: string; + startDate: string; + notifyOnComplete: boolean; + notifyOnError: boolean; +} + +interface OneshotConfig { + date: string; + hour: string; +} + +interface WizardState { + step: number; + taskName: string; + sourceType: SourceType; + dbConfig: DbConfig; + apiConfig: ApiConfig; + fields: FieldConfig[]; + processMode: ProcessMode; + scheduleConfig: ScheduleConfig; + oneshotConfig: OneshotConfig; + saveAsTemplate: boolean; + applyTemplate: string; + confirmed: boolean; +} + +// ─── 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: '박담당' }, +]; + +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 }, +]; + +const TECHNIQUES: DeidentifyTechnique[] = ['마스킹', '삭제', '범주화', '암호화', '샘플링', '가명처리', '유지']; + +const HOURS = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`); + +const WEEKDAYS = ['월', '화', '수', '목', '금', '토', '일']; + +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 } }, + ], + '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 } }, + ], + '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 } }, + ], + '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 } }, + ], + '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 } }, + ], +}; + +function fetchTasks(): Promise { + return new Promise((resolve) => { + setTimeout(() => resolve(MOCK_TASKS), 300); + }); +} + +// ─── 상태 뱃지 ───────────────────────────────────────────── + +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'; + } +} + +// ─── 진행률 바 ───────────────────────────────────────────── + +function ProgressBar({ value }: { value: number }) { + const colorClass = + value === 100 ? 'bg-emerald-500' : value > 0 ? 'bg-cyan-500' : 'bg-bg-elevated'; + return ( +
+
+
+
+ {value}% +
+ ); +} + +// ─── 작업 테이블 ──────────────────────────────────────────── + +const TABLE_HEADERS = ['작업ID', '작업명', '대상', '상태', '시작시간', '진행률', '등록자', '액션']; + +interface TaskTableProps { + rows: DeidentifyTask[]; + loading: boolean; + onAction: (action: string, task: DeidentifyTask) => void; +} + +function TaskTable({ rows, loading, onAction }: TaskTableProps) { + return ( +
+ + + + {TABLE_HEADERS.map((h) => ( + + ))} + + + + {loading && rows.length === 0 + ? Array.from({ length: 5 }).map((_, i) => ( + + {TABLE_HEADERS.map((_, j) => ( + + ))} + + )) + : rows.map((row) => ( + + + + + + + + + + + ))} + +
+ {h} +
+
+
{row.id}{row.name}{row.target} + + {row.status} + + {row.startTime} + + {row.createdBy} +
+ + + + + +
+
+
+ ); +} + +// ─── 마법사: 단계 표시기 ──────────────────────────────────── + +const STEP_LABELS = ['소스선택', '데이터검증', '비식별화규칙', '처리방식', '최종확인']; + +function StepIndicator({ current }: { current: number }) { + return ( +
+ {STEP_LABELS.map((label, i) => { + const stepNum = i + 1; + const isDone = stepNum < current; + const isActive = stepNum === current; + return ( +
+
+
+ {isDone ? ( + + + + ) : ( + stepNum + )} +
+ + {stepNum}.{label} + +
+ {i < STEP_LABELS.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); +} + +// ─── 마법사: Step 1 ───────────────────────────────────────── + +interface Step1Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +function Step1({ wizard, onChange }: Step1Props) { + const handleDbChange = (key: keyof DbConfig, value: string) => { + onChange({ dbConfig: { ...wizard.dbConfig, [key]: value } }); + }; + const handleApiChange = (key: keyof ApiConfig, value: string) => { + onChange({ apiConfig: { ...wizard.apiConfig, [key]: value } }); + }; + + return ( +
+
+ + onChange({ taskName: e.target.value })} + placeholder="작업 이름을 입력하세요" + className="w-full px-3 py-2 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 placeholder:text-t3 focus:outline-none focus:border-cyan-500" + /> +
+ +
+ +
+ {([ + ['db', '데이터베이스 연결'], + ['file', '파일 업로드'], + ['api', 'API 호출'], + ] as [SourceType, string][]).map(([val, label]) => ( + + ))} +
+
+ + {wizard.sourceType === 'db' && ( +
+ {( + [ + ['host', '호스트', 'localhost'], + ['port', '포트', '5432'], + ['database', '데이터베이스', 'wing'], + ['tableName', '테이블명', 'public.customers'], + ] as [keyof DbConfig, string, string][] + ).map(([key, labelText, placeholder]) => ( +
+ + handleDbChange(key, e.target.value)} + placeholder={placeholder} + className="w-full px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 placeholder:text-t3 focus:outline-none focus:border-cyan-500" + /> +
+ ))} +
+ )} + + {wizard.sourceType === 'file' && ( +
+ + + +

파일을 드래그하거나 클릭하여 업로드

+

CSV, XLSX, JSON 지원 (최대 500MB)

+
+ )} + + {wizard.sourceType === 'api' && ( +
+
+ + handleApiChange('url', e.target.value)} + placeholder="https://api.example.com/data" + className="w-full px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 placeholder:text-t3 focus:outline-none focus:border-cyan-500" + /> +
+
+ + +
+
+ )} +
+ ); +} + +// ─── 마법사: Step 2 ───────────────────────────────────────── + +interface Step2Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +function Step2({ wizard, onChange }: Step2Props) { + const toggleField = (idx: number) => { + const updated = wizard.fields.map((f, i) => + i === idx ? { ...f, selected: !f.selected } : f, + ); + onChange({ fields: updated }); + }; + + return ( +
+
+ {[ + { label: '총 데이터 건수', value: '15,240건', color: 'text-t1' }, + { label: '중복', value: '0건', color: 'text-emerald-400' }, + { label: '누락값', value: '23건', color: 'text-yellow-400' }, + ].map((stat) => ( +
+

{stat.label}

+

{stat.value}

+
+ ))} +
+ +
+

스키마 분석 결과 — 포함 필드 선택

+
+ + + + + + + + + + {wizard.fields.map((field, idx) => ( + + + + + + ))} + +
+ f.selected)} + onChange={(e) => + onChange({ fields: wizard.fields.map((f) => ({ ...f, selected: e.target.checked })) }) + } + className="accent-cyan-500" + /> + 필드명데이터 타입
+ toggleField(idx)} + className="accent-cyan-500" + /> + {field.name}{field.dataType}
+
+

+ {wizard.fields.filter((f) => f.selected).length}개 선택됨 (전체 {wizard.fields.length}개) +

+
+
+ ); +} + +// ─── 마법사: Step 3 ───────────────────────────────────────── + +interface Step3Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +function Step3({ wizard, onChange }: Step3Props) { + const updateField = (idx: number, key: keyof FieldConfig, value: string | boolean) => { + const updated = wizard.fields.map((f, i) => + i === idx ? { ...f, [key]: value } : f, + ); + onChange({ fields: updated }); + }; + + const selectedFields = wizard.fields.filter((f) => f.selected); + + return ( +
+
+ + + + + + + + + + + {selectedFields.map((field) => { + const globalIdx = wizard.fields.findIndex((f) => f.name === field.name); + return ( + + + + + + + ); + })} + +
필드명데이터타입선택된 기법설정값
{field.name}{field.dataType} + + + updateField(globalIdx, 'configValue', e.target.value)} + className="w-full px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500" + /> +
+
+ +
+ + +
+ 이전 템플릿 적용: + +
+
+
+ ); +} + +// ─── 마법사: Step 4 ───────────────────────────────────────── + +interface Step4Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +function Step4({ wizard, onChange }: Step4Props) { + const handleScheduleChange = (key: keyof ScheduleConfig, value: string | boolean) => { + onChange({ scheduleConfig: { ...wizard.scheduleConfig, [key]: value } }); + }; + const handleOneshotChange = (key: keyof OneshotConfig, value: string) => { + onChange({ oneshotConfig: { ...wizard.oneshotConfig, [key]: value } }); + }; + + return ( +
+
+ {( + [ + ['immediate', '즉시 처리', '지금 바로 데이터를 비식별화합니다.'], + ['scheduled', '배치 처리 - 정기 스케줄링', '반복 일정에 따라 자동으로 처리합니다.'], + ['oneshot', '배치 처리 - 일회성', '지정한 날짜/시간에 한 번 처리합니다.'], + ] as [ProcessMode, string, string][] + ).map(([val, label, desc]) => ( +
+ + + {val === 'scheduled' && wizard.processMode === 'scheduled' && ( +
+
+ + +
+
+ +
+ {( + [ + ['daily', '매일'], + ['weekly', '주 1회'], + ['monthly', '월 1회'], + ] as [RepeatType, string][] + ).map(([rt, rl]) => ( +
+ handleScheduleChange('repeatType', rt)} + className="accent-cyan-500" + /> + {rl} + {rt === 'weekly' && wizard.scheduleConfig.repeatType === 'weekly' && ( + + )} +
+ ))} +
+
+
+ + handleScheduleChange('startDate', 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" + /> +
+
+ + +
+
+ )} + + {val === 'oneshot' && wizard.processMode === 'oneshot' && ( +
+
+ + handleOneshotChange('date', 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" + /> +
+
+ + +
+
+ )} +
+ ))} +
+
+ ); +} + +// ─── 마법사: Step 5 ───────────────────────────────────────── + +interface Step5Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +function Step5({ wizard, onChange }: Step5Props) { + const selectedCount = wizard.fields.filter((f) => f.selected).length; + const ruleCount = wizard.fields.filter((f) => f.selected && f.technique !== '유지').length; + + const processModeLabel: Record = { + immediate: '즉시 처리', + scheduled: `배치 - 정기 (${wizard.scheduleConfig.hour} / ${wizard.scheduleConfig.repeatType === 'daily' ? '매일' : wizard.scheduleConfig.repeatType === 'weekly' ? `주1회 ${wizard.scheduleConfig.weekday}요일` : '월1회'})`, + oneshot: `배치 - 일회성 (${wizard.oneshotConfig.date} ${wizard.oneshotConfig.hour})`, + }; + + 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: '15,240건' }, + { label: '선택 필드 수', value: `${selectedCount}개` }, + { label: '비식별화 규칙 수', value: `${ruleCount}개` }, + { label: '처리 방식', value: processModeLabel[wizard.processMode] }, + { label: '예상 처리시간', value: '약 3~5분' }, + ]; + + return ( +
+
+ + + {summaryRows.map(({ label, value }) => ( + + + + + ))} + +
{label}{value}
+
+ + +
+ ); +} + +// ─── 마법사 모달 ───────────────────────────────────────────── + +const INITIAL_WIZARD: WizardState = { + step: 1, + taskName: '', + sourceType: 'db', + dbConfig: { host: '', port: '5432', database: '', tableName: '' }, + apiConfig: { url: '', method: 'GET' }, + fields: DEFAULT_FIELDS, + processMode: 'immediate', + scheduleConfig: { + hour: '02:00', + repeatType: 'daily', + weekday: '월', + startDate: '', + notifyOnComplete: true, + notifyOnError: true, + }, + oneshotConfig: { date: '', hour: '02:00' }, + saveAsTemplate: false, + applyTemplate: '', + confirmed: false, +}; + +// ─── 감사로그 모달 ───────────────────────────────────────── + +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'; + } +} + +interface AuditLogModalProps { + task: DeidentifyTask; + onClose: () => void; +} + +function AuditLogModal({ task, onClose }: AuditLogModalProps) { + const logs = MOCK_AUDIT_LOGS[task.id] ?? []; + const [selectedLog, setSelectedLog] = useState(null); + const [filterOperator, setFilterOperator] = useState('모두'); + const [startDate, setStartDate] = useState('2026-04-01'); + const [endDate, setEndDate] = useState('2026-04-11'); + + const operators = ['모두', ...Array.from(new Set(logs.map((l) => l.operator)))]; + const filteredLogs = logs.filter((l) => { + if (filterOperator !== '모두' && l.operator !== filterOperator) return false; + return true; + }); + + return ( +
+
+ {/* 헤더 */} +
+

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

+ +
+ + {/* 필터 바 */} +
+ 기간: + setStartDate(e.target.value)} + className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500" + /> + ~ + setEndDate(e.target.value)} + className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500" + /> + 작업자: + +
+ + {/* 로그 테이블 */} +
+ + + + {['시간', '작업자', '작업', '대상 데이터', '결과', '상세'].map((h) => ( + + ))} + + + + {filteredLogs.length === 0 ? ( + + + + ) : ( + filteredLogs.map((log) => ( + setSelectedLog(log)} + > + + + + + + + + )) + )} + +
+ {h} +
+ 감사로그가 없습니다. +
{log.time.split(' ')[1]}{log.operator}{log.action}{log.targetData} + + {log.result} + + + +
+
+ + {/* 로그 상세 정보 */} + {selectedLog && ( +
+

로그 상세 정보

+
+
로그ID: {selectedLog.id}
+
타임스탬프: {selectedLog.time}
+
작업자: {selectedLog.operator} ({selectedLog.operatorId})
+
작업 유형: {selectedLog.action}
+
대상: {selectedLog.targetData} ({selectedLog.detail.dataCount.toLocaleString()}건)
+
적용 규칙: {selectedLog.detail.rulesApplied}
+
결과: {selectedLog.result} (처리: {selectedLog.detail.processedCount.toLocaleString()}, 오류: {selectedLog.detail.errorCount})
+
IP 주소: {selectedLog.ip}
+
브라우저: {selectedLog.browser}
+
+
+ )} + + {/* 하단 버튼 */} +
+ + + +
+
+
+ ); +} + +// ─── 마법사 모달 ─────────────────────────────────────────── + +interface WizardModalProps { + onClose: () => void; + onSubmit: (wizard: WizardState) => void; +} + +function WizardModal({ onClose, onSubmit }: WizardModalProps) { + const [wizard, setWizard] = useState(INITIAL_WIZARD); + + const patch = useCallback((update: Partial) => { + setWizard((prev) => ({ ...prev, ...update })); + }, []); + + const handleNext = () => { + if (wizard.step < 5) patch({ step: wizard.step + 1 }); + }; + const handlePrev = () => { + if (wizard.step > 1) patch({ step: wizard.step - 1 }); + }; + const handleSubmit = () => { + onSubmit(wizard); + onClose(); + }; + + const canProceed = () => { + if (wizard.step === 1) return wizard.taskName.trim().length > 0; + if (wizard.step === 2) return wizard.fields.some((f) => f.selected); + if (wizard.step === 5) return wizard.confirmed; + return true; + }; + + return ( +
+
+ {/* 모달 헤더 */} +
+

새 비식별화 작업

+ +
+ + {/* 단계 표시기 */} + + + {/* 단계 내용 */} +
+ {wizard.step === 1 && } + {wizard.step === 2 && } + {wizard.step === 3 && } + {wizard.step === 4 && } + {wizard.step === 5 && } +
+ + {/* 푸터 버튼 */} +
+ +
+ + {wizard.step < 5 ? ( + + ) : ( + + )} +
+
+
+
+ ); +} + +// ─── 메인 패널 ────────────────────────────────────────────── + +type FilterStatus = '모두' | TaskStatus; + +export default function DeidentifyPanel() { + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(false); + const [showWizard, setShowWizard] = useState(false); + const [auditTask, setAuditTask] = useState(null); + const [searchName, setSearchName] = useState(''); + const [filterStatus, setFilterStatus] = useState('모두'); + const [filterPeriod, setFilterPeriod] = useState<'7' | '30' | '90'>('30'); + + const loadTasks = useCallback(async () => { + setLoading(true); + const data = await fetchTasks(); + setTasks(data); + setLoading(false); + }, []); + + useEffect(() => { + let isMounted = true; + if (tasks.length === 0) { + void Promise.resolve().then(() => { + if (isMounted) void loadTasks(); + }); + } + return () => { + isMounted = false; + }; + }, [tasks.length, loadTasks]); + + const handleAction = useCallback((action: string, task: DeidentifyTask) => { + // TODO: 실제 API 연동 시 각 액션에 맞는 API 호출로 교체 + if (action === 'delete') { + setTasks((prev) => prev.filter((t) => t.id !== task.id)); + } else if (action === 'audit') { + setAuditTask(task); + } + }, []); + + 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; + if (filterStatus !== '모두' && t.status !== filterStatus) return false; + return true; + }); + + const completedCount = tasks.filter((t) => t.status === '완료').length; + const inProgressCount = tasks.filter((t) => t.status === '진행중').length; + const errorCount = tasks.filter((t) => t.status === '오류').length; + + return ( +
+ {/* 헤더 */} +
+

비식별화조치

+ +
+ + {/* 상태 요약 */} +
+ + + 완료 {completedCount}건 + + + + 진행중 {inProgressCount}건 + + {errorCount > 0 && ( + + + 오류 {errorCount}건 + + )} + 전체 {tasks.length}건 +
+ + {/* 검색/필터 */} +
+ setSearchName(e.target.value)} + placeholder="작업명 검색" + className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 placeholder:text-t3 focus:outline-none focus:border-cyan-500 w-40" + /> + + +
+ + {/* 테이블 */} +
+ +
+ + {/* 감사로그 모달 */} + {auditTask && ( + setAuditTask(null)} /> + )} + + {/* 마법사 모달 */} + {showWizard && ( + setShowWizard(false)} + onSubmit={handleWizardSubmit} + /> + )} +
+ ); +} diff --git a/frontend/src/tabs/admin/components/adminMenuConfig.ts b/frontend/src/tabs/admin/components/adminMenuConfig.ts index d719756..3494ced 100644 --- a/frontend/src/tabs/admin/components/adminMenuConfig.ts +++ b/frontend/src/tabs/admin/components/adminMenuConfig.ts @@ -91,6 +91,7 @@ export const ADMIN_MENU: AdminMenuItem[] = [ { id: 'monitor-vessel', label: '선박위치정보' }, ], }, + { id: 'deidentify', label: '비식별화조치' }, ], }, ]; From 387e2a2e405353d15d76be3d39776e4f27e28133 Mon Sep 17 00:00:00 2001 From: Nan Kyung Lee Date: Sat, 11 Apr 2026 19:46:12 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat(rescue):=20=EA=B8=B4=EA=B8=89=EA=B5=AC?= =?UTF-8?q?=EB=82=9C/=EC=98=88=EC=B8=A1=EB=8F=84=20OSM=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=ED=8C=A8=EB=84=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RescueView: CenterMap을 MapView(useBaseMapStyle) 기반 OSM 지도로 교체 - RescueScenarioView: BASE_STYLE → useBaseMapStyle로 전환하여 OSM 통일 - 긴급구난 시나리오 시드 데이터 10건으로 확장 (모델 이론 기반) - 관리자 비식별화조치 R&D 패널 5종 추가 (HNS대기, KOSPS, POSEIDON, Rescue, 시스템아키텍처) Co-Authored-By: Claude Opus 4.6 (1M context) --- database/migration/016_rescue.sql | 124 +- .../src/tabs/admin/components/AdminView.tsx | 10 + .../admin/components/RndHnsAtmosPanel.tsx | 638 +++++++ .../tabs/admin/components/RndKospsPanel.tsx | 638 +++++++ .../admin/components/RndPoseidonPanel.tsx | 665 +++++++ .../tabs/admin/components/RndRescuePanel.tsx | 638 +++++++ .../tabs/admin/components/SystemArchPanel.tsx | 1544 +++++++++++++++++ .../tabs/admin/components/adminMenuConfig.ts | 11 + .../rescue/components/RescueScenarioView.tsx | 681 +++++++- .../src/tabs/rescue/components/RescueView.tsx | 378 ++-- 10 files changed, 5074 insertions(+), 253 deletions(-) create mode 100644 frontend/src/tabs/admin/components/RndHnsAtmosPanel.tsx create mode 100644 frontend/src/tabs/admin/components/RndKospsPanel.tsx create mode 100644 frontend/src/tabs/admin/components/RndPoseidonPanel.tsx create mode 100644 frontend/src/tabs/admin/components/RndRescuePanel.tsx create mode 100644 frontend/src/tabs/admin/components/SystemArchPanel.tsx diff --git a/database/migration/016_rescue.sql b/database/migration/016_rescue.sql index 5e8bd3c..9a2c9a1 100644 --- a/database/migration/016_rescue.sql +++ b/database/migration/016_rescue.sql @@ -128,55 +128,125 @@ INSERT INTO RESCUE_OPS ( ); -- ============================================================ --- 4. RESCUE_SCENARIO 시드 데이터 (5건, RESCUE_OPS_SN=1 기준) +-- 4. RESCUE_SCENARIO 시드 데이터 (10건, RESCUE_OPS_SN=1 기준) +-- 긴급구난 모델 이론 기반 시간 단계별 시나리오 +-- - 손상복원성(Damage Stability): GM, 횡경사, 트림 진행 +-- - 종강도(Longitudinal Strength): BM 비율 모니터링 +-- - 유출 모델링: 파공부 유출률 변화 +-- - 부력 잔여량: 침수 구획 확대에 따른 부력 변화 -- ============================================================ INSERT INTO RESCUE_SCENARIO ( RESCUE_OPS_SN, TIME_STEP, SCENARIO_DTM, SVRT_CD, GM_M, LIST_DEG, TRIM_M, BUOYANCY_PCT, OIL_RATE_LPM, BM_RATIO_PCT, DESCRIPTION, COMPARTMENTS, ASSESSMENT, ACTIONS, SORT_ORD ) VALUES +-- S-01: 사고 발생 (Initial Impact) +-- 충돌 직후 초기 손상 상태. 손상복원성 이론에 따라 파공부 침수 시작, GM 급락 ( 1, 'T+0h', '2024-10-27 10:30:00+09', 'CRITICAL', 0.8, 15.0, 2.5, 30.0, 100.0, 92.0, - '좌현 35° 충돌로 No.1P 화물탱크 파공, 벙커C유 유출 개시. 좌현 경사 15°, GM 위험수준.', + '좌현 35° 충돌로 No.1P 화물탱크 파공. 벙커C유 유출 개시. 손상복원성 분석: 초기 GM 0.8m으로 IMO 기준(1.0m) 미달, 복원력 위험 판정.', '[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"BREACHED","color":"var(--red)"},{"name":"#2 Port Tank","status":"RISK","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]', - '[{"label":"복원력","value":"위험 (GM < 1.0m)","color":"var(--red)"},{"label":"유출 위험","value":"활발 유출중","color":"var(--red)"},{"label":"선체 강도","value":"BM 92% (경계)","color":"var(--orange)"},{"label":"승선인원","value":"15/20 확인, 5명 수색중","color":"var(--red)"}]', - '[{"time":"10:30","text":"충돌 발생, VHF Ch.16 조난 통보","color":"var(--red)"},{"time":"10:35","text":"해경 3009함 출동 지시","color":"var(--orange)"},{"time":"10:42","text":"인근 선박 구조 활동 개시","color":"var(--cyan)"},{"time":"10:50","text":"유출유 방제선 배치 요청","color":"var(--orange)"}]', + '[{"label":"복원력","value":"위험 (GM 0.8m < IMO 1.0m)","color":"var(--red)"},{"label":"유출 위험","value":"활발 유출중 (100 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 92% (경계)","color":"var(--orange)"},{"label":"승선인원","value":"15/20 확인, 5명 수색중","color":"var(--red)"}]', + '[{"time":"10:30","text":"충돌 발생, VHF Ch.16 조난 통보 (GMDSS DSC Alert)","color":"var(--red)"},{"time":"10:32","text":"EPIRB 자동 발신 확인","color":"var(--red)"},{"time":"10:35","text":"해경 3009함 출동 지시","color":"var(--orange)"},{"time":"10:42","text":"인근 선박 구조 활동 개시","color":"var(--cyan)"}]', 1 ), +-- S-02: 초동 손상 평가 (Emergency Damage Assessment) +-- 잠수사 투입, 파공부 규모 확인. 침수 진행 모델링: 파공면적 A, 수두차 h 기반 유입률 Q=Cd·A·√(2gh) ( - 1, 'T+2h', '2024-10-27 12:30:00+09', 'HIGH', - 0.6, 18.0, 3.2, 25.0, 150.0, 88.0, - '침수 확대로 경사 증가, 유출량 증가 추세. 긴급 이초 작업 검토 필요.', - '[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]', - '[{"label":"복원력","value":"위험 (GM 0.6m)","color":"var(--red)"},{"label":"유출 위험","value":"증가 추세","color":"var(--red)"},{"label":"선체 강도","value":"BM 88%","color":"var(--orange)"},{"label":"승선인원","value":"전원 퇴선 완료","color":"var(--green)"}]', - '[{"time":"12:00","text":"2차 침수 확인 (#2 PT)","color":"var(--red)"},{"time":"12:15","text":"긴급 이초 작업 개시","color":"var(--orange)"},{"time":"12:20","text":"오일펜스 1차 전개 완료","color":"var(--cyan)"},{"time":"12:30","text":"항공기 유출유 촬영 요청","color":"var(--cyan)"}]', + 1, 'T+30m', '2024-10-27 11:00:00+09', 'CRITICAL', + 0.7, 17.0, 2.8, 28.0, 120.0, 90.0, + '잠수사 수중 조사 결과 좌현 No.1P 파공 크기 1.2m×0.8m 확인. Bernoulli 유입률 모델 적용: 수두차 4.5m 기준 유입률 약 2.1㎥/min. 30분 경과 침수량 추정 63㎥.', + '[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"BREACHED","color":"var(--red)"},{"name":"#2 Port Tank","status":"RISK","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]', + '[{"label":"복원력","value":"악화 (GM 0.7m, GZ 커브 감소)","color":"var(--red)"},{"label":"유출 위험","value":"증가 (120 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 90% — 종강도 모니터링 개시","color":"var(--orange)"},{"label":"승선인원","value":"15명 퇴선, 5명 수색중","color":"var(--red)"}]', + '[{"time":"10:50","text":"잠수사 투입, 수중 손상 조사 개시","color":"var(--cyan)"},{"time":"10:55","text":"파공 규모 확인: 1.2m×0.8m, 수선 하 2.5m","color":"var(--red)"},{"time":"11:00","text":"손상복원성 재계산 — IMO Res.A.749 기준 위험","color":"var(--red)"},{"time":"11:00","text":"유출유 방제선 배치 요청","color":"var(--orange)"}]', 2 ), +-- S-03: 구조 작전 개시 (SAR Operations Initiated) +-- 해경 함정 현장 도착, 인명 구조 우선. GM 지속 하락, 복원력 한계 접근 ( - 1, 'T+6h', '2024-10-27 16:30:00+09', 'HIGH', - 0.4, 12.0, 2.8, 35.0, 80.0, 90.0, - '평형수 이동으로 경사 일부 복원. 유출률 감소 추세.', - '[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"RISK","color":"var(--orange)"}]', - '[{"label":"복원력","value":"개선 추세 (GM 0.4m)","color":"var(--orange)"},{"label":"유출 위험","value":"감소 추세","color":"var(--orange)"},{"label":"선체 강도","value":"BM 90%","color":"var(--orange)"},{"label":"구조 상황","value":"구조 작전 진행중","color":"var(--cyan)"}]', - '[{"time":"14:00","text":"평형수 이동 작업 개시","color":"var(--cyan)"},{"time":"15:00","text":"해상크레인 도착","color":"var(--cyan)"},{"time":"15:30","text":"잔류유 이적 작업 개시","color":"var(--orange)"},{"time":"16:30","text":"예인준비 완료","color":"var(--green)"}]', + 1, 'T+1h', '2024-10-27 11:30:00+09', 'CRITICAL', + 0.65, 18.5, 3.0, 26.0, 135.0, 89.0, + '해경 3009함 현장 도착, SAR 작전 개시. 표류 예측 모델(Leeway Model) 적용: 풍속 8m/s, 해류 2.5kn NE 조건에서 실종자 표류 반경 1.2nm 산정. GZ 커브 분석: 최대 복원력 각도 25°로 감소.', + '[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODING","color":"var(--red)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]', + '[{"label":"복원력","value":"한계 접근 (GM 0.65m, GZ_max 25°)","color":"var(--red)"},{"label":"유출 위험","value":"파공 확대 우려 (135 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 89% — Hogging 모멘트 증가","color":"var(--orange)"},{"label":"인명구조","value":"실종 5명 수색중, 표류 반경 1.2nm","color":"var(--red)"}]', + '[{"time":"11:10","text":"해경 3009함 현장 도착, SAR 구역 설정","color":"var(--cyan)"},{"time":"11:15","text":"실종자 Leeway 표류 예측 모델 적용","color":"var(--cyan)"},{"time":"11:20","text":"회전익 항공기 수색 개시 (R=1.2nm)","color":"var(--cyan)"},{"time":"11:30","text":"#2 Port Tank 2차 침수 징후 감지","color":"var(--red)"}]', 3 ), +-- S-04: 침수 확대 및 복원력 위기 (Flooding Progression & Stability Crisis) +-- 2차 구획 침수, 자유표면효과(Free Surface Effect) 반영 GM 급락 ( - 1, 'T+12h', '2024-10-27 22:30:00+09', 'MEDIUM', - 0.6, 8.0, 1.5, 50.0, 30.0, 94.0, - '예인 작업 진행중, 선체 안정화 확인. 유출 대부분 차단.', - '[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]', - '[{"label":"복원력","value":"안정 (GM 0.6m)","color":"var(--orange)"},{"label":"유출 위험","value":"대부분 차단","color":"var(--green)"},{"label":"선체 강도","value":"BM 94%","color":"var(--green)"},{"label":"예인 상태","value":"목포항 예인 진행중","color":"var(--cyan)"}]', - '[{"time":"18:00","text":"예인 개시 (목포항 방향)","color":"var(--cyan)"},{"time":"19:00","text":"유출유 차단 확인","color":"var(--green)"},{"time":"20:00","text":"야간 감시 체제 전환","color":"var(--orange)"},{"time":"22:30","text":"예인 50% 진행","color":"var(--cyan)"}]', + 1, 'T+2h', '2024-10-27 12:30:00+09', 'CRITICAL', + 0.5, 20.0, 3.5, 22.0, 160.0, 86.0, + '격벽 관통으로 #2 Port Tank 침수 확대. 자유표면효과(FSE) 보정: GM_fluid = GM_solid - Σ(i/∇) = 0.5m. 종강도 분석: 중앙부 Sagging 모멘트 허용치 86% 도달. 침몰 위험 단계 진입.', + '[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]', + '[{"label":"복원력","value":"위기 (GM 0.5m, FSE 보정 후)","color":"var(--red)"},{"label":"유출 위험","value":"최대치 접근 (160 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 86% — Sagging 허용치 경고","color":"var(--red)"},{"label":"승선인원","value":"실종 3명 발견, 2명 수색 지속","color":"var(--orange)"}]', + '[{"time":"12:00","text":"#2 Port Tank 격벽 관통 침수 확인","color":"var(--red)"},{"time":"12:10","text":"자유표면효과(FSE) 보정 재계산","color":"var(--red)"},{"time":"12:15","text":"긴급 Counter-Flooding 검토","color":"var(--orange)"},{"time":"12:30","text":"실종자 3명 추가 발견 구조","color":"var(--green)"}]', 4 ), +-- S-05: 응급 복원 작업 (Emergency Counter-Flooding) +-- Counter-Flooding 이론: 반대편 구획 의도적 침수로 횡경사 교정 +( + 1, 'T+3h', '2024-10-27 13:30:00+09', 'HIGH', + 0.55, 16.0, 3.2, 25.0, 140.0, 87.0, + 'Counter-Flooding 실시: #3 Stbd Tank에 평형수 280톤 주입하여 횡경사 20°→16° 교정. 복원력 일시적 개선. 종강도: Counter-Flooding으로 중량 재배분, BM 87% 유지. 유출률 감소 추세.', + '[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]', + '[{"label":"복원력","value":"개선 중 (GM 0.55m, 경사 16°)","color":"var(--orange)"},{"label":"유출 위험","value":"감소 추세 (140 L/min)","color":"var(--orange)"},{"label":"선체 강도","value":"BM 87% — Counter-Flooding 영향 평가","color":"var(--orange)"},{"label":"구조 상황","value":"실종 2명 수색 지속, 헬기 투입","color":"var(--orange)"}]', + '[{"time":"12:45","text":"Counter-Flooding 결정 — #3 Stbd 평형수 주입 개시","color":"var(--orange)"},{"time":"13:00","text":"평형수 280톤 주입, 횡경사 20°→18° 교정 진행","color":"var(--cyan)"},{"time":"13:15","text":"종강도 재계산 — 허용 범위 내 확인","color":"var(--cyan)"},{"time":"13:30","text":"횡경사 16° 안정화, 유출률 감소 확인","color":"var(--green)"}]', + 5 +), +-- S-06: 선체 안정화 및 잔류유 이적 (Hull Stabilization & Oil Transfer) +-- 평형수 조정 완료, 임시 보강. Trim/Stability Booklet 기준 안정 범위 진입 +( + 1, 'T+6h', '2024-10-27 16:30:00+09', 'HIGH', + 0.7, 12.0, 2.5, 32.0, 80.0, 90.0, + '임시 수중패치 설치, 유입률 감소. 평형수 재조정으로 GM 0.7m 회복. Trim/Stability Booklet 기준 예인 가능 최소 조건(GM≥0.5m, List≤15°) 충족. 잔류유 이적선(M/T) 배치.', + '[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]', + '[{"label":"복원력","value":"개선 (GM 0.7m, 예인 가능 조건 충족)","color":"var(--orange)"},{"label":"유출 위험","value":"수중패치 효과 (80 L/min)","color":"var(--orange)"},{"label":"선체 강도","value":"BM 90% — 안정 범위","color":"var(--green)"},{"label":"구조 상황","value":"전원 구조 완료 (실종 2명 발견)","color":"var(--green)"}]', + '[{"time":"14:00","text":"수중패치 설치 작업 개시","color":"var(--cyan)"},{"time":"14:30","text":"잠수사 수중패치 설치 완료","color":"var(--green)"},{"time":"15:00","text":"해상크레인 도착, 잔류유 이적 준비","color":"var(--cyan)"},{"time":"16:30","text":"잔류유 1차 이적 완료 (약 45kL), 예인 준비 개시","color":"var(--green)"}]', + 6 +), +-- S-07: 오일 방제 전개 (Oil Boom Deployment & Containment) +-- 방제 이론: 오일붐 2중 전개, 유회수기 배치, 확산 모델 기반 방제 구역 설정 +( + 1, 'T+8h', '2024-10-27 18:30:00+09', 'MEDIUM', + 0.8, 10.0, 2.0, 38.0, 55.0, 91.0, + '오일붐 2중 전개 완료, 유회수기 3대 가동. 유출유 확산 예측 모델(GNOME) 적용: 풍향 NE 8m/s, 해류 2.5kn 조건에서 12시간 후 확산 면적 2.3km² 예측. 기계적 회수율 35% 달성.', + '[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]', + '[{"label":"복원력","value":"안정 (GM 0.8m)","color":"var(--orange)"},{"label":"유출 위험","value":"방제 진행 (55 L/min, 회수율 35%)","color":"var(--orange)"},{"label":"선체 강도","value":"BM 91%","color":"var(--green)"},{"label":"방제 현황","value":"오일붐 2중, 유회수기 3대 가동","color":"var(--cyan)"}]', + '[{"time":"17:00","text":"오일붐 1차 전개 (500m)","color":"var(--cyan)"},{"time":"17:30","text":"오일붐 2차 전개 (300m, 이중 방어선)","color":"var(--cyan)"},{"time":"17:45","text":"유회수기 3대 배치·가동 개시","color":"var(--cyan)"},{"time":"18:30","text":"GNOME 확산 예측 갱신 — 방제 구역 재설정","color":"var(--orange)"}]', + 7 +), +-- S-08: 예인 작업 개시 (Towing Operation Commenced) +-- 예인 이론: 예인 저항 계산, 기상·해상 조건 판단, 예인 경로 최적화 +( + 1, 'T+12h', '2024-10-27 22:30:00+09', 'MEDIUM', + 0.9, 8.0, 1.5, 45.0, 30.0, 94.0, + '예인 개시. 예인 저항 계산: Rt = 1/2·ρ·Cd·A·V² 기반 예인선 4,000HP급 배정. 예인 경로: 현 위치→목포항 직선 42nm, 예인 속도 3kn 기준 ETA 14시간. 야간 감시 체제 전환.', + '[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]', + '[{"label":"복원력","value":"안정 (GM 0.9m)","color":"var(--orange)"},{"label":"유출 위험","value":"억제 중 (30 L/min)","color":"var(--green)"},{"label":"선체 강도","value":"BM 94% — 예인 하중 반영","color":"var(--green)"},{"label":"예인 상태","value":"목포항 방향, ETA 14h, 3kn","color":"var(--cyan)"}]', + '[{"time":"18:00","text":"예인 접속 완료, 예인삭 250m 전개","color":"var(--cyan)"},{"time":"18:30","text":"예인 개시 (목포항 방향, 3kn)","color":"var(--cyan)"},{"time":"20:00","text":"야간 감시 체제 전환 (2시간 교대)","color":"var(--orange)"},{"time":"22:30","text":"예인 진행률 30%, 선체 상태 안정","color":"var(--green)"}]', + 8 +), +-- S-09: 이동 중 감시 및 안정성 유지 (Transit Monitoring) +-- 예인 중 동적 안정성 모니터링: 파랑 응답(RAO) 기반 횡동요 예측 +( + 1, 'T+18h', '2024-10-28 04:30:00+09', 'MEDIUM', + 1.0, 5.0, 1.0, 55.0, 15.0, 96.0, + '예인 진행률 65%. 파랑 응답 분석(RAO): 유의파고 1.2m, 주기 6s 조건에서 횡동요 진폭 ±3° 예측 — 안전 범위 내. 잔류 유출률 15 L/min으로 대폭 감소. 선체 안정성 지속 개선.', + '[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"STABLE","color":"var(--green)"}]', + '[{"label":"복원력","value":"양호 (GM 1.0m, IMO 기준 충족)","color":"var(--green)"},{"label":"유출 위험","value":"미량 유출 (15 L/min)","color":"var(--green)"},{"label":"선체 강도","value":"BM 96% — 정상 범위","color":"var(--green)"},{"label":"예인 상태","value":"진행률 65%, ETA 5.5h","color":"var(--cyan)"}]', + '[{"time":"00:00","text":"야간 예인 정상 진행, 기상 양호","color":"var(--green)"},{"time":"02:00","text":"파랑 응답 분석 — 안전 범위 확인","color":"var(--green)"},{"time":"03:00","text":"잔류유 유출률 15 L/min 확인","color":"var(--green)"},{"time":"04:30","text":"목포항 VTS 통보, 입항 예정 협의","color":"var(--cyan)"}]', + 9 +), +-- S-10: 상황 종료 및 사후 평가 (Resolution & Post-Assessment) +-- 접안 완료, 잔류유 이적, 사후 안정성 평가 ( 1, 'T+24h', '2024-10-28 10:30:00+09', 'RESOLVED', 1.2, 3.0, 0.5, 75.0, 5.0, 98.0, - '목포항 도착, 선체 안정. 잔류유 이적 완료.', - '[{"name":"#1 FP Tank","status":"SEALED","color":"var(--orange)"},{"name":"#1 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]', - '[{"label":"복원력","value":"안전 (GM 1.2m)","color":"var(--green)"},{"label":"유출 위험","value":"차단 완료","color":"var(--green)"},{"label":"선체 강도","value":"BM 98% 정상","color":"var(--green)"},{"label":"예인 상태","value":"목포항 접안 완료","color":"var(--green)"}]', - '[{"time":"06:00","text":"목포항 접근","color":"var(--cyan)"},{"time":"08:00","text":"도선사 승선, 접안 개시","color":"var(--cyan)"},{"time":"09:30","text":"접안 완료","color":"var(--green)"},{"time":"10:30","text":"잔류유 이적 완료, 상황 종료","color":"var(--green)"}]', - 5 + '목포항 접안 완료. 잔류유 전량 이적(총 120kL). 최종 손상복원성 평가: GM 1.2m으로 IMO 기준 충족, 횡경사 3° 잔류. 종강도 BM 98% 정상. 방제 총 회수량 85kL (회수율 71%). 상황 종료 선포.', + '[{"name":"#1 FP Tank","status":"SEALED","color":"var(--orange)"},{"name":"#1 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"STABLE","color":"var(--green)"}]', + '[{"label":"복원력","value":"안전 (GM 1.2m, IMO 기준 초과)","color":"var(--green)"},{"label":"유출 위험","value":"차단 완료 (잔류 5 L/min)","color":"var(--green)"},{"label":"선체 강도","value":"BM 98% 정상","color":"var(--green)"},{"label":"최종 상태","value":"접안 완료, 잔류유 이적 완료","color":"var(--green)"}]', + '[{"time":"06:00","text":"목포항 접근, 도선사 대기","color":"var(--cyan)"},{"time":"08:00","text":"도선사 승선, 접안 개시","color":"var(--cyan)"},{"time":"09:30","text":"접안 완료, 잔류유 이적선 접현","color":"var(--green)"},{"time":"10:30","text":"잔류유 전량 이적 완료, 상황 종료 선포","color":"var(--green)"}]', + 10 ); diff --git a/frontend/src/tabs/admin/components/AdminView.tsx b/frontend/src/tabs/admin/components/AdminView.tsx index 9708651..574dd66 100755 --- a/frontend/src/tabs/admin/components/AdminView.tsx +++ b/frontend/src/tabs/admin/components/AdminView.tsx @@ -20,6 +20,11 @@ import CollectHrPanel from './CollectHrPanel'; import MonitorForecastPanel from './MonitorForecastPanel'; import VesselMaterialsPanel from './VesselMaterialsPanel'; import DeidentifyPanel from './DeidentifyPanel'; +import RndPoseidonPanel from './RndPoseidonPanel'; +import RndKospsPanel from './RndKospsPanel'; +import RndHnsAtmosPanel from './RndHnsAtmosPanel'; +import RndRescuePanel from './RndRescuePanel'; +import SystemArchPanel from './SystemArchPanel'; /** 기존 패널이 있는 메뉴 ID 매핑 */ const PANEL_MAP: Record React.JSX.Element> = { @@ -44,6 +49,11 @@ const PANEL_MAP: Record React.JSX.Element> = { 'collect-hr': () => , 'monitor-forecast': () => , deidentify: () => , + 'rnd-poseidon': () => , + 'rnd-kosps': () => , + 'rnd-hns-atmos': () => , + 'rnd-rescue': () => , + 'system-arch': () => , }; export function AdminView() { diff --git a/frontend/src/tabs/admin/components/RndHnsAtmosPanel.tsx b/frontend/src/tabs/admin/components/RndHnsAtmosPanel.tsx new file mode 100644 index 0000000..4f006e1 --- /dev/null +++ b/frontend/src/tabs/admin/components/RndHnsAtmosPanel.tsx @@ -0,0 +1,638 @@ +import { useState, useEffect, useCallback } from 'react'; + +// ─── 타입 ────────────────────────────────────────────────────────────────────── + +type PipelineStatus = '정상' | '지연' | '중단'; +type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과'; +type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류'; +type DataSource = 'HYCOM' | '기상청' | '충북대 API'; +type AlertLevel = '경고' | '주의' | '정보'; + +interface PipelineNode { + id: string; + name: string; + status: PipelineStatus; + lastReceived: string; + cycle: string; +} + +interface DataLogRow { + id: string; + timestamp: string; + source: DataSource; + dataType: string; + size: string; + receiveStatus: ReceiveStatus; + processStatus: ProcessStatus; +} + +interface AlertItem { + id: string; + level: AlertLevel; + message: string; + timestamp: string; +} + +// ─── Mock 데이터 ──────────────────────────────────────────────────────────────── + +const MOCK_PIPELINE: PipelineNode[] = [ + { + id: 'hycom', + name: 'HYCOM 해양순환모델', + status: '정상', + lastReceived: '2026-04-11 06:00', + cycle: '6시간 주기', + }, + { + id: 'kma', + name: '기상청 수치모델', + status: '정상', + lastReceived: '2026-04-11 06:00', + cycle: '3시간 주기', + }, + { + id: 'chungbuk-api', + name: '충북대 API 서버', + status: '정상', + lastReceived: '2026-04-11 06:05', + cycle: 'API 호출', + }, + { + id: 'atmos-compute', + name: 'HNS 대기확산 연산', + status: '정상', + lastReceived: '2026-04-11 06:10', + cycle: '예측 시작 즉시', + }, + { + id: 'result-receive', + name: '결과 수신', + status: '지연', + lastReceived: '2026-04-11 06:00', + cycle: '연산 완료 즉시', + }, +]; + +const MOCK_LOGS: DataLogRow[] = [ + { + id: 'log-01', + timestamp: '2026-04-11 06:10', + source: 'HYCOM', + dataType: 'SST', + size: '98 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-02', + timestamp: '2026-04-11 06:10', + source: 'HYCOM', + dataType: '해류', + size: '142 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-03', + timestamp: '2026-04-11 06:05', + source: '기상청', + dataType: '풍향/풍속', + size: '38 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-04', + timestamp: '2026-04-11 06:05', + source: '기상청', + dataType: '기압', + size: '22 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-05', + timestamp: '2026-04-11 06:05', + source: '기상청', + dataType: '기온', + size: '19 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-06', + timestamp: '2026-04-11 06:05', + source: '기상청', + dataType: '대기안정도', + size: '14 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-07', + timestamp: '2026-04-11 06:07', + source: '충북대 API', + dataType: 'API 호출 요청', + size: '0.2 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-08', + timestamp: '2026-04-11 06:15', + source: '충북대 API', + dataType: 'HNS 확산 결과', + size: '-', + receiveStatus: '수신대기', + processStatus: '대기', + }, + { + id: 'log-09', + timestamp: '2026-04-11 06:00', + source: 'HYCOM', + dataType: 'SSH', + size: '54 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-10', + timestamp: '2026-04-11 03:05', + source: '기상청', + dataType: '풍향/풍속', + size: '37 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-11', + timestamp: '2026-04-11 03:07', + source: '충북대 API', + dataType: 'API 호출 요청', + size: '0.2 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-12', + timestamp: '2026-04-11 03:20', + source: '충북대 API', + dataType: 'HNS 확산 결과', + size: '12 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-13', + timestamp: '2026-04-11 03:20', + source: '충북대 API', + dataType: '피해범위 데이터', + size: '4 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-14', + timestamp: '2026-04-11 00:05', + source: '기상청', + dataType: '기압', + size: '23 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-15', + timestamp: '2026-04-11 00:00', + source: 'HYCOM', + dataType: 'SST', + size: '97 MB', + receiveStatus: '시간초과', + processStatus: '오류', + }, +]; + +const MOCK_ALERTS: AlertItem[] = [ + { + id: 'alert-01', + level: '주의', + message: '충북대 API 결과 수신 지연 — 최근 응답 15분 지연 (2026-04-11 06:15)', + timestamp: '2026-04-11 06:30', + }, + { + id: 'alert-02', + level: '정보', + message: 'HYCOM 데이터 정상 수신 완료 (2026-04-11 06:00)', + timestamp: '2026-04-11 06:00', + }, + { + id: 'alert-03', + level: '정보', + message: '금일 HNS 대기확산 예측 완료: 2회/4회', + timestamp: '2026-04-11 06:12', + }, +]; + +// ─── Mock fetch ───────────────────────────────────────────────────────────────── + +interface HnsAtmosData { + pipeline: PipelineNode[]; + logs: DataLogRow[]; + alerts: AlertItem[]; +} + +function fetchHnsAtmosData(): Promise { + return new Promise((resolve) => { + setTimeout( + () => + resolve({ + pipeline: MOCK_PIPELINE, + logs: MOCK_LOGS, + alerts: MOCK_ALERTS, + }), + 300, + ); + }); +} + +// ─── 유틸 ─────────────────────────────────────────────────────────────────────── + +function getPipelineStatusStyle(status: PipelineStatus): string { + if (status === '정상') return 'text-emerald-400 bg-emerald-500/10'; + if (status === '지연') return 'text-yellow-400 bg-yellow-500/10'; + return 'text-red-400 bg-red-500/10'; +} + +function getPipelineBorderStyle(status: PipelineStatus): string { + if (status === '정상') return 'border-l-emerald-500'; + if (status === '지연') return 'border-l-yellow-500'; + return 'border-l-red-500'; +} + +function getReceiveStatusStyle(status: ReceiveStatus): string { + if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10'; + if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10'; + return 'text-red-400 bg-red-500/10'; +} + +function getProcessStatusStyle(status: ProcessStatus): string { + if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10'; + if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10'; + if (status === '대기') return 'text-yellow-400 bg-yellow-500/10'; + return 'text-red-400 bg-red-500/10'; +} + +function getAlertStyle(level: AlertLevel): string { + if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30'; + if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30'; + return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30'; +} + +// ─── 파이프라인 카드 ───────────────────────────────────────────────────────────── + +function PipelineCard({ node }: { node: PipelineNode }) { + const badgeStyle = getPipelineStatusStyle(node.status); + const borderStyle = getPipelineBorderStyle(node.status); + + return ( +
+
{node.name}
+ + {node.status} + +
최근 수신: {node.lastReceived}
+
{node.cycle}
+
+ ); +} + +function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) { + if (loading && nodes.length === 0) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+ {i < 4 && } +
+ ))} +
+ ); + } + + return ( +
+ {nodes.map((node, idx) => ( +
+ + {idx < nodes.length - 1 && ( + + )} +
+ ))} +
+ ); +} + +// ─── 수신 이력 테이블 ──────────────────────────────────────────────────────────── + +type FilterSource = 'all' | DataSource; +type FilterReceive = 'all' | ReceiveStatus; +type FilterPeriod = '6h' | '12h' | '24h'; + +const PERIOD_HOURS: Record = { '6h': 6, '12h': 12, '24h': 24 }; + +function filterLogs( + rows: DataLogRow[], + source: FilterSource, + receive: FilterReceive, + period: FilterPeriod, +): DataLogRow[] { + const cutoff = new Date('2026-04-11T06:30:00'); + const hours = PERIOD_HOURS[period]; + const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000); + + return rows.filter((r) => { + if (source !== 'all' && r.source !== source) return false; + if (receive !== 'all' && r.receiveStatus !== receive) return false; + const ts = new Date(r.timestamp.replace(' ', 'T')); + if (ts < from) return false; + return true; + }); +} + +const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태']; + +function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) { + return ( +
+ + + + {LOG_HEADERS.map((h) => ( + + ))} + + + + {loading && rows.length === 0 + ? Array.from({ length: 8 }).map((_, i) => ( + + {LOG_HEADERS.map((_, j) => ( + + ))} + + )) + : rows.map((row) => ( + + + + + + + + + ))} + {!loading && rows.length === 0 && ( + + + + )} + +
+ {h} +
+
+
+ {row.timestamp} + {row.source}{row.dataType}{row.size} + + {row.receiveStatus} + + + + {row.processStatus} + +
+ 조회된 데이터가 없습니다. +
+
+ ); +} + +// ─── 알림 목록 ─────────────────────────────────────────────────────────────────── + +function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) { + if (loading && alerts.length === 0) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ); + } + + if (alerts.length === 0) { + return

활성 알림이 없습니다.

; + } + + return ( +
+ {alerts.map((alert) => ( +
+ [{alert.level}] + {alert.message} + {alert.timestamp} +
+ ))} +
+ ); +} + +// ─── 메인 패널 ─────────────────────────────────────────────────────────────────── + +export default function RndHnsAtmosPanel() { + const [pipeline, setPipeline] = useState([]); + const [logs, setLogs] = useState([]); + const [alerts, setAlerts] = useState([]); + const [loading, setLoading] = useState(false); + const [lastUpdate, setLastUpdate] = useState(null); + + // 필터 + const [filterSource, setFilterSource] = useState('all'); + const [filterReceive, setFilterReceive] = useState('all'); + const [filterPeriod, setFilterPeriod] = useState('24h'); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const data = await fetchHnsAtmosData(); + setPipeline(data.pipeline); + setLogs(data.logs); + setAlerts(data.alerts); + setLastUpdate(new Date()); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + let isMounted = true; + void Promise.resolve().then(() => { + if (isMounted) void fetchData(); + }); + return () => { + isMounted = false; + }; + }, [fetchData]); + + const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod); + + const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length; + const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length; + const totalFailed = logs.filter( + (r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과', + ).length; + + return ( +
+ {/* ── 헤더 ── */} +
+
+

HNS 대기확산 (충북대) 연계 모니터링

+
+ {lastUpdate && ( + + 갱신:{' '} + {lastUpdate.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} + + )} + +
+
+ {/* 요약 통계 바 */} +
+ + 정상 수신:{' '} + {totalReceived}건 + + | + + 지연: {totalDelayed}건 + + | + + 실패: {totalFailed}건 + + | + + 금일 예측 완료:{' '} + 2 / 4회 + +
+
+ + {/* ── 스크롤 영역 ── */} +
+ {/* 파이프라인 현황 */} +
+

+ 데이터 파이프라인 현황 +

+ +
+ + {/* 필터 바 + 수신 이력 테이블 */} +
+
+

+ 데이터 수신 이력 +

+
+ {/* 데이터소스 필터 */} + + {/* 수신상태 필터 */} + + {/* 기간 필터 */} + + {filteredLogs.length}건 +
+
+ +
+ + {/* 알림 현황 */} +
+

+ 알림 현황 +

+ +
+
+
+ ); +} diff --git a/frontend/src/tabs/admin/components/RndKospsPanel.tsx b/frontend/src/tabs/admin/components/RndKospsPanel.tsx new file mode 100644 index 0000000..7e38abb --- /dev/null +++ b/frontend/src/tabs/admin/components/RndKospsPanel.tsx @@ -0,0 +1,638 @@ +import { useState, useEffect, useCallback } from 'react'; + +// ─── 타입 ────────────────────────────────────────────────────────────────────── + +type PipelineStatus = '정상' | '지연' | '중단'; +type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과'; +type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류'; +type DataSource = 'HYCOM' | '기상청' | 'KOSPS DLL'; +type AlertLevel = '경고' | '주의' | '정보'; + +interface PipelineNode { + id: string; + name: string; + status: PipelineStatus; + lastReceived: string; + cycle: string; +} + +interface DataLogRow { + id: string; + timestamp: string; + source: DataSource; + dataType: string; + size: string; + receiveStatus: ReceiveStatus; + processStatus: ProcessStatus; +} + +interface AlertItem { + id: string; + level: AlertLevel; + message: string; + timestamp: string; +} + +// ─── Mock 데이터 ──────────────────────────────────────────────────────────────── + +const MOCK_PIPELINE: PipelineNode[] = [ + { + id: 'hycom', + name: 'HYCOM 해양순환모델', + status: '정상', + lastReceived: '2026-04-11 06:00', + cycle: '6시간 주기', + }, + { + id: 'kma', + name: '기상청 수치모델', + status: '정상', + lastReceived: '2026-04-11 06:00', + cycle: '3시간 주기', + }, + { + id: 'kosps-server', + name: '광주 KOSPS 서버', + status: '정상', + lastReceived: '2026-04-11 06:05', + cycle: '수신 즉시', + }, + { + id: 'fortran-dll', + name: 'KOSPS Fortran DLL 연산', + status: '지연', + lastReceived: '2026-04-11 05:45', + cycle: '예측 시작 즉시', + }, + { + id: 'result-api', + name: '결과 수신 API', + status: '정상', + lastReceived: '2026-04-11 06:10', + cycle: '예측 완료 즉시', + }, +]; + +const MOCK_LOGS: DataLogRow[] = [ + { + id: 'log-01', + timestamp: '2026-04-11 06:10', + source: 'KOSPS DLL', + dataType: 'DLL 응답 결과', + size: '28 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-02', + timestamp: '2026-04-11 06:05', + source: '기상청', + dataType: '풍향/풍속', + size: '38 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-03', + timestamp: '2026-04-11 06:05', + source: '기상청', + dataType: '기압', + size: '22 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-04', + timestamp: '2026-04-11 06:00', + source: 'HYCOM', + dataType: '해수면온도(SST)', + size: '98 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-05', + timestamp: '2026-04-11 06:00', + source: 'HYCOM', + dataType: '해류(U/V)', + size: '142 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-06', + timestamp: '2026-04-11 06:00', + source: '기상청', + dataType: '기온', + size: '19 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-07', + timestamp: '2026-04-11 05:55', + source: 'KOSPS DLL', + dataType: 'DLL 호출 요청', + size: '3 MB', + receiveStatus: '수신완료', + processStatus: '처리중', + }, + { + id: 'log-08', + timestamp: '2026-04-11 05:45', + source: 'KOSPS DLL', + dataType: 'DLL 응답 결과', + size: '-', + receiveStatus: '수신대기', + processStatus: '대기', + }, + { + id: 'log-09', + timestamp: '2026-04-11 03:10', + source: 'HYCOM', + dataType: '해류(U/V)', + size: '140 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-10', + timestamp: '2026-04-11 03:05', + source: '기상청', + dataType: '풍향/풍속', + size: '37 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-11', + timestamp: '2026-04-11 03:00', + source: 'HYCOM', + dataType: '해수면높이(SSH)', + size: '54 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-12', + timestamp: '2026-04-11 03:00', + source: 'KOSPS DLL', + dataType: 'DLL 응답 결과', + size: '27 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-13', + timestamp: '2026-04-11 00:05', + source: '기상청', + dataType: '기압', + size: '23 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-14', + timestamp: '2026-04-11 00:00', + source: 'HYCOM', + dataType: '해수면높이(SSH)', + size: '53 MB', + receiveStatus: '시간초과', + processStatus: '오류', + }, + { + id: 'log-15', + timestamp: '2026-04-11 00:00', + source: 'KOSPS DLL', + dataType: 'DLL 호출 요청', + size: '-', + receiveStatus: '수신실패', + processStatus: '오류', + }, +]; + +const MOCK_ALERTS: AlertItem[] = [ + { + id: 'alert-01', + level: '경고', + message: 'KOSPS Fortran DLL 응답 지연 — 평균 응답시간 초과', + timestamp: '2026-04-11 05:45', + }, + { + id: 'alert-02', + level: '주의', + message: 'HYCOM SSH 데이터 다음 수신 예정: 09:00', + timestamp: '2026-04-11 06:00', + }, + { + id: 'alert-03', + level: '정보', + message: '금일 KOSPS 예측 완료: 3회 / 6회', + timestamp: '2026-04-11 06:12', + }, +]; + +// ─── Mock fetch ───────────────────────────────────────────────────────────────── + +interface KospsData { + pipeline: PipelineNode[]; + logs: DataLogRow[]; + alerts: AlertItem[]; +} + +function fetchKospsData(): Promise { + return new Promise((resolve) => { + setTimeout( + () => + resolve({ + pipeline: MOCK_PIPELINE, + logs: MOCK_LOGS, + alerts: MOCK_ALERTS, + }), + 300, + ); + }); +} + +// ─── 유틸 ─────────────────────────────────────────────────────────────────────── + +function getPipelineStatusStyle(status: PipelineStatus): string { + if (status === '정상') return 'text-emerald-400 bg-emerald-500/10'; + if (status === '지연') return 'text-yellow-400 bg-yellow-500/10'; + return 'text-red-400 bg-red-500/10'; +} + +function getPipelineBorderStyle(status: PipelineStatus): string { + if (status === '정상') return 'border-l-emerald-500'; + if (status === '지연') return 'border-l-yellow-500'; + return 'border-l-red-500'; +} + +function getReceiveStatusStyle(status: ReceiveStatus): string { + if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10'; + if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10'; + return 'text-red-400 bg-red-500/10'; +} + +function getProcessStatusStyle(status: ProcessStatus): string { + if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10'; + if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10'; + if (status === '대기') return 'text-yellow-400 bg-yellow-500/10'; + return 'text-red-400 bg-red-500/10'; +} + +function getAlertStyle(level: AlertLevel): string { + if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30'; + if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30'; + return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30'; +} + +// ─── 파이프라인 카드 ───────────────────────────────────────────────────────────── + +function PipelineCard({ node }: { node: PipelineNode }) { + const badgeStyle = getPipelineStatusStyle(node.status); + const borderStyle = getPipelineBorderStyle(node.status); + + return ( +
+
{node.name}
+ + {node.status} + +
최근 수신: {node.lastReceived}
+
{node.cycle}
+
+ ); +} + +function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) { + if (loading && nodes.length === 0) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+ {i < 4 && } +
+ ))} +
+ ); + } + + return ( +
+ {nodes.map((node, idx) => ( +
+ + {idx < nodes.length - 1 && ( + + )} +
+ ))} +
+ ); +} + +// ─── 수신 이력 테이블 ──────────────────────────────────────────────────────────── + +type FilterSource = 'all' | DataSource; +type FilterReceive = 'all' | ReceiveStatus; +type FilterPeriod = '6h' | '12h' | '24h'; + +const PERIOD_HOURS: Record = { '6h': 6, '12h': 12, '24h': 24 }; + +function filterLogs( + rows: DataLogRow[], + source: FilterSource, + receive: FilterReceive, + period: FilterPeriod, +): DataLogRow[] { + const cutoff = new Date('2026-04-11T06:30:00'); + const hours = PERIOD_HOURS[period]; + const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000); + + return rows.filter((r) => { + if (source !== 'all' && r.source !== source) return false; + if (receive !== 'all' && r.receiveStatus !== receive) return false; + const ts = new Date(r.timestamp.replace(' ', 'T')); + if (ts < from) return false; + return true; + }); +} + +const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태']; + +function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) { + return ( +
+ + + + {LOG_HEADERS.map((h) => ( + + ))} + + + + {loading && rows.length === 0 + ? Array.from({ length: 8 }).map((_, i) => ( + + {LOG_HEADERS.map((_, j) => ( + + ))} + + )) + : rows.map((row) => ( + + + + + + + + + ))} + {!loading && rows.length === 0 && ( + + + + )} + +
+ {h} +
+
+
+ {row.timestamp} + {row.source}{row.dataType}{row.size} + + {row.receiveStatus} + + + + {row.processStatus} + +
+ 조회된 데이터가 없습니다. +
+
+ ); +} + +// ─── 알림 목록 ─────────────────────────────────────────────────────────────────── + +function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) { + if (loading && alerts.length === 0) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ); + } + + if (alerts.length === 0) { + return

활성 알림이 없습니다.

; + } + + return ( +
+ {alerts.map((alert) => ( +
+ [{alert.level}] + {alert.message} + {alert.timestamp} +
+ ))} +
+ ); +} + +// ─── 메인 패널 ─────────────────────────────────────────────────────────────────── + +export default function RndKospsPanel() { + const [pipeline, setPipeline] = useState([]); + const [logs, setLogs] = useState([]); + const [alerts, setAlerts] = useState([]); + const [loading, setLoading] = useState(false); + const [lastUpdate, setLastUpdate] = useState(null); + + // 필터 + const [filterSource, setFilterSource] = useState('all'); + const [filterReceive, setFilterReceive] = useState('all'); + const [filterPeriod, setFilterPeriod] = useState('24h'); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const data = await fetchKospsData(); + setPipeline(data.pipeline); + setLogs(data.logs); + setAlerts(data.alerts); + setLastUpdate(new Date()); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + let isMounted = true; + void Promise.resolve().then(() => { + if (isMounted) void fetchData(); + }); + return () => { + isMounted = false; + }; + }, [fetchData]); + + const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod); + + const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length; + const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length; + const totalFailed = logs.filter( + (r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과', + ).length; + + return ( +
+ {/* ── 헤더 ── */} +
+
+

유출유확산예측 (KOSPS) 연계 모니터링

+
+ {lastUpdate && ( + + 갱신:{' '} + {lastUpdate.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} + + )} + +
+
+ {/* 요약 통계 바 */} +
+ + 정상 수신:{' '} + {totalReceived}건 + + | + + 지연: {totalDelayed}건 + + | + + 실패: {totalFailed}건 + + | + + 금일 예측 완료:{' '} + 3 / 6회 + +
+
+ + {/* ── 스크롤 영역 ── */} +
+ {/* 파이프라인 현황 */} +
+

+ 데이터 파이프라인 현황 +

+ +
+ + {/* 필터 바 + 수신 이력 테이블 */} +
+
+

+ 데이터 수신 이력 +

+
+ {/* 데이터소스 필터 */} + + {/* 수신상태 필터 */} + + {/* 기간 필터 */} + + {filteredLogs.length}건 +
+
+ +
+ + {/* 알림 현황 */} +
+

+ 알림 현황 +

+ +
+
+
+ ); +} diff --git a/frontend/src/tabs/admin/components/RndPoseidonPanel.tsx b/frontend/src/tabs/admin/components/RndPoseidonPanel.tsx new file mode 100644 index 0000000..8d1bece --- /dev/null +++ b/frontend/src/tabs/admin/components/RndPoseidonPanel.tsx @@ -0,0 +1,665 @@ +import { useState, useEffect, useCallback } from 'react'; + +// ─── 타입 ────────────────────────────────────────────────────────────────────── + +type PipelineStatus = '정상' | '지연' | '중단'; +type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과'; +type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류'; +type DataSource = 'HYCOM' | '기상수치모델' | '기상기술'; +type AlertLevel = '경고' | '주의' | '정보'; + +interface PipelineNode { + id: string; + name: string; + status: PipelineStatus; + lastReceived: string; + cycle: string; +} + +interface DataLogRow { + id: string; + timestamp: string; + source: DataSource; + dataType: string; + size: string; + receiveStatus: ReceiveStatus; + processStatus: ProcessStatus; +} + +interface AlertItem { + id: string; + level: AlertLevel; + message: string; + timestamp: string; +} + +// ─── Mock 데이터 ──────────────────────────────────────────────────────────────── + +const MOCK_PIPELINE: PipelineNode[] = [ + { + id: 'hycom', + name: 'HYCOM 해양순환모델', + status: '정상', + lastReceived: '2026-04-11 06:00', + cycle: '6시간 주기', + }, + { + id: 'kma', + name: '기상수치모델(KMA)', + status: '정상', + lastReceived: '2026-04-11 06:00', + cycle: '3시간 주기', + }, + { + id: 'relay', + name: '기상기술 중계서버', + status: '지연', + lastReceived: '2026-04-11 05:30', + cycle: '3시간 주기', + }, + { + id: 'api', + name: '해경 9층 API서버', + status: '정상', + lastReceived: '2026-04-11 06:05', + cycle: '수신 즉시', + }, + { + id: 'gpu', + name: 'GPU 연산서버', + status: '정상', + lastReceived: '2026-04-11 06:10', + cycle: '예측 시작 즉시', + }, +]; + +const MOCK_LOGS: DataLogRow[] = [ + { + id: 'log-01', + timestamp: '2026-04-11 06:10', + source: 'HYCOM', + dataType: '해류(U/V)', + size: '142 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-02', + timestamp: '2026-04-11 06:05', + source: '기상수치모델', + dataType: '풍향/풍속', + size: '38 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-03', + timestamp: '2026-04-11 06:05', + source: '기상수치모델', + dataType: '기압', + size: '22 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-04', + timestamp: '2026-04-11 06:00', + source: 'HYCOM', + dataType: '해수면온도(SST)', + size: '98 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-05', + timestamp: '2026-04-11 06:00', + source: 'HYCOM', + dataType: '해수면높이(SSH)', + size: '54 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-06', + timestamp: '2026-04-11 06:00', + source: '기상수치모델', + dataType: '기온', + size: '19 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-07', + timestamp: '2026-04-11 06:00', + source: '기상수치모델', + dataType: '강수량', + size: '11 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-08', + timestamp: '2026-04-11 06:00', + source: '기상기술', + dataType: '전처리 완료 데이터', + size: '310 MB', + receiveStatus: '수신대기', + processStatus: '대기', + }, + { + id: 'log-09', + timestamp: '2026-04-11 03:10', + source: 'HYCOM', + dataType: '해류(U/V)', + size: '140 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-10', + timestamp: '2026-04-11 03:05', + source: '기상수치모델', + dataType: '풍향/풍속', + size: '37 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-11', + timestamp: '2026-04-11 03:00', + source: 'HYCOM', + dataType: '해수면온도(SST)', + size: '97 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-12', + timestamp: '2026-04-11 03:00', + source: '기상기술', + dataType: '전처리 완료 데이터', + size: '305 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-13', + timestamp: '2026-04-11 00:05', + source: '기상수치모델', + dataType: '기압', + size: '23 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-14', + timestamp: '2026-04-11 00:00', + source: 'HYCOM', + dataType: '해수면높이(SSH)', + size: '53 MB', + receiveStatus: '시간초과', + processStatus: '오류', + }, + { + id: 'log-15', + timestamp: '2026-04-11 00:00', + source: '기상기술', + dataType: '전처리 완료 데이터', + size: '-', + receiveStatus: '수신실패', + processStatus: '오류', + }, + { + id: 'log-16', + timestamp: '2026-04-10 21:05', + source: '기상수치모델', + dataType: '풍향/풍속', + size: '36 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-17', + timestamp: '2026-04-10 21:00', + source: 'HYCOM', + dataType: '해류(U/V)', + size: '139 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-18', + timestamp: '2026-04-10 21:00', + source: '기상기술', + dataType: '전처리 완료 데이터', + size: '302 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, +]; + +const MOCK_ALERTS: AlertItem[] = [ + { + id: 'alert-01', + level: '경고', + message: '기상기술 중계서버 데이터 30분 지연 — 06:00 배치 전처리 미완료', + timestamp: '2026-04-11 06:30', + }, + { + id: 'alert-02', + level: '주의', + message: 'HYCOM SSH 데이터 다음 수신 예정: 09:00', + timestamp: '2026-04-11 06:00', + }, + { + id: 'alert-03', + level: '정보', + message: 'GPU 연산서버 금일 처리 완료: 4회 / 8회', + timestamp: '2026-04-11 06:12', + }, +]; + +// ─── Mock fetch ───────────────────────────────────────────────────────────────── + +interface PoseidonData { + pipeline: PipelineNode[]; + logs: DataLogRow[]; + alerts: AlertItem[]; +} + +function fetchPoseidonData(): Promise { + return new Promise((resolve) => { + setTimeout( + () => + resolve({ + pipeline: MOCK_PIPELINE, + logs: MOCK_LOGS, + alerts: MOCK_ALERTS, + }), + 300, + ); + }); +} + +// ─── 유틸 ─────────────────────────────────────────────────────────────────────── + +function getPipelineStatusStyle(status: PipelineStatus): string { + if (status === '정상') return 'text-emerald-400 bg-emerald-500/10'; + if (status === '지연') return 'text-yellow-400 bg-yellow-500/10'; + return 'text-red-400 bg-red-500/10'; +} + +function getPipelineBorderStyle(status: PipelineStatus): string { + if (status === '정상') return 'border-l-emerald-500'; + if (status === '지연') return 'border-l-yellow-500'; + return 'border-l-red-500'; +} + +function getReceiveStatusStyle(status: ReceiveStatus): string { + if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10'; + if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10'; + return 'text-red-400 bg-red-500/10'; +} + +function getProcessStatusStyle(status: ProcessStatus): string { + if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10'; + if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10'; + if (status === '대기') return 'text-yellow-400 bg-yellow-500/10'; + return 'text-red-400 bg-red-500/10'; +} + +function getAlertStyle(level: AlertLevel): string { + if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30'; + if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30'; + return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30'; +} + +// ─── 파이프라인 카드 ───────────────────────────────────────────────────────────── + +function PipelineCard({ node }: { node: PipelineNode }) { + const badgeStyle = getPipelineStatusStyle(node.status); + const borderStyle = getPipelineBorderStyle(node.status); + + return ( +
+
{node.name}
+ + {node.status} + +
최근 수신: {node.lastReceived}
+
{node.cycle}
+
+ ); +} + +function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) { + if (loading && nodes.length === 0) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+ {i < 4 && } +
+ ))} +
+ ); + } + + return ( +
+ {nodes.map((node, idx) => ( +
+ + {idx < nodes.length - 1 && ( + + )} +
+ ))} +
+ ); +} + +// ─── 수신 이력 테이블 ──────────────────────────────────────────────────────────── + +type FilterSource = 'all' | DataSource; +type FilterReceive = 'all' | ReceiveStatus; +type FilterPeriod = '6h' | '12h' | '24h'; + +const PERIOD_HOURS: Record = { '6h': 6, '12h': 12, '24h': 24 }; + +function filterLogs( + rows: DataLogRow[], + source: FilterSource, + receive: FilterReceive, + period: FilterPeriod, +): DataLogRow[] { + const cutoff = new Date('2026-04-11T06:30:00'); + const hours = PERIOD_HOURS[period]; + const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000); + + return rows.filter((r) => { + if (source !== 'all' && r.source !== source) return false; + if (receive !== 'all' && r.receiveStatus !== receive) return false; + const ts = new Date(r.timestamp.replace(' ', 'T')); + if (ts < from) return false; + return true; + }); +} + +const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태']; + +function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) { + return ( +
+ + + + {LOG_HEADERS.map((h) => ( + + ))} + + + + {loading && rows.length === 0 + ? Array.from({ length: 8 }).map((_, i) => ( + + {LOG_HEADERS.map((_, j) => ( + + ))} + + )) + : rows.map((row) => ( + + + + + + + + + ))} + {!loading && rows.length === 0 && ( + + + + )} + +
+ {h} +
+
+
+ {row.timestamp} + {row.source}{row.dataType}{row.size} + + {row.receiveStatus} + + + + {row.processStatus} + +
+ 조회된 데이터가 없습니다. +
+
+ ); +} + +// ─── 알림 목록 ─────────────────────────────────────────────────────────────────── + +function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) { + if (loading && alerts.length === 0) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ); + } + + if (alerts.length === 0) { + return

활성 알림이 없습니다.

; + } + + return ( +
+ {alerts.map((alert) => ( +
+ [{alert.level}] + {alert.message} + {alert.timestamp} +
+ ))} +
+ ); +} + +// ─── 메인 패널 ─────────────────────────────────────────────────────────────────── + +export default function RndPoseidonPanel() { + const [pipeline, setPipeline] = useState([]); + const [logs, setLogs] = useState([]); + const [alerts, setAlerts] = useState([]); + const [loading, setLoading] = useState(false); + const [lastUpdate, setLastUpdate] = useState(null); + + // 필터 + const [filterSource, setFilterSource] = useState('all'); + const [filterReceive, setFilterReceive] = useState('all'); + const [filterPeriod, setFilterPeriod] = useState('24h'); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const data = await fetchPoseidonData(); + setPipeline(data.pipeline); + setLogs(data.logs); + setAlerts(data.alerts); + setLastUpdate(new Date()); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + let isMounted = true; + void Promise.resolve().then(() => { + if (isMounted) void fetchData(); + }); + return () => { + isMounted = false; + }; + }, [fetchData]); + + const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod); + + const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length; + const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length; + const totalFailed = logs.filter( + (r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과', + ).length; + + return ( +
+ {/* ── 헤더 ── */} +
+
+

유출유확산예측 (포세이돈) 연계 모니터링

+
+ {lastUpdate && ( + + 갱신:{' '} + {lastUpdate.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} + + )} + +
+
+ {/* 요약 통계 바 */} +
+ + 정상 수신:{' '} + {totalReceived}건 + + | + + 지연: {totalDelayed}건 + + | + + 실패: {totalFailed}건 + + | + + 금일 예측 완료:{' '} + 4 / 8회 + +
+
+ + {/* ── 스크롤 영역 ── */} +
+ {/* 파이프라인 현황 */} +
+

+ 데이터 파이프라인 현황 +

+ +
+ + {/* 필터 바 + 수신 이력 테이블 */} +
+
+

+ 데이터 수신 이력 +

+
+ {/* 데이터소스 필터 */} + + {/* 수신상태 필터 */} + + {/* 기간 필터 */} + + {filteredLogs.length}건 +
+
+ +
+ + {/* 알림 현황 */} +
+

+ 알림 현황 +

+ +
+
+
+ ); +} diff --git a/frontend/src/tabs/admin/components/RndRescuePanel.tsx b/frontend/src/tabs/admin/components/RndRescuePanel.tsx new file mode 100644 index 0000000..231b9dc --- /dev/null +++ b/frontend/src/tabs/admin/components/RndRescuePanel.tsx @@ -0,0 +1,638 @@ +import { useState, useEffect, useCallback } from 'react'; + +// ─── 타입 ────────────────────────────────────────────────────────────────────── + +type PipelineStatus = '정상' | '지연' | '중단'; +type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과'; +type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류'; +type DataSource = 'HYCOM' | '기상청' | '긴급구난시스템'; +type AlertLevel = '경고' | '주의' | '정보'; + +interface PipelineNode { + id: string; + name: string; + status: PipelineStatus; + lastReceived: string; + cycle: string; +} + +interface DataLogRow { + id: string; + timestamp: string; + source: DataSource; + dataType: string; + size: string; + receiveStatus: ReceiveStatus; + processStatus: ProcessStatus; +} + +interface AlertItem { + id: string; + level: AlertLevel; + message: string; + timestamp: string; +} + +// ─── Mock 데이터 ──────────────────────────────────────────────────────────────── + +const MOCK_PIPELINE: PipelineNode[] = [ + { + id: 'hycom', + name: 'HYCOM 해양순환모델', + status: '정상', + lastReceived: '2026-04-11 06:00', + cycle: '6시간 주기', + }, + { + id: 'kma', + name: '기상청 수치모델', + status: '정상', + lastReceived: '2026-04-11 06:00', + cycle: '3시간 주기', + }, + { + id: 'rescue', + name: '해경 긴급구난 시스템', + status: '정상', + lastReceived: '2026-04-11 06:30', + cycle: '내부 연계', + }, + { + id: 'analysis', + name: '구난 분석 연산', + status: '정상', + lastReceived: '2026-04-11 06:35', + cycle: '연계 시작 즉시', + }, + { + id: 'result', + name: '결과 연계 수신', + status: '정상', + lastReceived: '2026-04-11 06:40', + cycle: '분석 완료 즉시', + }, +]; + +const MOCK_LOGS: DataLogRow[] = [ + { + id: 'log-01', + timestamp: '2026-04-11 06:40', + source: '긴급구난시스템', + dataType: '구난 가능성 판단', + size: '1.2 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-02', + timestamp: '2026-04-11 06:35', + source: '긴급구난시스템', + dataType: '선체상태 분석', + size: '3.4 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-03', + timestamp: '2026-04-11 06:30', + source: '긴급구난시스템', + dataType: '사고선 위치정보', + size: '0.8 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-04', + timestamp: '2026-04-11 06:30', + source: '긴급구난시스템', + dataType: '비상배인력 정보', + size: '0.5 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-05', + timestamp: '2026-04-11 06:00', + source: 'HYCOM', + dataType: '해수면온도(SST)', + size: '98 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-06', + timestamp: '2026-04-11 06:00', + source: 'HYCOM', + dataType: '해류(U/V)', + size: '142 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-07', + timestamp: '2026-04-11 06:00', + source: 'HYCOM', + dataType: '해수면높이(SSH)', + size: '54 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-08', + timestamp: '2026-04-11 06:00', + source: '기상청', + dataType: '풍향/풍속', + size: '38 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-09', + timestamp: '2026-04-11 06:00', + source: '기상청', + dataType: '기압', + size: '22 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-10', + timestamp: '2026-04-11 06:00', + source: '기상청', + dataType: '기온', + size: '19 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-11', + timestamp: '2026-04-11 03:30', + source: '긴급구난시스템', + dataType: '선체상태 분석', + size: '3.1 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-12', + timestamp: '2026-04-11 03:30', + source: '긴급구난시스템', + dataType: '구난 가능성 판단', + size: '1.1 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-13', + timestamp: '2026-04-11 00:30', + source: '긴급구난시스템', + dataType: '사고선 위치정보', + size: '0.8 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, + { + id: 'log-14', + timestamp: '2026-04-11 00:00', + source: 'HYCOM', + dataType: '해수면높이(SSH)', + size: '53 MB', + receiveStatus: '시간초과', + processStatus: '오류', + }, + { + id: 'log-15', + timestamp: '2026-04-10 21:30', + source: '긴급구난시스템', + dataType: '비상배인력 정보', + size: '0.4 MB', + receiveStatus: '수신완료', + processStatus: '처리완료', + }, +]; + +const MOCK_ALERTS: AlertItem[] = [ + { + id: 'alert-01', + level: '정보', + message: '해경 긴급구난 시스템 정상 연계 중', + timestamp: '2026-04-11 06:30', + }, + { + id: 'alert-02', + level: '정보', + message: 'HYCOM 데이터 정상 수신', + timestamp: '2026-04-11 06:00', + }, + { + id: 'alert-03', + level: '정보', + message: '금일 긴급구난 분석 완료: 5회/6회', + timestamp: '2026-04-11 06:40', + }, +]; + +// ─── Mock fetch ───────────────────────────────────────────────────────────────── + +interface RescueData { + pipeline: PipelineNode[]; + logs: DataLogRow[]; + alerts: AlertItem[]; +} + +function fetchRescueData(): Promise { + return new Promise((resolve) => { + setTimeout( + () => + resolve({ + pipeline: MOCK_PIPELINE, + logs: MOCK_LOGS, + alerts: MOCK_ALERTS, + }), + 300, + ); + }); +} + +// ─── 유틸 ─────────────────────────────────────────────────────────────────────── + +function getPipelineStatusStyle(status: PipelineStatus): string { + if (status === '정상') return 'text-emerald-400 bg-emerald-500/10'; + if (status === '지연') return 'text-yellow-400 bg-yellow-500/10'; + return 'text-red-400 bg-red-500/10'; +} + +function getPipelineBorderStyle(status: PipelineStatus): string { + if (status === '정상') return 'border-l-emerald-500'; + if (status === '지연') return 'border-l-yellow-500'; + return 'border-l-red-500'; +} + +function getReceiveStatusStyle(status: ReceiveStatus): string { + if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10'; + if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10'; + return 'text-red-400 bg-red-500/10'; +} + +function getProcessStatusStyle(status: ProcessStatus): string { + if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10'; + if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10'; + if (status === '대기') return 'text-yellow-400 bg-yellow-500/10'; + return 'text-red-400 bg-red-500/10'; +} + +function getAlertStyle(level: AlertLevel): string { + if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30'; + if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30'; + return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30'; +} + +// ─── 파이프라인 카드 ───────────────────────────────────────────────────────────── + +function PipelineCard({ node }: { node: PipelineNode }) { + const badgeStyle = getPipelineStatusStyle(node.status); + const borderStyle = getPipelineBorderStyle(node.status); + + return ( +
+
{node.name}
+ + {node.status} + +
최근 수신: {node.lastReceived}
+
{node.cycle}
+
+ ); +} + +function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) { + if (loading && nodes.length === 0) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+ {i < 4 && } +
+ ))} +
+ ); + } + + return ( +
+ {nodes.map((node, idx) => ( +
+ + {idx < nodes.length - 1 && ( + + )} +
+ ))} +
+ ); +} + +// ─── 수신 이력 테이블 ──────────────────────────────────────────────────────────── + +type FilterSource = 'all' | DataSource; +type FilterReceive = 'all' | ReceiveStatus; +type FilterPeriod = '6h' | '12h' | '24h'; + +const PERIOD_HOURS: Record = { '6h': 6, '12h': 12, '24h': 24 }; + +function filterLogs( + rows: DataLogRow[], + source: FilterSource, + receive: FilterReceive, + period: FilterPeriod, +): DataLogRow[] { + const cutoff = new Date('2026-04-11T06:40:00'); + const hours = PERIOD_HOURS[period]; + const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000); + + return rows.filter((r) => { + if (source !== 'all' && r.source !== source) return false; + if (receive !== 'all' && r.receiveStatus !== receive) return false; + const ts = new Date(r.timestamp.replace(' ', 'T')); + if (ts < from) return false; + return true; + }); +} + +const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태']; + +function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) { + return ( +
+ + + + {LOG_HEADERS.map((h) => ( + + ))} + + + + {loading && rows.length === 0 + ? Array.from({ length: 8 }).map((_, i) => ( + + {LOG_HEADERS.map((_, j) => ( + + ))} + + )) + : rows.map((row) => ( + + + + + + + + + ))} + {!loading && rows.length === 0 && ( + + + + )} + +
+ {h} +
+
+
+ {row.timestamp} + {row.source}{row.dataType}{row.size} + + {row.receiveStatus} + + + + {row.processStatus} + +
+ 조회된 데이터가 없습니다. +
+
+ ); +} + +// ─── 알림 목록 ─────────────────────────────────────────────────────────────────── + +function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) { + if (loading && alerts.length === 0) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ); + } + + if (alerts.length === 0) { + return

활성 알림이 없습니다.

; + } + + return ( +
+ {alerts.map((alert) => ( +
+ [{alert.level}] + {alert.message} + {alert.timestamp} +
+ ))} +
+ ); +} + +// ─── 메인 패널 ─────────────────────────────────────────────────────────────────── + +export default function RndRescuePanel() { + const [pipeline, setPipeline] = useState([]); + const [logs, setLogs] = useState([]); + const [alerts, setAlerts] = useState([]); + const [loading, setLoading] = useState(false); + const [lastUpdate, setLastUpdate] = useState(null); + + // 필터 + const [filterSource, setFilterSource] = useState('all'); + const [filterReceive, setFilterReceive] = useState('all'); + const [filterPeriod, setFilterPeriod] = useState('24h'); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const data = await fetchRescueData(); + setPipeline(data.pipeline); + setLogs(data.logs); + setAlerts(data.alerts); + setLastUpdate(new Date()); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + let isMounted = true; + void Promise.resolve().then(() => { + if (isMounted) void fetchData(); + }); + return () => { + isMounted = false; + }; + }, [fetchData]); + + const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod); + + const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length; + const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length; + const totalFailed = logs.filter( + (r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과', + ).length; + + return ( +
+ {/* ── 헤더 ── */} +
+
+

긴급구난과제 연계 모니터링

+
+ {lastUpdate && ( + + 갱신:{' '} + {lastUpdate.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} + + )} + +
+
+ {/* 요약 통계 바 */} +
+ + 정상 수신:{' '} + {totalReceived}건 + + | + + 지연: {totalDelayed}건 + + | + + 실패: {totalFailed}건 + + | + + 금일 분석 완료:{' '} + 5 / 6회 + +
+
+ + {/* ── 스크롤 영역 ── */} +
+ {/* 파이프라인 현황 */} +
+

+ 데이터 파이프라인 현황 +

+ +
+ + {/* 필터 바 + 수신 이력 테이블 */} +
+
+

+ 데이터 수신 이력 +

+
+ {/* 데이터소스 필터 */} + + {/* 수신상태 필터 */} + + {/* 기간 필터 */} + + {filteredLogs.length}건 +
+
+ +
+ + {/* 알림 현황 */} +
+

+ 알림 현황 +

+ +
+
+
+ ); +} diff --git a/frontend/src/tabs/admin/components/SystemArchPanel.tsx b/frontend/src/tabs/admin/components/SystemArchPanel.tsx new file mode 100644 index 0000000..b160cd3 --- /dev/null +++ b/frontend/src/tabs/admin/components/SystemArchPanel.tsx @@ -0,0 +1,1544 @@ +import { useState } from 'react'; + +type TabId = 'framework' | 'target' | 'interface' | 'heterogeneous' | 'common-features'; + +const TABS: { id: TabId; label: string }[] = [ + { id: 'framework', label: '표준 프레임워크' }, + { id: 'target', label: '목표시스템 아키텍쳐' }, + { id: 'interface', label: '시스템 인터페이스 연계' }, + { id: 'heterogeneous', label: '이기종시스템연계' }, + { id: 'common-features', label: '공통기능' }, +]; + +// ─── 기술 스택 테이블 데이터 ────────────────────────────────────────────────────── + +interface TechStackRow { + category: string; + tech: string; + version: string; + description: string; +} + +const TECH_STACK: TechStackRow[] = [ + { category: 'Frontend', tech: 'React', version: '19.x', description: '컴포넌트 기반 SPA' }, + { category: 'Frontend', tech: 'TypeScript', version: '5.9', description: '정적 타입 시스템' }, + { category: 'Frontend', tech: 'Vite', version: '7.x', description: '빌드 도구 (HMR)' }, + { category: 'Frontend', tech: 'Tailwind CSS', version: '3.x', description: '유틸리티 기반 CSS' }, + { category: 'Frontend', tech: 'MapLibre GL', version: '5.x', description: '오픈소스 GIS 엔진' }, + { category: 'Frontend', tech: 'deck.gl', version: '9.x', description: '대규모 데이터 시각화' }, + { category: 'Frontend', tech: 'Zustand', version: '-', description: '클라이언트 상태관리' }, + { category: 'Frontend', tech: 'TanStack Query', version: '-', description: '서버 상태관리/캐싱' }, + { category: 'Backend', tech: 'Express', version: '4.x', description: 'REST API 서버' }, + { category: 'Backend', tech: 'Socket.IO', version: '-', description: '실시간 양방향 통신' }, + { category: 'DB', tech: 'PostgreSQL', version: '16', description: '관계형 데이터베이스' }, + { category: 'DB', tech: 'PostGIS', version: '-', description: '공간정보 확장' }, + { + category: '인증', + tech: 'JWT', + version: '-', + description: '토큰 기반 인증 (HttpOnly Cookie)', + }, + { category: '인증', tech: 'Google OAuth', version: '2.0', description: 'SSO 연동' }, + { category: '보안', tech: 'Helmet', version: '-', description: 'HTTP 헤더 보안' }, + { category: '보안', tech: 'Rate Limiting', version: '-', description: 'API 호출 제한' }, + { category: 'CI/CD', tech: 'Gitea Actions', version: '-', description: '자동 빌드/배포' }, +]; + +// ─── 탭 모듈 데이터 ─────────────────────────────────────────────────────────────── + +interface TabModuleRow { + module: string; + name: string; + feature: string; + integration: string; +} + +const TAB_MODULES: TabModuleRow[] = [ + { + module: '확산예측', + name: 'prediction', + feature: '유출유 확산 시뮬레이션, 역추적 분석, 오일붐 배치', + integration: 'KOSPS, 포세이돈 R&D', + }, + { + module: 'HNS 분석', + name: 'hns', + feature: '화학물질 확산 예측, 물질 DB, 위험도 평가', + integration: '충북대 R&D, 물질 DB', + }, + { + module: '구조 시나리오', + name: 'rescue', + feature: '긴급구난 분석, 표류 예측', + integration: '긴급구난 R&D', + }, + { + module: '항공 방제', + name: 'aerial', + feature: '위성영상 분석, 드론 영상, 유막 면적 분석', + integration: '위성/드론 데이터', + }, + { + module: '해양 기상', + name: 'weather', + feature: '기상·해상 정보, 조위·해류 관측', + integration: 'KHOA API, 기상청 API', + }, + { + module: '사건/사고', + name: 'incidents', + feature: '해양오염 사고 등록·관리·이력', + integration: '해경 사고 DB', + }, + { + module: '자산 관리', + name: 'assets', + feature: '기관·장비·선박 보험 관리', + integration: '해경 자산 DB', + }, + { + module: 'SCAT 조사', + name: 'scat', + feature: 'Pre-SCAT 해안 조사 기록', + integration: '현장 조사 데이터', + }, + { + module: '관리자', + name: 'admin', + feature: '사용자/권한/메뉴/설정/연계 관리', + integration: '전체 시스템', + }, +]; + +// ─── 연계 인터페이스 데이터 ─────────────────────────────────────────────────────── + +interface InterfaceRow { + system: string; + method: string; + data: string; + cycle: string; + protocol: string; +} + +const INTERFACES: InterfaceRow[] = [ + { + system: 'KHOA (해양조사원)', + method: 'REST API', + data: '조위, 해류, 수온', + cycle: '실시간/1시간', + protocol: 'HTTPS', + }, + { + system: '기상청', + method: 'REST API', + data: '풍향/풍속, 기압, 기온, 강수', + cycle: '3시간', + protocol: 'HTTPS', + }, + { + system: 'HYCOM', + method: '파일 수신', + data: 'SST, 해류(U/V), SSH', + cycle: '6시간', + protocol: 'HTTPS/FTP', + }, + { + system: '해경 KBP (인사)', + method: '배치 수집', + data: '사용자, 부서, 직위, 조직', + cycle: '1일 1회', + protocol: '내부망 API', + }, + { + system: 'AIS 선박위치', + method: '실시간 수집', + data: '선박 위치, 속도, 방향', + cycle: '실시간', + protocol: 'Socket/API', + }, + { + system: '포세이돈 R&D', + method: 'API 연계', + data: '유출유 확산 예측 결과', + cycle: '요청 시', + protocol: 'HTTPS', + }, + { + system: 'KOSPS (광주)', + method: 'DLL 호출', + data: '유출유 확산 예측 결과', + cycle: '요청 시', + protocol: 'HTTPS (Fortran DLL)', + }, + { + system: '충북대 HNS', + method: 'API 호출', + data: 'HNS 대기확산 결과', + cycle: '요청 시', + protocol: 'HTTPS', + }, + { + system: '긴급구난 R&D', + method: '내부 연계', + data: '구난 분석 결과', + cycle: '요청 시', + protocol: '내부망 API', + }, +]; + +// ─── 탭 1: 표준 프레임워크 ──────────────────────────────────────────────────────── + +function FrameworkTab() { + return ( +
+ {/* 1. 개발 프레임워크 구성 */} +
+

1. 개발 프레임워크 구성

+
+ {/* 프레젠테이션 계층 */} +
+

프레젠테이션 계층

+

React 19 + TypeScript 5.9 + Tailwind CSS 3

+
+ {[ + { name: 'MapLibre', sub: 'GL JS 5' }, + { name: 'deck.gl', sub: '9.x' }, + { name: 'Zustand', sub: '상태관리' }, + { name: 'TanStack', sub: 'Query' }, + ].map((item) => ( +
+

{item.name}

+

{item.sub}

+
+ ))} +
+
+ {/* 비즈니스 로직 계층 */} +
+

비즈니스 로직 계층

+

Express 4 + TypeScript

+
+ {[ + { name: 'JWT 인증', sub: 'OAuth2.0' }, + { name: 'RBAC', sub: '권한엔진' }, + { name: 'Socket.IO', sub: '실시간' }, + { name: 'Helmet', sub: '보안' }, + ].map((item) => ( +
+

{item.name}

+

{item.sub}

+
+ ))} +
+
+ {/* 데이터 접근 계층 */} +
+

데이터 접근 계층

+

PostgreSQL 16 + PostGIS

+
+ {[ + { name: 'wing DB', sub: '운영 DB' }, + { name: 'wing_auth', sub: '인증 DB' }, + { name: 'PostGIS', sub: '공간정보' }, + ].map((item) => ( +
+

{item.name}

+

{item.sub}

+
+ ))} +
+
+
+
+ + {/* 2. 기술 스택 상세 */} +
+

2. 기술 스택 상세

+
+ + + + {['구분', '기술', '버전', '설명'].map((h) => ( + + ))} + + + + {TECH_STACK.map((row, idx) => ( + + + + + + + ))} + +
+ {h} +
+ {row.category} + {row.tech}{row.version}{row.description}
+
+
+ + {/* 3. 개발 표준 및 규칙 */} +
+

3. 개발 표준 및 규칙

+
+ {[ + { + title: 'HTTP 정책', + content: + 'GET/POST만 사용 (PUT/DELETE/PATCH 금지) — 한국 보안취약점 점검 가이드 준수', + }, + { + title: '코드 표준', + content: 'ESLint + Prettier 적용, TypeScript strict 모드 필수', + }, + { + title: '모듈 구조', + content: '@common/ (공통 모듈) + @tabs/ (업무별 탭) Path Alias 기반 분리', + }, + { + title: '보안', + content: '입력 살균(sanitize), XSS/SQL Injection 방지, CORS 정책, Rate Limiting', + }, + ].map((item) => ( +
+

{item.title}

+

{item.content}

+
+ ))} +
+
+
+ ); +} + +// ─── 탭 2: 목표시스템 아키텍쳐 ─────────────────────────────────────────────────── + +function TargetArchTab() { + return ( +
+ {/* 1. 시스템 전체 구성도 */} +
+

1. 시스템 전체 구성도

+
+ {/* 사용자 접근 계층 */} +
+

사용자 접근 계층

+

웹 브라우저 (React SPA)

+

+ 확산예측 | HNS분석 | 구조시나리오 | 항공방제 | 기상정보 | 사고관리 | SCAT조사 | + 자산관리 | 관리자 +

+
+ {/* 화살표 + 프로토콜 */} +
+ + HTTPS (TLS 1.2+) +
+ {/* API 서버 계층 */} +
+

API 서버 계층

+

Express 4 REST API (Port 3001)

+
+ {[ + 'JWT 인증 미들웨어', + 'RBAC 권한 엔진 (permResolver)', + '감사로그 자동 기록', + '입력 살균 / Rate Limiting / Helmet', + ].map((item) => ( +
+

{item}

+
+ ))} +
+
+ {/* 화살표 + 프로토콜 */} +
+ + pg connection pool +
+ {/* 데이터 계층 */} +
+

데이터 계층

+

PostgreSQL 16 + PostGIS

+
+ {[ + { name: 'wing DB', sub: '운영' }, + { name: 'wing_auth', sub: '인증' }, + ].map((item) => ( +
+

{item.name}

+

({item.sub})

+
+ ))} +
+
+
+
+ + {/* 2. 탭 기반 업무 모듈 구조 */} +
+

2. 탭 기반 업무 모듈 구조

+
+ + + + {['모듈', '패키지명', '기능', '주요 연계'].map((h) => ( + + ))} + + + + {TAB_MODULES.map((row) => ( + + + + + + + ))} + +
+ {h} +
{row.module}{row.name}{row.feature}{row.integration}
+
+
+ + {/* 3. RBAC 권한 체계 */} +
+

3. RBAC 권한 체계

+
+ {[ + { + title: '2차원 권한 엔진', + content: + 'AUTH_PERM OPER_CD 기반: R(조회), C(생성), U(수정), D(삭제) — 역할별 메뉴·기능 접근 제어', + }, + { + title: 'permResolver', + content: + '역할(Role)과 권한(Permission)의 2차원 매핑으로 메뉴 표시 여부 및 기능 사용 가능 여부를 동적으로 판단', + }, + { + title: '감사로그 자동 기록', + content: + '누가(사용자) / 언제(타임스탬프) / 무엇을(기능) / 어디서(IP, 메뉴) — 모든 주요 작업 자동 기록', + }, + ].map((item) => ( +
+

{item.title}

+

{item.content}

+
+ ))} +
+
+
+ ); +} + +// ─── 탭 3: 시스템 인터페이스 연계 ──────────────────────────────────────────────── + +function InterfaceTab() { + const dataFlowSteps = [ + '수집', + '전처리', + '저장', + '분석/예측', + '시각화', + '의사결정지원', + ]; + + return ( +
+ {/* 1. 외부 시스템 연계 구성도 */} +
+

1. 외부 시스템 연계 구성도

+
+ {/* 외부 시스템 */} +
+

외부 시스템

+ {['KHOA API', '기상청 API', '해경 KBP', 'AIS 선박'].map((item) => ( +
+

{item}

+
+ ))} +
+ {/* 화살표 */} +
+ + +
+ {/* 통합지원시스템 */} +
+

+ 해양환경 위기대응 +
+ 통합지원시스템 +

+
+

연계관리 모듈

+
+ {['수집자료 관리', '연계 모니터링', '비식별화 조치'].map((item) => ( +

+ - {item} +

+ ))} +
+
+
+ {/* 화살표 */} +
+ + +
+ {/* R&D 시스템 */} +
+

R&D 시스템

+ {['포세이돈', 'KOSPS', '충북대 HNS', '긴급구난'].map((item) => ( +
+

{item}

+
+ ))} +
+
+
+ + {/* 2. 연계 인터페이스 목록 */} +
+

2. 연계 인터페이스 목록

+
+ + + + {['연계 시스템', '연계 방식', '데이터', '주기', '프로토콜'].map((h) => ( + + ))} + + + + {INTERFACES.map((row) => ( + + + + + + + + ))} + +
+ {h} +
{row.system}{row.method}{row.data}{row.cycle}{row.protocol}
+
+
+ + {/* 3. 데이터 흐름도 */} +
+

3. 데이터 흐름도

+
+ {dataFlowSteps.map((step, idx) => ( +
+
+

{step}

+
+ {idx < dataFlowSteps.length - 1 && ( + + )} +
+ ))} +
+
+ {[ + { step: '수집', desc: 'KHOA, 기상청, HYCOM, AIS 등 외부 원천 데이터 수신' }, + { step: '전처리', desc: '포맷 변환, 좌표계 통일, 비식별화, 품질 검사' }, + { step: '저장', desc: 'PostgreSQL 16 + PostGIS 공간정보 DB 적재' }, + { step: '분석/예측', desc: 'R&D 모델 연계 (포세이돈, KOSPS, 충북대, 긴급구난)' }, + { step: '시각화', desc: 'MapLibre GL + deck.gl 기반 지도 레이어 렌더링' }, + { step: '의사결정지원', desc: '방제작전 시나리오, 구조분석, 경보 발령 지원' }, + ].map((item) => ( +
+

{item.step}

+

{item.desc}

+
+ ))} +
+
+ + {/* 4. 연계 장애 대응 */} +
+

4. 연계 장애 대응

+
+ {[ + { + title: '연계 모니터링', + content: '관리자 > 연계관리 > 연계모니터링에서 실시간 연계 상태 확인', + }, + { + title: 'R&D 파이프라인 모니터링', + content: '관리자 > 연계관리 > R&D과제에서 과제별 데이터 수신 이력 및 처리 현황 확인', + }, + { + title: '장애 알림', + content: '데이터 수신 지연/실패 발생 시 알림 발생 — 운영자 즉시 인지 가능', + }, + { + title: '비식별화 조치', + content: '개인정보 포함 데이터(해경 KBP 인사 등) 수집 시 자동 비식별화 처리 적용', + }, + ].map((item) => ( +
+

{item.title}

+

{item.content}

+
+ ))} +
+
+
+ ); +} + + +// ─── 이기종시스템 연계 데이터 ───────────────────────────────────────────────────── + +interface HeterogeneousSystemRow { + system: string; + lang: string; + os: string; + location: string; + protocol: string; + description: string; +} + +const HETEROGENEOUS_SYSTEMS: HeterogeneousSystemRow[] = [ + { + system: 'KOSPS', + lang: 'Fortran', + os: 'Linux', + location: '광주', + protocol: 'HTTPS (REST 래퍼)', + description: '유출유 확산 예측 — Fortran DLL을 REST API로 래핑하여 연계', + }, + { + system: '충북대 HNS', + lang: 'Python / C++', + os: 'Linux', + location: '충북대', + protocol: 'HTTPS', + description: 'HNS 대기확산 예측 — Python/C++ 모델을 REST API로 호출', + }, + { + system: '긴급구난', + lang: 'Python', + os: 'Linux', + location: '해경 내부', + protocol: '내부망 API', + description: '구난 표류 분석 — Python 모델을 내부망 REST API로 연계', + }, + { + system: 'HYCOM', + lang: 'Fortran / NetCDF', + os: 'Linux HPC', + location: '미 해군 공개', + protocol: 'HTTPS / FTP', + description: '전지구 해류·수온 예측 — NetCDF 파일 수신 후 ETL 전처리', + }, + { + system: '기상청', + lang: '-', + os: '-', + location: '기상청 API Hub', + protocol: 'HTTPS', + description: '풍향·풍속·기온·강수 등 기상 데이터 REST API 수집', + }, + { + system: 'KHOA', + lang: '-', + os: '-', + location: '해양조사원', + protocol: 'HTTPS', + description: '조위·해류·수온 등 해양관측 데이터 REST API 수집', + }, + { + system: '해경 KBP', + lang: 'Java 전자정부', + os: 'Linux', + location: '해경 내부망', + protocol: '내부망 API', + description: '사용자·조직·직위 인사 데이터 배치 수집 (비식별화 적용)', + }, + { + system: 'AIS', + lang: '-', + os: '-', + location: '해경 AIS 서버', + protocol: 'Socket / API', + description: '선박 위치·속도·방향 실시간 수신', + }, +]; + +interface HeterogeneousStrategyCard { + challenge: string; + solution: string; + description: string; +} + +interface IntegrationPlanItem { + title: string; + description: string; + details?: string[]; +} + +const INTEGRATION_PLANS: IntegrationPlanItem[] = [ + { + title: '사용자 정보 연계', + description: + '해양경찰청의 인사관리플랫폼과 연계 또는 사용자 정보를 제공받아 구성할 수 있어야 함', + }, + { + title: '해양공간 데이터 연계', + description: + '해경 해양공간 데이터 구축사업(해양공간정보 활용체계 구축 및 빅데이터 관련 사업)의 \'데이터통합저장소\' 시스템과 연계하여 현장탐색 전자지도 자동표출 기술 등에 영상 및 사진자료를 연계하여 구축', + }, + { + title: 'DB 통합설계 기반 맞춤형 인터페이스', + description: + '플랫폼 변경 및 신규 통합설계 되는 데이터베이스(DB) 구조 설계를 기반으로 사용자 맞춤형 화면 인터페이스를 구현해야 함', + details: [ + 'DBMS는 분리되어 있는 시스템들을 통합설계를 통하여 공통, 분야별 등으로 설계하여야 함', + ], + }, + { + title: '유출유 확산예측 정확성 향상 (KOSPS 연계)', + description: + '유출유 확산예측 정확성 향상을 위해, 해양오염방제지원시스템(KOSPS)를 연계·탑재하여야 함', + details: [ + '다양한 유출유 확산 예측 결과를 사용자가 한눈에 확인 가능하여야 함', + '확산예측 기반으로 역추적, 최초 유출유 발생지점을 예측할 수 있어야 함', + '그 밖에 유출유 확산예측 정확성 향상을 위한 대책을 마련하여야 함', + ], + }, + { + title: '기타 시스템 연계', + description: + '그 밖에 시스템 구축 중 효율적인 사고대응을 위한 타 시스템 연계할 수 있음', + }, +]; + +const HETEROGENEOUS_STRATEGIES: HeterogeneousStrategyCard[] = [ + { + challenge: '언어 이질성', + solution: 'REST API 래퍼 계층', + description: + 'Fortran, Python, C++, Java 등 각 언어로 작성된 모델을 REST API 래퍼로 감싸 언어·플랫폼 독립적인 표준 인터페이스 제공', + }, + { + challenge: '데이터 형식 차이', + solution: 'ETL 전처리 파이프라인', + description: + 'NetCDF, CSV, Binary, JSON 등 이기종 포맷을 ETL 파이프라인으로 표준 JSON/GeoJSON 형식으로 변환 후 DB 적재', + }, + { + challenge: '네트워크 분리', + solution: '이중 네트워크 연계', + description: + '외부망(인터넷) 연계와 내부망(해경 내부) 연계를 분리 운영하여 보안 정책 준수 및 데이터 안전성 확보', + }, + { + challenge: '가용성·장애 대응', + solution: '연계 모니터링 + 알림', + description: + '연계 상태를 실시간 모니터링하고 수신 지연·실패 발생 시 운영자에게 즉시 알림 발송하여 신속 대응', + }, + { + challenge: '인증·보안 차이', + solution: 'API Gateway 패턴', + description: + '시스템별 상이한 인증 방식(API Key, JWT, IP 제한 등)을 API Gateway 계층에서 통합 관리하여 단일 보안 정책 적용', + }, + { + challenge: '프로토콜 차이', + solution: '어댑터 패턴 적용', + description: + 'HTTP REST, FTP, Socket, 배치 파일 등 다양한 프로토콜을 어댑터 패턴으로 추상화하여 표준 인터페이스로 통일', + }, +]; + +const HETEROGENEOUS_FLOW_STEPS = [ + '원본 데이터', + '수집 어댑터', + 'ETL 전처리', + '표준 변환', + 'DB 적재', + 'API 제공', +]; + +interface SecurityPolicyCard { + title: string; + items: string[]; +} + +const HETEROGENEOUS_SECURITY: SecurityPolicyCard[] = [ + { + title: '외부망 연계', + items: [ + 'TLS 1.2+ 암호화 통신', + 'API Key / OAuth 인증', + 'IP 화이트리스트 제한', + 'Rate Limiting 적용', + ], + }, + { + title: '내부망 연계', + items: [ + '전용 내부망 구간 분리', + '상호 인증서 검증', + '비식별화 자동 처리', + '접근 이력 감사로그', + ], + }, + { + title: '데이터 보호', + items: [ + '개인정보 수집 최소화', + 'ETL 단계 비식별화', + '전송 구간 암호화', + '저장 데이터 접근 제어', + ], + }, +]; + +// ─── 탭 4: 이기종시스템연계 ─────────────────────────────────────────────────────── + +function HeterogeneousTab() { + return ( +
+ {/* 1. 이기종시스템 연계 개요 */} +
+

1. 이기종시스템 연계 개요

+

+ 통합지원시스템은 Fortran, Python, C++, Java 등 다양한 언어와 플랫폼으로 구현된 이기종 + 시스템과 연계한다. REST API 표준화, ETL 전처리, 어댑터 패턴을 통해 언어·플랫폼 독립적인 + 연계 구조를 구현하며, 외부망·내부망 이중 네트워크 정책을 준수한다. +

+
+
+

이기종 시스템

+ {['Fortran KOSPS', 'Python/C++ 충북대', 'Java 해경KBP', 'NetCDF HYCOM'].map((item) => ( +

+ {item} +

+ ))} +
+
+ + +
+
+

연계 어댑터 계층

+ {['REST API 래퍼', 'ETL 전처리', '프로토콜 변환', '인증 통합'].map((item) => ( +

+ {item} +

+ ))} +
+
+ + +
+
+

통합지원시스템

+ {['Express REST API', 'PostgreSQL+PostGIS', 'React SPA', '표준 JSON'].map((item) => ( +

+ {item} +

+ ))} +
+
+
+ + {/* 2. 이기종 시스템 간의 연계 방안 */} +
+

2. 이기종 시스템 간의 연계 방안

+
+ {INTEGRATION_PLANS.map((item, idx) => ( +
+

+ {idx + 1}. {item.title} +

+

{item.description}

+ {item.details && ( +
    + {item.details.map((detail) => ( +
  • + {detail} +
  • + ))} +
+ )} +
+ ))} +
+
+ + {/* 3. 연계 대상 이기종 시스템 목록 */} +
+

3. 연계 대상 이기종 시스템 목록

+
+ + + + {['시스템', '구현 언어', 'OS', '위치', '연계 프로토콜', '연계 설명'].map((h) => ( + + ))} + + + + {HETEROGENEOUS_SYSTEMS.map((row) => ( + + + + + + + + + ))} + +
+ {h} +
{row.system}{row.lang}{row.os}{row.location}{row.protocol}{row.description}
+
+
+ + {/* 4. 이기종 연계 전략 */} +
+

4. 이기종 연계 전략

+
+ {HETEROGENEOUS_STRATEGIES.map((card) => ( +
+
+ {card.challenge} + + {card.solution} +
+

{card.description}

+
+ ))} +
+
+ + {/* 5. 이기종 데이터 변환 흐름 */} +
+

5. 이기종 데이터 변환 흐름

+
+ {HETEROGENEOUS_FLOW_STEPS.map((step, idx) => ( +
+
+

{step}

+
+ {idx < HETEROGENEOUS_FLOW_STEPS.length - 1 && ( + + )} +
+ ))} +
+
+ + {/* 6. 이기종 연계 보안 정책 */} +
+

6. 이기종 연계 보안 정책

+
+ {HETEROGENEOUS_SECURITY.map((card) => ( +
+

{card.title}

+
    + {card.items.map((item) => ( +
  • + · {item} +
  • + ))} +
+
+ ))} +
+
+
+ ); +} + +// ─── 공통기능 탭 데이터 ────────────────────────────────────────────────────────── + +interface CommonFeatureItem { + title: string; + description: string; + details: string[]; +} + +const COMMON_FEATURES: CommonFeatureItem[] = [ + { + title: '인증 시스템', + description: 'JWT 기반 세션 인증 + Google OAuth 소셜 로그인', + details: [ + 'HttpOnly 쿠키(WING_SESSION) 기반 토큰 관리 — XSS 방어', + 'Access Token(15분) + Refresh Token(7일) 이중 토큰 구조', + 'Google OAuth 2.0 소셜 로그인 지원', + 'Zustand authStore 기반 프론트엔드 인증 상태 통합 관리', + ], + }, + { + title: 'RBAC 2차원 권한', + description: 'AUTH_PERM 기반 기능별·역할별 2차원 권한 엔진', + details: [ + 'OPER_CD (R: 조회, C: 생성, U: 수정, D: 삭제) 4단계 조작 권한', + '역할(Role) × 기능(Feature) 매트릭스 기반 권한 매핑', + 'permResolver 엔진으로 백엔드·프론트엔드 동시 권한 검증', + '메뉴 접근, 버튼 노출, API 호출 3중 권한 통제', + ], + }, + { + title: 'API 통신 패턴', + description: 'Axios 기반 공통 API 클라이언트 + 자동 인증·에러 처리', + details: [ + 'GET/POST만 사용 (PUT/DELETE/PATCH 금지 — 보안취약점 점검 가이드 준수)', + '요청 인터셉터: 쿠키 자동 첨부 (withCredentials)', + '응답 인터셉터: 401 시 자동 토큰 갱신, 실패 시 로그아웃', + 'TanStack Query 기반 서버 상태 캐싱 및 자동 재검증', + ], + }, + { + title: '상태 관리', + description: 'Zustand(클라이언트) + TanStack Query(서버) 이중 상태 관리', + details: [ + 'Zustand: authStore(인증), menuStore(메뉴) 등 클라이언트 전역 상태', + 'TanStack Query: API 응답 캐싱, 자동 재요청, 낙관적 업데이트', + '컴포넌트 로컬 상태: useState 활용', + ], + }, + { + title: '메뉴 시스템', + description: 'DB 기반 동적 메뉴 + 권한 연동 자동 필터링', + details: [ + 'DB에서 메뉴 트리 구조를 동적으로 로드', + '사용자 권한에 따라 메뉴 항목 자동 필터링 (접근 불가 메뉴 미노출)', + '관리자 화면에서 메뉴 순서·표시 여부·아이콘 실시간 편집', + 'menuStore(Zustand)로 현재 활성 메뉴 상태 전역 관리', + ], + }, + { + title: '지도 엔진', + description: 'MapLibre GL JS 5.x + deck.gl 9.x 기반 GIS 시각화', + details: [ + 'MapLibre GL JS: 오픈소스 벡터 타일 기반 지도 렌더링', + 'deck.gl: 대규모 공간 데이터(파티클, 히트맵, 궤적) 고성능 시각화', + 'PostGIS 공간 쿼리 → GeoJSON → deck.gl 레이어 파이프라인', + '레이어 트리 UI로 사용자별 레이어 표시·숨김 제어', + ], + }, + { + title: '스타일링', + description: 'Tailwind CSS @layer 아키텍처 + CSS 변수 디자인 시스템', + details: [ + '@layer base → components → wing 3단계 CSS 계층 구조', + 'CSS 변수 기반 시맨틱 컬러 (bg-bg-base, text-t1, border-stroke-1 등)', + '다크 모드 기본 적용 — CSS 변수 전환으로 테마 일괄 변경', + '인라인 스타일 지양, Tailwind 유틸리티 클래스 우선', + ], + }, + { + title: '감사 로그', + description: '사용자 행위 자동 기록 — 접속·조회·변경 이력 추적', + details: [ + '로그인/로그아웃, 메뉴 접근, 데이터 변경 자동 기록', + 'App.tsx에서 탭 전환 시 감사 로그 자동 전송', + '관리자 화면에서 사용자별·기간별 감사 로그 조회 가능', + 'IP 주소, User-Agent, 요청 경로 등 부가 정보 기록', + ], + }, + { + title: '보안', + description: '입력 살균·CORS·CSP·Rate Limiting 다층 보안 정책', + details: [ + '입력 살균(sanitize): XSS·SQL Injection 방어 미들웨어 적용', + 'Helmet: CSP, X-Frame-Options, HSTS 등 보안 헤더 자동 설정', + 'CORS: 허용 오리진 화이트리스트 제한', + 'Rate Limiting: API 요청 빈도 제한으로 DoS 방어', + ], + }, +]; + +// ─── 방제대응 프로세스 데이터 ───────────────────────────────────────────────────── + +interface ProcessStep { + phase: string; + description: string; + modules: string[]; +} + +const RESPONSE_PROCESS: ProcessStep[] = [ + { + phase: '사고 접수', + description: '해양오염 사고 신고 접수 및 초동 상황 등록', + modules: ['사건/사고'], + }, + { + phase: '상황 파악', + description: '사고 현장 기상·해상 조건 확인, 유출원·유출량 파악', + modules: ['해양기상', '사건/사고'], + }, + { + phase: '확산 예측', + description: '유출유/HNS 확산 시뮬레이션 및 역추적 분석 수행', + modules: ['확산예측', 'HNS분석'], + }, + { + phase: '방제 계획', + description: '오일붐 배치, 유처리제 살포 구역, 방제선 투입 계획 수립', + modules: ['확산예측', '자산관리'], + }, + { + phase: '구조 작전', + description: '인명 구조 시나리오 수립, 표류 예측 기반 수색 구역 결정', + modules: ['구조시나리오'], + }, + { + phase: '항공 감시', + description: '위성·드론 영상으로 유막 면적 모니터링 및 방제 효과 확인', + modules: ['항공방제'], + }, + { + phase: '해안 조사', + description: 'Pre-SCAT 해안 오염 조사, 피해 범위 기록', + modules: ['SCAT조사'], + }, + { + phase: '상황 종료', + description: '방제 완료 보고, 감사 이력 정리, 사후 분석', + modules: ['사건/사고', '관리자'], + }, +]; + +// ─── 시스템별 기능 유무 매트릭스 데이터 ──────────────────────────────────────────── + +const SYSTEM_MODULES = [ + '확산예측', + 'HNS분석', + '구조시나리오', + '항공방제', + '해양기상', + '사건/사고', + '자산관리', + 'SCAT조사', + '게시판', + '관리자', +] as const; + +interface FeatureMatrixRow { + feature: string; + category: '공통기능' | '기본정보관리' | '업무기능'; + integrated: boolean; + systems: Record; +} + +const FEATURE_MATRIX: FeatureMatrixRow[] = [ + { + feature: '사용자 인증 (JWT)', + category: '공통기능', + integrated: true, + systems: { '확산예측': true, 'HNS분석': true, '구조시나리오': true, '항공방제': true, '해양기상': true, '사건/사고': true, '자산관리': true, 'SCAT조사': true, '게시판': true, '관리자': true }, + }, + { + feature: 'RBAC 권한 제어', + category: '공통기능', + integrated: true, + systems: { '확산예측': true, 'HNS분석': true, '구조시나리오': true, '항공방제': true, '해양기상': true, '사건/사고': true, '자산관리': true, 'SCAT조사': true, '게시판': true, '관리자': true }, + }, + { + feature: '감사 로그', + category: '공통기능', + integrated: true, + systems: { '확산예측': true, 'HNS분석': true, '구조시나리오': true, '항공방제': true, '해양기상': true, '사건/사고': true, '자산관리': true, 'SCAT조사': true, '게시판': true, '관리자': true }, + }, + { + feature: 'API 통신 (Axios)', + category: '공통기능', + integrated: true, + systems: { '확산예측': true, 'HNS분석': true, '구조시나리오': true, '항공방제': true, '해양기상': true, '사건/사고': true, '자산관리': true, 'SCAT조사': true, '게시판': true, '관리자': true }, + }, + { + feature: '입력 살균/보안', + category: '공통기능', + integrated: true, + systems: { '확산예측': true, 'HNS분석': true, '구조시나리오': true, '항공방제': true, '해양기상': true, '사건/사고': true, '자산관리': true, 'SCAT조사': true, '게시판': true, '관리자': true }, + }, + { + feature: '사용자 관리', + category: '기본정보관리', + integrated: true, + systems: { '확산예측': false, 'HNS분석': false, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': true }, + }, + { + feature: '지도 엔진 (MapLibre)', + category: '기본정보관리', + integrated: true, + systems: { '확산예측': true, 'HNS분석': true, '구조시나리오': true, '항공방제': true, '해양기상': true, '사건/사고': true, '자산관리': false, 'SCAT조사': true, '게시판': false, '관리자': false }, + }, + { + feature: '레이어 관리', + category: '기본정보관리', + integrated: true, + systems: { '확산예측': true, 'HNS분석': true, '구조시나리오': true, '항공방제': true, '해양기상': true, '사건/사고': true, '자산관리': false, 'SCAT조사': true, '게시판': false, '관리자': true }, + }, + { + feature: '메뉴 관리', + category: '기본정보관리', + integrated: true, + systems: { '확산예측': false, 'HNS분석': false, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': true }, + }, + { + feature: '시스템 설정', + category: '기본정보관리', + integrated: true, + systems: { '확산예측': false, 'HNS분석': false, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': true }, + }, + { + feature: '확산 시뮬레이션', + category: '업무기능', + integrated: false, + systems: { '확산예측': true, 'HNS분석': false, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': false }, + }, + { + feature: 'HNS 대기확산', + category: '업무기능', + integrated: false, + systems: { '확산예측': false, 'HNS분석': true, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': false }, + }, + { + feature: '표류 예측', + category: '업무기능', + integrated: false, + systems: { '확산예측': false, 'HNS분석': false, '구조시나리오': true, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': false }, + }, + { + feature: '위성/드론 영상', + category: '업무기능', + integrated: false, + systems: { '확산예측': false, 'HNS분석': false, '구조시나리오': false, '항공방제': true, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': false }, + }, + { + feature: '기상/해상 정보', + category: '업무기능', + integrated: false, + systems: { '확산예측': true, 'HNS분석': true, '구조시나리오': true, '항공방제': false, '해양기상': true, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': false }, + }, + { + feature: '역추적 분석', + category: '업무기능', + integrated: false, + systems: { '확산예측': true, 'HNS분석': false, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': false }, + }, + { + feature: '사고 등록/이력', + category: '업무기능', + integrated: false, + systems: { '확산예측': false, 'HNS분석': false, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': true, '자산관리': false, 'SCAT조사': false, '게시판': false, '관리자': false }, + }, + { + feature: '장비/선박 관리', + category: '업무기능', + integrated: false, + systems: { '확산예측': false, 'HNS분석': false, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': true, 'SCAT조사': false, '게시판': false, '관리자': false }, + }, + { + feature: '해안 조사', + category: '업무기능', + integrated: false, + systems: { '확산예측': false, 'HNS분석': false, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': true, '게시판': false, '관리자': false }, + }, + { + feature: '게시판 CRUD', + category: '업무기능', + integrated: false, + systems: { '확산예측': false, 'HNS분석': false, '구조시나리오': false, '항공방제': false, '해양기상': false, '사건/사고': false, '자산관리': false, 'SCAT조사': false, '게시판': true, '관리자': false }, + }, +]; + +const CATEGORY_STYLES: Record = { + '공통기능': 'bg-cyan-600/20 text-cyan-300', + '기본정보관리': 'bg-emerald-600/20 text-emerald-300', + '업무기능': 'bg-bg-elevated text-t3', +}; + +// ─── 탭 5: 공통기능 ───────────────────────────────────────────────────────────── + +function CommonFeaturesTab() { + return ( +
+ {/* 1. 방제대응 프로세스 */} +
+

1. 방제대응 프로세스

+

+ 해양오염 사고 발생 시 사고 접수부터 상황 종료까지의 단계별 대응 프로세스이며, + 각 단계에서 활용하는 시스템 모듈을 표시한다. +

+ {/* 프로세스 흐름도 */} +
+ {RESPONSE_PROCESS.map((step, idx) => ( +
+
+

{step.phase}

+
+ {step.modules.map((mod) => ( + {mod} + ))} +
+
+ {idx < RESPONSE_PROCESS.length - 1 && ( + + )} +
+ ))} +
+ {/* 프로세스 상세 */} +
+ {RESPONSE_PROCESS.map((step, idx) => ( +
+ + {idx + 1} + +
+

{step.phase}

+

{step.description}

+
+
+ {step.modules.map((mod) => ( + + {mod} + + ))} +
+
+ ))} +
+
+ + {/* 2. 시스템별 기능 유무 매트릭스 */} +
+

2. 시스템별 기능 유무 매트릭스

+

+ 각 시스템(업무 모듈)별 기능의 유무를 파악하여 공통기능, 기본정보 관리(사용자, 지도 등) 등 + 통합할 수 있는 기능을 표시한다. 통합 대상 기능은 + 공통 모듈로 일원화하여 중복 개발을 방지한다. +

+
+ + + + + + + {SYSTEM_MODULES.map((mod) => ( + + ))} + + + + {FEATURE_MATRIX.map((row) => ( + + + + + {SYSTEM_MODULES.map((mod) => ( + + ))} + + ))} + +
+ 기능 + + 분류 + + 통합 + + {mod} +
+ {row.feature} + + + {row.category} + + + {row.integrated ? ( + 통합 + ) : ( + 개별 + )} + + {row.systems[mod] ? ( + O + ) : ( + - + )} +
+
+ {/* 범례 */} +
+
+ 공통기능 + 전 모듈 공통 적용 +
+
+ 기본정보관리 + 사용자·지도·메뉴·설정 통합 관리 +
+
+ 업무기능 + 모듈별 고유 기능 +
+
+
+ + {/* 3. 공통기능 상세 */} +
+

3. 공통기능 상세

+
+ {COMMON_FEATURES.map((feature, idx) => ( +
+
+ + {idx + 1} + +

{feature.title}

+
+

{feature.description}

+
    + {feature.details.map((detail) => ( +
  • + {detail} +
  • + ))} +
+
+ ))} +
+
+ + {/* 4. 공통 모듈 구조 */} +
+

4. 공통 모듈 디렉토리 구조

+
+ + + + {['디렉토리', '역할', '주요 파일'].map((h) => ( + + ))} + + + + {[ + { dir: 'common/components/', role: '공통 UI 컴포넌트', files: 'auth/, layout/, map/, ui/, layer/' }, + { dir: 'common/hooks/', role: '공통 커스텀 훅', files: 'useLayers, useSubMenu, useFeatureTracking' }, + { dir: 'common/services/', role: 'API 통신 모듈', files: 'api.ts, authApi.ts, layerService.ts' }, + { dir: 'common/store/', role: '전역 상태 스토어', files: 'authStore.ts, menuStore.ts' }, + { dir: 'common/styles/', role: 'CSS @layer 스타일', files: 'base.css, components.css, wing.css' }, + { dir: 'common/types/', role: '공통 타입 정의', files: 'backtrack, hns, navigation 등' }, + { dir: 'common/utils/', role: '유틸리티 함수', files: 'coordinates, geo, sanitize, cn.ts' }, + { dir: 'common/constants/', role: '상수 정의', files: 'featureIds.ts' }, + { dir: 'common/data/', role: 'UI 데이터', files: 'layerData.ts (레이어 트리)' }, + ].map((row) => ( + + + + + + ))} + +
+ {h} +
{row.dir}{row.role}{row.files}
+
+
+
+ ); +} + +// ─── 메인 패널 ─────────────────────────────────────────────────────────────────── + +export default function SystemArchPanel() { + const [activeTab, setActiveTab] = useState('framework'); + + return ( +
+ {/* 헤더 */} +
+

시스템구조

+
+ + {/* 탭 버튼 */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* 탭 콘텐츠 */} +
+ {activeTab === 'framework' && } + {activeTab === 'target' && } + {activeTab === 'interface' && } + {activeTab === 'heterogeneous' && } + {activeTab === 'common-features' && } +
+
+ ); +} diff --git a/frontend/src/tabs/admin/components/adminMenuConfig.ts b/frontend/src/tabs/admin/components/adminMenuConfig.ts index 3494ced..643ce0f 100644 --- a/frontend/src/tabs/admin/components/adminMenuConfig.ts +++ b/frontend/src/tabs/admin/components/adminMenuConfig.ts @@ -15,6 +15,7 @@ export const ADMIN_MENU: AdminMenuItem[] = [ children: [ { id: 'menus', label: '메뉴관리' }, { id: 'settings', label: '시스템설정' }, + { id: 'system-arch', label: '시스템구조' }, ], }, { @@ -91,6 +92,16 @@ export const ADMIN_MENU: AdminMenuItem[] = [ { id: 'monitor-vessel', label: '선박위치정보' }, ], }, + { + id: 'rnd', + label: 'R&D과제', + children: [ + { id: 'rnd-poseidon', label: '유출유확산예측(포세이돈)' }, + { id: 'rnd-kosps', label: '유출유확산예측(KOSPS)' }, + { id: 'rnd-hns-atmos', label: 'HNS대기확산(충북대)' }, + { id: 'rnd-rescue', label: '긴급구난과제' }, + ], + }, { id: 'deidentify', label: '비식별화조치' }, ], }, diff --git a/frontend/src/tabs/rescue/components/RescueScenarioView.tsx b/frontend/src/tabs/rescue/components/RescueScenarioView.tsx index 4fd23a9..7cfe54b 100755 --- a/frontend/src/tabs/rescue/components/RescueScenarioView.tsx +++ b/frontend/src/tabs/rescue/components/RescueScenarioView.tsx @@ -1,4 +1,7 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { Map, Marker, Popup } from '@vis.gl/react-maplibre'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; import { fetchRescueOps, fetchRescueScenarios } from '../services/rescueApi'; import type { RescueOpsItem, RescueScenarioItem } from '../services/rescueApi'; @@ -103,6 +106,295 @@ interface ChartDataItem { severity: Severity; } +/* ─── 시나리오 관리 요건 ─── */ +const SCENARIO_MGMT_GUIDELINES = [ + '긴급구난 R&D 분석 결과는 시간 단계별 시나리오의 형태로 관리되어야 함', + '각 시나리오는 사고 발생 시점부터 구난 진행 단계별 상태 변화를 포함하여야 함', + '시나리오별 분석 결과는 사고 단위로 기존 사고 정보와 연계되어 관리되어야 함', + '동일 사고에 대해 복수 시나리오(시간대, 조건별)가 존재할 경우, 상호 비교·검토가 되어야 함', + '시나리오별 분석결과는 긴급구난 대응 판단을 지원할 수 있도록 요약 정보 형태로 제공되어야 함', + '시나리오 관리 기능은 기존 통합지원시스템의 흐름과 연계되어 실질적인 구난 대응 업무에 활용 가능하도록 반영되어야 함', + '긴급구난 시나리오 관리 기능 구현 시 1차 구축 완료된 GIS기능을 활용하여 구축하여 재개발하거나 중복구현하지 않도록 함', +]; + +/* ─── 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) 미달, 복원력 위험 판정.', + compartments: [ + { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, + { name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' }, + { name: '#2 Port Tank', status: 'RISK', color: 'var(--orange)' }, + { name: 'Engine Room', status: 'INTACT', color: 'var(--green)' }, + { name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' }, + ], + assessment: [ + { label: '복원력', value: '위험 (GM 0.8m < IMO 1.0m)', color: 'var(--red)' }, + { label: '유출 위험', value: '활발 유출중 (100 L/min)', color: 'var(--red)' }, + { label: '선체 강도', value: 'BM 92% (경계)', color: 'var(--orange)' }, + { label: '승선인원', value: '15/20 확인, 5명 수색중', color: 'var(--red)' }, + ], + actions: [ + { time: '10:30', text: '충돌 발생, VHF Ch.16 조난 통보', color: 'var(--red)' }, + { time: '10:32', text: 'EPIRB 자동 발신 확인', color: 'var(--red)' }, + { time: '10:35', text: '해경 3009함 출동 지시', color: 'var(--orange)' }, + { time: '10:42', text: '인근 선박 구조 활동 개시', color: 'var(--cyan)' }, + ], + 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.', + compartments: [ + { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, + { name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' }, + { name: '#2 Port Tank', status: 'RISK', color: 'var(--orange)' }, + { name: 'Engine Room', status: 'INTACT', color: 'var(--green)' }, + { name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' }, + ], + assessment: [ + { label: '복원력', value: '악화 (GM 0.7m, GZ 커브 감소)', color: 'var(--red)' }, + { label: '유출 위험', value: '증가 (120 L/min)', color: 'var(--red)' }, + { label: '선체 강도', value: 'BM 90% — 종강도 모니터링 개시', color: 'var(--orange)' }, + { label: '승선인원', value: '15명 퇴선, 5명 수색중', color: 'var(--red)' }, + ], + actions: [ + { time: '10:50', text: '잠수사 투입, 수중 손상 조사 개시', color: 'var(--cyan)' }, + { time: '10:55', text: '파공 규모 확인: 1.2m×0.8m', color: 'var(--red)' }, + { time: '11:00', text: '손상복원성 재계산 — IMO 기준 위험', color: 'var(--red)' }, + { time: '11:00', text: '유출유 방제선 배치 요청', color: 'var(--orange)' }, + ], + 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°로 감소.', + compartments: [ + { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, + { name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' }, + { name: '#2 Port Tank', status: 'FLOODING', color: 'var(--red)' }, + { name: 'Engine Room', status: 'INTACT', color: 'var(--green)' }, + { name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' }, + ], + assessment: [ + { label: '복원력', value: '한계 접근 (GM 0.65m)', color: 'var(--red)' }, + { label: '유출 위험', value: '파공 확대 우려 (135 L/min)', color: 'var(--red)' }, + { label: '선체 강도', value: 'BM 89% — Hogging 증가', color: 'var(--orange)' }, + { label: '인명구조', value: '실종 5명 수색중, 표류 1.2nm', color: 'var(--red)' }, + ], + actions: [ + { time: '11:10', text: '해경 3009함 현장 도착, SAR 구역 설정', color: 'var(--cyan)' }, + { time: '11:15', text: 'Leeway 표류 예측 모델 적용', color: 'var(--cyan)' }, + { time: '11:20', text: '회전익 항공기 수색 개시', color: 'var(--cyan)' }, + { time: '11:30', text: '#2 Port Tank 2차 침수 징후', color: 'var(--red)' }, + ], + 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%. 침몰 위험 단계 진입.', + compartments: [ + { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, + { name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' }, + { name: '#2 Port Tank', status: 'FLOODED', color: 'var(--red)' }, + { name: 'Engine Room', status: 'RISK', color: 'var(--orange)' }, + { name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' }, + ], + assessment: [ + { label: '복원력', value: '위기 (GM 0.5m, FSE 보정)', color: 'var(--red)' }, + { label: '유출 위험', value: '최대치 접근 (160 L/min)', color: 'var(--red)' }, + { label: '선체 강도', value: 'BM 86% — Sagging 경고', color: 'var(--red)' }, + { label: '승선인원', value: '실종 3명 발견, 2명 수색', color: 'var(--orange)' }, + ], + actions: [ + { time: '12:00', text: '#2 Port Tank 격벽 관통 침수', color: 'var(--red)' }, + { time: '12:10', text: '자유표면효과(FSE) 보정 재계산', color: 'var(--red)' }, + { time: '12:15', text: '긴급 Counter-Flooding 검토', color: 'var(--orange)' }, + { time: '12:30', text: '실종자 3명 추가 발견 구조', color: 'var(--green)' }, + ], + 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% 유지.', + compartments: [ + { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, + { name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' }, + { name: '#2 Port Tank', status: 'FLOODED', color: 'var(--red)' }, + { name: 'Engine Room', status: 'RISK', color: 'var(--orange)' }, + { name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' }, + ], + assessment: [ + { label: '복원력', value: '개선 중 (GM 0.55m, 경사 16°)', color: 'var(--orange)' }, + { label: '유출 위험', value: '감소 추세 (140 L/min)', color: 'var(--orange)' }, + { label: '선체 강도', value: 'BM 87% — Counter-Flooding 평가', color: 'var(--orange)' }, + { label: '구조 상황', value: '실종 2명 수색 지속', color: 'var(--orange)' }, + ], + actions: [ + { time: '12:45', text: 'Counter-Flooding — #3 Stbd 주입 개시', color: 'var(--orange)' }, + { time: '13:00', text: '평형수 280톤 주입, 경사 교정 진행', color: 'var(--cyan)' }, + { time: '13:15', text: '종강도 재계산 — 허용 범위 내', color: 'var(--cyan)' }, + { time: '13:30', text: '횡경사 16° 안정화 확인', color: 'var(--green)' }, + ], + 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°) 충족.', + compartments: [ + { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, + { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, + { name: '#2 Port Tank', status: 'FLOODED', color: 'var(--red)' }, + { name: 'Engine Room', status: 'INTACT', color: 'var(--green)' }, + { name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' }, + ], + assessment: [ + { label: '복원력', value: '개선 (GM 0.7m, 예인 가능)', color: 'var(--orange)' }, + { label: '유출 위험', value: '수중패치 효과 (80 L/min)', color: 'var(--orange)' }, + { label: '선체 강도', value: 'BM 90% — 안정 범위', color: 'var(--green)' }, + { label: '구조 상황', value: '전원 구조 완료', color: 'var(--green)' }, + ], + actions: [ + { time: '14:00', text: '수중패치 설치 작업 개시', color: 'var(--cyan)' }, + { time: '14:30', text: '수중패치 설치 완료', color: 'var(--green)' }, + { time: '15:00', text: '해상크레인 도착, 잔류유 이적 준비', color: 'var(--cyan)' }, + { time: '16:30', text: '잔류유 1차 이적 완료 (45kL)', color: 'var(--green)' }, + ], + 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%.', + compartments: [ + { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, + { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, + { name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' }, + { name: 'Engine Room', status: 'INTACT', color: 'var(--green)' }, + { name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' }, + ], + assessment: [ + { label: '복원력', value: '안정 (GM 0.8m)', color: 'var(--orange)' }, + { label: '유출 위험', value: '방제 진행 (55 L/min, 회수 35%)', color: 'var(--orange)' }, + { label: '선체 강도', value: 'BM 91%', color: 'var(--green)' }, + { label: '방제 현황', value: '오일붐 2중, 유회수기 3대', color: 'var(--cyan)' }, + ], + actions: [ + { time: '17:00', text: '오일붐 1차 전개 (500m)', color: 'var(--cyan)' }, + { time: '17:30', text: '오일붐 2차 전개 (이중 방어선)', color: 'var(--cyan)' }, + { time: '17:45', text: '유회수기 3대 배치·가동', color: 'var(--cyan)' }, + { time: '18:30', text: 'GNOME 확산 예측 갱신', color: 'var(--orange)' }, + ], + 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.', + compartments: [ + { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, + { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, + { name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' }, + { name: 'Engine Room', status: 'INTACT', color: 'var(--green)' }, + { name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' }, + ], + assessment: [ + { label: '복원력', value: '안정 (GM 0.9m)', color: 'var(--orange)' }, + { label: '유출 위험', value: '억제 중 (30 L/min)', color: 'var(--green)' }, + { label: '선체 강도', value: 'BM 94%', color: 'var(--green)' }, + { label: '예인 상태', value: '목포항, ETA 14h, 3kn', color: 'var(--cyan)' }, + ], + actions: [ + { time: '18:00', text: '예인 접속, 예인삭 250m 전개', color: 'var(--cyan)' }, + { time: '18:30', text: '예인 개시 (목포항 방향)', color: 'var(--cyan)' }, + { time: '20:00', text: '야간 감시 체제 전환', color: 'var(--orange)' }, + { time: '22:30', text: '예인 진행률 30%, 선체 안정', color: 'var(--green)' }, + ], + 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.', + compartments: [ + { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, + { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, + { name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' }, + { name: 'Engine Room', status: 'INTACT', color: 'var(--green)' }, + { name: '#3 Stbd Tank', status: 'STABLE', color: 'var(--green)' }, + ], + assessment: [ + { label: '복원력', value: '양호 (GM 1.0m, IMO 충족)', color: 'var(--green)' }, + { label: '유출 위험', value: '미량 유출 (15 L/min)', color: 'var(--green)' }, + { label: '선체 강도', value: 'BM 96% 정상', color: 'var(--green)' }, + { label: '예인 상태', value: '진행률 65%, ETA 5.5h', color: 'var(--cyan)' }, + ], + actions: [ + { time: '00:00', text: '야간 예인 정상 진행', color: 'var(--green)' }, + { time: '02:00', text: '파랑 응답 분석 — 안전 확인', color: 'var(--green)' }, + { time: '03:00', text: '잔류유 유출률 15 L/min', color: 'var(--green)' }, + { time: '04:30', text: '목포항 VTS 통보, 입항 협의', color: 'var(--cyan)' }, + ], + 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%). 상황 종료.', + compartments: [ + { name: '#1 FP Tank', status: 'SEALED', color: 'var(--orange)' }, + { name: '#1 Port Tank', status: 'SEALED', color: 'var(--orange)' }, + { name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' }, + { name: 'Engine Room', status: 'INTACT', color: 'var(--green)' }, + { name: '#3 Stbd Tank', status: 'STABLE', color: 'var(--green)' }, + ], + assessment: [ + { label: '복원력', value: '안전 (GM 1.2m)', color: 'var(--green)' }, + { label: '유출 위험', value: '차단 완료', color: 'var(--green)' }, + { label: '선체 강도', value: 'BM 98% 정상', color: 'var(--green)' }, + { label: '최종 상태', value: '접안 완료, 상황 종료', color: 'var(--green)' }, + ], + actions: [ + { time: '06:00', text: '목포항 접근, 도선사 대기', color: 'var(--cyan)' }, + { time: '08:00', text: '도선사 승선, 접안 개시', color: 'var(--cyan)' }, + { time: '09:30', text: '접안 완료, 잔류유 이적선 접현', color: 'var(--green)' }, + { time: '10:30', text: '잔류유 전량 이적, 상황 종료', color: 'var(--green)' }, + ], + sortOrd: 10, + }, +]; + +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', + }, +]; + /* ═══════════════════════════════════════════════════════════════════ RescueScenarioView ═══════════════════════════════════════════════════════════════════ */ @@ -116,14 +408,15 @@ export function RescueScenarioView() { const [sortBy, setSortBy] = useState<'time' | 'risk'>('time'); const [detailView, setDetailView] = useState(0); const [newScnModalOpen, setNewScnModalOpen] = useState(false); + const [guideOpen, setGuideOpen] = useState(false); const loadScenarios = useCallback(async (opsSn: number) => { setLoading(true); try { const items = await fetchRescueScenarios(opsSn); - setApiScenarios(items); - } catch (err) { - console.error('[rescue] 시나리오 조회 실패:', err); + setApiScenarios(items.length > 0 ? items : MOCK_SCENARIOS); + } catch { + setApiScenarios(MOCK_SCENARIOS); } finally { setLoading(false); } @@ -132,14 +425,17 @@ export function RescueScenarioView() { const loadOps = useCallback(async () => { try { const items = await fetchRescueOps(); - setOps(items); if (items.length > 0) { + setOps(items); loadScenarios(items[0].rescueOpsSn); } else { + setOps(MOCK_OPS); + setApiScenarios(MOCK_SCENARIOS); setLoading(false); } - } catch (err) { - console.error('[rescue] 구난 작전 목록 조회 실패:', err); + } catch { + setOps(MOCK_OPS); + setApiScenarios(MOCK_SCENARIOS); setLoading(false); } }, [loadScenarios]); @@ -229,9 +525,35 @@ export function RescueScenarioView() { > + 신규 시나리오 +
+ {/* ── 시나리오 관리 요건 가이드라인 ── */} + {guideOpen && ( +
+

시나리오 관리 요건

+
    + {SCENARIO_MGMT_GUIDELINES.map((g, i) => ( +
  • + {i + 1}. + {g} +
  • + ))} +
+
+ )} + {/* ── Content: Left List + Right Detail ── */}
{/* ═══ LEFT: 시나리오 목록 ═══ */} @@ -376,7 +698,7 @@ export function RescueScenarioView() {
{/* View content */} -
+
{/* ─── VIEW 0: 시나리오 상세 ─── */} {detailView === 0 && selected && (
@@ -536,37 +858,14 @@ export function RescueScenarioView() { {/* ─── VIEW 2: 지도 오버레이 ─── */} {detailView === 2 && ( -
-
-
🗺
-
GIS 기반 시나리오 비교
-
- 선택된 시나리오의 침수 구역을 지도 위에 오버레이하여 비교합니다. -
-
- {scenarios.map((sc) => ( -
- - {sc.id} - - {sc.name} -
- ))} -
-
-
- 지도 뷰 영역 — 구난 분석 지도와 연동하여 침수 구역 오버레이 표시 -
-
-
-
+ )}
@@ -578,6 +877,310 @@ export function RescueScenarioView() { ); } +/* ═══ 지도 오버레이 ═══ */ +interface ScenarioMapOverlayProps { + ops: RescueOpsItem[]; + selectedIncident: number; + scenarios: RescueScenario[]; + selectedId: string; + checked: Set; + onSelectScenario: (id: string) => void; +} + +function ScenarioMapOverlay({ + ops, + selectedIncident, + scenarios, + selectedId, + checked, + onSelectScenario, +}: ScenarioMapOverlayProps) { + const [popupId, setPopupId] = useState(null); + const baseMapStyle = useBaseMapStyle(); + + const currentOp = ops[selectedIncident] ?? null; + const center = useMemo<[number, number]>( + () => + currentOp?.lon != null && currentOp?.lat != null + ? [currentOp.lon, currentOp.lat] + : [126.25, 37.467], + [currentOp], + ); + + const visibleScenarios = useMemo( + () => scenarios.filter((s) => checked.has(s.id)), + [scenarios, checked], + ); + + const selected = scenarios.find((s) => s.id === selectedId); + + return ( +
+ {/* 시나리오 선택 바 */} +
+ 시나리오: + {visibleScenarios.map((sc) => { + const sev = SEV_STYLE[sc.severity]; + const isActive = selectedId === sc.id; + return ( + + ); + })} +
+ + {/* 지도 영역 */} +
+ + {/* 사고 위치 마커 */} + {currentOp && currentOp.lon != null && currentOp.lat != null && ( + +
+
+
+ + )} + + {/* 시나리오별 마커 — 사고 지점 주변에 시간 순서대로 배치 */} + {visibleScenarios.map((sc, idx) => { + const angle = (idx / visibleScenarios.length) * Math.PI * 2 - Math.PI / 2; + const radius = 0.015 + idx * 0.003; + const lng = center[0] + Math.cos(angle) * radius; + const lat = center[1] + Math.sin(angle) * radius * 0.8; + const sev = SEV_STYLE[sc.severity]; + const isActive = selectedId === sc.id; + + return ( + { + e.originalEvent.stopPropagation(); + onSelectScenario(sc.id); + setPopupId(popupId === sc.id ? null : sc.id); + }} + > +
+ + {sc.timeStep.replace('T+', '')} + +
+
+ ); + })} + + {/* 팝업 — 클릭한 시나리오 정보 표출 */} + {popupId && + (() => { + const sc = visibleScenarios.find((s) => s.id === popupId); + if (!sc) return null; + const idx = visibleScenarios.indexOf(sc); + const angle = (idx / visibleScenarios.length) * Math.PI * 2 - Math.PI / 2; + const radius = 0.015 + idx * 0.003; + const lng = center[0] + Math.cos(angle) * radius; + const lat = center[1] + Math.sin(angle) * radius * 0.8; + const sev = SEV_STYLE[sc.severity]; + + return ( + setPopupId(null)} + maxWidth="320px" + className="rescue-map-popup" + > +
+
+ + {sc.id} + + {sc.timeStep} + + {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.buoyancy}%`, color: buoyColor(sc.buoyancy) }, + { label: '유출', value: sc.oilRate.split(' ')[0], color: oilColor(parseFloat(sc.oilRate)) }, + ].map((m) => ( +
+
{m.label}
+
{m.value}
+
+ ))} +
+ {/* 구획 상태 */} + {sc.compartments.length > 0 && ( +
+
+ 구획 상태 +
+
+ {sc.compartments.map((c) => ( + + {c.name}: {c.status} + + ))} +
+
+ )} +
+
+ ); + })()} + + + {/* 좌측 하단 — 선택된 시나리오 요약 오버레이 */} + {selected && ( +
+
+ {selected.id} + {selected.timeStep} + + {SEV_STYLE[selected.severity].label} + +
+
+
+ {[ + { 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}
+
+ ))} +
+
+ {selected.description.slice(0, 120)} + {selected.description.length > 120 ? '...' : ''} +
+
+
+ )} + + {/* 우측 상단 — 범례 */} +
+
시나리오 범례
+ {(['CRITICAL', 'HIGH', 'MEDIUM', 'RESOLVED'] as Severity[]).map((sev) => ( +
+ + {SEV_STYLE[sev].label} +
+ ))} +
+ + 사고 위치 +
+
+
+
+ ); +} + /* ═══ 신규 시나리오 생성 모달 ═══ */ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: () => void }) { const overlayRef = useRef(null); diff --git a/frontend/src/tabs/rescue/components/RescueView.tsx b/frontend/src/tabs/rescue/components/RescueView.tsx index d0e22bc..59d27d7 100755 --- a/frontend/src/tabs/rescue/components/RescueView.tsx +++ b/frontend/src/tabs/rescue/components/RescueView.tsx @@ -1,9 +1,12 @@ import { Fragment, useState, useEffect, useCallback } from 'react'; import { useSubMenu } from '@common/hooks/useSubMenu'; +import { MapView } from '@common/components/map/MapView'; import { RescueTheoryView } from './RescueTheoryView'; import { RescueScenarioView } from './RescueScenarioView'; import { fetchRescueOps } from '../services/rescueApi'; import type { RescueOpsItem } from '../services/rescueApi'; +import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi'; +import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi'; /* ─── Types ─── */ type AccidentType = @@ -221,12 +224,145 @@ function TopInfoBar({ activeType }: { activeType: AccidentType }) { function LeftPanel({ activeType, onTypeChange, + incidents, + selectedAcdnt, + onSelectAcdnt, }: { activeType: AccidentType; onTypeChange: (t: AccidentType) => void; + incidents: IncidentListItem[]; + selectedAcdnt: IncidentListItem | null; + onSelectAcdnt: (item: IncidentListItem | null) => void; }) { + const [acdntName, setAcdntName] = useState(''); + const [acdntDate, setAcdntDate] = useState(''); + const [acdntTime, setAcdntTime] = useState(''); + const [acdntLat, setAcdntLat] = useState(''); + const [acdntLon, setAcdntLon] = useState(''); + const [showList, setShowList] = useState(false); + + // 사고 선택 시 필드 자동 채움 + const handlePickIncident = (item: IncidentListItem) => { + onSelectAcdnt(item); + setAcdntName(item.acdntNm); + const dt = new Date(item.occrnDtm); + setAcdntDate( + `${dt.getFullYear()}. ${String(dt.getMonth() + 1).padStart(2, '0')}. ${String(dt.getDate()).padStart(2, '0')}.`, + ); + setAcdntTime( + `${dt.getHours() >= 12 ? '오후' : '오전'} ${String(dt.getHours() % 12 || 12).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`, + ); + setAcdntLat(String(item.lat)); + setAcdntLon(String(item.lng)); + setShowList(false); + }; + return (
+ {/* ── 사고 기본정보 ── */} +
+ 사고 기본정보 +
+ + {/* 사고명 직접 입력 */} + { + setAcdntName(e.target.value); + if (selectedAcdnt) onSelectAcdnt(null); + }} + className="w-full px-2 py-1.5 text-caption bg-bg-card border border-stroke rounded font-korean placeholder:text-fg-disabled/50 text-fg focus:border-[rgba(6,182,212,0.5)] focus:outline-none" + /> + + {/* 또는 사고 리스트에서 선택 */} +
+ + {showList && ( +
+ {incidents.length === 0 && ( +
+ 사고 데이터 없음 +
+ )} + {incidents.map((item) => ( + + ))} +
+ )} +
+ + {/* 사고 발생 일시 */} +
사고 발생 일시
+
+ setAcdntDate(e.target.value)} + className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none" + /> + setAcdntTime(e.target.value)} + className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none" + /> +
+ + {/* 위도 / 경도 */} +
+ setAcdntLat(e.target.value)} + className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none" + /> + setAcdntLon(e.target.value)} + className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none" + /> + +
+
+ 지도에서 위치를 선택하세요 +
+ + {/* 구분선 */} +
+ {/* 사고유형 제목 */}
사고 유형 (INCIDENT TYPE) @@ -290,191 +426,6 @@ function LeftPanel({ } /* ─── 중앙 지도 영역 ─── */ -function CenterMap({ activeType }: { activeType: AccidentType }) { - const d = rscTypeData[activeType]; - - return ( -
- {/* 해양 배경 그라데이션 */} -
- {/* 격자 */} -
- {/* 해안선 힌트 */} -
- - {/* 사고 해역 정보 */} -
-
사고 해역 정보
-
- 위치 - 37°28'N, 126°15'E - 수심 - 45m - 조류 - 2.5 knots NE -
-
- - {/* 선박 모형 */} -
-
-
-
-
- M/V SEA GUARDIAN -
-
- - {/* 예측 구역 원 */} -
- {/* 구역 라벨 */} -
- {d.zone.replace('\\n', '\n')} -
- - {/* SAR 자산 */} -
- ETA 5 MIN ─ -
-
- ETA 15 MIN ─ -
-
- 🚁 -
-
- 6M -
-
🚢
- - {/* 환경 민감 구역 */} -
-
- ENVIRONMENTALLY SENSITIVE -
- AREA: AQUACULTURE FARM -
-
- - {/* 지도 컨트롤 */} -
- {['🗺', '🔍', '📐'].map((ico, i) => ( - - ))} -
- - {/* 스케일 바 */} -
-
- 5 km · Zoom: 100% -
- - {/* 사고 유형 표시 */} - {/*
-
현재 사고 유형
-
- {at.icon} {at.label} ({at.eng}) -
-
*/} - - {/* 타임라인 시뮬레이션 컨트롤 */} -
-
TIMELINE
-
- [-6h] - [NOW] - [+6H] - [+12H] - [+24H] -
-
-
-
-
- - - -
-
- 10:45 KST -
-
-
- ); -} - /* ─── 오른쪽 분석 패널 ─── */ function RightPanel({ activeAnalysis, @@ -1572,6 +1523,44 @@ export function RescueView() { const { activeSubTab } = useSubMenu('rescue'); const [activeType, setActiveType] = useState('collision'); const [activeAnalysis, setActiveAnalysis] = useState('rescue'); + const [incidents, setIncidents] = useState([]); + const [selectedAcdnt, setSelectedAcdnt] = useState(null); + const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null); + const [isSelectingLocation, setIsSelectingLocation] = useState(false); + + useEffect(() => { + fetchIncidentsRaw() + .then((items) => setIncidents(items)) + .catch(() => setIncidents([])); + }, []); + + // 지도 클릭 시 좌표 선택 + const handleMapClick = useCallback((lon: number, lat: number) => { + setIncidentCoord({ lon, lat }); + setIsSelectingLocation(false); + }, []); + + // 사고 선택 시 사고유형 자동 매핑 + 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 ( @@ -1596,8 +1585,23 @@ export function RescueView() { {/* 3단 레이아웃: 사고유형 | 지도 | 분석 패널 */}
- - + +
+ +
Date: Mon, 13 Apr 2026 16:41:56 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat(incidents):=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=B6=84=EC=84=9D=20=EC=97=B0=EB=8F=99=20=EA=B0=95?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EC=82=AC=EA=B3=A0=20=ED=8C=9D=EC=97=85?= =?UTF-8?q?=20=EB=A6=AC=EB=89=B4=EC=96=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사고별 이미지 분석 API 및 항공 미디어 조회 연동 - 사고 마커 팝업 디자인 개선, 필터링된 사고만 지도 표시 - 이미지 분석 시 사고명 파라미터 지원, 기본 예측시간 6시간으로 변경 - 유출량 정밀도 NUMERIC(14,10) 확대 (migration 031) - OpenDrift 유종 매핑 수정 (원유, 등유) --- backend/src/incidents/incidentsRouter.ts | 23 ++ backend/src/incidents/incidentsService.ts | 40 +++- backend/src/prediction/imageAnalyzeService.ts | 6 +- backend/src/prediction/predictionRouter.ts | 3 +- backend/src/routes/simulation.ts | 4 +- database/init.sql | 2 +- database/migration/009_incidents.sql | 2 +- database/migration/013_hns_analysis.sql | 2 +- database/migration/031_spil_qty_precision.sql | 7 + frontend/src/common/styles/components.css | 15 ++ .../components/ImageAnalysisModal.tsx | 2 + .../components/IncidentsLeftPanel.tsx | 28 +++ .../incidents/components/IncidentsView.tsx | 193 ++++++++++++++-- .../tabs/incidents/components/MediaModal.tsx | 214 ++++++++++++++---- .../tabs/incidents/services/incidentsApi.ts | 43 +++- .../components/AnalysisListTable.tsx | 12 +- .../prediction/components/OilSpillView.tsx | 10 +- .../components/PredictionInputSection.tsx | 38 ++-- .../tabs/prediction/services/predictionApi.ts | 3 +- 19 files changed, 534 insertions(+), 113 deletions(-) create mode 100644 database/migration/031_spil_qty_precision.sql create mode 100644 frontend/src/tabs/incidents/components/ImageAnalysisModal.tsx diff --git a/backend/src/incidents/incidentsRouter.ts b/backend/src/incidents/incidentsRouter.ts index abb09d6..ea65044 100644 --- a/backend/src/incidents/incidentsRouter.ts +++ b/backend/src/incidents/incidentsRouter.ts @@ -7,6 +7,7 @@ import { getIncidentWeather, saveIncidentWeather, getIncidentMedia, + getIncidentImageAnalysis, } from './incidentsService.js'; const router = Router(); @@ -133,4 +134,26 @@ router.get('/:sn/media', requireAuth, async (req, res) => { } }); +// ============================================================ +// GET /api/incidents/:sn/image-analysis — 이미지 분석 데이터 +// ============================================================ +router.get('/:sn/image-analysis', requireAuth, async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10); + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 사고 번호입니다.' }); + return; + } + const data = await getIncidentImageAnalysis(sn); + if (!data) { + res.status(404).json({ error: '이미지 분석 데이터가 없습니다.' }); + return; + } + res.json(data); + } catch (err) { + console.error('[incidents] 이미지 분석 데이터 조회 오류:', err); + res.status(500).json({ error: '이미지 분석 데이터 조회 중 오류가 발생했습니다.' }); + } +}); + export default router; diff --git a/backend/src/incidents/incidentsService.ts b/backend/src/incidents/incidentsService.ts index 104fee0..a02534b 100644 --- a/backend/src/incidents/incidentsService.ts +++ b/backend/src/incidents/incidentsService.ts @@ -24,7 +24,9 @@ interface IncidentListItem { spilQty: number | null; spilUnitCd: string | null; fcstHr: number | null; + hasPredCompleted: boolean; mediaCnt: number; + hasImgAnalysis: boolean; } interface PredExecItem { @@ -111,11 +113,17 @@ export async function listIncidents(filters: { a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM, a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM, s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR, + COALESCE(s.HAS_IMG_ANALYSIS, FALSE) AS has_img_analysis, + EXISTS ( + SELECT 1 FROM wing.PRED_EXEC pe + WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED' + ) AS has_pred_completed, COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0) + COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt FROM wing.ACDNT a LEFT JOIN LATERAL ( - SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR + SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, + IMG_RSLT_DATA IS NOT NULL AS HAS_IMG_ANALYSIS FROM wing.SPIL_DATA WHERE ACDNT_SN = a.ACDNT_SN ORDER BY SPIL_DATA_SN @@ -148,7 +156,9 @@ export async function listIncidents(filters: { spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null, spilUnitCd: (r.spil_unit_cd as string) ?? null, fcstHr: (r.fcst_hr as number) ?? null, + hasPredCompleted: r.has_pred_completed as boolean, mediaCnt: Number(r.media_cnt), + hasImgAnalysis: (r.has_img_analysis as boolean) ?? false, })); } @@ -162,11 +172,17 @@ export async function getIncident(acdntSn: number): Promise) ?? null, }; } + +// ============================================================ +// 이미지 분석 데이터 조회 +// ============================================================ +export async function getIncidentImageAnalysis(acdntSn: number): Promise | null> { + const sql = ` + SELECT IMG_RSLT_DATA + FROM wing.SPIL_DATA + WHERE ACDNT_SN = $1 AND IMG_RSLT_DATA IS NOT NULL + ORDER BY SPIL_DATA_SN + LIMIT 1 + `; + + const { rows } = await wingPool.query(sql, [acdntSn]); + if (rows.length === 0) return null; + + return (rows[0] as Record).img_rslt_data as Record; +} diff --git a/backend/src/prediction/imageAnalyzeService.ts b/backend/src/prediction/imageAnalyzeService.ts index 41452e7..7d68896 100644 --- a/backend/src/prediction/imageAnalyzeService.ts +++ b/backend/src/prediction/imageAnalyzeService.ts @@ -74,7 +74,7 @@ function parseMeta(metaStr: string): { lat: number; lon: number; occurredAt: str return { lat, lon, occurredAt }; } -export async function analyzeImageFile(imageBuffer: Buffer, originalName: string): Promise { +export async function analyzeImageFile(imageBuffer: Buffer, originalName: string, acdntNmOverride?: string): Promise { const fileId = crypto.randomUUID(); // camTy는 현재 "mx15hdi"로 하드코딩한다. @@ -122,7 +122,7 @@ export async function analyzeImageFile(imageBuffer: Buffer, originalName: string const volume = firstOil?.volume ?? 0; // ACDNT INSERT - const acdntNm = `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`; + const acdntNm = acdntNmOverride?.trim() || `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`; const acdntRes = await wingPool.query( `INSERT INTO wing.ACDNT (ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM) @@ -145,7 +145,7 @@ export async function analyzeImageFile(imageBuffer: Buffer, originalName: string await wingPool.query( `INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, IMG_RSLT_DATA, REG_DTM) - VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 48, $4, NOW())`, + VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 6, $4, NOW())`, [ acdntSn, OIL_DB_CODE_MAP[oilType] ?? 'BUNKER_C', diff --git a/backend/src/prediction/predictionRouter.ts b/backend/src/prediction/predictionRouter.ts index 6d6c357..497ec86 100644 --- a/backend/src/prediction/predictionRouter.ts +++ b/backend/src/prediction/predictionRouter.ts @@ -230,7 +230,8 @@ router.post( res.status(400).json({ error: '이미지 파일이 필요합니다' }); return; } - const result = await analyzeImageFile(req.file.buffer, req.file.originalname); + const acdntNm = typeof req.body?.acdntNm === 'string' ? req.body.acdntNm : undefined; + const result = await analyzeImageFile(req.file.buffer, req.file.originalname, acdntNm); res.json(result); } catch (err: unknown) { if (err instanceof Error) { diff --git a/backend/src/routes/simulation.ts b/backend/src/routes/simulation.ts index abba4e4..a8b69dd 100755 --- a/backend/src/routes/simulation.ts +++ b/backend/src/routes/simulation.ts @@ -20,9 +20,9 @@ const POLL_TIMEOUT_MS = 30 * 60 * 1000 // 30분 const OIL_TYPE_MAP: Record = { '벙커C유': 'GENERIC BUNKER C', '경유': 'GENERIC DIESEL', - '원유': 'WEST TEXAS INTERMEDIATE (WTI)', + '원유': 'WEST TEXAS INTERMEDIATE', '중유': 'GENERIC HEAVY FUEL OIL', - '등유': 'FUEL OIL NO.1 (KEROSENE)', + '등유': 'FUEL OIL NO.1 (KEROSENE) ', '휘발유': 'GENERIC GASOLINE', } diff --git a/database/init.sql b/database/init.sql index a23122d..b814167 100755 --- a/database/init.sql +++ b/database/init.sql @@ -293,7 +293,7 @@ CREATE TABLE SPIL_DATA ( SPIL_DATA_SN SERIAL NOT NULL, -- 유출정보순번 ACDNT_SN INTEGER NOT NULL, -- 사고순번 OIL_TP_CD VARCHAR(50) NOT NULL, -- 유종코드 - SPIL_QTY NUMERIC(12,2), -- 유출량 + SPIL_QTY NUMERIC(14,10), -- 유출량 SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', -- 유출단위코드 SPIL_TP_CD VARCHAR(20), -- 유출유형코드 SPIL_LOC_GEOM GEOMETRY(Point, 4326), -- 유출위치지오메트리 diff --git a/database/migration/009_incidents.sql b/database/migration/009_incidents.sql index 166c595..ae88141 100644 --- a/database/migration/009_incidents.sql +++ b/database/migration/009_incidents.sql @@ -40,7 +40,7 @@ CREATE TABLE IF NOT EXISTS SPIL_DATA ( SPIL_DATA_SN SERIAL NOT NULL, ACDNT_SN INTEGER NOT NULL, OIL_TP_CD VARCHAR(50) NOT NULL, - SPIL_QTY NUMERIC(12,2), + SPIL_QTY NUMERIC(14,10), SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', SPIL_TP_CD VARCHAR(20), FCST_HR INTEGER, diff --git a/database/migration/013_hns_analysis.sql b/database/migration/013_hns_analysis.sql index 0a05240..18858f0 100644 --- a/database/migration/013_hns_analysis.sql +++ b/database/migration/013_hns_analysis.sql @@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS HNS_ANALYSIS ( SBST_NM VARCHAR(100), UN_NO VARCHAR(10), CAS_NO VARCHAR(20), - SPIL_QTY NUMERIC(10,2), + SPIL_QTY NUMERIC(14,10), SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', SPIL_TP_CD VARCHAR(20), FCST_HR INTEGER, diff --git a/database/migration/031_spil_qty_precision.sql b/database/migration/031_spil_qty_precision.sql new file mode 100644 index 0000000..2fb18a5 --- /dev/null +++ b/database/migration/031_spil_qty_precision.sql @@ -0,0 +1,7 @@ +-- 031: 유출량(SPIL_QTY) 소수점 정밀도 확대 +-- 이미지 분석 결과로 1e-7 수준의 매우 작은 유출량을 저장할 수 있도록 +-- NUMERIC(12,2) / NUMERIC(10,2) → NUMERIC(14,10) 으로 변경 +-- 정수부 최대 4자리, 소수부 10자리 + +ALTER TABLE wing.SPIL_DATA ALTER COLUMN SPIL_QTY TYPE NUMERIC(14,10); +ALTER TABLE wing.HNS_ANALY ALTER COLUMN SPIL_QTY TYPE NUMERIC(14,10); diff --git a/frontend/src/common/styles/components.css b/frontend/src/common/styles/components.css index 0bd81c1..f0be483 100644 --- a/frontend/src/common/styles/components.css +++ b/frontend/src/common/styles/components.css @@ -4,6 +4,21 @@ z-index: 500; } +/* 사고 팝업 — @layer 밖에 위치해야 MapLibre 기본 스타일을 덮어씀 */ +.incident-popup .maplibregl-popup-content { + background: transparent; + border-radius: 0; + padding: 0; + box-shadow: none; + border: none; +} +.incident-popup .maplibregl-popup-tip { + border-top-color: var(--bg-elevated); + border-bottom-color: var(--bg-elevated); + border-left-color: transparent; + border-right-color: transparent; +} + @layer components { /* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */ .cctv-dark-popup .maplibregl-popup-content { diff --git a/frontend/src/tabs/incidents/components/ImageAnalysisModal.tsx b/frontend/src/tabs/incidents/components/ImageAnalysisModal.tsx new file mode 100644 index 0000000..94b2dfd --- /dev/null +++ b/frontend/src/tabs/incidents/components/ImageAnalysisModal.tsx @@ -0,0 +1,2 @@ +// 이 파일은 사용되지 않습니다. 이미지 보기 기능은 MediaModal에 통합되었습니다. +export {}; diff --git a/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx b/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx index ef73bcc..1627be8 100755 --- a/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx @@ -15,12 +15,14 @@ export interface Incident { prediction?: string; vesselName?: string; mediaCount?: number; + hasImgAnalysis?: boolean; } interface IncidentsLeftPanelProps { incidents: Incident[]; selectedIncidentId: string | null; onIncidentSelect: (id: string | null) => void; + onFilteredChange?: (filtered: Incident[]) => void; } const PERIOD_PRESETS = ['오늘', '1주일', '1개월', '3개월', '6개월', '1년'] as const; @@ -75,6 +77,7 @@ export function IncidentsLeftPanel({ incidents, selectedIncidentId, onIncidentSelect, + onFilteredChange, }: IncidentsLeftPanelProps) { const today = formatDate(new Date()); const todayLabel = today.replace(/-/g, '-'); @@ -157,6 +160,10 @@ export function IncidentsLeftPanel({ }); }, [incidents, searchTerm, selectedRegion, selectedStatus, dateFrom, dateTo]); + useEffect(() => { + onFilteredChange?.(filteredIncidents); + }, [filteredIncidents, onFilteredChange]); + const regionCounts = useMemo(() => { const dateFiltered = incidents.filter((i) => { const matchesSearch = @@ -551,6 +558,27 @@ export function IncidentsLeftPanel({ 📹 {inc.mediaCount} )} + {inc.hasImgAnalysis && ( + + )}
diff --git a/frontend/src/tabs/incidents/components/IncidentsView.tsx b/frontend/src/tabs/incidents/components/IncidentsView.tsx index 9d6837b..a1d17e6 100755 --- a/frontend/src/tabs/incidents/components/IncidentsView.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsView.tsx @@ -129,6 +129,7 @@ interface HoverInfo { ════════════════════════════════════════════════════ */ export function IncidentsView() { const [incidents, setIncidents] = useState([]); + const [filteredIncidents, setFilteredIncidents] = useState([]); const [selectedIncidentId, setSelectedIncidentId] = useState(null); const [selectedVessel, setSelectedVessel] = useState(null); const [detailVessel, setDetailVessel] = useState(null); @@ -249,7 +250,7 @@ export function IncidentsView() { () => new ScatterplotLayer({ id: 'incidents', - data: incidents, + data: filteredIncidents, getPosition: (d: IncidentCompat) => [d.location.lon, d.location.lat], getRadius: (d: IncidentCompat) => (selectedIncidentId === d.id ? 16 : 12), getFillColor: (d: IncidentCompat) => getMarkerColor(d.status), @@ -290,7 +291,7 @@ export function IncidentsView() { getLineWidth: [selectedIncidentId], }, }), - [incidents, selectedIncidentId], + [filteredIncidents, selectedIncidentId], ); // ── 선박 방향 지시 아이콘 (삼각형 SVG → IconLayer) ────── @@ -577,6 +578,7 @@ export function IncidentsView() { incidents={incidents} selectedIncidentId={selectedIncidentId} onIncidentSelect={setSelectedIncidentId} + onFilteredChange={setFilteredIncidents} /> {/* Center - Map + Analysis Views */} @@ -689,29 +691,15 @@ export function IncidentsView() { latitude={incidentPopup.latitude} anchor="bottom" onClose={() => setIncidentPopup(null)} - closeButton={true} + closeButton={false} closeOnClick={false} + className="incident-popup" + maxWidth="none" > -
-
- {incidentPopup.incident.name} -
-
-
상태: {getStatusLabel(incidentPopup.incident.status)}
-
- 일시: {incidentPopup.incident.date} {incidentPopup.incident.time} -
-
관할: {incidentPopup.incident.office}
- {incidentPopup.incident.causeType && ( -
원인: {incidentPopup.incident.causeType}
- )} - {incidentPopup.incident.prediction && ( -
- {incidentPopup.incident.prediction} -
- )} -
-
+ setIncidentPopup(null)} + /> )} @@ -1443,6 +1431,165 @@ function PopupRow({ ); } +/* ════════════════════════════════════════════════════ + IncidentPopupContent – 사고 마커 클릭 팝업 + ════════════════════════════════════════════════════ */ +function IncidentPopupContent({ + incident: inc, + onClose, +}: { + incident: IncidentCompat; + onClose: () => void; +}) { + const dotColor: Record = { + active: 'var(--color-danger)', + investigating: 'var(--color-warning)', + closed: 'var(--fg-disabled)', + }; + const stBg: Record = { + active: 'rgba(239,68,68,0.15)', + investigating: 'rgba(249,115,22,0.15)', + closed: 'rgba(100,116,139,0.15)', + }; + const stColor: Record = { + active: 'var(--color-danger)', + investigating: 'var(--color-warning)', + closed: 'var(--fg-disabled)', + }; + + return ( +
+ {/* Header */} +
+ +
+ {inc.name} +
+ + ✕ + +
+ + {/* Tags */} +
+ + {getStatusLabel(inc.status)} + + {inc.causeType && ( + + {inc.causeType} + + )} + {inc.oilType && ( + + {inc.oilType} + + )} +
+ + {/* Info rows */} +
+
+ 일시 + + {inc.date} {inc.time} + +
+
+ 관할 + {inc.office} +
+
+ 지역 + {inc.region} +
+
+ + {/* Prediction badge */} + {inc.prediction && ( +
+ + {inc.prediction} + +
+ )} +
+ ); +} + /* ════════════════════════════════════════════════════ VesselDetailModal ════════════════════════════════════════════════════ */ diff --git a/frontend/src/tabs/incidents/components/MediaModal.tsx b/frontend/src/tabs/incidents/components/MediaModal.tsx index 0f89875..626b2a1 100755 --- a/frontend/src/tabs/incidents/components/MediaModal.tsx +++ b/frontend/src/tabs/incidents/components/MediaModal.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import type { Incident } from './IncidentsLeftPanel'; -import { fetchIncidentMedia } from '../services/incidentsApi'; -import type { MediaInfo } from '../services/incidentsApi'; +import { fetchIncidentMedia, fetchIncidentAerialMedia, getMediaImageUrl } from '../services/incidentsApi'; +import type { MediaInfo, AerialMediaItem } from '../services/incidentsApi'; type MediaTab = 'all' | 'photo' | 'video' | 'satellite' | 'cctv'; @@ -35,9 +35,12 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose: const [activeTab, setActiveTab] = useState('all'); const [selectedCam, setSelectedCam] = useState(0); const [media, setMedia] = useState(null); + const [aerialImages, setAerialImages] = useState([]); + const [selectedImageIdx, setSelectedImageIdx] = useState(0); useEffect(() => { fetchIncidentMedia(parseInt(incident.id)).then(setMedia); + fetchIncidentAerialMedia(parseInt(incident.id)).then(setAerialImages); }, [incident.id]); // Timeline dots (UI constant) @@ -75,7 +78,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose: ); } - const total = media.photoCnt + media.videoCnt + media.satCnt + media.cctvCnt; + const total = (media.photoCnt ?? 0) + (media.videoCnt ?? 0) + (media.satCnt ?? 0) + (media.cctvCnt ?? 0) + aerialImages.length; const showPhoto = activeTab === 'all' || activeTab === 'photo'; const showVideo = activeTab === 'all' || activeTab === 'video'; @@ -233,61 +236,171 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
📷 - 현장사진 — {str(media.photoMeta, 'title', '현장 사진')} + 현장사진 — {aerialImages.length > 0 ? `${aerialImages.length}장` : str(media.photoMeta, 'title', '현장 사진')}
- + {aerialImages.length > 1 && ( + <> + setSelectedImageIdx((p) => Math.max(0, p - 1))} /> + setSelectedImageIdx((p) => Math.min(aerialImages.length - 1, p + 1))} /> + + )} +
{/* Photo content */} -
-
- 📷 -
-
- {incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')} 해상 사진 -
-
- {str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')} -
+
+ {aerialImages.length > 0 ? ( + <> + {aerialImages[selectedImageIdx].orgnlNm { + (e.target as HTMLImageElement).style.display = 'none'; + (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); + }} + /> +
+
📷
+
이미지를 불러올 수 없습니다
+
+ {aerialImages.length > 1 && ( + <> + + + + )} +
+ {selectedImageIdx + 1} / {aerialImages.length} +
+ + ) : ( +
+
📷
+
+ {incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')} 해상 사진 +
+
+ {str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')} +
+
+ )}
{/* Thumbnails */}
-
- {Array.from({ length: Math.min(num(media.photoMeta, 'thumbCount'), 7) }).map( - (_, i) => ( -
0 ? ( + <> +
+ {aerialImages.map((img, i) => ( +
- 📷 -
- ), - )} -
-
- - 📷 사진 {num(media.photoMeta, 'thumbCount')}장 · {str(media.photoMeta, 'stage')} - - - 🔗 R&D 연계 - -
+ }} + onClick={() => setSelectedImageIdx(i)} + > + {img.orgnlNm { + const el = e.target as HTMLImageElement; + el.style.display = 'none'; + }} + /> +
+ ))} +
+
+ + 📷 사진 {aerialImages.length}장 + {aerialImages[selectedImageIdx]?.takngDtm + ? ` · ${new Date(aerialImages[selectedImageIdx].takngDtm!).toLocaleDateString('ko-KR')}` + : ''} + + + {aerialImages[selectedImageIdx]?.orgnlNm ?? ''} + +
+ + ) : ( + <> +
+ {Array.from({ length: Math.min(num(media.photoMeta, 'thumbCount'), 7) }).map( + (_, i) => ( +
+ 📷 +
+ ), + )} +
+
+ + 📷 사진 {num(media.photoMeta, 'thumbCount')}장 · {str(media.photoMeta, 'stage')} + + + 🔗 R&D 연계 + +
+ + )}
)} @@ -560,16 +673,16 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose: >
- 📷 사진 {media.photoCnt} + 📷 사진 {aerialImages.length > 0 ? aerialImages.length : (media.photoCnt ?? 0)} - 🎬 영상 {media.videoCnt} + 🎬 영상 {media.videoCnt ?? 0} - 🛰 위성 {media.satCnt} + 🛰 위성 {media.satCnt ?? 0} - 📹 CCTV {media.cctvCnt} + 📹 CCTV {media.cctvCnt ?? 0} 📎 총 {total}건 @@ -604,9 +717,10 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose: ); } -function NavBtn({ label }: { label: string }) { +function NavBtn({ label, onClick }: { label: string; onClick?: () => void }) { return (
- {/* Direct Input Mode */} - {inputMode === 'direct' && ( - <> - onIncidentNameChange(e.target.value)} - style={ - validationErrors?.has('incidentName') - ? { borderColor: 'var(--color-danger)' } - : undefined - } - /> - - - )} + {/* 사고명 입력 (직접입력 / 이미지업로드 공통) */} + onIncidentNameChange(e.target.value)} + style={ + validationErrors?.has('incidentName') + ? { borderColor: 'var(--color-danger)' } + : undefined + } + /> + {/* Image Upload Mode */} {inputMode === 'upload' && ( @@ -353,10 +349,10 @@ const PredictionInputSection = ({ className="prd-i" placeholder="유출량" type="number" - min="1" - step="1" + min="0" + step="any" value={spillAmount} - onChange={(e) => onSpillAmountChange(parseInt(e.target.value) || 0)} + onChange={(e) => onSpillAmountChange(parseFloat(e.target.value) || 0)} /> => { +export const analyzeImage = async (file: File, acdntNm?: string): Promise => { const formData = new FormData(); formData.append('image', file); + if (acdntNm?.trim()) formData.append('acdntNm', acdntNm.trim()); const response = await api.post('/prediction/image-analyze', formData, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 330_000, From 965b238b081432878a0a7782b54bfa5ee71c4ea4 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 13 Apr 2026 16:43:33 +0900 Subject: [PATCH 7/9] =?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 | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index b5d12af..3ab4dab 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,19 @@ ## [Unreleased] +### 추가 +- 사고별 이미지 분석 데이터 조회 API 추가 +- 사고 리스트에 항공 미디어 연동 및 이미지 분석 뱃지 표시 +- 사고 마커 클릭 팝업 디자인 리뉴얼 +- 지도에 필터링된 사고만 표시되도록 개선 + +### 변경 +- 이미지 분석 시 사고명 파라미터 지원 +- 기본 예측시간 48시간 → 6시간으로 변경 +- 유출량(SPIL_QTY) 정밀도 NUMERIC(14,10)으로 확대 +- OpenDrift 유종 매핑 수정 (원유, 등유) +- 소량 유출량 과학적 표기법으로 표시 + ## [2026-04-09] ### 추가 From 9630b1daac680a2470c8df1c9bad284230c99977 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 13 Apr 2026 16:51:42 +0900 Subject: [PATCH 8/9] =?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=A0=95=EB=A6=AC=20(2026-04-13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 3ab4dab..5c127bb 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-04-13] + ### 추가 - 사고별 이미지 분석 데이터 조회 API 추가 - 사고 리스트에 항공 미디어 연동 및 이미지 분석 뱃지 표시 From af4ab9dd801e09f6a103230d69f638a6ee2ae221 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Tue, 14 Apr 2026 11:01:18 +0900 Subject: [PATCH 9/9] =?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 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 5c127bb..2e6a632 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,10 @@ ## [Unreleased] +### 추가 +- 관리자: 비식별화조치 메뉴 및 패널 추가 +- 긴급구난/예측도 OSM 지도 적용 및 관리자 패널 추가 + ## [2026-04-13] ### 추가