Merge pull request 'feat: ���̾� ������ ���̺� ���� + ������ ���� + ���� API Ȯ�� + �����͸� �г�' (#114) from feature/layer-data-table-mapping into develop

This commit is contained in:
jhkang 2026-03-24 17:00:32 +09:00
커밋 265ffe65ea
24개의 변경된 파일1598개의 추가작업 그리고 84개의 파일을 삭제

파일 보기

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

파일 보기

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

파일 보기

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