feat(레이어): 레이어 데이터 테이블 매핑 구현 및 어장 팝업 수정

This commit is contained in:
JHKANG9140 2026-03-22 12:36:31 +09:00
부모 087fe57e0d
커밋 aefd38b3bc
15개의 변경된 파일548개의 추가작업 그리고 21개의 파일을 삭제

파일 보기

@ -83,7 +83,5 @@
] ]
} }
] ]
}, }
"deny": [],
"allow": []
} }

파일 보기

@ -1,6 +1,6 @@
{ {
"applied_global_version": "1.6.1", "applied_global_version": "1.6.1",
"applied_date": "2026-03-20", "applied_date": "2026-03-22",
"project_type": "react-ts", "project_type": "react-ts",
"gitea_url": "https://gitea.gc-si.dev", "gitea_url": "https://gitea.gc-si.dev",
"custom_pre_commit": true "custom_pre_commit": true

파일 보기

@ -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_nm: string
cmn_cd_level: number cmn_cd_level: number
clnm: string | null clnm: string | null
data_tbl_nm: string | null
} }
// DB 컬럼 → API 응답 컬럼 매핑 (프론트엔드 호환성 유지) // DB 컬럼 → API 응답 컬럼 매핑 (프론트엔드 호환성 유지)
@ -27,7 +28,8 @@ const LAYER_COLUMNS = `
LAYER_FULL_NM AS cmn_cd_full_nm, LAYER_FULL_NM AS cmn_cd_full_nm,
LAYER_NM AS cmn_cd_nm, LAYER_NM AS cmn_cd_nm,
LAYER_LEVEL AS cmn_cd_level, LAYER_LEVEL AS cmn_cd_level,
WMS_LAYER_NM AS clnm WMS_LAYER_NM AS clnm,
DATA_TBL_NM AS data_tbl_nm
`.trim() `.trim()
// 모든 라우트에 파라미터 살균 적용 // 모든 라우트에 파라미터 살균 적용
@ -216,6 +218,7 @@ router.get('/admin/list', requireAuth, requireRole('ADMIN'), async (req, res) =>
LAYER_NM AS "layerNm", LAYER_NM AS "layerNm",
LAYER_LEVEL AS "layerLevel", LAYER_LEVEL AS "layerLevel",
WMS_LAYER_NM AS "wmsLayerNm", WMS_LAYER_NM AS "wmsLayerNm",
DATA_TBL_NM AS "dataTblNm",
USE_YN AS "useYn", USE_YN AS "useYn",
SORT_ORD AS "sortOrd", SORT_ORD AS "sortOrd",
TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm" 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 layerNm?: string
layerLevel?: number layerLevel?: number
wmsLayerNm?: string wmsLayerNm?: string
dataTblNm?: string
useYn?: string useYn?: string
sortOrd?: number 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)) { 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자 이내여야 합니다.' }) 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 sanitizedLayerCd = sanitizeString(layerCd)
const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
const sanitizedLayerFullNm = sanitizeString(layerFullNm) const sanitizedLayerFullNm = sanitizeString(layerFullNm)
const sanitizedLayerNm = sanitizeString(layerNm) const sanitizedLayerNm = sanitizeString(layerNm)
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y' const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
const { rows } = await wingPool.query( 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) `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, 'N') VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'N')
RETURNING LAYER_CD AS "layerCd"`, 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]) res.json(rows[0])
@ -364,11 +374,12 @@ router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res)
layerNm?: string layerNm?: string
layerLevel?: number layerLevel?: number
wmsLayerNm?: string wmsLayerNm?: string
dataTblNm?: string
useYn?: string useYn?: string
sortOrd?: number 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)) { 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자 이내여야 합니다.' }) 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 sanitizedLayerCd = sanitizeString(layerCd)
const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
const sanitizedLayerFullNm = sanitizeString(layerFullNm) const sanitizedLayerFullNm = sanitizeString(layerFullNm)
const sanitizedLayerNm = sanitizeString(layerNm) const sanitizedLayerNm = sanitizeString(layerNm)
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y' const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
const { rows } = await wingPool.query( const { rows } = await wingPool.query(
`UPDATE LAYER `UPDATE LAYER
SET UP_LAYER_CD = $2, LAYER_FULL_NM = $3, LAYER_NM = $4, LAYER_LEVEL = $5, 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 WHERE LAYER_CD = $1
RETURNING LAYER_CD AS "layerCd"`, 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) { if (rows.length === 0) {

파일 보기

@ -23,6 +23,7 @@ import predictionRouter from './prediction/predictionRouter.js'
import aerialRouter from './aerial/aerialRouter.js' import aerialRouter from './aerial/aerialRouter.js'
import rescueRouter from './rescue/rescueRouter.js' import rescueRouter from './rescue/rescueRouter.js'
import mapBaseRouter from './map-base/mapBaseRouter.js' import mapBaseRouter from './map-base/mapBaseRouter.js'
import analysisRouter from './analysis/analysisRouter.js'
import { import {
sanitizeBody, sanitizeBody,
sanitizeQuery, sanitizeQuery,
@ -170,6 +171,7 @@ app.use('/api/prediction', predictionRouter)
app.use('/api/aerial', aerialRouter) app.use('/api/aerial', aerialRouter)
app.use('/api/rescue', rescueRouter) app.use('/api/rescue', rescueRouter)
app.use('/api/map-base', mapBaseRouter) app.use('/api/map-base', mapBaseRouter)
app.use('/api/analysis', analysisRouter)
// 헬스 체크 // 헬스 체크
app.get('/health', (_req, res) => { app.get('/health', (_req, res) => {

파일 보기

@ -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 데이터 테이블명 (공간 데이터 직접 조회용)';

파일 보기

@ -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 { useState, useMemo, useEffect, useCallback, useRef } from 'react'
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre' import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
import { MapboxOverlay } from '@deck.gl/mapbox' 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 { 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 { StyleSpecification } from 'maplibre-gl'
import type { MapLayerMouseEvent } from 'maplibre-gl' import type { MapLayerMouseEvent } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css' import 'maplibre-gl/dist/maplibre-gl.css'
@ -365,6 +366,49 @@ interface MapViewProps {
lightMode?: boolean lightMode?: boolean
/** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */ /** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */
showOverlays?: boolean 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) // deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
@ -548,6 +592,7 @@ export function MapView({
analysisCircleRadiusM = 0, analysisCircleRadiusM = 0,
lightMode = false, lightMode = false,
showOverlays = true, showOverlays = true,
spatialQueryResult,
}: MapViewProps) { }: MapViewProps) {
const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore() const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore()
const { handleMeasureClick } = useMeasureTool() const { handleMeasureClick } = useMeasureTool()
@ -559,6 +604,15 @@ export function MapView({
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
const [playbackSpeed, setPlaybackSpeed] = useState(1) const [playbackSpeed, setPlaybackSpeed] = useState(1)
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null) 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 currentTime = isControlled ? externalCurrentTime : internalCurrentTime
const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => { const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => {
@ -569,6 +623,23 @@ export function MapView({
const handleMapClick = useCallback((e: MapLayerMouseEvent) => { const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
const { lng, lat } = e.lngLat const { lng, lat } = e.lngLat
setCurrentPosition([lat, lng]) 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) { if (measureMode !== null) {
handleMeasureClick(lng, lat) handleMeasureClick(lng, lat)
return return
@ -716,7 +787,7 @@ export function MapView({
getPath: (d: BoomLine) => d.coords.map(c => [c.lon, c.lat] as [number, number]), getPath: (d: BoomLine) => d.coords.map(c => [c.lon, c.lat] as [number, number]),
getColor: (d: BoomLine) => hexToRgba(PRIORITY_COLORS[d.priority] || '#f59e0b', 230), getColor: (d: BoomLine) => hexToRgba(PRIORITY_COLORS[d.priority] || '#f59e0b', 230),
getWidth: (d: BoomLine) => PRIORITY_WEIGHTS[d.priority] || 2, 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, dashJustified: true,
widthMinPixels: 2, widthMinPixels: 2,
widthMaxPixels: 6, widthMaxPixels: 6,
@ -1018,8 +1089,11 @@ export function MapView({
), ),
}); });
} else if (!info.object) { } else if (!info.object) {
// 클릭으로 열린 팝업(어장 등)이 있으면 호버로 닫지 않음
if (!persistentPopupRef.current) {
setPopupInfo(null); setPopupInfo(null);
} }
}
}, },
}) })
) )
@ -1225,7 +1299,45 @@ export function MapView({
// 거리/면적 측정 레이어 // 거리/면적 측정 레이어
result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements)) 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, oilTrajectory, currentTime, selectedModels,
boomLines, isDrawingBoom, drawingPoints, boomLines, isDrawingBoom, drawingPoints,
@ -1233,6 +1345,7 @@ export function MapView({
sensitiveResources, centerPoints, windData, sensitiveResources, centerPoints, windData,
showWind, showBeached, showTimeLabel, simulationStartTime, showWind, showBeached, showTimeLabel, simulationStartTime,
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode, analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode,
spatialQueryResult,
]) ])
// 3D 모드 / 밝은 톤에 따른 지도 스타일 전환 // 3D 모드 / 밝은 톤에 따른 지도 스타일 전환
@ -1318,7 +1431,10 @@ export function MapView({
longitude={popupInfo.longitude} longitude={popupInfo.longitude}
latitude={popupInfo.latitude} latitude={popupInfo.latitude}
anchor="bottom" anchor="bottom"
onClose={() => setPopupInfo(null)} onClose={() => {
persistentPopupRef.current = false
setPopupInfo(null)
}}
> >
<div className="text-[#333]">{popupInfo.content}</div> <div className="text-[#333]">{popupInfo.content}</div>
</Popup> </Popup>

파일 보기

@ -7,6 +7,7 @@ export interface LayerNode {
name: string name: string
level: number level: number
layerName: string | null layerName: string | null
dataTblNm?: string | null
icon?: string icon?: string
count?: number count?: number
defaultOn?: boolean defaultOn?: boolean

파일 보기

@ -46,6 +46,7 @@ export interface LayerDTO {
cmn_cd_nm: string cmn_cd_nm: string
cmn_cd_level: number cmn_cd_level: number
clnm: string | null clnm: string | null
data_tbl_nm?: string | null
icon?: string icon?: string
count?: number count?: number
children?: LayerDTO[] children?: LayerDTO[]
@ -58,6 +59,7 @@ export interface Layer {
fullName: string fullName: string
level: number level: number
wmsLayer: string | null wmsLayer: string | null
dataTblNm?: string | null
icon?: string icon?: string
count?: number count?: number
children?: Layer[] children?: Layer[]
@ -72,6 +74,7 @@ function convertToLayer(dto: LayerDTO): Layer {
fullName: dto.cmn_cd_full_nm, fullName: dto.cmn_cd_full_nm,
level: dto.cmn_cd_level, level: dto.cmn_cd_level,
wmsLayer: dto.clnm, wmsLayer: dto.clnm,
dataTblNm: dto.data_tbl_nm,
icon: dto.icon, icon: dto.icon,
count: dto.count, count: dto.count,
children: dto.children ? dto.children.map(convertToLayer) : undefined, children: dto.children ? dto.children.map(convertToLayer) : undefined,

파일 보기

@ -8,6 +8,7 @@ export interface Layer {
fullName: string fullName: string
level: number level: number
wmsLayer: string | null wmsLayer: string | null
dataTblNm?: string | null
icon?: string icon?: string
count?: number count?: number
children?: Layer[] children?: Layer[]

파일 보기

@ -1,3 +1,9 @@
/* 바람 입자 캔버스(z-index: 450) 위에 팝업이 표시되도록 z-index 설정
@layer 밖에 위치해야 non-layered CSS인 MapLibre 스타일보다 우선순위를 가짐 */
.maplibregl-popup {
z-index: 500;
}
@layer components { @layer components {
/* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */ /* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */
.cctv-dark-popup .maplibregl-popup-content { .cctv-dark-popup .maplibregl-popup-content {

파일 보기

@ -21,6 +21,8 @@ import SimulationErrorModal from './SimulationErrorModal'
import { api } from '@common/services/api' import { api } from '@common/services/api'
import { generateAIBoomLines, haversineDistance, pointInPolygon, polygonAreaKm2, circleAreaKm2 } from '@common/utils/geo' import { generateAIBoomLines, haversineDistance, pointInPolygon, polygonAreaKm2, circleAreaKm2 } from '@common/utils/geo'
import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal' import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal'
import { querySpatialLayers } from '../services/analysisService'
import type { SpatialQueryResult } from '../services/analysisService'
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift' export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
@ -203,6 +205,8 @@ export function OilSpillView() {
const [analysisPolygonPoints, setAnalysisPolygonPoints] = useState<{ lat: number; lon: number }[]>([]) const [analysisPolygonPoints, setAnalysisPolygonPoints] = useState<{ lat: number; lon: number }[]>([])
const [circleRadiusNm, setCircleRadiusNm] = useState<number>(5) const [circleRadiusNm, setCircleRadiusNm] = useState<number>(5)
const [analysisResult, setAnalysisResult] = useState<{ area: number; particleCount: number; particlePercent: number; sensitiveCount: number } | null>(null) 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 아님) // 원 분석용 derived 값 (state 아님)
const analysisCircleCenter = analysisTab === 'circle' && incidentCoord ? incidentCoord : null const analysisCircleCenter = analysisTab === 'circle' && incidentCoord ? incidentCoord : null
@ -567,7 +571,7 @@ export function OilSpillView() {
setAnalysisResult(null) setAnalysisResult(null)
} }
const handleRunPolygonAnalysis = () => { const handleRunPolygonAnalysis = async () => {
if (analysisPolygonPoints.length < 3) return if (analysisPolygonPoints.length < 3) return
const currentParticles = oilTrajectory.filter(p => p.time === currentStep) const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1 const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1
@ -580,9 +584,25 @@ export function OilSpillView() {
sensitiveCount, sensitiveCount,
}) })
setDrawAnalysisMode(null) 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 if (!incidentCoord) return
const radiusM = circleRadiusNm * 1852 const radiusM = circleRadiusNm * 1852
const currentParticles = oilTrajectory.filter(p => p.time === currentStep) const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
@ -599,6 +619,23 @@ export function OilSpillView() {
particlePercent: Math.round((inside / totalIds) * 100), particlePercent: Math.round((inside / totalIds) * 100),
sensitiveCount, 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 = () => { const handleCancelAnalysis = () => {
@ -610,6 +647,7 @@ export function OilSpillView() {
setDrawAnalysisMode(null) setDrawAnalysisMode(null)
setAnalysisPolygonPoints([]) setAnalysisPolygonPoints([])
setAnalysisResult(null) setAnalysisResult(null)
setSpatialQueryResult(null)
} }
const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => { const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => {
@ -1029,6 +1067,7 @@ export function OilSpillView() {
showBeached={displayControls.showBeached} showBeached={displayControls.showBeached}
showTimeLabel={displayControls.showTimeLabel} showTimeLabel={displayControls.showTimeLabel}
simulationStartTime={accidentTime || undefined} simulationStartTime={accidentTime || undefined}
spatialQueryResult={spatialQueryResult}
/> />
{/* 타임라인 플레이어 (리플레이 비활성 시) */} {/* 타임라인 플레이어 (리플레이 비활성 시) */}
@ -1244,6 +1283,8 @@ export function OilSpillView() {
onRunCircleAnalysis={handleRunCircleAnalysis} onRunCircleAnalysis={handleRunCircleAnalysis}
onCancelAnalysis={handleCancelAnalysis} onCancelAnalysis={handleCancelAnalysis}
onClearAnalysis={handleClearAnalysis} onClearAnalysis={handleClearAnalysis}
spatialQueryResult={spatialQueryResult}
isSpatialQuerying={isSpatialQuerying}
/> />
)} )}

파일 보기

@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import type { PredictionDetail, SimulationSummary } from '../services/predictionApi' import type { PredictionDetail, SimulationSummary } from '../services/predictionApi'
import type { DisplayControls } from './OilSpillView' import type { DisplayControls } from './OilSpillView'
import type { SpatialQueryResult } from '../services/analysisService'
interface AnalysisResult { interface AnalysisResult {
area: number area: number
@ -34,6 +35,8 @@ interface RightPanelProps {
onRunCircleAnalysis?: () => void onRunCircleAnalysis?: () => void
onCancelAnalysis?: () => void onCancelAnalysis?: () => void
onClearAnalysis?: () => void onClearAnalysis?: () => void
spatialQueryResult?: SpatialQueryResult | null
isSpatialQuerying?: boolean
} }
export function RightPanel({ export function RightPanel({
@ -46,6 +49,7 @@ export function RightPanel({
analysisResult, analysisResult,
onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis, onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis,
onCancelAnalysis, onClearAnalysis, onCancelAnalysis, onClearAnalysis,
spatialQueryResult, isSpatialQuerying = false,
}: RightPanelProps) { }: RightPanelProps) {
const vessel = detail?.vessels?.[0] const vessel = detail?.vessels?.[0]
const vessel2 = detail?.vessels?.[1] const vessel2 = detail?.vessels?.[1]
@ -217,6 +221,70 @@ export function RightPanel({
)} )}
</div> </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> </Section>
{/* 오염 종합 상황 */} {/* 오염 종합 상황 */}

파일 보기

@ -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
}