diff --git a/backend/src/incidents/incidentsRouter.ts b/backend/src/incidents/incidentsRouter.ts index abb09d6..ea65044 100644 --- a/backend/src/incidents/incidentsRouter.ts +++ b/backend/src/incidents/incidentsRouter.ts @@ -7,6 +7,7 @@ import { getIncidentWeather, saveIncidentWeather, getIncidentMedia, + getIncidentImageAnalysis, } from './incidentsService.js'; 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; diff --git a/backend/src/incidents/incidentsService.ts b/backend/src/incidents/incidentsService.ts index 104fee0..a02534b 100644 --- a/backend/src/incidents/incidentsService.ts +++ b/backend/src/incidents/incidentsService.ts @@ -24,7 +24,9 @@ interface IncidentListItem { spilQty: number | null; spilUnitCd: string | null; fcstHr: number | null; + hasPredCompleted: boolean; mediaCnt: number; + hasImgAnalysis: boolean; } 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.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, + 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.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt FROM wing.ACDNT a 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 WHERE ACDNT_SN = a.ACDNT_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, spilUnitCd: (r.spil_unit_cd as string) ?? null, fcstHr: (r.fcst_hr as number) ?? null, + hasPredCompleted: r.has_pred_completed as boolean, mediaCnt: Number(r.media_cnt), + hasImgAnalysis: (r.has_img_analysis as boolean) ?? false, })); } @@ -162,11 +172,17 @@ export async function getIncident(acdntSn: number): Promise) ?? null, }; } + +// ============================================================ +// 이미지 분석 데이터 조회 +// ============================================================ +export async function getIncidentImageAnalysis(acdntSn: number): Promise | 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).img_rslt_data as Record; +} diff --git a/backend/src/prediction/imageAnalyzeService.ts b/backend/src/prediction/imageAnalyzeService.ts index 41452e7..7d68896 100644 --- a/backend/src/prediction/imageAnalyzeService.ts +++ b/backend/src/prediction/imageAnalyzeService.ts @@ -74,7 +74,7 @@ function parseMeta(metaStr: string): { lat: number; lon: number; occurredAt: str return { lat, lon, occurredAt }; } -export async function analyzeImageFile(imageBuffer: Buffer, originalName: string): Promise { +export async function analyzeImageFile(imageBuffer: Buffer, originalName: string, acdntNmOverride?: string): Promise { const fileId = crypto.randomUUID(); // camTy는 현재 "mx15hdi"로 하드코딩한다. @@ -122,7 +122,7 @@ export async function analyzeImageFile(imageBuffer: Buffer, originalName: string const volume = firstOil?.volume ?? 0; // 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( `INSERT INTO wing.ACDNT (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( `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) - VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 48, $4, NOW())`, + VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 6, $4, NOW())`, [ acdntSn, OIL_DB_CODE_MAP[oilType] ?? 'BUNKER_C', diff --git a/backend/src/prediction/predictionRouter.ts b/backend/src/prediction/predictionRouter.ts index 6d6c357..497ec86 100644 --- a/backend/src/prediction/predictionRouter.ts +++ b/backend/src/prediction/predictionRouter.ts @@ -230,7 +230,8 @@ router.post( res.status(400).json({ error: '이미지 파일이 필요합니다' }); 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); } catch (err: unknown) { if (err instanceof Error) { diff --git a/backend/src/routes/simulation.ts b/backend/src/routes/simulation.ts index abba4e4..a8b69dd 100755 --- a/backend/src/routes/simulation.ts +++ b/backend/src/routes/simulation.ts @@ -20,9 +20,9 @@ const POLL_TIMEOUT_MS = 30 * 60 * 1000 // 30분 const OIL_TYPE_MAP: Record = { '벙커C유': 'GENERIC BUNKER C', '경유': 'GENERIC DIESEL', - '원유': 'WEST TEXAS INTERMEDIATE (WTI)', + '원유': 'WEST TEXAS INTERMEDIATE', '중유': 'GENERIC HEAVY FUEL OIL', - '등유': 'FUEL OIL NO.1 (KEROSENE)', + '등유': 'FUEL OIL NO.1 (KEROSENE) ', '휘발유': 'GENERIC GASOLINE', } diff --git a/database/init.sql b/database/init.sql index a23122d..b814167 100755 --- a/database/init.sql +++ b/database/init.sql @@ -293,7 +293,7 @@ CREATE TABLE SPIL_DATA ( SPIL_DATA_SN SERIAL NOT NULL, -- 유출정보순번 ACDNT_SN INTEGER 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_TP_CD VARCHAR(20), -- 유출유형코드 SPIL_LOC_GEOM GEOMETRY(Point, 4326), -- 유출위치지오메트리 diff --git a/database/migration/009_incidents.sql b/database/migration/009_incidents.sql index 166c595..ae88141 100644 --- a/database/migration/009_incidents.sql +++ b/database/migration/009_incidents.sql @@ -40,7 +40,7 @@ CREATE TABLE IF NOT EXISTS SPIL_DATA ( SPIL_DATA_SN SERIAL NOT NULL, ACDNT_SN INTEGER 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_TP_CD VARCHAR(20), FCST_HR INTEGER, diff --git a/database/migration/013_hns_analysis.sql b/database/migration/013_hns_analysis.sql index 0a05240..18858f0 100644 --- a/database/migration/013_hns_analysis.sql +++ b/database/migration/013_hns_analysis.sql @@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS HNS_ANALYSIS ( SBST_NM VARCHAR(100), UN_NO VARCHAR(10), CAS_NO VARCHAR(20), - SPIL_QTY NUMERIC(10,2), + SPIL_QTY NUMERIC(14,10), SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', SPIL_TP_CD VARCHAR(20), FCST_HR INTEGER, diff --git a/database/migration/031_spil_qty_precision.sql b/database/migration/031_spil_qty_precision.sql new file mode 100644 index 0000000..2fb18a5 --- /dev/null +++ b/database/migration/031_spil_qty_precision.sql @@ -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); diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index d88decc..5c127bb 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,9 +4,26 @@ ## [Unreleased] +## [2026-04-13] + +### 추가 +- 사고별 이미지 분석 데이터 조회 API 추가 +- 사고 리스트에 항공 미디어 연동 및 이미지 분석 뱃지 표시 +- 사고 마커 클릭 팝업 디자인 리뉴얼 +- 지도에 필터링된 사고만 표시되도록 개선 + +### 변경 +- 이미지 분석 시 사고명 파라미터 지원 +- 기본 예측시간 48시간 → 6시간으로 변경 +- 유출량(SPIL_QTY) 정밀도 NUMERIC(14,10)으로 확대 +- OpenDrift 유종 매핑 수정 (원유, 등유) +- 소량 유출량 과학적 표기법으로 표시 + ## [2026-04-09] ### 추가 +- HNS 확산 파티클 렌더링 성능 최적화 (TypedArray + 수동 Mercator 투영 + 페이드 트레일) +- 오염 종합 상황/확산 예측 요약 위험도 뱃지 동적 표시 (심각/경계/주의/관심 4단계) - 디자인 시스템 Float 카탈로그 추가 (Modal / Dropdown / Overlay / Toast) - 디자인 시스템 폰트/색상 토큰을 전 탭 컴포넌트에 전면 적용 (admin, aerial, assets, board, hns, incidents, prediction, reports, rescue, scat, weather) - SR 민감자원 벡터타일 오버레이 컴포넌트 및 백엔드 프록시 엔드포인트 추가 diff --git a/frontend/src/common/components/map/HydrParticleOverlay.tsx b/frontend/src/common/components/map/HydrParticleOverlay.tsx index 7f57f8a..a825242 100644 --- a/frontend/src/common/components/map/HydrParticleOverlay.tsx +++ b/frontend/src/common/components/map/HydrParticleOverlay.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef } from 'react'; import { useMap } from '@vis.gl/react-maplibre'; import type { HydrDataStep } from '@tabs/prediction/services/predictionApi'; -import { useThemeStore } from '@common/store/themeStore'; interface HydrParticleOverlayProps { hydrStep: HydrDataStep | null; @@ -9,24 +8,13 @@ interface HydrParticleOverlayProps { const PARTICLE_COUNT = 3000; const MAX_AGE = 300; -const SPEED_SCALE = 0.1; +const SPEED_SCALE = 0.15; const DT = 600; -const TRAIL_LENGTH = 30; // 파티클당 저장할 화면 좌표 수 -const NUM_ALPHA_BANDS = 4; // stroke 배치 단위 - -interface TrailPoint { - x: number; - y: number; -} -interface Particle { - lon: number; - lat: number; - trail: TrailPoint[]; - age: number; -} +const DEG_TO_RAD = Math.PI / 180; +const PI_4 = Math.PI / 4; +const FADE_ALPHA = 0.02; // 프레임당 페이드량 (낮을수록 긴 꼬리) export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayProps) { - const lightMode = useThemeStore((s) => s.theme) === 'light'; const { current: map } = useMap(); const animRef = useRef(); @@ -52,21 +40,21 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro const lats: number[] = [boundLonLat.bottom]; 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] { - let col = -1, - row = -1; - 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; - } - } + const col = bisect(lons, lon); + const row = bisect(lats, lat); if (col < 0 || row < 0) return [0, 0]; const fx = (lon - lons[col]) / (lons[col + 1] - lons[col]); 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; const v10 = v2d[row + 1]?.[col] ?? v00, v11 = v2d[row + 1]?.[col + 1] ?? v00; - const u = - u00 * (1 - fx) * (1 - fy) + u01 * fx * (1 - fy) + u10 * (1 - fx) * fy + u11 * fx * fy; - const v = - v00 * (1 - fx) * (1 - fy) + v01 * fx * (1 - fy) + v10 * (1 - fx) * fy + v11 * fx * fy; - return [u, v]; + const _1fx = 1 - fx, + _1fy = 1 - fy; + return [ + u00 * _1fx * _1fy + u01 * fx * _1fy + u10 * _1fx * fy + u11 * fx * fy, + v00 * _1fx * _1fy + v01 * fx * _1fy + v10 * _1fx * fy + v11 * fx * fy, + ]; } const bbox = boundLonLat; - const particles: Particle[] = Array.from({ length: PARTICLE_COUNT }, () => ({ - lon: bbox.left + Math.random() * (bbox.right - bbox.left), - lat: bbox.bottom + Math.random() * (bbox.top - bbox.bottom), - trail: [], - age: Math.floor(Math.random() * MAX_AGE), - })); + const bboxW = bbox.right - bbox.left; + const bboxH = bbox.top - bbox.bottom; - function resetParticle(p: Particle) { - p.lon = bbox.left + Math.random() * (bbox.right - bbox.left); - p.lat = bbox.bottom + Math.random() * (bbox.top - bbox.bottom); - p.trail = []; - p.age = 0; + // 파티클: 위치 + 이전 화면좌표 (선분 1개만 그리면 됨) + const pLon = new Float64Array(PARTICLE_COUNT); + const pLat = new Float64Array(PARTICLE_COUNT); + const pAge = new Int32Array(PARTICLE_COUNT); + 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 = () => { - 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); function animate() { - // 매 프레임 완전 초기화 → 잔상 없음 - ctx.clearRect(0, 0, canvas.width, canvas.height); + const w = canvas.width; + const h = canvas.height; - // alpha band별 세그먼트 버퍼 (드로우 콜 최소화) - const bands: [number, number, number, number][][] = Array.from( - { length: NUM_ALPHA_BANDS }, - () => [], - ); + // ── 페이드: 기존 내용을 서서히 지움 (destination-out) ── + ctx.globalCompositeOperation = 'destination-out'; + ctx.fillStyle = `rgba(0, 0, 0, ${FADE_ALPHA})`; + ctx.fillRect(0, 0, w, h); + ctx.globalCompositeOperation = 'source-over'; - for (const p of particles) { - const [u, v] = getUV(p.lon, p.lat); - const speed = Math.sqrt(u * u + v * v); - if (speed < 0.001) { - resetParticle(p); + // 뷰포트 transform (프레임당 1회) + const zoom = map.getZoom(); + const center = map.getCenter(); + const bearing = map.getBearing(); + 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; } - const cosLat = Math.cos((p.lat * Math.PI) / 180); - p.lon += (u * SPEED_SCALE * DT) / (cosLat * 111320); - p.lat += (v * SPEED_SCALE * DT) / 111320; - p.age++; + const cosLat = Math.cos(lat * DEG_TO_RAD); + pLon[i] = lon + (u * SPEED_SCALE * DT) / (cosLat * 111320); + pLat[i] = lat + (v * SPEED_SCALE * DT) / 111320; + pAge[i]++; if ( - p.lon < bbox.left || - p.lon > bbox.right || - p.lat < bbox.bottom || - p.lat > bbox.top || - p.age > MAX_AGE + pLon[i] < bbox.left || + pLon[i] > bbox.right || + pLat[i] < bbox.bottom || + pLat[i] > bbox.top || + pAge[i] > MAX_AGE ) { - resetParticle(p); + resetParticle(i); continue; } - const curr = map.project([p.lon, p.lat]); - if (!curr) continue; - - p.trail.push({ x: curr.x, y: curr.y }); - if (p.trail.length > TRAIL_LENGTH) p.trail.shift(); - if (p.trail.length < 2) continue; - - for (let i = 1; i < p.trail.length; i++) { - 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]); + // 수동 Mercator 투영 + let dx = lngToMercX(pLon[i], worldSize) - cx; + let dy = latToMercY(pLat[i], worldSize) - cy; + if (hasBearing) { + const rx = dx * cosB - dy * sinB; + const ry = dx * sinB + dy * cosB; + dx = rx; + dy = ry; } + 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.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(); - } + ctx.stroke(); animRef.current = requestAnimationFrame(animate); } @@ -186,7 +212,7 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro map.off('move', onMove); canvas.remove(); }; - }, [map, hydrStep, lightMode]); + }, [map, hydrStep]); return null; } diff --git a/frontend/src/common/styles/components.css b/frontend/src/common/styles/components.css index 0bd81c1..f0be483 100644 --- a/frontend/src/common/styles/components.css +++ b/frontend/src/common/styles/components.css @@ -4,6 +4,21 @@ 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 { /* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */ .cctv-dark-popup .maplibregl-popup-content { diff --git a/frontend/src/tabs/incidents/components/ImageAnalysisModal.tsx b/frontend/src/tabs/incidents/components/ImageAnalysisModal.tsx new file mode 100644 index 0000000..94b2dfd --- /dev/null +++ b/frontend/src/tabs/incidents/components/ImageAnalysisModal.tsx @@ -0,0 +1,2 @@ +// 이 파일은 사용되지 않습니다. 이미지 보기 기능은 MediaModal에 통합되었습니다. +export {}; diff --git a/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx b/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx index ef73bcc..1627be8 100755 --- a/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx @@ -15,12 +15,14 @@ export interface Incident { prediction?: string; vesselName?: string; mediaCount?: number; + hasImgAnalysis?: boolean; } interface IncidentsLeftPanelProps { incidents: Incident[]; selectedIncidentId: string | null; onIncidentSelect: (id: string | null) => void; + onFilteredChange?: (filtered: Incident[]) => void; } const PERIOD_PRESETS = ['오늘', '1주일', '1개월', '3개월', '6개월', '1년'] as const; @@ -75,6 +77,7 @@ export function IncidentsLeftPanel({ incidents, selectedIncidentId, onIncidentSelect, + onFilteredChange, }: IncidentsLeftPanelProps) { const today = formatDate(new Date()); const todayLabel = today.replace(/-/g, '-'); @@ -157,6 +160,10 @@ export function IncidentsLeftPanel({ }); }, [incidents, searchTerm, selectedRegion, selectedStatus, dateFrom, dateTo]); + useEffect(() => { + onFilteredChange?.(filteredIncidents); + }, [filteredIncidents, onFilteredChange]); + const regionCounts = useMemo(() => { const dateFiltered = incidents.filter((i) => { const matchesSearch = @@ -551,6 +558,27 @@ export function IncidentsLeftPanel({ 📹 {inc.mediaCount} )} + {inc.hasImgAnalysis && ( + + )} diff --git a/frontend/src/tabs/incidents/components/IncidentsView.tsx b/frontend/src/tabs/incidents/components/IncidentsView.tsx index 9d6837b..a1d17e6 100755 --- a/frontend/src/tabs/incidents/components/IncidentsView.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsView.tsx @@ -129,6 +129,7 @@ interface HoverInfo { ════════════════════════════════════════════════════ */ export function IncidentsView() { const [incidents, setIncidents] = useState([]); + const [filteredIncidents, setFilteredIncidents] = useState([]); const [selectedIncidentId, setSelectedIncidentId] = useState(null); const [selectedVessel, setSelectedVessel] = useState(null); const [detailVessel, setDetailVessel] = useState(null); @@ -249,7 +250,7 @@ export function IncidentsView() { () => new ScatterplotLayer({ id: 'incidents', - data: incidents, + data: filteredIncidents, getPosition: (d: IncidentCompat) => [d.location.lon, d.location.lat], getRadius: (d: IncidentCompat) => (selectedIncidentId === d.id ? 16 : 12), getFillColor: (d: IncidentCompat) => getMarkerColor(d.status), @@ -290,7 +291,7 @@ export function IncidentsView() { getLineWidth: [selectedIncidentId], }, }), - [incidents, selectedIncidentId], + [filteredIncidents, selectedIncidentId], ); // ── 선박 방향 지시 아이콘 (삼각형 SVG → IconLayer) ────── @@ -577,6 +578,7 @@ export function IncidentsView() { incidents={incidents} selectedIncidentId={selectedIncidentId} onIncidentSelect={setSelectedIncidentId} + onFilteredChange={setFilteredIncidents} /> {/* Center - Map + Analysis Views */} @@ -689,29 +691,15 @@ export function IncidentsView() { latitude={incidentPopup.latitude} anchor="bottom" onClose={() => setIncidentPopup(null)} - closeButton={true} + closeButton={false} closeOnClick={false} + className="incident-popup" + maxWidth="none" > -
-
- {incidentPopup.incident.name} -
-
-
상태: {getStatusLabel(incidentPopup.incident.status)}
-
- 일시: {incidentPopup.incident.date} {incidentPopup.incident.time} -
-
관할: {incidentPopup.incident.office}
- {incidentPopup.incident.causeType && ( -
원인: {incidentPopup.incident.causeType}
- )} - {incidentPopup.incident.prediction && ( -
- {incidentPopup.incident.prediction} -
- )} -
-
+ setIncidentPopup(null)} + /> )} @@ -1443,6 +1431,165 @@ function PopupRow({ ); } +/* ════════════════════════════════════════════════════ + IncidentPopupContent – 사고 마커 클릭 팝업 + ════════════════════════════════════════════════════ */ +function IncidentPopupContent({ + incident: inc, + onClose, +}: { + incident: IncidentCompat; + onClose: () => void; +}) { + const dotColor: Record = { + active: 'var(--color-danger)', + investigating: 'var(--color-warning)', + closed: 'var(--fg-disabled)', + }; + const stBg: Record = { + active: 'rgba(239,68,68,0.15)', + investigating: 'rgba(249,115,22,0.15)', + closed: 'rgba(100,116,139,0.15)', + }; + const stColor: Record = { + active: 'var(--color-danger)', + investigating: 'var(--color-warning)', + closed: 'var(--fg-disabled)', + }; + + return ( +
+ {/* Header */} +
+ +
+ {inc.name} +
+ + ✕ + +
+ + {/* Tags */} +
+ + {getStatusLabel(inc.status)} + + {inc.causeType && ( + + {inc.causeType} + + )} + {inc.oilType && ( + + {inc.oilType} + + )} +
+ + {/* Info rows */} +
+
+ 일시 + + {inc.date} {inc.time} + +
+
+ 관할 + {inc.office} +
+
+ 지역 + {inc.region} +
+
+ + {/* Prediction badge */} + {inc.prediction && ( +
+ + {inc.prediction} + +
+ )} +
+ ); +} + /* ════════════════════════════════════════════════════ VesselDetailModal ════════════════════════════════════════════════════ */ diff --git a/frontend/src/tabs/incidents/components/MediaModal.tsx b/frontend/src/tabs/incidents/components/MediaModal.tsx index 0f89875..626b2a1 100755 --- a/frontend/src/tabs/incidents/components/MediaModal.tsx +++ b/frontend/src/tabs/incidents/components/MediaModal.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import type { Incident } from './IncidentsLeftPanel'; -import { fetchIncidentMedia } from '../services/incidentsApi'; -import type { MediaInfo } from '../services/incidentsApi'; +import { fetchIncidentMedia, fetchIncidentAerialMedia, getMediaImageUrl } from '../services/incidentsApi'; +import type { MediaInfo, AerialMediaItem } from '../services/incidentsApi'; type MediaTab = 'all' | 'photo' | 'video' | 'satellite' | 'cctv'; @@ -35,9 +35,12 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose: const [activeTab, setActiveTab] = useState('all'); const [selectedCam, setSelectedCam] = useState(0); const [media, setMedia] = useState(null); + const [aerialImages, setAerialImages] = useState([]); + const [selectedImageIdx, setSelectedImageIdx] = useState(0); useEffect(() => { fetchIncidentMedia(parseInt(incident.id)).then(setMedia); + fetchIncidentAerialMedia(parseInt(incident.id)).then(setAerialImages); }, [incident.id]); // 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 showVideo = activeTab === 'all' || activeTab === 'video'; @@ -233,61 +236,171 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
📷 - 현장사진 — {str(media.photoMeta, 'title', '현장 사진')} + 현장사진 — {aerialImages.length > 0 ? `${aerialImages.length}장` : str(media.photoMeta, 'title', '현장 사진')}
- + {aerialImages.length > 1 && ( + <> + setSelectedImageIdx((p) => Math.max(0, p - 1))} /> + setSelectedImageIdx((p) => Math.min(aerialImages.length - 1, p + 1))} /> + + )} +
{/* Photo content */} -
-
- 📷 -
-
- {incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')} 해상 사진 -
-
- {str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')} -
+
+ {aerialImages.length > 0 ? ( + <> + {aerialImages[selectedImageIdx].orgnlNm { + (e.target as HTMLImageElement).style.display = 'none'; + (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); + }} + /> +
+
📷
+
이미지를 불러올 수 없습니다
+
+ {aerialImages.length > 1 && ( + <> + + + + )} +
+ {selectedImageIdx + 1} / {aerialImages.length} +
+ + ) : ( +
+
📷
+
+ {incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')} 해상 사진 +
+
+ {str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')} +
+
+ )}
{/* Thumbnails */}
-
- {Array.from({ length: Math.min(num(media.photoMeta, 'thumbCount'), 7) }).map( - (_, i) => ( -
0 ? ( + <> +
+ {aerialImages.map((img, i) => ( +
- 📷 -
- ), - )} -
-
- - 📷 사진 {num(media.photoMeta, 'thumbCount')}장 · {str(media.photoMeta, 'stage')} - - - 🔗 R&D 연계 - -
+ }} + onClick={() => setSelectedImageIdx(i)} + > + {img.orgnlNm { + const el = e.target as HTMLImageElement; + el.style.display = 'none'; + }} + /> +
+ ))} +
+
+ + 📷 사진 {aerialImages.length}장 + {aerialImages[selectedImageIdx]?.takngDtm + ? ` · ${new Date(aerialImages[selectedImageIdx].takngDtm!).toLocaleDateString('ko-KR')}` + : ''} + + + {aerialImages[selectedImageIdx]?.orgnlNm ?? ''} + +
+ + ) : ( + <> +
+ {Array.from({ length: Math.min(num(media.photoMeta, 'thumbCount'), 7) }).map( + (_, i) => ( +
+ 📷 +
+ ), + )} +
+
+ + 📷 사진 {num(media.photoMeta, 'thumbCount')}장 · {str(media.photoMeta, 'stage')} + + + 🔗 R&D 연계 + +
+ + )}
)} @@ -560,16 +673,16 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose: >
- 📷 사진 {media.photoCnt} + 📷 사진 {aerialImages.length > 0 ? aerialImages.length : (media.photoCnt ?? 0)} - 🎬 영상 {media.videoCnt} + 🎬 영상 {media.videoCnt ?? 0} - 🛰 위성 {media.satCnt} + 🛰 위성 {media.satCnt ?? 0} - 📹 CCTV {media.cctvCnt} + 📹 CCTV {media.cctvCnt ?? 0} 📎 총 {total}건 @@ -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 (
- {/* Direct Input Mode */} - {inputMode === 'direct' && ( - <> - onIncidentNameChange(e.target.value)} - style={ - validationErrors?.has('incidentName') - ? { borderColor: 'var(--color-danger)' } - : undefined - } - /> - - - )} + {/* 사고명 입력 (직접입력 / 이미지업로드 공통) */} + onIncidentNameChange(e.target.value)} + style={ + validationErrors?.has('incidentName') + ? { borderColor: 'var(--color-danger)' } + : undefined + } + /> + {/* Image Upload Mode */} {inputMode === 'upload' && ( @@ -353,10 +349,10 @@ const PredictionInputSection = ({ className="prd-i" placeholder="유출량" type="number" - min="1" - step="1" + min="0" + step="any" value={spillAmount} - onChange={(e) => onSpillAmountChange(parseInt(e.target.value) || 0)} + onChange={(e) => onSpillAmountChange(parseFloat(e.target.value) || 0)} /> {/* 오염 종합 상황 */} -
+
{/* 확산 예측 요약 */} -
+
= 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 +const BADGE_STYLES: Record = { + 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({ title, badge, @@ -607,7 +663,7 @@ function Section({ }: { title: string; badge?: string; - badgeColor?: 'red' | 'green'; + badgeColor?: 'red' | 'orange' | 'yellow' | 'green'; children: React.ReactNode; }) { return ( @@ -617,9 +673,7 @@ function Section({ {badge && ( {badge} diff --git a/frontend/src/tabs/prediction/services/predictionApi.ts b/frontend/src/tabs/prediction/services/predictionApi.ts index 248d4e9..64c446f 100644 --- a/frontend/src/tabs/prediction/services/predictionApi.ts +++ b/frontend/src/tabs/prediction/services/predictionApi.ts @@ -311,9 +311,10 @@ export interface ImageAnalyzeResult { occurredAt: string; } -export const analyzeImage = async (file: File): Promise => { +export const analyzeImage = async (file: File, acdntNm?: string): Promise => { const formData = new FormData(); formData.append('image', file); + if (acdntNm?.trim()) formData.append('acdntNm', acdntNm.trim()); const response = await api.post('/prediction/image-analyze', formData, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 330_000,