feat(reports): ������ ���� ��ȭ �� ������ ���� Ʈ�� Ȯ�� #112

병합
jhkang feature/report 에서 develop 로 4 commits 를 머지했습니다 2026-03-20 17:26:49 +09:00
30개의 변경된 파일866532개의 추가작업 그리고 348개의 파일을 삭제

파일 보기

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

파일 보기

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

파일 보기

@ -92,7 +92,7 @@ router.get('/:sn', requireAuth, requirePermission('reports', 'READ'), async (req
// ============================================================
router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => {
try {
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections, mapCaptureImg } = req.body;
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections, mapCaptureImg, step3MapImg, step6MapImg } = req.body;
const result = await createReport({
tmplSn,
ctgrSn,
@ -102,6 +102,8 @@ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req
sttsCd,
authorId: req.user!.sub,
mapCaptureImg,
step3MapImg,
step6MapImg,
sections,
});
res.status(201).json(result);
@ -125,8 +127,8 @@ router.post('/:sn/update', requireAuth, requirePermission('reports', 'UPDATE'),
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
return;
}
const { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg } = req.body;
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg }, req.user!.sub);
const { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg, step3MapImg, step6MapImg } = req.body;
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg, step3MapImg, step6MapImg }, req.user!.sub);
res.json({ success: true });
} catch (err) {
if (err instanceof AuthError) {

파일 보기

@ -76,6 +76,8 @@ interface ReportDetail extends ReportListItem {
acdntSn: number | null;
sections: SectionData[];
mapCaptureImg: string | null;
step3MapImg: string | null;
step6MapImg: string | null;
}
interface ListReportsInput {
@ -103,6 +105,8 @@ interface CreateReportInput {
sttsCd?: string;
authorId: string;
mapCaptureImg?: string;
step3MapImg?: string;
step6MapImg?: string;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}
@ -112,6 +116,8 @@ interface UpdateReportInput {
sttsCd?: string;
acdntSn?: number | null;
mapCaptureImg?: string | null;
step3MapImg?: string | null;
step6MapImg?: string | null;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}
@ -261,7 +267,10 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
r.TITLE, r.JRSD_CD, r.STTS_CD,
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
r.REG_DTM, r.MDFCN_DTM,
CASE WHEN r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '' THEN true ELSE false END AS HAS_MAP_CAPTURE
CASE WHEN (r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '')
OR (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '')
OR (r.STEP6_MAP_IMG IS NOT NULL AND r.STEP6_MAP_IMG <> '')
THEN true ELSE false END AS HAS_MAP_CAPTURE
FROM REPORT r
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
@ -300,8 +309,11 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
c.CTGR_CD, c.CTGR_NM,
r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN,
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
r.REG_DTM, r.MDFCN_DTM, r.MAP_CAPTURE_IMG,
CASE WHEN r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '' THEN true ELSE false END AS HAS_MAP_CAPTURE
r.REG_DTM, r.MDFCN_DTM, r.MAP_CAPTURE_IMG, r.STEP3_MAP_IMG, r.STEP6_MAP_IMG,
CASE WHEN (r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '')
OR (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '')
OR (r.STEP6_MAP_IMG IS NOT NULL AND r.STEP6_MAP_IMG <> '')
THEN true ELSE false END AS HAS_MAP_CAPTURE
FROM REPORT r
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
@ -339,6 +351,8 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
regDtm: r.reg_dtm,
mdfcnDtm: r.mdfcn_dtm,
mapCaptureImg: r.map_capture_img,
step3MapImg: r.step3_map_img,
step6MapImg: r.step6_map_img,
hasMapCapture: r.has_map_capture,
sections: sectRes.rows.map((s) => ({
sectCd: s.sect_cd,
@ -359,8 +373,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
await client.query('BEGIN');
const res = await client.query(
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, MAP_CAPTURE_IMG)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, MAP_CAPTURE_IMG, STEP3_MAP_IMG, STEP6_MAP_IMG)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING REPORT_SN`,
[
input.tmplSn || null,
@ -371,6 +385,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
input.sttsCd || 'DRAFT',
input.authorId,
input.mapCaptureImg || null,
input.step3MapImg || null,
input.step6MapImg || null,
]
);
const reportSn = res.rows[0].report_sn;
@ -446,6 +462,14 @@ export async function updateReport(
sets.push(`MAP_CAPTURE_IMG = $${idx++}`);
params.push(input.mapCaptureImg);
}
if (input.step3MapImg !== undefined) {
sets.push(`STEP3_MAP_IMG = $${idx++}`);
params.push(input.step3MapImg);
}
if (input.step6MapImg !== undefined) {
sets.push(`STEP6_MAP_IMG = $${idx++}`);
params.push(input.step6MapImg);
}
params.push(reportSn);
await client.query(

파일 보기

@ -191,6 +191,15 @@ router.get('/admin/list', requireAuth, requireRole('ADMIN'), async (req, res) =>
conditions.push(`USE_YN = $${params.length}`)
}
const rootCd = sanitizeString(String(req.query.rootCd ?? '')).trim()
if (rootCd) {
if (!/^[a-zA-Z0-9_-]+$/.test(rootCd) || !isValidStringLength(rootCd, 50)) {
return res.status(400).json({ error: '유효하지 않은 루트 레이어코드' })
}
params.push(`${rootCd}%`)
conditions.push(`LAYER_CD LIKE $${params.length}`)
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
// 데이터 쿼리 파라미터: WHERE 조건 + LIMIT + OFFSET

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -4,6 +4,15 @@
## [Unreleased]
### 추가
- 보고서: 기능 강화 (HWPX 내보내기, 확산 지도 패널, 보고서 생성기 개선)
- 관리자: 권한 트리 확장 (게시판관리·기준정보·연계관리 섹션 추가)
- 관리자: 유처리제 제한구역 패널, 민감자원 레이어 패널 추가
- 기상: 날씨 스냅샷 스토어, 유틸리티 모듈 추가
### 문서
- PREDICTION-GUIDE.md 삭제
## [2026-03-20.2]
### 변경

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

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

파일 보기

@ -203,6 +203,13 @@ export interface OilReportPayload {
windSpeed: string;
waveHeight: string;
temp: string;
pressure?: string;
visibility?: string;
salinity?: string;
waveMaxHeight?: string;
wavePeriod?: string;
currentDir?: string;
currentSpeed?: string;
} | null;
spread: {
kosps: string;

파일 보기

@ -0,0 +1,38 @@
import { create } from 'zustand';
export interface WeatherSnapshot {
stationName: string;
capturedAt: string;
wind: {
speed: number;
direction: number;
directionLabel: string;
speed_1k: number;
speed_3k: number;
};
wave: {
height: number;
maxHeight: number;
period: number;
direction: string;
};
temperature: {
current: number;
feelsLike: number;
};
pressure: number;
visibility: number;
salinity: number;
}
interface WeatherSnapshotStore {
snapshot: WeatherSnapshot | null;
setSnapshot: (data: WeatherSnapshot) => void;
clearSnapshot: () => void;
}
export const useWeatherSnapshotStore = create<WeatherSnapshotStore>((set) => ({
snapshot: null,
setSnapshot: (data) => set({ snapshot: data }),
clearSnapshot: () => set({ snapshot: null }),
}));

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -9,6 +9,8 @@ import { BacktrackModal } from './BacktrackModal'
import { RecalcModal } from './RecalcModal'
import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar'
import { useSubMenu, navigateToTab, setReportGenCategory, setOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu'
import { fetchWeatherSnapshotForCoord } from '@tabs/weather/services/weatherUtils'
import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore'
import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine'
import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack'
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
@ -746,9 +748,10 @@ export function OilSpillView() {
const newWindDataByModel: Record<string, WindPoint[][]> = {};
const newHydrDataByModel: Record<string, (HydrDataStep | null)[]> = {};
const newSummaryByModel: Record<string, SimulationSummary> = {};
const newStepSummariesByModel: Record<string, SimulationSummary[]> = {};
const errors: string[] = [];
data.results.forEach(({ model, status, trajectory, summary, centerPoints, windData, hydrData, error }) => {
data.results.forEach(({ model, status, trajectory, summary, stepSummaries, centerPoints, windData, hydrData, error }) => {
if (status === 'ERROR') {
errors.push(error || `${model} 분석 중 오류가 발생했습니다.`);
return;
@ -760,6 +763,7 @@ export function OilSpillView() {
newSummaryByModel[model] = summary;
if (model === 'OpenDrift' || !latestSummary) latestSummary = summary;
}
if (stepSummaries) newStepSummariesByModel[model] = stepSummaries;
if (windData) newWindDataByModel[model] = windData;
if (hydrData) newHydrDataByModel[model] = hydrData;
if (centerPoints) {
@ -788,6 +792,7 @@ export function OilSpillView() {
setWindDataByModel(newWindDataByModel);
setHydrDataByModel(newHydrDataByModel);
setSummaryByModel(newSummaryByModel);
setStepSummariesByModel(newStepSummariesByModel);
const booms = generateAIBoomLines(merged, effectiveCoord, algorithmSettings);
setBoomLines(booms);
setSensitiveResources(DEMO_SENSITIVE_RESOURCES);
@ -800,6 +805,11 @@ export function OilSpillView() {
setSimulationError(errors.join('; '));
} else {
simulationSucceeded = true;
if (effectiveCoord) {
fetchWeatherSnapshotForCoord(effectiveCoord.lat, effectiveCoord.lon)
.then(snapshot => useWeatherSnapshotStore.getState().setSnapshot(snapshot))
.catch(err => console.warn('[weather] 기상 데이터 수집 실패:', err));
}
}
} catch (err) {
const msg =
@ -827,6 +837,7 @@ export function OilSpillView() {
accidentTime ||
'';
const wx = analysisDetail?.weather?.[0] ?? null;
const weatherSnapshot = useWeatherSnapshotStore.getState().snapshot;
const payload: OilReportPayload = {
incident: {
@ -854,9 +865,27 @@ export function OilSpillView() {
coastLength: simulationSummary ? `${simulationSummary.pollutionCoastLength.toFixed(2)} km` : '—',
oilType: OIL_TYPE_CODE[oilType] || oilType,
},
weather: wx
? { windDir: wx.wind, windSpeed: wx.wind, waveHeight: wx.wave, temp: wx.temp }
: null,
weather: (() => {
if (weatherSnapshot) {
return {
windDir: `${weatherSnapshot.wind.directionLabel} ${weatherSnapshot.wind.direction}°`,
windSpeed: `${weatherSnapshot.wind.speed.toFixed(1)} m/s`,
waveHeight: `${weatherSnapshot.wave.height.toFixed(1)} m`,
temp: `${weatherSnapshot.temperature.current.toFixed(1)} °C`,
pressure: `${weatherSnapshot.pressure} hPa`,
visibility: `${weatherSnapshot.visibility} km`,
salinity: `${weatherSnapshot.salinity} PSU`,
waveMaxHeight: `${weatherSnapshot.wave.maxHeight.toFixed(1)} m`,
wavePeriod: `${weatherSnapshot.wave.period} s`,
currentDir: '',
currentSpeed: '',
};
}
if (wx) {
return { windDir: wx.wind, windSpeed: wx.wind, waveHeight: wx.wave, temp: wx.temp };
}
return null;
})(),
spread: (() => {
const fmt = (model: string) => {
const s = summaryByModel[model];

파일 보기

@ -190,6 +190,7 @@ export interface RunModelSyncResult {
status: 'DONE' | 'ERROR';
trajectory?: OilParticle[];
summary?: SimulationSummary;
stepSummaries?: SimulationSummary[];
centerPoints?: CenterPoint[];
windData?: WindPoint[][];
hydrData?: (HydrDataStep | null)[];

파일 보기

@ -39,6 +39,8 @@ export interface OilSpillReportData {
recovery: { shipName: string; period: string }[]
result: { spillTotal: string; weatheredTotal: string; recoveredTotal: string; seaRemainTotal: string; coastAttachTotal: string }
capturedMapImage?: string;
step3MapImage?: string;
step6MapImage?: string;
hasMapCapture?: boolean;
}
@ -268,8 +270,14 @@ function Page2({ data, editing, onChange }: { data: OilSpillReportData; editing:
<div style={S.sectionTitle}>3. </div>
<div className="flex gap-4 mb-4">
<div style={S.mapPlaceholder}> 3 </div>
<div style={S.mapPlaceholder}> 6 </div>
{data.step3MapImage
? <img src={data.step3MapImage} alt="확산예측 3시간 지도" style={{ width: '100%', maxHeight: '240px', objectFit: 'contain', borderRadius: '4px', border: '1px solid var(--bd)' }} />
: <div style={S.mapPlaceholder}> 3 </div>
}
{data.step6MapImage
? <img src={data.step6MapImage} alt="확산예측 6시간 지도" style={{ width: '100%', maxHeight: '240px', objectFit: 'contain', borderRadius: '4px', border: '1px solid var(--bd)' }} />
: <div style={S.mapPlaceholder}> 6 </div>
}
</div>
<div style={S.subHeader}> </div>
<table style={S.table}>

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -47,9 +47,10 @@ export async function exportAsHWP(
sections: { title: string; fields: { key: string; label: string }[] }[],
getVal: (key: string) => string,
filename: string,
images?: { step3?: string; step6?: string },
) {
const { exportAsHWPX } = await import('./hwpxExport');
await exportAsHWPX(templateLabel, meta, sections, getVal, filename);
await exportAsHWPX(templateLabel, meta, sections, getVal, filename, images);
}
export type ViewState =
@ -196,6 +197,18 @@ export function buildReportGetVal(report: OilSpillReportData) {
}
if (key === '__tide') return formatTideTable(report.tide)
if (key === '__weather') return formatWeatherTable(report.weather)
if (key === '__spreadMaps') {
const img3 = report.step3MapImage
const img6 = report.step6MapImage
if (!img3 && !img6) return ''
const cell = (label: string, src: string) =>
`<div style="flex:1;min-width:0"><p style="font-size:11px;font-weight:bold;color:#0891b2;margin:0 0 4px;">${label}</p>` +
`<img src="${src}" style="width:100%;max-height:260px;object-fit:contain;border:1px solid #ddd;border-radius:6px;" /></div>`
return `<div style="display:flex;gap:12px;margin-bottom:8px;">` +
(img3 ? cell('3시간 후', img3) : '') +
(img6 ? cell('6시간 후', img6) : '') +
`</div>`
}
if (key === '__spread') return formatSpreadTable(report.spread)
if (key === '__sensitive') return formatSensitiveTable(report)
if (key === '__vessels') return formatVesselsTable(report.vessels)

파일 보기

@ -76,6 +76,8 @@ export interface ApiReportDetail extends ApiReportListItem {
acdntSn: number | null;
sections: ApiReportSectionData[];
mapCaptureImg?: string | null;
step3MapImg?: string | null;
step6MapImg?: string | null;
}
export interface ApiReportListResponse {
@ -179,6 +181,8 @@ export async function createReportApi(input: {
jrsdCd?: string;
sttsCd?: string;
mapCaptureImg?: string;
step3MapImg?: string;
step6MapImg?: string;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}): Promise<{ sn: number }> {
const res = await api.post<{ sn: number }>('/reports', input);
@ -191,6 +195,8 @@ export async function updateReportApi(sn: number, input: {
sttsCd?: string;
acdntSn?: number | null;
mapCaptureImg?: string | null;
step3MapImg?: string | null;
step6MapImg?: string | null;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}): Promise<void> {
await api.post(`/reports/${sn}/update`, input);
@ -244,6 +250,8 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
jrsdCd: data.jurisdiction,
sttsCd,
mapCaptureImg: data.capturedMapImage !== undefined ? (data.capturedMapImage || null) : undefined,
step3MapImg: data.step3MapImage !== undefined ? (data.step3MapImage || null) : undefined,
step6MapImg: data.step6MapImage !== undefined ? (data.step6MapImage || null) : undefined,
sections,
});
return existingSn;
@ -256,6 +264,8 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
jrsdCd: data.jurisdiction,
sttsCd,
mapCaptureImg: data.capturedMapImage || undefined,
step3MapImg: data.step3MapImage || undefined,
step6MapImg: data.step6MapImage || undefined,
sections,
});
return result.sn;
@ -353,6 +363,12 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa
if (detail.mapCaptureImg) {
reportData.capturedMapImage = detail.mapCaptureImg;
}
if (detail.step3MapImg) {
reportData.step3MapImage = detail.step3MapImg;
}
if (detail.step6MapImg) {
reportData.step6MapImage = detail.step6MapImg;
}
return reportData;
}

파일 보기

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

파일 보기

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