Merge remote-tracking branch 'origin/develop' into feature/stitch-mcp
# Conflicts: # docs/RELEASE-NOTES.md
This commit is contained in:
커밋
a3b1a701e4
@ -5,29 +5,29 @@
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(curl -s *)",
|
||||
"Bash(fnm *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(npm test *)",
|
||||
"Bash(npx *)",
|
||||
"Bash(node *)",
|
||||
"Bash(git status)",
|
||||
"Bash(git diff *)",
|
||||
"Bash(git log *)",
|
||||
"Bash(git branch *)",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(git config *)",
|
||||
"Bash(git diff *)",
|
||||
"Bash(git fetch *)",
|
||||
"Bash(git log *)",
|
||||
"Bash(git merge *)",
|
||||
"Bash(git pull *)",
|
||||
"Bash(git fetch *)",
|
||||
"Bash(git merge *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(git remote *)",
|
||||
"Bash(git config *)",
|
||||
"Bash(git rev-parse *)",
|
||||
"Bash(git show *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(git status)",
|
||||
"Bash(git tag *)",
|
||||
"Bash(node *)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(npm test *)",
|
||||
"Bash(npx *)"
|
||||
"Bash(curl -s *)",
|
||||
"Bash(fnm *)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push --force*)",
|
||||
@ -83,7 +83,5 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"deny": [],
|
||||
"allow": []
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"applied_global_version": "1.6.1",
|
||||
"applied_date": "2026-03-19",
|
||||
"applied_date": "2026-03-24",
|
||||
"project_type": "react-ts",
|
||||
"gitea_url": "https://gitea.gc-si.dev",
|
||||
"custom_pre_commit": true
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
getIncident,
|
||||
listIncidentPredictions,
|
||||
getIncidentWeather,
|
||||
saveIncidentWeather,
|
||||
getIncidentMedia,
|
||||
} from './incidentsService.js';
|
||||
|
||||
@ -92,6 +93,24 @@ router.get('/:sn/weather', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// POST /api/incidents/:sn/weather — 기상정보 저장
|
||||
// ============================================================
|
||||
router.post('/:sn/weather', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const sn = parseInt(req.params.sn as string, 10);
|
||||
if (isNaN(sn)) {
|
||||
res.status(400).json({ error: '유효하지 않은 사고 번호입니다.' });
|
||||
return;
|
||||
}
|
||||
const weatherSn = await saveIncidentWeather(sn, req.body as Record<string, unknown>);
|
||||
res.json({ weatherSn });
|
||||
} catch (err) {
|
||||
console.error('[incidents] 기상정보 저장 오류:', err);
|
||||
res.status(500).json({ error: '기상정보 저장 중 오류가 발생했습니다.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET /api/incidents/:sn/media — 미디어 정보
|
||||
// ============================================================
|
||||
|
||||
@ -254,24 +254,143 @@ export async function getIncidentWeather(acdntSn: number): Promise<WeatherInfo |
|
||||
const r = rows[0] as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
locNm: r.loc_nm as string,
|
||||
obsDtm: (r.obs_dtm as Date).toISOString(),
|
||||
icon: r.icon as string,
|
||||
temp: r.temp as string,
|
||||
weatherDc: r.weather_dc as string,
|
||||
wind: r.wind as string,
|
||||
wave: r.wave as string,
|
||||
humid: r.humid as string,
|
||||
vis: r.vis as string,
|
||||
sst: r.sst as string,
|
||||
tide: r.tide as string,
|
||||
highTide: r.high_tide as string,
|
||||
lowTide: r.low_tide as string,
|
||||
locNm: (r.loc_nm as string | null) ?? '-',
|
||||
obsDtm: r.obs_dtm ? (r.obs_dtm as Date).toISOString() : '-',
|
||||
icon: (r.icon as string | null) ?? '',
|
||||
temp: (r.temp as string | null) ?? '-',
|
||||
weatherDc: (r.weather_dc as string | null) ?? '-',
|
||||
wind: (r.wind as string | null) ?? '-',
|
||||
wave: (r.wave as string | null) ?? '-',
|
||||
humid: (r.humid as string | null) ?? '-',
|
||||
vis: (r.vis as string | null) ?? '-',
|
||||
sst: (r.sst as string | null) ?? '-',
|
||||
tide: (r.tide as string | null) ?? '-',
|
||||
highTide: (r.high_tide as string | null) ?? '-',
|
||||
lowTide: (r.low_tide as string | null) ?? '-',
|
||||
forecast: (r.forecast as Array<{ hour: string; icon: string; temp: string }>) ?? [],
|
||||
impactDc: r.impact_dc as string,
|
||||
impactDc: (r.impact_dc as string | null) ?? '-',
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 기상정보 저장 (예측 실행 시 스냅샷 저장)
|
||||
// ============================================================
|
||||
interface WeatherSnapshotPayload {
|
||||
stationName?: string;
|
||||
capturedAt?: string;
|
||||
wind?: {
|
||||
speed?: number;
|
||||
direction?: number;
|
||||
directionLabel?: string;
|
||||
speed_1k?: number;
|
||||
speed_3k?: number;
|
||||
};
|
||||
wave?: {
|
||||
height?: number;
|
||||
maxHeight?: number;
|
||||
period?: number;
|
||||
direction?: string;
|
||||
};
|
||||
temperature?: {
|
||||
current?: number;
|
||||
feelsLike?: number;
|
||||
};
|
||||
pressure?: number;
|
||||
visibility?: number;
|
||||
salinity?: number;
|
||||
astronomy?: {
|
||||
sunrise?: string;
|
||||
sunset?: string;
|
||||
moonrise?: string;
|
||||
moonset?: string;
|
||||
moonPhase?: string;
|
||||
tidalRange?: number;
|
||||
} | null;
|
||||
alert?: string | null;
|
||||
forecast?: unknown[] | null;
|
||||
}
|
||||
|
||||
export async function saveIncidentWeather(
|
||||
acdntSn: number,
|
||||
snapshot: WeatherSnapshotPayload,
|
||||
): Promise<number> {
|
||||
// 팝업 표시용 포맷 문자열
|
||||
const windStr = (snapshot.wind?.directionLabel && snapshot.wind?.speed != null)
|
||||
? `${snapshot.wind.directionLabel} ${snapshot.wind.speed}m/s` : null;
|
||||
const waveStr = snapshot.wave?.height != null ? `${snapshot.wave.height}m` : null;
|
||||
const tempStr = snapshot.temperature?.feelsLike != null ? `${snapshot.temperature.feelsLike}°C` : null;
|
||||
const vis = snapshot.visibility != null ? String(snapshot.visibility) : null;
|
||||
const sst = snapshot.temperature?.current != null ? String(snapshot.temperature.current) : null;
|
||||
const highTideStr = snapshot.astronomy?.tidalRange != null
|
||||
? `조차 ${snapshot.astronomy.tidalRange}m` : null;
|
||||
|
||||
// 24h 예보: WeatherSnapshot 형식 → 팝업 표시 형식 변환
|
||||
type ForecastItem = { time?: string; icon?: string; temperature?: number };
|
||||
const forecastDisplay = (snapshot.forecast as ForecastItem[] | null)?.map(f => ({
|
||||
hour: f.time ?? '',
|
||||
icon: f.icon ?? '⛅',
|
||||
temp: f.temperature != null ? `${Math.round(f.temperature)}°` : '-',
|
||||
})) ?? null;
|
||||
|
||||
const sql = `
|
||||
INSERT INTO wing.ACDNT_WEATHER (
|
||||
ACDNT_SN, LOC_NM, OBS_DTM,
|
||||
WIND_SPEED, WIND_DIR, WIND_DIR_LBL, WIND_SPEED_1K, WIND_SPEED_3K,
|
||||
PRESSURE, VIS,
|
||||
WAVE_HEIGHT, WAVE_MAX_HT, WAVE_PERIOD, WAVE_DIR,
|
||||
SST, AIR_TEMP, SALINITY,
|
||||
SUNRISE, SUNSET, MOONRISE, MOONSET, MOON_PHASE, TIDAL_RANGE,
|
||||
WEATHER_ALERT, FORECAST,
|
||||
TEMP, WIND, WAVE, ICON, HIGH_TIDE, IMPACT_DC
|
||||
) VALUES (
|
||||
$1, $2, NOW(),
|
||||
$3, $4, $5, $6, $7,
|
||||
$8, $9,
|
||||
$10, $11, $12, $13,
|
||||
$14, $15, $16,
|
||||
$17, $18, $19, $20, $21, $22,
|
||||
$23, $24,
|
||||
$25, $26, $27, $28, $29, $30
|
||||
)
|
||||
RETURNING WEATHER_SN
|
||||
`;
|
||||
|
||||
const { rows } = await wingPool.query(sql, [
|
||||
acdntSn,
|
||||
snapshot.stationName ?? null,
|
||||
snapshot.wind?.speed ?? null,
|
||||
snapshot.wind?.direction ?? null,
|
||||
snapshot.wind?.directionLabel ?? null,
|
||||
snapshot.wind?.speed_1k ?? null,
|
||||
snapshot.wind?.speed_3k ?? null,
|
||||
snapshot.pressure ?? null,
|
||||
vis,
|
||||
snapshot.wave?.height ?? null,
|
||||
snapshot.wave?.maxHeight ?? null,
|
||||
snapshot.wave?.period ?? null,
|
||||
snapshot.wave?.direction ?? null,
|
||||
sst,
|
||||
snapshot.temperature?.feelsLike ?? null,
|
||||
snapshot.salinity ?? null,
|
||||
snapshot.astronomy?.sunrise ?? null,
|
||||
snapshot.astronomy?.sunset ?? null,
|
||||
snapshot.astronomy?.moonrise ?? null,
|
||||
snapshot.astronomy?.moonset ?? null,
|
||||
snapshot.astronomy?.moonPhase ?? null,
|
||||
snapshot.astronomy?.tidalRange ?? null,
|
||||
snapshot.alert ?? null,
|
||||
forecastDisplay ? JSON.stringify(forecastDisplay) : null,
|
||||
tempStr,
|
||||
windStr,
|
||||
waveStr,
|
||||
'🌊',
|
||||
highTideStr,
|
||||
snapshot.alert ?? null,
|
||||
]);
|
||||
|
||||
return (rows[0] as Record<string, unknown>).weather_sn as number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 미디어 정보 조회
|
||||
// ============================================================
|
||||
|
||||
@ -3,6 +3,8 @@ import multer from 'multer';
|
||||
import {
|
||||
listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt,
|
||||
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
|
||||
getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn,
|
||||
getPredictionParticlesGeojsonByAcdntSn, getSensitivityEvaluationGeojsonByAcdntSn,
|
||||
} from './predictionService.js';
|
||||
import { analyzeImageFile } from './imageAnalyzeService.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 — 사고별 역추적 목록
|
||||
router.get('/backtrack', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||
try {
|
||||
|
||||
@ -432,6 +432,8 @@ interface TrajectoryTimeStep {
|
||||
particles: TrajectoryParticle[];
|
||||
remaining_volume_m3: number;
|
||||
weathered_volume_m3: number;
|
||||
evaporation_volume_m3?: number;
|
||||
dispersion_volume_m3?: number;
|
||||
pollution_area_km2: number;
|
||||
beached_volume_m3: number;
|
||||
pollution_coast_length_m: number;
|
||||
@ -453,6 +455,8 @@ interface SingleModelTrajectoryResult {
|
||||
summary: {
|
||||
remainingVolume: number;
|
||||
weatheredVolume: number;
|
||||
evaporationVolume: number;
|
||||
dispersionVolume: number;
|
||||
pollutionArea: number;
|
||||
beachedVolume: number;
|
||||
pollutionCoastLength: number;
|
||||
@ -460,6 +464,8 @@ interface SingleModelTrajectoryResult {
|
||||
stepSummaries: Array<{
|
||||
remainingVolume: number;
|
||||
weatheredVolume: number;
|
||||
evaporationVolume: number;
|
||||
dispersionVolume: number;
|
||||
pollutionArea: number;
|
||||
beachedVolume: number;
|
||||
pollutionCoastLength: number;
|
||||
@ -474,6 +480,8 @@ interface TrajectoryResult {
|
||||
summary: {
|
||||
remainingVolume: number;
|
||||
weatheredVolume: number;
|
||||
evaporationVolume: number;
|
||||
dispersionVolume: number;
|
||||
pollutionArea: number;
|
||||
beachedVolume: number;
|
||||
pollutionCoastLength: number;
|
||||
@ -500,6 +508,8 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: strin
|
||||
const summary = {
|
||||
remainingVolume: lastStep.remaining_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,
|
||||
beachedVolume: lastStep.beached_volume_m3,
|
||||
pollutionCoastLength: lastStep.pollution_coast_length_m,
|
||||
@ -514,6 +524,8 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: strin
|
||||
const stepSummaries = rawResult.map((step) => ({
|
||||
remainingVolume: step.remaining_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,
|
||||
beachedVolume: step.beached_volume_m3,
|
||||
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[]> {
|
||||
const sql = `
|
||||
SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD,
|
||||
|
||||
@ -92,7 +92,7 @@ router.get('/:sn', requireAuth, requirePermission('reports', 'READ'), async (req
|
||||
// ============================================================
|
||||
router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => {
|
||||
try {
|
||||
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections, mapCaptureImg } = req.body;
|
||||
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections, step3MapImg, step6MapImg } = req.body;
|
||||
const result = await createReport({
|
||||
tmplSn,
|
||||
ctgrSn,
|
||||
@ -101,7 +101,8 @@ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req
|
||||
jrsdCd,
|
||||
sttsCd,
|
||||
authorId: req.user!.sub,
|
||||
mapCaptureImg,
|
||||
step3MapImg,
|
||||
step6MapImg,
|
||||
sections,
|
||||
});
|
||||
res.status(201).json(result);
|
||||
@ -125,8 +126,8 @@ router.post('/:sn/update', requireAuth, requirePermission('reports', 'UPDATE'),
|
||||
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
|
||||
return;
|
||||
}
|
||||
const { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg } = req.body;
|
||||
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg }, req.user!.sub);
|
||||
const { title, jrsdCd, sttsCd, acdntSn, sections, step3MapImg, step6MapImg } = req.body;
|
||||
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, step3MapImg, step6MapImg }, req.user!.sub);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) {
|
||||
|
||||
@ -60,6 +60,7 @@ interface ReportListItem {
|
||||
sttsCd: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
acdntSn: number | null;
|
||||
regDtm: string;
|
||||
mdfcnDtm: string | null;
|
||||
hasMapCapture: boolean;
|
||||
@ -75,7 +76,8 @@ interface SectionData {
|
||||
interface ReportDetail extends ReportListItem {
|
||||
acdntSn: number | null;
|
||||
sections: SectionData[];
|
||||
mapCaptureImg: string | null;
|
||||
step3MapImg: string | null;
|
||||
step6MapImg: string | null;
|
||||
}
|
||||
|
||||
interface ListReportsInput {
|
||||
@ -102,7 +104,8 @@ interface CreateReportInput {
|
||||
jrsdCd?: string;
|
||||
sttsCd?: string;
|
||||
authorId: string;
|
||||
mapCaptureImg?: string;
|
||||
step3MapImg?: string;
|
||||
step6MapImg?: string;
|
||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||
}
|
||||
|
||||
@ -111,7 +114,8 @@ interface UpdateReportInput {
|
||||
jrsdCd?: string;
|
||||
sttsCd?: string;
|
||||
acdntSn?: number | null;
|
||||
mapCaptureImg?: string | null;
|
||||
step3MapImg?: string | null;
|
||||
step6MapImg?: string | null;
|
||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||
}
|
||||
|
||||
@ -260,8 +264,10 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
|
||||
c.CTGR_CD, c.CTGR_NM,
|
||||
r.TITLE, r.JRSD_CD, r.STTS_CD,
|
||||
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
||||
r.REG_DTM, r.MDFCN_DTM,
|
||||
CASE WHEN r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '' THEN true ELSE false END AS HAS_MAP_CAPTURE
|
||||
r.ACDNT_SN, r.REG_DTM, r.MDFCN_DTM,
|
||||
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
|
||||
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
|
||||
@ -284,6 +290,7 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
|
||||
sttsCd: r.stts_cd,
|
||||
authorId: r.author_id,
|
||||
authorName: r.author_name || '',
|
||||
acdntSn: r.acdnt_sn,
|
||||
regDtm: r.reg_dtm,
|
||||
mdfcnDtm: r.mdfcn_dtm,
|
||||
hasMapCapture: r.has_map_capture,
|
||||
@ -300,8 +307,10 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
|
||||
c.CTGR_CD, c.CTGR_NM,
|
||||
r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN,
|
||||
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
||||
r.REG_DTM, r.MDFCN_DTM, r.MAP_CAPTURE_IMG,
|
||||
CASE WHEN r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '' THEN true ELSE false END AS HAS_MAP_CAPTURE
|
||||
r.REG_DTM, r.MDFCN_DTM, r.STEP3_MAP_IMG, r.STEP6_MAP_IMG,
|
||||
CASE WHEN (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '')
|
||||
OR (r.STEP6_MAP_IMG IS NOT NULL AND r.STEP6_MAP_IMG <> '')
|
||||
THEN true ELSE false END AS HAS_MAP_CAPTURE
|
||||
FROM REPORT r
|
||||
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
|
||||
@ -338,7 +347,8 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
|
||||
authorName: r.author_name || '',
|
||||
regDtm: r.reg_dtm,
|
||||
mdfcnDtm: r.mdfcn_dtm,
|
||||
mapCaptureImg: r.map_capture_img,
|
||||
step3MapImg: r.step3_map_img,
|
||||
step6MapImg: r.step6_map_img,
|
||||
hasMapCapture: r.has_map_capture,
|
||||
sections: sectRes.rows.map((s) => ({
|
||||
sectCd: s.sect_cd,
|
||||
@ -359,8 +369,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
|
||||
await client.query('BEGIN');
|
||||
|
||||
const res = await client.query(
|
||||
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, MAP_CAPTURE_IMG)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, STEP3_MAP_IMG, STEP6_MAP_IMG)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING REPORT_SN`,
|
||||
[
|
||||
input.tmplSn || null,
|
||||
@ -370,7 +380,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
|
||||
input.jrsdCd || null,
|
||||
input.sttsCd || 'DRAFT',
|
||||
input.authorId,
|
||||
input.mapCaptureImg || null,
|
||||
input.step3MapImg || null,
|
||||
input.step6MapImg || null,
|
||||
]
|
||||
);
|
||||
const reportSn = res.rows[0].report_sn;
|
||||
@ -442,9 +453,13 @@ export async function updateReport(
|
||||
sets.push(`ACDNT_SN = $${idx++}`);
|
||||
params.push(input.acdntSn);
|
||||
}
|
||||
if (input.mapCaptureImg !== undefined) {
|
||||
sets.push(`MAP_CAPTURE_IMG = $${idx++}`);
|
||||
params.push(input.mapCaptureImg);
|
||||
if (input.step3MapImg !== undefined) {
|
||||
sets.push(`STEP3_MAP_IMG = $${idx++}`);
|
||||
params.push(input.step3MapImg);
|
||||
}
|
||||
if (input.step6MapImg !== undefined) {
|
||||
sets.push(`STEP6_MAP_IMG = $${idx++}`);
|
||||
params.push(input.step6MapImg);
|
||||
}
|
||||
|
||||
params.push(reportSn);
|
||||
|
||||
@ -18,6 +18,7 @@ interface Layer {
|
||||
cmn_cd_nm: string
|
||||
cmn_cd_level: number
|
||||
clnm: string | null
|
||||
data_tbl_nm: string | null
|
||||
}
|
||||
|
||||
// DB 컬럼 → API 응답 컬럼 매핑 (프론트엔드 호환성 유지)
|
||||
@ -27,7 +28,21 @@ const LAYER_COLUMNS = `
|
||||
LAYER_FULL_NM AS cmn_cd_full_nm,
|
||||
LAYER_NM AS cmn_cd_nm,
|
||||
LAYER_LEVEL AS cmn_cd_level,
|
||||
WMS_LAYER_NM AS clnm
|
||||
WMS_LAYER_NM AS clnm,
|
||||
DATA_TBL_NM AS data_tbl_nm
|
||||
`.trim()
|
||||
|
||||
// 조상 중 하나라도 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()
|
||||
|
||||
// 모든 라우트에 파라미터 살균 적용
|
||||
@ -37,7 +52,10 @@ router.use(sanitizeParams)
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
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)
|
||||
res.json(enrichedLayers)
|
||||
@ -50,7 +68,10 @@ router.get('/', async (_req, res) => {
|
||||
router.get('/tree/all', async (_req, res) => {
|
||||
try {
|
||||
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)
|
||||
|
||||
@ -82,7 +103,10 @@ router.get('/tree/all', async (_req, res) => {
|
||||
router.get('/wms/all', async (_req, res) => {
|
||||
try {
|
||||
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)
|
||||
res.json(enrichedLayers)
|
||||
@ -104,7 +128,10 @@ router.get('/level/:level', async (req, res) => {
|
||||
}
|
||||
|
||||
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]
|
||||
)
|
||||
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}`)
|
||||
}
|
||||
|
||||
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 ')}` : ''
|
||||
|
||||
// 데이터 쿼리 파라미터: WHERE 조건 + LIMIT + OFFSET
|
||||
@ -201,19 +237,27 @@ router.get('/admin/list', requireAuth, requireRole('ADMIN'), async (req, res) =>
|
||||
const [dataResult, countResult] = await Promise.all([
|
||||
wingPool.query(
|
||||
`SELECT
|
||||
t.*,
|
||||
p.USE_YN AS "parentUseYn"
|
||||
FROM (
|
||||
SELECT
|
||||
LAYER_CD AS "layerCd",
|
||||
UP_LAYER_CD AS "upLayerCd",
|
||||
LAYER_FULL_NM AS "layerFullNm",
|
||||
LAYER_NM AS "layerNm",
|
||||
LAYER_LEVEL AS "layerLevel",
|
||||
WMS_LAYER_NM AS "wmsLayerNm",
|
||||
DATA_TBL_NM AS "dataTblNm",
|
||||
USE_YN AS "useYn",
|
||||
SORT_ORD AS "sortOrd",
|
||||
TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm"
|
||||
FROM LAYER
|
||||
${whereClause}
|
||||
ORDER BY LAYER_CD
|
||||
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
|
||||
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
|
||||
),
|
||||
wingPool.query(
|
||||
@ -288,11 +332,12 @@ router.post('/admin/create', requireAuth, requireRole('ADMIN'), async (req, res)
|
||||
layerNm?: string
|
||||
layerLevel?: number
|
||||
wmsLayerNm?: string
|
||||
dataTblNm?: string
|
||||
useYn?: string
|
||||
sortOrd?: number
|
||||
}
|
||||
|
||||
const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, useYn, sortOrd } = body
|
||||
const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, dataTblNm, useYn, sortOrd } = body
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
|
||||
@ -319,20 +364,26 @@ router.post('/admin/create', requireAuth, requireRole('ADMIN'), async (req, res)
|
||||
return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' })
|
||||
}
|
||||
}
|
||||
if (dataTblNm !== undefined && dataTblNm !== null && dataTblNm !== '') {
|
||||
if (!isValidStringLength(dataTblNm, 100) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(dataTblNm)) {
|
||||
return res.status(400).json({ error: '데이터 테이블명은 100자 이내의 유효한 PostgreSQL 테이블명이어야 합니다.' })
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedLayerCd = sanitizeString(layerCd)
|
||||
const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
|
||||
const sanitizedLayerFullNm = sanitizeString(layerFullNm)
|
||||
const sanitizedLayerNm = sanitizeString(layerNm)
|
||||
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
|
||||
const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null
|
||||
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
|
||||
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
|
||||
|
||||
const { rows } = await wingPool.query(
|
||||
`INSERT INTO LAYER (LAYER_CD, UP_LAYER_CD, LAYER_FULL_NM, LAYER_NM, LAYER_LEVEL, WMS_LAYER_NM, USE_YN, SORT_ORD, DEL_YN)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'N')
|
||||
`INSERT INTO LAYER (LAYER_CD, UP_LAYER_CD, LAYER_FULL_NM, LAYER_NM, LAYER_LEVEL, WMS_LAYER_NM, DATA_TBL_NM, USE_YN, SORT_ORD, DEL_YN)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'N')
|
||||
RETURNING LAYER_CD AS "layerCd"`,
|
||||
[sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedUseYn, sanitizedSortOrd]
|
||||
[sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedDataTblNm, sanitizedUseYn, sanitizedSortOrd]
|
||||
)
|
||||
|
||||
res.json(rows[0])
|
||||
@ -355,11 +406,12 @@ router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res)
|
||||
layerNm?: string
|
||||
layerLevel?: number
|
||||
wmsLayerNm?: string
|
||||
dataTblNm?: string
|
||||
useYn?: string
|
||||
sortOrd?: number
|
||||
}
|
||||
|
||||
const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, useYn, sortOrd } = body
|
||||
const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, dataTblNm, useYn, sortOrd } = body
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
|
||||
@ -386,22 +438,28 @@ router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res)
|
||||
return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' })
|
||||
}
|
||||
}
|
||||
if (dataTblNm !== undefined && dataTblNm !== null && dataTblNm !== '') {
|
||||
if (!isValidStringLength(dataTblNm, 100) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(dataTblNm)) {
|
||||
return res.status(400).json({ error: '데이터 테이블명은 100자 이내의 유효한 PostgreSQL 테이블명이어야 합니다.' })
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedLayerCd = sanitizeString(layerCd)
|
||||
const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
|
||||
const sanitizedLayerFullNm = sanitizeString(layerFullNm)
|
||||
const sanitizedLayerNm = sanitizeString(layerNm)
|
||||
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
|
||||
const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null
|
||||
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
|
||||
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
|
||||
|
||||
const { rows } = await wingPool.query(
|
||||
`UPDATE LAYER
|
||||
SET UP_LAYER_CD = $2, LAYER_FULL_NM = $3, LAYER_NM = $4, LAYER_LEVEL = $5,
|
||||
WMS_LAYER_NM = $6, USE_YN = $7, SORT_ORD = $8
|
||||
WMS_LAYER_NM = $6, DATA_TBL_NM = $7, USE_YN = $8, SORT_ORD = $9
|
||||
WHERE LAYER_CD = $1
|
||||
RETURNING LAYER_CD AS "layerCd"`,
|
||||
[sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedUseYn, sanitizedSortOrd]
|
||||
[sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedDataTblNm, sanitizedUseYn, sanitizedSortOrd]
|
||||
)
|
||||
|
||||
if (rows.length === 0) {
|
||||
@ -428,6 +486,18 @@ router.post('/admin/delete', requireAuth, requireRole('ADMIN'), async (req, res)
|
||||
|
||||
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(
|
||||
`UPDATE LAYER SET DEL_YN = 'Y' WHERE LAYER_CD = $1 AND DEL_YN = 'N'
|
||||
RETURNING LAYER_CD AS "layerCd"`,
|
||||
|
||||
@ -585,6 +585,7 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => {
|
||||
status: 'DONE' | 'ERROR'
|
||||
trajectory?: ReturnType<typeof transformResult>['trajectory']
|
||||
summary?: ReturnType<typeof transformResult>['summary']
|
||||
stepSummaries?: ReturnType<typeof transformResult>['stepSummaries']
|
||||
centerPoints?: ReturnType<typeof transformResult>['centerPoints']
|
||||
windData?: ReturnType<typeof transformResult>['windData']
|
||||
hydrData?: ReturnType<typeof transformResult>['hydrData']
|
||||
@ -656,9 +657,9 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => {
|
||||
WHERE PRED_EXEC_SN=$2`,
|
||||
[JSON.stringify(pythonData.result), predExecSn]
|
||||
)
|
||||
const { trajectory, summary, centerPoints, windData, hydrData } =
|
||||
const { trajectory, summary, stepSummaries, centerPoints, windData, hydrData } =
|
||||
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 {
|
||||
const rawResult = await runModelSync(jobId!, predExecSn, apiUrl)
|
||||
const { trajectory, summary, centerPoints, windData, hydrData } = transformResult(rawResult, model)
|
||||
return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, centerPoints, windData, hydrData }
|
||||
const { trajectory, summary, stepSummaries, centerPoints, windData, hydrData } = transformResult(rawResult, model)
|
||||
return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, stepSummaries, centerPoints, windData, hydrData }
|
||||
} catch (syncErr) {
|
||||
return { model, execSn: predExecSn, status: 'ERROR', error: (syncErr as Error).message }
|
||||
}
|
||||
@ -848,6 +849,8 @@ interface PythonTimeStep {
|
||||
particles: PythonParticle[]
|
||||
remaining_volume_m3: number
|
||||
weathered_volume_m3: number
|
||||
evaporation_m3?: number
|
||||
dispersion_m3?: number
|
||||
pollution_area_km2: number
|
||||
beached_volume_m3: number
|
||||
pollution_coast_length_m: number
|
||||
@ -884,6 +887,8 @@ function transformResult(rawResult: PythonTimeStep[], model: string) {
|
||||
const summary = {
|
||||
remainingVolume: lastStep.remaining_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,
|
||||
beachedVolume: lastStep.beached_volume_m3,
|
||||
pollutionCoastLength: lastStep.pollution_coast_length_m,
|
||||
@ -901,7 +906,16 @@ function transformResult(rawResult: PythonTimeStep[], model: string) {
|
||||
? { value: step.hydr_data, grid: step.hydr_grid }
|
||||
: 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
|
||||
|
||||
@ -77,7 +77,8 @@ CREATE TABLE IF NOT EXISTS REPORT (
|
||||
USE_YN CHAR(1) DEFAULT 'Y',
|
||||
REG_DTM TIMESTAMPTZ DEFAULT NOW(),
|
||||
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'))
|
||||
);
|
||||
|
||||
|
||||
57
database/migration/024_admin_perm_tree.sql
Normal file
57
database/migration/024_admin_perm_tree.sql
Normal file
@ -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;
|
||||
44
database/migration/025_weather_columns.sql
Normal file
44
database/migration/025_weather_columns.sql
Normal file
@ -0,0 +1,44 @@
|
||||
-- 027: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 추가
|
||||
-- 확산예측 실행 시 WeatherRightPanel에 표시되는 모든 기상정보 저장을 위해
|
||||
-- 기존 VARCHAR 컬럼(WIND, WAVE, TEMP, SST)은 하위 호환성 유지를 위해 보존
|
||||
|
||||
ALTER TABLE wing.ACDNT_WEATHER
|
||||
ADD COLUMN IF NOT EXISTS WIND_SPEED NUMERIC(5,1), -- 풍속 (m/s)
|
||||
ADD COLUMN IF NOT EXISTS WIND_DIR INTEGER, -- 풍향 (도)
|
||||
ADD COLUMN IF NOT EXISTS WIND_DIR_LBL VARCHAR(10), -- 풍향 텍스트 (N, NW, ...)
|
||||
ADD COLUMN IF NOT EXISTS WIND_SPEED_1K NUMERIC(5,1), -- 1k 최고 풍속 (m/s)
|
||||
ADD COLUMN IF NOT EXISTS WIND_SPEED_3K NUMERIC(5,1), -- 3k 평균 풍속 (m/s)
|
||||
ADD COLUMN IF NOT EXISTS PRESSURE NUMERIC(6,1), -- 기압 (hPa)
|
||||
ADD COLUMN IF NOT EXISTS WAVE_HEIGHT NUMERIC(4,1), -- 유의파고 (m)
|
||||
ADD COLUMN IF NOT EXISTS WAVE_MAX_HT NUMERIC(4,1), -- 최고파고 (m)
|
||||
ADD COLUMN IF NOT EXISTS WAVE_PERIOD NUMERIC(4,1), -- 파도 주기 (s)
|
||||
ADD COLUMN IF NOT EXISTS WAVE_DIR VARCHAR(10), -- 파향 (N, NE, ...)
|
||||
ADD COLUMN IF NOT EXISTS AIR_TEMP NUMERIC(5,1), -- 기온 (°C)
|
||||
ADD COLUMN IF NOT EXISTS SALINITY NUMERIC(5,1), -- 염분 (PSU)
|
||||
ADD COLUMN IF NOT EXISTS SUNRISE VARCHAR(10), -- 일출 시각 (HH:MM)
|
||||
ADD COLUMN IF NOT EXISTS SUNSET VARCHAR(10), -- 일몰 시각 (HH:MM)
|
||||
ADD COLUMN IF NOT EXISTS MOONRISE VARCHAR(10), -- 월출 시각 (HH:MM)
|
||||
ADD COLUMN IF NOT EXISTS MOONSET VARCHAR(10), -- 월몰 시각 (HH:MM)
|
||||
ADD COLUMN IF NOT EXISTS MOON_PHASE VARCHAR(30), -- 월상 (예: 상현달 14일)
|
||||
ADD COLUMN IF NOT EXISTS TIDAL_RANGE NUMERIC(4,1), -- 조차 (m)
|
||||
ADD COLUMN IF NOT EXISTS WEATHER_ALERT TEXT; -- 날씨 특보
|
||||
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED IS '풍속 (m/s)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_DIR IS '풍향 (도, 0-360)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_DIR_LBL IS '풍향 텍스트 (N/NE/E/...)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED_1K IS '1km 최고 풍속 (m/s)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED_3K IS '3km 평균 풍속 (m/s)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.PRESSURE IS '기압 (hPa)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_HEIGHT IS '유의파고 (m)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_MAX_HT IS '최고파고 (m)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_PERIOD IS '파도 주기 (s)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_DIR IS '파향 (N/NE/E/...)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.AIR_TEMP IS '기온 (°C)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.SALINITY IS '염분 (PSU)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.SUNRISE IS '일출 시각 (HH:MM)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.SUNSET IS '일몰 시각 (HH:MM)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.MOONRISE IS '월출 시각 (HH:MM)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.MOONSET IS '월몰 시각 (HH:MM)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.MOON_PHASE IS '월상 (예: 상현달 14일)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.TIDAL_RANGE IS '조차 (m)';
|
||||
COMMENT ON COLUMN wing.ACDNT_WEATHER.WEATHER_ALERT IS '날씨 특보 문자열';
|
||||
41
database/migration/026_sensitive_resources.sql
Normal file
41
database/migration/026_sensitive_resources.sql
Normal file
@ -0,0 +1,41 @@
|
||||
-- ============================================================
|
||||
-- 027: 민감자원 테이블 생성
|
||||
-- 모든 민감자원(양식장, 해수욕장, 무역항 등)을 단일 테이블로 관리
|
||||
-- properties는 JSONB로 유연하게 저장
|
||||
-- ============================================================
|
||||
|
||||
SET search_path TO wing, public;
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
|
||||
-- ============================================================
|
||||
-- 민감자원 테이블
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS SENSITIVE_RESOURCE (
|
||||
SR_ID BIGSERIAL PRIMARY KEY,
|
||||
CATEGORY VARCHAR(50) NOT NULL, -- 민감자원 유형 (양식장, 해수욕장, 무역항 등)
|
||||
GEOM public.geometry(Geometry, 4326) NOT NULL, -- 공간 데이터 (Point, LineString, Polygon 모두 수용)
|
||||
PROPERTIES JSONB NOT NULL DEFAULT '{}', -- 원본 GeoJSON properties
|
||||
REG_DT TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
MOD_DT TIMESTAMP
|
||||
);
|
||||
|
||||
-- 공간 인덱스
|
||||
CREATE INDEX IF NOT EXISTS IDX_SR_GEOM ON SENSITIVE_RESOURCE USING GIST(GEOM);
|
||||
|
||||
-- 카테고리 인덱스 (유형별 필터링)
|
||||
CREATE INDEX IF NOT EXISTS IDX_SR_CATEGORY ON SENSITIVE_RESOURCE (CATEGORY);
|
||||
|
||||
-- JSONB 인덱스 (properties 내부 검색용)
|
||||
CREATE INDEX IF NOT EXISTS IDX_SR_PROPERTIES ON SENSITIVE_RESOURCE USING GIN(PROPERTIES);
|
||||
|
||||
-- 카테고리 + 공간 복합 조회 최적화
|
||||
CREATE INDEX IF NOT EXISTS IDX_SR_CATEGORY_GEOM ON SENSITIVE_RESOURCE USING GIST(GEOM) WHERE CATEGORY IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE SENSITIVE_RESOURCE IS '민감자원 통합 테이블';
|
||||
COMMENT ON COLUMN SENSITIVE_RESOURCE.SR_ID IS '민감자원 ID';
|
||||
COMMENT ON COLUMN SENSITIVE_RESOURCE.CATEGORY IS '민감자원 유형 (양식장, 해수욕장, 무역항, 어항, 해안선_ESI 등)';
|
||||
COMMENT ON COLUMN SENSITIVE_RESOURCE.GEOM IS '공간 데이터 (EPSG:4326)';
|
||||
COMMENT ON COLUMN SENSITIVE_RESOURCE.PROPERTIES IS '원본 GeoJSON properties (JSONB)';
|
||||
COMMENT ON COLUMN SENSITIVE_RESOURCE.REG_DT IS '등록일시';
|
||||
COMMENT ON COLUMN SENSITIVE_RESOURCE.MOD_DT IS '수정일시';
|
||||
27
database/migration/027_sensitivity_evaluation.sql
Normal file
27
database/migration/027_sensitivity_evaluation.sql
Normal file
@ -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 래핑
|
||||
- SVG 아이콘 에셋 19종 추가
|
||||
- @/ path alias 추가
|
||||
- 레이어: 레이어 데이터 테이블 매핑 구현 + 어장 팝업 수정
|
||||
- 확산예측: 예측 실행 시 기상정보(풍속·풍향·기압·파고·수온·기온·염분 등) ACDNT_WEATHER 테이블에 자동 저장
|
||||
- DB: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 19개 추가 (025 마이그레이션)
|
||||
- DB: 민감자원 데이터 마이그레이션 (026_sensitive_resources)
|
||||
- 보고서: 유류유출 보고서 템플릿 전면 개선 (OilSpillReportTemplate)
|
||||
- 관리자: 실시간 기상·해상 모니터링 패널 추가 (MonitorRealtimePanel)
|
||||
- DB: 민감자원 평가 마이그레이션 추가 (027_sensitivity_evaluation)
|
||||
|
||||
### 변경
|
||||
- SCAT 지도 하드코딩 제주 해안선 제거, 인접 구간 기반 동적 방향 계산으로 전환
|
||||
- 예측: 분석 API를 예측 서비스로 통합 (analysisRouter 제거)
|
||||
- 예측: 예측 API 확장 (predictionRouter/Service, LeftPanel/RightPanel 연동)
|
||||
|
||||
### 문서
|
||||
- Foundation 탭 디자인 토큰 상세 문서화 (DESIGN-SYSTEM.md)
|
||||
|
||||
## [2026-03-20.3]
|
||||
|
||||
### 추가
|
||||
- 보고서: 기능 강화 (HWPX 내보내기, 확산 지도 패널, 보고서 생성기 개선)
|
||||
- 관리자: 권한 트리 확장 (게시판관리·기준정보·연계관리 섹션 추가)
|
||||
- 관리자: 유처리제 제한구역 패널, 민감자원 레이어 패널 추가
|
||||
- 기상: 날씨 스냅샷 스토어, 유틸리티 모듈 추가
|
||||
|
||||
### 문서
|
||||
- PREDICTION-GUIDE.md 삭제
|
||||
|
||||
## [2026-03-20.2]
|
||||
|
||||
### 변경
|
||||
|
||||
199501
frontend/public/dispersant-consider.geojson
Normal file
199501
frontend/public/dispersant-consider.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
665667
frontend/public/dispersant-restrict.geojson
Normal file
665667
frontend/public/dispersant-restrict.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1,14 +1,14 @@
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
|
||||
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer } from '@deck.gl/layers'
|
||||
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer, GeoJsonLayer } from '@deck.gl/layers'
|
||||
import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'
|
||||
import type { StyleSpecification } from 'maplibre-gl'
|
||||
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { layerDatabase } from '@common/services/layerService'
|
||||
import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView'
|
||||
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi'
|
||||
import type { HydrDataStep, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi'
|
||||
import HydrParticleOverlay from './HydrParticleOverlay'
|
||||
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
|
||||
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||
@ -289,6 +289,24 @@ const PRIORITY_LABELS: Record<string, string> = {
|
||||
'MEDIUM': '보통',
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
const f = (n: number) => {
|
||||
const k = (n + h * 12) % 12;
|
||||
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
|
||||
};
|
||||
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
|
||||
}
|
||||
|
||||
function categoryToRgb(category: string): [number, number, number] {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < category.length; i++) {
|
||||
hash = (hash * 31 + category.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
const hue = (hash * 137) % 360;
|
||||
return hslToRgb(hue / 360, 0.65, 0.55);
|
||||
}
|
||||
|
||||
const SENSITIVE_COLORS: Record<string, string> = {
|
||||
'aquaculture': '#22c55e',
|
||||
'beach': '#0ea5e9',
|
||||
@ -342,6 +360,7 @@ interface MapViewProps {
|
||||
incidentCoord: { lat: number; lon: number }
|
||||
}
|
||||
sensitiveResources?: SensitiveResource[]
|
||||
sensitiveResourceGeojson?: SensitiveResourceFeatureCollection | null
|
||||
flyToTarget?: { lng: number; lat: number; zoom?: number } | null
|
||||
fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null
|
||||
centerPoints?: Array<{ lat: number; lon: number; time: number; model?: string }>
|
||||
@ -528,6 +547,7 @@ export function MapView({
|
||||
layerBrightness = 50,
|
||||
backtrackReplay,
|
||||
sensitiveResources = [],
|
||||
sensitiveResourceGeojson,
|
||||
flyToTarget,
|
||||
fitBoundsTarget,
|
||||
centerPoints = [],
|
||||
@ -559,6 +579,12 @@ export function MapView({
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(1)
|
||||
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null)
|
||||
// deck.gl 레이어 클릭 시 MapLibre 맵 클릭 핸들러 차단용 플래그 (민감자원 등)
|
||||
const deckClickHandledRef = useRef(false)
|
||||
// 클릭으로 열린 팝업(닫기 전까지 유지) 추적 — 호버 핸들러가 닫지 않도록 방지
|
||||
const persistentPopupRef = useRef(false)
|
||||
// 현재 호버 중인 민감자원 feature properties (handleMapClick에서 팝업 생성에 사용)
|
||||
const hoveredSensitiveRef = useRef<Record<string, unknown> | null>(null)
|
||||
const currentTime = isControlled ? externalCurrentTime : internalCurrentTime
|
||||
|
||||
const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => {
|
||||
@ -569,6 +595,44 @@ export function MapView({
|
||||
const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
|
||||
const { lng, lat } = e.lngLat
|
||||
setCurrentPosition([lat, lng])
|
||||
// deck.gl 다른 레이어 onClick이 처리한 클릭 — 팝업 유지
|
||||
if (deckClickHandledRef.current) {
|
||||
deckClickHandledRef.current = false
|
||||
return
|
||||
}
|
||||
// 민감자원 hover 중이면 팝업 표시
|
||||
if (hoveredSensitiveRef.current) {
|
||||
const props = hoveredSensitiveRef.current
|
||||
const { category, ...rest } = props
|
||||
const entries = Object.entries(rest).filter(([k, v]) => k !== 'srId' && v !== null && v !== undefined && v !== '')
|
||||
persistentPopupRef.current = true
|
||||
setPopupInfo({
|
||||
longitude: lng,
|
||||
latitude: lat,
|
||||
content: (
|
||||
<div className="text-xs font-korean" style={{ minWidth: '180px', maxWidth: '260px' }}>
|
||||
<div className="font-semibold mb-1.5 pb-1 border-b border-[rgba(0,0,0,0.12)]">
|
||||
{String(category ?? '민감자원')}
|
||||
</div>
|
||||
{entries.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{entries.map(([key, val]) => (
|
||||
<div key={key} className="flex gap-2 justify-between">
|
||||
<span className="text-[10px] text-[#888] shrink-0">{key}</span>
|
||||
<span className="text-[10px] text-[#333] font-medium text-right break-all">
|
||||
{typeof val === 'object' ? JSON.stringify(val) : String(val)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-[#999]">상세 정보 없음</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (measureMode !== null) {
|
||||
handleMeasureClick(lng, lat)
|
||||
return
|
||||
@ -716,7 +780,7 @@ export function MapView({
|
||||
getPath: (d: BoomLine) => d.coords.map(c => [c.lon, c.lat] as [number, number]),
|
||||
getColor: (d: BoomLine) => hexToRgba(PRIORITY_COLORS[d.priority] || '#f59e0b', 230),
|
||||
getWidth: (d: BoomLine) => PRIORITY_WEIGHTS[d.priority] || 2,
|
||||
getDashArray: (d: BoomLine) => d.status === 'PLANNED' ? [10, 5] : null,
|
||||
getDashArray: (d: BoomLine) => d.status === 'PLANNED' ? [10, 5] : [0, 0],
|
||||
dashJustified: true,
|
||||
widthMinPixels: 2,
|
||||
widthMaxPixels: 6,
|
||||
@ -1018,8 +1082,11 @@ export function MapView({
|
||||
),
|
||||
});
|
||||
} else if (!info.object) {
|
||||
// 클릭으로 열린 팝업(어장 등)이 있으면 호버로 닫지 않음
|
||||
if (!persistentPopupRef.current) {
|
||||
setPopupInfo(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
@ -1111,6 +1178,41 @@ export function MapView({
|
||||
)
|
||||
}
|
||||
|
||||
// --- 민감자원 GeoJSON 레이어 ---
|
||||
if (sensitiveResourceGeojson && sensitiveResourceGeojson.features.length > 0) {
|
||||
result.push(
|
||||
new GeoJsonLayer({
|
||||
id: 'sensitive-resource-geojson',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: sensitiveResourceGeojson as any,
|
||||
pickable: true,
|
||||
stroked: true,
|
||||
filled: true,
|
||||
pointRadiusMinPixels: 10,
|
||||
pointRadiusMaxPixels: 20,
|
||||
lineWidthMinPixels: 1,
|
||||
getLineWidth: 1.5,
|
||||
getFillColor: (f: { properties: { category?: string } | null }) => {
|
||||
const cat = f.properties?.category ?? '';
|
||||
const [r, g, b] = categoryToRgb(cat);
|
||||
return [r, g, b, 80] as [number, number, number, number];
|
||||
},
|
||||
getLineColor: (f: { properties: { category?: string } | null }) => {
|
||||
const cat = f.properties?.category ?? '';
|
||||
const [r, g, b] = categoryToRgb(cat);
|
||||
return [r, g, b, 210] as [number, number, number, number];
|
||||
},
|
||||
onHover: (info: PickingInfo) => {
|
||||
if (info.object) {
|
||||
hoveredSensitiveRef.current = (info.object as { properties: Record<string, unknown> | null }).properties ?? {}
|
||||
} else {
|
||||
hoveredSensitiveRef.current = null
|
||||
}
|
||||
},
|
||||
}) as unknown as DeckLayer
|
||||
);
|
||||
}
|
||||
|
||||
// --- 입자 중심점 이동 경로 (모델별 PathLayer + ScatterplotLayer) ---
|
||||
const visibleCenters = centerPoints.filter(p => p.time <= currentTime)
|
||||
if (visibleCenters.length > 0) {
|
||||
@ -1225,12 +1327,12 @@ export function MapView({
|
||||
// 거리/면적 측정 레이어
|
||||
result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements))
|
||||
|
||||
return result
|
||||
return result.filter(Boolean)
|
||||
}, [
|
||||
oilTrajectory, currentTime, selectedModels,
|
||||
boomLines, isDrawingBoom, drawingPoints,
|
||||
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
|
||||
sensitiveResources, centerPoints, windData,
|
||||
sensitiveResources, sensitiveResourceGeojson, centerPoints, windData,
|
||||
showWind, showBeached, showTimeLabel, simulationStartTime,
|
||||
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode,
|
||||
])
|
||||
@ -1318,7 +1420,10 @@ export function MapView({
|
||||
longitude={popupInfo.longitude}
|
||||
latitude={popupInfo.latitude}
|
||||
anchor="bottom"
|
||||
onClose={() => setPopupInfo(null)}
|
||||
onClose={() => {
|
||||
persistentPopupRef.current = false
|
||||
setPopupInfo(null)
|
||||
}}
|
||||
>
|
||||
<div className="text-[#333]">{popupInfo.content}</div>
|
||||
</Popup>
|
||||
|
||||
@ -7,6 +7,7 @@ export interface LayerNode {
|
||||
name: string
|
||||
level: number
|
||||
layerName: string | null
|
||||
dataTblNm?: string | null
|
||||
icon?: string
|
||||
count?: number
|
||||
defaultOn?: boolean
|
||||
|
||||
@ -13,11 +13,11 @@ export function useLayers() {
|
||||
}
|
||||
|
||||
// 계층 구조 레이어 트리 조회 훅
|
||||
// staleTime 없음 → 마운트 시 항상 최신 데이터 요청 (관리자 설정 즉시 반영)
|
||||
export function useLayerTree() {
|
||||
return useQuery<Layer[], Error>({
|
||||
queryKey: ['layers', 'tree'],
|
||||
queryFn: fetchLayerTree,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
retry: 3,
|
||||
})
|
||||
}
|
||||
|
||||
@ -203,6 +203,13 @@ export interface OilReportPayload {
|
||||
windSpeed: string;
|
||||
waveHeight: string;
|
||||
temp: string;
|
||||
pressure?: string;
|
||||
visibility?: string;
|
||||
salinity?: string;
|
||||
waveMaxHeight?: string;
|
||||
wavePeriod?: string;
|
||||
currentDir?: string;
|
||||
currentSpeed?: string;
|
||||
} | null;
|
||||
spread: {
|
||||
kosps: string;
|
||||
@ -228,6 +235,12 @@ export interface OilReportPayload {
|
||||
centerPoints: { lat: number; lon: number; time: number }[];
|
||||
simulationStartTime: string;
|
||||
} | null;
|
||||
sensitiveResources?: Array<{
|
||||
category: string;
|
||||
count: number;
|
||||
totalArea: number | null;
|
||||
}>;
|
||||
acdntSn?: number;
|
||||
}
|
||||
|
||||
let _oilReportPayload: OilReportPayload | null = null;
|
||||
|
||||
@ -46,6 +46,7 @@ export interface LayerDTO {
|
||||
cmn_cd_nm: string
|
||||
cmn_cd_level: number
|
||||
clnm: string | null
|
||||
data_tbl_nm?: string | null
|
||||
icon?: string
|
||||
count?: number
|
||||
children?: LayerDTO[]
|
||||
@ -58,6 +59,7 @@ export interface Layer {
|
||||
fullName: string
|
||||
level: number
|
||||
wmsLayer: string | null
|
||||
dataTblNm?: string | null
|
||||
icon?: string
|
||||
count?: number
|
||||
children?: Layer[]
|
||||
@ -72,6 +74,7 @@ function convertToLayer(dto: LayerDTO): Layer {
|
||||
fullName: dto.cmn_cd_full_nm,
|
||||
level: dto.cmn_cd_level,
|
||||
wmsLayer: dto.clnm,
|
||||
dataTblNm: dto.data_tbl_nm,
|
||||
icon: dto.icon,
|
||||
count: dto.count,
|
||||
children: dto.children ? dto.children.map(convertToLayer) : undefined,
|
||||
|
||||
@ -8,6 +8,7 @@ export interface Layer {
|
||||
fullName: string
|
||||
level: number
|
||||
wmsLayer: string | null
|
||||
dataTblNm?: string | null
|
||||
icon?: string
|
||||
count?: number
|
||||
children?: Layer[]
|
||||
|
||||
53
frontend/src/common/store/weatherSnapshotStore.ts
Normal file
53
frontend/src/common/store/weatherSnapshotStore.ts
Normal file
@ -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 {
|
||||
/* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */
|
||||
.cctv-dark-popup .maplibregl-popup-content {
|
||||
|
||||
@ -12,6 +12,9 @@ import CleanupEquipPanel from './CleanupEquipPanel';
|
||||
import AssetUploadPanel from './AssetUploadPanel';
|
||||
import MapBasePanel from './MapBasePanel';
|
||||
import LayerPanel from './LayerPanel';
|
||||
import SensitiveLayerPanel from './SensitiveLayerPanel';
|
||||
import DispersingZonePanel from './DispersingZonePanel';
|
||||
import MonitorRealtimePanel from './MonitorRealtimePanel';
|
||||
|
||||
/** 기존 패널이 있는 메뉴 ID 매핑 */
|
||||
const PANEL_MAP: Record<string, () => JSX.Element> = {
|
||||
@ -27,6 +30,10 @@ const PANEL_MAP: Record<string, () => JSX.Element> = {
|
||||
'asset-upload': () => <AssetUploadPanel />,
|
||||
'map-base': () => <MapBasePanel />,
|
||||
'map-layer': () => <LayerPanel />,
|
||||
'env-ecology': () => <SensitiveLayerPanel categoryCode="LYR001002001" title="환경/생태" />,
|
||||
'social-economy': () => <SensitiveLayerPanel categoryCode="LYR001002002" title="사회/경제" />,
|
||||
'dispersant-zone': () => <DispersingZonePanel />,
|
||||
'monitor-realtime': () => <MonitorRealtimePanel />,
|
||||
};
|
||||
|
||||
export function AdminView() {
|
||||
|
||||
247
frontend/src/tabs/admin/components/DispersingZonePanel.tsx
Normal file
247
frontend/src/tabs/admin/components/DispersingZonePanel.tsx
Normal file
@ -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:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <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 { useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@common/services/api';
|
||||
|
||||
interface LayerAdminItem {
|
||||
@ -11,6 +12,7 @@ interface LayerAdminItem {
|
||||
useYn: string;
|
||||
sortOrd: number;
|
||||
regDtm: string | null;
|
||||
parentUseYn: string | null;
|
||||
}
|
||||
|
||||
interface LayerListResponse {
|
||||
@ -313,6 +315,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
||||
// ---------- LayerPanel ----------
|
||||
|
||||
const LayerPanel = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [items, setItems] = useState<LayerAdminItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
@ -359,10 +362,15 @@ const LayerPanel = () => {
|
||||
try {
|
||||
const result = await toggleLayerUse(layerCd);
|
||||
setItems(prev =>
|
||||
prev.map(item =>
|
||||
item.layerCd === result.layerCd ? { ...item, useYn: result.useYn } : item
|
||||
)
|
||||
prev.map(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 {
|
||||
setError('사용여부 변경에 실패했습니다.');
|
||||
} finally {
|
||||
@ -522,11 +530,19 @@ const LayerPanel = () => {
|
||||
<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'
|
||||
disabled={toggling === item.layerCd || item.parentUseYn === 'N'}
|
||||
title={
|
||||
item.parentUseYn === 'N'
|
||||
? '상위 레이어가 비활성화되어 있어 적용되지 않습니다'
|
||||
: 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'
|
||||
: item.useYn === 'Y' && item.parentUseYn === 'N'
|
||||
? 'bg-primary-cyan/40'
|
||||
: 'bg-[rgba(255,255,255,0.08)] border border-border'
|
||||
}`}
|
||||
>
|
||||
|
||||
436
frontend/src/tabs/admin/components/MonitorRealtimePanel.tsx
Normal file
436
frontend/src/tabs/admin/components/MonitorRealtimePanel.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
304
frontend/src/tabs/admin/components/SensitiveLayerPanel.tsx
Normal file
304
frontend/src/tabs/admin/components/SensitiveLayerPanel.tsx
Normal file
@ -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: 'dispersant-zone', label: '유처리제 제한구역' },
|
||||
{ id: 'vessel-materials', label: '방제선 보유자재' },
|
||||
{ id: 'cleanup-resource', label: '방제자원' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -70,7 +70,8 @@ export function IncidentsLeftPanel({
|
||||
// Weather popup
|
||||
const [weatherPopupId, setWeatherPopupId] = useState<string | null>(null)
|
||||
const [weatherPos, setWeatherPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 })
|
||||
const [weatherInfo, setWeatherInfo] = useState<WeatherInfo | null>(null)
|
||||
// undefined = 로딩 중, null = 데이터 없음, WeatherInfo = 데이터 있음
|
||||
const [weatherInfo, setWeatherInfo] = useState<WeatherInfo | null | undefined>(undefined)
|
||||
const weatherRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@ -79,7 +80,7 @@ export function IncidentsLeftPanel({
|
||||
fetchIncidentWeather(parseInt(weatherPopupId)).then((data) => {
|
||||
if (!cancelled) setWeatherInfo(data)
|
||||
})
|
||||
return () => { cancelled = true; setWeatherInfo(null) }
|
||||
return () => { cancelled = true; setWeatherInfo(undefined) }
|
||||
}, [weatherPopupId]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -361,7 +362,7 @@ export function IncidentsLeftPanel({
|
||||
)}
|
||||
|
||||
{/* Weather Popup (fixed position) */}
|
||||
{weatherPopupId && weatherInfo && (
|
||||
{weatherPopupId && weatherInfo !== undefined && (
|
||||
<WeatherPopup
|
||||
ref={weatherRef}
|
||||
data={weatherInfo}
|
||||
@ -412,10 +413,11 @@ function PgBtn({ label, active, disabled, onClick }: { label: string; active?: b
|
||||
WeatherPopup – 사고 위치 기상정보 팝업
|
||||
════════════════════════════════════════════════════ */
|
||||
const WeatherPopup = forwardRef<HTMLDivElement, {
|
||||
data: WeatherInfo
|
||||
data: WeatherInfo | null
|
||||
position: { top: number; left: number }
|
||||
onClose: () => void
|
||||
}>(({ data, position, onClose }, ref) => {
|
||||
const forecast = data?.forecast ?? []
|
||||
return (
|
||||
<div ref={ref} className="fixed overflow-hidden rounded-xl border border-border bg-bg-1" style={{
|
||||
zIndex: 9990, width: 280,
|
||||
@ -429,8 +431,8 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">🌤</span>
|
||||
<div>
|
||||
<div className="text-[11px] font-bold">{data.locNm}</div>
|
||||
<div className="text-text-3 font-mono text-[8px]">{data.obsDtm}</div>
|
||||
<div className="text-[11px] font-bold">{data?.locNm || '기상정보 없음'}</div>
|
||||
<div className="text-text-3 font-mono text-[8px]">{data?.obsDtm || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span onClick={onClose} className="cursor-pointer text-text-3 text-sm p-0.5">✕</span>
|
||||
@ -440,21 +442,21 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
||||
<div className="px-3.5 py-3">
|
||||
{/* Main weather */}
|
||||
<div className="flex items-center gap-3 mb-2.5">
|
||||
<div className="text-[28px]">{data.icon}</div>
|
||||
<div className="text-[28px]">{data?.icon || '❓'}</div>
|
||||
<div>
|
||||
<div className="font-bold font-mono text-[20px]">{data.temp}</div>
|
||||
<div className="text-text-3 text-[9px]">{data.weatherDc}</div>
|
||||
<div className="font-bold font-mono text-[20px]">{data?.temp || '-'}</div>
|
||||
<div className="text-text-3 text-[9px]">{data?.weatherDc || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail grid */}
|
||||
<div className="grid grid-cols-2 gap-1.5 text-[9px]">
|
||||
<WxCell icon="💨" label="풍향/풍속" value={data.wind} />
|
||||
<WxCell icon="🌊" label="파고" value={data.wave} />
|
||||
<WxCell icon="💧" label="습도" value={data.humid} />
|
||||
<WxCell icon="👁" label="시정" value={data.vis} />
|
||||
<WxCell icon="🌡" label="수온" value={data.sst} />
|
||||
<WxCell icon="🔄" label="조류" value={data.tide} />
|
||||
<WxCell icon="💨" label="풍향/풍속" value={data?.wind} />
|
||||
<WxCell icon="🌊" label="파고" value={data?.wave} />
|
||||
<WxCell icon="💧" label="습도" value={data?.humid} />
|
||||
<WxCell icon="👁" label="시정" value={data?.vis} />
|
||||
<WxCell icon="🌡" label="수온" value={data?.sst} />
|
||||
<WxCell icon="🔄" label="조류" value={data?.tide} />
|
||||
</div>
|
||||
|
||||
{/* Tide info */}
|
||||
@ -464,7 +466,7 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
||||
<span className="text-xs">⬆</span>
|
||||
<div>
|
||||
<div className="text-text-3 text-[7px]">고조 (만조)</div>
|
||||
<div className="font-bold font-mono text-[10px] text-[#60a5fa]">{data.highTide}</div>
|
||||
<div className="font-bold font-mono text-[10px] text-[#60a5fa]">{data?.highTide || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-1.5 px-2 py-1.5 rounded-md"
|
||||
@ -472,7 +474,7 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
||||
<span className="text-xs">⬇</span>
|
||||
<div>
|
||||
<div className="text-text-3 text-[7px]">저조 (간조)</div>
|
||||
<div className="text-primary-cyan font-bold font-mono text-[10px]">{data.lowTide}</div>
|
||||
<div className="text-primary-cyan font-bold font-mono text-[10px]">{data?.lowTide || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -480,8 +482,9 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
||||
{/* 24h Forecast */}
|
||||
<div className="bg-bg-0 mt-2.5 px-2.5 py-2 rounded-md">
|
||||
<div className="font-bold text-text-3 text-[8px] mb-1.5">24h 예보</div>
|
||||
{forecast.length > 0 ? (
|
||||
<div className="flex justify-between font-mono text-text-2 text-[8px]">
|
||||
{data.forecast.map((f, i) => (
|
||||
{forecast.map((f, i) => (
|
||||
<div key={i} className="text-center">
|
||||
<div>{f.hour}</div>
|
||||
<div className="text-xs my-0.5">{f.icon}</div>
|
||||
@ -489,6 +492,9 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-text-3 text-center text-[8px] py-1">예보 데이터 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Impact */}
|
||||
@ -497,7 +503,7 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
||||
background: 'rgba(249,115,22,0.05)', border: '1px solid rgba(249,115,22,0.12)',
|
||||
}}>
|
||||
<div className="font-bold text-status-orange text-[8px] mb-[3px]">⚠ 방제 작업 영향</div>
|
||||
<div className="text-text-2 text-[8px] leading-[1.5]">{data.impactDc}</div>
|
||||
<div className="text-text-2 text-[8px] leading-[1.5]">{data?.impactDc || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -505,13 +511,13 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
||||
})
|
||||
WeatherPopup.displayName = 'WeatherPopup'
|
||||
|
||||
function WxCell({ icon, label, value }: { icon: string; label: string; value: string }) {
|
||||
function WxCell({ icon, label, value }: { icon: string; label: string; value?: string | null }) {
|
||||
return (
|
||||
<div className="flex items-center bg-bg-0 rounded gap-[6px] py-1.5 px-2">
|
||||
<span className="text-[12px]">{icon}</span>
|
||||
<div>
|
||||
<div className="text-text-3 text-[7px]">{label}</div>
|
||||
<div className="font-semibold font-mono">{value}</div>
|
||||
<div className="font-semibold font-mono">{value || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { LayerTree } from '@common/components/layer/LayerTree'
|
||||
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'
|
||||
|
||||
interface InfoLayerSectionProps {
|
||||
@ -26,29 +24,13 @@ const InfoLayerSection = ({
|
||||
layerBrightness,
|
||||
onLayerBrightnessChange,
|
||||
}: InfoLayerSectionProps) => {
|
||||
// API에서 레이어 트리 데이터 가져오기
|
||||
// API에서 레이어 트리 데이터 가져오기 (관리자 설정 USE_YN='Y' 레이어만 반환)
|
||||
const { data: layerTree, isLoading } = useLayerTree()
|
||||
|
||||
const [layerColors, setLayerColors] = useState<Record<string, string>>({})
|
||||
|
||||
// 정적 데이터를 Layer 형식으로 변환 (API 실패 시 폴백)
|
||||
const staticLayers = useMemo(() => {
|
||||
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
|
||||
// 관리자에서 사용여부가 ON인 레이어만 표시 (정적 폴백 없음)
|
||||
const effectiveLayers: Layer[] = layerTree ?? []
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
|
||||
@ -1,5 +1,63 @@
|
||||
import { useState } from 'react'
|
||||
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 InfoLayerSection from './InfoLayerSection'
|
||||
import OilBoomSection from './OilBoomSection'
|
||||
@ -50,6 +108,7 @@ export function LeftPanel({
|
||||
onLayerOpacityChange,
|
||||
layerBrightness,
|
||||
onLayerBrightnessChange,
|
||||
sensitiveResources = [],
|
||||
onImageAnalysisResult,
|
||||
}: LeftPanelProps) {
|
||||
const [expandedSections, setExpandedSections] = useState<ExpandedSections>({
|
||||
@ -160,7 +219,7 @@ export function LeftPanel({
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고일시</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16) : '—'}</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16).replace(' ', 'T') : '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유종</span>
|
||||
@ -204,7 +263,33 @@ export function LeftPanel({
|
||||
|
||||
{expandedSections.impactResources && (
|
||||
<div className="px-4 pb-4">
|
||||
<p className="text-[11px] text-text-3">영향받는 민감자원 목록</p>
|
||||
{sensitiveResources.length === 0 ? (
|
||||
<p className="text-[11px] text-text-3 font-korean">영향받는 민감자원 목록</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{sensitiveResources.map(({ category, count, 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>
|
||||
|
||||
@ -9,11 +9,13 @@ import { BacktrackModal } from './BacktrackModal'
|
||||
import { RecalcModal } from './RecalcModal'
|
||||
import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar'
|
||||
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 { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
|
||||
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory } from '../services/predictionApi'
|
||||
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, WindPoint } from '../services/predictionApi'
|
||||
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory, fetchSensitiveResources, fetchSensitiveResourcesGeojson } from '../services/predictionApi'
|
||||
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, SensitiveResourceCategory, SensitiveResourceFeatureCollection, WindPoint } from '../services/predictionApi'
|
||||
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
|
||||
import SimulationErrorModal from './SimulationErrorModal'
|
||||
import { api } from '@common/services/api'
|
||||
@ -22,6 +24,13 @@ import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal'
|
||||
|
||||
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
|
||||
|
||||
const toLocalDateTimeStr = (raw: string): string => {
|
||||
const d = new Date(raw)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 민감자원 타입 + 데모 데이터
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -40,16 +49,9 @@ export interface DisplayControls {
|
||||
showWind: boolean; // 풍향/풍속
|
||||
showBeached: boolean; // 해안부착
|
||||
showTimeLabel: boolean; // 시간 표시
|
||||
showSensitiveResources: boolean; // 민감자원
|
||||
}
|
||||
|
||||
const DEMO_SENSITIVE_RESOURCES: SensitiveResource[] = [
|
||||
{ id: 'bc-1', name: '종포 해수욕장', type: 'beach', lat: 34.728, lon: 127.679, radiusM: 350, arrivalTimeH: 1 },
|
||||
{ id: 'aq-1', name: '국동 전복 양식장', type: 'aquaculture', lat: 34.718, lon: 127.672, radiusM: 500, arrivalTimeH: 3 },
|
||||
{ id: 'ec-1', name: '여자만 습지보호구역', type: 'ecology', lat: 34.758, lon: 127.614, radiusM: 1200, arrivalTimeH: 6 },
|
||||
{ id: 'aq-2', name: '화태도 김 양식장', type: 'aquaculture', lat: 34.648, lon: 127.652, radiusM: 800, arrivalTimeH: 10 },
|
||||
{ id: 'aq-3', name: '개도 해안 양식장', type: 'aquaculture', lat: 34.612, lon: 127.636, radiusM: 600, arrivalTimeH: 18 },
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 데모 궤적 생성 (seeded PRNG — deterministic)
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -137,6 +139,8 @@ export function OilSpillView() {
|
||||
|
||||
// 민감자원
|
||||
const [sensitiveResources, setSensitiveResources] = useState<SensitiveResource[]>([])
|
||||
const [sensitiveResourceCategories, setSensitiveResourceCategories] = useState<SensitiveResourceCategory[]>([])
|
||||
const [sensitiveResourceGeojson, setSensitiveResourceGeojson] = useState<SensitiveResourceFeatureCollection | null>(null)
|
||||
|
||||
// 오일펜스 배치 상태
|
||||
const [boomLines, setBoomLines] = useState<BoomLine[]>([])
|
||||
@ -160,6 +164,7 @@ export function OilSpillView() {
|
||||
showWind: false,
|
||||
showBeached: false,
|
||||
showTimeLabel: false,
|
||||
showSensitiveResources: false,
|
||||
})
|
||||
|
||||
// 타임라인 플레이어 상태
|
||||
@ -195,6 +200,13 @@ export function OilSpillView() {
|
||||
const [summaryByModel, setSummaryByModel] = 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 [drawAnalysisMode, setDrawAnalysisMode] = useState<'polygon' | null>(null)
|
||||
@ -215,7 +227,7 @@ export function OilSpillView() {
|
||||
setOilTrajectory(demoTrajectory)
|
||||
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
|
||||
setBoomLines(demoBooms)
|
||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||
setSensitiveResources([])
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeSubTab])
|
||||
@ -467,7 +479,7 @@ export function OilSpillView() {
|
||||
setSelectedAnalysis(analysis)
|
||||
setCenterPoints([])
|
||||
if (analysis.occurredAt) {
|
||||
setAccidentTime(analysis.occurredAt.slice(0, 16))
|
||||
setAccidentTime(toLocalDateTimeStr(analysis.occurredAt))
|
||||
}
|
||||
if (analysis.lon != null && analysis.lat != null) {
|
||||
setIncidentCoord({ lon: analysis.lon, lat: analysis.lat })
|
||||
@ -517,7 +529,13 @@ export function OilSpillView() {
|
||||
if (sbModel) setSummaryByModel(sbModel);
|
||||
if (stepSbModel) setStepSummariesByModel(stepSbModel);
|
||||
if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings))
|
||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||
setSensitiveResources([])
|
||||
fetchSensitiveResources(analysis.acdntSn)
|
||||
.then(setSensitiveResourceCategories)
|
||||
.catch(err => console.warn('[prediction] 민감자원 조회 실패:', err))
|
||||
fetchSensitiveResourcesGeojson(analysis.acdntSn)
|
||||
.then(setSensitiveResourceGeojson)
|
||||
.catch(err => console.warn('[prediction] 민감자원 GeoJSON 조회 실패:', err))
|
||||
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
||||
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
||||
pendingPlayRef.current = true
|
||||
@ -539,7 +557,8 @@ export function OilSpillView() {
|
||||
const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48)
|
||||
setOilTrajectory(demoTrajectory)
|
||||
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings))
|
||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||
setSensitiveResources([])
|
||||
setSensitiveResourceCategories([])
|
||||
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
||||
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
||||
pendingPlayRef.current = true
|
||||
@ -553,7 +572,7 @@ export function OilSpillView() {
|
||||
setDrawingPoints(prev => [...prev, { lat, lon }])
|
||||
} else if (drawAnalysisMode === 'polygon') {
|
||||
setAnalysisPolygonPoints(prev => [...prev, { lat, lon }])
|
||||
} else {
|
||||
} else if (isSelectingLocation) {
|
||||
setIncidentCoord({ lon, lat })
|
||||
setIsSelectingLocation(false)
|
||||
}
|
||||
@ -565,7 +584,7 @@ export function OilSpillView() {
|
||||
setAnalysisResult(null)
|
||||
}
|
||||
|
||||
const handleRunPolygonAnalysis = () => {
|
||||
const handleRunPolygonAnalysis = async () => {
|
||||
if (analysisPolygonPoints.length < 3) return
|
||||
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
|
||||
const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1
|
||||
@ -580,7 +599,7 @@ export function OilSpillView() {
|
||||
setDrawAnalysisMode(null)
|
||||
}
|
||||
|
||||
const handleRunCircleAnalysis = () => {
|
||||
const handleRunCircleAnalysis = async () => {
|
||||
if (!incidentCoord) return
|
||||
const radiusM = circleRadiusNm * 1852
|
||||
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
|
||||
@ -613,7 +632,7 @@ export function OilSpillView() {
|
||||
const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => {
|
||||
setIncidentCoord({ lat: result.lat, lon: result.lon })
|
||||
setFlyToCoord({ lat: result.lat, lon: result.lon })
|
||||
setAccidentTime(result.occurredAt.slice(0, 16))
|
||||
setAccidentTime(toLocalDateTimeStr(result.occurredAt))
|
||||
setOilType(result.oilType)
|
||||
setSpillAmount(parseFloat(result.volume.toFixed(4)))
|
||||
setSpillUnit('kL')
|
||||
@ -746,9 +765,10 @@ export function OilSpillView() {
|
||||
const newWindDataByModel: Record<string, WindPoint[][]> = {};
|
||||
const newHydrDataByModel: Record<string, (HydrDataStep | null)[]> = {};
|
||||
const newSummaryByModel: Record<string, SimulationSummary> = {};
|
||||
const newStepSummariesByModel: Record<string, SimulationSummary[]> = {};
|
||||
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') {
|
||||
errors.push(error || `${model} 분석 중 오류가 발생했습니다.`);
|
||||
return;
|
||||
@ -760,6 +780,7 @@ export function OilSpillView() {
|
||||
newSummaryByModel[model] = summary;
|
||||
if (model === 'OpenDrift' || !latestSummary) latestSummary = summary;
|
||||
}
|
||||
if (stepSummaries) newStepSummariesByModel[model] = stepSummaries;
|
||||
if (windData) newWindDataByModel[model] = windData;
|
||||
if (hydrData) newHydrDataByModel[model] = hydrData;
|
||||
if (centerPoints) {
|
||||
@ -788,9 +809,10 @@ export function OilSpillView() {
|
||||
setWindDataByModel(newWindDataByModel);
|
||||
setHydrDataByModel(newHydrDataByModel);
|
||||
setSummaryByModel(newSummaryByModel);
|
||||
setStepSummariesByModel(newStepSummariesByModel);
|
||||
const booms = generateAIBoomLines(merged, effectiveCoord, algorithmSettings);
|
||||
setBoomLines(booms);
|
||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES);
|
||||
setSensitiveResources([]);
|
||||
setCurrentStep(0);
|
||||
setIsPlaying(true);
|
||||
setFlyToCoord({ lon: effectiveCoord.lon, lat: effectiveCoord.lat });
|
||||
@ -800,6 +822,26 @@ export function OilSpillView() {
|
||||
setSimulationError(errors.join('; '));
|
||||
} else {
|
||||
simulationSucceeded = true;
|
||||
const effectiveAcdntSn = data.acdntSn ?? selectedAnalysis?.acdntSn;
|
||||
if (effectiveCoord) {
|
||||
fetchWeatherSnapshotForCoord(effectiveCoord.lat, effectiveCoord.lon)
|
||||
.then(snapshot => {
|
||||
useWeatherSnapshotStore.getState().setSnapshot(snapshot);
|
||||
if (effectiveAcdntSn) {
|
||||
api.post(`/incidents/${effectiveAcdntSn}/weather`, snapshot)
|
||||
.catch(err => console.warn('[weather] 기상 저장 실패:', err));
|
||||
}
|
||||
})
|
||||
.catch(err => console.warn('[weather] 기상 데이터 수집 실패:', err));
|
||||
}
|
||||
if (effectiveAcdntSn) {
|
||||
fetchSensitiveResources(effectiveAcdntSn)
|
||||
.then(setSensitiveResourceCategories)
|
||||
.catch(err => console.warn('[prediction] 민감자원 조회 실패:', err));
|
||||
fetchSensitiveResourcesGeojson(effectiveAcdntSn)
|
||||
.then(setSensitiveResourceGeojson)
|
||||
.catch(err => console.warn('[prediction] 민감자원 GeoJSON 조회 실패:', err));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const msg =
|
||||
@ -827,6 +869,7 @@ export function OilSpillView() {
|
||||
accidentTime ||
|
||||
'';
|
||||
const wx = analysisDetail?.weather?.[0] ?? null;
|
||||
const weatherSnapshot = useWeatherSnapshotStore.getState().snapshot;
|
||||
|
||||
const payload: OilReportPayload = {
|
||||
incident: {
|
||||
@ -854,9 +897,27 @@ export function OilSpillView() {
|
||||
coastLength: simulationSummary ? `${simulationSummary.pollutionCoastLength.toFixed(2)} km` : '—',
|
||||
oilType: OIL_TYPE_CODE[oilType] || oilType,
|
||||
},
|
||||
weather: wx
|
||||
? { windDir: wx.wind, windSpeed: wx.wind, waveHeight: wx.wave, temp: wx.temp }
|
||||
: null,
|
||||
weather: (() => {
|
||||
if (weatherSnapshot) {
|
||||
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: (() => {
|
||||
const fmt = (model: string) => {
|
||||
const s = summaryByModel[model];
|
||||
@ -884,6 +945,14 @@ export function OilSpillView() {
|
||||
return [toRow('3시간', steps[3]), toRow('6시간', steps[6])];
|
||||
})(),
|
||||
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 ? {
|
||||
center: [incidentCoord.lat, incidentCoord.lon],
|
||||
zoom: 10,
|
||||
@ -947,6 +1016,7 @@ export function OilSpillView() {
|
||||
onLayerOpacityChange={setLayerOpacity}
|
||||
layerBrightness={layerBrightness}
|
||||
onLayerBrightnessChange={setLayerBrightness}
|
||||
sensitiveResources={sensitiveResourceCategories}
|
||||
onImageAnalysisResult={handleImageAnalysisResult}
|
||||
/>
|
||||
)}
|
||||
@ -975,6 +1045,7 @@ export function OilSpillView() {
|
||||
layerOpacity={layerOpacity}
|
||||
layerBrightness={layerBrightness}
|
||||
sensitiveResources={sensitiveResources}
|
||||
sensitiveResourceGeojson={displayControls.showSensitiveResources ? sensitiveResourceGeojson : null}
|
||||
lightMode
|
||||
centerPoints={centerPoints.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))}
|
||||
windData={windData}
|
||||
@ -1197,6 +1268,7 @@ export function OilSpillView() {
|
||||
onOpenReport={handleOpenReport}
|
||||
detail={analysisDetail}
|
||||
summary={stepSummariesByModel[windHydrModel]?.[currentStep] ?? summaryByModel[windHydrModel] ?? simulationSummary}
|
||||
boomBlockedVolume={boomBlockedVolume}
|
||||
displayControls={displayControls}
|
||||
onDisplayControlsChange={setDisplayControls}
|
||||
windHydrModel={windHydrModel}
|
||||
@ -1210,6 +1282,8 @@ export function OilSpillView() {
|
||||
onCircleRadiusChange={setCircleRadiusNm}
|
||||
analysisResult={analysisResult}
|
||||
incidentCoord={incidentCoord}
|
||||
centerPoints={centerPoints}
|
||||
predictionTime={predictionTime}
|
||||
onStartPolygonDraw={handleStartPolygonDraw}
|
||||
onRunPolygonAnalysis={handleRunPolygonAnalysis}
|
||||
onRunCircleAnalysis={handleRunCircleAnalysis}
|
||||
|
||||
@ -430,7 +430,9 @@ function DateTimeInput({ value, onChange }: { value: string; onChange: (v: strin
|
||||
|
||||
const datePart = value ? value.split('T')[0] : ''
|
||||
const timePart = value && value.includes('T') ? value.split('T')[1] : '00:00'
|
||||
const [hh, mm] = timePart.split(':').map(Number)
|
||||
const timeParts = timePart.split(':').map(Number)
|
||||
const hh = isNaN(timeParts[0]) ? 0 : timeParts[0]
|
||||
const mm = (timeParts[1] === undefined || isNaN(timeParts[1])) ? 0 : timeParts[1]
|
||||
|
||||
const parsed = datePart ? new Date(datePart + 'T00:00:00') : new Date()
|
||||
const [viewYear, setViewYear] = useState(parsed.getFullYear())
|
||||
@ -561,9 +563,15 @@ function DateTimeInput({ value, onChange }: { value: string; onChange: (v: strin
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewYear(todayY)
|
||||
setViewMonth(todayM)
|
||||
pickDate(todayD)
|
||||
const now = new Date()
|
||||
setViewYear(now.getFullYear())
|
||||
setViewMonth(now.getMonth())
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(now.getDate()).padStart(2, '0')
|
||||
const hh = String(now.getHours()).padStart(2, '0')
|
||||
const mm = String(now.getMinutes()).padStart(2, '0')
|
||||
onChange(`${now.getFullYear()}-${m}-${d}T${hh}:${mm}`)
|
||||
setShowCal(false)
|
||||
}}
|
||||
className="w-full text-[8px] font-korean font-semibold cursor-pointer rounded-sm"
|
||||
style={{
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import type { PredictionDetail, SimulationSummary } from '../services/predictionApi'
|
||||
import { useState, useMemo } from 'react'
|
||||
import type { PredictionDetail, SimulationSummary, CenterPoint } from '../services/predictionApi'
|
||||
import type { DisplayControls } from './OilSpillView'
|
||||
import { haversineDistance, computeBearing } from '@common/utils/geo'
|
||||
|
||||
interface AnalysisResult {
|
||||
area: number
|
||||
@ -29,6 +30,9 @@ interface RightPanelProps {
|
||||
onCircleRadiusChange?: (nm: number) => void
|
||||
analysisResult?: AnalysisResult | null
|
||||
incidentCoord?: { lat: number; lon: number } | null
|
||||
centerPoints?: CenterPoint[]
|
||||
predictionTime?: number
|
||||
boomBlockedVolume?: number
|
||||
onStartPolygonDraw?: () => void
|
||||
onRunPolygonAnalysis?: () => void
|
||||
onRunCircleAnalysis?: () => void
|
||||
@ -44,6 +48,10 @@ export function RightPanel({
|
||||
drawAnalysisMode, analysisPolygonPoints = [],
|
||||
circleRadiusNm = 5, onCircleRadiusChange,
|
||||
analysisResult,
|
||||
incidentCoord,
|
||||
centerPoints,
|
||||
predictionTime,
|
||||
boomBlockedVolume = 0,
|
||||
onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis,
|
||||
onCancelAnalysis, onClearAnalysis,
|
||||
}: RightPanelProps) {
|
||||
@ -54,6 +62,38 @@ export function RightPanel({
|
||||
const [shipExpanded, setShipExpanded] = 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 (
|
||||
<div className="w-[300px] min-w-[300px] bg-bg-1 border-l border-border flex flex-col">
|
||||
{/* Tab Header */}
|
||||
@ -81,7 +121,10 @@ export function RightPanel({
|
||||
checked={displayControls?.showBeached ?? false}
|
||||
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showBeached: v })}
|
||||
>해안부착</ControlledCheckbox>
|
||||
<ControlledCheckbox checked={false} onChange={() => {}} disabled>
|
||||
<ControlledCheckbox
|
||||
checked={displayControls?.showSensitiveResources ?? false}
|
||||
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showSensitiveResources: v })}
|
||||
>
|
||||
민감자원
|
||||
</ControlledCheckbox>
|
||||
<ControlledCheckbox
|
||||
@ -233,23 +276,29 @@ export function RightPanel({
|
||||
</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]">
|
||||
<PredictionCard value="4.7 km²" label="영향 면적" color="var(--red)" />
|
||||
<PredictionCard value="6.2 km" label="확산 거리" color="var(--orange)" />
|
||||
<PredictionCard value="NE 42°" label="확산 방향" color="var(--cyan)" />
|
||||
<PredictionCard value="0.35 m/s" label="확산 속도" color="var(--t1)" />
|
||||
<PredictionCard value={spreadSummary?.area != null ? `${spreadSummary.area.toFixed(1)} km²` : '—'} label="영향 면적" color="var(--red)" />
|
||||
<PredictionCard value={spreadSummary?.distance != null ? `${spreadSummary.distance.toFixed(1)} km` : '—'} label="확산 거리" color="var(--orange)" />
|
||||
<PredictionCard value={spreadSummary?.directionLabel ?? '—'} label="확산 방향" color="var(--cyan)" />
|
||||
<PredictionCard value={spreadSummary?.speed != null ? `${spreadSummary.speed.toFixed(2)} m/s` : '—'} label="확산 속도" color="var(--t1)" />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 유출유 풍화 상태 */}
|
||||
<Section title="유출유 풍화 상태">
|
||||
<div className="flex flex-col gap-[3px] text-[8px]">
|
||||
<ProgressBar label="수면잔류" value={58} color="var(--blue)" />
|
||||
<ProgressBar label="증발" value={22} color="var(--cyan)" />
|
||||
<ProgressBar label="분산" value={12} color="var(--green)" />
|
||||
<ProgressBar label="펜스차단" value={5} color="var(--boom)" />
|
||||
<ProgressBar label="해안도달" value={3} color="var(--red)" />
|
||||
{weatheringStatus ? (
|
||||
<>
|
||||
<ProgressBar label="수면잔류" value={weatheringStatus.surface} color="var(--blue)" />
|
||||
<ProgressBar label="증발" value={weatheringStatus.evaporation} color="var(--cyan)" />
|
||||
<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>
|
||||
</Section>
|
||||
|
||||
@ -654,13 +703,13 @@ function PollResult({
|
||||
{summary && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-3">해상잔존량</span>
|
||||
<span className="font-semibold font-mono" style={{ color: 'var(--blue)' }}>{summary.remainingVolume.toFixed(2)} kL</span>
|
||||
<span className="font-semibold font-mono" style={{ color: 'var(--blue)' }}>{summary.remainingVolume.toFixed(2)} m³</span>
|
||||
</div>
|
||||
)}
|
||||
{summary && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-3">연안부착량</span>
|
||||
<span className="font-semibold font-mono" style={{ color: 'var(--red)' }}>{summary.beachedVolume.toFixed(2)} kL</span>
|
||||
<span className="font-semibold font-mono" style={{ color: 'var(--red)' }}>{summary.beachedVolume.toFixed(2)} m³</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { PredictionModel } from './OilSpillView'
|
||||
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
|
||||
import type { Analysis } from './AnalysisListTable'
|
||||
import type { ImageAnalyzeResult } from '../services/predictionApi'
|
||||
import type { ImageAnalyzeResult, SensitiveResourceCategory } from '../services/predictionApi'
|
||||
|
||||
export interface LeftPanelProps {
|
||||
selectedAnalysis?: Analysis | null
|
||||
@ -49,6 +49,8 @@ export interface LeftPanelProps {
|
||||
onLayerOpacityChange: (val: number) => void
|
||||
layerBrightness: number
|
||||
onLayerBrightnessChange: (val: number) => void
|
||||
// 영향 민감자원
|
||||
sensitiveResources?: SensitiveResourceCategory[]
|
||||
// 이미지 분석 결과 콜백
|
||||
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void
|
||||
}
|
||||
|
||||
@ -168,6 +168,8 @@ export interface OilParticle {
|
||||
export interface SimulationSummary {
|
||||
remainingVolume: number;
|
||||
weatheredVolume: number;
|
||||
evaporationVolume: number;
|
||||
dispersionVolume: number;
|
||||
pollutionArea: number;
|
||||
beachedVolume: number;
|
||||
pollutionCoastLength: number;
|
||||
@ -190,6 +192,7 @@ export interface RunModelSyncResult {
|
||||
status: 'DONE' | 'ERROR';
|
||||
trajectory?: OilParticle[];
|
||||
summary?: SimulationSummary;
|
||||
stepSummaries?: SimulationSummary[];
|
||||
centerPoints?: CenterPoint[];
|
||||
windData?: WindPoint[][];
|
||||
hydrData?: (HydrDataStep | null)[];
|
||||
@ -218,6 +221,73 @@ export const fetchAnalysisTrajectory = async (acdntSn: number): Promise<Trajecto
|
||||
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 { fetchSensitiveResourcesGeojson, fetchPredictionParticlesGeojson, fetchSensitivityEvaluationGeojson } from '@tabs/prediction/services/predictionApi'
|
||||
|
||||
// ─── Data Types ─────────────────────────────────────────────
|
||||
export type ReportType = '초기보고서' | '지휘부 보고' | '예측보고서' | '종합보고서' | '유출유 보고'
|
||||
@ -39,7 +42,12 @@ export interface OilSpillReportData {
|
||||
recovery: { shipName: string; period: string }[]
|
||||
result: { spillTotal: string; weatheredTotal: string; recoveredTotal: string; seaRemainTotal: string; coastAttachTotal: string }
|
||||
capturedMapImage?: string;
|
||||
step3MapImage?: string;
|
||||
step6MapImage?: string;
|
||||
hasMapCapture?: boolean;
|
||||
acdntSn?: number;
|
||||
sensitiveMapImage?: string;
|
||||
sensitivityMapImage?: string;
|
||||
}
|
||||
|
||||
// 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 className="flex gap-4 mb-4">
|
||||
<div style={S.mapPlaceholder}>확산예측 3시간 지도</div>
|
||||
<div style={S.mapPlaceholder}>확산예측 6시간 지도</div>
|
||||
{data.step3MapImage
|
||||
? <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 style={S.subHeader}>시간별 상세정보</div>
|
||||
<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 }) {
|
||||
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 })
|
||||
@ -321,7 +725,7 @@ function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing:
|
||||
<div style={S.page}>
|
||||
<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.mapPlaceholder}>민감자원 분포(10km 내) 지도</div>
|
||||
<SensitiveResourceMapSection data={data} editing={editing} onChange={onChange} />
|
||||
|
||||
<div style={S.subHeader}>양식장 분포</div>
|
||||
<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 }) {
|
||||
const setSens = (i: number, v: string) => { const s = [...data.sensitivity]; s[i] = { ...s[i], area: v }; onChange({ ...data, sensitivity: s }) }
|
||||
return (
|
||||
<div style={S.page}>
|
||||
<div className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold">해양오염방제지원시스템</div>
|
||||
<div style={S.sectionTitle}>통합민감도 평가 (해당 계절)</div>
|
||||
<div style={S.mapPlaceholder}>통합민감도 평가 지도</div>
|
||||
<SensitivityMapSection data={data} editing={editing} onChange={onChange} />
|
||||
<table style={S.table}>
|
||||
<thead><tr><th style={S.th}>민감도</th><th style={S.th}>분포 면적(km²)</th></tr></thead>
|
||||
<tbody>{data.sensitivity.map((s, i) => (
|
||||
|
||||
@ -4,12 +4,24 @@ import type { OilReportPayload } from '@common/hooks/useSubMenu';
|
||||
|
||||
interface OilSpreadMapPanelProps {
|
||||
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;
|
||||
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 [isCapturing, setIsCapturing] = useState(false);
|
||||
|
||||
@ -18,29 +30,29 @@ const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSp
|
||||
setIsCapturing(true);
|
||||
const dataUrl = await captureRef.current();
|
||||
setIsCapturing(false);
|
||||
if (dataUrl) {
|
||||
onCapture(dataUrl);
|
||||
}
|
||||
if (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 className="flex flex-col">
|
||||
{/* 라벨 */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
{/* 지도 + 오버레이 컨테이너 — MapView 항상 마운트 유지 (deck.gl rAF race condition 방지) */}
|
||||
<div className="relative w-full rounded-lg border border-border overflow-hidden" style={{ height: '620px' }}>
|
||||
{/* 지도 + 캡처 오버레이 */}
|
||||
<div className="relative rounded-lg border border-border overflow-hidden" style={{ height: '300px' }}>
|
||||
<MapView
|
||||
center={mapData.center}
|
||||
zoom={mapData.zoom}
|
||||
incidentCoord={{ lat: mapData.center[0], lon: mapData.center[1] }}
|
||||
oilTrajectory={mapData.trajectory}
|
||||
externalCurrentTime={mapData.currentStep}
|
||||
externalCurrentTime={step}
|
||||
centerPoints={mapData.centerPoints}
|
||||
showBeached={true}
|
||||
showTimeLabel={true}
|
||||
@ -50,27 +62,26 @@ const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSp
|
||||
lightMode
|
||||
/>
|
||||
|
||||
{/* 캡처 이미지 오버레이 — 우측 상단 */}
|
||||
{capturedImage && (
|
||||
<div className="absolute top-3 right-3 z-10" style={{ width: '220px' }}>
|
||||
{captured && (
|
||||
<div className="absolute top-2 right-2 z-10" style={{ width: '180px' }}>
|
||||
<div
|
||||
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)' }}
|
||||
>
|
||||
<img src={capturedImage} alt="확산예측 지도 캡처" className="w-full block" />
|
||||
<img src={captured} alt={`${label} 캡처`} className="w-full block" />
|
||||
<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)' }}
|
||||
>
|
||||
<span className="text-[10px] font-korean font-semibold" style={{ color: '#06b6d4' }}>
|
||||
<span className="text-[9px] font-korean font-semibold" style={{ color: '#06b6d4' }}>
|
||||
📷 캡처 완료
|
||||
</span>
|
||||
<button
|
||||
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)' }}
|
||||
>
|
||||
다시 선택
|
||||
다시
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -78,30 +89,69 @@ const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSp
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 안내 + 캡처 버튼 */}
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<p className="text-[10px] text-text-3 font-korean">
|
||||
{capturedImage
|
||||
? 'PDF 다운로드 시 캡처된 이미지가 포함됩니다.'
|
||||
: '지도를 이동/확대하여 원하는 범위를 선택한 후 캡처하세요.'}
|
||||
{/* 캡처 버튼 */}
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<p className="text-[9px] text-text-3 font-korean">
|
||||
{captured ? 'PDF 출력 시 포함됩니다.' : '원하는 범위를 선택 후 캡처하세요.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleCapture}
|
||||
disabled={isCapturing || !!capturedImage}
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded transition-all font-korean flex items-center gap-1.5"
|
||||
disabled={isCapturing || !!captured}
|
||||
className="px-2.5 py-1 text-[10px] font-semibold rounded transition-all font-korean flex items-center gap-1"
|
||||
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)',
|
||||
color: capturedImage ? 'rgba(6,182,212,0.5)' : '#06b6d4',
|
||||
color: captured ? 'rgba(6,182,212,0.5)' : '#06b6d4',
|
||||
opacity: isCapturing ? 0.6 : 1,
|
||||
cursor: capturedImage ? 'default' : 'pointer',
|
||||
cursor: captured ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{capturedImage ? '✓ 캡처됨' : '📷 이 범위로 캡처'}
|
||||
{captured ? '✓ 캡처됨' : '📷 캡처'}
|
||||
</button>
|
||||
</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;
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
createEmptyReport,
|
||||
} from './OilSpillReportTemplate';
|
||||
import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload, consumeOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu';
|
||||
import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore';
|
||||
import OilSpreadMapPanel from './OilSpreadMapPanel';
|
||||
import { saveReport } from '../services/reportsApi';
|
||||
import {
|
||||
@ -32,8 +33,11 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
const [hnsPayload, setHnsPayload] = useState<HnsReportPayload | null>(null)
|
||||
// OIL 실 데이터 (없으면 sampleOilData fallback)
|
||||
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(() => {
|
||||
@ -94,8 +98,8 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
sunset: '',
|
||||
windDir: oilPayload.weather.windDir,
|
||||
windSpeed: oilPayload.weather.windSpeed,
|
||||
currentDir: '',
|
||||
currentSpeed: '',
|
||||
currentDir: oilPayload.weather.currentDir ?? '',
|
||||
currentSpeed: oilPayload.weather.currentSpeed ?? '',
|
||||
waveHeight: oilPayload.weather.waveHeight,
|
||||
}];
|
||||
}
|
||||
@ -109,27 +113,24 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
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
|
||||
if (oilPayload.spreadSteps) {
|
||||
report.spread = oilPayload.spreadSteps;
|
||||
}
|
||||
|
||||
// acdntSn 전달 (민감자원 지도 로드용)
|
||||
if (oilPayload.acdntSn) {
|
||||
(report as typeof report & { acdntSn?: number }).acdntSn = oilPayload.acdntSn;
|
||||
}
|
||||
|
||||
} else {
|
||||
report.incident.pollutant = '';
|
||||
report.incident.spillAmount = '';
|
||||
}
|
||||
}
|
||||
if (activeCat === 0 && oilMapCaptured) {
|
||||
report.capturedMapImage = oilMapCaptured;
|
||||
if (activeCat === 0) {
|
||||
if (capturedStep3) report.step3MapImage = capturedStep3;
|
||||
if (capturedStep6) report.step6MapImage = capturedStep6;
|
||||
}
|
||||
try {
|
||||
await saveReport(report)
|
||||
@ -148,20 +149,22 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
// OIL 섹션에 실 데이터 삽입
|
||||
if (activeCat === 0) {
|
||||
if (sec.id === 'oil-spread') {
|
||||
const mapImg = oilMapCaptured
|
||||
? `<img src="${oilMapCaptured}" style="width:100%;max-height:300px;object-fit:contain;border:1px solid #ddd;border-radius:8px;margin-bottom:12px;" />`
|
||||
: '<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>';
|
||||
const spreadRows = oilPayload
|
||||
? [
|
||||
['KOSPS', oilPayload.spread.kosps],
|
||||
['OpenDrift', oilPayload.spread.openDrift],
|
||||
['POSEIDON', oilPayload.spread.poseidon],
|
||||
]
|
||||
: [['KOSPS', '—'], ['OpenDrift', '—'], ['POSEIDON', '—']];
|
||||
const tds = spreadRows.map(r =>
|
||||
`<td style="padding:8px;border:1px solid #ddd;text-align:center;"><b>${r[0]}</b><br/>${r[1]}</td>`
|
||||
).join('');
|
||||
content = `${mapImg}<table style="width:100%;border-collapse:collapse;font-size:12px;"><tr>${tds}</tr></table>`;
|
||||
const img3 = capturedStep3
|
||||
? `<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:6px;display:flex;align-items:center;justify-content:center;color:#999;font-size:11px;">[미캡처]</div>';
|
||||
const img6 = capturedStep6
|
||||
? `<img src="${capturedStep6}" 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:6px;display:flex;align-items:center;justify-content:center;color:#999;font-size:11px;">[미캡처]</div>';
|
||||
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>`;
|
||||
const spreadStepRows = oilPayload?.spreadSteps && oilPayload.spreadSteps.length > 0
|
||||
? oilPayload.spreadSteps.map(s =>
|
||||
`<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>`
|
||||
).join('')
|
||||
: '';
|
||||
const stepsTable = spreadStepRows
|
||||
? `<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') {
|
||||
@ -173,6 +176,17 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
: `<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 (sec.id === 'oil-pollution') {
|
||||
const rows = [
|
||||
@ -366,10 +380,39 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
<>
|
||||
<OilSpreadMapPanel
|
||||
mapData={oilPayload?.mapData ?? null}
|
||||
capturedImage={oilMapCaptured}
|
||||
onCapture={(dataUrl) => setOilMapCaptured(dataUrl)}
|
||||
onReset={() => setOilMapCaptured(null)}
|
||||
capturedStep3={capturedStep3}
|
||||
capturedStep6={capturedStep6}
|
||||
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">
|
||||
{[
|
||||
{ label: 'KOSPS', value: oilPayload?.spread.kosps || '—', color: '#06b6d4' },
|
||||
@ -410,11 +453,43 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
{sec.id === 'oil-sensitive' && (
|
||||
{sec.id === 'oil-sensitive' && (() => {
|
||||
const resources = oilPayload?.sensitiveResources;
|
||||
if (!resources || resources.length === 0) {
|
||||
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' && (() => {
|
||||
if (!oilPayload) {
|
||||
return (
|
||||
@ -448,11 +523,50 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sec.id === 'oil-tide' && (
|
||||
{sec.id === 'oil-tide' && (() => {
|
||||
const wx = oilPayload?.weather;
|
||||
if (!wx) {
|
||||
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 대기확산 섹션들 ── */}
|
||||
{sec.id === 'hns-atm' && (
|
||||
|
||||
@ -302,7 +302,12 @@ export function ReportsView() {
|
||||
const getVal = buildReportGetVal(previewReport)
|
||||
const meta = { writeTime: previewReport.incident.writeTime, author: previewReport.author, jurisdiction: previewReport.jurisdiction }
|
||||
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]"
|
||||
@ -357,38 +362,52 @@ export function ReportsView() {
|
||||
{[
|
||||
previewReport.incident.pollutant && `유출유종: ${previewReport.incident.pollutant}`,
|
||||
previewReport.incident.spillAmount && `유출량: ${previewReport.incident.spillAmount}`,
|
||||
previewReport.spread?.length > 0 && `확산예측: ${previewReport.spread.length}개 시점 데이터`,
|
||||
].filter(Boolean).join('\n') || '—'}
|
||||
</div>
|
||||
{(previewReport.capturedMapImage || previewReport.step3MapImage || previewReport.step6MapImage) && (
|
||||
<div className="flex flex-col gap-2 mt-3">
|
||||
{previewReport.capturedMapImage && (
|
||||
<img
|
||||
src={previewReport.capturedMapImage}
|
||||
alt="확산예측 지도 캡처"
|
||||
className="w-full rounded-lg border border-border mt-3"
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
@ -101,7 +101,7 @@ const MANIFEST_XML =
|
||||
/**
|
||||
* Contents/content.hpf: Skeleton.hwpx 기반, 날짜만 동적 생성
|
||||
*/
|
||||
function buildContentHpf(): string {
|
||||
function buildContentHpf(extraManifestItems = ''): string {
|
||||
const now = new Date().toISOString();
|
||||
return (
|
||||
'<?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="section0" href="Contents/section0.xml" media-type="application/xml"/>' +
|
||||
'<opf:item id="settings" href="settings.xml" media-type="application/xml"/>' +
|
||||
extraManifestItems +
|
||||
'</opf:manifest>' +
|
||||
'<opf:spine>' +
|
||||
'<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 내부용)
|
||||
*/
|
||||
@ -542,6 +567,104 @@ const CONTENT_WIDTH = 42520;
|
||||
const LABEL_WIDTH = Math.round(CONTENT_WIDTH * 0.33);
|
||||
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(
|
||||
fields: { key: string; label: string }[],
|
||||
getVal: (key: string) => string,
|
||||
@ -549,6 +672,14 @@ function buildFieldTable(
|
||||
const rowCnt = fields.length;
|
||||
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 = '';
|
||||
fields.forEach((field, rowIdx) => {
|
||||
const value = getVal(field.key) || '-';
|
||||
@ -604,6 +735,7 @@ function buildSection0Xml(
|
||||
meta: ReportMeta,
|
||||
sections: ReportSection[],
|
||||
getVal: (key: string) => string,
|
||||
imageBinIds?: { step3?: number; step6?: number; sensitiveMap?: number; sensitivityMap?: number },
|
||||
): string {
|
||||
// ID 시퀀스 초기화 (재사용 시 충돌 방지)
|
||||
_idSeq = 1000000000;
|
||||
@ -634,9 +766,43 @@ function buildSection0Xml(
|
||||
// 섹션 제목 (11pt = charPrId 6)
|
||||
body += buildPara(section.title, 6);
|
||||
|
||||
// 필드 테이블
|
||||
if (section.fields.length > 0) {
|
||||
// __spreadMaps 필드 포함 섹션: 이미지 단락 삽입 후 나머지 필드 처리
|
||||
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);
|
||||
// 통합민감도 평가 지도 — 테이블 뒤
|
||||
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) {
|
||||
lines.push(`[${section.title}]`);
|
||||
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) {
|
||||
lines.push(` ${field.label}: ${value}`);
|
||||
} else {
|
||||
@ -690,6 +859,7 @@ export async function exportAsHWPX(
|
||||
sections: ReportSection[],
|
||||
getVal: (key: string) => string,
|
||||
filename: string,
|
||||
images?: { step3?: string; step6?: string; sensitiveMap?: string; sensitivityMap?: string },
|
||||
): Promise<void> {
|
||||
const zip = new JSZip();
|
||||
|
||||
@ -703,10 +873,66 @@ export async function exportAsHWPX(
|
||||
zip.file('META-INF/container.rdf', CONTAINER_RDF);
|
||||
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
|
||||
zip.file('Contents/content.hpf', buildContentHpf());
|
||||
zip.file('Contents/header.xml', HEADER_XML);
|
||||
zip.file('Contents/section0.xml', buildSection0Xml(templateLabel, meta, sections, getVal));
|
||||
zip.file('Contents/content.hpf', buildContentHpf(extraManifestItems));
|
||||
zip.file('Contents/header.xml', headerXml);
|
||||
zip.file('Contents/section0.xml', buildSection0Xml(templateLabel, meta, sections, getVal, imageBinIds));
|
||||
|
||||
// Preview
|
||||
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: '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: '7. 방제 자원', fields: [{ key: '__vessels', 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 }[] }[],
|
||||
getVal: (key: string) => string,
|
||||
filename: string,
|
||||
images?: { step3?: string; step6?: string; sensitiveMap?: string; sensitivityMap?: string },
|
||||
) {
|
||||
const { exportAsHWPX } = await import('./hwpxExport');
|
||||
await exportAsHWPX(templateLabel, meta, sections, getVal, filename);
|
||||
await exportAsHWPX(templateLabel, meta, sections, getVal, filename, images);
|
||||
}
|
||||
|
||||
export type ViewState =
|
||||
@ -121,6 +122,14 @@ function formatSpreadTable(spread: OilSpillReportData['spread']): string {
|
||||
|
||||
function formatSensitiveTable(r: OilSpillReportData): 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) {
|
||||
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('')
|
||||
@ -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('')
|
||||
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) {
|
||||
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('')
|
||||
@ -196,6 +212,18 @@ export function buildReportGetVal(report: OilSpillReportData) {
|
||||
}
|
||||
if (key === '__tide') return formatTideTable(report.tide)
|
||||
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 === '__sensitive') return formatSensitiveTable(report)
|
||||
if (key === '__vessels') return formatVesselsTable(report.vessels)
|
||||
|
||||
@ -60,6 +60,7 @@ export interface ApiReportListItem {
|
||||
sttsCd: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
acdntSn?: number | null;
|
||||
regDtm: string;
|
||||
mdfcnDtm: string | null;
|
||||
hasMapCapture?: boolean;
|
||||
@ -75,7 +76,8 @@ export interface ApiReportSectionData {
|
||||
export interface ApiReportDetail extends ApiReportListItem {
|
||||
acdntSn: number | null;
|
||||
sections: ApiReportSectionData[];
|
||||
mapCaptureImg?: string | null;
|
||||
step3MapImg?: string | null;
|
||||
step6MapImg?: string | null;
|
||||
}
|
||||
|
||||
export interface ApiReportListResponse {
|
||||
@ -178,7 +180,8 @@ export async function createReportApi(input: {
|
||||
title: string;
|
||||
jrsdCd?: string;
|
||||
sttsCd?: string;
|
||||
mapCaptureImg?: string;
|
||||
step3MapImg?: string;
|
||||
step6MapImg?: string;
|
||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||
}): Promise<{ sn: number }> {
|
||||
const res = await api.post<{ sn: number }>('/reports', input);
|
||||
@ -190,7 +193,8 @@ export async function updateReportApi(sn: number, input: {
|
||||
jrsdCd?: string;
|
||||
sttsCd?: string;
|
||||
acdntSn?: number | null;
|
||||
mapCaptureImg?: string | null;
|
||||
step3MapImg?: string | null;
|
||||
step6MapImg?: string | null;
|
||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||
}): Promise<void> {
|
||||
await api.post(`/reports/${sn}/update`, input);
|
||||
@ -236,14 +240,26 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
|
||||
// analysis + etcEquipment 합산
|
||||
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
|
||||
const existingSn = (data as OilSpillReportData & { reportSn?: number }).reportSn;
|
||||
const existingSn = extData.reportSn;
|
||||
if (existingSn) {
|
||||
await updateReportApi(existingSn, {
|
||||
title: data.title || data.incident.name || '보고서',
|
||||
jrsdCd: data.jurisdiction,
|
||||
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,
|
||||
});
|
||||
return existingSn;
|
||||
@ -252,10 +268,12 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
|
||||
const result = await createReportApi({
|
||||
tmplSn,
|
||||
ctgrSn,
|
||||
acdntSn: extData.acdntSn,
|
||||
title: data.title || data.incident.name || '보고서',
|
||||
jrsdCd: data.jurisdiction,
|
||||
sttsCd,
|
||||
mapCaptureImg: data.capturedMapImage || undefined,
|
||||
step3MapImg: data.step3MapImage || undefined,
|
||||
step6MapImg: data.step6MapImage || undefined,
|
||||
sections,
|
||||
});
|
||||
return result.sn;
|
||||
@ -273,6 +291,7 @@ export function apiListItemToReportData(item: ApiReportListItem): OilSpillReport
|
||||
jurisdiction: (item.jrsdCd as Jurisdiction) || '남해청',
|
||||
status: CODE_TO_STATUS[item.sttsCd] || '테스트',
|
||||
hasMapCapture: item.hasMapCapture,
|
||||
acdntSn: item.acdntSn ?? undefined,
|
||||
// 목록에서는 섹션 데이터 없음 — 빈 기본값
|
||||
incident: { name: '', writeTime: '', shipName: '', agent: '', location: '', lat: '', lon: '', occurTime: '', accidentType: '', pollutant: '', spillAmount: '', depth: '', seabed: '' },
|
||||
tide: [], weather: [], spread: [],
|
||||
@ -341,6 +360,12 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa
|
||||
case 'result':
|
||||
reportData.result = d as OilSpillReportData['result'];
|
||||
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)}`;
|
||||
}
|
||||
|
||||
if (detail.mapCaptureImg) {
|
||||
reportData.capturedMapImage = detail.mapCaptureImg;
|
||||
if (detail.step3MapImg) {
|
||||
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;
|
||||
|
||||
@ -14,6 +14,7 @@ import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer'
|
||||
import { useWeatherData } from '../hooks/useWeatherData'
|
||||
// import { useOceanForecast } from '../hooks/useOceanForecast'
|
||||
import { WeatherMapControls } from './WeatherMapControls'
|
||||
import { degreesToCardinal } from '../services/weatherUtils'
|
||||
|
||||
type TimeOffset = '0' | '3' | '6' | '9'
|
||||
|
||||
@ -40,13 +41,6 @@ interface WeatherStation {
|
||||
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 {
|
||||
time: string
|
||||
hour: string
|
||||
|
||||
121
frontend/src/tabs/weather/services/weatherUtils.ts
Normal file
121
frontend/src/tabs/weather/services/weatherUtils.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user