feat(레이어): 레이어 데이터 테이블 매핑 구현 및 어장 팝업 수정
This commit is contained in:
부모
087fe57e0d
커밋
aefd38b3bc
@ -83,7 +83,5 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"deny": [],
|
||||
"allow": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
166
backend/src/analysis/analysisRouter.ts
Normal file
166
backend/src/analysis/analysisRouter.ts
Normal file
@ -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<string, {
|
||||
geomColumn: string;
|
||||
srid: number;
|
||||
properties: string[];
|
||||
}> = {
|
||||
'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<Record<string, unknown>> = []
|
||||
|
||||
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<Record<string, unknown>>
|
||||
|
||||
} 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<Record<string, unknown>>
|
||||
|
||||
} 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
|
||||
@ -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) {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
7
database/migration/025_layer_data_tbl_nm.sql
Normal file
7
database/migration/025_layer_data_tbl_nm.sql
Normal file
@ -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 데이터 테이블명 (공간 데이터 직접 조회용)';
|
||||
13
database/migration/026_register_srid_5179.sql
Normal file
13
database/migration/026_register_srid_5179.sql
Normal file
@ -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;
|
||||
@ -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 (
|
||||
<div style={{ minWidth: 200, maxWidth: 260, fontSize: 10 }}>
|
||||
<div style={{ fontWeight: 'bold', color: '#22c55e', marginBottom: 6, fontSize: 11 }}>
|
||||
🐟 어장 정보
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{rows
|
||||
.filter(([, v]) => v != null && v !== '')
|
||||
.map(([label, value]) => (
|
||||
<tr key={label} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
|
||||
<td style={{ padding: '2px 6px 2px 0', color: '#555', whiteSpace: 'nowrap', verticalAlign: 'top' }}>
|
||||
{label}
|
||||
</td>
|
||||
<td style={{ padding: '2px 0', color: '#222', wordBreak: 'break-all' }}>
|
||||
{String(value)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<PopupInfo | null>(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: <FshfrmPopup properties={props} />,
|
||||
})
|
||||
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)
|
||||
}}
|
||||
>
|
||||
<div className="text-[#333]">{popupInfo.content}</div>
|
||||
</Popup>
|
||||
|
||||
@ -7,6 +7,7 @@ export interface LayerNode {
|
||||
name: string
|
||||
level: number
|
||||
layerName: string | null
|
||||
dataTblNm?: string | null
|
||||
icon?: string
|
||||
count?: number
|
||||
defaultOn?: boolean
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -8,6 +8,7 @@ export interface Layer {
|
||||
fullName: string
|
||||
level: number
|
||||
wmsLayer: string | null
|
||||
dataTblNm?: string | null
|
||||
icon?: string
|
||||
count?: number
|
||||
children?: Layer[]
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<number>(5)
|
||||
const [analysisResult, setAnalysisResult] = useState<{ area: number; particleCount: number; particlePercent: number; sensitiveCount: number } | null>(null)
|
||||
const [spatialQueryResult, setSpatialQueryResult] = useState<SpatialQueryResult | null>(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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -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({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 공간 쿼리 결과 요약 (어장 등 GIS 레이어) */}
|
||||
{isSpatialQuerying && (
|
||||
<div className="mt-2 text-[9px] text-text-3 font-korean text-center py-1">
|
||||
어장 정보 조회 중...
|
||||
</div>
|
||||
)}
|
||||
{spatialQueryResult && !isSpatialQuerying && (
|
||||
<div className="mt-2 p-2 rounded border border-[rgba(34,197,94,0.2)] bg-[rgba(34,197,94,0.04)]">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<span className="text-[11px]">🐟</span>
|
||||
<span className="text-[10px] font-bold text-[#22c55e] font-korean">어장 정보</span>
|
||||
</div>
|
||||
{spatialQueryResult._meta.map(meta => (
|
||||
<div key={meta.tableName} className="text-[9px] text-text-2 font-korean">
|
||||
건수:{' '}
|
||||
<span className="font-bold text-[#22c55e]">{meta.count}</span>
|
||||
개소 (지도에서 클릭하여 상세 확인)
|
||||
</div>
|
||||
))}
|
||||
{spatialQueryResult.features.length === 0 && (
|
||||
<div className="text-[9px] text-text-3 font-korean">해당 영역 내 어장 없음</div>
|
||||
)}
|
||||
{spatialQueryResult.features.length > 0 && (() => {
|
||||
// 어업 종류별 건수 및 면적 합산
|
||||
const grouped = spatialQueryResult.features.reduce<
|
||||
Record<string, { count: number; totalArea: number }>
|
||||
>((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 (
|
||||
<div className="mt-1.5">
|
||||
{/* 헤더 */}
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-x-2 text-[8px] text-text-3 font-korean border-b border-[rgba(34,197,94,0.2)] pb-0.5 mb-0.5">
|
||||
<span>어업 종류</span>
|
||||
<span className="text-right">건수</span>
|
||||
<span className="text-right">면적(m²)</span>
|
||||
</div>
|
||||
{/* 종류별 행 */}
|
||||
{groupList.map(([kind, { count, totalArea: area }]) => (
|
||||
<div key={kind} className="grid grid-cols-[1fr_auto_auto] gap-x-2 text-[8px] text-text-2 font-korean py-px">
|
||||
<span className="truncate">{kind}</span>
|
||||
<span className="text-right font-mono text-[#22c55e]">{count}</span>
|
||||
<span className="text-right font-mono text-text-1">{area.toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
{/* 합계 행 */}
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-x-2 text-[8px] font-korean border-t border-[rgba(34,197,94,0.2)] pt-0.5 mt-0.5">
|
||||
<span className="text-text-3">합계</span>
|
||||
<span className="text-right font-mono font-bold text-[#22c55e]">{totalCount}</span>
|
||||
<span className="text-right font-mono font-bold text-text-1">{totalArea.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* 오염 종합 상황 */}
|
||||
|
||||
88
frontend/src/tabs/prediction/services/analysisService.ts
Normal file
88
frontend/src/tabs/prediction/services/analysisService.ts
Normal file
@ -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<SpatialQueryResult> => {
|
||||
const response = await api.post<SpatialQueryResult>(
|
||||
'/analysis/spatial-query',
|
||||
request
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user