release: 2026-04-13 (8�� Ŀ��) #167

병합
jhkang develop 에서 main 로 8 commits 를 머지했습니다 2026-04-13 16:53:18 +09:00
22개의 변경된 파일733개의 추가작업 그리고 215개의 파일을 삭제

파일 보기

@ -7,6 +7,7 @@ import {
getIncidentWeather, getIncidentWeather,
saveIncidentWeather, saveIncidentWeather,
getIncidentMedia, getIncidentMedia,
getIncidentImageAnalysis,
} from './incidentsService.js'; } from './incidentsService.js';
const router = Router(); const router = Router();
@ -133,4 +134,26 @@ router.get('/:sn/media', requireAuth, async (req, res) => {
} }
}); });
// ============================================================
// GET /api/incidents/:sn/image-analysis — 이미지 분석 데이터
// ============================================================
router.get('/:sn/image-analysis', requireAuth, async (req, res) => {
try {
const sn = parseInt(req.params.sn as string, 10);
if (isNaN(sn)) {
res.status(400).json({ error: '유효하지 않은 사고 번호입니다.' });
return;
}
const data = await getIncidentImageAnalysis(sn);
if (!data) {
res.status(404).json({ error: '이미지 분석 데이터가 없습니다.' });
return;
}
res.json(data);
} catch (err) {
console.error('[incidents] 이미지 분석 데이터 조회 오류:', err);
res.status(500).json({ error: '이미지 분석 데이터 조회 중 오류가 발생했습니다.' });
}
});
export default router; export default router;

파일 보기

@ -24,7 +24,9 @@ interface IncidentListItem {
spilQty: number | null; spilQty: number | null;
spilUnitCd: string | null; spilUnitCd: string | null;
fcstHr: number | null; fcstHr: number | null;
hasPredCompleted: boolean;
mediaCnt: number; mediaCnt: number;
hasImgAnalysis: boolean;
} }
interface PredExecItem { interface PredExecItem {
@ -111,11 +113,17 @@ export async function listIncidents(filters: {
a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM, a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM,
a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM, a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM,
s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR, s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR,
COALESCE(s.HAS_IMG_ANALYSIS, FALSE) AS has_img_analysis,
EXISTS (
SELECT 1 FROM wing.PRED_EXEC pe
WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED'
) AS has_pred_completed,
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0) COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt + COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
FROM wing.ACDNT a FROM wing.ACDNT a
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR,
IMG_RSLT_DATA IS NOT NULL AS HAS_IMG_ANALYSIS
FROM wing.SPIL_DATA FROM wing.SPIL_DATA
WHERE ACDNT_SN = a.ACDNT_SN WHERE ACDNT_SN = a.ACDNT_SN
ORDER BY SPIL_DATA_SN ORDER BY SPIL_DATA_SN
@ -148,7 +156,9 @@ export async function listIncidents(filters: {
spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null, spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null,
spilUnitCd: (r.spil_unit_cd as string) ?? null, spilUnitCd: (r.spil_unit_cd as string) ?? null,
fcstHr: (r.fcst_hr as number) ?? null, fcstHr: (r.fcst_hr as number) ?? null,
hasPredCompleted: r.has_pred_completed as boolean,
mediaCnt: Number(r.media_cnt), mediaCnt: Number(r.media_cnt),
hasImgAnalysis: (r.has_img_analysis as boolean) ?? false,
})); }));
} }
@ -162,11 +172,17 @@ export async function getIncident(acdntSn: number): Promise<IncidentDetail | nul
a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM, a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM,
a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM, a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM,
s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR, s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR,
COALESCE(s.HAS_IMG_ANALYSIS, FALSE) AS has_img_analysis,
EXISTS (
SELECT 1 FROM wing.PRED_EXEC pe
WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED'
) AS has_pred_completed,
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0) COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt + COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
FROM wing.ACDNT a FROM wing.ACDNT a
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR,
IMG_RSLT_DATA IS NOT NULL AS HAS_IMG_ANALYSIS
FROM wing.SPIL_DATA FROM wing.SPIL_DATA
WHERE ACDNT_SN = a.ACDNT_SN WHERE ACDNT_SN = a.ACDNT_SN
ORDER BY SPIL_DATA_SN ORDER BY SPIL_DATA_SN
@ -205,7 +221,9 @@ export async function getIncident(acdntSn: number): Promise<IncidentDetail | nul
spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null, spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null,
spilUnitCd: (r.spil_unit_cd as string) ?? null, spilUnitCd: (r.spil_unit_cd as string) ?? null,
fcstHr: (r.fcst_hr as number) ?? null, fcstHr: (r.fcst_hr as number) ?? null,
hasPredCompleted: r.has_pred_completed as boolean,
mediaCnt: Number(r.media_cnt), mediaCnt: Number(r.media_cnt),
hasImgAnalysis: (r.has_img_analysis as boolean) ?? false,
predictions, predictions,
weather, weather,
media, media,
@ -419,3 +437,21 @@ export async function getIncidentMedia(acdntSn: number): Promise<MediaInfo | nul
cctvMeta: (r.cctv_meta as Record<string, unknown>) ?? null, cctvMeta: (r.cctv_meta as Record<string, unknown>) ?? null,
}; };
} }
// ============================================================
// 이미지 분석 데이터 조회
// ============================================================
export async function getIncidentImageAnalysis(acdntSn: number): Promise<Record<string, unknown> | null> {
const sql = `
SELECT IMG_RSLT_DATA
FROM wing.SPIL_DATA
WHERE ACDNT_SN = $1 AND IMG_RSLT_DATA IS NOT NULL
ORDER BY SPIL_DATA_SN
LIMIT 1
`;
const { rows } = await wingPool.query(sql, [acdntSn]);
if (rows.length === 0) return null;
return (rows[0] as Record<string, unknown>).img_rslt_data as Record<string, unknown>;
}

파일 보기

