diff --git a/.claude/settings.json b/.claude/settings.json index 4224c81..868df2d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -83,7 +83,5 @@ ] } ] - }, - "deny": [], - "allow": [] -} \ No newline at end of file + } +} diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index 2086822..f746918 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,7 +1,7 @@ { "applied_global_version": "1.6.1", - "applied_date": "2026-03-20", + "applied_date": "2026-03-22", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true -} \ No newline at end of file +} diff --git a/backend/src/incidents/incidentsRouter.ts b/backend/src/incidents/incidentsRouter.ts index a27c82d..abb09d6 100644 --- a/backend/src/incidents/incidentsRouter.ts +++ b/backend/src/incidents/incidentsRouter.ts @@ -5,6 +5,7 @@ import { getIncident, listIncidentPredictions, getIncidentWeather, + saveIncidentWeather, getIncidentMedia, } 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); + res.json({ weatherSn }); + } catch (err) { + console.error('[incidents] 기상정보 저장 오류:', err); + res.status(500).json({ error: '기상정보 저장 중 오류가 발생했습니다.' }); + } +}); + // ============================================================ // GET /api/incidents/:sn/media — 미디어 정보 // ============================================================ diff --git a/backend/src/incidents/incidentsService.ts b/backend/src/incidents/incidentsService.ts index 8a23355..104fee0 100644 --- a/backend/src/incidents/incidentsService.ts +++ b/backend/src/incidents/incidentsService.ts @@ -254,24 +254,143 @@ export async function getIncidentWeather(acdntSn: number): Promise; return { - locNm: r.loc_nm as string, - obsDtm: (r.obs_dtm as Date).toISOString(), - icon: r.icon as string, - temp: r.temp as string, - weatherDc: r.weather_dc as string, - wind: r.wind as string, - wave: r.wave as string, - humid: r.humid as string, - vis: r.vis as string, - sst: r.sst as string, - tide: r.tide as string, - highTide: r.high_tide as string, - lowTide: r.low_tide as string, + locNm: (r.loc_nm as string | null) ?? '-', + obsDtm: r.obs_dtm ? (r.obs_dtm as Date).toISOString() : '-', + icon: (r.icon as string | null) ?? '', + temp: (r.temp as string | null) ?? '-', + weatherDc: (r.weather_dc as string | null) ?? '-', + wind: (r.wind as string | null) ?? '-', + wave: (r.wave as string | null) ?? '-', + humid: (r.humid as string | null) ?? '-', + vis: (r.vis as string | null) ?? '-', + sst: (r.sst as string | null) ?? '-', + tide: (r.tide as string | null) ?? '-', + highTide: (r.high_tide as string | null) ?? '-', + lowTide: (r.low_tide as string | null) ?? '-', 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 { + // 팝업 표시용 포맷 문자열 + 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).weather_sn as number; +} + // ============================================================ // 미디어 정보 조회 // ============================================================ diff --git a/backend/src/prediction/predictionRouter.ts b/backend/src/prediction/predictionRouter.ts index d017a19..0f91c93 100644 --- a/backend/src/prediction/predictionRouter.ts +++ b/backend/src/prediction/predictionRouter.ts @@ -3,6 +3,7 @@ import multer from 'multer'; import { listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt, createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory, + getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn, } from './predictionService.js'; import { analyzeImageFile } from './imageAnalyzeService.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 — 사고별 역추적 목록 router.get('/backtrack', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => { try { diff --git a/backend/src/prediction/predictionService.ts b/backend/src/prediction/predictionService.ts index 6d5df40..1af73aa 100644 --- a/backend/src/prediction/predictionService.ts +++ b/backend/src/prediction/predictionService.ts @@ -585,6 +585,76 @@ export async function getAnalysisTrajectory(acdntSn: number): Promise { + 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) => ({ + 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) => ({ + type: 'Feature', + geometry: r['geom_json'], + properties: { + srId: Number(r['sr_id']), + category: String(r['category'] ?? ''), + ...(r['properties'] as Record ?? {}), + }, + })); + return { type: 'FeatureCollection', features }; +} + export async function listBoomLines(acdntSn: number): Promise { const sql = ` SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD, diff --git a/backend/src/reports/reportsRouter.ts b/backend/src/reports/reportsRouter.ts index f69111f..ccda7eb 100644 --- a/backend/src/reports/reportsRouter.ts +++ b/backend/src/reports/reportsRouter.ts @@ -92,7 +92,7 @@ router.get('/:sn', requireAuth, requirePermission('reports', 'READ'), async (req // ============================================================ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => { 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({ tmplSn, ctgrSn, @@ -101,7 +101,6 @@ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req jrsdCd, sttsCd, authorId: req.user!.sub, - mapCaptureImg, step3MapImg, step6MapImg, sections, @@ -127,8 +126,8 @@ router.post('/:sn/update', requireAuth, requirePermission('reports', 'UPDATE'), res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' }); return; } - const { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg, step3MapImg, step6MapImg } = req.body; - await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg, step3MapImg, step6MapImg }, req.user!.sub); + const { title, jrsdCd, sttsCd, acdntSn, sections, step3MapImg, step6MapImg } = req.body; + await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, step3MapImg, step6MapImg }, req.user!.sub); res.json({ success: true }); } catch (err) { if (err instanceof AuthError) { diff --git a/backend/src/reports/reportsService.ts b/backend/src/reports/reportsService.ts index e75f0e8..674dceb 100644 --- a/backend/src/reports/reportsService.ts +++ b/backend/src/reports/reportsService.ts @@ -75,7 +75,6 @@ interface SectionData { interface ReportDetail extends ReportListItem { acdntSn: number | null; sections: SectionData[]; - mapCaptureImg: string | null; step3MapImg: string | null; step6MapImg: string | null; } @@ -104,7 +103,6 @@ interface CreateReportInput { jrsdCd?: string; sttsCd?: string; authorId: string; - mapCaptureImg?: string; step3MapImg?: string; step6MapImg?: string; sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[]; @@ -115,7 +113,6 @@ interface UpdateReportInput { jrsdCd?: string; sttsCd?: string; acdntSn?: number | null; - mapCaptureImg?: string | null; step3MapImg?: string | null; step6MapImg?: string | null; sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[]; @@ -267,8 +264,7 @@ export async function listReports(input: ListReportsInput): Promise '') - OR (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '') + CASE WHEN (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 <> '') THEN true ELSE false END AS HAS_MAP_CAPTURE FROM REPORT r @@ -309,9 +305,8 @@ export async function getReport(reportSn: number): Promise { c.CTGR_CD, c.CTGR_NM, r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN, 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, - CASE WHEN (r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '') - OR (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '') + r.REG_DTM, r.MDFCN_DTM, r.STEP3_MAP_IMG, r.STEP6_MAP_IMG, + CASE WHEN (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 <> '') THEN true ELSE false END AS HAS_MAP_CAPTURE FROM REPORT r @@ -350,7 +345,6 @@ export async function getReport(reportSn: number): Promise { authorName: r.author_name || '', regDtm: r.reg_dtm, mdfcnDtm: r.mdfcn_dtm, - mapCaptureImg: r.map_capture_img, step3MapImg: r.step3_map_img, step6MapImg: r.step6_map_img, hasMapCapture: r.has_map_capture, @@ -373,8 +367,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb await client.query('BEGIN'); 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) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `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) RETURNING REPORT_SN`, [ input.tmplSn || null, @@ -384,7 +378,6 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb input.jrsdCd || null, input.sttsCd || 'DRAFT', input.authorId, - input.mapCaptureImg || null, input.step3MapImg || null, input.step6MapImg || null, ] @@ -458,10 +451,6 @@ export async function updateReport( sets.push(`ACDNT_SN = $${idx++}`); params.push(input.acdntSn); } - if (input.mapCaptureImg !== undefined) { - sets.push(`MAP_CAPTURE_IMG = $${idx++}`); - params.push(input.mapCaptureImg); - } if (input.step3MapImg !== undefined) { sets.push(`STEP3_MAP_IMG = $${idx++}`); params.push(input.step3MapImg); diff --git a/backend/src/routes/layers.ts b/backend/src/routes/layers.ts index 7373146..0c949e5 100755 --- a/backend/src/routes/layers.ts +++ b/backend/src/routes/layers.ts @@ -18,6 +18,7 @@ interface Layer { cmn_cd_nm: string cmn_cd_level: number clnm: string | null + data_tbl_nm: string | null } // DB 컬럼 → API 응답 컬럼 매핑 (프론트엔드 호환성 유지) @@ -27,7 +28,8 @@ const LAYER_COLUMNS = ` LAYER_FULL_NM AS cmn_cd_full_nm, LAYER_NM AS cmn_cd_nm, LAYER_LEVEL AS cmn_cd_level, - WMS_LAYER_NM AS clnm + WMS_LAYER_NM AS clnm, + DATA_TBL_NM AS data_tbl_nm `.trim() // 모든 라우트에 파라미터 살균 적용 @@ -216,6 +218,7 @@ router.get('/admin/list', requireAuth, requireRole('ADMIN'), async (req, res) => LAYER_NM AS "layerNm", LAYER_LEVEL AS "layerLevel", WMS_LAYER_NM AS "wmsLayerNm", + DATA_TBL_NM AS "dataTblNm", USE_YN AS "useYn", SORT_ORD AS "sortOrd", TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm" @@ -297,11 +300,12 @@ router.post('/admin/create', requireAuth, requireRole('ADMIN'), async (req, res) layerNm?: string layerLevel?: number wmsLayerNm?: string + dataTblNm?: string useYn?: string sortOrd?: number } - const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, useYn, sortOrd } = body + const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, dataTblNm, useYn, sortOrd } = body // 필수 필드 검증 if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) { @@ -328,20 +332,26 @@ router.post('/admin/create', requireAuth, requireRole('ADMIN'), async (req, res) return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' }) } } + if (dataTblNm !== undefined && dataTblNm !== null && dataTblNm !== '') { + if (!isValidStringLength(dataTblNm, 100) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(dataTblNm)) { + return res.status(400).json({ error: '데이터 테이블명은 100자 이내의 유효한 PostgreSQL 테이블명이어야 합니다.' }) + } + } const sanitizedLayerCd = sanitizeString(layerCd) const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null const sanitizedLayerFullNm = sanitizeString(layerFullNm) const sanitizedLayerNm = sanitizeString(layerNm) const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null + const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y' const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null const { rows } = await wingPool.query( - `INSERT INTO LAYER (LAYER_CD, UP_LAYER_CD, LAYER_FULL_NM, LAYER_NM, LAYER_LEVEL, WMS_LAYER_NM, USE_YN, SORT_ORD, DEL_YN) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'N') + `INSERT INTO LAYER (LAYER_CD, UP_LAYER_CD, LAYER_FULL_NM, LAYER_NM, LAYER_LEVEL, WMS_LAYER_NM, DATA_TBL_NM, USE_YN, SORT_ORD, DEL_YN) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'N') RETURNING LAYER_CD AS "layerCd"`, - [sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedUseYn, sanitizedSortOrd] + [sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedDataTblNm, sanitizedUseYn, sanitizedSortOrd] ) res.json(rows[0]) @@ -364,11 +374,12 @@ router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res) layerNm?: string layerLevel?: number wmsLayerNm?: string + dataTblNm?: string useYn?: string sortOrd?: number } - const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, useYn, sortOrd } = body + const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, dataTblNm, useYn, sortOrd } = body // 필수 필드 검증 if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) { @@ -395,22 +406,28 @@ router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res) return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' }) } } + if (dataTblNm !== undefined && dataTblNm !== null && dataTblNm !== '') { + if (!isValidStringLength(dataTblNm, 100) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(dataTblNm)) { + return res.status(400).json({ error: '데이터 테이블명은 100자 이내의 유효한 PostgreSQL 테이블명이어야 합니다.' }) + } + } const sanitizedLayerCd = sanitizeString(layerCd) const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null const sanitizedLayerFullNm = sanitizeString(layerFullNm) const sanitizedLayerNm = sanitizeString(layerNm) const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null + const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y' const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null const { rows } = await wingPool.query( `UPDATE LAYER SET UP_LAYER_CD = $2, LAYER_FULL_NM = $3, LAYER_NM = $4, LAYER_LEVEL = $5, - WMS_LAYER_NM = $6, USE_YN = $7, SORT_ORD = $8 + WMS_LAYER_NM = $6, DATA_TBL_NM = $7, USE_YN = $8, SORT_ORD = $9 WHERE LAYER_CD = $1 RETURNING LAYER_CD AS "layerCd"`, - [sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedUseYn, sanitizedSortOrd] + [sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedDataTblNm, sanitizedUseYn, sanitizedSortOrd] ) if (rows.length === 0) { diff --git a/database/migration/025_weather_columns.sql b/database/migration/025_weather_columns.sql new file mode 100644 index 0000000..c9d390a --- /dev/null +++ b/database/migration/025_weather_columns.sql @@ -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 '날씨 특보 문자열'; diff --git a/database/migration/026_sensitive_resources.sql b/database/migration/026_sensitive_resources.sql new file mode 100644 index 0000000..d96d1c9 --- /dev/null +++ b/database/migration/026_sensitive_resources.sql @@ -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 '수정일시'; diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 75b0c9b..9d7c8ef 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,17 @@ ## [Unreleased] +### 추가 +- 레이어: 레이어 데이터 테이블 매핑 구현 + 어장 팝업 수정 +- 확산예측: 예측 실행 시 기상정보(풍속·풍향·기압·파고·수온·기온·염분 등) ACDNT_WEATHER 테이블에 자동 저장 +- DB: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 19개 추가 (025 마이그레이션) +- DB: 민감자원 데이터 마이그레이션 (026_sensitive_resources) + +### 변경 +- 예측: 분석 API를 예측 서비스로 통합 (analysisRouter 제거) + +## [2026-03-20.3] + ### 추가 - 보고서: 기능 강화 (HWPX 내보내기, 확산 지도 패널, 보고서 생성기 개선) - 관리자: 권한 트리 확장 (게시판관리·기준정보·연계관리 섹션 추가) diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 36a2bc1..c7d29f2 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -1,14 +1,14 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react' import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre' import { MapboxOverlay } from '@deck.gl/mapbox' -import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer } from '@deck.gl/layers' +import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer, GeoJsonLayer } from '@deck.gl/layers' import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core' import type { StyleSpecification } from 'maplibre-gl' import type { MapLayerMouseEvent } from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { layerDatabase } from '@common/services/layerService' 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 type { BoomLine, BoomLineCoord } from '@common/types/boomLine' import type { ReplayShip, CollisionEvent } from '@common/types/backtrack' @@ -289,6 +289,24 @@ const PRIORITY_LABELS: Record = { '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 = { 'aquaculture': '#22c55e', 'beach': '#0ea5e9', @@ -342,6 +360,7 @@ interface MapViewProps { incidentCoord: { lat: number; lon: number } } sensitiveResources?: SensitiveResource[] + sensitiveResourceGeojson?: SensitiveResourceFeatureCollection | null flyToTarget?: { lng: number; lat: number; zoom?: number } | null fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null centerPoints?: Array<{ lat: number; lon: number; time: number; model?: string }> @@ -528,6 +547,7 @@ export function MapView({ layerBrightness = 50, backtrackReplay, sensitiveResources = [], + sensitiveResourceGeojson, flyToTarget, fitBoundsTarget, centerPoints = [], @@ -559,6 +579,12 @@ export function MapView({ const [isPlaying, setIsPlaying] = useState(false) const [playbackSpeed, setPlaybackSpeed] = useState(1) const [popupInfo, setPopupInfo] = useState(null) + // deck.gl 레이어 클릭 시 MapLibre 맵 클릭 핸들러 차단용 플래그 (민감자원 등) + const deckClickHandledRef = useRef(false) + // 클릭으로 열린 팝업(닫기 전까지 유지) 추적 — 호버 핸들러가 닫지 않도록 방지 + const persistentPopupRef = useRef(false) + // 현재 호버 중인 민감자원 feature properties (handleMapClick에서 팝업 생성에 사용) + const hoveredSensitiveRef = useRef | null>(null) const currentTime = isControlled ? externalCurrentTime : internalCurrentTime const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => { @@ -569,6 +595,44 @@ export function MapView({ const handleMapClick = useCallback((e: MapLayerMouseEvent) => { const { lng, lat } = e.lngLat setCurrentPosition([lat, lng]) + // deck.gl 다른 레이어 onClick이 처리한 클릭 — 팝업 유지 + if (deckClickHandledRef.current) { + deckClickHandledRef.current = false + 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: ( +
+
+ {String(category ?? '민감자원')} +
+ {entries.length > 0 ? ( +
+ {entries.map(([key, val]) => ( +
+ {key} + + {typeof val === 'object' ? JSON.stringify(val) : String(val)} + +
+ ))} +
+ ) : ( +

상세 정보 없음

+ )} +
+ ), + }) + return + } if (measureMode !== null) { handleMeasureClick(lng, lat) return @@ -716,7 +780,7 @@ export function MapView({ getPath: (d: BoomLine) => d.coords.map(c => [c.lon, c.lat] as [number, number]), getColor: (d: BoomLine) => hexToRgba(PRIORITY_COLORS[d.priority] || '#f59e0b', 230), getWidth: (d: BoomLine) => PRIORITY_WEIGHTS[d.priority] || 2, - getDashArray: (d: BoomLine) => d.status === 'PLANNED' ? [10, 5] : null, + getDashArray: (d: BoomLine) => d.status === 'PLANNED' ? [10, 5] : [0, 0], dashJustified: true, widthMinPixels: 2, widthMaxPixels: 6, @@ -1018,7 +1082,10 @@ export function MapView({ ), }); } else if (!info.object) { - setPopupInfo(null); + // 클릭으로 열린 팝업(어장 등)이 있으면 호버로 닫지 않음 + if (!persistentPopupRef.current) { + setPopupInfo(null); + } } }, }) @@ -1111,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 | null }).properties ?? {} + } else { + hoveredSensitiveRef.current = null + } + }, + }) as unknown as DeckLayer + ); + } + // --- 입자 중심점 이동 경로 (모델별 PathLayer + ScatterplotLayer) --- const visibleCenters = centerPoints.filter(p => p.time <= currentTime) if (visibleCenters.length > 0) { @@ -1225,12 +1327,12 @@ export function MapView({ // 거리/면적 측정 레이어 result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements)) - return result + return result.filter(Boolean) }, [ oilTrajectory, currentTime, selectedModels, boomLines, isDrawingBoom, drawingPoints, dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay, - sensitiveResources, centerPoints, windData, + sensitiveResources, sensitiveResourceGeojson, centerPoints, windData, showWind, showBeached, showTimeLabel, simulationStartTime, analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode, ]) @@ -1318,7 +1420,10 @@ export function MapView({ longitude={popupInfo.longitude} latitude={popupInfo.latitude} anchor="bottom" - onClose={() => setPopupInfo(null)} + onClose={() => { + persistentPopupRef.current = false + setPopupInfo(null) + }} >
{popupInfo.content}
diff --git a/frontend/src/common/data/layerData.ts b/frontend/src/common/data/layerData.ts index 20b8116..8a402cb 100755 --- a/frontend/src/common/data/layerData.ts +++ b/frontend/src/common/data/layerData.ts @@ -7,6 +7,7 @@ export interface LayerNode { name: string level: number layerName: string | null + dataTblNm?: string | null icon?: string count?: number defaultOn?: boolean diff --git a/frontend/src/common/services/api.ts b/frontend/src/common/services/api.ts index 6d99764..88ea572 100755 --- a/frontend/src/common/services/api.ts +++ b/frontend/src/common/services/api.ts @@ -46,6 +46,7 @@ export interface LayerDTO { cmn_cd_nm: string cmn_cd_level: number clnm: string | null + data_tbl_nm?: string | null icon?: string count?: number children?: LayerDTO[] @@ -58,6 +59,7 @@ export interface Layer { fullName: string level: number wmsLayer: string | null + dataTblNm?: string | null icon?: string count?: number children?: Layer[] @@ -72,6 +74,7 @@ function convertToLayer(dto: LayerDTO): Layer { fullName: dto.cmn_cd_full_nm, level: dto.cmn_cd_level, wmsLayer: dto.clnm, + dataTblNm: dto.data_tbl_nm, icon: dto.icon, count: dto.count, children: dto.children ? dto.children.map(convertToLayer) : undefined, diff --git a/frontend/src/common/services/layerService.ts b/frontend/src/common/services/layerService.ts index 848c0e5..08a6ee6 100755 --- a/frontend/src/common/services/layerService.ts +++ b/frontend/src/common/services/layerService.ts @@ -8,6 +8,7 @@ export interface Layer { fullName: string level: number wmsLayer: string | null + dataTblNm?: string | null icon?: string count?: number children?: Layer[] diff --git a/frontend/src/common/store/weatherSnapshotStore.ts b/frontend/src/common/store/weatherSnapshotStore.ts index d1073ff..79847dd 100644 --- a/frontend/src/common/store/weatherSnapshotStore.ts +++ b/frontend/src/common/store/weatherSnapshotStore.ts @@ -23,6 +23,21 @@ export interface WeatherSnapshot { pressure: number; visibility: 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 { diff --git a/frontend/src/common/styles/components.css b/frontend/src/common/styles/components.css index 7593fa5..17faf2d 100644 --- a/frontend/src/common/styles/components.css +++ b/frontend/src/common/styles/components.css @@ -1,3 +1,9 @@ +/* 바람 입자 캔버스(z-index: 450) 위에 팝업이 표시되도록 z-index 설정 + @layer 밖에 위치해야 non-layered CSS인 MapLibre 스타일보다 우선순위를 가짐 */ +.maplibregl-popup { + z-index: 500; +} + @layer components { /* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */ .cctv-dark-popup .maplibregl-popup-content { diff --git a/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx b/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx index 905c6be..f3bedbc 100755 --- a/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx @@ -70,7 +70,8 @@ export function IncidentsLeftPanel({ // Weather popup const [weatherPopupId, setWeatherPopupId] = useState(null) const [weatherPos, setWeatherPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 }) - const [weatherInfo, setWeatherInfo] = useState(null) + // undefined = 로딩 중, null = 데이터 없음, WeatherInfo = 데이터 있음 + const [weatherInfo, setWeatherInfo] = useState(undefined) const weatherRef = useRef(null) useEffect(() => { @@ -79,7 +80,7 @@ export function IncidentsLeftPanel({ fetchIncidentWeather(parseInt(weatherPopupId)).then((data) => { if (!cancelled) setWeatherInfo(data) }) - return () => { cancelled = true; setWeatherInfo(null) } + return () => { cancelled = true; setWeatherInfo(undefined) } }, [weatherPopupId]); useEffect(() => { @@ -361,7 +362,7 @@ export function IncidentsLeftPanel({ )} {/* Weather Popup (fixed position) */} - {weatherPopupId && weatherInfo && ( + {weatherPopupId && weatherInfo !== undefined && ( void }>(({ data, position, onClose }, ref) => { + const forecast = data?.forecast ?? [] return (
🌤
-
{data.locNm}
-
{data.obsDtm}
+
{data?.locNm || '기상정보 없음'}
+
{data?.obsDtm || '-'}
@@ -440,21 +442,21 @@ const WeatherPopup = forwardRef {/* Main weather */}
-
{data.icon}
+
{data?.icon || '❓'}
-
{data.temp}
-
{data.weatherDc}
+
{data?.temp || '-'}
+
{data?.weatherDc || '-'}
{/* Detail grid */}
- - - - - - + + + + + +
{/* Tide info */} @@ -464,7 +466,7 @@ const WeatherPopup = forwardRef
고조 (만조)
-
{data.highTide}
+
{data?.highTide || '-'}
저조 (간조)
-
{data.lowTide}
+
{data?.lowTide || '-'}
@@ -480,15 +482,19 @@ const WeatherPopup = forwardRef
24h 예보
-
- {data.forecast.map((f, i) => ( -
-
{f.hour}
-
{f.icon}
-
{f.temp}
-
- ))} -
+ {forecast.length > 0 ? ( +
+ {forecast.map((f, i) => ( +
+
{f.hour}
+
{f.icon}
+
{f.temp}
+
+ ))} +
+ ) : ( +
예보 데이터 없음
+ )} {/* Impact */} @@ -497,7 +503,7 @@ const WeatherPopup = forwardRef
⚠ 방제 작업 영향
-
{data.impactDc}
+
{data?.impactDc || '-'}
@@ -505,13 +511,13 @@ const WeatherPopup = forwardRef {icon}
{label}
-
{value}
+
{value || '-'}
) diff --git a/frontend/src/tabs/prediction/components/LeftPanel.tsx b/frontend/src/tabs/prediction/components/LeftPanel.tsx index 887b39b..56846c5 100755 --- a/frontend/src/tabs/prediction/components/LeftPanel.tsx +++ b/frontend/src/tabs/prediction/components/LeftPanel.tsx @@ -50,6 +50,7 @@ export function LeftPanel({ onLayerOpacityChange, layerBrightness, onLayerBrightnessChange, + sensitiveResources = [], onImageAnalysisResult, }: LeftPanelProps) { const [expandedSections, setExpandedSections] = useState({ @@ -160,7 +161,7 @@ export function LeftPanel({
사고일시 - {selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16) : '—'} + {selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16).replace(' ', 'T') : '—'}
유종 @@ -204,7 +205,18 @@ export function LeftPanel({ {expandedSections.impactResources && (
-

영향받는 민감자원 목록

+ {sensitiveResources.length === 0 ? ( +

영향받는 민감자원 목록

+ ) : ( +
+ {sensitiveResources.map(({ category, count }) => ( +
+ {category} + {count}개 +
+ ))} +
+ )}
)}
diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index b63f394..bc40199 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -14,8 +14,8 @@ import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore' import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine' import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack' import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack' -import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory } from '../services/predictionApi' -import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, WindPoint } from '../services/predictionApi' +import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory, fetchSensitiveResources, fetchSensitiveResourcesGeojson } from '../services/predictionApi' +import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, SensitiveResourceCategory, SensitiveResourceFeatureCollection, WindPoint } from '../services/predictionApi' import SimulationLoadingOverlay from './SimulationLoadingOverlay' import SimulationErrorModal from './SimulationErrorModal' import { api } from '@common/services/api' @@ -24,6 +24,13 @@ import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal' 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())}` +} + // --------------------------------------------------------------------------- // 민감자원 타입 + 데모 데이터 // --------------------------------------------------------------------------- @@ -38,20 +45,13 @@ export interface SensitiveResource { } export interface DisplayControls { - showCurrent: boolean; // 유향/유속 - showWind: boolean; // 풍향/풍속 - showBeached: boolean; // 해안부착 - showTimeLabel: boolean; // 시간 표시 + showCurrent: boolean; // 유향/유속 + showWind: boolean; // 풍향/풍속 + showBeached: 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) // --------------------------------------------------------------------------- @@ -139,6 +139,8 @@ export function OilSpillView() { // 민감자원 const [sensitiveResources, setSensitiveResources] = useState([]) + const [sensitiveResourceCategories, setSensitiveResourceCategories] = useState([]) + const [sensitiveResourceGeojson, setSensitiveResourceGeojson] = useState(null) // 오일펜스 배치 상태 const [boomLines, setBoomLines] = useState([]) @@ -162,6 +164,7 @@ export function OilSpillView() { showWind: false, showBeached: false, showTimeLabel: false, + showSensitiveResources: false, }) // 타임라인 플레이어 상태 @@ -217,7 +220,7 @@ export function OilSpillView() { setOilTrajectory(demoTrajectory) const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings) setBoomLines(demoBooms) - setSensitiveResources(DEMO_SENSITIVE_RESOURCES) + setSensitiveResources([]) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeSubTab]) @@ -469,7 +472,7 @@ export function OilSpillView() { setSelectedAnalysis(analysis) setCenterPoints([]) if (analysis.occurredAt) { - setAccidentTime(analysis.occurredAt.slice(0, 16)) + setAccidentTime(toLocalDateTimeStr(analysis.occurredAt)) } if (analysis.lon != null && analysis.lat != null) { setIncidentCoord({ lon: analysis.lon, lat: analysis.lat }) @@ -519,7 +522,13 @@ export function OilSpillView() { if (sbModel) setSummaryByModel(sbModel); if (stepSbModel) setStepSummariesByModel(stepSbModel); 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 완료 후 재생, 그렇지 않으면 즉시 재생 if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) { pendingPlayRef.current = true @@ -541,7 +550,8 @@ export function OilSpillView() { const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48) setOilTrajectory(demoTrajectory) if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings)) - setSensitiveResources(DEMO_SENSITIVE_RESOURCES) + setSensitiveResources([]) + setSensitiveResourceCategories([]) // incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생 if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) { pendingPlayRef.current = true @@ -555,7 +565,7 @@ export function OilSpillView() { setDrawingPoints(prev => [...prev, { lat, lon }]) } else if (drawAnalysisMode === 'polygon') { setAnalysisPolygonPoints(prev => [...prev, { lat, lon }]) - } else { + } else if (isSelectingLocation) { setIncidentCoord({ lon, lat }) setIsSelectingLocation(false) } @@ -567,7 +577,7 @@ export function OilSpillView() { setAnalysisResult(null) } - const handleRunPolygonAnalysis = () => { + const handleRunPolygonAnalysis = async () => { if (analysisPolygonPoints.length < 3) return const currentParticles = oilTrajectory.filter(p => p.time === currentStep) const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1 @@ -582,7 +592,7 @@ export function OilSpillView() { setDrawAnalysisMode(null) } - const handleRunCircleAnalysis = () => { + const handleRunCircleAnalysis = async () => { if (!incidentCoord) return const radiusM = circleRadiusNm * 1852 const currentParticles = oilTrajectory.filter(p => p.time === currentStep) @@ -615,7 +625,7 @@ export function OilSpillView() { const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => { setIncidentCoord({ 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) setSpillAmount(parseFloat(result.volume.toFixed(4))) setSpillUnit('kL') @@ -795,7 +805,7 @@ export function OilSpillView() { setStepSummariesByModel(newStepSummariesByModel); const booms = generateAIBoomLines(merged, effectiveCoord, algorithmSettings); setBoomLines(booms); - setSensitiveResources(DEMO_SENSITIVE_RESOURCES); + setSensitiveResources([]); setCurrentStep(0); setIsPlaying(true); setFlyToCoord({ lon: effectiveCoord.lon, lat: effectiveCoord.lat }); @@ -805,11 +815,26 @@ export function OilSpillView() { setSimulationError(errors.join('; ')); } else { simulationSucceeded = true; + const effectiveAcdntSn = data.acdntSn ?? selectedAnalysis?.acdntSn; if (effectiveCoord) { 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)); } + 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) { const msg = @@ -976,6 +1001,7 @@ export function OilSpillView() { onLayerOpacityChange={setLayerOpacity} layerBrightness={layerBrightness} onLayerBrightnessChange={setLayerBrightness} + sensitiveResources={sensitiveResourceCategories} onImageAnalysisResult={handleImageAnalysisResult} /> )} @@ -1004,6 +1030,7 @@ export function OilSpillView() { layerOpacity={layerOpacity} layerBrightness={layerBrightness} sensitiveResources={sensitiveResources} + sensitiveResourceGeojson={displayControls.showSensitiveResources ? sensitiveResourceGeojson : null} lightMode centerPoints={centerPoints.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))} windData={windData} diff --git a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx index 01a25fd..e9bd3be 100644 --- a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx +++ b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx @@ -430,7 +430,9 @@ function DateTimeInput({ value, onChange }: { value: string; onChange: (v: strin const datePart = value ? value.split('T')[0] : '' 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 [viewYear, setViewYear] = useState(parsed.getFullYear()) @@ -561,9 +563,15 @@ function DateTimeInput({ value, onChange }: { value: string; onChange: (v: strin