Merge remote-tracking branch 'origin/develop' into feature/stitch-mcp

# Conflicts:
#	docs/RELEASE-NOTES.md
This commit is contained in:
leedano 2026-03-24 17:49:48 +09:00
커밋 a3b1a701e4
51개의 변경된 파일868805개의 추가작업 그리고 529개의 파일을 삭제

파일 보기

@ -5,29 +5,29 @@
}, },
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(curl -s *)", "Bash(npm run *)",
"Bash(fnm *)", "Bash(npm install *)",
"Bash(git add *)", "Bash(npm test *)",
"Bash(npx *)",
"Bash(node *)",
"Bash(git status)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(git branch *)", "Bash(git branch *)",
"Bash(git checkout *)", "Bash(git checkout *)",
"Bash(git add *)",
"Bash(git commit *)", "Bash(git commit *)",
"Bash(git config *)",
"Bash(git diff *)",
"Bash(git fetch *)",
"Bash(git log *)",
"Bash(git merge *)",
"Bash(git pull *)", "Bash(git pull *)",
"Bash(git fetch *)",
"Bash(git merge *)",
"Bash(git stash *)",
"Bash(git remote *)", "Bash(git remote *)",
"Bash(git config *)",
"Bash(git rev-parse *)", "Bash(git rev-parse *)",
"Bash(git show *)", "Bash(git show *)",
"Bash(git stash *)",
"Bash(git status)",
"Bash(git tag *)", "Bash(git tag *)",
"Bash(node *)", "Bash(curl -s *)",
"Bash(npm install *)", "Bash(fnm *)"
"Bash(npm run *)",
"Bash(npm test *)",
"Bash(npx *)"
], ],
"deny": [ "deny": [
"Bash(git push --force*)", "Bash(git push --force*)",
@ -83,7 +83,5 @@
] ]
} }
] ]
}, }
"deny": [], }
"allow": []
}

파일 보기

@ -1,7 +1,7 @@
{ {
"applied_global_version": "1.6.1", "applied_global_version": "1.6.1",
"applied_date": "2026-03-19", "applied_date": "2026-03-24",
"project_type": "react-ts", "project_type": "react-ts",
"gitea_url": "https://gitea.gc-si.dev", "gitea_url": "https://gitea.gc-si.dev",
"custom_pre_commit": true "custom_pre_commit": true
} }

파일 보기

