feat: 기상 스냅샷 자동 저장 + 민감자원 DB 마이그레이션 + 분석 API 예측 서비스 통합
This commit is contained in:
부모
aefd38b3bc
커밋
e06287ba5b
@ -1,166 +0,0 @@
|
|||||||
import express from 'express'
|
|
||||||
import { wingPool } from '../db/wingDb.js'
|
|
||||||
import { isValidNumber } from '../middleware/security.js'
|
|
||||||
|
|
||||||
const router = express.Router()
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 공간 쿼리 대상 테이블 레지스트리
|
|
||||||
// 새 테이블 추가 시: TABLE_META에 항목 추가 + LAYER 테이블 DATA_TBL_NM 컬럼에 해당 값 등록
|
|
||||||
// 예) 'gis.coastal_zone': { geomColumn: 'geom', srid: 4326, properties: ['id','zone_nm','...'] }
|
|
||||||
// ============================================================
|
|
||||||
const TABLE_META: Record<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
|
|
||||||
@ -5,6 +5,7 @@ import {
|
|||||||
getIncident,
|
getIncident,
|
||||||
listIncidentPredictions,
|
listIncidentPredictions,
|
||||||
getIncidentWeather,
|
getIncidentWeather,
|
||||||
|
saveIncidentWeather,
|
||||||
getIncidentMedia,
|
getIncidentMedia,
|
||||||
} from './incidentsService.js';
|
} from './incidentsService.js';
|
||||||
|
|
||||||
@ -92,6 +93,24 @@ router.get('/:sn/weather', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// POST /api/incidents/:sn/weather — 기상정보 저장
|
||||||
|
// ============================================================
|
||||||
|
router.post('/:sn/weather', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sn = parseInt(req.params.sn as string, 10);
|
||||||
|
if (isNaN(sn)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 사고 번호입니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const weatherSn = await saveIncidentWeather(sn, req.body as Record<string, unknown>);
|
||||||
|
res.json({ weatherSn });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[incidents] 기상정보 저장 오류:', err);
|
||||||
|
res.status(500).json({ error: '기상정보 저장 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// GET /api/incidents/:sn/media — 미디어 정보
|
// GET /api/incidents/:sn/media — 미디어 정보
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -254,24 +254,143 @@ export async function getIncidentWeather(acdntSn: number): Promise<WeatherInfo |
|
|||||||
const r = rows[0] as Record<string, unknown>;
|
const r = rows[0] as Record<string, unknown>;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locNm: r.loc_nm as string,
|
locNm: (r.loc_nm as string | null) ?? '-',
|
||||||
obsDtm: (r.obs_dtm as Date).toISOString(),
|
obsDtm: r.obs_dtm ? (r.obs_dtm as Date).toISOString() : '-',
|
||||||
icon: r.icon as string,
|
icon: (r.icon as string | null) ?? '',
|
||||||
temp: r.temp as string,
|
temp: (r.temp as string | null) ?? '-',
|
||||||
weatherDc: r.weather_dc as string,
|
weatherDc: (r.weather_dc as string | null) ?? '-',
|
||||||
wind: r.wind as string,
|
wind: (r.wind as string | null) ?? '-',
|
||||||
wave: r.wave as string,
|
wave: (r.wave as string | null) ?? '-',
|
||||||
humid: r.humid as string,
|
humid: (r.humid as string | null) ?? '-',
|
||||||
vis: r.vis as string,
|
vis: (r.vis as string | null) ?? '-',
|
||||||
sst: r.sst as string,
|
sst: (r.sst as string | null) ?? '-',
|
||||||
tide: r.tide as string,
|
tide: (r.tide as string | null) ?? '-',
|
||||||
highTide: r.high_tide as string,
|
highTide: (r.high_tide as string | null) ?? '-',
|
||||||
lowTide: r.low_tide as string,
|
lowTide: (r.low_tide as string | null) ?? '-',
|
||||||
forecast: (r.forecast as Array<{ hour: string; icon: string; temp: string }>) ?? [],
|
forecast: (r.forecast as Array<{ hour: string; icon: string; temp: string }>) ?? [],
|
||||||
impactDc: r.impact_dc as string,
|
impactDc: (r.impact_dc as string | null) ?? '-',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 기상정보 저장 (예측 실행 시 스냅샷 저장)
|
||||||
|
// ============================================================
|
||||||
|
interface WeatherSnapshotPayload {
|
||||||
|
stationName?: string;
|
||||||
|
capturedAt?: string;
|
||||||
|
wind?: {
|
||||||
|
speed?: number;
|
||||||
|
direction?: number;
|
||||||
|
directionLabel?: string;
|
||||||
|
speed_1k?: number;
|
||||||
|
speed_3k?: number;
|
||||||
|
};
|
||||||
|
wave?: {
|
||||||
|
height?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
period?: number;
|
||||||
|
direction?: string;
|
||||||
|
};
|
||||||
|
temperature?: {
|
||||||
|
current?: number;
|
||||||
|
feelsLike?: number;
|
||||||
|
};
|
||||||
|
pressure?: number;
|
||||||
|
visibility?: number;
|
||||||
|
salinity?: number;
|
||||||
|
astronomy?: {
|
||||||
|
sunrise?: string;
|
||||||
|
sunset?: string;
|
||||||
|
moonrise?: string;
|
||||||
|
moonset?: string;
|
||||||
|
moonPhase?: string;
|
||||||
|
tidalRange?: number;
|
||||||
|
} | null;
|
||||||
|
alert?: string | null;
|
||||||
|
forecast?: unknown[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveIncidentWeather(
|
||||||
|
acdntSn: number,
|
||||||
|
snapshot: WeatherSnapshotPayload,
|
||||||
|
): Promise<number> {
|
||||||
|
// 팝업 표시용 포맷 문자열
|
||||||
|
const windStr = (snapshot.wind?.directionLabel && snapshot.wind?.speed != null)
|
||||||
|
? `${snapshot.wind.directionLabel} ${snapshot.wind.speed}m/s` : null;
|
||||||
|
const waveStr = snapshot.wave?.height != null ? `${snapshot.wave.height}m` : null;
|
||||||
|
const tempStr = snapshot.temperature?.feelsLike != null ? `${snapshot.temperature.feelsLike}°C` : null;
|
||||||
|
const vis = snapshot.visibility != null ? String(snapshot.visibility) : null;
|
||||||
|
const sst = snapshot.temperature?.current != null ? String(snapshot.temperature.current) : null;
|
||||||
|
const highTideStr = snapshot.astronomy?.tidalRange != null
|
||||||
|
? `조차 ${snapshot.astronomy.tidalRange}m` : null;
|
||||||
|
|
||||||
|
// 24h 예보: WeatherSnapshot 형식 → 팝업 표시 형식 변환
|
||||||
|
type ForecastItem = { time?: string; icon?: string; temperature?: number };
|
||||||
|
const forecastDisplay = (snapshot.forecast as ForecastItem[] | null)?.map(f => ({
|
||||||
|
hour: f.time ?? '',
|
||||||
|
icon: f.icon ?? '⛅',
|
||||||
|
temp: f.temperature != null ? `${Math.round(f.temperature)}°` : '-',
|
||||||
|
})) ?? null;
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
INSERT INTO wing.ACDNT_WEATHER (
|
||||||
|
ACDNT_SN, LOC_NM, OBS_DTM,
|
||||||
|
WIND_SPEED, WIND_DIR, WIND_DIR_LBL, WIND_SPEED_1K, WIND_SPEED_3K,
|
||||||
|
PRESSURE, VIS,
|
||||||
|
WAVE_HEIGHT, WAVE_MAX_HT, WAVE_PERIOD, WAVE_DIR,
|
||||||
|
SST, AIR_TEMP, SALINITY,
|
||||||
|
SUNRISE, SUNSET, MOONRISE, MOONSET, MOON_PHASE, TIDAL_RANGE,
|
||||||
|
WEATHER_ALERT, FORECAST,
|
||||||
|
TEMP, WIND, WAVE, ICON, HIGH_TIDE, IMPACT_DC
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, NOW(),
|
||||||
|
$3, $4, $5, $6, $7,
|
||||||
|
$8, $9,
|
||||||
|
$10, $11, $12, $13,
|
||||||
|
$14, $15, $16,
|
||||||
|
$17, $18, $19, $20, $21, $22,
|
||||||
|
$23, $24,
|
||||||
|
$25, $26, $27, $28, $29, $30
|
||||||
|
)
|
||||||
|
RETURNING WEATHER_SN
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await wingPool.query(sql, [
|
||||||
|
acdntSn,
|
||||||
|
snapshot.stationName ?? null,
|
||||||
|
snapshot.wind?.speed ?? null,
|
||||||
|
snapshot.wind?.direction ?? null,
|
||||||
|
snapshot.wind?.directionLabel ?? null,
|
||||||
|
snapshot.wind?.speed_1k ?? null,
|
||||||
|
snapshot.wind?.speed_3k ?? null,
|
||||||
|
snapshot.pressure ?? null,
|
||||||
|
vis,
|
||||||
|
snapshot.wave?.height ?? null,
|
||||||
|
snapshot.wave?.maxHeight ?? null,
|
||||||
|
snapshot.wave?.period ?? null,
|
||||||
|
snapshot.wave?.direction ?? null,
|
||||||
|
sst,
|
||||||
|
snapshot.temperature?.feelsLike ?? null,
|
||||||
|
snapshot.salinity ?? null,
|
||||||
|
snapshot.astronomy?.sunrise ?? null,
|
||||||
|
snapshot.astronomy?.sunset ?? null,
|
||||||
|
snapshot.astronomy?.moonrise ?? null,
|
||||||
|
snapshot.astronomy?.moonset ?? null,
|
||||||
|
snapshot.astronomy?.moonPhase ?? null,
|
||||||
|
snapshot.astronomy?.tidalRange ?? null,
|
||||||
|
snapshot.alert ?? null,
|
||||||
|
forecastDisplay ? JSON.stringify(forecastDisplay) : null,
|
||||||
|
tempStr,
|
||||||
|
windStr,
|
||||||
|
waveStr,
|
||||||
|
'🌊',
|
||||||
|
highTideStr,
|
||||||
|
snapshot.alert ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (rows[0] as Record<string, unknown>).weather_sn as number;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 미디어 정보 조회
|
// 미디어 정보 조회
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import multer from 'multer';
|
|||||||
import {
|
import {
|
||||||
listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt,
|
listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt,
|
||||||
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
|
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
|
||||||
|
getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn,
|
||||||
} from './predictionService.js';
|
} from './predictionService.js';
|
||||||
import { analyzeImageFile } from './imageAnalyzeService.js';
|
import { analyzeImageFile } from './imageAnalyzeService.js';
|
||||||
import { isValidNumber } from '../middleware/security.js';
|
import { isValidNumber } from '../middleware/security.js';
|
||||||
@ -64,6 +65,38 @@ router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('pred
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/prediction/analyses/:acdntSn/sensitive-resources — 예측 영역 내 민감자원 집계
|
||||||
|
router.get('/analyses/:acdntSn/sensitive-resources', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
||||||
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await getSensitiveResourcesByAcdntSn(acdntSn);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[prediction] 민감자원 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '민감자원 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/prediction/analyses/:acdntSn/sensitive-resources/geojson — 예측 영역 내 민감자원 GeoJSON
|
||||||
|
router.get('/analyses/:acdntSn/sensitive-resources/geojson', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
||||||
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await getSensitiveResourcesGeoJsonByAcdntSn(acdntSn);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[prediction] 민감자원 GeoJSON 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '민감자원 GeoJSON 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/prediction/backtrack — 사고별 역추적 목록
|
// GET /api/prediction/backtrack — 사고별 역추적 목록
|
||||||
router.get('/backtrack', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
router.get('/backtrack', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -585,6 +585,76 @@ export async function getAnalysisTrajectory(acdntSn: number): Promise<Trajectory
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSensitiveResourcesByAcdntSn(
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<{ category: string; count: number }[]> {
|
||||||
|
const sql = `
|
||||||
|
WITH all_wkts AS (
|
||||||
|
SELECT step_data ->> 'wkt' AS wkt
|
||||||
|
FROM wing.PRED_EXEC,
|
||||||
|
jsonb_array_elements(RSLT_DATA) AS step_data
|
||||||
|
WHERE ACDNT_SN = $1
|
||||||
|
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
|
||||||
|
AND EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
AND RSLT_DATA IS NOT NULL
|
||||||
|
),
|
||||||
|
union_geom AS (
|
||||||
|
SELECT ST_Union(ST_GeomFromText(wkt, 4326)) AS geom
|
||||||
|
FROM all_wkts
|
||||||
|
WHERE wkt IS NOT NULL AND wkt <> ''
|
||||||
|
)
|
||||||
|
SELECT sr.CATEGORY, COUNT(*)::int AS count
|
||||||
|
FROM wing.SENSITIVE_RESOURCE sr, union_geom
|
||||||
|
WHERE union_geom.geom IS NOT NULL
|
||||||
|
AND ST_Intersects(sr.GEOM, union_geom.geom)
|
||||||
|
GROUP BY sr.CATEGORY
|
||||||
|
ORDER BY sr.CATEGORY
|
||||||
|
`;
|
||||||
|
const { rows } = await wingPool.query(sql, [acdntSn]);
|
||||||
|
return rows.map((r: Record<string, unknown>) => ({
|
||||||
|
category: String(r['category'] ?? ''),
|
||||||
|
count: Number(r['count'] ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSensitiveResourcesGeoJsonByAcdntSn(
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<{ type: 'FeatureCollection'; features: unknown[] }> {
|
||||||
|
const sql = `
|
||||||
|
WITH all_wkts AS (
|
||||||
|
SELECT step_data ->> 'wkt' AS wkt
|
||||||
|
FROM wing.PRED_EXEC,
|
||||||
|
jsonb_array_elements(RSLT_DATA) AS step_data
|
||||||
|
WHERE ACDNT_SN = $1
|
||||||
|
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
|
||||||
|
AND EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
AND RSLT_DATA IS NOT NULL
|
||||||
|
),
|
||||||
|
union_geom AS (
|
||||||
|
SELECT ST_Union(ST_GeomFromText(wkt, 4326)) AS geom
|
||||||
|
FROM all_wkts
|
||||||
|
WHERE wkt IS NOT NULL AND wkt <> ''
|
||||||
|
)
|
||||||
|
SELECT sr.SR_ID, sr.CATEGORY, sr.PROPERTIES,
|
||||||
|
ST_AsGeoJSON(sr.GEOM)::jsonb AS geom_json
|
||||||
|
FROM wing.SENSITIVE_RESOURCE sr, union_geom
|
||||||
|
WHERE union_geom.geom IS NOT NULL
|
||||||
|
AND ST_Intersects(sr.GEOM, union_geom.geom)
|
||||||
|
ORDER BY sr.CATEGORY, sr.SR_ID
|
||||||
|
`;
|
||||||
|
const { rows } = await wingPool.query(sql, [acdntSn]);
|
||||||
|
const features = rows.map((r: Record<string, unknown>) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: r['geom_json'],
|
||||||
|
properties: {
|
||||||
|
srId: Number(r['sr_id']),
|
||||||
|
category: String(r['category'] ?? ''),
|
||||||
|
...(r['properties'] as Record<string, unknown> ?? {}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return { type: 'FeatureCollection', features };
|
||||||
|
}
|
||||||
|
|
||||||
export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
|
export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD,
|
SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD,
|
||||||
|
|||||||
@ -92,7 +92,7 @@ router.get('/:sn', requireAuth, requirePermission('reports', 'READ'), async (req
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => {
|
router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections, mapCaptureImg, step3MapImg, step6MapImg } = req.body;
|
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections, step3MapImg, step6MapImg } = req.body;
|
||||||
const result = await createReport({
|
const result = await createReport({
|
||||||
tmplSn,
|
tmplSn,
|
||||||
ctgrSn,
|
ctgrSn,
|
||||||
@ -101,7 +101,6 @@ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req
|
|||||||
jrsdCd,
|
jrsdCd,
|
||||||
sttsCd,
|
sttsCd,
|
||||||
authorId: req.user!.sub,
|
authorId: req.user!.sub,
|
||||||
mapCaptureImg,
|
|
||||||
step3MapImg,
|
step3MapImg,
|
||||||
step6MapImg,
|
step6MapImg,
|
||||||
sections,
|
sections,
|
||||||
@ -127,8 +126,8 @@ router.post('/:sn/update', requireAuth, requirePermission('reports', 'UPDATE'),
|
|||||||
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
|
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg, step3MapImg, step6MapImg } = req.body;
|
const { title, jrsdCd, sttsCd, acdntSn, sections, step3MapImg, step6MapImg } = req.body;
|
||||||
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg, step3MapImg, step6MapImg }, req.user!.sub);
|
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, step3MapImg, step6MapImg }, req.user!.sub);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AuthError) {
|
if (err instanceof AuthError) {
|
||||||
|
|||||||
@ -75,7 +75,6 @@ interface SectionData {
|
|||||||
interface ReportDetail extends ReportListItem {
|
interface ReportDetail extends ReportListItem {
|
||||||
acdntSn: number | null;
|
acdntSn: number | null;
|
||||||
sections: SectionData[];
|
sections: SectionData[];
|
||||||
mapCaptureImg: string | null;
|
|
||||||
step3MapImg: string | null;
|
step3MapImg: string | null;
|
||||||
step6MapImg: string | null;
|
step6MapImg: string | null;
|
||||||
}
|
}
|
||||||
@ -104,7 +103,6 @@ interface CreateReportInput {
|
|||||||
jrsdCd?: string;
|
jrsdCd?: string;
|
||||||
sttsCd?: string;
|
sttsCd?: string;
|
||||||
authorId: string;
|
authorId: string;
|
||||||
mapCaptureImg?: string;
|
|
||||||
step3MapImg?: string;
|
step3MapImg?: string;
|
||||||
step6MapImg?: string;
|
step6MapImg?: string;
|
||||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||||
@ -115,7 +113,6 @@ interface UpdateReportInput {
|
|||||||
jrsdCd?: string;
|
jrsdCd?: string;
|
||||||
sttsCd?: string;
|
sttsCd?: string;
|
||||||
acdntSn?: number | null;
|
acdntSn?: number | null;
|
||||||
mapCaptureImg?: string | null;
|
|
||||||
step3MapImg?: string | null;
|
step3MapImg?: string | null;
|
||||||
step6MapImg?: string | null;
|
step6MapImg?: string | null;
|
||||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||||
@ -267,8 +264,7 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
|
|||||||
r.TITLE, r.JRSD_CD, r.STTS_CD,
|
r.TITLE, r.JRSD_CD, r.STTS_CD,
|
||||||
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
||||||
r.REG_DTM, r.MDFCN_DTM,
|
r.REG_DTM, r.MDFCN_DTM,
|
||||||
CASE WHEN (r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '')
|
CASE WHEN (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '')
|
||||||
OR (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '')
|
|
||||||
OR (r.STEP6_MAP_IMG IS NOT NULL AND r.STEP6_MAP_IMG <> '')
|
OR (r.STEP6_MAP_IMG IS NOT NULL AND r.STEP6_MAP_IMG <> '')
|
||||||
THEN true ELSE false END AS HAS_MAP_CAPTURE
|
THEN true ELSE false END AS HAS_MAP_CAPTURE
|
||||||
FROM REPORT r
|
FROM REPORT r
|
||||||
@ -309,9 +305,8 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
|
|||||||
c.CTGR_CD, c.CTGR_NM,
|
c.CTGR_CD, c.CTGR_NM,
|
||||||
r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN,
|
r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN,
|
||||||
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
||||||
r.REG_DTM, r.MDFCN_DTM, r.MAP_CAPTURE_IMG, r.STEP3_MAP_IMG, r.STEP6_MAP_IMG,
|
r.REG_DTM, r.MDFCN_DTM, r.STEP3_MAP_IMG, r.STEP6_MAP_IMG,
|
||||||
CASE WHEN (r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '')
|
CASE WHEN (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '')
|
||||||
OR (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '')
|
|
||||||
OR (r.STEP6_MAP_IMG IS NOT NULL AND r.STEP6_MAP_IMG <> '')
|
OR (r.STEP6_MAP_IMG IS NOT NULL AND r.STEP6_MAP_IMG <> '')
|
||||||
THEN true ELSE false END AS HAS_MAP_CAPTURE
|
THEN true ELSE false END AS HAS_MAP_CAPTURE
|
||||||
FROM REPORT r
|
FROM REPORT r
|
||||||
@ -350,7 +345,6 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
|
|||||||
authorName: r.author_name || '',
|
authorName: r.author_name || '',
|
||||||
regDtm: r.reg_dtm,
|
regDtm: r.reg_dtm,
|
||||||
mdfcnDtm: r.mdfcn_dtm,
|
mdfcnDtm: r.mdfcn_dtm,
|
||||||
mapCaptureImg: r.map_capture_img,
|
|
||||||
step3MapImg: r.step3_map_img,
|
step3MapImg: r.step3_map_img,
|
||||||
step6MapImg: r.step6_map_img,
|
step6MapImg: r.step6_map_img,
|
||||||
hasMapCapture: r.has_map_capture,
|
hasMapCapture: r.has_map_capture,
|
||||||
@ -373,8 +367,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
|
|||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
const res = await client.query(
|
const res = await client.query(
|
||||||
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, MAP_CAPTURE_IMG, STEP3_MAP_IMG, STEP6_MAP_IMG)
|
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, STEP3_MAP_IMG, STEP6_MAP_IMG)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING REPORT_SN`,
|
RETURNING REPORT_SN`,
|
||||||
[
|
[
|
||||||
input.tmplSn || null,
|
input.tmplSn || null,
|
||||||
@ -384,7 +378,6 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
|
|||||||
input.jrsdCd || null,
|
input.jrsdCd || null,
|
||||||
input.sttsCd || 'DRAFT',
|
input.sttsCd || 'DRAFT',
|
||||||
input.authorId,
|
input.authorId,
|
||||||
input.mapCaptureImg || null,
|
|
||||||
input.step3MapImg || null,
|
input.step3MapImg || null,
|
||||||
input.step6MapImg || null,
|
input.step6MapImg || null,
|
||||||
]
|
]
|
||||||
@ -458,10 +451,6 @@ export async function updateReport(
|
|||||||
sets.push(`ACDNT_SN = $${idx++}`);
|
sets.push(`ACDNT_SN = $${idx++}`);
|
||||||
params.push(input.acdntSn);
|
params.push(input.acdntSn);
|
||||||
}
|
}
|
||||||
if (input.mapCaptureImg !== undefined) {
|
|
||||||
sets.push(`MAP_CAPTURE_IMG = $${idx++}`);
|
|
||||||
params.push(input.mapCaptureImg);
|
|
||||||
}
|
|
||||||
if (input.step3MapImg !== undefined) {
|
if (input.step3MapImg !== undefined) {
|
||||||
sets.push(`STEP3_MAP_IMG = $${idx++}`);
|
sets.push(`STEP3_MAP_IMG = $${idx++}`);
|
||||||
params.push(input.step3MapImg);
|
params.push(input.step3MapImg);
|
||||||
|
|||||||
@ -23,7 +23,6 @@ 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,
|
||||||
@ -171,7 +170,6 @@ 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) => {
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
-- 025_layer_data_tbl_nm.sql
|
|
||||||
-- LAYER 테이블에 PostGIS 데이터 테이블명 컬럼 추가
|
|
||||||
-- 특정 구역 통계/분석 쿼리 시 실제 공간 데이터 테이블을 동적으로 참조하기 위해 사용
|
|
||||||
|
|
||||||
ALTER TABLE LAYER ADD COLUMN IF NOT EXISTS DATA_TBL_NM VARCHAR(100);
|
|
||||||
|
|
||||||
COMMENT ON COLUMN LAYER.DATA_TBL_NM IS 'PostGIS 데이터 테이블명 (공간 데이터 직접 조회용)';
|
|
||||||
44
database/migration/025_weather_columns.sql
Normal file
44
database/migration/025_weather_columns.sql
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
-- 027: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 추가
|
||||||
|
-- 확산예측 실행 시 WeatherRightPanel에 표시되는 모든 기상정보 저장을 위해
|
||||||
|
-- 기존 VARCHAR 컬럼(WIND, WAVE, TEMP, SST)은 하위 호환성 유지를 위해 보존
|
||||||
|
|
||||||
|
ALTER TABLE wing.ACDNT_WEATHER
|
||||||
|
ADD COLUMN IF NOT EXISTS WIND_SPEED NUMERIC(5,1), -- 풍속 (m/s)
|
||||||
|
ADD COLUMN IF NOT EXISTS WIND_DIR INTEGER, -- 풍향 (도)
|
||||||
|
ADD COLUMN IF NOT EXISTS WIND_DIR_LBL VARCHAR(10), -- 풍향 텍스트 (N, NW, ...)
|
||||||
|
ADD COLUMN IF NOT EXISTS WIND_SPEED_1K NUMERIC(5,1), -- 1k 최고 풍속 (m/s)
|
||||||
|
ADD COLUMN IF NOT EXISTS WIND_SPEED_3K NUMERIC(5,1), -- 3k 평균 풍속 (m/s)
|
||||||
|
ADD COLUMN IF NOT EXISTS PRESSURE NUMERIC(6,1), -- 기압 (hPa)
|
||||||
|
ADD COLUMN IF NOT EXISTS WAVE_HEIGHT NUMERIC(4,1), -- 유의파고 (m)
|
||||||
|
ADD COLUMN IF NOT EXISTS WAVE_MAX_HT NUMERIC(4,1), -- 최고파고 (m)
|
||||||
|
ADD COLUMN IF NOT EXISTS WAVE_PERIOD NUMERIC(4,1), -- 파도 주기 (s)
|
||||||
|
ADD COLUMN IF NOT EXISTS WAVE_DIR VARCHAR(10), -- 파향 (N, NE, ...)
|
||||||
|
ADD COLUMN IF NOT EXISTS AIR_TEMP NUMERIC(5,1), -- 기온 (°C)
|
||||||
|
ADD COLUMN IF NOT EXISTS SALINITY NUMERIC(5,1), -- 염분 (PSU)
|
||||||
|
ADD COLUMN IF NOT EXISTS SUNRISE VARCHAR(10), -- 일출 시각 (HH:MM)
|
||||||
|
ADD COLUMN IF NOT EXISTS SUNSET VARCHAR(10), -- 일몰 시각 (HH:MM)
|
||||||
|
ADD COLUMN IF NOT EXISTS MOONRISE VARCHAR(10), -- 월출 시각 (HH:MM)
|
||||||
|
ADD COLUMN IF NOT EXISTS MOONSET VARCHAR(10), -- 월몰 시각 (HH:MM)
|
||||||
|
ADD COLUMN IF NOT EXISTS MOON_PHASE VARCHAR(30), -- 월상 (예: 상현달 14일)
|
||||||
|
ADD COLUMN IF NOT EXISTS TIDAL_RANGE NUMERIC(4,1), -- 조차 (m)
|
||||||
|
ADD COLUMN IF NOT EXISTS WEATHER_ALERT TEXT; -- 날씨 특보
|
||||||
|
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED IS '풍속 (m/s)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_DIR IS '풍향 (도, 0-360)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_DIR_LBL IS '풍향 텍스트 (N/NE/E/...)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED_1K IS '1km 최고 풍속 (m/s)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED_3K IS '3km 평균 풍속 (m/s)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.PRESSURE IS '기압 (hPa)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_HEIGHT IS '유의파고 (m)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_MAX_HT IS '최고파고 (m)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_PERIOD IS '파도 주기 (s)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_DIR IS '파향 (N/NE/E/...)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.AIR_TEMP IS '기온 (°C)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.SALINITY IS '염분 (PSU)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.SUNRISE IS '일출 시각 (HH:MM)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.SUNSET IS '일몰 시각 (HH:MM)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.MOONRISE IS '월출 시각 (HH:MM)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.MOONSET IS '월몰 시각 (HH:MM)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.MOON_PHASE IS '월상 (예: 상현달 14일)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.TIDAL_RANGE IS '조차 (m)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WEATHER_ALERT IS '날씨 특보 문자열';
|
||||||
@ -1,13 +0,0 @@
|
|||||||
-- SRID 5179 (Korea 2000 / Unified CS) 등록
|
|
||||||
-- gis.fshfrm 등 한국 좌표계(EPSG:5179) 데이터의 ST_Transform 사용을 위해 필요
|
|
||||||
INSERT INTO spatial_ref_sys (srid, auth_name, auth_srid, proj4text, srtext)
|
|
||||||
VALUES (
|
|
||||||
5179,
|
|
||||||
'EPSG',
|
|
||||||
5179,
|
|
||||||
'+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +units=m +no_defs',
|
|
||||||
'PROJCS["Korea 2000 / Unified CS",GEOGCS["Korea 2000",DATUM["Geocentric_datum_of_Korea",SPHEROID["GRS 1980",6378137,298.257222101,AUTHORITY["EPSG","7019"]],TOWGS84[0,0,0,0,0,0,0],AUTHORITY["EPSG","6737"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4737"]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",38],PARAMETER["central_meridian",127.5],PARAMETER["scale_factor",0.9996],PARAMETER["false_easting",1000000],PARAMETER["false_northing",2000000],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Northing",NORTH],AXIS["Easting",EAST],AUTHORITY["EPSG","5179"]]'
|
|
||||||
)
|
|
||||||
ON CONFLICT (srid) DO UPDATE SET
|
|
||||||
proj4text = EXCLUDED.proj4text,
|
|
||||||
srtext = EXCLUDED.srtext;
|
|
||||||
41
database/migration/026_sensitive_resources.sql
Normal file
41
database/migration/026_sensitive_resources.sql
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 027: 민감자원 테이블 생성
|
||||||
|
-- 모든 민감자원(양식장, 해수욕장, 무역항 등)을 단일 테이블로 관리
|
||||||
|
-- properties는 JSONB로 유연하게 저장
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SET search_path TO wing, public;
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 민감자원 테이블
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS SENSITIVE_RESOURCE (
|
||||||
|
SR_ID BIGSERIAL PRIMARY KEY,
|
||||||
|
CATEGORY VARCHAR(50) NOT NULL, -- 민감자원 유형 (양식장, 해수욕장, 무역항 등)
|
||||||
|
GEOM public.geometry(Geometry, 4326) NOT NULL, -- 공간 데이터 (Point, LineString, Polygon 모두 수용)
|
||||||
|
PROPERTIES JSONB NOT NULL DEFAULT '{}', -- 원본 GeoJSON properties
|
||||||
|
REG_DT TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
MOD_DT TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 공간 인덱스
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_SR_GEOM ON SENSITIVE_RESOURCE USING GIST(GEOM);
|
||||||
|
|
||||||
|
-- 카테고리 인덱스 (유형별 필터링)
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_SR_CATEGORY ON SENSITIVE_RESOURCE (CATEGORY);
|
||||||
|
|
||||||
|
-- JSONB 인덱스 (properties 내부 검색용)
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_SR_PROPERTIES ON SENSITIVE_RESOURCE USING GIN(PROPERTIES);
|
||||||
|
|
||||||
|
-- 카테고리 + 공간 복합 조회 최적화
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_SR_CATEGORY_GEOM ON SENSITIVE_RESOURCE USING GIST(GEOM) WHERE CATEGORY IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON TABLE SENSITIVE_RESOURCE IS '민감자원 통합 테이블';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_RESOURCE.SR_ID IS '민감자원 ID';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_RESOURCE.CATEGORY IS '민감자원 유형 (양식장, 해수욕장, 무역항, 어항, 해안선_ESI 등)';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_RESOURCE.GEOM IS '공간 데이터 (EPSG:4326)';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_RESOURCE.PROPERTIES IS '원본 GeoJSON properties (JSONB)';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_RESOURCE.REG_DT IS '등록일시';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_RESOURCE.MOD_DT IS '수정일시';
|
||||||
@ -4,6 +4,12 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 확산예측: 예측 실행 시 기상정보(풍속·풍향·기압·파고·수온·기온·염분 등) ACDNT_WEATHER 테이블에 자동 저장
|
||||||
|
- DB: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 19개 추가 (027 마이그레이션)
|
||||||
|
|
||||||
|
## [2026-03-20.3]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
- 보고서: 기능 강화 (HWPX 내보내기, 확산 지도 패널, 보고서 생성기 개선)
|
- 보고서: 기능 강화 (HWPX 내보내기, 확산 지도 패널, 보고서 생성기 개선)
|
||||||
- 관리자: 권한 트리 확장 (게시판관리·기준정보·연계관리 섹션 추가)
|
- 관리자: 권한 트리 확장 (게시판관리·기준정보·연계관리 섹션 추가)
|
||||||
|
|||||||
@ -3,13 +3,12 @@ import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/r
|
|||||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||||
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer, GeoJsonLayer } 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'
|
||||||
import { layerDatabase } from '@common/services/layerService'
|
import { layerDatabase } from '@common/services/layerService'
|
||||||
import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView'
|
import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView'
|
||||||
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi'
|
import type { HydrDataStep, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi'
|
||||||
import HydrParticleOverlay from './HydrParticleOverlay'
|
import HydrParticleOverlay from './HydrParticleOverlay'
|
||||||
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
|
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
|
||||||
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||||
@ -290,6 +289,24 @@ const PRIORITY_LABELS: Record<string, string> = {
|
|||||||
'MEDIUM': '보통',
|
'MEDIUM': '보통',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||||
|
const a = s * Math.min(l, 1 - l);
|
||||||
|
const f = (n: number) => {
|
||||||
|
const k = (n + h * 12) % 12;
|
||||||
|
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
|
||||||
|
};
|
||||||
|
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryToRgb(category: string): [number, number, number] {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < category.length; i++) {
|
||||||
|
hash = (hash * 31 + category.charCodeAt(i)) >>> 0;
|
||||||
|
}
|
||||||
|
const hue = (hash * 137) % 360;
|
||||||
|
return hslToRgb(hue / 360, 0.65, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
const SENSITIVE_COLORS: Record<string, string> = {
|
const SENSITIVE_COLORS: Record<string, string> = {
|
||||||
'aquaculture': '#22c55e',
|
'aquaculture': '#22c55e',
|
||||||
'beach': '#0ea5e9',
|
'beach': '#0ea5e9',
|
||||||
@ -343,6 +360,7 @@ interface MapViewProps {
|
|||||||
incidentCoord: { lat: number; lon: number }
|
incidentCoord: { lat: number; lon: number }
|
||||||
}
|
}
|
||||||
sensitiveResources?: SensitiveResource[]
|
sensitiveResources?: SensitiveResource[]
|
||||||
|
sensitiveResourceGeojson?: SensitiveResourceFeatureCollection | null
|
||||||
flyToTarget?: { lng: number; lat: number; zoom?: number } | null
|
flyToTarget?: { lng: number; lat: number; zoom?: number } | null
|
||||||
fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null
|
fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null
|
||||||
centerPoints?: Array<{ lat: number; lon: number; time: number; model?: string }>
|
centerPoints?: Array<{ lat: number; lon: number; time: number; model?: string }>
|
||||||
@ -366,49 +384,6 @@ 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)
|
||||||
@ -572,6 +547,7 @@ export function MapView({
|
|||||||
layerBrightness = 50,
|
layerBrightness = 50,
|
||||||
backtrackReplay,
|
backtrackReplay,
|
||||||
sensitiveResources = [],
|
sensitiveResources = [],
|
||||||
|
sensitiveResourceGeojson,
|
||||||
flyToTarget,
|
flyToTarget,
|
||||||
fitBoundsTarget,
|
fitBoundsTarget,
|
||||||
centerPoints = [],
|
centerPoints = [],
|
||||||
@ -592,7 +568,6 @@ 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()
|
||||||
@ -608,11 +583,8 @@ export function MapView({
|
|||||||
const deckClickHandledRef = useRef(false)
|
const deckClickHandledRef = useRef(false)
|
||||||
// 클릭으로 열린 팝업(닫기 전까지 유지) 추적 — 호버 핸들러가 닫지 않도록 방지
|
// 클릭으로 열린 팝업(닫기 전까지 유지) 추적 — 호버 핸들러가 닫지 않도록 방지
|
||||||
const persistentPopupRef = useRef(false)
|
const persistentPopupRef = useRef(false)
|
||||||
// GeoJsonLayer(어장 폴리곤) hover 중인 피처 — handleMapClick에서 팝업 표시에 사용
|
// 현재 호버 중인 민감자원 feature properties (handleMapClick에서 팝업 생성에 사용)
|
||||||
const hoveredGeoLayerRef = useRef<{
|
const hoveredSensitiveRef = useRef<Record<string, unknown> | null>(null)
|
||||||
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) => {
|
||||||
@ -623,23 +595,44 @@ 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이 처리한 클릭 — 팝업 유지
|
||||||
// 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) {
|
if (deckClickHandledRef.current) {
|
||||||
deckClickHandledRef.current = false
|
deckClickHandledRef.current = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 민감자원 hover 중이면 팝업 표시
|
||||||
|
if (hoveredSensitiveRef.current) {
|
||||||
|
const props = hoveredSensitiveRef.current
|
||||||
|
const { category, ...rest } = props
|
||||||
|
const entries = Object.entries(rest).filter(([k, v]) => k !== 'srId' && v !== null && v !== undefined && v !== '')
|
||||||
|
persistentPopupRef.current = true
|
||||||
|
setPopupInfo({
|
||||||
|
longitude: lng,
|
||||||
|
latitude: lat,
|
||||||
|
content: (
|
||||||
|
<div className="text-xs font-korean" style={{ minWidth: '180px', maxWidth: '260px' }}>
|
||||||
|
<div className="font-semibold mb-1.5 pb-1 border-b border-[rgba(0,0,0,0.12)]">
|
||||||
|
{String(category ?? '민감자원')}
|
||||||
|
</div>
|
||||||
|
{entries.length > 0 ? (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{entries.map(([key, val]) => (
|
||||||
|
<div key={key} className="flex gap-2 justify-between">
|
||||||
|
<span className="text-[10px] text-[#888] shrink-0">{key}</span>
|
||||||
|
<span className="text-[10px] text-[#333] font-medium text-right break-all">
|
||||||
|
{typeof val === 'object' ? JSON.stringify(val) : String(val)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-[10px] text-[#999]">상세 정보 없음</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (measureMode !== null) {
|
if (measureMode !== null) {
|
||||||
handleMeasureClick(lng, lat)
|
handleMeasureClick(lng, lat)
|
||||||
return
|
return
|
||||||
@ -1185,6 +1178,41 @@ export function MapView({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 민감자원 GeoJSON 레이어 ---
|
||||||
|
if (sensitiveResourceGeojson && sensitiveResourceGeojson.features.length > 0) {
|
||||||
|
result.push(
|
||||||
|
new GeoJsonLayer({
|
||||||
|
id: 'sensitive-resource-geojson',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
data: sensitiveResourceGeojson as any,
|
||||||
|
pickable: true,
|
||||||
|
stroked: true,
|
||||||
|
filled: true,
|
||||||
|
pointRadiusMinPixels: 10,
|
||||||
|
pointRadiusMaxPixels: 20,
|
||||||
|
lineWidthMinPixels: 1,
|
||||||
|
getLineWidth: 1.5,
|
||||||
|
getFillColor: (f: { properties: { category?: string } | null }) => {
|
||||||
|
const cat = f.properties?.category ?? '';
|
||||||
|
const [r, g, b] = categoryToRgb(cat);
|
||||||
|
return [r, g, b, 80] as [number, number, number, number];
|
||||||
|
},
|
||||||
|
getLineColor: (f: { properties: { category?: string } | null }) => {
|
||||||
|
const cat = f.properties?.category ?? '';
|
||||||
|
const [r, g, b] = categoryToRgb(cat);
|
||||||
|
return [r, g, b, 210] as [number, number, number, number];
|
||||||
|
},
|
||||||
|
onHover: (info: PickingInfo) => {
|
||||||
|
if (info.object) {
|
||||||
|
hoveredSensitiveRef.current = (info.object as { properties: Record<string, unknown> | null }).properties ?? {}
|
||||||
|
} else {
|
||||||
|
hoveredSensitiveRef.current = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}) as unknown as DeckLayer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- 입자 중심점 이동 경로 (모델별 PathLayer + ScatterplotLayer) ---
|
// --- 입자 중심점 이동 경로 (모델별 PathLayer + ScatterplotLayer) ---
|
||||||
const visibleCenters = centerPoints.filter(p => p.time <= currentTime)
|
const visibleCenters = centerPoints.filter(p => p.time <= currentTime)
|
||||||
if (visibleCenters.length > 0) {
|
if (visibleCenters.length > 0) {
|
||||||
@ -1299,53 +1327,14 @@ export function MapView({
|
|||||||
// 거리/면적 측정 레이어
|
// 거리/면적 측정 레이어
|
||||||
result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements))
|
result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements))
|
||||||
|
|
||||||
// --- 오염분석 공간 쿼리 결과 (어장 등 GIS 레이어) ---
|
|
||||||
// [추후 확장] 다중 테이블 시 _tableName별 색상 분기: TABLE_COLORS[f.properties._tableName]
|
|
||||||
if (spatialQueryResult && spatialQueryResult.features.length > 0) {
|
|
||||||
result.push(
|
|
||||||
new GeoJsonLayer({
|
|
||||||
id: 'spatial-query-result',
|
|
||||||
data: {
|
|
||||||
...spatialQueryResult,
|
|
||||||
features: spatialQueryResult.features.filter(f => f.geometry != null),
|
|
||||||
} as unknown as object,
|
|
||||||
filled: true,
|
|
||||||
stroked: true,
|
|
||||||
getFillColor: [34, 197, 94, 55],
|
|
||||||
getLineColor: [34, 197, 94, 200],
|
|
||||||
getLineWidth: 2,
|
|
||||||
lineWidthMinPixels: 1,
|
|
||||||
lineWidthMaxPixels: 3,
|
|
||||||
pickable: true,
|
|
||||||
autoHighlight: true,
|
|
||||||
highlightColor: [34, 197, 94, 120],
|
|
||||||
// 클릭은 handleMapClick에서 처리 (deck.gl onClick vs MapLibre click 이벤트 순서 충돌 회피)
|
|
||||||
onHover: (info: PickingInfo) => {
|
|
||||||
if (info.object && info.coordinate) {
|
|
||||||
hoveredGeoLayerRef.current = {
|
|
||||||
props: info.object.properties as FshfrmProperties,
|
|
||||||
coord: [info.coordinate[0], info.coordinate[1]],
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
hoveredGeoLayerRef.current = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
updateTriggers: {
|
|
||||||
data: [spatialQueryResult],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.filter(Boolean)
|
return result.filter(Boolean)
|
||||||
}, [
|
}, [
|
||||||
oilTrajectory, currentTime, selectedModels,
|
oilTrajectory, currentTime, selectedModels,
|
||||||
boomLines, isDrawingBoom, drawingPoints,
|
boomLines, isDrawingBoom, drawingPoints,
|
||||||
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
|
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
|
||||||
sensitiveResources, centerPoints, windData,
|
sensitiveResources, sensitiveResourceGeojson, centerPoints, windData,
|
||||||
showWind, showBeached, showTimeLabel, simulationStartTime,
|
showWind, showBeached, showTimeLabel, simulationStartTime,
|
||||||
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode,
|
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode,
|
||||||
spatialQueryResult,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
// 3D 모드 / 밝은 톤에 따른 지도 스타일 전환
|
// 3D 모드 / 밝은 톤에 따른 지도 스타일 전환
|
||||||
|
|||||||
@ -23,6 +23,21 @@ export interface WeatherSnapshot {
|
|||||||
pressure: number;
|
pressure: number;
|
||||||
visibility: number;
|
visibility: number;
|
||||||
salinity: number;
|
salinity: number;
|
||||||
|
astronomy?: {
|
||||||
|
sunrise: string;
|
||||||
|
sunset: string;
|
||||||
|
moonrise: string;
|
||||||
|
moonset: string;
|
||||||
|
moonPhase: string;
|
||||||
|
tidalRange: number;
|
||||||
|
};
|
||||||
|
alert?: string;
|
||||||
|
forecast?: Array<{
|
||||||
|
time: string;
|
||||||
|
icon: string;
|
||||||
|
temperature: number;
|
||||||
|
windSpeed: number;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WeatherSnapshotStore {
|
interface WeatherSnapshotStore {
|
||||||
|
|||||||
@ -70,7 +70,8 @@ export function IncidentsLeftPanel({
|
|||||||
// Weather popup
|
// Weather popup
|
||||||
const [weatherPopupId, setWeatherPopupId] = useState<string | null>(null)
|
const [weatherPopupId, setWeatherPopupId] = useState<string | null>(null)
|
||||||
const [weatherPos, setWeatherPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 })
|
const [weatherPos, setWeatherPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 })
|
||||||
const [weatherInfo, setWeatherInfo] = useState<WeatherInfo | null>(null)
|
// undefined = 로딩 중, null = 데이터 없음, WeatherInfo = 데이터 있음
|
||||||
|
const [weatherInfo, setWeatherInfo] = useState<WeatherInfo | null | undefined>(undefined)
|
||||||
const weatherRef = useRef<HTMLDivElement>(null)
|
const weatherRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -79,7 +80,7 @@ export function IncidentsLeftPanel({
|
|||||||
fetchIncidentWeather(parseInt(weatherPopupId)).then((data) => {
|
fetchIncidentWeather(parseInt(weatherPopupId)).then((data) => {
|
||||||
if (!cancelled) setWeatherInfo(data)
|
if (!cancelled) setWeatherInfo(data)
|
||||||
})
|
})
|
||||||
return () => { cancelled = true; setWeatherInfo(null) }
|
return () => { cancelled = true; setWeatherInfo(undefined) }
|
||||||
}, [weatherPopupId]);
|
}, [weatherPopupId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -361,7 +362,7 @@ export function IncidentsLeftPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Weather Popup (fixed position) */}
|
{/* Weather Popup (fixed position) */}
|
||||||
{weatherPopupId && weatherInfo && (
|
{weatherPopupId && weatherInfo !== undefined && (
|
||||||
<WeatherPopup
|
<WeatherPopup
|
||||||
ref={weatherRef}
|
ref={weatherRef}
|
||||||
data={weatherInfo}
|
data={weatherInfo}
|
||||||
@ -412,10 +413,11 @@ function PgBtn({ label, active, disabled, onClick }: { label: string; active?: b
|
|||||||
WeatherPopup – 사고 위치 기상정보 팝업
|
WeatherPopup – 사고 위치 기상정보 팝업
|
||||||
════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════ */
|
||||||
const WeatherPopup = forwardRef<HTMLDivElement, {
|
const WeatherPopup = forwardRef<HTMLDivElement, {
|
||||||
data: WeatherInfo
|
data: WeatherInfo | null
|
||||||
position: { top: number; left: number }
|
position: { top: number; left: number }
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}>(({ data, position, onClose }, ref) => {
|
}>(({ data, position, onClose }, ref) => {
|
||||||
|
const forecast = data?.forecast ?? []
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="fixed overflow-hidden rounded-xl border border-border bg-bg-1" style={{
|
<div ref={ref} className="fixed overflow-hidden rounded-xl border border-border bg-bg-1" style={{
|
||||||
zIndex: 9990, width: 280,
|
zIndex: 9990, width: 280,
|
||||||
@ -429,8 +431,8 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm">🌤</span>
|
<span className="text-sm">🌤</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] font-bold">{data.locNm}</div>
|
<div className="text-[11px] font-bold">{data?.locNm || '기상정보 없음'}</div>
|
||||||
<div className="text-text-3 font-mono text-[8px]">{data.obsDtm}</div>
|
<div className="text-text-3 font-mono text-[8px]">{data?.obsDtm || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span onClick={onClose} className="cursor-pointer text-text-3 text-sm p-0.5">✕</span>
|
<span onClick={onClose} className="cursor-pointer text-text-3 text-sm p-0.5">✕</span>
|
||||||
@ -440,21 +442,21 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
|||||||
<div className="px-3.5 py-3">
|
<div className="px-3.5 py-3">
|
||||||
{/* Main weather */}
|
{/* Main weather */}
|
||||||
<div className="flex items-center gap-3 mb-2.5">
|
<div className="flex items-center gap-3 mb-2.5">
|
||||||
<div className="text-[28px]">{data.icon}</div>
|
<div className="text-[28px]">{data?.icon || '❓'}</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-bold font-mono text-[20px]">{data.temp}</div>
|
<div className="font-bold font-mono text-[20px]">{data?.temp || '-'}</div>
|
||||||
<div className="text-text-3 text-[9px]">{data.weatherDc}</div>
|
<div className="text-text-3 text-[9px]">{data?.weatherDc || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail grid */}
|
{/* Detail grid */}
|
||||||
<div className="grid grid-cols-2 gap-1.5 text-[9px]">
|
<div className="grid grid-cols-2 gap-1.5 text-[9px]">
|
||||||
<WxCell icon="💨" label="풍향/풍속" value={data.wind} />
|
<WxCell icon="💨" label="풍향/풍속" value={data?.wind} />
|
||||||
<WxCell icon="🌊" label="파고" value={data.wave} />
|
<WxCell icon="🌊" label="파고" value={data?.wave} />
|
||||||
<WxCell icon="💧" label="습도" value={data.humid} />
|
<WxCell icon="💧" label="습도" value={data?.humid} />
|
||||||
<WxCell icon="👁" label="시정" value={data.vis} />
|
<WxCell icon="👁" label="시정" value={data?.vis} />
|
||||||
<WxCell icon="🌡" label="수온" value={data.sst} />
|
<WxCell icon="🌡" label="수온" value={data?.sst} />
|
||||||
<WxCell icon="🔄" label="조류" value={data.tide} />
|
<WxCell icon="🔄" label="조류" value={data?.tide} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tide info */}
|
{/* Tide info */}
|
||||||
@ -464,7 +466,7 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
|||||||
<span className="text-xs">⬆</span>
|
<span className="text-xs">⬆</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-text-3 text-[7px]">고조 (만조)</div>
|
<div className="text-text-3 text-[7px]">고조 (만조)</div>
|
||||||
<div className="font-bold font-mono text-[10px] text-[#60a5fa]">{data.highTide}</div>
|
<div className="font-bold font-mono text-[10px] text-[#60a5fa]">{data?.highTide || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex items-center gap-1.5 px-2 py-1.5 rounded-md"
|
<div className="flex-1 flex items-center gap-1.5 px-2 py-1.5 rounded-md"
|
||||||
@ -472,7 +474,7 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
|||||||
<span className="text-xs">⬇</span>
|
<span className="text-xs">⬇</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-text-3 text-[7px]">저조 (간조)</div>
|
<div className="text-text-3 text-[7px]">저조 (간조)</div>
|
||||||
<div className="text-primary-cyan font-bold font-mono text-[10px]">{data.lowTide}</div>
|
<div className="text-primary-cyan font-bold font-mono text-[10px]">{data?.lowTide || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -480,15 +482,19 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
|||||||
{/* 24h Forecast */}
|
{/* 24h Forecast */}
|
||||||
<div className="bg-bg-0 mt-2.5 px-2.5 py-2 rounded-md">
|
<div className="bg-bg-0 mt-2.5 px-2.5 py-2 rounded-md">
|
||||||
<div className="font-bold text-text-3 text-[8px] mb-1.5">24h 예보</div>
|
<div className="font-bold text-text-3 text-[8px] mb-1.5">24h 예보</div>
|
||||||
<div className="flex justify-between font-mono text-text-2 text-[8px]">
|
{forecast.length > 0 ? (
|
||||||
{data.forecast.map((f, i) => (
|
<div className="flex justify-between font-mono text-text-2 text-[8px]">
|
||||||
<div key={i} className="text-center">
|
{forecast.map((f, i) => (
|
||||||
<div>{f.hour}</div>
|
<div key={i} className="text-center">
|
||||||
<div className="text-xs my-0.5">{f.icon}</div>
|
<div>{f.hour}</div>
|
||||||
<div className="font-semibold">{f.temp}</div>
|
<div className="text-xs my-0.5">{f.icon}</div>
|
||||||
</div>
|
<div className="font-semibold">{f.temp}</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-text-3 text-center text-[8px] py-1">예보 데이터 없음</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Impact */}
|
{/* Impact */}
|
||||||
@ -497,7 +503,7 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
|||||||
background: 'rgba(249,115,22,0.05)', border: '1px solid rgba(249,115,22,0.12)',
|
background: 'rgba(249,115,22,0.05)', border: '1px solid rgba(249,115,22,0.12)',
|
||||||
}}>
|
}}>
|
||||||
<div className="font-bold text-status-orange text-[8px] mb-[3px]">⚠ 방제 작업 영향</div>
|
<div className="font-bold text-status-orange text-[8px] mb-[3px]">⚠ 방제 작업 영향</div>
|
||||||
<div className="text-text-2 text-[8px] leading-[1.5]">{data.impactDc}</div>
|
<div className="text-text-2 text-[8px] leading-[1.5]">{data?.impactDc || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -505,13 +511,13 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
|||||||
})
|
})
|
||||||
WeatherPopup.displayName = 'WeatherPopup'
|
WeatherPopup.displayName = 'WeatherPopup'
|
||||||
|
|
||||||
function WxCell({ icon, label, value }: { icon: string; label: string; value: string }) {
|
function WxCell({ icon, label, value }: { icon: string; label: string; value?: string | null }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center bg-bg-0 rounded gap-[6px] py-1.5 px-2">
|
<div className="flex items-center bg-bg-0 rounded gap-[6px] py-1.5 px-2">
|
||||||
<span className="text-[12px]">{icon}</span>
|
<span className="text-[12px]">{icon}</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-text-3 text-[7px]">{label}</div>
|
<div className="text-text-3 text-[7px]">{label}</div>
|
||||||
<div className="font-semibold font-mono">{value}</div>
|
<div className="font-semibold font-mono">{value || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -50,6 +50,7 @@ export function LeftPanel({
|
|||||||
onLayerOpacityChange,
|
onLayerOpacityChange,
|
||||||
layerBrightness,
|
layerBrightness,
|
||||||
onLayerBrightnessChange,
|
onLayerBrightnessChange,
|
||||||
|
sensitiveResources = [],
|
||||||
onImageAnalysisResult,
|
onImageAnalysisResult,
|
||||||
}: LeftPanelProps) {
|
}: LeftPanelProps) {
|
||||||
const [expandedSections, setExpandedSections] = useState<ExpandedSections>({
|
const [expandedSections, setExpandedSections] = useState<ExpandedSections>({
|
||||||
@ -160,7 +161,7 @@ export function LeftPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고일시</span>
|
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고일시</span>
|
||||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16) : '—'}</span>
|
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16).replace(' ', 'T') : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유종</span>
|
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유종</span>
|
||||||
@ -204,7 +205,18 @@ export function LeftPanel({
|
|||||||
|
|
||||||
{expandedSections.impactResources && (
|
{expandedSections.impactResources && (
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<p className="text-[11px] text-text-3">영향받는 민감자원 목록</p>
|
{sensitiveResources.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-text-3 font-korean">영향받는 민감자원 목록</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{sensitiveResources.map(({ category, count }) => (
|
||||||
|
<div key={category} className="flex items-center justify-between">
|
||||||
|
<span className="text-[11px] text-text-2 font-korean">{category}</span>
|
||||||
|
<span className="text-[11px] text-primary font-bold font-mono">{count}개</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,18 +14,23 @@ import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore'
|
|||||||
import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine'
|
import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine'
|
||||||
import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||||
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
|
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
|
||||||
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory } from '../services/predictionApi'
|
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory, fetchSensitiveResources, fetchSensitiveResourcesGeojson } from '../services/predictionApi'
|
||||||
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, WindPoint } from '../services/predictionApi'
|
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, SensitiveResourceCategory, SensitiveResourceFeatureCollection, WindPoint } from '../services/predictionApi'
|
||||||
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
|
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
|
||||||
import SimulationErrorModal from './SimulationErrorModal'
|
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'
|
||||||
|
|
||||||
|
const toLocalDateTimeStr = (raw: string): string => {
|
||||||
|
const d = new Date(raw)
|
||||||
|
if (isNaN(d.getTime())) return ''
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 민감자원 타입 + 데모 데이터
|
// 민감자원 타입 + 데모 데이터
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -40,20 +45,13 @@ export interface SensitiveResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DisplayControls {
|
export interface DisplayControls {
|
||||||
showCurrent: boolean; // 유향/유속
|
showCurrent: boolean; // 유향/유속
|
||||||
showWind: boolean; // 풍향/풍속
|
showWind: boolean; // 풍향/풍속
|
||||||
showBeached: boolean; // 해안부착
|
showBeached: boolean; // 해안부착
|
||||||
showTimeLabel: boolean; // 시간 표시
|
showTimeLabel: boolean; // 시간 표시
|
||||||
|
showSensitiveResources: boolean; // 민감자원
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEMO_SENSITIVE_RESOURCES: SensitiveResource[] = [
|
|
||||||
{ id: 'bc-1', name: '종포 해수욕장', type: 'beach', lat: 34.728, lon: 127.679, radiusM: 350, arrivalTimeH: 1 },
|
|
||||||
{ id: 'aq-1', name: '국동 전복 양식장', type: 'aquaculture', lat: 34.718, lon: 127.672, radiusM: 500, arrivalTimeH: 3 },
|
|
||||||
{ id: 'ec-1', name: '여자만 습지보호구역', type: 'ecology', lat: 34.758, lon: 127.614, radiusM: 1200, arrivalTimeH: 6 },
|
|
||||||
{ id: 'aq-2', name: '화태도 김 양식장', type: 'aquaculture', lat: 34.648, lon: 127.652, radiusM: 800, arrivalTimeH: 10 },
|
|
||||||
{ id: 'aq-3', name: '개도 해안 양식장', type: 'aquaculture', lat: 34.612, lon: 127.636, radiusM: 600, arrivalTimeH: 18 },
|
|
||||||
]
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 데모 궤적 생성 (seeded PRNG — deterministic)
|
// 데모 궤적 생성 (seeded PRNG — deterministic)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -141,6 +139,8 @@ export function OilSpillView() {
|
|||||||
|
|
||||||
// 민감자원
|
// 민감자원
|
||||||
const [sensitiveResources, setSensitiveResources] = useState<SensitiveResource[]>([])
|
const [sensitiveResources, setSensitiveResources] = useState<SensitiveResource[]>([])
|
||||||
|
const [sensitiveResourceCategories, setSensitiveResourceCategories] = useState<SensitiveResourceCategory[]>([])
|
||||||
|
const [sensitiveResourceGeojson, setSensitiveResourceGeojson] = useState<SensitiveResourceFeatureCollection | null>(null)
|
||||||
|
|
||||||
// 오일펜스 배치 상태
|
// 오일펜스 배치 상태
|
||||||
const [boomLines, setBoomLines] = useState<BoomLine[]>([])
|
const [boomLines, setBoomLines] = useState<BoomLine[]>([])
|
||||||
@ -164,6 +164,7 @@ export function OilSpillView() {
|
|||||||
showWind: false,
|
showWind: false,
|
||||||
showBeached: false,
|
showBeached: false,
|
||||||
showTimeLabel: false,
|
showTimeLabel: false,
|
||||||
|
showSensitiveResources: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 타임라인 플레이어 상태
|
// 타임라인 플레이어 상태
|
||||||
@ -205,8 +206,6 @@ 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
|
||||||
@ -221,7 +220,7 @@ export function OilSpillView() {
|
|||||||
setOilTrajectory(demoTrajectory)
|
setOilTrajectory(demoTrajectory)
|
||||||
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
|
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
|
||||||
setBoomLines(demoBooms)
|
setBoomLines(demoBooms)
|
||||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
setSensitiveResources([])
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeSubTab])
|
}, [activeSubTab])
|
||||||
@ -473,7 +472,7 @@ export function OilSpillView() {
|
|||||||
setSelectedAnalysis(analysis)
|
setSelectedAnalysis(analysis)
|
||||||
setCenterPoints([])
|
setCenterPoints([])
|
||||||
if (analysis.occurredAt) {
|
if (analysis.occurredAt) {
|
||||||
setAccidentTime(analysis.occurredAt.slice(0, 16))
|
setAccidentTime(toLocalDateTimeStr(analysis.occurredAt))
|
||||||
}
|
}
|
||||||
if (analysis.lon != null && analysis.lat != null) {
|
if (analysis.lon != null && analysis.lat != null) {
|
||||||
setIncidentCoord({ lon: analysis.lon, lat: analysis.lat })
|
setIncidentCoord({ lon: analysis.lon, lat: analysis.lat })
|
||||||
@ -523,7 +522,13 @@ export function OilSpillView() {
|
|||||||
if (sbModel) setSummaryByModel(sbModel);
|
if (sbModel) setSummaryByModel(sbModel);
|
||||||
if (stepSbModel) setStepSummariesByModel(stepSbModel);
|
if (stepSbModel) setStepSummariesByModel(stepSbModel);
|
||||||
if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings))
|
if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings))
|
||||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
setSensitiveResources([])
|
||||||
|
fetchSensitiveResources(analysis.acdntSn)
|
||||||
|
.then(setSensitiveResourceCategories)
|
||||||
|
.catch(err => console.warn('[prediction] 민감자원 조회 실패:', err))
|
||||||
|
fetchSensitiveResourcesGeojson(analysis.acdntSn)
|
||||||
|
.then(setSensitiveResourceGeojson)
|
||||||
|
.catch(err => console.warn('[prediction] 민감자원 GeoJSON 조회 실패:', err))
|
||||||
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
||||||
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
||||||
pendingPlayRef.current = true
|
pendingPlayRef.current = true
|
||||||
@ -545,7 +550,8 @@ export function OilSpillView() {
|
|||||||
const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48)
|
const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48)
|
||||||
setOilTrajectory(demoTrajectory)
|
setOilTrajectory(demoTrajectory)
|
||||||
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings))
|
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings))
|
||||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
setSensitiveResources([])
|
||||||
|
setSensitiveResourceCategories([])
|
||||||
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
||||||
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
||||||
pendingPlayRef.current = true
|
pendingPlayRef.current = true
|
||||||
@ -559,7 +565,7 @@ export function OilSpillView() {
|
|||||||
setDrawingPoints(prev => [...prev, { lat, lon }])
|
setDrawingPoints(prev => [...prev, { lat, lon }])
|
||||||
} else if (drawAnalysisMode === 'polygon') {
|
} else if (drawAnalysisMode === 'polygon') {
|
||||||
setAnalysisPolygonPoints(prev => [...prev, { lat, lon }])
|
setAnalysisPolygonPoints(prev => [...prev, { lat, lon }])
|
||||||
} else {
|
} else if (isSelectingLocation) {
|
||||||
setIncidentCoord({ lon, lat })
|
setIncidentCoord({ lon, lat })
|
||||||
setIsSelectingLocation(false)
|
setIsSelectingLocation(false)
|
||||||
}
|
}
|
||||||
@ -584,22 +590,6 @@ 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 = async () => {
|
const handleRunCircleAnalysis = async () => {
|
||||||
@ -619,23 +609,6 @@ 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 = () => {
|
||||||
@ -647,13 +620,12 @@ 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) => {
|
||||||
setIncidentCoord({ lat: result.lat, lon: result.lon })
|
setIncidentCoord({ lat: result.lat, lon: result.lon })
|
||||||
setFlyToCoord({ lat: result.lat, lon: result.lon })
|
setFlyToCoord({ lat: result.lat, lon: result.lon })
|
||||||
setAccidentTime(result.occurredAt.slice(0, 16))
|
setAccidentTime(toLocalDateTimeStr(result.occurredAt))
|
||||||
setOilType(result.oilType)
|
setOilType(result.oilType)
|
||||||
setSpillAmount(parseFloat(result.volume.toFixed(4)))
|
setSpillAmount(parseFloat(result.volume.toFixed(4)))
|
||||||
setSpillUnit('kL')
|
setSpillUnit('kL')
|
||||||
@ -833,7 +805,7 @@ export function OilSpillView() {
|
|||||||
setStepSummariesByModel(newStepSummariesByModel);
|
setStepSummariesByModel(newStepSummariesByModel);
|
||||||
const booms = generateAIBoomLines(merged, effectiveCoord, algorithmSettings);
|
const booms = generateAIBoomLines(merged, effectiveCoord, algorithmSettings);
|
||||||
setBoomLines(booms);
|
setBoomLines(booms);
|
||||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES);
|
setSensitiveResources([]);
|
||||||
setCurrentStep(0);
|
setCurrentStep(0);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
setFlyToCoord({ lon: effectiveCoord.lon, lat: effectiveCoord.lat });
|
setFlyToCoord({ lon: effectiveCoord.lon, lat: effectiveCoord.lat });
|
||||||
@ -843,11 +815,26 @@ export function OilSpillView() {
|
|||||||
setSimulationError(errors.join('; '));
|
setSimulationError(errors.join('; '));
|
||||||
} else {
|
} else {
|
||||||
simulationSucceeded = true;
|
simulationSucceeded = true;
|
||||||
|
const effectiveAcdntSn = data.acdntSn ?? selectedAnalysis?.acdntSn;
|
||||||
if (effectiveCoord) {
|
if (effectiveCoord) {
|
||||||
fetchWeatherSnapshotForCoord(effectiveCoord.lat, effectiveCoord.lon)
|
fetchWeatherSnapshotForCoord(effectiveCoord.lat, effectiveCoord.lon)
|
||||||
.then(snapshot => useWeatherSnapshotStore.getState().setSnapshot(snapshot))
|
.then(snapshot => {
|
||||||
|
useWeatherSnapshotStore.getState().setSnapshot(snapshot);
|
||||||
|
if (effectiveAcdntSn) {
|
||||||
|
api.post(`/incidents/${effectiveAcdntSn}/weather`, snapshot)
|
||||||
|
.catch(err => console.warn('[weather] 기상 저장 실패:', err));
|
||||||
|
}
|
||||||
|
})
|
||||||
.catch(err => console.warn('[weather] 기상 데이터 수집 실패:', err));
|
.catch(err => console.warn('[weather] 기상 데이터 수집 실패:', err));
|
||||||
}
|
}
|
||||||
|
if (effectiveAcdntSn) {
|
||||||
|
fetchSensitiveResources(effectiveAcdntSn)
|
||||||
|
.then(setSensitiveResourceCategories)
|
||||||
|
.catch(err => console.warn('[prediction] 민감자원 조회 실패:', err));
|
||||||
|
fetchSensitiveResourcesGeojson(effectiveAcdntSn)
|
||||||
|
.then(setSensitiveResourceGeojson)
|
||||||
|
.catch(err => console.warn('[prediction] 민감자원 GeoJSON 조회 실패:', err));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg =
|
const msg =
|
||||||
@ -1014,6 +1001,7 @@ export function OilSpillView() {
|
|||||||
onLayerOpacityChange={setLayerOpacity}
|
onLayerOpacityChange={setLayerOpacity}
|
||||||
layerBrightness={layerBrightness}
|
layerBrightness={layerBrightness}
|
||||||
onLayerBrightnessChange={setLayerBrightness}
|
onLayerBrightnessChange={setLayerBrightness}
|
||||||
|
sensitiveResources={sensitiveResourceCategories}
|
||||||
onImageAnalysisResult={handleImageAnalysisResult}
|
onImageAnalysisResult={handleImageAnalysisResult}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -1042,6 +1030,7 @@ export function OilSpillView() {
|
|||||||
layerOpacity={layerOpacity}
|
layerOpacity={layerOpacity}
|
||||||
layerBrightness={layerBrightness}
|
layerBrightness={layerBrightness}
|
||||||
sensitiveResources={sensitiveResources}
|
sensitiveResources={sensitiveResources}
|
||||||
|
sensitiveResourceGeojson={displayControls.showSensitiveResources ? sensitiveResourceGeojson : null}
|
||||||
lightMode
|
lightMode
|
||||||
centerPoints={centerPoints.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))}
|
centerPoints={centerPoints.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))}
|
||||||
windData={windData}
|
windData={windData}
|
||||||
@ -1067,7 +1056,6 @@ export function OilSpillView() {
|
|||||||
showBeached={displayControls.showBeached}
|
showBeached={displayControls.showBeached}
|
||||||
showTimeLabel={displayControls.showTimeLabel}
|
showTimeLabel={displayControls.showTimeLabel}
|
||||||
simulationStartTime={accidentTime || undefined}
|
simulationStartTime={accidentTime || undefined}
|
||||||
spatialQueryResult={spatialQueryResult}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 타임라인 플레이어 (리플레이 비활성 시) */}
|
{/* 타임라인 플레이어 (리플레이 비활성 시) */}
|
||||||
@ -1283,8 +1271,6 @@ export function OilSpillView() {
|
|||||||
onRunCircleAnalysis={handleRunCircleAnalysis}
|
onRunCircleAnalysis={handleRunCircleAnalysis}
|
||||||
onCancelAnalysis={handleCancelAnalysis}
|
onCancelAnalysis={handleCancelAnalysis}
|
||||||
onClearAnalysis={handleClearAnalysis}
|
onClearAnalysis={handleClearAnalysis}
|
||||||
spatialQueryResult={spatialQueryResult}
|
|
||||||
isSpatialQuerying={isSpatialQuerying}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -430,7 +430,9 @@ function DateTimeInput({ value, onChange }: { value: string; onChange: (v: strin
|
|||||||
|
|
||||||
const datePart = value ? value.split('T')[0] : ''
|
const datePart = value ? value.split('T')[0] : ''
|
||||||
const timePart = value && value.includes('T') ? value.split('T')[1] : '00:00'
|
const timePart = value && value.includes('T') ? value.split('T')[1] : '00:00'
|
||||||
const [hh, mm] = timePart.split(':').map(Number)
|
const timeParts = timePart.split(':').map(Number)
|
||||||
|
const hh = isNaN(timeParts[0]) ? 0 : timeParts[0]
|
||||||
|
const mm = (timeParts[1] === undefined || isNaN(timeParts[1])) ? 0 : timeParts[1]
|
||||||
|
|
||||||
const parsed = datePart ? new Date(datePart + 'T00:00:00') : new Date()
|
const parsed = datePart ? new Date(datePart + 'T00:00:00') : new Date()
|
||||||
const [viewYear, setViewYear] = useState(parsed.getFullYear())
|
const [viewYear, setViewYear] = useState(parsed.getFullYear())
|
||||||
@ -561,9 +563,15 @@ function DateTimeInput({ value, onChange }: { value: string; onChange: (v: strin
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setViewYear(todayY)
|
const now = new Date()
|
||||||
setViewMonth(todayM)
|
setViewYear(now.getFullYear())
|
||||||
pickDate(todayD)
|
setViewMonth(now.getMonth())
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
const d = String(now.getDate()).padStart(2, '0')
|
||||||
|
const hh = String(now.getHours()).padStart(2, '0')
|
||||||
|
const mm = String(now.getMinutes()).padStart(2, '0')
|
||||||
|
onChange(`${now.getFullYear()}-${m}-${d}T${hh}:${mm}`)
|
||||||
|
setShowCal(false)
|
||||||
}}
|
}}
|
||||||
className="w-full text-[8px] font-korean font-semibold cursor-pointer rounded-sm"
|
className="w-full text-[8px] font-korean font-semibold cursor-pointer rounded-sm"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
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
|
||||||
@ -35,8 +34,6 @@ 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({
|
||||||
@ -49,7 +46,6 @@ 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]
|
||||||
@ -85,7 +81,10 @@ export function RightPanel({
|
|||||||
checked={displayControls?.showBeached ?? false}
|
checked={displayControls?.showBeached ?? false}
|
||||||
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showBeached: v })}
|
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showBeached: v })}
|
||||||
>해안부착</ControlledCheckbox>
|
>해안부착</ControlledCheckbox>
|
||||||
<ControlledCheckbox checked={false} onChange={() => {}} disabled>
|
<ControlledCheckbox
|
||||||
|
checked={displayControls?.showSensitiveResources ?? false}
|
||||||
|
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showSensitiveResources: v })}
|
||||||
|
>
|
||||||
민감자원
|
민감자원
|
||||||
</ControlledCheckbox>
|
</ControlledCheckbox>
|
||||||
<ControlledCheckbox
|
<ControlledCheckbox
|
||||||
@ -221,70 +220,6 @@ 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>
|
||||||
|
|
||||||
{/* 오염 종합 상황 */}
|
{/* 오염 종합 상황 */}
|
||||||
@ -722,13 +657,13 @@ function PollResult({
|
|||||||
{summary && (
|
{summary && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-text-3">해상잔존량</span>
|
<span className="text-text-3">해상잔존량</span>
|
||||||
<span className="font-semibold font-mono" style={{ color: 'var(--blue)' }}>{summary.remainingVolume.toFixed(2)} kL</span>
|
<span className="font-semibold font-mono" style={{ color: 'var(--blue)' }}>{summary.remainingVolume.toFixed(2)} m³</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{summary && (
|
{summary && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-text-3">연안부착량</span>
|
<span className="text-text-3">연안부착량</span>
|
||||||
<span className="font-semibold font-mono" style={{ color: 'var(--red)' }}>{summary.beachedVolume.toFixed(2)} kL</span>
|
<span className="font-semibold font-mono" style={{ color: 'var(--red)' }}>{summary.beachedVolume.toFixed(2)} m³</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import type { PredictionModel } from './OilSpillView'
|
import type { PredictionModel } from './OilSpillView'
|
||||||
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
|
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
|
||||||
import type { Analysis } from './AnalysisListTable'
|
import type { Analysis } from './AnalysisListTable'
|
||||||
import type { ImageAnalyzeResult } from '../services/predictionApi'
|
import type { ImageAnalyzeResult, SensitiveResourceCategory } from '../services/predictionApi'
|
||||||
|
|
||||||
export interface LeftPanelProps {
|
export interface LeftPanelProps {
|
||||||
selectedAnalysis?: Analysis | null
|
selectedAnalysis?: Analysis | null
|
||||||
@ -49,6 +49,8 @@ export interface LeftPanelProps {
|
|||||||
onLayerOpacityChange: (val: number) => void
|
onLayerOpacityChange: (val: number) => void
|
||||||
layerBrightness: number
|
layerBrightness: number
|
||||||
onLayerBrightnessChange: (val: number) => void
|
onLayerBrightnessChange: (val: number) => void
|
||||||
|
// 영향 민감자원
|
||||||
|
sensitiveResources?: SensitiveResourceCategory[]
|
||||||
// 이미지 분석 결과 콜백
|
// 이미지 분석 결과 콜백
|
||||||
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void
|
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,88 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -219,6 +219,44 @@ export const fetchAnalysisTrajectory = async (acdntSn: number): Promise<Trajecto
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface SensitiveResourceCategory {
|
||||||
|
category: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchSensitiveResources = async (
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<SensitiveResourceCategory[]> => {
|
||||||
|
const response = await api.get<SensitiveResourceCategory[]>(
|
||||||
|
`/prediction/analyses/${acdntSn}/sensitive-resources`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SensitiveResourceFeature {
|
||||||
|
type: 'Feature';
|
||||||
|
geometry: { type: string; coordinates: unknown };
|
||||||
|
properties: {
|
||||||
|
srId: number;
|
||||||
|
category: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SensitiveResourceFeatureCollection {
|
||||||
|
type: 'FeatureCollection';
|
||||||
|
features: SensitiveResourceFeature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchSensitiveResourcesGeojson = async (
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<SensitiveResourceFeatureCollection> => {
|
||||||
|
const response = await api.get<SensitiveResourceFeatureCollection>(
|
||||||
|
`/prediction/analyses/${acdntSn}/sensitive-resources/geojson`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 이미지 업로드 분석
|
// 이미지 업로드 분석
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -75,7 +75,6 @@ export interface ApiReportSectionData {
|
|||||||
export interface ApiReportDetail extends ApiReportListItem {
|
export interface ApiReportDetail extends ApiReportListItem {
|
||||||
acdntSn: number | null;
|
acdntSn: number | null;
|
||||||
sections: ApiReportSectionData[];
|
sections: ApiReportSectionData[];
|
||||||
mapCaptureImg?: string | null;
|
|
||||||
step3MapImg?: string | null;
|
step3MapImg?: string | null;
|
||||||
step6MapImg?: string | null;
|
step6MapImg?: string | null;
|
||||||
}
|
}
|
||||||
@ -180,7 +179,6 @@ export async function createReportApi(input: {
|
|||||||
title: string;
|
title: string;
|
||||||
jrsdCd?: string;
|
jrsdCd?: string;
|
||||||
sttsCd?: string;
|
sttsCd?: string;
|
||||||
mapCaptureImg?: string;
|
|
||||||
step3MapImg?: string;
|
step3MapImg?: string;
|
||||||
step6MapImg?: string;
|
step6MapImg?: string;
|
||||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||||
@ -194,7 +192,6 @@ export async function updateReportApi(sn: number, input: {
|
|||||||
jrsdCd?: string;
|
jrsdCd?: string;
|
||||||
sttsCd?: string;
|
sttsCd?: string;
|
||||||
acdntSn?: number | null;
|
acdntSn?: number | null;
|
||||||
mapCaptureImg?: string | null;
|
|
||||||
step3MapImg?: string | null;
|
step3MapImg?: string | null;
|
||||||
step6MapImg?: string | null;
|
step6MapImg?: string | null;
|
||||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||||
@ -249,7 +246,6 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
|
|||||||
title: data.title || data.incident.name || '보고서',
|
title: data.title || data.incident.name || '보고서',
|
||||||
jrsdCd: data.jurisdiction,
|
jrsdCd: data.jurisdiction,
|
||||||
sttsCd,
|
sttsCd,
|
||||||
mapCaptureImg: data.capturedMapImage !== undefined ? (data.capturedMapImage || null) : undefined,
|
|
||||||
step3MapImg: data.step3MapImage !== undefined ? (data.step3MapImage || null) : undefined,
|
step3MapImg: data.step3MapImage !== undefined ? (data.step3MapImage || null) : undefined,
|
||||||
step6MapImg: data.step6MapImage !== undefined ? (data.step6MapImage || null) : undefined,
|
step6MapImg: data.step6MapImage !== undefined ? (data.step6MapImage || null) : undefined,
|
||||||
sections,
|
sections,
|
||||||
@ -263,7 +259,6 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
|
|||||||
title: data.title || data.incident.name || '보고서',
|
title: data.title || data.incident.name || '보고서',
|
||||||
jrsdCd: data.jurisdiction,
|
jrsdCd: data.jurisdiction,
|
||||||
sttsCd,
|
sttsCd,
|
||||||
mapCaptureImg: data.capturedMapImage || undefined,
|
|
||||||
step3MapImg: data.step3MapImage || undefined,
|
step3MapImg: data.step3MapImage || undefined,
|
||||||
step6MapImg: data.step6MapImage || undefined,
|
step6MapImg: data.step6MapImage || undefined,
|
||||||
sections,
|
sections,
|
||||||
@ -360,9 +355,6 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa
|
|||||||
`위도 ${parseFloat(reportData.incident.lat).toFixed(4)}, 경도 ${parseFloat(reportData.incident.lon).toFixed(4)}`;
|
`위도 ${parseFloat(reportData.incident.lat).toFixed(4)}, 경도 ${parseFloat(reportData.incident.lon).toFixed(4)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detail.mapCaptureImg) {
|
|
||||||
reportData.capturedMapImage = detail.mapCaptureImg;
|
|
||||||
}
|
|
||||||
if (detail.step3MapImg) {
|
if (detail.step3MapImg) {
|
||||||
reportData.step3MapImage = detail.step3MapImg;
|
reportData.step3MapImage = detail.step3MapImg;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,13 @@ export async function fetchWeatherSnapshotForCoord(
|
|||||||
const obsCode = OBS_STATION_CODES[nearest.id];
|
const obsCode = OBS_STATION_CODES[nearest.id];
|
||||||
const obs = obsCode ? await getRecentObservation(obsCode) : null;
|
const obs = obsCode ? await getRecentObservation(obsCode) : null;
|
||||||
|
|
||||||
|
const windIcon = (spd: number) => spd > 12 ? '🌧️' : spd > 8 ? '🌦️' : spd > 5 ? '⛅' : '☀️';
|
||||||
|
const mockAstronomy = {
|
||||||
|
sunrise: '07:12', sunset: '17:58',
|
||||||
|
moonrise: '19:35', moonset: '01:50',
|
||||||
|
moonPhase: '상현달 14일', tidalRange: 6.7,
|
||||||
|
};
|
||||||
|
|
||||||
if (obs) {
|
if (obs) {
|
||||||
const windSpeed = r(obs.wind_speed ?? 8.0);
|
const windSpeed = r(obs.wind_speed ?? 8.0);
|
||||||
const windDir = obs.wind_dir ?? 315;
|
const windDir = obs.wind_dir ?? 315;
|
||||||
@ -64,6 +71,14 @@ export async function fetchWeatherSnapshotForCoord(
|
|||||||
pressure,
|
pressure,
|
||||||
visibility: pressure > 1010 ? 15 : 10,
|
visibility: pressure > 1010 ? 15 : 10,
|
||||||
salinity: 31.2,
|
salinity: 31.2,
|
||||||
|
forecast: [0, 3, 6, 9, 12].map((h, i) => ({
|
||||||
|
time: `${h}시`,
|
||||||
|
icon: windIcon(windSpeed + (i * 0.3 - 0.3)),
|
||||||
|
temperature: r(airTemp - i * 0.3),
|
||||||
|
windSpeed: r(windSpeed + (i * 0.2 - 0.2)),
|
||||||
|
})),
|
||||||
|
astronomy: mockAstronomy,
|
||||||
|
alert: windSpeed > 14 ? '풍랑주의보 예상' : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,5 +109,13 @@ export async function fetchWeatherSnapshotForCoord(
|
|||||||
pressure: 1010 + (Math.floor(seed) % 12),
|
pressure: 1010 + (Math.floor(seed) % 12),
|
||||||
visibility: 12 + (Math.floor(seed) % 10),
|
visibility: 12 + (Math.floor(seed) % 10),
|
||||||
salinity: 31.2,
|
salinity: 31.2,
|
||||||
|
forecast: [0, 3, 6, 9, 12].map((h, i) => ({
|
||||||
|
time: `${h}시`,
|
||||||
|
icon: windIcon(windSpeed + (i * 0.3 - 0.3)),
|
||||||
|
temperature: r(temp - i * 0.3),
|
||||||
|
windSpeed: r(windSpeed + (i * 0.2 - 0.2)),
|
||||||
|
})),
|
||||||
|
astronomy: mockAstronomy,
|
||||||
|
alert: windSpeed > 14 ? '풍랑주의보 예상' : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user