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

병합
jhkang feature/layer-data-table-mapping 에서 develop 로 3 commits 를 머지했습니다 2026-03-23 19:10:36 +09:00
27개의 변경된 파일708개의 추가작업 그리고 126개의 파일을 삭제

파일 보기

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

파일 보기

@ -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 '날씨 특보 문자열';

파일 보기

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