- Incidents 통합 분석 시 이전 분석 결과를 분할 화면으로 표출 - 유출유/HNS/구난 분석 선택 모달(AnalysisSelectModal) 추가 - prediction /analyses/:acdntSn/oil-summary API 신규 (primary + byModel) - HNS 분석 생성 시 acdntSn 연결 지원 - GSC 사고 목록 응답에 acdntSn 노출 - 민감자원 누적/카테고리 관리 및 HNS 확산 레이어 유틸(hnsDispersionLayers) 추가
938 lines
31 KiB
TypeScript
938 lines
31 KiB
TypeScript
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<PredictionAnalysis[]> {
|
|
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<string, unknown>) => ({
|
|
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<PredictionDetail | null> {
|
|
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<string, unknown>;
|
|
|
|
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<string, unknown>;
|
|
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<string, unknown>) => ({
|
|
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<string, unknown>) => ({
|
|
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<BacktrackResult | null> {
|
|
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<string, unknown>);
|
|
}
|
|
|
|
export async function listBacktracksByAcdnt(acdntSn: number): Promise<BacktrackResult[]> {
|
|
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<string, unknown>) => rowToBacktrack(r));
|
|
}
|
|
|
|
function rowToBacktrack(r: Record<string, unknown>): 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<BacktrackResult> {
|
|
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<string, unknown>)['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<string, unknown>)['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<string, string> = {
|
|
'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<string, TrajectoryWindPoint[][]>;
|
|
hydrDataByModel: Record<string, ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]>;
|
|
summaryByModel: Record<string, SingleModelTrajectoryResult['summary']>;
|
|
stepSummariesByModel: Record<string, SingleModelTrajectoryResult['stepSummaries']>;
|
|
}
|
|
|
|
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<TrajectoryResult | null> {
|
|
// 완료된 모든 모델(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<string, TrajectoryWindPoint[][]> = {};
|
|
const hydrDataByModel: Record<string, ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]> = {};
|
|
const summaryByModel: Record<string, SingleModelTrajectoryResult['summary']> = {};
|
|
const stepSummariesByModel: Record<string, SingleModelTrajectoryResult['stepSummaries']> = {};
|
|
|
|
// OpenDrift 우선, 없으면 POSEIDON 선택 (ORDER BY CMPL_DTM DESC이므로 첫 번째 행이 가장 최근)
|
|
const opendriftRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'OPENDRIFT');
|
|
const poseidonRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'POSEIDON');
|
|
const baseRow = opendriftRow ?? poseidonRow ?? null;
|
|
|
|
for (const row of rows as Array<Record<string, unknown>>) {
|
|
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<string, unknown>) => ({
|
|
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<string, unknown>) => ({
|
|
type: 'Feature',
|
|
geometry: r['geom_json'],
|
|
properties: {
|
|
srId: Number(r['sr_id']),
|
|
category: String(r['category'] ?? ''),
|
|
...(r['properties'] as Record<string, unknown> ?? {}),
|
|
},
|
|
}));
|
|
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<string, unknown>) => ({
|
|
type: 'Feature',
|
|
geometry: r['geom_json'],
|
|
properties: {
|
|
srId: Number(r['sr_id']),
|
|
area_km2: Number(r['area_km2']),
|
|
...(r['properties'] as Record<string, unknown> ?? {}),
|
|
},
|
|
}));
|
|
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<string, string> = { 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<BoomLineItem[]> {
|
|
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<string, unknown>) => ({
|
|
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<string, OilSpillSummary>;
|
|
}
|
|
|
|
export async function getOilSpillSummary(acdntSn: number, predRunSn?: number): Promise<OilSpillSummaryResponse | null> {
|
|
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<string, OilSpillSummary> = {};
|
|
|
|
// OpenDrift 우선, 없으면 POSEIDON
|
|
const opendriftRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'OPENDRIFT');
|
|
const poseidonRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'POSEIDON');
|
|
const primaryRow = opendriftRow ?? poseidonRow ?? null;
|
|
|
|
for (const row of rows as Array<Record<string, unknown>>) {
|
|
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,
|
|
};
|
|
}
|