Merge pull request 'feat(reports): ������ ���� ��ȭ �� ������ ���� Ʈ�� Ȯ��' (#112) from feature/report into develop
This commit is contained in:
커밋
087fe57e0d
@ -5,29 +5,29 @@
|
|||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(curl -s *)",
|
"Bash(npm run *)",
|
||||||
"Bash(fnm *)",
|
"Bash(npm install *)",
|
||||||
"Bash(git add *)",
|
"Bash(npm test *)",
|
||||||
|
"Bash(npx *)",
|
||||||
|
"Bash(node *)",
|
||||||
|
"Bash(git status)",
|
||||||
|
"Bash(git diff *)",
|
||||||
|
"Bash(git log *)",
|
||||||
"Bash(git branch *)",
|
"Bash(git branch *)",
|
||||||
"Bash(git checkout *)",
|
"Bash(git checkout *)",
|
||||||
|
"Bash(git add *)",
|
||||||
"Bash(git commit *)",
|
"Bash(git commit *)",
|
||||||
"Bash(git config *)",
|
|
||||||
"Bash(git diff *)",
|
|
||||||
"Bash(git fetch *)",
|
|
||||||
"Bash(git log *)",
|
|
||||||
"Bash(git merge *)",
|
|
||||||
"Bash(git pull *)",
|
"Bash(git pull *)",
|
||||||
|
"Bash(git fetch *)",
|
||||||
|
"Bash(git merge *)",
|
||||||
|
"Bash(git stash *)",
|
||||||
"Bash(git remote *)",
|
"Bash(git remote *)",
|
||||||
|
"Bash(git config *)",
|
||||||
"Bash(git rev-parse *)",
|
"Bash(git rev-parse *)",
|
||||||
"Bash(git show *)",
|
"Bash(git show *)",
|
||||||
"Bash(git stash *)",
|
|
||||||
"Bash(git status)",
|
|
||||||
"Bash(git tag *)",
|
"Bash(git tag *)",
|
||||||
"Bash(node *)",
|
"Bash(curl -s *)",
|
||||||
"Bash(npm install *)",
|
"Bash(fnm *)"
|
||||||
"Bash(npm run *)",
|
|
||||||
"Bash(npm test *)",
|
|
||||||
"Bash(npx *)"
|
|
||||||
],
|
],
|
||||||
"deny": [
|
"deny": [
|
||||||
"Bash(git push --force*)",
|
"Bash(git push --force*)",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"applied_global_version": "1.6.1",
|
"applied_global_version": "1.6.1",
|
||||||
"applied_date": "2026-03-19",
|
"applied_date": "2026-03-20",
|
||||||
"project_type": "react-ts",
|
"project_type": "react-ts",
|
||||||
"gitea_url": "https://gitea.gc-si.dev",
|
"gitea_url": "https://gitea.gc-si.dev",
|
||||||
"custom_pre_commit": true
|
"custom_pre_commit": true
|
||||||
|
|||||||
@ -92,7 +92,7 @@ router.get('/:sn', requireAuth, requirePermission('reports', 'READ'), async (req
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => {
|
router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => {
|
||||||
try {
|
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({
|
const result = await createReport({
|
||||||
tmplSn,
|
tmplSn,
|
||||||
ctgrSn,
|
ctgrSn,
|
||||||
@ -102,6 +102,8 @@ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req
|
|||||||
sttsCd,
|
sttsCd,
|
||||||
authorId: req.user!.sub,
|
authorId: req.user!.sub,
|
||||||
mapCaptureImg,
|
mapCaptureImg,
|
||||||
|
step3MapImg,
|
||||||
|
step6MapImg,
|
||||||
sections,
|
sections,
|
||||||
});
|
});
|
||||||
res.status(201).json(result);
|
res.status(201).json(result);
|
||||||
@ -125,8 +127,8 @@ router.post('/:sn/update', requireAuth, requirePermission('reports', 'UPDATE'),
|
|||||||
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
|
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg } = req.body;
|
const { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg, step3MapImg, step6MapImg } = req.body;
|
||||||
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg }, req.user!.sub);
|
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg, step3MapImg, step6MapImg }, req.user!.sub);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AuthError) {
|
if (err instanceof AuthError) {
|
||||||
|
|||||||
@ -76,6 +76,8 @@ interface ReportDetail extends ReportListItem {
|
|||||||
acdntSn: number | null;
|
acdntSn: number | null;
|
||||||
sections: SectionData[];
|
sections: SectionData[];
|
||||||
mapCaptureImg: string | null;
|
mapCaptureImg: string | null;
|
||||||
|
step3MapImg: string | null;
|
||||||
|
step6MapImg: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ListReportsInput {
|
interface ListReportsInput {
|
||||||
@ -103,6 +105,8 @@ interface CreateReportInput {
|
|||||||
sttsCd?: string;
|
sttsCd?: string;
|
||||||
authorId: string;
|
authorId: string;
|
||||||
mapCaptureImg?: string;
|
mapCaptureImg?: string;
|
||||||
|
step3MapImg?: string;
|
||||||
|
step6MapImg?: string;
|
||||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,6 +116,8 @@ interface UpdateReportInput {
|
|||||||
sttsCd?: string;
|
sttsCd?: string;
|
||||||
acdntSn?: number | null;
|
acdntSn?: number | null;
|
||||||
mapCaptureImg?: string | null;
|
mapCaptureImg?: string | null;
|
||||||
|
step3MapImg?: string | null;
|
||||||
|
step6MapImg?: string | null;
|
||||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
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.TITLE, r.JRSD_CD, r.STTS_CD,
|
||||||
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
||||||
r.REG_DTM, r.MDFCN_DTM,
|
r.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
|
FROM REPORT r
|
||||||
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
|
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
|
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,
|
c.CTGR_CD, c.CTGR_NM,
|
||||||
r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN,
|
r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN,
|
||||||
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
||||||
r.REG_DTM, r.MDFCN_DTM, r.MAP_CAPTURE_IMG,
|
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 <> '' 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
|
FROM REPORT r
|
||||||
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
|
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
|
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,
|
regDtm: r.reg_dtm,
|
||||||
mdfcnDtm: r.mdfcn_dtm,
|
mdfcnDtm: r.mdfcn_dtm,
|
||||||
mapCaptureImg: r.map_capture_img,
|
mapCaptureImg: r.map_capture_img,
|
||||||
|
step3MapImg: r.step3_map_img,
|
||||||
|
step6MapImg: r.step6_map_img,
|
||||||
hasMapCapture: r.has_map_capture,
|
hasMapCapture: r.has_map_capture,
|
||||||
sections: sectRes.rows.map((s) => ({
|
sections: sectRes.rows.map((s) => ({
|
||||||
sectCd: s.sect_cd,
|
sectCd: s.sect_cd,
|
||||||
@ -359,8 +373,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
|
|||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
const res = await client.query(
|
const res = await client.query(
|
||||||
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, MAP_CAPTURE_IMG)
|
`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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING REPORT_SN`,
|
RETURNING REPORT_SN`,
|
||||||
[
|
[
|
||||||
input.tmplSn || null,
|
input.tmplSn || null,
|
||||||
@ -371,6 +385,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
|
|||||||
input.sttsCd || 'DRAFT',
|
input.sttsCd || 'DRAFT',
|
||||||
input.authorId,
|
input.authorId,
|
||||||
input.mapCaptureImg || null,
|
input.mapCaptureImg || null,
|
||||||
|
input.step3MapImg || null,
|
||||||
|
input.step6MapImg || null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
const reportSn = res.rows[0].report_sn;
|
const reportSn = res.rows[0].report_sn;
|
||||||
@ -446,6 +462,14 @@ export async function updateReport(
|
|||||||
sets.push(`MAP_CAPTURE_IMG = $${idx++}`);
|
sets.push(`MAP_CAPTURE_IMG = $${idx++}`);
|
||||||
params.push(input.mapCaptureImg);
|
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);
|
params.push(reportSn);
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|||||||
@ -191,6 +191,15 @@ router.get('/admin/list', requireAuth, requireRole('ADMIN'), async (req, res) =>
|
|||||||
conditions.push(`USE_YN = $${params.length}`)
|
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 ')}` : ''
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||||
|
|
||||||
// 데이터 쿼리 파라미터: WHERE 조건 + LIMIT + OFFSET
|
// 데이터 쿼리 파라미터: WHERE 조건 + LIMIT + OFFSET
|
||||||
|
|||||||
@ -585,6 +585,7 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => {
|
|||||||
status: 'DONE' | 'ERROR'
|
status: 'DONE' | 'ERROR'
|
||||||
trajectory?: ReturnType<typeof transformResult>['trajectory']
|
trajectory?: ReturnType<typeof transformResult>['trajectory']
|
||||||
summary?: ReturnType<typeof transformResult>['summary']
|
summary?: ReturnType<typeof transformResult>['summary']
|
||||||
|
stepSummaries?: ReturnType<typeof transformResult>['stepSummaries']
|
||||||
centerPoints?: ReturnType<typeof transformResult>['centerPoints']
|
centerPoints?: ReturnType<typeof transformResult>['centerPoints']
|
||||||
windData?: ReturnType<typeof transformResult>['windData']
|
windData?: ReturnType<typeof transformResult>['windData']
|
||||||
hydrData?: ReturnType<typeof transformResult>['hydrData']
|
hydrData?: ReturnType<typeof transformResult>['hydrData']
|
||||||
@ -656,9 +657,9 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => {
|
|||||||
WHERE PRED_EXEC_SN=$2`,
|
WHERE PRED_EXEC_SN=$2`,
|
||||||
[JSON.stringify(pythonData.result), predExecSn]
|
[JSON.stringify(pythonData.result), predExecSn]
|
||||||
)
|
)
|
||||||
const { trajectory, summary, centerPoints, windData, hydrData } =
|
const { trajectory, summary, stepSummaries, centerPoints, windData, hydrData } =
|
||||||
transformResult(pythonData.result, model)
|
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 {
|
try {
|
||||||
const rawResult = await runModelSync(jobId!, predExecSn, apiUrl)
|
const rawResult = await runModelSync(jobId!, predExecSn, apiUrl)
|
||||||
const { trajectory, summary, centerPoints, windData, hydrData } = transformResult(rawResult, model)
|
const { trajectory, summary, stepSummaries, centerPoints, windData, hydrData } = transformResult(rawResult, model)
|
||||||
return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, centerPoints, windData, hydrData }
|
return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, stepSummaries, centerPoints, windData, hydrData }
|
||||||
} catch (syncErr) {
|
} catch (syncErr) {
|
||||||
return { model, execSn: predExecSn, status: 'ERROR', error: (syncErr as Error).message }
|
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 }
|
? { value: step.hydr_data, grid: step.hydr_grid }
|
||||||
: null
|
: 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
|
export default router
|
||||||
|
|||||||
@ -77,7 +77,8 @@ CREATE TABLE IF NOT EXISTS REPORT (
|
|||||||
USE_YN CHAR(1) DEFAULT 'Y',
|
USE_YN CHAR(1) DEFAULT 'Y',
|
||||||
REG_DTM TIMESTAMPTZ DEFAULT NOW(),
|
REG_DTM TIMESTAMPTZ DEFAULT NOW(),
|
||||||
MDFCN_DTM TIMESTAMPTZ,
|
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'))
|
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]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 보고서: 기능 강화 (HWPX 내보내기, 확산 지도 패널, 보고서 생성기 개선)
|
||||||
|
- 관리자: 권한 트리 확장 (게시판관리·기준정보·연계관리 섹션 추가)
|
||||||
|
- 관리자: 유처리제 제한구역 패널, 민감자원 레이어 패널 추가
|
||||||
|
- 기상: 날씨 스냅샷 스토어, 유틸리티 모듈 추가
|
||||||
|
|
||||||
|
### 문서
|
||||||
|
- PREDICTION-GUIDE.md 삭제
|
||||||
|
|
||||||
## [2026-03-20.2]
|
## [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;
|
windSpeed: string;
|
||||||
waveHeight: string;
|
waveHeight: string;
|
||||||
temp: string;
|
temp: string;
|
||||||
|
pressure?: string;
|
||||||
|
visibility?: string;
|
||||||
|
salinity?: string;
|
||||||
|
waveMaxHeight?: string;
|
||||||
|
wavePeriod?: string;
|
||||||
|
currentDir?: string;
|
||||||
|
currentSpeed?: string;
|
||||||
} | null;
|
} | null;
|
||||||
spread: {
|
spread: {
|
||||||
kosps: string;
|
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 AssetUploadPanel from './AssetUploadPanel';
|
||||||
import MapBasePanel from './MapBasePanel';
|
import MapBasePanel from './MapBasePanel';
|
||||||
import LayerPanel from './LayerPanel';
|
import LayerPanel from './LayerPanel';
|
||||||
|
import SensitiveLayerPanel from './SensitiveLayerPanel';
|
||||||
|
import DispersingZonePanel from './DispersingZonePanel';
|
||||||
|
|
||||||
/** 기존 패널이 있는 메뉴 ID 매핑 */
|
/** 기존 패널이 있는 메뉴 ID 매핑 */
|
||||||
const PANEL_MAP: Record<string, () => JSX.Element> = {
|
const PANEL_MAP: Record<string, () => JSX.Element> = {
|
||||||
@ -27,6 +29,9 @@ const PANEL_MAP: Record<string, () => JSX.Element> = {
|
|||||||
'asset-upload': () => <AssetUploadPanel />,
|
'asset-upload': () => <AssetUploadPanel />,
|
||||||
'map-base': () => <MapBasePanel />,
|
'map-base': () => <MapBasePanel />,
|
||||||
'map-layer': () => <LayerPanel />,
|
'map-layer': () => <LayerPanel />,
|
||||||
|
'env-ecology': () => <SensitiveLayerPanel categoryCode="LYR001002001" title="환경/생태" />,
|
||||||
|
'social-economy': () => <SensitiveLayerPanel categoryCode="LYR001002002" title="사회/경제" />,
|
||||||
|
'dispersant-zone': () => <DispersingZonePanel />,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AdminView() {
|
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: 'asset-upload', label: '자산현행화' },
|
||||||
{ id: 'dispersant-zone', label: '유처리제 제한구역' },
|
{ id: 'dispersant-zone', label: '유처리제 제한구역' },
|
||||||
{ id: 'vessel-materials', label: '방제선 보유자재' },
|
{ id: 'vessel-materials', label: '방제선 보유자재' },
|
||||||
{ id: 'cleanup-resource', label: '방제자원' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import { BacktrackModal } from './BacktrackModal'
|
|||||||
import { RecalcModal } from './RecalcModal'
|
import { RecalcModal } from './RecalcModal'
|
||||||
import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar'
|
import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar'
|
||||||
import { useSubMenu, navigateToTab, setReportGenCategory, setOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu'
|
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 { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine'
|
||||||
import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||||
import { TOTAL_REPLAY_FRAMES } 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 newWindDataByModel: Record<string, WindPoint[][]> = {};
|
||||||
const newHydrDataByModel: Record<string, (HydrDataStep | null)[]> = {};
|
const newHydrDataByModel: Record<string, (HydrDataStep | null)[]> = {};
|
||||||
const newSummaryByModel: Record<string, SimulationSummary> = {};
|
const newSummaryByModel: Record<string, SimulationSummary> = {};
|
||||||
|
const newStepSummariesByModel: Record<string, SimulationSummary[]> = {};
|
||||||
const errors: string[] = [];
|
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') {
|
if (status === 'ERROR') {
|
||||||
errors.push(error || `${model} 분석 중 오류가 발생했습니다.`);
|
errors.push(error || `${model} 분석 중 오류가 발생했습니다.`);
|
||||||
return;
|
return;
|
||||||
@ -760,6 +763,7 @@ export function OilSpillView() {
|
|||||||
newSummaryByModel[model] = summary;
|
newSummaryByModel[model] = summary;
|
||||||
if (model === 'OpenDrift' || !latestSummary) latestSummary = summary;
|
if (model === 'OpenDrift' || !latestSummary) latestSummary = summary;
|
||||||
}
|
}
|
||||||
|
if (stepSummaries) newStepSummariesByModel[model] = stepSummaries;
|
||||||
if (windData) newWindDataByModel[model] = windData;
|
if (windData) newWindDataByModel[model] = windData;
|
||||||
if (hydrData) newHydrDataByModel[model] = hydrData;
|
if (hydrData) newHydrDataByModel[model] = hydrData;
|
||||||
if (centerPoints) {
|
if (centerPoints) {
|
||||||
@ -788,6 +792,7 @@ export function OilSpillView() {
|
|||||||
setWindDataByModel(newWindDataByModel);
|
setWindDataByModel(newWindDataByModel);
|
||||||
setHydrDataByModel(newHydrDataByModel);
|
setHydrDataByModel(newHydrDataByModel);
|
||||||
setSummaryByModel(newSummaryByModel);
|
setSummaryByModel(newSummaryByModel);
|
||||||
|
setStepSummariesByModel(newStepSummariesByModel);
|
||||||
const booms = generateAIBoomLines(merged, effectiveCoord, algorithmSettings);
|
const booms = generateAIBoomLines(merged, effectiveCoord, algorithmSettings);
|
||||||
setBoomLines(booms);
|
setBoomLines(booms);
|
||||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES);
|
setSensitiveResources(DEMO_SENSITIVE_RESOURCES);
|
||||||
@ -800,6 +805,11 @@ export function OilSpillView() {
|
|||||||
setSimulationError(errors.join('; '));
|
setSimulationError(errors.join('; '));
|
||||||
} else {
|
} else {
|
||||||
simulationSucceeded = true;
|
simulationSucceeded = true;
|
||||||
|
if (effectiveCoord) {
|
||||||
|
fetchWeatherSnapshotForCoord(effectiveCoord.lat, effectiveCoord.lon)
|
||||||
|
.then(snapshot => useWeatherSnapshotStore.getState().setSnapshot(snapshot))
|
||||||
|
.catch(err => console.warn('[weather] 기상 데이터 수집 실패:', err));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg =
|
const msg =
|
||||||
@ -827,6 +837,7 @@ export function OilSpillView() {
|
|||||||
accidentTime ||
|
accidentTime ||
|
||||||
'';
|
'';
|
||||||
const wx = analysisDetail?.weather?.[0] ?? null;
|
const wx = analysisDetail?.weather?.[0] ?? null;
|
||||||
|
const weatherSnapshot = useWeatherSnapshotStore.getState().snapshot;
|
||||||
|
|
||||||
const payload: OilReportPayload = {
|
const payload: OilReportPayload = {
|
||||||
incident: {
|
incident: {
|
||||||
@ -854,9 +865,27 @@ export function OilSpillView() {
|
|||||||
coastLength: simulationSummary ? `${simulationSummary.pollutionCoastLength.toFixed(2)} km` : '—',
|
coastLength: simulationSummary ? `${simulationSummary.pollutionCoastLength.toFixed(2)} km` : '—',
|
||||||
oilType: OIL_TYPE_CODE[oilType] || oilType,
|
oilType: OIL_TYPE_CODE[oilType] || oilType,
|
||||||
},
|
},
|
||||||
weather: wx
|
weather: (() => {
|
||||||
? { windDir: wx.wind, windSpeed: wx.wind, waveHeight: wx.wave, temp: wx.temp }
|
if (weatherSnapshot) {
|
||||||
: null,
|
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: (() => {
|
spread: (() => {
|
||||||
const fmt = (model: string) => {
|
const fmt = (model: string) => {
|
||||||
const s = summaryByModel[model];
|
const s = summaryByModel[model];
|
||||||
|
|||||||
@ -190,6 +190,7 @@ export interface RunModelSyncResult {
|
|||||||
status: 'DONE' | 'ERROR';
|
status: 'DONE' | 'ERROR';
|
||||||
trajectory?: OilParticle[];
|
trajectory?: OilParticle[];
|
||||||
summary?: SimulationSummary;
|
summary?: SimulationSummary;
|
||||||
|
stepSummaries?: SimulationSummary[];
|
||||||
centerPoints?: CenterPoint[];
|
centerPoints?: CenterPoint[];
|
||||||
windData?: WindPoint[][];
|
windData?: WindPoint[][];
|
||||||
hydrData?: (HydrDataStep | null)[];
|
hydrData?: (HydrDataStep | null)[];
|
||||||
|
|||||||
@ -39,6 +39,8 @@ export interface OilSpillReportData {
|
|||||||
recovery: { shipName: string; period: string }[]
|
recovery: { shipName: string; period: string }[]
|
||||||
result: { spillTotal: string; weatheredTotal: string; recoveredTotal: string; seaRemainTotal: string; coastAttachTotal: string }
|
result: { spillTotal: string; weatheredTotal: string; recoveredTotal: string; seaRemainTotal: string; coastAttachTotal: string }
|
||||||
capturedMapImage?: string;
|
capturedMapImage?: string;
|
||||||
|
step3MapImage?: string;
|
||||||
|
step6MapImage?: string;
|
||||||
hasMapCapture?: boolean;
|
hasMapCapture?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -268,8 +270,14 @@ function Page2({ data, editing, onChange }: { data: OilSpillReportData; editing:
|
|||||||
|
|
||||||
<div style={S.sectionTitle}>3. 유출유 확산예측</div>
|
<div style={S.sectionTitle}>3. 유출유 확산예측</div>
|
||||||
<div className="flex gap-4 mb-4">
|
<div className="flex gap-4 mb-4">
|
||||||
<div style={S.mapPlaceholder}>확산예측 3시간 지도</div>
|
{data.step3MapImage
|
||||||
<div style={S.mapPlaceholder}>확산예측 6시간 지도</div>
|
? <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>
|
||||||
<div style={S.subHeader}>시간별 상세정보</div>
|
<div style={S.subHeader}>시간별 상세정보</div>
|
||||||
<table style={S.table}>
|
<table style={S.table}>
|
||||||
|
|||||||
@ -4,12 +4,24 @@ import type { OilReportPayload } from '@common/hooks/useSubMenu';
|
|||||||
|
|
||||||
interface OilSpreadMapPanelProps {
|
interface OilSpreadMapPanelProps {
|
||||||
mapData: OilReportPayload['mapData'];
|
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;
|
onCapture: (dataUrl: string) => void;
|
||||||
onReset: () => 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 captureRef = useRef<(() => Promise<string | null>) | null>(null);
|
||||||
const [isCapturing, setIsCapturing] = useState(false);
|
const [isCapturing, setIsCapturing] = useState(false);
|
||||||
|
|
||||||
@ -18,29 +30,29 @@ const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSp
|
|||||||
setIsCapturing(true);
|
setIsCapturing(true);
|
||||||
const dataUrl = await captureRef.current();
|
const dataUrl = await captureRef.current();
|
||||||
setIsCapturing(false);
|
setIsCapturing(false);
|
||||||
if (dataUrl) {
|
if (dataUrl) onCapture(dataUrl);
|
||||||
onCapture(dataUrl);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!mapData) {
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-[280px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean mb-4">
|
<div className="flex flex-col">
|
||||||
확산 예측 데이터가 없습니다. 예측 탭에서 시뮬레이션을 실행 후 보고서를 생성하세요.
|
{/* 라벨 */}
|
||||||
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
|
<span
|
||||||
|
className="text-[11px] font-bold font-korean px-2 py-0.5 rounded"
|
||||||
|
style={{ background: 'rgba(6,182,212,0.12)', color: '#06b6d4', border: '1px solid rgba(6,182,212,0.25)' }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
{/* 지도 + 캡처 오버레이 */}
|
||||||
<div className="mb-4">
|
<div className="relative rounded-lg border border-border overflow-hidden" style={{ height: '300px' }}>
|
||||||
{/* 지도 + 오버레이 컨테이너 — MapView 항상 마운트 유지 (deck.gl rAF race condition 방지) */}
|
|
||||||
<div className="relative w-full rounded-lg border border-border overflow-hidden" style={{ height: '620px' }}>
|
|
||||||
<MapView
|
<MapView
|
||||||
center={mapData.center}
|
center={mapData.center}
|
||||||
zoom={mapData.zoom}
|
zoom={mapData.zoom}
|
||||||
incidentCoord={{ lat: mapData.center[0], lon: mapData.center[1] }}
|
incidentCoord={{ lat: mapData.center[0], lon: mapData.center[1] }}
|
||||||
oilTrajectory={mapData.trajectory}
|
oilTrajectory={mapData.trajectory}
|
||||||
externalCurrentTime={mapData.currentStep}
|
externalCurrentTime={step}
|
||||||
centerPoints={mapData.centerPoints}
|
centerPoints={mapData.centerPoints}
|
||||||
showBeached={true}
|
showBeached={true}
|
||||||
showTimeLabel={true}
|
showTimeLabel={true}
|
||||||
@ -50,27 +62,26 @@ const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSp
|
|||||||
lightMode
|
lightMode
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 캡처 이미지 오버레이 — 우측 상단 */}
|
{captured && (
|
||||||
{capturedImage && (
|
<div className="absolute top-2 right-2 z-10" style={{ width: '180px' }}>
|
||||||
<div className="absolute top-3 right-3 z-10" style={{ width: '220px' }}>
|
|
||||||
<div
|
<div
|
||||||
className="rounded-lg overflow-hidden"
|
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)' }}
|
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
|
<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)' }}
|
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>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={onReset}
|
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)' }}
|
style={{ color: 'rgba(148,163,184,0.8)' }}
|
||||||
>
|
>
|
||||||
다시 선택
|
다시
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -78,30 +89,69 @@ const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSp
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 하단 안내 + 캡처 버튼 */}
|
{/* 캡처 버튼 */}
|
||||||
<div className="flex items-center justify-between mt-2">
|
<div className="flex items-center justify-between mt-1.5">
|
||||||
<p className="text-[10px] text-text-3 font-korean">
|
<p className="text-[9px] text-text-3 font-korean">
|
||||||
{capturedImage
|
{captured ? 'PDF 출력 시 포함됩니다.' : '원하는 범위를 선택 후 캡처하세요.'}
|
||||||
? 'PDF 다운로드 시 캡처된 이미지가 포함됩니다.'
|
|
||||||
: '지도를 이동/확대하여 원하는 범위를 선택한 후 캡처하세요.'}
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleCapture}
|
onClick={handleCapture}
|
||||||
disabled={isCapturing || !!capturedImage}
|
disabled={isCapturing || !!captured}
|
||||||
className="px-3 py-1.5 text-[11px] font-semibold rounded transition-all font-korean flex items-center gap-1.5"
|
className="px-2.5 py-1 text-[10px] font-semibold rounded transition-all font-korean flex items-center gap-1"
|
||||||
style={{
|
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)',
|
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,
|
opacity: isCapturing ? 0.6 : 1,
|
||||||
cursor: capturedImage ? 'default' : 'pointer',
|
cursor: captured ? 'default' : 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{capturedImage ? '✓ 캡처됨' : '📷 이 범위로 캡처'}
|
{captured ? '✓ 캡처됨' : '📷 캡처'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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;
|
export default OilSpreadMapPanel;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
createEmptyReport,
|
createEmptyReport,
|
||||||
} from './OilSpillReportTemplate';
|
} from './OilSpillReportTemplate';
|
||||||
import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload, consumeOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu';
|
import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload, consumeOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu';
|
||||||
|
import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore';
|
||||||
import OilSpreadMapPanel from './OilSpreadMapPanel';
|
import OilSpreadMapPanel from './OilSpreadMapPanel';
|
||||||
import { saveReport } from '../services/reportsApi';
|
import { saveReport } from '../services/reportsApi';
|
||||||
import {
|
import {
|
||||||
@ -32,8 +33,11 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
const [hnsPayload, setHnsPayload] = useState<HnsReportPayload | null>(null)
|
const [hnsPayload, setHnsPayload] = useState<HnsReportPayload | null>(null)
|
||||||
// OIL 실 데이터 (없으면 sampleOilData fallback)
|
// OIL 실 데이터 (없으면 sampleOilData fallback)
|
||||||
const [oilPayload, setOilPayload] = useState<OilReportPayload | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
@ -94,8 +98,8 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
sunset: '',
|
sunset: '',
|
||||||
windDir: oilPayload.weather.windDir,
|
windDir: oilPayload.weather.windDir,
|
||||||
windSpeed: oilPayload.weather.windSpeed,
|
windSpeed: oilPayload.weather.windSpeed,
|
||||||
currentDir: '',
|
currentDir: oilPayload.weather.currentDir ?? '',
|
||||||
currentSpeed: '',
|
currentSpeed: oilPayload.weather.currentSpeed ?? '',
|
||||||
waveHeight: oilPayload.weather.waveHeight,
|
waveHeight: oilPayload.weather.waveHeight,
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@ -109,16 +113,6 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
coastAttachTotal: oilPayload.pollution.coastAttach,
|
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
|
// 스텝별 오염종합 상황 (3h/6h) → report.spread
|
||||||
if (oilPayload.spreadSteps) {
|
if (oilPayload.spreadSteps) {
|
||||||
report.spread = oilPayload.spreadSteps;
|
report.spread = oilPayload.spreadSteps;
|
||||||
@ -128,8 +122,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
report.incident.spillAmount = '';
|
report.incident.spillAmount = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (activeCat === 0 && oilMapCaptured) {
|
if (activeCat === 0) {
|
||||||
report.capturedMapImage = oilMapCaptured;
|
if (capturedStep3) report.step3MapImage = capturedStep3;
|
||||||
|
if (capturedStep6) report.step6MapImage = capturedStep6;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await saveReport(report)
|
await saveReport(report)
|
||||||
@ -148,20 +143,22 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
// OIL 섹션에 실 데이터 삽입
|
// OIL 섹션에 실 데이터 삽입
|
||||||
if (activeCat === 0) {
|
if (activeCat === 0) {
|
||||||
if (sec.id === 'oil-spread') {
|
if (sec.id === 'oil-spread') {
|
||||||
const mapImg = oilMapCaptured
|
const img3 = capturedStep3
|
||||||
? `<img src="${oilMapCaptured}" style="width:100%;max-height:300px;object-fit:contain;border:1px solid #ddd;border-radius:8px;margin-bottom:12px;" />`
|
? `<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:8px;display:flex;align-items:center;justify-content:center;color:#999;margin-bottom:12px;font-size:12px;">[확산예측 지도 미캡처]</div>';
|
: '<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 spreadRows = oilPayload
|
const img6 = capturedStep6
|
||||||
? [
|
? `<img src="${capturedStep6}" style="width:100%;max-height:260px;object-fit:contain;border:1px solid #ddd;border-radius:6px;" />`
|
||||||
['KOSPS', oilPayload.spread.kosps],
|
: '<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>';
|
||||||
['OpenDrift', oilPayload.spread.openDrift],
|
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>`;
|
||||||
['POSEIDON', oilPayload.spread.poseidon],
|
const spreadStepRows = oilPayload?.spreadSteps && oilPayload.spreadSteps.length > 0
|
||||||
]
|
? oilPayload.spreadSteps.map(s =>
|
||||||
: [['KOSPS', '—'], ['OpenDrift', '—'], ['POSEIDON', '—']];
|
`<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>`
|
||||||
const tds = spreadRows.map(r =>
|
).join('')
|
||||||
`<td style="padding:8px;border:1px solid #ddd;text-align:center;"><b>${r[0]}</b><br/>${r[1]}</td>`
|
: '';
|
||||||
).join('');
|
const stepsTable = spreadStepRows
|
||||||
content = `${mapImg}<table style="width:100%;border-collapse:collapse;font-size:12px;"><tr>${tds}</tr></table>`;
|
? `<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') {
|
if (activeCat === 0 && sec.id === 'oil-coastal') {
|
||||||
@ -366,10 +363,39 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
<>
|
<>
|
||||||
<OilSpreadMapPanel
|
<OilSpreadMapPanel
|
||||||
mapData={oilPayload?.mapData ?? null}
|
mapData={oilPayload?.mapData ?? null}
|
||||||
capturedImage={oilMapCaptured}
|
capturedStep3={capturedStep3}
|
||||||
onCapture={(dataUrl) => setOilMapCaptured(dataUrl)}
|
capturedStep6={capturedStep6}
|
||||||
onReset={() => setOilMapCaptured(null)}
|
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">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{[
|
{[
|
||||||
{ label: 'KOSPS', value: oilPayload?.spread.kosps || '—', color: '#06b6d4' },
|
{ label: 'KOSPS', value: oilPayload?.spread.kosps || '—', color: '#06b6d4' },
|
||||||
@ -448,11 +474,50 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sec.id === 'oil-tide' && (
|
{sec.id === 'oil-tide' && (() => {
|
||||||
|
const wx = oilPayload?.weather;
|
||||||
|
if (!wx) {
|
||||||
|
return (
|
||||||
<p className="text-[12px] text-text-3 font-korean italic">
|
<p className="text-[12px] text-text-3 font-korean italic">
|
||||||
현재 조석·기상 데이터가 없습니다.
|
현재 조석·기상 데이터가 없습니다.
|
||||||
</p>
|
</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 대기확산 섹션들 ── */}
|
{/* ── HNS 대기확산 섹션들 ── */}
|
||||||
{sec.id === 'hns-atm' && (
|
{sec.id === 'hns-atm' && (
|
||||||
|
|||||||
@ -302,7 +302,10 @@ export function ReportsView() {
|
|||||||
const getVal = buildReportGetVal(previewReport)
|
const getVal = buildReportGetVal(previewReport)
|
||||||
const meta = { writeTime: previewReport.incident.writeTime, author: previewReport.author, jurisdiction: previewReport.jurisdiction }
|
const meta = { writeTime: previewReport.incident.writeTime, author: previewReport.author, jurisdiction: previewReport.jurisdiction }
|
||||||
const filename = previewReport.title || tpl.label
|
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]"
|
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.pollutant && `유출유종: ${previewReport.incident.pollutant}`,
|
||||||
previewReport.incident.spillAmount && `유출량: ${previewReport.incident.spillAmount}`,
|
previewReport.incident.spillAmount && `유출량: ${previewReport.incident.spillAmount}`,
|
||||||
previewReport.spread?.length > 0 && `확산예측: ${previewReport.spread.length}개 시점 데이터`,
|
|
||||||
].filter(Boolean).join('\n') || '—'}
|
].filter(Boolean).join('\n') || '—'}
|
||||||
</div>
|
</div>
|
||||||
|
{(previewReport.capturedMapImage || previewReport.step3MapImage || previewReport.step6MapImage) && (
|
||||||
|
<div className="flex flex-col gap-2 mt-3">
|
||||||
{previewReport.capturedMapImage && (
|
{previewReport.capturedMapImage && (
|
||||||
<img
|
<img
|
||||||
src={previewReport.capturedMapImage}
|
src={previewReport.capturedMapImage}
|
||||||
alt="확산예측 지도 캡처"
|
alt="확산예측 지도"
|
||||||
className="w-full rounded-lg border border-border mt-3"
|
className="w-full rounded-lg border border-border"
|
||||||
style={{ maxHeight: '300px', objectFit: 'contain' }}
|
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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -101,7 +101,7 @@ const MANIFEST_XML =
|
|||||||
/**
|
/**
|
||||||
* Contents/content.hpf: Skeleton.hwpx 기반, 날짜만 동적 생성
|
* Contents/content.hpf: Skeleton.hwpx 기반, 날짜만 동적 생성
|
||||||
*/
|
*/
|
||||||
function buildContentHpf(): string {
|
function buildContentHpf(extraManifestItems = ''): string {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
return (
|
return (
|
||||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>' +
|
'<?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="header" href="Contents/header.xml" media-type="application/xml"/>' +
|
||||||
'<opf:item id="section0" href="Contents/section0.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"/>' +
|
'<opf:item id="settings" href="settings.xml" media-type="application/xml"/>' +
|
||||||
|
extraManifestItems +
|
||||||
'</opf:manifest>' +
|
'</opf:manifest>' +
|
||||||
'<opf:spine>' +
|
'<opf:spine>' +
|
||||||
'<opf:itemref idref="header" linear="yes"/>' +
|
'<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 내부용)
|
* 테이블 셀 내 단락 (subList 내부용)
|
||||||
*/
|
*/
|
||||||
@ -542,6 +567,104 @@ const CONTENT_WIDTH = 42520;
|
|||||||
const LABEL_WIDTH = Math.round(CONTENT_WIDTH * 0.33);
|
const LABEL_WIDTH = Math.round(CONTENT_WIDTH * 0.33);
|
||||||
const VALUE_WIDTH = CONTENT_WIDTH - LABEL_WIDTH;
|
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(
|
function buildFieldTable(
|
||||||
fields: { key: string; label: string }[],
|
fields: { key: string; label: string }[],
|
||||||
getVal: (key: string) => string,
|
getVal: (key: string) => string,
|
||||||
@ -549,6 +672,14 @@ function buildFieldTable(
|
|||||||
const rowCnt = fields.length;
|
const rowCnt = fields.length;
|
||||||
if (rowCnt === 0) return '';
|
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 = '';
|
let rows = '';
|
||||||
fields.forEach((field, rowIdx) => {
|
fields.forEach((field, rowIdx) => {
|
||||||
const value = getVal(field.key) || '-';
|
const value = getVal(field.key) || '-';
|
||||||
@ -604,6 +735,7 @@ function buildSection0Xml(
|
|||||||
meta: ReportMeta,
|
meta: ReportMeta,
|
||||||
sections: ReportSection[],
|
sections: ReportSection[],
|
||||||
getVal: (key: string) => string,
|
getVal: (key: string) => string,
|
||||||
|
imageBinIds?: { step3?: number; step6?: number },
|
||||||
): string {
|
): string {
|
||||||
// ID 시퀀스 초기화 (재사용 시 충돌 방지)
|
// ID 시퀀스 초기화 (재사용 시 충돌 방지)
|
||||||
_idSeq = 1000000000;
|
_idSeq = 1000000000;
|
||||||
@ -634,10 +766,31 @@ function buildSection0Xml(
|
|||||||
// 섹션 제목 (11pt = charPrId 6)
|
// 섹션 제목 (11pt = charPrId 6)
|
||||||
body += buildPara(section.title, 6);
|
body += buildPara(section.title, 6);
|
||||||
|
|
||||||
|
// __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 {
|
||||||
// 필드 테이블
|
// 필드 테이블
|
||||||
if (section.fields.length > 0) {
|
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);
|
body += buildFieldTable(section.fields, getVal);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 섹션 후 빈 줄
|
// 섹션 후 빈 줄
|
||||||
body += buildEmptyPara();
|
body += buildEmptyPara();
|
||||||
@ -669,7 +822,10 @@ function buildPrvText(
|
|||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
lines.push(`[${section.title}]`);
|
lines.push(`[${section.title}]`);
|
||||||
for (const field of section.fields) {
|
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) {
|
if (field.label) {
|
||||||
lines.push(` ${field.label}: ${value}`);
|
lines.push(` ${field.label}: ${value}`);
|
||||||
} else {
|
} else {
|
||||||
@ -690,6 +846,7 @@ export async function exportAsHWPX(
|
|||||||
sections: ReportSection[],
|
sections: ReportSection[],
|
||||||
getVal: (key: string) => string,
|
getVal: (key: string) => string,
|
||||||
filename: string,
|
filename: string,
|
||||||
|
images?: { step3?: string; step6?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
|
|
||||||
@ -703,10 +860,56 @@ export async function exportAsHWPX(
|
|||||||
zip.file('META-INF/container.rdf', CONTAINER_RDF);
|
zip.file('META-INF/container.rdf', CONTAINER_RDF);
|
||||||
zip.file('META-INF/manifest.xml', MANIFEST_XML);
|
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
|
// Contents
|
||||||
zip.file('Contents/content.hpf', buildContentHpf());
|
zip.file('Contents/content.hpf', buildContentHpf(extraManifestItems));
|
||||||
zip.file('Contents/header.xml', HEADER_XML);
|
zip.file('Contents/header.xml', headerXml);
|
||||||
zip.file('Contents/section0.xml', buildSection0Xml(templateLabel, meta, sections, getVal));
|
zip.file('Contents/section0.xml', buildSection0Xml(templateLabel, meta, sections, getVal, imageBinIds));
|
||||||
|
|
||||||
// Preview
|
// Preview
|
||||||
zip.file('Preview/PrvText.txt', buildPrvText(templateLabel, meta, sections, getVal));
|
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: '3. 조석 현황', fields: [{ key: '__tide', label: '', type: 'textarea' }] },
|
||||||
{ title: '4. 해양기상 현황', fields: [{ key: '__weather', 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: '6. 민감자원 현황', fields: [{ key: '__sensitive', label: '', type: 'textarea' }] },
|
||||||
{ title: '7. 방제 자원', fields: [{ key: '__vessels', label: '', type: 'textarea' }] },
|
{ title: '7. 방제 자원', fields: [{ key: '__vessels', label: '', type: 'textarea' }] },
|
||||||
{ title: '8. 회수·처리 현황',fields: [{ key: '__recovery', 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 }[] }[],
|
sections: { title: string; fields: { key: string; label: string }[] }[],
|
||||||
getVal: (key: string) => string,
|
getVal: (key: string) => string,
|
||||||
filename: string,
|
filename: string,
|
||||||
|
images?: { step3?: string; step6?: string },
|
||||||
) {
|
) {
|
||||||
const { exportAsHWPX } = await import('./hwpxExport');
|
const { exportAsHWPX } = await import('./hwpxExport');
|
||||||
await exportAsHWPX(templateLabel, meta, sections, getVal, filename);
|
await exportAsHWPX(templateLabel, meta, sections, getVal, filename, images);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ViewState =
|
export type ViewState =
|
||||||
@ -196,6 +197,18 @@ export function buildReportGetVal(report: OilSpillReportData) {
|
|||||||
}
|
}
|
||||||
if (key === '__tide') return formatTideTable(report.tide)
|
if (key === '__tide') return formatTideTable(report.tide)
|
||||||
if (key === '__weather') return formatWeatherTable(report.weather)
|
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 === '__spread') return formatSpreadTable(report.spread)
|
||||||
if (key === '__sensitive') return formatSensitiveTable(report)
|
if (key === '__sensitive') return formatSensitiveTable(report)
|
||||||
if (key === '__vessels') return formatVesselsTable(report.vessels)
|
if (key === '__vessels') return formatVesselsTable(report.vessels)
|
||||||
|
|||||||
@ -76,6 +76,8 @@ export interface ApiReportDetail extends ApiReportListItem {
|
|||||||
acdntSn: number | null;
|
acdntSn: number | null;
|
||||||
sections: ApiReportSectionData[];
|
sections: ApiReportSectionData[];
|
||||||
mapCaptureImg?: string | null;
|
mapCaptureImg?: string | null;
|
||||||
|
step3MapImg?: string | null;
|
||||||
|
step6MapImg?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiReportListResponse {
|
export interface ApiReportListResponse {
|
||||||
@ -179,6 +181,8 @@ export async function createReportApi(input: {
|
|||||||
jrsdCd?: string;
|
jrsdCd?: string;
|
||||||
sttsCd?: string;
|
sttsCd?: string;
|
||||||
mapCaptureImg?: string;
|
mapCaptureImg?: string;
|
||||||
|
step3MapImg?: string;
|
||||||
|
step6MapImg?: string;
|
||||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||||
}): Promise<{ sn: number }> {
|
}): Promise<{ sn: number }> {
|
||||||
const res = await api.post<{ sn: number }>('/reports', input);
|
const res = await api.post<{ sn: number }>('/reports', input);
|
||||||
@ -191,6 +195,8 @@ export async function updateReportApi(sn: number, input: {
|
|||||||
sttsCd?: string;
|
sttsCd?: string;
|
||||||
acdntSn?: number | null;
|
acdntSn?: number | null;
|
||||||
mapCaptureImg?: string | null;
|
mapCaptureImg?: string | null;
|
||||||
|
step3MapImg?: string | null;
|
||||||
|
step6MapImg?: string | null;
|
||||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await api.post(`/reports/${sn}/update`, input);
|
await api.post(`/reports/${sn}/update`, input);
|
||||||
@ -244,6 +250,8 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
|
|||||||
jrsdCd: data.jurisdiction,
|
jrsdCd: data.jurisdiction,
|
||||||
sttsCd,
|
sttsCd,
|
||||||
mapCaptureImg: data.capturedMapImage !== undefined ? (data.capturedMapImage || null) : undefined,
|
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,
|
sections,
|
||||||
});
|
});
|
||||||
return existingSn;
|
return existingSn;
|
||||||
@ -256,6 +264,8 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
|
|||||||
jrsdCd: data.jurisdiction,
|
jrsdCd: data.jurisdiction,
|
||||||
sttsCd,
|
sttsCd,
|
||||||
mapCaptureImg: data.capturedMapImage || undefined,
|
mapCaptureImg: data.capturedMapImage || undefined,
|
||||||
|
step3MapImg: data.step3MapImage || undefined,
|
||||||
|
step6MapImg: data.step6MapImage || undefined,
|
||||||
sections,
|
sections,
|
||||||
});
|
});
|
||||||
return result.sn;
|
return result.sn;
|
||||||
@ -353,6 +363,12 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa
|
|||||||
if (detail.mapCaptureImg) {
|
if (detail.mapCaptureImg) {
|
||||||
reportData.capturedMapImage = detail.mapCaptureImg;
|
reportData.capturedMapImage = detail.mapCaptureImg;
|
||||||
}
|
}
|
||||||
|
if (detail.step3MapImg) {
|
||||||
|
reportData.step3MapImage = detail.step3MapImg;
|
||||||
|
}
|
||||||
|
if (detail.step6MapImg) {
|
||||||
|
reportData.step6MapImage = detail.step6MapImg;
|
||||||
|
}
|
||||||
|
|
||||||
return reportData;
|
return reportData;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer'
|
|||||||
import { useWeatherData } from '../hooks/useWeatherData'
|
import { useWeatherData } from '../hooks/useWeatherData'
|
||||||
// import { useOceanForecast } from '../hooks/useOceanForecast'
|
// import { useOceanForecast } from '../hooks/useOceanForecast'
|
||||||
import { WeatherMapControls } from './WeatherMapControls'
|
import { WeatherMapControls } from './WeatherMapControls'
|
||||||
|
import { degreesToCardinal } from '../services/weatherUtils'
|
||||||
|
|
||||||
type TimeOffset = '0' | '3' | '6' | '9'
|
type TimeOffset = '0' | '3' | '6' | '9'
|
||||||
|
|
||||||
@ -40,13 +41,6 @@ interface WeatherStation {
|
|||||||
salinity?: number
|
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 {
|
interface WeatherForecast {
|
||||||
time: string
|
time: string
|
||||||
hour: 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