release: 2026-03-24 (160건 커밋) #118
@ -83,7 +83,5 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"deny": [],
|
||||
"allow": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"applied_global_version": "1.6.1",
|
||||
"applied_date": "2026-03-20",
|
||||
"applied_date": "2026-03-22",
|
||||
"project_type": "react-ts",
|
||||
"gitea_url": "https://gitea.gc-si.dev",
|
||||
"custom_pre_commit": true
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -18,6 +18,7 @@ interface Layer {
|
||||
cmn_cd_nm: string
|
||||
cmn_cd_level: number
|
||||
clnm: string | null
|
||||
data_tbl_nm: string | null
|
||||
}
|
||||
|
||||
// DB 컬럼 → API 응답 컬럼 매핑 (프론트엔드 호환성 유지)
|
||||
@ -27,7 +28,8 @@ const LAYER_COLUMNS = `
|
||||
LAYER_FULL_NM AS cmn_cd_full_nm,
|
||||
LAYER_NM AS cmn_cd_nm,
|
||||
LAYER_LEVEL AS cmn_cd_level,
|
||||
WMS_LAYER_NM AS clnm
|
||||
WMS_LAYER_NM AS clnm,
|
||||
DATA_TBL_NM AS data_tbl_nm
|
||||
`.trim()
|
||||
|
||||
// 모든 라우트에 파라미터 살균 적용
|
||||
@ -216,6 +218,7 @@ router.get('/admin/list', requireAuth, requireRole('ADMIN'), async (req, res) =>
|
||||
LAYER_NM AS "layerNm",
|
||||
LAYER_LEVEL AS "layerLevel",
|
||||
WMS_LAYER_NM AS "wmsLayerNm",
|
||||
DATA_TBL_NM AS "dataTblNm",
|
||||
USE_YN AS "useYn",
|
||||
SORT_ORD AS "sortOrd",
|
||||
TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm"
|
||||
@ -297,11 +300,12 @@ router.post('/admin/create', requireAuth, requireRole('ADMIN'), async (req, res)
|
||||
layerNm?: string
|
||||
layerLevel?: number
|
||||
wmsLayerNm?: string
|
||||
dataTblNm?: string
|
||||
useYn?: string
|
||||
sortOrd?: number
|
||||
}
|
||||
|
||||
const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, useYn, sortOrd } = body
|
||||
const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, dataTblNm, useYn, sortOrd } = body
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
|
||||
@ -328,20 +332,26 @@ router.post('/admin/create', requireAuth, requireRole('ADMIN'), async (req, res)
|
||||
return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' })
|
||||
}
|
||||
}
|
||||
if (dataTblNm !== undefined && dataTblNm !== null && dataTblNm !== '') {
|
||||
if (!isValidStringLength(dataTblNm, 100) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(dataTblNm)) {
|
||||
return res.status(400).json({ error: '데이터 테이블명은 100자 이내의 유효한 PostgreSQL 테이블명이어야 합니다.' })
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedLayerCd = sanitizeString(layerCd)
|
||||
const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
|
||||
const sanitizedLayerFullNm = sanitizeString(layerFullNm)
|
||||
const sanitizedLayerNm = sanitizeString(layerNm)
|
||||
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
|
||||
const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null
|
||||
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
|
||||
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
|
||||
|
||||
const { rows } = await wingPool.query(
|
||||
`INSERT INTO LAYER (LAYER_CD, UP_LAYER_CD, LAYER_FULL_NM, LAYER_NM, LAYER_LEVEL, WMS_LAYER_NM, USE_YN, SORT_ORD, DEL_YN)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'N')
|
||||
`INSERT INTO LAYER (LAYER_CD, UP_LAYER_CD, LAYER_FULL_NM, LAYER_NM, LAYER_LEVEL, WMS_LAYER_NM, DATA_TBL_NM, USE_YN, SORT_ORD, DEL_YN)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'N')
|
||||
RETURNING LAYER_CD AS "layerCd"`,
|
||||
[sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedUseYn, sanitizedSortOrd]
|
||||
[sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedDataTblNm, sanitizedUseYn, sanitizedSortOrd]
|
||||
)
|
||||
|
||||
res.json(rows[0])
|
||||
@ -364,11 +374,12 @@ router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res)
|
||||
layerNm?: string
|
||||
layerLevel?: number
|
||||
wmsLayerNm?: string
|
||||
dataTblNm?: string
|
||||
useYn?: string
|
||||
sortOrd?: number
|
||||
}
|
||||
|
||||
const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, useYn, sortOrd } = body
|
||||
const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, dataTblNm, useYn, sortOrd } = body
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
|
||||
@ -395,22 +406,28 @@ router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res)
|
||||
return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' })
|
||||
}
|
||||
}
|
||||
if (dataTblNm !== undefined && dataTblNm !== null && dataTblNm !== '') {
|
||||
if (!isValidStringLength(dataTblNm, 100) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(dataTblNm)) {
|
||||
return res.status(400).json({ error: '데이터 테이블명은 100자 이내의 유효한 PostgreSQL 테이블명이어야 합니다.' })
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedLayerCd = sanitizeString(layerCd)
|
||||
const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
|
||||
const sanitizedLayerFullNm = sanitizeString(layerFullNm)
|
||||
const sanitizedLayerNm = sanitizeString(layerNm)
|
||||
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
|
||||
const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null
|
||||
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
|
||||
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
|
||||
|
||||
const { rows } = await wingPool.query(
|
||||
`UPDATE LAYER
|
||||
SET UP_LAYER_CD = $2, LAYER_FULL_NM = $3, LAYER_NM = $4, LAYER_LEVEL = $5,
|
||||
WMS_LAYER_NM = $6, USE_YN = $7, SORT_ORD = $8
|
||||
WMS_LAYER_NM = $6, DATA_TBL_NM = $7, USE_YN = $8, SORT_ORD = $9
|
||||
WHERE LAYER_CD = $1
|
||||
RETURNING LAYER_CD AS "layerCd"`,
|
||||
[sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedUseYn, sanitizedSortOrd]
|
||||
[sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedDataTblNm, sanitizedUseYn, sanitizedSortOrd]
|
||||
)
|
||||
|
||||
if (rows.length === 0) {
|
||||
|
||||
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 '날씨 특보 문자열';
|
||||
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,17 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 추가
|
||||
- 레이어: 레이어 데이터 테이블 매핑 구현 + 어장 팝업 수정
|
||||
- 확산예측: 예측 실행 시 기상정보(풍속·풍향·기압·파고·수온·기온·염분 등) ACDNT_WEATHER 테이블에 자동 저장
|
||||
- DB: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 19개 추가 (025 마이그레이션)
|
||||
- DB: 민감자원 데이터 마이그레이션 (026_sensitive_resources)
|
||||
|
||||
### 변경
|
||||
- 예측: 분석 API를 예측 서비스로 통합 (analysisRouter 제거)
|
||||
|
||||
## [2026-03-20.3]
|
||||
|
||||
### 추가
|
||||
- 보고서: 기능 강화 (HWPX 내보내기, 확산 지도 패널, 보고서 생성기 개선)
|
||||
- 관리자: 권한 트리 확장 (게시판관리·기준정보·연계관리 섹션 추가)
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
|
||||
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer } from '@deck.gl/layers'
|
||||
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer, GeoJsonLayer } from '@deck.gl/layers'
|
||||
import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'
|
||||
import type { StyleSpecification } from 'maplibre-gl'
|
||||
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { layerDatabase } from '@common/services/layerService'
|
||||
import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView'
|
||||
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi'
|
||||
import type { HydrDataStep, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi'
|
||||
import HydrParticleOverlay from './HydrParticleOverlay'
|
||||
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
|
||||
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||
@ -289,6 +289,24 @@ const PRIORITY_LABELS: Record<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',
|
||||
@ -342,6 +360,7 @@ interface MapViewProps {
|
||||
incidentCoord: { lat: number; lon: number }
|
||||
}
|
||||
sensitiveResources?: SensitiveResource[]
|
||||
sensitiveResourceGeojson?: SensitiveResourceFeatureCollection | null
|
||||
flyToTarget?: { lng: number; lat: number; zoom?: number } | null
|
||||
fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null
|
||||
centerPoints?: Array<{ lat: number; lon: number; time: number; model?: string }>
|
||||
@ -528,6 +547,7 @@ export function MapView({
|
||||
layerBrightness = 50,
|
||||
backtrackReplay,
|
||||
sensitiveResources = [],
|
||||
sensitiveResourceGeojson,
|
||||
flyToTarget,
|
||||
fitBoundsTarget,
|
||||
centerPoints = [],
|
||||
@ -559,6 +579,12 @@ export function MapView({
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(1)
|
||||
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null)
|
||||
// deck.gl 레이어 클릭 시 MapLibre 맵 클릭 핸들러 차단용 플래그 (민감자원 등)
|
||||
const deckClickHandledRef = useRef(false)
|
||||
// 클릭으로 열린 팝업(닫기 전까지 유지) 추적 — 호버 핸들러가 닫지 않도록 방지
|
||||
const persistentPopupRef = useRef(false)
|
||||
// 현재 호버 중인 민감자원 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) => {
|
||||
@ -569,6 +595,44 @@ export function MapView({
|
||||
const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
|
||||
const { lng, lat } = e.lngLat
|
||||
setCurrentPosition([lat, lng])
|
||||
// deck.gl 다른 레이어 onClick이 처리한 클릭 — 팝업 유지
|
||||
if (deckClickHandledRef.current) {
|
||||
deckClickHandledRef.current = false
|
||||
return
|
||||
}
|
||||
// 민감자원 hover 중이면 팝업 표시
|
||||
if (hoveredSensitiveRef.current) {
|
||||
const props = hoveredSensitiveRef.current
|
||||
const { category, ...rest } = props
|
||||
const entries = Object.entries(rest).filter(([k, v]) => k !== 'srId' && v !== null && v !== undefined && v !== '')
|
||||
persistentPopupRef.current = true
|
||||
setPopupInfo({
|
||||
longitude: lng,
|
||||
latitude: lat,
|
||||
content: (
|
||||
<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
|
||||
@ -716,7 +780,7 @@ export function MapView({
|
||||
getPath: (d: BoomLine) => d.coords.map(c => [c.lon, c.lat] as [number, number]),
|
||||
getColor: (d: BoomLine) => hexToRgba(PRIORITY_COLORS[d.priority] || '#f59e0b', 230),
|
||||
getWidth: (d: BoomLine) => PRIORITY_WEIGHTS[d.priority] || 2,
|
||||
getDashArray: (d: BoomLine) => d.status === 'PLANNED' ? [10, 5] : null,
|
||||
getDashArray: (d: BoomLine) => d.status === 'PLANNED' ? [10, 5] : [0, 0],
|
||||
dashJustified: true,
|
||||
widthMinPixels: 2,
|
||||
widthMaxPixels: 6,
|
||||
@ -1018,7 +1082,10 @@ export function MapView({
|
||||
),
|
||||
});
|
||||
} else if (!info.object) {
|
||||
setPopupInfo(null);
|
||||
// 클릭으로 열린 팝업(어장 등)이 있으면 호버로 닫지 않음
|
||||
if (!persistentPopupRef.current) {
|
||||
setPopupInfo(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
@ -1111,6 +1178,41 @@ export function MapView({
|
||||
)
|
||||
}
|
||||
|
||||
// --- 민감자원 GeoJSON 레이어 ---
|
||||
if (sensitiveResourceGeojson && sensitiveResourceGeojson.features.length > 0) {
|
||||
result.push(
|
||||
new GeoJsonLayer({
|
||||
id: 'sensitive-resource-geojson',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: sensitiveResourceGeojson as any,
|
||||
pickable: true,
|
||||
stroked: true,
|
||||
filled: true,
|
||||
pointRadiusMinPixels: 10,
|
||||
pointRadiusMaxPixels: 20,
|
||||
lineWidthMinPixels: 1,
|
||||
getLineWidth: 1.5,
|
||||
getFillColor: (f: { properties: { category?: string } | null }) => {
|
||||
const cat = f.properties?.category ?? '';
|
||||
const [r, g, b] = categoryToRgb(cat);
|
||||
return [r, g, b, 80] as [number, number, number, number];
|
||||
},
|
||||
getLineColor: (f: { properties: { category?: string } | null }) => {
|
||||
const cat = f.properties?.category ?? '';
|
||||
const [r, g, b] = categoryToRgb(cat);
|
||||
return [r, g, b, 210] as [number, number, number, number];
|
||||
},
|
||||
onHover: (info: PickingInfo) => {
|
||||
if (info.object) {
|
||||
hoveredSensitiveRef.current = (info.object as { properties: Record<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) {
|
||||
@ -1225,12 +1327,12 @@ export function MapView({
|
||||
// 거리/면적 측정 레이어
|
||||
result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements))
|
||||
|
||||
return result
|
||||
return result.filter(Boolean)
|
||||
}, [
|
||||
oilTrajectory, currentTime, selectedModels,
|
||||
boomLines, isDrawingBoom, drawingPoints,
|
||||
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
|
||||
sensitiveResources, centerPoints, windData,
|
||||
sensitiveResources, sensitiveResourceGeojson, centerPoints, windData,
|
||||
showWind, showBeached, showTimeLabel, simulationStartTime,
|
||||
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode,
|
||||
])
|
||||
@ -1318,7 +1420,10 @@ export function MapView({
|
||||
longitude={popupInfo.longitude}
|
||||
latitude={popupInfo.latitude}
|
||||
anchor="bottom"
|
||||
onClose={() => setPopupInfo(null)}
|
||||
onClose={() => {
|
||||
persistentPopupRef.current = false
|
||||
setPopupInfo(null)
|
||||
}}
|
||||
>
|
||||
<div className="text-[#333]">{popupInfo.content}</div>
|
||||
</Popup>
|
||||
|
||||
@ -7,6 +7,7 @@ export interface LayerNode {
|
||||
name: string
|
||||
level: number
|
||||
layerName: string | null
|
||||
dataTblNm?: string | null
|
||||
icon?: string
|
||||
count?: number
|
||||
defaultOn?: boolean
|
||||
|
||||
@ -46,6 +46,7 @@ export interface LayerDTO {
|
||||
cmn_cd_nm: string
|
||||
cmn_cd_level: number
|
||||
clnm: string | null
|
||||
data_tbl_nm?: string | null
|
||||
icon?: string
|
||||
count?: number
|
||||
children?: LayerDTO[]
|
||||
@ -58,6 +59,7 @@ export interface Layer {
|
||||
fullName: string
|
||||
level: number
|
||||
wmsLayer: string | null
|
||||
dataTblNm?: string | null
|
||||
icon?: string
|
||||
count?: number
|
||||
children?: Layer[]
|
||||
@ -72,6 +74,7 @@ function convertToLayer(dto: LayerDTO): Layer {
|
||||
fullName: dto.cmn_cd_full_nm,
|
||||
level: dto.cmn_cd_level,
|
||||
wmsLayer: dto.clnm,
|
||||
dataTblNm: dto.data_tbl_nm,
|
||||
icon: dto.icon,
|
||||
count: dto.count,
|
||||
children: dto.children ? dto.children.map(convertToLayer) : undefined,
|
||||
|
||||
@ -8,6 +8,7 @@ export interface Layer {
|
||||
fullName: string
|
||||
level: number
|
||||
wmsLayer: string | null
|
||||
dataTblNm?: string | null
|
||||
icon?: string
|
||||
count?: number
|
||||
children?: Layer[]
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1,3 +1,9 @@
|
||||
/* 바람 입자 캔버스(z-index: 450) 위에 팝업이 표시되도록 z-index 설정
|
||||
@layer 밖에 위치해야 non-layered CSS인 MapLibre 스타일보다 우선순위를 가짐 */
|
||||
.maplibregl-popup {
|
||||
z-index: 500;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */
|
||||
.cctv-dark-popup .maplibregl-popup-content {
|
||||
|
||||
@ -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,8 +14,8 @@ import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore'
|
||||
import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine'
|
||||
import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
|
||||
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory } from '../services/predictionApi'
|
||||
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, WindPoint } from '../services/predictionApi'
|
||||
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory, fetchSensitiveResources, fetchSensitiveResourcesGeojson } from '../services/predictionApi'
|
||||
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, SensitiveResourceCategory, SensitiveResourceFeatureCollection, WindPoint } from '../services/predictionApi'
|
||||
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
|
||||
import SimulationErrorModal from './SimulationErrorModal'
|
||||
import { api } from '@common/services/api'
|
||||
@ -24,6 +24,13 @@ import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal'
|
||||
|
||||
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
|
||||
|
||||
const toLocalDateTimeStr = (raw: string): string => {
|
||||
const d = new Date(raw)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 민감자원 타입 + 데모 데이터
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -38,20 +45,13 @@ export interface SensitiveResource {
|
||||
}
|
||||
|
||||
export interface DisplayControls {
|
||||
showCurrent: boolean; // 유향/유속
|
||||
showWind: boolean; // 풍향/풍속
|
||||
showBeached: boolean; // 해안부착
|
||||
showTimeLabel: boolean; // 시간 표시
|
||||
showCurrent: boolean; // 유향/유속
|
||||
showWind: boolean; // 풍향/풍속
|
||||
showBeached: boolean; // 해안부착
|
||||
showTimeLabel: boolean; // 시간 표시
|
||||
showSensitiveResources: boolean; // 민감자원
|
||||
}
|
||||
|
||||
const DEMO_SENSITIVE_RESOURCES: SensitiveResource[] = [
|
||||
{ id: 'bc-1', name: '종포 해수욕장', type: 'beach', lat: 34.728, lon: 127.679, radiusM: 350, arrivalTimeH: 1 },
|
||||
{ id: 'aq-1', name: '국동 전복 양식장', type: 'aquaculture', lat: 34.718, lon: 127.672, radiusM: 500, arrivalTimeH: 3 },
|
||||
{ id: 'ec-1', name: '여자만 습지보호구역', type: 'ecology', lat: 34.758, lon: 127.614, radiusM: 1200, arrivalTimeH: 6 },
|
||||
{ id: 'aq-2', name: '화태도 김 양식장', type: 'aquaculture', lat: 34.648, lon: 127.652, radiusM: 800, arrivalTimeH: 10 },
|
||||
{ id: 'aq-3', name: '개도 해안 양식장', type: 'aquaculture', lat: 34.612, lon: 127.636, radiusM: 600, arrivalTimeH: 18 },
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 데모 궤적 생성 (seeded PRNG — deterministic)
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -139,6 +139,8 @@ export function OilSpillView() {
|
||||
|
||||
// 민감자원
|
||||
const [sensitiveResources, setSensitiveResources] = useState<SensitiveResource[]>([])
|
||||
const [sensitiveResourceCategories, setSensitiveResourceCategories] = useState<SensitiveResourceCategory[]>([])
|
||||
const [sensitiveResourceGeojson, setSensitiveResourceGeojson] = useState<SensitiveResourceFeatureCollection | null>(null)
|
||||
|
||||
// 오일펜스 배치 상태
|
||||
const [boomLines, setBoomLines] = useState<BoomLine[]>([])
|
||||
@ -162,6 +164,7 @@ export function OilSpillView() {
|
||||
showWind: false,
|
||||
showBeached: false,
|
||||
showTimeLabel: false,
|
||||
showSensitiveResources: false,
|
||||
})
|
||||
|
||||
// 타임라인 플레이어 상태
|
||||
@ -217,7 +220,7 @@ export function OilSpillView() {
|
||||
setOilTrajectory(demoTrajectory)
|
||||
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
|
||||
setBoomLines(demoBooms)
|
||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||
setSensitiveResources([])
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeSubTab])
|
||||
@ -469,7 +472,7 @@ export function OilSpillView() {
|
||||
setSelectedAnalysis(analysis)
|
||||
setCenterPoints([])
|
||||
if (analysis.occurredAt) {
|
||||
setAccidentTime(analysis.occurredAt.slice(0, 16))
|
||||
setAccidentTime(toLocalDateTimeStr(analysis.occurredAt))
|
||||
}
|
||||
if (analysis.lon != null && analysis.lat != null) {
|
||||
setIncidentCoord({ lon: analysis.lon, lat: analysis.lat })
|
||||
@ -519,7 +522,13 @@ export function OilSpillView() {
|
||||
if (sbModel) setSummaryByModel(sbModel);
|
||||
if (stepSbModel) setStepSummariesByModel(stepSbModel);
|
||||
if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings))
|
||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||
setSensitiveResources([])
|
||||
fetchSensitiveResources(analysis.acdntSn)
|
||||
.then(setSensitiveResourceCategories)
|
||||
.catch(err => console.warn('[prediction] 민감자원 조회 실패:', err))
|
||||
fetchSensitiveResourcesGeojson(analysis.acdntSn)
|
||||
.then(setSensitiveResourceGeojson)
|
||||
.catch(err => console.warn('[prediction] 민감자원 GeoJSON 조회 실패:', err))
|
||||
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
||||
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
||||
pendingPlayRef.current = true
|
||||
@ -541,7 +550,8 @@ export function OilSpillView() {
|
||||
const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48)
|
||||
setOilTrajectory(demoTrajectory)
|
||||
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings))
|
||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||
setSensitiveResources([])
|
||||
setSensitiveResourceCategories([])
|
||||
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
||||
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
||||
pendingPlayRef.current = true
|
||||
@ -555,7 +565,7 @@ export function OilSpillView() {
|
||||
setDrawingPoints(prev => [...prev, { lat, lon }])
|
||||
} else if (drawAnalysisMode === 'polygon') {
|
||||
setAnalysisPolygonPoints(prev => [...prev, { lat, lon }])
|
||||
} else {
|
||||
} else if (isSelectingLocation) {
|
||||
setIncidentCoord({ lon, lat })
|
||||
setIsSelectingLocation(false)
|
||||
}
|
||||
@ -567,7 +577,7 @@ export function OilSpillView() {
|
||||
setAnalysisResult(null)
|
||||
}
|
||||
|
||||
const handleRunPolygonAnalysis = () => {
|
||||
const handleRunPolygonAnalysis = async () => {
|
||||
if (analysisPolygonPoints.length < 3) return
|
||||
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
|
||||
const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1
|
||||
@ -582,7 +592,7 @@ export function OilSpillView() {
|
||||
setDrawAnalysisMode(null)
|
||||
}
|
||||
|
||||
const handleRunCircleAnalysis = () => {
|
||||
const handleRunCircleAnalysis = async () => {
|
||||
if (!incidentCoord) return
|
||||
const radiusM = circleRadiusNm * 1852
|
||||
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
|
||||
@ -615,7 +625,7 @@ export function OilSpillView() {
|
||||
const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => {
|
||||
setIncidentCoord({ lat: result.lat, lon: result.lon })
|
||||
setFlyToCoord({ lat: result.lat, lon: result.lon })
|
||||
setAccidentTime(result.occurredAt.slice(0, 16))
|
||||
setAccidentTime(toLocalDateTimeStr(result.occurredAt))
|
||||
setOilType(result.oilType)
|
||||
setSpillAmount(parseFloat(result.volume.toFixed(4)))
|
||||
setSpillUnit('kL')
|
||||
@ -795,7 +805,7 @@ export function OilSpillView() {
|
||||
setStepSummariesByModel(newStepSummariesByModel);
|
||||
const booms = generateAIBoomLines(merged, effectiveCoord, algorithmSettings);
|
||||
setBoomLines(booms);
|
||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES);
|
||||
setSensitiveResources([]);
|
||||
setCurrentStep(0);
|
||||
setIsPlaying(true);
|
||||
setFlyToCoord({ lon: effectiveCoord.lon, lat: effectiveCoord.lat });
|
||||
@ -805,11 +815,26 @@ export function OilSpillView() {
|
||||
setSimulationError(errors.join('; '));
|
||||
} else {
|
||||
simulationSucceeded = true;
|
||||
const effectiveAcdntSn = data.acdntSn ?? selectedAnalysis?.acdntSn;
|
||||
if (effectiveCoord) {
|
||||
fetchWeatherSnapshotForCoord(effectiveCoord.lat, effectiveCoord.lon)
|
||||
.then(snapshot => useWeatherSnapshotStore.getState().setSnapshot(snapshot))
|
||||
.then(snapshot => {
|
||||
useWeatherSnapshotStore.getState().setSnapshot(snapshot);
|
||||
if (effectiveAcdntSn) {
|
||||
api.post(`/incidents/${effectiveAcdntSn}/weather`, snapshot)
|
||||
.catch(err => console.warn('[weather] 기상 저장 실패:', err));
|
||||
}
|
||||
})
|
||||
.catch(err => console.warn('[weather] 기상 데이터 수집 실패:', err));
|
||||
}
|
||||
if (effectiveAcdntSn) {
|
||||
fetchSensitiveResources(effectiveAcdntSn)
|
||||
.then(setSensitiveResourceCategories)
|
||||
.catch(err => console.warn('[prediction] 민감자원 조회 실패:', err));
|
||||
fetchSensitiveResourcesGeojson(effectiveAcdntSn)
|
||||
.then(setSensitiveResourceGeojson)
|
||||
.catch(err => console.warn('[prediction] 민감자원 GeoJSON 조회 실패:', err));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const msg =
|
||||
@ -976,6 +1001,7 @@ export function OilSpillView() {
|
||||
onLayerOpacityChange={setLayerOpacity}
|
||||
layerBrightness={layerBrightness}
|
||||
onLayerBrightnessChange={setLayerBrightness}
|
||||
sensitiveResources={sensitiveResourceCategories}
|
||||
onImageAnalysisResult={handleImageAnalysisResult}
|
||||
/>
|
||||
)}
|
||||
@ -1004,6 +1030,7 @@ export function OilSpillView() {
|
||||
layerOpacity={layerOpacity}
|
||||
layerBrightness={layerBrightness}
|
||||
sensitiveResources={sensitiveResources}
|
||||
sensitiveResourceGeojson={displayControls.showSensitiveResources ? sensitiveResourceGeojson : null}
|
||||
lightMode
|
||||
centerPoints={centerPoints.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))}
|
||||
windData={windData}
|
||||
|
||||
@ -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={{
|
||||
|
||||
@ -81,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
|
||||
@ -654,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
|
||||
}
|
||||
|
||||
@ -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