feat: ���̾� ������ ���̺� ���� + ������ ���� + ���� API Ȯ�� + ������ �г� #114
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"applied_global_version": "1.6.1",
|
"applied_global_version": "1.6.1",
|
||||||
"applied_date": "2026-03-22",
|
"applied_date": "2026-03-24",
|
||||||
"project_type": "react-ts",
|
"project_type": "react-ts",
|
||||||
"gitea_url": "https://gitea.gc-si.dev",
|
"gitea_url": "https://gitea.gc-si.dev",
|
||||||
"custom_pre_commit": true
|
"custom_pre_commit": true
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt,
|
listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt,
|
||||||
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
|
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
|
||||||
getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn,
|
getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn,
|
||||||
|
getPredictionParticlesGeojsonByAcdntSn, getSensitivityEvaluationGeojsonByAcdntSn,
|
||||||
} from './predictionService.js';
|
} from './predictionService.js';
|
||||||
import { analyzeImageFile } from './imageAnalyzeService.js';
|
import { analyzeImageFile } from './imageAnalyzeService.js';
|
||||||
import { isValidNumber } from '../middleware/security.js';
|
import { isValidNumber } from '../middleware/security.js';
|
||||||
@ -97,6 +98,38 @@ router.get('/analyses/:acdntSn/sensitive-resources/geojson', requireAuth, requir
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/prediction/analyses/:acdntSn/spread-particles — 예측 확산 파티클 GeoJSON
|
||||||
|
router.get('/analyses/:acdntSn/spread-particles', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
||||||
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await getPredictionParticlesGeojsonByAcdntSn(acdntSn);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[prediction] 확산 파티클 GeoJSON 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '확산 파티클 GeoJSON 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/prediction/analyses/:acdntSn/sensitivity-evaluation — 통합민감도 평가 GeoJSON
|
||||||
|
router.get('/analyses/:acdntSn/sensitivity-evaluation', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
||||||
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await getSensitivityEvaluationGeojsonByAcdntSn(acdntSn);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[prediction] 통합민감도 평가 GeoJSON 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '통합민감도 평가 GeoJSON 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/prediction/backtrack — 사고별 역추적 목록
|
// GET /api/prediction/backtrack — 사고별 역추적 목록
|
||||||
router.get('/backtrack', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
router.get('/backtrack', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -432,6 +432,8 @@ interface TrajectoryTimeStep {
|
|||||||
particles: TrajectoryParticle[];
|
particles: TrajectoryParticle[];
|
||||||
remaining_volume_m3: number;
|
remaining_volume_m3: number;
|
||||||
weathered_volume_m3: number;
|
weathered_volume_m3: number;
|
||||||
|
evaporation_volume_m3?: number;
|
||||||
|
dispersion_volume_m3?: number;
|
||||||
pollution_area_km2: number;
|
pollution_area_km2: number;
|
||||||
beached_volume_m3: number;
|
beached_volume_m3: number;
|
||||||
pollution_coast_length_m: number;
|
pollution_coast_length_m: number;
|
||||||
@ -453,6 +455,8 @@ interface SingleModelTrajectoryResult {
|
|||||||
summary: {
|
summary: {
|
||||||
remainingVolume: number;
|
remainingVolume: number;
|
||||||
weatheredVolume: number;
|
weatheredVolume: number;
|
||||||
|
evaporationVolume: number;
|
||||||
|
dispersionVolume: number;
|
||||||
pollutionArea: number;
|
pollutionArea: number;
|
||||||
beachedVolume: number;
|
beachedVolume: number;
|
||||||
pollutionCoastLength: number;
|
pollutionCoastLength: number;
|
||||||
@ -460,6 +464,8 @@ interface SingleModelTrajectoryResult {
|
|||||||
stepSummaries: Array<{
|
stepSummaries: Array<{
|
||||||
remainingVolume: number;
|
remainingVolume: number;
|
||||||
weatheredVolume: number;
|
weatheredVolume: number;
|
||||||
|
evaporationVolume: number;
|
||||||
|
dispersionVolume: number;
|
||||||
pollutionArea: number;
|
pollutionArea: number;
|
||||||
beachedVolume: number;
|
beachedVolume: number;
|
||||||
pollutionCoastLength: number;
|
pollutionCoastLength: number;
|
||||||
@ -474,6 +480,8 @@ interface TrajectoryResult {
|
|||||||
summary: {
|
summary: {
|
||||||
remainingVolume: number;
|
remainingVolume: number;
|
||||||
weatheredVolume: number;
|
weatheredVolume: number;
|
||||||
|
evaporationVolume: number;
|
||||||
|
dispersionVolume: number;
|
||||||
pollutionArea: number;
|
pollutionArea: number;
|
||||||
beachedVolume: number;
|
beachedVolume: number;
|
||||||
pollutionCoastLength: number;
|
pollutionCoastLength: number;
|
||||||
@ -500,6 +508,8 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: strin
|
|||||||
const summary = {
|
const summary = {
|
||||||
remainingVolume: lastStep.remaining_volume_m3,
|
remainingVolume: lastStep.remaining_volume_m3,
|
||||||
weatheredVolume: lastStep.weathered_volume_m3,
|
weatheredVolume: lastStep.weathered_volume_m3,
|
||||||
|
evaporationVolume: lastStep.evaporation_volume_m3 ?? lastStep.weathered_volume_m3 * 0.65,
|
||||||
|
dispersionVolume: lastStep.dispersion_volume_m3 ?? lastStep.weathered_volume_m3 * 0.35,
|
||||||
pollutionArea: lastStep.pollution_area_km2,
|
pollutionArea: lastStep.pollution_area_km2,
|
||||||
beachedVolume: lastStep.beached_volume_m3,
|
beachedVolume: lastStep.beached_volume_m3,
|
||||||
pollutionCoastLength: lastStep.pollution_coast_length_m,
|
pollutionCoastLength: lastStep.pollution_coast_length_m,
|
||||||
@ -514,6 +524,8 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: strin
|
|||||||
const stepSummaries = rawResult.map((step) => ({
|
const stepSummaries = rawResult.map((step) => ({
|
||||||
remainingVolume: step.remaining_volume_m3,
|
remainingVolume: step.remaining_volume_m3,
|
||||||
weatheredVolume: step.weathered_volume_m3,
|
weatheredVolume: step.weathered_volume_m3,
|
||||||
|
evaporationVolume: step.evaporation_volume_m3 ?? step.weathered_volume_m3 * 0.65,
|
||||||
|
dispersionVolume: step.dispersion_volume_m3 ?? step.weathered_volume_m3 * 0.35,
|
||||||
pollutionArea: step.pollution_area_km2,
|
pollutionArea: step.pollution_area_km2,
|
||||||
beachedVolume: step.beached_volume_m3,
|
beachedVolume: step.beached_volume_m3,
|
||||||
pollutionCoastLength: step.pollution_coast_length_m,
|
pollutionCoastLength: step.pollution_coast_length_m,
|
||||||
@ -587,7 +599,7 @@ export async function getAnalysisTrajectory(acdntSn: number): Promise<Trajectory
|
|||||||
|
|
||||||
export async function getSensitiveResourcesByAcdntSn(
|
export async function getSensitiveResourcesByAcdntSn(
|
||||||
acdntSn: number,
|
acdntSn: number,
|
||||||
): Promise<{ category: string; count: number }[]> {
|
): Promise<{ category: string; count: number; totalArea: number | null }[]> {
|
||||||
const sql = `
|
const sql = `
|
||||||
WITH all_wkts AS (
|
WITH all_wkts AS (
|
||||||
SELECT step_data ->> 'wkt' AS wkt
|
SELECT step_data ->> 'wkt' AS wkt
|
||||||
@ -603,7 +615,13 @@ export async function getSensitiveResourcesByAcdntSn(
|
|||||||
FROM all_wkts
|
FROM all_wkts
|
||||||
WHERE wkt IS NOT NULL AND wkt <> ''
|
WHERE wkt IS NOT NULL AND wkt <> ''
|
||||||
)
|
)
|
||||||
SELECT sr.CATEGORY, COUNT(*)::int AS count
|
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
|
FROM wing.SENSITIVE_RESOURCE sr, union_geom
|
||||||
WHERE union_geom.geom IS NOT NULL
|
WHERE union_geom.geom IS NOT NULL
|
||||||
AND ST_Intersects(sr.GEOM, union_geom.geom)
|
AND ST_Intersects(sr.GEOM, union_geom.geom)
|
||||||
@ -614,6 +632,7 @@ export async function getSensitiveResourcesByAcdntSn(
|
|||||||
return rows.map((r: Record<string, unknown>) => ({
|
return rows.map((r: Record<string, unknown>) => ({
|
||||||
category: String(r['category'] ?? ''),
|
category: String(r['category'] ?? ''),
|
||||||
count: Number(r['count'] ?? 0),
|
count: Number(r['count'] ?? 0),
|
||||||
|
totalArea: r['total_area'] != null ? Number(r['total_area']) : null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -655,6 +674,83 @@ export async function getSensitiveResourcesGeoJsonByAcdntSn(
|
|||||||
return { type: 'FeatureCollection', features };
|
return { type: 'FeatureCollection', features };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSensitivityEvaluationGeojsonByAcdntSn(
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<{ type: 'FeatureCollection'; features: unknown[] }> {
|
||||||
|
const acdntSql = `SELECT LAT, LNG FROM wing.ACDNT WHERE ACDNT_SN = $1 AND USE_YN = 'Y'`;
|
||||||
|
const { rows: acdntRows } = await wingPool.query(acdntSql, [acdntSn]);
|
||||||
|
if (acdntRows.length === 0 || acdntRows[0]['lat'] == null) return { type: 'FeatureCollection', features: [] };
|
||||||
|
const lat = Number(acdntRows[0]['lat']);
|
||||||
|
const lng = Number(acdntRows[0]['lng']);
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT SR_ID, PROPERTIES,
|
||||||
|
ST_AsGeoJSON(GEOM)::jsonb AS geom_json,
|
||||||
|
ST_Area(GEOM::geography) / 1000000.0 AS area_km2
|
||||||
|
FROM wing.SENSITIVE_EVALUATION
|
||||||
|
WHERE ST_DWithin(
|
||||||
|
GEOM::geography,
|
||||||
|
ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography,
|
||||||
|
10000
|
||||||
|
)
|
||||||
|
ORDER BY SR_ID
|
||||||
|
`;
|
||||||
|
const { rows } = await wingPool.query(sql, [lat, lng]);
|
||||||
|
const features = rows.map((r: Record<string, unknown>) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: r['geom_json'],
|
||||||
|
properties: {
|
||||||
|
srId: Number(r['sr_id']),
|
||||||
|
area_km2: Number(r['area_km2']),
|
||||||
|
...(r['properties'] as Record<string, unknown> ?? {}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return { type: 'FeatureCollection', features };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPredictionParticlesGeojsonByAcdntSn(
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<{ type: 'FeatureCollection'; features: unknown[]; maxStep: number }> {
|
||||||
|
const sql = `
|
||||||
|
SELECT ALGO_CD, RSLT_DATA
|
||||||
|
FROM wing.PRED_EXEC
|
||||||
|
WHERE ACDNT_SN = $1
|
||||||
|
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
|
||||||
|
AND EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
AND RSLT_DATA IS NOT NULL
|
||||||
|
`;
|
||||||
|
const { rows } = await wingPool.query(sql, [acdntSn]);
|
||||||
|
if (rows.length === 0) return { type: 'FeatureCollection', features: [], maxStep: 0 };
|
||||||
|
|
||||||
|
const ALGO_TO_MODEL: Record<string, string> = { OPENDRIFT: 'OpenDrift', POSEIDON: 'POSEIDON' };
|
||||||
|
const features: unknown[] = [];
|
||||||
|
let globalMaxStep = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const model = ALGO_TO_MODEL[String(row['algo_cd'])] ?? String(row['algo_cd']);
|
||||||
|
const steps = row['rslt_data'] as TrajectoryTimeStep[];
|
||||||
|
const maxStep = steps.length - 1;
|
||||||
|
if (maxStep > globalMaxStep) globalMaxStep = maxStep;
|
||||||
|
|
||||||
|
steps.forEach((step, stepIdx) => {
|
||||||
|
step.particles.forEach(p => {
|
||||||
|
features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
|
||||||
|
properties: {
|
||||||
|
model,
|
||||||
|
time: stepIdx,
|
||||||
|
stranded: p.stranded ?? 0,
|
||||||
|
isLastStep: stepIdx === maxStep,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'FeatureCollection', features, maxStep: globalMaxStep };
|
||||||
|
}
|
||||||
|
|
||||||
export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
|
export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD,
|
SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD,
|
||||||
|
|||||||
@ -60,6 +60,7 @@ interface ReportListItem {
|
|||||||
sttsCd: string;
|
sttsCd: string;
|
||||||
authorId: string;
|
authorId: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
|
acdntSn: number | null;
|
||||||
regDtm: string;
|
regDtm: string;
|
||||||
mdfcnDtm: string | null;
|
mdfcnDtm: string | null;
|
||||||
hasMapCapture: boolean;
|
hasMapCapture: boolean;
|
||||||
@ -263,7 +264,7 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
|
|||||||
c.CTGR_CD, c.CTGR_NM,
|
c.CTGR_CD, c.CTGR_NM,
|
||||||
r.TITLE, r.JRSD_CD, r.STTS_CD,
|
r.TITLE, r.JRSD_CD, r.STTS_CD,
|
||||||
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
||||||
r.REG_DTM, r.MDFCN_DTM,
|
r.ACDNT_SN, r.REG_DTM, r.MDFCN_DTM,
|
||||||
CASE WHEN (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '')
|
CASE WHEN (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '')
|
||||||
OR (r.STEP6_MAP_IMG IS NOT NULL AND r.STEP6_MAP_IMG <> '')
|
OR (r.STEP6_MAP_IMG IS NOT NULL AND r.STEP6_MAP_IMG <> '')
|
||||||
THEN true ELSE false END AS HAS_MAP_CAPTURE
|
THEN true ELSE false END AS HAS_MAP_CAPTURE
|
||||||
@ -289,6 +290,7 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
|
|||||||
sttsCd: r.stts_cd,
|
sttsCd: r.stts_cd,
|
||||||
authorId: r.author_id,
|
authorId: r.author_id,
|
||||||
authorName: r.author_name || '',
|
authorName: r.author_name || '',
|
||||||
|
acdntSn: r.acdnt_sn,
|
||||||
regDtm: r.reg_dtm,
|
regDtm: r.reg_dtm,
|
||||||
mdfcnDtm: r.mdfcn_dtm,
|
mdfcnDtm: r.mdfcn_dtm,
|
||||||
hasMapCapture: r.has_map_capture,
|
hasMapCapture: r.has_map_capture,
|
||||||
|
|||||||
@ -32,6 +32,19 @@ const LAYER_COLUMNS = `
|
|||||||
DATA_TBL_NM AS data_tbl_nm
|
DATA_TBL_NM AS data_tbl_nm
|
||||||
`.trim()
|
`.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()
|
||||||
|
|
||||||
// 모든 라우트에 파라미터 살균 적용
|
// 모든 라우트에 파라미터 살균 적용
|
||||||
router.use(sanitizeParams)
|
router.use(sanitizeParams)
|
||||||
|
|
||||||
@ -39,7 +52,10 @@ router.use(sanitizeParams)
|
|||||||
router.get('/', async (_req, res) => {
|
router.get('/', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`
|
`${ACTIVE_TREE_CTE}
|
||||||
|
SELECT ${LAYER_COLUMNS} FROM LAYER
|
||||||
|
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree)
|
||||||
|
ORDER BY LAYER_CD`
|
||||||
)
|
)
|
||||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||||
res.json(enrichedLayers)
|
res.json(enrichedLayers)
|
||||||
@ -52,7 +68,10 @@ router.get('/', async (_req, res) => {
|
|||||||
router.get('/tree/all', async (_req, res) => {
|
router.get('/tree/all', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`
|
`${ACTIVE_TREE_CTE}
|
||||||
|
SELECT ${LAYER_COLUMNS} FROM LAYER
|
||||||
|
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree)
|
||||||
|
ORDER BY LAYER_CD`
|
||||||
)
|
)
|
||||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||||
|
|
||||||
@ -84,7 +103,10 @@ router.get('/tree/all', async (_req, res) => {
|
|||||||
router.get('/wms/all', async (_req, res) => {
|
router.get('/wms/all', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE WMS_LAYER_NM IS NOT NULL AND USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`
|
`${ACTIVE_TREE_CTE}
|
||||||
|
SELECT ${LAYER_COLUMNS} FROM LAYER
|
||||||
|
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree) AND WMS_LAYER_NM IS NOT NULL
|
||||||
|
ORDER BY LAYER_CD`
|
||||||
)
|
)
|
||||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||||
res.json(enrichedLayers)
|
res.json(enrichedLayers)
|
||||||
@ -106,7 +128,10 @@ router.get('/level/:level', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_LEVEL = $1 AND USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`,
|
`${ACTIVE_TREE_CTE}
|
||||||
|
SELECT ${LAYER_COLUMNS} FROM LAYER
|
||||||
|
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree) AND LAYER_LEVEL = $1
|
||||||
|
ORDER BY LAYER_CD`,
|
||||||
[level]
|
[level]
|
||||||
)
|
)
|
||||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||||
@ -212,20 +237,27 @@ router.get('/admin/list', requireAuth, requireRole('ADMIN'), async (req, res) =>
|
|||||||
const [dataResult, countResult] = await Promise.all([
|
const [dataResult, countResult] = await Promise.all([
|
||||||
wingPool.query(
|
wingPool.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
LAYER_CD AS "layerCd",
|
t.*,
|
||||||
UP_LAYER_CD AS "upLayerCd",
|
p.USE_YN AS "parentUseYn"
|
||||||
LAYER_FULL_NM AS "layerFullNm",
|
FROM (
|
||||||
LAYER_NM AS "layerNm",
|
SELECT
|
||||||
LAYER_LEVEL AS "layerLevel",
|
LAYER_CD AS "layerCd",
|
||||||
WMS_LAYER_NM AS "wmsLayerNm",
|
UP_LAYER_CD AS "upLayerCd",
|
||||||
DATA_TBL_NM AS "dataTblNm",
|
LAYER_FULL_NM AS "layerFullNm",
|
||||||
USE_YN AS "useYn",
|
LAYER_NM AS "layerNm",
|
||||||
SORT_ORD AS "sortOrd",
|
LAYER_LEVEL AS "layerLevel",
|
||||||
TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm"
|
WMS_LAYER_NM AS "wmsLayerNm",
|
||||||
FROM LAYER
|
DATA_TBL_NM AS "dataTblNm",
|
||||||
${whereClause}
|
USE_YN AS "useYn",
|
||||||
ORDER BY LAYER_CD
|
SORT_ORD AS "sortOrd",
|
||||||
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
|
TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm"
|
||||||
|
FROM LAYER
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY LAYER_CD
|
||||||
|
LIMIT $${limitIdx} OFFSET $${offsetIdx}
|
||||||
|
) t
|
||||||
|
LEFT JOIN LAYER p ON t."upLayerCd" = p.LAYER_CD AND p.DEL_YN = 'N'
|
||||||
|
ORDER BY t."layerCd"`,
|
||||||
dataParams
|
dataParams
|
||||||
),
|
),
|
||||||
wingPool.query(
|
wingPool.query(
|
||||||
@ -454,6 +486,18 @@ router.post('/admin/delete', requireAuth, requireRole('ADMIN'), async (req, res)
|
|||||||
|
|
||||||
const sanitizedCd = sanitizeString(layerCd)
|
const sanitizedCd = sanitizeString(layerCd)
|
||||||
|
|
||||||
|
// 하위 레이어 존재 여부 확인 (자식이 있으면 삭제 차단)
|
||||||
|
const { rows: childRows } = await wingPool.query(
|
||||||
|
`SELECT COUNT(*)::int AS cnt FROM LAYER WHERE UP_LAYER_CD = $1 AND DEL_YN = 'N'`,
|
||||||
|
[sanitizedCd]
|
||||||
|
)
|
||||||
|
const childCount: number = childRows[0].cnt
|
||||||
|
if (childCount > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `하위 레이어 ${childCount}개가 있어 삭제할 수 없습니다. 하위 레이어를 먼저 삭제해주세요.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const { rows } = await wingPool.query(
|
const { rows } = await wingPool.query(
|
||||||
`UPDATE LAYER SET DEL_YN = 'Y' WHERE LAYER_CD = $1 AND DEL_YN = 'N'
|
`UPDATE LAYER SET DEL_YN = 'Y' WHERE LAYER_CD = $1 AND DEL_YN = 'N'
|
||||||
RETURNING LAYER_CD AS "layerCd"`,
|
RETURNING LAYER_CD AS "layerCd"`,
|
||||||
|
|||||||
@ -849,6 +849,8 @@ interface PythonTimeStep {
|
|||||||
particles: PythonParticle[]
|
particles: PythonParticle[]
|
||||||
remaining_volume_m3: number
|
remaining_volume_m3: number
|
||||||
weathered_volume_m3: number
|
weathered_volume_m3: number
|
||||||
|
evaporation_m3?: number
|
||||||
|
dispersion_m3?: number
|
||||||
pollution_area_km2: number
|
pollution_area_km2: number
|
||||||
beached_volume_m3: number
|
beached_volume_m3: number
|
||||||
pollution_coast_length_m: number
|
pollution_coast_length_m: number
|
||||||
@ -885,6 +887,8 @@ function transformResult(rawResult: PythonTimeStep[], model: string) {
|
|||||||
const summary = {
|
const summary = {
|
||||||
remainingVolume: lastStep.remaining_volume_m3,
|
remainingVolume: lastStep.remaining_volume_m3,
|
||||||
weatheredVolume: lastStep.weathered_volume_m3,
|
weatheredVolume: lastStep.weathered_volume_m3,
|
||||||
|
evaporationVolume: lastStep.evaporation_m3 ?? lastStep.weathered_volume_m3 * 0.65,
|
||||||
|
dispersionVolume: lastStep.dispersion_m3 ?? lastStep.weathered_volume_m3 * 0.35,
|
||||||
pollutionArea: lastStep.pollution_area_km2,
|
pollutionArea: lastStep.pollution_area_km2,
|
||||||
beachedVolume: lastStep.beached_volume_m3,
|
beachedVolume: lastStep.beached_volume_m3,
|
||||||
pollutionCoastLength: lastStep.pollution_coast_length_m,
|
pollutionCoastLength: lastStep.pollution_coast_length_m,
|
||||||
@ -905,6 +909,8 @@ function transformResult(rawResult: PythonTimeStep[], model: string) {
|
|||||||
const stepSummaries = rawResult.map((step) => ({
|
const stepSummaries = rawResult.map((step) => ({
|
||||||
remainingVolume: step.remaining_volume_m3,
|
remainingVolume: step.remaining_volume_m3,
|
||||||
weatheredVolume: step.weathered_volume_m3,
|
weatheredVolume: step.weathered_volume_m3,
|
||||||
|
evaporationVolume: step.evaporation_m3 ?? step.weathered_volume_m3 * 0.65,
|
||||||
|
dispersionVolume: step.dispersion_m3 ?? step.weathered_volume_m3 * 0.35,
|
||||||
pollutionArea: step.pollution_area_km2,
|
pollutionArea: step.pollution_area_km2,
|
||||||
beachedVolume: step.beached_volume_m3,
|
beachedVolume: step.beached_volume_m3,
|
||||||
pollutionCoastLength: step.pollution_coast_length_m,
|
pollutionCoastLength: step.pollution_coast_length_m,
|
||||||
|
|||||||
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 }';
|
||||||
@ -9,9 +9,13 @@
|
|||||||
- 확산예측: 예측 실행 시 기상정보(풍속·풍향·기압·파고·수온·기온·염분 등) ACDNT_WEATHER 테이블에 자동 저장
|
- 확산예측: 예측 실행 시 기상정보(풍속·풍향·기압·파고·수온·기온·염분 등) ACDNT_WEATHER 테이블에 자동 저장
|
||||||
- DB: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 19개 추가 (025 마이그레이션)
|
- DB: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 19개 추가 (025 마이그레이션)
|
||||||
- DB: 민감자원 데이터 마이그레이션 (026_sensitive_resources)
|
- DB: 민감자원 데이터 마이그레이션 (026_sensitive_resources)
|
||||||
|
- 보고서: 유류유출 보고서 템플릿 전면 개선 (OilSpillReportTemplate)
|
||||||
|
- 관리자: 실시간 기상·해상 모니터링 패널 추가 (MonitorRealtimePanel)
|
||||||
|
- DB: 민감자원 평가 마이그레이션 추가 (027_sensitivity_evaluation)
|
||||||
|
|
||||||
### 변경
|
### 변경
|
||||||
- 예측: 분석 API를 예측 서비스로 통합 (analysisRouter 제거)
|
- 예측: 분석 API를 예측 서비스로 통합 (analysisRouter 제거)
|
||||||
|
- 예측: 예측 API 확장 (predictionRouter/Service, LeftPanel/RightPanel 연동)
|
||||||
|
|
||||||
## [2026-03-20.3]
|
## [2026-03-20.3]
|
||||||
|
|
||||||
|
|||||||
@ -13,11 +13,11 @@ export function useLayers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 계층 구조 레이어 트리 조회 훅
|
// 계층 구조 레이어 트리 조회 훅
|
||||||
|
// staleTime 없음 → 마운트 시 항상 최신 데이터 요청 (관리자 설정 즉시 반영)
|
||||||
export function useLayerTree() {
|
export function useLayerTree() {
|
||||||
return useQuery<Layer[], Error>({
|
return useQuery<Layer[], Error>({
|
||||||
queryKey: ['layers', 'tree'],
|
queryKey: ['layers', 'tree'],
|
||||||
queryFn: fetchLayerTree,
|
queryFn: fetchLayerTree,
|
||||||
staleTime: 1000 * 60 * 5,
|
|
||||||
retry: 3,
|
retry: 3,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -235,6 +235,12 @@ export interface OilReportPayload {
|
|||||||
centerPoints: { lat: number; lon: number; time: number }[];
|
centerPoints: { lat: number; lon: number; time: number }[];
|
||||||
simulationStartTime: string;
|
simulationStartTime: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
sensitiveResources?: Array<{
|
||||||
|
category: string;
|
||||||
|
count: number;
|
||||||
|
totalArea: number | null;
|
||||||
|
}>;
|
||||||
|
acdntSn?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _oilReportPayload: OilReportPayload | null = null;
|
let _oilReportPayload: OilReportPayload | null = null;
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import MapBasePanel from './MapBasePanel';
|
|||||||
import LayerPanel from './LayerPanel';
|
import LayerPanel from './LayerPanel';
|
||||||
import SensitiveLayerPanel from './SensitiveLayerPanel';
|
import SensitiveLayerPanel from './SensitiveLayerPanel';
|
||||||
import DispersingZonePanel from './DispersingZonePanel';
|
import DispersingZonePanel from './DispersingZonePanel';
|
||||||
|
import MonitorRealtimePanel from './MonitorRealtimePanel';
|
||||||
|
|
||||||
/** 기존 패널이 있는 메뉴 ID 매핑 */
|
/** 기존 패널이 있는 메뉴 ID 매핑 */
|
||||||
const PANEL_MAP: Record<string, () => JSX.Element> = {
|
const PANEL_MAP: Record<string, () => JSX.Element> = {
|
||||||
@ -32,6 +33,7 @@ const PANEL_MAP: Record<string, () => JSX.Element> = {
|
|||||||
'env-ecology': () => <SensitiveLayerPanel categoryCode="LYR001002001" title="환경/생태" />,
|
'env-ecology': () => <SensitiveLayerPanel categoryCode="LYR001002001" title="환경/생태" />,
|
||||||
'social-economy': () => <SensitiveLayerPanel categoryCode="LYR001002002" title="사회/경제" />,
|
'social-economy': () => <SensitiveLayerPanel categoryCode="LYR001002002" title="사회/경제" />,
|
||||||
'dispersant-zone': () => <DispersingZonePanel />,
|
'dispersant-zone': () => <DispersingZonePanel />,
|
||||||
|
'monitor-realtime': () => <MonitorRealtimePanel />,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AdminView() {
|
export function AdminView() {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
interface LayerAdminItem {
|
interface LayerAdminItem {
|
||||||
@ -11,6 +12,7 @@ interface LayerAdminItem {
|
|||||||
useYn: string;
|
useYn: string;
|
||||||
sortOrd: number;
|
sortOrd: number;
|
||||||
regDtm: string | null;
|
regDtm: string | null;
|
||||||
|
parentUseYn: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LayerListResponse {
|
interface LayerListResponse {
|
||||||
@ -313,6 +315,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
|||||||
// ---------- LayerPanel ----------
|
// ---------- LayerPanel ----------
|
||||||
|
|
||||||
const LayerPanel = () => {
|
const LayerPanel = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [items, setItems] = useState<LayerAdminItem[]>([]);
|
const [items, setItems] = useState<LayerAdminItem[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
@ -359,10 +362,15 @@ const LayerPanel = () => {
|
|||||||
try {
|
try {
|
||||||
const result = await toggleLayerUse(layerCd);
|
const result = await toggleLayerUse(layerCd);
|
||||||
setItems(prev =>
|
setItems(prev =>
|
||||||
prev.map(item =>
|
prev.map(item => {
|
||||||
item.layerCd === result.layerCd ? { ...item, useYn: result.useYn } : item
|
if (item.layerCd === result.layerCd) return { ...item, useYn: result.useYn };
|
||||||
)
|
// 직접 자식의 parentUseYn도 즉시 동기화
|
||||||
|
if (item.upLayerCd === result.layerCd) return { ...item, parentUseYn: result.useYn };
|
||||||
|
return item;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
// 레이어 캐시 무효화 → 예측 탭 등 useLayerTree 구독자가 최신 데이터 수신
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['layers'] });
|
||||||
} catch {
|
} catch {
|
||||||
setError('사용여부 변경에 실패했습니다.');
|
setError('사용여부 변경에 실패했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
@ -522,12 +530,20 @@ const LayerPanel = () => {
|
|||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggle(item.layerCd)}
|
onClick={() => handleToggle(item.layerCd)}
|
||||||
disabled={toggling === item.layerCd}
|
disabled={toggling === item.layerCd || item.parentUseYn === 'N'}
|
||||||
title={item.useYn === 'Y' ? '사용 중 (클릭하여 비활성화)' : '미사용 (클릭하여 활성화)'}
|
title={
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-50 ${
|
item.parentUseYn === 'N'
|
||||||
item.useYn === 'Y'
|
? '상위 레이어가 비활성화되어 있어 적용되지 않습니다'
|
||||||
|
: item.useYn === 'Y'
|
||||||
|
? '사용 중 (클릭하여 비활성화)'
|
||||||
|
: '미사용 (클릭하여 활성화)'
|
||||||
|
}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-40 ${
|
||||||
|
item.useYn === 'Y' && item.parentUseYn !== 'N'
|
||||||
? 'bg-primary-cyan'
|
? 'bg-primary-cyan'
|
||||||
: 'bg-[rgba(255,255,255,0.08)] border border-border'
|
: item.useYn === 'Y' && item.parentUseYn === 'N'
|
||||||
|
? 'bg-primary-cyan/40'
|
||||||
|
: 'bg-[rgba(255,255,255,0.08)] border border-border'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,8 +1,6 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState } from 'react'
|
||||||
import { LayerTree } from '@common/components/layer/LayerTree'
|
import { LayerTree } from '@common/components/layer/LayerTree'
|
||||||
import { useLayerTree } from '@common/hooks/useLayers'
|
import { useLayerTree } from '@common/hooks/useLayers'
|
||||||
import { layerData } from '@common/data/layerData'
|
|
||||||
import type { LayerNode } from '@common/data/layerData'
|
|
||||||
import type { Layer } from '@common/services/layerService'
|
import type { Layer } from '@common/services/layerService'
|
||||||
|
|
||||||
interface InfoLayerSectionProps {
|
interface InfoLayerSectionProps {
|
||||||
@ -26,29 +24,13 @@ const InfoLayerSection = ({
|
|||||||
layerBrightness,
|
layerBrightness,
|
||||||
onLayerBrightnessChange,
|
onLayerBrightnessChange,
|
||||||
}: InfoLayerSectionProps) => {
|
}: InfoLayerSectionProps) => {
|
||||||
// API에서 레이어 트리 데이터 가져오기
|
// API에서 레이어 트리 데이터 가져오기 (관리자 설정 USE_YN='Y' 레이어만 반환)
|
||||||
const { data: layerTree, isLoading } = useLayerTree()
|
const { data: layerTree, isLoading } = useLayerTree()
|
||||||
|
|
||||||
const [layerColors, setLayerColors] = useState<Record<string, string>>({})
|
const [layerColors, setLayerColors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
// 정적 데이터를 Layer 형식으로 변환 (API 실패 시 폴백)
|
// 관리자에서 사용여부가 ON인 레이어만 표시 (정적 폴백 없음)
|
||||||
const staticLayers = useMemo(() => {
|
const effectiveLayers: Layer[] = layerTree ?? []
|
||||||
const convert = (node: LayerNode): Layer => ({
|
|
||||||
id: node.code,
|
|
||||||
parentId: node.parentCode,
|
|
||||||
name: node.name,
|
|
||||||
fullName: node.fullName,
|
|
||||||
level: node.level,
|
|
||||||
wmsLayer: node.layerName,
|
|
||||||
icon: node.icon,
|
|
||||||
count: node.count,
|
|
||||||
children: node.children?.map(convert),
|
|
||||||
})
|
|
||||||
return layerData.map(convert)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// API 데이터 우선, 실패 시 정적 데이터 폴백
|
|
||||||
const effectiveLayers = (layerTree && layerTree.length > 0) ? layerTree : staticLayers
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border">
|
<div className="border-b border-border">
|
||||||
|
|||||||
@ -1,5 +1,63 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { LeftPanelProps, ExpandedSections } from './leftPanelTypes'
|
import type { LeftPanelProps, ExpandedSections } from './leftPanelTypes'
|
||||||
|
|
||||||
|
interface CategoryMeta {
|
||||||
|
icon: string;
|
||||||
|
bg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_ICON_MAP: Record<string, CategoryMeta> = {
|
||||||
|
// 수산자원 / 양식장 (green)
|
||||||
|
'어장정보': { icon: '🐟', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'양식장': { icon: '🦪', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'양식어업': { icon: '🦪', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'어류양식장': { icon: '🐟', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'패류양식장': { icon: '🦪', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'해조류양식장': { icon: '🌿', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'가두리양식장': { icon: '🔲', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'갑각류양식장': { icon: '🦐', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'기타양식장': { icon: '📦', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'영세어업': { icon: '🎣', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'유어장': { icon: '🎣', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'수산시장': { icon: '🐟', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'인공어초': { icon: '🪸', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'암초': { icon: '🪨', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'침선': { icon: '🚢', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
// 관광자원 / 낚시 (yellow)
|
||||||
|
'해수욕장': { icon: '🏖', bg: 'rgba(250,204,21,0.15)' },
|
||||||
|
'갯바위낚시': { icon: '🪨', bg: 'rgba(250,204,21,0.15)' },
|
||||||
|
'선상낚시': { icon: '🚤', bg: 'rgba(250,204,21,0.15)' },
|
||||||
|
'마리나항': { icon: '⛵', bg: 'rgba(250,204,21,0.15)' },
|
||||||
|
// 항만 / 산업시설 (blue)
|
||||||
|
'무역항': { icon: '🚢', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'연안항': { icon: '⛵', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'국가어항': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'지방어항': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'어항': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'항만구역': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'항로': { icon: '🚢', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'정박지': { icon: '⛵', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'항로표지': { icon: '🔴', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'해수취수시설': { icon: '💧', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'취수구·배수구': { icon: '🚰', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'LNG': { icon: '⚡', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'발전소': { icon: '🔌', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'발전소·산단': { icon: '🏭', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'임해공단': { icon: '🏭', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'저유시설': { icon: '🛢', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'해저케이블·배관': { icon: '🔌', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
// 환경 / 생태 (lime)
|
||||||
|
'갯벌': { icon: '🪨', bg: 'rgba(163,230,53,0.12)' },
|
||||||
|
'해안선_ESI': { icon: '🏖', bg: 'rgba(163,230,53,0.12)' },
|
||||||
|
'보호지역': { icon: '🛡', bg: 'rgba(163,230,53,0.12)' },
|
||||||
|
'해양보호구역': { icon: '🌿', bg: 'rgba(163,230,53,0.12)' },
|
||||||
|
'철새도래지': { icon: '🐦', bg: 'rgba(163,230,53,0.12)' },
|
||||||
|
'습지보호구역': { icon: '🏖', bg: 'rgba(163,230,53,0.12)' },
|
||||||
|
'보호종서식지': { icon: '🐢', bg: 'rgba(163,230,53,0.12)' },
|
||||||
|
'보호종 서식지': { icon: '🐢', bg: 'rgba(163,230,53,0.12)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_META: CategoryMeta = { icon: '🌊', bg: 'rgba(148,163,184,0.15)' };
|
||||||
import PredictionInputSection from './PredictionInputSection'
|
import PredictionInputSection from './PredictionInputSection'
|
||||||
import InfoLayerSection from './InfoLayerSection'
|
import InfoLayerSection from './InfoLayerSection'
|
||||||
import OilBoomSection from './OilBoomSection'
|
import OilBoomSection from './OilBoomSection'
|
||||||
@ -209,12 +267,27 @@ export function LeftPanel({
|
|||||||
<p className="text-[11px] text-text-3 font-korean">영향받는 민감자원 목록</p>
|
<p className="text-[11px] text-text-3 font-korean">영향받는 민감자원 목록</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{sensitiveResources.map(({ category, count }) => (
|
{sensitiveResources.map(({ category, count, totalArea }) => {
|
||||||
<div key={category} className="flex items-center justify-between">
|
const meta = CATEGORY_ICON_MAP[category] ?? FALLBACK_META;
|
||||||
<span className="text-[11px] text-text-2 font-korean">{category}</span>
|
return (
|
||||||
<span className="text-[11px] text-primary font-bold font-mono">{count}개</span>
|
<div key={category} className="flex items-center justify-between">
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
))}
|
<span
|
||||||
|
className="inline-flex items-center justify-center w-5 h-5 rounded text-[11px] shrink-0"
|
||||||
|
style={{ background: meta.bg }}
|
||||||
|
>
|
||||||
|
{meta.icon}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-text-2 font-korean">{category}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-primary font-bold font-mono">
|
||||||
|
{totalArea != null
|
||||||
|
? `${totalArea.toLocaleString('ko-KR', { maximumFractionDigits: 2 })} ha`
|
||||||
|
: `${count}개`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -200,6 +200,13 @@ export function OilSpillView() {
|
|||||||
const [summaryByModel, setSummaryByModel] = useState<Record<string, SimulationSummary>>({})
|
const [summaryByModel, setSummaryByModel] = useState<Record<string, SimulationSummary>>({})
|
||||||
const [stepSummariesByModel, setStepSummariesByModel] = useState<Record<string, SimulationSummary[]>>({})
|
const [stepSummariesByModel, setStepSummariesByModel] = useState<Record<string, SimulationSummary[]>>({})
|
||||||
|
|
||||||
|
// 펜스차단량 계산 (오일붐 차단 효율 × 총 유류량)
|
||||||
|
const boomBlockedVolume = useMemo(() => {
|
||||||
|
if (!containmentResult || !simulationSummary) return 0;
|
||||||
|
const totalVolumeM3 = simulationSummary.remainingVolume + simulationSummary.weatheredVolume + simulationSummary.beachedVolume;
|
||||||
|
return totalVolumeM3 * (containmentResult.overallEfficiency / 100);
|
||||||
|
}, [containmentResult, simulationSummary])
|
||||||
|
|
||||||
// 오염분석 상태
|
// 오염분석 상태
|
||||||
const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon')
|
const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon')
|
||||||
const [drawAnalysisMode, setDrawAnalysisMode] = useState<'polygon' | null>(null)
|
const [drawAnalysisMode, setDrawAnalysisMode] = useState<'polygon' | null>(null)
|
||||||
@ -938,6 +945,14 @@ export function OilSpillView() {
|
|||||||
return [toRow('3시간', steps[3]), toRow('6시간', steps[6])];
|
return [toRow('3시간', steps[3]), toRow('6시간', steps[6])];
|
||||||
})(),
|
})(),
|
||||||
hasSimulation: simulationSummary !== null,
|
hasSimulation: simulationSummary !== null,
|
||||||
|
sensitiveResources: sensitiveResourceCategories.length > 0
|
||||||
|
? sensitiveResourceCategories.map(r => ({
|
||||||
|
category: r.category,
|
||||||
|
count: r.count,
|
||||||
|
totalArea: r.totalArea,
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
acdntSn: selectedAnalysis?.acdntSn ?? undefined,
|
||||||
mapData: incidentCoord ? {
|
mapData: incidentCoord ? {
|
||||||
center: [incidentCoord.lat, incidentCoord.lon],
|
center: [incidentCoord.lat, incidentCoord.lon],
|
||||||
zoom: 10,
|
zoom: 10,
|
||||||
@ -1253,6 +1268,7 @@ export function OilSpillView() {
|
|||||||
onOpenReport={handleOpenReport}
|
onOpenReport={handleOpenReport}
|
||||||
detail={analysisDetail}
|
detail={analysisDetail}
|
||||||
summary={stepSummariesByModel[windHydrModel]?.[currentStep] ?? summaryByModel[windHydrModel] ?? simulationSummary}
|
summary={stepSummariesByModel[windHydrModel]?.[currentStep] ?? summaryByModel[windHydrModel] ?? simulationSummary}
|
||||||
|
boomBlockedVolume={boomBlockedVolume}
|
||||||
displayControls={displayControls}
|
displayControls={displayControls}
|
||||||
onDisplayControlsChange={setDisplayControls}
|
onDisplayControlsChange={setDisplayControls}
|
||||||
windHydrModel={windHydrModel}
|
windHydrModel={windHydrModel}
|
||||||
@ -1266,6 +1282,8 @@ export function OilSpillView() {
|
|||||||
onCircleRadiusChange={setCircleRadiusNm}
|
onCircleRadiusChange={setCircleRadiusNm}
|
||||||
analysisResult={analysisResult}
|
analysisResult={analysisResult}
|
||||||
incidentCoord={incidentCoord}
|
incidentCoord={incidentCoord}
|
||||||
|
centerPoints={centerPoints}
|
||||||
|
predictionTime={predictionTime}
|
||||||
onStartPolygonDraw={handleStartPolygonDraw}
|
onStartPolygonDraw={handleStartPolygonDraw}
|
||||||
onRunPolygonAnalysis={handleRunPolygonAnalysis}
|
onRunPolygonAnalysis={handleRunPolygonAnalysis}
|
||||||
onRunCircleAnalysis={handleRunCircleAnalysis}
|
onRunCircleAnalysis={handleRunCircleAnalysis}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import type { PredictionDetail, SimulationSummary } from '../services/predictionApi'
|
import type { PredictionDetail, SimulationSummary, CenterPoint } from '../services/predictionApi'
|
||||||
import type { DisplayControls } from './OilSpillView'
|
import type { DisplayControls } from './OilSpillView'
|
||||||
|
import { haversineDistance, computeBearing } from '@common/utils/geo'
|
||||||
|
|
||||||
interface AnalysisResult {
|
interface AnalysisResult {
|
||||||
area: number
|
area: number
|
||||||
@ -29,6 +30,9 @@ interface RightPanelProps {
|
|||||||
onCircleRadiusChange?: (nm: number) => void
|
onCircleRadiusChange?: (nm: number) => void
|
||||||
analysisResult?: AnalysisResult | null
|
analysisResult?: AnalysisResult | null
|
||||||
incidentCoord?: { lat: number; lon: number } | null
|
incidentCoord?: { lat: number; lon: number } | null
|
||||||
|
centerPoints?: CenterPoint[]
|
||||||
|
predictionTime?: number
|
||||||
|
boomBlockedVolume?: number
|
||||||
onStartPolygonDraw?: () => void
|
onStartPolygonDraw?: () => void
|
||||||
onRunPolygonAnalysis?: () => void
|
onRunPolygonAnalysis?: () => void
|
||||||
onRunCircleAnalysis?: () => void
|
onRunCircleAnalysis?: () => void
|
||||||
@ -44,6 +48,10 @@ export function RightPanel({
|
|||||||
drawAnalysisMode, analysisPolygonPoints = [],
|
drawAnalysisMode, analysisPolygonPoints = [],
|
||||||
circleRadiusNm = 5, onCircleRadiusChange,
|
circleRadiusNm = 5, onCircleRadiusChange,
|
||||||
analysisResult,
|
analysisResult,
|
||||||
|
incidentCoord,
|
||||||
|
centerPoints,
|
||||||
|
predictionTime,
|
||||||
|
boomBlockedVolume = 0,
|
||||||
onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis,
|
onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis,
|
||||||
onCancelAnalysis, onClearAnalysis,
|
onCancelAnalysis, onClearAnalysis,
|
||||||
}: RightPanelProps) {
|
}: RightPanelProps) {
|
||||||
@ -54,6 +62,38 @@ export function RightPanel({
|
|||||||
const [shipExpanded, setShipExpanded] = useState(false)
|
const [shipExpanded, setShipExpanded] = useState(false)
|
||||||
const [insuranceExpanded, setInsuranceExpanded] = useState(false)
|
const [insuranceExpanded, setInsuranceExpanded] = useState(false)
|
||||||
|
|
||||||
|
const weatheringStatus = useMemo(() => {
|
||||||
|
if (!summary) return null;
|
||||||
|
const total = summary.remainingVolume + summary.evaporationVolume
|
||||||
|
+ summary.dispersionVolume + summary.beachedVolume + boomBlockedVolume;
|
||||||
|
if (total <= 0) return null;
|
||||||
|
const pct = (v: number) => Math.round((v / total) * 100);
|
||||||
|
return {
|
||||||
|
surface: pct(summary.remainingVolume),
|
||||||
|
evaporation: pct(summary.evaporationVolume),
|
||||||
|
dispersion: pct(summary.dispersionVolume),
|
||||||
|
boom: pct(boomBlockedVolume),
|
||||||
|
beached: pct(summary.beachedVolume),
|
||||||
|
};
|
||||||
|
}, [summary, boomBlockedVolume])
|
||||||
|
|
||||||
|
const spreadSummary = useMemo(() => {
|
||||||
|
if (!incidentCoord || !centerPoints || centerPoints.length === 0) return null
|
||||||
|
const finalPoint = [...centerPoints].sort((a, b) => b.time - a.time)[0]
|
||||||
|
const distM = haversineDistance(incidentCoord, { lat: finalPoint.lat, lon: finalPoint.lon })
|
||||||
|
const distKm = distM / 1000
|
||||||
|
const bearing = computeBearing(incidentCoord, { lat: finalPoint.lat, lon: finalPoint.lon })
|
||||||
|
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
|
||||||
|
const dirLabel = directions[Math.round(bearing / 45) % 8]
|
||||||
|
const speedMs = predictionTime && predictionTime > 0 ? distM / (predictionTime * 3600) : null
|
||||||
|
return {
|
||||||
|
area: summary?.pollutionArea ?? null,
|
||||||
|
distance: distKm,
|
||||||
|
directionLabel: `${dirLabel} ${Math.round(bearing)}°`,
|
||||||
|
speed: speedMs,
|
||||||
|
}
|
||||||
|
}, [incidentCoord, centerPoints, summary, predictionTime])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[300px] min-w-[300px] bg-bg-1 border-l border-border flex flex-col">
|
<div className="w-[300px] min-w-[300px] bg-bg-1 border-l border-border flex flex-col">
|
||||||
{/* Tab Header */}
|
{/* Tab Header */}
|
||||||
@ -236,23 +276,29 @@ export function RightPanel({
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* 확산 예측 요약 */}
|
{/* 확산 예측 요약 */}
|
||||||
<Section title="확산 예측 요약 (+18h)" badge="위험" badgeColor="red">
|
<Section title={`확산 예측 요약 (+${predictionTime ?? 18}h)`} badge="위험" badgeColor="red">
|
||||||
<div className="grid grid-cols-2 gap-0.5 text-[9px]">
|
<div className="grid grid-cols-2 gap-0.5 text-[9px]">
|
||||||
<PredictionCard value="4.7 km²" label="영향 면적" color="var(--red)" />
|
<PredictionCard value={spreadSummary?.area != null ? `${spreadSummary.area.toFixed(1)} km²` : '—'} label="영향 면적" color="var(--red)" />
|
||||||
<PredictionCard value="6.2 km" label="확산 거리" color="var(--orange)" />
|
<PredictionCard value={spreadSummary?.distance != null ? `${spreadSummary.distance.toFixed(1)} km` : '—'} label="확산 거리" color="var(--orange)" />
|
||||||
<PredictionCard value="NE 42°" label="확산 방향" color="var(--cyan)" />
|
<PredictionCard value={spreadSummary?.directionLabel ?? '—'} label="확산 방향" color="var(--cyan)" />
|
||||||
<PredictionCard value="0.35 m/s" label="확산 속도" color="var(--t1)" />
|
<PredictionCard value={spreadSummary?.speed != null ? `${spreadSummary.speed.toFixed(2)} m/s` : '—'} label="확산 속도" color="var(--t1)" />
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* 유출유 풍화 상태 */}
|
{/* 유출유 풍화 상태 */}
|
||||||
<Section title="유출유 풍화 상태">
|
<Section title="유출유 풍화 상태">
|
||||||
<div className="flex flex-col gap-[3px] text-[8px]">
|
<div className="flex flex-col gap-[3px] text-[8px]">
|
||||||
<ProgressBar label="수면잔류" value={58} color="var(--blue)" />
|
{weatheringStatus ? (
|
||||||
<ProgressBar label="증발" value={22} color="var(--cyan)" />
|
<>
|
||||||
<ProgressBar label="분산" value={12} color="var(--green)" />
|
<ProgressBar label="수면잔류" value={weatheringStatus.surface} color="var(--blue)" />
|
||||||
<ProgressBar label="펜스차단" value={5} color="var(--boom)" />
|
<ProgressBar label="증발" value={weatheringStatus.evaporation} color="var(--cyan)" />
|
||||||
<ProgressBar label="해안도달" value={3} color="var(--red)" />
|
<ProgressBar label="분산" value={weatheringStatus.dispersion} color="var(--green)" />
|
||||||
|
<ProgressBar label="펜스차단" value={weatheringStatus.boom} color="var(--boom)" />
|
||||||
|
<ProgressBar label="해안도달" value={weatheringStatus.beached} color="var(--red)" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[9px] text-text-3 font-korean text-center py-2">시뮬레이션 실행 후 표시됩니다</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|||||||
@ -168,6 +168,8 @@ export interface OilParticle {
|
|||||||
export interface SimulationSummary {
|
export interface SimulationSummary {
|
||||||
remainingVolume: number;
|
remainingVolume: number;
|
||||||
weatheredVolume: number;
|
weatheredVolume: number;
|
||||||
|
evaporationVolume: number;
|
||||||
|
dispersionVolume: number;
|
||||||
pollutionArea: number;
|
pollutionArea: number;
|
||||||
beachedVolume: number;
|
beachedVolume: number;
|
||||||
pollutionCoastLength: number;
|
pollutionCoastLength: number;
|
||||||
@ -222,6 +224,7 @@ export const fetchAnalysisTrajectory = async (acdntSn: number): Promise<Trajecto
|
|||||||
export interface SensitiveResourceCategory {
|
export interface SensitiveResourceCategory {
|
||||||
category: string;
|
category: string;
|
||||||
count: number;
|
count: number;
|
||||||
|
totalArea: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchSensitiveResources = async (
|
export const fetchSensitiveResources = async (
|
||||||
@ -257,6 +260,34 @@ export const fetchSensitiveResourcesGeojson = async (
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface SpreadParticlesGeojson {
|
||||||
|
type: 'FeatureCollection';
|
||||||
|
features: Array<{
|
||||||
|
type: 'Feature';
|
||||||
|
geometry: { type: 'Point'; coordinates: [number, number] };
|
||||||
|
properties: { model: string; time: number; stranded: 0 | 1; isLastStep: boolean };
|
||||||
|
}>;
|
||||||
|
maxStep: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchPredictionParticlesGeojson = async (
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<SpreadParticlesGeojson> => {
|
||||||
|
const response = await api.get<SpreadParticlesGeojson>(
|
||||||
|
`/prediction/analyses/${acdntSn}/spread-particles`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchSensitivityEvaluationGeojson = async (
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<{ type: 'FeatureCollection'; features: unknown[] }> => {
|
||||||
|
const response = await api.get<{ type: 'FeatureCollection'; features: unknown[] }>(
|
||||||
|
`/prediction/analyses/${acdntSn}/sensitivity-evaluation`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 이미지 업로드 분석
|
// 이미지 업로드 분석
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import maplibregl from 'maplibre-gl'
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import { saveReport } from '../services/reportsApi'
|
import { saveReport } from '../services/reportsApi'
|
||||||
|
import { fetchSensitiveResourcesGeojson, fetchPredictionParticlesGeojson, fetchSensitivityEvaluationGeojson } from '@tabs/prediction/services/predictionApi'
|
||||||
|
|
||||||
// ─── Data Types ─────────────────────────────────────────────
|
// ─── Data Types ─────────────────────────────────────────────
|
||||||
export type ReportType = '초기보고서' | '지휘부 보고' | '예측보고서' | '종합보고서' | '유출유 보고'
|
export type ReportType = '초기보고서' | '지휘부 보고' | '예측보고서' | '종합보고서' | '유출유 보고'
|
||||||
@ -42,6 +45,9 @@ export interface OilSpillReportData {
|
|||||||
step3MapImage?: string;
|
step3MapImage?: string;
|
||||||
step6MapImage?: string;
|
step6MapImage?: string;
|
||||||
hasMapCapture?: boolean;
|
hasMapCapture?: boolean;
|
||||||
|
acdntSn?: number;
|
||||||
|
sensitiveMapImage?: string;
|
||||||
|
sensitivityMapImage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
@ -320,6 +326,396 @@ function Page3({ data, editing, onChange }: { data: OilSpillReportData; editing:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSeasonKey = (occurTime: string): 'SP_G' | 'SM_G' | 'FA_G' | 'WT_G' => {
|
||||||
|
const m = occurTime.match(/\d{4}[.\-\s]+(\d{1,2})[.\-\s]/)
|
||||||
|
const month = m ? parseInt(m[1]) : 0
|
||||||
|
if (month >= 3 && month <= 5) return 'SP_G'
|
||||||
|
if (month >= 6 && month <= 8) return 'SM_G'
|
||||||
|
if (month >= 9 && month <= 11) return 'FA_G'
|
||||||
|
return 'WT_G'
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseCoord = (s: string): number | null => {
|
||||||
|
const d = parseFloat(s)
|
||||||
|
if (!isNaN(d)) return d
|
||||||
|
const m = s.match(/(\d+)[°]\s*(\d+)[′']\s*([\d.]+)[″"]/)
|
||||||
|
if (m) return Number(m[1]) + Number(m[2]) / 60 + Number(m[3]) / 3600
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const haversineKm = (lat1: number, lon1: number, lat2: number, lon2: number): number => {
|
||||||
|
const R = 6371
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180
|
||||||
|
const dLon = (lon2 - lon1) * Math.PI / 180
|
||||||
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getGeomCentroid = (geom: { type: string; coordinates: unknown }): [number, number] | null => {
|
||||||
|
if (geom.type === 'Point') return geom.coordinates as [number, number]
|
||||||
|
if (geom.type === 'Polygon') {
|
||||||
|
const pts = (geom.coordinates as [number, number][][])[0]
|
||||||
|
const avg = pts.reduce((a, c) => [a[0] + c[0], a[1] + c[1]], [0, 0])
|
||||||
|
return [avg[0] / pts.length, avg[1] / pts.length]
|
||||||
|
}
|
||||||
|
if (geom.type === 'MultiPolygon') {
|
||||||
|
const all = (geom.coordinates as [number, number][][][]).flatMap(p => p[0])
|
||||||
|
const avg = all.reduce((a, c) => [a[0] + c[0], a[1] + c[1]], [0, 0])
|
||||||
|
return [avg[0] / all.length, avg[1] / all.length]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_COLORS = [
|
||||||
|
{ keywords: ['어장', '양식'], color: '#f97316', label: '어장/양식장' },
|
||||||
|
{ keywords: ['해수욕'], color: '#3b82f6', label: '해수욕장' },
|
||||||
|
{ keywords: ['수산시장', '어시장'], color: '#a855f7', label: '수산시장' },
|
||||||
|
{ keywords: ['갯벌'], color: '#92400e', label: '갯벌' },
|
||||||
|
{ keywords: ['서식지'], color: '#16a34a', label: '서식지' },
|
||||||
|
{ keywords: ['보호종', '생물', '조류', '포유', '파충', '양서'], color: '#ec4899', label: '보호종/생물종' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const getCategoryColor = (category: string): string => {
|
||||||
|
for (const { keywords, color } of CATEGORY_COLORS) {
|
||||||
|
if ((keywords as readonly string[]).some(kw => category.includes(kw))) return color
|
||||||
|
}
|
||||||
|
return '#06b6d4'
|
||||||
|
}
|
||||||
|
|
||||||
|
function SensitiveResourceMapSection({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
|
||||||
|
const mapContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||||
|
const [mapVisible, setMapVisible] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [capturing, setCapturing] = useState(false)
|
||||||
|
const [legendLabels, setLegendLabels] = useState<string[]>([])
|
||||||
|
const acdntSn = data.acdntSn
|
||||||
|
|
||||||
|
const RELEVANT_KEYWORDS = ['양식', '어장', '해수욕', '수산시장', '어시장', '갯벌', '서식지', '보호종', '생물', '조류', '포유', '파충']
|
||||||
|
|
||||||
|
const handleLoad = async () => {
|
||||||
|
if (!acdntSn) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [geojson, particlesGeojson] = await Promise.all([
|
||||||
|
fetchSensitiveResourcesGeojson(acdntSn),
|
||||||
|
fetchPredictionParticlesGeojson(acdntSn),
|
||||||
|
])
|
||||||
|
// 관련 카테고리만 필터링 + 카테고리별 색상 추가
|
||||||
|
const filteredGeojson = {
|
||||||
|
...geojson,
|
||||||
|
features: geojson.features
|
||||||
|
.filter(f => {
|
||||||
|
const cat = (f.properties as { category?: string })?.category ?? ''
|
||||||
|
return RELEVANT_KEYWORDS.some(kw => cat.includes(kw))
|
||||||
|
})
|
||||||
|
.map(f => ({
|
||||||
|
...f,
|
||||||
|
properties: {
|
||||||
|
...f.properties,
|
||||||
|
_color: getCategoryColor((f.properties as { category?: string })?.category ?? ''),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
// 실제 존재하는 카테고리 라벨만 범례에 표시
|
||||||
|
const presentColors = new Set(filteredGeojson.features.map(f => (f.properties as { _color: string })._color))
|
||||||
|
setLegendLabels(CATEGORY_COLORS.filter(c => presentColors.has(c.color)).map(c => c.label))
|
||||||
|
// 민감자원 GeoJSON → 6개 섹션 자동 채우기
|
||||||
|
const HABITAT_TYPES = ['갯벌', '해조류서식지', '바다목장', '바다숲', '산호류서식지', '인공어초', '해초류서식지']
|
||||||
|
const incLat = parseCoord(data.incident.lat)
|
||||||
|
const incLon = parseCoord(data.incident.lon)
|
||||||
|
const calcDist = (geom: { type: string; coordinates: unknown }) => {
|
||||||
|
if (incLat == null || incLon == null) return ''
|
||||||
|
const centroid = getGeomCentroid(geom)
|
||||||
|
return centroid ? haversineKm(incLat, incLon, centroid[1], centroid[0]).toFixed(2) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// aquaculture (어장)
|
||||||
|
const aquacultureRows = geojson.features
|
||||||
|
.filter(f => ((f.properties as { category?: string })?.category ?? '').includes('어장'))
|
||||||
|
.map(f => {
|
||||||
|
const p = f.properties as Record<string, unknown>
|
||||||
|
return { type: String(p['fids_knd'] ?? ''), area: p['area'] != null ? Number(p['area']).toFixed(2) : '', distance: calcDist(f.geometry as { type: string; coordinates: unknown }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// beaches (해수욕장)
|
||||||
|
const beachRows = geojson.features
|
||||||
|
.filter(f => ((f.properties as { category?: string })?.category ?? '').includes('해수욕'))
|
||||||
|
.map(f => {
|
||||||
|
const p = f.properties as Record<string, unknown>
|
||||||
|
return { name: String(p['beach_nm'] ?? p['name'] ?? p['nm'] ?? ''), distance: calcDist(f.geometry as { type: string; coordinates: unknown }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// markets (수산시장·어시장)
|
||||||
|
const marketRows = geojson.features
|
||||||
|
.filter(f => { const cat = (f.properties as { category?: string })?.category ?? ''; return cat.includes('수산시장') || cat.includes('어시장') })
|
||||||
|
.map(f => {
|
||||||
|
const p = f.properties as Record<string, unknown>
|
||||||
|
return { name: String(p['name'] ?? p['market_nm'] ?? p['nm'] ?? ''), distance: calcDist(f.geometry as { type: string; coordinates: unknown }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// esi (해안선) — 기존 10개 행의 length만 갱신
|
||||||
|
const esiFeatures = geojson.features.filter(f => { const cat = (f.properties as { category?: string })?.category ?? ''; return cat.includes('해안선') || cat.includes('ESI') })
|
||||||
|
const esiLengthMap: Record<string, string> = {}
|
||||||
|
esiFeatures.forEach(f => {
|
||||||
|
const p = f.properties as Record<string, unknown>
|
||||||
|
const code = String(p['esi_cd'] ?? '')
|
||||||
|
const len = p['length_km'] ?? p['len_km']
|
||||||
|
if (code && len != null) esiLengthMap[code] = `${Number(len).toFixed(2)} km`
|
||||||
|
})
|
||||||
|
const esiRows = esiFeatures.length > 0
|
||||||
|
? data.esi.map(row => ({ ...row, length: esiLengthMap[row.code.replace('ESI ', '')] ?? esiLengthMap[row.code] ?? row.length }))
|
||||||
|
: data.esi
|
||||||
|
|
||||||
|
// species (보호종·생물종) — 3개 고정 행 유지, species 컬럼만 갱신
|
||||||
|
const SPECIES_MAP: Record<string, string[]> = { '양서파충류': ['파충', '양서'], '조류': ['조류'], '포유류': ['포유'] }
|
||||||
|
const speciesCollected: Record<string, string[]> = { '양서파충류': [], '조류': [], '포유류': [] }
|
||||||
|
geojson.features.forEach(f => {
|
||||||
|
const cat = (f.properties as { category?: string })?.category ?? ''
|
||||||
|
const p = f.properties as Record<string, unknown>
|
||||||
|
const nm = String(p['name'] ?? p['species_nm'] ?? '')
|
||||||
|
if (!nm) return
|
||||||
|
for (const [row, kws] of Object.entries(SPECIES_MAP)) {
|
||||||
|
if (kws.some(kw => cat.includes(kw))) { speciesCollected[row].push(nm); break }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const hasSpecies = Object.values(speciesCollected).some(arr => arr.length > 0)
|
||||||
|
const speciesRows = hasSpecies
|
||||||
|
? data.species.map(row => ({ ...row, species: speciesCollected[row.category]?.join(', ') ?? row.species }))
|
||||||
|
: data.species
|
||||||
|
|
||||||
|
// habitat (서식지) — 타입별 면적 합산
|
||||||
|
const habitatFeatures = geojson.features.filter(f => HABITAT_TYPES.some(t => ((f.properties as { category?: string })?.category ?? '').includes(t)))
|
||||||
|
const habitatMap: Record<string, number> = {}
|
||||||
|
habitatFeatures.forEach(f => {
|
||||||
|
const cat = (f.properties as { category?: string })?.category ?? ''
|
||||||
|
const p = f.properties as Record<string, unknown>
|
||||||
|
habitatMap[cat] = (habitatMap[cat] ?? 0) + (p['area'] != null ? Number(p['area']) : 0)
|
||||||
|
})
|
||||||
|
const habitatRows = habitatFeatures.length > 0
|
||||||
|
? Object.entries(habitatMap).map(([type, area]) => ({ type, area: area > 0 ? area.toFixed(2) : '' }))
|
||||||
|
: data.habitat
|
||||||
|
|
||||||
|
// 단일 onChange 일괄 업데이트
|
||||||
|
const updates: Partial<OilSpillReportData> = {}
|
||||||
|
if (aquacultureRows.length > 0) updates.aquaculture = aquacultureRows
|
||||||
|
if (beachRows.length > 0) updates.beaches = beachRows
|
||||||
|
if (marketRows.length > 0) updates.markets = marketRows
|
||||||
|
if (esiFeatures.length > 0) updates.esi = esiRows
|
||||||
|
if (hasSpecies) updates.species = speciesRows
|
||||||
|
if (habitatFeatures.length > 0) updates.habitat = habitatRows
|
||||||
|
if (Object.keys(updates).length > 0) onChange({ ...data, ...updates })
|
||||||
|
|
||||||
|
setMapVisible(true)
|
||||||
|
// 다음 렌더 사이클 후 지도 초기화
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!mapContainerRef.current) return
|
||||||
|
const map = new maplibregl.Map({
|
||||||
|
container: mapContainerRef.current,
|
||||||
|
style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||||||
|
center: [127.5, 35.5],
|
||||||
|
zoom: 8,
|
||||||
|
preserveDrawingBuffer: true,
|
||||||
|
})
|
||||||
|
mapRef.current = map
|
||||||
|
map.on('load', () => {
|
||||||
|
// 확산 파티클 — sensitive 레이어 아래 (예측 탭과 동일한 색상 로직)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
map.addSource('particles', { type: 'geojson', data: particlesGeojson as any })
|
||||||
|
// 과거 스텝: 회색 반투명
|
||||||
|
map.addLayer({ id: 'particles-past', type: 'circle', source: 'particles', filter: ['==', ['get', 'isLastStep'], false], paint: { 'circle-radius': 2.5, 'circle-color': '#828282', 'circle-opacity': 0.4 } })
|
||||||
|
// 최신 스텝: 모델별 색상 (OpenDrift=파랑, POSEIDON=빨강)
|
||||||
|
map.addLayer({ id: 'particles-current', type: 'circle', source: 'particles', filter: ['==', ['get', 'isLastStep'], true], paint: { 'circle-radius': 3, 'circle-color': ['case', ['==', ['get', 'model'], 'OpenDrift'], '#3b82f6', ['==', ['get', 'model'], 'POSEIDON'], '#ef4444', '#06b6d4'], 'circle-opacity': 0.85 } })
|
||||||
|
// 민감자원 레이어
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
map.addSource('sensitive', { type: 'geojson', data: filteredGeojson as any })
|
||||||
|
map.addLayer({ id: 'sensitive-fill', type: 'fill', source: 'sensitive', filter: ['==', '$type', 'Polygon'], paint: { 'fill-color': ['get', '_color'], 'fill-opacity': 0.4 } })
|
||||||
|
map.addLayer({ id: 'sensitive-line', type: 'line', source: 'sensitive', filter: ['==', '$type', 'Polygon'], paint: { 'line-color': ['get', '_color'], 'line-width': 1.5 } })
|
||||||
|
map.addLayer({ id: 'sensitive-circle', type: 'circle', source: 'sensitive', filter: ['==', '$type', 'Point'], paint: { 'circle-radius': 6, 'circle-color': ['get', '_color'], 'circle-stroke-width': 1.5, 'circle-stroke-color': '#fff' } })
|
||||||
|
// fit bounds — spread + sensitive 합산
|
||||||
|
const coords: [number, number][] = []
|
||||||
|
const collectCoords = (geom: { type: string; coordinates: unknown }) => {
|
||||||
|
if (geom.type === 'Point') {
|
||||||
|
coords.push(geom.coordinates as [number, number])
|
||||||
|
} else if (geom.type === 'Polygon') {
|
||||||
|
const rings = geom.coordinates as [number, number][][]
|
||||||
|
rings[0]?.forEach(c => coords.push(c))
|
||||||
|
} else if (geom.type === 'MultiPolygon') {
|
||||||
|
const polys = geom.coordinates as [number, number][][][]
|
||||||
|
polys.forEach(rings => rings[0]?.forEach(c => coords.push(c)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filteredGeojson.features.forEach(f => collectCoords(f.geometry as { type: string; coordinates: unknown }))
|
||||||
|
particlesGeojson.features.forEach(f => {
|
||||||
|
coords.push(f.geometry.coordinates as [number, number])
|
||||||
|
})
|
||||||
|
if (coords.length > 0) {
|
||||||
|
const lngs = coords.map(c => c[0])
|
||||||
|
const lats = coords.map(c => c[1])
|
||||||
|
map.fitBounds(
|
||||||
|
[[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]],
|
||||||
|
{ padding: 60, maxZoom: 13 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 사고 위치 마커 (캔버스 레이어 — 캡처에 포함)
|
||||||
|
if (incLat != null && incLon != null) {
|
||||||
|
map.addSource('incident-point', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'Feature', geometry: { type: 'Point', coordinates: [incLon, incLat] }, properties: {} },
|
||||||
|
})
|
||||||
|
map.addLayer({ id: 'incident-circle', type: 'circle', source: 'incident-point', paint: { 'circle-radius': 7, 'circle-color': '#ef4444', 'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff' } })
|
||||||
|
map.addLayer({ id: 'incident-label', type: 'symbol', source: 'incident-point', layout: { 'text-field': '사고위치', 'text-size': 11, 'text-offset': [0, 1.6], 'text-anchor': 'top', 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'] }, paint: { 'text-color': '#ffffff', 'text-halo-color': '#ef4444', 'text-halo-width': 3 } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
} catch {
|
||||||
|
alert('민감자원 지도 데이터를 불러오지 못했습니다.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCapture = () => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map) return
|
||||||
|
setCapturing(true)
|
||||||
|
map.once('idle', () => {
|
||||||
|
const mapCanvas = map.getCanvas()
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
const W = mapCanvas.width
|
||||||
|
const H = mapCanvas.height
|
||||||
|
|
||||||
|
const composite = document.createElement('canvas')
|
||||||
|
composite.width = W
|
||||||
|
composite.height = H
|
||||||
|
const ctx = composite.getContext('2d')!
|
||||||
|
|
||||||
|
// 지도 그리기
|
||||||
|
ctx.drawImage(mapCanvas, 0, 0)
|
||||||
|
|
||||||
|
// 범례 그리기
|
||||||
|
const items = CATEGORY_COLORS.filter(c => legendLabels.includes(c.label))
|
||||||
|
if (items.length > 0) {
|
||||||
|
const pad = 8 * dpr
|
||||||
|
const swSize = 12 * dpr
|
||||||
|
const lineH = 18 * dpr
|
||||||
|
const fontSize = 11 * dpr
|
||||||
|
const boxW = 130 * dpr
|
||||||
|
const boxH = pad * 2 + items.length * lineH
|
||||||
|
const lx = pad, ly = pad
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.88)'
|
||||||
|
ctx.fillRect(lx, ly, boxW, boxH)
|
||||||
|
ctx.strokeStyle = 'rgba(0,0,0,0.15)'
|
||||||
|
ctx.lineWidth = dpr
|
||||||
|
ctx.strokeRect(lx, ly, boxW, boxH)
|
||||||
|
|
||||||
|
items.forEach(({ color, label }, i) => {
|
||||||
|
const iy = ly + pad + i * lineH
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.fillRect(lx + pad, iy + (lineH - swSize) / 2, swSize, swSize)
|
||||||
|
ctx.fillStyle = '#1f2937'
|
||||||
|
ctx.font = `${fontSize}px sans-serif`
|
||||||
|
ctx.fillText(label, lx + pad + swSize + 4 * dpr, iy + lineH / 2 + fontSize * 0.35)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange({ ...data, sensitiveMapImage: composite.toDataURL('image/png') })
|
||||||
|
setCapturing(false)
|
||||||
|
})
|
||||||
|
map.triggerRepaint()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (mapRef.current) {
|
||||||
|
mapRef.current.remove()
|
||||||
|
mapRef.current = null
|
||||||
|
}
|
||||||
|
setMapVisible(false)
|
||||||
|
setLegendLabels([])
|
||||||
|
onChange({ ...data, sensitiveMapImage: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { mapRef.current?.remove() }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 뷰 모드: 이미지 있으면 표시, 없으면 플레이스홀더
|
||||||
|
if (!editing) {
|
||||||
|
if (data.sensitiveMapImage) {
|
||||||
|
return <img src={data.sensitiveMapImage} alt="민감자원 분포 지도" style={{ width: '100%', maxHeight: 440, objectFit: 'contain', border: '1px solid #ddd', borderRadius: 4 }} />
|
||||||
|
}
|
||||||
|
return <div style={S.mapPlaceholder}>민감자원 분포(10km 내) 지도</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편집 모드: acdntSn 없음
|
||||||
|
if (!acdntSn) {
|
||||||
|
return <div style={S.mapPlaceholder}>민감자원 분포(10km 내) 지도</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편집 모드: 캡처 이미지 있음
|
||||||
|
if (data.sensitiveMapImage) {
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<img src={data.sensitiveMapImage} alt="민감자원 분포 지도" style={{ width: '100%', maxHeight: 440, objectFit: 'contain', border: '1px solid #ddd', borderRadius: 4 }} />
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
style={{ position: 'absolute', top: 8, right: 8, background: 'rgba(0,0,0,0.6)', color: '#fff', border: 'none', borderRadius: 4, padding: '3px 8px', fontSize: 11, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
재캡처
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편집 모드: 지도 로드/캡처
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{!mapVisible ? (
|
||||||
|
<div style={{ ...S.mapPlaceholder, flexDirection: 'column', gap: 8 }}>
|
||||||
|
<span>민감자원 분포(10km 내) 지도</span>
|
||||||
|
<button
|
||||||
|
onClick={handleLoad}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ background: '#0891b2', color: '#fff', border: 'none', borderRadius: 4, padding: '5px 14px', fontSize: 12, cursor: loading ? 'not-allowed' : 'pointer', opacity: loading ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{loading ? '불러오는 중...' : '지도 불러오기'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<div ref={mapContainerRef} style={{ width: '100%', height: 440, borderRadius: 4, overflow: 'hidden' }} />
|
||||||
|
{legendLabels.length > 0 && (
|
||||||
|
<div style={{ position: 'absolute', top: 8, left: 8, background: 'rgba(255,255,255,0.88)', border: '1px solid rgba(0,0,0,0.15)', borderRadius: 4, padding: '6px 10px', pointerEvents: 'none' }}>
|
||||||
|
{CATEGORY_COLORS.filter(c => legendLabels.includes(c.label)).map(({ color, label }) => (
|
||||||
|
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3, fontSize: 11, color: '#1f2937', whiteSpace: 'nowrap' }}>
|
||||||
|
<div style={{ width: 12, height: 12, background: color, flexShrink: 0 }} />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 6, justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
style={{ background: 'transparent', color: '#6b7280', border: '1px solid #374151', borderRadius: 4, padding: '3px 10px', fontSize: 11, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCapture}
|
||||||
|
disabled={capturing}
|
||||||
|
style={{ background: '#0891b2', color: '#fff', border: 'none', borderRadius: 4, padding: '3px 12px', fontSize: 11, cursor: capturing ? 'not-allowed' : 'pointer', opacity: capturing ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{capturing ? '캡처 중...' : '이 화면으로 캡처'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
|
function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
|
||||||
const setArr = <T extends Record<string, string>>(key: keyof OilSpillReportData, arr: T[], i: number, k: string, v: string) => {
|
const setArr = <T extends Record<string, string>>(key: keyof OilSpillReportData, arr: T[], i: number, k: string, v: string) => {
|
||||||
const copy = [...arr]; copy[i] = { ...copy[i], [k]: v }; onChange({ ...data, [key]: copy })
|
const copy = [...arr]; copy[i] = { ...copy[i], [k]: v }; onChange({ ...data, [key]: copy })
|
||||||
@ -329,7 +725,7 @@ function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing:
|
|||||||
<div style={S.page}>
|
<div style={S.page}>
|
||||||
<div className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold">해양오염방제지원시스템</div>
|
<div className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold">해양오염방제지원시스템</div>
|
||||||
<div style={S.sectionTitle}>4. 민감자원 및 민감도 평가</div>
|
<div style={S.sectionTitle}>4. 민감자원 및 민감도 평가</div>
|
||||||
<div style={S.mapPlaceholder}>민감자원 분포(10km 내) 지도</div>
|
<SensitiveResourceMapSection data={data} editing={editing} onChange={onChange} />
|
||||||
|
|
||||||
<div style={S.subHeader}>양식장 분포</div>
|
<div style={S.subHeader}>양식장 분포</div>
|
||||||
<table style={S.table}>
|
<table style={S.table}>
|
||||||
@ -414,13 +810,197 @@ function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SENS_LEVELS = [
|
||||||
|
{ key: 5, level: '매우 높음', color: '#ef4444' },
|
||||||
|
{ key: 4, level: '높음', color: '#f97316' },
|
||||||
|
{ key: 3, level: '보통', color: '#eab308' },
|
||||||
|
{ key: 2, level: '낮음', color: '#22c55e' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function SensitivityMapSection({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
|
||||||
|
const mapContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||||
|
const [mapVisible, setMapVisible] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [capturing, setCapturing] = useState(false)
|
||||||
|
const acdntSn = data.acdntSn
|
||||||
|
|
||||||
|
const handleLoad = async () => {
|
||||||
|
if (!acdntSn) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const geojson = await fetchSensitivityEvaluationGeojson(acdntSn)
|
||||||
|
const seasonKey = getSeasonKey(data.incident.occurTime)
|
||||||
|
|
||||||
|
// 레벨 계산 및 면적 집계 (레벨 1 제외)
|
||||||
|
const areaByLevel: Record<number, number> = {}
|
||||||
|
geojson.features.forEach(f => {
|
||||||
|
const p = (f as { properties: Record<string, unknown> }).properties
|
||||||
|
const lvl = Number(p[seasonKey] ?? 1)
|
||||||
|
if (lvl >= 2) areaByLevel[lvl] = (areaByLevel[lvl] ?? 0) + Number(p['area_km2'] ?? 0)
|
||||||
|
})
|
||||||
|
const sensitivityRows = SENS_LEVELS.map(s => ({
|
||||||
|
level: s.level, color: s.color,
|
||||||
|
area: areaByLevel[s.key] != null ? areaByLevel[s.key].toFixed(2) : '',
|
||||||
|
}))
|
||||||
|
onChange({ ...data, sensitivity: sensitivityRows })
|
||||||
|
|
||||||
|
// 레벨 속성 추가 + 레벨 1 제외한 표시용 GeoJSON
|
||||||
|
const displayGeojson = {
|
||||||
|
...geojson,
|
||||||
|
features: geojson.features
|
||||||
|
.map(f => {
|
||||||
|
const p = (f as { properties: Record<string, unknown> }).properties
|
||||||
|
return { ...(f as object), properties: { ...p, level: Number(p[seasonKey] ?? 1) } }
|
||||||
|
})
|
||||||
|
.filter(f => (f as { properties: { level: number } }).properties.level >= 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
setMapVisible(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!mapContainerRef.current) return
|
||||||
|
const map = new maplibregl.Map({
|
||||||
|
container: mapContainerRef.current,
|
||||||
|
style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||||||
|
center: [127.5, 35.5],
|
||||||
|
zoom: 8,
|
||||||
|
preserveDrawingBuffer: true,
|
||||||
|
})
|
||||||
|
mapRef.current = map
|
||||||
|
map.on('load', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
map.addSource('sensitivity', { type: 'geojson', data: displayGeojson as any })
|
||||||
|
const colorExpr: maplibregl.ExpressionSpecification = ['case',
|
||||||
|
['==', ['get', 'level'], 5], '#ef4444',
|
||||||
|
['==', ['get', 'level'], 4], '#f97316',
|
||||||
|
['==', ['get', 'level'], 3], '#eab308',
|
||||||
|
['==', ['get', 'level'], 2], '#22c55e',
|
||||||
|
'transparent',
|
||||||
|
]
|
||||||
|
map.addLayer({ id: 'sensitivity-fill', type: 'fill', source: 'sensitivity', filter: ['==', '$type', 'Polygon'], paint: { 'fill-color': colorExpr, 'fill-opacity': 0.6 } })
|
||||||
|
map.addLayer({ id: 'sensitivity-line', type: 'line', source: 'sensitivity', filter: ['==', '$type', 'Polygon'], paint: { 'line-color': colorExpr, 'line-width': 0.5, 'line-opacity': 0.4 } })
|
||||||
|
map.addLayer({ id: 'sensitivity-circle', type: 'circle', source: 'sensitivity', filter: ['==', '$type', 'Point'], paint: { 'circle-radius': 5, 'circle-color': colorExpr } })
|
||||||
|
|
||||||
|
// fitBounds
|
||||||
|
const coords: [number, number][] = []
|
||||||
|
displayGeojson.features.forEach(f => {
|
||||||
|
const geom = (f as { geometry: { type: string; coordinates: unknown } }).geometry
|
||||||
|
if (geom.type === 'Point') coords.push(geom.coordinates as [number, number])
|
||||||
|
else if (geom.type === 'Polygon') (geom.coordinates as [number, number][][])[0]?.forEach(c => coords.push(c))
|
||||||
|
else if (geom.type === 'MultiPolygon') (geom.coordinates as [number, number][][][]).forEach(rings => rings[0]?.forEach(c => coords.push(c)))
|
||||||
|
})
|
||||||
|
if (coords.length > 0) {
|
||||||
|
const lngs = coords.map(c => c[0])
|
||||||
|
const lats = coords.map(c => c[1])
|
||||||
|
map.fitBounds([[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]], { padding: 60, maxZoom: 13 })
|
||||||
|
}
|
||||||
|
// 사고 위치 마커 (캔버스 레이어 — 캡처에 포함)
|
||||||
|
const incLat = parseCoord(data.incident.lat)
|
||||||
|
const incLon = parseCoord(data.incident.lon)
|
||||||
|
if (incLat != null && incLon != null) {
|
||||||
|
map.addSource('incident-point', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'Feature', geometry: { type: 'Point', coordinates: [incLon, incLat] }, properties: {} },
|
||||||
|
})
|
||||||
|
map.addLayer({ id: 'incident-circle', type: 'circle', source: 'incident-point', paint: { 'circle-radius': 7, 'circle-color': '#ef4444', 'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff' } })
|
||||||
|
map.addLayer({ id: 'incident-label', type: 'symbol', source: 'incident-point', layout: { 'text-field': '사고위치', 'text-size': 11, 'text-offset': [0, 1.6], 'text-anchor': 'top', 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'] }, paint: { 'text-color': '#ffffff', 'text-halo-color': '#ef4444', 'text-halo-width': 3 } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
} catch {
|
||||||
|
alert('통합민감도 평가 데이터를 불러오지 못했습니다.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCapture = () => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map) return
|
||||||
|
setCapturing(true)
|
||||||
|
map.once('idle', () => {
|
||||||
|
const dataUrl = map.getCanvas().toDataURL('image/png')
|
||||||
|
onChange({ ...data, sensitivityMapImage: dataUrl })
|
||||||
|
setCapturing(false)
|
||||||
|
})
|
||||||
|
map.triggerRepaint()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (mapRef.current) { mapRef.current.remove(); mapRef.current = null }
|
||||||
|
setMapVisible(false)
|
||||||
|
onChange({ ...data, sensitivityMapImage: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { return () => { mapRef.current?.remove() } }, [])
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
if (data.sensitivityMapImage) {
|
||||||
|
return <img src={data.sensitivityMapImage} alt="통합민감도 평가 지도" style={{ width: '100%', maxHeight: 440, objectFit: 'contain', border: '1px solid #ddd', borderRadius: 4, marginBottom: 16 }} />
|
||||||
|
}
|
||||||
|
return <div style={S.mapPlaceholder}>통합민감도 평가 지도</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!acdntSn) return <div style={S.mapPlaceholder}>통합민감도 평가 지도</div>
|
||||||
|
|
||||||
|
if (data.sensitivityMapImage) {
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<img src={data.sensitivityMapImage} alt="통합민감도 평가 지도" style={{ width: '100%', maxHeight: 440, objectFit: 'contain', border: '1px solid #ddd', borderRadius: 4 }} />
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
style={{ position: 'absolute', top: 8, right: 8, background: 'rgba(0,0,0,0.6)', color: '#fff', border: 'none', borderRadius: 4, padding: '3px 8px', fontSize: 11, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
재캡처
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{!mapVisible ? (
|
||||||
|
<div style={{ ...S.mapPlaceholder, flexDirection: 'column', gap: 8 }}>
|
||||||
|
<span>민감도 분포(10km내) 지도</span>
|
||||||
|
<button
|
||||||
|
onClick={handleLoad}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ background: '#0891b2', color: '#fff', border: 'none', borderRadius: 4, padding: '5px 14px', fontSize: 12, cursor: loading ? 'not-allowed' : 'pointer', opacity: loading ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{loading ? '불러오는 중...' : '지도 불러오기'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<div ref={mapContainerRef} style={{ width: '100%', height: 440, borderRadius: 4, overflow: 'hidden' }} />
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 6, justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
style={{ background: 'transparent', color: '#6b7280', border: '1px solid #374151', borderRadius: 4, padding: '3px 10px', fontSize: 11, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCapture}
|
||||||
|
disabled={capturing}
|
||||||
|
style={{ background: '#0891b2', color: '#fff', border: 'none', borderRadius: 4, padding: '3px 12px', fontSize: 11, cursor: capturing ? 'not-allowed' : 'pointer', opacity: capturing ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{capturing ? '캡처 중...' : '이 화면으로 캡처'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Page5({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
|
function Page5({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
|
||||||
const setSens = (i: number, v: string) => { const s = [...data.sensitivity]; s[i] = { ...s[i], area: v }; onChange({ ...data, sensitivity: s }) }
|
const setSens = (i: number, v: string) => { const s = [...data.sensitivity]; s[i] = { ...s[i], area: v }; onChange({ ...data, sensitivity: s }) }
|
||||||
return (
|
return (
|
||||||
<div style={S.page}>
|
<div style={S.page}>
|
||||||
<div className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold">해양오염방제지원시스템</div>
|
<div className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold">해양오염방제지원시스템</div>
|
||||||
<div style={S.sectionTitle}>통합민감도 평가 (해당 계절)</div>
|
<div style={S.sectionTitle}>통합민감도 평가 (해당 계절)</div>
|
||||||
<div style={S.mapPlaceholder}>통합민감도 평가 지도</div>
|
<SensitivityMapSection data={data} editing={editing} onChange={onChange} />
|
||||||
<table style={S.table}>
|
<table style={S.table}>
|
||||||
<thead><tr><th style={S.th}>민감도</th><th style={S.th}>분포 면적(km²)</th></tr></thead>
|
<thead><tr><th style={S.th}>민감도</th><th style={S.th}>분포 면적(km²)</th></tr></thead>
|
||||||
<tbody>{data.sensitivity.map((s, i) => (
|
<tbody>{data.sensitivity.map((s, i) => (
|
||||||
|
|||||||
@ -117,6 +117,12 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
if (oilPayload.spreadSteps) {
|
if (oilPayload.spreadSteps) {
|
||||||
report.spread = oilPayload.spreadSteps;
|
report.spread = oilPayload.spreadSteps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// acdntSn 전달 (민감자원 지도 로드용)
|
||||||
|
if (oilPayload.acdntSn) {
|
||||||
|
(report as typeof report & { acdntSn?: number }).acdntSn = oilPayload.acdntSn;
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
report.incident.pollutant = '';
|
report.incident.pollutant = '';
|
||||||
report.incident.spillAmount = '';
|
report.incident.spillAmount = '';
|
||||||
@ -170,6 +176,17 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
: `<p style="font-size:12px;">최초 부착시간: <b>${oilPayload.coastal?.firstTime ?? '—'}</b> / 부착 해안길이: <b>${coastLength}</b></p>`;
|
: `<p style="font-size:12px;">최초 부착시간: <b>${oilPayload.coastal?.firstTime ?? '—'}</b> / 부착 해안길이: <b>${coastLength}</b></p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (activeCat === 0 && sec.id === 'oil-sensitive') {
|
||||||
|
const resources = oilPayload?.sensitiveResources;
|
||||||
|
if (resources && resources.length > 0) {
|
||||||
|
const headerRow = `<tr><th style="padding:6px 8px;border:1px solid #ddd;background:#f5f5f5;text-align:left;">구분</th><th style="padding:6px 8px;border:1px solid #ddd;background:#f5f5f5;text-align:center;">개소</th><th style="padding:6px 8px;border:1px solid #ddd;background:#f5f5f5;text-align:right;">면적</th></tr>`;
|
||||||
|
const dataRows = resources.map(r => {
|
||||||
|
const areaText = r.totalArea != null ? `${r.totalArea.toFixed(2)} ha` : '—';
|
||||||
|
return `<tr><td style="padding:6px 8px;border:1px solid #ddd;">${r.category}</td><td style="padding:6px 8px;border:1px solid #ddd;text-align:center;">${r.count}개소</td><td style="padding:6px 8px;border:1px solid #ddd;text-align:right;">${areaText}</td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
content = `<table style="width:100%;border-collapse:collapse;font-size:12px;">${headerRow}${dataRows}</table>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (activeCat === 0 && oilPayload) {
|
if (activeCat === 0 && oilPayload) {
|
||||||
if (sec.id === 'oil-pollution') {
|
if (sec.id === 'oil-pollution') {
|
||||||
const rows = [
|
const rows = [
|
||||||
@ -436,11 +453,43 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
</table>
|
</table>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{sec.id === 'oil-sensitive' && (
|
{sec.id === 'oil-sensitive' && (() => {
|
||||||
<p className="text-[12px] text-text-3 font-korean italic">
|
const resources = oilPayload?.sensitiveResources;
|
||||||
현재 민감자원 데이터가 없습니다.
|
if (!resources || resources.length === 0) {
|
||||||
</p>
|
return (
|
||||||
)}
|
<p className="text-[12px] text-text-3 font-korean italic">
|
||||||
|
현재 민감자원 데이터가 없습니다.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<table className="w-full table-fixed border-collapse">
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: '40%' }} />
|
||||||
|
<col style={{ width: '30%' }} />
|
||||||
|
<col style={{ width: '30%' }} />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th className="px-4 py-2 text-[11px] text-text-3 font-korean text-left bg-[rgba(255,255,255,0.02)]">구분</th>
|
||||||
|
<th className="px-4 py-2 text-[11px] text-text-3 font-korean text-right bg-[rgba(255,255,255,0.02)]">개소</th>
|
||||||
|
<th className="px-4 py-2 text-[11px] text-text-3 font-korean text-right bg-[rgba(255,255,255,0.02)]">면적</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{resources.map((r, i) => (
|
||||||
|
<tr key={i} className="border-b border-border">
|
||||||
|
<td className="px-4 py-3 text-[12px] text-text-1 font-korean">{r.category}</td>
|
||||||
|
<td className="px-4 py-3 text-[12px] text-text-1 text-right"><span className="font-mono">{r.count}</span><span className="font-korean">개소</span></td>
|
||||||
|
<td className="px-4 py-3 text-[12px] text-text-1 font-mono text-right">
|
||||||
|
{r.totalArea != null ? `${r.totalArea.toFixed(2)} ha` : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{sec.id === 'oil-coastal' && (() => {
|
{sec.id === 'oil-coastal' && (() => {
|
||||||
if (!oilPayload) {
|
if (!oilPayload) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -305,6 +305,8 @@ export function ReportsView() {
|
|||||||
exportAsHWP(tpl.label, meta, tpl.sections, getVal, filename, {
|
exportAsHWP(tpl.label, meta, tpl.sections, getVal, filename, {
|
||||||
step3: previewReport.step3MapImage || undefined,
|
step3: previewReport.step3MapImage || undefined,
|
||||||
step6: previewReport.step6MapImage || undefined,
|
step6: previewReport.step6MapImage || undefined,
|
||||||
|
sensitiveMap: previewReport.sensitiveMapImage || undefined,
|
||||||
|
sensitivityMap: previewReport.sensitivityMapImage || undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -735,7 +735,7 @@ function buildSection0Xml(
|
|||||||
meta: ReportMeta,
|
meta: ReportMeta,
|
||||||
sections: ReportSection[],
|
sections: ReportSection[],
|
||||||
getVal: (key: string) => string,
|
getVal: (key: string) => string,
|
||||||
imageBinIds?: { step3?: number; step6?: number },
|
imageBinIds?: { step3?: number; step6?: number; sensitiveMap?: number; sensitivityMap?: number },
|
||||||
): string {
|
): string {
|
||||||
// ID 시퀀스 초기화 (재사용 시 충돌 방지)
|
// ID 시퀀스 초기화 (재사용 시 충돌 방지)
|
||||||
_idSeq = 1000000000;
|
_idSeq = 1000000000;
|
||||||
@ -768,6 +768,7 @@ function buildSection0Xml(
|
|||||||
|
|
||||||
// __spreadMaps 필드 포함 섹션: 이미지 단락 삽입 후 나머지 필드 처리
|
// __spreadMaps 필드 포함 섹션: 이미지 단락 삽입 후 나머지 필드 처리
|
||||||
const hasSpreadMaps = section.fields.some(f => f.key === '__spreadMaps');
|
const hasSpreadMaps = section.fields.some(f => f.key === '__spreadMaps');
|
||||||
|
const hasSensitive = section.fields.some(f => f.key === '__sensitive');
|
||||||
if (hasSpreadMaps && imageBinIds) {
|
if (hasSpreadMaps && imageBinIds) {
|
||||||
const regularFields = section.fields.filter(f => f.key !== '__spreadMaps');
|
const regularFields = section.fields.filter(f => f.key !== '__spreadMaps');
|
||||||
if (imageBinIds.step3) {
|
if (imageBinIds.step3) {
|
||||||
@ -781,6 +782,18 @@ function buildSection0Xml(
|
|||||||
if (regularFields.length > 0) {
|
if (regularFields.length > 0) {
|
||||||
body += buildFieldTable(regularFields, getVal);
|
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 {
|
} else {
|
||||||
// 필드 테이블
|
// 필드 테이블
|
||||||
const fields = section.fields.filter(f => f.key !== '__spreadMaps');
|
const fields = section.fields.filter(f => f.key !== '__spreadMaps');
|
||||||
@ -846,7 +859,7 @@ export async function exportAsHWPX(
|
|||||||
sections: ReportSection[],
|
sections: ReportSection[],
|
||||||
getVal: (key: string) => string,
|
getVal: (key: string) => string,
|
||||||
filename: string,
|
filename: string,
|
||||||
images?: { step3?: string; step6?: string },
|
images?: { step3?: string; step6?: string; sensitiveMap?: string; sensitivityMap?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
|
|
||||||
@ -861,7 +874,7 @@ export async function exportAsHWPX(
|
|||||||
zip.file('META-INF/manifest.xml', MANIFEST_XML);
|
zip.file('META-INF/manifest.xml', MANIFEST_XML);
|
||||||
|
|
||||||
// 이미지 처리
|
// 이미지 처리
|
||||||
let imageBinIds: { step3?: number; step6?: number } | undefined;
|
let imageBinIds: { step3?: number; step6?: number; sensitiveMap?: number; sensitivityMap?: number } | undefined;
|
||||||
let extraManifestItems = '';
|
let extraManifestItems = '';
|
||||||
let binDataListXml = '';
|
let binDataListXml = '';
|
||||||
let binCount = 0;
|
let binCount = 0;
|
||||||
@ -894,6 +907,16 @@ export async function exportAsHWPX(
|
|||||||
processImage(images.step6, 2, 'image2');
|
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 스펙 준수)
|
// header.xml: binDataList를 hh:refList 내부에 삽입 (HWPML 스펙 준수)
|
||||||
let headerXml = HEADER_XML;
|
let headerXml = HEADER_XML;
|
||||||
|
|||||||
@ -47,7 +47,7 @@ export async function exportAsHWP(
|
|||||||
sections: { title: string; fields: { key: string; label: string }[] }[],
|
sections: { title: string; fields: { key: string; label: string }[] }[],
|
||||||
getVal: (key: string) => string,
|
getVal: (key: string) => string,
|
||||||
filename: string,
|
filename: string,
|
||||||
images?: { step3?: string; step6?: string },
|
images?: { step3?: string; step6?: string; sensitiveMap?: string; sensitivityMap?: string },
|
||||||
) {
|
) {
|
||||||
const { exportAsHWPX } = await import('./hwpxExport');
|
const { exportAsHWPX } = await import('./hwpxExport');
|
||||||
await exportAsHWPX(templateLabel, meta, sections, getVal, filename, images);
|
await exportAsHWPX(templateLabel, meta, sections, getVal, filename, images);
|
||||||
@ -122,6 +122,14 @@ function formatSpreadTable(spread: OilSpillReportData['spread']): string {
|
|||||||
|
|
||||||
function formatSensitiveTable(r: OilSpillReportData): string {
|
function formatSensitiveTable(r: OilSpillReportData): string {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
|
||||||
|
if (r.sensitiveMapImage) {
|
||||||
|
parts.push(
|
||||||
|
'<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">민감자원 분포 지도</p>' +
|
||||||
|
`<img src="${r.sensitiveMapImage}" style="width:100%;max-height:300px;object-fit:contain;border:1px solid #ddd;border-radius:6px;display:block;margin-bottom:8px;" />`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (r.aquaculture?.length) {
|
if (r.aquaculture?.length) {
|
||||||
const h = `<tr><th style="${TH}">종류</th><th style="${TH}">면적</th><th style="${TH}">거리</th></tr>`
|
const h = `<tr><th style="${TH}">종류</th><th style="${TH}">면적</th><th style="${TH}">거리</th></tr>`
|
||||||
const rows = r.aquaculture.map(a => `<tr><td style="${TD}">${a.type}</td><td style="${TD}">${a.area}</td><td style="${TD}">${a.distance}</td></tr>`).join('')
|
const rows = r.aquaculture.map(a => `<tr><td style="${TD}">${a.type}</td><td style="${TD}">${a.area}</td><td style="${TD}">${a.distance}</td></tr>`).join('')
|
||||||
@ -152,6 +160,13 @@ function formatSensitiveTable(r: OilSpillReportData): string {
|
|||||||
const rows = r.habitat.map(h2 => `<tr><td style="${TD}">${h2.type}</td><td style="${TD}">${h2.area}</td></tr>`).join('')
|
const rows = r.habitat.map(h2 => `<tr><td style="${TD}">${h2.type}</td><td style="${TD}">${h2.area}</td></tr>`).join('')
|
||||||
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">서식지</p><table style="${TABLE}">${h}${rows}</table>`)
|
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">서식지</p><table style="${TABLE}">${h}${rows}</table>`)
|
||||||
}
|
}
|
||||||
|
if (r.sensitivityMapImage) {
|
||||||
|
parts.push(
|
||||||
|
'<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">통합민감도 평가 지도</p>' +
|
||||||
|
`<img src="${r.sensitivityMapImage}" style="width:100%;max-height:300px;object-fit:contain;border:1px solid #ddd;border-radius:6px;display:block;margin-bottom:8px;" />`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (r.sensitivity?.length) {
|
if (r.sensitivity?.length) {
|
||||||
const h = `<tr><th style="${TH}">민감도</th><th style="${TH}">면적</th></tr>`
|
const h = `<tr><th style="${TH}">민감도</th><th style="${TH}">면적</th></tr>`
|
||||||
const rows = r.sensitivity.map(s => `<tr><td style="${TD}">${s.level}</td><td style="${TD}">${s.area}</td></tr>`).join('')
|
const rows = r.sensitivity.map(s => `<tr><td style="${TD}">${s.level}</td><td style="${TD}">${s.area}</td></tr>`).join('')
|
||||||
|
|||||||
@ -60,6 +60,7 @@ export interface ApiReportListItem {
|
|||||||
sttsCd: string;
|
sttsCd: string;
|
||||||
authorId: string;
|
authorId: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
|
acdntSn?: number | null;
|
||||||
regDtm: string;
|
regDtm: string;
|
||||||
mdfcnDtm: string | null;
|
mdfcnDtm: string | null;
|
||||||
hasMapCapture?: boolean;
|
hasMapCapture?: boolean;
|
||||||
@ -239,13 +240,24 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
|
|||||||
// analysis + etcEquipment 합산
|
// analysis + etcEquipment 합산
|
||||||
sections.push({ sectCd: 'analysis', sectData: { analysis: data.analysis, etcEquipment: data.etcEquipment }, sortOrd: sortOrd++ });
|
sections.push({ sectCd: 'analysis', sectData: { analysis: data.analysis, etcEquipment: data.etcEquipment }, sortOrd: sortOrd++ });
|
||||||
|
|
||||||
|
// 민감자원 지도 이미지 (섹션으로 저장)
|
||||||
|
const extData = data as OilSpillReportData & { reportSn?: number; acdntSn?: number };
|
||||||
|
if (extData.sensitiveMapImage !== undefined) {
|
||||||
|
sections.push({ sectCd: 'sensitive-map', sectData: { mapImage: extData.sensitiveMapImage }, sortOrd: sortOrd++ });
|
||||||
|
}
|
||||||
|
// 통합민감도 평가 지도 이미지 (섹션으로 저장)
|
||||||
|
if (extData.sensitivityMapImage !== undefined) {
|
||||||
|
sections.push({ sectCd: 'sensitivity-map', sectData: { mapImage: extData.sensitivityMapImage }, sortOrd: sortOrd++ });
|
||||||
|
}
|
||||||
|
|
||||||
// reportSn이 있으면 update, 없으면 create
|
// reportSn이 있으면 update, 없으면 create
|
||||||
const existingSn = (data as OilSpillReportData & { reportSn?: number }).reportSn;
|
const existingSn = extData.reportSn;
|
||||||
if (existingSn) {
|
if (existingSn) {
|
||||||
await updateReportApi(existingSn, {
|
await updateReportApi(existingSn, {
|
||||||
title: data.title || data.incident.name || '보고서',
|
title: data.title || data.incident.name || '보고서',
|
||||||
jrsdCd: data.jurisdiction,
|
jrsdCd: data.jurisdiction,
|
||||||
sttsCd,
|
sttsCd,
|
||||||
|
acdntSn: extData.acdntSn ?? null,
|
||||||
step3MapImg: data.step3MapImage !== undefined ? (data.step3MapImage || null) : undefined,
|
step3MapImg: data.step3MapImage !== undefined ? (data.step3MapImage || null) : undefined,
|
||||||
step6MapImg: data.step6MapImage !== undefined ? (data.step6MapImage || null) : undefined,
|
step6MapImg: data.step6MapImage !== undefined ? (data.step6MapImage || null) : undefined,
|
||||||
sections,
|
sections,
|
||||||
@ -256,6 +268,7 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
|
|||||||
const result = await createReportApi({
|
const result = await createReportApi({
|
||||||
tmplSn,
|
tmplSn,
|
||||||
ctgrSn,
|
ctgrSn,
|
||||||
|
acdntSn: extData.acdntSn,
|
||||||
title: data.title || data.incident.name || '보고서',
|
title: data.title || data.incident.name || '보고서',
|
||||||
jrsdCd: data.jurisdiction,
|
jrsdCd: data.jurisdiction,
|
||||||
sttsCd,
|
sttsCd,
|
||||||
@ -278,6 +291,7 @@ export function apiListItemToReportData(item: ApiReportListItem): OilSpillReport
|
|||||||
jurisdiction: (item.jrsdCd as Jurisdiction) || '남해청',
|
jurisdiction: (item.jrsdCd as Jurisdiction) || '남해청',
|
||||||
status: CODE_TO_STATUS[item.sttsCd] || '테스트',
|
status: CODE_TO_STATUS[item.sttsCd] || '테스트',
|
||||||
hasMapCapture: item.hasMapCapture,
|
hasMapCapture: item.hasMapCapture,
|
||||||
|
acdntSn: item.acdntSn ?? undefined,
|
||||||
// 목록에서는 섹션 데이터 없음 — 빈 기본값
|
// 목록에서는 섹션 데이터 없음 — 빈 기본값
|
||||||
incident: { name: '', writeTime: '', shipName: '', agent: '', location: '', lat: '', lon: '', occurTime: '', accidentType: '', pollutant: '', spillAmount: '', depth: '', seabed: '' },
|
incident: { name: '', writeTime: '', shipName: '', agent: '', location: '', lat: '', lon: '', occurTime: '', accidentType: '', pollutant: '', spillAmount: '', depth: '', seabed: '' },
|
||||||
tide: [], weather: [], spread: [],
|
tide: [], weather: [], spread: [],
|
||||||
@ -346,6 +360,12 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa
|
|||||||
case 'result':
|
case 'result':
|
||||||
reportData.result = d as OilSpillReportData['result'];
|
reportData.result = d as OilSpillReportData['result'];
|
||||||
break;
|
break;
|
||||||
|
case 'sensitive-map':
|
||||||
|
reportData.sensitiveMapImage = (d as { mapImage?: string }).mapImage;
|
||||||
|
break;
|
||||||
|
case 'sensitivity-map':
|
||||||
|
reportData.sensitivityMapImage = (d as { mapImage?: string }).mapImage;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -361,6 +381,9 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa
|
|||||||
if (detail.step6MapImg) {
|
if (detail.step6MapImg) {
|
||||||
reportData.step6MapImage = detail.step6MapImg;
|
reportData.step6MapImage = detail.step6MapImg;
|
||||||
}
|
}
|
||||||
|
if (detail.acdntSn != null) {
|
||||||
|
(reportData as typeof reportData & { acdntSn?: number }).acdntSn = detail.acdntSn;
|
||||||
|
}
|
||||||
|
|
||||||
return reportData;
|
return reportData;
|
||||||
}
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user