feat(incidents): 통합 분석 패널 분할 뷰 및 유출유 확산 요약 API 추가

- Incidents 통합 분석 시 이전 분석 결과를 분할 화면으로 표출
- 유출유/HNS/구난 분석 선택 모달(AnalysisSelectModal) 추가
- prediction /analyses/:acdntSn/oil-summary API 신규 (primary + byModel)
- HNS 분석 생성 시 acdntSn 연결 지원
- GSC 사고 목록 응답에 acdntSn 노출
- 민감자원 누적/카테고리 관리 및 HNS 확산 레이어 유틸(hnsDispersionLayers) 추가
This commit is contained in:
jeonghyo.k 2026-04-16 15:24:06 +09:00
부모 1f66723060
커밋 1da2553694
13개의 변경된 파일2150개의 추가작업 그리고 310개의 파일을 삭제

파일 보기

@ -1,6 +1,7 @@
import { wingPool } from '../db/wingDb.js';
export interface GscAccidentListItem {
acdntSn: number;
acdntMngNo: string;
pollNm: string;
pollDate: string | null;
@ -11,6 +12,7 @@ export interface GscAccidentListItem {
export async function listGscAccidents(limit = 20): Promise<GscAccidentListItem[]> {
const sql = `
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",
@ -23,6 +25,7 @@ export async function listGscAccidents(limit = 20): Promise<GscAccidentListItem[
`;
const result = await wingPool.query<{
acdntSn: number;
acdntMngNo: string;
pollNm: string;
pollDate: string | null;
@ -31,6 +34,7 @@ export async function listGscAccidents(limit = 20): Promise<GscAccidentListItem[
}>(sql, [limit]);
return result.rows.map((row) => ({
acdntSn: row.acdntSn,
acdntMngNo: row.acdntMngNo,
pollNm: row.pollNm,
pollDate: row.pollDate,

파일 보기

@ -50,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) {

파일 보기

@ -201,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
@ -220,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,

파일 보기

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

파일 보기

@ -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,

파일 보기

@ -29,6 +29,7 @@ export interface HnsAnalysisItem {
export interface CreateHnsAnalysisInput {
anlysNm: string;
acdntSn?: number;
acdntDtm?: string;
locNm?: string;
lon?: number;

파일 보기

@ -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,6 +10,7 @@ import {
import type {
PredictionAnalysis,
SensitiveResourceCategory,
SensitiveResourceFeature,
SensitiveResourceFeatureCollection,
} from '@tabs/prediction/services/predictionApi';
import { fetchNearbyOrgs } from '../services/incidentsApi';
@ -45,6 +48,13 @@ interface IncidentsRightPanelProps {
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>,
@ -153,6 +163,10 @@ export function IncidentsRightPanel({
onCheckedPredsChange,
onCheckedHnsChange,
onCheckedRescueChange,
onCheckedPredItemsChange,
onCheckedHnsItemsChange,
onCheckedRescueItemsChange,
onSensitiveCategoriesChange,
onSensitiveDataChange,
selectedVessel,
}: IncidentsRightPanelProps) {
@ -166,9 +180,14 @@ export function IncidentsRightPanel({
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) {
@ -178,6 +197,8 @@ export function IncidentsRightPanel({
setRescueItems([]);
setSensCategories([]);
setSensitiveGeojson(null);
setSensByAcdntSn(new Map());
knownSensCatsRef.current = new Set();
onCheckedPredsChange?.([]);
onCheckedHnsChange?.([]);
onCheckedRescueChange?.([]);
@ -229,22 +250,9 @@ export function IncidentsRightPanel({
);
})
.catch(() => setRescueItems([]));
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),
);
})
.catch(() => {
setSensCategories([]);
setSensitiveGeojson(null);
});
// 민감자원 캐시 초기화 (새 사고 선택 시 기존 캐시 제거)
knownSensCatsRef.current = new Set();
void Promise.resolve().then(() => setSensByAcdntSn(new Map()));
}, [incident?.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
@ -262,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);
@ -455,6 +577,45 @@ export function IncidentsRightPanel({
}),
};
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">
@ -496,6 +657,7 @@ export function IncidentsRightPanel({
</div>
<button
className="text-caption font-semibold cursor-pointer"
onClick={() => setModalType('oil')}
style={{
padding: '3px 10px',
borderRadius: '4px',
@ -555,9 +717,9 @@ export function IncidentsRightPanel({
{/* HNS 대기확산 / 긴급구난 섹션 */}
{[
{ sec: hnsSection, onToggle: toggleHnsItem, onRemove: removeHnsItem },
{ sec: rescueSection, onToggle: toggleRescueItem, onRemove: removeRescueItem },
].map(({ sec, onToggle, onRemove }) => {
{ 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">
@ -567,6 +729,7 @@ export function IncidentsRightPanel({
</div>
<button
className="text-caption font-semibold cursor-pointer"
onClick={() => setModalType(modalKey)}
style={{
padding: '3px 10px',
borderRadius: '4px',
@ -754,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 */}
@ -794,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

파일 보기

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