diff --git a/backend/src/gsc/gscAccidentsService.ts b/backend/src/gsc/gscAccidentsService.ts index 69fbff0..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; @@ -8,54 +9,32 @@ export interface GscAccidentListItem { lon: number | null; } -const ACDNT_ASORT_CODES = [ - '055001001', - '055001002', - '055001003', - '055001004', - '055001005', - '055001006', - '055003001', - '055003002', - '055003003', - '055003004', - '055003005', - '055004003', -]; - export async function listGscAccidents(limit = 20): Promise { const sql = ` - SELECT DISTINCT ON (a.acdnt_mng_no) - a.acdnt_mng_no AS "acdntMngNo", - a.acdnt_title AS "pollNm", - to_char(a.rcept_dt, 'YYYY-MM-DD"T"HH24:MI') AS "pollDate", - a.rcept_dt AS "rceptDt", - b.la AS "lat", - b.lo AS "lon" - FROM gsc.tgs_acdnt_info AS a - LEFT JOIN gsc.tgs_acdnt_lc AS b - ON a.acdnt_mng_no = b.acdnt_mng_no - WHERE a.acdnt_asort_code = ANY($1::varchar[]) - AND a.acdnt_title IS NOT NULL - ORDER BY a.acdnt_mng_no, b.acdnt_lc_sn ASC - `; - - const orderedSql = ` - SELECT "acdntMngNo", "pollNm", "pollDate", "lat", "lon" - FROM (${sql}) t - ORDER BY t."rceptDt" DESC NULLS LAST - LIMIT $2 + 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", + LAT AS "lat", + LNG AS "lon" + FROM wing.ACDNT + WHERE ACDNT_NM IS NOT NULL + ORDER BY OCCRN_DTM DESC NULLS LAST + LIMIT $1 `; const result = await wingPool.query<{ + acdntSn: number; acdntMngNo: string; pollNm: string; pollDate: string | null; lat: string | null; lon: string | null; - }>(orderedSql, [ACDNT_ASORT_CODES, limit]); + }>(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 828ed7a..7985d72 100644 --- a/backend/src/hns/hnsRouter.ts +++ b/backend/src/hns/hnsRouter.ts @@ -12,11 +12,13 @@ const router = express.Router() // GET /api/hns/analyses — 분석 목록 router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (req, res) => { try { - const { status, substance, search } = req.query + const { status, substance, search, acdntSn } = req.query + const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined const items = await listAnalyses({ status: status as string | undefined, substance: substance as string | undefined, search: search as string | undefined, + acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined, }) res.json(items) } catch (err) { @@ -48,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 10a001c..ff10c2e 100644 --- a/backend/src/hns/hnsService.ts +++ b/backend/src/hns/hnsService.ts @@ -94,6 +94,7 @@ export async function searchSubstances(params: HnsSearchParams) { interface HnsAnalysisItem { hnsAnlysSn: number + acdntSn: number | null anlysNm: string acdntDtm: string | null locNm: string | null @@ -118,11 +119,13 @@ interface ListAnalysesInput { status?: string substance?: string search?: string + acdntSn?: number } function rowToAnalysis(r: Record): HnsAnalysisItem { return { hnsAnlysSn: r.hns_anlys_sn as number, + acdntSn: (r.acdnt_sn as number) ?? null, anlysNm: r.anlys_nm as string, acdntDtm: r.acdnt_dtm as string | null, locNm: r.loc_nm as string | null, @@ -146,7 +149,7 @@ function rowToAnalysis(r: Record): HnsAnalysisItem { export async function listAnalyses(input: ListAnalysesInput): Promise { const conditions: string[] = ["USE_YN = 'Y'"] - const params: string[] = [] + const params: (string | number)[] = [] let idx = 1 if (input.status) { @@ -162,9 +165,13 @@ export async function listAnalyses(input: ListAnalysesInput): Promise { const { rows } = await wingPool.query( - `SELECT HNS_ANLYS_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT, + `SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT, SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD, WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD, EXEC_STTS_CD, RISK_CD, ANALYST_NM, @@ -194,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 @@ -213,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/incidents/incidentsService.ts b/backend/src/incidents/incidentsService.ts index a02534b..34b1ba8 100644 --- a/backend/src/incidents/incidentsService.ts +++ b/backend/src/incidents/incidentsService.ts @@ -25,6 +25,8 @@ interface IncidentListItem { spilUnitCd: string | null; fcstHr: number | null; hasPredCompleted: boolean; + hasHnsCompleted: boolean; + hasRescueCompleted: boolean; mediaCnt: number; hasImgAnalysis: boolean; } @@ -118,6 +120,18 @@ export async function listIncidents(filters: { SELECT 1 FROM wing.PRED_EXEC pe WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED' ) AS has_pred_completed, + EXISTS ( + SELECT 1 FROM wing.HNS_ANALYSIS h + WHERE h.ACDNT_SN = a.ACDNT_SN + AND h.EXEC_STTS_CD = 'COMPLETED' + AND h.USE_YN = 'Y' + ) AS has_hns_completed, + EXISTS ( + SELECT 1 FROM wing.RESCUE_OPS r + WHERE r.ACDNT_SN = a.ACDNT_SN + AND r.STTS_CD = 'RESOLVED' + AND r.USE_YN = 'Y' + ) AS has_rescue_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 @@ -157,6 +171,8 @@ export async function listIncidents(filters: { spilUnitCd: (r.spil_unit_cd as string) ?? null, fcstHr: (r.fcst_hr as number) ?? null, hasPredCompleted: r.has_pred_completed as boolean, + hasHnsCompleted: r.has_hns_completed as boolean, + hasRescueCompleted: r.has_rescue_completed as boolean, mediaCnt: Number(r.media_cnt), hasImgAnalysis: (r.has_img_analysis as boolean) ?? false, })); @@ -177,6 +193,18 @@ export async function getIncident(acdntSn: number): Promise { + 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/backend/src/rescue/rescueRouter.ts b/backend/src/rescue/rescueRouter.ts index ba984d8..a61a487 100644 --- a/backend/src/rescue/rescueRouter.ts +++ b/backend/src/rescue/rescueRouter.ts @@ -10,11 +10,13 @@ const router = express.Router(); // ============================================================ router.get('/ops', requireAuth, requirePermission('rescue', 'READ'), async (req, res) => { try { - const { sttsCd, acdntTpCd, search } = req.query; + const { sttsCd, acdntTpCd, search, acdntSn } = req.query; + const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined; const items = await listOps({ sttsCd: sttsCd as string | undefined, acdntTpCd: acdntTpCd as string | undefined, search: search as string | undefined, + acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined, }); res.json(items); } catch (err) { diff --git a/backend/src/rescue/rescueService.ts b/backend/src/rescue/rescueService.ts index 7ca4cb2..19ccdcd 100644 --- a/backend/src/rescue/rescueService.ts +++ b/backend/src/rescue/rescueService.ts @@ -59,6 +59,7 @@ interface ListOpsInput { sttsCd?: string; acdntTpCd?: string; search?: string; + acdntSn?: number; } // ============================================================ @@ -82,6 +83,10 @@ export async function listOps(input?: ListOpsInput): Promise= '2026-04-10' (본 이관 추가 조건) +-- +-- ACDNT_CD 생성 규칙 +-- 'INC-YYYY-NNNN' (YYYY = rcept_dt 의 연도, NNNN = 해당 연도 내 순번 4자리) +-- 기존 wing.ACDNT 에 이미 부여된 'INC-YYYY-NNNN' 중 같은 연도의 최대 순번을 +-- 구해 이어서 증가시킨다. +-- +-- 중복 방지 +-- (ACDNT_NM = acdnt_title, OCCRN_DTM = rcept_dt) 조합이 이미 존재하면 제외. +-- acdnt_mng_no 를 별도 컬럼으로 보관하지 않으므로 이 조합을 자연 키로 사용. +-- +-- ACDNT_TP_CD +-- gsc.tcm_code.code_nm 으로 치환 (JOIN: tcm_code.code = acdnt_asort_code) +-- 매핑 누락 시 원본 코드값으로 폴백. +-- +-- 사전 확인 쿼리 (실행 전 참고) +-- SELECT COUNT(DISTINCT a.acdnt_mng_no) +-- FROM gsc.tgs_acdnt_info a JOIN gsc.tgs_acdnt_lc b USING (acdnt_mng_no) +-- WHERE a.acdnt_asort_code = ANY(ARRAY[ +-- '055001001','055001002','055001003','055001004','055001005','055001006', +-- '055003001','055003002','055003003','055003004','055003005','055004003' +-- ]::varchar[]) +-- AND a.acdnt_title IS NOT NULL +-- AND a.rcept_dt >= '2026-04-10'; +-- ============================================================ + +WITH src AS ( + SELECT DISTINCT ON (a.acdnt_mng_no) + a.acdnt_mng_no, + a.acdnt_title, + a.acdnt_asort_code, + a.rcept_dt, + b.la, + b.lo + FROM gsc.tgs_acdnt_info AS a + JOIN gsc.tgs_acdnt_lc AS b ON a.acdnt_mng_no = b.acdnt_mng_no + WHERE a.acdnt_asort_code = ANY(ARRAY[ + '055001001','055001002','055001003','055001004','055001005','055001006', + '055003001','055003002','055003003','055003004','055003005','055004003' + ]::varchar[]) + AND a.acdnt_title IS NOT NULL + AND a.rcept_dt >= '2026-04-10'::timestamptz + AND b.la IS NOT NULL AND b.lo IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM wing.ACDNT w + WHERE w.ACDNT_NM = a.acdnt_title + AND w.OCCRN_DTM = a.rcept_dt + ) + ORDER BY a.acdnt_mng_no, b.acdnt_lc_sn ASC +), +numbered AS ( + SELECT + src.*, + EXTRACT(YEAR FROM src.rcept_dt)::int AS yr, + ROW_NUMBER() OVER ( + PARTITION BY EXTRACT(YEAR FROM src.rcept_dt) + ORDER BY src.rcept_dt ASC, src.acdnt_mng_no ASC + ) AS rn_in_year + FROM src +), +year_max AS ( + SELECT + (split_part(ACDNT_CD, '-', 2))::int AS yr, + MAX((split_part(ACDNT_CD, '-', 3))::int) AS max_seq + FROM wing.ACDNT + WHERE ACDNT_CD ~ '^INC-[0-9]{4}-[0-9]+$' + GROUP BY split_part(ACDNT_CD, '-', 2) +) +INSERT INTO wing.ACDNT ( + ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, ACDNT_STTS_CD, + LAT, LNG, LOC_GEOM, LOC_DC, OCCRN_DTM, REG_DTM, MDFCN_DTM +) +SELECT + 'INC-' || lpad(n.yr::text, 4, '0') || '-' || + lpad((COALESCE(ym.max_seq, 0) + n.rn_in_year)::text, 4, '0') AS ACDNT_CD, + n.acdnt_title AS ACDNT_NM, + COALESCE(c.code_nm, n.acdnt_asort_code) AS ACDNT_TP_CD, + 'ACTIVE' AS ACDNT_STTS_CD, + n.la::numeric AS LAT, + n.lo::numeric AS LNG, + ST_SetSRID(ST_MakePoint(n.lo::float8, n.la::float8), 4326) AS LOC_GEOM, + NULL AS LOC_DC, + n.rcept_dt AS OCCRN_DTM, + NOW(), NOW() +FROM numbered n +LEFT JOIN year_max ym ON ym.yr = n.yr +LEFT JOIN gsc.tcm_code c ON c.code = n.acdnt_asort_code +ORDER BY n.rcept_dt ASC, n.acdnt_mng_no ASC; + +-- ============================================================ +-- 사후 검증 (필요 시 주석 해제 실행) +-- SELECT COUNT(*) FROM wing.ACDNT WHERE OCCRN_DTM >= '2026-04-10'; +-- +-- SELECT ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, ST_AsText(LOC_GEOM), OCCRN_DTM +-- FROM wing.ACDNT +-- WHERE OCCRN_DTM >= '2026-04-10' +-- ORDER BY ACDNT_CD DESC +-- LIMIT 20; +-- +-- SELECT ACDNT_TP_CD, COUNT(*) +-- FROM wing.ACDNT +-- WHERE OCCRN_DTM >= '2026-04-10' +-- GROUP BY 1 +-- ORDER BY 2 DESC; +-- ============================================================ diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 37be4e8..093dcdd 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -6,6 +6,11 @@ ### 추가 - HNS: AEGL 등농도선 표출 및 자동 줌·동적 도메인 기능 추가 +- 사건사고: 통합 분석 패널 HNS/구난 연동 및 사고 목록을 wing.ACDNT 기반으로 전환 +- 사건사고: 통합 분석 패널 분할 뷰 및 이전 분석 결과 비교 표출 + 분석 선택 모달 추가 +- 확산예측: 유출유 확산 요약 API 신규 (`/analyses/:acdntSn/oil-summary`, primary + byModel) +- HNS: 분석 생성 시 `acdntSn` 연결 지원 +- GSC: 사고 목록 응답에 `acdntSn` 노출 및 민감자원 누적 카테고리 관리 + HNS 확산 레이어 유틸 추가 ### 변경 - 탭 디렉토리를 MPA 컴포넌트 구조로 재편 (src/tabs → src/components, src/interfaces, src/types) diff --git a/frontend/src/components/hns/components/HNSLeftPanel.tsx b/frontend/src/components/hns/components/HNSLeftPanel.tsx index dae2a13..13a56bc 100644 --- a/frontend/src/components/hns/components/HNSLeftPanel.tsx +++ b/frontend/src/components/hns/components/HNSLeftPanel.tsx @@ -48,6 +48,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] })); @@ -126,6 +127,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) { @@ -160,7 +162,10 @@ export function HNSLeftPanel({ // 파라미터 변경 시 부모에 통지 useEffect(() => { if (onParamsChange) { - onParamsChange(buildCurrentParams()); + onParamsChange({ + ...buildCurrentParams(), + selectedAcdntSn, + }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -182,10 +187,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/components/hns/components/HNSView.tsx b/frontend/src/components/hns/components/HNSView.tsx index 3197a99..ec33471 100644 --- a/frontend/src/components/hns/components/HNSView.tsx +++ b/frontend/src/components/hns/components/HNSView.tsx @@ -343,6 +343,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/components/hns/services/hnsApi.ts b/frontend/src/components/hns/services/hnsApi.ts index 7de2c29..f626dec 100644 --- a/frontend/src/components/hns/services/hnsApi.ts +++ b/frontend/src/components/hns/services/hnsApi.ts @@ -5,10 +5,12 @@ import type { HnsAnalysisItem, CreateHnsAnalysisInput } from '@interfaces/hns/Hn // HNS 분석 API // ============================================================ + export async function fetchHnsAnalyses(params?: { status?: string; substance?: string; search?: string; + acdntSn?: number; }): Promise { const response = await api.get('/hns/analyses', { params }); return response.data; diff --git a/frontend/src/components/incidents/components/AnalysisSelectModal.tsx b/frontend/src/components/incidents/components/AnalysisSelectModal.tsx new file mode 100644 index 0000000..0463c4e --- /dev/null +++ b/frontend/src/components/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/components/incidents/components/IncidentsRightPanel.tsx b/frontend/src/components/incidents/components/IncidentsRightPanel.tsx index 6748ea4..901e8b5 100644 --- a/frontend/src/components/incidents/components/IncidentsRightPanel.tsx +++ b/frontend/src/components/incidents/components/IncidentsRightPanel.tsx @@ -1,5 +1,7 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import type { Incident } from '@interfaces/incidents/IncidentsInterface'; +import { AnalysisSelectModal } from './AnalysisSelectModal'; +import type { AnalysisApplyPayload } from './AnalysisSelectModal'; import { fetchPredictionAnalyses, fetchSensitiveResources, @@ -8,10 +10,15 @@ import { import type { PredictionAnalysis, SensitiveResourceCategory, + SensitiveResourceFeature, SensitiveResourceFeatureCollection, } from '@components/prediction/services/predictionApi'; import { fetchNearbyOrgs } from '../services/incidentsApi'; import type { NearbyOrgItem } from '@interfaces/incidents/IncidentsInterface'; +import { fetchHnsAnalyses } from '@components/hns/services/hnsApi'; +import type { HnsAnalysisItem } from '@interfaces/hns/HnsInterface'; +import { fetchRescueOps } from '@components/rescue/services/rescueApi'; +import type { RescueOpsItem } from '@interfaces/rescue/RescueInterface'; export type ViewMode = 'overlay' | 'split2' | 'split3'; @@ -35,6 +42,19 @@ interface IncidentsRightPanelProps { onCheckedPredsChange?: ( checked: Array<{ id: string; acdntSn: number; predRunSn: number | null; occurredAt: string }>, ) => void; + onCheckedHnsChange?: ( + checked: Array<{ id: string; hnsAnlysSn: number; acdntSn: number | null }>, + ) => void; + 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, @@ -115,23 +135,21 @@ function getActiveModels(p: PredictionAnalysis): string { return models || '분석중'; } -/* ── HNS/구난 섹션 (미개발, 고정 구조만 유지) ────── */ -const STATIC_SECTIONS = [ - { - key: 'hns', +/* ── 섹션 메타 (색상/아이콘) ────── */ +const SECTION_META = { + hns: { icon: '🧪', title: 'HNS 대기확산', - color: 'var(--color-accent)', - colorRgb: '6,182,212', + color: 'var(--color-warning)', + colorRgb: '249,115,22', }, - { - key: 'rsc', + rescue: { icon: '🚨', title: '긴급구난', color: 'var(--color-accent)', colorRgb: '6,182,212', }, -]; +}; /* ── Component ───────────────────────────────────── */ @@ -143,26 +161,47 @@ export function IncidentsRightPanel({ analysisActive, onCloseAnalysis, onCheckedPredsChange, + onCheckedHnsChange, + onCheckedRescueChange, + onCheckedPredItemsChange, + onCheckedHnsItemsChange, + onCheckedRescueItemsChange, + onSensitiveCategoriesChange, onSensitiveDataChange, selectedVessel, }: IncidentsRightPanelProps) { const [predItems, setPredItems] = useState([]); const [checkedPredIds, setCheckedPredIds] = useState>(new Set()); + const [hnsItems, setHnsItems] = useState([]); + const [checkedHnsIds, setCheckedHnsIds] = useState>(new Set()); + const [rescueItems, setRescueItems] = useState([]); + const [checkedRescueIds, setCheckedRescueIds] = useState>(new Set()); const [sensCategories, setSensCategories] = useState([]); 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) { void Promise.resolve().then(() => { setPredItems([]); + setHnsItems([]); + setRescueItems([]); setSensCategories([]); setSensitiveGeojson(null); + setSensByAcdntSn(new Map()); + knownSensCatsRef.current = new Set(); onCheckedPredsChange?.([]); + onCheckedHnsChange?.([]); + onCheckedRescueChange?.([]); onSensitiveDataChange?.(null, new Set(), []); }); return; @@ -183,22 +222,37 @@ export function IncidentsRightPanel({ ); }) .catch(() => setPredItems([])); - 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), + fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' }) + .then((items) => { + setHnsItems(items); + const allIds = new Set(items.map((i) => String(i.hnsAnlysSn))); + setCheckedHnsIds(allIds); + onCheckedHnsChange?.( + items.map((h) => ({ + id: String(h.hnsAnlysSn), + hnsAnlysSn: h.hnsAnlysSn, + acdntSn: h.acdntSn, + })), ); }) - .catch(() => { - setSensCategories([]); - setSensitiveGeojson(null); - }); + .catch(() => setHnsItems([])); + fetchRescueOps({ acdntSn, sttsCd: 'RESOLVED' }) + .then((items) => { + setRescueItems(items); + const allIds = new Set(items.map((i) => String(i.rescueOpsSn))); + setCheckedRescueIds(allIds); + onCheckedRescueChange?.( + items.map((r) => ({ + id: String(r.rescueOpsSn), + rescueOpsSn: r.rescueOpsSn, + acdntSn: r.acdntSn, + })), + ); + }) + .catch(() => setRescueItems([])); + // 민감자원 캐시 초기화 (새 사고 선택 시 기존 캐시 제거) + knownSensCatsRef.current = new Set(); + void Promise.resolve().then(() => setSensByAcdntSn(new Map())); }, [incident?.id]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { @@ -216,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); @@ -276,6 +444,76 @@ export function IncidentsRightPanel({ }); }; + const toggleHnsItem = (id: string) => { + setCheckedHnsIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + onCheckedHnsChange?.( + hnsItems + .filter((h) => next.has(String(h.hnsAnlysSn))) + .map((h) => ({ id: String(h.hnsAnlysSn), hnsAnlysSn: h.hnsAnlysSn, acdntSn: h.acdntSn })), + ); + return next; + }); + }; + + const removeHnsItem = (id: string) => { + setHnsItems((prev) => { + const next = prev.filter((h) => String(h.hnsAnlysSn) !== id); + onCheckedHnsChange?.( + next + .filter((h) => checkedHnsIds.has(String(h.hnsAnlysSn))) + .map((h) => ({ id: String(h.hnsAnlysSn), hnsAnlysSn: h.hnsAnlysSn, acdntSn: h.acdntSn })), + ); + return next; + }); + setCheckedHnsIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + }; + + const toggleRescueItem = (id: string) => { + setCheckedRescueIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + onCheckedRescueChange?.( + rescueItems + .filter((r) => next.has(String(r.rescueOpsSn))) + .map((r) => ({ + id: String(r.rescueOpsSn), + rescueOpsSn: r.rescueOpsSn, + acdntSn: r.acdntSn, + })), + ); + return next; + }); + }; + + const removeRescueItem = (id: string) => { + setRescueItems((prev) => { + const next = prev.filter((r) => String(r.rescueOpsSn) !== id); + onCheckedRescueChange?.( + next + .filter((r) => checkedRescueIds.has(String(r.rescueOpsSn))) + .map((r) => ({ + id: String(r.rescueOpsSn), + rescueOpsSn: r.rescueOpsSn, + acdntSn: r.acdntSn, + })), + ); + return next; + }); + setCheckedRescueIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + }; + /* 유출유 섹션을 AnalysisSection 형태로 변환 (통합 분석 실행 콜백용) */ const oilSection: AnalysisSection = { key: 'oil', @@ -297,6 +535,87 @@ export function IncidentsRightPanel({ }), }; + const hnsSection: AnalysisSection = { + key: 'hns', + icon: SECTION_META.hns.icon, + title: SECTION_META.hns.title, + color: SECTION_META.hns.color, + colorRgb: SECTION_META.hns.colorRgb, + totalLabel: `전체 ${hnsItems.length}건`, + items: hnsItems.map((h) => { + const id = String(h.hnsAnlysSn); + const dateStr = 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, + name: `${dateStr} ${sbst} 대기확산`.trim(), + sub: sub || '-', + checked: checkedHnsIds.has(id), + }; + }), + }; + + const rescueSection: AnalysisSection = { + key: 'rescue', + icon: SECTION_META.rescue.icon, + title: SECTION_META.rescue.title, + color: SECTION_META.rescue.color, + colorRgb: SECTION_META.rescue.colorRgb, + totalLabel: `전체 ${rescueItems.length}건`, + items: rescueItems.map((r) => { + const id = String(r.rescueOpsSn); + const dateStr = r.regDtm ? r.regDtm.slice(0, 10) : ''; + const vessel = r.vesselNm || '선박'; + const sub = [r.acdntTpCd, r.commanderNm].filter(Boolean).join(' · '); + return { + id, + name: `${dateStr} ${vessel} 긴급구난`.trim(), + sub: sub || '-', + checked: checkedRescueIds.has(id), + }; + }), + }; + + 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 (
@@ -338,6 +657,7 @@ export function IncidentsRightPanel({
+ +
+ {sec.items.length === 0 ? ( +
+ 예측 실행 이력이 없습니다 +
+ ) : ( + sec.items.map((item) => ( +
+ onToggle(item.id)} + className="shrink-0" + style={{ accentColor: sec.color }} + /> +
+
+ {item.name} +
+
{item.sub}
+
+ onRemove(item.id)} + title="제거" + className="text-caption cursor-pointer text-fg-disabled shrink-0" + > + ✕ + +
+ )) + )} +
+
+ 선택: {checkedCount}건 · {sec.totalLabel}
- -
준비 중입니다
-
- 선택: 0건 · 전체 0건 -
- - ))} + ); + })} {/* 민감자원 */}
@@ -552,6 +917,23 @@ export function IncidentsRightPanel({
+ {/* 분석 목록 선택 모달 */} + {modalType && ( + setModalType(null)} + initialSelectedIds={ + modalType === 'oil' + ? checkedPredIds + : modalType === 'hns' + ? checkedHnsIds + : checkedRescueIds + } + onApply={handleModalApply} + /> + )} + {/* Footer */}
{/* View Mode */} @@ -592,9 +974,10 @@ export function IncidentsRightPanel({ onCloseAnalysis(); return; } - const checkedOilItems = oilSection.items.filter((it) => it.checked); - const checkedSections = - checkedOilItems.length > 0 ? [{ ...oilSection, items: checkedOilItems }] : []; + const allSections = [oilSection, hnsSection, rescueSection]; + const checkedSections = allSections + .map((sec) => ({ ...sec, items: sec.items.filter((it) => it.checked) })) + .filter((sec) => sec.items.length > 0); const sensChecked = checkedSensCategories.size; onRunAnalysis(checkedSections, sensChecked); }} diff --git a/frontend/src/components/incidents/components/IncidentsView.tsx b/frontend/src/components/incidents/components/IncidentsView.tsx index e220d0e..46fe44b 100644 --- a/frontend/src/components/incidents/components/IncidentsView.tsx +++ b/frontend/src/components/incidents/components/IncidentsView.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; -import { Popup } from '@vis.gl/react-maplibre'; +import { Map as MapLibreMap, Popup } from '@vis.gl/react-maplibre'; +import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; import { ScatterplotLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers'; import { PathStyleExtension } from '@deck.gl/extensions'; import 'maplibre-gl/dist/maplibre-gl.css'; @@ -14,11 +15,18 @@ import { IncidentsLeftPanel } from './IncidentsLeftPanel'; import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel'; import { fetchIncidents } from '../services/incidentsApi'; import type { IncidentCompat } from '@interfaces/incidents/IncidentsInterface'; -import { fetchAnalysisTrajectory } from '@components/prediction/services/predictionApi'; +import { fetchHnsAnalyses } from '@components/hns/services/hnsApi'; +import type { HnsAnalysisItem } from '@interfaces/hns/HnsInterface'; +import { buildHnsDispersionLayers } from '../utils/hnsDispersionLayers'; +import { fetchAnalysisTrajectory, fetchOilSpillSummary } from '@components/prediction/services/predictionApi'; import type { TrajectoryResponse, SensitiveResourceFeatureCollection, + SensitiveResourceCategory, + PredictionAnalysis, + OilSpillSummaryResponse, } from '@components/prediction/services/predictionApi'; +import type { RescueOpsItem } from '@interfaces/rescue/RescueInterface'; import { DischargeZonePanel } from './DischargeZonePanel'; import { estimateDistanceFromCoast, @@ -154,15 +162,36 @@ export function IncidentsView() { // Analysis view mode const [viewMode, setViewMode] = useState('overlay'); - const [analysisActive, setAnalysisActive] = useState(false); - const [analysisTags, setAnalysisTags] = useState< - { icon: string; label: string; color: string }[] - >([]); + const [analysisActive, setAnalysisActive] = useState(true); + + // 분할 뷰에서 사용할 체크된 원본 아이템들 (우측 패널에서 주입) + const [checkedPredItems, setCheckedPredItems] = useState([]); + const [checkedHnsItems, setCheckedHnsItems] = useState([]); + const [checkedRescueItems, setCheckedRescueItems] = useState([]); + const [sensCategoriesFull, setSensCategoriesFull] = useState([]); + const [checkedSensCategoriesFull, setCheckedSensCategoriesFull] = useState>( + new Set(), + ); + + // 2분할 좌/우 슬롯에 표시할 분석 종류 + const [split2Slots, setSplit2Slots] = useState<[SplitSlotKey | null, SplitSlotKey | null]>([ + null, + null, + ]); // 예측 trajectory & 민감자원 지도 표출 const [trajectoryEntries, setTrajectoryEntries] = useState< Record >({}); + // 유출유 확산 요약 (분할 패널용) + const [oilSummaryEntries, setOilSummaryEntries] = useState< + Record + >({}); + + // HNS 대기확산 분석 (선택 사고에 연결된 완료 분석들) + const [hnsAnalyses, setHnsAnalyses] = useState([]); + // null = 아직 패널에서 전달되지 않음 → 전체 표시 / Set = 체크된 항목만 표시 + const [checkedHnsIds, setCheckedHnsIds] = useState | null>(null); const [sensitiveGeojson, setSensitiveGeojson] = useState(null); const [sensCheckedCategories, setSensCheckedCategories] = useState>(new Set()); @@ -177,60 +206,122 @@ export function IncidentsView() { Promise.all([loadTerritorialBaseline(), loadZoneGeoJSON()]).then(() => setBaselineLoaded(true)); }, []); - // 사고 전환 시 지도 레이어 즉시 초기화 + // 사고 전환 시 지도 레이어 즉시 초기화 + HNS 분석 자동 로드 useEffect(() => { setTrajectoryEntries({}); setSensitiveGeojson(null); setSensCheckedCategories(new Set()); setSensColorMap(new Map()); + setHnsAnalyses([]); + setCheckedHnsIds(null); + + if (!selectedIncidentId) return; + const acdntSn = parseInt(selectedIncidentId, 10); + if (Number.isNaN(acdntSn)) return; + fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' }) + .then((items) => setHnsAnalyses(items)) + .catch(() => {}); }, [selectedIncidentId]); const selectedIncident = incidents.find((i) => i.id === selectedIncidentId) ?? null; const handleRunAnalysis = (sections: AnalysisSection[], sensitiveCount: number) => { - if (sections.length === 0) return; - const tags: { icon: string; label: string; color: string }[] = []; - sections.forEach((s) => { - if (s.key === 'oil') - tags.push({ icon: '🛢', label: '유출유', color: 'var(--color-warning)' }); - if (s.key === 'hns') tags.push({ icon: '🧪', label: 'HNS', color: 'var(--color-tertiary)' }); - if (s.key === 'rsc') tags.push({ icon: '🚨', label: '구난', color: 'var(--color-accent)' }); - }); - if (sensitiveCount > 0) - tags.push({ - icon: '🐟', - label: `민감자원 ${sensitiveCount}건`, - color: 'var(--color-success)', - }); - setAnalysisTags(tags); + if (sections.length === 0 && sensitiveCount === 0) return; + + // 2분할 기본 슬롯 설정 — 체크된 분석 목록에 실제 아이템이 있는 종류로 자동 배치 + const available: SplitSlotKey[] = []; + if (checkedPredItems.length > 0) available.push('oil'); + if (checkedHnsItems.length > 0) available.push('hns'); + if (checkedRescueItems.length > 0) available.push('rescue'); + setSplit2Slots([available[0] ?? null, available[1] ?? null]); + setAnalysisActive(true); }; const handleCloseAnalysis = () => { setAnalysisActive(false); - setAnalysisTags([]); }; + // 상단 분석 태그 — 현재 viewMode에 따라 동적으로 계산 + // overlay: 체크된 섹션 + 민감자원 + // split2: split2Slots에 선택된 2개 슬롯만 + // split3: oil/hns/rescue 3개 고정 + const analysisTags = useMemo<{ icon: string; label: string; color: string }[]>(() => { + if (!analysisActive) return []; + const OIL = { icon: '🛢', label: '유출유', color: 'var(--color-warning)' }; + const HNS = { icon: '🧪', label: 'HNS', color: 'var(--color-tertiary)' }; + const RESCUE = { icon: '🚨', label: '구난', color: 'var(--color-accent)' }; + + if (viewMode === 'split2') { + const tags: { icon: string; label: string; color: string }[] = []; + split2Slots.forEach((key) => { + if (key === 'oil') tags.push(OIL); + else if (key === 'hns') tags.push(HNS); + else if (key === 'rescue') tags.push(RESCUE); + }); + return tags; + } + + if (viewMode === 'split3') { + return [OIL, HNS, RESCUE]; + } + + // overlay + const tags: { icon: string; label: string; color: string }[] = []; + if (checkedPredItems.length > 0) tags.push(OIL); + if (checkedHnsItems.length > 0) tags.push(HNS); + if (checkedRescueItems.length > 0) tags.push(RESCUE); + const sensCount = checkedSensCategoriesFull.size; + if (sensCount > 0) + tags.push({ + icon: '🐟', + label: `민감자원 ${sensCount}건`, + color: 'var(--color-success)', + }); + return tags; + }, [ + analysisActive, + viewMode, + split2Slots, + checkedPredItems.length, + checkedHnsItems.length, + checkedRescueItems.length, + checkedSensCategoriesFull, + ]); + const handleCheckedPredsChange = async ( checked: Array<{ id: string; acdntSn: number; predRunSn: number | null; occurredAt: string }>, ) => { const newEntries: Record = {}; + const newSummaries: Record = {}; await Promise.all( checked.map(async ({ id, acdntSn, predRunSn, occurredAt }) => { const existing = trajectoryEntries[id]; if (existing) { newEntries[id] = existing; + if (oilSummaryEntries[id]) newSummaries[id] = oilSummaryEntries[id]; return; } try { - const data = await fetchAnalysisTrajectory(acdntSn, predRunSn ?? undefined); + const [data, summary] = await Promise.all([ + fetchAnalysisTrajectory(acdntSn, predRunSn ?? undefined), + fetchOilSpillSummary(acdntSn, predRunSn ?? undefined), + ]); newEntries[id] = { data, occurredAt }; + newSummaries[id] = summary; } catch { /* 조용히 실패 */ } }), ); setTrajectoryEntries(newEntries); + setOilSummaryEntries(newSummaries); + }; + + const handleCheckedHnsChange = ( + checked: Array<{ id: string; hnsAnlysSn: number; acdntSn: number | null }>, + ) => { + setCheckedHnsIds(new Set(checked.map((h) => String(h.hnsAnlysSn)))); }; const handleSensitiveDataChange = ( @@ -316,6 +407,17 @@ export function IncidentsView() { ); }, [dischargeMode, baselineLoaded]); + // ── HNS 대기확산 레이어 (히트맵 BitmapLayer + AEGL 원) ── + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hnsZoneLayers: any[] = useMemo(() => { + // null = 패널 초기화 전 → 전체 표시 / Set → 체크된 항목만 + const visibleAnalyses = + checkedHnsIds === null + ? hnsAnalyses + : hnsAnalyses.filter((a) => checkedHnsIds.has(String(a.hnsAnlysSn))); + return buildHnsDispersionLayers(visibleAnalyses, analysisActive); + }, [hnsAnalyses, checkedHnsIds, analysisActive]); + // ── 예측 결과 레이어 (입자 클라우드, 중심점 경로, 시간 라벨, 해안 부착 입자) ────── // eslint-disable-next-line @typescript-eslint/no-explicit-any const trajectoryLayers: any[] = useMemo(() => { @@ -361,6 +463,7 @@ export function IncidentsView() { getRadius: 3, radiusMinPixels: 2, radiusMaxPixels: 5, + visible: analysisActive, }), ); }); @@ -389,6 +492,7 @@ export function IncidentsView() { getLineColor: [255, 255, 255, 160] as [number, number, number, number], getLineWidth: 1, lineWidthMinPixels: 1, + visible: analysisActive, }), ); }); @@ -415,6 +519,7 @@ export function IncidentsView() { getWidth: 2, widthMinPixels: 2, widthMaxPixels: 4, + visible: analysisActive, }), ); layers.push( @@ -426,6 +531,7 @@ export function IncidentsView() { getRadius: 5, radiusMinPixels: 4, radiusMaxPixels: 8, + visible: analysisActive, }), ); layers.push( @@ -444,13 +550,14 @@ export function IncidentsView() { outlineColor: [0, 0, 0, 180] as [number, number, number, number], fontSettings: { sdf: true }, billboard: true, + visible: analysisActive, }), ); }); } } return layers; - }, [trajectoryEntries]); + }, [trajectoryEntries, analysisActive]); // ── 민감자원 GeoJSON 레이어 ────────────────────────── const sensLayer = useMemo(() => { @@ -485,12 +592,13 @@ export function IncidentsView() { return [...color, 180] as [number, number, number, number]; }, getLineWidth: 1, + visible: analysisActive, updateTriggers: { getFillColor: [sensColorMap], getLineColor: [sensColorMap], }, }); - }, [sensitiveGeojson, sensCheckedCategories, sensColorMap]); + }, [sensitiveGeojson, sensCheckedCategories, sensColorMap, analysisActive]); const realVesselLayers = useMemo( () => @@ -526,10 +634,11 @@ export function IncidentsView() { incidentLayer, ...realVesselLayers, ...dischargeZoneLayers, + ...hnsZoneLayers, ...trajectoryLayers, ...(sensLayer ? [sensLayer] : []), ], - [incidentLayer, realVesselLayers, dischargeZoneLayers, trajectoryLayers, sensLayer], + [incidentLayer, realVesselLayers, dischargeZoneLayers, hnsZoneLayers, trajectoryLayers, sensLayer], ); return ( @@ -661,13 +770,12 @@ export function IncidentsView() { {rightCollapsed ? '◀' : '▶'} - {/* Default Map (visible when not in analysis or in overlay mode) */} - {(!analysisActive || viewMode === 'overlay') && ( -
- + { if (dischargeMode) { const distanceNm = estimateDistanceFromCoast(lat, lon); @@ -722,56 +830,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} + +
+
+
+ +
+
+
+ ); + })}
)}
@@ -1092,6 +1156,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 @@ -1109,6 +1181,599 @@ export function IncidentsView() { } /* ════════════════════════════════════════════════════ + + 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({ + slotKey, + incident, + checkedPreds, + checkedHns, + checkedRescues, + sensCategories, + checkedSensCategories, + trajectoryLayers, + hnsZoneLayers, + sensLayer, + oilSummaries, +}: { + 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 (!slotKey) { + return ( +
+ 분석 결과를 선택하세요 +
+ ); + } + + const tag = SLOT_TAG[slotKey]; + const mock = SPLIT_MOCK_FALLBACK[slotKey]; + + // 슬롯별 체크된 항목 리스트 행 (우측 패널 포맷과 유사) + 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} {tag.fullLabel} 결과 +
+
{modelString}
+ {incident && ( +
+ 사고: {incident.name} · {incident.date} {incident.time} +
+ )} +
+ + {/* 체크된 분석 목록 */} +
+
+ 선택된 분석 ({listRows.length}) +
+ {listRows.length === 0 ? ( +
+ 선택된 분석이 없습니다 +
+ ) : ( + listRows.map((r, i) => ( +
+
+ {r.name} +
+
{r.sub}
+
+ )) + )} +
+ + {/* 메트릭 테이블 */} +
+ {items.map((item, i) => ( +
+ {item.label} + + {item.value || '-'} + +
+ ))} +
+ + + {/* 시각화 영역 — 실제 지도 캡처 (선택 분석 레이어만 표출, 4:3 고정 비율) */} +
+ {incident ? ( + + ) : ( +
+ 사고를 선택하세요 +
+ )} +
+ {tag.icon} {tag.label} 지도 +
+
+ + {/* 민감자원 섹션 (유출유 전용) */} + {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 공용 유틸 ════════════════════════════════════════════════════ */ export function formatDateTime(iso: string): string { diff --git a/frontend/src/components/incidents/services/incidentsApi.ts b/frontend/src/components/incidents/services/incidentsApi.ts index 2ec08c2..ddcca14 100644 --- a/frontend/src/components/incidents/services/incidentsApi.ts +++ b/frontend/src/components/incidents/services/incidentsApi.ts @@ -1,4 +1,5 @@ import { api } from '@common/services/api'; + import type { IncidentListItem, PredExecItem, @@ -10,6 +11,7 @@ import type { } from '@interfaces/incidents/IncidentsInterface'; import type { AerialMediaItem } from '@interfaces/aerial/AerialInterface'; + function toCompat(item: IncidentListItem): IncidentCompat { const dt = new Date(item.occrnDtm); const statusMap: Record = { @@ -28,7 +30,10 @@ function toCompat(item: IncidentListItem): IncidentCompat { location: { lat: item.lat, lon: item.lng }, causeType: item.acdntTpCd, oilType: item.oilTpCd ?? undefined, - prediction: item.hasPredCompleted ? '예측완료' : undefined, + prediction: + item.hasPredCompleted || item.hasHnsCompleted || item.hasRescueCompleted + ? '예측완료' + : undefined, mediaCount: item.mediaCnt, hasImgAnalysis: item.hasImgAnalysis || undefined, }; diff --git a/frontend/src/components/incidents/utils/hnsDispersionLayers.ts b/frontend/src/components/incidents/utils/hnsDispersionLayers.ts new file mode 100644 index 0000000..bb70351 --- /dev/null +++ b/frontend/src/components/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/interfaces/hns/HnsInterface.ts b/frontend/src/interfaces/hns/HnsInterface.ts index fa09ca4..8aea965 100644 --- a/frontend/src/interfaces/hns/HnsInterface.ts +++ b/frontend/src/interfaces/hns/HnsInterface.ts @@ -18,6 +18,7 @@ import type { /** HNS 분석 — 분석 목록 API 응답 아이템 */ export interface HnsAnalysisItem { hnsAnlysSn: number; + acdntSn: number | null; anlysNm: string; acdntDtm: string | null; locNm: string | null; @@ -41,6 +42,7 @@ export interface HnsAnalysisItem { /** HNS 분석 — 분석 생성 요청 페이로드 */ export interface CreateHnsAnalysisInput { anlysNm: string; + acdntSn?: number; acdntDtm?: string; locNm?: string; lon?: number; @@ -193,6 +195,8 @@ export interface HNSInputParams { accidentTime: string; predictionTime: string; accidentName: string; + /** wing.ACDNT 사고번호 (사고 리스트에서 선택된 경우) */ + selectedAcdntSn?: number; } /** HNS 분석 — 재계산 모달 입력 파라미터 */ diff --git a/frontend/src/interfaces/prediction/PredictionInterface.ts b/frontend/src/interfaces/prediction/PredictionInterface.ts index c1fb3ec..464da08 100644 --- a/frontend/src/interfaces/prediction/PredictionInterface.ts +++ b/frontend/src/interfaces/prediction/PredictionInterface.ts @@ -199,7 +199,46 @@ export interface TrajectoryResponse { stepSummariesByModel?: Record; } + /** 민감자원 — 카테고리별 집계 응답 (count·totalArea) */ +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, +): Promise => { + const response = await api.get( + `/prediction/analyses/${acdntSn}/trajectory`, + predRunSn != null ? { params: { predRunSn } } : undefined, + ); + 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; @@ -248,6 +287,7 @@ export interface ImageAnalyzeResult { /** GSC 연계 — 외부 수집 사고 목록 (확산 예측 입력 셀렉트용) */ export interface GscAccidentListItem { + acdntSn: number; acdntMngNo: string; pollNm: string; pollDate: string | null;