Compare commits
5 커밋
d6d476e9bd
...
087fe57e0d
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| 087fe57e0d | |||
| b4ddbff770 | |||
| f0f1d0e14d | |||
| c3bb23f919 | |||
| 7a1eb80627 |
@ -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'))
|
||||
);
|
||||
|
||||
|
||||
57
database/migration/024_admin_perm_tree.sql
Normal file
57
database/migration/024_admin_perm_tree.sql
Normal file
@ -0,0 +1,57 @@
|
||||
-- 관리자 권한 트리 확장: 게시판관리, 기준정보, 연계관리 섹션 추가
|
||||
-- AdminView.tsx의 adminMenuConfig.ts에 정의된 전체 메뉴 구조를 AUTH_PERM_TREE에 반영
|
||||
|
||||
-- Level 1 섹션 노드 (3개)
|
||||
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||
('admin:board-mgmt', 'admin', '게시판관리', 1, 5),
|
||||
('admin:reference', 'admin', '기준정보', 1, 6),
|
||||
('admin:external', 'admin', '연계관리', 1, 7)
|
||||
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||
|
||||
-- Level 2 그룹/리프 노드
|
||||
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||
('admin:notice', 'admin:board-mgmt', '공지사항', 2, 1),
|
||||
('admin:board', 'admin:board-mgmt', '게시판', 2, 2),
|
||||
('admin:qna', 'admin:board-mgmt', 'QNA', 2, 3),
|
||||
('admin:map-mgmt', 'admin:reference', '지도관리', 2, 1),
|
||||
('admin:sensitive-map', 'admin:reference', '민감자원지도', 2, 2),
|
||||
('admin:coast-guard-assets', 'admin:reference', '해경자산', 2, 3),
|
||||
('admin:collection', 'admin:external', '수집자료', 2, 1),
|
||||
('admin:monitoring', 'admin:external', '연계모니터링', 2, 2)
|
||||
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||
|
||||
-- Level 3 리프 노드
|
||||
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||
('admin:map-base', 'admin:map-mgmt', '지도백데이터', 3, 1),
|
||||
('admin:map-layer', 'admin:map-mgmt', '레이어', 3, 2),
|
||||
('admin:env-ecology', 'admin:sensitive-map', '환경/생태', 3, 1),
|
||||
('admin:social-economy', 'admin:sensitive-map', '사회/경제', 3, 2),
|
||||
('admin:cleanup-equip', 'admin:coast-guard-assets', '방제장비', 3, 1),
|
||||
('admin:asset-upload', 'admin:coast-guard-assets', '자산현행화', 3, 2),
|
||||
('admin:dispersant-zone', 'admin:coast-guard-assets', '유처리제 제한구역', 3, 3),
|
||||
('admin:vessel-materials', 'admin:coast-guard-assets', '방제선 보유자재', 3, 4),
|
||||
('admin:collect-vessel-signal', 'admin:collection', '선박신호', 3, 1),
|
||||
('admin:collect-hr', 'admin:collection', '인사정보', 3, 2),
|
||||
('admin:monitor-realtime', 'admin:monitoring', '실시간 관측자료', 3, 1),
|
||||
('admin:monitor-forecast', 'admin:monitoring', '수치예측자료', 3, 2),
|
||||
('admin:monitor-vessel', 'admin:monitoring', '선박위치정보', 3, 3),
|
||||
('admin:monitor-hr', 'admin:monitoring', '인사', 3, 4)
|
||||
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||
|
||||
-- AUTH_PERM: 신규 섹션/그룹 노드에 권한 복사
|
||||
-- admin 권한이 있는 역할에 동일하게 부여 (permResolver의 parent READ gate 충족)
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
|
||||
SELECT ap.ROLE_SN, nc.RSRC_CD, ap.OPER_CD, ap.GRANT_YN
|
||||
FROM AUTH_PERM ap
|
||||
CROSS JOIN (VALUES
|
||||
('admin:board-mgmt'),
|
||||
('admin:reference'),
|
||||
('admin:external'),
|
||||
('admin:map-mgmt'),
|
||||
('admin:sensitive-map'),
|
||||
('admin:coast-guard-assets'),
|
||||
('admin:collection'),
|
||||
('admin:monitoring')
|
||||
) AS nc(RSRC_CD)
|
||||
WHERE ap.RSRC_CD = 'admin'
|
||||
ON CONFLICT (ROLE_SN, RSRC_CD, OPER_CD) DO NOTHING;
|
||||
@ -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]
|
||||
|
||||
### 변경
|
||||
|
||||
199501
frontend/public/dispersant-consider.geojson
Normal file
199501
frontend/public/dispersant-consider.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
665667
frontend/public/dispersant-restrict.geojson
Normal file
665667
frontend/public/dispersant-restrict.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -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;
|
||||
|
||||
38
frontend/src/common/store/weatherSnapshotStore.ts
Normal file
38
frontend/src/common/store/weatherSnapshotStore.ts
Normal file
@ -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() {
|
||||
|
||||
247
frontend/src/tabs/admin/components/DispersingZonePanel.tsx
Normal file
247
frontend/src/tabs/admin/components/DispersingZonePanel.tsx
Normal file
@ -0,0 +1,247 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Map, useControl } from '@vis.gl/react-maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { GeoJsonLayer } from '@deck.gl/layers';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import type { StyleSpecification } from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
||||
// CartoDB Dark Matter 스타일
|
||||
const MAP_STYLE: StyleSpecification = {
|
||||
version: 8,
|
||||
sources: {
|
||||
'carto-dark': {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
||||
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
||||
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
||||
],
|
||||
tileSize: 256,
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'carto-dark-layer',
|
||||
type: 'raster',
|
||||
source: 'carto-dark',
|
||||
minzoom: 0,
|
||||
maxzoom: 22,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const MAP_CENTER: [number, number] = [127.5, 36.0];
|
||||
const MAP_ZOOM = 5.5;
|
||||
|
||||
const CONSIDER_FILL: [number, number, number, number] = [59, 130, 246, 60];
|
||||
const CONSIDER_LINE: [number, number, number, number] = [59, 130, 246, 220];
|
||||
const RESTRICT_FILL: [number, number, number, number] = [239, 68, 68, 60];
|
||||
const RESTRICT_LINE: [number, number, number, number] = [239, 68, 68, 220];
|
||||
|
||||
type ZoneKey = 'consider' | 'restrict';
|
||||
|
||||
// deck.gl 오버레이 컴포넌트
|
||||
function DeckGLOverlay({ layers }: { layers: Layer[] }) {
|
||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
||||
overlay.setProps({ layers });
|
||||
return null;
|
||||
}
|
||||
|
||||
// 구역 설명 데이터
|
||||
const ZONE_INFO: Record<ZoneKey, { label: string; rows: { key: string; value: string }[] }> = {
|
||||
consider: {
|
||||
label: '사용고려해역',
|
||||
rows: [
|
||||
{ key: '수심', value: '20m 이상 ※ (IMO) 대형 20m, 중소형 10m 이상' },
|
||||
{
|
||||
key: '사용거리',
|
||||
value:
|
||||
'해안 2km, 중요 민감자원으로부터 5km 이상 떨어진 경우 ※ (IMO) 대형 1km, 중소형 0.5km 이상',
|
||||
},
|
||||
{ key: '사용승인(절차)', value: '현장 방제책임자 재량 사용 ※ (IMO) 의결정 절차 지침' },
|
||||
],
|
||||
},
|
||||
restrict: {
|
||||
label: '사용제한해역',
|
||||
rows: [
|
||||
{ key: '수심', value: '수심 10m 이하' },
|
||||
{
|
||||
key: '사용거리',
|
||||
value:
|
||||
'어장·양식장, 발전소 취수구, 종묘배양장 및 폐쇄성 해역 특정해역중 수자원 보호구역',
|
||||
},
|
||||
{
|
||||
key: '사용승인(절차)',
|
||||
value:
|
||||
'심의위원회 승인을 받아 관할 방제책임기관 또는 방제대책 본부장이 결정 ※ 긴급한 경우 先사용, 後심의',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const DispersingZonePanel = () => {
|
||||
const [showConsider, setShowConsider] = useState(true);
|
||||
const [showRestrict, setShowRestrict] = useState(true);
|
||||
const [expandedZone, setExpandedZone] = useState<ZoneKey | null>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [considerData, setConsiderData] = useState<any>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [restrictData, setRestrictData] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/dispersant-consider.geojson')
|
||||
.then(r => r.json())
|
||||
.then(setConsiderData)
|
||||
.catch(() => {/* GeoJSON 없을 때 빈 상태 유지 */});
|
||||
|
||||
fetch('/dispersant-restrict.geojson')
|
||||
.then(r => r.json())
|
||||
.then(setRestrictData)
|
||||
.catch(() => {/* GeoJSON 없을 때 빈 상태 유지 */});
|
||||
}, []);
|
||||
|
||||
const layers: Layer[] = [
|
||||
...(showConsider && considerData
|
||||
? [
|
||||
new GeoJsonLayer({
|
||||
id: 'dispersant-consider',
|
||||
data: considerData,
|
||||
getFillColor: CONSIDER_FILL,
|
||||
getLineColor: CONSIDER_LINE,
|
||||
lineWidthMinPixels: 1.5,
|
||||
pickable: false,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(showRestrict && restrictData
|
||||
? [
|
||||
new GeoJsonLayer({
|
||||
id: 'dispersant-restrict',
|
||||
data: restrictData,
|
||||
getFillColor: RESTRICT_FILL,
|
||||
getLineColor: RESTRICT_LINE,
|
||||
lineWidthMinPixels: 1.5,
|
||||
pickable: false,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const handleToggleExpand = (zone: ZoneKey) => {
|
||||
setExpandedZone(prev => (prev === zone ? null : zone));
|
||||
};
|
||||
|
||||
const renderZoneCard = (zone: ZoneKey) => {
|
||||
const info = ZONE_INFO[zone];
|
||||
const isConsider = zone === 'consider';
|
||||
const showLayer = isConsider ? showConsider : showRestrict;
|
||||
const setShowLayer = isConsider ? setShowConsider : setShowRestrict;
|
||||
const swatchColor = isConsider ? 'bg-blue-500' : 'bg-red-500';
|
||||
const isExpanded = expandedZone === zone;
|
||||
|
||||
return (
|
||||
<div key={zone} className="border border-border rounded-lg overflow-hidden">
|
||||
{/* 카드 헤더 */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-[rgba(255,255,255,0.03)] transition-colors"
|
||||
onClick={() => handleToggleExpand(zone)}
|
||||
>
|
||||
<span className={`w-3 h-3 rounded-sm shrink-0 ${swatchColor}`} />
|
||||
<span className="flex-1 text-xs font-semibold text-text-1 font-korean">{info.label}</span>
|
||||
{/* 토글 스위치 */}
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setShowLayer(prev => !prev);
|
||||
}}
|
||||
title={showLayer ? '레이어 숨기기' : '레이어 표시'}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 ${
|
||||
showLayer
|
||||
? 'bg-primary-cyan'
|
||||
: 'bg-[rgba(255,255,255,0.08)] border border-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
||||
showLayer ? 'translate-x-[18px]' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{/* 펼침 화살표 */}
|
||||
<span className="text-text-3 text-[10px] shrink-0">{isExpanded ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
|
||||
{/* 펼침 영역 */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border px-3 py-3">
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{info.rows.map(row => (
|
||||
<tr key={row.key} className="border-b border-border last:border-0">
|
||||
<td className="py-2 pr-2 text-[11px] text-text-3 font-korean whitespace-nowrap align-top w-24">
|
||||
{row.key}
|
||||
</td>
|
||||
<td className="py-2 text-[11px] text-text-2 font-korean leading-relaxed">
|
||||
{row.value}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 지도 영역 */}
|
||||
<div className="flex-1 relative">
|
||||
<Map
|
||||
initialViewState={{
|
||||
longitude: MAP_CENTER[0],
|
||||
latitude: MAP_CENTER[1],
|
||||
zoom: MAP_ZOOM,
|
||||
}}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
mapStyle={MAP_STYLE}
|
||||
>
|
||||
<DeckGLOverlay layers={layers} />
|
||||
</Map>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="absolute bottom-4 left-4 bg-bg-1 border border-border rounded-lg px-3 py-2 flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-sm bg-blue-500 opacity-80" />
|
||||
<span className="text-[11px] text-text-2 font-korean">사용고려해역</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-sm bg-red-500 opacity-80" />
|
||||
<span className="text-[11px] text-text-2 font-korean">사용제한해역</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 패널 */}
|
||||
<div className="w-[280px] bg-bg-1 border-l border-border flex flex-col overflow-hidden shrink-0">
|
||||
{/* 헤더 */}
|
||||
<div className="px-4 py-4 border-b border-border shrink-0">
|
||||
<h1 className="text-sm font-bold text-text-1 font-korean">유처리제 제한구역</h1>
|
||||
<p className="text-[11px] text-text-3 mt-0.5 font-korean">해양환경관리법 기준</p>
|
||||
</div>
|
||||
|
||||
{/* 구역 카드 목록 */}
|
||||
<div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2">
|
||||
{renderZoneCard('consider')}
|
||||
{renderZoneCard('restrict')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DispersingZonePanel;
|
||||
304
frontend/src/tabs/admin/components/SensitiveLayerPanel.tsx
Normal file
304
frontend/src/tabs/admin/components/SensitiveLayerPanel.tsx
Normal file
@ -0,0 +1,304 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '@common/services/api';
|
||||
|
||||
interface SensitiveLayerPanelProps {
|
||||
categoryCode: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface LayerAdminItem {
|
||||
layerCd: string;
|
||||
upLayerCd: string | null;
|
||||
layerFullNm: string;
|
||||
layerNm: string;
|
||||
layerLevel: number;
|
||||
wmsLayerNm: string | null;
|
||||
useYn: string;
|
||||
sortOrd: number;
|
||||
regDtm: string | null;
|
||||
}
|
||||
|
||||
interface LayerListResponse {
|
||||
items: LayerAdminItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
async function fetchSensitiveLayers(
|
||||
page: number,
|
||||
search: string,
|
||||
useYn: string,
|
||||
rootCd: string,
|
||||
): Promise<LayerListResponse> {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE), rootCd });
|
||||
if (search) params.set('search', search);
|
||||
if (useYn) params.set('useYn', useYn);
|
||||
const res = await api.get<LayerListResponse>(`/layers/admin/list?${params}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function toggleLayerUse(layerCd: string): Promise<{ layerCd: string; useYn: string }> {
|
||||
const res = await api.post<{ layerCd: string; useYn: string }>('/layers/admin/toggle-use', { layerCd });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// ---------- SensitiveLayerPanel ----------
|
||||
|
||||
const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps) => {
|
||||
const [items, setItems] = useState<LayerAdminItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [toggling, setToggling] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [appliedSearch, setAppliedSearch] = useState('');
|
||||
const [filterUseYn, setFilterUseYn] = useState('');
|
||||
|
||||
const load = useCallback(async (p: number, search: string, useYn: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetchSensitiveLayers(p, search, useYn, categoryCode);
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
setTotalPages(res.totalPages);
|
||||
} catch {
|
||||
setError('레이어 목록을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [categoryCode]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setAppliedSearch('');
|
||||
setFilterUseYn('');
|
||||
setSearchInput('');
|
||||
}, [categoryCode]);
|
||||
|
||||
useEffect(() => {
|
||||
load(page, appliedSearch, filterUseYn);
|
||||
}, [load, page, appliedSearch, filterUseYn]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setAppliedSearch(searchInput);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleToggle = async (layerCd: string) => {
|
||||
if (toggling) return;
|
||||
setToggling(layerCd);
|
||||
try {
|
||||
const result = await toggleLayerUse(layerCd);
|
||||
setItems(prev =>
|
||||
prev.map(item =>
|
||||
item.layerCd === result.layerCd ? { ...item, useYn: result.useYn } : item
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
setError('사용여부 변경에 실패했습니다.');
|
||||
} finally {
|
||||
setToggling(null);
|
||||
}
|
||||
};
|
||||
|
||||
const buildPageButtons = () => {
|
||||
const buttons: (number | 'ellipsis')[] = [];
|
||||
const delta = 2;
|
||||
const left = page - delta;
|
||||
const right = page + delta;
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (i === 1 || i === totalPages || (i >= left && i <= right)) {
|
||||
buttons.push(i);
|
||||
} else if (buttons[buttons.length - 1] !== 'ellipsis') {
|
||||
buttons.push('ellipsis');
|
||||
}
|
||||
}
|
||||
return buttons;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-text-1 font-korean">{title}</h1>
|
||||
<p className="text-xs text-text-3 mt-1 font-korean">총 {total}개</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="레이어코드 / 레이어명 검색"
|
||||
className="flex-1 px-3 py-1.5 text-xs bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
/>
|
||||
<select
|
||||
value={filterUseYn}
|
||||
onChange={e => setFilterUseYn(e.target.value)}
|
||||
className="px-2 py-1.5 text-xs bg-bg-2 border border-border rounded text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="Y">사용</option>
|
||||
<option value="N">미사용</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="px-3 py-1.5 text-xs border border-border text-text-2 rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
>
|
||||
검색
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오류 메시지 */}
|
||||
{error && (
|
||||
<div className="px-6 py-2 text-xs text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-border shrink-0 font-korean">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-text-3 text-sm font-korean">
|
||||
불러오는 중...
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-1 sticky top-0 z-10">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-10 whitespace-nowrap">번호</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-mono">레이어코드</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">레이어명</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">레이어전체명</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-12 whitespace-nowrap">레벨</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-mono">WMS레이어명</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-16">정렬</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-28">등록일시</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-20">사용여부</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-12 text-center text-text-3 text-sm font-korean">
|
||||
데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((item, idx) => (
|
||||
<tr
|
||||
key={item.layerCd}
|
||||
className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 text-xs text-text-3 font-mono">
|
||||
{(page - 1) * PAGE_SIZE + idx + 1}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-text-2 font-mono">
|
||||
{item.layerCd}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-text-1 font-korean">
|
||||
{item.layerNm}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-text-2 font-korean max-w-[200px]">
|
||||
<span className="block truncate" title={item.layerFullNm}>
|
||||
{item.layerFullNm}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-semibold bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-[rgba(6,182,212,0.3)]">
|
||||
{item.layerLevel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-text-2 font-mono">
|
||||
{item.wmsLayerNm ?? <span className="text-text-3">-</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-text-3 text-center font-mono">
|
||||
{item.sortOrd}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-text-3 font-mono">
|
||||
{item.regDtm ?? '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => handleToggle(item.layerCd)}
|
||||
disabled={toggling === item.layerCd}
|
||||
title={item.useYn === 'Y' ? '사용 중 (클릭하여 비활성화)' : '미사용 (클릭하여 활성화)'}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-50 ${
|
||||
item.useYn === 'Y'
|
||||
? 'bg-primary-cyan'
|
||||
: 'bg-[rgba(255,255,255,0.08)] border border-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
||||
item.useYn === 'Y' ? 'translate-x-[18px]' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{!loading && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-border bg-bg-1 shrink-0">
|
||||
<span className="text-[11px] text-text-3 font-korean">
|
||||
{(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} / {total}개
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-2.5 py-1 text-[11px] border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
{buildPageButtons().map((btn, i) =>
|
||||
btn === 'ellipsis' ? (
|
||||
<span key={`e${i}`} className="px-1.5 text-[11px] text-text-3">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={btn}
|
||||
onClick={() => setPage(btn)}
|
||||
className={`px-2.5 py-1 text-[11px] rounded transition-all ${
|
||||
page === btn
|
||||
? 'bg-primary-cyan text-bg-0 font-semibold shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||||
: 'border border-border text-text-3 hover:bg-[rgba(255,255,255,0.04)]'
|
||||
}`}
|
||||
>
|
||||
{btn}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-2.5 py-1 text-[11px] border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SensitiveLayerPanel;
|
||||
@ -54,7 +54,6 @@ export const ADMIN_MENU: AdminMenuItem[] = [
|
||||
{ id: 'asset-upload', label: '자산현행화' },
|
||||
{ id: 'dispersant-zone', label: '유처리제 제한구역' },
|
||||
{ id: 'vessel-materials', label: '방제선 보유자재' },
|
||||
{ id: 'cleanup-resource', label: '방제자원' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -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
|
||||
|
||||
98
frontend/src/tabs/weather/services/weatherUtils.ts
Normal file
98
frontend/src/tabs/weather/services/weatherUtils.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user