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; occurredAt: string; analysisDate: string; requestor: string; duration: string; oilType: string; volume: number | null; location: string; lat: number | null; lon: number | null; kospsStatus: string; poseidonStatus: string; opendriftStatus: string; backtrackStatus: string; analyst: string; officeName: string; acdntSttsCd: string; predRunSn: number | null; runDtm: string | null; } interface PredictionDetail { acdnt: { acdntSn: number; acdntNm: string; occurredAt: string; lat: number | null; lon: number | null; location: string; analyst: string; officeName: string; }; spill: { oilType: string; volume: number | null; unit: string; fcstHr: number | null; } | null; vessels: Array<{ vesselInfoSn: number; imoNo: string; vesselNm: string; vesselTp: string; loaM: number | null; breadthM: number | null; draftM: number | null; gt: number | null; dwt: number | null; builtYr: number | null; flagCd: string; callsign: string; engineDc: string; insuranceData: unknown; }>; weather: Array<{ obsDtm: string; locNm: string; temp: string; weatherDc: string; wind: string; wave: string; humid: string; vis: string; sst: string; }>; } interface BacktrackResult { backtrackSn: number; acdntSn: number; estSpilDtm: string | null; anlysRange: string | null; lon: number | null; lat: number | null; srchRadiusNm: number | null; totalVessels: number | null; execSttsCd: string; rsltData: unknown; regDtm: string; } interface CreateBacktrackInput { acdntSn: number; lat: number; lon: number; estSpilDtm?: string; anlysRange?: string; srchRadiusNm?: number; } interface SaveBoomLineInput { acdntSn: number; boomNm: string; priorityOrd?: number; geojson: unknown; lengthM?: number; efficiencyPct?: number; } interface BoomLineItem { boomLineSn: number; acdntSn: number; boomNm: string; priorityOrd: number; geom: unknown; lengthM: number | null; efficiencyPct: number | null; sttsCd: string; regDtm: string; } interface ListAnalysesInput { search?: string; acdntSn?: number; } export async function listAnalyses(input: ListAnalysesInput): Promise { const params: unknown[] = []; const conditions: string[] = ["A.USE_YN = 'Y'"]; if (input.acdntSn) { params.push(input.acdntSn); conditions.push(`A.ACDNT_SN = $${params.length}`); } if (input.search) { params.push(`%${input.search}%`); conditions.push(`(A.ACDNT_NM ILIKE $${params.length} OR A.LOC_DC ILIKE $${params.length})`); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const sql = ` SELECT A.ACDNT_SN, A.ACDNT_NM, A.ACDNT_STTS_CD, A.OCCRN_DTM, A.LAT, A.LNG, A.LOC_DC, A.ANALYST_NM, A.OFFICE_NM, A.REGION_NM, S.OIL_TP_CD, S.SPIL_QTY, S.SPIL_UNIT_CD, S.FCST_HR, P.PRED_RUN_SN, P.RUN_DTM, P.KOSPS_STATUS, P.POSEIDON_STATUS, P.OPENDRIFT_STATUS, B.BACKTRACK_STATUS, COALESCE(U.USER_NM, A.ANALYST_NM) AS RESOLVED_ANALYST, COALESCE(O.ORG_NM, A.OFFICE_NM) AS RESOLVED_OFFICE FROM ACDNT A INNER JOIN ( SELECT ACDNT_SN, PRED_RUN_SN, MIN(BGNG_DTM) AS RUN_DTM, MIN(SPIL_DATA_SN) AS SPIL_DATA_SN, MIN(EXEC_USER_ID::TEXT)::UUID AS EXEC_USER_ID, MAX(CASE WHEN ALGO_CD = 'KOSPS' THEN EXEC_STTS_CD END) AS KOSPS_STATUS, MAX(CASE WHEN ALGO_CD = 'POSEIDON' THEN EXEC_STTS_CD END) AS POSEIDON_STATUS, MAX(CASE WHEN ALGO_CD = 'OPENDRIFT' THEN EXEC_STTS_CD END) AS OPENDRIFT_STATUS FROM PRED_EXEC GROUP BY ACDNT_SN, PRED_RUN_SN ) P ON P.ACDNT_SN = A.ACDNT_SN LEFT JOIN SPIL_DATA S ON S.SPIL_DATA_SN = P.SPIL_DATA_SN LEFT JOIN AUTH_USER U ON U.USER_ID = P.EXEC_USER_ID LEFT JOIN AUTH_ORG O ON O.ORG_SN = U.ORG_SN LEFT JOIN ( SELECT ACDNT_SN, MAX(CASE WHEN B.EXEC_STTS_CD IS NOT NULL THEN B.EXEC_STTS_CD ELSE 'pending' END) AS BACKTRACK_STATUS FROM BACKTRACK B GROUP BY ACDNT_SN ) B ON B.ACDNT_SN = A.ACDNT_SN ${whereClause} ORDER BY P.RUN_DTM DESC NULLS LAST, A.OCCRN_DTM DESC `; const { rows } = await wingPool.query(sql, params); return rows.map((row: Record) => ({ acdntSn: Number(row['acdnt_sn']), acdntNm: String(row['acdnt_nm'] ?? ''), occurredAt: row['occrn_dtm'] ? String(row['occrn_dtm']) : '', analysisDate: row['occrn_dtm'] ? String(row['occrn_dtm']) : '', requestor: String(row['analyst_nm'] ?? ''), duration: row['fcst_hr'] != null ? `${row['fcst_hr']}hr` : '', oilType: String(row['oil_tp_cd'] ?? ''), volume: row['spil_qty'] != null ? parseFloat(String(row['spil_qty'])) : null, location: String(row['loc_dc'] ?? ''), lat: row['lat'] != null ? parseFloat(String(row['lat'])) : null, lon: row['lng'] != null ? parseFloat(String(row['lng'])) : null, kospsStatus: String(row['kosps_status'] ?? 'pending').toLowerCase(), poseidonStatus: String(row['poseidon_status'] ?? 'pending').toLowerCase(), opendriftStatus: String(row['opendrift_status'] ?? 'pending').toLowerCase(), backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(), analyst: String(row['resolved_analyst'] ?? ''), officeName: String(row['resolved_office'] ?? ''), acdntSttsCd: String(row['acdnt_stts_cd'] ?? 'ACTIVE'), predRunSn: row['pred_run_sn'] != null ? Number(row['pred_run_sn']) : null, runDtm: row['run_dtm'] ? String(row['run_dtm']) : null, })); } export async function getAnalysisDetail(acdntSn: number): Promise { const acdntSql = ` SELECT A.ACDNT_SN, A.ACDNT_NM, A.OCCRN_DTM, A.LAT, A.LNG, A.LOC_DC, A.ANALYST_NM, A.OFFICE_NM FROM ACDNT A WHERE A.ACDNT_SN = $1 AND A.USE_YN = 'Y' `; const { rows: acdntRows } = await wingPool.query(acdntSql, [acdntSn]); if (acdntRows.length === 0) return null; const a = acdntRows[0] as Record; const spillSql = ` SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR FROM SPIL_DATA WHERE ACDNT_SN = $1 ORDER BY SPIL_DATA_SN ASC LIMIT 1 `; const { rows: spillRows } = await wingPool.query(spillSql, [acdntSn]); const vesselSql = ` SELECT VESSEL_INFO_SN, IMO_NO, VESSEL_NM, VESSEL_TP, LOA_M, BREADTH_M, DRAFT_M, GT, DWT, BUILT_YR, FLAG_CD, CALLSIGN, ENGINE_DC, INSURANCE_DATA FROM VESSEL_INFO WHERE ACDNT_SN = $1 ORDER BY VESSEL_INFO_SN ASC `; const { rows: vesselRows } = await wingPool.query(vesselSql, [acdntSn]); const weatherSql = ` SELECT OBS_DTM, LOC_NM, TEMP, WEATHER_DC, WIND, WAVE, HUMID, VIS, SST FROM ACDNT_WEATHER WHERE ACDNT_SN = $1 ORDER BY OBS_DTM ASC `; const { rows: weatherRows } = await wingPool.query(weatherSql, [acdntSn]); const spill = spillRows.length > 0 ? (() => { const s = spillRows[0] as Record; return { oilType: String(s['oil_tp_cd'] ?? ''), volume: s['spil_qty'] != null ? parseFloat(String(s['spil_qty'])) : null, unit: String(s['spil_unit_cd'] ?? ''), fcstHr: s['fcst_hr'] != null ? parseFloat(String(s['fcst_hr'])) : null, }; })() : null; const vessels = vesselRows.map((v: Record) => ({ vesselInfoSn: Number(v['vessel_info_sn']), imoNo: String(v['imo_no'] ?? ''), vesselNm: String(v['vessel_nm'] ?? ''), vesselTp: String(v['vessel_tp'] ?? ''), loaM: v['loa_m'] != null ? parseFloat(String(v['loa_m'])) : null, breadthM: v['breadth_m'] != null ? parseFloat(String(v['breadth_m'])) : null, draftM: v['draft_m'] != null ? parseFloat(String(v['draft_m'])) : null, gt: v['gt'] != null ? parseFloat(String(v['gt'])) : null, dwt: v['dwt'] != null ? parseFloat(String(v['dwt'])) : null, builtYr: v['built_yr'] != null ? Number(v['built_yr']) : null, flagCd: String(v['flag_cd'] ?? ''), callsign: String(v['callsign'] ?? ''), engineDc: String(v['engine_dc'] ?? ''), insuranceData: v['insurance_data'] ?? null, })); const weather = weatherRows.map((w: Record) => ({ obsDtm: w['obs_dtm'] ? String(w['obs_dtm']) : '', locNm: String(w['loc_nm'] ?? ''), temp: String(w['temp'] ?? ''), weatherDc: String(w['weather_dc'] ?? ''), wind: String(w['wind'] ?? ''), wave: String(w['wave'] ?? ''), humid: String(w['humid'] ?? ''), vis: String(w['vis'] ?? ''), sst: String(w['sst'] ?? ''), })); return { acdnt: { acdntSn: Number(a['acdnt_sn']), acdntNm: String(a['acdnt_nm'] ?? ''), occurredAt: a['occrn_dtm'] ? String(a['occrn_dtm']) : '', lat: a['lat'] != null ? parseFloat(String(a['lat'])) : null, lon: a['lng'] != null ? parseFloat(String(a['lng'])) : null, location: String(a['loc_dc'] ?? ''), analyst: String(a['analyst_nm'] ?? ''), officeName: String(a['office_nm'] ?? ''), }, spill, vessels, weather, }; } export async function getBacktrack(sn: number): Promise { const sql = ` SELECT BACKTRACK_SN, ACDNT_SN, EST_SPIL_DTM, ANLYS_RANGE, LON, LAT, SRCH_RADIUS_NM, TOTAL_VESSELS, EXEC_STTS_CD, RSLT_DATA, REG_DTM FROM BACKTRACK WHERE BACKTRACK_SN = $1 AND USE_YN = 'Y' `; const { rows } = await wingPool.query(sql, [sn]); if (rows.length === 0) return null; return rowToBacktrack(rows[0] as Record); } export async function listBacktracksByAcdnt(acdntSn: number): Promise { const sql = ` SELECT BACKTRACK_SN, ACDNT_SN, EST_SPIL_DTM, ANLYS_RANGE, LON, LAT, SRCH_RADIUS_NM, TOTAL_VESSELS, EXEC_STTS_CD, RSLT_DATA, REG_DTM FROM BACKTRACK WHERE ACDNT_SN = $1 AND USE_YN = 'Y' ORDER BY REG_DTM DESC `; const { rows } = await wingPool.query(sql, [acdntSn]); return rows.map((r: Record) => rowToBacktrack(r)); } function rowToBacktrack(r: Record): BacktrackResult { return { backtrackSn: Number(r['backtrack_sn']), acdntSn: Number(r['acdnt_sn']), estSpilDtm: r['est_spil_dtm'] ? new Date(r['est_spil_dtm'] as string | Date).toISOString() : null, anlysRange: r['anlys_range'] ? String(r['anlys_range']) : null, lon: r['lon'] != null ? parseFloat(String(r['lon'])) : null, lat: r['lat'] != null ? parseFloat(String(r['lat'])) : null, srchRadiusNm: r['srch_radius_nm'] != null ? parseFloat(String(r['srch_radius_nm'])) : null, totalVessels: r['total_vessels'] != null ? Number(r['total_vessels']) : null, execSttsCd: String(r['exec_stts_cd'] ?? ''), rsltData: r['rslt_data'] ?? null, regDtm: String(r['reg_dtm'] ?? ''), }; } export async function createBacktrack( input: CreateBacktrackInput, ): Promise { const { acdntSn, lat, lon, estSpilDtm, anlysRange, srchRadiusNm } = input; const sql = ` INSERT INTO BACKTRACK (ACDNT_SN, LAT, LON, GEOM, LOC_DC, EST_SPIL_DTM, ANLYS_RANGE, SRCH_RADIUS_NM, EXEC_STTS_CD) VALUES ( $1, $2::double precision, $3::double precision, ST_SetSRID(ST_MakePoint($3::double precision, $2::double precision), 4326), $3::text || ' + ' || $2::text, $4, $5, $6, 'PENDING' ) RETURNING BACKTRACK_SN `; const { rows } = await wingPool.query(sql, [ acdntSn, lat, lon, estSpilDtm || null, anlysRange || null, srchRadiusNm || null, ]); const backtrackSn = Number((rows[0] as Record)['backtrack_sn']); // 동기 분석 (완료까지 대기 후 결과 반환) await runBacktrackAnalysis(backtrackSn); const result = await getBacktrack(backtrackSn); if (!result) throw new Error('역추적 결과를 찾을 수 없습니다'); return result; } export async function saveBoomLine(input: SaveBoomLineInput): Promise<{ boomLineSn: number }> { const { acdntSn, boomNm, priorityOrd = 0, geojson, lengthM, efficiencyPct } = input; const sql = ` INSERT INTO BOOM_LINE (ACDNT_SN, BOOM_NM, PRIORITY_ORD, GEOM, LENGTH_M, EFFICIENCY_PCT) VALUES ($1, $2, $3, ST_GeomFromGeoJSON($4), $5, $6) RETURNING BOOM_LINE_SN `; const { rows } = await wingPool.query(sql, [ acdntSn, boomNm, priorityOrd, JSON.stringify(geojson), lengthM || null, efficiencyPct || null, ]); return { boomLineSn: Number((rows[0] as Record)['boom_line_sn']) }; } interface TrajectoryParticle { lat: number; lon: number; stranded?: 0 | 1; } interface TrajectoryWindPoint { lat: number; lon: number; wind_speed: number; wind_direction: number; } interface TrajectoryHydrGrid { lonInterval: number[]; boundLonLat: { top: number; bottom: number; left: number; right: number }; rows: number; cols: number; latInterval: number[]; } interface TrajectoryTimeStep { particles: TrajectoryParticle[]; remaining_volume_m3: number; weathered_volume_m3: number; evaporation_volume_m3?: number; dispersion_volume_m3?: number; pollution_area_km2: number; beached_volume_m3: number; pollution_coast_length_m: number; center_lat?: number; center_lon?: number; wind_data?: TrajectoryWindPoint[]; hydr_data?: [number[][], number[][]]; hydr_grid?: TrajectoryHydrGrid; } // ALGO_CD → 프론트엔드 모델명 매핑 const ALGO_CD_TO_MODEL: Record = { 'OPENDRIFT': 'OpenDrift', 'POSEIDON': 'POSEIDON', }; interface SingleModelTrajectoryResult { trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1; model: string }>; summary: { remainingVolume: number; weatheredVolume: number; evaporationVolume: number; dispersionVolume: number; pollutionArea: number; beachedVolume: number; pollutionCoastLength: number; }; stepSummaries: Array<{ remainingVolume: number; weatheredVolume: number; evaporationVolume: number; dispersionVolume: number; pollutionArea: number; beachedVolume: number; pollutionCoastLength: number; }>; centerPoints: Array<{ lat: number; lon: number; time: number; model: string }>; windData: TrajectoryWindPoint[][]; hydrData: ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]; } interface TrajectoryResult { trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1; model: string }>; summary: { remainingVolume: number; weatheredVolume: number; evaporationVolume: number; dispersionVolume: number; pollutionArea: number; beachedVolume: number; pollutionCoastLength: number; }; centerPoints: Array<{ lat: number; lon: number; time: number; model: string }>; windDataByModel: Record; hydrDataByModel: Record; summaryByModel: Record; stepSummariesByModel: Record; } function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: string): SingleModelTrajectoryResult { const trajectory = rawResult.flatMap((step, stepIdx) => step.particles.map((p, i) => ({ lat: p.lat, lon: p.lon, time: stepIdx, particle: i, stranded: p.stranded, model, })) ); const lastStep = rawResult[rawResult.length - 1]; const summary = { remainingVolume: lastStep.remaining_volume_m3, weatheredVolume: lastStep.weathered_volume_m3, evaporationVolume: lastStep.evaporation_volume_m3 ?? lastStep.weathered_volume_m3 * 0.65, dispersionVolume: lastStep.dispersion_volume_m3 ?? lastStep.weathered_volume_m3 * 0.35, pollutionArea: lastStep.pollution_area_km2, beachedVolume: lastStep.beached_volume_m3, pollutionCoastLength: lastStep.pollution_coast_length_m, }; const centerPoints = rawResult .map((step, stepIdx) => step.center_lat != null && step.center_lon != null ? { lat: step.center_lat, lon: step.center_lon, time: stepIdx, model } : null ) .filter((p): p is { lat: number; lon: number; time: number; model: string } => p !== null); const stepSummaries = rawResult.map((step) => ({ remainingVolume: step.remaining_volume_m3, weatheredVolume: step.weathered_volume_m3, evaporationVolume: step.evaporation_volume_m3 ?? step.weathered_volume_m3 * 0.65, dispersionVolume: step.dispersion_volume_m3 ?? step.weathered_volume_m3 * 0.35, pollutionArea: step.pollution_area_km2, beachedVolume: step.beached_volume_m3, pollutionCoastLength: step.pollution_coast_length_m, })); const windData = rawResult.map((step) => step.wind_data ?? []); const hydrData = rawResult.map((step) => step.hydr_data && step.hydr_grid ? { value: step.hydr_data, grid: step.hydr_grid } : null ); return { trajectory, summary, stepSummaries, centerPoints, windData, hydrData }; } export async function getAnalysisTrajectory(acdntSn: number, predRunSn?: number): Promise { // 완료된 모든 모델(OPENDRIFT, POSEIDON) 결과 조회 // predRunSn이 있으면 해당 실행의 결과만, 없으면 최신 결과 const sql = predRunSn != null ? ` SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC WHERE ACDNT_SN = $1 AND PRED_RUN_SN = $2 AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON') AND EXEC_STTS_CD = 'COMPLETED' ORDER BY CMPL_DTM DESC ` : ` SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC WHERE ACDNT_SN = $1 AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON') AND EXEC_STTS_CD = 'COMPLETED' ORDER BY CMPL_DTM DESC `; const params = predRunSn != null ? [acdntSn, predRunSn] : [acdntSn]; const { rows } = await wingPool.query(sql, params); if (rows.length === 0) return null; // 모든 모델의 파티클을 하나의 배열로 병합 let mergedTrajectory: TrajectoryResult['trajectory'] = []; let allCenterPoints: TrajectoryResult['centerPoints'] = []; // summary: 가장 최근 완료된 OpenDrift 기준, 없으면 POSEIDON 기준 let baseResult: SingleModelTrajectoryResult | null = null; const windDataByModel: Record = {}; const hydrDataByModel: Record = {}; const summaryByModel: Record = {}; const stepSummariesByModel: Record = {}; // OpenDrift 우선, 없으면 POSEIDON 선택 (ORDER BY CMPL_DTM DESC이므로 첫 번째 행이 가장 최근) const opendriftRow = (rows as Array>).find((r) => r['algo_cd'] === 'OPENDRIFT'); const poseidonRow = (rows as Array>).find((r) => r['algo_cd'] === 'POSEIDON'); const baseRow = opendriftRow ?? poseidonRow ?? null; for (const row of rows as Array>) { if (!row['rslt_data']) continue; const algoCd = String(row['algo_cd'] ?? ''); const modelName = ALGO_CD_TO_MODEL[algoCd] ?? algoCd; const parsed = transformTrajectoryResult(row['rslt_data'] as TrajectoryTimeStep[], modelName); mergedTrajectory = mergedTrajectory.concat(parsed.trajectory); allCenterPoints = allCenterPoints.concat(parsed.centerPoints); windDataByModel[modelName] = parsed.windData; hydrDataByModel[modelName] = parsed.hydrData; summaryByModel[modelName] = parsed.summary; stepSummariesByModel[modelName] = parsed.stepSummaries; if (row === baseRow) { baseResult = parsed; } } if (!baseResult) return null; return { trajectory: mergedTrajectory, summary: baseResult.summary, centerPoints: allCenterPoints, windDataByModel, hydrDataByModel, summaryByModel, stepSummariesByModel, }; } export async function getSensitiveResourcesByAcdntSn( acdntSn: number, ): Promise<{ category: string; count: number; totalArea: number | null }[]> { const sql = ` WITH all_wkts AS ( SELECT step_data ->> 'wkt' AS wkt FROM wing.PRED_EXEC, jsonb_array_elements(RSLT_DATA) AS step_data WHERE ACDNT_SN = $1 AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON') AND EXEC_STTS_CD = 'COMPLETED' AND RSLT_DATA IS NOT NULL ), union_geom AS ( SELECT ST_Union(ST_GeomFromText(wkt, 4326)) AS geom FROM all_wkts WHERE wkt IS NOT NULL AND wkt <> '' ) SELECT sr.CATEGORY, COUNT(*)::int AS count, CASE WHEN bool_and(sr.PROPERTIES ? 'area') THEN SUM((sr.PROPERTIES->>'area')::float) ELSE NULL END AS total_area FROM wing.SENSITIVE_RESOURCE sr, union_geom WHERE union_geom.geom IS NOT NULL AND ST_Intersects(sr.GEOM, union_geom.geom) GROUP BY sr.CATEGORY ORDER BY sr.CATEGORY `; const { rows } = await wingPool.query(sql, [acdntSn]); return rows.map((r: Record) => ({ category: String(r['category'] ?? ''), count: Number(r['count'] ?? 0), totalArea: r['total_area'] != null ? Number(r['total_area']) : null, })); } export async function getSensitiveResourcesGeoJsonByAcdntSn( acdntSn: number, ): Promise<{ type: 'FeatureCollection'; features: unknown[] }> { const sql = ` WITH all_wkts AS ( SELECT step_data ->> 'wkt' AS wkt FROM wing.PRED_EXEC, jsonb_array_elements(RSLT_DATA) AS step_data WHERE ACDNT_SN = $1 AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON') AND EXEC_STTS_CD = 'COMPLETED' AND RSLT_DATA IS NOT NULL ), union_geom AS ( SELECT ST_Union(ST_GeomFromText(wkt, 4326)) AS geom FROM all_wkts WHERE wkt IS NOT NULL AND wkt <> '' ) SELECT sr.SR_ID, sr.CATEGORY, sr.PROPERTIES, ST_AsGeoJSON(sr.GEOM)::jsonb AS geom_json FROM wing.SENSITIVE_RESOURCE sr, union_geom WHERE union_geom.geom IS NOT NULL AND ST_Intersects(sr.GEOM, union_geom.geom) ORDER BY sr.CATEGORY, sr.SR_ID `; const { rows } = await wingPool.query(sql, [acdntSn]); const features = rows.map((r: Record) => ({ type: 'Feature', geometry: r['geom_json'], properties: { srId: Number(r['sr_id']), category: String(r['category'] ?? ''), ...(r['properties'] as Record ?? {}), }, })); return { type: 'FeatureCollection', features }; } export async function getSensitivityEvaluationGeojsonByAcdntSn( acdntSn: number, ): Promise<{ type: 'FeatureCollection'; features: unknown[] }> { const acdntSql = `SELECT LAT, LNG FROM wing.ACDNT WHERE ACDNT_SN = $1 AND USE_YN = 'Y'`; const { rows: acdntRows } = await wingPool.query(acdntSql, [acdntSn]); if (acdntRows.length === 0 || acdntRows[0]['lat'] == null) return { type: 'FeatureCollection', features: [] }; const lat = Number(acdntRows[0]['lat']); const lng = Number(acdntRows[0]['lng']); const sql = ` SELECT SR_ID, PROPERTIES, ST_AsGeoJSON(GEOM)::jsonb AS geom_json, ST_Area(GEOM::geography) / 1000000.0 AS area_km2 FROM wing.SENSITIVE_EVALUATION WHERE ST_DWithin( GEOM::geography, ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography, 10000 ) ORDER BY SR_ID `; const { rows } = await wingPool.query(sql, [lat, lng]); const features = rows.map((r: Record) => ({ type: 'Feature', geometry: r['geom_json'], properties: { srId: Number(r['sr_id']), area_km2: Number(r['area_km2']), ...(r['properties'] as Record ?? {}), }, })); return { type: 'FeatureCollection', features }; } export async function getPredictionParticlesGeojsonByAcdntSn( acdntSn: number, ): Promise<{ type: 'FeatureCollection'; features: unknown[]; maxStep: number }> { const sql = ` SELECT ALGO_CD, RSLT_DATA FROM wing.PRED_EXEC WHERE ACDNT_SN = $1 AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON') AND EXEC_STTS_CD = 'COMPLETED' AND RSLT_DATA IS NOT NULL `; const { rows } = await wingPool.query(sql, [acdntSn]); if (rows.length === 0) return { type: 'FeatureCollection', features: [], maxStep: 0 }; const ALGO_TO_MODEL: Record = { OPENDRIFT: 'OpenDrift', POSEIDON: 'POSEIDON' }; const features: unknown[] = []; let globalMaxStep = 0; for (const row of rows) { const model = ALGO_TO_MODEL[String(row['algo_cd'])] ?? String(row['algo_cd']); const steps = row['rslt_data'] as TrajectoryTimeStep[]; const maxStep = steps.length - 1; if (maxStep > globalMaxStep) globalMaxStep = maxStep; steps.forEach((step, stepIdx) => { step.particles.forEach(p => { features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [p.lon, p.lat] }, properties: { model, time: stepIdx, stranded: p.stranded ?? 0, isLastStep: stepIdx === maxStep, }, }); }); }); } return { type: 'FeatureCollection', features, maxStep: globalMaxStep }; } export async function listBoomLines(acdntSn: number): Promise { const sql = ` SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD, ST_AsGeoJSON(GEOM) AS GEOM, LENGTH_M, EFFICIENCY_PCT, STTS_CD, REG_DTM FROM BOOM_LINE WHERE ACDNT_SN = $1 AND USE_YN = 'Y' ORDER BY PRIORITY_ORD ASC `; const { rows } = await wingPool.query(sql, [acdntSn]); return rows.map((r: Record) => ({ boomLineSn: Number(r['boom_line_sn']), acdntSn: Number(r['acdnt_sn']), boomNm: String(r['boom_nm'] ?? ''), priorityOrd: Number(r['priority_ord'] ?? 0), geom: r['geom'] != null ? JSON.parse(String(r['geom'])) : null, lengthM: r['length_m'] != null ? parseFloat(String(r['length_m'])) : null, efficiencyPct: r['efficiency_pct'] != null ? parseFloat(String(r['efficiency_pct'])) : null, sttsCd: String(r['stts_cd'] ?? 'PLANNED'), 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, }; }