From aefd38b3bca26b3e492dbf9547dedf7b4fb8ea0a Mon Sep 17 00:00:00 2001 From: JHKANG9140 Date: Sun, 22 Mar 2026 12:36:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EB=A0=88=EC=9D=B4=EC=96=B4):=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EB=A7=A4=ED=95=91=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=B0=8F=20=EC=96=B4=EC=9E=A5=20=ED=8C=9D=EC=97=85=20=EC=88=98?= =?UTF-8?q?=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 +}