@ -74,7 +74,7 @@ function parseMeta(metaStr: string): { lat: number; lon: number; occurredAt: str
return { lat, lon, occurredAt }; return { lat, lon, occurredAt };
} }
export async function analyzeImageFile(imageBuffer: Buffer, originalName: string): Promise<ImageAnalyzeResult> { export async function analyzeImageFile(imageBuffer: Buffer, originalName: string, acdntNmOverride?: string): Promise<ImageAnalyzeResult> {
const fileId = crypto.randomUUID(); const fileId = crypto.randomUUID();
// camTy는 현재 "mx15hdi"로 하드코딩한다. // camTy는 현재 "mx15hdi"로 하드코딩한다.
@ -122,7 +122,7 @@ export async function analyzeImageFile(imageBuffer: Buffer, originalName: string
const volume = firstOil?.volume ?? 0; const volume = firstOil?.volume ?? 0;
// ACDNT INSERT // ACDNT INSERT
const acdntNm = `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`; const acdntNm = acdntNmOverride?.trim() || `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
const acdntRes = await wingPool.query( const acdntRes = await wingPool.query(
`INSERT INTO wing.ACDNT `INSERT INTO wing.ACDNT
(ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM) (ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM)
@ -145,7 +145,7 @@ export async function analyzeImageFile(imageBuffer: Buffer, originalName: string
await wingPool.query( await wingPool.query(
`INSERT INTO wing.SPIL_DATA `INSERT INTO wing.SPIL_DATA
(ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, IMG_RSLT_DATA, REG_DTM) (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, IMG_RSLT_DATA, REG_DTM)
VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 48, $4, NOW())`, VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 6, $4, NOW())`,
[ [
acdntSn, acdntSn,
OIL_DB_CODE_MAP[oilType] ?? 'BUNKER_C', OIL_DB_CODE_MAP[oilType] ?? 'BUNKER_C',

파일 보기

@ -230,7 +230,8 @@ router.post(
res.status(400).json({ error: '이미지 파일이 필요합니다' }); res.status(400).json({ error: '이미지 파일이 필요합니다' });
return; return;
} }
const result = await analyzeImageFile(req.file.buffer, req.file.originalname); const acdntNm = typeof req.body?.acdntNm === 'string' ? req.body.acdntNm : undefined;
const result = await analyzeImageFile(req.file.buffer, req.file.originalname, acdntNm);
res.json(result); res.json(result);
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error) { if (err instanceof Error) {

파일 보기

@ -20,9 +20,9 @@ const POLL_TIMEOUT_MS = 30 * 60 * 1000 // 30분
const OIL_TYPE_MAP: Record<string, string> = { const OIL_TYPE_MAP: Record<string, string> = {
'벙커C유': 'GENERIC BUNKER C', '벙커C유': 'GENERIC BUNKER C',
'경유': 'GENERIC DIESEL', '경유': 'GENERIC DIESEL',
'원유': 'WEST TEXAS INTERMEDIATE (WTI)', '원유': 'WEST TEXAS INTERMEDIATE',
'중유': 'GENERIC HEAVY FUEL OIL', '중유': 'GENERIC HEAVY FUEL OIL',
'등유': 'FUEL OIL NO.1 (KEROSENE)', '등유': 'FUEL OIL NO.1 (KEROSENE) ',
'휘발유': 'GENERIC GASOLINE', '휘발유': 'GENERIC GASOLINE',
} }

파일 보기

@ -293,7 +293,7 @@ CREATE TABLE SPIL_DATA (
SPIL_DATA_SN SERIAL NOT NULL, -- 유출정보순번 SPIL_DATA_SN SERIAL NOT NULL, -- 유출정보순번
ACDNT_SN INTEGER NOT NULL, -- 사고순번 ACDNT_SN INTEGER NOT NULL, -- 사고순번
OIL_TP_CD VARCHAR(50) NOT NULL, -- 유종코드 OIL_TP_CD VARCHAR(50) NOT NULL, -- 유종코드
SPIL_QTY NUMERIC(12,2), -- 유출량 SPIL_QTY NUMERIC(14,10), -- 유출량
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', -- 유출단위코드 SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', -- 유출단위코드
SPIL_TP_CD VARCHAR(20), -- 유출유형코드 SPIL_TP_CD VARCHAR(20), -- 유출유형코드
SPIL_LOC_GEOM GEOMETRY(Point, 4326), -- 유출위치지오메트리 SPIL_LOC_GEOM GEOMETRY(Point, 4326), -- 유출위치지오메트리

파일 보기

@ -40,7 +40,7 @@ CREATE TABLE IF NOT EXISTS SPIL_DATA (
SPIL_DATA_SN SERIAL NOT NULL, SPIL_DATA_SN SERIAL NOT NULL,
ACDNT_SN INTEGER NOT NULL, ACDNT_SN INTEGER NOT NULL,
OIL_TP_CD VARCHAR(50) NOT NULL, OIL_TP_CD VARCHAR(50) NOT NULL,
SPIL_QTY NUMERIC(12,2), SPIL_QTY NUMERIC(14,10),
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL',
SPIL_TP_CD VARCHAR(20), SPIL_TP_CD VARCHAR(20),
FCST_HR INTEGER, FCST_HR INTEGER,

파일 보기

@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS HNS_ANALYSIS (
SBST_NM VARCHAR(100), SBST_NM VARCHAR(100),
UN_NO VARCHAR(10), UN_NO VARCHAR(10),
CAS_NO VARCHAR(20), CAS_NO VARCHAR(20),
SPIL_QTY NUMERIC(10,2), SPIL_QTY NUMERIC(14,10),
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL',
SPIL_TP_CD VARCHAR(20), SPIL_TP_CD VARCHAR(20),
FCST_HR INTEGER, FCST_HR INTEGER,

파일 보기

@ -0,0 +1,7 @@
-- 031: 유출량(SPIL_QTY) 소수점 정밀도 확대
-- 이미지 분석 결과로 1e-7 수준의 매우 작은 유출량을 저장할 수 있도록
-- NUMERIC(12,2) / NUMERIC(10,2) → NUMERIC(14,10) 으로 변경
-- 정수부 최대 4자리, 소수부 10자리
ALTER TABLE wing.SPIL_DATA ALTER COLUMN SPIL_QTY TYPE NUMERIC(14,10);
ALTER TABLE wing.HNS_ANALY ALTER COLUMN SPIL_QTY TYPE NUMERIC(14,10);

파일 보기

@ -4,9 +4,26 @@
## [Unreleased] ## [Unreleased]
## [2026-04-13]
### 추가
- 사고별 이미지 분석 데이터 조회 API 추가
- 사고 리스트에 항공 미디어 연동 및 이미지 분석 뱃지 표시
- 사고 마커 클릭 팝업 디자인 리뉴얼
- 지도에 필터링된 사고만 표시되도록 개선
### 변경
- 이미지 분석 시 사고명 파라미터 지원
- 기본 예측시간 48시간 → 6시간으로 변경
- 유출량(SPIL_QTY) 정밀도 NUMERIC(14,10)으로 확대
- OpenDrift 유종 매핑 수정 (원유, 등유)
- 소량 유출량 과학적 표기법으로 표시
## [2026-04-09] ## [2026-04-09]
### 추가 ### 추가
- HNS 확산 파티클 렌더링 성능 최적화 (TypedArray + 수동 Mercator 투영 + 페이드 트레일)
- 오염 종합 상황/확산 예측 요약 위험도 뱃지 동적 표시 (심각/경계/주의/관심 4단계)
- 디자인 시스템 Float 카탈로그 추가 (Modal / Dropdown / Overlay / Toast) - 디자인 시스템 Float 카탈로그 추가 (Modal / Dropdown / Overlay / Toast)
- 디자인 시스템 폰트/색상 토큰을 전 탭 컴포넌트에 전면 적용 (admin, aerial, assets, board, hns, incidents, prediction, reports, rescue, scat, weather) - 디자인 시스템 폰트/색상 토큰을 전 탭 컴포넌트에 전면 적용 (admin, aerial, assets, board, hns, incidents, prediction, reports, rescue, scat, weather)
- SR 민감자원 벡터타일 오버레이 컴포넌트 및 백엔드 프록시 엔드포인트 추가 - SR 민감자원 벡터타일 오버레이 컴포넌트 및 백엔드 프록시 엔드포인트 추가

파일 보기

@ -1,7 +1,6 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useMap } from '@vis.gl/react-maplibre'; import { useMap } from '@vis.gl/react-maplibre';
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi'; import type { HydrDataStep } from '@tabs/prediction/services/predictionApi';
import { useThemeStore } from '@common/store/themeStore';
interface HydrParticleOverlayProps { interface HydrParticleOverlayProps {
hydrStep: HydrDataStep | null; hydrStep: HydrDataStep | null;
@ -9,24 +8,13 @@ interface HydrParticleOverlayProps {
const PARTICLE_COUNT = 3000; const PARTICLE_COUNT = 3000;
const MAX_AGE = 300; const MAX_AGE = 300;
const SPEED_SCALE = 0.1; const SPEED_SCALE = 0.15;
const DT = 600; const DT = 600;
const TRAIL_LENGTH = 30; // 파티클당 저장할 화면 좌표 수 const DEG_TO_RAD = Math.PI / 180;
const NUM_ALPHA_BANDS = 4; // stroke 배치 단위 const PI_4 = Math.PI / 4;
const FADE_ALPHA = 0.02; // 프레임당 페이드량 (낮을수록 긴 꼬리)
interface TrailPoint {
x: number;
y: number;
}
interface Particle {
lon: number;
lat: number;
trail: TrailPoint[];
age: number;
}
export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayProps) { export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayProps) {
const lightMode = useThemeStore((s) => s.theme) === 'light';
const { current: map } = useMap(); const { current: map } = useMap();
const animRef = useRef<number>(); const animRef = useRef<number>();
@ -52,21 +40,21 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro
const lats: number[] = [boundLonLat.bottom]; const lats: number[] = [boundLonLat.bottom];
for (const d of latInterval) lats.push(lats[lats.length - 1] + d); for (const d of latInterval) lats.push(lats[lats.length - 1] + d);
function bisect(arr: number[], val: number): number {
let lo = 0,
hi = arr.length - 2;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (val < arr[mid]) hi = mid - 1;
else if (val >= arr[mid + 1]) lo = mid + 1;
else return mid;
}
return -1;
}
function getUV(lon: number, lat: number): [number, number] { function getUV(lon: number, lat: number): [number, number] {
let col = -1, const col = bisect(lons, lon);
row = -1; const row = bisect(lats, lat);
for (let i = 0; i < lons.length - 1; i++) {
if (lon >= lons[i] && lon < lons[i + 1]) {
col = i;
break;
}
}
for (let i = 0; i < lats.length - 1; i++) {
if (lat >= lats[i] && lat < lats[i + 1]) {
row = i;
break;
}
}
if (col < 0 || row < 0) return [0, 0]; if (col < 0 || row < 0) return [0, 0];
const fx = (lon - lons[col]) / (lons[col + 1] - lons[col]); const fx = (lon - lons[col]) / (lons[col + 1] - lons[col]);
const fy = (lat - lats[row]) / (lats[row + 1] - lats[row]); const fy = (lat - lats[row]) / (lats[row + 1] - lats[row]);
@ -78,96 +66,134 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro
v01 = v2d[row]?.[col + 1] ?? v00; v01 = v2d[row]?.[col + 1] ?? v00;
const v10 = v2d[row + 1]?.[col] ?? v00, const v10 = v2d[row + 1]?.[col] ?? v00,
v11 = v2d[row + 1]?.[col + 1] ?? v00; v11 = v2d[row + 1]?.[col + 1] ?? v00;
const u = const _1fx = 1 - fx,
u00 * (1 - fx) * (1 - fy) + u01 * fx * (1 - fy) + u10 * (1 - fx) * fy + u11 * fx * fy; _1fy = 1 - fy;
const v = return [
v00 * (1 - fx) * (1 - fy) + v01 * fx * (1 - fy) + v10 * (1 - fx) * fy + v11 * fx * fy; u00 * _1fx * _1fy + u01 * fx * _1fy + u10 * _1fx * fy + u11 * fx * fy,
return [u, v]; v00 * _1fx * _1fy + v01 * fx * _1fy + v10 * _1fx * fy + v11 * fx * fy,
];
} }
const bbox = boundLonLat; const bbox = boundLonLat;
const particles: Particle[] = Array.from({ length: PARTICLE_COUNT }, () => ({ const bboxW = bbox.right - bbox.left;
lon: bbox.left + Math.random() * (bbox.right - bbox.left), const bboxH = bbox.top - bbox.bottom;
lat: bbox.bottom + Math.random() * (bbox.top - bbox.bottom),
trail: [],
age: Math.floor(Math.random() * MAX_AGE),
}));
function resetParticle(p: Particle) { // 파티클: 위치 + 이전 화면좌표 (선분 1개만 그리면 됨)
p.lon = bbox.left + Math.random() * (bbox.right - bbox.left); const pLon = new Float64Array(PARTICLE_COUNT);
p.lat = bbox.bottom + Math.random() * (bbox.top - bbox.bottom); const pLat = new Float64Array(PARTICLE_COUNT);
p.trail = []; const pAge = new Int32Array(PARTICLE_COUNT);
p.age = 0; const pPrevX = new Float32Array(PARTICLE_COUNT); // 이전 프레임 화면 X
const pPrevY = new Float32Array(PARTICLE_COUNT); // 이전 프레임 화면 Y
const pHasPrev = new Uint8Array(PARTICLE_COUNT); // 이전 좌표 유효 여부
for (let i = 0; i < PARTICLE_COUNT; i++) {
pLon[i] = bbox.left + Math.random() * bboxW;
pLat[i] = bbox.bottom + Math.random() * bboxH;
pAge[i] = Math.floor(Math.random() * MAX_AGE);
pHasPrev[i] = 0;
} }
// 지도 이동/줌 시 화면 좌표가 틀어지므로 trail 초기화 function resetParticle(i: number) {
pLon[i] = bbox.left + Math.random() * bboxW;
pLat[i] = bbox.bottom + Math.random() * bboxH;
pAge[i] = 0;
pHasPrev[i] = 0;
}
// Mercator 수동 투영
function lngToMercX(lng: number, worldSize: number): number {
return ((lng + 180) / 360) * worldSize;
}
function latToMercY(lat: number, worldSize: number): number {
return ((1 - Math.log(Math.tan(PI_4 + (lat * DEG_TO_RAD) / 2)) / Math.PI) / 2) * worldSize;
}
// 지도 이동 시 캔버스 초기화 + 이전 좌표 무효화
const onMove = () => { const onMove = () => {
for (const p of particles) p.trail = []; ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < PARTICLE_COUNT; i++) pHasPrev[i] = 0;
}; };
map.on('move', onMove); map.on('move', onMove);
function animate() { function animate() {
// 매 프레임 완전 초기화 → 잔상 없음 const w = canvas.width;
ctx.clearRect(0, 0, canvas.width, canvas.height); const h = canvas.height;
// alpha band별 세그먼트 버퍼 (드로우 콜 최소화) // ── 페이드: 기존 내용을 서서히 지움 (destination-out) ──
const bands: [number, number, number, number][][] = Array.from( ctx.globalCompositeOperation = 'destination-out';
{ length: NUM_ALPHA_BANDS }, ctx.fillStyle = `rgba(0, 0, 0, ${FADE_ALPHA})`;
() => [], ctx.fillRect(0, 0, w, h);
); ctx.globalCompositeOperation = 'source-over';
for (const p of particles) { // 뷰포트 transform (프레임당 1회)
const [u, v] = getUV(p.lon, p.lat); const zoom = map.getZoom();
const speed = Math.sqrt(u * u + v * v); const center = map.getCenter();
if (speed < 0.001) { const bearing = map.getBearing();
resetParticle(p); const worldSize = 512 * Math.pow(2, zoom);
const cx = lngToMercX(center.lng, worldSize);
const cy = latToMercY(center.lat, worldSize);
const halfW = w / 2;
const halfH = h / 2;
const bearingRad = -bearing * DEG_TO_RAD;
const cosB = Math.cos(bearingRad);
const sinB = Math.sin(bearingRad);
const hasBearing = Math.abs(bearing) > 0.01;
// ── 파티클당 선분 1개만 그리기 (3000 선분) ──
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < PARTICLE_COUNT; i++) {
const lon = pLon[i],
lat = pLat[i];
const [u, v] = getUV(lon, lat);
const speed2 = u * u + v * v;
if (speed2 < 0.000001) {
resetParticle(i);
continue; continue;
} }
const cosLat = Math.cos((p.lat * Math.PI) / 180); const cosLat = Math.cos(lat * DEG_TO_RAD);
p.lon += (u * SPEED_SCALE * DT) / (cosLat * 111320); pLon[i] = lon + (u * SPEED_SCALE * DT) / (cosLat * 111320);
p.lat += (v * SPEED_SCALE * DT) / 111320; pLat[i] = lat + (v * SPEED_SCALE * DT) / 111320;
p.age++; pAge[i]++;
if ( if (
p.lon < bbox.left || pLon[i] < bbox.left ||
p.lon > bbox.right || pLon[i] > bbox.right ||
p.lat < bbox.bottom || pLat[i] < bbox.bottom ||
p.lat > bbox.top || pLat[i] > bbox.top ||
p.age > MAX_AGE pAge[i] > MAX_AGE
) { ) {
resetParticle(p); resetParticle(i);
continue; continue;
} }
const curr = map.project([p.lon, p.lat]); // 수동 Mercator 투영
if (!curr) continue; let dx = lngToMercX(pLon[i], worldSize) - cx;
let dy = latToMercY(pLat[i], worldSize) - cy;
p.trail.push({ x: curr.x, y: curr.y }); if (hasBearing) {
if (p.trail.length > TRAIL_LENGTH) p.trail.shift(); const rx = dx * cosB - dy * sinB;
if (p.trail.length < 2) continue; const ry = dx * sinB + dy * cosB;
dx = rx;
for (let i = 1; i < p.trail.length; i++) { dy = ry;
const t = i / p.trail.length; // 0=oldest, 1=newest
const band = Math.min(NUM_ALPHA_BANDS - 1, Math.floor(t * NUM_ALPHA_BANDS));
const a = p.trail[i - 1],
b = p.trail[i];
bands[band].push([a.x, a.y, b.x, b.y]);
} }
const sx = dx + halfW;
const sy = dy + halfH;
// 이전 좌표가 있으면 선분 1개 추가
if (pHasPrev[i]) {
ctx.moveTo(pPrevX[i], pPrevY[i]);
ctx.lineTo(sx, sy);
}
pPrevX[i] = sx;
pPrevY[i] = sy;
pHasPrev[i] = 1;
} }
// alpha band별 일괄 렌더링 ctx.stroke();
ctx.lineWidth = 0.8;
for (let b = 0; b < NUM_ALPHA_BANDS; b++) {
const [pr, pg, pb] = lightMode ? [30, 90, 180] : [180, 210, 255];
ctx.strokeStyle = `rgba(${pr}, ${pg}, ${pb}, ${((b + 1) / NUM_ALPHA_BANDS) * 0.75})`;
ctx.beginPath();
for (const [x1, y1, x2, y2] of bands[b]) {
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
}
ctx.stroke();
}
animRef.current = requestAnimationFrame(animate); animRef.current = requestAnimationFrame(animate);
} }
@ -186,7 +212,7 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro
map.off('move', onMove); map.off('move', onMove);
canvas.remove(); canvas.remove();
}; };
}, [map, hydrStep, lightMode]); }, [map, hydrStep]);
return null; return null;
} }

파일 보기

@ -4,6 +4,21 @@
z-index: 500; z-index: 500;
} }
/* 사고 팝업 — @layer 밖에 위치해야 MapLibre 기본 스타일을 덮어씀 */
.incident-popup .maplibregl-popup-content {
background: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
border: none;
}
.incident-popup .maplibregl-popup-tip {
border-top-color: var(--bg-elevated);
border-bottom-color: var(--bg-elevated);
border-left-color: transparent;
border-right-color: transparent;
}
@layer components { @layer components {
/* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */ /* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */
.cctv-dark-popup .maplibregl-popup-content { .cctv-dark-popup .maplibregl-popup-content {

파일 보기

@ -0,0 +1,2 @@
// 이 파일은 사용되지 않습니다. 이미지 보기 기능은 MediaModal에 통합되었습니다.
export {};

파일 보기

@ -15,12 +15,14 @@ export interface Incident {
prediction?: string; prediction?: string;
vesselName?: string; vesselName?: string;
mediaCount?: number; mediaCount?: number;
hasImgAnalysis?: boolean;
} }
interface IncidentsLeftPanelProps { interface IncidentsLeftPanelProps {
incidents: Incident[]; incidents: Incident[];
selectedIncidentId: string | null; selectedIncidentId: string | null;
onIncidentSelect: (id: string | null) => void; onIncidentSelect: (id: string | null) => void;
onFilteredChange?: (filtered: Incident[]) => void;
} }
const PERIOD_PRESETS = ['오늘', '1주일', '1개월', '3개월', '6개월', '1년'] as const; const PERIOD_PRESETS = ['오늘', '1주일', '1개월', '3개월', '6개월', '1년'] as const;
@ -75,6 +77,7 @@ export function IncidentsLeftPanel({
incidents, incidents,
selectedIncidentId, selectedIncidentId,
onIncidentSelect, onIncidentSelect,
onFilteredChange,
}: IncidentsLeftPanelProps) { }: IncidentsLeftPanelProps) {
const today = formatDate(new Date()); const today = formatDate(new Date());
const todayLabel = today.replace(/-/g, '-'); const todayLabel = today.replace(/-/g, '-');
@ -157,6 +160,10 @@ export function IncidentsLeftPanel({
}); });
}, [incidents, searchTerm, selectedRegion, selectedStatus, dateFrom, dateTo]); }, [incidents, searchTerm, selectedRegion, selectedStatus, dateFrom, dateTo]);
useEffect(() => {
onFilteredChange?.(filteredIncidents);
}, [filteredIncidents, onFilteredChange]);
const regionCounts = useMemo(() => { const regionCounts = useMemo(() => {
const dateFiltered = incidents.filter((i) => { const dateFiltered = incidents.filter((i) => {
const matchesSearch = const matchesSearch =
@ -551,6 +558,27 @@ export function IncidentsLeftPanel({
📹 <span className="text-caption">{inc.mediaCount}</span> 📹 <span className="text-caption">{inc.mediaCount}</span>
</button> </button>
)} )}
{inc.hasImgAnalysis && (
<button
onClick={(e) => {
e.stopPropagation();
setMediaModalIncident(inc);
}}
title="현장 이미지 보기"
className="cursor-pointer text-label-2"
style={{
padding: '3px 7px',
borderRadius: '4px',
lineHeight: 1,
border: '1px solid rgba(59,130,246,0.25)',
background: 'rgba(59,130,246,0.08)',
color: '#60a5fa',
transition: '0.15s',
}}
>
📷
</button>
)}
</div> </div>
</div> </div>
</div> </div>

파일 보기

@ -129,6 +129,7 @@ interface HoverInfo {
*/ */
export function IncidentsView() { export function IncidentsView() {
const [incidents, setIncidents] = useState<IncidentCompat[]>([]); const [incidents, setIncidents] = useState<IncidentCompat[]>([]);
const [filteredIncidents, setFilteredIncidents] = useState<IncidentCompat[]>([]);
const [selectedIncidentId, setSelectedIncidentId] = useState<string | null>(null); const [selectedIncidentId, setSelectedIncidentId] = useState<string | null>(null);
const [selectedVessel, setSelectedVessel] = useState<Vessel | null>(null); const [selectedVessel, setSelectedVessel] = useState<Vessel | null>(null);
const [detailVessel, setDetailVessel] = useState<Vessel | null>(null); const [detailVessel, setDetailVessel] = useState<Vessel | null>(null);
@ -249,7 +250,7 @@ export function IncidentsView() {
() => () =>
new ScatterplotLayer({ new ScatterplotLayer({
id: 'incidents', id: 'incidents',
data: incidents, data: filteredIncidents,
getPosition: (d: IncidentCompat) => [d.location.lon, d.location.lat], getPosition: (d: IncidentCompat) => [d.location.lon, d.location.lat],
getRadius: (d: IncidentCompat) => (selectedIncidentId === d.id ? 16 : 12), getRadius: (d: IncidentCompat) => (selectedIncidentId === d.id ? 16 : 12),
getFillColor: (d: IncidentCompat) => getMarkerColor(d.status), getFillColor: (d: IncidentCompat) => getMarkerColor(d.status),
@ -290,7 +291,7 @@ export function IncidentsView() {
getLineWidth: [selectedIncidentId], getLineWidth: [selectedIncidentId],
}, },
}), }),
[incidents, selectedIncidentId], [filteredIncidents, selectedIncidentId],
); );
// ── 선박 방향 지시 아이콘 (삼각형 SVG → IconLayer) ────── // ── 선박 방향 지시 아이콘 (삼각형 SVG → IconLayer) ──────
@ -577,6 +578,7 @@ export function IncidentsView() {
incidents={incidents} incidents={incidents}
selectedIncidentId={selectedIncidentId} selectedIncidentId={selectedIncidentId}
onIncidentSelect={setSelectedIncidentId} onIncidentSelect={setSelectedIncidentId}
onFilteredChange={setFilteredIncidents}
/> />
{/* Center - Map + Analysis Views */} {/* Center - Map + Analysis Views */}
@ -689,29 +691,15 @@ export function IncidentsView() {
latitude={incidentPopup.latitude} latitude={incidentPopup.latitude}
anchor="bottom" anchor="bottom"
onClose={() => setIncidentPopup(null)} onClose={() => setIncidentPopup(null)}
closeButton={true} closeButton={false}
closeOnClick={false} closeOnClick={false}
className="incident-popup"
maxWidth="none"
> >
<div className="text-center min-w-[180px] text-xs"> <IncidentPopupContent
<div className="font-semibold text-fg" style={{ marginBottom: 6 }}> incident={incidentPopup.incident}
{incidentPopup.incident.name} onClose={() => setIncidentPopup(null)}
</div> />
<div className="text-label-2 text-fg-disabled leading-[1.6]">
<div>: {getStatusLabel(incidentPopup.incident.status)}</div>
<div>
: {incidentPopup.incident.date} {incidentPopup.incident.time}
</div>
<div>: {incidentPopup.incident.office}</div>
{incidentPopup.incident.causeType && (
<div>: {incidentPopup.incident.causeType}</div>
)}
{incidentPopup.incident.prediction && (
<div className="text-color-accent">
{incidentPopup.incident.prediction}
</div>
)}
</div>
</div>
</Popup> </Popup>
)} )}
</MapLibre> </MapLibre>
@ -1443,6 +1431,165 @@ function PopupRow({
); );
} }
/*
IncidentPopupContent
*/
function IncidentPopupContent({
incident: inc,
onClose,
}: {
incident: IncidentCompat;
onClose: () => void;
}) {
const dotColor: Record<string, string> = {
active: 'var(--color-danger)',
investigating: 'var(--color-warning)',
closed: 'var(--fg-disabled)',
};
const stBg: Record<string, string> = {
active: 'rgba(239,68,68,0.15)',
investigating: 'rgba(249,115,22,0.15)',
closed: 'rgba(100,116,139,0.15)',
};
const stColor: Record<string, string> = {
active: 'var(--color-danger)',
investigating: 'var(--color-warning)',
closed: 'var(--fg-disabled)',
};
return (
<div
style={{
width: 260,
background: 'var(--bg-elevated)',
border: '1px solid var(--stroke-default)',
borderRadius: 10,
boxShadow: '0 12px 40px rgba(0,0,0,0.5)',
overflow: 'hidden',
}}
>
{/* Header */}
<div
className="flex items-center gap-2 border-b border-stroke"
style={{ padding: '10px 14px' }}
>
<span
className="shrink-0"
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: dotColor[inc.status],
boxShadow: inc.status !== 'closed' ? `0 0 6px ${dotColor[inc.status]}` : 'none',
}}
/>
<div className="flex-1 min-w-0 text-label-1 font-bold text-fg whitespace-nowrap overflow-hidden text-ellipsis">
{inc.name}
</div>
<span
onClick={onClose}
className="cursor-pointer text-fg-disabled hover:text-fg flex items-center justify-center"
style={{
width: 22,
height: 22,
borderRadius: 'var(--radius-sm)',
fontSize: 14,
lineHeight: 1,
transition: '0.15s',
}}
>
</span>
</div>
{/* Tags */}
<div
className="flex flex-wrap gap-1.5 border-b border-stroke"
style={{ padding: '8px 14px' }}
>
<span
className="text-caption font-semibold rounded-sm"
style={{
padding: '2px 8px',
background: stBg[inc.status],
border: `1px solid ${stColor[inc.status]}`,
color: stColor[inc.status],
}}
>
{getStatusLabel(inc.status)}
</span>
{inc.causeType && (
<span
className="text-caption font-medium text-fg-sub rounded-sm"
style={{
padding: '2px 8px',
background: 'rgba(100,116,139,0.08)',
border: '1px solid rgba(100,116,139,0.2)',
}}
>
{inc.causeType}
</span>
)}
{inc.oilType && (
<span
className="text-caption font-medium text-color-warning rounded-sm"
style={{
padding: '2px 8px',
background: 'rgba(249,115,22,0.08)',
border: '1px solid rgba(249,115,22,0.2)',
}}
>
{inc.oilType}
</span>
)}
</div>
{/* Info rows */}
<div style={{ padding: '4px 0' }}>
<div
className="flex justify-between text-caption"
style={{ padding: '5px 14px', borderBottom: '1px solid rgba(48,54,61,0.4)' }}
>
<span className="text-fg-disabled"></span>
<span className="text-fg-sub font-semibold font-mono">
{inc.date} {inc.time}
</span>
</div>
<div
className="flex justify-between text-caption"
style={{ padding: '5px 14px', borderBottom: '1px solid rgba(48,54,61,0.4)' }}
>
<span className="text-fg-disabled"></span>
<span className="text-fg-sub font-semibold">{inc.office}</span>
</div>
<div
className="flex justify-between text-caption"
style={{ padding: '5px 14px' }}
>
<span className="text-fg-disabled"></span>
<span className="text-fg-sub font-semibold">{inc.region}</span>
</div>
</div>
{/* Prediction badge */}
{inc.prediction && (
<div className="border-t border-stroke" style={{ padding: '8px 14px' }}>
<span
className="text-caption font-semibold text-color-accent rounded-sm"
style={{
padding: '3px 10px',
background: 'rgba(6,182,212,0.1)',
border: '1px solid rgba(6,182,212,0.25)',
}}
>
{inc.prediction}
</span>
</div>
)}
</div>
);
}
/* /*
VesselDetailModal VesselDetailModal
*/ */

파일 보기

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import type { Incident } from './IncidentsLeftPanel'; import type { Incident } from './IncidentsLeftPanel';
import { fetchIncidentMedia } from '../services/incidentsApi'; import { fetchIncidentMedia, fetchIncidentAerialMedia, getMediaImageUrl } from '../services/incidentsApi';
import type { MediaInfo } from '../services/incidentsApi'; import type { MediaInfo, AerialMediaItem } from '../services/incidentsApi';
type MediaTab = 'all' | 'photo' | 'video' | 'satellite' | 'cctv'; type MediaTab = 'all' | 'photo' | 'video' | 'satellite' | 'cctv';
@ -35,9 +35,12 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
const [activeTab, setActiveTab] = useState<MediaTab>('all'); const [activeTab, setActiveTab] = useState<MediaTab>('all');
const [selectedCam, setSelectedCam] = useState(0); const [selectedCam, setSelectedCam] = useState(0);
const [media, setMedia] = useState<MediaInfo | null>(null); const [media, setMedia] = useState<MediaInfo | null>(null);
const [aerialImages, setAerialImages] = useState<AerialMediaItem[]>([]);
const [selectedImageIdx, setSelectedImageIdx] = useState(0);
useEffect(() => { useEffect(() => {
fetchIncidentMedia(parseInt(incident.id)).then(setMedia); fetchIncidentMedia(parseInt(incident.id)).then(setMedia);
fetchIncidentAerialMedia(parseInt(incident.id)).then(setAerialImages);
}, [incident.id]); }, [incident.id]);
// Timeline dots (UI constant) // Timeline dots (UI constant)
@ -75,7 +78,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
); );
} }
const total = media.photoCnt + media.videoCnt + media.satCnt + media.cctvCnt; const total = (media.photoCnt ?? 0) + (media.videoCnt ?? 0) + (media.satCnt ?? 0) + (media.cctvCnt ?? 0) + aerialImages.length;
const showPhoto = activeTab === 'all' || activeTab === 'photo'; const showPhoto = activeTab === 'all' || activeTab === 'photo';
const showVideo = activeTab === 'all' || activeTab === 'video'; const showVideo = activeTab === 'all' || activeTab === 'video';
@ -233,61 +236,171 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
<div className="flex items-center gap-[6px]"> <div className="flex items-center gap-[6px]">
<span className="text-label-1">📷</span> <span className="text-label-1">📷</span>
<span className="text-label-1 font-bold text-fg"> <span className="text-label-1 font-bold text-fg">
{str(media.photoMeta, 'title', '현장 사진')} {aerialImages.length > 0 ? `${aerialImages.length}` : str(media.photoMeta, 'title', '현장 사진')}
</span> </span>
</div> </div>
<div className="flex gap-[4px]"> <div className="flex gap-[4px]">
<NavBtn label="◀" /> <NavBtn label="▶" /> <NavBtn label="↗" /> {aerialImages.length > 1 && (
<>
<NavBtn label="◀" onClick={() => setSelectedImageIdx((p) => Math.max(0, p - 1))} />
<NavBtn label="▶" onClick={() => setSelectedImageIdx((p) => Math.min(aerialImages.length - 1, p + 1))} />
</>
)}
<NavBtn label="↗" />
</div> </div>
</div> </div>
{/* Photo content */} {/* Photo content */}
<div className="flex-1 flex items-center justify-center flex-col gap-2"> <div className="flex-1 flex items-center justify-center overflow-hidden relative">
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}> {aerialImages.length > 0 ? (
📷 <>
</div> <img
<div className="text-label-1 text-fg-sub font-semibold"> src={getMediaImageUrl(aerialImages[selectedImageIdx].aerialMediaSn)}
{incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')} alt={aerialImages[selectedImageIdx].orgnlNm ?? '현장 사진'}
</div> style={{ width: '100%', height: '100%', objectFit: 'contain' }}
<div className="text-caption text-fg-disabled font-mono"> onError={(e) => {
{str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')} (e.target as HTMLImageElement).style.display = 'none';
</div> (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
}}
/>
<div className="hidden flex-col items-center gap-2">
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>📷</div>
<div className="text-label-1 text-fg-disabled"> </div>
</div>
{aerialImages.length > 1 && (
<>
<button
onClick={() => setSelectedImageIdx((prev) => Math.max(0, prev - 1))}
className="absolute left-2 top-1/2 -translate-y-1/2 flex items-center justify-center text-fg-disabled cursor-pointer rounded"
style={{
width: 28, height: 28,
background: 'rgba(0,0,0,0.5)',
border: '1px solid var(--stroke-default)',
opacity: selectedImageIdx === 0 ? 0.3 : 1,
}}
disabled={selectedImageIdx === 0}
>
</button>
<button
onClick={() => setSelectedImageIdx((prev) => Math.min(aerialImages.length - 1, prev + 1))}
className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center text-fg-disabled cursor-pointer rounded"
style={{
width: 28, height: 28,
background: 'rgba(0,0,0,0.5)',
border: '1px solid var(--stroke-default)',
opacity: selectedImageIdx === aerialImages.length - 1 ? 0.3 : 1,
}}
disabled={selectedImageIdx === aerialImages.length - 1}
>
</button>
</>
)}
<div
className="absolute bottom-2 left-1/2 -translate-x-1/2 text-caption font-mono text-fg-disabled"
style={{
padding: '2px 8px',
background: 'rgba(0,0,0,0.6)',
borderRadius: 4,
}}
>
{selectedImageIdx + 1} / {aerialImages.length}
</div>
</>
) : (
<div className="flex flex-col items-center gap-2">
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>📷</div>
<div className="text-label-1 text-fg-sub font-semibold">
{incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')}
</div>
<div className="text-caption text-fg-disabled font-mono">
{str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')}
</div>
</div>
)}
</div> </div>
{/* Thumbnails */} {/* Thumbnails */}
<div <div
className="shrink-0" className="shrink-0"
style={{ padding: '8px 12px', borderTop: '1px solid var(--stroke-light)' }} style={{ padding: '8px 12px', borderTop: '1px solid var(--stroke-light)' }}
> >
<div className="flex gap-1.5" style={{ marginBottom: 6 }}> {aerialImages.length > 0 ? (
{Array.from({ length: Math.min(num(media.photoMeta, 'thumbCount'), 7) }).map( <>
(_, i) => ( <div className="flex gap-1.5 overflow-x-auto" style={{ marginBottom: 6 }}>
<div {aerialImages.map((img, i) => (
key={i} <div
className="flex items-center justify-center text-title-3 cursor-pointer" key={img.aerialMediaSn}
style={{ className="shrink-0 cursor-pointer overflow-hidden"
width: 40, style={{
height: 36, width: 48,
borderRadius: 4, height: 40,
color: 'var(--stroke-default)', borderRadius: 4,
background: i === 0 ? 'rgba(168,85,247,0.15)' : 'var(--bg-elevated)', background: i === selectedImageIdx ? 'rgba(6,182,212,0.15)' : 'var(--bg-elevated)',
border: border: i === selectedImageIdx
i === 0 ? '2px solid rgba(6,182,212,0.5)'
? '2px solid rgba(168,85,247,0.5)'
: '1px solid var(--stroke-default)', : '1px solid var(--stroke-default)',
}} }}
> onClick={() => setSelectedImageIdx(i)}
📷 >
</div> <img
), src={getMediaImageUrl(img.aerialMediaSn)}
)} alt={img.orgnlNm ?? `사진 ${i + 1}`}
</div> style={{ width: '100%', height: '100%', objectFit: 'cover' }}
<div className="flex justify-between items-center"> onError={(e) => {
<span className="text-caption text-fg-disabled"> const el = e.target as HTMLImageElement;
📷 {num(media.photoMeta, 'thumbCount')} · {str(media.photoMeta, 'stage')} el.style.display = 'none';
</span> }}
<span className="text-caption text-color-tertiary cursor-pointer"> />
🔗 R&D </div>
</span> ))}
</div> </div>
<div className="flex justify-between items-center">
<span className="text-caption text-fg-disabled">
📷 {aerialImages.length}
{aerialImages[selectedImageIdx]?.takngDtm
? ` · ${new Date(aerialImages[selectedImageIdx].takngDtm!).toLocaleDateString('ko-KR')}`
: ''}
</span>
<span className="text-caption text-fg-disabled font-mono">
{aerialImages[selectedImageIdx]?.orgnlNm ?? ''}
</span>
</div>
</>
) : (
<>
<div className="flex gap-1.5" style={{ marginBottom: 6 }}>
{Array.from({ length: Math.min(num(media.photoMeta, 'thumbCount'), 7) }).map(
(_, i) => (
<div
key={i}
className="flex items-center justify-center text-title-3 cursor-pointer"
style={{
width: 40,
height: 36,
borderRadius: 4,
color: 'var(--stroke-default)',
background: i === 0 ? 'rgba(6,182,212,0.15)' : 'var(--bg-elevated)',
border:
i === 0
? '2px solid rgba(6,182,212,0.5)'
: '1px solid var(--stroke-default)',
}}
>
📷
</div>
),
)}
</div>
<div className="flex justify-between items-center">
<span className="text-caption text-fg-disabled">
📷 {num(media.photoMeta, 'thumbCount')} · {str(media.photoMeta, 'stage')}
</span>
<span className="text-caption text-color-tertiary cursor-pointer">
🔗 R&D
</span>
</div>
</>
)}
</div> </div>
</div> </div>
)} )}
@ -560,16 +673,16 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
> >
<div className="flex gap-4 text-caption font-mono text-fg-disabled"> <div className="flex gap-4 text-caption font-mono text-fg-disabled">
<span> <span>
📷 <b className="text-fg">{media.photoCnt}</b> 📷 <b className="text-fg">{aerialImages.length > 0 ? aerialImages.length : (media.photoCnt ?? 0)}</b>
</span> </span>
<span> <span>
🎬 <b className="text-fg">{media.videoCnt}</b> 🎬 <b className="text-fg">{media.videoCnt ?? 0}</b>
</span> </span>
<span> <span>
🛰 <b className="text-fg">{media.satCnt}</b> 🛰 <b className="text-fg">{media.satCnt ?? 0}</b>
</span> </span>
<span> <span>
📹 CCTV <b className="text-fg">{media.cctvCnt}</b> 📹 CCTV <b className="text-fg">{media.cctvCnt ?? 0}</b>
</span> </span>
<span> <span>
📎 <b className="text-color-tertiary">{total}</b> 📎 <b className="text-color-tertiary">{total}</b>
@ -604,9 +717,10 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
); );
} }
function NavBtn({ label }: { label: string }) { function NavBtn({ label, onClick }: { label: string; onClick?: () => void }) {
return ( return (
<button <button
onClick={onClick}
className="flex items-center justify-center text-caption text-fg-disabled cursor-pointer rounded bg-bg-elevated" className="flex items-center justify-center text-caption text-fg-disabled cursor-pointer rounded bg-bg-elevated"
style={{ style={{
width: 22, width: 22,

파일 보기

@ -24,7 +24,9 @@ export interface IncidentListItem {
spilQty: number | null; spilQty: number | null;
spilUnitCd: string | null; spilUnitCd: string | null;
fcstHr: number | null; fcstHr: number | null;
hasPredCompleted: boolean;
mediaCnt: number; mediaCnt: number;
hasImgAnalysis: boolean;
} }
export interface PredExecItem { export interface PredExecItem {
@ -89,6 +91,7 @@ export interface IncidentCompat {
prediction?: string; prediction?: string;
vesselName?: string; vesselName?: string;
mediaCount?: number; mediaCount?: number;
hasImgAnalysis?: boolean;
} }
function toCompat(item: IncidentListItem): IncidentCompat { function toCompat(item: IncidentListItem): IncidentCompat {
@ -109,8 +112,9 @@ function toCompat(item: IncidentListItem): IncidentCompat {
location: { lat: item.lat, lon: item.lng }, location: { lat: item.lat, lon: item.lng },
causeType: item.acdntTpCd, causeType: item.acdntTpCd,
oilType: item.oilTpCd ?? undefined, oilType: item.oilTpCd ?? undefined,
prediction: item.fcstHr ? '예측완료' : undefined, prediction: item.hasPredCompleted ? '예측완료' : undefined,
mediaCount: item.mediaCnt, mediaCount: item.mediaCnt,
hasImgAnalysis: item.hasImgAnalysis || undefined,
}; };
} }
@ -201,3 +205,40 @@ export async function fetchNearbyOrgs(
}); });
return data; return data;
} }
// ============================================================
// 사고 관련 이미지 (AERIAL_MEDIA)
// ============================================================
export interface AerialMediaItem {
aerialMediaSn: number;
acdntSn: number | null;
fileNm: string;
orgnlNm: string | null;
filePath: string | null;
lon: number | null;
lat: number | null;
locDc: string | null;
equipTpCd: string | null;
equipNm: string | null;
mediaTpCd: string | null;
takngDtm: string | null;
fileSz: string | null;
resolution: string | null;
regDtm: string;
}
export async function fetchIncidentAerialMedia(acdntSn: number): Promise<AerialMediaItem[]> {
try {
const { data } = await api.get<AerialMediaItem[]>('/aerial/media', {
params: { acdntSn },
});
return data;
} catch {
return [];
}
}
export function getMediaImageUrl(aerialMediaSn: number): string {
return `/api/aerial/media/${aerialMediaSn}/download`;
}

파일 보기

@ -58,6 +58,12 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
</span> </span>
); );
case 'failed':
return (
<span className="px-2 py-1 text-label-2 font-medium rounded-md bg-[rgba(239,68,68,0.15)] text-color-danger">
</span>
);
default: default:
return null; return null;
} }
@ -246,7 +252,11 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
</td> </td>
<td className="px-4 py-3 text-title-3 text-fg-sub">{analysis.oilType}</td> <td className="px-4 py-3 text-title-3 text-fg-sub">{analysis.oilType}</td>
<td className="px-4 py-3 text-title-3 text-fg font-mono text-right font-medium"> <td className="px-4 py-3 text-title-3 text-fg font-mono text-right font-medium">
{analysis.volume != null ? analysis.volume.toFixed(2) : '—'} {analysis.volume != null
? analysis.volume >= 0.01
? analysis.volume.toFixed(2)
: analysis.volume.toExponential(2)
: '—'}
</td> </td>
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.kospsStatus)}</td> <td className="px-4 py-3 text-center">{getStatusBadge(analysis.kospsStatus)}</td>
<td className="px-4 py-3 text-center"> <td className="px-4 py-3 text-center">

파일 보기

@ -188,7 +188,7 @@ export function OilSpillView() {
new Set(['OpenDrift']), new Set(['OpenDrift']),
); );
const [visibleModels, setVisibleModels] = useState<Set<PredictionModel>>(new Set(['OpenDrift'])); const [visibleModels, setVisibleModels] = useState<Set<PredictionModel>>(new Set(['OpenDrift']));
const [predictionTime, setPredictionTime] = useState(48); const [predictionTime, setPredictionTime] = useState(6);
const [accidentTime, setAccidentTime] = useState<string>(''); const [accidentTime, setAccidentTime] = useState<string>('');
const [spillType, setSpillType] = useState('연속'); const [spillType, setSpillType] = useState('연속');
const [oilType, setOilType] = useState('벙커C유'); const [oilType, setOilType] = useState('벙커C유');
@ -586,7 +586,7 @@ export function OilSpillView() {
}; };
setOilType(oilTypeMap[analysis.oilType] || '벙커C유'); setOilType(oilTypeMap[analysis.oilType] || '벙커C유');
setSpillAmount(analysis.volume ?? 100); setSpillAmount(analysis.volume ?? 100);
setPredictionTime(parseInt(analysis.duration) || 48); setPredictionTime(parseInt(analysis.duration) || 6);
// 모델 상태에 따라 선택 모델 설정 // 모델 상태에 따라 선택 모델 설정
const models = new Set<PredictionModel>(); const models = new Set<PredictionModel>();
if (analysis.kospsStatus !== 'pending') models.add('KOSPS'); if (analysis.kospsStatus !== 'pending') models.add('KOSPS');
@ -661,7 +661,7 @@ export function OilSpillView() {
const demoTrajectory = generateDemoTrajectory( const demoTrajectory = generateDemoTrajectory(
coord ?? { lat: 37.39, lon: 126.64 }, coord ?? { lat: 37.39, lon: 126.64 },
demoModels, demoModels,
parseInt(analysis.duration) || 48, parseInt(analysis.duration) || 6,
); );
setOilTrajectory(demoTrajectory); setOilTrajectory(demoTrajectory);
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings)); if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings));
@ -754,7 +754,7 @@ export function OilSpillView() {
setFlyToCoord({ lat: result.lat, lon: result.lon }); setFlyToCoord({ lat: result.lat, lon: result.lon });
setAccidentTime(toLocalDateTimeStr(result.occurredAt)); setAccidentTime(toLocalDateTimeStr(result.occurredAt));
setOilType(result.oilType); setOilType(result.oilType);
setSpillAmount(parseFloat(result.volume.toFixed(4))); setSpillAmount(parseFloat(result.volume.toFixed(20)));
setSpillUnit('kL'); setSpillUnit('kL');
setSelectedAnalysis({ setSelectedAnalysis({
acdntSn: result.acdntSn, acdntSn: result.acdntSn,
@ -762,7 +762,7 @@ export function OilSpillView() {
occurredAt: result.occurredAt, occurredAt: result.occurredAt,
analysisDate: '', analysisDate: '',
requestor: '', requestor: '',
duration: '48', duration: '6',
oilType: result.oilType, oilType: result.oilType,
volume: result.volume, volume: result.volume,
location: '', location: '',

파일 보기

@ -92,7 +92,7 @@ const PredictionInputSection = ({
setIsAnalyzing(true); setIsAnalyzing(true);
setAnalyzeError(null); setAnalyzeError(null);
try { try {
const result = await analyzeImage(uploadedFile); const result = await analyzeImage(uploadedFile, incidentName);
setAnalyzeResult(result); setAnalyzeResult(result);
onImageAnalysisResult?.(result); onImageAnalysisResult?.(result);
} catch (err: unknown) { } catch (err: unknown) {
@ -149,23 +149,19 @@ const PredictionInputSection = ({
</label> </label>
</div> </div>
{/* Direct Input Mode */} {/* 사고명 입력 (직접입력 / 이미지업로드 공통) */}
{inputMode === 'direct' && ( <input
<> className="prd-i"
<input placeholder="사고명 직접 입력"
className="prd-i" value={incidentName}
placeholder="사고명 직접 입력" onChange={(e) => onIncidentNameChange(e.target.value)}
value={incidentName} style={
onChange={(e) => onIncidentNameChange(e.target.value)} validationErrors?.has('incidentName')
style={ ? { borderColor: 'var(--color-danger)' }
validationErrors?.has('incidentName') : undefined
? { borderColor: 'var(--color-danger)' } }
: undefined />
} <input className="prd-i" placeholder="또는 사고 리스트에서 선택" readOnly />
/>
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" readOnly />
</>
)}
{/* Image Upload Mode */} {/* Image Upload Mode */}
{inputMode === 'upload' && ( {inputMode === 'upload' && (
@ -353,10 +349,10 @@ const PredictionInputSection = ({
className="prd-i" className="prd-i"
placeholder="유출량" placeholder="유출량"
type="number" type="number"
min="1" min="0"
step="1" step="any"
value={spillAmount} value={spillAmount}
onChange={(e) => onSpillAmountChange(parseInt(e.target.value) || 0)} onChange={(e) => onSpillAmountChange(parseFloat(e.target.value) || 0)}
/> />
<ComboBox <ComboBox
className="prd-i" className="prd-i"

파일 보기

@ -329,7 +329,11 @@ export function RightPanel({
</Section> </Section>
{/* 오염 종합 상황 */} {/* 오염 종합 상황 */}
<Section title="오염 종합 상황" badge="위험" badgeColor="red"> <Section
title="오염 종합 상황"
badge={getPollutionSeverity(spill?.volume)?.label}
badgeColor={getPollutionSeverity(spill?.volume)?.color}
>
<div className="grid grid-cols-2 gap-0.5 text-label-2"> <div className="grid grid-cols-2 gap-0.5 text-label-2">
<StatBox <StatBox
label="유출량" label="유출량"
@ -367,7 +371,11 @@ export function RightPanel({
</Section> </Section>
{/* 확산 예측 요약 */} {/* 확산 예측 요약 */}
<Section title={`확산 예측 요약 (+${predictionTime ?? 18}h)`} badge="위험" badgeColor="red"> <Section
title={`확산 예측 요약 (+${predictionTime ?? 18}h)`}
badge={getSpreadSeverity(spreadSummary?.distance, spreadSummary?.speed)?.label}
badgeColor={getSpreadSeverity(spreadSummary?.distance, spreadSummary?.speed)?.color}
>
<div className="grid grid-cols-2 gap-0.5 text-label-2"> <div className="grid grid-cols-2 gap-0.5 text-label-2">
<PredictionCard <PredictionCard
value={spreadSummary?.area != null ? `${spreadSummary.area.toFixed(1)} km²` : '—'} value={spreadSummary?.area != null ? `${spreadSummary.area.toFixed(1)} km²` : '—'}
@ -598,7 +606,55 @@ export function RightPanel({
); );
} }
// 위험도 등급 (방제대책본부 운영 규칙 유출량 기준 + 국가 위기경보 4단계)
type SeverityColor = 'red' | 'orange' | 'yellow' | 'green';
interface SeverityLevel {
label: string;
color: SeverityColor;
}
const SEVERITY_LEVELS: SeverityLevel[] = [
{ label: '심각', color: 'red' },
{ label: '경계', color: 'orange' },
{ label: '주의', color: 'yellow' },
{ label: '관심', color: 'green' },
];
/** 오염 종합 상황 — 유출량(kl) 기준 */
function getPollutionSeverity(volumeKl: number | null | undefined): SeverityLevel | null {
if (volumeKl == null) return null;
if (volumeKl >= 500) return SEVERITY_LEVELS[0]; // 심각 (중앙방제대책본부)
if (volumeKl >= 50) return SEVERITY_LEVELS[1]; // 경계 (광역방제대책본부)
if (volumeKl >= 10) return SEVERITY_LEVELS[2]; // 주의 (지역방제대책본부)
return SEVERITY_LEVELS[3]; // 관심
}
/** 확산 예측 요약 — 확산거리(km) + 속도(m/s) 중 높은 등급 */
function getSpreadSeverity(
distanceKm: number | null | undefined,
speedMs: number | null | undefined,
): SeverityLevel | null {
if (distanceKm == null && speedMs == null) return null;
const distLevel =
distanceKm == null ? 3 : distanceKm >= 15 ? 0 : distanceKm >= 5 ? 1 : distanceKm >= 1 ? 2 : 3;
const speedLevel =
speedMs == null ? 3 : speedMs >= 0.3 ? 0 : speedMs >= 0.15 ? 1 : speedMs >= 0.05 ? 2 : 3;
return SEVERITY_LEVELS[Math.min(distLevel, speedLevel)];
}
// Helper Components // Helper Components
const BADGE_STYLES: Record<string, string> = {
red: 'bg-[rgba(239,68,68,0.08)] text-color-danger border border-[rgba(239,68,68,0.25)]',
orange:
'bg-[rgba(249,115,22,0.08)] text-color-warning border border-[rgba(249,115,22,0.25)]',
yellow:
'bg-[rgba(234,179,8,0.08)] text-color-caution border border-[rgba(234,179,8,0.25)]',
green:
'bg-[rgba(34,197,94,0.08)] text-color-success border border-[rgba(34,197,94,0.25)]',
};
function Section({ function Section({
title, title,
badge, badge,
@ -607,7 +663,7 @@ function Section({
}: { }: {
title: string; title: string;
badge?: string; badge?: string;
badgeColor?: 'red' | 'green'; badgeColor?: 'red' | 'orange' | 'yellow' | 'green';
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
@ -617,9 +673,7 @@ function Section({
{badge && ( {badge && (
<span <span
className={`text-label-2 font-medium px-2 py-0.5 rounded-full ${ className={`text-label-2 font-medium px-2 py-0.5 rounded-full ${
badgeColor === 'red' BADGE_STYLES[badgeColor ?? 'green']
? 'bg-[rgba(239,68,68,0.08)] text-color-danger border border-[rgba(239,68,68,0.25)]'
: 'bg-[rgba(34,197,94,0.08)] text-color-success border border-[rgba(34,197,94,0.25)]'
}`} }`}
> >
{badge} {badge}

파일 보기

@ -311,9 +311,10 @@ export interface ImageAnalyzeResult {
occurredAt: string; occurredAt: string;
} }
export const analyzeImage = async (file: File): Promise<ImageAnalyzeResult> => { export const analyzeImage = async (file: File, acdntNm?: string): Promise<ImageAnalyzeResult> => {
const formData = new FormData(); const formData = new FormData();
formData.append('image', file); formData.append('image', file);
if (acdntNm?.trim()) formData.append('acdntNm', acdntNm.trim());
const response = await api.post<ImageAnalyzeResult>('/prediction/image-analyze', formData, { const response = await api.post<ImageAnalyzeResult>('/prediction/image-analyze', formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
timeout: 330_000, timeout: 330_000,