Merge pull request 'feat(incidents): 통합 분석 패널 분할 뷰 및 유출유 확산 요약 API 추가' (#177) from feature/integrated-analysis-split-view into develop

This commit is contained in:
jhkang 2026-04-16 15:27:25 +09:00
커밋 28544d5c8f
20개의 변경된 파일2576개의 추가작업 그리고 380개의 파일을 삭제

파일 보기

@ -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 ');

파일 보기

@ -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;
-- ============================================================

파일 보기

@ -4,6 +4,13 @@
## [Unreleased]
### 추가
- 사건사고: 통합 분석 패널 HNS/구난 연동 및 사고 목록을 wing.ACDNT 기반으로 전환
- 사건사고: 통합 분석 패널 분할 뷰 및 이전 분석 결과 비교 표출 + 분석 선택 모달 추가
- 확산예측: 유출유 확산 요약 API 신규 (`/analyses/:acdntSn/oil-summary`, primary + byModel)
- HNS: 분석 생성 시 `acdntSn` 연결 지원
- GSC: 사고 목록 응답에 `acdntSn` 노출 및 민감자원 누적 카테고리 관리 + HNS 확산 레이어 유틸 추가
## [2026-04-15]
### 추가

파일 보기

@ -31,6 +31,8 @@ export interface HNSInputParams {
predictionTime: string;
/** 사고명 (직접 입력 또는 사고 리스트 선택) */
accidentName: string;
/** wing.ACDNT 사고번호 (사고 리스트에서 선택된 경우) */
selectedAcdntSn?: number;
}
interface HNSLeftPanelProps {
@ -72,6 +74,7 @@ export function HNSLeftPanel({
}: HNSLeftPanelProps) {
const [incidents, setIncidents] = useState<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] }));
@ -150,6 +153,7 @@ export function HNSLeftPanel({
setSelectedIncidentSn(mngNo);
const incident = incidents.find((i) => i.acdntMngNo === mngNo);
if (!incident) return;
setSelectedAcdntSn(incident.acdntSn);
setAccidentName(incident.pollNm);
if (incident.pollDate) {
@ -181,6 +185,7 @@ export function HNSLeftPanel({
accidentTime,
predictionTime,
accidentName,
selectedAcdntSn,
});
}
}, [
@ -202,10 +207,12 @@ export function HNSLeftPanel({
accidentTime,
predictionTime,
accidentName,
selectedAcdntSn,
]);
const handleReset = () => {
setSelectedIncidentSn('');
setSelectedAcdntSn(undefined);
setAccidentName('');
const now = new Date();
setAccidentDate(now.toISOString().slice(0, 10));

파일 보기

@ -529,6 +529,7 @@ export function HNSView() {
: params?.accidentDate || undefined;
const result = await createHnsAnalysis({
anlysNm: params?.accidentName || `HNS 분석 ${new Date().toLocaleDateString('ko-KR')}`,
acdntSn: params?.selectedAcdntSn,
acdntDtm,
lon: incidentCoord.lon,
lat: incidentCoord.lat,

파일 보기

@ -6,6 +6,7 @@ import { api } from '@common/services/api';
export interface HnsAnalysisItem {
hnsAnlysSn: number;
acdntSn: number | null;
anlysNm: string;
acdntDtm: string | null;
locNm: string | null;
@ -28,6 +29,7 @@ export interface HnsAnalysisItem {
export interface CreateHnsAnalysisInput {
anlysNm: string;
acdntSn?: number;
acdntDtm?: string;
locNm?: string;
lon?: number;
@ -50,6 +52,7 @@ 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 './IncidentsLeftPanel';
import { AnalysisSelectModal } from './AnalysisSelectModal';
import type { AnalysisApplyPayload } from './AnalysisSelectModal';
import {
fetchPredictionAnalyses,
fetchSensitiveResources,
@ -8,10 +10,15 @@ import {
import type {
PredictionAnalysis,
SensitiveResourceCategory,
SensitiveResourceFeature,
SensitiveResourceFeatureCollection,
} from '@tabs/prediction/services/predictionApi';
import { fetchNearbyOrgs } from '../services/incidentsApi';
import type { NearbyOrgItem } from '../services/incidentsApi';
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 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

파일 보기

@ -25,6 +25,8 @@ export interface IncidentListItem {
spilUnitCd: string | null;
fcstHr: number | null;
hasPredCompleted: boolean;
hasHnsCompleted: boolean;
hasRescueCompleted: boolean;
mediaCnt: number;
hasImgAnalysis: boolean;
}
@ -112,7 +114,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,
};

파일 보기

@ -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;
}

파일 보기

@ -218,6 +218,21 @@ export interface TrajectoryResponse {
stepSummariesByModel?: Record<string, SimulationSummary[]>;
}
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,
@ -229,6 +244,17 @@ export const fetchAnalysisTrajectory = async (
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;
@ -327,6 +353,7 @@ export const analyzeImage = async (file: File, acdntNm?: string): Promise<ImageA
// ============================================================
export interface GscAccidentListItem {
acdntSn: number;
acdntMngNo: string;
pollNm: string;
pollDate: string | null;

파일 보기

@ -56,6 +56,7 @@ export async function fetchRescueOps(params?: {
sttsCd?: string;
acdntTpCd?: string;
search?: string;
acdntSn?: number;
}): Promise<RescueOpsItem[]> {
const response = await api.get<RescueOpsItem[]>('/rescue/ops', { params });
return response.data;