feat(레이어): 레이어 데이터 테이블 매핑 + 기상 스냅샷 저장 + 민감자원 DB #113

병합
jhkang feature/layer-data-table-mapping 에서 develop 로 3 commits 를 머지했습니다 2026-03-23 19:10:36 +09:00
25개의 변경된 파일642개의 추가작업 그리고 592개의 파일을 삭제
Showing only changes of commit e06287ba5b - Show all commits

파일 보기

@ -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,
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<string, unknown>);
res.json({ weatherSn });
} catch (err) {
console.error('[incidents] 기상정보 저장 오류:', err);
res.status(500).json({ error: '기상정보 저장 중 오류가 발생했습니다.' });
}
});
// ============================================================
// 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>;
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<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 {
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 {

파일 보기

@ -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[]> {
const sql = `
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) => {
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) {

파일 보기

@ -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<ListReportsR
r.TITLE, r.JRSD_CD, r.STTS_CD,
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
r.REG_DTM, r.MDFCN_DTM,
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 <> '')
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<ReportDetail> {
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<ReportDetail> {
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);

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -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]
### 추가
- 확산예측: 예측 실행 시 기상정보(풍속·풍향·기압·파고·수온·기온·염분 등) ACDNT_WEATHER 테이블에 자동 저장
- DB: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 19개 추가 (027 마이그레이션)
## [2026-03-20.3]
### 추가
- 보고서: 기능 강화 (HWPX 내보내기, 확산 지도 패널, 보고서 생성기 개선)
- 관리자: 권한 트리 확장 (게시판관리·기준정보·연계관리 섹션 추가)

파일 보기

@ -3,13 +3,12 @@ import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/r
import { MapboxOverlay } from '@deck.gl/mapbox'
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer, GeoJsonLayer } from '@deck.gl/layers'
import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'
import type { SpatialQueryResult, FshfrmProperties } from '@tabs/prediction/services/analysisService'
import type { StyleSpecification } from 'maplibre-gl'
import type { MapLayerMouseEvent } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
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'
@ -290,6 +289,24 @@ const PRIORITY_LABELS: Record<string, string> = {
'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> = {
'aquaculture': '#22c55e',
'beach': '#0ea5e9',
@ -343,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 }>
@ -366,49 +384,6 @@ interface MapViewProps {
lightMode?: boolean
/** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */
showOverlays?: boolean
/** 오염분석 공간 쿼리 결과 (어장 등 GIS 레이어) */
spatialQueryResult?: SpatialQueryResult | null
}
// 어장 정보 팝업 컴포넌트 (gis.fshfrm)
function FshfrmPopup({ properties }: { properties: FshfrmProperties }) {
const rows: [string, string | number | null | undefined][] = [
['시도', properties.ctpv_nm],
['시군구', properties.sgg_nm],
['행정동', properties.admdst_nm],
['어장 구분', properties.fids_se],
['어업 종류', properties.fids_knd],
['양식 방법', properties.fids_mthd],
['양식 품종', properties.farm_knd],
['주소', properties.addr],
['면적(㎡)', properties.area != null ? parseFloat(String(properties.area)).toFixed(2) : null],
['허가번호', properties.lcns_no],
['허가일자', properties.lcns_bgng_],
['허가만료', properties.lcns_end_y],
]
return (
<div style={{ minWidth: 200, maxWidth: 260, fontSize: 10 }}>
<div style={{ fontWeight: 'bold', color: '#22c55e', marginBottom: 6, fontSize: 11 }}>
🐟
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
{rows
.filter(([, v]) => v != null && v !== '')
.map(([label, value]) => (
<tr key={label} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
<td style={{ padding: '2px 6px 2px 0', color: '#555', whiteSpace: 'nowrap', verticalAlign: 'top' }}>
{label}
</td>
<td style={{ padding: '2px 0', color: '#222', wordBreak: 'break-all' }}>
{String(value)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
@ -572,6 +547,7 @@ export function MapView({
layerBrightness = 50,
backtrackReplay,
sensitiveResources = [],
sensitiveResourceGeojson,
flyToTarget,
fitBoundsTarget,
centerPoints = [],
@ -592,7 +568,6 @@ export function MapView({
analysisCircleRadiusM = 0,
lightMode = false,
showOverlays = true,
spatialQueryResult,
}: MapViewProps) {
const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore()
const { handleMeasureClick } = useMeasureTool()
@ -608,11 +583,8 @@ export function MapView({
const deckClickHandledRef = useRef(false)
// 클릭으로 열린 팝업(닫기 전까지 유지) 추적 — 호버 핸들러가 닫지 않도록 방지
const persistentPopupRef = useRef(false)
// GeoJsonLayer(어장 폴리곤) hover 중인 피처 — handleMapClick에서 팝업 표시에 사용
const hoveredGeoLayerRef = useRef<{
props: FshfrmProperties;
coord: [number, number];
} | null>(null)
// 현재 호버 중인 민감자원 feature properties (handleMapClick에서 팝업 생성에 사용)
const hoveredSensitiveRef = useRef<Record<string, unknown> | null>(null)
const currentTime = isControlled ? externalCurrentTime : internalCurrentTime
const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => {
@ -623,23 +595,44 @@ export function MapView({
const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
const { lng, lat } = e.lngLat
setCurrentPosition([lat, lng])
// 어장 폴리곤(GeoJsonLayer) 위에서 좌클릭 시 팝업 표시
// deck.gl onClick 대신 handleMapClick에서 처리하여 이벤트 순서 문제 회피
if (hoveredGeoLayerRef.current) {
const { props, coord } = hoveredGeoLayerRef.current
persistentPopupRef.current = true
setPopupInfo({
longitude: coord[0],
latitude: coord[1],
content: <FshfrmPopup properties={props} />,
})
return
}
// deck.gl 다른 레이어(민감자원 등) onClick이 처리한 클릭 — 팝업 유지
// 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: (
<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) {
handleMeasureClick(lng, lat)
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) ---
const visibleCenters = centerPoints.filter(p => p.time <= currentTime)
if (visibleCenters.length > 0) {
@ -1299,53 +1327,14 @@ export function MapView({
// 거리/면적 측정 레이어
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)
}, [
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,
spatialQueryResult,
])
// 3D 모드 / 밝은 톤에 따른 지도 스타일 전환

파일 보기

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

파일 보기

@ -70,7 +70,8 @@ export function IncidentsLeftPanel({
// Weather popup
const [weatherPopupId, setWeatherPopupId] = useState<string | null>(null)
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)
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 && (
<WeatherPopup
ref={weatherRef}
data={weatherInfo}
@ -412,10 +413,11 @@ function PgBtn({ label, active, disabled, onClick }: { label: string; active?: b
WeatherPopup
*/
const WeatherPopup = forwardRef<HTMLDivElement, {
data: WeatherInfo
data: WeatherInfo | null
position: { top: number; left: number }
onClose: () => void
}>(({ data, position, onClose }, ref) => {
const forecast = data?.forecast ?? []
return (
<div ref={ref} className="fixed overflow-hidden rounded-xl border border-border bg-bg-1" style={{
zIndex: 9990, width: 280,
@ -429,8 +431,8 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
<div className="flex items-center gap-1.5">
<span className="text-sm">🌤</span>
<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-[11px] font-bold">{data?.locNm || '기상정보 없음'}</div>
<div className="text-text-3 font-mono text-[8px]">{data?.obsDtm || '-'}</div>
</div>
</div>
<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">
{/* Main weather */}
<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 className="font-bold font-mono text-[20px]">{data.temp}</div>
<div className="text-text-3 text-[9px]">{data.weatherDc}</div>
<div className="font-bold font-mono text-[20px]">{data?.temp || '-'}</div>
<div className="text-text-3 text-[9px]">{data?.weatherDc || '-'}</div>
</div>
</div>
{/* Detail grid */}
<div className="grid grid-cols-2 gap-1.5 text-[9px]">
<WxCell icon="💨" label="풍향/풍속" value={data.wind} />
<WxCell icon="🌊" label="파고" value={data.wave} />
<WxCell icon="💧" label="습도" value={data.humid} />
<WxCell icon="👁" label="시정" value={data.vis} />
<WxCell icon="🌡" label="수온" value={data.sst} />
<WxCell icon="🔄" label="조류" value={data.tide} />
<WxCell icon="💨" label="풍향/풍속" value={data?.wind} />
<WxCell icon="🌊" label="파고" value={data?.wave} />
<WxCell icon="💧" label="습도" value={data?.humid} />
<WxCell icon="👁" label="시정" value={data?.vis} />
<WxCell icon="🌡" label="수온" value={data?.sst} />
<WxCell icon="🔄" label="조류" value={data?.tide} />
</div>
{/* Tide info */}
@ -464,7 +466,7 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
<span className="text-xs"></span>
<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 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>
<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>
@ -480,15 +482,19 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
{/* 24h Forecast */}
<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="flex justify-between font-mono text-text-2 text-[8px]">
{data.forecast.map((f, i) => (
<div key={i} className="text-center">
<div>{f.hour}</div>
<div className="text-xs my-0.5">{f.icon}</div>
<div className="font-semibold">{f.temp}</div>
</div>
))}
</div>
{forecast.length > 0 ? (
<div className="flex justify-between font-mono text-text-2 text-[8px]">
{forecast.map((f, i) => (
<div key={i} className="text-center">
<div>{f.hour}</div>
<div className="text-xs my-0.5">{f.icon}</div>
<div className="font-semibold">{f.temp}</div>
</div>
))}
</div>
) : (
<div className="text-text-3 text-center text-[8px] py-1"> </div>
)}
</div>
{/* 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)',
}}>
<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>
@ -505,13 +511,13 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
})
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 (
<div className="flex items-center bg-bg-0 rounded gap-[6px] py-1.5 px-2">
<span className="text-[12px]">{icon}</span>
<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>
)

파일 보기

@ -50,6 +50,7 @@ export function LeftPanel({
onLayerOpacityChange,
layerBrightness,
onLayerBrightnessChange,
sensitiveResources = [],
onImageAnalysisResult,
}: LeftPanelProps) {
const [expandedSections, setExpandedSections] = useState<ExpandedSections>({
@ -160,7 +161,7 @@ export function LeftPanel({
</div>
<div className="flex items-baseline gap-1.5">
<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 className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
@ -204,7 +205,18 @@ export function LeftPanel({
{expandedSections.impactResources && (
<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>

파일 보기

@ -14,18 +14,23 @@ 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'
import { generateAIBoomLines, haversineDistance, pointInPolygon, polygonAreaKm2, circleAreaKm2 } from '@common/utils/geo'
import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal'
import { querySpatialLayers } from '../services/analysisService'
import type { SpatialQueryResult } from '../services/analysisService'
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
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 {
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)
// ---------------------------------------------------------------------------
@ -141,6 +139,8 @@ export function OilSpillView() {
// 민감자원
const [sensitiveResources, setSensitiveResources] = useState<SensitiveResource[]>([])
const [sensitiveResourceCategories, setSensitiveResourceCategories] = useState<SensitiveResourceCategory[]>([])
const [sensitiveResourceGeojson, setSensitiveResourceGeojson] = useState<SensitiveResourceFeatureCollection | null>(null)
// 오일펜스 배치 상태
const [boomLines, setBoomLines] = useState<BoomLine[]>([])
@ -164,6 +164,7 @@ export function OilSpillView() {
showWind: false,
showBeached: false,
showTimeLabel: false,
showSensitiveResources: false,
})
// 타임라인 플레이어 상태
@ -205,8 +206,6 @@ export function OilSpillView() {
const [analysisPolygonPoints, setAnalysisPolygonPoints] = useState<{ lat: number; lon: number }[]>([])
const [circleRadiusNm, setCircleRadiusNm] = useState<number>(5)
const [analysisResult, setAnalysisResult] = useState<{ area: number; particleCount: number; particlePercent: number; sensitiveCount: number } | null>(null)
const [spatialQueryResult, setSpatialQueryResult] = useState<SpatialQueryResult | null>(null)
const [isSpatialQuerying, setIsSpatialQuerying] = useState(false)
// 원 분석용 derived 값 (state 아님)
const analysisCircleCenter = analysisTab === 'circle' && incidentCoord ? incidentCoord : null
@ -221,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])
@ -473,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 })
@ -523,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
@ -545,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
@ -559,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)
}
@ -584,22 +590,6 @@ export function OilSpillView() {
sensitiveCount,
})
setDrawAnalysisMode(null)
// 공간 데이터 쿼리 (어장 등 정보 레이어)
// [추후 확장] 좌측 정보 레이어 활성화 목록 기반으로 layers 파라미터를 전달할 수 있습니다.
setIsSpatialQuerying(true)
try {
const result = await querySpatialLayers({
type: 'polygon',
polygon: analysisPolygonPoints,
})
setSpatialQueryResult(result)
} catch (err) {
console.error('[analysis] 공간 쿼리 실패:', err)
setSpatialQueryResult(null)
} finally {
setIsSpatialQuerying(false)
}
}
const handleRunCircleAnalysis = async () => {
@ -619,23 +609,6 @@ export function OilSpillView() {
particlePercent: Math.round((inside / totalIds) * 100),
sensitiveCount,
})
// 공간 데이터 쿼리 (어장 등 정보 레이어)
// [추후 확장] 좌측 정보 레이어 활성화 목록 기반으로 layers 파라미터를 전달할 수 있습니다.
setIsSpatialQuerying(true)
try {
const result = await querySpatialLayers({
type: 'circle',
center: { lat: incidentCoord.lat, lon: incidentCoord.lon },
radiusM,
})
setSpatialQueryResult(result)
} catch (err) {
console.error('[analysis] 공간 쿼리 실패:', err)
setSpatialQueryResult(null)
} finally {
setIsSpatialQuerying(false)
}
}
const handleCancelAnalysis = () => {
@ -647,13 +620,12 @@ export function OilSpillView() {
setDrawAnalysisMode(null)
setAnalysisPolygonPoints([])
setAnalysisResult(null)
setSpatialQueryResult(null)
}
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')
@ -833,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 });
@ -843,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 =
@ -1014,6 +1001,7 @@ export function OilSpillView() {
onLayerOpacityChange={setLayerOpacity}
layerBrightness={layerBrightness}
onLayerBrightnessChange={setLayerBrightness}
sensitiveResources={sensitiveResourceCategories}
onImageAnalysisResult={handleImageAnalysisResult}
/>
)}
@ -1042,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}
@ -1067,7 +1056,6 @@ export function OilSpillView() {
showBeached={displayControls.showBeached}
showTimeLabel={displayControls.showTimeLabel}
simulationStartTime={accidentTime || undefined}
spatialQueryResult={spatialQueryResult}
/>
{/* 타임라인 플레이어 (리플레이 비활성 시) */}
@ -1283,8 +1271,6 @@ export function OilSpillView() {
onRunCircleAnalysis={handleRunCircleAnalysis}
onCancelAnalysis={handleCancelAnalysis}
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 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
<button
type="button"
onClick={() => {
setViewYear(todayY)
setViewMonth(todayM)
pickDate(todayD)
const now = new Date()
setViewYear(now.getFullYear())
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"
style={{

파일 보기

@ -1,7 +1,6 @@
import { useState } from 'react'
import type { PredictionDetail, SimulationSummary } from '../services/predictionApi'
import type { DisplayControls } from './OilSpillView'
import type { SpatialQueryResult } from '../services/analysisService'
interface AnalysisResult {
area: number
@ -35,8 +34,6 @@ interface RightPanelProps {
onRunCircleAnalysis?: () => void
onCancelAnalysis?: () => void
onClearAnalysis?: () => void
spatialQueryResult?: SpatialQueryResult | null
isSpatialQuerying?: boolean
}
export function RightPanel({
@ -49,7 +46,6 @@ export function RightPanel({
analysisResult,
onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis,
onCancelAnalysis, onClearAnalysis,
spatialQueryResult, isSpatialQuerying = false,
}: RightPanelProps) {
const vessel = detail?.vessels?.[0]
const vessel2 = detail?.vessels?.[1]
@ -85,7 +81,10 @@ export function RightPanel({
checked={displayControls?.showBeached ?? false}
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showBeached: v })}
></ControlledCheckbox>
<ControlledCheckbox checked={false} onChange={() => {}} disabled>
<ControlledCheckbox
checked={displayControls?.showSensitiveResources ?? false}
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showSensitiveResources: v })}
>
</ControlledCheckbox>
<ControlledCheckbox
@ -221,70 +220,6 @@ export function RightPanel({
)}
</div>
)}
{/* 공간 쿼리 결과 요약 (어장 등 GIS 레이어) */}
{isSpatialQuerying && (
<div className="mt-2 text-[9px] text-text-3 font-korean text-center py-1">
...
</div>
)}
{spatialQueryResult && !isSpatialQuerying && (
<div className="mt-2 p-2 rounded border border-[rgba(34,197,94,0.2)] bg-[rgba(34,197,94,0.04)]">
<div className="flex items-center gap-1 mb-1">
<span className="text-[11px]">🐟</span>
<span className="text-[10px] font-bold text-[#22c55e] font-korean"> </span>
</div>
{spatialQueryResult._meta.map(meta => (
<div key={meta.tableName} className="text-[9px] text-text-2 font-korean">
:{' '}
<span className="font-bold text-[#22c55e]">{meta.count}</span>
( )
</div>
))}
{spatialQueryResult.features.length === 0 && (
<div className="text-[9px] text-text-3 font-korean"> </div>
)}
{spatialQueryResult.features.length > 0 && (() => {
// 어업 종류별 건수 및 면적 합산
const grouped = spatialQueryResult.features.reduce<
Record<string, { count: number; totalArea: number }>
>((acc, feat) => {
const kind = feat.properties.fids_knd ?? '미분류';
const area = Number(feat.properties.area ?? 0);
if (!acc[kind]) acc[kind] = { count: 0, totalArea: 0 };
acc[kind].count += 1;
acc[kind].totalArea += area;
return acc;
}, {});
const groupList = Object.entries(grouped);
const totalCount = groupList.reduce((s, [, v]) => s + v.count, 0);
const totalArea = groupList.reduce((s, [, v]) => s + v.totalArea, 0);
return (
<div className="mt-1.5">
{/* 헤더 */}
<div className="grid grid-cols-[1fr_auto_auto] gap-x-2 text-[8px] text-text-3 font-korean border-b border-[rgba(34,197,94,0.2)] pb-0.5 mb-0.5">
<span> </span>
<span className="text-right"></span>
<span className="text-right">(m²)</span>
</div>
{/* 종류별 행 */}
{groupList.map(([kind, { count, totalArea: area }]) => (
<div key={kind} className="grid grid-cols-[1fr_auto_auto] gap-x-2 text-[8px] text-text-2 font-korean py-px">
<span className="truncate">{kind}</span>
<span className="text-right font-mono text-[#22c55e]">{count}</span>
<span className="text-right font-mono text-text-1">{area.toFixed(2)}</span>
</div>
))}
{/* 합계 행 */}
<div className="grid grid-cols-[1fr_auto_auto] gap-x-2 text-[8px] font-korean border-t border-[rgba(34,197,94,0.2)] pt-0.5 mt-0.5">
<span className="text-text-3"></span>
<span className="text-right font-mono font-bold text-[#22c55e]">{totalCount}</span>
<span className="text-right font-mono font-bold text-text-1">{totalArea.toFixed(2)}</span>
</div>
</div>
);
})()}
</div>
)}
</Section>
{/* 오염 종합 상황 */}
@ -722,13 +657,13 @@ function PollResult({
{summary && (
<div className="flex justify-between">
<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>
)}
{summary && (
<div className="flex justify-between">
<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 className="flex justify-between">

파일 보기

@ -1,7 +1,7 @@
import type { PredictionModel } from './OilSpillView'
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
import type { Analysis } from './AnalysisListTable'
import type { ImageAnalyzeResult } from '../services/predictionApi'
import type { ImageAnalyzeResult, SensitiveResourceCategory } from '../services/predictionApi'
export interface LeftPanelProps {
selectedAnalysis?: Analysis | null
@ -49,6 +49,8 @@ export interface LeftPanelProps {
onLayerOpacityChange: (val: number) => void
layerBrightness: number
onLayerBrightnessChange: (val: number) => void
// 영향 민감자원
sensitiveResources?: SensitiveResourceCategory[]
// 이미지 분석 결과 콜백
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;
};
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 {
acdntSn: number | null;
sections: ApiReportSectionData[];
mapCaptureImg?: string | null;
step3MapImg?: string | null;
step6MapImg?: string | null;
}
@ -180,7 +179,6 @@ export async function createReportApi(input: {
title: string;
jrsdCd?: string;
sttsCd?: string;
mapCaptureImg?: string;
step3MapImg?: string;
step6MapImg?: string;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
@ -194,7 +192,6 @@ export async function updateReportApi(sn: number, input: {
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 }[];
@ -249,7 +246,6 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
title: data.title || data.incident.name || '보고서',
jrsdCd: data.jurisdiction,
sttsCd,
mapCaptureImg: data.capturedMapImage !== undefined ? (data.capturedMapImage || null) : undefined,
step3MapImg: data.step3MapImage !== undefined ? (data.step3MapImage || null) : undefined,
step6MapImg: data.step6MapImage !== undefined ? (data.step6MapImage || null) : undefined,
sections,
@ -263,7 +259,6 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
title: data.title || data.incident.name || '보고서',
jrsdCd: data.jurisdiction,
sttsCd,
mapCaptureImg: data.capturedMapImage || undefined,
step3MapImg: data.step3MapImage || undefined,
step6MapImg: data.step6MapImage || undefined,
sections,
@ -360,9 +355,6 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa
`위도 ${parseFloat(reportData.incident.lat).toFixed(4)}, 경도 ${parseFloat(reportData.incident.lon).toFixed(4)}`;
}
if (detail.mapCaptureImg) {
reportData.capturedMapImage = detail.mapCaptureImg;
}
if (detail.step3MapImg) {
reportData.step3MapImage = detail.step3MapImg;
}

파일 보기

@ -36,6 +36,13 @@ export async function fetchWeatherSnapshotForCoord(
const obsCode = OBS_STATION_CODES[nearest.id];
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) {
const windSpeed = r(obs.wind_speed ?? 8.0);
const windDir = obs.wind_dir ?? 315;
@ -64,6 +71,14 @@ export async function fetchWeatherSnapshotForCoord(
pressure,
visibility: pressure > 1010 ? 15 : 10,
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),
visibility: 12 + (Math.floor(seed) % 10),
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,
};
}