From 50945c90492117f463552160d99ba33840c7bf62 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Tue, 24 Mar 2026 16:56:03 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=9C=A0=EB=A5=98=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B3=B4=EA=B3=A0=EC=84=9C=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EA=B0=9C=EC=84=A0=20+=20=EC=98=88=EC=B8=A1=20API?= =?UTF-8?q?=20=ED=99=95=EC=9E=A5=20+=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=88=ED=84=B0=EB=A7=81=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .claude/workflow-version.json | 2 +- backend/src/prediction/predictionRouter.ts | 33 + backend/src/prediction/predictionService.ts | 100 ++- backend/src/reports/reportsService.ts | 4 +- backend/src/routes/layers.ts | 80 ++- backend/src/routes/simulation.ts | 6 + .../migration/027_sensitivity_evaluation.sql | 27 + frontend/src/common/hooks/useLayers.ts | 2 +- frontend/src/common/hooks/useSubMenu.ts | 6 + .../src/tabs/admin/components/AdminView.tsx | 2 + .../src/tabs/admin/components/LayerPanel.tsx | 32 +- .../admin/components/MonitorRealtimePanel.tsx | 436 +++++++++++++ .../components/InfoLayerSection.tsx | 26 +- .../tabs/prediction/components/LeftPanel.tsx | 85 ++- .../prediction/components/OilSpillView.tsx | 18 + .../tabs/prediction/components/RightPanel.tsx | 70 ++- .../tabs/prediction/services/predictionApi.ts | 31 + .../components/OilSpillReportTemplate.tsx | 586 +++++++++++++++++- .../reports/components/ReportGenerator.tsx | 59 +- .../tabs/reports/components/ReportsView.tsx | 2 + .../src/tabs/reports/components/hwpxExport.ts | 29 +- .../tabs/reports/components/reportUtils.ts | 17 +- .../src/tabs/reports/services/reportsApi.ts | 25 +- 23 files changed, 1594 insertions(+), 84 deletions(-) create mode 100644 database/migration/027_sensitivity_evaluation.sql create mode 100644 frontend/src/tabs/admin/components/MonitorRealtimePanel.tsx diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index f746918..a9c8b08 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,6 +1,6 @@ { "applied_global_version": "1.6.1", - "applied_date": "2026-03-22", + "applied_date": "2026-03-24", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true diff --git a/backend/src/prediction/predictionRouter.ts b/backend/src/prediction/predictionRouter.ts index 0f91c93..a2111b1 100644 --- a/backend/src/prediction/predictionRouter.ts +++ b/backend/src/prediction/predictionRouter.ts @@ -4,6 +4,7 @@ import { listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt, createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory, getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn, + getPredictionParticlesGeojsonByAcdntSn, getSensitivityEvaluationGeojsonByAcdntSn, } from './predictionService.js'; import { analyzeImageFile } from './imageAnalyzeService.js'; import { isValidNumber } from '../middleware/security.js'; @@ -97,6 +98,38 @@ router.get('/analyses/:acdntSn/sensitive-resources/geojson', requireAuth, requir } }); +// GET /api/prediction/analyses/:acdntSn/spread-particles — 예측 확산 파티클 GeoJSON +router.get('/analyses/:acdntSn/spread-particles', 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 result = await getPredictionParticlesGeojsonByAcdntSn(acdntSn); + res.json(result); + } catch (err) { + console.error('[prediction] 확산 파티클 GeoJSON 조회 오류:', err); + res.status(500).json({ error: '확산 파티클 GeoJSON 조회 실패' }); + } +}); + +// GET /api/prediction/analyses/:acdntSn/sensitivity-evaluation — 통합민감도 평가 GeoJSON +router.get('/analyses/:acdntSn/sensitivity-evaluation', 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 result = await getSensitivityEvaluationGeojsonByAcdntSn(acdntSn); + res.json(result); + } catch (err) { + console.error('[prediction] 통합민감도 평가 GeoJSON 조회 오류:', err); + res.status(500).json({ error: '통합민감도 평가 GeoJSON 조회 실패' }); + } +}); + // GET /api/prediction/backtrack — 사고별 역추적 목록 router.get('/backtrack', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => { try { diff --git a/backend/src/prediction/predictionService.ts b/backend/src/prediction/predictionService.ts index 1af73aa..833304e 100644 --- a/backend/src/prediction/predictionService.ts +++ b/backend/src/prediction/predictionService.ts @@ -432,6 +432,8 @@ interface TrajectoryTimeStep { particles: TrajectoryParticle[]; remaining_volume_m3: number; weathered_volume_m3: number; + evaporation_volume_m3?: number; + dispersion_volume_m3?: number; pollution_area_km2: number; beached_volume_m3: number; pollution_coast_length_m: number; @@ -453,6 +455,8 @@ interface SingleModelTrajectoryResult { summary: { remainingVolume: number; weatheredVolume: number; + evaporationVolume: number; + dispersionVolume: number; pollutionArea: number; beachedVolume: number; pollutionCoastLength: number; @@ -460,6 +464,8 @@ interface SingleModelTrajectoryResult { stepSummaries: Array<{ remainingVolume: number; weatheredVolume: number; + evaporationVolume: number; + dispersionVolume: number; pollutionArea: number; beachedVolume: number; pollutionCoastLength: number; @@ -474,6 +480,8 @@ interface TrajectoryResult { summary: { remainingVolume: number; weatheredVolume: number; + evaporationVolume: number; + dispersionVolume: number; pollutionArea: number; beachedVolume: number; pollutionCoastLength: number; @@ -500,6 +508,8 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: strin const summary = { remainingVolume: lastStep.remaining_volume_m3, weatheredVolume: lastStep.weathered_volume_m3, + evaporationVolume: lastStep.evaporation_volume_m3 ?? lastStep.weathered_volume_m3 * 0.65, + dispersionVolume: lastStep.dispersion_volume_m3 ?? lastStep.weathered_volume_m3 * 0.35, pollutionArea: lastStep.pollution_area_km2, beachedVolume: lastStep.beached_volume_m3, pollutionCoastLength: lastStep.pollution_coast_length_m, @@ -514,6 +524,8 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: strin const stepSummaries = rawResult.map((step) => ({ remainingVolume: step.remaining_volume_m3, weatheredVolume: step.weathered_volume_m3, + evaporationVolume: step.evaporation_volume_m3 ?? step.weathered_volume_m3 * 0.65, + dispersionVolume: step.dispersion_volume_m3 ?? step.weathered_volume_m3 * 0.35, pollutionArea: step.pollution_area_km2, beachedVolume: step.beached_volume_m3, pollutionCoastLength: step.pollution_coast_length_m, @@ -587,7 +599,7 @@ export async function getAnalysisTrajectory(acdntSn: number): Promise { +): Promise<{ category: string; count: number; totalArea: number | null }[]> { const sql = ` WITH all_wkts AS ( SELECT step_data ->> 'wkt' AS wkt @@ -603,7 +615,13 @@ export async function getSensitiveResourcesByAcdntSn( FROM all_wkts WHERE wkt IS NOT NULL AND wkt <> '' ) - SELECT sr.CATEGORY, COUNT(*)::int AS count + SELECT sr.CATEGORY, + COUNT(*)::int AS count, + CASE + WHEN bool_and(sr.PROPERTIES ? 'area') + THEN SUM((sr.PROPERTIES->>'area')::float) + ELSE NULL + END AS total_area FROM wing.SENSITIVE_RESOURCE sr, union_geom WHERE union_geom.geom IS NOT NULL AND ST_Intersects(sr.GEOM, union_geom.geom) @@ -614,6 +632,7 @@ export async function getSensitiveResourcesByAcdntSn( return rows.map((r: Record) => ({ category: String(r['category'] ?? ''), count: Number(r['count'] ?? 0), + totalArea: r['total_area'] != null ? Number(r['total_area']) : null, })); } @@ -655,6 +674,83 @@ export async function getSensitiveResourcesGeoJsonByAcdntSn( return { type: 'FeatureCollection', features }; } +export async function getSensitivityEvaluationGeojsonByAcdntSn( + acdntSn: number, +): Promise<{ type: 'FeatureCollection'; features: unknown[] }> { + const acdntSql = `SELECT LAT, LNG FROM wing.ACDNT WHERE ACDNT_SN = $1 AND USE_YN = 'Y'`; + const { rows: acdntRows } = await wingPool.query(acdntSql, [acdntSn]); + if (acdntRows.length === 0 || acdntRows[0]['lat'] == null) return { type: 'FeatureCollection', features: [] }; + const lat = Number(acdntRows[0]['lat']); + const lng = Number(acdntRows[0]['lng']); + + const sql = ` + SELECT SR_ID, PROPERTIES, + ST_AsGeoJSON(GEOM)::jsonb AS geom_json, + ST_Area(GEOM::geography) / 1000000.0 AS area_km2 + FROM wing.SENSITIVE_EVALUATION + WHERE ST_DWithin( + GEOM::geography, + ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography, + 10000 + ) + ORDER BY SR_ID + `; + const { rows } = await wingPool.query(sql, [lat, lng]); + const features = rows.map((r: Record) => ({ + type: 'Feature', + geometry: r['geom_json'], + properties: { + srId: Number(r['sr_id']), + area_km2: Number(r['area_km2']), + ...(r['properties'] as Record ?? {}), + }, + })); + return { type: 'FeatureCollection', features }; +} + +export async function getPredictionParticlesGeojsonByAcdntSn( + acdntSn: number, +): Promise<{ type: 'FeatureCollection'; features: unknown[]; maxStep: number }> { + const sql = ` + SELECT ALGO_CD, RSLT_DATA + FROM wing.PRED_EXEC + WHERE ACDNT_SN = $1 + AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON') + AND EXEC_STTS_CD = 'COMPLETED' + AND RSLT_DATA IS NOT NULL + `; + const { rows } = await wingPool.query(sql, [acdntSn]); + if (rows.length === 0) return { type: 'FeatureCollection', features: [], maxStep: 0 }; + + const ALGO_TO_MODEL: Record = { OPENDRIFT: 'OpenDrift', POSEIDON: 'POSEIDON' }; + const features: unknown[] = []; + let globalMaxStep = 0; + + for (const row of rows) { + const model = ALGO_TO_MODEL[String(row['algo_cd'])] ?? String(row['algo_cd']); + const steps = row['rslt_data'] as TrajectoryTimeStep[]; + const maxStep = steps.length - 1; + if (maxStep > globalMaxStep) globalMaxStep = maxStep; + + steps.forEach((step, stepIdx) => { + step.particles.forEach(p => { + features.push({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [p.lon, p.lat] }, + properties: { + model, + time: stepIdx, + stranded: p.stranded ?? 0, + isLastStep: stepIdx === maxStep, + }, + }); + }); + }); + } + + return { type: 'FeatureCollection', features, maxStep: globalMaxStep }; +} + export async function listBoomLines(acdntSn: number): Promise { const sql = ` SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD, diff --git a/backend/src/reports/reportsService.ts b/backend/src/reports/reportsService.ts index 674dceb..147aff0 100644 --- a/backend/src/reports/reportsService.ts +++ b/backend/src/reports/reportsService.ts @@ -60,6 +60,7 @@ interface ReportListItem { sttsCd: string; authorId: string; authorName: string; + acdntSn: number | null; regDtm: string; mdfcnDtm: string | null; hasMapCapture: boolean; @@ -263,7 +264,7 @@ export async function listReports(input: ListReportsInput): Promise '') OR (r.STEP6_MAP_IMG IS NOT NULL AND r.STEP6_MAP_IMG <> '') THEN true ELSE false END AS HAS_MAP_CAPTURE @@ -289,6 +290,7 @@ export async function listReports(input: ListReportsInput): Promise { try { const { rows } = await wingPool.query( - `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD` + `${ACTIVE_TREE_CTE} + SELECT ${LAYER_COLUMNS} FROM LAYER + WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree) + ORDER BY LAYER_CD` ) const enrichedLayers = rows.map(enrichLayerWithMetadata) res.json(enrichedLayers) @@ -52,7 +68,10 @@ router.get('/', async (_req, res) => { router.get('/tree/all', async (_req, res) => { try { const { rows } = await wingPool.query( - `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD` + `${ACTIVE_TREE_CTE} + SELECT ${LAYER_COLUMNS} FROM LAYER + WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree) + ORDER BY LAYER_CD` ) const enrichedLayers = rows.map(enrichLayerWithMetadata) @@ -84,7 +103,10 @@ router.get('/tree/all', async (_req, res) => { router.get('/wms/all', async (_req, res) => { try { const { rows } = await wingPool.query( - `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE WMS_LAYER_NM IS NOT NULL AND USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD` + `${ACTIVE_TREE_CTE} + SELECT ${LAYER_COLUMNS} FROM LAYER + WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree) AND WMS_LAYER_NM IS NOT NULL + ORDER BY LAYER_CD` ) const enrichedLayers = rows.map(enrichLayerWithMetadata) res.json(enrichedLayers) @@ -106,7 +128,10 @@ router.get('/level/:level', async (req, res) => { } const { rows } = await wingPool.query( - `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_LEVEL = $1 AND USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`, + `${ACTIVE_TREE_CTE} + SELECT ${LAYER_COLUMNS} FROM LAYER + WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree) AND LAYER_LEVEL = $1 + ORDER BY LAYER_CD`, [level] ) const enrichedLayers = rows.map(enrichLayerWithMetadata) @@ -212,20 +237,27 @@ router.get('/admin/list', requireAuth, requireRole('ADMIN'), async (req, res) => const [dataResult, countResult] = await Promise.all([ wingPool.query( `SELECT - LAYER_CD AS "layerCd", - UP_LAYER_CD AS "upLayerCd", - LAYER_FULL_NM AS "layerFullNm", - LAYER_NM AS "layerNm", - LAYER_LEVEL AS "layerLevel", - WMS_LAYER_NM AS "wmsLayerNm", - DATA_TBL_NM AS "dataTblNm", - USE_YN AS "useYn", - SORT_ORD AS "sortOrd", - TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm" - FROM LAYER - ${whereClause} - ORDER BY LAYER_CD - LIMIT $${limitIdx} OFFSET $${offsetIdx}`, + t.*, + p.USE_YN AS "parentUseYn" + FROM ( + SELECT + LAYER_CD AS "layerCd", + UP_LAYER_CD AS "upLayerCd", + LAYER_FULL_NM AS "layerFullNm", + LAYER_NM AS "layerNm", + LAYER_LEVEL AS "layerLevel", + WMS_LAYER_NM AS "wmsLayerNm", + DATA_TBL_NM AS "dataTblNm", + USE_YN AS "useYn", + SORT_ORD AS "sortOrd", + TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm" + FROM LAYER + ${whereClause} + ORDER BY LAYER_CD + LIMIT $${limitIdx} OFFSET $${offsetIdx} + ) t + LEFT JOIN LAYER p ON t."upLayerCd" = p.LAYER_CD AND p.DEL_YN = 'N' + ORDER BY t."layerCd"`, dataParams ), wingPool.query( @@ -454,6 +486,18 @@ router.post('/admin/delete', requireAuth, requireRole('ADMIN'), async (req, res) const sanitizedCd = sanitizeString(layerCd) + // 하위 레이어 존재 여부 확인 (자식이 있으면 삭제 차단) + const { rows: childRows } = await wingPool.query( + `SELECT COUNT(*)::int AS cnt FROM LAYER WHERE UP_LAYER_CD = $1 AND DEL_YN = 'N'`, + [sanitizedCd] + ) + const childCount: number = childRows[0].cnt + if (childCount > 0) { + return res.status(400).json({ + error: `하위 레이어 ${childCount}개가 있어 삭제할 수 없습니다. 하위 레이어를 먼저 삭제해주세요.`, + }) + } + const { rows } = await wingPool.query( `UPDATE LAYER SET DEL_YN = 'Y' WHERE LAYER_CD = $1 AND DEL_YN = 'N' RETURNING LAYER_CD AS "layerCd"`, diff --git a/backend/src/routes/simulation.ts b/backend/src/routes/simulation.ts index d8ce272..2eea5bd 100755 --- a/backend/src/routes/simulation.ts +++ b/backend/src/routes/simulation.ts @@ -849,6 +849,8 @@ interface PythonTimeStep { particles: PythonParticle[] remaining_volume_m3: number weathered_volume_m3: number + evaporation_m3?: number + dispersion_m3?: number pollution_area_km2: number beached_volume_m3: number pollution_coast_length_m: number @@ -885,6 +887,8 @@ function transformResult(rawResult: PythonTimeStep[], model: string) { const summary = { remainingVolume: lastStep.remaining_volume_m3, weatheredVolume: lastStep.weathered_volume_m3, + evaporationVolume: lastStep.evaporation_m3 ?? lastStep.weathered_volume_m3 * 0.65, + dispersionVolume: lastStep.dispersion_m3 ?? lastStep.weathered_volume_m3 * 0.35, pollutionArea: lastStep.pollution_area_km2, beachedVolume: lastStep.beached_volume_m3, pollutionCoastLength: lastStep.pollution_coast_length_m, @@ -905,6 +909,8 @@ function transformResult(rawResult: PythonTimeStep[], model: string) { const stepSummaries = rawResult.map((step) => ({ remainingVolume: step.remaining_volume_m3, weatheredVolume: step.weathered_volume_m3, + evaporationVolume: step.evaporation_m3 ?? step.weathered_volume_m3 * 0.65, + dispersionVolume: step.dispersion_m3 ?? step.weathered_volume_m3 * 0.35, pollutionArea: step.pollution_area_km2, beachedVolume: step.beached_volume_m3, pollutionCoastLength: step.pollution_coast_length_m, diff --git a/database/migration/027_sensitivity_evaluation.sql b/database/migration/027_sensitivity_evaluation.sql new file mode 100644 index 0000000..c6c5b8d --- /dev/null +++ b/database/migration/027_sensitivity_evaluation.sql @@ -0,0 +1,27 @@ +-- ============================================================ +-- 027: 통합민감도 평가 테이블 생성 +-- 계절별 민감도 평가 그리드 데이터 저장 +-- properties 구조: { ID, FA_G, SM_G, SP_G, WT_G, MAX_G, GRID_LEVEL } +-- ============================================================ + +SET search_path TO wing, public; + +CREATE EXTENSION IF NOT EXISTS postgis; + +CREATE TABLE IF NOT EXISTS SENSITIVE_EVALUATION ( + SR_ID BIGSERIAL PRIMARY KEY, + CATEGORY VARCHAR(50) NOT NULL DEFAULT '민감도평가', + GEOM public.geometry(Geometry, 4326) NOT NULL, + PROPERTIES JSONB NOT NULL DEFAULT '{}', + REG_DT TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + MOD_DT TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS IDX_SE_GEOM ON SENSITIVE_EVALUATION USING GIST(GEOM); +CREATE INDEX IF NOT EXISTS IDX_SE_PROPERTIES ON SENSITIVE_EVALUATION USING GIN(PROPERTIES); + +COMMENT ON TABLE SENSITIVE_EVALUATION IS '통합민감도 평가 그리드 테이블'; +COMMENT ON COLUMN SENSITIVE_EVALUATION.SR_ID IS '민감도 평가 ID'; +COMMENT ON COLUMN SENSITIVE_EVALUATION.CATEGORY IS '카테고리 (기본값: 민감도평가)'; +COMMENT ON COLUMN SENSITIVE_EVALUATION.GEOM IS '공간 데이터 (EPSG:4326)'; +COMMENT ON COLUMN SENSITIVE_EVALUATION.PROPERTIES IS '계절별 민감도 값 { SP_G, SM_G, FA_G, WT_G, MAX_G, GRID_LEVEL }'; diff --git a/frontend/src/common/hooks/useLayers.ts b/frontend/src/common/hooks/useLayers.ts index 62654a2..670a221 100755 --- a/frontend/src/common/hooks/useLayers.ts +++ b/frontend/src/common/hooks/useLayers.ts @@ -13,11 +13,11 @@ export function useLayers() { } // 계층 구조 레이어 트리 조회 훅 +// staleTime 없음 → 마운트 시 항상 최신 데이터 요청 (관리자 설정 즉시 반영) export function useLayerTree() { return useQuery({ queryKey: ['layers', 'tree'], queryFn: fetchLayerTree, - staleTime: 1000 * 60 * 5, retry: 3, }) } diff --git a/frontend/src/common/hooks/useSubMenu.ts b/frontend/src/common/hooks/useSubMenu.ts index d84fb70..972ff15 100755 --- a/frontend/src/common/hooks/useSubMenu.ts +++ b/frontend/src/common/hooks/useSubMenu.ts @@ -235,6 +235,12 @@ export interface OilReportPayload { centerPoints: { lat: number; lon: number; time: number }[]; simulationStartTime: string; } | null; + sensitiveResources?: Array<{ + category: string; + count: number; + totalArea: number | null; + }>; + acdntSn?: number; } let _oilReportPayload: OilReportPayload | null = null; diff --git a/frontend/src/tabs/admin/components/AdminView.tsx b/frontend/src/tabs/admin/components/AdminView.tsx index 1401309..be9e70b 100755 --- a/frontend/src/tabs/admin/components/AdminView.tsx +++ b/frontend/src/tabs/admin/components/AdminView.tsx @@ -14,6 +14,7 @@ import MapBasePanel from './MapBasePanel'; import LayerPanel from './LayerPanel'; import SensitiveLayerPanel from './SensitiveLayerPanel'; import DispersingZonePanel from './DispersingZonePanel'; +import MonitorRealtimePanel from './MonitorRealtimePanel'; /** 기존 패널이 있는 메뉴 ID 매핑 */ const PANEL_MAP: Record JSX.Element> = { @@ -32,6 +33,7 @@ const PANEL_MAP: Record JSX.Element> = { 'env-ecology': () => , 'social-economy': () => , 'dispersant-zone': () => , + 'monitor-realtime': () => , }; export function AdminView() { diff --git a/frontend/src/tabs/admin/components/LayerPanel.tsx b/frontend/src/tabs/admin/components/LayerPanel.tsx index c224f05..6b69ec6 100644 --- a/frontend/src/tabs/admin/components/LayerPanel.tsx +++ b/frontend/src/tabs/admin/components/LayerPanel.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { api } from '@common/services/api'; interface LayerAdminItem { @@ -11,6 +12,7 @@ interface LayerAdminItem { useYn: string; sortOrd: number; regDtm: string | null; + parentUseYn: string | null; } interface LayerListResponse { @@ -313,6 +315,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP // ---------- LayerPanel ---------- const LayerPanel = () => { + const queryClient = useQueryClient(); const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [totalPages, setTotalPages] = useState(1); @@ -359,10 +362,15 @@ const LayerPanel = () => { try { const result = await toggleLayerUse(layerCd); setItems(prev => - prev.map(item => - item.layerCd === result.layerCd ? { ...item, useYn: result.useYn } : item - ) + prev.map(item => { + if (item.layerCd === result.layerCd) return { ...item, useYn: result.useYn }; + // 직접 자식의 parentUseYn도 즉시 동기화 + if (item.upLayerCd === result.layerCd) return { ...item, parentUseYn: result.useYn }; + return item; + }) ); + // 레이어 캐시 무효화 → 예측 탭 등 useLayerTree 구독자가 최신 데이터 수신 + queryClient.invalidateQueries({ queryKey: ['layers'] }); } catch { setError('사용여부 변경에 실패했습니다.'); } finally { @@ -522,12 +530,20 @@ const LayerPanel = () => { + + + + {/* 탭 */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* 상태 표시줄 */} +
+ + + {activeTab === 'khoa' && `관측소 ${totalCount}개`} + {activeTab === 'kma-ultra' && `지점 ${totalCount}개`} + {activeTab === 'kma-marine' && `해역 ${totalCount}개`} + +
+ + {/* 테이블 콘텐츠 */} +
+ {activeTab === 'khoa' && ( + + )} + {activeTab === 'kma-ultra' && ( + + )} + {activeTab === 'kma-marine' && ( + + )} +
+ + ); +} diff --git a/frontend/src/tabs/prediction/components/InfoLayerSection.tsx b/frontend/src/tabs/prediction/components/InfoLayerSection.tsx index 74d1338..8a90a26 100644 --- a/frontend/src/tabs/prediction/components/InfoLayerSection.tsx +++ b/frontend/src/tabs/prediction/components/InfoLayerSection.tsx @@ -1,8 +1,6 @@ -import { useState, useMemo } from 'react' +import { useState } from 'react' import { LayerTree } from '@common/components/layer/LayerTree' import { useLayerTree } from '@common/hooks/useLayers' -import { layerData } from '@common/data/layerData' -import type { LayerNode } from '@common/data/layerData' import type { Layer } from '@common/services/layerService' interface InfoLayerSectionProps { @@ -26,29 +24,13 @@ const InfoLayerSection = ({ layerBrightness, onLayerBrightnessChange, }: InfoLayerSectionProps) => { - // API에서 레이어 트리 데이터 가져오기 + // API에서 레이어 트리 데이터 가져오기 (관리자 설정 USE_YN='Y' 레이어만 반환) const { data: layerTree, isLoading } = useLayerTree() const [layerColors, setLayerColors] = useState>({}) - // 정적 데이터를 Layer 형식으로 변환 (API 실패 시 폴백) - const staticLayers = useMemo(() => { - const convert = (node: LayerNode): Layer => ({ - id: node.code, - parentId: node.parentCode, - name: node.name, - fullName: node.fullName, - level: node.level, - wmsLayer: node.layerName, - icon: node.icon, - count: node.count, - children: node.children?.map(convert), - }) - return layerData.map(convert) - }, []) - - // API 데이터 우선, 실패 시 정적 데이터 폴백 - const effectiveLayers = (layerTree && layerTree.length > 0) ? layerTree : staticLayers + // 관리자에서 사용여부가 ON인 레이어만 표시 (정적 폴백 없음) + const effectiveLayers: Layer[] = layerTree ?? [] return (
diff --git a/frontend/src/tabs/prediction/components/LeftPanel.tsx b/frontend/src/tabs/prediction/components/LeftPanel.tsx index 56846c5..cfcf81c 100755 --- a/frontend/src/tabs/prediction/components/LeftPanel.tsx +++ b/frontend/src/tabs/prediction/components/LeftPanel.tsx @@ -1,5 +1,63 @@ import { useState } from 'react' import type { LeftPanelProps, ExpandedSections } from './leftPanelTypes' + +interface CategoryMeta { + icon: string; + bg: string; +} + +const CATEGORY_ICON_MAP: Record = { + // 수산자원 / 양식장 (green) + '어장정보': { icon: '🐟', bg: 'rgba(34,197,94,0.15)' }, + '양식장': { icon: '🦪', bg: 'rgba(34,197,94,0.15)' }, + '양식어업': { icon: '🦪', bg: 'rgba(34,197,94,0.15)' }, + '어류양식장': { icon: '🐟', bg: 'rgba(34,197,94,0.15)' }, + '패류양식장': { icon: '🦪', bg: 'rgba(34,197,94,0.15)' }, + '해조류양식장': { icon: '🌿', bg: 'rgba(34,197,94,0.15)' }, + '가두리양식장': { icon: '🔲', bg: 'rgba(34,197,94,0.15)' }, + '갑각류양식장': { icon: '🦐', bg: 'rgba(34,197,94,0.15)' }, + '기타양식장': { icon: '📦', bg: 'rgba(34,197,94,0.15)' }, + '영세어업': { icon: '🎣', bg: 'rgba(34,197,94,0.15)' }, + '유어장': { icon: '🎣', bg: 'rgba(34,197,94,0.15)' }, + '수산시장': { icon: '🐟', bg: 'rgba(34,197,94,0.15)' }, + '인공어초': { icon: '🪸', bg: 'rgba(34,197,94,0.15)' }, + '암초': { icon: '🪨', bg: 'rgba(34,197,94,0.15)' }, + '침선': { icon: '🚢', bg: 'rgba(34,197,94,0.15)' }, + // 관광자원 / 낚시 (yellow) + '해수욕장': { icon: '🏖', bg: 'rgba(250,204,21,0.15)' }, + '갯바위낚시': { icon: '🪨', bg: 'rgba(250,204,21,0.15)' }, + '선상낚시': { icon: '🚤', bg: 'rgba(250,204,21,0.15)' }, + '마리나항': { icon: '⛵', bg: 'rgba(250,204,21,0.15)' }, + // 항만 / 산업시설 (blue) + '무역항': { icon: '🚢', bg: 'rgba(99,179,237,0.15)' }, + '연안항': { icon: '⛵', bg: 'rgba(99,179,237,0.15)' }, + '국가어항': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' }, + '지방어항': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' }, + '어항': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' }, + '항만구역': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' }, + '항로': { icon: '🚢', bg: 'rgba(99,179,237,0.15)' }, + '정박지': { icon: '⛵', bg: 'rgba(99,179,237,0.15)' }, + '항로표지': { icon: '🔴', bg: 'rgba(99,179,237,0.15)' }, + '해수취수시설': { icon: '💧', bg: 'rgba(99,179,237,0.15)' }, + '취수구·배수구': { icon: '🚰', bg: 'rgba(99,179,237,0.15)' }, + 'LNG': { icon: '⚡', bg: 'rgba(99,179,237,0.15)' }, + '발전소': { icon: '🔌', bg: 'rgba(99,179,237,0.15)' }, + '발전소·산단': { icon: '🏭', bg: 'rgba(99,179,237,0.15)' }, + '임해공단': { icon: '🏭', bg: 'rgba(99,179,237,0.15)' }, + '저유시설': { icon: '🛢', bg: 'rgba(99,179,237,0.15)' }, + '해저케이블·배관': { icon: '🔌', bg: 'rgba(99,179,237,0.15)' }, + // 환경 / 생태 (lime) + '갯벌': { icon: '🪨', bg: 'rgba(163,230,53,0.12)' }, + '해안선_ESI': { icon: '🏖', bg: 'rgba(163,230,53,0.12)' }, + '보호지역': { icon: '🛡', bg: 'rgba(163,230,53,0.12)' }, + '해양보호구역': { icon: '🌿', bg: 'rgba(163,230,53,0.12)' }, + '철새도래지': { icon: '🐦', bg: 'rgba(163,230,53,0.12)' }, + '습지보호구역': { icon: '🏖', bg: 'rgba(163,230,53,0.12)' }, + '보호종서식지': { icon: '🐢', bg: 'rgba(163,230,53,0.12)' }, + '보호종 서식지': { icon: '🐢', bg: 'rgba(163,230,53,0.12)' }, +}; + +const FALLBACK_META: CategoryMeta = { icon: '🌊', bg: 'rgba(148,163,184,0.15)' }; import PredictionInputSection from './PredictionInputSection' import InfoLayerSection from './InfoLayerSection' import OilBoomSection from './OilBoomSection' @@ -209,12 +267,27 @@ export function LeftPanel({

영향받는 민감자원 목록

) : (
- {sensitiveResources.map(({ category, count }) => ( -
- {category} - {count}개 -
- ))} + {sensitiveResources.map(({ category, count, totalArea }) => { + const meta = CATEGORY_ICON_MAP[category] ?? FALLBACK_META; + return ( +
+
+ + {meta.icon} + + {category} +
+ + {totalArea != null + ? `${totalArea.toLocaleString('ko-KR', { maximumFractionDigits: 2 })} ha` + : `${count}개`} + +
+ ); + })}
)}
diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index bc40199..d8d5196 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -200,6 +200,13 @@ export function OilSpillView() { const [summaryByModel, setSummaryByModel] = useState>({}) const [stepSummariesByModel, setStepSummariesByModel] = useState>({}) + // 펜스차단량 계산 (오일붐 차단 효율 × 총 유류량) + const boomBlockedVolume = useMemo(() => { + if (!containmentResult || !simulationSummary) return 0; + const totalVolumeM3 = simulationSummary.remainingVolume + simulationSummary.weatheredVolume + simulationSummary.beachedVolume; + return totalVolumeM3 * (containmentResult.overallEfficiency / 100); + }, [containmentResult, simulationSummary]) + // 오염분석 상태 const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon') const [drawAnalysisMode, setDrawAnalysisMode] = useState<'polygon' | null>(null) @@ -938,6 +945,14 @@ export function OilSpillView() { return [toRow('3시간', steps[3]), toRow('6시간', steps[6])]; })(), hasSimulation: simulationSummary !== null, + sensitiveResources: sensitiveResourceCategories.length > 0 + ? sensitiveResourceCategories.map(r => ({ + category: r.category, + count: r.count, + totalArea: r.totalArea, + })) + : undefined, + acdntSn: selectedAnalysis?.acdntSn ?? undefined, mapData: incidentCoord ? { center: [incidentCoord.lat, incidentCoord.lon], zoom: 10, @@ -1253,6 +1268,7 @@ export function OilSpillView() { onOpenReport={handleOpenReport} detail={analysisDetail} summary={stepSummariesByModel[windHydrModel]?.[currentStep] ?? summaryByModel[windHydrModel] ?? simulationSummary} + boomBlockedVolume={boomBlockedVolume} displayControls={displayControls} onDisplayControlsChange={setDisplayControls} windHydrModel={windHydrModel} @@ -1266,6 +1282,8 @@ export function OilSpillView() { onCircleRadiusChange={setCircleRadiusNm} analysisResult={analysisResult} incidentCoord={incidentCoord} + centerPoints={centerPoints} + predictionTime={predictionTime} onStartPolygonDraw={handleStartPolygonDraw} onRunPolygonAnalysis={handleRunPolygonAnalysis} onRunCircleAnalysis={handleRunCircleAnalysis} diff --git a/frontend/src/tabs/prediction/components/RightPanel.tsx b/frontend/src/tabs/prediction/components/RightPanel.tsx index f3cf152..2094c32 100755 --- a/frontend/src/tabs/prediction/components/RightPanel.tsx +++ b/frontend/src/tabs/prediction/components/RightPanel.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react' -import type { PredictionDetail, SimulationSummary } from '../services/predictionApi' +import { useState, useMemo } from 'react' +import type { PredictionDetail, SimulationSummary, CenterPoint } from '../services/predictionApi' import type { DisplayControls } from './OilSpillView' +import { haversineDistance, computeBearing } from '@common/utils/geo' interface AnalysisResult { area: number @@ -29,6 +30,9 @@ interface RightPanelProps { onCircleRadiusChange?: (nm: number) => void analysisResult?: AnalysisResult | null incidentCoord?: { lat: number; lon: number } | null + centerPoints?: CenterPoint[] + predictionTime?: number + boomBlockedVolume?: number onStartPolygonDraw?: () => void onRunPolygonAnalysis?: () => void onRunCircleAnalysis?: () => void @@ -44,6 +48,10 @@ export function RightPanel({ drawAnalysisMode, analysisPolygonPoints = [], circleRadiusNm = 5, onCircleRadiusChange, analysisResult, + incidentCoord, + centerPoints, + predictionTime, + boomBlockedVolume = 0, onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis, onCancelAnalysis, onClearAnalysis, }: RightPanelProps) { @@ -54,6 +62,38 @@ export function RightPanel({ const [shipExpanded, setShipExpanded] = useState(false) const [insuranceExpanded, setInsuranceExpanded] = useState(false) + const weatheringStatus = useMemo(() => { + if (!summary) return null; + const total = summary.remainingVolume + summary.evaporationVolume + + summary.dispersionVolume + summary.beachedVolume + boomBlockedVolume; + if (total <= 0) return null; + const pct = (v: number) => Math.round((v / total) * 100); + return { + surface: pct(summary.remainingVolume), + evaporation: pct(summary.evaporationVolume), + dispersion: pct(summary.dispersionVolume), + boom: pct(boomBlockedVolume), + beached: pct(summary.beachedVolume), + }; + }, [summary, boomBlockedVolume]) + + const spreadSummary = useMemo(() => { + if (!incidentCoord || !centerPoints || centerPoints.length === 0) return null + const finalPoint = [...centerPoints].sort((a, b) => b.time - a.time)[0] + const distM = haversineDistance(incidentCoord, { lat: finalPoint.lat, lon: finalPoint.lon }) + const distKm = distM / 1000 + const bearing = computeBearing(incidentCoord, { lat: finalPoint.lat, lon: finalPoint.lon }) + const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] + const dirLabel = directions[Math.round(bearing / 45) % 8] + const speedMs = predictionTime && predictionTime > 0 ? distM / (predictionTime * 3600) : null + return { + area: summary?.pollutionArea ?? null, + distance: distKm, + directionLabel: `${dirLabel} ${Math.round(bearing)}°`, + speed: speedMs, + } + }, [incidentCoord, centerPoints, summary, predictionTime]) + return (
{/* Tab Header */} @@ -236,23 +276,29 @@ export function RightPanel({ {/* 확산 예측 요약 */} -
+
- - - - + + + +
{/* 유출유 풍화 상태 */}
- - - - - + {weatheringStatus ? ( + <> + + + + + + + ) : ( +

시뮬레이션 실행 후 표시됩니다

+ )}
diff --git a/frontend/src/tabs/prediction/services/predictionApi.ts b/frontend/src/tabs/prediction/services/predictionApi.ts index 57ea61f..0d1c12b 100644 --- a/frontend/src/tabs/prediction/services/predictionApi.ts +++ b/frontend/src/tabs/prediction/services/predictionApi.ts @@ -168,6 +168,8 @@ export interface OilParticle { export interface SimulationSummary { remainingVolume: number; weatheredVolume: number; + evaporationVolume: number; + dispersionVolume: number; pollutionArea: number; beachedVolume: number; pollutionCoastLength: number; @@ -222,6 +224,7 @@ export const fetchAnalysisTrajectory = async (acdntSn: number): Promise; + maxStep: number; +} + +export const fetchPredictionParticlesGeojson = async ( + acdntSn: number, +): Promise => { + const response = await api.get( + `/prediction/analyses/${acdntSn}/spread-particles`, + ); + return response.data; +}; + +export const fetchSensitivityEvaluationGeojson = async ( + acdntSn: number, +): Promise<{ type: 'FeatureCollection'; features: unknown[] }> => { + const response = await api.get<{ type: 'FeatureCollection'; features: unknown[] }>( + `/prediction/analyses/${acdntSn}/sensitivity-evaluation`, + ); + return response.data; +}; + // ============================================================ // 이미지 업로드 분석 // ============================================================ diff --git a/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx b/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx index abde3a4..fe73419 100755 --- a/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx +++ b/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx @@ -1,5 +1,8 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' +import maplibregl from 'maplibre-gl' +import 'maplibre-gl/dist/maplibre-gl.css' import { saveReport } from '../services/reportsApi' +import { fetchSensitiveResourcesGeojson, fetchPredictionParticlesGeojson, fetchSensitivityEvaluationGeojson } from '@tabs/prediction/services/predictionApi' // ─── Data Types ───────────────────────────────────────────── export type ReportType = '초기보고서' | '지휘부 보고' | '예측보고서' | '종합보고서' | '유출유 보고' @@ -42,6 +45,9 @@ export interface OilSpillReportData { step3MapImage?: string; step6MapImage?: string; hasMapCapture?: boolean; + acdntSn?: number; + sensitiveMapImage?: string; + sensitivityMapImage?: string; } // eslint-disable-next-line react-refresh/only-export-components @@ -320,6 +326,396 @@ function Page3({ data, editing, onChange }: { data: OilSpillReportData; editing: ) } +const getSeasonKey = (occurTime: string): 'SP_G' | 'SM_G' | 'FA_G' | 'WT_G' => { + const m = occurTime.match(/\d{4}[.\-\s]+(\d{1,2})[.\-\s]/) + const month = m ? parseInt(m[1]) : 0 + if (month >= 3 && month <= 5) return 'SP_G' + if (month >= 6 && month <= 8) return 'SM_G' + if (month >= 9 && month <= 11) return 'FA_G' + return 'WT_G' +} + +const parseCoord = (s: string): number | null => { + const d = parseFloat(s) + if (!isNaN(d)) return d + const m = s.match(/(\d+)[°]\s*(\d+)[′']\s*([\d.]+)[″"]/) + if (m) return Number(m[1]) + Number(m[2]) / 60 + Number(m[3]) / 3600 + return null +} + +const 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)) +} + +const getGeomCentroid = (geom: { type: string; coordinates: unknown }): [number, number] | null => { + if (geom.type === 'Point') return geom.coordinates as [number, number] + if (geom.type === 'Polygon') { + const pts = (geom.coordinates as [number, number][][])[0] + const avg = pts.reduce((a, c) => [a[0] + c[0], a[1] + c[1]], [0, 0]) + return [avg[0] / pts.length, avg[1] / pts.length] + } + if (geom.type === 'MultiPolygon') { + const all = (geom.coordinates as [number, number][][][]).flatMap(p => p[0]) + const avg = all.reduce((a, c) => [a[0] + c[0], a[1] + c[1]], [0, 0]) + return [avg[0] / all.length, avg[1] / all.length] + } + return null +} + +const CATEGORY_COLORS = [ + { keywords: ['어장', '양식'], color: '#f97316', label: '어장/양식장' }, + { keywords: ['해수욕'], color: '#3b82f6', label: '해수욕장' }, + { keywords: ['수산시장', '어시장'], color: '#a855f7', label: '수산시장' }, + { keywords: ['갯벌'], color: '#92400e', label: '갯벌' }, + { keywords: ['서식지'], color: '#16a34a', label: '서식지' }, + { keywords: ['보호종', '생물', '조류', '포유', '파충', '양서'], color: '#ec4899', label: '보호종/생물종' }, +] as const + +const getCategoryColor = (category: string): string => { + for (const { keywords, color } of CATEGORY_COLORS) { + if ((keywords as readonly string[]).some(kw => category.includes(kw))) return color + } + return '#06b6d4' +} + +function SensitiveResourceMapSection({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) { + const mapContainerRef = useRef(null) + const mapRef = useRef(null) + const [mapVisible, setMapVisible] = useState(false) + const [loading, setLoading] = useState(false) + const [capturing, setCapturing] = useState(false) + const [legendLabels, setLegendLabels] = useState([]) + const acdntSn = data.acdntSn + + const RELEVANT_KEYWORDS = ['양식', '어장', '해수욕', '수산시장', '어시장', '갯벌', '서식지', '보호종', '생물', '조류', '포유', '파충'] + + const handleLoad = async () => { + if (!acdntSn) return + setLoading(true) + try { + const [geojson, particlesGeojson] = await Promise.all([ + fetchSensitiveResourcesGeojson(acdntSn), + fetchPredictionParticlesGeojson(acdntSn), + ]) + // 관련 카테고리만 필터링 + 카테고리별 색상 추가 + const filteredGeojson = { + ...geojson, + features: geojson.features + .filter(f => { + const cat = (f.properties as { category?: string })?.category ?? '' + return RELEVANT_KEYWORDS.some(kw => cat.includes(kw)) + }) + .map(f => ({ + ...f, + properties: { + ...f.properties, + _color: getCategoryColor((f.properties as { category?: string })?.category ?? ''), + }, + })), + } + // 실제 존재하는 카테고리 라벨만 범례에 표시 + const presentColors = new Set(filteredGeojson.features.map(f => (f.properties as { _color: string })._color)) + setLegendLabels(CATEGORY_COLORS.filter(c => presentColors.has(c.color)).map(c => c.label)) + // 민감자원 GeoJSON → 6개 섹션 자동 채우기 + const HABITAT_TYPES = ['갯벌', '해조류서식지', '바다목장', '바다숲', '산호류서식지', '인공어초', '해초류서식지'] + const incLat = parseCoord(data.incident.lat) + const incLon = parseCoord(data.incident.lon) + const calcDist = (geom: { type: string; coordinates: unknown }) => { + if (incLat == null || incLon == null) return '' + const centroid = getGeomCentroid(geom) + return centroid ? haversineKm(incLat, incLon, centroid[1], centroid[0]).toFixed(2) : '' + } + + // aquaculture (어장) + const aquacultureRows = geojson.features + .filter(f => ((f.properties as { category?: string })?.category ?? '').includes('어장')) + .map(f => { + const p = f.properties as Record + return { type: String(p['fids_knd'] ?? ''), area: p['area'] != null ? Number(p['area']).toFixed(2) : '', distance: calcDist(f.geometry as { type: string; coordinates: unknown }) } + }) + + // beaches (해수욕장) + const beachRows = geojson.features + .filter(f => ((f.properties as { category?: string })?.category ?? '').includes('해수욕')) + .map(f => { + const p = f.properties as Record + return { name: String(p['beach_nm'] ?? p['name'] ?? p['nm'] ?? ''), distance: calcDist(f.geometry as { type: string; coordinates: unknown }) } + }) + + // markets (수산시장·어시장) + const marketRows = geojson.features + .filter(f => { const cat = (f.properties as { category?: string })?.category ?? ''; return cat.includes('수산시장') || cat.includes('어시장') }) + .map(f => { + const p = f.properties as Record + return { name: String(p['name'] ?? p['market_nm'] ?? p['nm'] ?? ''), distance: calcDist(f.geometry as { type: string; coordinates: unknown }) } + }) + + // esi (해안선) — 기존 10개 행의 length만 갱신 + const esiFeatures = geojson.features.filter(f => { const cat = (f.properties as { category?: string })?.category ?? ''; return cat.includes('해안선') || cat.includes('ESI') }) + const esiLengthMap: Record = {} + esiFeatures.forEach(f => { + const p = f.properties as Record + const code = String(p['esi_cd'] ?? '') + const len = p['length_km'] ?? p['len_km'] + if (code && len != null) esiLengthMap[code] = `${Number(len).toFixed(2)} km` + }) + const esiRows = esiFeatures.length > 0 + ? data.esi.map(row => ({ ...row, length: esiLengthMap[row.code.replace('ESI ', '')] ?? esiLengthMap[row.code] ?? row.length })) + : data.esi + + // species (보호종·생물종) — 3개 고정 행 유지, species 컬럼만 갱신 + const SPECIES_MAP: Record = { '양서파충류': ['파충', '양서'], '조류': ['조류'], '포유류': ['포유'] } + const speciesCollected: Record = { '양서파충류': [], '조류': [], '포유류': [] } + geojson.features.forEach(f => { + const cat = (f.properties as { category?: string })?.category ?? '' + const p = f.properties as Record + const nm = String(p['name'] ?? p['species_nm'] ?? '') + if (!nm) return + for (const [row, kws] of Object.entries(SPECIES_MAP)) { + if (kws.some(kw => cat.includes(kw))) { speciesCollected[row].push(nm); break } + } + }) + const hasSpecies = Object.values(speciesCollected).some(arr => arr.length > 0) + const speciesRows = hasSpecies + ? data.species.map(row => ({ ...row, species: speciesCollected[row.category]?.join(', ') ?? row.species })) + : data.species + + // habitat (서식지) — 타입별 면적 합산 + const habitatFeatures = geojson.features.filter(f => HABITAT_TYPES.some(t => ((f.properties as { category?: string })?.category ?? '').includes(t))) + const habitatMap: Record = {} + habitatFeatures.forEach(f => { + const cat = (f.properties as { category?: string })?.category ?? '' + const p = f.properties as Record + habitatMap[cat] = (habitatMap[cat] ?? 0) + (p['area'] != null ? Number(p['area']) : 0) + }) + const habitatRows = habitatFeatures.length > 0 + ? Object.entries(habitatMap).map(([type, area]) => ({ type, area: area > 0 ? area.toFixed(2) : '' })) + : data.habitat + + // 단일 onChange 일괄 업데이트 + const updates: Partial = {} + if (aquacultureRows.length > 0) updates.aquaculture = aquacultureRows + if (beachRows.length > 0) updates.beaches = beachRows + if (marketRows.length > 0) updates.markets = marketRows + if (esiFeatures.length > 0) updates.esi = esiRows + if (hasSpecies) updates.species = speciesRows + if (habitatFeatures.length > 0) updates.habitat = habitatRows + if (Object.keys(updates).length > 0) onChange({ ...data, ...updates }) + + setMapVisible(true) + // 다음 렌더 사이클 후 지도 초기화 + setTimeout(() => { + if (!mapContainerRef.current) return + const map = new maplibregl.Map({ + container: mapContainerRef.current, + style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json', + center: [127.5, 35.5], + zoom: 8, + preserveDrawingBuffer: true, + }) + mapRef.current = map + map.on('load', () => { + // 확산 파티클 — sensitive 레이어 아래 (예측 탭과 동일한 색상 로직) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + map.addSource('particles', { type: 'geojson', data: particlesGeojson as any }) + // 과거 스텝: 회색 반투명 + map.addLayer({ id: 'particles-past', type: 'circle', source: 'particles', filter: ['==', ['get', 'isLastStep'], false], paint: { 'circle-radius': 2.5, 'circle-color': '#828282', 'circle-opacity': 0.4 } }) + // 최신 스텝: 모델별 색상 (OpenDrift=파랑, POSEIDON=빨강) + map.addLayer({ id: 'particles-current', type: 'circle', source: 'particles', filter: ['==', ['get', 'isLastStep'], true], paint: { 'circle-radius': 3, 'circle-color': ['case', ['==', ['get', 'model'], 'OpenDrift'], '#3b82f6', ['==', ['get', 'model'], 'POSEIDON'], '#ef4444', '#06b6d4'], 'circle-opacity': 0.85 } }) + // 민감자원 레이어 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + map.addSource('sensitive', { type: 'geojson', data: filteredGeojson as any }) + map.addLayer({ id: 'sensitive-fill', type: 'fill', source: 'sensitive', filter: ['==', '$type', 'Polygon'], paint: { 'fill-color': ['get', '_color'], 'fill-opacity': 0.4 } }) + map.addLayer({ id: 'sensitive-line', type: 'line', source: 'sensitive', filter: ['==', '$type', 'Polygon'], paint: { 'line-color': ['get', '_color'], 'line-width': 1.5 } }) + map.addLayer({ id: 'sensitive-circle', type: 'circle', source: 'sensitive', filter: ['==', '$type', 'Point'], paint: { 'circle-radius': 6, 'circle-color': ['get', '_color'], 'circle-stroke-width': 1.5, 'circle-stroke-color': '#fff' } }) + // fit bounds — spread + sensitive 합산 + const coords: [number, number][] = [] + const collectCoords = (geom: { type: string; coordinates: unknown }) => { + if (geom.type === 'Point') { + coords.push(geom.coordinates as [number, number]) + } else if (geom.type === 'Polygon') { + const rings = geom.coordinates as [number, number][][] + rings[0]?.forEach(c => coords.push(c)) + } else if (geom.type === 'MultiPolygon') { + const polys = geom.coordinates as [number, number][][][] + polys.forEach(rings => rings[0]?.forEach(c => coords.push(c))) + } + } + filteredGeojson.features.forEach(f => collectCoords(f.geometry as { type: string; coordinates: unknown })) + particlesGeojson.features.forEach(f => { + coords.push(f.geometry.coordinates as [number, number]) + }) + if (coords.length > 0) { + const lngs = coords.map(c => c[0]) + const lats = coords.map(c => c[1]) + map.fitBounds( + [[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]], + { padding: 60, maxZoom: 13 } + ) + } + // 사고 위치 마커 (캔버스 레이어 — 캡처에 포함) + if (incLat != null && incLon != null) { + map.addSource('incident-point', { + type: 'geojson', + data: { type: 'Feature', geometry: { type: 'Point', coordinates: [incLon, incLat] }, properties: {} }, + }) + map.addLayer({ id: 'incident-circle', type: 'circle', source: 'incident-point', paint: { 'circle-radius': 7, 'circle-color': '#ef4444', 'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff' } }) + map.addLayer({ id: 'incident-label', type: 'symbol', source: 'incident-point', layout: { 'text-field': '사고위치', 'text-size': 11, 'text-offset': [0, 1.6], 'text-anchor': 'top', 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'] }, paint: { 'text-color': '#ffffff', 'text-halo-color': '#ef4444', 'text-halo-width': 3 } }) + } + }) + }, 100) + } catch { + alert('민감자원 지도 데이터를 불러오지 못했습니다.') + } finally { + setLoading(false) + } + } + + const handleCapture = () => { + const map = mapRef.current + if (!map) return + setCapturing(true) + map.once('idle', () => { + const mapCanvas = map.getCanvas() + const dpr = window.devicePixelRatio || 1 + const W = mapCanvas.width + const H = mapCanvas.height + + const composite = document.createElement('canvas') + composite.width = W + composite.height = H + const ctx = composite.getContext('2d')! + + // 지도 그리기 + ctx.drawImage(mapCanvas, 0, 0) + + // 범례 그리기 + const items = CATEGORY_COLORS.filter(c => legendLabels.includes(c.label)) + if (items.length > 0) { + const pad = 8 * dpr + const swSize = 12 * dpr + const lineH = 18 * dpr + const fontSize = 11 * dpr + const boxW = 130 * dpr + const boxH = pad * 2 + items.length * lineH + const lx = pad, ly = pad + + ctx.fillStyle = 'rgba(255,255,255,0.88)' + ctx.fillRect(lx, ly, boxW, boxH) + ctx.strokeStyle = 'rgba(0,0,0,0.15)' + ctx.lineWidth = dpr + ctx.strokeRect(lx, ly, boxW, boxH) + + items.forEach(({ color, label }, i) => { + const iy = ly + pad + i * lineH + ctx.fillStyle = color + ctx.fillRect(lx + pad, iy + (lineH - swSize) / 2, swSize, swSize) + ctx.fillStyle = '#1f2937' + ctx.font = `${fontSize}px sans-serif` + ctx.fillText(label, lx + pad + swSize + 4 * dpr, iy + lineH / 2 + fontSize * 0.35) + }) + } + + onChange({ ...data, sensitiveMapImage: composite.toDataURL('image/png') }) + setCapturing(false) + }) + map.triggerRepaint() + } + + const handleReset = () => { + if (mapRef.current) { + mapRef.current.remove() + mapRef.current = null + } + setMapVisible(false) + setLegendLabels([]) + onChange({ ...data, sensitiveMapImage: undefined }) + } + + useEffect(() => { + return () => { mapRef.current?.remove() } + }, []) + + // 뷰 모드: 이미지 있으면 표시, 없으면 플레이스홀더 + if (!editing) { + if (data.sensitiveMapImage) { + return 민감자원 분포 지도 + } + return
민감자원 분포(10km 내) 지도
+ } + + // 편집 모드: acdntSn 없음 + if (!acdntSn) { + return
민감자원 분포(10km 내) 지도
+ } + + // 편집 모드: 캡처 이미지 있음 + if (data.sensitiveMapImage) { + return ( +
+ 민감자원 분포 지도 + +
+ ) + } + + // 편집 모드: 지도 로드/캡처 + return ( +
+ {!mapVisible ? ( +
+ 민감자원 분포(10km 내) 지도 + +
+ ) : ( +
+
+ {legendLabels.length > 0 && ( +
+ {CATEGORY_COLORS.filter(c => legendLabels.includes(c.label)).map(({ color, label }) => ( +
+
+ {label} +
+ ))} +
+ )} +
+ + +
+
+ )} +
+ ) +} + function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) { const setArr = >(key: keyof OilSpillReportData, arr: T[], i: number, k: string, v: string) => { const copy = [...arr]; copy[i] = { ...copy[i], [k]: v }; onChange({ ...data, [key]: copy }) @@ -329,7 +725,7 @@ function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing:
해양오염방제지원시스템
4. 민감자원 및 민감도 평가
-
민감자원 분포(10km 내) 지도
+
양식장 분포
@@ -414,13 +810,197 @@ function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing: ) } +const SENS_LEVELS = [ + { key: 5, level: '매우 높음', color: '#ef4444' }, + { key: 4, level: '높음', color: '#f97316' }, + { key: 3, level: '보통', color: '#eab308' }, + { key: 2, level: '낮음', color: '#22c55e' }, +] + +function SensitivityMapSection({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) { + const mapContainerRef = useRef(null) + const mapRef = useRef(null) + const [mapVisible, setMapVisible] = useState(false) + const [loading, setLoading] = useState(false) + const [capturing, setCapturing] = useState(false) + const acdntSn = data.acdntSn + + const handleLoad = async () => { + if (!acdntSn) return + setLoading(true) + try { + const geojson = await fetchSensitivityEvaluationGeojson(acdntSn) + const seasonKey = getSeasonKey(data.incident.occurTime) + + // 레벨 계산 및 면적 집계 (레벨 1 제외) + const areaByLevel: Record = {} + geojson.features.forEach(f => { + const p = (f as { properties: Record }).properties + const lvl = Number(p[seasonKey] ?? 1) + if (lvl >= 2) areaByLevel[lvl] = (areaByLevel[lvl] ?? 0) + Number(p['area_km2'] ?? 0) + }) + const sensitivityRows = SENS_LEVELS.map(s => ({ + level: s.level, color: s.color, + area: areaByLevel[s.key] != null ? areaByLevel[s.key].toFixed(2) : '', + })) + onChange({ ...data, sensitivity: sensitivityRows }) + + // 레벨 속성 추가 + 레벨 1 제외한 표시용 GeoJSON + const displayGeojson = { + ...geojson, + features: geojson.features + .map(f => { + const p = (f as { properties: Record }).properties + return { ...(f as object), properties: { ...p, level: Number(p[seasonKey] ?? 1) } } + }) + .filter(f => (f as { properties: { level: number } }).properties.level >= 2), + } + + setMapVisible(true) + setTimeout(() => { + if (!mapContainerRef.current) return + const map = new maplibregl.Map({ + container: mapContainerRef.current, + style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json', + center: [127.5, 35.5], + zoom: 8, + preserveDrawingBuffer: true, + }) + mapRef.current = map + map.on('load', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + map.addSource('sensitivity', { type: 'geojson', data: displayGeojson as any }) + const colorExpr: maplibregl.ExpressionSpecification = ['case', + ['==', ['get', 'level'], 5], '#ef4444', + ['==', ['get', 'level'], 4], '#f97316', + ['==', ['get', 'level'], 3], '#eab308', + ['==', ['get', 'level'], 2], '#22c55e', + 'transparent', + ] + map.addLayer({ id: 'sensitivity-fill', type: 'fill', source: 'sensitivity', filter: ['==', '$type', 'Polygon'], paint: { 'fill-color': colorExpr, 'fill-opacity': 0.6 } }) + map.addLayer({ id: 'sensitivity-line', type: 'line', source: 'sensitivity', filter: ['==', '$type', 'Polygon'], paint: { 'line-color': colorExpr, 'line-width': 0.5, 'line-opacity': 0.4 } }) + map.addLayer({ id: 'sensitivity-circle', type: 'circle', source: 'sensitivity', filter: ['==', '$type', 'Point'], paint: { 'circle-radius': 5, 'circle-color': colorExpr } }) + + // fitBounds + const coords: [number, number][] = [] + displayGeojson.features.forEach(f => { + const geom = (f as { geometry: { type: string; coordinates: unknown } }).geometry + if (geom.type === 'Point') coords.push(geom.coordinates as [number, number]) + else if (geom.type === 'Polygon') (geom.coordinates as [number, number][][])[0]?.forEach(c => coords.push(c)) + else if (geom.type === 'MultiPolygon') (geom.coordinates as [number, number][][][]).forEach(rings => rings[0]?.forEach(c => coords.push(c))) + }) + if (coords.length > 0) { + const lngs = coords.map(c => c[0]) + const lats = coords.map(c => c[1]) + map.fitBounds([[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]], { padding: 60, maxZoom: 13 }) + } + // 사고 위치 마커 (캔버스 레이어 — 캡처에 포함) + const incLat = parseCoord(data.incident.lat) + const incLon = parseCoord(data.incident.lon) + if (incLat != null && incLon != null) { + map.addSource('incident-point', { + type: 'geojson', + data: { type: 'Feature', geometry: { type: 'Point', coordinates: [incLon, incLat] }, properties: {} }, + }) + map.addLayer({ id: 'incident-circle', type: 'circle', source: 'incident-point', paint: { 'circle-radius': 7, 'circle-color': '#ef4444', 'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff' } }) + map.addLayer({ id: 'incident-label', type: 'symbol', source: 'incident-point', layout: { 'text-field': '사고위치', 'text-size': 11, 'text-offset': [0, 1.6], 'text-anchor': 'top', 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'] }, paint: { 'text-color': '#ffffff', 'text-halo-color': '#ef4444', 'text-halo-width': 3 } }) + } + }) + }, 100) + } catch { + alert('통합민감도 평가 데이터를 불러오지 못했습니다.') + } finally { + setLoading(false) + } + } + + const handleCapture = () => { + const map = mapRef.current + if (!map) return + setCapturing(true) + map.once('idle', () => { + const dataUrl = map.getCanvas().toDataURL('image/png') + onChange({ ...data, sensitivityMapImage: dataUrl }) + setCapturing(false) + }) + map.triggerRepaint() + } + + const handleReset = () => { + if (mapRef.current) { mapRef.current.remove(); mapRef.current = null } + setMapVisible(false) + onChange({ ...data, sensitivityMapImage: undefined }) + } + + useEffect(() => { return () => { mapRef.current?.remove() } }, []) + + if (!editing) { + if (data.sensitivityMapImage) { + return 통합민감도 평가 지도 + } + return
통합민감도 평가 지도
+ } + + if (!acdntSn) return
통합민감도 평가 지도
+ + if (data.sensitivityMapImage) { + return ( +
+ 통합민감도 평가 지도 + +
+ ) + } + + return ( +
+ {!mapVisible ? ( +
+ 민감도 분포(10km내) 지도 + +
+ ) : ( +
+
+
+ + +
+
+ )} +
+ ) +} + function Page5({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) { const setSens = (i: number, v: string) => { const s = [...data.sensitivity]; s[i] = { ...s[i], area: v }; onChange({ ...data, sensitivity: s }) } return (
해양오염방제지원시스템
통합민감도 평가 (해당 계절)
-
통합민감도 평가 지도
+
{data.sensitivity.map((s, i) => ( diff --git a/frontend/src/tabs/reports/components/ReportGenerator.tsx b/frontend/src/tabs/reports/components/ReportGenerator.tsx index a84b798..fb0cbc1 100644 --- a/frontend/src/tabs/reports/components/ReportGenerator.tsx +++ b/frontend/src/tabs/reports/components/ReportGenerator.tsx @@ -117,6 +117,12 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { if (oilPayload.spreadSteps) { report.spread = oilPayload.spreadSteps; } + + // acdntSn 전달 (민감자원 지도 로드용) + if (oilPayload.acdntSn) { + (report as typeof report & { acdntSn?: number }).acdntSn = oilPayload.acdntSn; + } + } else { report.incident.pollutant = ''; report.incident.spillAmount = ''; @@ -170,6 +176,17 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { : `

최초 부착시간: ${oilPayload.coastal?.firstTime ?? '—'} / 부착 해안길이: ${coastLength}

`; } } + if (activeCat === 0 && sec.id === 'oil-sensitive') { + const resources = oilPayload?.sensitiveResources; + if (resources && resources.length > 0) { + const headerRow = ``; + const dataRows = resources.map(r => { + const areaText = r.totalArea != null ? `${r.totalArea.toFixed(2)} ha` : '—'; + return ``; + }).join(''); + content = `
민감도분포 면적(km²)
구분개소면적
${r.category}${r.count}개소${areaText}
${headerRow}${dataRows}
`; + } + } if (activeCat === 0 && oilPayload) { if (sec.id === 'oil-pollution') { const rows = [ @@ -436,11 +453,43 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { )} - {sec.id === 'oil-sensitive' && ( -

- 현재 민감자원 데이터가 없습니다. -

- )} + {sec.id === 'oil-sensitive' && (() => { + const resources = oilPayload?.sensitiveResources; + if (!resources || resources.length === 0) { + return ( +

+ 현재 민감자원 데이터가 없습니다. +

+ ); + } + return ( + + + + + + + + + + + + + + + {resources.map((r, i) => ( + + + + + + ))} + +
구분개소면적
{r.category}{r.count}개소 + {r.totalArea != null ? `${r.totalArea.toFixed(2)} ha` : '—'} +
+ ); + })()} {sec.id === 'oil-coastal' && (() => { if (!oilPayload) { return ( diff --git a/frontend/src/tabs/reports/components/ReportsView.tsx b/frontend/src/tabs/reports/components/ReportsView.tsx index 3255066..3a21559 100755 --- a/frontend/src/tabs/reports/components/ReportsView.tsx +++ b/frontend/src/tabs/reports/components/ReportsView.tsx @@ -305,6 +305,8 @@ export function ReportsView() { exportAsHWP(tpl.label, meta, tpl.sections, getVal, filename, { step3: previewReport.step3MapImage || undefined, step6: previewReport.step6MapImage || undefined, + sensitiveMap: previewReport.sensitiveMapImage || undefined, + sensitivityMap: previewReport.sensitivityMapImage || undefined, }) } }} diff --git a/frontend/src/tabs/reports/components/hwpxExport.ts b/frontend/src/tabs/reports/components/hwpxExport.ts index b1e4d53..e42f6b5 100644 --- a/frontend/src/tabs/reports/components/hwpxExport.ts +++ b/frontend/src/tabs/reports/components/hwpxExport.ts @@ -735,7 +735,7 @@ function buildSection0Xml( meta: ReportMeta, sections: ReportSection[], getVal: (key: string) => string, - imageBinIds?: { step3?: number; step6?: number }, + imageBinIds?: { step3?: number; step6?: number; sensitiveMap?: number; sensitivityMap?: number }, ): string { // ID 시퀀스 초기화 (재사용 시 충돌 방지) _idSeq = 1000000000; @@ -768,6 +768,7 @@ function buildSection0Xml( // __spreadMaps 필드 포함 섹션: 이미지 단락 삽입 후 나머지 필드 처리 const hasSpreadMaps = section.fields.some(f => f.key === '__spreadMaps'); + const hasSensitive = section.fields.some(f => f.key === '__sensitive'); if (hasSpreadMaps && imageBinIds) { const regularFields = section.fields.filter(f => f.key !== '__spreadMaps'); if (imageBinIds.step3) { @@ -781,6 +782,18 @@ function buildSection0Xml( if (regularFields.length > 0) { body += buildFieldTable(regularFields, getVal); } + } else if (hasSensitive) { + // 민감자원 분포 지도 — 테이블 앞 + if (imageBinIds?.sensitiveMap) { + body += buildPara('민감자원 분포 지도', 0); + body += buildPicParagraph(imageBinIds.sensitiveMap, CONTENT_WIDTH, 24000); + } + body += buildFieldTable(section.fields, getVal); + // 통합민감도 평가 지도 — 테이블 뒤 + if (imageBinIds?.sensitivityMap) { + body += buildPara('통합민감도 평가 지도', 0); + body += buildPicParagraph(imageBinIds.sensitivityMap, CONTENT_WIDTH, 24000); + } } else { // 필드 테이블 const fields = section.fields.filter(f => f.key !== '__spreadMaps'); @@ -846,7 +859,7 @@ export async function exportAsHWPX( sections: ReportSection[], getVal: (key: string) => string, filename: string, - images?: { step3?: string; step6?: string }, + images?: { step3?: string; step6?: string; sensitiveMap?: string; sensitivityMap?: string }, ): Promise { const zip = new JSZip(); @@ -861,7 +874,7 @@ export async function exportAsHWPX( zip.file('META-INF/manifest.xml', MANIFEST_XML); // 이미지 처리 - let imageBinIds: { step3?: number; step6?: number } | undefined; + let imageBinIds: { step3?: number; step6?: number; sensitiveMap?: number; sensitivityMap?: number } | undefined; let extraManifestItems = ''; let binDataListXml = ''; let binCount = 0; @@ -894,6 +907,16 @@ export async function exportAsHWPX( processImage(images.step6, 2, 'image2'); } } + if (images?.sensitiveMap) { + imageBinIds = imageBinIds ?? {}; + imageBinIds.sensitiveMap = 3; + processImage(images.sensitiveMap, 3, 'image3'); + } + if (images?.sensitivityMap) { + imageBinIds = imageBinIds ?? {}; + imageBinIds.sensitivityMap = 4; + processImage(images.sensitivityMap, 4, 'image4'); + } // header.xml: binDataList를 hh:refList 내부에 삽입 (HWPML 스펙 준수) let headerXml = HEADER_XML; diff --git a/frontend/src/tabs/reports/components/reportUtils.ts b/frontend/src/tabs/reports/components/reportUtils.ts index 0506aa5..737714f 100644 --- a/frontend/src/tabs/reports/components/reportUtils.ts +++ b/frontend/src/tabs/reports/components/reportUtils.ts @@ -47,7 +47,7 @@ export async function exportAsHWP( sections: { title: string; fields: { key: string; label: string }[] }[], getVal: (key: string) => string, filename: string, - images?: { step3?: string; step6?: string }, + images?: { step3?: string; step6?: string; sensitiveMap?: string; sensitivityMap?: string }, ) { const { exportAsHWPX } = await import('./hwpxExport'); await exportAsHWPX(templateLabel, meta, sections, getVal, filename, images); @@ -122,6 +122,14 @@ function formatSpreadTable(spread: OilSpillReportData['spread']): string { function formatSensitiveTable(r: OilSpillReportData): string { const parts: string[] = [] + + if (r.sensitiveMapImage) { + parts.push( + '

민감자원 분포 지도

' + + `` + ) + } + if (r.aquaculture?.length) { const h = `종류면적거리` const rows = r.aquaculture.map(a => `${a.type}${a.area}${a.distance}`).join('') @@ -152,6 +160,13 @@ function formatSensitiveTable(r: OilSpillReportData): string { const rows = r.habitat.map(h2 => `${h2.type}${h2.area}`).join('') parts.push(`

서식지

${h}${rows}
`) } + if (r.sensitivityMapImage) { + parts.push( + '

통합민감도 평가 지도

' + + `` + ) + } + if (r.sensitivity?.length) { const h = `민감도면적` const rows = r.sensitivity.map(s => `${s.level}${s.area}`).join('') diff --git a/frontend/src/tabs/reports/services/reportsApi.ts b/frontend/src/tabs/reports/services/reportsApi.ts index 6b79e01..cf50a6a 100644 --- a/frontend/src/tabs/reports/services/reportsApi.ts +++ b/frontend/src/tabs/reports/services/reportsApi.ts @@ -60,6 +60,7 @@ export interface ApiReportListItem { sttsCd: string; authorId: string; authorName: string; + acdntSn?: number | null; regDtm: string; mdfcnDtm: string | null; hasMapCapture?: boolean; @@ -239,13 +240,24 @@ export async function saveReport(data: OilSpillReportData): Promise { // analysis + etcEquipment 합산 sections.push({ sectCd: 'analysis', sectData: { analysis: data.analysis, etcEquipment: data.etcEquipment }, sortOrd: sortOrd++ }); + // 민감자원 지도 이미지 (섹션으로 저장) + const extData = data as OilSpillReportData & { reportSn?: number; acdntSn?: number }; + if (extData.sensitiveMapImage !== undefined) { + sections.push({ sectCd: 'sensitive-map', sectData: { mapImage: extData.sensitiveMapImage }, sortOrd: sortOrd++ }); + } + // 통합민감도 평가 지도 이미지 (섹션으로 저장) + if (extData.sensitivityMapImage !== undefined) { + sections.push({ sectCd: 'sensitivity-map', sectData: { mapImage: extData.sensitivityMapImage }, sortOrd: sortOrd++ }); + } + // reportSn이 있으면 update, 없으면 create - const existingSn = (data as OilSpillReportData & { reportSn?: number }).reportSn; + const existingSn = extData.reportSn; if (existingSn) { await updateReportApi(existingSn, { title: data.title || data.incident.name || '보고서', jrsdCd: data.jurisdiction, sttsCd, + acdntSn: extData.acdntSn ?? null, step3MapImg: data.step3MapImage !== undefined ? (data.step3MapImage || null) : undefined, step6MapImg: data.step6MapImage !== undefined ? (data.step6MapImage || null) : undefined, sections, @@ -256,6 +268,7 @@ export async function saveReport(data: OilSpillReportData): Promise { const result = await createReportApi({ tmplSn, ctgrSn, + acdntSn: extData.acdntSn, title: data.title || data.incident.name || '보고서', jrsdCd: data.jurisdiction, sttsCd, @@ -278,6 +291,7 @@ export function apiListItemToReportData(item: ApiReportListItem): OilSpillReport jurisdiction: (item.jrsdCd as Jurisdiction) || '남해청', status: CODE_TO_STATUS[item.sttsCd] || '테스트', hasMapCapture: item.hasMapCapture, + acdntSn: item.acdntSn ?? undefined, // 목록에서는 섹션 데이터 없음 — 빈 기본값 incident: { name: '', writeTime: '', shipName: '', agent: '', location: '', lat: '', lon: '', occurTime: '', accidentType: '', pollutant: '', spillAmount: '', depth: '', seabed: '' }, tide: [], weather: [], spread: [], @@ -346,6 +360,12 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa case 'result': reportData.result = d as OilSpillReportData['result']; break; + case 'sensitive-map': + reportData.sensitiveMapImage = (d as { mapImage?: string }).mapImage; + break; + case 'sensitivity-map': + reportData.sensitivityMapImage = (d as { mapImage?: string }).mapImage; + break; } } @@ -361,6 +381,9 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa if (detail.step6MapImg) { reportData.step6MapImage = detail.step6MapImg; } + if (detail.acdntSn != null) { + (reportData as typeof reportData & { acdntSn?: number }).acdntSn = detail.acdntSn; + } return reportData; } -- 2.45.2 From d4b3bbdc9980edc95dfbda1f05d2e76d424041b8 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Tue, 24 Mar 2026 16:57:27 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 9d7c8ef..95a6138 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -9,9 +9,13 @@ - 확산예측: 예측 실행 시 기상정보(풍속·풍향·기압·파고·수온·기온·염분 등) ACDNT_WEATHER 테이블에 자동 저장 - DB: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 19개 추가 (025 마이그레이션) - DB: 민감자원 데이터 마이그레이션 (026_sensitive_resources) +- 보고서: 유류유출 보고서 템플릿 전면 개선 (OilSpillReportTemplate) +- 관리자: 실시간 기상·해상 모니터링 패널 추가 (MonitorRealtimePanel) +- DB: 민감자원 평가 마이그레이션 추가 (027_sensitivity_evaluation) ### 변경 - 예측: 분석 API를 예측 서비스로 통합 (analysisRouter 제거) +- 예측: 예측 API 확장 (predictionRouter/Service, LeftPanel/RightPanel 연동) ## [2026-03-20.3] -- 2.45.2