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_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
|
||||||
}
|
}
|
||||||
|
|||||||
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_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) => {
|
||||||
|
|||||||
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 { 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,7 +1089,10 @@ export function MapView({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
} else if (!info.object) {
|
} else if (!info.object) {
|
||||||
setPopupInfo(null);
|
// 클릭으로 열린 팝업(어장 등)이 있으면 호버로 닫지 않음
|
||||||
|
if (!persistentPopupRef.current) {
|
||||||
|
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>
|
||||||
|
|
||||||
{/* 오염 종합 상황 */}
|
{/* 오염 종합 상황 */}
|
||||||
|
|||||||
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