From 1da25536947e481d5158313bf55648e3599bc6b1 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Thu, 16 Apr 2026 15:24:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(incidents):=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=ED=8C=A8=EB=84=90=20=EB=B6=84=ED=95=A0=20?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=8F=20=EC=9C=A0=EC=B6=9C=EC=9C=A0=20=ED=99=95?= =?UTF-8?q?=EC=82=B0=20=EC=9A=94=EC=95=BD=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Incidents 통합 분석 시 이전 분석 결과를 분할 화면으로 표출 - 유출유/HNS/구난 분석 선택 모달(AnalysisSelectModal) 추가 - prediction /analyses/:acdntSn/oil-summary API 신규 (primary + byModel) - HNS 분석 생성 시 acdntSn 연결 지원 - GSC 사고 목록 응답에 acdntSn 노출 - 민감자원 누적/카테고리 관리 및 HNS 확산 레이어 유틸(hnsDispersionLayers) 추가 --- backend/src/gsc/gscAccidentsService.ts | 4 + backend/src/hns/hnsRouter.ts | 6 +- backend/src/hns/hnsService.ts | 17 +- backend/src/prediction/predictionRouter.ts | 22 + backend/src/prediction/predictionService.ts | 123 ++ .../src/tabs/hns/components/HNSLeftPanel.tsx | 7 + frontend/src/tabs/hns/components/HNSView.tsx | 1 + frontend/src/tabs/hns/services/hnsApi.ts | 1 + .../components/AnalysisSelectModal.tsx | 703 +++++++++++ .../components/IncidentsRightPanel.tsx | 227 +++- .../incidents/components/IncidentsView.tsx | 1080 ++++++++++++----- .../incidents/utils/hnsDispersionLayers.ts | 242 ++++ .../tabs/prediction/services/predictionApi.ts | 27 + 13 files changed, 2150 insertions(+), 310 deletions(-) create mode 100644 frontend/src/tabs/incidents/components/AnalysisSelectModal.tsx create mode 100644 frontend/src/tabs/incidents/utils/hnsDispersionLayers.ts diff --git a/backend/src/gsc/gscAccidentsService.ts b/backend/src/gsc/gscAccidentsService.ts index 38d21fb..4365173 100644 --- a/backend/src/gsc/gscAccidentsService.ts +++ b/backend/src/gsc/gscAccidentsService.ts @@ -1,6 +1,7 @@ import { wingPool } from '../db/wingDb.js'; export interface GscAccidentListItem { + acdntSn: number; acdntMngNo: string; pollNm: string; pollDate: string | null; @@ -11,6 +12,7 @@ export interface GscAccidentListItem { export async function listGscAccidents(limit = 20): Promise { const sql = ` SELECT + ACDNT_SN AS "acdntSn", ACDNT_CD AS "acdntMngNo", ACDNT_NM AS "pollNm", to_char(OCCRN_DTM, 'YYYY-MM-DD"T"HH24:MI') AS "pollDate", @@ -23,6 +25,7 @@ export async function listGscAccidents(limit = 20): Promise(sql, [limit]); return result.rows.map((row) => ({ + acdntSn: row.acdntSn, acdntMngNo: row.acdntMngNo, pollNm: row.pollNm, pollDate: row.pollDate, diff --git a/backend/src/hns/hnsRouter.ts b/backend/src/hns/hnsRouter.ts index 6f9946c..7985d72 100644 --- a/backend/src/hns/hnsRouter.ts +++ b/backend/src/hns/hnsRouter.ts @@ -50,13 +50,15 @@ router.get('/analyses/:sn', requireAuth, requirePermission('hns', 'READ'), async // POST /api/hns/analyses — 분석 생성 router.post('/analyses', requireAuth, requirePermission('hns', 'CREATE'), async (req, res) => { try { - const { anlysNm, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm } = req.body + const { anlysNm, acdntSn, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm } = req.body if (!anlysNm) { res.status(400).json({ error: '분석명은 필수입니다.' }) return } + const acdntSnNum = acdntSn != null ? parseInt(String(acdntSn), 10) : undefined const result = await createAnalysis({ - anlysNm, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm, + anlysNm, acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined, + acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm, }) res.status(201).json(result) } catch (err) { diff --git a/backend/src/hns/hnsService.ts b/backend/src/hns/hnsService.ts index fd1d86a..ff10c2e 100644 --- a/backend/src/hns/hnsService.ts +++ b/backend/src/hns/hnsService.ts @@ -201,6 +201,7 @@ export async function getAnalysis(sn: number): Promise { export async function createAnalysis(input: { anlysNm: string + acdntSn?: number acdntDtm?: string locNm?: string lon?: number @@ -220,21 +221,21 @@ export async function createAnalysis(input: { }): Promise<{ hnsAnlysSn: number }> { const { rows } = await wingPool.query( `INSERT INTO HNS_ANALYSIS ( - ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT, + ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT, GEOM, LOC_DC, SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD, WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD, ANALYST_NM, EXEC_STTS_CD ) VALUES ( - $1, $2, $3, $4::numeric, $5::numeric, - CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($4::double precision, $5::double precision), 4326) END, - CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN $4::text || ' + ' || $5::text END, - $6, $7, $8, $9, $10, $11, - $12, $13, $14, $15, $16, - $17, 'PENDING' + $1, $2, $3, $4, $5::numeric, $6::numeric, + CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($5::double precision, $6::double precision), 4326) END, + CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN $5::text || ' + ' || $6::text END, + $7, $8, $9, $10, $11, $12, + $13, $14, $15, $16, $17, + $18, 'PENDING' ) RETURNING HNS_ANLYS_SN`, [ - input.anlysNm, input.acdntDtm || null, input.locNm || null, input.lon || null, input.lat || null, + input.acdntSn || null, input.anlysNm, input.acdntDtm || null, input.locNm || null, input.lon || null, input.lat || null, input.sbstNm || null, input.spilQty || null, input.spilUnitCd || 'KL', input.fcstHr || null, input.algoCd || null, input.critMdlCd || null, input.windSpd || null, input.windDir || null, input.temp || null, input.humid || null, input.atmStblCd || null, diff --git a/backend/src/prediction/predictionRouter.ts b/backend/src/prediction/predictionRouter.ts index 497ec86..95e07da 100644 --- a/backend/src/prediction/predictionRouter.ts +++ b/backend/src/prediction/predictionRouter.ts @@ -5,6 +5,7 @@ import { createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory, getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn, getPredictionParticlesGeojsonByAcdntSn, getSensitivityEvaluationGeojsonByAcdntSn, + getOilSpillSummary, } from './predictionService.js'; import { analyzeImageFile } from './imageAnalyzeService.js'; import { isValidNumber } from '../middleware/security.js'; @@ -70,6 +71,27 @@ router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('pred } }); +// GET /api/prediction/analyses/:acdntSn/oil-summary — 유출유 확산 요약 (분할 패널용) +router.get('/analyses/:acdntSn/oil-summary', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => { + try { + const acdntSn = parseInt(req.params.acdntSn as string, 10); + if (!isValidNumber(acdntSn, 1, 999999)) { + res.status(400).json({ error: '유효하지 않은 사고 번호' }); + return; + } + const predRunSn = req.query.predRunSn ? parseInt(req.query.predRunSn as string, 10) : undefined; + const result = await getOilSpillSummary(acdntSn, predRunSn); + if (!result) { + res.json({ primary: null, byModel: {} }); + return; + } + res.json(result); + } catch (err) { + console.error('[prediction] oil-summary 조회 오류:', err); + res.status(500).json({ error: 'oil-summary 조회 실패' }); + } +}); + // GET /api/prediction/analyses/:acdntSn/sensitive-resources — 예측 영역 내 민감자원 집계 router.get('/analyses/:acdntSn/sensitive-resources', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => { try { diff --git a/backend/src/prediction/predictionService.ts b/backend/src/prediction/predictionService.ts index e5ec6cb..fb26a14 100644 --- a/backend/src/prediction/predictionService.ts +++ b/backend/src/prediction/predictionService.ts @@ -1,6 +1,16 @@ import { wingPool } from '../db/wingDb.js'; import { runBacktrackAnalysis } from './backtrackAnalysisService.js'; +function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371; + const dLat = (lat2 - lat1) * Math.PI / 180; + const dLon = (lon2 - lon1) * Math.PI / 180; + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * + Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + interface PredictionAnalysis { acdntSn: number; acdntNm: string; @@ -812,3 +822,116 @@ export async function listBoomLines(acdntSn: number): Promise { regDtm: String(r['reg_dtm'] ?? ''), })); } + +// ── 유출유 확산 요약 (통합조회 분할 패널용) ────────────── +export interface OilSpillSummary { + model: string; + forecastDurationHr: number | null; + maxSpreadDistanceKm: number | null; + coastArrivalTimeHr: number | null; + affectedCoastlineKm: number | null; + weatheringRatePct: number | null; + remainingVolumeKl: number | null; +} + +export interface OilSpillSummaryResponse { + primary: OilSpillSummary; + byModel: Record; +} + +export async function getOilSpillSummary(acdntSn: number, predRunSn?: number): Promise { + const baseSql = ` + SELECT pe.ALGO_CD, pe.RSLT_DATA, + sd.FCST_HR, + ST_Y(a.LOC_GEOM) AS spil_lat, + ST_X(a.LOC_GEOM) AS spil_lon + FROM wing.PRED_EXEC pe + LEFT JOIN wing.SPIL_DATA sd ON sd.ACDNT_SN = pe.ACDNT_SN + LEFT JOIN wing.ACDNT a ON a.ACDNT_SN = pe.ACDNT_SN + WHERE pe.ACDNT_SN = $1 + AND pe.ALGO_CD IN ('OPENDRIFT', 'POSEIDON') + AND pe.EXEC_STTS_CD = 'COMPLETED' + AND pe.RSLT_DATA IS NOT NULL + `; + const sql = predRunSn != null + ? baseSql + ' AND pe.PRED_RUN_SN = $2 ORDER BY pe.CMPL_DTM DESC' + : baseSql + ' ORDER BY pe.CMPL_DTM DESC'; + const params = predRunSn != null ? [acdntSn, predRunSn] : [acdntSn]; + const { rows } = await wingPool.query(sql, params); + if (rows.length === 0) return null; + + const byModel: Record = {}; + + // OpenDrift 우선, 없으면 POSEIDON + const opendriftRow = (rows as Array>).find((r) => r['algo_cd'] === 'OPENDRIFT'); + const poseidonRow = (rows as Array>).find((r) => r['algo_cd'] === 'POSEIDON'); + const primaryRow = opendriftRow ?? poseidonRow ?? null; + + for (const row of rows as Array>) { + const rsltData = row['rslt_data'] as TrajectoryTimeStep[] | null; + if (!rsltData || rsltData.length === 0) continue; + + const algoCd = String(row['algo_cd'] ?? ''); + const modelName = ALGO_CD_TO_MODEL[algoCd] ?? algoCd; + const fcstHr = row['fcst_hr'] != null ? Number(row['fcst_hr']) : null; + const spilLat = row['spil_lat'] != null ? Number(row['spil_lat']) : null; + const spilLon = row['spil_lon'] != null ? Number(row['spil_lon']) : null; + const totalSteps = rsltData.length; + const lastStep = rsltData[totalSteps - 1]; + + // 최대 확산거리 — 사고 위치 또는 첫 파티클 위치를 원점으로 사용 + let maxDist: number | null = null; + const originLat = spilLat ?? rsltData[0]?.particles[0]?.lat ?? null; + const originLon = spilLon ?? rsltData[0]?.particles[0]?.lon ?? null; + if (originLat != null && originLon != null) { + let maxVal = 0; + for (const step of rsltData) { + for (const p of step.particles) { + const d = haversineKm(originLat, originLon, p.lat, p.lon); + if (d > maxVal) maxVal = d; + } + } + maxDist = maxVal; + } + + // 해안 도달 시간 (stranded===1 최초 등장 step) + let coastArrivalHr: number | null = null; + for (let i = 0; i < totalSteps; i++) { + if (rsltData[i].particles.some((p) => p.stranded === 1)) { + coastArrivalHr = fcstHr != null && totalSteps > 1 + ? parseFloat(((i / (totalSteps - 1)) * fcstHr).toFixed(1)) + : i; + break; + } + } + + // 풍화율 + const totalVol = lastStep.remaining_volume_m3 + lastStep.weathered_volume_m3 + lastStep.beached_volume_m3; + const weatheringPct = totalVol > 0 + ? parseFloat(((lastStep.weathered_volume_m3 / totalVol) * 100).toFixed(1)) + : null; + + byModel[modelName] = { + model: modelName, + forecastDurationHr: fcstHr, + maxSpreadDistanceKm: maxDist != null ? parseFloat(maxDist.toFixed(1)) : null, + coastArrivalTimeHr: coastArrivalHr, + affectedCoastlineKm: lastStep.pollution_coast_length_m != null + ? parseFloat((lastStep.pollution_coast_length_m / 1000).toFixed(1)) + : null, + weatheringRatePct: weatheringPct, + remainingVolumeKl: lastStep.remaining_volume_m3 != null + ? parseFloat(lastStep.remaining_volume_m3.toFixed(1)) + : null, + }; + } + + if (!primaryRow) return null; + const primaryAlgo = String(primaryRow['algo_cd'] ?? ''); + const primaryModel = ALGO_CD_TO_MODEL[primaryAlgo] ?? primaryAlgo; + + return { + primary: byModel[primaryModel] ?? Object.values(byModel)[0], + byModel, + }; +} diff --git a/frontend/src/tabs/hns/components/HNSLeftPanel.tsx b/frontend/src/tabs/hns/components/HNSLeftPanel.tsx index 7822dd8..f149a3c 100755 --- a/frontend/src/tabs/hns/components/HNSLeftPanel.tsx +++ b/frontend/src/tabs/hns/components/HNSLeftPanel.tsx @@ -31,6 +31,8 @@ export interface HNSInputParams { predictionTime: string; /** 사고명 (직접 입력 또는 사고 리스트 선택) */ accidentName: string; + /** wing.ACDNT 사고번호 (사고 리스트에서 선택된 경우) */ + selectedAcdntSn?: number; } interface HNSLeftPanelProps { @@ -72,6 +74,7 @@ export function HNSLeftPanel({ }: HNSLeftPanelProps) { const [incidents, setIncidents] = useState([]); const [selectedIncidentSn, setSelectedIncidentSn] = useState(''); + const [selectedAcdntSn, setSelectedAcdntSn] = useState(undefined); const [expandedSections, setExpandedSections] = useState({ accident: true, params: true }); const toggleSection = (key: 'accident' | 'params') => setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] })); @@ -150,6 +153,7 @@ export function HNSLeftPanel({ setSelectedIncidentSn(mngNo); const incident = incidents.find((i) => i.acdntMngNo === mngNo); if (!incident) return; + setSelectedAcdntSn(incident.acdntSn); setAccidentName(incident.pollNm); if (incident.pollDate) { @@ -181,6 +185,7 @@ export function HNSLeftPanel({ accidentTime, predictionTime, accidentName, + selectedAcdntSn, }); } }, [ @@ -202,10 +207,12 @@ export function HNSLeftPanel({ accidentTime, predictionTime, accidentName, + selectedAcdntSn, ]); const handleReset = () => { setSelectedIncidentSn(''); + setSelectedAcdntSn(undefined); setAccidentName(''); const now = new Date(); setAccidentDate(now.toISOString().slice(0, 10)); diff --git a/frontend/src/tabs/hns/components/HNSView.tsx b/frontend/src/tabs/hns/components/HNSView.tsx index 625abf8..3459b8a 100755 --- a/frontend/src/tabs/hns/components/HNSView.tsx +++ b/frontend/src/tabs/hns/components/HNSView.tsx @@ -529,6 +529,7 @@ export function HNSView() { : params?.accidentDate || undefined; const result = await createHnsAnalysis({ anlysNm: params?.accidentName || `HNS 분석 ${new Date().toLocaleDateString('ko-KR')}`, + acdntSn: params?.selectedAcdntSn, acdntDtm, lon: incidentCoord.lon, lat: incidentCoord.lat, diff --git a/frontend/src/tabs/hns/services/hnsApi.ts b/frontend/src/tabs/hns/services/hnsApi.ts index 70bab2f..f5ee208 100644 --- a/frontend/src/tabs/hns/services/hnsApi.ts +++ b/frontend/src/tabs/hns/services/hnsApi.ts @@ -29,6 +29,7 @@ export interface HnsAnalysisItem { export interface CreateHnsAnalysisInput { anlysNm: string; + acdntSn?: number; acdntDtm?: string; locNm?: string; lon?: number; diff --git a/frontend/src/tabs/incidents/components/AnalysisSelectModal.tsx b/frontend/src/tabs/incidents/components/AnalysisSelectModal.tsx new file mode 100644 index 0000000..0463c4e --- /dev/null +++ b/frontend/src/tabs/incidents/components/AnalysisSelectModal.tsx @@ -0,0 +1,703 @@ +import { useState, useEffect, useRef } from 'react'; +import { fetchPredictionAnalyses } from '@tabs/prediction/services/predictionApi'; +import type { PredictionAnalysis } from '@tabs/prediction/services/predictionApi'; +import { fetchHnsAnalyses } from '@tabs/hns/services/hnsApi'; +import type { HnsAnalysisItem } from '@tabs/hns/services/hnsApi'; +import { fetchRescueOps } from '@tabs/rescue/services/rescueApi'; +import type { RescueOpsItem } from '@tabs/rescue/services/rescueApi'; + +// ── 타입 정의 ────────────────────────────────────────── +export type AnalysisModalType = 'oil' | 'hns' | 'rescue'; + +export type AnalysisApplyPayload = + | { type: 'oil'; items: PredictionAnalysis[] } + | { type: 'hns'; items: HnsAnalysisItem[] } + | { type: 'rescue'; items: RescueOpsItem[] }; + +export interface AnalysisSelectModalProps { + type: AnalysisModalType; + isOpen: boolean; + onClose: () => void; + initialSelectedIds: Set; + onApply: (payload: AnalysisApplyPayload) => void; +} + +type StatusTab = 'all' | 'active' | 'done'; + +// ── 메타 설정 ─────────────────────────────────────────── +const MODAL_META: Record = { + oil: { icon: '🛢', title: '유출유 확산예측 분석 목록', color: 'var(--color-warning)' }, + hns: { icon: '🧪', title: 'HNS 대기확산 분석 목록', color: 'var(--color-warning)' }, + rescue: { icon: '🚨', title: '긴급구난 분석 목록', color: 'var(--color-accent)' }, +}; + +// ── 상태 배지 ─────────────────────────────────────────── +function StatusBadge({ code }: { code: string }) { + const upper = code.toUpperCase(); + let label = code; + let color = 'var(--fg-disabled)'; + let bg = 'rgba(107,114,128,0.1)'; + + if (upper === 'ACTIVE' || upper === 'RUNNING' || upper === 'IN_PROGRESS') { + label = '대응중'; + color = 'var(--color-warning)'; + bg = 'rgba(249,115,22,0.1)'; + } else if ( + upper === 'COMPLETED' || + upper === 'RESOLVED' || + upper === 'CLOSED' || + upper === 'DONE' + ) { + label = '완료'; + color = 'var(--fg-disabled)'; + bg = 'rgba(107,114,128,0.1)'; + } else if (upper === 'CRITICAL' || upper === 'EMERGENCY') { + label = '긴급'; + color = 'var(--color-danger)'; + bg = 'rgba(239,68,68,0.1)'; + } else if (upper === 'INVESTIGATING') { + label = '조사중'; + color = 'var(--color-info)'; + bg = 'rgba(59,130,246,0.1)'; + } + + return ( + + {label} + + ); +} + +// ── 상태 코드 → 탭 카테고리 분류 ─────────────────────── +function classifyStatus(code: string): 'active' | 'done' { + const upper = code.toUpperCase(); + if ( + upper === 'COMPLETED' || + upper === 'RESOLVED' || + upper === 'CLOSED' || + upper === 'DONE' + ) { + return 'done'; + } + return 'active'; +} + +// ── rsltData 에서 안전하게 값 추출 ────────────────────── +function rslt(data: Record | null, key: string): string { + if (!data) return '-'; + const val = data[key]; + if (val == null) return '-'; + return String(val); +} + +// ── 모델 문자열 헬퍼 ──────────────────────────────────── +function getPredModels(p: PredictionAnalysis): string { + const models = [ + p.kospsStatus && p.kospsStatus !== 'pending' && p.kospsStatus !== 'none' ? 'KOSPS' : null, + p.poseidonStatus && p.poseidonStatus !== 'pending' && p.poseidonStatus !== 'none' + ? 'POSEIDON' + : null, + p.opendriftStatus && p.opendriftStatus !== 'pending' && p.opendriftStatus !== 'none' + ? 'OpenDrift' + : null, + ] + .filter(Boolean) + .join('+'); + return models || '-'; +} + +// ── 날짜 포맷 ─────────────────────────────────────────── +function fmtDate(dtm: string | null): string { + if (!dtm) return '-'; + return dtm.slice(0, 16).replace('T', ' '); +} + +/* ════════════════════════════════════════════════════ + AnalysisSelectModal + ════════════════════════════════════════════════════ */ +export function AnalysisSelectModal({ + type, + isOpen, + onClose, + initialSelectedIds, + onApply, +}: AnalysisSelectModalProps) { + const [loading, setLoading] = useState(false); + const [predItems, setPredItems] = useState([]); + const [hnsItems, setHnsItems] = useState([]); + const [rescueItems, setRescueItems] = useState([]); + const [checkedIds, setCheckedIds] = useState>(new Set(initialSelectedIds)); + const [statusTab, setStatusTab] = useState('all'); + const [search, setSearch] = useState(''); + + const backdropRef = useRef(null); + + // 모달 오픈 시 데이터 로드 + useEffect(() => { + if (!isOpen) return; + setCheckedIds(new Set(initialSelectedIds)); + setStatusTab('all'); + setSearch(''); + setLoading(true); + + const load = async () => { + try { + if (type === 'oil') { + const items = await fetchPredictionAnalyses(); + setPredItems(items); + } else if (type === 'hns') { + const items = await fetchHnsAnalyses(); + setHnsItems(items); + } else { + const items = await fetchRescueOps(); + setRescueItems(items); + } + } catch { + // 조용히 실패 + } finally { + setLoading(false); + } + }; + void load(); + }, [isOpen, type]); // eslint-disable-line react-hooks/exhaustive-deps + + // Backdrop 클릭 닫기 + useEffect(() => { + const handler = (e: MouseEvent) => { + if (e.target === backdropRef.current) onClose(); + }; + if (isOpen) document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const meta = MODAL_META[type]; + + // ── 필터 적용 ── + const filteredOil = predItems.filter((p) => { + const statusCode = p.acdntSttsCd || ''; + const tabOk = + statusTab === 'all' || + (statusTab === 'active' && classifyStatus(statusCode) === 'active') || + (statusTab === 'done' && classifyStatus(statusCode) === 'done'); + const searchOk = + search === '' || + (p.acdntNm || '').toLowerCase().includes(search.toLowerCase()) || + (p.oilType || '').toLowerCase().includes(search.toLowerCase()); + return tabOk && searchOk; + }); + + const filteredHns = hnsItems.filter((h) => { + const statusCode = h.execSttsCd || ''; + const tabOk = + statusTab === 'all' || + (statusTab === 'active' && classifyStatus(statusCode) === 'active') || + (statusTab === 'done' && classifyStatus(statusCode) === 'done'); + const searchOk = + search === '' || + (h.anlysNm || '').toLowerCase().includes(search.toLowerCase()) || + (h.sbstNm || '').toLowerCase().includes(search.toLowerCase()); + return tabOk && searchOk; + }); + + const filteredRescue = rescueItems.filter((r) => { + const statusCode = r.sttsCd || ''; + const tabOk = + statusTab === 'all' || + (statusTab === 'active' && classifyStatus(statusCode) === 'active') || + (statusTab === 'done' && classifyStatus(statusCode) === 'done'); + const searchOk = + search === '' || + (r.vesselNm || '').toLowerCase().includes(search.toLowerCase()) || + (r.acdntTpCd || '').toLowerCase().includes(search.toLowerCase()); + return tabOk && searchOk; + }); + + const toggleId = (id: string) => { + setCheckedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const handleApply = () => { + if (type === 'oil') { + onApply({ type: 'oil', items: predItems.filter((p) => checkedIds.has(String(p.predRunSn ?? p.acdntSn))) }); + } else if (type === 'hns') { + onApply({ type: 'hns', items: hnsItems.filter((h) => checkedIds.has(String(h.hnsAnlysSn))) }); + } else { + onApply({ type: 'rescue', items: rescueItems.filter((r) => checkedIds.has(String(r.rescueOpsSn))) }); + } + }; + + const tabItems: { id: StatusTab; label: string }[] = [ + { id: 'all', label: '전체' }, + { id: 'active', label: '대응중' }, + { id: 'done', label: '완료' }, + ]; + + // ── 테이블 헤더 스타일 ── + const thStyle: React.CSSProperties = { + padding: '8px 10px', + textAlign: 'left', + fontWeight: 600, + color: 'var(--fg-disabled)', + fontSize: '11px', + whiteSpace: 'nowrap', + borderBottom: '1px solid var(--stroke-default)', + background: 'var(--bg-elevated)', + }; + + const tdStyle: React.CSSProperties = { + padding: '8px 10px', + fontSize: '12px', + borderBottom: '1px solid var(--stroke-default)', + whiteSpace: 'nowrap', + }; + + return ( +
+
+ {/* ── Header ── */} +
+
+
+
+ {meta.icon} {meta.title} +
+
+ 비교 분석 결과를 선택하세요 (다중 선택 가능) +
+
+
+ + 선택:{' '} + {checkedIds.size}건 + + +
+
+
+ + {/* ── Filter bar ── */} +
+ {/* Status tabs */} +
+ {tabItems.map((tab) => { + const isActive = statusTab === tab.id; + return ( + + ); + })} +
+ + {/* Search */} + setSearch(e.target.value)} + style={{ + padding: '5px 10px', + borderRadius: '4px', + border: '1px solid var(--stroke-default)', + background: 'var(--bg-surface)', + color: 'var(--fg)', + fontSize: '12px', + width: '180px', + outline: 'none', + }} + /> +
+ + {/* ── Table ── */} +
+ {loading ? ( +
+ 불러오는 중... +
+ ) : ( + + {type === 'oil' && ( + <> + + + + + + + + + + + + + {filteredOil.length === 0 ? ( + + + + ) : ( + filteredOil.map((p) => { + const id = String(p.predRunSn ?? p.acdntSn); + const checked = checkedIds.has(id); + return ( + toggleId(id)} + style={{ + cursor: 'pointer', + background: checked ? 'rgba(6,182,212,0.05)' : undefined, + }} + onMouseEnter={(e) => { + if (!checked) (e.currentTarget as HTMLTableRowElement).style.background = 'var(--bg-elevated)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLTableRowElement).style.background = checked ? 'rgba(6,182,212,0.05)' : ''; + }} + > + + + + + + + + + + ); + }) + )} + + + )} + + {type === 'hns' && ( + <> + + + + + + + + + + + + + {filteredHns.length === 0 ? ( + + + + ) : ( + filteredHns.map((h) => { + const id = String(h.hnsAnlysSn); + const checked = checkedIds.has(id); + const maxConc = rslt(h.rsltData, 'maxConcentration'); + const idlhDist = rslt(h.rsltData, 'idlhDistance'); + return ( + toggleId(id)} + style={{ + cursor: 'pointer', + background: checked ? 'rgba(6,182,212,0.05)' : undefined, + }} + onMouseEnter={(e) => { + if (!checked) (e.currentTarget as HTMLTableRowElement).style.background = 'var(--bg-elevated)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLTableRowElement).style.background = checked ? 'rgba(6,182,212,0.05)' : ''; + }} + > + + + + + + + + + + ); + }) + )} + + + )} + + {type === 'rescue' && ( + <> + + + + + + + + + + + + + {filteredRescue.length === 0 ? ( + + + + ) : ( + filteredRescue.map((r) => { + const id = String(r.rescueOpsSn); + const checked = checkedIds.has(id); + const crew = r.totalCrew != null ? r.totalCrew : null; + const surv = r.survivors != null ? r.survivors : null; + const crewLabel = + surv != null && crew != null + ? `${surv}/${crew}` + : surv != null + ? String(surv) + : '-'; + return ( + toggleId(id)} + style={{ + cursor: 'pointer', + background: checked ? 'rgba(6,182,212,0.05)' : undefined, + }} + onMouseEnter={(e) => { + if (!checked) (e.currentTarget as HTMLTableRowElement).style.background = 'var(--bg-elevated)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLTableRowElement).style.background = checked ? 'rgba(6,182,212,0.05)' : ''; + }} + > + + + + + + + + + + ); + }) + )} + + + )} +
+ 상태분석명유종유출량모델일시12h면적
+ 분석 결과가 없습니다 +
+ toggleId(id)} + onClick={(e) => e.stopPropagation()} + style={{ accentColor: meta.color, cursor: 'pointer' }} + /> + + + + {p.acdntNm || '-'} + + {p.oilType || '-'} + + {p.volume != null ? `${p.volume} kL` : '-'} + + {getPredModels(p)} + + {fmtDate(p.runDtm || p.analysisDate)} + + - +
+ 상태분석명물질최대농도모델일시IDLH
+ 분석 결과가 없습니다 +
+ toggleId(id)} + onClick={(e) => e.stopPropagation()} + style={{ accentColor: meta.color, cursor: 'pointer' }} + /> + + + + {h.anlysNm || '-'} + + {h.sbstNm || '-'} + + {maxConc !== '-' ? `${maxConc} ppm` : '-'} + + {h.algoCd || '-'} + + {fmtDate(h.regDtm)} + + {idlhDist !== '-' ? `${idlhDist} km` : '-'} +
+ 상태선박명 / 사고사고유형GM횡경사일시인명
+ 분석 결과가 없습니다 +
+ toggleId(id)} + onClick={(e) => e.stopPropagation()} + style={{ accentColor: meta.color, cursor: 'pointer' }} + /> + + + + {r.vesselNm || '-'} + + {r.acdntTpCd || '-'} + + {r.gmM != null ? `${r.gmM}m` : '-'} + + {r.listDeg != null ? `${r.listDeg}°` : '-'} + + {fmtDate(r.regDtm)} + 0 ? 'var(--color-danger)' : 'var(--color-success)', + }}> + {crewLabel} +
+ )} +
+ + {/* ── Footer ── */} +
+ + 선택한 분석 결과가 오른쪽 패널에 반영됩니다 + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx b/frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx index 57bb131..f7fb3a6 100755 --- a/frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx @@ -1,5 +1,7 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import type { Incident } from './IncidentsLeftPanel'; +import { AnalysisSelectModal } from './AnalysisSelectModal'; +import type { AnalysisApplyPayload } from './AnalysisSelectModal'; import { fetchPredictionAnalyses, fetchSensitiveResources, @@ -8,6 +10,7 @@ import { import type { PredictionAnalysis, SensitiveResourceCategory, + SensitiveResourceFeature, SensitiveResourceFeatureCollection, } from '@tabs/prediction/services/predictionApi'; import { fetchNearbyOrgs } from '../services/incidentsApi'; @@ -45,6 +48,13 @@ interface IncidentsRightPanelProps { onCheckedRescueChange?: ( checked: Array<{ id: string; rescueOpsSn: number; acdntSn: number | null }>, ) => void; + onCheckedPredItemsChange?: (items: PredictionAnalysis[]) => void; + onCheckedHnsItemsChange?: (items: HnsAnalysisItem[]) => void; + onCheckedRescueItemsChange?: (items: RescueOpsItem[]) => void; + onSensitiveCategoriesChange?: ( + categories: SensitiveResourceCategory[], + checkedCategories: Set, + ) => void; onSensitiveDataChange?: ( geojson: SensitiveResourceFeatureCollection | null, checkedCategories: Set, @@ -153,6 +163,10 @@ export function IncidentsRightPanel({ onCheckedPredsChange, onCheckedHnsChange, onCheckedRescueChange, + onCheckedPredItemsChange, + onCheckedHnsItemsChange, + onCheckedRescueItemsChange, + onSensitiveCategoriesChange, onSensitiveDataChange, selectedVessel, }: IncidentsRightPanelProps) { @@ -166,9 +180,14 @@ export function IncidentsRightPanel({ const [checkedSensCategories, setCheckedSensCategories] = useState>(new Set()); const [sensitiveGeojson, setSensitiveGeojson] = useState(null); + const [sensByAcdntSn, setSensByAcdntSn] = useState< + Map + >(new Map()); + const knownSensCatsRef = useRef>(new Set()); const [nearbyRadius, setNearbyRadius] = useState(50); const [nearbyOrgs, setNearbyOrgs] = useState([]); const [nearbyLoading, setNearbyLoading] = useState(false); + const [modalType, setModalType] = useState<'oil' | 'hns' | 'rescue' | null>(null); useEffect(() => { if (!incident) { @@ -178,6 +197,8 @@ export function IncidentsRightPanel({ setRescueItems([]); setSensCategories([]); setSensitiveGeojson(null); + setSensByAcdntSn(new Map()); + knownSensCatsRef.current = new Set(); onCheckedPredsChange?.([]); onCheckedHnsChange?.([]); onCheckedRescueChange?.([]); @@ -229,22 +250,9 @@ export function IncidentsRightPanel({ ); }) .catch(() => setRescueItems([])); - Promise.all([fetchSensitiveResources(acdntSn), fetchSensitiveResourcesGeojson(acdntSn)]) - .then(([cats, geojson]) => { - const allCategories = new Set(cats.map((c) => c.category)); - setSensCategories(cats); - setCheckedSensCategories(allCategories); - setSensitiveGeojson(geojson); - onSensitiveDataChange?.( - geojson, - allCategories, - cats.map((c) => c.category), - ); - }) - .catch(() => { - setSensCategories([]); - setSensitiveGeojson(null); - }); + // 민감자원 캐시 초기화 (새 사고 선택 시 기존 캐시 제거) + knownSensCatsRef.current = new Set(); + void Promise.resolve().then(() => setSensByAcdntSn(new Map())); }, [incident?.id]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { @@ -262,6 +270,120 @@ export function IncidentsRightPanel({ .finally(() => setNearbyLoading(false)); }, [selectedVessel, nearbyRadius]); + // 체크된 원본 아이템을 상위로 전달 (통합분석 분할 뷰에서 소비) + useEffect(() => { + const checked = predItems.filter((p) => checkedPredIds.has(String(p.predRunSn ?? p.acdntSn))); + onCheckedPredItemsChange?.(checked); + }, [predItems, checkedPredIds]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + const checked = hnsItems.filter((h) => checkedHnsIds.has(String(h.hnsAnlysSn))); + onCheckedHnsItemsChange?.(checked); + }, [hnsItems, checkedHnsIds]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + const checked = rescueItems.filter((r) => checkedRescueIds.has(String(r.rescueOpsSn))); + onCheckedRescueItemsChange?.(checked); + }, [rescueItems, checkedRescueIds]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + onSensitiveCategoriesChange?.(sensCategories, checkedSensCategories); + }, [sensCategories, checkedSensCategories]); // eslint-disable-line react-hooks/exhaustive-deps + + // Effect A: 체크된 예측의 acdntSn 중 미캐시된 민감자원 fetch + useEffect(() => { + if (!incident) return; + const incidentAcdntSn = parseInt(incident.id, 10); + + const checkedAcdntSns = new Set([ + incidentAcdntSn, + ...predItems + .filter((p) => checkedPredIds.has(String(p.predRunSn ?? p.acdntSn))) + .map((p) => p.acdntSn), + ]); + + const missing = [...checkedAcdntSns].filter((sn) => !sensByAcdntSn.has(sn)); + if (missing.length === 0) return; + + Promise.all( + missing.map((sn) => + Promise.all([fetchSensitiveResources(sn), fetchSensitiveResourcesGeojson(sn)]) + .then(([cats, geojson]) => ({ sn, cats, geojson })) + .catch(() => null), + ), + ).then((results) => { + setSensByAcdntSn((prev) => { + const newMap = new Map(prev); + results + .filter((r) => r !== null) + .forEach((r) => newMap.set(r!.sn, { categories: r!.cats, geojson: r!.geojson })); + return newMap; + }); + }); + }, [incident, predItems, checkedPredIds, sensByAcdntSn]); + + // Effect B: sensByAcdntSn + checkedPredIds → 합산 → sensCategories/sensitiveGeojson 업데이트 + useEffect(() => { + const checkedAcdntSns = new Set( + predItems + .filter((p) => checkedPredIds.has(String(p.predRunSn ?? p.acdntSn))) + .map((p) => p.acdntSn), + ); + + const catMap = new Map(); + const allFeatures: SensitiveResourceFeature[] = []; + const seenSrIds = new Set(); + + for (const sn of checkedAcdntSns) { + const data = sensByAcdntSn.get(sn); + if (!data) continue; + + data.categories.forEach((cat) => { + if (catMap.has(cat.category)) { + const ex = catMap.get(cat.category)!; + catMap.set(cat.category, { + category: cat.category, + count: ex.count + cat.count, + totalArea: + ex.totalArea != null || cat.totalArea != null + ? (ex.totalArea ?? 0) + (cat.totalArea ?? 0) + : null, + }); + } else { + catMap.set(cat.category, { ...cat }); + } + }); + + data.geojson.features.forEach((f) => { + const srId = (f.properties as Record)?.['srId'] as number; + if (srId == null || !seenSrIds.has(srId)) { + if (srId != null) seenSrIds.add(srId); + allFeatures.push(f); + } + }); + } + + const merged = [...catMap.values()]; + const newCatNames = new Set(merged.map((c) => c.category)); + const mergedGeojson: SensitiveResourceFeatureCollection | null = + allFeatures.length > 0 ? { type: 'FeatureCollection', features: allFeatures } : null; + + const newChecked = new Set(); + for (const cat of newCatNames) { + if (!knownSensCatsRef.current.has(cat) || checkedSensCategories.has(cat)) { + newChecked.add(cat); + } + } + knownSensCatsRef.current = newCatNames; + + void Promise.resolve().then(() => { + setSensCategories(merged); + setSensitiveGeojson(mergedGeojson); + setCheckedSensCategories(newChecked); + onSensitiveDataChange?.(mergedGeojson, newChecked, merged.map((c) => c.category)); + }); + }, [predItems, checkedPredIds, sensByAcdntSn]); // eslint-disable-line react-hooks/exhaustive-deps + const togglePredItem = (id: string) => { setCheckedPredIds((prev) => { const next = new Set(prev); @@ -455,6 +577,45 @@ export function IncidentsRightPanel({ }), }; + const handleModalApply = (payload: AnalysisApplyPayload) => { + if (payload.type === 'oil') { + setPredItems(payload.items); + const newIds = new Set(payload.items.map((p) => String(p.predRunSn ?? p.acdntSn))); + setCheckedPredIds(newIds); + onCheckedPredsChange?.( + payload.items.map((p) => ({ + id: String(p.predRunSn ?? p.acdntSn), + acdntSn: p.acdntSn, + predRunSn: p.predRunSn, + occurredAt: p.occurredAt, + })), + ); + } else if (payload.type === 'hns') { + setHnsItems(payload.items); + const newIds = new Set(payload.items.map((h) => String(h.hnsAnlysSn))); + setCheckedHnsIds(newIds); + onCheckedHnsChange?.( + payload.items.map((h) => ({ + id: String(h.hnsAnlysSn), + hnsAnlysSn: h.hnsAnlysSn, + acdntSn: h.acdntSn, + })), + ); + } else { + setRescueItems(payload.items); + const newIds = new Set(payload.items.map((r) => String(r.rescueOpsSn))); + setCheckedRescueIds(newIds); + onCheckedRescueChange?.( + payload.items.map((r) => ({ + id: String(r.rescueOpsSn), + rescueOpsSn: r.rescueOpsSn, + acdntSn: r.acdntSn, + })), + ); + } + setModalType(null); + }; + if (!incident) { return (
@@ -496,6 +657,7 @@ export function IncidentsRightPanel({
- {/* Default Map (visible when not in analysis or in overlay mode) */} - {(!analysisActive || viewMode === 'overlay') && ( -
- + { if (dischargeMode) { const distanceNm = estimateDistanceFromCoast(lat, lon); @@ -733,56 +841,6 @@ export function IncidentsView() {
)} - {/* 분석 오버레이 (지도 위 시각효과) */} - {analysisActive && viewMode === 'overlay' && ( -
- {analysisTags.some((t) => t.label === '유출유') && ( -
- )} - {analysisTags.some((t) => t.label === 'HNS') && ( -
- )} - {analysisTags.some((t) => t.label === '구난') && ( -
- )} -
- )} - {/* 오염물 배출 규정 토글 */}
- )} {/* ── 2분할 View ─────────────────────────────── */} {analysisActive && viewMode === 'split2' && ( -
-
-
- - {analysisTags[0] - ? `${analysisTags[0].icon} ${analysisTags[0].label}` - : '— 분석 결과를 선택하세요 —'} - -
-
- -
-
-
-
- - {analysisTags[1] - ? `${analysisTags[1].icon} ${analysisTags[1].label}` - : '— 분석 결과를 선택하세요 —'} - -
-
- -
-
+
+ {[0, 1].map((slotIndex) => { + const slotKey = split2Slots[slotIndex]; + const otherKey = split2Slots[slotIndex === 0 ? 1 : 0]; + const tag = slotKey ? SLOT_TAG[slotKey] : undefined; + return ( +
+
+ + {tag ? `${tag.icon} ${tag.label}` : '분석 선택'} + + +
+
+
+ +
+
+
+ ); + })}
)} {/* ── 3분할 View ─────────────────────────────── */} {analysisActive && viewMode === 'split3' && ( -
-
-
- + {(['oil', 'hns', 'rescue'] as const).map((slotKey, i) => { + const tag = SLOT_TAG[slotKey]; + return ( +
- 🛢 유출유 확산예측 - -
-
- -
-
-
-
- - 🧪 HNS 대기확산 - -
-
- -
-
-
-
- 🚨 긴급구난 -
-
- -
-
+
+ + {tag.icon} {tag.fullLabel} + +
+
+
+ +
+
+
+ ); + })}
)}
@@ -1103,6 +1167,14 @@ export function IncidentsView() { analysisActive={analysisActive} onCloseAnalysis={handleCloseAnalysis} onCheckedPredsChange={handleCheckedPredsChange} + onCheckedHnsChange={handleCheckedHnsChange} + onCheckedPredItemsChange={setCheckedPredItems} + onCheckedHnsItemsChange={setCheckedHnsItems} + onCheckedRescueItemsChange={setCheckedRescueItems} + onSensitiveCategoriesChange={(cats, checked) => { + setSensCategoriesFull(cats); + setCheckedSensCategoriesFull(checked); + }} onSensitiveDataChange={handleSensitiveDataChange} selectedVessel={ selectedVessel @@ -1120,78 +1192,237 @@ export function IncidentsView() { } /* ════════════════════════════════════════════════════ - SplitPanelContent + SplitPanelContent — 2/3분할 내부 렌더러 ════════════════════════════════════════════════════ */ +type SplitSlotKey = 'oil' | 'hns' | 'rescue'; + +const SLOT_TAG: Record< + SplitSlotKey, + { icon: string; label: string; fullLabel: string; color: string } +> = { + oil: { icon: '🛢', label: '유출유', fullLabel: '유출유 확산예측', color: 'var(--color-warning)' }, + hns: { icon: '🧪', label: 'HNS', fullLabel: 'HNS 대기확산', color: 'var(--color-tertiary)' }, + rescue: { icon: '🚨', label: '구난', fullLabel: '긴급구난', color: 'var(--color-accent)' }, +}; + +const SPLIT_CATEGORY_ICON: Record = { + 어장정보: '🐟', + 양식장: '🦪', + 양식어업: '🦪', + 어류양식장: '🐟', + 패류양식장: '🦪', + 해조류양식장: '🌿', + 가두리양식장: '🔲', + 갑각류양식장: '🦐', + 수산시장: '🐟', + 해수욕장: '🏖', + 마리나항: '⛵', + 무역항: '🚢', + 연안항: '⛵', + 국가어항: '⚓', + 지방어항: '⚓', + 어항: '⚓', + 항만구역: '⚓', + 해수취수시설: '💧', + '취수구·배수구': '🚰', + LNG: '⚡', + 발전소: '🔌', + 저유시설: '🛢', + 갯벌: '🪨', + 해안선_ESI: '🏖', + 보호지역: '🛡', + 해양보호구역: '🌿', + 철새도래지: '🐦', + 습지보호구역: '🏖', + 보호종서식지: '🐢', +}; + +const SPLIT_MOCK_FALLBACK: Record< + SplitSlotKey, + { + model: string; + items: { label: string; value: string; color?: string }[]; + summary: string; + } +> = { + oil: { + model: '-', + items: [ + { label: '예측 시간', value: '-' }, + { label: '최대 확산거리', value: '-', color: 'var(--color-warning)' }, + { label: '해안 도달 시간', value: '-', color: 'var(--color-danger)' }, + { label: '영향 해안선', value: '-' }, + { label: '풍화율', value: '-' }, + { label: '잔존유량', value: '-', color: 'var(--color-warning)' }, + ], + summary: '-', + }, + hns: { + model: '-', + items: [ + { label: 'IDLH 범위', value: '-', color: 'var(--color-danger)' }, + { label: 'ERPG-2 범위', value: '-', color: 'var(--color-warning)' }, + { label: 'ERPG-1 범위', value: '-', color: 'var(--color-caution)' }, + { label: '풍향', value: '-' }, + { label: '대기 안정도', value: '-' }, + { label: '영향 인구', value: '-', color: 'var(--color-danger)' }, + ], + summary: '-', + }, + rescue: { + model: '-', + items: [ + { label: '95% 확률 범위', value: '-', color: 'var(--color-accent)' }, + { label: '최적 탐색 경로', value: '-' }, + { label: '예상 표류 속도', value: '-' }, + { label: '표류 방향', value: '-' }, + { label: '생존 가능 시간', value: '-', color: 'var(--color-danger)' }, + { label: '필요 자산', value: '-', color: 'var(--color-warning)' }, + ], + summary: '-', + }, +}; + function SplitPanelContent({ - tag, + slotKey, incident, + checkedPreds, + checkedHns, + checkedRescues, + sensCategories, + checkedSensCategories, + trajectoryLayers, + hnsZoneLayers, + sensLayer, + oilSummaries, }: { - tag?: { icon: string; label: string; color: string }; + slotKey: SplitSlotKey | null; incident: Incident | null; + checkedPreds: PredictionAnalysis[]; + checkedHns: HnsAnalysisItem[]; + checkedRescues: RescueOpsItem[]; + sensCategories: SensitiveResourceCategory[]; + checkedSensCategories: Set; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + trajectoryLayers: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + hnsZoneLayers: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sensLayer: any; + oilSummaries: Record; }) { - if (!tag) { + if (!slotKey) { return (
- R&D 분석 결과를 선택하세요 + 분석 결과를 선택하세요
); } - const mockData: Record< - string, - { - title: string; - model: string; - items: { label: string; value: string; color?: string }[]; - summary: string; - } - > = { - 유출유: { - title: '유출유 확산예측 결과', - model: 'KOSPS + OpenDrift · BUNKER-C 150kL', - items: [ - { label: '예측 시간', value: '72시간 (3일)' }, - { label: '최대 확산거리', value: '12.3 NM', color: 'var(--color-warning)' }, - { label: '해안 도달 시간', value: '18시간 후', color: 'var(--color-danger)' }, - { label: '영향 해안선', value: '27.5 km' }, - { label: '풍화율', value: '32.4%' }, - { label: '잔존유량', value: '101.4 kL', color: 'var(--color-warning)' }, - ], - summary: - '여수항 남동쪽 방향 확산, 18시간 후 돌산도 해안 도달 예상. 조류 영향으로 남서쪽 이동.', - }, - HNS: { - title: 'HNS 대기확산 결과', - model: 'ALOHA + PHAST · 톨루엔 5톤', - items: [ - { label: 'IDLH 범위', value: '1.2 km', color: 'var(--color-danger)' }, - { label: 'ERPG-2 범위', value: '2.8 km', color: 'var(--color-warning)' }, - { label: 'ERPG-1 범위', value: '5.1 km', color: 'var(--color-caution)' }, - { label: '풍향', value: 'SW → NE 방향' }, - { label: '대기 안정도', value: 'D등급 (중립)' }, - { label: '영향 인구', value: '약 2,400명', color: 'var(--color-danger)' }, - ], - summary: '남서풍에 의해 북동쪽 내륙 방향 확산. IDLH 1.2km 이내 즉시 대피 필요.', - }, - 구난: { - title: '긴급구난 SAR 결과', - model: 'SAROPS · Monte Carlo 10,000회 시뮬레이션', - items: [ - { label: '95% 확률 범위', value: '8.5 NM²', color: 'var(--color-accent)' }, - { label: '최적 탐색 경로', value: 'Sector Search' }, - { label: '예상 표류 속도', value: '1.8 kn' }, - { label: '표류 방향', value: 'NE (045°)' }, - { label: '생존 가능 시간', value: '36시간', color: 'var(--color-danger)' }, - { label: '필요 자산', value: '헬기 2 + 경비정 3', color: 'var(--color-warning)' }, - ], - summary: '북동쪽 방향 표류 예상. 06:00 기준 최적 탐색 패턴: 섹터탐색(Sector Search).', - }, - }; + const tag = SLOT_TAG[slotKey]; + const mock = SPLIT_MOCK_FALLBACK[slotKey]; - const data = mockData[tag.label] || mockData['유출유']; + // 슬롯별 체크된 항목 리스트 행 (우측 패널 포맷과 유사) + const listRows: { id: string; name: string; sub: string }[] = + slotKey === 'oil' + ? checkedPreds.map((p) => { + const date = p.runDtm ? p.runDtm.slice(0, 10) : (p.occurredAt?.slice(0, 10) ?? '-'); + const oil = p.oilType || '유출유'; + const models = [ + p.kospsStatus && p.kospsStatus !== 'pending' && p.kospsStatus !== 'none' + ? 'KOSPS' + : null, + p.poseidonStatus && p.poseidonStatus !== 'pending' && p.poseidonStatus !== 'none' + ? 'POSEIDON' + : null, + p.opendriftStatus && p.opendriftStatus !== 'pending' && p.opendriftStatus !== 'none' + ? 'OpenDrift' + : null, + ] + .filter(Boolean) + .join('+'); + return { + id: String(p.predRunSn ?? p.acdntSn), + name: `${date} ${oil} 확산예측`.trim(), + sub: `${models || '-'}${p.volume != null ? ` · ${p.volume}kL` : ''}`, + }; + }) + : slotKey === 'hns' + ? checkedHns.map((h) => { + const date = h.regDtm ? h.regDtm.slice(0, 10) : '-'; + const sbst = h.sbstNm || 'HNS'; + const sub = + [h.algoCd, h.fcstHr != null ? `${h.fcstHr}h` : null].filter(Boolean).join(' · ') || + '-'; + return { + id: String(h.hnsAnlysSn), + name: `${date} ${sbst} 대기확산`.trim(), + sub, + }; + }) + : checkedRescues.map((r) => { + const date = r.regDtm ? r.regDtm.slice(0, 10) : '-'; + const vessel = r.vesselNm || '선박'; + const sub = [r.acdntTpCd, r.commanderNm].filter(Boolean).join(' · ') || '-'; + return { + id: String(r.rescueOpsSn), + name: `${date} ${vessel} 긴급구난`.trim(), + sub, + }; + }); + + // 첫 번째 체크된 항목을 기준으로 메트릭 실제 값으로 보정 + const first = listRows[0]; + const items = mock.items.map((m) => { + if (!first) return m; + if (slotKey === 'oil') { + const p = checkedPreds[0]; + if (!p) return m; + const summaryKey = String(p.predRunSn ?? p.acdntSn); + const oilSummary = oilSummaries[summaryKey]?.primary; + if (!oilSummary) return m; + switch (m.label) { + case '예측 시간': + return oilSummary.forecastDurationHr != null + ? { ...m, value: `${oilSummary.forecastDurationHr}시간` } : m; + case '최대 확산거리': + return oilSummary.maxSpreadDistanceKm != null + ? { ...m, value: `${oilSummary.maxSpreadDistanceKm.toFixed(1)} km` } : m; + case '해안 도달 시간': + return oilSummary.coastArrivalTimeHr != null + ? { ...m, value: `${oilSummary.coastArrivalTimeHr}시간` } : m; + case '영향 해안선': + return oilSummary.affectedCoastlineKm != null + ? { ...m, value: `${oilSummary.affectedCoastlineKm.toFixed(1)} km` } : m; + case '풍화율': + return oilSummary.weatheringRatePct != null + ? { ...m, value: `${oilSummary.weatheringRatePct.toFixed(1)}%` } : m; + case '잔존유량': + return oilSummary.remainingVolumeKl != null + ? { ...m, value: `${oilSummary.remainingVolumeKl.toFixed(1)} kL` } : m; + default: + return m; + } + } else if (slotKey === 'hns') { + const h = checkedHns[0]; + if (!h) return m; + if (m.label === '풍향' && h.windDir) return { ...m, value: `${h.windDir}` }; + } + return m; + }); + + const modelString = + slotKey === 'oil' && checkedPreds[0] + ? `${checkedPreds[0].oilType || '-'}${checkedPreds[0].volume != null ? ` · ${checkedPreds[0].volume}kL` : ''}` + : slotKey === 'hns' && checkedHns[0] + ? `${checkedHns[0].algoCd ?? '-'} · ${checkedHns[0].sbstNm ?? '-'}${checkedHns[0].spilQty != null ? ` ${checkedHns[0].spilQty}${checkedHns[0].spilUnitCd ?? ''}` : ''}` + : slotKey === 'rescue' && checkedRescues[0] + ? `${checkedRescues[0].acdntTpCd ?? '-'} · ${checkedRescues[0].vesselNm ?? '-'}` + : mock.model; return ( <> + {/* 헤더 카드 */}
- {tag.icon} {data.title} + {tag.icon} {tag.fullLabel} 결과
-
{data.model}
+
{modelString}
{incident && (
사고: {incident.name} · {incident.date} {incident.time} @@ -1211,14 +1442,55 @@ function SplitPanelContent({ )}
+ {/* 체크된 분석 목록 */}
- {data.items.map((item, i) => ( +
+ 선택된 분석 ({listRows.length}) +
+ {listRows.length === 0 ? ( +
+ 선택된 분석이 없습니다 +
+ ) : ( + listRows.map((r, i) => ( +
+
+ {r.name} +
+
{r.sub}
+
+ )) + )} +
+ + {/* 메트릭 테이블 */} +
+ {items.map((item, i) => (
@@ -1227,35 +1499,289 @@ function SplitPanelContent({ className="text-caption font-semibold font-mono" style={{ color: item.color || 'var(--fg)' }} > - {item.value} + {item.value || '-'}
))}
+ + {/* 시각화 영역 — 실제 지도 캡처 (선택 분석 레이어만 표출, 4:3 고정 비율) */}
- 💡 {data.summary} + {incident ? ( + + ) : ( +
+ 사고를 선택하세요 +
+ )} +
+ {tag.icon} {tag.label} 지도 +
-
-
{tag.icon}
-
시각화 영역
-
+ {/* 민감자원 섹션 (유출유 전용) */} + {slotKey === 'oil' && ( +
+
+ 🐟 민감자원 ({sensCategories.length}) +
+ {sensCategories.length === 0 ? ( +
+ - +
+ ) : ( + sensCategories.map((cat, i) => { + const areaLabel = + cat.totalArea != null + ? `${cat.totalArea.toLocaleString('ko-KR', { maximumFractionDigits: 0 })}ha` + : `${cat.count}개소`; + const isChecked = checkedSensCategories.has(cat.category); + return ( +
+ + {SPLIT_CATEGORY_ICON[cat.category] ?? '📍'} + + {cat.category} + {areaLabel} +
+ ); + }) + )} +
+ )} ); } +/* ── 분할 패널 내부 미니 지도 — 실제 체크된 분석 레이어 표출 ────────────── */ +function SplitResultMap({ + incident, + layers, + instanceKey, +}: { + incident: Incident; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + layers: any[]; + instanceKey: string; +}) { + const mapStyle = useBaseMapStyle(); + const center: [number, number] = [incident.location.lon, incident.location.lat]; + // deck.gl 레이어는 단일 Deck 인스턴스 소유를 가정 → 분할마다 고유 id로 clone + const scopedLayers = useMemo( + () => + layers + .filter((l) => l != null) + .map((l) => (l && typeof l.clone === 'function' ? l.clone({ id: `${l.id}__${instanceKey}` }) : l)), + [layers, instanceKey], + ); + return ( + + + + ); +} + +/* ── (미사용) 분석별 SVG placeholder — 참고용 보존 ────────────────── */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function SplitVisualization({ slotKey, color }: { slotKey: SplitSlotKey; color: string }) { + if (slotKey === 'oil') { + return ( + + + + + + + + + + + + + + {/* 해안선 */} + + + {/* 확산 타원 */} + + {/* 사고점 */} + + + {/* 궤적 화살표 */} + + + 유출유 확산 시뮬레이션 (72h) + + + ); + } + if (slotKey === 'hns') { + return ( + + + + + + + + + {/* AEGL 3단 동심원 (풍하방향 오프셋) */} + + + + {/* 누출점 */} + + + {/* 풍향 콘 */} + + + 풍향 + + {/* 범례 */} + + + IDLH + + ERPG-2 + + ERPG-1 + + + HNS 대기 확산 (AEGL 등급) + + + ); + } + // rescue + return ( + + + + + + + + + {/* 표류 확률 타원(Monte Carlo) */} + + + {/* Sector Search 패턴 */} + + + + + + + + + {/* 사고점 */} + + + + 사고점 + + {/* 표류 화살표 */} + + + 구조 시나리오 (Sector Search) + + + ); +} + /* ════════════════════════════════════════════════════ VesselPopupPanel / VesselDetailModal 공용 유틸 ════════════════════════════════════════════════════ */ diff --git a/frontend/src/tabs/incidents/utils/hnsDispersionLayers.ts b/frontend/src/tabs/incidents/utils/hnsDispersionLayers.ts new file mode 100644 index 0000000..bb70351 --- /dev/null +++ b/frontend/src/tabs/incidents/utils/hnsDispersionLayers.ts @@ -0,0 +1,242 @@ +/** + * HNS 대기확산 결과(rsltData)를 deck.gl 레이어로 변환하는 유틸리티 + * + * - rsltData에 저장된 inputParams + coord + weather 로 확산 엔진 재실행 + * - MapView와 동일한 BitmapLayer (캔버스 히트맵) + ScatterplotLayer (AEGL 원) 생성 + */ +import { BitmapLayer, ScatterplotLayer } from '@deck.gl/layers'; +import { computeDispersion } from '@tabs/hns/utils/dispersionEngine'; +import { getSubstanceToxicity } from '@tabs/hns/utils/toxicityData'; +import { hexToRgba } from '@common/components/map/mapUtils'; +import type { HnsAnalysisItem } from '@tabs/hns/services/hnsApi'; +import type { + MeteoParams, + SourceParams, + SimParams, + DispersionModel, + AlgorithmType, + StabilityClass, +} from '@tabs/hns/utils/dispersionTypes'; + +// MapView와 동일한 색상 정지점 +const COLOR_STOPS: [number, number, number, number][] = [ + [34, 197, 94, 220], // green (저농도) + [234, 179, 8, 235], // yellow + [249, 115, 22, 245], // orange + [239, 68, 68, 250], // red (고농도) + [185, 28, 28, 255], // dark red (초고농도) +]; + +/** rsltData.weather → MeteoParams 변환 */ +function toMeteo(weather: Record): MeteoParams { + return { + windSpeed: (weather.windSpeed as number) ?? 5.0, + windDirDeg: (weather.windDirection as number) ?? 270, + stability: ((weather.stability as string) ?? 'D') as StabilityClass, + temperature: ((weather.temperature as number) ?? 15) + 273.15, + pressure: 101325, + mixingHeight: 800, + }; +} + +/** rsltData.inputParams + toxicity → SourceParams 변환 */ +function toSource( + inputParams: Record, + tox: ReturnType, +): SourceParams { + return { + Q: (inputParams.emissionRate as number) ?? tox.Q, + QTotal: (inputParams.totalRelease as number) ?? tox.QTotal, + x0: 0, + y0: 0, + z0: (inputParams.releaseHeight as number) ?? 0.5, + releaseDuration: + inputParams.releaseType === '연속 유출' + ? ((inputParams.releaseDuration as number) ?? 300) + : 0, + molecularWeight: tox.mw, + vaporPressure: tox.vaporPressure, + densityGas: tox.densityGas, + poolRadius: (inputParams.poolRadius as number) ?? tox.poolRadius, + }; +} + +const SIM_PARAMS: SimParams = { + xRange: [-100, 10000], + yRange: [-2000, 2000], + nx: 300, + ny: 200, + zRef: 1.5, + tStart: 0, + tEnd: 600, + dt: 30, +}; + +/** 농도 포인트 배열 → 캔버스 BitmapLayer */ +function buildBitmapLayer( + id: string, + points: Array<{ lon: number; lat: number; concentration: number }>, + visible: boolean, +): BitmapLayer | null { + const filtered = points.filter((p) => p.concentration > 0.01); + if (filtered.length === 0) return null; + + const maxConc = Math.max(...points.map((p) => p.concentration)); + const minConc = Math.min(...filtered.map((p) => p.concentration)); + const logMin = Math.log(minConc); + const logMax = Math.log(maxConc); + const logRange = logMax - logMin || 1; + + let minLon = Infinity, maxLon = -Infinity, minLat = Infinity, maxLat = -Infinity; + for (const p of points) { + if (p.lon < minLon) minLon = p.lon; + if (p.lon > maxLon) maxLon = p.lon; + if (p.lat < minLat) minLat = p.lat; + if (p.lat > maxLat) maxLat = p.lat; + } + const padLon = (maxLon - minLon) * 0.02; + const padLat = (maxLat - minLat) * 0.02; + minLon -= padLon; maxLon += padLon; + minLat -= padLat; maxLat += padLat; + + const W = 1200, H = 960; + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d')!; + ctx.clearRect(0, 0, W, H); + + for (const p of filtered) { + const ratio = Math.max(0, Math.min(1, (Math.log(p.concentration) - logMin) / logRange)); + const t = ratio * (COLOR_STOPS.length - 1); + const lo = Math.floor(t); + const hi = Math.min(lo + 1, COLOR_STOPS.length - 1); + const f = t - lo; + const r = Math.round(COLOR_STOPS[lo][0] + (COLOR_STOPS[hi][0] - COLOR_STOPS[lo][0]) * f); + const g = Math.round(COLOR_STOPS[lo][1] + (COLOR_STOPS[hi][1] - COLOR_STOPS[lo][1]) * f); + const b = Math.round(COLOR_STOPS[lo][2] + (COLOR_STOPS[hi][2] - COLOR_STOPS[lo][2]) * f); + const a = (COLOR_STOPS[lo][3] + (COLOR_STOPS[hi][3] - COLOR_STOPS[lo][3]) * f) / 255; + + const px = ((p.lon - minLon) / (maxLon - minLon)) * W; + const py = (1 - (p.lat - minLat) / (maxLat - minLat)) * H; + + ctx.fillStyle = `rgba(${r},${g},${b},${a.toFixed(2)})`; + ctx.beginPath(); + ctx.arc(px, py, 6, 0, Math.PI * 2); + ctx.fill(); + } + + const imageUrl = canvas.toDataURL('image/png'); + return new BitmapLayer({ + id, + image: imageUrl, + bounds: [minLon, minLat, maxLon, maxLat], + opacity: 1.0, + pickable: false, + visible, + }); +} + +/** + * HnsAnalysisItem[] → deck.gl 레이어 배열 (BitmapLayer + ScatterplotLayer) + * + * IncidentsView의 useMemo 에서 사용 + */ +export function buildHnsDispersionLayers( + analyses: HnsAnalysisItem[], + visible: boolean = true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): any[] { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const layers: any[] = []; + + for (const analysis of analyses) { + const rslt = analysis.rsltData; + if (!rslt) continue; + + const coord = rslt.coord as { lon: number; lat: number } | undefined; + const inputParams = rslt.inputParams as Record | undefined; + const weather = rslt.weather as Record | undefined; + const zones = rslt.zones as + | Array<{ level: string; color: string; radius: number; angle: number }> + | undefined; + + if (!coord || !inputParams || !weather) continue; + + // ── 1. 확산 엔진 재실행 ────────────────────────── + const substanceName = (inputParams.substance as string) ?? '톨루엔 (Toluene)'; + const tox = getSubstanceToxicity(substanceName); + const meteo = toMeteo(weather); + const source = toSource(inputParams, tox); + + const releaseType = (inputParams.releaseType as string) ?? '연속 유출'; + const modelType: DispersionModel = + releaseType === '연속 유출' ? 'plume' + : releaseType === '순간 유출' ? 'puff' + : 'dense_gas'; + + const algo = ((inputParams.algorithm as string) ?? 'ALOHA (EPA)') as AlgorithmType; + + let points: Array<{ lon: number; lat: number; concentration: number }> = []; + try { + const result = computeDispersion({ + meteo, + source, + sim: SIM_PARAMS, + modelType, + originLon: coord.lon, + originLat: coord.lat, + substanceName, + t: SIM_PARAMS.dt, + algorithm: algo, + }); + points = result.points; + } catch { + // 재계산 실패 시 히트맵 생략, 원 레이어만 표출 + } + + // ── 2. BitmapLayer (히트맵 콘) ──────────────────── + if (points.length > 0) { + const bitmapLayer = buildBitmapLayer( + `hns-bitmap-${analysis.hnsAnlysSn}`, + points, + visible, + ); + if (bitmapLayer) layers.push(bitmapLayer); + } + + // ── 3. ScatterplotLayer (AEGL 원) ───────────────── + if (zones?.length) { + const zoneData = zones + .filter((z) => z.radius > 0) + .map((zone, idx) => ({ + position: [coord.lon, coord.lat] as [number, number], + radius: zone.radius, + fillColor: hexToRgba(zone.color, 40) as [number, number, number, number], + lineColor: hexToRgba(zone.color, 200) as [number, number, number, number], + level: zone.level, + idx, + })); + + if (zoneData.length > 0) { + layers.push( + new ScatterplotLayer({ + id: `hns-zones-${analysis.hnsAnlysSn}`, + data: zoneData, + getPosition: (d: (typeof zoneData)[0]) => d.position, + getRadius: (d: (typeof zoneData)[0]) => d.radius, + getFillColor: (d: (typeof zoneData)[0]) => d.fillColor, + getLineColor: (d: (typeof zoneData)[0]) => d.lineColor, + getLineWidth: 2, + stroked: true, + radiusUnits: 'meters' as const, + pickable: false, + visible, + }), + ); + } + } + } + + return layers; +} diff --git a/frontend/src/tabs/prediction/services/predictionApi.ts b/frontend/src/tabs/prediction/services/predictionApi.ts index e2783eb..7efe2eb 100644 --- a/frontend/src/tabs/prediction/services/predictionApi.ts +++ b/frontend/src/tabs/prediction/services/predictionApi.ts @@ -218,6 +218,21 @@ export interface TrajectoryResponse { stepSummariesByModel?: Record; } +export interface OilSpillSummary { + model: string; + forecastDurationHr: number | null; + maxSpreadDistanceKm: number | null; + coastArrivalTimeHr: number | null; + affectedCoastlineKm: number | null; + weatheringRatePct: number | null; + remainingVolumeKl: number | null; +} + +export interface OilSpillSummaryResponse { + primary: OilSpillSummary | null; + byModel: Record; +} + export const fetchAnalysisTrajectory = async ( acdntSn: number, predRunSn?: number, @@ -229,6 +244,17 @@ export const fetchAnalysisTrajectory = async ( return response.data; }; +export const fetchOilSpillSummary = async ( + acdntSn: number, + predRunSn?: number, +): Promise => { + const response = await api.get( + `/prediction/analyses/${acdntSn}/oil-summary`, + predRunSn != null ? { params: { predRunSn } } : undefined, + ); + return response.data; +}; + export interface SensitiveResourceCategory { category: string; count: number; @@ -327,6 +353,7 @@ export const analyzeImage = async (file: File, acdntNm?: string): Promise