release: 2026-04-16 (294건 커밋) #180
@ -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<GscAccidentListItem[]> {
|
||||
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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<string, unknown>): 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<string, unknown>): HnsAnalysisItem {
|
||||
|
||||
export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysisItem[]> {
|
||||
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<HnsAnalysi
|
||||
params.push(input.search)
|
||||
idx++
|
||||
}
|
||||
if (input.acdntSn != null) {
|
||||
conditions.push(`ACDNT_SN = $${idx++}`)
|
||||
params.push(input.acdntSn)
|
||||
}
|
||||
|
||||
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, EXEC_STTS_CD, RISK_CD, ANALYST_NM,
|
||||
RSLT_DATA, REG_DTM
|
||||
@ -179,7 +186,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysi
|
||||
|
||||
export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
|
||||
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<HnsAnalysisItem | null> {
|
||||
|
||||
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,
|
||||
|
||||
@ -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<IncidentDetail | nul
|
||||
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
|
||||
@ -222,6 +250,8 @@ export async function getIncident(acdntSn: number): Promise<IncidentDetail | nul
|
||||
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,
|
||||
predictions,
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
|
||||
getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn,
|
||||
getPredictionParticlesGeojsonByAcdntSn, getSensitivityEvaluationGeojsonByAcdntSn,
|
||||
getOilSpillSummary,
|
||||
} from './predictionService.js';
|
||||
import { analyzeImageFile } from './imageAnalyzeService.js';
|
||||
import { isValidNumber } from '../middleware/security.js';
|
||||
@ -70,6 +71,27 @@ router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('pred
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/prediction/analyses/:acdntSn/oil-summary — 유출유 확산 요약 (분할 패널용)
|
||||
router.get('/analyses/:acdntSn/oil-summary', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||
try {
|
||||
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
||||
if (!isValidNumber(acdntSn, 1, 999999)) {
|
||||
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
||||
return;
|
||||
}
|
||||
const predRunSn = req.query.predRunSn ? parseInt(req.query.predRunSn as string, 10) : undefined;
|
||||
const result = await getOilSpillSummary(acdntSn, predRunSn);
|
||||
if (!result) {
|
||||
res.json({ primary: null, byModel: {} });
|
||||
return;
|
||||
}
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[prediction] oil-summary 조회 오류:', err);
|
||||
res.status(500).json({ error: 'oil-summary 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/prediction/analyses/:acdntSn/sensitive-resources — 예측 영역 내 민감자원 집계
|
||||
router.get('/analyses/:acdntSn/sensitive-resources', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||
try {
|
||||
|
||||
@ -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<BoomLineItem[]> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<RescueOpsListItem[]
|
||||
conditions.push(`VESSEL_NM ILIKE '%' || $${idx++} || '%'`);
|
||||
params.push(input.search);
|
||||
}
|
||||
if (input?.acdntSn != null) {
|
||||
conditions.push(`ACDNT_SN = $${idx++}`);
|
||||
params.push(input.acdntSn);
|
||||
}
|
||||
|
||||
const where = 'WHERE ' + conditions.join(' AND ');
|
||||
|
||||
|
||||
118
database/migration/032_sync_gsc_accidents_to_wing.sql
Normal file
118
database/migration/032_sync_gsc_accidents_to_wing.sql
Normal file
@ -0,0 +1,118 @@
|
||||
-- ============================================================
|
||||
-- 032: gsc.tgs_acdnt_info → wing.ACDNT 동기화 (2026-04-10 이후)
|
||||
-- ------------------------------------------------------------
|
||||
-- 목적
|
||||
-- 3개 예측 탭(유출유확산예측 / HNS 대기확산 / 긴급구난)의 사고
|
||||
-- 선택 셀렉트박스에 노출되는 gsc 사고 레코드를 wing.ACDNT에
|
||||
-- 이관하여 wing 운영 로직과 동일한 사고 마스터를 공유한다.
|
||||
--
|
||||
-- 필터 정책 (backend/src/gsc/gscAccidentsService.ts 의 listGscAccidents 와 동일)
|
||||
-- - acdnt_asort_code IN (12개 코드)
|
||||
-- - acdnt_title IS NOT NULL
|
||||
-- - 좌표(tgs_acdnt_lc.la, lo) 존재
|
||||
-- - rcept_dt >= '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;
|
||||
-- ============================================================
|
||||
@ -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)
|
||||
|
||||
@ -48,6 +48,7 @@ export function HNSLeftPanel({
|
||||
}: HNSLeftPanelProps) {
|
||||
const [incidents, setIncidents] = useState<GscAccidentListItem[]>([]);
|
||||
const [selectedIncidentSn, setSelectedIncidentSn] = useState('');
|
||||
const [selectedAcdntSn, setSelectedAcdntSn] = useState<number | undefined>(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));
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<HnsAnalysisItem[]> {
|
||||
const response = await api.get<HnsAnalysisItem[]>('/hns/analyses', { params });
|
||||
return response.data;
|
||||
|
||||
@ -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<string>;
|
||||
onApply: (payload: AnalysisApplyPayload) => void;
|
||||
}
|
||||
|
||||
type StatusTab = 'all' | 'active' | 'done';
|
||||
|
||||
// ── 메타 설정 ───────────────────────────────────────────
|
||||
const MODAL_META: Record<AnalysisModalType, { icon: string; title: string; color: string }> = {
|
||||
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 (
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
borderRadius: '3px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color,
|
||||
background: bg,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 상태 코드 → 탭 카테고리 분류 ───────────────────────
|
||||
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<string, unknown> | 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<PredictionAnalysis[]>([]);
|
||||
const [hnsItems, setHnsItems] = useState<HnsAnalysisItem[]>([]);
|
||||
const [rescueItems, setRescueItems] = useState<RescueOpsItem[]>([]);
|
||||
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set(initialSelectedIds));
|
||||
const [statusTab, setStatusTab] = useState<StatusTab>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const backdropRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={backdropRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 'min(900px, 95vw)',
|
||||
maxHeight: '82vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'var(--bg-surface)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
<div
|
||||
style={{
|
||||
padding: '14px 16px 12px',
|
||||
borderBottom: '1px solid var(--stroke-default)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 700, marginBottom: '3px' }}>
|
||||
{meta.icon} {meta.title}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--fg-disabled)' }}>
|
||||
비교 분석 결과를 선택하세요 (다중 선택 가능)
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginTop: '2px' }}>
|
||||
<span style={{ fontSize: '12px', color: 'var(--fg-disabled)' }}>
|
||||
선택:{' '}
|
||||
<b style={{ color: meta.color }}>{checkedIds.size}건</b>
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--fg-disabled)',
|
||||
fontSize: '16px',
|
||||
lineHeight: 1,
|
||||
padding: '2px',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Filter bar ── */}
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid var(--stroke-default)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexShrink: 0,
|
||||
background: 'var(--bg-elevated)',
|
||||
}}
|
||||
>
|
||||
{/* Status tabs */}
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
{tabItems.map((tab) => {
|
||||
const isActive = statusTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setStatusTab(tab.id)}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: isActive ? 700 : 500,
|
||||
cursor: 'pointer',
|
||||
border: isActive ? `1px solid ${meta.color}` : '1px solid var(--stroke-default)',
|
||||
background: isActive ? `rgba(${type === 'rescue' ? '6,182,212' : '249,115,22'},0.1)` : 'var(--bg-surface)',
|
||||
color: isActive ? meta.color : 'var(--fg-disabled)',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="검색..."
|
||||
value={search}
|
||||
onChange={(e) => 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',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Table ── */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: 'var(--fg-disabled)', fontSize: '13px' }}>
|
||||
불러오는 중...
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
{type === 'oil' && (
|
||||
<>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...thStyle, width: 36 }} />
|
||||
<th style={{ ...thStyle, width: 64 }}>상태</th>
|
||||
<th style={{ ...thStyle, width: 'auto' }}>분석명</th>
|
||||
<th style={{ ...thStyle, width: 96 }}>유종</th>
|
||||
<th style={{ ...thStyle, width: 70 }}>유출량</th>
|
||||
<th style={{ ...thStyle, width: 160 }}>모델</th>
|
||||
<th style={{ ...thStyle, width: 130 }}>일시</th>
|
||||
<th style={{ ...thStyle, width: 72 }}>12h면적</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredOil.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} style={{ ...tdStyle, textAlign: 'center', color: 'var(--fg-disabled)', padding: '24px' }}>
|
||||
분석 결과가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredOil.map((p) => {
|
||||
const id = String(p.predRunSn ?? p.acdntSn);
|
||||
const checked = checkedIds.has(id);
|
||||
return (
|
||||
<tr
|
||||
key={id}
|
||||
onClick={() => 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)' : '';
|
||||
}}
|
||||
>
|
||||
<td style={{ ...tdStyle, textAlign: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleId(id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ accentColor: meta.color, cursor: 'pointer' }}
|
||||
/>
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<StatusBadge code={p.acdntSttsCd || 'active'} />
|
||||
</td>
|
||||
<td style={{ ...tdStyle, overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: 600 }}>
|
||||
{p.acdntNm || '-'}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, color: 'var(--color-warning)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{p.oilType || '-'}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, fontFamily: 'var(--font-mono)' }}>
|
||||
{p.volume != null ? `${p.volume} kL` : '-'}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, fontSize: '11px', color: 'var(--fg-muted)', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{getPredModels(p)}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, fontFamily: 'var(--font-mono)', fontSize: '11px' }}>
|
||||
{fmtDate(p.runDtm || p.analysisDate)}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, fontFamily: 'var(--font-mono)', color: 'var(--color-accent)' }}>
|
||||
-
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'hns' && (
|
||||
<>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...thStyle, width: 36 }} />
|
||||
<th style={{ ...thStyle, width: 64 }}>상태</th>
|
||||
<th style={{ ...thStyle, width: 'auto' }}>분석명</th>
|
||||
<th style={{ ...thStyle, width: 100 }}>물질</th>
|
||||
<th style={{ ...thStyle, width: 80 }}>최대농도</th>
|
||||
<th style={{ ...thStyle, width: 120 }}>모델</th>
|
||||
<th style={{ ...thStyle, width: 130 }}>일시</th>
|
||||
<th style={{ ...thStyle, width: 64 }}>IDLH</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredHns.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} style={{ ...tdStyle, textAlign: 'center', color: 'var(--fg-disabled)', padding: '24px' }}>
|
||||
분석 결과가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
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 (
|
||||
<tr
|
||||
key={id}
|
||||
onClick={() => 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)' : '';
|
||||
}}
|
||||
>
|
||||
<td style={{ ...tdStyle, textAlign: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleId(id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ accentColor: meta.color, cursor: 'pointer' }}
|
||||
/>
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<StatusBadge code={h.execSttsCd || 'active'} />
|
||||
</td>
|
||||
<td style={{ ...tdStyle, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: 600 }}>
|
||||
{h.anlysNm || '-'}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, color: 'var(--color-warning)', fontWeight: 600 }}>
|
||||
{h.sbstNm || '-'}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, fontFamily: 'var(--font-mono)', color: 'var(--color-accent)' }}>
|
||||
{maxConc !== '-' ? `${maxConc} ppm` : '-'}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, fontSize: '11px', color: 'var(--fg-muted)' }}>
|
||||
{h.algoCd || '-'}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, fontFamily: 'var(--font-mono)', fontSize: '11px' }}>
|
||||
{fmtDate(h.regDtm)}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, fontFamily: 'var(--font-mono)', color: 'var(--color-accent)' }}>
|
||||
{idlhDist !== '-' ? `${idlhDist} km` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'rescue' && (
|
||||
<>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...thStyle, width: 36 }} />
|
||||
<th style={{ ...thStyle, width: 64 }}>상태</th>
|
||||
<th style={{ ...thStyle, width: 'auto' }}>선박명 / 사고</th>
|
||||
<th style={{ ...thStyle, width: 100 }}>사고유형</th>
|
||||
<th style={{ ...thStyle, width: 64 }}>GM</th>
|
||||
<th style={{ ...thStyle, width: 64 }}>횡경사</th>
|
||||
<th style={{ ...thStyle, width: 130 }}>일시</th>
|
||||
<th style={{ ...thStyle, width: 56 }}>인명</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRescue.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} style={{ ...tdStyle, textAlign: 'center', color: 'var(--fg-disabled)', padding: '24px' }}>
|
||||
분석 결과가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
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 (
|
||||
<tr
|
||||
key={id}
|
||||
onClick={() => 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)' : '';
|
||||
}}
|
||||
>
|
||||
<td style={{ ...tdStyle, textAlign: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleId(id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ accentColor: meta.color, cursor: 'pointer' }}
|
||||
/>
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<StatusBadge code={r.sttsCd || 'active'} />
|
||||
</td>
|
||||
<td style={{ ...tdStyle, fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{r.vesselNm || '-'}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, fontSize: '11px' }}>
|
||||
{r.acdntTpCd || '-'}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, fontFamily: 'var(--font-mono)', color: 'var(--color-success)' }}>
|
||||
{r.gmM != null ? `${r.gmM}m` : '-'}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, fontFamily: 'var(--font-mono)', color: 'var(--color-warning)' }}>
|
||||
{r.listDeg != null ? `${r.listDeg}°` : '-'}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, fontFamily: 'var(--font-mono)', fontSize: '11px' }}>
|
||||
{fmtDate(r.regDtm)}
|
||||
</td>
|
||||
<td style={{
|
||||
...tdStyle,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontWeight: 600,
|
||||
color: r.missing != null && r.missing > 0 ? 'var(--color-danger)' : 'var(--color-success)',
|
||||
}}>
|
||||
{crewLabel}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</>
|
||||
)}
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid var(--stroke-default)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexShrink: 0,
|
||||
background: 'var(--bg-elevated)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '12px', color: 'var(--fg-disabled)' }}>
|
||||
선택한 분석 결과가 오른쪽 패널에 반영됩니다
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
background: 'var(--bg-surface)',
|
||||
color: 'var(--fg-disabled)',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
style={{
|
||||
padding: '6px 18px',
|
||||
borderRadius: '4px',
|
||||
border: `1px solid ${meta.color}`,
|
||||
background: `rgba(${type === 'rescue' ? '6,182,212' : '249,115,22'},0.15)`,
|
||||
color: meta.color,
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✓ 선택 적용
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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<string>,
|
||||
) => void;
|
||||
onSensitiveDataChange?: (
|
||||
geojson: SensitiveResourceFeatureCollection | null,
|
||||
checkedCategories: Set<string>,
|
||||
@ -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<PredictionAnalysis[]>([]);
|
||||
const [checkedPredIds, setCheckedPredIds] = useState<Set<string>>(new Set());
|
||||
const [hnsItems, setHnsItems] = useState<HnsAnalysisItem[]>([]);
|
||||
const [checkedHnsIds, setCheckedHnsIds] = useState<Set<string>>(new Set());
|
||||
const [rescueItems, setRescueItems] = useState<RescueOpsItem[]>([]);
|
||||
const [checkedRescueIds, setCheckedRescueIds] = useState<Set<string>>(new Set());
|
||||
const [sensCategories, setSensCategories] = useState<SensitiveResourceCategory[]>([]);
|
||||
const [checkedSensCategories, setCheckedSensCategories] = useState<Set<string>>(new Set());
|
||||
const [sensitiveGeojson, setSensitiveGeojson] =
|
||||
useState<SensitiveResourceFeatureCollection | null>(null);
|
||||
const [sensByAcdntSn, setSensByAcdntSn] = useState<
|
||||
Map<number, { categories: SensitiveResourceCategory[]; geojson: SensitiveResourceFeatureCollection }>
|
||||
>(new Map());
|
||||
const knownSensCatsRef = useRef<Set<string>>(new Set());
|
||||
const [nearbyRadius, setNearbyRadius] = useState(50);
|
||||
const [nearbyOrgs, setNearbyOrgs] = useState<NearbyOrgItem[]>([]);
|
||||
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<number>([
|
||||
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<string, SensitiveResourceCategory>();
|
||||
const allFeatures: SensitiveResourceFeature[] = [];
|
||||
const seenSrIds = new Set<number>();
|
||||
|
||||
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<string, unknown>)?.['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<string>();
|
||||
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 (
|
||||
<div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-full h-full">
|
||||
@ -338,6 +657,7 @@ export function IncidentsRightPanel({
|
||||
</div>
|
||||
<button
|
||||
className="text-caption font-semibold cursor-pointer"
|
||||
onClick={() => setModalType('oil')}
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
borderRadius: '4px',
|
||||
@ -395,32 +715,77 @@ export function IncidentsRightPanel({
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* HNS 대기확산 / 긴급구난 섹션 (미개발 - 구조 유지) */}
|
||||
{STATIC_SECTIONS.map((sec) => (
|
||||
<div key={sec.key} className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* <span className="text-body-2">{sec.icon}</span> */}
|
||||
<span className="text-caption">{sec.title}</span>
|
||||
{/* HNS 대기확산 / 긴급구난 섹션 */}
|
||||
{[
|
||||
{ sec: hnsSection, modalKey: 'hns' as const, onToggle: toggleHnsItem, onRemove: removeHnsItem },
|
||||
{ sec: rescueSection, modalKey: 'rescue' as const, onToggle: toggleRescueItem, onRemove: removeRescueItem },
|
||||
].map(({ sec, modalKey, onToggle, onRemove }) => {
|
||||
const checkedCount = sec.items.filter((it) => it.checked).length;
|
||||
return (
|
||||
<div key={sec.key} className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-caption">{sec.title}</span>
|
||||
</div>
|
||||
<button
|
||||
className="text-caption font-semibold cursor-pointer"
|
||||
onClick={() => setModalType(modalKey)}
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
color: sec.color,
|
||||
}}
|
||||
>
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{sec.items.length === 0 ? (
|
||||
<div className="text-caption text-fg-disabled text-center py-1.5">
|
||||
예측 실행 이력이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
sec.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-1.5"
|
||||
style={{
|
||||
padding: '5px 8px',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onChange={() => onToggle(item.id)}
|
||||
className="shrink-0"
|
||||
style={{ accentColor: sec.color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-caption font-semibold whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="text-fg-disabled font-mono text-caption">{item.sub}</div>
|
||||
</div>
|
||||
<span
|
||||
onClick={() => onRemove(item.id)}
|
||||
title="제거"
|
||||
className="text-caption cursor-pointer text-fg-disabled shrink-0"
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-caption text-fg-disabled">
|
||||
선택: <b style={{ color: sec.color }}>{checkedCount}건</b> · {sec.totalLabel}
|
||||
</div>
|
||||
<button
|
||||
className="text-caption font-semibold cursor-pointer"
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
color: sec.color,
|
||||
}}
|
||||
>
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-caption text-fg-disabled text-center py-1.5">준비 중입니다</div>
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-caption text-fg-disabled">
|
||||
선택: <b style={{ color: sec.color }}>0건</b> · 전체 0건
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 민감자원 */}
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
||||
@ -552,6 +917,23 @@ export function IncidentsRightPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 분석 목록 선택 모달 */}
|
||||
{modalType && (
|
||||
<AnalysisSelectModal
|
||||
type={modalType}
|
||||
isOpen={true}
|
||||
onClose={() => setModalType(null)}
|
||||
initialSelectedIds={
|
||||
modalType === 'oil'
|
||||
? checkedPredIds
|
||||
: modalType === 'hns'
|
||||
? checkedHnsIds
|
||||
: checkedRescueIds
|
||||
}
|
||||
onApply={handleModalApply}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-col gap-1.5 p-2.5 border-t border-stroke shrink-0">
|
||||
{/* 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);
|
||||
}}
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -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<string, 'active' | 'investigating' | 'closed'> = {
|
||||
@ -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,
|
||||
};
|
||||
|
||||
242
frontend/src/components/incidents/utils/hnsDispersionLayers.ts
Normal file
242
frontend/src/components/incidents/utils/hnsDispersionLayers.ts
Normal file
@ -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<string, unknown>): 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<string, unknown>,
|
||||
tox: ReturnType<typeof getSubstanceToxicity>,
|
||||
): 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<string, unknown> | undefined;
|
||||
const weather = rslt.weather as Record<string, unknown> | 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;
|
||||
}
|
||||
@ -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 분석 — 재계산 모달 입력 파라미터 */
|
||||
|
||||
@ -199,7 +199,46 @@ export interface TrajectoryResponse {
|
||||
stepSummariesByModel?: Record<string, SimulationSummary[]>;
|
||||
}
|
||||
|
||||
|
||||
/** 민감자원 — 카테고리별 집계 응답 (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<string, OilSpillSummary>;
|
||||
}
|
||||
|
||||
export const fetchAnalysisTrajectory = async (
|
||||
acdntSn: number,
|
||||
predRunSn?: number,
|
||||
): Promise<TrajectoryResponse> => {
|
||||
const response = await api.get<TrajectoryResponse>(
|
||||
`/prediction/analyses/${acdntSn}/trajectory`,
|
||||
predRunSn != null ? { params: { predRunSn } } : undefined,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const fetchOilSpillSummary = async (
|
||||
acdntSn: number,
|
||||
predRunSn?: number,
|
||||
): Promise<OilSpillSummaryResponse> => {
|
||||
const response = await api.get<OilSpillSummaryResponse>(
|
||||
`/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;
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user