@ -5,6 +5,7 @@ import {
getIncident, getIncident,
listIncidentPredictions, listIncidentPredictions,
getIncidentWeather, getIncidentWeather,
saveIncidentWeather,
getIncidentMedia, getIncidentMedia,
} from './incidentsService.js'; } 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 — 미디어 정보 // 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>; const r = rows[0] as Record<string, unknown>;
return { return {
locNm: r.loc_nm as string, locNm: (r.loc_nm as string | null) ?? '-',
obsDtm: (r.obs_dtm as Date).toISOString(), obsDtm: r.obs_dtm ? (r.obs_dtm as Date).toISOString() : '-',
icon: r.icon as string, icon: (r.icon as string | null) ?? '',
temp: r.temp as string, temp: (r.temp as string | null) ?? '-',
weatherDc: r.weather_dc as string, weatherDc: (r.weather_dc as string | null) ?? '-',
wind: r.wind as string, wind: (r.wind as string | null) ?? '-',
wave: r.wave as string, wave: (r.wave as string | null) ?? '-',
humid: r.humid as string, humid: (r.humid as string | null) ?? '-',
vis: r.vis as string, vis: (r.vis as string | null) ?? '-',
sst: r.sst as string, sst: (r.sst as string | null) ?? '-',
tide: r.tide as string, tide: (r.tide as string | null) ?? '-',
highTide: r.high_tide as string, highTide: (r.high_tide as string | null) ?? '-',
lowTide: r.low_tide as string, lowTide: (r.low_tide as string | null) ?? '-',
forecast: (r.forecast as Array<{ hour: string; icon: string; temp: string }>) ?? [], 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,8 @@ import multer from 'multer';
import { import {
listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt, listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt,
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory, createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn,
getPredictionParticlesGeojsonByAcdntSn, getSensitivityEvaluationGeojsonByAcdntSn,
} from './predictionService.js'; } from './predictionService.js';
import { analyzeImageFile } from './imageAnalyzeService.js'; import { analyzeImageFile } from './imageAnalyzeService.js';
import { isValidNumber } from '../middleware/security.js'; import { isValidNumber } from '../middleware/security.js';
@ -64,6 +66,70 @@ 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/analyses/:acdntSn/spread-particles — 예측 확산 파티클 GeoJSON
router.get('/analyses/:acdntSn/spread-particles', 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 getPredictionParticlesGeojsonByAcdntSn(acdntSn);
res.json(result);
} catch (err) {
console.error('[prediction] 확산 파티클 GeoJSON 조회 오류:', err);
res.status(500).json({ error: '확산 파티클 GeoJSON 조회 실패' });
}
});
// GET /api/prediction/analyses/:acdntSn/sensitivity-evaluation — 통합민감도 평가 GeoJSON
router.get('/analyses/:acdntSn/sensitivity-evaluation', 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 getSensitivityEvaluationGeojsonByAcdntSn(acdntSn);
res.json(result);
} catch (err) {
console.error('[prediction] 통합민감도 평가 GeoJSON 조회 오류:', err);
res.status(500).json({ error: '통합민감도 평가 GeoJSON 조회 실패' });
}
});
// GET /api/prediction/backtrack — 사고별 역추적 목록 // GET /api/prediction/backtrack — 사고별 역추적 목록
router.get('/backtrack', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => { router.get('/backtrack', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
try { try {

파일 보기

@ -432,6 +432,8 @@ interface TrajectoryTimeStep {
particles: TrajectoryParticle[]; particles: TrajectoryParticle[];
remaining_volume_m3: number; remaining_volume_m3: number;
weathered_volume_m3: number; weathered_volume_m3: number;
evaporation_volume_m3?: number;
dispersion_volume_m3?: number;
pollution_area_km2: number; pollution_area_km2: number;
beached_volume_m3: number; beached_volume_m3: number;
pollution_coast_length_m: number; pollution_coast_length_m: number;
@ -453,6 +455,8 @@ interface SingleModelTrajectoryResult {
summary: { summary: {
remainingVolume: number; remainingVolume: number;
weatheredVolume: number; weatheredVolume: number;
evaporationVolume: number;
dispersionVolume: number;
pollutionArea: number; pollutionArea: number;
beachedVolume: number; beachedVolume: number;
pollutionCoastLength: number; pollutionCoastLength: number;
@ -460,6 +464,8 @@ interface SingleModelTrajectoryResult {
stepSummaries: Array<{ stepSummaries: Array<{
remainingVolume: number; remainingVolume: number;
weatheredVolume: number; weatheredVolume: number;
evaporationVolume: number;
dispersionVolume: number;
pollutionArea: number; pollutionArea: number;
beachedVolume: number; beachedVolume: number;
pollutionCoastLength: number; pollutionCoastLength: number;
@ -474,6 +480,8 @@ interface TrajectoryResult {
summary: { summary: {
remainingVolume: number; remainingVolume: number;
weatheredVolume: number; weatheredVolume: number;
evaporationVolume: number;
dispersionVolume: number;
pollutionArea: number; pollutionArea: number;
beachedVolume: number; beachedVolume: number;
pollutionCoastLength: number; pollutionCoastLength: number;
@ -500,6 +508,8 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: strin
const summary = { const summary = {
remainingVolume: lastStep.remaining_volume_m3, remainingVolume: lastStep.remaining_volume_m3,
weatheredVolume: lastStep.weathered_volume_m3, weatheredVolume: lastStep.weathered_volume_m3,
evaporationVolume: lastStep.evaporation_volume_m3 ?? lastStep.weathered_volume_m3 * 0.65,
dispersionVolume: lastStep.dispersion_volume_m3 ?? lastStep.weathered_volume_m3 * 0.35,
pollutionArea: lastStep.pollution_area_km2, pollutionArea: lastStep.pollution_area_km2,
beachedVolume: lastStep.beached_volume_m3, beachedVolume: lastStep.beached_volume_m3,
pollutionCoastLength: lastStep.pollution_coast_length_m, pollutionCoastLength: lastStep.pollution_coast_length_m,
@ -514,6 +524,8 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: strin
const stepSummaries = rawResult.map((step) => ({ const stepSummaries = rawResult.map((step) => ({
remainingVolume: step.remaining_volume_m3, remainingVolume: step.remaining_volume_m3,
weatheredVolume: step.weathered_volume_m3, weatheredVolume: step.weathered_volume_m3,
evaporationVolume: step.evaporation_volume_m3 ?? step.weathered_volume_m3 * 0.65,
dispersionVolume: step.dispersion_volume_m3 ?? step.weathered_volume_m3 * 0.35,
pollutionArea: step.pollution_area_km2, pollutionArea: step.pollution_area_km2,
beachedVolume: step.beached_volume_m3, beachedVolume: step.beached_volume_m3,
pollutionCoastLength: step.pollution_coast_length_m, pollutionCoastLength: step.pollution_coast_length_m,
@ -585,6 +597,160 @@ export async function getAnalysisTrajectory(acdntSn: number): Promise<Trajectory
}; };
} }
export async function getSensitiveResourcesByAcdntSn(
acdntSn: number,
): Promise<{ category: string; count: number; totalArea: number | null }[]> {
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,
CASE
WHEN bool_and(sr.PROPERTIES ? 'area')
THEN SUM((sr.PROPERTIES->>'area')::float)
ELSE NULL
END AS total_area
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),
totalArea: r['total_area'] != null ? Number(r['total_area']) : null,
}));
}
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 getSensitivityEvaluationGeojsonByAcdntSn(
acdntSn: number,
): Promise<{ type: 'FeatureCollection'; features: unknown[] }> {
const acdntSql = `SELECT LAT, LNG FROM wing.ACDNT WHERE ACDNT_SN = $1 AND USE_YN = 'Y'`;
const { rows: acdntRows } = await wingPool.query(acdntSql, [acdntSn]);
if (acdntRows.length === 0 || acdntRows[0]['lat'] == null) return { type: 'FeatureCollection', features: [] };
const lat = Number(acdntRows[0]['lat']);
const lng = Number(acdntRows[0]['lng']);
const sql = `
SELECT SR_ID, PROPERTIES,
ST_AsGeoJSON(GEOM)::jsonb AS geom_json,
ST_Area(GEOM::geography) / 1000000.0 AS area_km2
FROM wing.SENSITIVE_EVALUATION
WHERE ST_DWithin(
GEOM::geography,
ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography,
10000
)
ORDER BY SR_ID
`;
const { rows } = await wingPool.query(sql, [lat, lng]);
const features = rows.map((r: Record<string, unknown>) => ({
type: 'Feature',
geometry: r['geom_json'],
properties: {
srId: Number(r['sr_id']),
area_km2: Number(r['area_km2']),
...(r['properties'] as Record<string, unknown> ?? {}),
},
}));
return { type: 'FeatureCollection', features };
}
export async function getPredictionParticlesGeojsonByAcdntSn(
acdntSn: number,
): Promise<{ type: 'FeatureCollection'; features: unknown[]; maxStep: number }> {
const sql = `
SELECT ALGO_CD, RSLT_DATA
FROM wing.PRED_EXEC
WHERE ACDNT_SN = $1
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
AND EXEC_STTS_CD = 'COMPLETED'
AND RSLT_DATA IS NOT NULL
`;
const { rows } = await wingPool.query(sql, [acdntSn]);
if (rows.length === 0) return { type: 'FeatureCollection', features: [], maxStep: 0 };
const ALGO_TO_MODEL: Record<string, string> = { OPENDRIFT: 'OpenDrift', POSEIDON: 'POSEIDON' };
const features: unknown[] = [];
let globalMaxStep = 0;
for (const row of rows) {
const model = ALGO_TO_MODEL[String(row['algo_cd'])] ?? String(row['algo_cd']);
const steps = row['rslt_data'] as TrajectoryTimeStep[];
const maxStep = steps.length - 1;
if (maxStep > globalMaxStep) globalMaxStep = maxStep;
steps.forEach((step, stepIdx) => {
step.particles.forEach(p => {
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
properties: {
model,
time: stepIdx,
stranded: p.stranded ?? 0,
isLastStep: stepIdx === maxStep,
},
});
});
});
}
return { type: 'FeatureCollection', features, maxStep: globalMaxStep };
}
export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> { export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
const sql = ` const sql = `
SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD, 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) => { router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => {
try { try {
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections, mapCaptureImg } = req.body; const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections, step3MapImg, step6MapImg } = req.body;
const result = await createReport({ const result = await createReport({
tmplSn, tmplSn,
ctgrSn, ctgrSn,
@ -101,7 +101,8 @@ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req
jrsdCd, jrsdCd,
sttsCd, sttsCd,
authorId: req.user!.sub, authorId: req.user!.sub,
mapCaptureImg, step3MapImg,
step6MapImg,
sections, sections,
}); });
res.status(201).json(result); res.status(201).json(result);
@ -125,8 +126,8 @@ router.post('/:sn/update', requireAuth, requirePermission('reports', 'UPDATE'),
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' }); res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
return; return;
} }
const { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg } = req.body; const { title, jrsdCd, sttsCd, acdntSn, sections, step3MapImg, step6MapImg } = req.body;
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg }, req.user!.sub); await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, step3MapImg, step6MapImg }, req.user!.sub);
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
if (err instanceof AuthError) { if (err instanceof AuthError) {

파일 보기

@ -60,6 +60,7 @@ interface ReportListItem {
sttsCd: string; sttsCd: string;
authorId: string; authorId: string;
authorName: string; authorName: string;
acdntSn: number | null;
regDtm: string; regDtm: string;
mdfcnDtm: string | null; mdfcnDtm: string | null;
hasMapCapture: boolean; hasMapCapture: boolean;
@ -75,7 +76,8 @@ interface SectionData {
interface ReportDetail extends ReportListItem { interface ReportDetail extends ReportListItem {
acdntSn: number | null; acdntSn: number | null;
sections: SectionData[]; sections: SectionData[];
mapCaptureImg: string | null; step3MapImg: string | null;
step6MapImg: string | null;
} }
interface ListReportsInput { interface ListReportsInput {
@ -102,7 +104,8 @@ interface CreateReportInput {
jrsdCd?: string; jrsdCd?: string;
sttsCd?: string; sttsCd?: string;
authorId: string; authorId: string;
mapCaptureImg?: string; step3MapImg?: string;
step6MapImg?: string;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[]; sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
} }
@ -111,7 +114,8 @@ interface UpdateReportInput {
jrsdCd?: string; jrsdCd?: string;
sttsCd?: string; sttsCd?: string;
acdntSn?: number | null; acdntSn?: number | null;
mapCaptureImg?: string | null; step3MapImg?: string | null;
step6MapImg?: string | null;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[]; sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
} }
@ -260,8 +264,10 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
c.CTGR_CD, c.CTGR_NM, c.CTGR_CD, c.CTGR_NM,
r.TITLE, r.JRSD_CD, r.STTS_CD, r.TITLE, r.JRSD_CD, r.STTS_CD,
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME, r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
r.REG_DTM, r.MDFCN_DTM, r.ACDNT_SN, r.REG_DTM, r.MDFCN_DTM,
CASE WHEN r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '' THEN true ELSE false END AS HAS_MAP_CAPTURE 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 FROM REPORT r
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
@ -284,6 +290,7 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
sttsCd: r.stts_cd, sttsCd: r.stts_cd,
authorId: r.author_id, authorId: r.author_id,
authorName: r.author_name || '', authorName: r.author_name || '',
acdntSn: r.acdnt_sn,
regDtm: r.reg_dtm, regDtm: r.reg_dtm,
mdfcnDtm: r.mdfcn_dtm, mdfcnDtm: r.mdfcn_dtm,
hasMapCapture: r.has_map_capture, hasMapCapture: r.has_map_capture,
@ -300,8 +307,10 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
c.CTGR_CD, c.CTGR_NM, c.CTGR_CD, c.CTGR_NM,
r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN, r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN,
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME, r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
r.REG_DTM, r.MDFCN_DTM, r.MAP_CAPTURE_IMG, r.REG_DTM, r.MDFCN_DTM, r.STEP3_MAP_IMG, r.STEP6_MAP_IMG,
CASE WHEN r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '' THEN true ELSE false END AS HAS_MAP_CAPTURE 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 FROM REPORT r
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
@ -338,7 +347,8 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
authorName: r.author_name || '', authorName: r.author_name || '',
regDtm: r.reg_dtm, regDtm: r.reg_dtm,
mdfcnDtm: r.mdfcn_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, hasMapCapture: r.has_map_capture,
sections: sectRes.rows.map((s) => ({ sections: sectRes.rows.map((s) => ({
sectCd: s.sect_cd, sectCd: s.sect_cd,
@ -359,8 +369,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
await client.query('BEGIN'); await client.query('BEGIN');
const res = await client.query( const res = await client.query(
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, MAP_CAPTURE_IMG) `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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING REPORT_SN`, RETURNING REPORT_SN`,
[ [
input.tmplSn || null, input.tmplSn || null,
@ -370,7 +380,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
input.jrsdCd || null, input.jrsdCd || null,
input.sttsCd || 'DRAFT', input.sttsCd || 'DRAFT',
input.authorId, input.authorId,
input.mapCaptureImg || null, input.step3MapImg || null,
input.step6MapImg || null,
] ]
); );
const reportSn = res.rows[0].report_sn; const reportSn = res.rows[0].report_sn;
@ -442,9 +453,13 @@ export async function updateReport(
sets.push(`ACDNT_SN = $${idx++}`); sets.push(`ACDNT_SN = $${idx++}`);
params.push(input.acdntSn); params.push(input.acdntSn);
} }
if (input.mapCaptureImg !== undefined) { if (input.step3MapImg !== undefined) {
sets.push(`MAP_CAPTURE_IMG = $${idx++}`); sets.push(`STEP3_MAP_IMG = $${idx++}`);
params.push(input.mapCaptureImg); params.push(input.step3MapImg);
}
if (input.step6MapImg !== undefined) {
sets.push(`STEP6_MAP_IMG = $${idx++}`);
params.push(input.step6MapImg);
} }
params.push(reportSn); params.push(reportSn);

파일 보기

@ -18,6 +18,7 @@ interface Layer {
cmn_cd_nm: string cmn_cd_nm: string
cmn_cd_level: number cmn_cd_level: number
clnm: string | null clnm: string | null
data_tbl_nm: string | null
} }
// DB 컬럼 → API 응답 컬럼 매핑 (프론트엔드 호환성 유지) // DB 컬럼 → API 응답 컬럼 매핑 (프론트엔드 호환성 유지)
@ -27,7 +28,21 @@ const LAYER_COLUMNS = `
LAYER_FULL_NM AS cmn_cd_full_nm, LAYER_FULL_NM AS cmn_cd_full_nm,
LAYER_NM AS cmn_cd_nm, LAYER_NM AS cmn_cd_nm,
LAYER_LEVEL AS cmn_cd_level, LAYER_LEVEL AS cmn_cd_level,
WMS_LAYER_NM AS clnm WMS_LAYER_NM AS clnm,
DATA_TBL_NM AS data_tbl_nm
`.trim()
// 조상 중 하나라도 USE_YN='N'이면 제외하는 재귀 CTE
// 부모가 비활성화되면 자식도 공개 API에서 제외됨 (상속 방식)
const ACTIVE_TREE_CTE = `
WITH RECURSIVE active_tree AS (
SELECT LAYER_CD FROM LAYER
WHERE UP_LAYER_CD IS NULL AND USE_YN = 'Y' AND DEL_YN = 'N'
UNION ALL
SELECT l.LAYER_CD FROM LAYER l
JOIN active_tree a ON l.UP_LAYER_CD = a.LAYER_CD
WHERE l.USE_YN = 'Y' AND l.DEL_YN = 'N'
)
`.trim() `.trim()
// 모든 라우트에 파라미터 살균 적용 // 모든 라우트에 파라미터 살균 적용
@ -37,7 +52,10 @@ router.use(sanitizeParams)
router.get('/', async (_req, res) => { router.get('/', async (_req, res) => {
try { try {
const { rows } = await wingPool.query<Layer>( const { rows } = await wingPool.query<Layer>(
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD` `${ACTIVE_TREE_CTE}
SELECT ${LAYER_COLUMNS} FROM LAYER
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree)
ORDER BY LAYER_CD`
) )
const enrichedLayers = rows.map(enrichLayerWithMetadata) const enrichedLayers = rows.map(enrichLayerWithMetadata)
res.json(enrichedLayers) res.json(enrichedLayers)
@ -50,7 +68,10 @@ router.get('/', async (_req, res) => {
router.get('/tree/all', async (_req, res) => { router.get('/tree/all', async (_req, res) => {
try { try {
const { rows } = await wingPool.query<Layer>( const { rows } = await wingPool.query<Layer>(
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD` `${ACTIVE_TREE_CTE}
SELECT ${LAYER_COLUMNS} FROM LAYER
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree)
ORDER BY LAYER_CD`
) )
const enrichedLayers = rows.map(enrichLayerWithMetadata) const enrichedLayers = rows.map(enrichLayerWithMetadata)
@ -82,7 +103,10 @@ router.get('/tree/all', async (_req, res) => {
router.get('/wms/all', async (_req, res) => { router.get('/wms/all', async (_req, res) => {
try { try {
const { rows } = await wingPool.query<Layer>( const { rows } = await wingPool.query<Layer>(
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE WMS_LAYER_NM IS NOT NULL AND USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD` `${ACTIVE_TREE_CTE}
SELECT ${LAYER_COLUMNS} FROM LAYER
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree) AND WMS_LAYER_NM IS NOT NULL
ORDER BY LAYER_CD`
) )
const enrichedLayers = rows.map(enrichLayerWithMetadata) const enrichedLayers = rows.map(enrichLayerWithMetadata)
res.json(enrichedLayers) res.json(enrichedLayers)
@ -104,7 +128,10 @@ router.get('/level/:level', async (req, res) => {
} }
const { rows } = await wingPool.query<Layer>( const { rows } = await wingPool.query<Layer>(
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_LEVEL = $1 AND USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`, `${ACTIVE_TREE_CTE}
SELECT ${LAYER_COLUMNS} FROM LAYER
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree) AND LAYER_LEVEL = $1
ORDER BY LAYER_CD`,
[level] [level]
) )
const enrichedLayers = rows.map(enrichLayerWithMetadata) const enrichedLayers = rows.map(enrichLayerWithMetadata)
@ -191,6 +218,15 @@ router.get('/admin/list', requireAuth, requireRole('ADMIN'), async (req, res) =>
conditions.push(`USE_YN = $${params.length}`) conditions.push(`USE_YN = $${params.length}`)
} }
const rootCd = sanitizeString(String(req.query.rootCd ?? '')).trim()
if (rootCd) {
if (!/^[a-zA-Z0-9_-]+$/.test(rootCd) || !isValidStringLength(rootCd, 50)) {
return res.status(400).json({ error: '유효하지 않은 루트 레이어코드' })
}
params.push(`${rootCd}%`)
conditions.push(`LAYER_CD LIKE $${params.length}`)
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
// 데이터 쿼리 파라미터: WHERE 조건 + LIMIT + OFFSET // 데이터 쿼리 파라미터: WHERE 조건 + LIMIT + OFFSET
@ -201,19 +237,27 @@ router.get('/admin/list', requireAuth, requireRole('ADMIN'), async (req, res) =>
const [dataResult, countResult] = await Promise.all([ const [dataResult, countResult] = await Promise.all([
wingPool.query( wingPool.query(
`SELECT `SELECT
LAYER_CD AS "layerCd", t.*,
UP_LAYER_CD AS "upLayerCd", p.USE_YN AS "parentUseYn"
LAYER_FULL_NM AS "layerFullNm", FROM (
LAYER_NM AS "layerNm", SELECT
LAYER_LEVEL AS "layerLevel", LAYER_CD AS "layerCd",
WMS_LAYER_NM AS "wmsLayerNm", UP_LAYER_CD AS "upLayerCd",
USE_YN AS "useYn", LAYER_FULL_NM AS "layerFullNm",
SORT_ORD AS "sortOrd", LAYER_NM AS "layerNm",
TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm" LAYER_LEVEL AS "layerLevel",
FROM LAYER WMS_LAYER_NM AS "wmsLayerNm",
${whereClause} DATA_TBL_NM AS "dataTblNm",
ORDER BY LAYER_CD USE_YN AS "useYn",
LIMIT $${limitIdx} OFFSET $${offsetIdx}`, SORT_ORD AS "sortOrd",
TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm"
FROM LAYER
${whereClause}
ORDER BY LAYER_CD
LIMIT $${limitIdx} OFFSET $${offsetIdx}
) t
LEFT JOIN LAYER p ON t."upLayerCd" = p.LAYER_CD AND p.DEL_YN = 'N'
ORDER BY t."layerCd"`,
dataParams dataParams
), ),
wingPool.query( wingPool.query(
@ -288,11 +332,12 @@ router.post('/admin/create', requireAuth, requireRole('ADMIN'), async (req, res)
layerNm?: string layerNm?: string
layerLevel?: number layerLevel?: number
wmsLayerNm?: string wmsLayerNm?: string
dataTblNm?: string
useYn?: string useYn?: string
sortOrd?: number 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)) { if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
@ -319,20 +364,26 @@ router.post('/admin/create', requireAuth, requireRole('ADMIN'), async (req, res)
return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' }) 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 sanitizedLayerCd = sanitizeString(layerCd)
const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
const sanitizedLayerFullNm = sanitizeString(layerFullNm) const sanitizedLayerFullNm = sanitizeString(layerFullNm)
const sanitizedLayerNm = sanitizeString(layerNm) const sanitizedLayerNm = sanitizeString(layerNm)
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y' const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
const { rows } = await wingPool.query( 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) `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, 'N') VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'N')
RETURNING LAYER_CD AS "layerCd"`, 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]) res.json(rows[0])
@ -355,11 +406,12 @@ router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res)
layerNm?: string layerNm?: string
layerLevel?: number layerLevel?: number
wmsLayerNm?: string wmsLayerNm?: string
dataTblNm?: string
useYn?: string useYn?: string
sortOrd?: number 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)) { if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
@ -386,22 +438,28 @@ router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res)
return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' }) 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 sanitizedLayerCd = sanitizeString(layerCd)
const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
const sanitizedLayerFullNm = sanitizeString(layerFullNm) const sanitizedLayerFullNm = sanitizeString(layerFullNm)
const sanitizedLayerNm = sanitizeString(layerNm) const sanitizedLayerNm = sanitizeString(layerNm)
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y' const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
const { rows } = await wingPool.query( const { rows } = await wingPool.query(
`UPDATE LAYER `UPDATE LAYER
SET UP_LAYER_CD = $2, LAYER_FULL_NM = $3, LAYER_NM = $4, LAYER_LEVEL = $5, 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 WHERE LAYER_CD = $1
RETURNING LAYER_CD AS "layerCd"`, 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) { if (rows.length === 0) {
@ -428,6 +486,18 @@ router.post('/admin/delete', requireAuth, requireRole('ADMIN'), async (req, res)
const sanitizedCd = sanitizeString(layerCd) const sanitizedCd = sanitizeString(layerCd)
// 하위 레이어 존재 여부 확인 (자식이 있으면 삭제 차단)
const { rows: childRows } = await wingPool.query(
`SELECT COUNT(*)::int AS cnt FROM LAYER WHERE UP_LAYER_CD = $1 AND DEL_YN = 'N'`,
[sanitizedCd]
)
const childCount: number = childRows[0].cnt
if (childCount > 0) {
return res.status(400).json({
error: `하위 레이어 ${childCount}개가 있어 삭제할 수 없습니다. 하위 레이어를 먼저 삭제해주세요.`,
})
}
const { rows } = await wingPool.query( const { rows } = await wingPool.query(
`UPDATE LAYER SET DEL_YN = 'Y' WHERE LAYER_CD = $1 AND DEL_YN = 'N' `UPDATE LAYER SET DEL_YN = 'Y' WHERE LAYER_CD = $1 AND DEL_YN = 'N'
RETURNING LAYER_CD AS "layerCd"`, RETURNING LAYER_CD AS "layerCd"`,

파일 보기

@ -585,6 +585,7 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => {
status: 'DONE' | 'ERROR' status: 'DONE' | 'ERROR'
trajectory?: ReturnType<typeof transformResult>['trajectory'] trajectory?: ReturnType<typeof transformResult>['trajectory']
summary?: ReturnType<typeof transformResult>['summary'] summary?: ReturnType<typeof transformResult>['summary']
stepSummaries?: ReturnType<typeof transformResult>['stepSummaries']
centerPoints?: ReturnType<typeof transformResult>['centerPoints'] centerPoints?: ReturnType<typeof transformResult>['centerPoints']
windData?: ReturnType<typeof transformResult>['windData'] windData?: ReturnType<typeof transformResult>['windData']
hydrData?: ReturnType<typeof transformResult>['hydrData'] hydrData?: ReturnType<typeof transformResult>['hydrData']
@ -656,9 +657,9 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => {
WHERE PRED_EXEC_SN=$2`, WHERE PRED_EXEC_SN=$2`,
[JSON.stringify(pythonData.result), predExecSn] [JSON.stringify(pythonData.result), predExecSn]
) )
const { trajectory, summary, centerPoints, windData, hydrData } = const { trajectory, summary, stepSummaries, centerPoints, windData, hydrData } =
transformResult(pythonData.result, model) transformResult(pythonData.result, model)
return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, centerPoints, windData, hydrData } return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, stepSummaries, centerPoints, windData, hydrData }
} }
// 비동기 응답 (하위 호환) // 비동기 응답 (하위 호환)
@ -691,8 +692,8 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => {
// 결과 동기 대기 // 결과 동기 대기
try { try {
const rawResult = await runModelSync(jobId!, predExecSn, apiUrl) const rawResult = await runModelSync(jobId!, predExecSn, apiUrl)
const { trajectory, summary, centerPoints, windData, hydrData } = transformResult(rawResult, model) const { trajectory, summary, stepSummaries, centerPoints, windData, hydrData } = transformResult(rawResult, model)
return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, centerPoints, windData, hydrData } return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, stepSummaries, centerPoints, windData, hydrData }
} catch (syncErr) { } catch (syncErr) {
return { model, execSn: predExecSn, status: 'ERROR', error: (syncErr as Error).message } return { model, execSn: predExecSn, status: 'ERROR', error: (syncErr as Error).message }
} }
@ -848,6 +849,8 @@ interface PythonTimeStep {
particles: PythonParticle[] particles: PythonParticle[]
remaining_volume_m3: number remaining_volume_m3: number
weathered_volume_m3: number weathered_volume_m3: number
evaporation_m3?: number
dispersion_m3?: number
pollution_area_km2: number pollution_area_km2: number
beached_volume_m3: number beached_volume_m3: number
pollution_coast_length_m: number pollution_coast_length_m: number
@ -884,6 +887,8 @@ function transformResult(rawResult: PythonTimeStep[], model: string) {
const summary = { const summary = {
remainingVolume: lastStep.remaining_volume_m3, remainingVolume: lastStep.remaining_volume_m3,
weatheredVolume: lastStep.weathered_volume_m3, weatheredVolume: lastStep.weathered_volume_m3,
evaporationVolume: lastStep.evaporation_m3 ?? lastStep.weathered_volume_m3 * 0.65,
dispersionVolume: lastStep.dispersion_m3 ?? lastStep.weathered_volume_m3 * 0.35,
pollutionArea: lastStep.pollution_area_km2, pollutionArea: lastStep.pollution_area_km2,
beachedVolume: lastStep.beached_volume_m3, beachedVolume: lastStep.beached_volume_m3,
pollutionCoastLength: lastStep.pollution_coast_length_m, pollutionCoastLength: lastStep.pollution_coast_length_m,
@ -901,7 +906,16 @@ function transformResult(rawResult: PythonTimeStep[], model: string) {
? { value: step.hydr_data, grid: step.hydr_grid } ? { value: step.hydr_data, grid: step.hydr_grid }
: null : null
) )
return { trajectory, summary, centerPoints, windData, hydrData } const stepSummaries = rawResult.map((step) => ({
remainingVolume: step.remaining_volume_m3,
weatheredVolume: step.weathered_volume_m3,
evaporationVolume: step.evaporation_m3 ?? step.weathered_volume_m3 * 0.65,
dispersionVolume: step.dispersion_m3 ?? step.weathered_volume_m3 * 0.35,
pollutionArea: step.pollution_area_km2,
beachedVolume: step.beached_volume_m3,
pollutionCoastLength: step.pollution_coast_length_m,
}))
return { trajectory, summary, stepSummaries, centerPoints, windData, hydrData }
} }
export default router export default router

파일 보기

@ -77,7 +77,8 @@ CREATE TABLE IF NOT EXISTS REPORT (
USE_YN CHAR(1) DEFAULT 'Y', USE_YN CHAR(1) DEFAULT 'Y',
REG_DTM TIMESTAMPTZ DEFAULT NOW(), REG_DTM TIMESTAMPTZ DEFAULT NOW(),
MDFCN_DTM TIMESTAMPTZ, MDFCN_DTM TIMESTAMPTZ,
MAP_CAPTURE_IMG TEXT STEP3_MAP_IMG TEXT,
STEP6_MAP_IMG TEXT,
CONSTRAINT CK_REPORT_STATUS CHECK (STTS_CD IN ('DRAFT','IN_PROGRESS','COMPLETED')) CONSTRAINT CK_REPORT_STATUS CHECK (STTS_CD IN ('DRAFT','IN_PROGRESS','COMPLETED'))
); );

파일 보기

@ -0,0 +1,57 @@
-- 관리자 권한 트리 확장: 게시판관리, 기준정보, 연계관리 섹션 추가
-- AdminView.tsx의 adminMenuConfig.ts에 정의된 전체 메뉴 구조를 AUTH_PERM_TREE에 반영
-- Level 1 섹션 노드 (3개)
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
('admin:board-mgmt', 'admin', '게시판관리', 1, 5),
('admin:reference', 'admin', '기준정보', 1, 6),
('admin:external', 'admin', '연계관리', 1, 7)
ON CONFLICT (RSRC_CD) DO NOTHING;
-- Level 2 그룹/리프 노드
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
('admin:notice', 'admin:board-mgmt', '공지사항', 2, 1),
('admin:board', 'admin:board-mgmt', '게시판', 2, 2),
('admin:qna', 'admin:board-mgmt', 'QNA', 2, 3),
('admin:map-mgmt', 'admin:reference', '지도관리', 2, 1),
('admin:sensitive-map', 'admin:reference', '민감자원지도', 2, 2),
('admin:coast-guard-assets', 'admin:reference', '해경자산', 2, 3),
('admin:collection', 'admin:external', '수집자료', 2, 1),
('admin:monitoring', 'admin:external', '연계모니터링', 2, 2)
ON CONFLICT (RSRC_CD) DO NOTHING;
-- Level 3 리프 노드
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
('admin:map-base', 'admin:map-mgmt', '지도백데이터', 3, 1),
('admin:map-layer', 'admin:map-mgmt', '레이어', 3, 2),
('admin:env-ecology', 'admin:sensitive-map', '환경/생태', 3, 1),
('admin:social-economy', 'admin:sensitive-map', '사회/경제', 3, 2),
('admin:cleanup-equip', 'admin:coast-guard-assets', '방제장비', 3, 1),
('admin:asset-upload', 'admin:coast-guard-assets', '자산현행화', 3, 2),
('admin:dispersant-zone', 'admin:coast-guard-assets', '유처리제 제한구역', 3, 3),
('admin:vessel-materials', 'admin:coast-guard-assets', '방제선 보유자재', 3, 4),
('admin:collect-vessel-signal', 'admin:collection', '선박신호', 3, 1),
('admin:collect-hr', 'admin:collection', '인사정보', 3, 2),
('admin:monitor-realtime', 'admin:monitoring', '실시간 관측자료', 3, 1),
('admin:monitor-forecast', 'admin:monitoring', '수치예측자료', 3, 2),
('admin:monitor-vessel', 'admin:monitoring', '선박위치정보', 3, 3),
('admin:monitor-hr', 'admin:monitoring', '인사', 3, 4)
ON CONFLICT (RSRC_CD) DO NOTHING;
-- AUTH_PERM: 신규 섹션/그룹 노드에 권한 복사
-- admin 권한이 있는 역할에 동일하게 부여 (permResolver의 parent READ gate 충족)
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
SELECT ap.ROLE_SN, nc.RSRC_CD, ap.OPER_CD, ap.GRANT_YN
FROM AUTH_PERM ap
CROSS JOIN (VALUES
('admin:board-mgmt'),
('admin:reference'),
('admin:external'),
('admin:map-mgmt'),
('admin:sensitive-map'),
('admin:coast-guard-assets'),
('admin:collection'),
('admin:monitoring')
) AS nc(RSRC_CD)
WHERE ap.RSRC_CD = 'admin'
ON CONFLICT (ROLE_SN, RSRC_CD, OPER_CD) DO NOTHING;

파일 보기

@ -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 '수정일시';

파일 보기

@ -0,0 +1,27 @@
-- ============================================================
-- 027: 통합민감도 평가 테이블 생성
-- 계절별 민감도 평가 그리드 데이터 저장
-- properties 구조: { ID, FA_G, SM_G, SP_G, WT_G, MAX_G, GRID_LEVEL }
-- ============================================================
SET search_path TO wing, public;
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE IF NOT EXISTS SENSITIVE_EVALUATION (
SR_ID BIGSERIAL PRIMARY KEY,
CATEGORY VARCHAR(50) NOT NULL DEFAULT '민감도평가',
GEOM public.geometry(Geometry, 4326) NOT NULL,
PROPERTIES JSONB NOT NULL DEFAULT '{}',
REG_DT TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
MOD_DT TIMESTAMP
);
CREATE INDEX IF NOT EXISTS IDX_SE_GEOM ON SENSITIVE_EVALUATION USING GIST(GEOM);
CREATE INDEX IF NOT EXISTS IDX_SE_PROPERTIES ON SENSITIVE_EVALUATION USING GIN(PROPERTIES);
COMMENT ON TABLE SENSITIVE_EVALUATION IS '통합민감도 평가 그리드 테이블';
COMMENT ON COLUMN SENSITIVE_EVALUATION.SR_ID IS '민감도 평가 ID';
COMMENT ON COLUMN SENSITIVE_EVALUATION.CATEGORY IS '카테고리 (기본값: 민감도평가)';
COMMENT ON COLUMN SENSITIVE_EVALUATION.GEOM IS '공간 데이터 (EPSG:4326)';
COMMENT ON COLUMN SENSITIVE_EVALUATION.PROPERTIES IS '계절별 민감도 값 { SP_G, SM_G, FA_G, WT_G, MAX_G, GRID_LEVEL }';

파일 보기

@ -1,191 +0,0 @@
# 확산 예측 기능 가이드
> 대상: 확산 예측(OpenDrift) 기능 개발 및 유지보수 담당자
---
## 1. 아키텍처 개요
**폴링 방식** — HTTP 연결 불안정 문제 해결을 위해 비동기 폴링 구조를 채택했다.
```
[프론트] 실행 버튼
→ POST /api/simulation/run 즉시 { execSn, status:'RUNNING' } 반환
→ "분석 중..." UI 표시
→ 3초마다 GET /api/simulation/status/:execSn 폴링
[Express 백엔드]
→ PRED_EXEC INSERT (PENDING)
→ POST Python /run-model 즉시 { job_id } 수신
→ 응답 즉시 반환 (프론트 블록 없음)
→ 백그라운드: 3초마다 Python GET /status/:job_id 폴링
→ DONE 시 PRED_EXEC UPDATE (결과 JSONB 저장)
[Python FastAPI :5003]
→ 동시 처리 초과 시 503 즉시 반환
→ 여유 시 job_id 반환 + 백그라운드 OpenDrift 시뮬레이션 실행
→ NC 결과 → JSON 변환 → 상태 DONE
```
---
## 2. DB 스키마 (PRED_EXEC)
```sql
PRED_EXEC_SN SERIAL PRIMARY KEY
ACDNT_SN INTEGER NOT NULL -- 사고 FK
SPIL_DATA_SN INTEGER -- 유출정보 FK (NULL 허용)
EXEC_NM VARCHAR(100) UNIQUE -- EXPC_{timestamp} 형식
ALGO_CD VARCHAR(20) NOT NULL -- 'OPENDRIFT'
EXEC_STTS_CD VARCHAR(20) DEFAULT 'PENDING'
-- PENDING | RUNNING | COMPLETED | FAILED
BGNG_DTM TIMESTAMPTZ
CMPL_DTM TIMESTAMPTZ
REQD_SEC INTEGER
RSLT_DATA JSONB -- 시뮬레이션 결과 전체
ERR_MSG TEXT
```
인덱스: `IDX_PRED_STTS` (EXEC_STTS_CD), `uix_pred_exec_nm` (EXEC_NM, partial)
---
## 3. Python FastAPI 엔드포인트 (포트 5003)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/get-received-date` | 최신 예보 수신 가능 날짜 |
| GET | `/get-uv/{datetime}/{category}` | 바람/해류 U/V 벡터 (`wind`\|`hydr`) |
| POST | `/check-nc` | NetCDF 파일 존재 여부 확인 |
| POST | `/run-model` | 시뮬레이션 제출 → 즉시 `job_id` 반환 |
| GET | `/status/{job_id}` | 시뮬레이션 진행 상태 조회 |
### POST /run-model 입력 파라미터
```json
{
"startTime": "2025-01-15 12:00:00", // KST (내부 UTC 변환)
"runTime": 72, // 예측 시간 (시간)
"matTy": "CRUDE OIL", // OpenDrift 유류명
"matVol": 100.0, // 시간당 유출량 (m³/hr)
"lon": 126.1,
"lat": 36.6,
"spillTime": 12, // 유출 지속 시간 (0=순간)
"name": "EXPC_1710000000000"
}
```
### 유류 코드 매핑 (DB → OpenDrift)
| DB SPIL_MAT_CD | OpenDrift 이름 |
|---------------|---------------|
| CRUD | CRUDE OIL |
| DSEL | DIESEL |
| BNKR | BUNKER |
| HEFO | IFO 180 |
---
## 4. Express 백엔드 주요 엔드포인트
파일: [backend/src/routes/simulation.ts](../backend/src/routes/simulation.ts)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/api/simulation/run` | 시뮬레이션 제출 → `execSn` 즉시 반환 |
| GET | `/api/simulation/status/:execSn` | 프론트 폴링용 상태 조회 |
파일: [backend/src/prediction/predictionService.ts](../backend/src/prediction/predictionService.ts)
- `fetchPredictionList()` — PRED_EXEC 목록 조회
- `fetchTrajectoryResult()` — 저장된 결과 조회 (`RSLT_DATA` JSONB 파싱)
---
## 5. 프론트엔드 주요 파일
| 파일 | 역할 |
|------|------|
| [frontend/src/tabs/prediction/components/OilSpillView.tsx](../frontend/src/tabs/prediction/components/OilSpillView.tsx) | 예측 탭 메인 뷰, 시뮬레이션 실행·폴링 상태 관리 |
| [frontend/src/tabs/prediction/hooks/](../frontend/src/tabs/prediction/hooks/) | `useSimulationStatus` 폴링 훅 |
| [frontend/src/tabs/prediction/services/predictionApi.ts](../frontend/src/tabs/prediction/services/predictionApi.ts) | API 요청 함수 + 타입 정의 |
| [frontend/src/tabs/prediction/components/RightPanel.tsx](../frontend/src/tabs/prediction/components/RightPanel.tsx) | 풍화량·잔류량·오염면적 표시 (마지막 스텝 실제 값) |
| [frontend/src/common/components/map/HydrParticleOverlay.tsx](../frontend/src/common/components/map/HydrParticleOverlay.tsx) | 해류 파티클 Canvas 오버레이 |
### 핵심 타입 (predictionApi.ts)
```typescript
interface HydrGrid {
lonInterval: number[];
latInterval: number[];
boundLonLat: { top: number; bottom: number; left: number; right: number };
rows: number; cols: number;
}
interface HydrDataStep {
value: [number[][], number[][]]; // [u_2d, v_2d]
grid: HydrGrid;
}
```
### 폴링 훅 패턴
```typescript
useQuery({
queryKey: ['simulationStatus', execSn],
queryFn: () => api.get(`/api/simulation/status/${execSn}`),
enabled: execSn !== null,
refetchInterval: (data) =>
data?.status === 'DONE' || data?.status === 'ERROR' ? false : 3000,
});
```
---
## 6. Python 코드 위치 (prediction/)
```
prediction/opendrift/
├── api.py FastAPI 진입점 (수정 필요: 폴링 지원 + CORS)
├── config.py 경로 설정 (수정 필요: 환경변수화)
├── createJsonResult.py NC → JSON 변환 (핵심 후처리)
├── coastline/ TN_SHORLINE.shp (한국 해안선)
├── startup.sh / shutdown.sh
├── .env.example 환경변수 샘플
└── environment-opendrift.yml conda 환경 재현용
```
---
## 7. 환경변수
### backend/.env
```bash
PYTHON_API_URL=http://localhost:5003
```
### prediction/opendrift/.env
```bash
MPR_STORAGE_ROOT=/data/storage # NetCDF 기상·해양 데이터 루트
MPR_RESULT_ROOT=./result # 시뮬레이션 결과 저장 경로
MAX_CONCURRENT_JOBS=4 # 동시 처리 최대 수
```
---
## 8. 위험 요소
| 위험 | 내용 |
|------|------|
| NetCDF 파일 부재 | `MPR_STORAGE_ROOT` 경로에 KMA GDAPS·MOHID NC 파일 필요. 없으면 시뮬레이션 불가 |
| conda 환경 | `opendrift` conda 환경 설치 필요 (`environment-opendrift.yml`) |
| Workers 포화 | 동시 4개 초과 시 503 반환 → `MAX_CONCURRENT_JOBS` 조정 |
| 결과 용량 | 12시간 결과 ≈ 1500KB/건. 90일 주기 `RSLT_DATA = NULL` 정리 권장 |
---
## 9. 관련 문서
- [CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md) — Express API 개발 패턴
- [COMMON-GUIDE.md](./COMMON-GUIDE.md) — 인증·상태관리 공통 로직

파일 보기

@ -9,13 +9,33 @@
- react-router-dom 도입, BrowserRouter 래핑 - react-router-dom 도입, BrowserRouter 래핑
- SVG 아이콘 에셋 19종 추가 - SVG 아이콘 에셋 19종 추가
- @/ path alias 추가 - @/ path alias 추가
- 레이어: 레이어 데이터 테이블 매핑 구현 + 어장 팝업 수정
- 확산예측: 예측 실행 시 기상정보(풍속·풍향·기압·파고·수온·기온·염분 등) ACDNT_WEATHER 테이블에 자동 저장
- DB: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 19개 추가 (025 마이그레이션)
- DB: 민감자원 데이터 마이그레이션 (026_sensitive_resources)
- 보고서: 유류유출 보고서 템플릿 전면 개선 (OilSpillReportTemplate)
- 관리자: 실시간 기상·해상 모니터링 패널 추가 (MonitorRealtimePanel)
- DB: 민감자원 평가 마이그레이션 추가 (027_sensitivity_evaluation)
### 변경 ### 변경
- SCAT 지도 하드코딩 제주 해안선 제거, 인접 구간 기반 동적 방향 계산으로 전환 - SCAT 지도 하드코딩 제주 해안선 제거, 인접 구간 기반 동적 방향 계산으로 전환
- 예측: 분석 API를 예측 서비스로 통합 (analysisRouter 제거)
- 예측: 예측 API 확장 (predictionRouter/Service, LeftPanel/RightPanel 연동)
### 문서 ### 문서
- Foundation 탭 디자인 토큰 상세 문서화 (DESIGN-SYSTEM.md) - Foundation 탭 디자인 토큰 상세 문서화 (DESIGN-SYSTEM.md)
## [2026-03-20.3]
### 추가
- 보고서: 기능 강화 (HWPX 내보내기, 확산 지도 패널, 보고서 생성기 개선)
- 관리자: 권한 트리 확장 (게시판관리·기준정보·연계관리 섹션 추가)
- 관리자: 유처리제 제한구역 패널, 민감자원 레이어 패널 추가
- 기상: 날씨 스냅샷 스토어, 유틸리티 모듈 추가
### 문서
- PREDICTION-GUIDE.md 삭제
## [2026-03-20.2] ## [2026-03-20.2]
### 변경 ### 변경

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -1,14 +1,14 @@
import { useState, useMemo, useEffect, useCallback, useRef } from 'react' import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre' import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
import { MapboxOverlay } from '@deck.gl/mapbox' 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 { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'
import type { StyleSpecification } from 'maplibre-gl' import type { StyleSpecification } from 'maplibre-gl'
import type { MapLayerMouseEvent } from 'maplibre-gl' import type { MapLayerMouseEvent } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css' import 'maplibre-gl/dist/maplibre-gl.css'
import { layerDatabase } from '@common/services/layerService' import { layerDatabase } from '@common/services/layerService'
import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView' 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 HydrParticleOverlay from './HydrParticleOverlay'
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine' import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack' import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
@ -289,6 +289,24 @@ const PRIORITY_LABELS: Record<string, string> = {
'MEDIUM': '보통', '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> = { const SENSITIVE_COLORS: Record<string, string> = {
'aquaculture': '#22c55e', 'aquaculture': '#22c55e',
'beach': '#0ea5e9', 'beach': '#0ea5e9',
@ -342,6 +360,7 @@ interface MapViewProps {
incidentCoord: { lat: number; lon: number } incidentCoord: { lat: number; lon: number }
} }
sensitiveResources?: SensitiveResource[] sensitiveResources?: SensitiveResource[]
sensitiveResourceGeojson?: SensitiveResourceFeatureCollection | null
flyToTarget?: { lng: number; lat: number; zoom?: number } | null flyToTarget?: { lng: number; lat: number; zoom?: number } | null
fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null
centerPoints?: Array<{ lat: number; lon: number; time: number; model?: string }> centerPoints?: Array<{ lat: number; lon: number; time: number; model?: string }>
@ -528,6 +547,7 @@ export function MapView({
layerBrightness = 50, layerBrightness = 50,
backtrackReplay, backtrackReplay,
sensitiveResources = [], sensitiveResources = [],
sensitiveResourceGeojson,
flyToTarget, flyToTarget,
fitBoundsTarget, fitBoundsTarget,
centerPoints = [], centerPoints = [],
@ -559,6 +579,12 @@ export function MapView({
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
const [playbackSpeed, setPlaybackSpeed] = useState(1) const [playbackSpeed, setPlaybackSpeed] = useState(1)
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null) 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 currentTime = isControlled ? externalCurrentTime : internalCurrentTime
const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => { const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => {
@ -569,6 +595,44 @@ export function MapView({
const handleMapClick = useCallback((e: MapLayerMouseEvent) => { const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
const { lng, lat } = e.lngLat const { lng, lat } = e.lngLat
setCurrentPosition([lat, lng]) 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) { if (measureMode !== null) {
handleMeasureClick(lng, lat) handleMeasureClick(lng, lat)
return return
@ -716,7 +780,7 @@ export function MapView({
getPath: (d: BoomLine) => d.coords.map(c => [c.lon, c.lat] as [number, number]), getPath: (d: BoomLine) => d.coords.map(c => [c.lon, c.lat] as [number, number]),
getColor: (d: BoomLine) => hexToRgba(PRIORITY_COLORS[d.priority] || '#f59e0b', 230), getColor: (d: BoomLine) => hexToRgba(PRIORITY_COLORS[d.priority] || '#f59e0b', 230),
getWidth: (d: BoomLine) => PRIORITY_WEIGHTS[d.priority] || 2, 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, dashJustified: true,
widthMinPixels: 2, widthMinPixels: 2,
widthMaxPixels: 6, widthMaxPixels: 6,
@ -1018,7 +1082,10 @@ export function MapView({
), ),
}); });
} else if (!info.object) { } 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) --- // --- 입자 중심점 이동 경로 (모델별 PathLayer + ScatterplotLayer) ---
const visibleCenters = centerPoints.filter(p => p.time <= currentTime) const visibleCenters = centerPoints.filter(p => p.time <= currentTime)
if (visibleCenters.length > 0) { if (visibleCenters.length > 0) {
@ -1225,12 +1327,12 @@ export function MapView({
// 거리/면적 측정 레이어 // 거리/면적 측정 레이어
result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements)) result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements))
return result return result.filter(Boolean)
}, [ }, [
oilTrajectory, currentTime, selectedModels, oilTrajectory, currentTime, selectedModels,
boomLines, isDrawingBoom, drawingPoints, boomLines, isDrawingBoom, drawingPoints,
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay, dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
sensitiveResources, centerPoints, windData, sensitiveResources, sensitiveResourceGeojson, centerPoints, windData,
showWind, showBeached, showTimeLabel, simulationStartTime, showWind, showBeached, showTimeLabel, simulationStartTime,
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode, analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode,
]) ])
@ -1318,7 +1420,10 @@ export function MapView({
longitude={popupInfo.longitude} longitude={popupInfo.longitude}
latitude={popupInfo.latitude} latitude={popupInfo.latitude}
anchor="bottom" anchor="bottom"
onClose={() => setPopupInfo(null)} onClose={() => {
persistentPopupRef.current = false
setPopupInfo(null)
}}
> >
<div className="text-[#333]">{popupInfo.content}</div> <div className="text-[#333]">{popupInfo.content}</div>
</Popup> </Popup>

파일 보기

@ -7,6 +7,7 @@ export interface LayerNode {
name: string name: string
level: number level: number
layerName: string | null layerName: string | null
dataTblNm?: string | null
icon?: string icon?: string
count?: number count?: number
defaultOn?: boolean defaultOn?: boolean

파일 보기

@ -13,11 +13,11 @@ export function useLayers() {
} }
// 계층 구조 레이어 트리 조회 훅 // 계층 구조 레이어 트리 조회 훅
// staleTime 없음 → 마운트 시 항상 최신 데이터 요청 (관리자 설정 즉시 반영)
export function useLayerTree() { export function useLayerTree() {
return useQuery<Layer[], Error>({ return useQuery<Layer[], Error>({
queryKey: ['layers', 'tree'], queryKey: ['layers', 'tree'],
queryFn: fetchLayerTree, queryFn: fetchLayerTree,
staleTime: 1000 * 60 * 5,
retry: 3, retry: 3,
}) })
} }

파일 보기

@ -203,6 +203,13 @@ export interface OilReportPayload {
windSpeed: string; windSpeed: string;
waveHeight: string; waveHeight: string;
temp: string; temp: string;
pressure?: string;
visibility?: string;
salinity?: string;
waveMaxHeight?: string;
wavePeriod?: string;
currentDir?: string;
currentSpeed?: string;
} | null; } | null;
spread: { spread: {
kosps: string; kosps: string;
@ -228,6 +235,12 @@ export interface OilReportPayload {
centerPoints: { lat: number; lon: number; time: number }[]; centerPoints: { lat: number; lon: number; time: number }[];
simulationStartTime: string; simulationStartTime: string;
} | null; } | null;
sensitiveResources?: Array<{
category: string;
count: number;
totalArea: number | null;
}>;
acdntSn?: number;
} }
let _oilReportPayload: OilReportPayload | null = null; let _oilReportPayload: OilReportPayload | null = null;

파일 보기

@ -46,6 +46,7 @@ export interface LayerDTO {
cmn_cd_nm: string cmn_cd_nm: string
cmn_cd_level: number cmn_cd_level: number
clnm: string | null clnm: string | null
data_tbl_nm?: string | null
icon?: string icon?: string
count?: number count?: number
children?: LayerDTO[] children?: LayerDTO[]
@ -58,6 +59,7 @@ export interface Layer {
fullName: string fullName: string
level: number level: number
wmsLayer: string | null wmsLayer: string | null
dataTblNm?: string | null
icon?: string icon?: string
count?: number count?: number
children?: Layer[] children?: Layer[]
@ -72,6 +74,7 @@ function convertToLayer(dto: LayerDTO): Layer {
fullName: dto.cmn_cd_full_nm, fullName: dto.cmn_cd_full_nm,
level: dto.cmn_cd_level, level: dto.cmn_cd_level,
wmsLayer: dto.clnm, wmsLayer: dto.clnm,
dataTblNm: dto.data_tbl_nm,
icon: dto.icon, icon: dto.icon,
count: dto.count, count: dto.count,
children: dto.children ? dto.children.map(convertToLayer) : undefined, children: dto.children ? dto.children.map(convertToLayer) : undefined,

파일 보기

@ -8,6 +8,7 @@ export interface Layer {
fullName: string fullName: string
level: number level: number
wmsLayer: string | null wmsLayer: string | null
dataTblNm?: string | null
icon?: string icon?: string
count?: number count?: number
children?: Layer[] children?: Layer[]

파일 보기

@ -0,0 +1,53 @@
import { create } from 'zustand';
export interface WeatherSnapshot {
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;
};
alert?: string;
forecast?: Array<{
time: string;
icon: string;
temperature: number;
windSpeed: number;
}>;
}
interface WeatherSnapshotStore {
snapshot: WeatherSnapshot | null;
setSnapshot: (data: WeatherSnapshot) => void;
clearSnapshot: () => void;
}
export const useWeatherSnapshotStore = create<WeatherSnapshotStore>((set) => ({
snapshot: null,
setSnapshot: (data) => set({ snapshot: data }),
clearSnapshot: () => set({ snapshot: null }),
}));

파일 보기

@ -1,3 +1,9 @@
/* 바람 입자 캔버스(z-index: 450) 위에 팝업이 표시되도록 z-index 설정
@layer 밖에 위치해야 non-layered CSS인 MapLibre 스타일보다 우선순위를 가짐 */
.maplibregl-popup {
z-index: 500;
}
@layer components { @layer components {
/* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */ /* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */
.cctv-dark-popup .maplibregl-popup-content { .cctv-dark-popup .maplibregl-popup-content {

파일 보기

@ -12,6 +12,9 @@ import CleanupEquipPanel from './CleanupEquipPanel';
import AssetUploadPanel from './AssetUploadPanel'; import AssetUploadPanel from './AssetUploadPanel';
import MapBasePanel from './MapBasePanel'; import MapBasePanel from './MapBasePanel';
import LayerPanel from './LayerPanel'; import LayerPanel from './LayerPanel';
import SensitiveLayerPanel from './SensitiveLayerPanel';
import DispersingZonePanel from './DispersingZonePanel';
import MonitorRealtimePanel from './MonitorRealtimePanel';
/** 기존 패널이 있는 메뉴 ID 매핑 */ /** 기존 패널이 있는 메뉴 ID 매핑 */
const PANEL_MAP: Record<string, () => JSX.Element> = { const PANEL_MAP: Record<string, () => JSX.Element> = {
@ -27,6 +30,10 @@ const PANEL_MAP: Record<string, () => JSX.Element> = {
'asset-upload': () => <AssetUploadPanel />, 'asset-upload': () => <AssetUploadPanel />,
'map-base': () => <MapBasePanel />, 'map-base': () => <MapBasePanel />,
'map-layer': () => <LayerPanel />, 'map-layer': () => <LayerPanel />,
'env-ecology': () => <SensitiveLayerPanel categoryCode="LYR001002001" title="환경/생태" />,
'social-economy': () => <SensitiveLayerPanel categoryCode="LYR001002002" title="사회/경제" />,
'dispersant-zone': () => <DispersingZonePanel />,
'monitor-realtime': () => <MonitorRealtimePanel />,
}; };
export function AdminView() { export function AdminView() {

파일 보기

@ -0,0 +1,247 @@
import { useEffect, useState } from 'react';
import { Map, useControl } from '@vis.gl/react-maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { GeoJsonLayer } from '@deck.gl/layers';
import type { Layer } from '@deck.gl/core';
import type { StyleSpecification } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
// CartoDB Dark Matter 스타일
const MAP_STYLE: StyleSpecification = {
version: 8,
sources: {
'carto-dark': {
type: 'raster',
tiles: [
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
],
tileSize: 256,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
},
},
layers: [
{
id: 'carto-dark-layer',
type: 'raster',
source: 'carto-dark',
minzoom: 0,
maxzoom: 22,
},
],
};
const MAP_CENTER: [number, number] = [127.5, 36.0];
const MAP_ZOOM = 5.5;
const CONSIDER_FILL: [number, number, number, number] = [59, 130, 246, 60];
const CONSIDER_LINE: [number, number, number, number] = [59, 130, 246, 220];
const RESTRICT_FILL: [number, number, number, number] = [239, 68, 68, 60];
const RESTRICT_LINE: [number, number, number, number] = [239, 68, 68, 220];
type ZoneKey = 'consider' | 'restrict';
// deck.gl 오버레이 컴포넌트
function DeckGLOverlay({ layers }: { layers: Layer[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers });
return null;
}
// 구역 설명 데이터
const ZONE_INFO: Record<ZoneKey, { label: string; rows: { key: string; value: string }[] }> = {
consider: {
label: '사용고려해역',
rows: [
{ key: '수심', value: '20m 이상 ※ (IMO) 대형 20m, 중소형 10m 이상' },
{
key: '사용거리',
value:
'해안 2km, 중요 민감자원으로부터 5km 이상 떨어진 경우 ※ (IMO) 대형 1km, 중소형 0.5km 이상',
},
{ key: '사용승인(절차)', value: '현장 방제책임자 재량 사용 ※ (IMO) 의결정 절차 지침' },
],
},
restrict: {
label: '사용제한해역',
rows: [
{ key: '수심', value: '수심 10m 이하' },
{
key: '사용거리',
value:
'어장·양식장, 발전소 취수구, 종묘배양장 및 폐쇄성 해역 특정해역중 수자원 보호구역',
},
{
key: '사용승인(절차)',
value:
'심의위원회 승인을 받아 관할 방제책임기관 또는 방제대책 본부장이 결정 ※ 긴급한 경우 先사용, 後심의',
},
],
},
};
const DispersingZonePanel = () => {
const [showConsider, setShowConsider] = useState(true);
const [showRestrict, setShowRestrict] = useState(true);
const [expandedZone, setExpandedZone] = useState<ZoneKey | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [considerData, setConsiderData] = useState<any>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [restrictData, setRestrictData] = useState<any>(null);
useEffect(() => {
fetch('/dispersant-consider.geojson')
.then(r => r.json())
.then(setConsiderData)
.catch(() => {/* GeoJSON 없을 때 빈 상태 유지 */});
fetch('/dispersant-restrict.geojson')
.then(r => r.json())
.then(setRestrictData)
.catch(() => {/* GeoJSON 없을 때 빈 상태 유지 */});
}, []);
const layers: Layer[] = [
...(showConsider && considerData
? [
new GeoJsonLayer({
id: 'dispersant-consider',
data: considerData,
getFillColor: CONSIDER_FILL,
getLineColor: CONSIDER_LINE,
lineWidthMinPixels: 1.5,
pickable: false,
}),
]
: []),
...(showRestrict && restrictData
? [
new GeoJsonLayer({
id: 'dispersant-restrict',
data: restrictData,
getFillColor: RESTRICT_FILL,
getLineColor: RESTRICT_LINE,
lineWidthMinPixels: 1.5,
pickable: false,
}),
]
: []),
];
const handleToggleExpand = (zone: ZoneKey) => {
setExpandedZone(prev => (prev === zone ? null : zone));
};
const renderZoneCard = (zone: ZoneKey) => {
const info = ZONE_INFO[zone];
const isConsider = zone === 'consider';
const showLayer = isConsider ? showConsider : showRestrict;
const setShowLayer = isConsider ? setShowConsider : setShowRestrict;
const swatchColor = isConsider ? 'bg-blue-500' : 'bg-red-500';
const isExpanded = expandedZone === zone;
return (
<div key={zone} className="border border-border rounded-lg overflow-hidden">
{/* 카드 헤더 */}
<div
className="flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-[rgba(255,255,255,0.03)] transition-colors"
onClick={() => handleToggleExpand(zone)}
>
<span className={`w-3 h-3 rounded-sm shrink-0 ${swatchColor}`} />
<span className="flex-1 text-xs font-semibold text-text-1 font-korean">{info.label}</span>
{/* 토글 스위치 */}
<button
onClick={e => {
e.stopPropagation();
setShowLayer(prev => !prev);
}}
title={showLayer ? '레이어 숨기기' : '레이어 표시'}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 ${
showLayer
? 'bg-primary-cyan'
: 'bg-[rgba(255,255,255,0.08)] border border-border'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
showLayer ? 'translate-x-[18px]' : 'translate-x-0.5'
}`}
/>
</button>
{/* 펼침 화살표 */}
<span className="text-text-3 text-[10px] shrink-0">{isExpanded ? '▲' : '▼'}</span>
</div>
{/* 펼침 영역 */}
{isExpanded && (
<div className="border-t border-border px-3 py-3">
<table className="w-full">
<tbody>
{info.rows.map(row => (
<tr key={row.key} className="border-b border-border last:border-0">
<td className="py-2 pr-2 text-[11px] text-text-3 font-korean whitespace-nowrap align-top w-24">
{row.key}
</td>
<td className="py-2 text-[11px] text-text-2 font-korean leading-relaxed">
{row.value}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
};
return (
<div className="flex flex-1 overflow-hidden">
{/* 지도 영역 */}
<div className="flex-1 relative">
<Map
initialViewState={{
longitude: MAP_CENTER[0],
latitude: MAP_CENTER[1],
zoom: MAP_ZOOM,
}}
style={{ width: '100%', height: '100%' }}
mapStyle={MAP_STYLE}
>
<DeckGLOverlay layers={layers} />
</Map>
{/* 범례 */}
<div className="absolute bottom-4 left-4 bg-bg-1 border border-border rounded-lg px-3 py-2 flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-sm bg-blue-500 opacity-80" />
<span className="text-[11px] text-text-2 font-korean"></span>
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-sm bg-red-500 opacity-80" />
<span className="text-[11px] text-text-2 font-korean"></span>
</div>
</div>
</div>
{/* 우측 패널 */}
<div className="w-[280px] bg-bg-1 border-l border-border flex flex-col overflow-hidden shrink-0">
{/* 헤더 */}
<div className="px-4 py-4 border-b border-border shrink-0">
<h1 className="text-sm font-bold text-text-1 font-korean"> </h1>
<p className="text-[11px] text-text-3 mt-0.5 font-korean"> </p>
</div>
{/* 구역 카드 목록 */}
<div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2">
{renderZoneCard('consider')}
{renderZoneCard('restrict')}
</div>
</div>
</div>
);
};
export default DispersingZonePanel;

파일 보기

@ -1,4 +1,5 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { api } from '@common/services/api'; import { api } from '@common/services/api';
interface LayerAdminItem { interface LayerAdminItem {
@ -11,6 +12,7 @@ interface LayerAdminItem {
useYn: string; useYn: string;
sortOrd: number; sortOrd: number;
regDtm: string | null; regDtm: string | null;
parentUseYn: string | null;
} }
interface LayerListResponse { interface LayerListResponse {
@ -313,6 +315,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
// ---------- LayerPanel ---------- // ---------- LayerPanel ----------
const LayerPanel = () => { const LayerPanel = () => {
const queryClient = useQueryClient();
const [items, setItems] = useState<LayerAdminItem[]>([]); const [items, setItems] = useState<LayerAdminItem[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
@ -359,10 +362,15 @@ const LayerPanel = () => {
try { try {
const result = await toggleLayerUse(layerCd); const result = await toggleLayerUse(layerCd);
setItems(prev => setItems(prev =>
prev.map(item => prev.map(item => {
item.layerCd === result.layerCd ? { ...item, useYn: result.useYn } : item if (item.layerCd === result.layerCd) return { ...item, useYn: result.useYn };
) // 직접 자식의 parentUseYn도 즉시 동기화
if (item.upLayerCd === result.layerCd) return { ...item, parentUseYn: result.useYn };
return item;
})
); );
// 레이어 캐시 무효화 → 예측 탭 등 useLayerTree 구독자가 최신 데이터 수신
queryClient.invalidateQueries({ queryKey: ['layers'] });
} catch { } catch {
setError('사용여부 변경에 실패했습니다.'); setError('사용여부 변경에 실패했습니다.');
} finally { } finally {
@ -522,12 +530,20 @@ const LayerPanel = () => {
<td className="px-4 py-3 text-center"> <td className="px-4 py-3 text-center">
<button <button
onClick={() => handleToggle(item.layerCd)} onClick={() => handleToggle(item.layerCd)}
disabled={toggling === item.layerCd} disabled={toggling === item.layerCd || item.parentUseYn === 'N'}
title={item.useYn === 'Y' ? '사용 중 (클릭하여 비활성화)' : '미사용 (클릭하여 활성화)'} title={
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-50 ${ item.parentUseYn === 'N'
item.useYn === 'Y' ? '상위 레이어가 비활성화되어 있어 적용되지 않습니다'
: item.useYn === 'Y'
? '사용 중 (클릭하여 비활성화)'
: '미사용 (클릭하여 활성화)'
}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-40 ${
item.useYn === 'Y' && item.parentUseYn !== 'N'
? 'bg-primary-cyan' ? 'bg-primary-cyan'
: 'bg-[rgba(255,255,255,0.08)] border border-border' : item.useYn === 'Y' && item.parentUseYn === 'N'
? 'bg-primary-cyan/40'
: 'bg-[rgba(255,255,255,0.08)] border border-border'
}`} }`}
> >
<span <span

파일 보기

@ -0,0 +1,436 @@
import { useState, useEffect, useCallback } from 'react';
import {
getRecentObservation,
OBS_STATION_CODES,
type RecentObservation,
} from '@tabs/weather/services/khoaApi';
import {
getUltraShortForecast,
getMarineForecast,
convertToGridCoords,
getCurrentBaseDateTime,
MARINE_REGIONS,
type WeatherForecastData,
type MarineWeatherData,
} from '@tabs/weather/services/weatherApi';
const KEY_TO_NAME: Record<string, string> = {
incheon: '인천',
gunsan: '군산',
mokpo: '목포',
yeosu: '여수',
tongyeong: '통영',
ulsan: '울산',
pohang: '포항',
donghae: '동해',
sokcho: '속초',
jeju: '제주',
};
// 조위관측소 목록
const STATIONS = Object.entries(OBS_STATION_CODES).map(([key, code]) => ({
key,
code,
name: KEY_TO_NAME[key] ?? key,
}));
// 기상청 초단기실황 지점 (위경도)
const WEATHER_STATIONS = [
{ name: '인천', lat: 37.4563, lon: 126.7052 },
{ name: '군산', lat: 35.9679, lon: 126.7361 },
{ name: '목포', lat: 34.8118, lon: 126.3922 },
{ name: '부산', lat: 35.1028, lon: 129.0323 },
{ name: '제주', lat: 33.5131, lon: 126.5297 },
];
// 해역 목록
const MARINE_REGION_LIST = Object.entries(MARINE_REGIONS).map(([name, regId]) => ({
name,
regId,
}));
type TabId = 'khoa' | 'kma-ultra' | 'kma-marine';
interface KhoaRow {
stationName: string;
data: RecentObservation | null;
error: boolean;
}
interface KmaUltraRow {
stationName: string;
data: WeatherForecastData | null;
error: boolean;
}
interface MarineRow {
name: string;
regId: string;
data: MarineWeatherData | null;
error: boolean;
}
const fmt = (v: number | null | undefined, digits = 1): string =>
v != null ? v.toFixed(digits) : '-';
function StatusBadge({ loading, errorCount, total }: { loading: boolean; errorCount: number; total: number }) {
if (loading) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-2 text-t2">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
...
</span>
);
}
if (errorCount === total) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-500/10 text-red-400">
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
</span>
);
}
if (errorCount > 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-yellow-500/10 text-yellow-400">
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
({errorCount}/{total})
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-500/10 text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
</span>
);
}
function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
const headers = ['관측소', '수온(°C)', '기온(°C)', '기압(hPa)', '풍향(°)', '풍속(m/s)', '유향(°)', '유속(m/s)', '조위(cm)', '상태'];
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-bg-2 text-t3 uppercase tracking-wide">
{headers.map((h) => (
<th key={h} className="px-3 py-2 text-left font-medium border-b border-border-1 whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{loading && rows.length === 0
? Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border-1 animate-pulse">
{headers.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-2 rounded w-12" />
</td>
))}
</tr>
))
: rows.map((row) => (
<tr key={row.stationName} className="border-b border-border-1 hover:bg-bg-1/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.stationName}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.water_temp)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.air_temp)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.air_pres)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.wind_dir, 0)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.wind_speed)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.current_dir, 0)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.current_speed)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.tide_level, 0)}</td>
<td className="px-3 py-2">
{row.error ? (
<span className="text-red-400 text-xs"></span>
) : row.data ? (
<span className="text-emerald-400 text-xs"></span>
) : (
<span className="text-t3 text-xs">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolean }) {
const headers = ['지점', '기온(°C)', '풍속(m/s)', '풍향(°)', '파고(m)', '강수(mm)', '습도(%)', '상태'];
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-bg-2 text-t3 uppercase tracking-wide">
{headers.map((h) => (
<th key={h} className="px-3 py-2 text-left font-medium border-b border-border-1 whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{loading && rows.length === 0
? Array.from({ length: 3 }).map((_, i) => (
<tr key={i} className="border-b border-border-1 animate-pulse">
{headers.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-2 rounded w-12" />
</td>
))}
</tr>
))
: rows.map((row) => (
<tr key={row.stationName} className="border-b border-border-1 hover:bg-bg-1/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.stationName}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.temperature)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.windSpeed)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.windDirection, 0)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.waveHeight)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.precipitation)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.humidity, 0)}</td>
<td className="px-3 py-2">
{row.error ? (
<span className="text-red-400 text-xs"></span>
) : row.data ? (
<span className="text-emerald-400 text-xs"></span>
) : (
<span className="text-t3 text-xs">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean }) {
const headers = ['해역명', '파고(m)', '풍속(m/s)', '풍향', '수온(°C)', '상태'];
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-bg-2 text-t3 uppercase tracking-wide">
{headers.map((h) => (
<th key={h} className="px-3 py-2 text-left font-medium border-b border-border-1 whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{loading && rows.length === 0
? Array.from({ length: 4 }).map((_, i) => (
<tr key={i} className="border-b border-border-1 animate-pulse">
{headers.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-2 rounded w-14" />
</td>
))}
</tr>
))
: rows.map((row) => (
<tr key={row.regId} className="border-b border-border-1 hover:bg-bg-1/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.name}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.waveHeight)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.windSpeed)}</td>
<td className="px-3 py-2 text-t2">{row.data?.windDirection || '-'}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.temperature)}</td>
<td className="px-3 py-2">
{row.error ? (
<span className="text-red-400 text-xs"></span>
) : row.data ? (
<span className="text-emerald-400 text-xs"></span>
) : (
<span className="text-t3 text-xs">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export default function MonitorRealtimePanel() {
const [activeTab, setActiveTab] = useState<TabId>('khoa');
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
// KHOA 조위관측소
const [khoaRows, setKhoaRows] = useState<KhoaRow[]>([]);
const [khoaLoading, setKhoaLoading] = useState(false);
// 기상청 초단기실황
const [kmaRows, setKmaRows] = useState<KmaUltraRow[]>([]);
const [kmaLoading, setKmaLoading] = useState(false);
// 기상청 해상예보
const [marineRows, setMarineRows] = useState<MarineRow[]>([]);
const [marineLoading, setMarineLoading] = useState(false);
const fetchKhoa = useCallback(async () => {
setKhoaLoading(true);
const results = await Promise.allSettled(
STATIONS.map((s) => getRecentObservation(s.code))
);
const rows: KhoaRow[] = STATIONS.map((s, i) => {
const result = results[i];
if (result.status === 'fulfilled') {
return { stationName: s.name, data: result.value, error: false };
}
return { stationName: s.name, data: null, error: true };
});
setKhoaRows(rows);
setKhoaLoading(false);
setLastUpdate(new Date());
}, []);
const fetchKmaUltra = useCallback(async () => {
setKmaLoading(true);
const { baseDate, baseTime } = getCurrentBaseDateTime();
const results = await Promise.allSettled(
WEATHER_STATIONS.map((s) => {
const { nx, ny } = convertToGridCoords(s.lat, s.lon);
return getUltraShortForecast(nx, ny, baseDate, baseTime);
})
);
const rows: KmaUltraRow[] = WEATHER_STATIONS.map((s, i) => {
const result = results[i];
if (result.status === 'fulfilled' && result.value.length > 0) {
return { stationName: s.name, data: result.value[0], error: false };
}
return { stationName: s.name, data: null, error: result.status === 'rejected' };
});
setKmaRows(rows);
setKmaLoading(false);
setLastUpdate(new Date());
}, []);
const fetchMarine = useCallback(async () => {
setMarineLoading(true);
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const tmFc = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}00`;
const results = await Promise.allSettled(
MARINE_REGION_LIST.map((r) => getMarineForecast(r.regId, tmFc))
);
const rows: MarineRow[] = MARINE_REGION_LIST.map((r, i) => {
const result = results[i];
if (result.status === 'fulfilled') {
return { name: r.name, regId: r.regId, data: result.value, error: false };
}
return { name: r.name, regId: r.regId, data: null, error: true };
});
setMarineRows(rows);
setMarineLoading(false);
setLastUpdate(new Date());
}, []);
// 탭 전환 시 해당 데이터 로드
useEffect(() => {
let isMounted = true;
if (activeTab === 'khoa' && khoaRows.length === 0) {
void Promise.resolve().then(() => { if (isMounted) void fetchKhoa(); });
} else if (activeTab === 'kma-ultra' && kmaRows.length === 0) {
void Promise.resolve().then(() => { if (isMounted) void fetchKmaUltra(); });
} else if (activeTab === 'kma-marine' && marineRows.length === 0) {
void Promise.resolve().then(() => { if (isMounted) void fetchMarine(); });
}
return () => { isMounted = false; };
}, [activeTab, khoaRows.length, kmaRows.length, marineRows.length, fetchKhoa, fetchKmaUltra, fetchMarine]);
const handleRefresh = () => {
if (activeTab === 'khoa') fetchKhoa();
else if (activeTab === 'kma-ultra') fetchKmaUltra();
else fetchMarine();
};
const isLoading = activeTab === 'khoa' ? khoaLoading : activeTab === 'kma-ultra' ? kmaLoading : marineLoading;
const currentRows = activeTab === 'khoa' ? khoaRows : activeTab === 'kma-ultra' ? kmaRows : marineRows;
const errorCount = currentRows.filter((r) => r.error).length;
const totalCount = activeTab === 'khoa' ? STATIONS.length : activeTab === 'kma-ultra' ? WEATHER_STATIONS.length : MARINE_REGION_LIST.length;
const TABS: { id: TabId; label: string }[] = [
{ id: 'khoa', label: 'KHOA 조위관측소' },
{ id: 'kma-ultra', label: '기상청 초단기실황' },
{ id: 'kma-marine', label: '기상청 해상예보' },
];
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-border-1 shrink-0">
<h2 className="text-sm font-semibold text-t1"> </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
: {lastUpdate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
)}
<button
onClick={handleRefresh}
disabled={isLoading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-2 hover:bg-bg-3 text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
{/* 탭 */}
<div className="flex gap-0 border-b border-border-1 shrink-0 px-5">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2.5 text-xs font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-cyan-400 text-cyan-400'
: 'border-transparent text-t3 hover:text-t2'
}`}
>
{tab.label}
</button>
))}
</div>
{/* 상태 표시줄 */}
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-border-1 bg-bg-0">
<StatusBadge loading={isLoading} errorCount={errorCount} total={totalCount} />
<span className="text-xs text-t3">
{activeTab === 'khoa' && `관측소 ${totalCount}`}
{activeTab === 'kma-ultra' && `지점 ${totalCount}`}
{activeTab === 'kma-marine' && `해역 ${totalCount}`}
</span>
</div>
{/* 테이블 콘텐츠 */}
<div className="flex-1 overflow-auto p-5">
{activeTab === 'khoa' && (
<KhoaTable rows={khoaRows} loading={khoaLoading} />
)}
{activeTab === 'kma-ultra' && (
<KmaUltraTable rows={kmaRows} loading={kmaLoading} />
)}
{activeTab === 'kma-marine' && (
<MarineTable rows={marineRows} loading={marineLoading} />
)}
</div>
</div>
);
}

파일 보기

@ -0,0 +1,304 @@
import { useEffect, useState, useCallback } from 'react';
import { api } from '@common/services/api';
interface SensitiveLayerPanelProps {
categoryCode: string;
title: string;
}
interface LayerAdminItem {
layerCd: string;
upLayerCd: string | null;
layerFullNm: string;
layerNm: string;
layerLevel: number;
wmsLayerNm: string | null;
useYn: string;
sortOrd: number;
regDtm: string | null;
}
interface LayerListResponse {
items: LayerAdminItem[];
total: number;
page: number;
totalPages: number;
}
const PAGE_SIZE = 10;
async function fetchSensitiveLayers(
page: number,
search: string,
useYn: string,
rootCd: string,
): Promise<LayerListResponse> {
const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE), rootCd });
if (search) params.set('search', search);
if (useYn) params.set('useYn', useYn);
const res = await api.get<LayerListResponse>(`/layers/admin/list?${params}`);
return res.data;
}
async function toggleLayerUse(layerCd: string): Promise<{ layerCd: string; useYn: string }> {
const res = await api.post<{ layerCd: string; useYn: string }>('/layers/admin/toggle-use', { layerCd });
return res.data;
}
// ---------- SensitiveLayerPanel ----------
const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps) => {
const [items, setItems] = useState<LayerAdminItem[]>([]);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [toggling, setToggling] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [searchInput, setSearchInput] = useState('');
const [appliedSearch, setAppliedSearch] = useState('');
const [filterUseYn, setFilterUseYn] = useState('');
const load = useCallback(async (p: number, search: string, useYn: string) => {
setLoading(true);
setError(null);
try {
const res = await fetchSensitiveLayers(p, search, useYn, categoryCode);
setItems(res.items);
setTotal(res.total);
setTotalPages(res.totalPages);
} catch {
setError('레이어 목록을 불러오지 못했습니다.');
} finally {
setLoading(false);
}
}, [categoryCode]);
useEffect(() => {
setPage(1);
setAppliedSearch('');
setFilterUseYn('');
setSearchInput('');
}, [categoryCode]);
useEffect(() => {
load(page, appliedSearch, filterUseYn);
}, [load, page, appliedSearch, filterUseYn]);
const handleSearch = () => {
setAppliedSearch(searchInput);
setPage(1);
};
const handleToggle = async (layerCd: string) => {
if (toggling) return;
setToggling(layerCd);
try {
const result = await toggleLayerUse(layerCd);
setItems(prev =>
prev.map(item =>
item.layerCd === result.layerCd ? { ...item, useYn: result.useYn } : item
)
);
} catch {
setError('사용여부 변경에 실패했습니다.');
} finally {
setToggling(null);
}
};
const buildPageButtons = () => {
const buttons: (number | 'ellipsis')[] = [];
const delta = 2;
const left = page - delta;
const right = page + delta;
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= left && i <= right)) {
buttons.push(i);
} else if (buttons[buttons.length - 1] !== 'ellipsis') {
buttons.push('ellipsis');
}
}
return buttons;
};
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="px-6 py-4 border-b border-border shrink-0">
<div className="flex items-center justify-between mb-3">
<div>
<h1 className="text-lg font-bold text-text-1 font-korean">{title}</h1>
<p className="text-xs text-text-3 mt-1 font-korean"> {total}</p>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
placeholder="레이어코드 / 레이어명 검색"
className="flex-1 px-3 py-1.5 text-xs bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
/>
<select
value={filterUseYn}
onChange={e => setFilterUseYn(e.target.value)}
className="px-2 py-1.5 text-xs bg-bg-2 border border-border rounded text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
>
<option value=""></option>
<option value="Y"></option>
<option value="N"></option>
</select>
<button
onClick={handleSearch}
className="px-3 py-1.5 text-xs border border-border text-text-2 rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
>
</button>
</div>
</div>
{/* 오류 메시지 */}
{error && (
<div className="px-6 py-2 text-xs text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-border shrink-0 font-korean">
{error}
</div>
)}
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-full text-text-3 text-sm font-korean">
...
</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-border bg-bg-1 sticky top-0 z-10">
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-10 whitespace-nowrap"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-mono"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-12 whitespace-nowrap"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-mono">WMS레이어명</th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-16"></th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-28"></th>
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-20"></th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td colSpan={9} className="px-4 py-12 text-center text-text-3 text-sm font-korean">
.
</td>
</tr>
) : (
items.map((item, idx) => (
<tr
key={item.layerCd}
className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors"
>
<td className="px-4 py-3 text-xs text-text-3 font-mono">
{(page - 1) * PAGE_SIZE + idx + 1}
</td>
<td className="px-4 py-3 text-[11px] text-text-2 font-mono">
{item.layerCd}
</td>
<td className="px-4 py-3 text-xs text-text-1 font-korean">
{item.layerNm}
</td>
<td className="px-4 py-3 text-xs text-text-2 font-korean max-w-[200px]">
<span className="block truncate" title={item.layerFullNm}>
{item.layerFullNm}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-semibold bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-[rgba(6,182,212,0.3)]">
{item.layerLevel}
</span>
</td>
<td className="px-4 py-3 text-[11px] text-text-2 font-mono">
{item.wmsLayerNm ?? <span className="text-text-3">-</span>}
</td>
<td className="px-4 py-3 text-xs text-text-3 text-center font-mono">
{item.sortOrd}
</td>
<td className="px-4 py-3 text-[11px] text-text-3 font-mono">
{item.regDtm ?? '-'}
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleToggle(item.layerCd)}
disabled={toggling === item.layerCd}
title={item.useYn === 'Y' ? '사용 중 (클릭하여 비활성화)' : '미사용 (클릭하여 활성화)'}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-50 ${
item.useYn === 'Y'
? 'bg-primary-cyan'
: 'bg-[rgba(255,255,255,0.08)] border border-border'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
item.useYn === 'Y' ? 'translate-x-[18px]' : 'translate-x-0.5'
}`}
/>
</button>
</td>
</tr>
))
)}
</tbody>
</table>
)}
</div>
{/* 페이지네이션 */}
{!loading && totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-3 border-t border-border bg-bg-1 shrink-0">
<span className="text-[11px] text-text-3 font-korean">
{(page - 1) * PAGE_SIZE + 1}{Math.min(page * PAGE_SIZE, total)} / {total}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-2.5 py-1 text-[11px] border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
>
</button>
{buildPageButtons().map((btn, i) =>
btn === 'ellipsis' ? (
<span key={`e${i}`} className="px-1.5 text-[11px] text-text-3"></span>
) : (
<button
key={btn}
onClick={() => setPage(btn)}
className={`px-2.5 py-1 text-[11px] rounded transition-all ${
page === btn
? 'bg-primary-cyan text-bg-0 font-semibold shadow-[0_0_8px_rgba(6,182,212,0.25)]'
: 'border border-border text-text-3 hover:bg-[rgba(255,255,255,0.04)]'
}`}
>
{btn}
</button>
)
)}
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-2.5 py-1 text-[11px] border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
>
</button>
</div>
</div>
)}
</div>
);
};
export default SensitiveLayerPanel;

파일 보기

@ -54,7 +54,6 @@ export const ADMIN_MENU: AdminMenuItem[] = [
{ id: 'asset-upload', label: '자산현행화' }, { id: 'asset-upload', label: '자산현행화' },
{ id: 'dispersant-zone', label: '유처리제 제한구역' }, { id: 'dispersant-zone', label: '유처리제 제한구역' },
{ id: 'vessel-materials', label: '방제선 보유자재' }, { id: 'vessel-materials', label: '방제선 보유자재' },
{ id: 'cleanup-resource', label: '방제자원' },
], ],
}, },
], ],

파일 보기

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

파일 보기

@ -1,8 +1,6 @@
import { useState, useMemo } from 'react' import { useState } from 'react'
import { LayerTree } from '@common/components/layer/LayerTree' import { LayerTree } from '@common/components/layer/LayerTree'
import { useLayerTree } from '@common/hooks/useLayers' import { useLayerTree } from '@common/hooks/useLayers'
import { layerData } from '@common/data/layerData'
import type { LayerNode } from '@common/data/layerData'
import type { Layer } from '@common/services/layerService' import type { Layer } from '@common/services/layerService'
interface InfoLayerSectionProps { interface InfoLayerSectionProps {
@ -26,29 +24,13 @@ const InfoLayerSection = ({
layerBrightness, layerBrightness,
onLayerBrightnessChange, onLayerBrightnessChange,
}: InfoLayerSectionProps) => { }: InfoLayerSectionProps) => {
// API에서 레이어 트리 데이터 가져오기 // API에서 레이어 트리 데이터 가져오기 (관리자 설정 USE_YN='Y' 레이어만 반환)
const { data: layerTree, isLoading } = useLayerTree() const { data: layerTree, isLoading } = useLayerTree()
const [layerColors, setLayerColors] = useState<Record<string, string>>({}) const [layerColors, setLayerColors] = useState<Record<string, string>>({})
// 정적 데이터를 Layer 형식으로 변환 (API 실패 시 폴백) // 관리자에서 사용여부가 ON인 레이어만 표시 (정적 폴백 없음)
const staticLayers = useMemo(() => { const effectiveLayers: Layer[] = layerTree ?? []
const convert = (node: LayerNode): Layer => ({
id: node.code,
parentId: node.parentCode,
name: node.name,
fullName: node.fullName,
level: node.level,
wmsLayer: node.layerName,
icon: node.icon,
count: node.count,
children: node.children?.map(convert),
})
return layerData.map(convert)
}, [])
// API 데이터 우선, 실패 시 정적 데이터 폴백
const effectiveLayers = (layerTree && layerTree.length > 0) ? layerTree : staticLayers
return ( return (
<div className="border-b border-border"> <div className="border-b border-border">

파일 보기

@ -1,5 +1,63 @@
import { useState } from 'react' import { useState } from 'react'
import type { LeftPanelProps, ExpandedSections } from './leftPanelTypes' import type { LeftPanelProps, ExpandedSections } from './leftPanelTypes'
interface CategoryMeta {
icon: string;
bg: string;
}
const CATEGORY_ICON_MAP: Record<string, CategoryMeta> = {
// 수산자원 / 양식장 (green)
'어장정보': { icon: '🐟', bg: 'rgba(34,197,94,0.15)' },
'양식장': { icon: '🦪', bg: 'rgba(34,197,94,0.15)' },
'양식어업': { icon: '🦪', bg: 'rgba(34,197,94,0.15)' },
'어류양식장': { icon: '🐟', bg: 'rgba(34,197,94,0.15)' },
'패류양식장': { icon: '🦪', bg: 'rgba(34,197,94,0.15)' },
'해조류양식장': { icon: '🌿', bg: 'rgba(34,197,94,0.15)' },
'가두리양식장': { icon: '🔲', bg: 'rgba(34,197,94,0.15)' },
'갑각류양식장': { icon: '🦐', bg: 'rgba(34,197,94,0.15)' },
'기타양식장': { icon: '📦', bg: 'rgba(34,197,94,0.15)' },
'영세어업': { icon: '🎣', bg: 'rgba(34,197,94,0.15)' },
'유어장': { icon: '🎣', bg: 'rgba(34,197,94,0.15)' },
'수산시장': { icon: '🐟', bg: 'rgba(34,197,94,0.15)' },
'인공어초': { icon: '🪸', bg: 'rgba(34,197,94,0.15)' },
'암초': { icon: '🪨', bg: 'rgba(34,197,94,0.15)' },
'침선': { icon: '🚢', bg: 'rgba(34,197,94,0.15)' },
// 관광자원 / 낚시 (yellow)
'해수욕장': { icon: '🏖', bg: 'rgba(250,204,21,0.15)' },
'갯바위낚시': { icon: '🪨', bg: 'rgba(250,204,21,0.15)' },
'선상낚시': { icon: '🚤', bg: 'rgba(250,204,21,0.15)' },
'마리나항': { icon: '⛵', bg: 'rgba(250,204,21,0.15)' },
// 항만 / 산업시설 (blue)
'무역항': { icon: '🚢', bg: 'rgba(99,179,237,0.15)' },
'연안항': { icon: '⛵', bg: 'rgba(99,179,237,0.15)' },
'국가어항': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' },
'지방어항': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' },
'어항': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' },
'항만구역': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' },
'항로': { icon: '🚢', bg: 'rgba(99,179,237,0.15)' },
'정박지': { icon: '⛵', bg: 'rgba(99,179,237,0.15)' },
'항로표지': { icon: '🔴', bg: 'rgba(99,179,237,0.15)' },
'해수취수시설': { icon: '💧', bg: 'rgba(99,179,237,0.15)' },
'취수구·배수구': { icon: '🚰', bg: 'rgba(99,179,237,0.15)' },
'LNG': { icon: '⚡', bg: 'rgba(99,179,237,0.15)' },
'발전소': { icon: '🔌', bg: 'rgba(99,179,237,0.15)' },
'발전소·산단': { icon: '🏭', bg: 'rgba(99,179,237,0.15)' },
'임해공단': { icon: '🏭', bg: 'rgba(99,179,237,0.15)' },
'저유시설': { icon: '🛢', bg: 'rgba(99,179,237,0.15)' },
'해저케이블·배관': { icon: '🔌', bg: 'rgba(99,179,237,0.15)' },
// 환경 / 생태 (lime)
'갯벌': { icon: '🪨', bg: 'rgba(163,230,53,0.12)' },
'해안선_ESI': { icon: '🏖', bg: 'rgba(163,230,53,0.12)' },
'보호지역': { icon: '🛡', bg: 'rgba(163,230,53,0.12)' },
'해양보호구역': { icon: '🌿', bg: 'rgba(163,230,53,0.12)' },
'철새도래지': { icon: '🐦', bg: 'rgba(163,230,53,0.12)' },
'습지보호구역': { icon: '🏖', bg: 'rgba(163,230,53,0.12)' },
'보호종서식지': { icon: '🐢', bg: 'rgba(163,230,53,0.12)' },
'보호종 서식지': { icon: '🐢', bg: 'rgba(163,230,53,0.12)' },
};
const FALLBACK_META: CategoryMeta = { icon: '🌊', bg: 'rgba(148,163,184,0.15)' };
import PredictionInputSection from './PredictionInputSection' import PredictionInputSection from './PredictionInputSection'
import InfoLayerSection from './InfoLayerSection' import InfoLayerSection from './InfoLayerSection'
import OilBoomSection from './OilBoomSection' import OilBoomSection from './OilBoomSection'
@ -50,6 +108,7 @@ export function LeftPanel({
onLayerOpacityChange, onLayerOpacityChange,
layerBrightness, layerBrightness,
onLayerBrightnessChange, onLayerBrightnessChange,
sensitiveResources = [],
onImageAnalysisResult, onImageAnalysisResult,
}: LeftPanelProps) { }: LeftPanelProps) {
const [expandedSections, setExpandedSections] = useState<ExpandedSections>({ const [expandedSections, setExpandedSections] = useState<ExpandedSections>({
@ -160,7 +219,7 @@ export function LeftPanel({
</div> </div>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span> <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>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span> <span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
@ -204,7 +263,33 @@ export function LeftPanel({
{expandedSections.impactResources && ( {expandedSections.impactResources && (
<div className="px-4 pb-4"> <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, totalArea }) => {
const meta = CATEGORY_ICON_MAP[category] ?? FALLBACK_META;
return (
<div key={category} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className="inline-flex items-center justify-center w-5 h-5 rounded text-[11px] shrink-0"
style={{ background: meta.bg }}
>
{meta.icon}
</span>
<span className="text-[11px] text-text-2 font-korean">{category}</span>
</div>
<span className="text-[11px] text-primary font-bold font-mono">
{totalArea != null
? `${totalArea.toLocaleString('ko-KR', { maximumFractionDigits: 2 })} ha`
: `${count}`}
</span>
</div>
);
})}
</div>
)}
</div> </div>
)} )}
</div> </div>

파일 보기

@ -9,11 +9,13 @@ import { BacktrackModal } from './BacktrackModal'
import { RecalcModal } from './RecalcModal' import { RecalcModal } from './RecalcModal'
import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar' import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar'
import { useSubMenu, navigateToTab, setReportGenCategory, setOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu' import { useSubMenu, navigateToTab, setReportGenCategory, setOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu'
import { fetchWeatherSnapshotForCoord } from '@tabs/weather/services/weatherUtils'
import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore'
import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine' import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine'
import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack' import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack'
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack' import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory } from '../services/predictionApi' import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory, fetchSensitiveResources, fetchSensitiveResourcesGeojson } from '../services/predictionApi'
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, WindPoint } from '../services/predictionApi' import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, SensitiveResourceCategory, SensitiveResourceFeatureCollection, WindPoint } from '../services/predictionApi'
import SimulationLoadingOverlay from './SimulationLoadingOverlay' import SimulationLoadingOverlay from './SimulationLoadingOverlay'
import SimulationErrorModal from './SimulationErrorModal' import SimulationErrorModal from './SimulationErrorModal'
import { api } from '@common/services/api' import { api } from '@common/services/api'
@ -22,6 +24,13 @@ import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal'
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift' 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())}`
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 민감자원 타입 + 데모 데이터 // 민감자원 타입 + 데모 데이터
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -36,20 +45,13 @@ export interface SensitiveResource {
} }
export interface DisplayControls { export interface DisplayControls {
showCurrent: boolean; // 유향/유속 showCurrent: boolean; // 유향/유속
showWind: boolean; // 풍향/풍속 showWind: boolean; // 풍향/풍속
showBeached: boolean; // 해안부착 showBeached: boolean; // 해안부착
showTimeLabel: 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) // 데모 궤적 생성 (seeded PRNG — deterministic)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -137,6 +139,8 @@ export function OilSpillView() {
// 민감자원 // 민감자원
const [sensitiveResources, setSensitiveResources] = useState<SensitiveResource[]>([]) const [sensitiveResources, setSensitiveResources] = useState<SensitiveResource[]>([])
const [sensitiveResourceCategories, setSensitiveResourceCategories] = useState<SensitiveResourceCategory[]>([])
const [sensitiveResourceGeojson, setSensitiveResourceGeojson] = useState<SensitiveResourceFeatureCollection | null>(null)
// 오일펜스 배치 상태 // 오일펜스 배치 상태
const [boomLines, setBoomLines] = useState<BoomLine[]>([]) const [boomLines, setBoomLines] = useState<BoomLine[]>([])
@ -160,6 +164,7 @@ export function OilSpillView() {
showWind: false, showWind: false,
showBeached: false, showBeached: false,
showTimeLabel: false, showTimeLabel: false,
showSensitiveResources: false,
}) })
// 타임라인 플레이어 상태 // 타임라인 플레이어 상태
@ -195,6 +200,13 @@ export function OilSpillView() {
const [summaryByModel, setSummaryByModel] = useState<Record<string, SimulationSummary>>({}) const [summaryByModel, setSummaryByModel] = useState<Record<string, SimulationSummary>>({})
const [stepSummariesByModel, setStepSummariesByModel] = useState<Record<string, SimulationSummary[]>>({}) const [stepSummariesByModel, setStepSummariesByModel] = useState<Record<string, SimulationSummary[]>>({})
// 펜스차단량 계산 (오일붐 차단 효율 × 총 유류량)
const boomBlockedVolume = useMemo(() => {
if (!containmentResult || !simulationSummary) return 0;
const totalVolumeM3 = simulationSummary.remainingVolume + simulationSummary.weatheredVolume + simulationSummary.beachedVolume;
return totalVolumeM3 * (containmentResult.overallEfficiency / 100);
}, [containmentResult, simulationSummary])
// 오염분석 상태 // 오염분석 상태
const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon') const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon')
const [drawAnalysisMode, setDrawAnalysisMode] = useState<'polygon' | null>(null) const [drawAnalysisMode, setDrawAnalysisMode] = useState<'polygon' | null>(null)
@ -215,7 +227,7 @@ export function OilSpillView() {
setOilTrajectory(demoTrajectory) setOilTrajectory(demoTrajectory)
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings) const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
setBoomLines(demoBooms) setBoomLines(demoBooms)
setSensitiveResources(DEMO_SENSITIVE_RESOURCES) setSensitiveResources([])
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSubTab]) }, [activeSubTab])
@ -467,7 +479,7 @@ export function OilSpillView() {
setSelectedAnalysis(analysis) setSelectedAnalysis(analysis)
setCenterPoints([]) setCenterPoints([])
if (analysis.occurredAt) { if (analysis.occurredAt) {
setAccidentTime(analysis.occurredAt.slice(0, 16)) setAccidentTime(toLocalDateTimeStr(analysis.occurredAt))
} }
if (analysis.lon != null && analysis.lat != null) { if (analysis.lon != null && analysis.lat != null) {
setIncidentCoord({ lon: analysis.lon, lat: analysis.lat }) setIncidentCoord({ lon: analysis.lon, lat: analysis.lat })
@ -517,7 +529,13 @@ export function OilSpillView() {
if (sbModel) setSummaryByModel(sbModel); if (sbModel) setSummaryByModel(sbModel);
if (stepSbModel) setStepSummariesByModel(stepSbModel); if (stepSbModel) setStepSummariesByModel(stepSbModel);
if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings)) 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 완료 후 재생, 그렇지 않으면 즉시 재생 // incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) { if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
pendingPlayRef.current = true pendingPlayRef.current = true
@ -539,7 +557,8 @@ export function OilSpillView() {
const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48) const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48)
setOilTrajectory(demoTrajectory) setOilTrajectory(demoTrajectory)
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings)) if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings))
setSensitiveResources(DEMO_SENSITIVE_RESOURCES) setSensitiveResources([])
setSensitiveResourceCategories([])
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생 // incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) { if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
pendingPlayRef.current = true pendingPlayRef.current = true
@ -553,7 +572,7 @@ export function OilSpillView() {
setDrawingPoints(prev => [...prev, { lat, lon }]) setDrawingPoints(prev => [...prev, { lat, lon }])
} else if (drawAnalysisMode === 'polygon') { } else if (drawAnalysisMode === 'polygon') {
setAnalysisPolygonPoints(prev => [...prev, { lat, lon }]) setAnalysisPolygonPoints(prev => [...prev, { lat, lon }])
} else { } else if (isSelectingLocation) {
setIncidentCoord({ lon, lat }) setIncidentCoord({ lon, lat })
setIsSelectingLocation(false) setIsSelectingLocation(false)
} }
@ -565,7 +584,7 @@ export function OilSpillView() {
setAnalysisResult(null) setAnalysisResult(null)
} }
const handleRunPolygonAnalysis = () => { const handleRunPolygonAnalysis = async () => {
if (analysisPolygonPoints.length < 3) return if (analysisPolygonPoints.length < 3) return
const currentParticles = oilTrajectory.filter(p => p.time === currentStep) const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1 const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1
@ -580,7 +599,7 @@ export function OilSpillView() {
setDrawAnalysisMode(null) setDrawAnalysisMode(null)
} }
const handleRunCircleAnalysis = () => { const handleRunCircleAnalysis = async () => {
if (!incidentCoord) return if (!incidentCoord) return
const radiusM = circleRadiusNm * 1852 const radiusM = circleRadiusNm * 1852
const currentParticles = oilTrajectory.filter(p => p.time === currentStep) const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
@ -613,7 +632,7 @@ export function OilSpillView() {
const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => { const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => {
setIncidentCoord({ lat: result.lat, lon: result.lon }) setIncidentCoord({ lat: result.lat, lon: result.lon })
setFlyToCoord({ 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) setOilType(result.oilType)
setSpillAmount(parseFloat(result.volume.toFixed(4))) setSpillAmount(parseFloat(result.volume.toFixed(4)))
setSpillUnit('kL') setSpillUnit('kL')
@ -746,9 +765,10 @@ export function OilSpillView() {
const newWindDataByModel: Record<string, WindPoint[][]> = {}; const newWindDataByModel: Record<string, WindPoint[][]> = {};
const newHydrDataByModel: Record<string, (HydrDataStep | null)[]> = {}; const newHydrDataByModel: Record<string, (HydrDataStep | null)[]> = {};
const newSummaryByModel: Record<string, SimulationSummary> = {}; const newSummaryByModel: Record<string, SimulationSummary> = {};
const newStepSummariesByModel: Record<string, SimulationSummary[]> = {};
const errors: string[] = []; const errors: string[] = [];
data.results.forEach(({ model, status, trajectory, summary, centerPoints, windData, hydrData, error }) => { data.results.forEach(({ model, status, trajectory, summary, stepSummaries, centerPoints, windData, hydrData, error }) => {
if (status === 'ERROR') { if (status === 'ERROR') {
errors.push(error || `${model} 분석 중 오류가 발생했습니다.`); errors.push(error || `${model} 분석 중 오류가 발생했습니다.`);
return; return;
@ -760,6 +780,7 @@ export function OilSpillView() {
newSummaryByModel[model] = summary; newSummaryByModel[model] = summary;
if (model === 'OpenDrift' || !latestSummary) latestSummary = summary; if (model === 'OpenDrift' || !latestSummary) latestSummary = summary;
} }
if (stepSummaries) newStepSummariesByModel[model] = stepSummaries;
if (windData) newWindDataByModel[model] = windData; if (windData) newWindDataByModel[model] = windData;
if (hydrData) newHydrDataByModel[model] = hydrData; if (hydrData) newHydrDataByModel[model] = hydrData;
if (centerPoints) { if (centerPoints) {
@ -788,9 +809,10 @@ export function OilSpillView() {
setWindDataByModel(newWindDataByModel); setWindDataByModel(newWindDataByModel);
setHydrDataByModel(newHydrDataByModel); setHydrDataByModel(newHydrDataByModel);
setSummaryByModel(newSummaryByModel); setSummaryByModel(newSummaryByModel);
setStepSummariesByModel(newStepSummariesByModel);
const booms = generateAIBoomLines(merged, effectiveCoord, algorithmSettings); const booms = generateAIBoomLines(merged, effectiveCoord, algorithmSettings);
setBoomLines(booms); setBoomLines(booms);
setSensitiveResources(DEMO_SENSITIVE_RESOURCES); setSensitiveResources([]);
setCurrentStep(0); setCurrentStep(0);
setIsPlaying(true); setIsPlaying(true);
setFlyToCoord({ lon: effectiveCoord.lon, lat: effectiveCoord.lat }); setFlyToCoord({ lon: effectiveCoord.lon, lat: effectiveCoord.lat });
@ -800,6 +822,26 @@ export function OilSpillView() {
setSimulationError(errors.join('; ')); setSimulationError(errors.join('; '));
} else { } else {
simulationSucceeded = true; simulationSucceeded = true;
const effectiveAcdntSn = data.acdntSn ?? selectedAnalysis?.acdntSn;
if (effectiveCoord) {
fetchWeatherSnapshotForCoord(effectiveCoord.lat, effectiveCoord.lon)
.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) { } catch (err) {
const msg = const msg =
@ -827,6 +869,7 @@ export function OilSpillView() {
accidentTime || accidentTime ||
''; '';
const wx = analysisDetail?.weather?.[0] ?? null; const wx = analysisDetail?.weather?.[0] ?? null;
const weatherSnapshot = useWeatherSnapshotStore.getState().snapshot;
const payload: OilReportPayload = { const payload: OilReportPayload = {
incident: { incident: {
@ -854,9 +897,27 @@ export function OilSpillView() {
coastLength: simulationSummary ? `${simulationSummary.pollutionCoastLength.toFixed(2)} km` : '—', coastLength: simulationSummary ? `${simulationSummary.pollutionCoastLength.toFixed(2)} km` : '—',
oilType: OIL_TYPE_CODE[oilType] || oilType, oilType: OIL_TYPE_CODE[oilType] || oilType,
}, },
weather: wx weather: (() => {
? { windDir: wx.wind, windSpeed: wx.wind, waveHeight: wx.wave, temp: wx.temp } if (weatherSnapshot) {
: null, return {
windDir: `${weatherSnapshot.wind.directionLabel} ${weatherSnapshot.wind.direction}°`,
windSpeed: `${weatherSnapshot.wind.speed.toFixed(1)} m/s`,
waveHeight: `${weatherSnapshot.wave.height.toFixed(1)} m`,
temp: `${weatherSnapshot.temperature.current.toFixed(1)} °C`,
pressure: `${weatherSnapshot.pressure} hPa`,
visibility: `${weatherSnapshot.visibility} km`,
salinity: `${weatherSnapshot.salinity} PSU`,
waveMaxHeight: `${weatherSnapshot.wave.maxHeight.toFixed(1)} m`,
wavePeriod: `${weatherSnapshot.wave.period} s`,
currentDir: '',
currentSpeed: '',
};
}
if (wx) {
return { windDir: wx.wind, windSpeed: wx.wind, waveHeight: wx.wave, temp: wx.temp };
}
return null;
})(),
spread: (() => { spread: (() => {
const fmt = (model: string) => { const fmt = (model: string) => {
const s = summaryByModel[model]; const s = summaryByModel[model];
@ -884,6 +945,14 @@ export function OilSpillView() {
return [toRow('3시간', steps[3]), toRow('6시간', steps[6])]; return [toRow('3시간', steps[3]), toRow('6시간', steps[6])];
})(), })(),
hasSimulation: simulationSummary !== null, hasSimulation: simulationSummary !== null,
sensitiveResources: sensitiveResourceCategories.length > 0
? sensitiveResourceCategories.map(r => ({
category: r.category,
count: r.count,
totalArea: r.totalArea,
}))
: undefined,
acdntSn: selectedAnalysis?.acdntSn ?? undefined,
mapData: incidentCoord ? { mapData: incidentCoord ? {
center: [incidentCoord.lat, incidentCoord.lon], center: [incidentCoord.lat, incidentCoord.lon],
zoom: 10, zoom: 10,
@ -947,6 +1016,7 @@ export function OilSpillView() {
onLayerOpacityChange={setLayerOpacity} onLayerOpacityChange={setLayerOpacity}
layerBrightness={layerBrightness} layerBrightness={layerBrightness}
onLayerBrightnessChange={setLayerBrightness} onLayerBrightnessChange={setLayerBrightness}
sensitiveResources={sensitiveResourceCategories}
onImageAnalysisResult={handleImageAnalysisResult} onImageAnalysisResult={handleImageAnalysisResult}
/> />
)} )}
@ -975,6 +1045,7 @@ export function OilSpillView() {
layerOpacity={layerOpacity} layerOpacity={layerOpacity}
layerBrightness={layerBrightness} layerBrightness={layerBrightness}
sensitiveResources={sensitiveResources} sensitiveResources={sensitiveResources}
sensitiveResourceGeojson={displayControls.showSensitiveResources ? sensitiveResourceGeojson : null}
lightMode lightMode
centerPoints={centerPoints.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))} centerPoints={centerPoints.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))}
windData={windData} windData={windData}
@ -1197,6 +1268,7 @@ export function OilSpillView() {
onOpenReport={handleOpenReport} onOpenReport={handleOpenReport}
detail={analysisDetail} detail={analysisDetail}
summary={stepSummariesByModel[windHydrModel]?.[currentStep] ?? summaryByModel[windHydrModel] ?? simulationSummary} summary={stepSummariesByModel[windHydrModel]?.[currentStep] ?? summaryByModel[windHydrModel] ?? simulationSummary}
boomBlockedVolume={boomBlockedVolume}
displayControls={displayControls} displayControls={displayControls}
onDisplayControlsChange={setDisplayControls} onDisplayControlsChange={setDisplayControls}
windHydrModel={windHydrModel} windHydrModel={windHydrModel}
@ -1210,6 +1282,8 @@ export function OilSpillView() {
onCircleRadiusChange={setCircleRadiusNm} onCircleRadiusChange={setCircleRadiusNm}
analysisResult={analysisResult} analysisResult={analysisResult}
incidentCoord={incidentCoord} incidentCoord={incidentCoord}
centerPoints={centerPoints}
predictionTime={predictionTime}
onStartPolygonDraw={handleStartPolygonDraw} onStartPolygonDraw={handleStartPolygonDraw}
onRunPolygonAnalysis={handleRunPolygonAnalysis} onRunPolygonAnalysis={handleRunPolygonAnalysis}
onRunCircleAnalysis={handleRunCircleAnalysis} onRunCircleAnalysis={handleRunCircleAnalysis}

파일 보기

@ -430,7 +430,9 @@ function DateTimeInput({ value, onChange }: { value: string; onChange: (v: strin
const datePart = value ? value.split('T')[0] : '' const datePart = value ? value.split('T')[0] : ''
const timePart = value && value.includes('T') ? value.split('T')[1] : '00:00' 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 parsed = datePart ? new Date(datePart + 'T00:00:00') : new Date()
const [viewYear, setViewYear] = useState(parsed.getFullYear()) const [viewYear, setViewYear] = useState(parsed.getFullYear())
@ -561,9 +563,15 @@ function DateTimeInput({ value, onChange }: { value: string; onChange: (v: strin
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setViewYear(todayY) const now = new Date()
setViewMonth(todayM) setViewYear(now.getFullYear())
pickDate(todayD) 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" className="w-full text-[8px] font-korean font-semibold cursor-pointer rounded-sm"
style={{ style={{

파일 보기

@ -1,6 +1,7 @@
import { useState } from 'react' import { useState, useMemo } from 'react'
import type { PredictionDetail, SimulationSummary } from '../services/predictionApi' import type { PredictionDetail, SimulationSummary, CenterPoint } from '../services/predictionApi'
import type { DisplayControls } from './OilSpillView' import type { DisplayControls } from './OilSpillView'
import { haversineDistance, computeBearing } from '@common/utils/geo'
interface AnalysisResult { interface AnalysisResult {
area: number area: number
@ -29,6 +30,9 @@ interface RightPanelProps {
onCircleRadiusChange?: (nm: number) => void onCircleRadiusChange?: (nm: number) => void
analysisResult?: AnalysisResult | null analysisResult?: AnalysisResult | null
incidentCoord?: { lat: number; lon: number } | null incidentCoord?: { lat: number; lon: number } | null
centerPoints?: CenterPoint[]
predictionTime?: number
boomBlockedVolume?: number
onStartPolygonDraw?: () => void onStartPolygonDraw?: () => void
onRunPolygonAnalysis?: () => void onRunPolygonAnalysis?: () => void
onRunCircleAnalysis?: () => void onRunCircleAnalysis?: () => void
@ -44,6 +48,10 @@ export function RightPanel({
drawAnalysisMode, analysisPolygonPoints = [], drawAnalysisMode, analysisPolygonPoints = [],
circleRadiusNm = 5, onCircleRadiusChange, circleRadiusNm = 5, onCircleRadiusChange,
analysisResult, analysisResult,
incidentCoord,
centerPoints,
predictionTime,
boomBlockedVolume = 0,
onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis, onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis,
onCancelAnalysis, onClearAnalysis, onCancelAnalysis, onClearAnalysis,
}: RightPanelProps) { }: RightPanelProps) {
@ -54,6 +62,38 @@ export function RightPanel({
const [shipExpanded, setShipExpanded] = useState(false) const [shipExpanded, setShipExpanded] = useState(false)
const [insuranceExpanded, setInsuranceExpanded] = useState(false) const [insuranceExpanded, setInsuranceExpanded] = useState(false)
const weatheringStatus = useMemo(() => {
if (!summary) return null;
const total = summary.remainingVolume + summary.evaporationVolume
+ summary.dispersionVolume + summary.beachedVolume + boomBlockedVolume;
if (total <= 0) return null;
const pct = (v: number) => Math.round((v / total) * 100);
return {
surface: pct(summary.remainingVolume),
evaporation: pct(summary.evaporationVolume),
dispersion: pct(summary.dispersionVolume),
boom: pct(boomBlockedVolume),
beached: pct(summary.beachedVolume),
};
}, [summary, boomBlockedVolume])
const spreadSummary = useMemo(() => {
if (!incidentCoord || !centerPoints || centerPoints.length === 0) return null
const finalPoint = [...centerPoints].sort((a, b) => b.time - a.time)[0]
const distM = haversineDistance(incidentCoord, { lat: finalPoint.lat, lon: finalPoint.lon })
const distKm = distM / 1000
const bearing = computeBearing(incidentCoord, { lat: finalPoint.lat, lon: finalPoint.lon })
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
const dirLabel = directions[Math.round(bearing / 45) % 8]
const speedMs = predictionTime && predictionTime > 0 ? distM / (predictionTime * 3600) : null
return {
area: summary?.pollutionArea ?? null,
distance: distKm,
directionLabel: `${dirLabel} ${Math.round(bearing)}°`,
speed: speedMs,
}
}, [incidentCoord, centerPoints, summary, predictionTime])
return ( return (
<div className="w-[300px] min-w-[300px] bg-bg-1 border-l border-border flex flex-col"> <div className="w-[300px] min-w-[300px] bg-bg-1 border-l border-border flex flex-col">
{/* Tab Header */} {/* Tab Header */}
@ -81,7 +121,10 @@ export function RightPanel({
checked={displayControls?.showBeached ?? false} checked={displayControls?.showBeached ?? false}
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showBeached: v })} onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showBeached: v })}
></ControlledCheckbox> ></ControlledCheckbox>
<ControlledCheckbox checked={false} onChange={() => {}} disabled> <ControlledCheckbox
checked={displayControls?.showSensitiveResources ?? false}
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showSensitiveResources: v })}
>
</ControlledCheckbox> </ControlledCheckbox>
<ControlledCheckbox <ControlledCheckbox
@ -233,23 +276,29 @@ export function RightPanel({
</Section> </Section>
{/* 확산 예측 요약 */} {/* 확산 예측 요약 */}
<Section title="확산 예측 요약 (+18h)" badge="위험" badgeColor="red"> <Section title={`확산 예측 요약 (+${predictionTime ?? 18}h)`} badge="위험" badgeColor="red">
<div className="grid grid-cols-2 gap-0.5 text-[9px]"> <div className="grid grid-cols-2 gap-0.5 text-[9px]">
<PredictionCard value="4.7 km²" label="영향 면적" color="var(--red)" /> <PredictionCard value={spreadSummary?.area != null ? `${spreadSummary.area.toFixed(1)} km²` : '—'} label="영향 면적" color="var(--red)" />
<PredictionCard value="6.2 km" label="확산 거리" color="var(--orange)" /> <PredictionCard value={spreadSummary?.distance != null ? `${spreadSummary.distance.toFixed(1)} km` : '—'} label="확산 거리" color="var(--orange)" />
<PredictionCard value="NE 42°" label="확산 방향" color="var(--cyan)" /> <PredictionCard value={spreadSummary?.directionLabel ?? '—'} label="확산 방향" color="var(--cyan)" />
<PredictionCard value="0.35 m/s" label="확산 속도" color="var(--t1)" /> <PredictionCard value={spreadSummary?.speed != null ? `${spreadSummary.speed.toFixed(2)} m/s` : '—'} label="확산 속도" color="var(--t1)" />
</div> </div>
</Section> </Section>
{/* 유출유 풍화 상태 */} {/* 유출유 풍화 상태 */}
<Section title="유출유 풍화 상태"> <Section title="유출유 풍화 상태">
<div className="flex flex-col gap-[3px] text-[8px]"> <div className="flex flex-col gap-[3px] text-[8px]">
<ProgressBar label="수면잔류" value={58} color="var(--blue)" /> {weatheringStatus ? (
<ProgressBar label="증발" value={22} color="var(--cyan)" /> <>
<ProgressBar label="분산" value={12} color="var(--green)" /> <ProgressBar label="수면잔류" value={weatheringStatus.surface} color="var(--blue)" />
<ProgressBar label="펜스차단" value={5} color="var(--boom)" /> <ProgressBar label="증발" value={weatheringStatus.evaporation} color="var(--cyan)" />
<ProgressBar label="해안도달" value={3} color="var(--red)" /> <ProgressBar label="분산" value={weatheringStatus.dispersion} color="var(--green)" />
<ProgressBar label="펜스차단" value={weatheringStatus.boom} color="var(--boom)" />
<ProgressBar label="해안도달" value={weatheringStatus.beached} color="var(--red)" />
</>
) : (
<p className="text-[9px] text-text-3 font-korean text-center py-2"> </p>
)}
</div> </div>
</Section> </Section>
@ -654,13 +703,13 @@ function PollResult({
{summary && ( {summary && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-text-3"></span> <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> </div>
)} )}
{summary && ( {summary && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-text-3"></span> <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>
)} )}
<div className="flex justify-between"> <div className="flex justify-between">

파일 보기

@ -1,7 +1,7 @@
import type { PredictionModel } from './OilSpillView' import type { PredictionModel } from './OilSpillView'
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine' import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
import type { Analysis } from './AnalysisListTable' import type { Analysis } from './AnalysisListTable'
import type { ImageAnalyzeResult } from '../services/predictionApi' import type { ImageAnalyzeResult, SensitiveResourceCategory } from '../services/predictionApi'
export interface LeftPanelProps { export interface LeftPanelProps {
selectedAnalysis?: Analysis | null selectedAnalysis?: Analysis | null
@ -49,6 +49,8 @@ export interface LeftPanelProps {
onLayerOpacityChange: (val: number) => void onLayerOpacityChange: (val: number) => void
layerBrightness: number layerBrightness: number
onLayerBrightnessChange: (val: number) => void onLayerBrightnessChange: (val: number) => void
// 영향 민감자원
sensitiveResources?: SensitiveResourceCategory[]
// 이미지 분석 결과 콜백 // 이미지 분석 결과 콜백
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void onImageAnalysisResult?: (result: ImageAnalyzeResult) => void
} }

파일 보기

@ -168,6 +168,8 @@ export interface OilParticle {
export interface SimulationSummary { export interface SimulationSummary {
remainingVolume: number; remainingVolume: number;
weatheredVolume: number; weatheredVolume: number;
evaporationVolume: number;
dispersionVolume: number;
pollutionArea: number; pollutionArea: number;
beachedVolume: number; beachedVolume: number;
pollutionCoastLength: number; pollutionCoastLength: number;
@ -190,6 +192,7 @@ export interface RunModelSyncResult {
status: 'DONE' | 'ERROR'; status: 'DONE' | 'ERROR';
trajectory?: OilParticle[]; trajectory?: OilParticle[];
summary?: SimulationSummary; summary?: SimulationSummary;
stepSummaries?: SimulationSummary[];
centerPoints?: CenterPoint[]; centerPoints?: CenterPoint[];
windData?: WindPoint[][]; windData?: WindPoint[][];
hydrData?: (HydrDataStep | null)[]; hydrData?: (HydrDataStep | null)[];
@ -218,6 +221,73 @@ export const fetchAnalysisTrajectory = async (acdntSn: number): Promise<Trajecto
return response.data; return response.data;
}; };
export interface SensitiveResourceCategory {
category: string;
count: number;
totalArea: number | null;
}
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;
};
export interface SpreadParticlesGeojson {
type: 'FeatureCollection';
features: Array<{
type: 'Feature';
geometry: { type: 'Point'; coordinates: [number, number] };
properties: { model: string; time: number; stranded: 0 | 1; isLastStep: boolean };
}>;
maxStep: number;
}
export const fetchPredictionParticlesGeojson = async (
acdntSn: number,
): Promise<SpreadParticlesGeojson> => {
const response = await api.get<SpreadParticlesGeojson>(
`/prediction/analyses/${acdntSn}/spread-particles`,
);
return response.data;
};
export const fetchSensitivityEvaluationGeojson = async (
acdntSn: number,
): Promise<{ type: 'FeatureCollection'; features: unknown[] }> => {
const response = await api.get<{ type: 'FeatureCollection'; features: unknown[] }>(
`/prediction/analyses/${acdntSn}/sensitivity-evaluation`,
);
return response.data;
};
// ============================================================ // ============================================================
// 이미지 업로드 분석 // 이미지 업로드 분석
// ============================================================ // ============================================================

파일 보기

@ -1,5 +1,8 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { saveReport } from '../services/reportsApi' import { saveReport } from '../services/reportsApi'
import { fetchSensitiveResourcesGeojson, fetchPredictionParticlesGeojson, fetchSensitivityEvaluationGeojson } from '@tabs/prediction/services/predictionApi'
// ─── Data Types ───────────────────────────────────────────── // ─── Data Types ─────────────────────────────────────────────
export type ReportType = '초기보고서' | '지휘부 보고' | '예측보고서' | '종합보고서' | '유출유 보고' export type ReportType = '초기보고서' | '지휘부 보고' | '예측보고서' | '종합보고서' | '유출유 보고'
@ -39,7 +42,12 @@ export interface OilSpillReportData {
recovery: { shipName: string; period: string }[] recovery: { shipName: string; period: string }[]
result: { spillTotal: string; weatheredTotal: string; recoveredTotal: string; seaRemainTotal: string; coastAttachTotal: string } result: { spillTotal: string; weatheredTotal: string; recoveredTotal: string; seaRemainTotal: string; coastAttachTotal: string }
capturedMapImage?: string; capturedMapImage?: string;
step3MapImage?: string;
step6MapImage?: string;
hasMapCapture?: boolean; hasMapCapture?: boolean;
acdntSn?: number;
sensitiveMapImage?: string;
sensitivityMapImage?: string;
} }
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
@ -268,8 +276,14 @@ function Page2({ data, editing, onChange }: { data: OilSpillReportData; editing:
<div style={S.sectionTitle}>3. </div> <div style={S.sectionTitle}>3. </div>
<div className="flex gap-4 mb-4"> <div className="flex gap-4 mb-4">
<div style={S.mapPlaceholder}> 3 </div> {data.step3MapImage
<div style={S.mapPlaceholder}> 6 </div> ? <img src={data.step3MapImage} alt="확산예측 3시간 지도" style={{ width: '100%', maxHeight: '240px', objectFit: 'contain', borderRadius: '4px', border: '1px solid var(--bd)' }} />
: <div style={S.mapPlaceholder}> 3 </div>
}
{data.step6MapImage
? <img src={data.step6MapImage} alt="확산예측 6시간 지도" style={{ width: '100%', maxHeight: '240px', objectFit: 'contain', borderRadius: '4px', border: '1px solid var(--bd)' }} />
: <div style={S.mapPlaceholder}> 6 </div>
}
</div> </div>
<div style={S.subHeader}> </div> <div style={S.subHeader}> </div>
<table style={S.table}> <table style={S.table}>
@ -312,6 +326,396 @@ function Page3({ data, editing, onChange }: { data: OilSpillReportData; editing:
) )
} }
const getSeasonKey = (occurTime: string): 'SP_G' | 'SM_G' | 'FA_G' | 'WT_G' => {
const m = occurTime.match(/\d{4}[.\-\s]+(\d{1,2})[.\-\s]/)
const month = m ? parseInt(m[1]) : 0
if (month >= 3 && month <= 5) return 'SP_G'
if (month >= 6 && month <= 8) return 'SM_G'
if (month >= 9 && month <= 11) return 'FA_G'
return 'WT_G'
}
const parseCoord = (s: string): number | null => {
const d = parseFloat(s)
if (!isNaN(d)) return d
const m = s.match(/(\d+)[°]\s*(\d+)[']\s*([\d.]+)[″"]/)
if (m) return Number(m[1]) + Number(m[2]) / 60 + Number(m[3]) / 3600
return null
}
const haversineKm = (lat1: number, lon1: number, lat2: number, lon2: number): number => {
const R = 6371
const dLat = (lat2 - lat1) * Math.PI / 180
const dLon = (lon2 - lon1) * Math.PI / 180
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
const getGeomCentroid = (geom: { type: string; coordinates: unknown }): [number, number] | null => {
if (geom.type === 'Point') return geom.coordinates as [number, number]
if (geom.type === 'Polygon') {
const pts = (geom.coordinates as [number, number][][])[0]
const avg = pts.reduce((a, c) => [a[0] + c[0], a[1] + c[1]], [0, 0])
return [avg[0] / pts.length, avg[1] / pts.length]
}
if (geom.type === 'MultiPolygon') {
const all = (geom.coordinates as [number, number][][][]).flatMap(p => p[0])
const avg = all.reduce((a, c) => [a[0] + c[0], a[1] + c[1]], [0, 0])
return [avg[0] / all.length, avg[1] / all.length]
}
return null
}
const CATEGORY_COLORS = [
{ keywords: ['어장', '양식'], color: '#f97316', label: '어장/양식장' },
{ keywords: ['해수욕'], color: '#3b82f6', label: '해수욕장' },
{ keywords: ['수산시장', '어시장'], color: '#a855f7', label: '수산시장' },
{ keywords: ['갯벌'], color: '#92400e', label: '갯벌' },
{ keywords: ['서식지'], color: '#16a34a', label: '서식지' },
{ keywords: ['보호종', '생물', '조류', '포유', '파충', '양서'], color: '#ec4899', label: '보호종/생물종' },
] as const
const getCategoryColor = (category: string): string => {
for (const { keywords, color } of CATEGORY_COLORS) {
if ((keywords as readonly string[]).some(kw => category.includes(kw))) return color
}
return '#06b6d4'
}
function SensitiveResourceMapSection({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
const mapContainerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<maplibregl.Map | null>(null)
const [mapVisible, setMapVisible] = useState(false)
const [loading, setLoading] = useState(false)
const [capturing, setCapturing] = useState(false)
const [legendLabels, setLegendLabels] = useState<string[]>([])
const acdntSn = data.acdntSn
const RELEVANT_KEYWORDS = ['양식', '어장', '해수욕', '수산시장', '어시장', '갯벌', '서식지', '보호종', '생물', '조류', '포유', '파충']
const handleLoad = async () => {
if (!acdntSn) return
setLoading(true)
try {
const [geojson, particlesGeojson] = await Promise.all([
fetchSensitiveResourcesGeojson(acdntSn),
fetchPredictionParticlesGeojson(acdntSn),
])
// 관련 카테고리만 필터링 + 카테고리별 색상 추가
const filteredGeojson = {
...geojson,
features: geojson.features
.filter(f => {
const cat = (f.properties as { category?: string })?.category ?? ''
return RELEVANT_KEYWORDS.some(kw => cat.includes(kw))
})
.map(f => ({
...f,
properties: {
...f.properties,
_color: getCategoryColor((f.properties as { category?: string })?.category ?? ''),
},
})),
}
// 실제 존재하는 카테고리 라벨만 범례에 표시
const presentColors = new Set(filteredGeojson.features.map(f => (f.properties as { _color: string })._color))
setLegendLabels(CATEGORY_COLORS.filter(c => presentColors.has(c.color)).map(c => c.label))
// 민감자원 GeoJSON → 6개 섹션 자동 채우기
const HABITAT_TYPES = ['갯벌', '해조류서식지', '바다목장', '바다숲', '산호류서식지', '인공어초', '해초류서식지']
const incLat = parseCoord(data.incident.lat)
const incLon = parseCoord(data.incident.lon)
const calcDist = (geom: { type: string; coordinates: unknown }) => {
if (incLat == null || incLon == null) return ''
const centroid = getGeomCentroid(geom)
return centroid ? haversineKm(incLat, incLon, centroid[1], centroid[0]).toFixed(2) : ''
}
// aquaculture (어장)
const aquacultureRows = geojson.features
.filter(f => ((f.properties as { category?: string })?.category ?? '').includes('어장'))
.map(f => {
const p = f.properties as Record<string, unknown>
return { type: String(p['fids_knd'] ?? ''), area: p['area'] != null ? Number(p['area']).toFixed(2) : '', distance: calcDist(f.geometry as { type: string; coordinates: unknown }) }
})
// beaches (해수욕장)
const beachRows = geojson.features
.filter(f => ((f.properties as { category?: string })?.category ?? '').includes('해수욕'))
.map(f => {
const p = f.properties as Record<string, unknown>
return { name: String(p['beach_nm'] ?? p['name'] ?? p['nm'] ?? ''), distance: calcDist(f.geometry as { type: string; coordinates: unknown }) }
})
// markets (수산시장·어시장)
const marketRows = geojson.features
.filter(f => { const cat = (f.properties as { category?: string })?.category ?? ''; return cat.includes('수산시장') || cat.includes('어시장') })
.map(f => {
const p = f.properties as Record<string, unknown>
return { name: String(p['name'] ?? p['market_nm'] ?? p['nm'] ?? ''), distance: calcDist(f.geometry as { type: string; coordinates: unknown }) }
})
// esi (해안선) — 기존 10개 행의 length만 갱신
const esiFeatures = geojson.features.filter(f => { const cat = (f.properties as { category?: string })?.category ?? ''; return cat.includes('해안선') || cat.includes('ESI') })
const esiLengthMap: Record<string, string> = {}
esiFeatures.forEach(f => {
const p = f.properties as Record<string, unknown>
const code = String(p['esi_cd'] ?? '')
const len = p['length_km'] ?? p['len_km']
if (code && len != null) esiLengthMap[code] = `${Number(len).toFixed(2)} km`
})
const esiRows = esiFeatures.length > 0
? data.esi.map(row => ({ ...row, length: esiLengthMap[row.code.replace('ESI ', '')] ?? esiLengthMap[row.code] ?? row.length }))
: data.esi
// species (보호종·생물종) — 3개 고정 행 유지, species 컬럼만 갱신
const SPECIES_MAP: Record<string, string[]> = { '양서파충류': ['파충', '양서'], '조류': ['조류'], '포유류': ['포유'] }
const speciesCollected: Record<string, string[]> = { '양서파충류': [], '조류': [], '포유류': [] }
geojson.features.forEach(f => {
const cat = (f.properties as { category?: string })?.category ?? ''
const p = f.properties as Record<string, unknown>
const nm = String(p['name'] ?? p['species_nm'] ?? '')
if (!nm) return
for (const [row, kws] of Object.entries(SPECIES_MAP)) {
if (kws.some(kw => cat.includes(kw))) { speciesCollected[row].push(nm); break }
}
})
const hasSpecies = Object.values(speciesCollected).some(arr => arr.length > 0)
const speciesRows = hasSpecies
? data.species.map(row => ({ ...row, species: speciesCollected[row.category]?.join(', ') ?? row.species }))
: data.species
// habitat (서식지) — 타입별 면적 합산
const habitatFeatures = geojson.features.filter(f => HABITAT_TYPES.some(t => ((f.properties as { category?: string })?.category ?? '').includes(t)))
const habitatMap: Record<string, number> = {}
habitatFeatures.forEach(f => {
const cat = (f.properties as { category?: string })?.category ?? ''
const p = f.properties as Record<string, unknown>
habitatMap[cat] = (habitatMap[cat] ?? 0) + (p['area'] != null ? Number(p['area']) : 0)
})
const habitatRows = habitatFeatures.length > 0
? Object.entries(habitatMap).map(([type, area]) => ({ type, area: area > 0 ? area.toFixed(2) : '' }))
: data.habitat
// 단일 onChange 일괄 업데이트
const updates: Partial<OilSpillReportData> = {}
if (aquacultureRows.length > 0) updates.aquaculture = aquacultureRows
if (beachRows.length > 0) updates.beaches = beachRows
if (marketRows.length > 0) updates.markets = marketRows
if (esiFeatures.length > 0) updates.esi = esiRows
if (hasSpecies) updates.species = speciesRows
if (habitatFeatures.length > 0) updates.habitat = habitatRows
if (Object.keys(updates).length > 0) onChange({ ...data, ...updates })
setMapVisible(true)
// 다음 렌더 사이클 후 지도 초기화
setTimeout(() => {
if (!mapContainerRef.current) return
const map = new maplibregl.Map({
container: mapContainerRef.current,
style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
center: [127.5, 35.5],
zoom: 8,
preserveDrawingBuffer: true,
})
mapRef.current = map
map.on('load', () => {
// 확산 파티클 — sensitive 레이어 아래 (예측 탭과 동일한 색상 로직)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map.addSource('particles', { type: 'geojson', data: particlesGeojson as any })
// 과거 스텝: 회색 반투명
map.addLayer({ id: 'particles-past', type: 'circle', source: 'particles', filter: ['==', ['get', 'isLastStep'], false], paint: { 'circle-radius': 2.5, 'circle-color': '#828282', 'circle-opacity': 0.4 } })
// 최신 스텝: 모델별 색상 (OpenDrift=파랑, POSEIDON=빨강)
map.addLayer({ id: 'particles-current', type: 'circle', source: 'particles', filter: ['==', ['get', 'isLastStep'], true], paint: { 'circle-radius': 3, 'circle-color': ['case', ['==', ['get', 'model'], 'OpenDrift'], '#3b82f6', ['==', ['get', 'model'], 'POSEIDON'], '#ef4444', '#06b6d4'], 'circle-opacity': 0.85 } })
// 민감자원 레이어
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map.addSource('sensitive', { type: 'geojson', data: filteredGeojson as any })
map.addLayer({ id: 'sensitive-fill', type: 'fill', source: 'sensitive', filter: ['==', '$type', 'Polygon'], paint: { 'fill-color': ['get', '_color'], 'fill-opacity': 0.4 } })
map.addLayer({ id: 'sensitive-line', type: 'line', source: 'sensitive', filter: ['==', '$type', 'Polygon'], paint: { 'line-color': ['get', '_color'], 'line-width': 1.5 } })
map.addLayer({ id: 'sensitive-circle', type: 'circle', source: 'sensitive', filter: ['==', '$type', 'Point'], paint: { 'circle-radius': 6, 'circle-color': ['get', '_color'], 'circle-stroke-width': 1.5, 'circle-stroke-color': '#fff' } })
// fit bounds — spread + sensitive 합산
const coords: [number, number][] = []
const collectCoords = (geom: { type: string; coordinates: unknown }) => {
if (geom.type === 'Point') {
coords.push(geom.coordinates as [number, number])
} else if (geom.type === 'Polygon') {
const rings = geom.coordinates as [number, number][][]
rings[0]?.forEach(c => coords.push(c))
} else if (geom.type === 'MultiPolygon') {
const polys = geom.coordinates as [number, number][][][]
polys.forEach(rings => rings[0]?.forEach(c => coords.push(c)))
}
}
filteredGeojson.features.forEach(f => collectCoords(f.geometry as { type: string; coordinates: unknown }))
particlesGeojson.features.forEach(f => {
coords.push(f.geometry.coordinates as [number, number])
})
if (coords.length > 0) {
const lngs = coords.map(c => c[0])
const lats = coords.map(c => c[1])
map.fitBounds(
[[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]],
{ padding: 60, maxZoom: 13 }
)
}
// 사고 위치 마커 (캔버스 레이어 — 캡처에 포함)
if (incLat != null && incLon != null) {
map.addSource('incident-point', {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'Point', coordinates: [incLon, incLat] }, properties: {} },
})
map.addLayer({ id: 'incident-circle', type: 'circle', source: 'incident-point', paint: { 'circle-radius': 7, 'circle-color': '#ef4444', 'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff' } })
map.addLayer({ id: 'incident-label', type: 'symbol', source: 'incident-point', layout: { 'text-field': '사고위치', 'text-size': 11, 'text-offset': [0, 1.6], 'text-anchor': 'top', 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'] }, paint: { 'text-color': '#ffffff', 'text-halo-color': '#ef4444', 'text-halo-width': 3 } })
}
})
}, 100)
} catch {
alert('민감자원 지도 데이터를 불러오지 못했습니다.')
} finally {
setLoading(false)
}
}
const handleCapture = () => {
const map = mapRef.current
if (!map) return
setCapturing(true)
map.once('idle', () => {
const mapCanvas = map.getCanvas()
const dpr = window.devicePixelRatio || 1
const W = mapCanvas.width
const H = mapCanvas.height
const composite = document.createElement('canvas')
composite.width = W
composite.height = H
const ctx = composite.getContext('2d')!
// 지도 그리기
ctx.drawImage(mapCanvas, 0, 0)
// 범례 그리기
const items = CATEGORY_COLORS.filter(c => legendLabels.includes(c.label))
if (items.length > 0) {
const pad = 8 * dpr
const swSize = 12 * dpr
const lineH = 18 * dpr
const fontSize = 11 * dpr
const boxW = 130 * dpr
const boxH = pad * 2 + items.length * lineH
const lx = pad, ly = pad
ctx.fillStyle = 'rgba(255,255,255,0.88)'
ctx.fillRect(lx, ly, boxW, boxH)
ctx.strokeStyle = 'rgba(0,0,0,0.15)'
ctx.lineWidth = dpr
ctx.strokeRect(lx, ly, boxW, boxH)
items.forEach(({ color, label }, i) => {
const iy = ly + pad + i * lineH
ctx.fillStyle = color
ctx.fillRect(lx + pad, iy + (lineH - swSize) / 2, swSize, swSize)
ctx.fillStyle = '#1f2937'
ctx.font = `${fontSize}px sans-serif`
ctx.fillText(label, lx + pad + swSize + 4 * dpr, iy + lineH / 2 + fontSize * 0.35)
})
}
onChange({ ...data, sensitiveMapImage: composite.toDataURL('image/png') })
setCapturing(false)
})
map.triggerRepaint()
}
const handleReset = () => {
if (mapRef.current) {
mapRef.current.remove()
mapRef.current = null
}
setMapVisible(false)
setLegendLabels([])
onChange({ ...data, sensitiveMapImage: undefined })
}
useEffect(() => {
return () => { mapRef.current?.remove() }
}, [])
// 뷰 모드: 이미지 있으면 표시, 없으면 플레이스홀더
if (!editing) {
if (data.sensitiveMapImage) {
return <img src={data.sensitiveMapImage} alt="민감자원 분포 지도" style={{ width: '100%', maxHeight: 440, objectFit: 'contain', border: '1px solid #ddd', borderRadius: 4 }} />
}
return <div style={S.mapPlaceholder}> (10km ) </div>
}
// 편집 모드: acdntSn 없음
if (!acdntSn) {
return <div style={S.mapPlaceholder}> (10km ) </div>
}
// 편집 모드: 캡처 이미지 있음
if (data.sensitiveMapImage) {
return (
<div style={{ position: 'relative' }}>
<img src={data.sensitiveMapImage} alt="민감자원 분포 지도" style={{ width: '100%', maxHeight: 440, objectFit: 'contain', border: '1px solid #ddd', borderRadius: 4 }} />
<button
onClick={handleReset}
style={{ position: 'absolute', top: 8, right: 8, background: 'rgba(0,0,0,0.6)', color: '#fff', border: 'none', borderRadius: 4, padding: '3px 8px', fontSize: 11, cursor: 'pointer' }}
>
</button>
</div>
)
}
// 편집 모드: 지도 로드/캡처
return (
<div>
{!mapVisible ? (
<div style={{ ...S.mapPlaceholder, flexDirection: 'column', gap: 8 }}>
<span> (10km ) </span>
<button
onClick={handleLoad}
disabled={loading}
style={{ background: '#0891b2', color: '#fff', border: 'none', borderRadius: 4, padding: '5px 14px', fontSize: 12, cursor: loading ? 'not-allowed' : 'pointer', opacity: loading ? 0.7 : 1 }}
>
{loading ? '불러오는 중...' : '지도 불러오기'}
</button>
</div>
) : (
<div style={{ position: 'relative' }}>
<div ref={mapContainerRef} style={{ width: '100%', height: 440, borderRadius: 4, overflow: 'hidden' }} />
{legendLabels.length > 0 && (
<div style={{ position: 'absolute', top: 8, left: 8, background: 'rgba(255,255,255,0.88)', border: '1px solid rgba(0,0,0,0.15)', borderRadius: 4, padding: '6px 10px', pointerEvents: 'none' }}>
{CATEGORY_COLORS.filter(c => legendLabels.includes(c.label)).map(({ color, label }) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3, fontSize: 11, color: '#1f2937', whiteSpace: 'nowrap' }}>
<div style={{ width: 12, height: 12, background: color, flexShrink: 0 }} />
{label}
</div>
))}
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 6, justifyContent: 'flex-end' }}>
<button
onClick={handleReset}
style={{ background: 'transparent', color: '#6b7280', border: '1px solid #374151', borderRadius: 4, padding: '3px 10px', fontSize: 11, cursor: 'pointer' }}
>
</button>
<button
onClick={handleCapture}
disabled={capturing}
style={{ background: '#0891b2', color: '#fff', border: 'none', borderRadius: 4, padding: '3px 12px', fontSize: 11, cursor: capturing ? 'not-allowed' : 'pointer', opacity: capturing ? 0.7 : 1 }}
>
{capturing ? '캡처 중...' : '이 화면으로 캡처'}
</button>
</div>
</div>
)}
</div>
)
}
function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) { function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
const setArr = <T extends Record<string, string>>(key: keyof OilSpillReportData, arr: T[], i: number, k: string, v: string) => { const setArr = <T extends Record<string, string>>(key: keyof OilSpillReportData, arr: T[], i: number, k: string, v: string) => {
const copy = [...arr]; copy[i] = { ...copy[i], [k]: v }; onChange({ ...data, [key]: copy }) const copy = [...arr]; copy[i] = { ...copy[i], [k]: v }; onChange({ ...data, [key]: copy })
@ -321,7 +725,7 @@ function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing:
<div style={S.page}> <div style={S.page}>
<div className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold"></div> <div className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold"></div>
<div style={S.sectionTitle}>4. </div> <div style={S.sectionTitle}>4. </div>
<div style={S.mapPlaceholder}> (10km ) </div> <SensitiveResourceMapSection data={data} editing={editing} onChange={onChange} />
<div style={S.subHeader}> </div> <div style={S.subHeader}> </div>
<table style={S.table}> <table style={S.table}>
@ -406,13 +810,197 @@ function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing:
) )
} }
const SENS_LEVELS = [
{ key: 5, level: '매우 높음', color: '#ef4444' },
{ key: 4, level: '높음', color: '#f97316' },
{ key: 3, level: '보통', color: '#eab308' },
{ key: 2, level: '낮음', color: '#22c55e' },
]
function SensitivityMapSection({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
const mapContainerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<maplibregl.Map | null>(null)
const [mapVisible, setMapVisible] = useState(false)
const [loading, setLoading] = useState(false)
const [capturing, setCapturing] = useState(false)
const acdntSn = data.acdntSn
const handleLoad = async () => {
if (!acdntSn) return
setLoading(true)
try {
const geojson = await fetchSensitivityEvaluationGeojson(acdntSn)
const seasonKey = getSeasonKey(data.incident.occurTime)
// 레벨 계산 및 면적 집계 (레벨 1 제외)
const areaByLevel: Record<number, number> = {}
geojson.features.forEach(f => {
const p = (f as { properties: Record<string, unknown> }).properties
const lvl = Number(p[seasonKey] ?? 1)
if (lvl >= 2) areaByLevel[lvl] = (areaByLevel[lvl] ?? 0) + Number(p['area_km2'] ?? 0)
})
const sensitivityRows = SENS_LEVELS.map(s => ({
level: s.level, color: s.color,
area: areaByLevel[s.key] != null ? areaByLevel[s.key].toFixed(2) : '',
}))
onChange({ ...data, sensitivity: sensitivityRows })
// 레벨 속성 추가 + 레벨 1 제외한 표시용 GeoJSON
const displayGeojson = {
...geojson,
features: geojson.features
.map(f => {
const p = (f as { properties: Record<string, unknown> }).properties
return { ...(f as object), properties: { ...p, level: Number(p[seasonKey] ?? 1) } }
})
.filter(f => (f as { properties: { level: number } }).properties.level >= 2),
}
setMapVisible(true)
setTimeout(() => {
if (!mapContainerRef.current) return
const map = new maplibregl.Map({
container: mapContainerRef.current,
style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
center: [127.5, 35.5],
zoom: 8,
preserveDrawingBuffer: true,
})
mapRef.current = map
map.on('load', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map.addSource('sensitivity', { type: 'geojson', data: displayGeojson as any })
const colorExpr: maplibregl.ExpressionSpecification = ['case',
['==', ['get', 'level'], 5], '#ef4444',
['==', ['get', 'level'], 4], '#f97316',
['==', ['get', 'level'], 3], '#eab308',
['==', ['get', 'level'], 2], '#22c55e',
'transparent',
]
map.addLayer({ id: 'sensitivity-fill', type: 'fill', source: 'sensitivity', filter: ['==', '$type', 'Polygon'], paint: { 'fill-color': colorExpr, 'fill-opacity': 0.6 } })
map.addLayer({ id: 'sensitivity-line', type: 'line', source: 'sensitivity', filter: ['==', '$type', 'Polygon'], paint: { 'line-color': colorExpr, 'line-width': 0.5, 'line-opacity': 0.4 } })
map.addLayer({ id: 'sensitivity-circle', type: 'circle', source: 'sensitivity', filter: ['==', '$type', 'Point'], paint: { 'circle-radius': 5, 'circle-color': colorExpr } })
// fitBounds
const coords: [number, number][] = []
displayGeojson.features.forEach(f => {
const geom = (f as { geometry: { type: string; coordinates: unknown } }).geometry
if (geom.type === 'Point') coords.push(geom.coordinates as [number, number])
else if (geom.type === 'Polygon') (geom.coordinates as [number, number][][])[0]?.forEach(c => coords.push(c))
else if (geom.type === 'MultiPolygon') (geom.coordinates as [number, number][][][]).forEach(rings => rings[0]?.forEach(c => coords.push(c)))
})
if (coords.length > 0) {
const lngs = coords.map(c => c[0])
const lats = coords.map(c => c[1])
map.fitBounds([[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]], { padding: 60, maxZoom: 13 })
}
// 사고 위치 마커 (캔버스 레이어 — 캡처에 포함)
const incLat = parseCoord(data.incident.lat)
const incLon = parseCoord(data.incident.lon)
if (incLat != null && incLon != null) {
map.addSource('incident-point', {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'Point', coordinates: [incLon, incLat] }, properties: {} },
})
map.addLayer({ id: 'incident-circle', type: 'circle', source: 'incident-point', paint: { 'circle-radius': 7, 'circle-color': '#ef4444', 'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff' } })
map.addLayer({ id: 'incident-label', type: 'symbol', source: 'incident-point', layout: { 'text-field': '사고위치', 'text-size': 11, 'text-offset': [0, 1.6], 'text-anchor': 'top', 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'] }, paint: { 'text-color': '#ffffff', 'text-halo-color': '#ef4444', 'text-halo-width': 3 } })
}
})
}, 100)
} catch {
alert('통합민감도 평가 데이터를 불러오지 못했습니다.')
} finally {
setLoading(false)
}
}
const handleCapture = () => {
const map = mapRef.current
if (!map) return
setCapturing(true)
map.once('idle', () => {
const dataUrl = map.getCanvas().toDataURL('image/png')
onChange({ ...data, sensitivityMapImage: dataUrl })
setCapturing(false)
})
map.triggerRepaint()
}
const handleReset = () => {
if (mapRef.current) { mapRef.current.remove(); mapRef.current = null }
setMapVisible(false)
onChange({ ...data, sensitivityMapImage: undefined })
}
useEffect(() => { return () => { mapRef.current?.remove() } }, [])
if (!editing) {
if (data.sensitivityMapImage) {
return <img src={data.sensitivityMapImage} alt="통합민감도 평가 지도" style={{ width: '100%', maxHeight: 440, objectFit: 'contain', border: '1px solid #ddd', borderRadius: 4, marginBottom: 16 }} />
}
return <div style={S.mapPlaceholder}> </div>
}
if (!acdntSn) return <div style={S.mapPlaceholder}> </div>
if (data.sensitivityMapImage) {
return (
<div style={{ position: 'relative' }}>
<img src={data.sensitivityMapImage} alt="통합민감도 평가 지도" style={{ width: '100%', maxHeight: 440, objectFit: 'contain', border: '1px solid #ddd', borderRadius: 4 }} />
<button
onClick={handleReset}
style={{ position: 'absolute', top: 8, right: 8, background: 'rgba(0,0,0,0.6)', color: '#fff', border: 'none', borderRadius: 4, padding: '3px 8px', fontSize: 11, cursor: 'pointer' }}
>
</button>
</div>
)
}
return (
<div style={{ position: 'relative' }}>
{!mapVisible ? (
<div style={{ ...S.mapPlaceholder, flexDirection: 'column', gap: 8 }}>
<span> (10km내) </span>
<button
onClick={handleLoad}
disabled={loading}
style={{ background: '#0891b2', color: '#fff', border: 'none', borderRadius: 4, padding: '5px 14px', fontSize: 12, cursor: loading ? 'not-allowed' : 'pointer', opacity: loading ? 0.7 : 1 }}
>
{loading ? '불러오는 중...' : '지도 불러오기'}
</button>
</div>
) : (
<div style={{ position: 'relative' }}>
<div ref={mapContainerRef} style={{ width: '100%', height: 440, borderRadius: 4, overflow: 'hidden' }} />
<div style={{ display: 'flex', gap: 8, marginTop: 6, justifyContent: 'flex-end' }}>
<button
onClick={handleReset}
style={{ background: 'transparent', color: '#6b7280', border: '1px solid #374151', borderRadius: 4, padding: '3px 10px', fontSize: 11, cursor: 'pointer' }}
>
</button>
<button
onClick={handleCapture}
disabled={capturing}
style={{ background: '#0891b2', color: '#fff', border: 'none', borderRadius: 4, padding: '3px 12px', fontSize: 11, cursor: capturing ? 'not-allowed' : 'pointer', opacity: capturing ? 0.7 : 1 }}
>
{capturing ? '캡처 중...' : '이 화면으로 캡처'}
</button>
</div>
</div>
)}
</div>
)
}
function Page5({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) { function Page5({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
const setSens = (i: number, v: string) => { const s = [...data.sensitivity]; s[i] = { ...s[i], area: v }; onChange({ ...data, sensitivity: s }) } const setSens = (i: number, v: string) => { const s = [...data.sensitivity]; s[i] = { ...s[i], area: v }; onChange({ ...data, sensitivity: s }) }
return ( return (
<div style={S.page}> <div style={S.page}>
<div className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold"></div> <div className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold"></div>
<div style={S.sectionTitle}> ( )</div> <div style={S.sectionTitle}> ( )</div>
<div style={S.mapPlaceholder}> </div> <SensitivityMapSection data={data} editing={editing} onChange={onChange} />
<table style={S.table}> <table style={S.table}>
<thead><tr><th style={S.th}></th><th style={S.th}> (km²)</th></tr></thead> <thead><tr><th style={S.th}></th><th style={S.th}> (km²)</th></tr></thead>
<tbody>{data.sensitivity.map((s, i) => ( <tbody>{data.sensitivity.map((s, i) => (

파일 보기

@ -4,12 +4,24 @@ import type { OilReportPayload } from '@common/hooks/useSubMenu';
interface OilSpreadMapPanelProps { interface OilSpreadMapPanelProps {
mapData: OilReportPayload['mapData']; mapData: OilReportPayload['mapData'];
capturedImage: string | null; capturedStep3: string | null;
capturedStep6: string | null;
onCaptureStep3: (dataUrl: string) => void;
onCaptureStep6: (dataUrl: string) => void;
onResetStep3: () => void;
onResetStep6: () => void;
}
interface MapSlotProps {
label: string;
step: number;
mapData: NonNullable<OilReportPayload['mapData']>;
captured: string | null;
onCapture: (dataUrl: string) => void; onCapture: (dataUrl: string) => void;
onReset: () => void; onReset: () => void;
} }
const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSpreadMapPanelProps) => { const MapSlot = ({ label, step, mapData, captured, onCapture, onReset }: MapSlotProps) => {
const captureRef = useRef<(() => Promise<string | null>) | null>(null); const captureRef = useRef<(() => Promise<string | null>) | null>(null);
const [isCapturing, setIsCapturing] = useState(false); const [isCapturing, setIsCapturing] = useState(false);
@ -18,29 +30,29 @@ const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSp
setIsCapturing(true); setIsCapturing(true);
const dataUrl = await captureRef.current(); const dataUrl = await captureRef.current();
setIsCapturing(false); setIsCapturing(false);
if (dataUrl) { if (dataUrl) onCapture(dataUrl);
onCapture(dataUrl);
}
}; };
if (!mapData) {
return (
<div className="w-full h-[280px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean mb-4">
. .
</div>
);
}
return ( return (
<div className="mb-4"> <div className="flex flex-col">
{/* 지도 + 오버레이 컨테이너 — MapView 항상 마운트 유지 (deck.gl rAF race condition 방지) */} {/* 라벨 */}
<div className="relative w-full rounded-lg border border-border overflow-hidden" style={{ height: '620px' }}> <div className="flex items-center gap-1.5 mb-1.5">
<span
className="text-[11px] font-bold font-korean px-2 py-0.5 rounded"
style={{ background: 'rgba(6,182,212,0.12)', color: '#06b6d4', border: '1px solid rgba(6,182,212,0.25)' }}
>
{label}
</span>
</div>
{/* 지도 + 캡처 오버레이 */}
<div className="relative rounded-lg border border-border overflow-hidden" style={{ height: '300px' }}>
<MapView <MapView
center={mapData.center} center={mapData.center}
zoom={mapData.zoom} zoom={mapData.zoom}
incidentCoord={{ lat: mapData.center[0], lon: mapData.center[1] }} incidentCoord={{ lat: mapData.center[0], lon: mapData.center[1] }}
oilTrajectory={mapData.trajectory} oilTrajectory={mapData.trajectory}
externalCurrentTime={mapData.currentStep} externalCurrentTime={step}
centerPoints={mapData.centerPoints} centerPoints={mapData.centerPoints}
showBeached={true} showBeached={true}
showTimeLabel={true} showTimeLabel={true}
@ -50,27 +62,26 @@ const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSp
lightMode lightMode
/> />
{/* 캡처 이미지 오버레이 — 우측 상단 */} {captured && (
{capturedImage && ( <div className="absolute top-2 right-2 z-10" style={{ width: '180px' }}>
<div className="absolute top-3 right-3 z-10" style={{ width: '220px' }}>
<div <div
className="rounded-lg overflow-hidden" className="rounded-lg overflow-hidden"
style={{ border: '1px solid rgba(6,182,212,0.5)', boxShadow: '0 4px 16px rgba(0,0,0,0.5)' }} style={{ border: '1px solid rgba(6,182,212,0.5)', boxShadow: '0 4px 16px rgba(0,0,0,0.5)' }}
> >
<img src={capturedImage} alt="확산예측 지도 캡처" className="w-full block" /> <img src={captured} alt={`${label} 캡처`} className="w-full block" />
<div <div
className="flex items-center justify-between px-2.5 py-1.5" className="flex items-center justify-between px-2 py-1"
style={{ background: 'rgba(15,23,42,0.85)', borderTop: '1px solid rgba(6,182,212,0.3)' }} style={{ background: 'rgba(15,23,42,0.85)', borderTop: '1px solid rgba(6,182,212,0.3)' }}
> >
<span className="text-[10px] font-korean font-semibold" style={{ color: '#06b6d4' }}> <span className="text-[9px] font-korean font-semibold" style={{ color: '#06b6d4' }}>
📷 📷
</span> </span>
<button <button
onClick={onReset} onClick={onReset}
className="text-[10px] font-korean hover:text-text-1 transition-colors" className="text-[9px] font-korean hover:text-text-1 transition-colors"
style={{ color: 'rgba(148,163,184,0.8)' }} style={{ color: 'rgba(148,163,184,0.8)' }}
> >
</button> </button>
</div> </div>
</div> </div>
@ -78,30 +89,69 @@ const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSp
)} )}
</div> </div>
{/* 하단 안내 + 캡처 버튼 */} {/* 캡처 버튼 */}
<div className="flex items-center justify-between mt-2"> <div className="flex items-center justify-between mt-1.5">
<p className="text-[10px] text-text-3 font-korean"> <p className="text-[9px] text-text-3 font-korean">
{capturedImage {captured ? 'PDF 출력 시 포함됩니다.' : '원하는 범위를 선택 후 캡처하세요.'}
? 'PDF 다운로드 시 캡처된 이미지가 포함됩니다.'
: '지도를 이동/확대하여 원하는 범위를 선택한 후 캡처하세요.'}
</p> </p>
<button <button
onClick={handleCapture} onClick={handleCapture}
disabled={isCapturing || !!capturedImage} disabled={isCapturing || !!captured}
className="px-3 py-1.5 text-[11px] font-semibold rounded transition-all font-korean flex items-center gap-1.5" className="px-2.5 py-1 text-[10px] font-semibold rounded transition-all font-korean flex items-center gap-1"
style={{ style={{
background: capturedImage ? 'rgba(6,182,212,0.06)' : 'rgba(6,182,212,0.12)', background: captured ? 'rgba(6,182,212,0.06)' : 'rgba(6,182,212,0.12)',
border: '1px solid rgba(6,182,212,0.4)', border: '1px solid rgba(6,182,212,0.4)',
color: capturedImage ? 'rgba(6,182,212,0.5)' : '#06b6d4', color: captured ? 'rgba(6,182,212,0.5)' : '#06b6d4',
opacity: isCapturing ? 0.6 : 1, opacity: isCapturing ? 0.6 : 1,
cursor: capturedImage ? 'default' : 'pointer', cursor: captured ? 'default' : 'pointer',
}} }}
> >
{capturedImage ? '✓ 캡처됨' : '📷 이 범위로 캡처'} {captured ? '✓ 캡처됨' : '📷 캡처'}
</button> </button>
</div> </div>
</div> </div>
); );
}; };
const OilSpreadMapPanel = ({
mapData,
capturedStep3,
capturedStep6,
onCaptureStep3,
onCaptureStep6,
onResetStep3,
onResetStep6,
}: OilSpreadMapPanelProps) => {
if (!mapData) {
return (
<div className="w-full h-[200px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean mb-4">
. .
</div>
);
}
return (
<div className="mb-4">
<div className="grid grid-cols-2 gap-4">
<MapSlot
label="3시간 후"
step={3}
mapData={mapData}
captured={capturedStep3}
onCapture={onCaptureStep3}
onReset={onResetStep3}
/>
<MapSlot
label="6시간 후"
step={6}
mapData={mapData}
captured={capturedStep6}
onCapture={onCaptureStep6}
onReset={onResetStep6}
/>
</div>
</div>
);
};
export default OilSpreadMapPanel; export default OilSpreadMapPanel;

파일 보기

@ -3,6 +3,7 @@ import {
createEmptyReport, createEmptyReport,
} from './OilSpillReportTemplate'; } from './OilSpillReportTemplate';
import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload, consumeOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu'; import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload, consumeOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu';
import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore';
import OilSpreadMapPanel from './OilSpreadMapPanel'; import OilSpreadMapPanel from './OilSpreadMapPanel';
import { saveReport } from '../services/reportsApi'; import { saveReport } from '../services/reportsApi';
import { import {
@ -32,8 +33,11 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
const [hnsPayload, setHnsPayload] = useState<HnsReportPayload | null>(null) const [hnsPayload, setHnsPayload] = useState<HnsReportPayload | null>(null)
// OIL 실 데이터 (없으면 sampleOilData fallback) // OIL 실 데이터 (없으면 sampleOilData fallback)
const [oilPayload, setOilPayload] = useState<OilReportPayload | null>(null) const [oilPayload, setOilPayload] = useState<OilReportPayload | null>(null)
// 확산예측 지도 캡처 이미지 // 기상 스냅샷 (관측소명, 수집시각)
const [oilMapCaptured, setOilMapCaptured] = useState<string | null>(null) const weatherSnapshot = useWeatherSnapshotStore(s => s.snapshot)
// 확산예측 지도 캡처 이미지 (3h/6h)
const [capturedStep3, setCapturedStep3] = useState<string | null>(null)
const [capturedStep6, setCapturedStep6] = useState<string | null>(null)
// 외부에서 카테고리 힌트가 변경되면 반영 // 외부에서 카테고리 힌트가 변경되면 반영
useEffect(() => { useEffect(() => {
@ -94,8 +98,8 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
sunset: '', sunset: '',
windDir: oilPayload.weather.windDir, windDir: oilPayload.weather.windDir,
windSpeed: oilPayload.weather.windSpeed, windSpeed: oilPayload.weather.windSpeed,
currentDir: '', currentDir: oilPayload.weather.currentDir ?? '',
currentSpeed: '', currentSpeed: oilPayload.weather.currentSpeed ?? '',
waveHeight: oilPayload.weather.waveHeight, waveHeight: oilPayload.weather.waveHeight,
}]; }];
} }
@ -109,27 +113,24 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
coastAttachTotal: oilPayload.pollution.coastAttach, coastAttachTotal: oilPayload.pollution.coastAttach,
}; };
// 유출유확산예측 결과 — 모델별 비교 (oil-spread)
const spreadLines = [
oilPayload.spread.kosps ? `KOSPS: ${oilPayload.spread.kosps}` : '',
oilPayload.spread.openDrift ? `OpenDrift: ${oilPayload.spread.openDrift}` : '',
oilPayload.spread.poseidon ? `POSEIDON: ${oilPayload.spread.poseidon}` : '',
].filter(Boolean);
if (spreadLines.length > 0) {
report.analysis = spreadLines.join('\n');
}
// 스텝별 오염종합 상황 (3h/6h) → report.spread // 스텝별 오염종합 상황 (3h/6h) → report.spread
if (oilPayload.spreadSteps) { if (oilPayload.spreadSteps) {
report.spread = oilPayload.spreadSteps; report.spread = oilPayload.spreadSteps;
} }
// acdntSn 전달 (민감자원 지도 로드용)
if (oilPayload.acdntSn) {
(report as typeof report & { acdntSn?: number }).acdntSn = oilPayload.acdntSn;
}
} else { } else {
report.incident.pollutant = ''; report.incident.pollutant = '';
report.incident.spillAmount = ''; report.incident.spillAmount = '';
} }
} }
if (activeCat === 0 && oilMapCaptured) { if (activeCat === 0) {
report.capturedMapImage = oilMapCaptured; if (capturedStep3) report.step3MapImage = capturedStep3;
if (capturedStep6) report.step6MapImage = capturedStep6;
} }
try { try {
await saveReport(report) await saveReport(report)
@ -148,20 +149,22 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
// OIL 섹션에 실 데이터 삽입 // OIL 섹션에 실 데이터 삽입
if (activeCat === 0) { if (activeCat === 0) {
if (sec.id === 'oil-spread') { if (sec.id === 'oil-spread') {
const mapImg = oilMapCaptured const img3 = capturedStep3
? `<img src="${oilMapCaptured}" style="width:100%;max-height:300px;object-fit:contain;border:1px solid #ddd;border-radius:8px;margin-bottom:12px;" />` ? `<img src="${capturedStep3}" style="width:100%;max-height:260px;object-fit:contain;border:1px solid #ddd;border-radius:6px;" />`
: '<div style="height:60px;background:#f5f5f5;border:1px solid #ddd;border-radius:8px;display:flex;align-items:center;justify-content:center;color:#999;margin-bottom:12px;font-size:12px;">[확산예측 지도 미캡처]</div>'; : '<div style="height:60px;background:#f5f5f5;border:1px solid #ddd;border-radius:6px;display:flex;align-items:center;justify-content:center;color:#999;font-size:11px;">[미캡처]</div>';
const spreadRows = oilPayload const img6 = capturedStep6
? [ ? `<img src="${capturedStep6}" style="width:100%;max-height:260px;object-fit:contain;border:1px solid #ddd;border-radius:6px;" />`
['KOSPS', oilPayload.spread.kosps], : '<div style="height:60px;background:#f5f5f5;border:1px solid #ddd;border-radius:6px;display:flex;align-items:center;justify-content:center;color:#999;font-size:11px;">[미캡처]</div>';
['OpenDrift', oilPayload.spread.openDrift], const mapsHtml = `<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px"><div><p style="font-size:11px;font-weight:bold;color:#0891b2;margin-bottom:4px;">3시간 후</p>${img3}</div><div><p style="font-size:11px;font-weight:bold;color:#0891b2;margin-bottom:4px;">6시간 후</p>${img6}</div></div>`;
['POSEIDON', oilPayload.spread.poseidon], const spreadStepRows = oilPayload?.spreadSteps && oilPayload.spreadSteps.length > 0
] ? oilPayload.spreadSteps.map(s =>
: [['KOSPS', '—'], ['OpenDrift', '—'], ['POSEIDON', '—']]; `<tr><td style="padding:6px 8px;border:1px solid #ddd;text-align:center;font-weight:bold;">${s.elapsed}</td><td style="padding:6px 8px;border:1px solid #ddd;text-align:right;">${s.weathered || '—'}</td><td style="padding:6px 8px;border:1px solid #ddd;text-align:right;">${s.seaRemain || '—'}</td><td style="padding:6px 8px;border:1px solid #ddd;text-align:right;">${s.coastAttach || '—'}</td><td style="padding:6px 8px;border:1px solid #ddd;text-align:right;">${s.area || '—'}</td></tr>`
const tds = spreadRows.map(r => ).join('')
`<td style="padding:8px;border:1px solid #ddd;text-align:center;"><b>${r[0]}</b><br/>${r[1]}</td>` : '';
).join(''); const stepsTable = spreadStepRows
content = `${mapImg}<table style="width:100%;border-collapse:collapse;font-size:12px;"><tr>${tds}</tr></table>`; ? `<table style="width:100%;border-collapse:collapse;font-size:11px;margin-top:8px;"><thead><tr><th style="padding:6px 8px;border:1px solid #ddd;background:#f5f5f5;">경과시간</th><th style="padding:6px 8px;border:1px solid #ddd;background:#f5f5f5;">풍화량(kl)</th><th style="padding:6px 8px;border:1px solid #ddd;background:#f5f5f5;">해상잔유량(kl)</th><th style="padding:6px 8px;border:1px solid #ddd;background:#f5f5f5;">연안부착량(kl)</th><th style="padding:6px 8px;border:1px solid #ddd;background:#f5f5f5;">오염해역면적(km²)</th></tr></thead><tbody>${spreadStepRows}</tbody></table>`
: '';
content = `${mapsHtml}${stepsTable}`;
} }
} }
if (activeCat === 0 && sec.id === 'oil-coastal') { if (activeCat === 0 && sec.id === 'oil-coastal') {
@ -173,6 +176,17 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
: `<p style="font-size:12px;">최초 부착시간: <b>${oilPayload.coastal?.firstTime ?? '—'}</b> / 부착 해안길이: <b>${coastLength}</b></p>`; : `<p style="font-size:12px;">최초 부착시간: <b>${oilPayload.coastal?.firstTime ?? '—'}</b> / 부착 해안길이: <b>${coastLength}</b></p>`;
} }
} }
if (activeCat === 0 && sec.id === 'oil-sensitive') {
const resources = oilPayload?.sensitiveResources;
if (resources && resources.length > 0) {
const headerRow = `<tr><th style="padding:6px 8px;border:1px solid #ddd;background:#f5f5f5;text-align:left;">구분</th><th style="padding:6px 8px;border:1px solid #ddd;background:#f5f5f5;text-align:center;">개소</th><th style="padding:6px 8px;border:1px solid #ddd;background:#f5f5f5;text-align:right;">면적</th></tr>`;
const dataRows = resources.map(r => {
const areaText = r.totalArea != null ? `${r.totalArea.toFixed(2)} ha` : '—';
return `<tr><td style="padding:6px 8px;border:1px solid #ddd;">${r.category}</td><td style="padding:6px 8px;border:1px solid #ddd;text-align:center;">${r.count}개소</td><td style="padding:6px 8px;border:1px solid #ddd;text-align:right;">${areaText}</td></tr>`;
}).join('');
content = `<table style="width:100%;border-collapse:collapse;font-size:12px;">${headerRow}${dataRows}</table>`;
}
}
if (activeCat === 0 && oilPayload) { if (activeCat === 0 && oilPayload) {
if (sec.id === 'oil-pollution') { if (sec.id === 'oil-pollution') {
const rows = [ const rows = [
@ -366,10 +380,39 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
<> <>
<OilSpreadMapPanel <OilSpreadMapPanel
mapData={oilPayload?.mapData ?? null} mapData={oilPayload?.mapData ?? null}
capturedImage={oilMapCaptured} capturedStep3={capturedStep3}
onCapture={(dataUrl) => setOilMapCaptured(dataUrl)} capturedStep6={capturedStep6}
onReset={() => setOilMapCaptured(null)} onCaptureStep3={setCapturedStep3}
onCaptureStep6={setCapturedStep6}
onResetStep3={() => setCapturedStep3(null)}
onResetStep6={() => setCapturedStep6(null)}
/> />
{oilPayload?.spreadSteps && oilPayload.spreadSteps.length > 0 && (
<div className="mb-4 overflow-x-auto">
<table className="w-full border-collapse text-[11px]">
<thead>
<tr className="border-b border-border bg-bg-3">
<th className="px-3 py-2 text-center font-semibold text-text-3 font-korean"></th>
<th className="px-3 py-2 text-right font-semibold text-text-3 font-korean">(kl)</th>
<th className="px-3 py-2 text-right font-semibold text-text-3 font-korean">(kl)</th>
<th className="px-3 py-2 text-right font-semibold text-text-3 font-korean">(kl)</th>
<th className="px-3 py-2 text-right font-semibold text-text-3 font-korean">(km²)</th>
</tr>
</thead>
<tbody>
{oilPayload.spreadSteps.map((s, i) => (
<tr key={i} className="border-b border-border">
<td className="px-3 py-2 text-center font-semibold text-accent-1 font-korean">{s.elapsed}</td>
<td className="px-3 py-2 text-right font-mono text-text-1">{s.weathered || '—'}</td>
<td className="px-3 py-2 text-right font-mono text-text-1">{s.seaRemain || '—'}</td>
<td className="px-3 py-2 text-right font-mono text-text-1">{s.coastAttach || '—'}</td>
<td className="px-3 py-2 text-right font-mono text-text-1">{s.area || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{[ {[
{ label: 'KOSPS', value: oilPayload?.spread.kosps || '—', color: '#06b6d4' }, { label: 'KOSPS', value: oilPayload?.spread.kosps || '—', color: '#06b6d4' },
@ -410,11 +453,43 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
</table> </table>
</> </>
)} )}
{sec.id === 'oil-sensitive' && ( {sec.id === 'oil-sensitive' && (() => {
<p className="text-[12px] text-text-3 font-korean italic"> const resources = oilPayload?.sensitiveResources;
. if (!resources || resources.length === 0) {
</p> return (
)} <p className="text-[12px] text-text-3 font-korean italic">
.
</p>
);
}
return (
<table className="w-full table-fixed border-collapse">
<colgroup>
<col style={{ width: '40%' }} />
<col style={{ width: '30%' }} />
<col style={{ width: '30%' }} />
</colgroup>
<thead>
<tr className="border-b border-border">
<th className="px-4 py-2 text-[11px] text-text-3 font-korean text-left bg-[rgba(255,255,255,0.02)]"></th>
<th className="px-4 py-2 text-[11px] text-text-3 font-korean text-right bg-[rgba(255,255,255,0.02)]"></th>
<th className="px-4 py-2 text-[11px] text-text-3 font-korean text-right bg-[rgba(255,255,255,0.02)]"></th>
</tr>
</thead>
<tbody>
{resources.map((r, i) => (
<tr key={i} className="border-b border-border">
<td className="px-4 py-3 text-[12px] text-text-1 font-korean">{r.category}</td>
<td className="px-4 py-3 text-[12px] text-text-1 text-right"><span className="font-mono">{r.count}</span><span className="font-korean"></span></td>
<td className="px-4 py-3 text-[12px] text-text-1 font-mono text-right">
{r.totalArea != null ? `${r.totalArea.toFixed(2)} ha` : '—'}
</td>
</tr>
))}
</tbody>
</table>
);
})()}
{sec.id === 'oil-coastal' && (() => { {sec.id === 'oil-coastal' && (() => {
if (!oilPayload) { if (!oilPayload) {
return ( return (
@ -448,11 +523,50 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
</div> </div>
</div> </div>
)} )}
{sec.id === 'oil-tide' && ( {sec.id === 'oil-tide' && (() => {
<p className="text-[12px] text-text-3 font-korean italic"> const wx = oilPayload?.weather;
· . if (!wx) {
</p> return (
)} <p className="text-[12px] text-text-3 font-korean italic">
· .
</p>
);
}
const stationLabel = weatherSnapshot
? `${weatherSnapshot.stationName} 조위관측소`
: '조위관측소';
const capturedAt = weatherSnapshot
? new Date(weatherSnapshot.capturedAt).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
: '';
const rows = [
{ label: '풍향/풍속', value: `${wx.windDir} / ${wx.windSpeed}` },
{ label: '파고', value: wx.waveHeight + (wx.waveMaxHeight ? ` (최대 ${wx.waveMaxHeight})` : '') },
{ label: '파주기', value: wx.wavePeriod ?? '—' },
{ label: '수온', value: wx.temp },
{ label: '기압', value: wx.pressure ?? '—' },
{ label: '시정', value: wx.visibility ?? '—' },
{ label: '염분', value: wx.salinity ?? '—' },
...(wx.currentDir ? [{ label: '유향/유속', value: `${wx.currentDir} / ${wx.currentSpeed ?? '—'}` }] : []),
];
return (
<div className="space-y-2">
<div className="flex items-center gap-2 mb-3">
<span className="text-[11px] font-semibold text-accent-1 font-korean">{stationLabel}</span>
{capturedAt && (
<span className="text-[10px] text-text-3 font-korean">: {capturedAt}</span>
)}
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5">
{rows.map(row => (
<div key={row.label} className="flex items-center gap-2">
<span className="text-[10px] text-text-3 font-korean w-[64px] shrink-0">{row.label}</span>
<span className="text-[12px] font-semibold text-text-1 font-mono">{row.value}</span>
</div>
))}
</div>
</div>
);
})()}
{/* ── HNS 대기확산 섹션들 ── */} {/* ── HNS 대기확산 섹션들 ── */}
{sec.id === 'hns-atm' && ( {sec.id === 'hns-atm' && (

파일 보기

@ -302,7 +302,12 @@ export function ReportsView() {
const getVal = buildReportGetVal(previewReport) const getVal = buildReportGetVal(previewReport)
const meta = { writeTime: previewReport.incident.writeTime, author: previewReport.author, jurisdiction: previewReport.jurisdiction } const meta = { writeTime: previewReport.incident.writeTime, author: previewReport.author, jurisdiction: previewReport.jurisdiction }
const filename = previewReport.title || tpl.label const filename = previewReport.title || tpl.label
exportAsHWP(tpl.label, meta, tpl.sections, getVal, filename) exportAsHWP(tpl.label, meta, tpl.sections, getVal, filename, {
step3: previewReport.step3MapImage || undefined,
step6: previewReport.step6MapImage || undefined,
sensitiveMap: previewReport.sensitiveMapImage || undefined,
sensitivityMap: previewReport.sensitivityMapImage || undefined,
})
} }
}} }}
className="w-full flex items-center justify-center gap-1.5 font-korean text-[12px] font-bold cursor-pointer rounded-md py-[11px]" className="w-full flex items-center justify-center gap-1.5 font-korean text-[12px] font-bold cursor-pointer rounded-md py-[11px]"
@ -357,38 +362,52 @@ export function ReportsView() {
{[ {[
previewReport.incident.pollutant && `유출유종: ${previewReport.incident.pollutant}`, previewReport.incident.pollutant && `유출유종: ${previewReport.incident.pollutant}`,
previewReport.incident.spillAmount && `유출량: ${previewReport.incident.spillAmount}`, previewReport.incident.spillAmount && `유출량: ${previewReport.incident.spillAmount}`,
previewReport.spread?.length > 0 && `확산예측: ${previewReport.spread.length}개 시점 데이터`,
].filter(Boolean).join('\n') || '—'} ].filter(Boolean).join('\n') || '—'}
</div> </div>
{previewReport.capturedMapImage && ( {(previewReport.capturedMapImage || previewReport.step3MapImage || previewReport.step6MapImage) && (
<img <div className="flex flex-col gap-2 mt-3">
src={previewReport.capturedMapImage} {previewReport.capturedMapImage && (
alt="확산예측 지도 캡처" <img
className="w-full rounded-lg border border-border mt-3" src={previewReport.capturedMapImage}
style={{ maxHeight: '300px', objectFit: 'contain' }} alt="확산예측 지도"
/> className="w-full rounded-lg border border-border"
style={{ maxHeight: '300px', objectFit: 'contain' }}
/>
)}
{(previewReport.step3MapImage || previewReport.step6MapImage) && (
<div className="grid grid-cols-2 gap-3">
{previewReport.step3MapImage && (
<div className="relative">
<span className="absolute top-1.5 left-1.5 z-10 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-cyan-500/70 text-white">
3
</span>
<img
src={previewReport.step3MapImage}
alt="3시간 예측 지도"
className="w-full rounded-lg border border-border"
style={{ maxHeight: '220px', objectFit: 'contain' }}
/>
</div>
)}
{previewReport.step6MapImage && (
<div className="relative">
<span className="absolute top-1.5 left-1.5 z-10 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-cyan-500/70 text-white">
6
</span>
<img
src={previewReport.step6MapImage}
alt="6시간 예측 지도"
className="w-full rounded-lg border border-border"
style={{ maxHeight: '220px', objectFit: 'contain' }}
/>
</div>
)}
</div>
)}
</div>
)} )}
</div> </div>
{/* 3. 초동조치 / 대응현황 */}
<div>
<div className="font-korean text-[12px] font-bold text-primary-cyan border-b pb-1 mb-2" style={{ borderColor: 'rgba(6,182,212,0.15)' }}>
3. /
</div>
<div className="font-korean text-[12px] leading-[1.7] whitespace-pre-wrap mt-2">
{previewReport.analysis || '—'}
</div>
</div>
{/* 4. 향후 계획 */}
<div>
<div className="font-korean text-[12px] font-bold text-primary-cyan border-b pb-1 mb-2" style={{ borderColor: 'rgba(6,182,212,0.15)' }}>
4.
</div>
<div className="font-korean text-[12px] leading-[1.7] whitespace-pre-wrap mt-2">
{previewReport.etcEquipment || '—'}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

파일 보기

@ -101,7 +101,7 @@ const MANIFEST_XML =
/** /**
* Contents/content.hpf: Skeleton.hwpx , * Contents/content.hpf: Skeleton.hwpx ,
*/ */
function buildContentHpf(): string { function buildContentHpf(extraManifestItems = ''): string {
const now = new Date().toISOString(); const now = new Date().toISOString();
return ( return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>' + '<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>' +
@ -135,6 +135,7 @@ function buildContentHpf(): string {
'<opf:item id="header" href="Contents/header.xml" media-type="application/xml"/>' + '<opf:item id="header" href="Contents/header.xml" media-type="application/xml"/>' +
'<opf:item id="section0" href="Contents/section0.xml" media-type="application/xml"/>' + '<opf:item id="section0" href="Contents/section0.xml" media-type="application/xml"/>' +
'<opf:item id="settings" href="settings.xml" media-type="application/xml"/>' + '<opf:item id="settings" href="settings.xml" media-type="application/xml"/>' +
extraManifestItems +
'</opf:manifest>' + '</opf:manifest>' +
'<opf:spine>' + '<opf:spine>' +
'<opf:itemref idref="header" linear="yes"/>' + '<opf:itemref idref="header" linear="yes"/>' +
@ -490,6 +491,30 @@ function buildEmptyPara(): string {
); );
} }
/**
* ( )
* binDataId: hh:binData id , widthHwp/heightHwp: HWPUNIT
*/
function buildPicParagraph(binDataId: number, widthHwp: number, heightHwp: number): string {
const pId = nextId();
const picId = nextId();
return (
`<hp:p id="${pId}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">` +
'<hp:run charPrIDRef="0">' +
`<hp:pic id="${picId}" zOrder="0" numberingType="FIGURE" textWrap="FLOAT" ` +
`textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" pageBreak="CELL">` +
`<hp:sz width="${widthHwp}" height="${heightHwp}" widthRelTo="ABSOLUTE" heightRelTo="ABSOLUTE" protect="0"/>` +
'<hp:pos treatAsChar="1" affectLSpacing="0" flowWithText="1" allowOverlap="0" ' +
'holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="COLUMN" ' +
'vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/>' +
'<hp:outMargin left="0" right="0" top="0" bottom="0"/>' +
`<hp:img binDataIDRef="${binDataId}" effect="REAL_PIC" alpha="0"/>` +
'</hp:pic>' +
'</hp:run>' +
'</hp:p>'
);
}
/** /**
* (subList ) * (subList )
*/ */
@ -542,6 +567,104 @@ const CONTENT_WIDTH = 42520;
const LABEL_WIDTH = Math.round(CONTENT_WIDTH * 0.33); const LABEL_WIDTH = Math.round(CONTENT_WIDTH * 0.33);
const VALUE_WIDTH = CONTENT_WIDTH - LABEL_WIDTH; const VALUE_WIDTH = CONTENT_WIDTH - LABEL_WIDTH;
/**
* HTML (reportUtils의 __tide, __weather HTML )
*/
function isHtmlContent(text: string): boolean {
return text.trimStart().startsWith('<');
}
/**
* HTML <table> Element HWPX hp:tbl XML
*/
function buildHwpxFromHtmlTableElement(table: Element): string {
const rows = Array.from(table.querySelectorAll('tr'));
if (rows.length === 0) return '';
// 최대 열 수 산출 (colspan 고려)
let colCount = 0;
for (const row of rows) {
let rowCols = 0;
for (const cell of Array.from(row.children)) {
const span = parseInt((cell as HTMLElement).getAttribute('colspan') || '1', 10) || 1;
rowCols += span;
}
if (rowCols > colCount) colCount = rowCols;
}
if (colCount === 0) colCount = 1;
const colWidth = Math.floor(CONTENT_WIDTH / colCount);
const rowCnt = rows.length;
let rowsXml = '';
rows.forEach((row, rowIdx) => {
let colAddr = 0;
let cells = '';
Array.from(row.children).forEach((cell) => {
const isLabel = cell.tagName.toLowerCase() === 'th';
const colSpan = parseInt((cell as HTMLElement).getAttribute('colspan') || '1', 10) || 1;
const text = ((cell as HTMLElement).textContent || '').trim();
const cellWidth = colWidth * colSpan;
cells += buildCell(text, colAddr, rowIdx, colSpan, 1, cellWidth, isLabel);
colAddr += colSpan;
});
rowsXml += '<hp:tr>' + cells + '</hp:tr>';
});
const pId = nextId();
const tblId = nextId();
const tblHeight = rowCnt * 564;
return (
`<hp:p id="${pId}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">` +
'<hp:run charPrIDRef="0">' +
`<hp:tbl id="${tblId}" zOrder="0" numberingType="TABLE" textWrap="TOP_AND_BOTTOM" ` +
`textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" pageBreak="CELL" ` +
`repeatHeader="0" rowCnt="${rowCnt}" colCnt="${colCount}" cellSpacing="0" ` +
`borderFillIDRef="2" noAdjust="0">` +
`<hp:sz width="${CONTENT_WIDTH}" height="${tblHeight}" widthRelTo="ABSOLUTE" heightRelTo="ABSOLUTE" protect="0"/>` +
'<hp:pos treatAsChar="1" affectLSpacing="0" flowWithText="1" allowOverlap="0" ' +
'holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="COLUMN" ' +
'vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/>' +
'<hp:outMargin left="0" right="0" top="0" bottom="0"/>' +
'<hp:inMargin left="0" right="0" top="0" bottom="0"/>' +
rowsXml +
'</hp:tbl>' +
'</hp:run>' +
'</hp:p>'
);
}
/**
* HTML HWPX XML
* <table> hp:tbl, <p> buildPara,
*/
function htmlContentToHwpx(html: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(`<div>${html}</div>`, 'text/html');
const container = doc.body.firstElementChild;
if (!container) return buildPara('-', 0);
let xml = '';
for (const child of Array.from(container.childNodes)) {
if (child.nodeType === Node.ELEMENT_NODE) {
const el = child as Element;
const tag = el.tagName.toLowerCase();
if (tag === 'table') {
xml += buildHwpxFromHtmlTableElement(el);
} else {
const text = ((el as HTMLElement).textContent || '').trim();
if (text) xml += buildPara(text, 0);
}
} else if (child.nodeType === Node.TEXT_NODE) {
const text = (child.textContent || '').trim();
if (text) xml += buildPara(text, 0);
}
}
return xml || buildPara('-', 0);
}
function buildFieldTable( function buildFieldTable(
fields: { key: string; label: string }[], fields: { key: string; label: string }[],
getVal: (key: string) => string, getVal: (key: string) => string,
@ -549,6 +672,14 @@ function buildFieldTable(
const rowCnt = fields.length; const rowCnt = fields.length;
if (rowCnt === 0) return ''; if (rowCnt === 0) return '';
// 단일 필드 + 빈 label + HTML 값인 경우 → HTML→HWPX 변환
if (fields.length === 1 && !fields[0].label) {
const value = getVal(fields[0].key) || '-';
if (isHtmlContent(value)) {
return htmlContentToHwpx(value);
}
}
let rows = ''; let rows = '';
fields.forEach((field, rowIdx) => { fields.forEach((field, rowIdx) => {
const value = getVal(field.key) || '-'; const value = getVal(field.key) || '-';
@ -604,6 +735,7 @@ function buildSection0Xml(
meta: ReportMeta, meta: ReportMeta,
sections: ReportSection[], sections: ReportSection[],
getVal: (key: string) => string, getVal: (key: string) => string,
imageBinIds?: { step3?: number; step6?: number; sensitiveMap?: number; sensitivityMap?: number },
): string { ): string {
// ID 시퀀스 초기화 (재사용 시 충돌 방지) // ID 시퀀스 초기화 (재사용 시 충돌 방지)
_idSeq = 1000000000; _idSeq = 1000000000;
@ -634,9 +766,43 @@ function buildSection0Xml(
// 섹션 제목 (11pt = charPrId 6) // 섹션 제목 (11pt = charPrId 6)
body += buildPara(section.title, 6); body += buildPara(section.title, 6);
// 필드 테이블 // __spreadMaps 필드 포함 섹션: 이미지 단락 삽입 후 나머지 필드 처리
if (section.fields.length > 0) { const hasSpreadMaps = section.fields.some(f => f.key === '__spreadMaps');
const hasSensitive = section.fields.some(f => f.key === '__sensitive');
if (hasSpreadMaps && imageBinIds) {
const regularFields = section.fields.filter(f => f.key !== '__spreadMaps');
if (imageBinIds.step3) {
body += buildPara('3시간 후 예측', 0);
body += buildPicParagraph(imageBinIds.step3, CONTENT_WIDTH, 24000);
}
if (imageBinIds.step6) {
body += buildPara('6시간 후 예측', 0);
body += buildPicParagraph(imageBinIds.step6, CONTENT_WIDTH, 24000);
}
if (regularFields.length > 0) {
body += buildFieldTable(regularFields, getVal);
}
} else if (hasSensitive) {
// 민감자원 분포 지도 — 테이블 앞
if (imageBinIds?.sensitiveMap) {
body += buildPara('민감자원 분포 지도', 0);
body += buildPicParagraph(imageBinIds.sensitiveMap, CONTENT_WIDTH, 24000);
}
body += buildFieldTable(section.fields, getVal); body += buildFieldTable(section.fields, getVal);
// 통합민감도 평가 지도 — 테이블 뒤
if (imageBinIds?.sensitivityMap) {
body += buildPara('통합민감도 평가 지도', 0);
body += buildPicParagraph(imageBinIds.sensitivityMap, CONTENT_WIDTH, 24000);
}
} else {
// 필드 테이블
const fields = section.fields.filter(f => f.key !== '__spreadMaps');
if (hasSpreadMaps) {
// 이미지 없는 경우 __spreadMaps 필드 제외하고 나머지만 출력
if (fields.length > 0) body += buildFieldTable(fields, getVal);
} else if (section.fields.length > 0) {
body += buildFieldTable(section.fields, getVal);
}
} }
// 섹션 후 빈 줄 // 섹션 후 빈 줄
@ -669,7 +835,10 @@ function buildPrvText(
for (const section of sections) { for (const section of sections) {
lines.push(`[${section.title}]`); lines.push(`[${section.title}]`);
for (const field of section.fields) { for (const field of section.fields) {
const value = getVal(field.key) || '-'; const raw = getVal(field.key) || '-';
const value = isHtmlContent(raw)
? raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() || '-'
: raw;
if (field.label) { if (field.label) {
lines.push(` ${field.label}: ${value}`); lines.push(` ${field.label}: ${value}`);
} else { } else {
@ -690,6 +859,7 @@ export async function exportAsHWPX(
sections: ReportSection[], sections: ReportSection[],
getVal: (key: string) => string, getVal: (key: string) => string,
filename: string, filename: string,
images?: { step3?: string; step6?: string; sensitiveMap?: string; sensitivityMap?: string },
): Promise<void> { ): Promise<void> {
const zip = new JSZip(); const zip = new JSZip();
@ -703,10 +873,66 @@ export async function exportAsHWPX(
zip.file('META-INF/container.rdf', CONTAINER_RDF); zip.file('META-INF/container.rdf', CONTAINER_RDF);
zip.file('META-INF/manifest.xml', MANIFEST_XML); zip.file('META-INF/manifest.xml', MANIFEST_XML);
// 이미지 처리
let imageBinIds: { step3?: number; step6?: number; sensitiveMap?: number; sensitivityMap?: number } | undefined;
let extraManifestItems = '';
let binDataListXml = '';
let binCount = 0;
const processImage = (src: string, binId: number, fileId: string) => {
// 실제 이미지 포맷 감지 (JPEG vs PNG)
const isJpeg = src.startsWith('data:image/jpeg') || src.startsWith('data:image/jpg');
const ext = isJpeg ? 'jpg' : 'png';
const mediaType = isJpeg ? 'image/jpeg' : 'image/png';
const filePath = `BinData/image${binId}.${ext}`;
const base64 = src.replace(/^data:image\/\w+;base64,/, '');
zip.file(filePath, base64, { base64: true });
extraManifestItems += `<opf:item id="${fileId}" href="${filePath}" media-type="${mediaType}"/>`;
// inMemory="NO": 데이터는 ZIP 내 파일로 저장, 요소 내용은 파일 경로
binDataListXml +=
`<hh:binData id="${binId}" isSameDocData="0" compress="YES" inMemory="NO" ` +
`doNotCompressFile="0" blockDecompress="0" limitWidth="0" limitHeight="0">${filePath}</hh:binData>`;
binCount++;
};
if (images?.step3 || images?.step6) {
imageBinIds = {};
if (images.step3) {
imageBinIds.step3 = 1;
processImage(images.step3, 1, 'image1');
}
if (images.step6) {
imageBinIds.step6 = 2;
processImage(images.step6, 2, 'image2');
}
}
if (images?.sensitiveMap) {
imageBinIds = imageBinIds ?? {};
imageBinIds.sensitiveMap = 3;
processImage(images.sensitiveMap, 3, 'image3');
}
if (images?.sensitivityMap) {
imageBinIds = imageBinIds ?? {};
imageBinIds.sensitivityMap = 4;
processImage(images.sensitivityMap, 4, 'image4');
}
// header.xml: binDataList를 hh:refList 내부에 삽입 (HWPML 스펙 준수)
let headerXml = HEADER_XML;
if (binCount > 0) {
const binDataList =
`<hh:binDataList itemCnt="${binCount}">` +
binDataListXml +
'</hh:binDataList>';
// refList 닫힘 태그 직전에 삽입해야 함 (binDataList는 refList의 자식)
headerXml = HEADER_XML.replace('</hh:refList>', binDataList + '</hh:refList>');
}
// Contents // Contents
zip.file('Contents/content.hpf', buildContentHpf()); zip.file('Contents/content.hpf', buildContentHpf(extraManifestItems));
zip.file('Contents/header.xml', HEADER_XML); zip.file('Contents/header.xml', headerXml);
zip.file('Contents/section0.xml', buildSection0Xml(templateLabel, meta, sections, getVal)); zip.file('Contents/section0.xml', buildSection0Xml(templateLabel, meta, sections, getVal, imageBinIds));
// Preview // Preview
zip.file('Preview/PrvText.txt', buildPrvText(templateLabel, meta, sections, getVal)); zip.file('Preview/PrvText.txt', buildPrvText(templateLabel, meta, sections, getVal));

파일 보기

@ -77,7 +77,10 @@ export const templateTypes: TemplateType[] = [
]}, ]},
{ title: '3. 조석 현황', fields: [{ key: '__tide', label: '', type: 'textarea' }] }, { title: '3. 조석 현황', fields: [{ key: '__tide', label: '', type: 'textarea' }] },
{ title: '4. 해양기상 현황', fields: [{ key: '__weather', label: '', type: 'textarea' }] }, { title: '4. 해양기상 현황', fields: [{ key: '__weather', label: '', type: 'textarea' }] },
{ title: '5. 확산예측 결과', fields: [{ key: '__spread', label: '', type: 'textarea' }] }, { title: '5. 확산예측 결과', fields: [
{ key: '__spreadMaps', label: '', type: 'textarea' },
{ key: '__spread', label: '', type: 'textarea' },
] },
{ title: '6. 민감자원 현황', fields: [{ key: '__sensitive', label: '', type: 'textarea' }] }, { title: '6. 민감자원 현황', fields: [{ key: '__sensitive', label: '', type: 'textarea' }] },
{ title: '7. 방제 자원', fields: [{ key: '__vessels', label: '', type: 'textarea' }] }, { title: '7. 방제 자원', fields: [{ key: '__vessels', label: '', type: 'textarea' }] },
{ title: '8. 회수·처리 현황',fields: [{ key: '__recovery', label: '', type: 'textarea' }] }, { title: '8. 회수·처리 현황',fields: [{ key: '__recovery', label: '', type: 'textarea' }] },

파일 보기

@ -47,9 +47,10 @@ export async function exportAsHWP(
sections: { title: string; fields: { key: string; label: string }[] }[], sections: { title: string; fields: { key: string; label: string }[] }[],
getVal: (key: string) => string, getVal: (key: string) => string,
filename: string, filename: string,
images?: { step3?: string; step6?: string; sensitiveMap?: string; sensitivityMap?: string },
) { ) {
const { exportAsHWPX } = await import('./hwpxExport'); const { exportAsHWPX } = await import('./hwpxExport');
await exportAsHWPX(templateLabel, meta, sections, getVal, filename); await exportAsHWPX(templateLabel, meta, sections, getVal, filename, images);
} }
export type ViewState = export type ViewState =
@ -121,6 +122,14 @@ function formatSpreadTable(spread: OilSpillReportData['spread']): string {
function formatSensitiveTable(r: OilSpillReportData): string { function formatSensitiveTable(r: OilSpillReportData): string {
const parts: string[] = [] const parts: string[] = []
if (r.sensitiveMapImage) {
parts.push(
'<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">민감자원 분포 지도</p>' +
`<img src="${r.sensitiveMapImage}" style="width:100%;max-height:300px;object-fit:contain;border:1px solid #ddd;border-radius:6px;display:block;margin-bottom:8px;" />`
)
}
if (r.aquaculture?.length) { if (r.aquaculture?.length) {
const h = `<tr><th style="${TH}">종류</th><th style="${TH}">면적</th><th style="${TH}">거리</th></tr>` const h = `<tr><th style="${TH}">종류</th><th style="${TH}">면적</th><th style="${TH}">거리</th></tr>`
const rows = r.aquaculture.map(a => `<tr><td style="${TD}">${a.type}</td><td style="${TD}">${a.area}</td><td style="${TD}">${a.distance}</td></tr>`).join('') const rows = r.aquaculture.map(a => `<tr><td style="${TD}">${a.type}</td><td style="${TD}">${a.area}</td><td style="${TD}">${a.distance}</td></tr>`).join('')
@ -151,6 +160,13 @@ function formatSensitiveTable(r: OilSpillReportData): string {
const rows = r.habitat.map(h2 => `<tr><td style="${TD}">${h2.type}</td><td style="${TD}">${h2.area}</td></tr>`).join('') const rows = r.habitat.map(h2 => `<tr><td style="${TD}">${h2.type}</td><td style="${TD}">${h2.area}</td></tr>`).join('')
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">서식지</p><table style="${TABLE}">${h}${rows}</table>`) parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">서식지</p><table style="${TABLE}">${h}${rows}</table>`)
} }
if (r.sensitivityMapImage) {
parts.push(
'<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">통합민감도 평가 지도</p>' +
`<img src="${r.sensitivityMapImage}" style="width:100%;max-height:300px;object-fit:contain;border:1px solid #ddd;border-radius:6px;display:block;margin-bottom:8px;" />`
)
}
if (r.sensitivity?.length) { if (r.sensitivity?.length) {
const h = `<tr><th style="${TH}">민감도</th><th style="${TH}">면적</th></tr>` const h = `<tr><th style="${TH}">민감도</th><th style="${TH}">면적</th></tr>`
const rows = r.sensitivity.map(s => `<tr><td style="${TD}">${s.level}</td><td style="${TD}">${s.area}</td></tr>`).join('') const rows = r.sensitivity.map(s => `<tr><td style="${TD}">${s.level}</td><td style="${TD}">${s.area}</td></tr>`).join('')
@ -196,6 +212,18 @@ export function buildReportGetVal(report: OilSpillReportData) {
} }
if (key === '__tide') return formatTideTable(report.tide) if (key === '__tide') return formatTideTable(report.tide)
if (key === '__weather') return formatWeatherTable(report.weather) if (key === '__weather') return formatWeatherTable(report.weather)
if (key === '__spreadMaps') {
const img3 = report.step3MapImage
const img6 = report.step6MapImage
if (!img3 && !img6) return ''
const cell = (label: string, src: string) =>
`<div style="flex:1;min-width:0"><p style="font-size:11px;font-weight:bold;color:#0891b2;margin:0 0 4px;">${label}</p>` +
`<img src="${src}" style="width:100%;max-height:260px;object-fit:contain;border:1px solid #ddd;border-radius:6px;" /></div>`
return `<div style="display:flex;gap:12px;margin-bottom:8px;">` +
(img3 ? cell('3시간 후', img3) : '') +
(img6 ? cell('6시간 후', img6) : '') +
`</div>`
}
if (key === '__spread') return formatSpreadTable(report.spread) if (key === '__spread') return formatSpreadTable(report.spread)
if (key === '__sensitive') return formatSensitiveTable(report) if (key === '__sensitive') return formatSensitiveTable(report)
if (key === '__vessels') return formatVesselsTable(report.vessels) if (key === '__vessels') return formatVesselsTable(report.vessels)

파일 보기

@ -60,6 +60,7 @@ export interface ApiReportListItem {
sttsCd: string; sttsCd: string;
authorId: string; authorId: string;
authorName: string; authorName: string;
acdntSn?: number | null;
regDtm: string; regDtm: string;
mdfcnDtm: string | null; mdfcnDtm: string | null;
hasMapCapture?: boolean; hasMapCapture?: boolean;
@ -75,7 +76,8 @@ export interface ApiReportSectionData {
export interface ApiReportDetail extends ApiReportListItem { export interface ApiReportDetail extends ApiReportListItem {
acdntSn: number | null; acdntSn: number | null;
sections: ApiReportSectionData[]; sections: ApiReportSectionData[];
mapCaptureImg?: string | null; step3MapImg?: string | null;
step6MapImg?: string | null;
} }
export interface ApiReportListResponse { export interface ApiReportListResponse {
@ -178,7 +180,8 @@ export async function createReportApi(input: {
title: string; title: string;
jrsdCd?: string; jrsdCd?: string;
sttsCd?: string; sttsCd?: string;
mapCaptureImg?: string; step3MapImg?: string;
step6MapImg?: string;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[]; sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}): Promise<{ sn: number }> { }): Promise<{ sn: number }> {
const res = await api.post<{ sn: number }>('/reports', input); const res = await api.post<{ sn: number }>('/reports', input);
@ -190,7 +193,8 @@ export async function updateReportApi(sn: number, input: {
jrsdCd?: string; jrsdCd?: string;
sttsCd?: string; sttsCd?: string;
acdntSn?: number | null; acdntSn?: number | null;
mapCaptureImg?: string | null; step3MapImg?: string | null;
step6MapImg?: string | null;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[]; sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}): Promise<void> { }): Promise<void> {
await api.post(`/reports/${sn}/update`, input); await api.post(`/reports/${sn}/update`, input);
@ -236,14 +240,26 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
// analysis + etcEquipment 합산 // analysis + etcEquipment 합산
sections.push({ sectCd: 'analysis', sectData: { analysis: data.analysis, etcEquipment: data.etcEquipment }, sortOrd: sortOrd++ }); sections.push({ sectCd: 'analysis', sectData: { analysis: data.analysis, etcEquipment: data.etcEquipment }, sortOrd: sortOrd++ });
// 민감자원 지도 이미지 (섹션으로 저장)
const extData = data as OilSpillReportData & { reportSn?: number; acdntSn?: number };
if (extData.sensitiveMapImage !== undefined) {
sections.push({ sectCd: 'sensitive-map', sectData: { mapImage: extData.sensitiveMapImage }, sortOrd: sortOrd++ });
}
// 통합민감도 평가 지도 이미지 (섹션으로 저장)
if (extData.sensitivityMapImage !== undefined) {
sections.push({ sectCd: 'sensitivity-map', sectData: { mapImage: extData.sensitivityMapImage }, sortOrd: sortOrd++ });
}
// reportSn이 있으면 update, 없으면 create // reportSn이 있으면 update, 없으면 create
const existingSn = (data as OilSpillReportData & { reportSn?: number }).reportSn; const existingSn = extData.reportSn;
if (existingSn) { if (existingSn) {
await updateReportApi(existingSn, { await updateReportApi(existingSn, {
title: data.title || data.incident.name || '보고서', title: data.title || data.incident.name || '보고서',
jrsdCd: data.jurisdiction, jrsdCd: data.jurisdiction,
sttsCd, sttsCd,
mapCaptureImg: data.capturedMapImage !== undefined ? (data.capturedMapImage || null) : undefined, acdntSn: extData.acdntSn ?? null,
step3MapImg: data.step3MapImage !== undefined ? (data.step3MapImage || null) : undefined,
step6MapImg: data.step6MapImage !== undefined ? (data.step6MapImage || null) : undefined,
sections, sections,
}); });
return existingSn; return existingSn;
@ -252,10 +268,12 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
const result = await createReportApi({ const result = await createReportApi({
tmplSn, tmplSn,
ctgrSn, ctgrSn,
acdntSn: extData.acdntSn,
title: data.title || data.incident.name || '보고서', title: data.title || data.incident.name || '보고서',
jrsdCd: data.jurisdiction, jrsdCd: data.jurisdiction,
sttsCd, sttsCd,
mapCaptureImg: data.capturedMapImage || undefined, step3MapImg: data.step3MapImage || undefined,
step6MapImg: data.step6MapImage || undefined,
sections, sections,
}); });
return result.sn; return result.sn;
@ -273,6 +291,7 @@ export function apiListItemToReportData(item: ApiReportListItem): OilSpillReport
jurisdiction: (item.jrsdCd as Jurisdiction) || '남해청', jurisdiction: (item.jrsdCd as Jurisdiction) || '남해청',
status: CODE_TO_STATUS[item.sttsCd] || '테스트', status: CODE_TO_STATUS[item.sttsCd] || '테스트',
hasMapCapture: item.hasMapCapture, hasMapCapture: item.hasMapCapture,
acdntSn: item.acdntSn ?? undefined,
// 목록에서는 섹션 데이터 없음 — 빈 기본값 // 목록에서는 섹션 데이터 없음 — 빈 기본값
incident: { name: '', writeTime: '', shipName: '', agent: '', location: '', lat: '', lon: '', occurTime: '', accidentType: '', pollutant: '', spillAmount: '', depth: '', seabed: '' }, incident: { name: '', writeTime: '', shipName: '', agent: '', location: '', lat: '', lon: '', occurTime: '', accidentType: '', pollutant: '', spillAmount: '', depth: '', seabed: '' },
tide: [], weather: [], spread: [], tide: [], weather: [], spread: [],
@ -341,6 +360,12 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa
case 'result': case 'result':
reportData.result = d as OilSpillReportData['result']; reportData.result = d as OilSpillReportData['result'];
break; break;
case 'sensitive-map':
reportData.sensitiveMapImage = (d as { mapImage?: string }).mapImage;
break;
case 'sensitivity-map':
reportData.sensitivityMapImage = (d as { mapImage?: string }).mapImage;
break;
} }
} }
@ -350,8 +375,14 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa
`위도 ${parseFloat(reportData.incident.lat).toFixed(4)}, 경도 ${parseFloat(reportData.incident.lon).toFixed(4)}`; `위도 ${parseFloat(reportData.incident.lat).toFixed(4)}, 경도 ${parseFloat(reportData.incident.lon).toFixed(4)}`;
} }
if (detail.mapCaptureImg) { if (detail.step3MapImg) {
reportData.capturedMapImage = detail.mapCaptureImg; reportData.step3MapImage = detail.step3MapImg;
}
if (detail.step6MapImg) {
reportData.step6MapImage = detail.step6MapImg;
}
if (detail.acdntSn != null) {
(reportData as typeof reportData & { acdntSn?: number }).acdntSn = detail.acdntSn;
} }
return reportData; return reportData;

파일 보기

@ -14,6 +14,7 @@ import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer'
import { useWeatherData } from '../hooks/useWeatherData' import { useWeatherData } from '../hooks/useWeatherData'
// import { useOceanForecast } from '../hooks/useOceanForecast' // import { useOceanForecast } from '../hooks/useOceanForecast'
import { WeatherMapControls } from './WeatherMapControls' import { WeatherMapControls } from './WeatherMapControls'
import { degreesToCardinal } from '../services/weatherUtils'
type TimeOffset = '0' | '3' | '6' | '9' type TimeOffset = '0' | '3' | '6' | '9'
@ -40,13 +41,6 @@ interface WeatherStation {
salinity?: number salinity?: number
} }
const CARDINAL_LABELS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] as const
function degreesToCardinal(deg: number): string {
const idx = Math.round(((deg % 360) + 360) % 360 / 22.5) % 16
return CARDINAL_LABELS[idx]
}
interface WeatherForecast { interface WeatherForecast {
time: string time: string
hour: string hour: string

파일 보기

@ -0,0 +1,121 @@
import { getRecentObservation, OBS_STATION_CODES } from './khoaApi';
import type { WeatherSnapshot } from '@common/store/weatherSnapshotStore';
const CARDINAL_LABELS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] as const;
export function degreesToCardinal(deg: number): string {
const idx = Math.round(((deg % 360) + 360) % 360 / 22.5) % 16;
return CARDINAL_LABELS[idx];
}
const BASE_STATIONS = [
{ id: 'incheon', name: '인천', location: { lat: 37.45, lon: 126.43 } },
{ id: 'ulsan', name: '울산', location: { lat: 35.52, lon: 129.38 } },
{ id: 'yeosu', name: '여수', location: { lat: 34.74, lon: 127.75 } },
{ id: 'jeju', name: '제주', location: { lat: 33.51, lon: 126.53 } },
{ id: 'pohang', name: '포항', location: { lat: 36.03, lon: 129.38 } },
{ id: 'mokpo', name: '목포', location: { lat: 34.78, lon: 126.38 } },
{ id: 'gunsan', name: '군산', location: { lat: 35.97, lon: 126.7 } },
{ id: 'sokcho', name: '속초', location: { lat: 38.21, lon: 128.59 } },
{ id: 'tongyeong', name: '통영', location: { lat: 34.83, lon: 128.43 } },
{ id: 'donghae', name: '동해', location: { lat: 37.52, lon: 129.14 } },
];
export async function fetchWeatherSnapshotForCoord(
lat: number,
lon: number,
): Promise<WeatherSnapshot> {
const nearest = BASE_STATIONS.reduce((best, s) => {
const d = (s.location.lat - lat) ** 2 + (s.location.lon - lon) ** 2;
const bd = (best.location.lat - lat) ** 2 + (best.location.lon - lon) ** 2;
return d < bd ? s : best;
});
const r = (n: number) => Math.round(n * 10) / 10;
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;
const waterTemp = r(obs.water_temp ?? 8.0);
const airTemp = r(obs.air_temp ?? waterTemp);
const pressure = Math.round(obs.air_pres ?? 1013);
const waveHeight = r(1.0 + windSpeed * 0.1);
return {
stationName: nearest.name,
capturedAt: new Date().toISOString(),
wind: {
speed: windSpeed,
direction: windDir,
directionLabel: degreesToCardinal(windDir),
speed_1k: r(windSpeed * 0.8),
speed_3k: r(windSpeed * 1.2),
},
wave: {
height: waveHeight,
maxHeight: r(waveHeight * 1.6),
period: Math.floor(4 + windSpeed * 0.3),
direction: degreesToCardinal(windDir + 45),
},
temperature: { current: waterTemp, feelsLike: r(airTemp - windSpeed * 0.3) },
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,
};
}
// Fallback: 시드 기반 더미
const seed = nearest.location.lat * 100 + nearest.location.lon;
const windSpeed = r(6 + (seed % 7));
const windDir = [0, 45, 90, 135, 180, 225, 270, 315][Math.floor(seed) % 8];
const waveHeight = r(0.8 + (seed % 20) / 10);
const temp = r(5 + (seed % 8));
return {
stationName: nearest.name,
capturedAt: new Date().toISOString(),
wind: {
speed: windSpeed,
direction: windDir,
directionLabel: degreesToCardinal(windDir),
speed_1k: r(windSpeed * 0.8),
speed_3k: r(windSpeed * 1.2),
},
wave: {
height: waveHeight,
maxHeight: r(waveHeight * 1.6),
period: 4 + (Math.floor(seed) % 3),
direction: degreesToCardinal(windDir + 45),
},
temperature: { current: temp, feelsLike: r(temp - windSpeed * 0.3) },
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,
};
}