release: 2026-03-24 (160건 커밋) #118
@ -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 데이터 테이블명 (공간 데이터 직접 조회용)';
|
||||
44
database/migration/025_weather_columns.sql
Normal file
44
database/migration/025_weather_columns.sql
Normal file
@ -0,0 +1,44 @@
|
||||
-- 027: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 추가
|
||||
-- 확산예측 실행 시 WeatherRightPanel에 표시되는 모든 기상정보 저장을 위해
|
||||
-- 기존 VARCHAR 컬럼(WIND, WAVE, TEMP, SST)은 하위 호환성 유지를 위해 보존
|
||||
|
||||
ALTER TABLE wing.ACDNT_WEATHER
|
||||
ADD COLUMN IF NOT EXISTS WIND_SPEED NUMERIC(5,1), -- 풍속 (m/s)
|
||||
ADD COLUMN IF NOT EXISTS WIND_DIR INTEGER, -- 풍향 (도)
|
||||
ADD COLUMN IF NOT EXISTS WIND_DIR_LBL VARCHAR(10), -- 풍향 텍스트 (N, NW, ...)
|
||||
ADD COLUMN IF NOT EXISTS WIND_SPEED_1K NUMERIC(5,1), -- 1k 최고 풍속 (m/s)
|
||||
ADD COLUMN IF NOT EXISTS WIND_SPEED_3K NUMERIC(5,1), -- 3k 평균 풍속 (m/s)
|
||||
ADD COLUMN IF NOT EXISTS PRESSURE NUMERIC(6,1), -- 기압 (hPa)
|
||||
ADD COLUMN IF NOT EXISTS WAVE_HEIGHT NUMERIC(4,1), -- 유의파고 (m)
|
||||
ADD COLUMN IF NOT EXISTS WAVE_MAX_HT NUMERIC(4,1), -- 최고파고 (m)
|
||||
ADD COLUMN IF NOT EXISTS WAVE_PERIOD NUMERIC(4,1), -- 파도 주기 (s)
|
||||
ADD COLUMN IF NOT EXISTS WAVE_DIR VARCHAR(10), -- 파향 (N, NE, ...)
|
||||
ADD COLUMN IF NOT EXISTS AIR_TEMP NUMERIC(5,1), -- 기온 (°C)
|
||||
ADD COLUMN IF NOT EXISTS SALINITY NUMERIC(5,1), -- 염분 (PSU)
|
||||
ADD COLUMN IF NOT EXISTS SUNRISE VARCHAR(10), -- 일출 시각 (HH:MM)
|
||||
ADD COLUMN IF NOT EXISTS SUNSET VARCHAR(10), -- 일몰 시각 (HH:MM)
|
||||
ADD COLUMN IF NOT EXISTS MOONRISE VARCHAR(10), -- 월출 시각 (HH:MM)
|
||||
ADD COLUMN IF NOT EXISTS MOONSET VARCHAR(10), -- 월몰 시각 (HH:MM)
|
||||
ADD COLUMN IF NOT EXISTS MOON_PHASE VARCHAR(30), -- 월상 (예: 상현달 14일)
|
||||
ADD COLUMN IF NOT EXISTS TIDAL_RANGE NUMERIC(4,1), -- 조차 (m)
|
||||
ADD COLUMN IF NOT EXISTS WEATHER_ALERT TEXT; -- 날씨 특보
|
||||
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED IS '풍속 (m/s)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_DIR IS '풍향 (도, 0-360)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_DIR_LBL IS '풍향 텍스트 (N/NE/E/...)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED_1K IS '1km 최고 풍속 (m/s)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED_3K IS '3km 평균 풍속 (m/s)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.PRESSURE IS '기압 (hPa)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_HEIGHT IS '유의파고 (m)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_MAX_HT IS '최고파고 (m)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_PERIOD IS '파도 주기 (s)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_DIR IS '파향 (N/NE/E/...)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.AIR_TEMP IS '기온 (°C)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.SALINITY IS '염분 (PSU)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.SUNRISE IS '일출 시각 (HH:MM)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.SUNSET IS '일몰 시각 (HH:MM)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.MOONRISE IS '월출 시각 (HH:MM)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.MOONSET IS '월몰 시각 (HH:MM)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.MOON_PHASE IS '월상 (예: 상현달 14일)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.TIDAL_RANGE IS '조차 (m)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WEATHER_ALERT IS '날씨 특보 문자열';
|
||||
@ -1,13 +0,0 @@
|
||||
-- SRID 5179 (Korea 2000 / Unified CS) 등록
|
||||
-- gis.fshfrm 등 한국 좌표계(EPSG:5179) 데이터의 ST_Transform 사용을 위해 필요
|
||||
INSERT INTO spatial_ref_sys (srid, auth_name, auth_srid, proj4text, srtext)
|
||||
VALUES (
|
||||
5179,
|
||||
'EPSG',
|
||||
5179,
|
||||
'+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +units=m +no_defs',
|
||||
'PROJCS["Korea 2000 / Unified CS",GEOGCS["Korea 2000",DATUM["Geocentric_datum_of_Korea",SPHEROID["GRS 1980",6378137,298.257222101,AUTHORITY["EPSG","7019"]],TOWGS84[0,0,0,0,0,0,0],AUTHORITY["EPSG","6737"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4737"]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",38],PARAMETER["central_meridian",127.5],PARAMETER["scale_factor",0.9996],PARAMETER["false_easting",1000000],PARAMETER["false_northing",2000000],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Northing",NORTH],AXIS["Easting",EAST],AUTHORITY["EPSG","5179"]]'
|
||||
)
|
||||
ON CONFLICT (srid) DO UPDATE SET
|
||||
proj4text = EXCLUDED.proj4text,
|
||||
srtext = EXCLUDED.srtext;
|
||||
41
database/migration/026_sensitive_resources.sql
Normal file
41
database/migration/026_sensitive_resources.sql
Normal file
@ -0,0 +1,41 @@
|
||||
-- ============================================================
|
||||
-- 027: 민감자원 테이블 생성
|
||||
-- 모든 민감자원(양식장, 해수욕장, 무역항 등)을 단일 테이블로 관리
|
||||
-- properties는 JSONB로 유연하게 저장
|
||||
-- ============================================================
|
||||
|
||||
SET search_path TO wing, public;
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
|
||||
-- ============================================================
|
||||
-- 민감자원 테이블
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS SENSITIVE_RESOURCE (
|
||||
SR_ID BIGSERIAL PRIMARY KEY,
|
||||
CATEGORY VARCHAR(50) NOT NULL, -- 민감자원 유형 (양식장, 해수욕장, 무역항 등)
|
||||
GEOM public.geometry(Geometry, 4326) NOT NULL, -- 공간 데이터 (Point, LineString, Polygon 모두 수용)
|
||||
PROPERTIES JSONB NOT NULL DEFAULT '{}', -- 원본 GeoJSON properties
|
||||
REG_DT TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
MOD_DT TIMESTAMP
|
||||
);
|
||||
|
||||
-- 공간 인덱스
|
||||
CREATE INDEX IF NOT EXISTS IDX_SR_GEOM ON SENSITIVE_RESOURCE USING GIST(GEOM);
|
||||
|
||||
-- 카테고리 인덱스 (유형별 필터링)
|
||||
CREATE INDEX IF NOT EXISTS IDX_SR_CATEGORY ON SENSITIVE_RESOURCE (CATEGORY);
|
||||
|
||||
-- JSONB 인덱스 (properties 내부 검색용)
|
||||
CREATE INDEX IF NOT EXISTS IDX_SR_PROPERTIES ON SENSITIVE_RESOURCE USING GIN(PROPERTIES);
|
||||
|
||||
-- 카테고리 + 공간 복합 조회 최적화
|
||||
CREATE INDEX IF NOT EXISTS IDX_SR_CATEGORY_GEOM ON SENSITIVE_RESOURCE USING GIST(GEOM) WHERE CATEGORY IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE SENSITIVE_RESOURCE IS '민감자원 통합 테이블';
|
||||
COMMENT ON COLUMN SENSITIVE_RESOURCE.SR_ID IS '민감자원 ID';
|
||||
COMMENT ON COLUMN SENSITIVE_RESOURCE.CATEGORY IS '민감자원 유형 (양식장, 해수욕장, 무역항, 어항, 해안선_ESI 등)';
|
||||
COMMENT ON COLUMN SENSITIVE_RESOURCE.GEOM IS '공간 데이터 (EPSG:4326)';
|
||||
COMMENT ON COLUMN SENSITIVE_RESOURCE.PROPERTIES IS '원본 GeoJSON properties (JSONB)';
|
||||
COMMENT ON COLUMN SENSITIVE_RESOURCE.REG_DT IS '등록일시';
|
||||
COMMENT ON COLUMN SENSITIVE_RESOURCE.MOD_DT IS '수정일시';
|
||||
@ -4,6 +4,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 추가
|
||||
- 확산예측: 예측 실행 시 기상정보(풍속·풍향·기압·파고·수온·기온·염분 등) 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,
|
||||
};
|
||||
}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user