From aefd38b3bca26b3e492dbf9547dedf7b4fb8ea0a Mon Sep 17 00:00:00 2001 From: JHKANG9140 Date: Sun, 22 Mar 2026 12:36:31 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(=EB=A0=88=EC=9D=B4=EC=96=B4):=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=A7=A4=ED=95=91=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=EC=96=B4=EC=9E=A5=20=ED=8C=9D=EC=97=85?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 6 +- .claude/workflow-version.json | 4 +- backend/src/analysis/analysisRouter.ts | 166 ++++++++++++++++++ backend/src/routes/layers.ts | 33 +++- backend/src/server.ts | 2 + database/migration/025_layer_data_tbl_nm.sql | 7 + database/migration/026_register_srid_5179.sql | 13 ++ .../src/common/components/map/MapView.tsx | 126 ++++++++++++- frontend/src/common/data/layerData.ts | 1 + frontend/src/common/services/api.ts | 3 + frontend/src/common/services/layerService.ts | 1 + frontend/src/common/styles/components.css | 6 + .../prediction/components/OilSpillView.tsx | 45 ++++- .../tabs/prediction/components/RightPanel.tsx | 68 +++++++ .../prediction/services/analysisService.ts | 88 ++++++++++ 15 files changed, 548 insertions(+), 21 deletions(-) create mode 100644 backend/src/analysis/analysisRouter.ts create mode 100644 database/migration/025_layer_data_tbl_nm.sql create mode 100644 database/migration/026_register_srid_5179.sql create mode 100644 frontend/src/tabs/prediction/services/analysisService.ts diff --git a/.claude/settings.json b/.claude/settings.json index 4224c81..868df2d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -83,7 +83,5 @@ ] } ] - }, - "deny": [], - "allow": [] -} \ No newline at end of file + } +} diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index 2086822..f746918 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,7 +1,7 @@ { "applied_global_version": "1.6.1", - "applied_date": "2026-03-20", + "applied_date": "2026-03-22", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true -} \ No newline at end of file +} diff --git a/backend/src/analysis/analysisRouter.ts b/backend/src/analysis/analysisRouter.ts new file mode 100644 index 0000000..c7b5cdd --- /dev/null +++ b/backend/src/analysis/analysisRouter.ts @@ -0,0 +1,166 @@ +import express from 'express' +import { wingPool } from '../db/wingDb.js' +import { isValidNumber } from '../middleware/security.js' + +const router = express.Router() + +// ============================================================ +// 공간 쿼리 대상 테이블 레지스트리 +// 새 테이블 추가 시: TABLE_META에 항목 추가 + LAYER 테이블 DATA_TBL_NM 컬럼에 해당 값 등록 +// 예) 'gis.coastal_zone': { geomColumn: 'geom', srid: 4326, properties: ['id','zone_nm','...'] } +// ============================================================ +const TABLE_META: Record = { + 'gis.fshfrm': { + geomColumn: 'geom', + srid: 5179, + properties: [ + 'gid', 'rgn_nm', 'ctgry_cd', 'admdst_nm', 'fids_se', + 'fids_knd', 'fids_mthd', 'farm_knd', 'addr', 'area', + 'lcns_no', 'ctpv_nm', 'sgg_nm', 'lcns_bgng_', 'lcns_end_y', + ], + }, +} + +// ============================================================ +// POST /api/analysis/spatial-query +// 영역(다각형 또는 원) 내 공간 데이터 조회 +// ============================================================ +router.post('/spatial-query', async (req, res) => { + try { + const { type, polygon, center, radiusM, layers } = req.body as { + type: 'polygon' | 'circle' + polygon?: Array<{ lat: number; lon: number }> + center?: { lat: number; lon: number } + radiusM?: number + layers?: string[] + } + + // 조회할 테이블 결정: 요청에서 전달된 layers 또는 기본값 + const requestedLayers: string[] = Array.isArray(layers) && layers.length > 0 + ? layers + : ['gis.fshfrm'] + + // DB 화이트리스트 검증 (LAYER.DATA_TBL_NM에 등록된 테이블만 허용) + const { rows: allowedRows } = await wingPool.query<{ data_tbl_nm: string }>( + `SELECT DATA_TBL_NM AS data_tbl_nm FROM LAYER + WHERE DATA_TBL_NM = ANY($1) AND USE_YN = 'Y' AND DEL_YN = 'N'`, + [requestedLayers] + ) + // TABLE_META에도 등록되어 있어야 실제 쿼리 가능 + const allowedTables = allowedRows + .map(r => r.data_tbl_nm) + .filter(tbl => tbl in TABLE_META) + + // LAYER 테이블에 없더라도 TABLE_META에 있고 기본 요청이면 허용 (초기 데이터 미등록 시 fallback) + const finalTables = allowedTables.length > 0 + ? allowedTables + : requestedLayers.filter(tbl => tbl in TABLE_META) + + if (finalTables.length === 0) { + return res.status(400).json({ error: '조회 가능한 레이어가 없습니다.' }) + } + + const allFeatures: object[] = [] + const metaList: Array<{ tableName: string; count: number }> = [] + + for (const tableName of finalTables) { + const meta = TABLE_META[tableName] + const { geomColumn, srid, properties } = meta + + const propColumns = properties.join(', ') + + let rows: Array> = [] + + if (type === 'polygon') { + if (!Array.isArray(polygon) || polygon.length < 3) { + return res.status(400).json({ error: '다각형 분석에는 최소 3개의 좌표가 필요합니다.' }) + } + + // 좌표 숫자 검증 후 WKT 조립 (SQL 인젝션 방지) + const validCoords = polygon.filter(p => + isValidNumber(p.lat, -90, 90) && isValidNumber(p.lon, -180, 180) + ) + if (validCoords.length < 3) { + return res.status(400).json({ error: '유효하지 않은 좌표가 포함되어 있습니다.' }) + } + + // 폴리곤을 닫기 위해 첫 번째 좌표를 마지막에 추가 + const coordStr = [...validCoords, validCoords[0]] + .map(p => `${p.lon} ${p.lat}`) + .join(', ') + const wkt = `POLYGON((${coordStr}))` + + const { rows: queryRows } = await wingPool.query( + `SELECT ${propColumns}, + ST_AsGeoJSON( + ST_SimplifyPreserveTopology(ST_Transform(${geomColumn}, 4326), 0.00001) + ) AS geom_geojson + FROM ${tableName} + WHERE ST_Intersects( + ${geomColumn}, + ST_Transform(ST_GeomFromText($1, 4326), ${srid}) + )`, + [wkt] + ) + rows = queryRows as Array> + + } else if (type === 'circle') { + if (!center || !isValidNumber(center.lat, -90, 90) || !isValidNumber(center.lon, -180, 180)) { + return res.status(400).json({ error: '원 분석에 유효하지 않은 중심 좌표입니다.' }) + } + if (!isValidNumber(radiusM, 1, 10000000)) { + return res.status(400).json({ error: '원 분석에 유효하지 않은 반경 값입니다.' }) + } + + const { rows: queryRows } = await wingPool.query( + `SELECT ${propColumns}, + ST_AsGeoJSON( + ST_SimplifyPreserveTopology(ST_Transform(${geomColumn}, 4326), 0.00001) + ) AS geom_geojson + FROM ${tableName} + WHERE ST_DWithin( + ${geomColumn}, + ST_Transform(ST_SetSRID(ST_MakePoint($1, $2), 4326), ${srid}), + $3 + )`, + [center.lon, center.lat, radiusM] + ) + rows = queryRows as Array> + + } else { + return res.status(400).json({ error: '지원하지 않는 분석 유형입니다. polygon 또는 circle을 사용하세요.' }) + } + + const features = rows.map(row => { + const { geom_geojson, ...props } = row + return { + type: 'Feature', + geometry: JSON.parse(String(geom_geojson)), + properties: { + ...props, + _tableName: tableName, + }, + } + }) + + allFeatures.push(...features) + metaList.push({ tableName, count: features.length }) + } + + res.json({ + type: 'FeatureCollection', + features: allFeatures, + _meta: metaList, + }) + + } catch (err) { + console.error('[analysis] 공간 쿼리 오류:', err) + res.status(500).json({ error: '공간 쿼리 처리 중 오류가 발생했습니다.' }) + } +}) + +export default router diff --git a/backend/src/routes/layers.ts b/backend/src/routes/layers.ts index 7373146..0c949e5 100755 --- a/backend/src/routes/layers.ts +++ b/backend/src/routes/layers.ts @@ -18,6 +18,7 @@ interface Layer { cmn_cd_nm: string cmn_cd_level: number clnm: string | null + data_tbl_nm: string | null } // DB 컬럼 → API 응답 컬럼 매핑 (프론트엔드 호환성 유지) @@ -27,7 +28,8 @@ const LAYER_COLUMNS = ` LAYER_FULL_NM AS cmn_cd_full_nm, LAYER_NM AS cmn_cd_nm, LAYER_LEVEL AS cmn_cd_level, - WMS_LAYER_NM AS clnm + WMS_LAYER_NM AS clnm, + DATA_TBL_NM AS data_tbl_nm `.trim() // 모든 라우트에 파라미터 살균 적용 @@ -216,6 +218,7 @@ router.get('/admin/list', requireAuth, requireRole('ADMIN'), async (req, res) => 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" @@ -297,11 +300,12 @@ router.post('/admin/create', requireAuth, requireRole('ADMIN'), async (req, res) layerNm?: string layerLevel?: number wmsLayerNm?: string + dataTblNm?: string useYn?: string sortOrd?: number } - const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, useYn, sortOrd } = body + const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, dataTblNm, useYn, sortOrd } = body // 필수 필드 검증 if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) { @@ -328,20 +332,26 @@ router.post('/admin/create', requireAuth, requireRole('ADMIN'), async (req, res) return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' }) } } + if (dataTblNm !== undefined && dataTblNm !== null && dataTblNm !== '') { + if (!isValidStringLength(dataTblNm, 100) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(dataTblNm)) { + return res.status(400).json({ error: '데이터 테이블명은 100자 이내의 유효한 PostgreSQL 테이블명이어야 합니다.' }) + } + } const sanitizedLayerCd = sanitizeString(layerCd) const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null const sanitizedLayerFullNm = sanitizeString(layerFullNm) const sanitizedLayerNm = sanitizeString(layerNm) const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null + const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y' const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null const { rows } = await wingPool.query( - `INSERT INTO LAYER (LAYER_CD, UP_LAYER_CD, LAYER_FULL_NM, LAYER_NM, LAYER_LEVEL, WMS_LAYER_NM, USE_YN, SORT_ORD, DEL_YN) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'N') + `INSERT INTO LAYER (LAYER_CD, UP_LAYER_CD, LAYER_FULL_NM, LAYER_NM, LAYER_LEVEL, WMS_LAYER_NM, DATA_TBL_NM, USE_YN, SORT_ORD, DEL_YN) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'N') RETURNING LAYER_CD AS "layerCd"`, - [sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedUseYn, sanitizedSortOrd] + [sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedDataTblNm, sanitizedUseYn, sanitizedSortOrd] ) res.json(rows[0]) @@ -364,11 +374,12 @@ router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res) layerNm?: string layerLevel?: number wmsLayerNm?: string + dataTblNm?: string useYn?: string sortOrd?: number } - const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, useYn, sortOrd } = body + const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, dataTblNm, useYn, sortOrd } = body // 필수 필드 검증 if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) { @@ -395,22 +406,28 @@ router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res) return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' }) } } + if (dataTblNm !== undefined && dataTblNm !== null && dataTblNm !== '') { + if (!isValidStringLength(dataTblNm, 100) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(dataTblNm)) { + return res.status(400).json({ error: '데이터 테이블명은 100자 이내의 유효한 PostgreSQL 테이블명이어야 합니다.' }) + } + } const sanitizedLayerCd = sanitizeString(layerCd) const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null const sanitizedLayerFullNm = sanitizeString(layerFullNm) const sanitizedLayerNm = sanitizeString(layerNm) const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null + const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y' const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null const { rows } = await wingPool.query( `UPDATE LAYER SET UP_LAYER_CD = $2, LAYER_FULL_NM = $3, LAYER_NM = $4, LAYER_LEVEL = $5, - WMS_LAYER_NM = $6, USE_YN = $7, SORT_ORD = $8 + WMS_LAYER_NM = $6, DATA_TBL_NM = $7, USE_YN = $8, SORT_ORD = $9 WHERE LAYER_CD = $1 RETURNING LAYER_CD AS "layerCd"`, - [sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedUseYn, sanitizedSortOrd] + [sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedDataTblNm, sanitizedUseYn, sanitizedSortOrd] ) if (rows.length === 0) { diff --git a/backend/src/server.ts b/backend/src/server.ts index 8e48bdb..71d5efc 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -23,6 +23,7 @@ import predictionRouter from './prediction/predictionRouter.js' import aerialRouter from './aerial/aerialRouter.js' import rescueRouter from './rescue/rescueRouter.js' import mapBaseRouter from './map-base/mapBaseRouter.js' +import analysisRouter from './analysis/analysisRouter.js' import { sanitizeBody, sanitizeQuery, @@ -170,6 +171,7 @@ app.use('/api/prediction', predictionRouter) app.use('/api/aerial', aerialRouter) app.use('/api/rescue', rescueRouter) app.use('/api/map-base', mapBaseRouter) +app.use('/api/analysis', analysisRouter) // 헬스 체크 app.get('/health', (_req, res) => { diff --git a/database/migration/025_layer_data_tbl_nm.sql b/database/migration/025_layer_data_tbl_nm.sql new file mode 100644 index 0000000..4c5ae1e --- /dev/null +++ b/database/migration/025_layer_data_tbl_nm.sql @@ -0,0 +1,7 @@ +-- 025_layer_data_tbl_nm.sql +-- LAYER 테이블에 PostGIS 데이터 테이블명 컬럼 추가 +-- 특정 구역 통계/분석 쿼리 시 실제 공간 데이터 테이블을 동적으로 참조하기 위해 사용 + +ALTER TABLE LAYER ADD COLUMN IF NOT EXISTS DATA_TBL_NM VARCHAR(100); + +COMMENT ON COLUMN LAYER.DATA_TBL_NM IS 'PostGIS 데이터 테이블명 (공간 데이터 직접 조회용)'; diff --git a/database/migration/026_register_srid_5179.sql b/database/migration/026_register_srid_5179.sql new file mode 100644 index 0000000..7bd079d --- /dev/null +++ b/database/migration/026_register_srid_5179.sql @@ -0,0 +1,13 @@ +-- SRID 5179 (Korea 2000 / Unified CS) 등록 +-- gis.fshfrm 등 한국 좌표계(EPSG:5179) 데이터의 ST_Transform 사용을 위해 필요 +INSERT INTO spatial_ref_sys (srid, auth_name, auth_srid, proj4text, srtext) +VALUES ( + 5179, + 'EPSG', + 5179, + '+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +units=m +no_defs', + 'PROJCS["Korea 2000 / Unified CS",GEOGCS["Korea 2000",DATUM["Geocentric_datum_of_Korea",SPHEROID["GRS 1980",6378137,298.257222101,AUTHORITY["EPSG","7019"]],TOWGS84[0,0,0,0,0,0,0],AUTHORITY["EPSG","6737"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4737"]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",38],PARAMETER["central_meridian",127.5],PARAMETER["scale_factor",0.9996],PARAMETER["false_easting",1000000],PARAMETER["false_northing",2000000],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Northing",NORTH],AXIS["Easting",EAST],AUTHORITY["EPSG","5179"]]' +) +ON CONFLICT (srid) DO UPDATE SET + proj4text = EXCLUDED.proj4text, + srtext = EXCLUDED.srtext; diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 36a2bc1..e193c5f 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -1,8 +1,9 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react' import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre' import { MapboxOverlay } from '@deck.gl/mapbox' -import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer } from '@deck.gl/layers' +import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer, GeoJsonLayer } from '@deck.gl/layers' import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core' +import type { SpatialQueryResult, FshfrmProperties } from '@tabs/prediction/services/analysisService' import type { StyleSpecification } from 'maplibre-gl' import type { MapLayerMouseEvent } from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' @@ -365,6 +366,49 @@ interface MapViewProps { lightMode?: boolean /** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */ showOverlays?: boolean + /** 오염분석 공간 쿼리 결과 (어장 등 GIS 레이어) */ + spatialQueryResult?: SpatialQueryResult | null +} + +// 어장 정보 팝업 컴포넌트 (gis.fshfrm) +function FshfrmPopup({ properties }: { properties: FshfrmProperties }) { + const rows: [string, string | number | null | undefined][] = [ + ['시도', properties.ctpv_nm], + ['시군구', properties.sgg_nm], + ['행정동', properties.admdst_nm], + ['어장 구분', properties.fids_se], + ['어업 종류', properties.fids_knd], + ['양식 방법', properties.fids_mthd], + ['양식 품종', properties.farm_knd], + ['주소', properties.addr], + ['면적(㎡)', properties.area != null ? parseFloat(String(properties.area)).toFixed(2) : null], + ['허가번호', properties.lcns_no], + ['허가일자', properties.lcns_bgng_], + ['허가만료', properties.lcns_end_y], + ] + return ( +
+
+ 🐟 어장 정보 +
+ + + {rows + .filter(([, v]) => v != null && v !== '') + .map(([label, value]) => ( + + + + + ))} + +
+ {label} + + {String(value)} +
+
+ ) } // deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved) @@ -548,6 +592,7 @@ export function MapView({ analysisCircleRadiusM = 0, lightMode = false, showOverlays = true, + spatialQueryResult, }: MapViewProps) { const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore() const { handleMeasureClick } = useMeasureTool() @@ -559,6 +604,15 @@ export function MapView({ const [isPlaying, setIsPlaying] = useState(false) const [playbackSpeed, setPlaybackSpeed] = useState(1) const [popupInfo, setPopupInfo] = useState(null) + // deck.gl 레이어 클릭 시 MapLibre 맵 클릭 핸들러 차단용 플래그 (민감자원 등) + const deckClickHandledRef = useRef(false) + // 클릭으로 열린 팝업(닫기 전까지 유지) 추적 — 호버 핸들러가 닫지 않도록 방지 + const persistentPopupRef = useRef(false) + // GeoJsonLayer(어장 폴리곤) hover 중인 피처 — handleMapClick에서 팝업 표시에 사용 + const hoveredGeoLayerRef = useRef<{ + props: FshfrmProperties; + coord: [number, number]; + } | null>(null) const currentTime = isControlled ? externalCurrentTime : internalCurrentTime const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => { @@ -569,6 +623,23 @@ export function MapView({ const handleMapClick = useCallback((e: MapLayerMouseEvent) => { const { lng, lat } = e.lngLat setCurrentPosition([lat, lng]) + // 어장 폴리곤(GeoJsonLayer) 위에서 좌클릭 시 팝업 표시 + // deck.gl onClick 대신 handleMapClick에서 처리하여 이벤트 순서 문제 회피 + if (hoveredGeoLayerRef.current) { + const { props, coord } = hoveredGeoLayerRef.current + persistentPopupRef.current = true + setPopupInfo({ + longitude: coord[0], + latitude: coord[1], + content: , + }) + return + } + // deck.gl 다른 레이어(민감자원 등) onClick이 처리한 클릭 — 팝업 유지 + if (deckClickHandledRef.current) { + deckClickHandledRef.current = false + return + } if (measureMode !== null) { handleMeasureClick(lng, lat) return @@ -716,7 +787,7 @@ export function MapView({ getPath: (d: BoomLine) => d.coords.map(c => [c.lon, c.lat] as [number, number]), getColor: (d: BoomLine) => hexToRgba(PRIORITY_COLORS[d.priority] || '#f59e0b', 230), getWidth: (d: BoomLine) => PRIORITY_WEIGHTS[d.priority] || 2, - getDashArray: (d: BoomLine) => d.status === 'PLANNED' ? [10, 5] : null, + getDashArray: (d: BoomLine) => d.status === 'PLANNED' ? [10, 5] : [0, 0], dashJustified: true, widthMinPixels: 2, widthMaxPixels: 6, @@ -1018,7 +1089,10 @@ export function MapView({ ), }); } else if (!info.object) { - setPopupInfo(null); + // 클릭으로 열린 팝업(어장 등)이 있으면 호버로 닫지 않음 + if (!persistentPopupRef.current) { + setPopupInfo(null); + } } }, }) @@ -1225,7 +1299,45 @@ export function MapView({ // 거리/면적 측정 레이어 result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements)) - return result + // --- 오염분석 공간 쿼리 결과 (어장 등 GIS 레이어) --- + // [추후 확장] 다중 테이블 시 _tableName별 색상 분기: TABLE_COLORS[f.properties._tableName] + if (spatialQueryResult && spatialQueryResult.features.length > 0) { + result.push( + new GeoJsonLayer({ + id: 'spatial-query-result', + data: { + ...spatialQueryResult, + features: spatialQueryResult.features.filter(f => f.geometry != null), + } as unknown as object, + filled: true, + stroked: true, + getFillColor: [34, 197, 94, 55], + getLineColor: [34, 197, 94, 200], + getLineWidth: 2, + lineWidthMinPixels: 1, + lineWidthMaxPixels: 3, + pickable: true, + autoHighlight: true, + highlightColor: [34, 197, 94, 120], + // 클릭은 handleMapClick에서 처리 (deck.gl onClick vs MapLibre click 이벤트 순서 충돌 회피) + onHover: (info: PickingInfo) => { + if (info.object && info.coordinate) { + hoveredGeoLayerRef.current = { + props: info.object.properties as FshfrmProperties, + coord: [info.coordinate[0], info.coordinate[1]], + } + } else { + hoveredGeoLayerRef.current = null + } + }, + updateTriggers: { + data: [spatialQueryResult], + }, + }) + ) + } + + return result.filter(Boolean) }, [ oilTrajectory, currentTime, selectedModels, boomLines, isDrawingBoom, drawingPoints, @@ -1233,6 +1345,7 @@ export function MapView({ sensitiveResources, centerPoints, windData, showWind, showBeached, showTimeLabel, simulationStartTime, analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode, + spatialQueryResult, ]) // 3D 모드 / 밝은 톤에 따른 지도 스타일 전환 @@ -1318,7 +1431,10 @@ export function MapView({ longitude={popupInfo.longitude} latitude={popupInfo.latitude} anchor="bottom" - onClose={() => setPopupInfo(null)} + onClose={() => { + persistentPopupRef.current = false + setPopupInfo(null) + }} >
{popupInfo.content}
diff --git a/frontend/src/common/data/layerData.ts b/frontend/src/common/data/layerData.ts index 20b8116..8a402cb 100755 --- a/frontend/src/common/data/layerData.ts +++ b/frontend/src/common/data/layerData.ts @@ -7,6 +7,7 @@ export interface LayerNode { name: string level: number layerName: string | null + dataTblNm?: string | null icon?: string count?: number defaultOn?: boolean diff --git a/frontend/src/common/services/api.ts b/frontend/src/common/services/api.ts index 6d99764..88ea572 100755 --- a/frontend/src/common/services/api.ts +++ b/frontend/src/common/services/api.ts @@ -46,6 +46,7 @@ export interface LayerDTO { cmn_cd_nm: string cmn_cd_level: number clnm: string | null + data_tbl_nm?: string | null icon?: string count?: number children?: LayerDTO[] @@ -58,6 +59,7 @@ export interface Layer { fullName: string level: number wmsLayer: string | null + dataTblNm?: string | null icon?: string count?: number children?: Layer[] @@ -72,6 +74,7 @@ function convertToLayer(dto: LayerDTO): Layer { fullName: dto.cmn_cd_full_nm, level: dto.cmn_cd_level, wmsLayer: dto.clnm, + dataTblNm: dto.data_tbl_nm, icon: dto.icon, count: dto.count, children: dto.children ? dto.children.map(convertToLayer) : undefined, diff --git a/frontend/src/common/services/layerService.ts b/frontend/src/common/services/layerService.ts index 848c0e5..08a6ee6 100755 --- a/frontend/src/common/services/layerService.ts +++ b/frontend/src/common/services/layerService.ts @@ -8,6 +8,7 @@ export interface Layer { fullName: string level: number wmsLayer: string | null + dataTblNm?: string | null icon?: string count?: number children?: Layer[] diff --git a/frontend/src/common/styles/components.css b/frontend/src/common/styles/components.css index 7593fa5..17faf2d 100644 --- a/frontend/src/common/styles/components.css +++ b/frontend/src/common/styles/components.css @@ -1,3 +1,9 @@ +/* 바람 입자 캔버스(z-index: 450) 위에 팝업이 표시되도록 z-index 설정 + @layer 밖에 위치해야 non-layered CSS인 MapLibre 스타일보다 우선순위를 가짐 */ +.maplibregl-popup { + z-index: 500; +} + @layer components { /* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */ .cctv-dark-popup .maplibregl-popup-content { diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index b63f394..c7d6a47 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -21,6 +21,8 @@ import SimulationErrorModal from './SimulationErrorModal' import { api } from '@common/services/api' import { generateAIBoomLines, haversineDistance, pointInPolygon, polygonAreaKm2, circleAreaKm2 } from '@common/utils/geo' import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal' +import { querySpatialLayers } from '../services/analysisService' +import type { SpatialQueryResult } from '../services/analysisService' export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift' @@ -203,6 +205,8 @@ export function OilSpillView() { const [analysisPolygonPoints, setAnalysisPolygonPoints] = useState<{ lat: number; lon: number }[]>([]) const [circleRadiusNm, setCircleRadiusNm] = useState(5) const [analysisResult, setAnalysisResult] = useState<{ area: number; particleCount: number; particlePercent: number; sensitiveCount: number } | null>(null) + const [spatialQueryResult, setSpatialQueryResult] = useState(null) + const [isSpatialQuerying, setIsSpatialQuerying] = useState(false) // 원 분석용 derived 값 (state 아님) const analysisCircleCenter = analysisTab === 'circle' && incidentCoord ? incidentCoord : null @@ -567,7 +571,7 @@ export function OilSpillView() { setAnalysisResult(null) } - const handleRunPolygonAnalysis = () => { + const handleRunPolygonAnalysis = async () => { if (analysisPolygonPoints.length < 3) return const currentParticles = oilTrajectory.filter(p => p.time === currentStep) const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1 @@ -580,9 +584,25 @@ export function OilSpillView() { sensitiveCount, }) setDrawAnalysisMode(null) + + // 공간 데이터 쿼리 (어장 등 정보 레이어) + // [추후 확장] 좌측 정보 레이어 활성화 목록 기반으로 layers 파라미터를 전달할 수 있습니다. + setIsSpatialQuerying(true) + try { + const result = await querySpatialLayers({ + type: 'polygon', + polygon: analysisPolygonPoints, + }) + setSpatialQueryResult(result) + } catch (err) { + console.error('[analysis] 공간 쿼리 실패:', err) + setSpatialQueryResult(null) + } finally { + setIsSpatialQuerying(false) + } } - const handleRunCircleAnalysis = () => { + const handleRunCircleAnalysis = async () => { if (!incidentCoord) return const radiusM = circleRadiusNm * 1852 const currentParticles = oilTrajectory.filter(p => p.time === currentStep) @@ -599,6 +619,23 @@ export function OilSpillView() { particlePercent: Math.round((inside / totalIds) * 100), sensitiveCount, }) + + // 공간 데이터 쿼리 (어장 등 정보 레이어) + // [추후 확장] 좌측 정보 레이어 활성화 목록 기반으로 layers 파라미터를 전달할 수 있습니다. + setIsSpatialQuerying(true) + try { + const result = await querySpatialLayers({ + type: 'circle', + center: { lat: incidentCoord.lat, lon: incidentCoord.lon }, + radiusM, + }) + setSpatialQueryResult(result) + } catch (err) { + console.error('[analysis] 공간 쿼리 실패:', err) + setSpatialQueryResult(null) + } finally { + setIsSpatialQuerying(false) + } } const handleCancelAnalysis = () => { @@ -610,6 +647,7 @@ export function OilSpillView() { setDrawAnalysisMode(null) setAnalysisPolygonPoints([]) setAnalysisResult(null) + setSpatialQueryResult(null) } const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => { @@ -1029,6 +1067,7 @@ export function OilSpillView() { showBeached={displayControls.showBeached} showTimeLabel={displayControls.showTimeLabel} simulationStartTime={accidentTime || undefined} + spatialQueryResult={spatialQueryResult} /> {/* 타임라인 플레이어 (리플레이 비활성 시) */} @@ -1244,6 +1283,8 @@ export function OilSpillView() { onRunCircleAnalysis={handleRunCircleAnalysis} onCancelAnalysis={handleCancelAnalysis} onClearAnalysis={handleClearAnalysis} + spatialQueryResult={spatialQueryResult} + isSpatialQuerying={isSpatialQuerying} /> )} diff --git a/frontend/src/tabs/prediction/components/RightPanel.tsx b/frontend/src/tabs/prediction/components/RightPanel.tsx index 4a3f212..55deaf2 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 type { DisplayControls } from './OilSpillView' +import type { SpatialQueryResult } from '../services/analysisService' interface AnalysisResult { area: number @@ -34,6 +35,8 @@ interface RightPanelProps { onRunCircleAnalysis?: () => void onCancelAnalysis?: () => void onClearAnalysis?: () => void + spatialQueryResult?: SpatialQueryResult | null + isSpatialQuerying?: boolean } export function RightPanel({ @@ -46,6 +49,7 @@ export function RightPanel({ analysisResult, onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis, onCancelAnalysis, onClearAnalysis, + spatialQueryResult, isSpatialQuerying = false, }: RightPanelProps) { const vessel = detail?.vessels?.[0] const vessel2 = detail?.vessels?.[1] @@ -217,6 +221,70 @@ export function RightPanel({ )} )} + {/* 공간 쿼리 결과 요약 (어장 등 GIS 레이어) */} + {isSpatialQuerying && ( +
+ 어장 정보 조회 중... +
+ )} + {spatialQueryResult && !isSpatialQuerying && ( +
+
+ 🐟 + 어장 정보 +
+ {spatialQueryResult._meta.map(meta => ( +
+ 건수:{' '} + {meta.count} + 개소 (지도에서 클릭하여 상세 확인) +
+ ))} + {spatialQueryResult.features.length === 0 && ( +
해당 영역 내 어장 없음
+ )} + {spatialQueryResult.features.length > 0 && (() => { + // 어업 종류별 건수 및 면적 합산 + const grouped = spatialQueryResult.features.reduce< + Record + >((acc, feat) => { + const kind = feat.properties.fids_knd ?? '미분류'; + const area = Number(feat.properties.area ?? 0); + if (!acc[kind]) acc[kind] = { count: 0, totalArea: 0 }; + acc[kind].count += 1; + acc[kind].totalArea += area; + return acc; + }, {}); + const groupList = Object.entries(grouped); + const totalCount = groupList.reduce((s, [, v]) => s + v.count, 0); + const totalArea = groupList.reduce((s, [, v]) => s + v.totalArea, 0); + return ( +
+ {/* 헤더 */} +
+ 어업 종류 + 건수 + 면적(m²) +
+ {/* 종류별 행 */} + {groupList.map(([kind, { count, totalArea: area }]) => ( +
+ {kind} + {count} + {area.toFixed(2)} +
+ ))} + {/* 합계 행 */} +
+ 합계 + {totalCount} + {totalArea.toFixed(2)} +
+
+ ); + })()} +
+ )} {/* 오염 종합 상황 */} diff --git a/frontend/src/tabs/prediction/services/analysisService.ts b/frontend/src/tabs/prediction/services/analysisService.ts new file mode 100644 index 0000000..090eeea --- /dev/null +++ b/frontend/src/tabs/prediction/services/analysisService.ts @@ -0,0 +1,88 @@ +import { api } from '@common/services/api' + +// ============================================================ +// 공간 쿼리 요청 타입 +// ============================================================ + +export interface SpatialQueryPolygonRequest { + type: 'polygon' + polygon: Array<{ lat: number; lon: number }> + /** + * 조회할 PostGIS 테이블명 목록 (DATA_TBL_NM 기준) + * 미전달 시 서버 기본값(gis.fshfrm) 사용 + * + * [추후 확장] 좌측 "정보 레이어"의 활성화된 레이어를 연동할 경우: + * const activeLayers = [...enabledLayers] + * .map(id => allLayers.find(l => l.id === id)?.dataTblNm) + * .filter((tbl): tbl is string => !!tbl) + * 위 배열을 layers 파라미터로 전달하면 됩니다. + */ + layers?: string[] +} + +export interface SpatialQueryCircleRequest { + type: 'circle' + center: { lat: number; lon: number } + /** 반경 (미터 단위) */ + radiusM: number + /** @see SpatialQueryPolygonRequest.layers */ + layers?: string[] +} + +export type SpatialQueryRequest = SpatialQueryPolygonRequest | SpatialQueryCircleRequest + +// ============================================================ +// 공간 쿼리 응답 타입 — gis.fshfrm (어장 정보) +// ============================================================ + +export interface FshfrmProperties { + gid: number + rgn_nm: string | null + ctgry_cd: string | null + admdst_nm: string | null + fids_se: string | null + fids_knd: string | null + fids_mthd: string | null + farm_knd: string | null + addr: string | null + area: number | null + lcns_no: string | null + ctpv_nm: string | null + sgg_nm: string | null + lcns_bgng_: string | null + lcns_end_y: string | null + /** 데이터 출처 테이블명 (색상/팝업 분기에 활용) */ + _tableName: string +} + +/** 추후 다른 테이블 properties 타입 추가 시 union으로 확장 */ +export type SpatialFeatureProperties = FshfrmProperties + +export interface SpatialQueryFeature { + type: 'Feature' + geometry: { + type: 'MultiPolygon' | 'Polygon' + coordinates: number[][][][] + } + properties: SpatialFeatureProperties +} + +export interface SpatialQueryResult { + type: 'FeatureCollection' + features: SpatialQueryFeature[] + _meta: Array<{ tableName: string; count: number }> +} + +// ============================================================ +// API 함수 +// ============================================================ + +export const querySpatialLayers = async ( + request: SpatialQueryRequest +): Promise => { + const response = await api.post( + '/analysis/spatial-query', + request + ) + return response.data +} From e06287ba5b6e6bf8e2d0c7c114a8a91892ed96e1 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 23 Mar 2026 19:08:27 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EA=B8=B0=EC=83=81=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85=EC=83=B7=20=EC=9E=90=EB=8F=99=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?+=20=EB=AF=BC=EA=B0=90=EC=9E=90=EC=9B=90=20DB=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20+=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20API=20=EC=98=88=EC=B8=A1=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/analysis/analysisRouter.ts | 166 --------------- backend/src/incidents/incidentsRouter.ts | 19 ++ backend/src/incidents/incidentsService.ts | 147 +++++++++++-- backend/src/prediction/predictionRouter.ts | 33 +++ backend/src/prediction/predictionService.ts | 70 +++++++ backend/src/reports/reportsRouter.ts | 7 +- backend/src/reports/reportsService.ts | 21 +- backend/src/server.ts | 2 - database/migration/025_layer_data_tbl_nm.sql | 7 - database/migration/025_weather_columns.sql | 44 ++++ database/migration/026_register_srid_5179.sql | 13 -- .../migration/026_sensitive_resources.sql | 41 ++++ docs/RELEASE-NOTES.md | 6 + .../src/common/components/map/MapView.tsx | 197 +++++++++--------- .../src/common/store/weatherSnapshotStore.ts | 15 ++ .../components/IncidentsLeftPanel.tsx | 64 +++--- .../tabs/prediction/components/LeftPanel.tsx | 16 +- .../prediction/components/OilSpillView.tsx | 112 +++++----- .../components/PredictionInputSection.tsx | 16 +- .../tabs/prediction/components/RightPanel.tsx | 77 +------ .../prediction/components/leftPanelTypes.ts | 4 +- .../prediction/services/analysisService.ts | 88 -------- .../tabs/prediction/services/predictionApi.ts | 38 ++++ .../src/tabs/reports/services/reportsApi.ts | 8 - .../src/tabs/weather/services/weatherUtils.ts | 23 ++ 25 files changed, 642 insertions(+), 592 deletions(-) delete mode 100644 backend/src/analysis/analysisRouter.ts delete mode 100644 database/migration/025_layer_data_tbl_nm.sql create mode 100644 database/migration/025_weather_columns.sql delete mode 100644 database/migration/026_register_srid_5179.sql create mode 100644 database/migration/026_sensitive_resources.sql delete mode 100644 frontend/src/tabs/prediction/services/analysisService.ts diff --git a/backend/src/analysis/analysisRouter.ts b/backend/src/analysis/analysisRouter.ts deleted file mode 100644 index c7b5cdd..0000000 --- a/backend/src/analysis/analysisRouter.ts +++ /dev/null @@ -1,166 +0,0 @@ -import express from 'express' -import { wingPool } from '../db/wingDb.js' -import { isValidNumber } from '../middleware/security.js' - -const router = express.Router() - -// ============================================================ -// 공간 쿼리 대상 테이블 레지스트리 -// 새 테이블 추가 시: TABLE_META에 항목 추가 + LAYER 테이블 DATA_TBL_NM 컬럼에 해당 값 등록 -// 예) 'gis.coastal_zone': { geomColumn: 'geom', srid: 4326, properties: ['id','zone_nm','...'] } -// ============================================================ -const TABLE_META: Record = { - 'gis.fshfrm': { - geomColumn: 'geom', - srid: 5179, - properties: [ - 'gid', 'rgn_nm', 'ctgry_cd', 'admdst_nm', 'fids_se', - 'fids_knd', 'fids_mthd', 'farm_knd', 'addr', 'area', - 'lcns_no', 'ctpv_nm', 'sgg_nm', 'lcns_bgng_', 'lcns_end_y', - ], - }, -} - -// ============================================================ -// POST /api/analysis/spatial-query -// 영역(다각형 또는 원) 내 공간 데이터 조회 -// ============================================================ -router.post('/spatial-query', async (req, res) => { - try { - const { type, polygon, center, radiusM, layers } = req.body as { - type: 'polygon' | 'circle' - polygon?: Array<{ lat: number; lon: number }> - center?: { lat: number; lon: number } - radiusM?: number - layers?: string[] - } - - // 조회할 테이블 결정: 요청에서 전달된 layers 또는 기본값 - const requestedLayers: string[] = Array.isArray(layers) && layers.length > 0 - ? layers - : ['gis.fshfrm'] - - // DB 화이트리스트 검증 (LAYER.DATA_TBL_NM에 등록된 테이블만 허용) - const { rows: allowedRows } = await wingPool.query<{ data_tbl_nm: string }>( - `SELECT DATA_TBL_NM AS data_tbl_nm FROM LAYER - WHERE DATA_TBL_NM = ANY($1) AND USE_YN = 'Y' AND DEL_YN = 'N'`, - [requestedLayers] - ) - // TABLE_META에도 등록되어 있어야 실제 쿼리 가능 - const allowedTables = allowedRows - .map(r => r.data_tbl_nm) - .filter(tbl => tbl in TABLE_META) - - // LAYER 테이블에 없더라도 TABLE_META에 있고 기본 요청이면 허용 (초기 데이터 미등록 시 fallback) - const finalTables = allowedTables.length > 0 - ? allowedTables - : requestedLayers.filter(tbl => tbl in TABLE_META) - - if (finalTables.length === 0) { - return res.status(400).json({ error: '조회 가능한 레이어가 없습니다.' }) - } - - const allFeatures: object[] = [] - const metaList: Array<{ tableName: string; count: number }> = [] - - for (const tableName of finalTables) { - const meta = TABLE_META[tableName] - const { geomColumn, srid, properties } = meta - - const propColumns = properties.join(', ') - - let rows: Array> = [] - - if (type === 'polygon') { - if (!Array.isArray(polygon) || polygon.length < 3) { - return res.status(400).json({ error: '다각형 분석에는 최소 3개의 좌표가 필요합니다.' }) - } - - // 좌표 숫자 검증 후 WKT 조립 (SQL 인젝션 방지) - const validCoords = polygon.filter(p => - isValidNumber(p.lat, -90, 90) && isValidNumber(p.lon, -180, 180) - ) - if (validCoords.length < 3) { - return res.status(400).json({ error: '유효하지 않은 좌표가 포함되어 있습니다.' }) - } - - // 폴리곤을 닫기 위해 첫 번째 좌표를 마지막에 추가 - const coordStr = [...validCoords, validCoords[0]] - .map(p => `${p.lon} ${p.lat}`) - .join(', ') - const wkt = `POLYGON((${coordStr}))` - - const { rows: queryRows } = await wingPool.query( - `SELECT ${propColumns}, - ST_AsGeoJSON( - ST_SimplifyPreserveTopology(ST_Transform(${geomColumn}, 4326), 0.00001) - ) AS geom_geojson - FROM ${tableName} - WHERE ST_Intersects( - ${geomColumn}, - ST_Transform(ST_GeomFromText($1, 4326), ${srid}) - )`, - [wkt] - ) - rows = queryRows as Array> - - } else if (type === 'circle') { - if (!center || !isValidNumber(center.lat, -90, 90) || !isValidNumber(center.lon, -180, 180)) { - return res.status(400).json({ error: '원 분석에 유효하지 않은 중심 좌표입니다.' }) - } - if (!isValidNumber(radiusM, 1, 10000000)) { - return res.status(400).json({ error: '원 분석에 유효하지 않은 반경 값입니다.' }) - } - - const { rows: queryRows } = await wingPool.query( - `SELECT ${propColumns}, - ST_AsGeoJSON( - ST_SimplifyPreserveTopology(ST_Transform(${geomColumn}, 4326), 0.00001) - ) AS geom_geojson - FROM ${tableName} - WHERE ST_DWithin( - ${geomColumn}, - ST_Transform(ST_SetSRID(ST_MakePoint($1, $2), 4326), ${srid}), - $3 - )`, - [center.lon, center.lat, radiusM] - ) - rows = queryRows as Array> - - } else { - return res.status(400).json({ error: '지원하지 않는 분석 유형입니다. polygon 또는 circle을 사용하세요.' }) - } - - const features = rows.map(row => { - const { geom_geojson, ...props } = row - return { - type: 'Feature', - geometry: JSON.parse(String(geom_geojson)), - properties: { - ...props, - _tableName: tableName, - }, - } - }) - - allFeatures.push(...features) - metaList.push({ tableName, count: features.length }) - } - - res.json({ - type: 'FeatureCollection', - features: allFeatures, - _meta: metaList, - }) - - } catch (err) { - console.error('[analysis] 공간 쿼리 오류:', err) - res.status(500).json({ error: '공간 쿼리 처리 중 오류가 발생했습니다.' }) - } -}) - -export default router diff --git a/backend/src/incidents/incidentsRouter.ts b/backend/src/incidents/incidentsRouter.ts index a27c82d..abb09d6 100644 --- a/backend/src/incidents/incidentsRouter.ts +++ b/backend/src/incidents/incidentsRouter.ts @@ -5,6 +5,7 @@ import { getIncident, listIncidentPredictions, getIncidentWeather, + saveIncidentWeather, getIncidentMedia, } from './incidentsService.js'; @@ -92,6 +93,24 @@ router.get('/:sn/weather', requireAuth, async (req, res) => { } }); +// ============================================================ +// POST /api/incidents/:sn/weather — 기상정보 저장 +// ============================================================ +router.post('/:sn/weather', requireAuth, async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10); + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 사고 번호입니다.' }); + return; + } + const weatherSn = await saveIncidentWeather(sn, req.body as Record); + res.json({ weatherSn }); + } catch (err) { + console.error('[incidents] 기상정보 저장 오류:', err); + res.status(500).json({ error: '기상정보 저장 중 오류가 발생했습니다.' }); + } +}); + // ============================================================ // GET /api/incidents/:sn/media — 미디어 정보 // ============================================================ diff --git a/backend/src/incidents/incidentsService.ts b/backend/src/incidents/incidentsService.ts index 8a23355..104fee0 100644 --- a/backend/src/incidents/incidentsService.ts +++ b/backend/src/incidents/incidentsService.ts @@ -254,24 +254,143 @@ export async function getIncidentWeather(acdntSn: number): Promise; return { - locNm: r.loc_nm as string, - obsDtm: (r.obs_dtm as Date).toISOString(), - icon: r.icon as string, - temp: r.temp as string, - weatherDc: r.weather_dc as string, - wind: r.wind as string, - wave: r.wave as string, - humid: r.humid as string, - vis: r.vis as string, - sst: r.sst as string, - tide: r.tide as string, - highTide: r.high_tide as string, - lowTide: r.low_tide as string, + locNm: (r.loc_nm as string | null) ?? '-', + obsDtm: r.obs_dtm ? (r.obs_dtm as Date).toISOString() : '-', + icon: (r.icon as string | null) ?? '', + temp: (r.temp as string | null) ?? '-', + weatherDc: (r.weather_dc as string | null) ?? '-', + wind: (r.wind as string | null) ?? '-', + wave: (r.wave as string | null) ?? '-', + humid: (r.humid as string | null) ?? '-', + vis: (r.vis as string | null) ?? '-', + sst: (r.sst as string | null) ?? '-', + tide: (r.tide as string | null) ?? '-', + highTide: (r.high_tide as string | null) ?? '-', + lowTide: (r.low_tide as string | null) ?? '-', forecast: (r.forecast as Array<{ hour: string; icon: string; temp: string }>) ?? [], - impactDc: r.impact_dc as string, + impactDc: (r.impact_dc as string | null) ?? '-', }; } +// ============================================================ +// 기상정보 저장 (예측 실행 시 스냅샷 저장) +// ============================================================ +interface WeatherSnapshotPayload { + stationName?: string; + capturedAt?: string; + wind?: { + speed?: number; + direction?: number; + directionLabel?: string; + speed_1k?: number; + speed_3k?: number; + }; + wave?: { + height?: number; + maxHeight?: number; + period?: number; + direction?: string; + }; + temperature?: { + current?: number; + feelsLike?: number; + }; + pressure?: number; + visibility?: number; + salinity?: number; + astronomy?: { + sunrise?: string; + sunset?: string; + moonrise?: string; + moonset?: string; + moonPhase?: string; + tidalRange?: number; + } | null; + alert?: string | null; + forecast?: unknown[] | null; +} + +export async function saveIncidentWeather( + acdntSn: number, + snapshot: WeatherSnapshotPayload, +): Promise { + // 팝업 표시용 포맷 문자열 + const windStr = (snapshot.wind?.directionLabel && snapshot.wind?.speed != null) + ? `${snapshot.wind.directionLabel} ${snapshot.wind.speed}m/s` : null; + const waveStr = snapshot.wave?.height != null ? `${snapshot.wave.height}m` : null; + const tempStr = snapshot.temperature?.feelsLike != null ? `${snapshot.temperature.feelsLike}°C` : null; + const vis = snapshot.visibility != null ? String(snapshot.visibility) : null; + const sst = snapshot.temperature?.current != null ? String(snapshot.temperature.current) : null; + const highTideStr = snapshot.astronomy?.tidalRange != null + ? `조차 ${snapshot.astronomy.tidalRange}m` : null; + + // 24h 예보: WeatherSnapshot 형식 → 팝업 표시 형식 변환 + type ForecastItem = { time?: string; icon?: string; temperature?: number }; + const forecastDisplay = (snapshot.forecast as ForecastItem[] | null)?.map(f => ({ + hour: f.time ?? '', + icon: f.icon ?? '⛅', + temp: f.temperature != null ? `${Math.round(f.temperature)}°` : '-', + })) ?? null; + + const sql = ` + INSERT INTO wing.ACDNT_WEATHER ( + ACDNT_SN, LOC_NM, OBS_DTM, + WIND_SPEED, WIND_DIR, WIND_DIR_LBL, WIND_SPEED_1K, WIND_SPEED_3K, + PRESSURE, VIS, + WAVE_HEIGHT, WAVE_MAX_HT, WAVE_PERIOD, WAVE_DIR, + SST, AIR_TEMP, SALINITY, + SUNRISE, SUNSET, MOONRISE, MOONSET, MOON_PHASE, TIDAL_RANGE, + WEATHER_ALERT, FORECAST, + TEMP, WIND, WAVE, ICON, HIGH_TIDE, IMPACT_DC + ) VALUES ( + $1, $2, NOW(), + $3, $4, $5, $6, $7, + $8, $9, + $10, $11, $12, $13, + $14, $15, $16, + $17, $18, $19, $20, $21, $22, + $23, $24, + $25, $26, $27, $28, $29, $30 + ) + RETURNING WEATHER_SN + `; + + const { rows } = await wingPool.query(sql, [ + acdntSn, + snapshot.stationName ?? null, + snapshot.wind?.speed ?? null, + snapshot.wind?.direction ?? null, + snapshot.wind?.directionLabel ?? null, + snapshot.wind?.speed_1k ?? null, + snapshot.wind?.speed_3k ?? null, + snapshot.pressure ?? null, + vis, + snapshot.wave?.height ?? null, + snapshot.wave?.maxHeight ?? null, + snapshot.wave?.period ?? null, + snapshot.wave?.direction ?? null, + sst, + snapshot.temperature?.feelsLike ?? null, + snapshot.salinity ?? null, + snapshot.astronomy?.sunrise ?? null, + snapshot.astronomy?.sunset ?? null, + snapshot.astronomy?.moonrise ?? null, + snapshot.astronomy?.moonset ?? null, + snapshot.astronomy?.moonPhase ?? null, + snapshot.astronomy?.tidalRange ?? null, + snapshot.alert ?? null, + forecastDisplay ? JSON.stringify(forecastDisplay) : null, + tempStr, + windStr, + waveStr, + '🌊', + highTideStr, + snapshot.alert ?? null, + ]); + + return (rows[0] as Record).weather_sn as number; +} + // ============================================================ // 미디어 정보 조회 // ============================================================ diff --git a/backend/src/prediction/predictionRouter.ts b/backend/src/prediction/predictionRouter.ts index d017a19..0f91c93 100644 --- a/backend/src/prediction/predictionRouter.ts +++ b/backend/src/prediction/predictionRouter.ts @@ -3,6 +3,7 @@ import multer from 'multer'; import { listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt, createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory, + getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn, } from './predictionService.js'; import { analyzeImageFile } from './imageAnalyzeService.js'; import { isValidNumber } from '../middleware/security.js'; @@ -64,6 +65,38 @@ router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('pred } }); +// GET /api/prediction/analyses/:acdntSn/sensitive-resources — 예측 영역 내 민감자원 집계 +router.get('/analyses/:acdntSn/sensitive-resources', 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 getSensitiveResourcesByAcdntSn(acdntSn); + res.json(result); + } catch (err) { + console.error('[prediction] 민감자원 조회 오류:', err); + res.status(500).json({ error: '민감자원 조회 실패' }); + } +}); + +// GET /api/prediction/analyses/:acdntSn/sensitive-resources/geojson — 예측 영역 내 민감자원 GeoJSON +router.get('/analyses/:acdntSn/sensitive-resources/geojson', 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 getSensitiveResourcesGeoJsonByAcdntSn(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 6d5df40..1af73aa 100644 --- a/backend/src/prediction/predictionService.ts +++ b/backend/src/prediction/predictionService.ts @@ -585,6 +585,76 @@ export async function getAnalysisTrajectory(acdntSn: number): Promise { + const sql = ` + WITH all_wkts AS ( + SELECT step_data ->> 'wkt' AS wkt + FROM wing.PRED_EXEC, + jsonb_array_elements(RSLT_DATA) AS step_data + WHERE ACDNT_SN = $1 + AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON') + AND EXEC_STTS_CD = 'COMPLETED' + AND RSLT_DATA IS NOT NULL + ), + union_geom AS ( + SELECT ST_Union(ST_GeomFromText(wkt, 4326)) AS geom + FROM all_wkts + WHERE wkt IS NOT NULL AND wkt <> '' + ) + SELECT sr.CATEGORY, COUNT(*)::int AS count + FROM wing.SENSITIVE_RESOURCE sr, union_geom + WHERE union_geom.geom IS NOT NULL + AND ST_Intersects(sr.GEOM, union_geom.geom) + GROUP BY sr.CATEGORY + ORDER BY sr.CATEGORY + `; + const { rows } = await wingPool.query(sql, [acdntSn]); + return rows.map((r: Record) => ({ + category: String(r['category'] ?? ''), + count: Number(r['count'] ?? 0), + })); +} + +export async function getSensitiveResourcesGeoJsonByAcdntSn( + acdntSn: number, +): Promise<{ type: 'FeatureCollection'; features: unknown[] }> { + const sql = ` + WITH all_wkts AS ( + SELECT step_data ->> 'wkt' AS wkt + FROM wing.PRED_EXEC, + jsonb_array_elements(RSLT_DATA) AS step_data + WHERE ACDNT_SN = $1 + AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON') + AND EXEC_STTS_CD = 'COMPLETED' + AND RSLT_DATA IS NOT NULL + ), + union_geom AS ( + SELECT ST_Union(ST_GeomFromText(wkt, 4326)) AS geom + FROM all_wkts + WHERE wkt IS NOT NULL AND wkt <> '' + ) + SELECT sr.SR_ID, sr.CATEGORY, sr.PROPERTIES, + ST_AsGeoJSON(sr.GEOM)::jsonb AS geom_json + FROM wing.SENSITIVE_RESOURCE sr, union_geom + WHERE union_geom.geom IS NOT NULL + AND ST_Intersects(sr.GEOM, union_geom.geom) + ORDER BY sr.CATEGORY, sr.SR_ID + `; + const { rows } = await wingPool.query(sql, [acdntSn]); + const features = rows.map((r: Record) => ({ + type: 'Feature', + geometry: r['geom_json'], + properties: { + srId: Number(r['sr_id']), + category: String(r['category'] ?? ''), + ...(r['properties'] as Record ?? {}), + }, + })); + return { type: 'FeatureCollection', features }; +} + 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/reportsRouter.ts b/backend/src/reports/reportsRouter.ts index f69111f..ccda7eb 100644 --- a/backend/src/reports/reportsRouter.ts +++ b/backend/src/reports/reportsRouter.ts @@ -92,7 +92,7 @@ router.get('/:sn', requireAuth, requirePermission('reports', 'READ'), async (req // ============================================================ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => { try { - const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections, mapCaptureImg, step3MapImg, step6MapImg } = req.body; + const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections, step3MapImg, step6MapImg } = req.body; const result = await createReport({ tmplSn, ctgrSn, @@ -101,7 +101,6 @@ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req jrsdCd, sttsCd, authorId: req.user!.sub, - mapCaptureImg, step3MapImg, step6MapImg, sections, @@ -127,8 +126,8 @@ router.post('/:sn/update', requireAuth, requirePermission('reports', 'UPDATE'), res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' }); return; } - const { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg, step3MapImg, step6MapImg } = req.body; - await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg, step3MapImg, step6MapImg }, req.user!.sub); + const { title, jrsdCd, sttsCd, acdntSn, sections, step3MapImg, step6MapImg } = req.body; + await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, step3MapImg, step6MapImg }, req.user!.sub); res.json({ success: true }); } catch (err) { if (err instanceof AuthError) { diff --git a/backend/src/reports/reportsService.ts b/backend/src/reports/reportsService.ts index e75f0e8..674dceb 100644 --- a/backend/src/reports/reportsService.ts +++ b/backend/src/reports/reportsService.ts @@ -75,7 +75,6 @@ interface SectionData { interface ReportDetail extends ReportListItem { acdntSn: number | null; sections: SectionData[]; - mapCaptureImg: string | null; step3MapImg: string | null; step6MapImg: string | null; } @@ -104,7 +103,6 @@ interface CreateReportInput { jrsdCd?: string; sttsCd?: string; authorId: string; - mapCaptureImg?: string; step3MapImg?: string; step6MapImg?: string; sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[]; @@ -115,7 +113,6 @@ interface UpdateReportInput { jrsdCd?: string; sttsCd?: string; acdntSn?: number | null; - mapCaptureImg?: string | null; step3MapImg?: string | null; step6MapImg?: string | null; sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[]; @@ -267,8 +264,7 @@ export async function listReports(input: ListReportsInput): Promise '') - OR (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '') + CASE WHEN (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '') OR (r.STEP6_MAP_IMG IS NOT NULL AND r.STEP6_MAP_IMG <> '') THEN true ELSE false END AS HAS_MAP_CAPTURE FROM REPORT r @@ -309,9 +305,8 @@ export async function getReport(reportSn: number): Promise { c.CTGR_CD, c.CTGR_NM, r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN, r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME, - r.REG_DTM, r.MDFCN_DTM, r.MAP_CAPTURE_IMG, r.STEP3_MAP_IMG, r.STEP6_MAP_IMG, - CASE WHEN (r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '') - OR (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '') + r.REG_DTM, r.MDFCN_DTM, r.STEP3_MAP_IMG, r.STEP6_MAP_IMG, + CASE WHEN (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '') OR (r.STEP6_MAP_IMG IS NOT NULL AND r.STEP6_MAP_IMG <> '') THEN true ELSE false END AS HAS_MAP_CAPTURE FROM REPORT r @@ -350,7 +345,6 @@ export async function getReport(reportSn: number): Promise { authorName: r.author_name || '', regDtm: r.reg_dtm, mdfcnDtm: r.mdfcn_dtm, - mapCaptureImg: r.map_capture_img, step3MapImg: r.step3_map_img, step6MapImg: r.step6_map_img, hasMapCapture: r.has_map_capture, @@ -373,8 +367,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb await client.query('BEGIN'); const res = await client.query( - `INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, MAP_CAPTURE_IMG, STEP3_MAP_IMG, STEP6_MAP_IMG) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, STEP3_MAP_IMG, STEP6_MAP_IMG) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING REPORT_SN`, [ input.tmplSn || null, @@ -384,7 +378,6 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb input.jrsdCd || null, input.sttsCd || 'DRAFT', input.authorId, - input.mapCaptureImg || null, input.step3MapImg || null, input.step6MapImg || null, ] @@ -458,10 +451,6 @@ export async function updateReport( sets.push(`ACDNT_SN = $${idx++}`); params.push(input.acdntSn); } - if (input.mapCaptureImg !== undefined) { - sets.push(`MAP_CAPTURE_IMG = $${idx++}`); - params.push(input.mapCaptureImg); - } if (input.step3MapImg !== undefined) { sets.push(`STEP3_MAP_IMG = $${idx++}`); params.push(input.step3MapImg); diff --git a/backend/src/server.ts b/backend/src/server.ts index 71d5efc..8e48bdb 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -23,7 +23,6 @@ import predictionRouter from './prediction/predictionRouter.js' import aerialRouter from './aerial/aerialRouter.js' import rescueRouter from './rescue/rescueRouter.js' import mapBaseRouter from './map-base/mapBaseRouter.js' -import analysisRouter from './analysis/analysisRouter.js' import { sanitizeBody, sanitizeQuery, @@ -171,7 +170,6 @@ app.use('/api/prediction', predictionRouter) app.use('/api/aerial', aerialRouter) app.use('/api/rescue', rescueRouter) app.use('/api/map-base', mapBaseRouter) -app.use('/api/analysis', analysisRouter) // 헬스 체크 app.get('/health', (_req, res) => { diff --git a/database/migration/025_layer_data_tbl_nm.sql b/database/migration/025_layer_data_tbl_nm.sql deleted file mode 100644 index 4c5ae1e..0000000 --- a/database/migration/025_layer_data_tbl_nm.sql +++ /dev/null @@ -1,7 +0,0 @@ --- 025_layer_data_tbl_nm.sql --- LAYER 테이블에 PostGIS 데이터 테이블명 컬럼 추가 --- 특정 구역 통계/분석 쿼리 시 실제 공간 데이터 테이블을 동적으로 참조하기 위해 사용 - -ALTER TABLE LAYER ADD COLUMN IF NOT EXISTS DATA_TBL_NM VARCHAR(100); - -COMMENT ON COLUMN LAYER.DATA_TBL_NM IS 'PostGIS 데이터 테이블명 (공간 데이터 직접 조회용)'; diff --git a/database/migration/025_weather_columns.sql b/database/migration/025_weather_columns.sql new file mode 100644 index 0000000..c9d390a --- /dev/null +++ b/database/migration/025_weather_columns.sql @@ -0,0 +1,44 @@ +-- 027: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 추가 +-- 확산예측 실행 시 WeatherRightPanel에 표시되는 모든 기상정보 저장을 위해 +-- 기존 VARCHAR 컬럼(WIND, WAVE, TEMP, SST)은 하위 호환성 유지를 위해 보존 + +ALTER TABLE wing.ACDNT_WEATHER + ADD COLUMN IF NOT EXISTS WIND_SPEED NUMERIC(5,1), -- 풍속 (m/s) + ADD COLUMN IF NOT EXISTS WIND_DIR INTEGER, -- 풍향 (도) + ADD COLUMN IF NOT EXISTS WIND_DIR_LBL VARCHAR(10), -- 풍향 텍스트 (N, NW, ...) + ADD COLUMN IF NOT EXISTS WIND_SPEED_1K NUMERIC(5,1), -- 1k 최고 풍속 (m/s) + ADD COLUMN IF NOT EXISTS WIND_SPEED_3K NUMERIC(5,1), -- 3k 평균 풍속 (m/s) + ADD COLUMN IF NOT EXISTS PRESSURE NUMERIC(6,1), -- 기압 (hPa) + ADD COLUMN IF NOT EXISTS WAVE_HEIGHT NUMERIC(4,1), -- 유의파고 (m) + ADD COLUMN IF NOT EXISTS WAVE_MAX_HT NUMERIC(4,1), -- 최고파고 (m) + ADD COLUMN IF NOT EXISTS WAVE_PERIOD NUMERIC(4,1), -- 파도 주기 (s) + ADD COLUMN IF NOT EXISTS WAVE_DIR VARCHAR(10), -- 파향 (N, NE, ...) + ADD COLUMN IF NOT EXISTS AIR_TEMP NUMERIC(5,1), -- 기온 (°C) + ADD COLUMN IF NOT EXISTS SALINITY NUMERIC(5,1), -- 염분 (PSU) + ADD COLUMN IF NOT EXISTS SUNRISE VARCHAR(10), -- 일출 시각 (HH:MM) + ADD COLUMN IF NOT EXISTS SUNSET VARCHAR(10), -- 일몰 시각 (HH:MM) + ADD COLUMN IF NOT EXISTS MOONRISE VARCHAR(10), -- 월출 시각 (HH:MM) + ADD COLUMN IF NOT EXISTS MOONSET VARCHAR(10), -- 월몰 시각 (HH:MM) + ADD COLUMN IF NOT EXISTS MOON_PHASE VARCHAR(30), -- 월상 (예: 상현달 14일) + ADD COLUMN IF NOT EXISTS TIDAL_RANGE NUMERIC(4,1), -- 조차 (m) + ADD COLUMN IF NOT EXISTS WEATHER_ALERT TEXT; -- 날씨 특보 + +COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED IS '풍속 (m/s)'; +COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_DIR IS '풍향 (도, 0-360)'; +COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_DIR_LBL IS '풍향 텍스트 (N/NE/E/...)'; +COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED_1K IS '1km 최고 풍속 (m/s)'; +COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED_3K IS '3km 평균 풍속 (m/s)'; +COMMENT ON COLUMN wing.ACDNT_WEATHER.PRESSURE IS '기압 (hPa)'; +COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_HEIGHT IS '유의파고 (m)'; +COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_MAX_HT IS '최고파고 (m)'; +COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_PERIOD IS '파도 주기 (s)'; +COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_DIR IS '파향 (N/NE/E/...)'; +COMMENT ON COLUMN wing.ACDNT_WEATHER.AIR_TEMP IS '기온 (°C)'; +COMMENT ON COLUMN wing.ACDNT_WEATHER.SALINITY IS '염분 (PSU)'; +COMMENT ON COLUMN wing.ACDNT_WEATHER.SUNRISE IS '일출 시각 (HH:MM)'; +COMMENT ON COLUMN wing.ACDNT_WEATHER.SUNSET IS '일몰 시각 (HH:MM)'; +COMMENT ON COLUMN wing.ACDNT_WEATHER.MOONRISE IS '월출 시각 (HH:MM)'; +COMMENT ON COLUMN wing.ACDNT_WEATHER.MOONSET IS '월몰 시각 (HH:MM)'; +COMMENT ON COLUMN wing.ACDNT_WEATHER.MOON_PHASE IS '월상 (예: 상현달 14일)'; +COMMENT ON COLUMN wing.ACDNT_WEATHER.TIDAL_RANGE IS '조차 (m)'; +COMMENT ON COLUMN wing.ACDNT_WEATHER.WEATHER_ALERT IS '날씨 특보 문자열'; diff --git a/database/migration/026_register_srid_5179.sql b/database/migration/026_register_srid_5179.sql deleted file mode 100644 index 7bd079d..0000000 --- a/database/migration/026_register_srid_5179.sql +++ /dev/null @@ -1,13 +0,0 @@ --- SRID 5179 (Korea 2000 / Unified CS) 등록 --- gis.fshfrm 등 한국 좌표계(EPSG:5179) 데이터의 ST_Transform 사용을 위해 필요 -INSERT INTO spatial_ref_sys (srid, auth_name, auth_srid, proj4text, srtext) -VALUES ( - 5179, - 'EPSG', - 5179, - '+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +units=m +no_defs', - 'PROJCS["Korea 2000 / Unified CS",GEOGCS["Korea 2000",DATUM["Geocentric_datum_of_Korea",SPHEROID["GRS 1980",6378137,298.257222101,AUTHORITY["EPSG","7019"]],TOWGS84[0,0,0,0,0,0,0],AUTHORITY["EPSG","6737"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4737"]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",38],PARAMETER["central_meridian",127.5],PARAMETER["scale_factor",0.9996],PARAMETER["false_easting",1000000],PARAMETER["false_northing",2000000],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Northing",NORTH],AXIS["Easting",EAST],AUTHORITY["EPSG","5179"]]' -) -ON CONFLICT (srid) DO UPDATE SET - proj4text = EXCLUDED.proj4text, - srtext = EXCLUDED.srtext; diff --git a/database/migration/026_sensitive_resources.sql b/database/migration/026_sensitive_resources.sql new file mode 100644 index 0000000..d96d1c9 --- /dev/null +++ b/database/migration/026_sensitive_resources.sql @@ -0,0 +1,41 @@ +-- ============================================================ +-- 027: 민감자원 테이블 생성 +-- 모든 민감자원(양식장, 해수욕장, 무역항 등)을 단일 테이블로 관리 +-- properties는 JSONB로 유연하게 저장 +-- ============================================================ + +SET search_path TO wing, public; + +CREATE EXTENSION IF NOT EXISTS postgis; + +-- ============================================================ +-- 민감자원 테이블 +-- ============================================================ +CREATE TABLE IF NOT EXISTS SENSITIVE_RESOURCE ( + SR_ID BIGSERIAL PRIMARY KEY, + CATEGORY VARCHAR(50) NOT NULL, -- 민감자원 유형 (양식장, 해수욕장, 무역항 등) + GEOM public.geometry(Geometry, 4326) NOT NULL, -- 공간 데이터 (Point, LineString, Polygon 모두 수용) + PROPERTIES JSONB NOT NULL DEFAULT '{}', -- 원본 GeoJSON properties + REG_DT TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + MOD_DT TIMESTAMP +); + +-- 공간 인덱스 +CREATE INDEX IF NOT EXISTS IDX_SR_GEOM ON SENSITIVE_RESOURCE USING GIST(GEOM); + +-- 카테고리 인덱스 (유형별 필터링) +CREATE INDEX IF NOT EXISTS IDX_SR_CATEGORY ON SENSITIVE_RESOURCE (CATEGORY); + +-- JSONB 인덱스 (properties 내부 검색용) +CREATE INDEX IF NOT EXISTS IDX_SR_PROPERTIES ON SENSITIVE_RESOURCE USING GIN(PROPERTIES); + +-- 카테고리 + 공간 복합 조회 최적화 +CREATE INDEX IF NOT EXISTS IDX_SR_CATEGORY_GEOM ON SENSITIVE_RESOURCE USING GIST(GEOM) WHERE CATEGORY IS NOT NULL; + +COMMENT ON TABLE SENSITIVE_RESOURCE IS '민감자원 통합 테이블'; +COMMENT ON COLUMN SENSITIVE_RESOURCE.SR_ID IS '민감자원 ID'; +COMMENT ON COLUMN SENSITIVE_RESOURCE.CATEGORY IS '민감자원 유형 (양식장, 해수욕장, 무역항, 어항, 해안선_ESI 등)'; +COMMENT ON COLUMN SENSITIVE_RESOURCE.GEOM IS '공간 데이터 (EPSG:4326)'; +COMMENT ON COLUMN SENSITIVE_RESOURCE.PROPERTIES IS '원본 GeoJSON properties (JSONB)'; +COMMENT ON COLUMN SENSITIVE_RESOURCE.REG_DT IS '등록일시'; +COMMENT ON COLUMN SENSITIVE_RESOURCE.MOD_DT IS '수정일시'; diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 75b0c9b..65b336c 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,12 @@ ## [Unreleased] +### 추가 +- 확산예측: 예측 실행 시 기상정보(풍속·풍향·기압·파고·수온·기온·염분 등) ACDNT_WEATHER 테이블에 자동 저장 +- DB: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 19개 추가 (027 마이그레이션) + +## [2026-03-20.3] + ### 추가 - 보고서: 기능 강화 (HWPX 내보내기, 확산 지도 패널, 보고서 생성기 개선) - 관리자: 권한 트리 확장 (게시판관리·기준정보·연계관리 섹션 추가) diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index e193c5f..c7d29f2 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -3,13 +3,12 @@ import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/r import { MapboxOverlay } from '@deck.gl/mapbox' import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer, GeoJsonLayer } from '@deck.gl/layers' import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core' -import type { SpatialQueryResult, FshfrmProperties } from '@tabs/prediction/services/analysisService' import type { StyleSpecification } from 'maplibre-gl' import type { MapLayerMouseEvent } from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { layerDatabase } from '@common/services/layerService' import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView' -import type { HydrDataStep } from '@tabs/prediction/services/predictionApi' +import type { HydrDataStep, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi' import HydrParticleOverlay from './HydrParticleOverlay' import type { BoomLine, BoomLineCoord } from '@common/types/boomLine' import type { ReplayShip, CollisionEvent } from '@common/types/backtrack' @@ -290,6 +289,24 @@ const PRIORITY_LABELS: Record = { 'MEDIUM': '보통', } +function hslToRgb(h: number, s: number, l: number): [number, number, number] { + const a = s * Math.min(l, 1 - l); + const f = (n: number) => { + const k = (n + h * 12) % 12; + return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); + }; + return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)]; +} + +function categoryToRgb(category: string): [number, number, number] { + let hash = 0; + for (let i = 0; i < category.length; i++) { + hash = (hash * 31 + category.charCodeAt(i)) >>> 0; + } + const hue = (hash * 137) % 360; + return hslToRgb(hue / 360, 0.65, 0.55); +} + const SENSITIVE_COLORS: Record = { 'aquaculture': '#22c55e', 'beach': '#0ea5e9', @@ -343,6 +360,7 @@ interface MapViewProps { incidentCoord: { lat: number; lon: number } } sensitiveResources?: SensitiveResource[] + sensitiveResourceGeojson?: SensitiveResourceFeatureCollection | null flyToTarget?: { lng: number; lat: number; zoom?: number } | null fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null centerPoints?: Array<{ lat: number; lon: number; time: number; model?: string }> @@ -366,49 +384,6 @@ interface MapViewProps { lightMode?: boolean /** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */ showOverlays?: boolean - /** 오염분석 공간 쿼리 결과 (어장 등 GIS 레이어) */ - spatialQueryResult?: SpatialQueryResult | null -} - -// 어장 정보 팝업 컴포넌트 (gis.fshfrm) -function FshfrmPopup({ properties }: { properties: FshfrmProperties }) { - const rows: [string, string | number | null | undefined][] = [ - ['시도', properties.ctpv_nm], - ['시군구', properties.sgg_nm], - ['행정동', properties.admdst_nm], - ['어장 구분', properties.fids_se], - ['어업 종류', properties.fids_knd], - ['양식 방법', properties.fids_mthd], - ['양식 품종', properties.farm_knd], - ['주소', properties.addr], - ['면적(㎡)', properties.area != null ? parseFloat(String(properties.area)).toFixed(2) : null], - ['허가번호', properties.lcns_no], - ['허가일자', properties.lcns_bgng_], - ['허가만료', properties.lcns_end_y], - ] - return ( -
-
- 🐟 어장 정보 -
- - - {rows - .filter(([, v]) => v != null && v !== '') - .map(([label, value]) => ( - - - - - ))} - -
- {label} - - {String(value)} -
-
- ) } // deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved) @@ -572,6 +547,7 @@ export function MapView({ layerBrightness = 50, backtrackReplay, sensitiveResources = [], + sensitiveResourceGeojson, flyToTarget, fitBoundsTarget, centerPoints = [], @@ -592,7 +568,6 @@ export function MapView({ analysisCircleRadiusM = 0, lightMode = false, showOverlays = true, - spatialQueryResult, }: MapViewProps) { const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore() const { handleMeasureClick } = useMeasureTool() @@ -608,11 +583,8 @@ export function MapView({ const deckClickHandledRef = useRef(false) // 클릭으로 열린 팝업(닫기 전까지 유지) 추적 — 호버 핸들러가 닫지 않도록 방지 const persistentPopupRef = useRef(false) - // GeoJsonLayer(어장 폴리곤) hover 중인 피처 — handleMapClick에서 팝업 표시에 사용 - const hoveredGeoLayerRef = useRef<{ - props: FshfrmProperties; - coord: [number, number]; - } | null>(null) + // 현재 호버 중인 민감자원 feature properties (handleMapClick에서 팝업 생성에 사용) + const hoveredSensitiveRef = useRef | null>(null) const currentTime = isControlled ? externalCurrentTime : internalCurrentTime const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => { @@ -623,23 +595,44 @@ export function MapView({ const handleMapClick = useCallback((e: MapLayerMouseEvent) => { const { lng, lat } = e.lngLat setCurrentPosition([lat, lng]) - // 어장 폴리곤(GeoJsonLayer) 위에서 좌클릭 시 팝업 표시 - // deck.gl onClick 대신 handleMapClick에서 처리하여 이벤트 순서 문제 회피 - if (hoveredGeoLayerRef.current) { - const { props, coord } = hoveredGeoLayerRef.current - persistentPopupRef.current = true - setPopupInfo({ - longitude: coord[0], - latitude: coord[1], - content: , - }) - return - } - // deck.gl 다른 레이어(민감자원 등) onClick이 처리한 클릭 — 팝업 유지 + // deck.gl 다른 레이어 onClick이 처리한 클릭 — 팝업 유지 if (deckClickHandledRef.current) { deckClickHandledRef.current = false return } + // 민감자원 hover 중이면 팝업 표시 + if (hoveredSensitiveRef.current) { + const props = hoveredSensitiveRef.current + const { category, ...rest } = props + const entries = Object.entries(rest).filter(([k, v]) => k !== 'srId' && v !== null && v !== undefined && v !== '') + persistentPopupRef.current = true + setPopupInfo({ + longitude: lng, + latitude: lat, + content: ( +
+
+ {String(category ?? '민감자원')} +
+ {entries.length > 0 ? ( +
+ {entries.map(([key, val]) => ( +
+ {key} + + {typeof val === 'object' ? JSON.stringify(val) : String(val)} + +
+ ))} +
+ ) : ( +

상세 정보 없음

+ )} +
+ ), + }) + return + } if (measureMode !== null) { handleMeasureClick(lng, lat) return @@ -1185,6 +1178,41 @@ export function MapView({ ) } + // --- 민감자원 GeoJSON 레이어 --- + if (sensitiveResourceGeojson && sensitiveResourceGeojson.features.length > 0) { + result.push( + new GeoJsonLayer({ + id: 'sensitive-resource-geojson', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: sensitiveResourceGeojson as any, + pickable: true, + stroked: true, + filled: true, + pointRadiusMinPixels: 10, + pointRadiusMaxPixels: 20, + lineWidthMinPixels: 1, + getLineWidth: 1.5, + getFillColor: (f: { properties: { category?: string } | null }) => { + const cat = f.properties?.category ?? ''; + const [r, g, b] = categoryToRgb(cat); + return [r, g, b, 80] as [number, number, number, number]; + }, + getLineColor: (f: { properties: { category?: string } | null }) => { + const cat = f.properties?.category ?? ''; + const [r, g, b] = categoryToRgb(cat); + return [r, g, b, 210] as [number, number, number, number]; + }, + onHover: (info: PickingInfo) => { + if (info.object) { + hoveredSensitiveRef.current = (info.object as { properties: Record | null }).properties ?? {} + } else { + hoveredSensitiveRef.current = null + } + }, + }) as unknown as DeckLayer + ); + } + // --- 입자 중심점 이동 경로 (모델별 PathLayer + ScatterplotLayer) --- const visibleCenters = centerPoints.filter(p => p.time <= currentTime) if (visibleCenters.length > 0) { @@ -1299,53 +1327,14 @@ export function MapView({ // 거리/면적 측정 레이어 result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements)) - // --- 오염분석 공간 쿼리 결과 (어장 등 GIS 레이어) --- - // [추후 확장] 다중 테이블 시 _tableName별 색상 분기: TABLE_COLORS[f.properties._tableName] - if (spatialQueryResult && spatialQueryResult.features.length > 0) { - result.push( - new GeoJsonLayer({ - id: 'spatial-query-result', - data: { - ...spatialQueryResult, - features: spatialQueryResult.features.filter(f => f.geometry != null), - } as unknown as object, - filled: true, - stroked: true, - getFillColor: [34, 197, 94, 55], - getLineColor: [34, 197, 94, 200], - getLineWidth: 2, - lineWidthMinPixels: 1, - lineWidthMaxPixels: 3, - pickable: true, - autoHighlight: true, - highlightColor: [34, 197, 94, 120], - // 클릭은 handleMapClick에서 처리 (deck.gl onClick vs MapLibre click 이벤트 순서 충돌 회피) - onHover: (info: PickingInfo) => { - if (info.object && info.coordinate) { - hoveredGeoLayerRef.current = { - props: info.object.properties as FshfrmProperties, - coord: [info.coordinate[0], info.coordinate[1]], - } - } else { - hoveredGeoLayerRef.current = null - } - }, - updateTriggers: { - data: [spatialQueryResult], - }, - }) - ) - } - return result.filter(Boolean) }, [ oilTrajectory, currentTime, selectedModels, boomLines, isDrawingBoom, drawingPoints, dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay, - sensitiveResources, centerPoints, windData, + sensitiveResources, sensitiveResourceGeojson, centerPoints, windData, showWind, showBeached, showTimeLabel, simulationStartTime, analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode, - spatialQueryResult, ]) // 3D 모드 / 밝은 톤에 따른 지도 스타일 전환 diff --git a/frontend/src/common/store/weatherSnapshotStore.ts b/frontend/src/common/store/weatherSnapshotStore.ts index d1073ff..79847dd 100644 --- a/frontend/src/common/store/weatherSnapshotStore.ts +++ b/frontend/src/common/store/weatherSnapshotStore.ts @@ -23,6 +23,21 @@ export interface WeatherSnapshot { pressure: number; visibility: number; salinity: number; + astronomy?: { + sunrise: string; + sunset: string; + moonrise: string; + moonset: string; + moonPhase: string; + tidalRange: number; + }; + alert?: string; + forecast?: Array<{ + time: string; + icon: string; + temperature: number; + windSpeed: number; + }>; } interface WeatherSnapshotStore { diff --git a/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx b/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx index 905c6be..f3bedbc 100755 --- a/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx @@ -70,7 +70,8 @@ export function IncidentsLeftPanel({ // Weather popup const [weatherPopupId, setWeatherPopupId] = useState(null) const [weatherPos, setWeatherPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 }) - const [weatherInfo, setWeatherInfo] = useState(null) + // undefined = 로딩 중, null = 데이터 없음, WeatherInfo = 데이터 있음 + const [weatherInfo, setWeatherInfo] = useState(undefined) const weatherRef = useRef(null) useEffect(() => { @@ -79,7 +80,7 @@ export function IncidentsLeftPanel({ fetchIncidentWeather(parseInt(weatherPopupId)).then((data) => { if (!cancelled) setWeatherInfo(data) }) - return () => { cancelled = true; setWeatherInfo(null) } + return () => { cancelled = true; setWeatherInfo(undefined) } }, [weatherPopupId]); useEffect(() => { @@ -361,7 +362,7 @@ export function IncidentsLeftPanel({ )} {/* Weather Popup (fixed position) */} - {weatherPopupId && weatherInfo && ( + {weatherPopupId && weatherInfo !== undefined && ( void }>(({ data, position, onClose }, ref) => { + const forecast = data?.forecast ?? [] return (
🌤
-
{data.locNm}
-
{data.obsDtm}
+
{data?.locNm || '기상정보 없음'}
+
{data?.obsDtm || '-'}
@@ -440,21 +442,21 @@ const WeatherPopup = forwardRef {/* Main weather */}
-
{data.icon}
+
{data?.icon || '❓'}
-
{data.temp}
-
{data.weatherDc}
+
{data?.temp || '-'}
+
{data?.weatherDc || '-'}
{/* Detail grid */}
- - - - - - + + + + + +
{/* Tide info */} @@ -464,7 +466,7 @@ const WeatherPopup = forwardRef
고조 (만조)
-
{data.highTide}
+
{data?.highTide || '-'}
저조 (간조)
-
{data.lowTide}
+
{data?.lowTide || '-'}
@@ -480,15 +482,19 @@ const WeatherPopup = forwardRef
24h 예보
-
- {data.forecast.map((f, i) => ( -
-
{f.hour}
-
{f.icon}
-
{f.temp}
-
- ))} -
+ {forecast.length > 0 ? ( +
+ {forecast.map((f, i) => ( +
+
{f.hour}
+
{f.icon}
+
{f.temp}
+
+ ))} +
+ ) : ( +
예보 데이터 없음
+ )} {/* Impact */} @@ -497,7 +503,7 @@ const WeatherPopup = forwardRef
⚠ 방제 작업 영향
-
{data.impactDc}
+
{data?.impactDc || '-'}
@@ -505,13 +511,13 @@ const WeatherPopup = forwardRef {icon}
{label}
-
{value}
+
{value || '-'}
) diff --git a/frontend/src/tabs/prediction/components/LeftPanel.tsx b/frontend/src/tabs/prediction/components/LeftPanel.tsx index 887b39b..56846c5 100755 --- a/frontend/src/tabs/prediction/components/LeftPanel.tsx +++ b/frontend/src/tabs/prediction/components/LeftPanel.tsx @@ -50,6 +50,7 @@ export function LeftPanel({ onLayerOpacityChange, layerBrightness, onLayerBrightnessChange, + sensitiveResources = [], onImageAnalysisResult, }: LeftPanelProps) { const [expandedSections, setExpandedSections] = useState({ @@ -160,7 +161,7 @@ export function LeftPanel({
사고일시 - {selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16) : '—'} + {selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16).replace(' ', 'T') : '—'}
유종 @@ -204,7 +205,18 @@ export function LeftPanel({ {expandedSections.impactResources && (
-

영향받는 민감자원 목록

+ {sensitiveResources.length === 0 ? ( +

영향받는 민감자원 목록

+ ) : ( +
+ {sensitiveResources.map(({ category, count }) => ( +
+ {category} + {count}개 +
+ ))} +
+ )}
)}
diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index c7d6a47..bc40199 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -14,18 +14,23 @@ import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore' import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine' import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack' import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack' -import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory } from '../services/predictionApi' -import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, WindPoint } from '../services/predictionApi' +import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory, fetchSensitiveResources, fetchSensitiveResourcesGeojson } from '../services/predictionApi' +import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, SensitiveResourceCategory, SensitiveResourceFeatureCollection, WindPoint } from '../services/predictionApi' import SimulationLoadingOverlay from './SimulationLoadingOverlay' import SimulationErrorModal from './SimulationErrorModal' import { api } from '@common/services/api' import { generateAIBoomLines, haversineDistance, pointInPolygon, polygonAreaKm2, circleAreaKm2 } from '@common/utils/geo' import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal' -import { querySpatialLayers } from '../services/analysisService' -import type { SpatialQueryResult } from '../services/analysisService' export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift' +const toLocalDateTimeStr = (raw: string): string => { + const d = new Date(raw) + if (isNaN(d.getTime())) return '' + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` +} + // --------------------------------------------------------------------------- // 민감자원 타입 + 데모 데이터 // --------------------------------------------------------------------------- @@ -40,20 +45,13 @@ export interface SensitiveResource { } export interface DisplayControls { - showCurrent: boolean; // 유향/유속 - showWind: boolean; // 풍향/풍속 - showBeached: boolean; // 해안부착 - showTimeLabel: boolean; // 시간 표시 + showCurrent: boolean; // 유향/유속 + showWind: boolean; // 풍향/풍속 + showBeached: boolean; // 해안부착 + showTimeLabel: boolean; // 시간 표시 + showSensitiveResources: boolean; // 민감자원 } -const DEMO_SENSITIVE_RESOURCES: SensitiveResource[] = [ - { id: 'bc-1', name: '종포 해수욕장', type: 'beach', lat: 34.728, lon: 127.679, radiusM: 350, arrivalTimeH: 1 }, - { id: 'aq-1', name: '국동 전복 양식장', type: 'aquaculture', lat: 34.718, lon: 127.672, radiusM: 500, arrivalTimeH: 3 }, - { id: 'ec-1', name: '여자만 습지보호구역', type: 'ecology', lat: 34.758, lon: 127.614, radiusM: 1200, arrivalTimeH: 6 }, - { id: 'aq-2', name: '화태도 김 양식장', type: 'aquaculture', lat: 34.648, lon: 127.652, radiusM: 800, arrivalTimeH: 10 }, - { id: 'aq-3', name: '개도 해안 양식장', type: 'aquaculture', lat: 34.612, lon: 127.636, radiusM: 600, arrivalTimeH: 18 }, -] - // --------------------------------------------------------------------------- // 데모 궤적 생성 (seeded PRNG — deterministic) // --------------------------------------------------------------------------- @@ -141,6 +139,8 @@ export function OilSpillView() { // 민감자원 const [sensitiveResources, setSensitiveResources] = useState([]) + const [sensitiveResourceCategories, setSensitiveResourceCategories] = useState([]) + const [sensitiveResourceGeojson, setSensitiveResourceGeojson] = useState(null) // 오일펜스 배치 상태 const [boomLines, setBoomLines] = useState([]) @@ -164,6 +164,7 @@ export function OilSpillView() { showWind: false, showBeached: false, showTimeLabel: false, + showSensitiveResources: false, }) // 타임라인 플레이어 상태 @@ -205,8 +206,6 @@ export function OilSpillView() { const [analysisPolygonPoints, setAnalysisPolygonPoints] = useState<{ lat: number; lon: number }[]>([]) const [circleRadiusNm, setCircleRadiusNm] = useState(5) const [analysisResult, setAnalysisResult] = useState<{ area: number; particleCount: number; particlePercent: number; sensitiveCount: number } | null>(null) - const [spatialQueryResult, setSpatialQueryResult] = useState(null) - const [isSpatialQuerying, setIsSpatialQuerying] = useState(false) // 원 분석용 derived 값 (state 아님) const analysisCircleCenter = analysisTab === 'circle' && incidentCoord ? incidentCoord : null @@ -221,7 +220,7 @@ export function OilSpillView() { setOilTrajectory(demoTrajectory) const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings) setBoomLines(demoBooms) - setSensitiveResources(DEMO_SENSITIVE_RESOURCES) + setSensitiveResources([]) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeSubTab]) @@ -473,7 +472,7 @@ export function OilSpillView() { setSelectedAnalysis(analysis) setCenterPoints([]) if (analysis.occurredAt) { - setAccidentTime(analysis.occurredAt.slice(0, 16)) + setAccidentTime(toLocalDateTimeStr(analysis.occurredAt)) } if (analysis.lon != null && analysis.lat != null) { setIncidentCoord({ lon: analysis.lon, lat: analysis.lat }) @@ -523,7 +522,13 @@ export function OilSpillView() { if (sbModel) setSummaryByModel(sbModel); if (stepSbModel) setStepSummariesByModel(stepSbModel); if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings)) - setSensitiveResources(DEMO_SENSITIVE_RESOURCES) + setSensitiveResources([]) + fetchSensitiveResources(analysis.acdntSn) + .then(setSensitiveResourceCategories) + .catch(err => console.warn('[prediction] 민감자원 조회 실패:', err)) + fetchSensitiveResourcesGeojson(analysis.acdntSn) + .then(setSensitiveResourceGeojson) + .catch(err => console.warn('[prediction] 민감자원 GeoJSON 조회 실패:', err)) // incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생 if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) { pendingPlayRef.current = true @@ -545,7 +550,8 @@ export function OilSpillView() { const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48) setOilTrajectory(demoTrajectory) if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings)) - setSensitiveResources(DEMO_SENSITIVE_RESOURCES) + setSensitiveResources([]) + setSensitiveResourceCategories([]) // incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생 if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) { pendingPlayRef.current = true @@ -559,7 +565,7 @@ export function OilSpillView() { setDrawingPoints(prev => [...prev, { lat, lon }]) } else if (drawAnalysisMode === 'polygon') { setAnalysisPolygonPoints(prev => [...prev, { lat, lon }]) - } else { + } else if (isSelectingLocation) { setIncidentCoord({ lon, lat }) setIsSelectingLocation(false) } @@ -584,22 +590,6 @@ export function OilSpillView() { sensitiveCount, }) setDrawAnalysisMode(null) - - // 공간 데이터 쿼리 (어장 등 정보 레이어) - // [추후 확장] 좌측 정보 레이어 활성화 목록 기반으로 layers 파라미터를 전달할 수 있습니다. - setIsSpatialQuerying(true) - try { - const result = await querySpatialLayers({ - type: 'polygon', - polygon: analysisPolygonPoints, - }) - setSpatialQueryResult(result) - } catch (err) { - console.error('[analysis] 공간 쿼리 실패:', err) - setSpatialQueryResult(null) - } finally { - setIsSpatialQuerying(false) - } } const handleRunCircleAnalysis = async () => { @@ -619,23 +609,6 @@ export function OilSpillView() { particlePercent: Math.round((inside / totalIds) * 100), sensitiveCount, }) - - // 공간 데이터 쿼리 (어장 등 정보 레이어) - // [추후 확장] 좌측 정보 레이어 활성화 목록 기반으로 layers 파라미터를 전달할 수 있습니다. - setIsSpatialQuerying(true) - try { - const result = await querySpatialLayers({ - type: 'circle', - center: { lat: incidentCoord.lat, lon: incidentCoord.lon }, - radiusM, - }) - setSpatialQueryResult(result) - } catch (err) { - console.error('[analysis] 공간 쿼리 실패:', err) - setSpatialQueryResult(null) - } finally { - setIsSpatialQuerying(false) - } } const handleCancelAnalysis = () => { @@ -647,13 +620,12 @@ export function OilSpillView() { setDrawAnalysisMode(null) setAnalysisPolygonPoints([]) setAnalysisResult(null) - setSpatialQueryResult(null) } const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => { setIncidentCoord({ lat: result.lat, lon: result.lon }) setFlyToCoord({ lat: result.lat, lon: result.lon }) - setAccidentTime(result.occurredAt.slice(0, 16)) + setAccidentTime(toLocalDateTimeStr(result.occurredAt)) setOilType(result.oilType) setSpillAmount(parseFloat(result.volume.toFixed(4))) setSpillUnit('kL') @@ -833,7 +805,7 @@ export function OilSpillView() { setStepSummariesByModel(newStepSummariesByModel); const booms = generateAIBoomLines(merged, effectiveCoord, algorithmSettings); setBoomLines(booms); - setSensitiveResources(DEMO_SENSITIVE_RESOURCES); + setSensitiveResources([]); setCurrentStep(0); setIsPlaying(true); setFlyToCoord({ lon: effectiveCoord.lon, lat: effectiveCoord.lat }); @@ -843,11 +815,26 @@ export function OilSpillView() { setSimulationError(errors.join('; ')); } else { simulationSucceeded = true; + const effectiveAcdntSn = data.acdntSn ?? selectedAnalysis?.acdntSn; if (effectiveCoord) { fetchWeatherSnapshotForCoord(effectiveCoord.lat, effectiveCoord.lon) - .then(snapshot => useWeatherSnapshotStore.getState().setSnapshot(snapshot)) + .then(snapshot => { + useWeatherSnapshotStore.getState().setSnapshot(snapshot); + if (effectiveAcdntSn) { + api.post(`/incidents/${effectiveAcdntSn}/weather`, snapshot) + .catch(err => console.warn('[weather] 기상 저장 실패:', err)); + } + }) .catch(err => console.warn('[weather] 기상 데이터 수집 실패:', err)); } + if (effectiveAcdntSn) { + fetchSensitiveResources(effectiveAcdntSn) + .then(setSensitiveResourceCategories) + .catch(err => console.warn('[prediction] 민감자원 조회 실패:', err)); + fetchSensitiveResourcesGeojson(effectiveAcdntSn) + .then(setSensitiveResourceGeojson) + .catch(err => console.warn('[prediction] 민감자원 GeoJSON 조회 실패:', err)); + } } } catch (err) { const msg = @@ -1014,6 +1001,7 @@ export function OilSpillView() { onLayerOpacityChange={setLayerOpacity} layerBrightness={layerBrightness} onLayerBrightnessChange={setLayerBrightness} + sensitiveResources={sensitiveResourceCategories} onImageAnalysisResult={handleImageAnalysisResult} /> )} @@ -1042,6 +1030,7 @@ export function OilSpillView() { layerOpacity={layerOpacity} layerBrightness={layerBrightness} sensitiveResources={sensitiveResources} + sensitiveResourceGeojson={displayControls.showSensitiveResources ? sensitiveResourceGeojson : null} lightMode centerPoints={centerPoints.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))} windData={windData} @@ -1067,7 +1056,6 @@ export function OilSpillView() { showBeached={displayControls.showBeached} showTimeLabel={displayControls.showTimeLabel} simulationStartTime={accidentTime || undefined} - spatialQueryResult={spatialQueryResult} /> {/* 타임라인 플레이어 (리플레이 비활성 시) */} @@ -1283,8 +1271,6 @@ export function OilSpillView() { onRunCircleAnalysis={handleRunCircleAnalysis} onCancelAnalysis={handleCancelAnalysis} onClearAnalysis={handleClearAnalysis} - spatialQueryResult={spatialQueryResult} - isSpatialQuerying={isSpatialQuerying} /> )} diff --git a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx index 01a25fd..e9bd3be 100644 --- a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx +++ b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx @@ -430,7 +430,9 @@ function DateTimeInput({ value, onChange }: { value: string; onChange: (v: strin const datePart = value ? value.split('T')[0] : '' const timePart = value && value.includes('T') ? value.split('T')[1] : '00:00' - const [hh, mm] = timePart.split(':').map(Number) + const timeParts = timePart.split(':').map(Number) + const hh = isNaN(timeParts[0]) ? 0 : timeParts[0] + const mm = (timeParts[1] === undefined || isNaN(timeParts[1])) ? 0 : timeParts[1] const parsed = datePart ? new Date(datePart + 'T00:00:00') : new Date() const [viewYear, setViewYear] = useState(parsed.getFullYear()) @@ -561,9 +563,15 @@ function DateTimeInput({ value, onChange }: { value: string; onChange: (v: strin