feat(aerial): CCTV 실시간 HLS 스트림 + HNS 분석 고도화
CCTV 실시간 영상: - CCTVPlayer 컴포넌트 (hls.js 기반 HLS/MJPEG/MP4 재생) - 백엔드 HLS 프록시 엔드포인트 (CORS 우회, m3u8 URL 재작성) - KHOA 15개 + KBS 6개 실제 해안 CCTV 연동 - Vite dev proxy, 스트림 타입 자동 감지 유틸리티 HNS 분석: - HNS 시나리오 저장/불러오기/재계산 기능 - 물질 DB 검색 및 상세 정보 연동 - 좌표/파라미터 입력 UI 개선 - Python 확산 모델 스크립트 (hns_dispersion.py) 공통: - 3D 지도 토글, 보고서 생성 개선 - useSubMenu 훅, mapUtils 확장 - ESLint set-state-in-effect 수정 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
9faf928e40
커밋
8f98f63aa5
2
backend/package-lock.json
generated
2
backend/package-lock.json
generated
@ -558,6 +558,7 @@
|
|||||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
"@types/express-serve-static-core": "^5.0.0",
|
"@types/express-serve-static-core": "^5.0.0",
|
||||||
@ -1991,6 +1992,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
||||||
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
|
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.11.0",
|
"pg-connection-string": "^2.11.0",
|
||||||
"pg-pool": "^3.12.0",
|
"pg-pool": "^3.12.0",
|
||||||
|
|||||||
@ -80,6 +80,86 @@ router.get('/cctv', requireAuth, requirePermission('aerial', 'READ'), async (req
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// CCTV HLS 스트림 프록시 (CORS 우회)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 허용 도메인 목록 */
|
||||||
|
const ALLOWED_STREAM_HOSTS = [
|
||||||
|
'www.khoa.go.kr',
|
||||||
|
'kbsapi.loomex.net',
|
||||||
|
];
|
||||||
|
|
||||||
|
// GET /api/aerial/cctv/stream-proxy — HLS 스트림 프록시 (호스트 화이트리스트로 보안)
|
||||||
|
router.get('/cctv/stream-proxy', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const targetUrl = req.query.url as string | undefined;
|
||||||
|
if (!targetUrl) {
|
||||||
|
res.status(400).json({ error: 'url 파라미터가 필요합니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(targetUrl);
|
||||||
|
} catch {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 URL' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_STREAM_HOSTS.includes(parsed.hostname)) {
|
||||||
|
res.status(403).json({ error: '허용되지 않은 스트림 호스트' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstream = await fetch(targetUrl, {
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!upstream.ok) {
|
||||||
|
res.status(upstream.status).json({ error: `스트림 서버 응답: ${upstream.status}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = upstream.headers.get('content-type') || '';
|
||||||
|
|
||||||
|
// .m3u8 매니페스트: 상대 URL을 프록시 URL로 재작성
|
||||||
|
if (targetUrl.includes('.m3u8') || contentType.includes('mpegurl') || contentType.includes('m3u8')) {
|
||||||
|
const text = await upstream.text();
|
||||||
|
const baseUrl = targetUrl.substring(0, targetUrl.lastIndexOf('/') + 1);
|
||||||
|
const proxyBase = '/api/aerial/cctv/stream-proxy?url=';
|
||||||
|
|
||||||
|
const rewritten = text.replace(/^(?!#)(\S+)/gm, (line) => {
|
||||||
|
if (line.startsWith('http://') || line.startsWith('https://')) {
|
||||||
|
return `${proxyBase}${encodeURIComponent(line)}`;
|
||||||
|
}
|
||||||
|
return `${proxyBase}${encodeURIComponent(baseUrl + line)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
});
|
||||||
|
res.send(rewritten);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// .ts 세그먼트 등: 바이너리 스트리밍
|
||||||
|
res.set({
|
||||||
|
'Content-Type': contentType || 'video/mp2t',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await upstream.arrayBuffer());
|
||||||
|
res.send(buffer);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[aerial] CCTV 스트림 프록시 오류:', err);
|
||||||
|
res.status(502).json({ error: '스트림 프록시 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// SAT_REQUEST 라우트
|
// SAT_REQUEST 라우트
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
import 'dotenv/config'
|
import 'dotenv/config'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve, dirname } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
import { wingPool } from './wingDb.js'
|
import { wingPool } from './wingDb.js'
|
||||||
|
|
||||||
// 프론트엔드 정적 데이터를 직접 import (tsx로 실행)
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
import { HNS_SEARCH_DB } from '../../../frontend/src/data/hnsSubstanceSearchData.js'
|
|
||||||
|
// 통합 물질 데이터 JSON (기존 20종 상세 + Excel 1,202종 기본)
|
||||||
|
const dataPath = resolve(__dirname, '../../../frontend/src/data/hnsSubstanceData.json')
|
||||||
|
const HNS_SEARCH_DB = JSON.parse(readFileSync(dataPath, 'utf-8'))
|
||||||
|
|
||||||
async function seedHnsSubstances() {
|
async function seedHnsSubstances() {
|
||||||
console.log('HNS 물질정보 시드 시작...')
|
console.log('HNS 물질정보 시드 시작...')
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { searchSubstances, getSubstanceById, listAnalyses, getAnalysis, createAnalysis, deleteAnalysis } from './hnsService.js'
|
import { searchSubstances, getSubstanceById, listAnalyses, getAnalysis, createAnalysis, updateAnalysisResult, deleteAnalysis } from './hnsService.js'
|
||||||
import { isValidNumber } from '../middleware/security.js'
|
import { isValidNumber } from '../middleware/security.js'
|
||||||
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
||||||
|
|
||||||
@ -63,6 +63,27 @@ router.post('/analyses', requireAuth, requirePermission('hns', 'CREATE'), async
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// POST /api/hns/analyses/:sn/save — 분석 결과 저장 (PUT 금지 정책 준수)
|
||||||
|
router.post('/analyses/:sn/save', requireAuth, requirePermission('hns', 'CREATE'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sn = parseInt(req.params.sn as string, 10)
|
||||||
|
if (!isValidNumber(sn, 1, 999999)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 분석 번호' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { rsltData, execSttsCd, riskCd } = req.body
|
||||||
|
if (!rsltData) {
|
||||||
|
res.status(400).json({ error: '결과 데이터는 필수입니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await updateAnalysisResult(sn, { rsltData, execSttsCd, riskCd })
|
||||||
|
res.json({ success: true })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[hns] 분석 결과 저장 오류:', err)
|
||||||
|
res.status(500).json({ error: 'HNS 분석 결과 저장 실패' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// DELETE /api/hns/analyses/:sn — 분석 삭제
|
// DELETE /api/hns/analyses/:sn — 분석 삭제
|
||||||
router.delete('/analyses/:sn', requireAuth, requirePermission('hns', 'DELETE'), async (req, res) => {
|
router.delete('/analyses/:sn', requireAuth, requirePermission('hns', 'DELETE'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -9,7 +9,7 @@ interface HnsSearchParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function searchSubstances(params: HnsSearchParams) {
|
export async function searchSubstances(params: HnsSearchParams) {
|
||||||
const { q, type = 'nameKr', sebc, page = 1, limit = 50 } = params
|
const { q, type, sebc, page = 1, limit = 50 } = params
|
||||||
const conditions: string[] = ["USE_YN = 'Y'"]
|
const conditions: string[] = ["USE_YN = 'Y'"]
|
||||||
const values: (string | number)[] = []
|
const values: (string | number)[] = []
|
||||||
let paramIdx = 1
|
let paramIdx = 1
|
||||||
@ -42,7 +42,7 @@ export async function searchSubstances(params: HnsSearchParams) {
|
|||||||
values.push(JSON.stringify([{ code: keyword }]))
|
values.push(JSON.stringify([{ code: keyword }]))
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
conditions.push(`(NM_KR ILIKE $${paramIdx} OR NM_EN ILIKE $${paramIdx} OR ABBREVIATION ILIKE $${paramIdx})`)
|
conditions.push(`(ABBREVIATION ILIKE $${paramIdx} OR NM_KR ILIKE $${paramIdx} OR NM_EN ILIKE $${paramIdx})`)
|
||||||
values.push(`%${keyword}%`)
|
values.push(`%${keyword}%`)
|
||||||
}
|
}
|
||||||
paramIdx++
|
paramIdx++
|
||||||
@ -238,6 +238,19 @@ export async function createAnalysis(input: {
|
|||||||
return { hnsAnlysSn: rows[0].hns_anlys_sn }
|
return { hnsAnlysSn: rows[0].hns_anlys_sn }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateAnalysisResult(sn: number, input: {
|
||||||
|
rsltData: Record<string, unknown>
|
||||||
|
execSttsCd?: string
|
||||||
|
riskCd?: string
|
||||||
|
}): Promise<void> {
|
||||||
|
await wingPool.query(
|
||||||
|
`UPDATE HNS_ANALYSIS
|
||||||
|
SET RSLT_DATA = $1, EXEC_STTS_CD = $2, RISK_CD = $3, MDFCN_DTM = NOW()
|
||||||
|
WHERE HNS_ANLYS_SN = $4 AND USE_YN = 'Y'`,
|
||||||
|
[JSON.stringify(input.rsltData), input.execSttsCd || 'COMPLETED', input.riskCd || null, sn]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteAnalysis(sn: number): Promise<void> {
|
export async function deleteAnalysis(sn: number): Promise<void> {
|
||||||
await wingPool.query(
|
await wingPool.query(
|
||||||
`UPDATE HNS_ANALYSIS SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE HNS_ANLYS_SN = $1`,
|
`UPDATE HNS_ANALYSIS SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE HNS_ANLYS_SN = $1`,
|
||||||
|
|||||||
40
database/migration/017_cctv_stream_urls.sql
Normal file
40
database/migration/017_cctv_stream_urls.sql
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 017: CCTV_CAMERA 실제 해안 CCTV 데이터로 교체
|
||||||
|
-- 출처: 국립해양조사원(KHOA) + KBS 재난안전포털
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SET search_path TO wing, public;
|
||||||
|
|
||||||
|
-- 기존 시드 데이터 제거
|
||||||
|
DELETE FROM CCTV_CAMERA;
|
||||||
|
|
||||||
|
-- 실제 해안 CCTV 데이터 (21건)
|
||||||
|
INSERT INTO CCTV_CAMERA (CCTV_SN, CAMERA_NM, REGION_NM, LON, LAT, GEOM, LOC_DC, COORD_DC, STTS_CD, PTZ_YN, SOURCE_NM, STREAM_URL) VALUES
|
||||||
|
-- 서해 (5건)
|
||||||
|
(29, '인천항 조위관측소', '서해', 126.5922, 37.4519, ST_SetSRID(ST_MakePoint(126.5922, 37.4519), 4326), '인천광역시 중구 항동', '37.45°N 126.59°E', 'LIVE', 'N', 'KHOA', 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa/Incheon/s.m3u8'),
|
||||||
|
(30, '인천항 해무관측', '서해', 126.6161, 37.3797, ST_SetSRID(ST_MakePoint(126.6161, 37.3797), 4326), '인천광역시 중구 항동', 'N 37°22''47" E 126°36''58"', 'LIVE', 'N', 'KHOA', 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa/SeaFog_Incheon/s.m3u8'),
|
||||||
|
(31, '대산항 해무관측', '서해', 126.3526, 37.0058, ST_SetSRID(ST_MakePoint(126.3526, 37.0058), 4326), '충남 서산시 대산읍', '37.01°N 126.35°E', 'LIVE', 'N', 'KHOA', 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa/SeaFog_Daesan/s.m3u8'),
|
||||||
|
(32, '평택·당진항 해무관측', '서해', 126.3936, 37.1131, ST_SetSRID(ST_MakePoint(126.3936, 37.1131), 4326), '충남 당진시 송악읍', 'N 37°06''47" E 126°23''37"', 'LIVE', 'N', 'KHOA', 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa/SeaFog_PTDJ/s.m3u8'),
|
||||||
|
(100, '인천 연안부두', '서해', 126.6125, 37.4625, ST_SetSRID(ST_MakePoint(126.6125, 37.4625), 4326), '인천광역시 중구 연안부두', '37.46°N 126.61°E', 'LIVE', 'N', 'KBS', NULL),
|
||||||
|
-- 남해 (9건)
|
||||||
|
(35, '목포항 해무관측', '남해', 126.3780, 34.7780, ST_SetSRID(ST_MakePoint(126.3780, 34.7780), 4326), '전남 목포시 항동', '34.78°N 126.38°E', 'LIVE', 'N', 'KHOA', 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa/SeaFog_Mokpo/s.m3u8'),
|
||||||
|
(36, '진도항 조위관측소', '남해', 126.3085, 34.4710, ST_SetSRID(ST_MakePoint(126.3085, 34.4710), 4326), '전남 진도군 진도읍', '34.47°N 126.31°E', 'LIVE', 'N', 'KHOA', 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa/Jindo/s.m3u8'),
|
||||||
|
(37, '여수항 해무관측', '남해', 127.7669, 34.7384, ST_SetSRID(ST_MakePoint(127.7669, 34.7384), 4326), '전남 여수시 종화동', '34.74°N 127.77°E', 'LIVE', 'N', 'KHOA', 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa/SeaFog_Yeosu/s.m3u8'),
|
||||||
|
(38, '여수항 조위관측소', '남해', 127.7650, 34.7370, ST_SetSRID(ST_MakePoint(127.7650, 34.7370), 4326), '전남 여수시 종화동', '34.74°N 127.77°E', 'LIVE', 'N', 'KHOA', 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa/Yeosu/s.m3u8'),
|
||||||
|
(39, '부산항 조위관측소', '남해', 129.0756, 35.0969, ST_SetSRID(ST_MakePoint(129.0756, 35.0969), 4326), '부산광역시 중구 중앙동', '35.10°N 129.08°E', 'LIVE', 'N', 'KHOA', 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa/Busan/s.m3u8'),
|
||||||
|
(40, '부산항 해무관측', '남해', 129.0780, 35.0980, ST_SetSRID(ST_MakePoint(129.0780, 35.0980), 4326), '부산광역시 중구', '35.10°N 129.08°E', 'LIVE', 'N', 'KHOA', 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa/SeaFog_Busan/s.m3u8'),
|
||||||
|
(41, '해운대 해무관측', '남해', 129.1718, 35.1587, ST_SetSRID(ST_MakePoint(129.1718, 35.1587), 4326), '부산광역시 해운대구', '35.16°N 129.17°E', 'LIVE', 'N', 'KHOA', 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa/SeaFog_Haeundae/s.m3u8'),
|
||||||
|
(97, '오동도', '남해', 127.7836, 34.7369, ST_SetSRID(ST_MakePoint(127.7836, 34.7369), 4326), '전남 여수시 수정동', '34.74°N 127.78°E', 'LIVE', 'N', 'KBS', NULL),
|
||||||
|
(108, '완도항', '남해', 126.7550, 34.3114, ST_SetSRID(ST_MakePoint(126.7550, 34.3114), 4326), '전남 완도군 완도읍', '34.31°N 126.76°E', 'LIVE', 'N', 'KBS', NULL),
|
||||||
|
-- 동해 (5건)
|
||||||
|
(42, '울산항 해무관측', '동해', 129.3870, 35.5000, ST_SetSRID(ST_MakePoint(129.3870, 35.5000), 4326), '울산광역시 남구', '35.50°N 129.39°E', 'LIVE', 'N', 'KHOA', 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa/SeaFog_Ulsan/s.m3u8'),
|
||||||
|
(43, '포항항 해무관측', '동해', 129.3798, 36.0323, ST_SetSRID(ST_MakePoint(129.3798, 36.0323), 4326), '경북 포항시 북구', '36.03°N 129.38°E', 'LIVE', 'N', 'KHOA', 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa/SeaFog_Pohang/s.m3u8'),
|
||||||
|
(44, '묵호항 조위관측소', '동해', 129.1146, 37.5500, ST_SetSRID(ST_MakePoint(129.1146, 37.5500), 4326), '강원 동해시 묵호동', '37.55°N 129.11°E', 'LIVE', 'N', 'KHOA', 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa/Mukho/s.m3u8'),
|
||||||
|
(113, '속초등대', '동해', 128.5964, 38.2070, ST_SetSRID(ST_MakePoint(128.5964, 38.2070), 4326), '강원 속초시 영랑동', '38.21°N 128.60°E', 'LIVE', 'N', 'KBS', NULL),
|
||||||
|
(115, '독도', '동해', 131.8689, 37.2394, ST_SetSRID(ST_MakePoint(131.8689, 37.2394), 4326), '경북 울릉군 울릉읍 독도리', '37.24°N 131.87°E', 'LIVE', 'N', 'KBS', NULL),
|
||||||
|
-- 제주 (2건)
|
||||||
|
(45, '모슬포항 조위관측소', '제주', 126.2519, 33.2136, ST_SetSRID(ST_MakePoint(126.2519, 33.2136), 4326), '제주 서귀포시 대정읍', '33.21°N 126.25°E', 'LIVE', 'N', 'KHOA', 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa/Moseulpo/s.m3u8'),
|
||||||
|
(116, '마라도', '제주', 126.2669, 33.1140, ST_SetSRID(ST_MakePoint(126.2669, 33.1140), 4326), '제주 서귀포시 대정읍 마라리', '33.11°N 126.27°E', 'LIVE', 'N', 'KBS', NULL);
|
||||||
|
|
||||||
|
-- 시퀀스 리셋
|
||||||
|
SELECT setval('cctv_camera_cctv_sn_seq', (SELECT MAX(cctv_sn) FROM cctv_camera));
|
||||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@ -25,6 +25,7 @@
|
|||||||
"@vis.gl/react-maplibre": "^8.1.0",
|
"@vis.gl/react-maplibre": "^8.1.0",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
|
"hls.js": "^1.6.15",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.564.0",
|
||||||
"maplibre-gl": "^5.19.0",
|
"maplibre-gl": "^5.19.0",
|
||||||
@ -4245,6 +4246,12 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hls.js": {
|
||||||
|
"version": "1.6.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
|
||||||
|
"integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
"@vis.gl/react-maplibre": "^8.1.0",
|
"@vis.gl/react-maplibre": "^8.1.0",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
|
"hls.js": "^1.6.15",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.564.0",
|
||||||
"maplibre-gl": "^5.19.0",
|
"maplibre-gl": "^5.19.0",
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
import { useState, useMemo, useEffect, useCallback } from 'react'
|
import { useState, useMemo, useEffect, useCallback } from 'react'
|
||||||
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
|
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||||
import { ScatterplotLayer, PathLayer, TextLayer } from '@deck.gl/layers'
|
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer } from '@deck.gl/layers'
|
||||||
import type { PickingInfo } from '@deck.gl/core'
|
import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'
|
||||||
import type { StyleSpecification } from 'maplibre-gl'
|
import type { StyleSpecification } from 'maplibre-gl'
|
||||||
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import { layerDatabase } from '@common/services/layerService'
|
import { layerDatabase } from '@common/services/layerService'
|
||||||
import { decimalToDMS } from '@common/utils/coordinates'
|
|
||||||
import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView'
|
import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView'
|
||||||
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
|
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
|
||||||
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||||
@ -163,6 +162,7 @@ interface MapViewProps {
|
|||||||
oilTrajectory?: Array<{ lat: number; lon: number; time: number; particle?: number; model?: PredictionModel }>
|
oilTrajectory?: Array<{ lat: number; lon: number; time: number; particle?: number; model?: PredictionModel }>
|
||||||
selectedModels?: Set<PredictionModel>
|
selectedModels?: Set<PredictionModel>
|
||||||
dispersionResult?: DispersionResult | null
|
dispersionResult?: DispersionResult | null
|
||||||
|
dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>
|
||||||
boomLines?: BoomLine[]
|
boomLines?: BoomLine[]
|
||||||
isDrawingBoom?: boolean
|
isDrawingBoom?: boolean
|
||||||
drawingPoints?: BoomLineCoord[]
|
drawingPoints?: BoomLineCoord[]
|
||||||
@ -177,6 +177,7 @@ interface MapViewProps {
|
|||||||
incidentCoord: { lat: number; lon: number }
|
incidentCoord: { lat: number; lon: number }
|
||||||
}
|
}
|
||||||
sensitiveResources?: SensitiveResource[]
|
sensitiveResources?: SensitiveResource[]
|
||||||
|
mapCaptureRef?: React.MutableRefObject<(() => string | null) | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
|
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
|
||||||
@ -201,6 +202,40 @@ function MapPitchController({ threeD }: { threeD: boolean }) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 사고 지점 변경 시 지도 이동 (Map 내부 컴포넌트)
|
||||||
|
function MapFlyToIncident({ lon, lat }: { lon?: number; lat?: number }) {
|
||||||
|
const { current: map } = useMap()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map || lon == null || lat == null) return
|
||||||
|
|
||||||
|
const doFly = () => {
|
||||||
|
map.flyTo({ center: [lon, lat], zoom: 12, duration: 1200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.loaded()) {
|
||||||
|
doFly()
|
||||||
|
} else {
|
||||||
|
map.once('load', doFly)
|
||||||
|
}
|
||||||
|
}, [lon, lat, map])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 지도 캡처 지원 (preserveDrawingBuffer 필요)
|
||||||
|
function MapCaptureSetup({ captureRef }: { captureRef: React.MutableRefObject<(() => string | null) | null> }) {
|
||||||
|
const { current: map } = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
captureRef.current = () => {
|
||||||
|
try { return map.getCanvas().toDataURL('image/png'); }
|
||||||
|
catch { return null; }
|
||||||
|
};
|
||||||
|
}, [map, captureRef]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// 팝업 정보
|
// 팝업 정보
|
||||||
interface PopupInfo {
|
interface PopupInfo {
|
||||||
longitude: number
|
longitude: number
|
||||||
@ -218,6 +253,7 @@ export function MapView({
|
|||||||
oilTrajectory = [],
|
oilTrajectory = [],
|
||||||
selectedModels = new Set(['OpenDrift'] as PredictionModel[]),
|
selectedModels = new Set(['OpenDrift'] as PredictionModel[]),
|
||||||
dispersionResult = null,
|
dispersionResult = null,
|
||||||
|
dispersionHeatmap = [],
|
||||||
boomLines = [],
|
boomLines = [],
|
||||||
isDrawingBoom = false,
|
isDrawingBoom = false,
|
||||||
drawingPoints = [],
|
drawingPoints = [],
|
||||||
@ -225,6 +261,7 @@ export function MapView({
|
|||||||
layerBrightness = 50,
|
layerBrightness = 50,
|
||||||
backtrackReplay,
|
backtrackReplay,
|
||||||
sensitiveResources = [],
|
sensitiveResources = [],
|
||||||
|
mapCaptureRef,
|
||||||
}: MapViewProps) {
|
}: MapViewProps) {
|
||||||
const { mapToggles } = useMapStore()
|
const { mapToggles } = useMapStore()
|
||||||
const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER)
|
const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER)
|
||||||
@ -429,6 +466,81 @@ export function MapView({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) ---
|
||||||
|
if (dispersionHeatmap && dispersionHeatmap.length > 0) {
|
||||||
|
const maxConc = Math.max(...dispersionHeatmap.map(p => p.concentration));
|
||||||
|
const minConc = Math.min(...dispersionHeatmap.filter(p => p.concentration > 0.01).map(p => p.concentration));
|
||||||
|
const filtered = dispersionHeatmap.filter(p => p.concentration > 0.01);
|
||||||
|
console.log('[MapView] HNS 히트맵:', dispersionHeatmap.length, '→ filtered:', filtered.length, 'maxConc:', maxConc.toFixed(2));
|
||||||
|
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
// 경위도 바운드 계산
|
||||||
|
let minLon = Infinity, maxLon = -Infinity, minLat = Infinity, maxLat = -Infinity;
|
||||||
|
for (const p of dispersionHeatmap) {
|
||||||
|
if (p.lon < minLon) minLon = p.lon;
|
||||||
|
if (p.lon > maxLon) maxLon = p.lon;
|
||||||
|
if (p.lat < minLat) minLat = p.lat;
|
||||||
|
if (p.lat > maxLat) maxLat = p.lat;
|
||||||
|
}
|
||||||
|
const padLon = (maxLon - minLon) * 0.02;
|
||||||
|
const padLat = (maxLat - minLat) * 0.02;
|
||||||
|
minLon -= padLon; maxLon += padLon;
|
||||||
|
minLat -= padLat; maxLat += padLat;
|
||||||
|
|
||||||
|
// 캔버스에 농도 이미지 렌더링
|
||||||
|
const W = 1200, H = 960;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = W;
|
||||||
|
canvas.height = H;
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// 로그 스케일: 농도 범위를 고르게 분포
|
||||||
|
const logMin = Math.log(minConc);
|
||||||
|
const logMax = Math.log(maxConc);
|
||||||
|
const logRange = logMax - logMin || 1;
|
||||||
|
|
||||||
|
const stops: [number, number, number, number][] = [
|
||||||
|
[34, 197, 94, 220], // green (저농도)
|
||||||
|
[234, 179, 8, 235], // yellow
|
||||||
|
[249, 115, 22, 245], // orange
|
||||||
|
[239, 68, 68, 250], // red (고농도)
|
||||||
|
[185, 28, 28, 255], // dark red (초고농도)
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const p of filtered) {
|
||||||
|
// 로그 스케일 정규화 (0~1)
|
||||||
|
const ratio = Math.max(0, Math.min(1, (Math.log(p.concentration) - logMin) / logRange));
|
||||||
|
const t = ratio * (stops.length - 1);
|
||||||
|
const lo = Math.floor(t);
|
||||||
|
const hi = Math.min(lo + 1, stops.length - 1);
|
||||||
|
const f = t - lo;
|
||||||
|
const r = Math.round(stops[lo][0] + (stops[hi][0] - stops[lo][0]) * f);
|
||||||
|
const g = Math.round(stops[lo][1] + (stops[hi][1] - stops[lo][1]) * f);
|
||||||
|
const b = Math.round(stops[lo][2] + (stops[hi][2] - stops[lo][2]) * f);
|
||||||
|
const a = (stops[lo][3] + (stops[hi][3] - stops[lo][3]) * f) / 255;
|
||||||
|
|
||||||
|
const px = ((p.lon - minLon) / (maxLon - minLon)) * W;
|
||||||
|
const py = (1 - (p.lat - minLat) / (maxLat - minLat)) * H;
|
||||||
|
|
||||||
|
ctx.fillStyle = `rgba(${r},${g},${b},${a.toFixed(2)})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py, 6, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(
|
||||||
|
new BitmapLayer({
|
||||||
|
id: 'hns-dispersion-bitmap',
|
||||||
|
image: canvas,
|
||||||
|
bounds: [minLon, minLat, maxLon, maxLat],
|
||||||
|
opacity: 1.0,
|
||||||
|
pickable: false,
|
||||||
|
}) as unknown as DeckLayer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) ---
|
// --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) ---
|
||||||
if (dispersionResult && incidentCoord) {
|
if (dispersionResult && incidentCoord) {
|
||||||
const zones = dispersionResult.zones.map((zone, idx) => ({
|
const zones = dispersionResult.zones.map((zone, idx) => ({
|
||||||
@ -452,24 +564,39 @@ export function MapView({
|
|||||||
stroked: true,
|
stroked: true,
|
||||||
radiusUnits: 'meters' as const,
|
radiusUnits: 'meters' as const,
|
||||||
pickable: true,
|
pickable: true,
|
||||||
onClick: (info: PickingInfo) => {
|
autoHighlight: true,
|
||||||
if (info.object) {
|
onHover: (info: PickingInfo) => {
|
||||||
const d = info.object as (typeof zones)[0]
|
if (info.object && info.coordinate) {
|
||||||
|
const zoneAreas = zones.map(z => ({
|
||||||
|
level: z.level,
|
||||||
|
area: Math.PI * z.radius * z.radius / 1e6,
|
||||||
|
}));
|
||||||
|
const totalArea = Math.PI * Math.max(...zones.map(z => z.radius)) ** 2 / 1e6;
|
||||||
setPopupInfo({
|
setPopupInfo({
|
||||||
longitude: incidentCoord.lon,
|
longitude: info.coordinate[0],
|
||||||
latitude: incidentCoord.lat,
|
latitude: info.coordinate[1],
|
||||||
content: (
|
content: (
|
||||||
<div className="text-xs">
|
<div className="text-xs leading-relaxed" style={{ minWidth: 180 }}>
|
||||||
<strong className="text-status-orange">{d.level}</strong>
|
<strong className="text-status-orange">{dispersionResult.substance} 대기확산 면적</strong>
|
||||||
<br />
|
<table style={{ width: '100%', marginTop: 4, borderCollapse: 'collapse' }}>
|
||||||
물질: {dispersionResult.substance}
|
<tbody>
|
||||||
<br />
|
{zoneAreas.map(z => (
|
||||||
농도: {dispersionResult.concentration[d.level]}
|
<tr key={z.level} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
|
||||||
<br />
|
<td style={{ padding: '2px 0', fontSize: 10 }}>{z.level}</td>
|
||||||
반경: {d.radius}m
|
<td style={{ padding: '2px 0', fontSize: 10, textAlign: 'right', fontFamily: 'monospace' }}>{z.area.toFixed(3)} km²</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: '3px 0 0', fontSize: 10, fontWeight: 700 }}>총 면적</td>
|
||||||
|
<td style={{ padding: '3px 0 0', fontSize: 10, fontWeight: 700, textAlign: 'right', fontFamily: 'monospace' }}>{totalArea.toFixed(3)} km²</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
})
|
});
|
||||||
|
} else if (!info.object) {
|
||||||
|
setPopupInfo(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -601,7 +728,7 @@ export function MapView({
|
|||||||
}, [
|
}, [
|
||||||
oilTrajectory, currentTime, selectedModels,
|
oilTrajectory, currentTime, selectedModels,
|
||||||
boomLines, isDrawingBoom, drawingPoints,
|
boomLines, isDrawingBoom, drawingPoints,
|
||||||
dispersionResult, incidentCoord, backtrackReplay,
|
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
|
||||||
sensitiveResources,
|
sensitiveResources,
|
||||||
])
|
])
|
||||||
|
|
||||||
@ -621,9 +748,14 @@ export function MapView({
|
|||||||
style={{ cursor: isSelectingLocation ? 'crosshair' : 'grab' }}
|
style={{ cursor: isSelectingLocation ? 'crosshair' : 'grab' }}
|
||||||
onClick={handleMapClick}
|
onClick={handleMapClick}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
|
preserveDrawingBuffer={true}
|
||||||
>
|
>
|
||||||
|
{/* 지도 캡처 셋업 */}
|
||||||
|
{mapCaptureRef && <MapCaptureSetup captureRef={mapCaptureRef} />}
|
||||||
{/* 3D 모드 pitch 제어 */}
|
{/* 3D 모드 pitch 제어 */}
|
||||||
<MapPitchController threeD={mapToggles.threeD} />
|
<MapPitchController threeD={mapToggles.threeD} />
|
||||||
|
{/* 사고 지점 변경 시 지도 이동 */}
|
||||||
|
<MapFlyToIncident lon={incidentCoord?.lon} lat={incidentCoord?.lat} />
|
||||||
|
|
||||||
{/* WMS 레이어 */}
|
{/* WMS 레이어 */}
|
||||||
{wmsLayers.map(layer => (
|
{wmsLayers.map(layer => (
|
||||||
@ -652,9 +784,10 @@ export function MapView({
|
|||||||
<DeckGLOverlay layers={deckLayers} />
|
<DeckGLOverlay layers={deckLayers} />
|
||||||
|
|
||||||
{/* 사고 위치 마커 (MapLibre Marker) */}
|
{/* 사고 위치 마커 (MapLibre Marker) */}
|
||||||
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && (
|
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && !(dispersionHeatmap && dispersionHeatmap.length > 0) && (
|
||||||
<Marker longitude={incidentCoord.lon} latitude={incidentCoord.lat} anchor="bottom">
|
<Marker longitude={incidentCoord.lon} latitude={incidentCoord.lat} anchor="bottom">
|
||||||
<div
|
<div
|
||||||
|
title={`사고 지점\n${incidentCoord.lat.toFixed(4)}°N, ${incidentCoord.lon.toFixed(4)}°E`}
|
||||||
className="w-6 h-6 bg-primary-cyan border-2 border-white"
|
className="w-6 h-6 bg-primary-cyan border-2 border-white"
|
||||||
style={{
|
style={{
|
||||||
borderRadius: '50% 50% 50% 0',
|
borderRadius: '50% 50% 50% 0',
|
||||||
@ -665,25 +798,6 @@ export function MapView({
|
|||||||
</Marker>
|
</Marker>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 사고 위치 팝업 (클릭 시) */}
|
|
||||||
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && !popupInfo && (
|
|
||||||
<Popup longitude={incidentCoord.lon} latitude={incidentCoord.lat} anchor="bottom" offset={30} closeButton={false} closeOnClick={false}>
|
|
||||||
<div className="text-sm text-[#333]">
|
|
||||||
<strong>사고 지점</strong>
|
|
||||||
<br />
|
|
||||||
<span className="text-xs text-[#666]">
|
|
||||||
{decimalToDMS(incidentCoord.lat, true)}
|
|
||||||
<br />
|
|
||||||
{decimalToDMS(incidentCoord.lon, false)}
|
|
||||||
</span>
|
|
||||||
<br />
|
|
||||||
<span className="text-xs font-mono text-[#888]">
|
|
||||||
({incidentCoord.lat.toFixed(4)}°, {incidentCoord.lon.toFixed(4)}°)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* deck.gl 객체 클릭 팝업 */}
|
{/* deck.gl 객체 클릭 팝업 */}
|
||||||
{popupInfo && (
|
{popupInfo && (
|
||||||
<Popup
|
<Popup
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
/** hex 색상(#rrggbb)을 deck.gl용 RGBA 배열로 변환 */
|
/** 색상 문자열(#rrggbb 또는 rgba(...))을 deck.gl용 RGBA 배열로 변환 */
|
||||||
export function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
|
export function hexToRgba(color: string, alpha = 255): [number, number, number, number] {
|
||||||
const r = parseInt(hex.slice(1, 3), 16)
|
// rgba(r,g,b,a) 형식 처리
|
||||||
const g = parseInt(hex.slice(3, 5), 16)
|
const rgbaMatch = color.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/)
|
||||||
const b = parseInt(hex.slice(5, 7), 16)
|
if (rgbaMatch) {
|
||||||
return [r, g, b, alpha]
|
return [Number(rgbaMatch[1]), Number(rgbaMatch[2]), Number(rgbaMatch[3]), alpha]
|
||||||
|
}
|
||||||
|
// hex #rrggbb 형식 처리
|
||||||
|
const r = parseInt(color.slice(1, 3), 16)
|
||||||
|
const g = parseInt(color.slice(3, 5), 16)
|
||||||
|
const b = parseInt(color.slice(5, 7), 16)
|
||||||
|
return [isNaN(r) ? 0 : r, isNaN(g) ? 0 : g, isNaN(b) ? 0 : b, alpha]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -157,4 +157,23 @@ export function consumeReportGenCategory(): number | null {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── HNS 보고서 실 데이터 전달 ──────────────────────────
|
||||||
|
export interface HnsReportPayload {
|
||||||
|
mapImageDataUrl: string | null;
|
||||||
|
substance: { name: string; un?: string; cas?: string; class?: string; toxicity: string };
|
||||||
|
hazard: { aegl3: string; aegl2: string; aegl1: string };
|
||||||
|
atm: { model: string; maxDistance: string };
|
||||||
|
weather: { windDir: string; windSpeed: string; stability: string; temperature: string };
|
||||||
|
maxConcentration: string;
|
||||||
|
aeglAreas: { aegl1: string; aegl2: string; aegl3: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
let _hnsReportPayload: HnsReportPayload | null = null;
|
||||||
|
export function setHnsReportPayload(d: HnsReportPayload | null) { _hnsReportPayload = d; }
|
||||||
|
export function consumeHnsReportPayload(): HnsReportPayload | null {
|
||||||
|
const v = _hnsReportPayload;
|
||||||
|
_hnsReportPayload = null;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
export { subMenuState }
|
export { subMenuState }
|
||||||
|
|||||||
83562
frontend/src/data/hnsSubstanceData.json
Normal file
83562
frontend/src/data/hnsSubstanceData.json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
250
frontend/src/tabs/aerial/components/CCTVPlayer.tsx
Normal file
250
frontend/src/tabs/aerial/components/CCTVPlayer.tsx
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
|
import Hls from 'hls.js';
|
||||||
|
import { detectStreamType } from '../utils/streamUtils';
|
||||||
|
|
||||||
|
interface CCTVPlayerProps {
|
||||||
|
cameraNm: string;
|
||||||
|
streamUrl: string | null;
|
||||||
|
sttsCd: string;
|
||||||
|
coordDc?: string | null;
|
||||||
|
sourceNm?: string | null;
|
||||||
|
cellIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerState = 'loading' | 'playing' | 'error' | 'offline' | 'no-url';
|
||||||
|
|
||||||
|
/** 외부 HLS URL을 백엔드 프록시 경유 URL로 변환 */
|
||||||
|
function toProxyUrl(url: string): string {
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
return `/api/aerial/cctv/stream-proxy?url=${encodeURIComponent(url)}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CCTVPlayer({
|
||||||
|
cameraNm,
|
||||||
|
streamUrl,
|
||||||
|
sttsCd,
|
||||||
|
coordDc,
|
||||||
|
sourceNm,
|
||||||
|
cellIndex = 0,
|
||||||
|
}: CCTVPlayerProps) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const hlsRef = useRef<Hls | null>(null);
|
||||||
|
const [hlsPlayerState, setHlsPlayerState] = useState<'loading' | 'playing' | 'error'>('loading');
|
||||||
|
const [retryKey, setRetryKey] = useState(0);
|
||||||
|
|
||||||
|
/** 원본 URL 기반으로 타입 감지, 재생은 프록시 URL 사용 */
|
||||||
|
const proxiedUrl = useMemo(
|
||||||
|
() => (streamUrl ? toProxyUrl(streamUrl) : null),
|
||||||
|
[streamUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** props 기반으로 상태를 동기적으로 파생 */
|
||||||
|
const isOffline = sttsCd === 'OFFLINE' || sttsCd === 'MAINT';
|
||||||
|
const hasNoUrl = !isOffline && (!streamUrl || !proxiedUrl);
|
||||||
|
const streamType = useMemo(
|
||||||
|
() => (streamUrl && !isOffline ? detectStreamType(streamUrl) : null),
|
||||||
|
[streamUrl, isOffline],
|
||||||
|
);
|
||||||
|
|
||||||
|
const playerState: PlayerState = isOffline
|
||||||
|
? 'offline'
|
||||||
|
: hasNoUrl
|
||||||
|
? 'no-url'
|
||||||
|
: (streamType === 'mjpeg' || streamType === 'iframe')
|
||||||
|
? 'playing'
|
||||||
|
: hlsPlayerState;
|
||||||
|
|
||||||
|
const destroyHls = useCallback(() => {
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOffline || hasNoUrl || !streamUrl || !proxiedUrl) {
|
||||||
|
destroyHls();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = detectStreamType(streamUrl);
|
||||||
|
queueMicrotask(() => setHlsPlayerState('loading'));
|
||||||
|
|
||||||
|
if (type === 'hls') {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
destroyHls();
|
||||||
|
const hls = new Hls({
|
||||||
|
enableWorker: true,
|
||||||
|
lowLatencyMode: true,
|
||||||
|
maxBufferLength: 10,
|
||||||
|
maxMaxBufferLength: 30,
|
||||||
|
});
|
||||||
|
hlsRef.current = hls;
|
||||||
|
hls.loadSource(proxiedUrl);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
setHlsPlayerState('playing');
|
||||||
|
video.play().catch(() => {});
|
||||||
|
});
|
||||||
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
|
if (data.fatal) {
|
||||||
|
setHlsPlayerState('error');
|
||||||
|
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||||
|
setTimeout(() => hls.startLoad(), 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => destroyHls();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safari 네이티브 HLS (프록시 경유)
|
||||||
|
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
video.src = proxiedUrl;
|
||||||
|
const onLoaded = () => setHlsPlayerState('playing');
|
||||||
|
const onError = () => setHlsPlayerState('error');
|
||||||
|
video.addEventListener('loadeddata', onLoaded);
|
||||||
|
video.addEventListener('error', onError);
|
||||||
|
video.play().catch(() => {});
|
||||||
|
return () => {
|
||||||
|
video.removeEventListener('loadeddata', onLoaded);
|
||||||
|
video.removeEventListener('error', onError);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
queueMicrotask(() => setHlsPlayerState('error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'mp4') {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
video.src = proxiedUrl;
|
||||||
|
const onLoaded = () => setHlsPlayerState('playing');
|
||||||
|
const onError = () => setHlsPlayerState('error');
|
||||||
|
video.addEventListener('loadeddata', onLoaded);
|
||||||
|
video.addEventListener('error', onError);
|
||||||
|
video.play().catch(() => {});
|
||||||
|
return () => {
|
||||||
|
video.removeEventListener('loadeddata', onLoaded);
|
||||||
|
video.removeEventListener('error', onError);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'mjpeg' || type === 'iframe') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queueMicrotask(() => setHlsPlayerState('error'));
|
||||||
|
return () => destroyHls();
|
||||||
|
}, [streamUrl, proxiedUrl, isOffline, hasNoUrl, destroyHls, retryKey]);
|
||||||
|
|
||||||
|
// 오프라인
|
||||||
|
if (playerState === 'offline') {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18]">
|
||||||
|
<div className="text-2xl opacity-30 mb-2">📹</div>
|
||||||
|
<div className="text-[11px] font-korean text-text-3 opacity-70">
|
||||||
|
{sttsCd === 'MAINT' ? '점검중' : '오프라인'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] font-korean text-text-3 opacity-40 mt-1">{cameraNm}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL 미설정
|
||||||
|
if (playerState === 'no-url') {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18]">
|
||||||
|
<div className="text-2xl opacity-20 mb-2">📹</div>
|
||||||
|
<div className="text-[10px] font-korean text-text-3 opacity-50">스트림 URL 미설정</div>
|
||||||
|
<div className="text-[9px] font-korean text-text-3 opacity-30 mt-1">{cameraNm}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 에러
|
||||||
|
if (playerState === 'error') {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18]">
|
||||||
|
<div className="text-2xl opacity-30 mb-2">⚠️</div>
|
||||||
|
<div className="text-[10px] font-korean text-status-red opacity-70">연결 실패</div>
|
||||||
|
<div className="text-[9px] font-korean text-text-3 opacity-40 mt-1">{cameraNm}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setRetryKey(k => k + 1)}
|
||||||
|
className="mt-2 px-2.5 py-1 rounded text-[9px] font-korean bg-bg-3 border border-border text-text-2 cursor-pointer hover:bg-bg-hover transition-colors"
|
||||||
|
>
|
||||||
|
재시도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 로딩 오버레이 */}
|
||||||
|
{playerState === 'loading' && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18] z-10">
|
||||||
|
<div className="text-lg opacity-40 animate-pulse mb-2">📹</div>
|
||||||
|
<div className="text-[10px] font-korean text-text-3 opacity-50">연결 중...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* HLS / MP4 */}
|
||||||
|
{(streamType === 'hls' || streamType === 'mp4') && (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
key={`video-${cellIndex}-${retryKey}`}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
muted
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
loop={streamType === 'mp4'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MJPEG */}
|
||||||
|
{streamType === 'mjpeg' && proxiedUrl && (
|
||||||
|
<img
|
||||||
|
src={proxiedUrl}
|
||||||
|
alt={cameraNm}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
onError={() => setPlayerState('error')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* iframe (원본 URL 사용 — iframe은 자체 CORS) */}
|
||||||
|
{streamType === 'iframe' && streamUrl && (
|
||||||
|
<iframe
|
||||||
|
src={streamUrl}
|
||||||
|
title={cameraNm}
|
||||||
|
className="absolute inset-0 w-full h-full border-none"
|
||||||
|
allow="autoplay; encrypted-media"
|
||||||
|
onError={() => setPlayerState('error')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* OSD 오버레이 */}
|
||||||
|
<div className="absolute top-2 left-2 flex items-center gap-1.5 z-20">
|
||||||
|
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-black/70 text-white">
|
||||||
|
{cameraNm}
|
||||||
|
</span>
|
||||||
|
{sttsCd === 'LIVE' && (
|
||||||
|
<span
|
||||||
|
className="text-[8px] font-bold px-1 py-0.5 rounded text-[#f87171]"
|
||||||
|
style={{ background: 'rgba(239,68,68,.3)' }}
|
||||||
|
>
|
||||||
|
● REC
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded text-text-3 bg-black/70 z-20">
|
||||||
|
{coordDc ?? ''}{sourceNm ? ` · ${sourceNm}` : ''}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,11 +1,49 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react'
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
import { fetchCctvCameras } from '../services/aerialApi'
|
import { fetchCctvCameras } from '../services/aerialApi'
|
||||||
import type { CctvCameraItem } from '../services/aerialApi'
|
import type { CctvCameraItem } from '../services/aerialApi'
|
||||||
|
import { CCTVPlayer } from './CCTVPlayer'
|
||||||
|
|
||||||
|
/** KHOA HLS 스트림 베이스 URL */
|
||||||
|
const KHOA_HLS = 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa';
|
||||||
|
|
||||||
|
/** KHOA HLS 스트림 URL 생성 */
|
||||||
|
function khoaHlsUrl(siteName: string): string {
|
||||||
|
return `${KHOA_HLS}/${siteName}/s.m3u8`;
|
||||||
|
}
|
||||||
|
|
||||||
const cctvFavorites = [
|
const cctvFavorites = [
|
||||||
{ name: '서귀포항 동측', reason: '유출 사고 인접' },
|
{ name: '여수항 해무관측', reason: '유출 사고 인접' },
|
||||||
{ name: '여수 신항', reason: '주요 방제 거점' },
|
{ name: '부산항 조위관측소', reason: '주요 방제 거점' },
|
||||||
{ name: '목포 내항', reason: '서해 모니터링' },
|
{ name: '목포항 해무관측', reason: '서해 모니터링' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** badatime.com 실제 해안 CCTV 데이터 (API 미연결 시 폴백) */
|
||||||
|
const FALLBACK_CAMERAS: CctvCameraItem[] = [
|
||||||
|
// 서해
|
||||||
|
{ cctvSn: 29, cameraNm: '인천항 조위관측소', regionNm: '서해', lon: 126.5922, lat: 37.4519, locDc: '인천광역시 중구 항동', coordDc: '37.45°N 126.59°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Incheon') },
|
||||||
|
{ cctvSn: 30, cameraNm: '인천항 해무관측', regionNm: '서해', lon: 126.6161, lat: 37.3797, locDc: '인천광역시 중구 항동', coordDc: 'N 37°22\'47" E 126°36\'58"', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Incheon') },
|
||||||
|
{ cctvSn: 31, cameraNm: '대산항 해무관측', regionNm: '서해', lon: 126.3526, lat: 37.0058, locDc: '충남 서산시 대산읍', coordDc: '37.01°N 126.35°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Daesan') },
|
||||||
|
{ cctvSn: 32, cameraNm: '평택·당진항 해무관측', regionNm: '서해', lon: 126.3936, lat: 37.1131, locDc: '충남 당진시 송악읍', coordDc: 'N 37°06\'47" E 126°23\'37"', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_PTDJ') },
|
||||||
|
{ cctvSn: 100, cameraNm: '인천 연안부두', regionNm: '서해', lon: 126.6125, lat: 37.4625, locDc: '인천광역시 중구 연안부두', coordDc: '37.46°N 126.61°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
|
||||||
|
// 남해
|
||||||
|
{ cctvSn: 35, cameraNm: '목포항 해무관측', regionNm: '남해', lon: 126.3780, lat: 34.7780, locDc: '전남 목포시 항동', coordDc: '34.78°N 126.38°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Mokpo') },
|
||||||
|
{ cctvSn: 36, cameraNm: '진도항 조위관측소', regionNm: '남해', lon: 126.3085, lat: 34.4710, locDc: '전남 진도군 진도읍', coordDc: '34.47°N 126.31°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Jindo') },
|
||||||
|
{ cctvSn: 37, cameraNm: '여수항 해무관측', regionNm: '남해', lon: 127.7669, lat: 34.7384, locDc: '전남 여수시 종화동', coordDc: '34.74°N 127.77°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Yeosu') },
|
||||||
|
{ cctvSn: 38, cameraNm: '여수항 조위관측소', regionNm: '남해', lon: 127.7650, lat: 34.7370, locDc: '전남 여수시 종화동', coordDc: '34.74°N 127.77°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Yeosu') },
|
||||||
|
{ cctvSn: 39, cameraNm: '부산항 조위관측소', regionNm: '남해', lon: 129.0756, lat: 35.0969, locDc: '부산광역시 중구 중앙동', coordDc: '35.10°N 129.08°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Busan') },
|
||||||
|
{ cctvSn: 40, cameraNm: '부산항 해무관측', regionNm: '남해', lon: 129.0780, lat: 35.0980, locDc: '부산광역시 중구', coordDc: '35.10°N 129.08°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Busan') },
|
||||||
|
{ cctvSn: 41, cameraNm: '해운대 해무관측', regionNm: '남해', lon: 129.1718, lat: 35.1587, locDc: '부산광역시 해운대구', coordDc: '35.16°N 129.17°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Haeundae') },
|
||||||
|
{ cctvSn: 97, cameraNm: '오동도', regionNm: '남해', lon: 127.7836, lat: 34.7369, locDc: '전남 여수시 수정동', coordDc: '34.74°N 127.78°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
|
||||||
|
{ cctvSn: 108, cameraNm: '완도항', regionNm: '남해', lon: 126.7550, lat: 34.3114, locDc: '전남 완도군 완도읍', coordDc: '34.31°N 126.76°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
|
||||||
|
// 동해
|
||||||
|
{ cctvSn: 42, cameraNm: '울산항 해무관측', regionNm: '동해', lon: 129.3870, lat: 35.5000, locDc: '울산광역시 남구', coordDc: '35.50°N 129.39°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Ulsan') },
|
||||||
|
{ cctvSn: 43, cameraNm: '포항항 해무관측', regionNm: '동해', lon: 129.3798, lat: 36.0323, locDc: '경북 포항시 북구', coordDc: '36.03°N 129.38°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Pohang') },
|
||||||
|
{ cctvSn: 44, cameraNm: '묵호항 조위관측소', regionNm: '동해', lon: 129.1146, lat: 37.5500, locDc: '강원 동해시 묵호동', coordDc: '37.55°N 129.11°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Mukho') },
|
||||||
|
{ cctvSn: 113, cameraNm: '속초등대', regionNm: '동해', lon: 128.5964, lat: 38.2070, locDc: '강원 속초시 영랑동', coordDc: '38.21°N 128.60°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
|
||||||
|
{ cctvSn: 115, cameraNm: '독도', regionNm: '동해', lon: 131.8689, lat: 37.2394, locDc: '경북 울릉군 울릉읍 독도리', coordDc: '37.24°N 131.87°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
|
||||||
|
// 제주
|
||||||
|
{ cctvSn: 45, cameraNm: '모슬포항 조위관측소', regionNm: '제주', lon: 126.2519, lat: 33.2136, locDc: '제주 서귀포시 대정읍', coordDc: '33.21°N 126.25°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Moseulpo') },
|
||||||
|
{ cctvSn: 116, cameraNm: '마라도', regionNm: '제주', lon: 126.2669, lat: 33.1140, locDc: '제주 서귀포시 대정읍 마라리', coordDc: '33.11°N 126.27°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function CctvView() {
|
export function CctvView() {
|
||||||
@ -21,9 +59,9 @@ export function CctvView() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const items = await fetchCctvCameras()
|
const items = await fetchCctvCameras()
|
||||||
setCameras(items)
|
setCameras(items.length > 0 ? items : FALLBACK_CAMERAS)
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error('[aerial] CCTV 목록 조회 실패:', err)
|
setCameras(FALLBACK_CAMERAS)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@ -203,21 +241,14 @@ export function CctvView() {
|
|||||||
return (
|
return (
|
||||||
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-[#0a0e18]" style={{ border: '1px solid rgba(255,255,255,.06)' }}>
|
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-[#0a0e18]" style={{ border: '1px solid rgba(255,255,255,.06)' }}>
|
||||||
{cam ? (
|
{cam ? (
|
||||||
<>
|
<CCTVPlayer
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
cameraNm={cam.cameraNm}
|
||||||
<div className="text-4xl opacity-20">📹</div>
|
streamUrl={cam.streamUrl}
|
||||||
</div>
|
sttsCd={cam.sttsCd}
|
||||||
<div className="absolute top-2 left-2 flex items-center gap-1.5">
|
coordDc={cam.coordDc}
|
||||||
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-black/70">{cam.cameraNm}</span>
|
sourceNm={cam.sourceNm}
|
||||||
<span className="text-[8px] font-bold px-1 py-0.5 rounded text-[#f87171]" style={{ background: 'rgba(239,68,68,.3)' }}>● REC</span>
|
cellIndex={i}
|
||||||
</div>
|
/>
|
||||||
<div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded text-text-3 bg-black/70">
|
|
||||||
{cam.coordDc ?? ''} · {cam.sourceNm ?? ''}
|
|
||||||
</div>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-[11px] font-korean text-text-3 opacity-60">
|
|
||||||
CCTV 스트리밍 영역
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[10px] text-text-3 font-korean opacity-40">카메라를 선택하세요</div>
|
<div className="text-[10px] text-text-3 font-korean opacity-40">카메라를 선택하세요</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
42
frontend/src/tabs/aerial/utils/streamUtils.ts
Normal file
42
frontend/src/tabs/aerial/utils/streamUtils.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* CCTV 스트림 타입 감지 유틸리티
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type StreamType = 'hls' | 'mjpeg' | 'iframe' | 'mp4' | 'unknown';
|
||||||
|
|
||||||
|
/** URL 패턴으로 스트림 타입을 자동 감지한다. */
|
||||||
|
export function detectStreamType(url: string): StreamType {
|
||||||
|
const lower = url.toLowerCase();
|
||||||
|
|
||||||
|
// KHOA 공식 팝업 페이지 (내장 video.js — videoUrl 파라미터에 .m3u8이 포함되므로 HLS보다 먼저 체크)
|
||||||
|
if (lower.includes('khoa.go.kr') && lower.includes('popup.do')) {
|
||||||
|
return 'iframe';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.includes('.m3u8') || lower.includes('/hls/')) {
|
||||||
|
return 'hls';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lower.includes('mjpeg') ||
|
||||||
|
lower.includes('mjpg') ||
|
||||||
|
lower.includes('/video/mjpg') ||
|
||||||
|
lower.includes('action=stream')
|
||||||
|
) {
|
||||||
|
return 'mjpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lower.includes('youtube.com/embed') ||
|
||||||
|
lower.includes('player.') ||
|
||||||
|
lower.includes('/embed/')
|
||||||
|
) {
|
||||||
|
return 'iframe';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.endsWith('.mp4') || lower.endsWith('.webm') || lower.endsWith('.ogg')) {
|
||||||
|
return 'mp4';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { fetchHnsAnalyses, type HnsAnalysisItem } from '../services/hnsApi'
|
|||||||
|
|
||||||
interface HNSAnalysisListTableProps {
|
interface HNSAnalysisListTableProps {
|
||||||
onTabChange: Dispatch<SetStateAction<'analysis' | 'list'>>
|
onTabChange: Dispatch<SetStateAction<'analysis' | 'list'>>
|
||||||
|
onSelectAnalysis?: (sn: number, localRsltData?: Record<string, unknown>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const RISK_LABEL: Record<string, string> = {
|
const RISK_LABEL: Record<string, string> = {
|
||||||
@ -34,7 +35,7 @@ function substanceTag(sbstNm: string | null): string {
|
|||||||
return sbstNm.length > 6 ? sbstNm.slice(0, 6) : sbstNm
|
return sbstNm.length > 6 ? sbstNm.slice(0, 6) : sbstNm
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps) {
|
export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnalysisListTableProps) {
|
||||||
const [analyses, setAnalyses] = useState<HnsAnalysisItem[]>([])
|
const [analyses, setAnalyses] = useState<HnsAnalysisItem[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@ -44,7 +45,49 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
|
|||||||
const items = await fetchHnsAnalyses()
|
const items = await fetchHnsAnalyses()
|
||||||
setAnalyses(items)
|
setAnalyses(items)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[hns] 분석 목록 조회 실패:', err)
|
console.error('[hns] 분석 목록 조회 실패, localStorage fallback:', err)
|
||||||
|
// DB 실패 시 localStorage에서 불러오기
|
||||||
|
try {
|
||||||
|
const localRaw = localStorage.getItem('hns_saved_analyses')
|
||||||
|
if (localRaw) {
|
||||||
|
const localItems = JSON.parse(localRaw) as Record<string, unknown>[]
|
||||||
|
const mapped: HnsAnalysisItem[] = localItems.map((entry) => {
|
||||||
|
const rslt = entry.rsltData as Record<string, unknown> | null
|
||||||
|
const inputP = rslt?.inputParams as Record<string, unknown> | null
|
||||||
|
const coord = rslt?.coord as { lon: number; lat: number } | null
|
||||||
|
const weather = rslt?.weather as Record<string, unknown> | null
|
||||||
|
return {
|
||||||
|
hnsAnlysSn: (entry.id as number) || 0,
|
||||||
|
anlysNm: (entry.anlysNm as string) || '로컬 저장 분석',
|
||||||
|
acdntDtm: (entry.acdntDtm as string)
|
||||||
|
|| (inputP?.accidentDate && inputP?.accidentTime
|
||||||
|
? `${inputP.accidentDate as string}T${inputP.accidentTime as string}:00`
|
||||||
|
: (inputP?.accidentDate as string))
|
||||||
|
|| null,
|
||||||
|
locNm: coord ? `${coord.lat.toFixed(4)} / ${coord.lon.toFixed(4)}` : null,
|
||||||
|
lon: coord?.lon ?? null,
|
||||||
|
lat: coord?.lat ?? null,
|
||||||
|
sbstNm: (entry.sbstNm as string) || null,
|
||||||
|
spilQty: (entry.spilQty as number) ?? null,
|
||||||
|
spilUnitCd: (entry.spilUnitCd as string) || null,
|
||||||
|
fcstHr: (entry.fcstHr as number) ?? null,
|
||||||
|
algoCd: (inputP?.algorithm as string) || null,
|
||||||
|
critMdlCd: (inputP?.criteriaModel as string) || null,
|
||||||
|
windSpd: (weather?.windSpeed as number) ?? null,
|
||||||
|
windDir: weather?.windDirection != null ? String(weather.windDirection) : null,
|
||||||
|
execSttsCd: 'COMPLETED',
|
||||||
|
riskCd: (entry.riskCd as string) || null,
|
||||||
|
analystNm: (entry.analystNm as string) || null,
|
||||||
|
rsltData: rslt ?? null,
|
||||||
|
regDtm: (entry.regDtm as string) || new Date().toISOString(),
|
||||||
|
_isLocal: true,
|
||||||
|
} as HnsAnalysisItem & { _isLocal?: boolean }
|
||||||
|
})
|
||||||
|
setAnalyses(mapped)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage 파싱 실패 무시
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@ -129,6 +172,7 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
|
|||||||
<tbody>
|
<tbody>
|
||||||
{analyses.map((item, index) => {
|
{analyses.map((item, index) => {
|
||||||
const rslt = item.rsltData as Record<string, unknown> | null
|
const rslt = item.rsltData as Record<string, unknown> | null
|
||||||
|
const isLocal = !!(item as HnsAnalysisItem & { _isLocal?: boolean })._isLocal
|
||||||
const riskLabel = RISK_LABEL[item.riskCd || ''] || item.riskCd || '—'
|
const riskLabel = RISK_LABEL[item.riskCd || ''] || item.riskCd || '—'
|
||||||
const riskStyle = RISK_STYLE[item.riskCd || ''] || { bg: 'rgba(100,100,100,0.1)', color: 'var(--t3)' }
|
const riskStyle = RISK_STYLE[item.riskCd || ''] || { bg: 'rgba(100,100,100,0.1)', color: 'var(--t3)' }
|
||||||
const aegl3 = rslt?.aegl3 as boolean | undefined
|
const aegl3 = rslt?.aegl3 as boolean | undefined
|
||||||
@ -141,6 +185,7 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
|
|||||||
<tr
|
<tr
|
||||||
key={item.hnsAnlysSn}
|
key={item.hnsAnlysSn}
|
||||||
className="border-b border-border cursor-pointer"
|
className="border-b border-border cursor-pointer"
|
||||||
|
onClick={() => onSelectAnalysis?.(item.hnsAnlysSn, isLocal && rslt ? rslt : undefined)}
|
||||||
style={{
|
style={{
|
||||||
transition: 'background 0.15s',
|
transition: 'background 0.15s',
|
||||||
background: index % 2 === 0 ? 'transparent' : 'rgba(255,255,255,0.01)'
|
background: index % 2 === 0 ? 'transparent' : 'rgba(255,255,255,0.01)'
|
||||||
|
|||||||
@ -1,55 +1,197 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { ComboBox } from '@common/components/ui/ComboBox'
|
import { ComboBox } from '@common/components/ui/ComboBox';
|
||||||
|
import { useWeatherFetch } from '../hooks/useWeatherFetch';
|
||||||
|
import { getSubstanceToxicity } from '../utils/toxicityData';
|
||||||
|
import type { WeatherFetchResult, ReleaseType } from '../utils/dispersionTypes';
|
||||||
|
import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi';
|
||||||
|
import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi';
|
||||||
|
|
||||||
|
/** HNS 분석 입력 파라미터 (부모에 전달) */
|
||||||
|
export interface HNSInputParams {
|
||||||
|
substance: string;
|
||||||
|
releaseType: ReleaseType;
|
||||||
|
/** 배출률 (g/s) — Plume, Dense Gas */
|
||||||
|
emissionRate: number;
|
||||||
|
/** 총 누출량 (g) — Puff */
|
||||||
|
totalRelease: number;
|
||||||
|
/** 누출 높이 (m) — 전 모델 */
|
||||||
|
releaseHeight: number;
|
||||||
|
/** 누출 지속시간 (s) — Plume */
|
||||||
|
releaseDuration: number;
|
||||||
|
/** 액체풀 반경 (m) — Dense Gas */
|
||||||
|
poolRadius: number;
|
||||||
|
algorithm: string;
|
||||||
|
criteriaModel: string;
|
||||||
|
weather: WeatherFetchResult;
|
||||||
|
/** 사고 발생일 (YYYY-MM-DD) */
|
||||||
|
accidentDate: string;
|
||||||
|
/** 사고 발생시각 (HH:mm) */
|
||||||
|
accidentTime: string;
|
||||||
|
/** 예측시간 (예: '24시간') */
|
||||||
|
predictionTime: string;
|
||||||
|
/** 사고명 (직접 입력 또는 사고 리스트 선택) */
|
||||||
|
accidentName: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface HNSLeftPanelProps {
|
interface HNSLeftPanelProps {
|
||||||
activeSubTab: 'analysis' | 'list'
|
activeSubTab: 'analysis' | 'list';
|
||||||
onSubTabChange: (tab: 'analysis' | 'list') => void
|
onSubTabChange: (tab: 'analysis' | 'list') => void;
|
||||||
incidentCoord: { lon: number; lat: number }
|
incidentCoord: { lon: number; lat: number };
|
||||||
onCoordChange: (coord: { lon: number; lat: number }) => void
|
onCoordChange: (coord: { lon: number; lat: number }) => void;
|
||||||
onMapSelectClick: () => void
|
onMapSelectClick: () => void;
|
||||||
onRunPrediction: () => void
|
onRunPrediction: () => void;
|
||||||
isRunningPrediction: boolean
|
isRunningPrediction: boolean;
|
||||||
|
onParamsChange?: (params: HNSInputParams) => void;
|
||||||
|
onReset?: () => void;
|
||||||
|
loadedParams?: Partial<HNSInputParams> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 십진 좌표 → 도분초 변환 */
|
||||||
|
function toDMS(decimal: number, type: 'lat' | 'lon'): string {
|
||||||
|
const abs = Math.abs(decimal);
|
||||||
|
const d = Math.floor(abs);
|
||||||
|
const m = Math.floor((abs - d) * 60);
|
||||||
|
const s = ((abs - d - m / 60) * 3600).toFixed(2);
|
||||||
|
const dir = type === 'lat' ? (decimal >= 0 ? 'N' : 'S') : (decimal >= 0 ? 'E' : 'W');
|
||||||
|
return `${d}\u00B0 ${m}' ${s}" ${dir}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HNSLeftPanel({
|
export function HNSLeftPanel({
|
||||||
activeSubTab,
|
activeSubTab,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
onSubTabChange: _onSubTabChange, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
onSubTabChange,
|
|
||||||
incidentCoord,
|
incidentCoord,
|
||||||
onCoordChange,
|
onCoordChange,
|
||||||
onMapSelectClick,
|
onMapSelectClick,
|
||||||
onRunPrediction,
|
onRunPrediction,
|
||||||
isRunningPrediction
|
isRunningPrediction,
|
||||||
|
onParamsChange,
|
||||||
|
onReset,
|
||||||
|
loadedParams,
|
||||||
}: HNSLeftPanelProps) {
|
}: HNSLeftPanelProps) {
|
||||||
const [accidentName, setAccidentName] = useState('울산 온산항 톨루엔 누출')
|
const [incidents, setIncidents] = useState<IncidentListItem[]>([]);
|
||||||
const [predictionTime, setPredictionTime] = useState('24시간')
|
const [selectedIncidentSn, setSelectedIncidentSn] = useState('');
|
||||||
const [locationName, setLocationName] = useState('울산 온산항 제3부두')
|
|
||||||
const [materialCategory, setMaterialCategory] = useState('인화성 액체')
|
|
||||||
const [substance, setSubstance] = useState('톨루엔 (Toluene)')
|
|
||||||
const [unNumber] = useState('UN 1294')
|
|
||||||
const [casNumber] = useState('108-88-3')
|
|
||||||
const [amount, setAmount] = useState('12.0')
|
|
||||||
const [unit, setUnit] = useState('kL')
|
|
||||||
const [releaseType, setReleaseType] = useState('연속 유출')
|
|
||||||
const [algorithm, setAlgorithm] = useState('ALOHA (EPA)')
|
|
||||||
const [criteriaModel, setCriteriaModel] = useState('AEGL')
|
|
||||||
|
|
||||||
const handleReset = () => {
|
const [accidentName, setAccidentName] = useState('');
|
||||||
setAccidentName('울산 온산항 톨루엔 누출')
|
const [accidentDate, setAccidentDate] = useState<string>(() => {
|
||||||
setPredictionTime('24시간')
|
const now = new Date();
|
||||||
setLocationName('울산 온산항 제3부두')
|
return now.toISOString().slice(0, 10);
|
||||||
setMaterialCategory('인화성 액체')
|
});
|
||||||
setSubstance('톨루엔 (Toluene)')
|
const [accidentTime, setAccidentTime] = useState<string>(() => {
|
||||||
setAmount('12.0')
|
const now = new Date();
|
||||||
setUnit('kL')
|
return now.toTimeString().slice(0, 5);
|
||||||
setReleaseType('연속 유출')
|
});
|
||||||
setAlgorithm('ALOHA (EPA)')
|
const [predictionTime, setPredictionTime] = useState('24시간');
|
||||||
setCriteriaModel('AEGL')
|
const [substance, setSubstance] = useState('톨루엔 (Toluene)');
|
||||||
onCoordChange({ lon: 129.3542, lat: 35.4215 })
|
const [releaseType, setReleaseType] = useState<ReleaseType>('연속 유출');
|
||||||
|
const [emissionRate, setEmissionRate] = useState(''); // g/s (Plume, Dense Gas)
|
||||||
|
const [totalRelease, setTotalRelease] = useState(''); // g (Puff)
|
||||||
|
const [releaseHeight, setReleaseHeight] = useState('0.5'); // m
|
||||||
|
const [releaseDuration, setReleaseDuration] = useState('300'); // s (Plume)
|
||||||
|
const [poolRadius, setPoolRadius] = useState(''); // m (Dense Gas)
|
||||||
|
const [algorithm, setAlgorithm] = useState('ALOHA (EPA)');
|
||||||
|
const [criteriaModel, setCriteriaModel] = useState('AEGL');
|
||||||
|
|
||||||
|
// 불러오기 시 입력값 복원
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loadedParams) return;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (loadedParams.substance) setSubstance(loadedParams.substance);
|
||||||
|
if (loadedParams.releaseType) setReleaseType(loadedParams.releaseType);
|
||||||
|
if (loadedParams.emissionRate != null) setEmissionRate(String(loadedParams.emissionRate));
|
||||||
|
if (loadedParams.totalRelease != null) setTotalRelease(String(loadedParams.totalRelease));
|
||||||
|
if (loadedParams.releaseHeight != null) setReleaseHeight(String(loadedParams.releaseHeight));
|
||||||
|
if (loadedParams.releaseDuration != null) setReleaseDuration(String(loadedParams.releaseDuration));
|
||||||
|
if (loadedParams.poolRadius != null) setPoolRadius(String(loadedParams.poolRadius));
|
||||||
|
if (loadedParams.algorithm) setAlgorithm(loadedParams.algorithm);
|
||||||
|
if (loadedParams.criteriaModel) setCriteriaModel(loadedParams.criteriaModel);
|
||||||
|
if (loadedParams.accidentDate) setAccidentDate(loadedParams.accidentDate);
|
||||||
|
if (loadedParams.accidentTime) setAccidentTime(loadedParams.accidentTime);
|
||||||
|
if (loadedParams.predictionTime) setPredictionTime(loadedParams.predictionTime);
|
||||||
|
if (loadedParams.accidentName) setAccidentName(loadedParams.accidentName);
|
||||||
|
});
|
||||||
|
}, [loadedParams]);
|
||||||
|
|
||||||
|
// 기상정보 자동조회 (사고 발생 일시 기반)
|
||||||
|
const weather = useWeatherFetch(incidentCoord.lat, incidentCoord.lon, accidentDate, accidentTime);
|
||||||
|
|
||||||
|
// 물질 독성 정보
|
||||||
|
const tox = getSubstanceToxicity(substance);
|
||||||
|
|
||||||
|
// 물질 변경 시 기본값 동기화 (핸들러에서 직접)
|
||||||
|
const handleSubstanceChange = (value: string) => {
|
||||||
|
setSubstance(value);
|
||||||
|
const newTox = getSubstanceToxicity(value);
|
||||||
|
setEmissionRate(String(newTox.Q));
|
||||||
|
setTotalRelease(String(newTox.QTotal));
|
||||||
|
setPoolRadius(String(newTox.poolRadius));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사고 목록 불러오기 (마운트 시 1회, ref 초기화 패턴)
|
||||||
|
const incidentsPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
|
if (incidentsPromiseRef.current == null) {
|
||||||
|
incidentsPromiseRef.current = fetchIncidentsRaw()
|
||||||
|
.then(data => setIncidents(data))
|
||||||
|
.catch(() => setIncidents([]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 사고 선택 시 필드 자동 채움
|
||||||
|
const handleSelectIncident = (snStr: string) => {
|
||||||
|
setSelectedIncidentSn(snStr);
|
||||||
|
const sn = parseInt(snStr);
|
||||||
|
const incident = incidents.find(i => i.acdntSn === sn);
|
||||||
|
if (!incident) return;
|
||||||
|
|
||||||
|
setAccidentName(incident.acdntNm);
|
||||||
|
if (incident.lat && incident.lng) {
|
||||||
|
onCoordChange({ lat: incident.lat, lon: incident.lng });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파라미터 변경 시 부모에 통지
|
||||||
|
useEffect(() => {
|
||||||
|
if (onParamsChange) {
|
||||||
|
onParamsChange({
|
||||||
|
substance,
|
||||||
|
releaseType,
|
||||||
|
emissionRate: parseFloat(emissionRate) || tox.Q,
|
||||||
|
totalRelease: parseFloat(totalRelease) || tox.QTotal,
|
||||||
|
releaseHeight: parseFloat(releaseHeight) || 0.5,
|
||||||
|
releaseDuration: parseFloat(releaseDuration) || 300,
|
||||||
|
poolRadius: parseFloat(poolRadius) || tox.poolRadius,
|
||||||
|
algorithm,
|
||||||
|
criteriaModel,
|
||||||
|
weather,
|
||||||
|
accidentDate,
|
||||||
|
accidentTime,
|
||||||
|
predictionTime,
|
||||||
|
accidentName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [substance, releaseType, emissionRate, totalRelease, releaseHeight, releaseDuration, poolRadius, algorithm, criteriaModel, weather, onParamsChange, tox.Q, tox.QTotal, tox.poolRadius, accidentDate, accidentTime, predictionTime, accidentName]);
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setSelectedIncidentSn('');
|
||||||
|
setAccidentName('');
|
||||||
|
const now = new Date();
|
||||||
|
setAccidentDate(now.toISOString().slice(0, 10));
|
||||||
|
setAccidentTime(now.toTimeString().slice(0, 5));
|
||||||
|
setPredictionTime('24시간');
|
||||||
|
setSubstance('톨루엔 (Toluene)');
|
||||||
|
setReleaseType('연속 유출');
|
||||||
|
const defaultTox = getSubstanceToxicity('톨루엔 (Toluene)');
|
||||||
|
setEmissionRate(String(defaultTox.Q));
|
||||||
|
setTotalRelease(String(defaultTox.QTotal));
|
||||||
|
setReleaseHeight('0.5');
|
||||||
|
setReleaseDuration('300');
|
||||||
|
setPoolRadius(String(defaultTox.poolRadius));
|
||||||
|
setAlgorithm('ALOHA (EPA)');
|
||||||
|
setCriteriaModel('AEGL');
|
||||||
|
onCoordChange({ lon: 129.3542, lat: 35.4215 });
|
||||||
|
onReset?.();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-bg-1 border-r border-border overflow-hidden">
|
<div className="w-80 min-w-[320px] flex flex-col h-full bg-bg-1 border-r border-border overflow-hidden">
|
||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border-light scrollbar-track-transparent bg-bg-0">
|
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border-light scrollbar-track-transparent bg-bg-0">
|
||||||
{activeSubTab === 'analysis' && (
|
{activeSubTab === 'analysis' && (
|
||||||
@ -65,10 +207,10 @@ export function HNSLeftPanel({
|
|||||||
}}
|
}}
|
||||||
>🧪</div>
|
>🧪</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-bold">
|
<div className="text-[13px] font-bold text-text-2 font-korean">
|
||||||
HNS 대기확산 예측
|
HNS 대기확산 예측
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[9px] text-text-3">
|
<div className="text-[10px] text-text-3">
|
||||||
ALOHA/CAMEO 기반 대기확산 시뮬레이션
|
ALOHA/CAMEO 기반 대기확산 시뮬레이션
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -79,35 +221,130 @@ export function HNSLeftPanel({
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
|
||||||
{/* 사고 기본정보 */}
|
{/* 사고 기본정보 */}
|
||||||
<div className="bg-bg-3 border border-border rounded-md p-[14px]">
|
<div>
|
||||||
<div className="text-[11px] font-bold text-primary-cyan mb-3 flex items-center gap-1.5">
|
<div className="text-[13px] font-bold text-text-2 font-korean mb-3 flex items-center gap-1.5">
|
||||||
📋 사고 기본정보
|
📋 사고 기본정보
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-[6px]">
|
||||||
{/* 사고명 */}
|
|
||||||
<div>
|
{/* 사고명 직접 입력 */}
|
||||||
<label className="hns-lbl text-[8px] text-text-3 block mb-1">사고명</label>
|
|
||||||
<input
|
<input
|
||||||
className="hns-inp w-full px-[10px] py-2 bg-bg-0 border border-border rounded text-[11px]"
|
className="prd-i w-full"
|
||||||
value={accidentName}
|
value={accidentName}
|
||||||
onChange={(e) => setAccidentName(e.target.value)}
|
onChange={(e) => setAccidentName(e.target.value)}
|
||||||
|
placeholder="사고명 직접 입력"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 또는 사고 리스트에서 선택 */}
|
||||||
|
<ComboBox
|
||||||
|
className="prd-i"
|
||||||
|
value={selectedIncidentSn}
|
||||||
|
onChange={handleSelectIncident}
|
||||||
|
placeholder="또는 사고 리스트에서 선택"
|
||||||
|
options={incidents.map(inc => ({
|
||||||
|
value: String(inc.acdntSn),
|
||||||
|
label: `${inc.acdntNm} (${new Date(inc.occrnDtm).toLocaleDateString('ko-KR')})`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 사고 발생 일시 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-text-3 block mb-0.5">사고 발생 일시</label>
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
<input
|
||||||
|
className="prd-i"
|
||||||
|
type="date"
|
||||||
|
value={accidentDate}
|
||||||
|
onChange={(e) => setAccidentDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="prd-i"
|
||||||
|
type="time"
|
||||||
|
value={accidentTime}
|
||||||
|
onChange={(e) => setAccidentTime(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 좌표 + 지도 버튼 */}
|
||||||
|
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 1fr auto' }}>
|
||||||
|
<input
|
||||||
|
className="prd-i flex-1 font-mono"
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
value={incidentCoord.lat.toFixed(4)}
|
||||||
|
onChange={(e) => onCoordChange({ ...incidentCoord, lat: parseFloat(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="prd-i flex-1 font-mono"
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
value={incidentCoord.lon.toFixed(4)}
|
||||||
|
onChange={(e) => onCoordChange({ ...incidentCoord, lon: parseFloat(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
<button className="prd-map-btn" onClick={onMapSelectClick}>
|
||||||
|
📍 지도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DMS 표시 */}
|
||||||
|
<div className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
|
||||||
|
style={{ padding: '4px 8px', borderRadius: 'var(--rS)' }}>
|
||||||
|
{toDMS(incidentCoord.lat, 'lat')} / {toDMS(incidentCoord.lon, 'lon')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 유출형태 + 물질명 */}
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
<ComboBox
|
||||||
|
className="prd-i"
|
||||||
|
value={releaseType}
|
||||||
|
onChange={(v) => setReleaseType(v as ReleaseType)}
|
||||||
|
options={[
|
||||||
|
{ value: '연속 유출', label: '연속' },
|
||||||
|
{ value: '순간 유출', label: '순간' },
|
||||||
|
{ value: '풀(Pool) 증발', label: '풀 증발' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ComboBox
|
||||||
|
className="prd-i"
|
||||||
|
value={substance}
|
||||||
|
onChange={handleSubstanceChange}
|
||||||
|
options={[
|
||||||
|
{ value: '톨루엔 (Toluene)', label: '톨루엔' },
|
||||||
|
{ value: '벤젠 (Benzene)', label: '벤젠' },
|
||||||
|
{ value: '자일렌 (Xylene)', label: '자일렌' },
|
||||||
|
{ value: '스티렌 (Styrene)', label: '스티렌' },
|
||||||
|
{ value: '메탄올 (Methanol)', label: '메탄올' },
|
||||||
|
{ value: '아세톤 (Acetone)', label: '아세톤' },
|
||||||
|
{ value: '염소 (Chlorine)', label: '염소' },
|
||||||
|
{ value: '암모니아 (Ammonia)', label: '암모니아' },
|
||||||
|
{ value: '염화수소 (HCl)', label: '염화수소' },
|
||||||
|
{ value: '황화수소 (H2S)', label: '황화수소' },
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 사고일시 + 예측시간 */}
|
{/* 유출량 + 단위 + 예측시간 */}
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 65px 1fr' }}>
|
||||||
<div>
|
|
||||||
<label className="hns-lbl text-[8px] text-text-3 block mb-1">사고일시</label>
|
|
||||||
<input
|
<input
|
||||||
className="hns-inp w-full px-[10px] py-2 bg-bg-0 border border-border rounded text-[11px]"
|
className="prd-i font-mono"
|
||||||
type="datetime-local"
|
type="number"
|
||||||
defaultValue="2025-02-11T05:02"
|
value={releaseType === '순간 유출' ? totalRelease : emissionRate}
|
||||||
|
onChange={(e) => releaseType === '순간 유출' ? setTotalRelease(e.target.value) : setEmissionRate(e.target.value)}
|
||||||
|
placeholder={releaseType === '순간 유출' ? 'g' : 'g/s'}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="hns-lbl text-[8px] text-text-3 block mb-1">예측시간</label>
|
|
||||||
<ComboBox
|
<ComboBox
|
||||||
className="hns-inp"
|
className="prd-i"
|
||||||
|
value={releaseType === '순간 유출' ? 'g' : 'g/s'}
|
||||||
|
onChange={() => {}}
|
||||||
|
options={
|
||||||
|
releaseType === '순간 유출'
|
||||||
|
? [{ value: 'g', label: 'g' }, { value: 'kg', label: 'kg' }]
|
||||||
|
: [{ value: 'g/s', label: 'g/s' }, { value: 'kg/s', label: 'kg/s' }]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ComboBox
|
||||||
|
className="prd-i"
|
||||||
value={predictionTime}
|
value={predictionTime}
|
||||||
onChange={setPredictionTime}
|
onChange={setPredictionTime}
|
||||||
options={[
|
options={[
|
||||||
@ -115,107 +352,18 @@ export function HNSLeftPanel({
|
|||||||
{ value: '12시간', label: '12시간' },
|
{ value: '12시간', label: '12시간' },
|
||||||
{ value: '24시간', label: '24시간' },
|
{ value: '24시간', label: '24시간' },
|
||||||
{ value: '48시간', label: '48시간' },
|
{ value: '48시간', label: '48시간' },
|
||||||
{ value: '72시간', label: '72시간' }
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 사고지점 */}
|
{/* 기상 정보 (자동조회) — 내부적으로 사용, UI 숨김 */}
|
||||||
<div
|
|
||||||
className="p-[10px] rounded-md bg-bg-0"
|
|
||||||
style={{ border: '1px solid rgba(6,182,212,0.2)' }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<label className="hns-lbl text-[8px] text-text-3">📍 사고지점</label>
|
|
||||||
<button
|
|
||||||
onClick={onMapSelectClick}
|
|
||||||
className="text-primary-cyan text-[8px] font-bold cursor-pointer px-[10px] py-1 rounded"
|
|
||||||
style={{
|
|
||||||
border: '1px solid rgba(6,182,212,0.3)',
|
|
||||||
background: 'rgba(6,182,212,0.08)',
|
|
||||||
transition: '0.15s'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🗺 지도에서 클릭
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-1.5 mb-1.5">
|
|
||||||
<div>
|
|
||||||
<label className="hns-lbl text-[8px] text-text-3 block mb-1">위도</label>
|
|
||||||
<input
|
|
||||||
className="hns-inp w-full px-[10px] py-2 bg-bg-0 border border-border rounded text-[11px]"
|
|
||||||
value={incidentCoord.lat.toFixed(4)}
|
|
||||||
onChange={(e) => onCoordChange({ ...incidentCoord, lat: parseFloat(e.target.value) || 0 })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="hns-lbl text-[8px] text-text-3 block mb-1">경도</label>
|
|
||||||
<input
|
|
||||||
className="hns-inp w-full px-[10px] py-2 bg-bg-0 border border-border rounded text-[11px]"
|
|
||||||
value={incidentCoord.lon.toFixed(4)}
|
|
||||||
onChange={(e) => onCoordChange({ ...incidentCoord, lon: parseFloat(e.target.value) || 0 })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="hns-lbl text-[8px] text-text-3 block mb-1">상세 위치</label>
|
|
||||||
<input
|
|
||||||
className="hns-inp w-full px-[10px] py-2 bg-bg-0 border border-border rounded text-[11px]"
|
|
||||||
value={locationName}
|
|
||||||
onChange={(e) => setLocationName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 기상 정보 */}
|
|
||||||
<div
|
|
||||||
className="p-[10px] rounded-md"
|
|
||||||
style={{ background: 'linear-gradient(135deg, rgba(168,85,247,0.04), rgba(6,182,212,0.03))', border: '1px solid rgba(168,85,247,0.15)' }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-[5px]">
|
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-status-green"></div>
|
|
||||||
<span className="hns-lbl text-[8px] text-primary-purple">🌬 기상정보 (자동조회)</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-[7px] text-text-3 font-mono">KMA API · 울산 AWS</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 gap-1.5 mb-1.5">
|
|
||||||
<div className="text-center bg-bg-0 rounded border border-border py-1.5 px-0.5">
|
|
||||||
<div className="text-[12px] font-extrabold font-mono text-primary-cyan">5.2</div>
|
|
||||||
<div className="text-[7px] text-text-3">풍속(m/s)</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center bg-bg-0 rounded border border-border py-1.5 px-0.5">
|
|
||||||
<div className="text-[12px] font-extrabold font-mono text-primary-cyan">SW 225°</div>
|
|
||||||
<div className="text-[7px] text-text-3">풍향</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center bg-bg-0 rounded border border-border py-1.5 px-0.5">
|
|
||||||
<div className="text-[12px] font-extrabold font-mono text-status-orange">8.5°C</div>
|
|
||||||
<div className="text-[7px] text-text-3">기온</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center bg-bg-0 rounded border border-border py-1.5 px-0.5">
|
|
||||||
<div className="text-[12px] font-extrabold font-mono text-primary-blue">62%</div>
|
|
||||||
<div className="text-[7px] text-text-3">습도</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
|
||||||
<div className="flex justify-between bg-bg-0 text-[8px] px-1.5 py-[3px] rounded-[3px]">
|
|
||||||
<span className="text-text-3">대기안정도</span>
|
|
||||||
<span className="font-semibold">D (중립)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between bg-bg-0 text-[8px] px-1.5 py-[3px] rounded-[3px]">
|
|
||||||
<span className="text-text-3">지표 조도</span>
|
|
||||||
<span className="font-semibold">해안</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 알고리즘 선택 */}
|
{/* 알고리즘 선택 */}
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
<div className="grid grid-cols-2 gap-1">
|
||||||
<div>
|
<div>
|
||||||
<label className="hns-lbl text-[8px] text-text-3 block mb-1">예측 알고리즘</label>
|
<label className="text-[10px] text-text-3 block mb-0.5">예측 알고리즘</label>
|
||||||
<ComboBox
|
<ComboBox
|
||||||
className="hns-inp"
|
className="prd-i"
|
||||||
value={algorithm}
|
value={algorithm}
|
||||||
onChange={setAlgorithm}
|
onChange={setAlgorithm}
|
||||||
options={[
|
options={[
|
||||||
@ -227,9 +375,9 @@ export function HNSLeftPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="hns-lbl text-[8px] text-text-3 block mb-1">확산 등급 기준</label>
|
<label className="text-[10px] text-text-3 block mb-0.5">확산 등급 기준</label>
|
||||||
<ComboBox
|
<ComboBox
|
||||||
className="hns-inp"
|
className="prd-i"
|
||||||
value={criteriaModel}
|
value={criteriaModel}
|
||||||
onChange={setCriteriaModel}
|
onChange={setCriteriaModel}
|
||||||
options={[
|
options={[
|
||||||
@ -244,140 +392,160 @@ export function HNSLeftPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 물질 정보 */}
|
{/* 모델 파라미터 & 물질 정보 */}
|
||||||
<div className="bg-bg-3 border border-border rounded-md p-[14px]">
|
|
||||||
<div className="text-[11px] font-bold text-status-orange mb-3 flex items-center gap-1.5">
|
|
||||||
🧪 물질 정보
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{/* 물질 분류 */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="hns-lbl text-[8px] text-text-3 block mb-1">물질 분류</label>
|
<div className="text-[13px] font-bold text-text-2 font-korean mb-3 flex items-center gap-1.5">
|
||||||
<ComboBox
|
🧪 {releaseType === '연속 유출' ? 'Plume' : releaseType === '순간 유출' ? 'Puff' : 'Dense Gas'} 파라미터
|
||||||
className="hns-inp"
|
|
||||||
value={materialCategory}
|
|
||||||
onChange={setMaterialCategory}
|
|
||||||
options={[
|
|
||||||
{ value: '유독성 액체', label: '유독성 액체' },
|
|
||||||
{ value: '유독성 기체', label: '유독성 기체' },
|
|
||||||
{ value: '인화성 액체', label: '인화성 액체' },
|
|
||||||
{ value: '인화성 기체', label: '인화성 기체' },
|
|
||||||
{ value: '부식성 물질', label: '부식성 물질' },
|
|
||||||
{ value: '친환경 연료', label: '친환경 연료' }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-[6px]">
|
||||||
|
{/* 모델별 입력 파라미터 */}
|
||||||
|
<div
|
||||||
|
className="p-[10px] rounded-md"
|
||||||
|
style={{ background: 'rgba(6,182,212,0.04)', border: '1px solid rgba(6,182,212,0.15)' }}
|
||||||
|
>
|
||||||
|
|
||||||
{/* 물질명 */}
|
{/* 연속 유출 (Plume): 배출률, 누출지속시간, 누출높이 */}
|
||||||
|
{releaseType === '연속 유출' && (
|
||||||
|
<div className="flex flex-col gap-[6px]">
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
<div>
|
<div>
|
||||||
<label className="hns-lbl text-[8px] text-text-3 block mb-1">물질명</label>
|
<label className="text-[10px] text-text-3 block mb-0.5">배출률 (g/s)</label>
|
||||||
<ComboBox
|
|
||||||
className="hns-inp"
|
|
||||||
value={substance}
|
|
||||||
onChange={setSubstance}
|
|
||||||
options={[
|
|
||||||
{ value: '톨루엔 (Toluene)', label: '톨루엔 (Toluene)' },
|
|
||||||
{ value: '벤젠 (Benzene)', label: '벤젠 (Benzene)' },
|
|
||||||
{ value: '자일렌 (Xylene)', label: '자일렌 (Xylene)' },
|
|
||||||
{ value: '스티렌 (Styrene)', label: '스티렌 (Styrene)' },
|
|
||||||
{ value: '메탄올 (Methanol)', label: '메탄올 (Methanol)' },
|
|
||||||
{ value: '아세톤 (Acetone)', label: '아세톤 (Acetone)' }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* UN번호 / CAS번호 */}
|
|
||||||
<div>
|
|
||||||
<label className="hns-lbl text-[8px] text-text-3 block mb-1">UN번호 / CAS번호</label>
|
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
|
||||||
<input
|
<input
|
||||||
className="hns-inp w-full px-[10px] py-2 bg-bg-0 border border-border rounded text-[11px]"
|
className="prd-i w-full font-mono"
|
||||||
value={unNumber}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="hns-inp w-full px-[10px] py-2 bg-bg-0 border border-border rounded text-[11px]"
|
|
||||||
value={casNumber}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 유출량 + 단위 */}
|
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
|
||||||
<div>
|
|
||||||
<label className="hns-lbl text-[8px] text-text-3 block mb-1">유출량</label>
|
|
||||||
<input
|
|
||||||
className="hns-inp w-full px-[10px] py-2 bg-bg-0 border border-border rounded text-[11px]"
|
|
||||||
type="number"
|
type="number"
|
||||||
value={amount}
|
value={emissionRate}
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
onChange={(e) => setEmissionRate(e.target.value)}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
|
min="0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="hns-lbl text-[8px] text-text-3 block mb-1">단위</label>
|
<label className="text-[10px] text-text-3 block mb-0.5">지속시간 (s)</label>
|
||||||
<ComboBox
|
<input
|
||||||
className="hns-inp"
|
className="prd-i w-full font-mono"
|
||||||
value={unit}
|
type="number"
|
||||||
onChange={setUnit}
|
value={releaseDuration}
|
||||||
options={[
|
onChange={(e) => setReleaseDuration(e.target.value)}
|
||||||
{ value: 'kL', label: 'kL' },
|
step="10"
|
||||||
{ value: '톤', label: '톤' },
|
min="1"
|
||||||
{ value: 'kg', label: 'kg' },
|
|
||||||
{ value: '배럴', label: '배럴' }
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-text-3 block mb-0.5">누출 높이 (m)</label>
|
||||||
|
<input
|
||||||
|
className="prd-i w-full font-mono"
|
||||||
|
type="number"
|
||||||
|
value={releaseHeight}
|
||||||
|
onChange={(e) => setReleaseHeight(e.target.value)}
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 유출 형태 */}
|
{/* 순간 유출 (Puff): 총 누출량, 누출높이 */}
|
||||||
|
{releaseType === '순간 유출' && (
|
||||||
|
<div className="flex flex-col gap-[6px]">
|
||||||
<div>
|
<div>
|
||||||
<label className="hns-lbl text-[8px] text-text-3 block mb-1">유출 형태</label>
|
<label className="text-[10px] text-text-3 block mb-0.5">총 누출량 (g)</label>
|
||||||
<ComboBox
|
<input
|
||||||
className="hns-inp"
|
className="prd-i w-full font-mono"
|
||||||
value={releaseType}
|
type="number"
|
||||||
onChange={setReleaseType}
|
value={totalRelease}
|
||||||
options={[
|
onChange={(e) => setTotalRelease(e.target.value)}
|
||||||
{ value: '연속 유출', label: '연속 유출' },
|
step="100"
|
||||||
{ value: '순간 유출', label: '순간 유출' },
|
min="0"
|
||||||
{ value: '풀(Pool) 증발', label: '풀(Pool) 증발' }
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-text-3 block mb-0.5">누출 높이 (m)</label>
|
||||||
|
<input
|
||||||
|
className="prd-i w-full font-mono"
|
||||||
|
type="number"
|
||||||
|
value={releaseHeight}
|
||||||
|
onChange={(e) => setReleaseHeight(e.target.value)}
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 풀(Pool) 증발 (Dense Gas): 배출률, 풀반경, 누출높이 */}
|
||||||
|
{releaseType === '풀(Pool) 증발' && (
|
||||||
|
<div className="flex flex-col gap-[6px]">
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-text-3 block mb-0.5">배출률 (g/s)</label>
|
||||||
|
<input
|
||||||
|
className="prd-i w-full font-mono"
|
||||||
|
type="number"
|
||||||
|
value={emissionRate}
|
||||||
|
onChange={(e) => setEmissionRate(e.target.value)}
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-text-3 block mb-0.5">풀 반경 (m)</label>
|
||||||
|
<input
|
||||||
|
className="prd-i w-full font-mono"
|
||||||
|
type="number"
|
||||||
|
value={poolRadius}
|
||||||
|
onChange={(e) => setPoolRadius(e.target.value)}
|
||||||
|
step="0.5"
|
||||||
|
min="0.1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-text-3 block mb-0.5">누출 높이 (m)</label>
|
||||||
|
<input
|
||||||
|
className="prd-i w-full font-mono"
|
||||||
|
type="number"
|
||||||
|
value={releaseHeight}
|
||||||
|
onChange={(e) => setReleaseHeight(e.target.value)}
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 모델 설명 */}
|
||||||
|
<div className="text-[9px] text-text-3 mt-1 leading-[1.4]">
|
||||||
|
{releaseType === '연속 유출' && '정상상태 연속 배출. 바람 방향으로 플룸이 형성됩니다.'}
|
||||||
|
{releaseType === '순간 유출' && '한 번에 전량 방출. 시간에 따라 구름이 이동하며 확산됩니다.'}
|
||||||
|
{releaseType === '풀(Pool) 증발' && '고밀도 가스가 지표면을 따라 확산됩니다 (Britter-McQuaid 모델).'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 물질 위험 특성 */}
|
{/* 물질 위험 특성 */}
|
||||||
<div
|
<div
|
||||||
className="p-2 rounded-sm mt-0.5"
|
className="p-2 rounded-sm mt-0.5"
|
||||||
style={{ background: 'rgba(249,115,22,0.05)', border: '1px solid rgba(249,115,22,0.12)' }}
|
style={{ background: 'rgba(249,115,22,0.05)', border: '1px solid rgba(249,115,22,0.12)' }}
|
||||||
>
|
>
|
||||||
<div className="text-[8px] font-bold text-status-orange mb-1">
|
<div className="text-[10px] font-bold text-status-orange mb-1">
|
||||||
⚠ 물질 위험 특성
|
⚠ 물질 위험 특성
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-[3px] text-[8px]">
|
<div className="grid grid-cols-2 gap-[3px] text-[9px]">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-text-3">인화점</span>
|
<span className="text-text-3">분자량</span>
|
||||||
<span className="text-status-red font-semibold font-mono">4°C</span>
|
<span className="font-mono">{tox.mw} g/mol</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-text-3">비중</span>
|
<span className="text-text-3">가스밀도</span>
|
||||||
<span className="font-mono">0.867</span>
|
<span className="font-mono">{tox.densityGas} kg/m³</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-text-3">증기압</span>
|
<span className="text-text-3">증기압</span>
|
||||||
<span className="font-mono">22 mmHg</span>
|
<span className="font-mono">{tox.vaporPressure} mmHg</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-text-3">IDLH</span>
|
<span className="text-text-3">IDLH</span>
|
||||||
<span className="text-status-red font-semibold font-mono">500 ppm</span>
|
<span className="text-status-red font-semibold font-mono">{tox.idlh} ppm</span>
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-text-3">TWA</span>
|
|
||||||
<span className="font-mono">50 ppm</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-text-3">AEGL-2(1h)</span>
|
|
||||||
<span className="text-status-orange font-semibold font-mono">150 ppm</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -387,21 +555,21 @@ export function HNSLeftPanel({
|
|||||||
className="p-2 rounded-sm"
|
className="p-2 rounded-sm"
|
||||||
style={{ background: 'rgba(168,85,247,0.05)', border: '1px solid rgba(168,85,247,0.12)' }}
|
style={{ background: 'rgba(168,85,247,0.05)', border: '1px solid rgba(168,85,247,0.12)' }}
|
||||||
>
|
>
|
||||||
<div className="text-[8px] font-bold text-primary-purple mb-1">
|
<div className="text-[10px] font-bold text-primary-purple mb-1">
|
||||||
📊 확산 등급 기준 (AEGL)
|
📊 확산 등급 기준 (AEGL)
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5 text-[8px]">
|
<div className="flex flex-col gap-0.5 text-[9px]">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<div className="w-2 h-2 rounded-[2px]" style={{ background: 'rgba(239,68,68,0.7)' }}></div>
|
<div className="w-2 h-2 rounded-[2px]" style={{ background: 'rgba(239,68,68,0.7)' }}></div>
|
||||||
<span className="text-text-3">AEGL-3 (생명위협) — 500 ppm</span>
|
<span className="text-text-3">AEGL-3 (생명위협) — {tox.aegl3} ppm</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<div className="w-2 h-2 rounded-[2px]" style={{ background: 'rgba(249,115,22,0.7)' }}></div>
|
<div className="w-2 h-2 rounded-[2px]" style={{ background: 'rgba(249,115,22,0.7)' }}></div>
|
||||||
<span className="text-text-3">AEGL-2 (건강피해) — 150 ppm</span>
|
<span className="text-text-3">AEGL-2 (건강피해) — {tox.aegl2} ppm</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<div className="w-2 h-2 rounded-[2px]" style={{ background: 'rgba(234,179,8,0.7)' }}></div>
|
<div className="w-2 h-2 rounded-[2px]" style={{ background: 'rgba(234,179,8,0.7)' }}></div>
|
||||||
<span className="text-text-3">AEGL-1 (불쾌감) — 37 ppm</span>
|
<span className="text-text-3">AEGL-1 (불쾌감) — {tox.aegl1} ppm</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -410,28 +578,21 @@ export function HNSLeftPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 실행 버튼 */}
|
{/* 실행 버튼 */}
|
||||||
<div className="flex gap-2 mt-[14px] justify-center">
|
<div className="flex flex-col gap-1 mt-2">
|
||||||
<button
|
<button
|
||||||
|
className="prd-btn pri"
|
||||||
|
style={{ padding: '7px', fontSize: '11px' }}
|
||||||
onClick={onRunPrediction}
|
onClick={onRunPrediction}
|
||||||
disabled={isRunningPrediction}
|
disabled={isRunningPrediction}
|
||||||
className="text-white text-[13px] font-bold rounded-md px-[40px] py-3"
|
|
||||||
style={{
|
|
||||||
background: isRunningPrediction
|
|
||||||
? 'var(--t4)'
|
|
||||||
: 'linear-gradient(135deg, var(--orange), var(--red))',
|
|
||||||
border: 'none',
|
|
||||||
cursor: isRunningPrediction ? 'not-allowed' : 'pointer',
|
|
||||||
transition: '0.2s',
|
|
||||||
boxShadow: isRunningPrediction ? 'none' : '0 4px 16px rgba(249,115,22,0.25)'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
🧪 {isRunningPrediction ? '예측 실행 중...' : '대기확산 예측 실행'}
|
{isRunningPrediction ? '⏳ 실행 중...' : '🧪 대기확산 예측 실행'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
className="prd-btn sec"
|
||||||
|
style={{ padding: '7px', fontSize: '11px' }}
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
className="bg-bg-3 border border-border rounded-md text-text-2 text-[12px] font-semibold cursor-pointer px-6 py-3"
|
|
||||||
>
|
>
|
||||||
🔄 초기화
|
초기화
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -449,10 +610,10 @@ export function HNSLeftPanel({
|
|||||||
}}
|
}}
|
||||||
>📋</div>
|
>📋</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-bold">
|
<div className="text-[13px] font-bold text-text-2 font-korean">
|
||||||
분석 목록
|
분석 목록
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[9px] text-text-3">
|
<div className="text-[10px] text-text-3">
|
||||||
저장된 대기확산 예측 결과
|
저장된 대기확산 예측 결과
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -460,13 +621,13 @@ export function HNSLeftPanel({
|
|||||||
|
|
||||||
{/* 필터 섹션 */}
|
{/* 필터 섹션 */}
|
||||||
<div className="bg-bg-3 border border-border rounded-md p-[14px] mb-3">
|
<div className="bg-bg-3 border border-border rounded-md p-[14px] mb-3">
|
||||||
<div className="text-[11px] font-bold text-primary-cyan mb-3 flex items-center gap-1.5">
|
<div className="text-[13px] font-bold text-text-2 font-korean mb-3 flex items-center gap-1.5">
|
||||||
🔍 필터
|
🔍 필터
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{/* 기간 선택 */}
|
{/* 기간 선택 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[8px] text-text-3 block mb-1">기간</label>
|
<label className="text-[10px] text-text-3 block mb-0.5">기간</label>
|
||||||
<ComboBox
|
<ComboBox
|
||||||
value="최근 7일"
|
value="최근 7일"
|
||||||
onChange={() => {}}
|
onChange={() => {}}
|
||||||
@ -481,7 +642,7 @@ export function HNSLeftPanel({
|
|||||||
|
|
||||||
{/* 물질 분류 */}
|
{/* 물질 분류 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[8px] text-text-3 block mb-1">물질 분류</label>
|
<label className="text-[10px] text-text-3 block mb-0.5">물질 분류</label>
|
||||||
<ComboBox
|
<ComboBox
|
||||||
value="전체"
|
value="전체"
|
||||||
onChange={() => {}}
|
onChange={() => {}}
|
||||||
@ -497,7 +658,7 @@ export function HNSLeftPanel({
|
|||||||
|
|
||||||
{/* 위험도 */}
|
{/* 위험도 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[8px] text-text-3 block mb-1">위험도</label>
|
<label className="text-[10px] text-text-3 block mb-0.5">위험도</label>
|
||||||
<ComboBox
|
<ComboBox
|
||||||
value="전체"
|
value="전체"
|
||||||
onChange={() => {}}
|
onChange={() => {}}
|
||||||
@ -514,7 +675,7 @@ export function HNSLeftPanel({
|
|||||||
|
|
||||||
{/* 통계 요약 */}
|
{/* 통계 요약 */}
|
||||||
<div className="bg-bg-3 border border-border rounded-md p-[14px]">
|
<div className="bg-bg-3 border border-border rounded-md p-[14px]">
|
||||||
<div className="text-[11px] font-bold text-primary-purple mb-3 flex items-center gap-1.5">
|
<div className="text-[13px] font-bold text-text-2 font-korean mb-3 flex items-center gap-1.5">
|
||||||
📊 통계
|
📊 통계
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@ -536,5 +697,5 @@ export function HNSLeftPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,80 +1,73 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { createHnsAnalysis } from '../services/hnsApi'
|
import { ComboBox } from '@common/components/ui/ComboBox';
|
||||||
|
|
||||||
|
export interface RecalcParams {
|
||||||
|
substance: string;
|
||||||
|
releaseType: '연속 유출' | '순간 유출' | '밀도가스 유출';
|
||||||
|
emissionRate: number;
|
||||||
|
totalRelease: number;
|
||||||
|
algorithm: string;
|
||||||
|
predictionTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface HNSRecalcModalProps {
|
interface HNSRecalcModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean;
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
onSubmit: () => void
|
onSubmit: (params: RecalcParams) => void;
|
||||||
|
currentParams?: Partial<RecalcParams> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecalcPhase = 'editing' | 'running' | 'done'
|
export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNSRecalcModalProps) {
|
||||||
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const HNS_SUBSTANCES = ['톨루엔', '암모니아', '메탄올', '수소', '벤젠', '스티렌', 'LNG', '염소', '황화수소']
|
const [substance, setSubstance] = useState('톨루엔 (Toluene)');
|
||||||
const RELEASE_TYPES = ['순간 유출', '연속 유출', '반연속']
|
const [releaseType, setReleaseType] = useState<RecalcParams['releaseType']>('연속 유출');
|
||||||
const MODELS = ['ALOHA', 'PHAST', 'CALPUFF', 'Lagrangian']
|
const [amount, setAmount] = useState('10');
|
||||||
const STABILITIES = ['A (매우 불안정)', 'B (불안정)', 'C (약간 불안정)', 'D (중립)', 'E (약간 안정)', 'F (안정)']
|
const [algorithm, setAlgorithm] = useState('ALOHA (EPA)');
|
||||||
const PRED_TIMES = [1, 3, 6, 12, 24, 48]
|
const [predictionTime, setPredictionTime] = useState('24시간');
|
||||||
|
|
||||||
export function HNSRecalcModal({ isOpen, onClose, onSubmit }: HNSRecalcModalProps) {
|
|
||||||
const backdropRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const [substance, setSubstance] = useState('톨루엔')
|
|
||||||
const [releaseType, setReleaseType] = useState('순간 유출')
|
|
||||||
const [amount, setAmount] = useState(2.5)
|
|
||||||
const [unit, setUnit] = useState<'t' | 'kg' | 'm³' | 'L'>('t')
|
|
||||||
const [windDir, setWindDir] = useState('SW')
|
|
||||||
const [windSpeed, setWindSpeed] = useState(5.2)
|
|
||||||
const [temp, setTemp] = useState(18.5)
|
|
||||||
const [stability, setStability] = useState('D (중립)')
|
|
||||||
const [model, setModel] = useState('ALOHA')
|
|
||||||
const [predTime, setPredTime] = useState(6)
|
|
||||||
const [lat, setLat] = useState(35.4215)
|
|
||||||
const [lon, setLon] = useState(129.3542)
|
|
||||||
const [phase, setPhase] = useState<RecalcPhase>('editing')
|
|
||||||
|
|
||||||
|
// 모달 열릴 때 현재 파라미터로 초기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
if (!isOpen || !currentParams) return;
|
||||||
if (isOpen) setPhase('editing')
|
queueMicrotask(() => {
|
||||||
}, [isOpen])
|
if (currentParams.substance) setSubstance(currentParams.substance);
|
||||||
|
if (currentParams.releaseType) setReleaseType(currentParams.releaseType);
|
||||||
|
if (currentParams.releaseType === '순간 유출') {
|
||||||
|
setAmount(String(currentParams.totalRelease ?? ''));
|
||||||
|
} else {
|
||||||
|
setAmount(String(currentParams.emissionRate ?? ''));
|
||||||
|
}
|
||||||
|
if (currentParams.algorithm) setAlgorithm(currentParams.algorithm);
|
||||||
|
if (currentParams.predictionTime) setPredictionTime(currentParams.predictionTime);
|
||||||
|
});
|
||||||
|
}, [isOpen, currentParams]);
|
||||||
|
|
||||||
|
// 배경 클릭으로 닫기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
if (e.target === backdropRef.current) onClose()
|
if (e.target === backdropRef.current) onClose();
|
||||||
}
|
};
|
||||||
if (isOpen) document.addEventListener('mousedown', handler)
|
if (isOpen) document.addEventListener('mousedown', handler);
|
||||||
return () => document.removeEventListener('mousedown', handler)
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
}, [isOpen, onClose])
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
const handleRun = async () => {
|
const handleRun = () => {
|
||||||
setPhase('running')
|
const numAmount = parseFloat(amount) || 10;
|
||||||
try {
|
onSubmit({
|
||||||
await createHnsAnalysis({
|
substance,
|
||||||
anlysNm: `HNS 재계산 — ${substance}`,
|
releaseType,
|
||||||
lon,
|
emissionRate: releaseType !== '순간 유출' ? numAmount : 10,
|
||||||
lat,
|
totalRelease: releaseType === '순간 유출' ? numAmount : 5000,
|
||||||
sbstNm: substance,
|
algorithm,
|
||||||
spilQty: amount,
|
predictionTime,
|
||||||
spilUnitCd: unit,
|
});
|
||||||
fcstHr: predTime,
|
onClose();
|
||||||
algoCd: model,
|
};
|
||||||
windSpd: windSpeed,
|
|
||||||
windDir,
|
|
||||||
temp,
|
|
||||||
atmStblCd: stability.charAt(0),
|
|
||||||
})
|
|
||||||
setPhase('done')
|
|
||||||
setTimeout(() => {
|
|
||||||
onSubmit()
|
|
||||||
onClose()
|
|
||||||
}, 800)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[hns] 재계산 실패:', err)
|
|
||||||
setPhase('editing')
|
|
||||||
alert('재계산 실행에 실패했습니다.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const amountLabel = releaseType === '순간 유출' ? '총 누출량' : '배출률';
|
||||||
|
const amountUnit = releaseType === '순간 유출' ? 'g' : 'g/s';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -82,168 +75,142 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit }: HNSRecalcModalProp
|
|||||||
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
||||||
style={{ background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(4px)' }}
|
style={{ background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(4px)' }}
|
||||||
>
|
>
|
||||||
<div className="w-[400px] max-h-[calc(100vh-100px)] bg-bg-1 border border-border rounded-[14px] overflow-hidden flex flex-col"
|
<div
|
||||||
style={{ boxShadow: '0 20px 60px rgba(0,0,0,0.5)' }}>
|
className="w-[380px] bg-bg-1 border border-border rounded-[14px] overflow-hidden flex flex-col"
|
||||||
|
style={{ boxShadow: '0 20px 60px rgba(0,0,0,0.5)' }}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-5 py-4 border-b border-border flex items-center gap-3">
|
<div className="px-5 py-4 border-b border-border flex items-center gap-3">
|
||||||
<div className="w-9 h-9 rounded-[10px] border border-[rgba(249,115,22,0.3)] flex items-center justify-center text-base shrink-0"
|
<div
|
||||||
style={{ background: 'linear-gradient(135deg, rgba(249,115,22,0.2), rgba(239,68,68,0.15))' }}>🔄</div>
|
className="w-9 h-9 rounded-[10px] border border-[rgba(249,115,22,0.3)] flex items-center justify-center text-base shrink-0"
|
||||||
|
style={{ background: 'linear-gradient(135deg, rgba(249,115,22,0.2), rgba(239,68,68,0.15))' }}
|
||||||
|
>
|
||||||
|
🔄
|
||||||
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className="text-[15px] font-bold m-0">
|
<h2 className="text-[15px] font-bold m-0">대기확산 재계산</h2>
|
||||||
HNS 대기확산 재계산
|
|
||||||
</h2>
|
|
||||||
<div className="text-[10px] text-text-3 mt-0.5">
|
<div className="text-[10px] text-text-3 mt-0.5">
|
||||||
물질·기상조건을 수정하여 대기확산 예측을 재실행합니다
|
조건을 변경하여 재계산합니다
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="w-7 h-7 rounded-md border border-border bg-bg-3 text-text-3 text-xs cursor-pointer flex items-center justify-center shrink-0">✕</button>
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-7 h-7 rounded-md border border-border bg-bg-3 text-text-3 text-xs cursor-pointer flex items-center justify-center shrink-0"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4 flex flex-col gap-[14px]"
|
<div
|
||||||
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
className="px-5 py-4 flex flex-col gap-3"
|
||||||
{/* 현재 분석 정보 */}
|
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}
|
||||||
<div className="py-2.5 px-3 border border-[rgba(249,115,22,0.15)] rounded-md"
|
>
|
||||||
style={{ background: 'rgba(249,115,22,0.04)' }}>
|
|
||||||
<div className="text-[9px] font-bold text-status-orange mb-1.5">
|
|
||||||
현재 분석 정보
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-1 text-[9px]">
|
|
||||||
<InfoRow label="사고명" value="울산 온산항 톨루엔 유출" />
|
|
||||||
<InfoRow label="물질" value="톨루엔 (Toluene)" />
|
|
||||||
<InfoRow label="유출량" value="2.5 ton" />
|
|
||||||
<InfoRow label="확산 모델" value="ALOHA v5.4.7" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* HNS 물질 */}
|
{/* HNS 물질 */}
|
||||||
<FG label="HNS 물질">
|
<FG label="HNS 물질">
|
||||||
<select className="prd-i" value={substance} onChange={e => setSubstance(e.target.value)}>
|
<ComboBox
|
||||||
{HNS_SUBSTANCES.map(s => <option key={s} value={s}>{s}</option>)}
|
className="prd-i"
|
||||||
</select>
|
value={substance}
|
||||||
|
onChange={setSubstance}
|
||||||
|
options={[
|
||||||
|
{ value: '톨루엔 (Toluene)', label: '톨루엔' },
|
||||||
|
{ value: '벤젠 (Benzene)', label: '벤젠' },
|
||||||
|
{ value: '메탄올 (Methanol)', label: '메탄올' },
|
||||||
|
{ value: '암모니아 (Ammonia)', label: '암모니아' },
|
||||||
|
{ value: '염화수소 (HCl)', label: '염화수소' },
|
||||||
|
{ value: '황화수소 (H2S)', label: '황화수소' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</FG>
|
</FG>
|
||||||
|
|
||||||
{/* 유출 유형 + 유출량 */}
|
{/* 유출 유형 + 유출량 */}
|
||||||
<div className="grid grid-cols-2 gap-[10px]">
|
<div className="grid grid-cols-2 gap-[10px]">
|
||||||
<FG label="유출 유형">
|
<FG label="유출 유형">
|
||||||
<select className="prd-i" value={releaseType} onChange={e => setReleaseType(e.target.value)}>
|
<ComboBox
|
||||||
{RELEASE_TYPES.map(r => <option key={r} value={r}>{r}</option>)}
|
className="prd-i"
|
||||||
</select>
|
value={releaseType}
|
||||||
|
onChange={(v) => setReleaseType(v as RecalcParams['releaseType'])}
|
||||||
|
options={[
|
||||||
|
{ value: '연속 유출', label: '연속 유출' },
|
||||||
|
{ value: '순간 유출', label: '순간 유출' },
|
||||||
|
{ value: '밀도가스 유출', label: '밀도가스 유출' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</FG>
|
</FG>
|
||||||
<FG label="유출량">
|
<FG label={`${amountLabel} (${amountUnit})`}>
|
||||||
<div className="flex gap-1">
|
<input
|
||||||
<input className="prd-i flex-1" type="number" value={amount} onChange={e => setAmount(Number(e.target.value))} step={0.1} />
|
className="prd-i font-mono"
|
||||||
<select className="prd-i w-[55px]" value={unit} onChange={e => setUnit(e.target.value as typeof unit)}>
|
type="number"
|
||||||
{['t', 'kg', 'm³', 'L'].map(u => <option key={u} value={u}>{u}</option>)}
|
value={amount}
|
||||||
</select>
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
</div>
|
placeholder={amountUnit}
|
||||||
|
/>
|
||||||
</FG>
|
</FG>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 풍향 / 풍속 / 기온 */}
|
{/* 확산 모델 + 예측 시간 */}
|
||||||
<div className="grid grid-cols-3 gap-[10px]">
|
|
||||||
<FG label="풍향">
|
|
||||||
<select className="prd-i" value={windDir} onChange={e => setWindDir(e.target.value)}>
|
|
||||||
{['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'].map(d => <option key={d} value={d}>{d}</option>)}
|
|
||||||
</select>
|
|
||||||
</FG>
|
|
||||||
<FG label="풍속 (m/s)">
|
|
||||||
<input className="prd-i" type="number" value={windSpeed} onChange={e => setWindSpeed(Number(e.target.value))} step={0.1} />
|
|
||||||
</FG>
|
|
||||||
<FG label="기온 (°C)">
|
|
||||||
<input className="prd-i" type="number" value={temp} onChange={e => setTemp(Number(e.target.value))} step={0.1} />
|
|
||||||
</FG>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 대기안정도 + 확산 모델 */}
|
|
||||||
<div className="grid grid-cols-2 gap-[10px]">
|
<div className="grid grid-cols-2 gap-[10px]">
|
||||||
<FG label="대기안정도 (Pasquill)">
|
<FG label="예측 알고리즘">
|
||||||
<select className="prd-i" value={stability} onChange={e => setStability(e.target.value)}>
|
<ComboBox
|
||||||
{STABILITIES.map(s => <option key={s} value={s}>{s}</option>)}
|
className="prd-i"
|
||||||
</select>
|
value={algorithm}
|
||||||
|
onChange={setAlgorithm}
|
||||||
|
options={[
|
||||||
|
{ value: 'ALOHA (EPA)', label: 'ALOHA (EPA)' },
|
||||||
|
{ value: 'CAMEO', label: 'CAMEO' },
|
||||||
|
{ value: 'Gaussian Plume', label: 'Gaussian Plume' },
|
||||||
|
{ value: 'AERMOD', label: 'AERMOD' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</FG>
|
</FG>
|
||||||
<FG label="확산 모델">
|
|
||||||
<select className="prd-i" value={model} onChange={e => setModel(e.target.value)}>
|
|
||||||
{MODELS.map(m => <option key={m} value={m}>{m}</option>)}
|
|
||||||
</select>
|
|
||||||
</FG>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 예측 시간 */}
|
|
||||||
<FG label="예측 시간">
|
<FG label="예측 시간">
|
||||||
<select className="prd-i" value={predTime} onChange={e => setPredTime(Number(e.target.value))}>
|
<ComboBox
|
||||||
{PRED_TIMES.map(h => <option key={h} value={h}>{h}시간</option>)}
|
className="prd-i"
|
||||||
</select>
|
value={predictionTime}
|
||||||
|
onChange={setPredictionTime}
|
||||||
|
options={[
|
||||||
|
{ value: '6시간', label: '6시간' },
|
||||||
|
{ value: '12시간', label: '12시간' },
|
||||||
|
{ value: '24시간', label: '24시간' },
|
||||||
|
{ value: '48시간', label: '48시간' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</FG>
|
</FG>
|
||||||
|
|
||||||
{/* 유출 위치 */}
|
|
||||||
<FG label="유출 위치 (좌표)">
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-[8px] text-text-3 mb-[3px]">위도 (N)</div>
|
|
||||||
<input className="prd-i font-mono" type="number" value={lat} step={0.0001} onChange={e => setLat(Number(e.target.value))} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-[8px] text-text-3 mb-[3px]">경도 (E)</div>
|
|
||||||
<input className="prd-i font-mono" type="number" value={lon} step={0.0001} onChange={e => setLon(Number(e.target.value))} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FG>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="px-5 py-[14px] border-t border-border flex gap-2">
|
<div className="px-5 py-[14px] border-t border-border flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={phase !== 'editing'}
|
|
||||||
className="flex-1 py-2.5 text-xs font-semibold rounded-md cursor-pointer bg-bg-3 border border-border text-text-2"
|
className="flex-1 py-2.5 text-xs font-semibold rounded-md cursor-pointer bg-bg-3 border border-border text-text-2"
|
||||||
style={{ opacity: phase !== 'editing' ? 0.5 : 1 }}
|
>
|
||||||
>취소</button>
|
취소
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleRun}
|
onClick={handleRun}
|
||||||
disabled={phase !== 'editing'}
|
className="flex-[2] py-2.5 text-xs font-bold rounded-md text-white"
|
||||||
className="flex-[2] py-2.5 text-xs font-bold rounded-md"
|
|
||||||
style={{
|
style={{
|
||||||
cursor: phase === 'editing' ? 'pointer' : 'wait',
|
cursor: 'pointer',
|
||||||
background: phase === 'done'
|
background: 'linear-gradient(135deg, var(--orange), #ef4444)',
|
||||||
? 'rgba(34,197,94,0.15)'
|
border: 'none',
|
||||||
: phase === 'running'
|
|
||||||
? 'var(--bg3)'
|
|
||||||
: 'linear-gradient(135deg, var(--orange), #ef4444)',
|
|
||||||
border: phase === 'done'
|
|
||||||
? '1px solid rgba(34,197,94,0.4)'
|
|
||||||
: phase === 'running'
|
|
||||||
? '1px solid var(--bd)'
|
|
||||||
: 'none',
|
|
||||||
color: phase === 'done'
|
|
||||||
? '#22c55e'
|
|
||||||
: phase === 'running'
|
|
||||||
? 'var(--orange)'
|
|
||||||
: '#fff',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{phase === 'done' ? '✅ 재계산 완료!' : phase === 'running' ? '⏳ 재계산 실행중...' : '🔄 재계산 실행'}
|
🔄 재계산 실행
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FG({ label, children }: { label: string; children: React.ReactNode }) {
|
function FG({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '10px', fontWeight: 700, color: 'var(--t2)', marginBottom: '6px' }}>{label}</div>
|
<div style={{ fontSize: '10px', fontWeight: 700, color: 'var(--t2)', marginBottom: '6px' }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0' }}>
|
|
||||||
<span className="text-text-3">{label}</span>
|
|
||||||
<span style={{ fontWeight: 600, fontFamily: 'var(--fM)' }}>{value}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,25 +1,38 @@
|
|||||||
|
import type { DispersionGridResult, WeatherFetchResult } from '../utils/dispersionTypes';
|
||||||
|
import { windDirToCompass } from '../hooks/useWeatherFetch';
|
||||||
|
|
||||||
interface HNSRightPanelProps {
|
interface HNSRightPanelProps {
|
||||||
dispersionResult: {
|
dispersionResult: {
|
||||||
zones: Array<{
|
zones: Array<{
|
||||||
level: string
|
level: string;
|
||||||
color: string
|
color: string;
|
||||||
radius: number
|
radius: number;
|
||||||
angle: number
|
angle: number;
|
||||||
}>
|
}>;
|
||||||
timestamp: string
|
timestamp: string;
|
||||||
windDirection: number
|
windDirection: number;
|
||||||
substance: string
|
substance: string;
|
||||||
concentration: {
|
concentration: {
|
||||||
'AEGL-3': string
|
'AEGL-3': string;
|
||||||
'AEGL-2': string
|
'AEGL-2': string;
|
||||||
'AEGL-1': string
|
'AEGL-1': string;
|
||||||
}
|
};
|
||||||
} | null
|
} | null;
|
||||||
onOpenRecalc?: () => void
|
computedResult?: DispersionGridResult | null;
|
||||||
onOpenReport?: () => void
|
weatherData?: WeatherFetchResult | null;
|
||||||
|
onOpenRecalc?: () => void;
|
||||||
|
onOpenReport?: () => void;
|
||||||
|
onSave?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HNSRightPanel({ dispersionResult, onOpenRecalc, onOpenReport }: HNSRightPanelProps) {
|
export function HNSRightPanel({
|
||||||
|
dispersionResult,
|
||||||
|
computedResult,
|
||||||
|
weatherData,
|
||||||
|
onOpenRecalc,
|
||||||
|
onOpenReport,
|
||||||
|
onSave,
|
||||||
|
}: HNSRightPanelProps) {
|
||||||
if (!dispersionResult) {
|
if (!dispersionResult) {
|
||||||
return (
|
return (
|
||||||
<div className="w-[300px] bg-bg-1 border-l border-border p-4 overflow-auto">
|
<div className="w-[300px] bg-bg-1 border-l border-border p-4 overflow-auto">
|
||||||
@ -28,9 +41,18 @@ export function HNSRightPanel({ dispersionResult, onOpenRecalc, onOpenReport }:
|
|||||||
<div>예측 실행 후 결과가 표시됩니다</div>
|
<div>예측 실행 후 결과가 표시됩니다</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const area = computedResult?.aeglAreas.aegl1 ?? 0;
|
||||||
|
const maxConc = computedResult?.maxConcentration ?? 0;
|
||||||
|
const windSpd = weatherData?.windSpeed ?? 5.0;
|
||||||
|
const windDir = weatherData?.windDirection ?? dispersionResult.windDirection;
|
||||||
|
const modelLabel = computedResult?.modelType === 'plume' ? 'Gaussian Plume'
|
||||||
|
: computedResult?.modelType === 'puff' ? 'Gaussian Puff'
|
||||||
|
: computedResult?.modelType === 'dense_gas' ? 'Dense Gas (B-M)'
|
||||||
|
: 'ALOHA';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[300px] bg-bg-1 border-l border-border p-4 overflow-auto flex flex-col gap-4">
|
<div className="w-[300px] bg-bg-1 border-l border-border p-4 overflow-auto flex flex-col gap-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -48,79 +70,125 @@ export function HNSRightPanel({ dispersionResult, onOpenRecalc, onOpenReport }:
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-text-3 font-mono">
|
<div className="text-[10px] text-text-3 font-mono">
|
||||||
{dispersionResult.substance} · ALOHA v5.4.7
|
{dispersionResult.substance} · {modelLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI Cards */}
|
{/* KPI Cards */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
{/* 최대 농도 */}
|
||||||
|
<div className="p-3 bg-bg-3 border border-[rgba(239,68,68,0.2)] rounded-[var(--rS)]">
|
||||||
|
<div className="text-[10px] text-text-3 mb-1.5">
|
||||||
|
최대 농도
|
||||||
|
</div>
|
||||||
|
<div className="text-[20px] font-bold font-mono text-status-red">
|
||||||
|
{maxConc > 0 ? maxConc.toFixed(1) : '—'} <span className="text-[10px] font-medium">ppm</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 확산 면적 */}
|
||||||
<div className="p-3 bg-bg-3 border border-[rgba(6,182,212,0.2)] rounded-[var(--rS)]">
|
<div className="p-3 bg-bg-3 border border-[rgba(6,182,212,0.2)] rounded-[var(--rS)]">
|
||||||
<div className="text-[10px] text-text-3 mb-1.5">
|
<div className="text-[10px] text-text-3 mb-1.5">
|
||||||
평균 확산 면적
|
AEGL-1 확산 면적
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[20px] font-bold font-mono text-primary-cyan">
|
<div className="text-[20px] font-bold font-mono text-primary-cyan">
|
||||||
8.2 <span className="text-[10px] font-medium">km²</span>
|
{area > 0 ? area.toFixed(2) : '—'} <span className="text-[10px] font-medium">km²</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 bg-bg-3 border border-[rgba(249,115,22,0.2)] rounded-[var(--rS)]">
|
|
||||||
<div className="text-[10px] text-text-3 mb-1.5">
|
|
||||||
고위험 구역
|
|
||||||
</div>
|
|
||||||
<div className="text-[20px] font-bold font-mono text-status-orange">
|
|
||||||
2
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 풍속 */}
|
||||||
<div className="p-3 bg-bg-3 border border-border rounded-[var(--rS)]">
|
<div className="p-3 bg-bg-3 border border-border rounded-[var(--rS)]">
|
||||||
<div className="text-[10px] text-text-3 mb-1.5">
|
<div className="text-[10px] text-text-3 mb-1.5">
|
||||||
평균 풍속
|
풍속
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[20px] font-bold font-mono">
|
<div className="text-[20px] font-bold font-mono">
|
||||||
5.2 <span className="text-[10px] font-medium">m/s</span>
|
{windSpd.toFixed(1)} <span className="text-[10px] font-medium">m/s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 풍향 */}
|
||||||
<div className="p-3 bg-bg-3 border border-border rounded-[var(--rS)]">
|
<div className="p-3 bg-bg-3 border border-border rounded-[var(--rS)]">
|
||||||
<div className="text-[10px] text-text-3 mb-1.5">
|
<div className="text-[10px] text-text-3 mb-1.5">
|
||||||
풍향
|
풍향
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[20px] font-bold font-mono">
|
<div className="text-[20px] font-bold font-mono">
|
||||||
SW <span className="text-[10px] font-medium">225°</span>
|
{windDirToCompass(windDir)} <span className="text-[10px] font-medium">{windDir}°</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zone Details */}
|
{/* AEGL Zone Details */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-[11px] font-semibold text-text-2 mt-0 mb-2.5">
|
<h4 className="text-[11px] font-semibold text-text-2 mt-0 mb-2.5">
|
||||||
확산 구역 상세
|
AEGL 구역 상세
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{dispersionResult.zones.map((zone, idx) => (
|
{/* AEGL-3 */}
|
||||||
<div
|
<div
|
||||||
key={idx}
|
|
||||||
className="py-2.5 px-3 bg-bg-2 rounded-[var(--rS)]"
|
className="py-2.5 px-3 bg-bg-2 rounded-[var(--rS)]"
|
||||||
style={{
|
style={{ borderLeft: '3px solid rgba(239,68,68,1)' }}
|
||||||
borderLeft: `3px solid ${zone.color.replace('0.4', '1').replace('0.3', '1').replace('0.25', '1')}`
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center mb-1">
|
<div className="flex justify-between items-center mb-1">
|
||||||
<span className="text-[11px] font-semibold">
|
<span className="text-[11px] font-semibold">AEGL-3 (생명위협)</span>
|
||||||
{zone.level}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] font-mono text-text-3">
|
<span className="text-[10px] font-mono text-text-3">
|
||||||
{zone.radius}m
|
{computedResult?.aeglDistances.aegl3 || 0}m
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-text-3">
|
<div className="flex justify-between text-[10px] text-text-3">
|
||||||
{dispersionResult.concentration[zone.level as keyof typeof dispersionResult.concentration]}
|
<span>{dispersionResult.concentration['AEGL-3']}</span>
|
||||||
|
<span className="font-mono">{computedResult?.aeglAreas.aegl3 ?? 0} km²</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
|
{/* AEGL-2 */}
|
||||||
|
<div
|
||||||
|
className="py-2.5 px-3 bg-bg-2 rounded-[var(--rS)]"
|
||||||
|
style={{ borderLeft: '3px solid rgba(249,115,22,1)' }}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<span className="text-[11px] font-semibold">AEGL-2 (건강피해)</span>
|
||||||
|
<span className="text-[10px] font-mono text-text-3">
|
||||||
|
{computedResult?.aeglDistances.aegl2 || 0}m
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[10px] text-text-3">
|
||||||
|
<span>{dispersionResult.concentration['AEGL-2']}</span>
|
||||||
|
<span className="font-mono">{computedResult?.aeglAreas.aegl2 ?? 0} km²</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* AEGL-1 */}
|
||||||
|
<div
|
||||||
|
className="py-2.5 px-3 bg-bg-2 rounded-[var(--rS)]"
|
||||||
|
style={{ borderLeft: '3px solid rgba(234,179,8,1)' }}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<span className="text-[11px] font-semibold">AEGL-1 (불쾌감)</span>
|
||||||
|
<span className="text-[10px] font-mono text-text-3">
|
||||||
|
{computedResult?.aeglDistances.aegl1 || 0}m
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[10px] text-text-3">
|
||||||
|
<span>{dispersionResult.concentration['AEGL-1']}</span>
|
||||||
|
<span className="font-mono">{computedResult?.aeglAreas.aegl1 ?? 0} km²</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시간 정보 (puff/dense_gas) */}
|
||||||
|
{computedResult && computedResult.modelType !== 'plume' && (
|
||||||
|
<div className="p-2.5 bg-bg-3 border border-border rounded-[var(--rS)]">
|
||||||
|
<div className="text-[10px] text-text-3 mb-1">현재 시뮬레이션 시간</div>
|
||||||
|
<div className="text-[14px] font-bold font-mono text-primary-cyan">
|
||||||
|
t = {computedResult.timeStep}s
|
||||||
|
<span className="text-[10px] font-normal text-text-3 ml-1.5">
|
||||||
|
({(computedResult.timeStep / 60).toFixed(1)}분)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Timestamp */}
|
{/* Timestamp */}
|
||||||
<div className="mt-auto pt-3 border-t border-border text-[10px] text-text-3 font-mono">
|
<div className="mt-auto pt-3 border-t border-border text-[10px] text-text-3 font-mono">
|
||||||
예측 시각: {new Date(dispersionResult.timestamp).toLocaleString('ko-KR')}
|
예측 시각: {new Date(dispersionResult.timestamp).toLocaleString('ko-KR')}
|
||||||
@ -128,7 +196,7 @@ export function HNSRightPanel({ dispersionResult, onOpenRecalc, onOpenReport }:
|
|||||||
|
|
||||||
{/* Bottom Action Buttons */}
|
{/* Bottom Action Buttons */}
|
||||||
<div className="flex gap-1.5 pt-3 border-t border-border">
|
<div className="flex gap-1.5 pt-3 border-t border-border">
|
||||||
<button className="flex-1 py-2 px-1 rounded text-[11px] font-semibold bg-gradient-to-r from-boom to-[#d97706] text-black font-korean">
|
<button onClick={onSave} className="flex-1 py-2 px-1 rounded text-[11px] font-semibold bg-gradient-to-r from-boom to-[#d97706] text-black font-korean">
|
||||||
💾 저장
|
💾 저장
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onOpenRecalc} className="flex-1 py-2 px-1 rounded text-[11px] font-semibold bg-[rgba(249,115,22,0.1)] border border-[rgba(249,115,22,0.3)] text-status-orange font-korean">
|
<button onClick={onOpenRecalc} className="flex-1 py-2 px-1 rounded text-[11px] font-semibold bg-[rgba(249,115,22,0.1)] border border-[rgba(249,115,22,0.3)] text-status-orange font-korean">
|
||||||
@ -139,5 +207,5 @@ export function HNSRightPanel({ dispersionResult, onOpenRecalc, onOpenReport }:
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,7 +61,7 @@ export function HNSSubstanceView() {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const [detailSearchSebc, setDetailSearchSebc] = useState('전체 거동분류')
|
const [detailSearchSebc, setDetailSearchSebc] = useState('전체 거동분류')
|
||||||
/* Panel 3: 물질 상세검색 state */
|
/* Panel 3: 물질 상세검색 state */
|
||||||
const [hmsSearchType, setHmsSearchType] = useState<'abbr' | 'korName' | 'engName' | 'cas' | 'un'>('abbr')
|
const [hmsSearchType, setHmsSearchType] = useState<'all' | 'abbr' | 'korName' | 'engName' | 'cas' | 'un'>('all')
|
||||||
const [hmsSearchInput, setHmsSearchInput] = useState('')
|
const [hmsSearchInput, setHmsSearchInput] = useState('')
|
||||||
const [hmsFilterSebc, setHmsFilterSebc] = useState('전체 거동분류')
|
const [hmsFilterSebc, setHmsFilterSebc] = useState('전체 거동분류')
|
||||||
const [hmsSelectedId, setHmsSelectedId] = useState<number | null>(null)
|
const [hmsSelectedId, setHmsSelectedId] = useState<number | null>(null)
|
||||||
@ -85,15 +85,18 @@ export function HNSSubstanceView() {
|
|||||||
const params: Record<string, string | number> = { page: hmsPage, limit: 10 }
|
const params: Record<string, string | number> = { page: hmsPage, limit: 10 }
|
||||||
if (hmsSearchInput.trim()) {
|
if (hmsSearchInput.trim()) {
|
||||||
params.q = hmsSearchInput.trim()
|
params.q = hmsSearchInput.trim()
|
||||||
|
if (hmsSearchType !== 'all') {
|
||||||
params.type = searchTypeMap[hmsSearchType] || 'abbreviation'
|
params.type = searchTypeMap[hmsSearchType] || 'abbreviation'
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (hmsFilterSebc !== '전체 거동분류') {
|
if (hmsFilterSebc !== '전체 거동분류') {
|
||||||
params.sebc = hmsFilterSebc.split(' ')[0]
|
params.sebc = hmsFilterSebc.split(' ')[0]
|
||||||
}
|
}
|
||||||
const { data } = await api.get('/hns', { params })
|
const { data } = await api.get('/hns', { params })
|
||||||
setHmsResults(data.items)
|
setHmsResults(data.items)
|
||||||
setHmsTotal(data.total)
|
setHmsTotal(data.total)
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('[HNS] 물질 검색 오류:', err)
|
||||||
setHmsResults([])
|
setHmsResults([])
|
||||||
setHmsTotal(0)
|
setHmsTotal(0)
|
||||||
} finally {
|
} finally {
|
||||||
@ -600,42 +603,43 @@ ${styles}
|
|||||||
<div className="flex gap-2 mb-[10px] items-center">
|
<div className="flex gap-2 mb-[10px] items-center">
|
||||||
<div className="shrink-0 flex items-center gap-1">
|
<div className="shrink-0 flex items-center gap-1">
|
||||||
<span className="text-[9px] font-semibold text-text-3">구분:</span>
|
<span className="text-[9px] font-semibold text-text-3">구분:</span>
|
||||||
<select value={hmsSearchType} onChange={e => { setHmsSearchType(e.target.value as typeof hmsSearchType); setHmsPage(1) }} className="rounded-sm border border-border text-[10px] outline-none bg-bg-0 px-2.5 py-1.5" style={{ minWidth: 130 }}>
|
<select value={hmsSearchType} onChange={e => { setHmsSearchType(e.target.value as typeof hmsSearchType); setHmsPage(1) }} className="rounded-sm border border-border text-[12px] outline-none bg-bg-0 px-3 py-2" style={{ minWidth: 140 }}>
|
||||||
<option value="abbr">❶ 약자/제품명</option>
|
<option value="all">전체 통합검색</option>
|
||||||
<option value="korName">❷ 국문명 (동의어)</option>
|
<option value="abbr">약자/제품명</option>
|
||||||
<option value="engName">❸ 영문명 (동의어)</option>
|
<option value="korName">국문명</option>
|
||||||
<option value="cas">❹ CAS번호</option>
|
<option value="engName">영문명</option>
|
||||||
<option value="un">❺ UN번호</option>
|
<option value="cas">CAS번호</option>
|
||||||
|
<option value="un">UN번호</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" value={hmsSearchInput} onChange={e => { setHmsSearchInput(e.target.value); setHmsPage(1) }} placeholder={hmsSearchType === 'abbr' ? '검색어 입력 (부호·띄어쓰기 제외)' : hmsSearchType === 'cas' ? 'CAS번호 입력 (예: 71-43-2)' : hmsSearchType === 'un' ? 'UN번호 입력 (예: 1114)' : '검색어 입력 (* 동의어 검색)'} className="flex-1 rounded-sm border border-border text-[11px] outline-none bg-bg-0 px-3 py-2" />
|
<input type="text" value={hmsSearchInput} onChange={e => { setHmsSearchInput(e.target.value); setHmsPage(1) }} placeholder={hmsSearchType === 'all' ? '물질명, 약자, CAS, UN번호 통합 검색' : hmsSearchType === 'abbr' ? '약자/제품명 입력' : hmsSearchType === 'cas' ? 'CAS번호 입력 (예: 71-43-2)' : hmsSearchType === 'un' ? 'UN번호 입력 (예: 1114)' : '검색어 입력'} className="flex-1 rounded-sm border border-border text-[13px] outline-none bg-bg-0 px-3 py-2" />
|
||||||
<button onClick={() => setHmsPage(1)} className="text-[11px] font-bold cursor-pointer shrink-0 rounded-sm text-white px-5 py-2" style={{ border: 'none', background: 'linear-gradient(135deg,var(--orange),var(--red))' }}>🔎 검색</button>
|
<button onClick={() => setHmsPage(1)} className="text-[13px] font-bold cursor-pointer shrink-0 rounded-sm text-white px-5 py-2" style={{ border: 'none', background: 'linear-gradient(135deg,var(--orange),var(--red))' }}>🔎 검색</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[8px] text-text-3 leading-[1.6]">
|
<div className="text-[8px] text-text-3 leading-[1.6]">
|
||||||
※ 국문명·영문명 검색 시 <b className="text-status-orange">동의어까지 검색</b> | 약자/제품명 검색 시 <b className="text-status-orange">부호, 띄어쓰기 제외</b> 후 검색 | 총 <b className="text-primary-cyan">1,316종</b> 등록 (화물적부도 277종 / 해양시설 56종 / 용선자 화물코드 983종)
|
※ 국문명·영문명 검색 시 <b className="text-status-orange">동의어까지 검색</b> | 약자/제품명 검색 시 <b className="text-status-orange">부호, 띄어쓰기 제외</b> 후 검색 | 총 <b className="text-primary-cyan">1,222종</b> 등록
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 검색 결과 테이블 ── */}
|
{/* ── 검색 결과 테이블 ── */}
|
||||||
<div className="rounded-[10px] p-4 mb-4 border border-border bg-bg-3">
|
<div className="rounded-[10px] p-4 mb-4 border border-border bg-bg-3">
|
||||||
<div className="flex items-center justify-between mb-[10px]">
|
<div className="flex items-center justify-between mb-[10px]">
|
||||||
<div className="text-[11px] font-bold">📋 검색 결과 <span className="text-[9px] font-normal text-text-3">— {hmsTotal}건 조회</span></div>
|
<div className="text-[13px] font-bold">📋 검색 결과 <span className="text-[11px] font-normal text-text-3">— {hmsTotal}건 조회</span></div>
|
||||||
<select value={hmsFilterSebc} onChange={e => { setHmsFilterSebc(e.target.value); setHmsPage(1) }} className="rounded border border-border text-[9px] text-text-2 outline-none bg-bg-0 px-2 py-[3px]">
|
<select value={hmsFilterSebc} onChange={e => { setHmsFilterSebc(e.target.value); setHmsPage(1) }} className="rounded border border-border text-[11px] text-text-2 outline-none bg-bg-0 px-2.5 py-1">
|
||||||
<option>전체 거동분류</option><option>G (Gas)</option><option>E (Evaporator)</option><option>F (Floater)</option><option>D (Dissolver)</option><option>S (Sinker)</option>
|
<option>전체 거동분류</option><option>G (Gas)</option><option>E (Evaporator)</option><option>F (Floater)</option><option>D (Dissolver)</option><option>S (Sinker)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full border-collapse text-[9px]">
|
<table className="w-full border-collapse text-[11px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: 'rgba(249,115,22,.06)' }}>
|
<tr style={{ background: 'rgba(249,115,22,.06)' }}>
|
||||||
<th className="text-status-orange text-left" style={{ padding: '7px 6px', borderBottom: '2px solid var(--bdL)', width: 28 }}>No.</th>
|
<th className="text-status-orange text-left" style={{ padding: '8px 8px', borderBottom: '2px solid var(--bdL)', width: 36 }}>No.</th>
|
||||||
<th className="text-status-orange text-left" style={{ padding: '7px 6px', borderBottom: '2px solid var(--bdL)' }}>약자/제품명</th>
|
<th className="text-status-orange text-left" style={{ padding: '8px 8px', borderBottom: '2px solid var(--bdL)' }}>약자/제품명</th>
|
||||||
<th className="text-text-2 text-left" style={{ padding: '7px 6px', borderBottom: '2px solid var(--bdL)' }}>영문명</th>
|
<th className="text-text-2 text-left" style={{ padding: '8px 8px', borderBottom: '2px solid var(--bdL)' }}>영문명</th>
|
||||||
<th className="text-text-2 text-left" style={{ padding: '7px 6px', borderBottom: '2px solid var(--bdL)' }}>영문명 동의어</th>
|
<th className="text-text-2 text-left" style={{ padding: '8px 8px', borderBottom: '2px solid var(--bdL)' }}>영문명 동의어</th>
|
||||||
<th className="text-primary-cyan text-left" style={{ padding: '7px 6px', borderBottom: '2px solid var(--bdL)' }}>국문명</th>
|
<th className="text-primary-cyan text-left" style={{ padding: '8px 8px', borderBottom: '2px solid var(--bdL)' }}>국문명</th>
|
||||||
<th className="text-text-2 text-left text-[8px]" style={{ padding: '7px 6px', borderBottom: '2px solid var(--bdL)' }}>국문 동의어 / 주요 사용처</th>
|
<th className="text-text-2 text-left text-[10px]" style={{ padding: '8px 8px', borderBottom: '2px solid var(--bdL)' }}>국문 동의어 / 주요 사용처</th>
|
||||||
<th className="text-text-2 text-center" style={{ padding: '7px 6px', borderBottom: '2px solid var(--bdL)', width: 60 }}>UN번호</th>
|
<th className="text-text-2 text-center" style={{ padding: '8px 8px', borderBottom: '2px solid var(--bdL)', width: 68 }}>UN번호</th>
|
||||||
<th className="text-text-2 text-center" style={{ padding: '7px 6px', borderBottom: '2px solid var(--bdL)', width: 72 }}>CAS번호</th>
|
<th className="text-text-2 text-center" style={{ padding: '8px 8px', borderBottom: '2px solid var(--bdL)', width: 80 }}>CAS번호</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -649,12 +653,12 @@ ${styles}
|
|||||||
onMouseOver={e => { if (!isSel) e.currentTarget.style.background = 'rgba(249,115,22,.03)' }}
|
onMouseOver={e => { if (!isSel) e.currentTarget.style.background = 'rgba(249,115,22,.03)' }}
|
||||||
onMouseOut={e => { if (!isSel) e.currentTarget.style.background = '' }}
|
onMouseOut={e => { if (!isSel) e.currentTarget.style.background = '' }}
|
||||||
>
|
>
|
||||||
<td className="font-mono text-text-3 p-1.5">{(hmsPage - 1) * HMS_PER_PAGE + idx + 1}</td>
|
<td className="font-mono text-text-3 px-2 py-2">{(hmsPage - 1) * HMS_PER_PAGE + idx + 1}</td>
|
||||||
<td className="font-semibold font-mono text-status-orange p-1.5">{s.abbreviation}</td>
|
<td className="font-semibold font-mono text-status-orange px-2 py-2">{s.abbreviation}</td>
|
||||||
<td className="p-1.5">{s.nameEn}</td>
|
<td className="px-2 py-2">{s.nameEn}</td>
|
||||||
<td className="text-text-3 text-[8px] p-1.5">{s.synonymsEn}</td>
|
<td className="text-text-3 text-[10px] px-2 py-2">{s.synonymsEn}</td>
|
||||||
<td className="font-semibold p-1.5"><span className="text-primary-cyan underline cursor-pointer" onClick={e => { e.stopPropagation(); setHmsSelectedId(s.id); setHmsDetailTab(0) }}>{s.nameKr}</span></td>
|
<td className="font-semibold px-2 py-2"><span className="text-primary-cyan underline cursor-pointer" onClick={e => { e.stopPropagation(); setHmsSelectedId(s.id); setHmsDetailTab(0) }}>{s.nameKr}</span></td>
|
||||||
<td className="text-text-3 text-[8px] p-1.5">{s.synonymsKr}</td>
|
<td className="text-text-3 text-[10px] px-2 py-2">{s.synonymsKr}</td>
|
||||||
<td className="text-center font-mono">{s.unNumber}</td>
|
<td className="text-center font-mono">{s.unNumber}</td>
|
||||||
<td className="text-center font-mono">{s.casNumber}</td>
|
<td className="text-center font-mono">{s.casNumber}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -665,15 +669,29 @@ ${styles}
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-[10px] flex items-center justify-between text-[9px] text-text-3">
|
<div className="mt-[10px] text-center text-[11px] text-text-3">
|
||||||
<span>총 <b className="text-status-orange">{hmsTotal.toLocaleString()}</b>종 등록 · Port-MIS 화물적부도 연동 · 해경청 물질정보집 · IBC CODE 692종</span>
|
<div className="flex items-center justify-center gap-1 mb-1.5">
|
||||||
<div className="flex gap-1">
|
<button onClick={() => setHmsPage(1)} disabled={hmsPage <= 1} className="rounded cursor-pointer border border-border text-[11px] text-text-2 bg-bg-0 px-2.5 py-1" style={{ opacity: hmsPage <= 1 ? 0.4 : 1 }}>◀◀</button>
|
||||||
<button onClick={() => setHmsPage(p => Math.max(1, p - 1))} disabled={hmsPage <= 1} className="rounded cursor-pointer border border-border text-[9px] text-text-2 bg-bg-0 px-2.5 py-1" style={{ opacity: hmsPage <= 1 ? 0.4 : 1 }}>◀</button>
|
<button onClick={() => setHmsPage(p => Math.max(1, p - 1))} disabled={hmsPage <= 1} className="rounded cursor-pointer border border-border text-[11px] text-text-2 bg-bg-0 px-3 py-1" style={{ opacity: hmsPage <= 1 ? 0.4 : 1 }}>◀</button>
|
||||||
{Array.from({ length: Math.min(hmsTotalPages, 5) }, (_, i) => i + 1).map(p => (
|
{(() => {
|
||||||
<button key={p} onClick={() => setHmsPage(p)} className="rounded cursor-pointer border border-border text-[9px] px-2.5 py-1" style={{ background: p === hmsPage ? 'rgba(249,115,22,.08)' : 'var(--bg0)', color: p === hmsPage ? 'var(--orange)' : 'var(--t2)', fontWeight: p === hmsPage ? 600 : 400 }}>{p}</button>
|
const range = 2;
|
||||||
))}
|
let start = Math.max(1, hmsPage - range);
|
||||||
<button onClick={() => setHmsPage(p => Math.min(hmsTotalPages, p + 1))} disabled={hmsPage >= hmsTotalPages} className="rounded cursor-pointer border border-border text-[9px] text-text-2 bg-bg-0 px-2.5 py-1" style={{ opacity: hmsPage >= hmsTotalPages ? 0.4 : 1 }}>▶</button>
|
let end = Math.min(hmsTotalPages, hmsPage + range);
|
||||||
|
if (end - start < range * 2) {
|
||||||
|
start = Math.max(1, end - range * 2);
|
||||||
|
end = Math.min(hmsTotalPages, start + range * 2);
|
||||||
|
}
|
||||||
|
const pages = [];
|
||||||
|
for (let p = start; p <= end; p++) pages.push(p);
|
||||||
|
return pages.map(p => (
|
||||||
|
<button key={p} onClick={() => setHmsPage(p)} className="rounded cursor-pointer border border-border text-[11px] px-3 py-1" style={{ background: p === hmsPage ? 'rgba(249,115,22,.15)' : 'var(--bg0)', color: p === hmsPage ? 'var(--orange)' : 'var(--t2)', fontWeight: p === hmsPage ? 700 : 400 }}>{p}</button>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
<button onClick={() => setHmsPage(p => Math.min(hmsTotalPages, p + 1))} disabled={hmsPage >= hmsTotalPages} className="rounded cursor-pointer border border-border text-[11px] text-text-2 bg-bg-0 px-3 py-1" style={{ opacity: hmsPage >= hmsTotalPages ? 0.4 : 1 }}>▶</button>
|
||||||
|
<button onClick={() => setHmsPage(hmsTotalPages)} disabled={hmsPage >= hmsTotalPages} className="rounded cursor-pointer border border-border text-[11px] text-text-2 bg-bg-0 px-2.5 py-1" style={{ opacity: hmsPage >= hmsTotalPages ? 0.4 : 1 }}>▶▶</button>
|
||||||
|
<span className="ml-2 text-text-3">{hmsPage} / {hmsTotalPages} 페이지</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span>총 <b className="text-status-orange">{hmsTotal.toLocaleString()}</b>종 등록</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,24 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { HNSLeftPanel } from './HNSLeftPanel'
|
import { HNSLeftPanel } from './HNSLeftPanel';
|
||||||
import { HNSRightPanel } from './HNSRightPanel'
|
import type { HNSInputParams } from './HNSLeftPanel';
|
||||||
import { MapView } from '@common/components/map/MapView'
|
import { HNSRightPanel } from './HNSRightPanel';
|
||||||
import { HNSAnalysisListTable } from './HNSAnalysisListTable'
|
import { MapView } from '@common/components/map/MapView';
|
||||||
import { HNSTheoryView } from './HNSTheoryView'
|
import { HNSAnalysisListTable } from './HNSAnalysisListTable';
|
||||||
import { HNSSubstanceView } from './HNSSubstanceView'
|
import { HNSTheoryView } from './HNSTheoryView';
|
||||||
import { HNSScenarioView } from './HNSScenarioView'
|
import { HNSSubstanceView } from './HNSSubstanceView';
|
||||||
import { HNSRecalcModal } from './HNSRecalcModal'
|
import { HNSScenarioView } from './HNSScenarioView';
|
||||||
import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/useSubMenu'
|
import { HNSRecalcModal } from './HNSRecalcModal';
|
||||||
import { createHnsAnalysis } from '../services/hnsApi'
|
import type { RecalcParams } from './HNSRecalcModal';
|
||||||
|
import { useSubMenu, navigateToTab, setReportGenCategory, setHnsReportPayload } from '@common/hooks/useSubMenu';
|
||||||
|
import { windDirToCompass } from '../hooks/useWeatherFetch';
|
||||||
|
import { useAuthStore } from '@common/store/authStore';
|
||||||
|
import { createHnsAnalysis, saveHnsAnalysis, fetchHnsAnalysis } from '../services/hnsApi';
|
||||||
|
import { computeDispersion } from '../utils/dispersionEngine';
|
||||||
|
import { getSubstanceToxicity } from '../utils/toxicityData';
|
||||||
|
import type {
|
||||||
|
DispersionPoint, DispersionGridResult, DispersionModel,
|
||||||
|
MeteoParams, SourceParams, SimParams, AlgorithmType,
|
||||||
|
} from '../utils/dispersionTypes';
|
||||||
|
|
||||||
/* ─── HNS 매뉴얼 뷰어 컴포넌트 ─── */
|
/* ─── HNS 매뉴얼 뷰어 컴포넌트 ─── */
|
||||||
function HNSManualViewer() {
|
function HNSManualViewer() {
|
||||||
@ -47,7 +57,7 @@ function HNSManualViewer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SEBC 거동 분류 */}
|
{/* SEBC 거동 분류 */}
|
||||||
<div className={card} className="bg-bg-3 border border-border">
|
<div className={`${card} bg-bg-3 border border-border`}>
|
||||||
<div className="text-[13px] font-bold mb-2.5">SEBC 거동 분류 (Standard European Behaviour Classification)</div>
|
<div className="text-[13px] font-bold mb-2.5">SEBC 거동 분류 (Standard European Behaviour Classification)</div>
|
||||||
<div className="text-[9px] text-text-3 mb-2.5 leading-normal">물질의 물리적·화학적 특성(용해도, 밀도, 증기압, 점도)에 따라 이론적 거동을 5가지 주요 범주 + 7가지 하위 범주로 분류</div>
|
<div className="text-[9px] text-text-3 mb-2.5 leading-normal">물질의 물리적·화학적 특성(용해도, 밀도, 증기압, 점도)에 따라 이론적 거동을 5가지 주요 범주 + 7가지 하위 범주로 분류</div>
|
||||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(5,1fr)' }}>
|
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(5,1fr)' }}>
|
||||||
@ -67,175 +77,6 @@ function HNSManualViewer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* IMDG Code 위험물 등급 */}
|
|
||||||
<div className={card} className="bg-bg-3 border border-border">
|
|
||||||
<div className="text-[13px] font-bold mb-2.5">IMDG Code 위험물 등급 (Hazard Classification)</div>
|
|
||||||
<div className="grid gap-1.5" style={{ gridTemplateColumns: 'repeat(3,1fr)' }}>
|
|
||||||
{[
|
|
||||||
{ icon: '💥', label: 'Class 1 — 폭발물', sub: 'Explosives', bg: 'rgba(249,115,22,.15)' },
|
|
||||||
{ icon: '🫧', label: 'Class 2 — 가스', sub: '인화성/비인화성/독성', bg: 'rgba(34,197,94,.12)' },
|
|
||||||
{ icon: '🔥', label: 'Class 3 — 인화성 액체', sub: 'Flammable Liquids', bg: 'rgba(239,68,68,.12)' },
|
|
||||||
{ icon: '🧱', label: 'Class 4 — 인화성 고체', sub: '자연발화성/물반응성', bg: 'rgba(251,191,36,.12)' },
|
|
||||||
{ icon: '⚡', label: 'Class 5 — 산화제', sub: '산화성 물질/유기과산화물', bg: 'rgba(251,191,36,.12)' },
|
|
||||||
{ icon: '☠️', label: 'Class 6 — 독성물질', sub: '독성/감염성 물질', bg: 'rgba(139,92,246,.12)' },
|
|
||||||
{ icon: '☢️', label: 'Class 7 — 방사성', sub: 'Radioactive Material', bg: 'rgba(251,191,36,.12)' },
|
|
||||||
{ icon: '🧪', label: 'Class 8 — 부식성', sub: 'Corrosive Substances', bg: 'rgba(139,148,158,.12)' },
|
|
||||||
{ icon: '⚠️', label: 'Class 9 — 기타', sub: '환경유해물질 포함', bg: 'rgba(139,148,158,.12)' },
|
|
||||||
].map(c => (
|
|
||||||
<div key={c.label} className="flex items-center gap-2 p-2 bg-bg-0 rounded-[5px]">
|
|
||||||
<div className="flex items-center justify-center shrink-0 text-sm w-7 h-7 rounded" style={{ background: c.bg }}>{c.icon}</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-[10px] font-bold">{c.label}</div>
|
|
||||||
<div className="text-text-3 text-[8px]">{c.sub}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* HNS 사고 대응 프로세스 */}
|
|
||||||
<div className={card} className="bg-bg-3 border border-border">
|
|
||||||
<div className="text-[13px] font-bold mb-2.5">HNS 사고 대응 프로세스</div>
|
|
||||||
<div className="flex items-stretch gap-1 text-[9px]">
|
|
||||||
{[
|
|
||||||
{ icon: '🚨', step: '1단계: 사고 통보', desc: 'HNS 유출 감지\nGMDSS/DSC 신호\n관계기관 통보', color: 'rgba(239,68,68', textColor: '#f87171' },
|
|
||||||
{ icon: '🔍', step: '2단계: 상황 평가', desc: '물질 식별 (UN번호)\nSEBC 거동 판단\n위험 구역 설정', color: 'rgba(249,115,22', textColor: '#fb923c' },
|
|
||||||
{ icon: '🛡️', step: '3단계: 최초 조치', desc: '선원/대응인력 보호\nPPE 착용\n안전구역 설정', color: 'rgba(251,191,36', textColor: '#fbbf24' },
|
|
||||||
{ icon: '⚙️', step: '4단계: 현장 대응', desc: '선박 중심 조치\n오염물질 중심 조치\n모니터링 수행', color: 'rgba(6,182,212', textColor: 'var(--cyan)' },
|
|
||||||
{ icon: '🔄', step: '5단계: 유출후 관리', desc: '환경 회복/복원\n비용 문서화\n사고 검토/교훈', color: 'rgba(34,197,94', textColor: '#22c55e' },
|
|
||||||
].map((s, i) => (
|
|
||||||
<div key={s.step} className="flex items-stretch flex-1">
|
|
||||||
<div className="flex-1 text-center px-2 py-[10px] rounded-sm" style={{ background: `${s.color},.08)`, border: `1px solid ${s.color},.2)` }}>
|
|
||||||
<div className="text-base mb-1">{s.icon}</div>
|
|
||||||
<div className="font-bold mb-0.5" style={{ color: s.textColor }}>{s.step}</div>
|
|
||||||
<div className="text-text-3 whitespace-pre-line text-[8px] leading-[1.3]">{s.desc}</div>
|
|
||||||
</div>
|
|
||||||
{i < 4 && <div className="flex items-center text-sm px-[2px]" style={{ color: 'var(--bd)' }}>→</div>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 대응 기술 매트릭스 */}
|
|
||||||
<div className="grid gap-[14px] mb-[14px]" style={{ gridTemplateColumns: '1fr 1fr' }}>
|
|
||||||
{/* 선박 중심 조치 */}
|
|
||||||
<div className={card} className="bg-bg-3 border border-border">
|
|
||||||
<div className="text-[12px] font-bold mb-2">🚢 선박 중심 조치</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{[
|
|
||||||
{ icon: '🚁', title: '긴급 승선 (Emergency Boarding)', desc: '전문 대응팀의 사고 선박 접근 및 상황 파악' },
|
|
||||||
{ icon: '🔗', title: '긴급 예인 (Emergency Towing)', desc: '사고 선박을 안전 해역으로 이동' },
|
|
||||||
{ icon: '⚓', title: '피난 지역 (Place of Refuge)', desc: '선박이 수리/하역할 수 있는 보호 구역' },
|
|
||||||
{ icon: '🔄', title: '화물 이송 (Cargo Transfer)', desc: '위험 화물을 다른 선박/탱크로 이적' },
|
|
||||||
{ icon: '🔧', title: '밀봉/마개 (Sealing & Plugging)', desc: '유출 지점 임시 차단 및 봉쇄' },
|
|
||||||
].map(item => (
|
|
||||||
<div key={item.title} className="flex items-center gap-2 px-2 py-1.5 bg-bg-0 rounded">
|
|
||||||
<span className="text-[12px] text-center w-5">{item.icon}</span>
|
|
||||||
<div className="text-[9px]">
|
|
||||||
<b>{item.title}</b><br />
|
|
||||||
<span className="text-text-3">{item.desc}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* 오염물질 중심 조치 */}
|
|
||||||
<div className={card} className="bg-bg-3 border border-border">
|
|
||||||
<div className="text-[12px] font-bold mb-2">🧪 오염물질 중심 조치</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{[
|
|
||||||
{ icon: '💨', title: '대기 확산 모니터링', desc: '가스/증발 물질의 대기 농도 감시 (ALOHA/CAMEO)' },
|
|
||||||
{ icon: '🌊', title: '해수면 회수 (Surface Recovery)', desc: '부유 물질 흡착재/스키머로 회수' },
|
|
||||||
{ icon: '💧', title: '희석/분산 (Dilution/Dispersion)', desc: '용해 물질의 자연 희석 촉진' },
|
|
||||||
{ icon: '⬇️', title: '해저 회수 (Subsea Recovery)', desc: '침강 물질 ROV/잠수 회수' },
|
|
||||||
{ icon: '🔥', title: '제어 연소 (Controlled Burning)', desc: '인화성 물질 현장 소각 처리' },
|
|
||||||
].map(item => (
|
|
||||||
<div key={item.title} className="flex items-center gap-2 px-2 py-1.5 bg-bg-0 rounded">
|
|
||||||
<span className="text-[12px] text-center w-5">{item.icon}</span>
|
|
||||||
<div className="text-[9px]">
|
|
||||||
<b>{item.title}</b><br />
|
|
||||||
<span className="text-text-3">{item.desc}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PPE / 안전구역 / 노출한계 */}
|
|
||||||
<div className="grid gap-[14px] mb-[14px]" style={{ gridTemplateColumns: '1fr 1fr 1fr' }}>
|
|
||||||
{/* PPE */}
|
|
||||||
<div className={card} className="bg-bg-3 border border-border">
|
|
||||||
<div className="text-[12px] font-bold mb-2">🦺 개인보호장비 (PPE)</div>
|
|
||||||
<div className="flex flex-col gap-1 text-[9px]">
|
|
||||||
{[
|
|
||||||
{ level: 'Level A', desc: '완전 밀폐형 화학보호복 + SCBA\n증기/가스 직접 노출 구역', color: '#ef4444' },
|
|
||||||
{ level: 'Level B', desc: '비밀폐형 화학보호복 + SCBA\n액체 스플래시 위험 구역', color: '#f97316' },
|
|
||||||
{ level: 'Level C', desc: '화학보호복 + 공기정화식 호흡기\n물질 확인 완료, 농도 기준 이하', color: '#fbbf24' },
|
|
||||||
{ level: 'Level D', desc: '작업복 + 안전장화\n오염 위험 최소 구역', color: '#22c55e' },
|
|
||||||
].map(p => (
|
|
||||||
<div key={p.level} style={{ padding: '6px 8px', background: `color-mix(in srgb,${p.color} 6%,transparent)`, borderLeft: `3px solid ${p.color}`, borderRadius: '0 4px 4px 0' }}>
|
|
||||||
<b style={{ color: p.color }}>{p.level}</b><br />
|
|
||||||
<span className="text-text-3 whitespace-pre-line">{p.desc}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* 안전구역 */}
|
|
||||||
<div className={card} className="bg-bg-3 border border-border">
|
|
||||||
<div className="text-[12px] font-bold mb-2">🔴 안전 구역 설정</div>
|
|
||||||
<div className="flex flex-col gap-1.5 text-[9px]">
|
|
||||||
<div className="text-center flex flex-col items-center justify-center p-[10px]" style={{ background: 'rgba(239,68,68,.08)', border: '2px solid rgba(239,68,68,.3)', borderRadius: '50%', aspectRatio: '1' }}>
|
|
||||||
<b style={{ color: '#f87171', fontSize: '11px' }}>HOT ZONE</b>
|
|
||||||
<span className="text-text-3 text-[8px]">직접 위험구역<br />Level A/B PPE 필수</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-2 rounded-sm" style={{ background: 'rgba(251,191,36,.06)', border: '1.5px solid rgba(251,191,36,.25)' }}>
|
|
||||||
<b style={{ color: '#fbbf24', fontSize: '10px' }}>WARM ZONE</b>
|
|
||||||
<span className="text-text-3 text-[8px]"> — 오염제거/전환 구역</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-2 rounded-sm" style={{ background: 'rgba(34,197,94,.06)', border: '1.5px solid rgba(34,197,94,.25)' }}>
|
|
||||||
<b style={{ color: '#22c55e', fontSize: '10px' }}>COLD ZONE</b>
|
|
||||||
<span className="text-text-3 text-[8px]"> — 지휘/지원 구역</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* 노출한계 (AEGL) */}
|
|
||||||
<div className={card} className="bg-bg-3 border border-border">
|
|
||||||
<div className="text-[12px] font-bold mb-2">📊 노출한계 (AEGL 기준)</div>
|
|
||||||
<div className="text-[9px] text-text-3 mb-1.5 leading-[1.4]">Acute Exposure Guideline Levels (EPA)<br />암모니아(NH₃) 예시 — ppm 기준</div>
|
|
||||||
<table className="w-full font-mono border-collapse text-[8px]">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border">
|
|
||||||
<th className="p-1 text-left text-text-3">구분</th>
|
|
||||||
<th className="p-1 text-center text-text-3">10분</th>
|
|
||||||
<th className="p-1 text-center text-text-3">30분</th>
|
|
||||||
<th className="p-1 text-center text-text-3">60분</th>
|
|
||||||
<th className="p-1 text-center text-text-3">4시간</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{[
|
|
||||||
{ label: 'AEGL-1', color: '#22c55e', vals: [30, 30, 30, 30] },
|
|
||||||
{ label: 'AEGL-2', color: '#fbbf24', vals: [220, 220, 160, 110] },
|
|
||||||
{ label: 'AEGL-3', color: '#f87171', vals: [2700, 1600, 1100, 550] },
|
|
||||||
].map((row, ri) => (
|
|
||||||
<tr key={row.label} className={ri < 2 ? 'border-b border-border' : ''}>
|
|
||||||
<td className="p-1 font-bold" style={{ color: row.color }}>{row.label}</td>
|
|
||||||
{row.vals.map((v, vi) => (
|
|
||||||
<td key={vi} className="p-1 text-center text-text-2">{v}</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div className="text-text-3 mt-1.5 text-[7px] leading-[1.4]">
|
|
||||||
AEGL-1: 불쾌감 (비장애성)<br />
|
|
||||||
AEGL-2: 심각한 건강 영향 (비가역적)<br />
|
|
||||||
AEGL-3: 생명 위협 또는 사망
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 출처 */}
|
{/* 출처 */}
|
||||||
<div className="text-text-3 rounded-sm bg-bg-3 p-[10px] text-[8px] leading-[1.5]">
|
<div className="text-text-3 rounded-sm bg-bg-3 p-[10px] text-[8px] leading-[1.5]">
|
||||||
<b>출처:</b> Marine HNS Response Manual — Bonn Agreement / HELCOM / REMPEC (WestMOPoCo Project, 2024 한국어판)<br />
|
<b>출처:</b> Marine HNS Response Manual — Bonn Agreement / HELCOM / REMPEC (WestMOPoCo Project, 2024 한국어판)<br />
|
||||||
@ -247,72 +88,585 @@ function HNSManualViewer() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── 시간 슬라이더 컴포넌트 ─── */
|
||||||
|
function DispersionTimeSlider({
|
||||||
|
currentFrame,
|
||||||
|
totalFrames,
|
||||||
|
isPlaying,
|
||||||
|
onFrameChange,
|
||||||
|
onPlayPause,
|
||||||
|
dt,
|
||||||
|
}: {
|
||||||
|
currentFrame: number;
|
||||||
|
totalFrames: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
onFrameChange: (frame: number) => void;
|
||||||
|
onPlayPause: () => void;
|
||||||
|
dt: number;
|
||||||
|
}) {
|
||||||
|
const currentTime = (currentFrame + 1) * dt;
|
||||||
|
const endTime = totalFrames * dt;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-3 px-4 py-2.5 rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(10,22,40,0.92)',
|
||||||
|
border: '1px solid rgba(6,182,212,0.25)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
minWidth: '360px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onPlayPause}
|
||||||
|
className="flex items-center justify-center w-7 h-7 rounded-full text-[14px]"
|
||||||
|
style={{
|
||||||
|
background: isPlaying ? 'rgba(239,68,68,0.2)' : 'rgba(6,182,212,0.2)',
|
||||||
|
border: `1px solid ${isPlaying ? 'rgba(239,68,68,0.4)' : 'rgba(6,182,212,0.4)'}`,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPlaying ? '⏸' : '▶'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between text-[9px]">
|
||||||
|
<span className="text-primary-cyan font-mono font-bold">t = {currentTime}s</span>
|
||||||
|
<span className="text-text-3 font-mono">{endTime}s</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={totalFrames - 1}
|
||||||
|
value={currentFrame}
|
||||||
|
onChange={(e) => onFrameChange(parseInt(e.target.value))}
|
||||||
|
className="w-full h-1 accent-[var(--cyan)]"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-[9px] text-text-3 font-mono whitespace-nowrap">
|
||||||
|
{currentFrame + 1}/{totalFrames}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── 메인 HNSView ─── */
|
/* ─── 메인 HNSView ─── */
|
||||||
export function HNSView() {
|
export function HNSView() {
|
||||||
const { activeSubTab, setActiveSubTab } = useSubMenu('hns')
|
const { activeSubTab, setActiveSubTab } = useSubMenu('hns');
|
||||||
const [incidentCoord, setIncidentCoord] = useState({ lon: 129.3542, lat: 35.4215 })
|
const { user } = useAuthStore();
|
||||||
const [isSelectingLocation, setIsSelectingLocation] = useState(false)
|
const [incidentCoord, setIncidentCoord] = useState({ lon: 129.3542, lat: 35.4215 });
|
||||||
const [isRunningPrediction, setIsRunningPrediction] = useState(false)
|
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
||||||
|
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const [dispersionResult, setDispersionResult] = useState<any>(null)
|
const [dispersionResult, setDispersionResult] = useState<any>(null);
|
||||||
const [recalcModalOpen, setRecalcModalOpen] = useState(false)
|
const [recalcModalOpen, setRecalcModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// 확산 엔진 결과
|
||||||
|
const [heatmapData, setHeatmapData] = useState<DispersionPoint[]>([]);
|
||||||
|
const [computedResult, setComputedResult] = useState<DispersionGridResult | null>(null);
|
||||||
|
const [allTimeFrames, setAllTimeFrames] = useState<DispersionGridResult[]>([]);
|
||||||
|
const [currentFrame, setCurrentFrame] = useState(0);
|
||||||
|
const [isPuffPlaying, setIsPuffPlaying] = useState(false);
|
||||||
|
|
||||||
|
// 좌측 패널 입력 파라미터 (state로 관리하여 변경 시 재계산 트리거)
|
||||||
|
const [inputParams, setInputParams] = useState<HNSInputParams | null>(null);
|
||||||
|
const [loadedParams, setLoadedParams] = useState<Partial<HNSInputParams> | null>(null);
|
||||||
|
const hasRunOnce = useRef(false); // 최초 실행 여부
|
||||||
|
const mapCaptureRef = useRef<(() => string | null) | null>(null);
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setDispersionResult(null);
|
||||||
|
setHeatmapData([]);
|
||||||
|
setComputedResult(null);
|
||||||
|
setAllTimeFrames([]);
|
||||||
|
setCurrentFrame(0);
|
||||||
|
setIsPuffPlaying(false);
|
||||||
|
setInputParams(null);
|
||||||
|
hasRunOnce.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleParamsChange = useCallback((params: HNSInputParams) => {
|
||||||
|
setInputParams(params);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleMapClick = (lon: number, lat: number) => {
|
const handleMapClick = (lon: number, lat: number) => {
|
||||||
if (isSelectingLocation) {
|
if (isSelectingLocation) {
|
||||||
setIncidentCoord({ lon, lat })
|
setIncidentCoord({ lon, lat });
|
||||||
setIsSelectingLocation(false)
|
setIsSelectingLocation(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 시간 애니메이션 (puff/dense_gas)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPuffPlaying || allTimeFrames.length === 0) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCurrentFrame(prev => {
|
||||||
|
const next = prev + 1;
|
||||||
|
if (next >= allTimeFrames.length) {
|
||||||
|
setIsPuffPlaying(false);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
setHeatmapData(allTimeFrames[next].points);
|
||||||
|
setComputedResult(allTimeFrames[next]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isPuffPlaying, allTimeFrames]);
|
||||||
|
|
||||||
|
const handleFrameChange = (frame: number) => {
|
||||||
|
setCurrentFrame(frame);
|
||||||
|
if (allTimeFrames[frame]) {
|
||||||
|
setHeatmapData(allTimeFrames[frame].points);
|
||||||
|
setComputedResult(allTimeFrames[frame]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 확산 계산 핵심 로직 (버튼 클릭 + 파라미터 변경 시 공유) */
|
||||||
|
const runComputation = useCallback((params: HNSInputParams | null, coord: { lon: number; lat: number }) => {
|
||||||
|
const substanceName = params?.substance || '톨루엔 (Toluene)';
|
||||||
|
const tox = getSubstanceToxicity(substanceName);
|
||||||
|
|
||||||
|
const weather = params?.weather;
|
||||||
|
const meteo: MeteoParams = {
|
||||||
|
windSpeed: weather?.windSpeed ?? 5.0,
|
||||||
|
windDirDeg: weather?.windDirection ?? 270,
|
||||||
|
stability: weather?.stability ?? 'D',
|
||||||
|
temperature: ((weather?.temperature ?? 15) + 273.15),
|
||||||
|
pressure: 101325,
|
||||||
|
mixingHeight: 800,
|
||||||
|
};
|
||||||
|
|
||||||
|
const releaseType = params?.releaseType || '연속 유출';
|
||||||
|
const source: SourceParams = {
|
||||||
|
Q: params?.emissionRate ?? tox.Q,
|
||||||
|
QTotal: params?.totalRelease ?? tox.QTotal,
|
||||||
|
x0: 0, y0: 0,
|
||||||
|
z0: params?.releaseHeight ?? 0.5,
|
||||||
|
releaseDuration: releaseType === '연속 유출' ? (params?.releaseDuration ?? 300) : 0,
|
||||||
|
molecularWeight: tox.mw,
|
||||||
|
vaporPressure: tox.vaporPressure,
|
||||||
|
densityGas: tox.densityGas,
|
||||||
|
poolRadius: params?.poolRadius ?? tox.poolRadius,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sim: SimParams = {
|
||||||
|
xRange: [-100, 10000],
|
||||||
|
yRange: [-2000, 2000],
|
||||||
|
nx: 300,
|
||||||
|
ny: 200,
|
||||||
|
zRef: 1.5,
|
||||||
|
tStart: 0,
|
||||||
|
tEnd: 600,
|
||||||
|
dt: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modelType: DispersionModel =
|
||||||
|
releaseType === '연속 유출' ? 'plume' :
|
||||||
|
releaseType === '순간 유출' ? 'puff' : 'dense_gas';
|
||||||
|
|
||||||
|
const algo = (params?.algorithm || 'ALOHA (EPA)') as AlgorithmType;
|
||||||
|
|
||||||
|
if (modelType === 'plume') {
|
||||||
|
const result = computeDispersion({
|
||||||
|
meteo, source, sim, modelType,
|
||||||
|
originLon: coord.lon, originLat: coord.lat,
|
||||||
|
substanceName, t: sim.dt, algorithm: algo,
|
||||||
|
});
|
||||||
|
console.log('[HNS] plume 계산 완료:', result.points.length, '포인트, maxConc:', result.maxConcentration.toFixed(2), 'ppm');
|
||||||
|
setComputedResult(result);
|
||||||
|
setHeatmapData(result.points);
|
||||||
|
setAllTimeFrames([result]);
|
||||||
|
setCurrentFrame(0);
|
||||||
|
setIsPuffPlaying(false);
|
||||||
|
} else {
|
||||||
|
const times: number[] = [];
|
||||||
|
for (let t = sim.dt; t <= sim.tEnd; t += sim.dt) times.push(t);
|
||||||
|
const frames = times.map(t => computeDispersion({
|
||||||
|
meteo, source, sim, modelType,
|
||||||
|
originLon: coord.lon, originLat: coord.lat,
|
||||||
|
substanceName, t, algorithm: algo,
|
||||||
|
}));
|
||||||
|
console.log('[HNS]', modelType, '계산 완료:', frames.length, '프레임, 첫 프레임:', frames[0].points.length, '포인트');
|
||||||
|
setAllTimeFrames(frames);
|
||||||
|
setComputedResult(frames[0]);
|
||||||
|
setHeatmapData(frames[0].points);
|
||||||
|
setCurrentFrame(0);
|
||||||
|
setIsPuffPlaying(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRunPrediction = async () => {
|
// AEGL 구역 (MapView 레거시 호환)
|
||||||
setIsRunningPrediction(true)
|
const resultForZones = modelType === 'plume'
|
||||||
|
? computeDispersion({
|
||||||
|
meteo, source, sim, modelType,
|
||||||
|
originLon: coord.lon, originLat: coord.lat,
|
||||||
|
substanceName, t: sim.dt, algorithm: algo,
|
||||||
|
})
|
||||||
|
: computeDispersion({
|
||||||
|
meteo, source, sim, modelType,
|
||||||
|
originLon: coord.lon, originLat: coord.lat,
|
||||||
|
substanceName, t: sim.dt * 5, algorithm: algo,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { tox, meteo, resultForZones, substanceName };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRunPrediction = async (paramsOverride?: HNSInputParams | null) => {
|
||||||
|
setIsRunningPrediction(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { hnsAnlysSn } = await createHnsAnalysis({
|
const params = paramsOverride ?? inputParams;
|
||||||
anlysNm: `HNS 분석 ${new Date().toLocaleDateString('ko-KR')}`,
|
|
||||||
|
// 1. 계산 먼저 실행 (동기, 히트맵 즉시 표시)
|
||||||
|
const { tox, meteo, resultForZones, substanceName } = runComputation(params, incidentCoord);
|
||||||
|
hasRunOnce.current = true;
|
||||||
|
|
||||||
|
setDispersionResult({
|
||||||
|
hnsAnlysSn: 0,
|
||||||
|
zones: [
|
||||||
|
{ level: 'AEGL-3', color: '#ef4444', radius: resultForZones.aeglDistances.aegl3, angle: meteo.windDirDeg },
|
||||||
|
{ level: 'AEGL-2', color: '#f97316', radius: resultForZones.aeglDistances.aegl2, angle: meteo.windDirDeg },
|
||||||
|
{ level: 'AEGL-1', color: '#eab308', radius: resultForZones.aeglDistances.aegl1, angle: meteo.windDirDeg },
|
||||||
|
].filter(z => z.radius > 0),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
windDirection: meteo.windDirDeg,
|
||||||
|
substance: substanceName,
|
||||||
|
concentration: {
|
||||||
|
'AEGL-3': `${tox.aegl3} ppm`,
|
||||||
|
'AEGL-2': `${tox.aegl2} ppm`,
|
||||||
|
'AEGL-1': `${tox.aegl1} ppm`,
|
||||||
|
},
|
||||||
|
maxConcentration: resultForZones.maxConcentration,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 분석 레코드 DB 저장 (비동기, 실패해도 무시)
|
||||||
|
try {
|
||||||
|
const acdntDtm = params?.accidentDate && params?.accidentTime
|
||||||
|
? `${params.accidentDate}T${params.accidentTime}:00`
|
||||||
|
: params?.accidentDate || undefined;
|
||||||
|
const result = await createHnsAnalysis({
|
||||||
|
anlysNm: params?.accidentName || `HNS 분석 ${new Date().toLocaleDateString('ko-KR')}`,
|
||||||
|
acdntDtm,
|
||||||
lon: incidentCoord.lon,
|
lon: incidentCoord.lon,
|
||||||
lat: incidentCoord.lat,
|
lat: incidentCoord.lat,
|
||||||
locNm: `${incidentCoord.lon.toFixed(4)}, ${incidentCoord.lat.toFixed(4)}`,
|
locNm: `${incidentCoord.lat.toFixed(4)} / ${incidentCoord.lon.toFixed(4)}`,
|
||||||
})
|
sbstNm: params?.substance,
|
||||||
|
windSpd: params?.weather?.windSpeed,
|
||||||
// 시뮬레이션 엔진 미구현 — 프론트 임시 결과 생성
|
windDir: params?.weather?.windDirection != null ? String(params.weather.windDirection) : undefined,
|
||||||
const windAngle = 225
|
temp: params?.weather?.temperature,
|
||||||
const result = {
|
humid: params?.weather?.humidity,
|
||||||
hnsAnlysSn,
|
atmStblCd: params?.weather?.stability,
|
||||||
zones: [
|
analystNm: user?.name || undefined,
|
||||||
{ level: 'AEGL-3', color: 'rgba(239,68,68,0.4)', radius: 500, angle: windAngle },
|
});
|
||||||
{ level: 'AEGL-2', color: 'rgba(249,115,22,0.3)', radius: 1000, angle: windAngle },
|
// DB 저장 성공 시 SN 업데이트
|
||||||
{ level: 'AEGL-1', color: 'rgba(234,179,8,0.25)', radius: 1500, angle: windAngle },
|
setDispersionResult((prev: Record<string, unknown> | null) => prev ? { ...prev, hnsAnlysSn: result.hnsAnlysSn } : prev);
|
||||||
],
|
} catch {
|
||||||
timestamp: new Date().toISOString(),
|
// API 실패 시 무시 (히트맵은 이미 표시됨)
|
||||||
windDirection: windAngle,
|
|
||||||
substance: 'Toluene',
|
|
||||||
concentration: { 'AEGL-3': '500 ppm', 'AEGL-2': '150 ppm', 'AEGL-1': '37 ppm' }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setDispersionResult(result)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('대기확산 예측 오류:', error)
|
console.error('대기확산 예측 오류:', error);
|
||||||
alert('대기확산 예측 중 오류가 발생했습니다.')
|
alert('대기확산 예측 중 오류가 발생했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsRunningPrediction(false)
|
setIsRunningPrediction(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 분석 결과 저장 */
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!dispersionResult || !inputParams || !computedResult) {
|
||||||
|
alert('저장할 분석 결과가 없습니다. 먼저 예측을 실행해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rsltData: Record<string, unknown> = {
|
||||||
|
inputParams: {
|
||||||
|
substance: inputParams.substance,
|
||||||
|
releaseType: inputParams.releaseType,
|
||||||
|
emissionRate: inputParams.emissionRate,
|
||||||
|
totalRelease: inputParams.totalRelease,
|
||||||
|
releaseHeight: inputParams.releaseHeight,
|
||||||
|
releaseDuration: inputParams.releaseDuration,
|
||||||
|
poolRadius: inputParams.poolRadius,
|
||||||
|
algorithm: inputParams.algorithm,
|
||||||
|
criteriaModel: inputParams.criteriaModel,
|
||||||
|
accidentDate: inputParams.accidentDate,
|
||||||
|
accidentTime: inputParams.accidentTime,
|
||||||
|
predictionTime: inputParams.predictionTime,
|
||||||
|
accidentName: inputParams.accidentName,
|
||||||
|
},
|
||||||
|
coord: { lon: incidentCoord.lon, lat: incidentCoord.lat },
|
||||||
|
zones: dispersionResult.zones,
|
||||||
|
aeglDistances: computedResult.aeglDistances,
|
||||||
|
aeglAreas: computedResult.aeglAreas,
|
||||||
|
maxConcentration: computedResult.maxConcentration,
|
||||||
|
modelType: computedResult.modelType,
|
||||||
|
weather: {
|
||||||
|
windSpeed: inputParams.weather.windSpeed,
|
||||||
|
windDirection: inputParams.weather.windDirection,
|
||||||
|
temperature: inputParams.weather.temperature,
|
||||||
|
humidity: inputParams.weather.humidity,
|
||||||
|
stability: inputParams.weather.stability,
|
||||||
|
},
|
||||||
|
aegl3: (computedResult.aeglDistances?.aegl3 ?? 0) > 0,
|
||||||
|
aegl2: (computedResult.aeglDistances?.aegl2 ?? 0) > 0,
|
||||||
|
aegl1: (computedResult.aeglDistances?.aegl1 ?? 0) > 0,
|
||||||
|
damageRadius: `${((computedResult.aeglDistances?.aegl1 ?? 0) / 1000).toFixed(1)} km`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 위험등급 자동 판정
|
||||||
|
let riskCd = 'LOW';
|
||||||
|
if ((computedResult.aeglDistances?.aegl3 ?? 0) > 0) riskCd = 'CRITICAL';
|
||||||
|
else if ((computedResult.aeglDistances?.aegl2 ?? 0) > 0) riskCd = 'HIGH';
|
||||||
|
else if ((computedResult.aeglDistances?.aegl1 ?? 0) > 0) riskCd = 'MEDIUM';
|
||||||
|
|
||||||
|
// DB 저장 시도
|
||||||
|
let savedToDb = false;
|
||||||
|
try {
|
||||||
|
let sn = dispersionResult.hnsAnlysSn as number;
|
||||||
|
if (!sn) {
|
||||||
|
const fcstHrNum = parseInt(inputParams.predictionTime) || 24;
|
||||||
|
const spilQtyVal = inputParams.releaseType === '순간 유출'
|
||||||
|
? inputParams.totalRelease
|
||||||
|
: inputParams.emissionRate;
|
||||||
|
const anlysNm = inputParams.accidentName || `HNS 분석 ${new Date().toLocaleDateString('ko-KR')}`;
|
||||||
|
const saveAcdntDtm = inputParams.accidentDate && inputParams.accidentTime
|
||||||
|
? `${inputParams.accidentDate}T${inputParams.accidentTime}:00`
|
||||||
|
: inputParams.accidentDate || undefined;
|
||||||
|
const created = await createHnsAnalysis({
|
||||||
|
anlysNm,
|
||||||
|
acdntDtm: saveAcdntDtm,
|
||||||
|
lon: incidentCoord.lon,
|
||||||
|
lat: incidentCoord.lat,
|
||||||
|
locNm: `${incidentCoord.lat.toFixed(4)} / ${incidentCoord.lon.toFixed(4)}`,
|
||||||
|
sbstNm: inputParams.substance,
|
||||||
|
spilQty: spilQtyVal,
|
||||||
|
spilUnitCd: inputParams.releaseType === '순간 유출' ? 'g' : 'g/s',
|
||||||
|
fcstHr: fcstHrNum,
|
||||||
|
windSpd: inputParams.weather.windSpeed,
|
||||||
|
windDir: String(inputParams.weather.windDirection),
|
||||||
|
algoCd: inputParams.algorithm,
|
||||||
|
critMdlCd: inputParams.criteriaModel,
|
||||||
|
analystNm: user?.name || undefined,
|
||||||
|
});
|
||||||
|
sn = created.hnsAnlysSn;
|
||||||
|
setDispersionResult((prev: Record<string, unknown> | null) => prev ? { ...prev, hnsAnlysSn: sn } : prev);
|
||||||
|
}
|
||||||
|
await saveHnsAnalysis(sn, { rsltData, execSttsCd: 'COMPLETED', riskCd });
|
||||||
|
savedToDb = true;
|
||||||
|
} catch {
|
||||||
|
// DB 저장 실패 — localStorage fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// localStorage에도 저장 (DB 실패 시 fallback, 성공 시 백업)
|
||||||
|
try {
|
||||||
|
const localKey = 'hns_saved_analyses';
|
||||||
|
const existing: Record<string, unknown>[] = JSON.parse(localStorage.getItem(localKey) || '[]');
|
||||||
|
const fcstHrLocal = parseInt(inputParams.predictionTime) || 24;
|
||||||
|
const spilQtyLocal = inputParams.releaseType === '순간 유출'
|
||||||
|
? inputParams.totalRelease
|
||||||
|
: inputParams.emissionRate;
|
||||||
|
const entry = {
|
||||||
|
id: Date.now(),
|
||||||
|
anlysNm: inputParams.accidentName || `HNS 분석 ${new Date().toLocaleDateString('ko-KR')}`,
|
||||||
|
acdntDtm: inputParams.accidentDate && inputParams.accidentTime
|
||||||
|
? `${inputParams.accidentDate}T${inputParams.accidentTime}:00`
|
||||||
|
: inputParams.accidentDate || null,
|
||||||
|
sbstNm: inputParams.substance,
|
||||||
|
analystNm: user?.name || null,
|
||||||
|
spilQty: spilQtyLocal,
|
||||||
|
spilUnitCd: inputParams.releaseType === '순간 유출' ? 'g' : 'g/s',
|
||||||
|
fcstHr: fcstHrLocal,
|
||||||
|
regDtm: new Date().toISOString(),
|
||||||
|
riskCd,
|
||||||
|
rsltData,
|
||||||
|
};
|
||||||
|
existing.unshift(entry);
|
||||||
|
// 최대 50건 유지
|
||||||
|
if (existing.length > 50) existing.length = 50;
|
||||||
|
localStorage.setItem(localKey, JSON.stringify(existing));
|
||||||
|
} catch {
|
||||||
|
// localStorage 저장 실패 무시
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(savedToDb ? '분석 결과가 저장되었습니다.' : '분석 결과가 로컬에 저장되었습니다. (서버 연결 실패)');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 분석 목록에서 불러오기 (DB 또는 localStorage) */
|
||||||
|
const handleLoadAnalysis = async (sn: number, localRsltData?: Record<string, unknown>) => {
|
||||||
|
try {
|
||||||
|
let rslt: Record<string, unknown>;
|
||||||
|
|
||||||
|
if (localRsltData) {
|
||||||
|
// localStorage에서 직접 전달된 데이터
|
||||||
|
rslt = localRsltData;
|
||||||
|
} else {
|
||||||
|
// DB에서 조회
|
||||||
|
const analysis = await fetchHnsAnalysis(sn);
|
||||||
|
if (!analysis.rsltData) {
|
||||||
|
alert('저장된 분석 결과가 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rslt = analysis.rsltData as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
const savedParams = rslt.inputParams as Record<string, unknown> | undefined;
|
||||||
|
const savedCoord = rslt.coord as { lon: number; lat: number } | undefined;
|
||||||
|
|
||||||
|
// 좌표 복원
|
||||||
|
if (savedCoord) {
|
||||||
|
setIncidentCoord(savedCoord);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 입력 파라미터 복원 → HNSLeftPanel에 전달
|
||||||
|
if (savedParams) {
|
||||||
|
setLoadedParams({
|
||||||
|
substance: savedParams.substance as string,
|
||||||
|
releaseType: savedParams.releaseType as HNSInputParams['releaseType'],
|
||||||
|
emissionRate: savedParams.emissionRate as number,
|
||||||
|
totalRelease: savedParams.totalRelease as number,
|
||||||
|
releaseHeight: savedParams.releaseHeight as number,
|
||||||
|
releaseDuration: savedParams.releaseDuration as number,
|
||||||
|
poolRadius: savedParams.poolRadius as number,
|
||||||
|
algorithm: savedParams.algorithm as string,
|
||||||
|
criteriaModel: savedParams.criteriaModel as string,
|
||||||
|
accidentDate: savedParams.accidentDate as string,
|
||||||
|
accidentTime: savedParams.accidentTime as string,
|
||||||
|
predictionTime: savedParams.predictionTime as string,
|
||||||
|
accidentName: savedParams.accidentName as string,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 탭 전환 → analysis
|
||||||
|
setActiveSubTab('analysis');
|
||||||
|
|
||||||
|
// 약간의 딜레이 후 계산 실행 (loadedParams → inputParams 동기화 대기)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (savedParams && savedCoord) {
|
||||||
|
const savedWeather = rslt.weather as Record<string, unknown> | undefined;
|
||||||
|
const params: HNSInputParams = {
|
||||||
|
substance: (savedParams.substance as string) || '톨루엔 (Toluene)',
|
||||||
|
releaseType: (savedParams.releaseType as HNSInputParams['releaseType']) || '연속 유출',
|
||||||
|
emissionRate: (savedParams.emissionRate as number) || 10,
|
||||||
|
totalRelease: (savedParams.totalRelease as number) || 5000,
|
||||||
|
releaseHeight: (savedParams.releaseHeight as number) || 0.5,
|
||||||
|
releaseDuration: (savedParams.releaseDuration as number) || 300,
|
||||||
|
poolRadius: (savedParams.poolRadius as number) || 5,
|
||||||
|
algorithm: (savedParams.algorithm as string) || 'ALOHA (EPA)',
|
||||||
|
criteriaModel: (savedParams.criteriaModel as string) || 'AEGL',
|
||||||
|
accidentDate: (savedParams.accidentDate as string) || '',
|
||||||
|
accidentTime: (savedParams.accidentTime as string) || '',
|
||||||
|
predictionTime: (savedParams.predictionTime as string) || '24시간',
|
||||||
|
accidentName: (savedParams.accidentName as string) || '',
|
||||||
|
weather: {
|
||||||
|
windSpeed: (savedWeather?.windSpeed as number) ?? 5.0,
|
||||||
|
windDirection: (savedWeather?.windDirection as number) ?? 270,
|
||||||
|
temperature: (savedWeather?.temperature as number) ?? 15,
|
||||||
|
humidity: (savedWeather?.humidity as number) ?? 60,
|
||||||
|
stability: (savedWeather?.stability as string ?? 'D') as HNSInputParams['weather']['stability'],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdate: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
setInputParams(params);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tox, meteo, resultForZones, substanceName } = runComputation(params, savedCoord);
|
||||||
|
hasRunOnce.current = true;
|
||||||
|
|
||||||
|
setDispersionResult({
|
||||||
|
hnsAnlysSn: sn,
|
||||||
|
zones: [
|
||||||
|
{ level: 'AEGL-3', color: 'rgba(239,68,68,0.4)', radius: resultForZones.aeglDistances.aegl3, angle: meteo.windDirDeg },
|
||||||
|
{ level: 'AEGL-2', color: 'rgba(249,115,22,0.3)', radius: resultForZones.aeglDistances.aegl2, angle: meteo.windDirDeg },
|
||||||
|
{ level: 'AEGL-1', color: 'rgba(234,179,8,0.25)', radius: resultForZones.aeglDistances.aegl1, angle: meteo.windDirDeg },
|
||||||
|
].filter((z: { radius: number }) => z.radius > 0),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
windDirection: meteo.windDirDeg,
|
||||||
|
substance: substanceName,
|
||||||
|
concentration: {
|
||||||
|
'AEGL-3': `${tox.aegl3} ppm`,
|
||||||
|
'AEGL-2': `${tox.aegl2} ppm`,
|
||||||
|
'AEGL-1': `${tox.aegl1} ppm`,
|
||||||
|
},
|
||||||
|
maxConcentration: resultForZones.maxConcentration,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 재계산 실패 시 무시
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}, 200);
|
||||||
|
} catch {
|
||||||
|
alert('분석 불러오기에 실패했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 보고서 생성 — 실 데이터 수집 + 지도 캡처 후 탭 이동 */
|
||||||
|
const handleOpenReport = () => {
|
||||||
|
try {
|
||||||
|
let mapImage: string | null = null;
|
||||||
|
try { mapImage = mapCaptureRef.current?.() ?? null; } catch { /* canvas capture 실패 무시 */ }
|
||||||
|
|
||||||
|
const tox = getSubstanceToxicity(inputParams?.substance || '');
|
||||||
|
const modelType = computedResult?.modelType;
|
||||||
|
const modelLabel = modelType === 'plume' ? 'Gaussian Plume'
|
||||||
|
: modelType === 'puff' ? 'Gaussian Puff'
|
||||||
|
: modelType === 'dense_gas' ? 'Dense Gas (B-M)' : 'ALOHA';
|
||||||
|
|
||||||
|
setHnsReportPayload({
|
||||||
|
mapImageDataUrl: mapImage,
|
||||||
|
substance: {
|
||||||
|
name: inputParams?.substance || '—',
|
||||||
|
toxicity: `AEGL-2: ${tox.aegl2} ppm / AEGL-3: ${tox.aegl3} ppm`,
|
||||||
|
},
|
||||||
|
hazard: {
|
||||||
|
aegl3: `${((computedResult?.aeglDistances.aegl3 ?? 0) / 1000).toFixed(1)} km`,
|
||||||
|
aegl2: `${((computedResult?.aeglDistances.aegl2 ?? 0) / 1000).toFixed(1)} km`,
|
||||||
|
aegl1: `${((computedResult?.aeglDistances.aegl1 ?? 0) / 1000).toFixed(1)} km`,
|
||||||
|
},
|
||||||
|
atm: {
|
||||||
|
model: modelLabel,
|
||||||
|
maxDistance: `${((computedResult?.aeglDistances.aegl1 ?? 0) / 1000).toFixed(1)} km`,
|
||||||
|
},
|
||||||
|
weather: {
|
||||||
|
windDir: `${windDirToCompass(inputParams?.weather?.windDirection ?? 0)} ${inputParams?.weather?.windDirection ?? 0}°`,
|
||||||
|
windSpeed: `${inputParams?.weather?.windSpeed ?? 0} m/s`,
|
||||||
|
stability: `${inputParams?.weather?.stability ?? 'D'}`,
|
||||||
|
temperature: `${inputParams?.weather?.temperature ?? 0}°C`,
|
||||||
|
},
|
||||||
|
maxConcentration: `${(computedResult?.maxConcentration ?? 0).toFixed(1)} ppm`,
|
||||||
|
aeglAreas: {
|
||||||
|
aegl1: `${(computedResult?.aeglAreas.aegl1 ?? 0).toFixed(2)} km²`,
|
||||||
|
aegl2: `${(computedResult?.aeglAreas.aegl2 ?? 0).toFixed(2)} km²`,
|
||||||
|
aegl3: `${(computedResult?.aeglAreas.aegl3 ?? 0).toFixed(2)} km²`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[HNS] 보고서 데이터 수집 오류:', err);
|
||||||
|
}
|
||||||
|
setReportGenCategory(1);
|
||||||
|
navigateToTab('reports', 'generate');
|
||||||
|
};
|
||||||
|
|
||||||
if (activeSubTab === 'scenario') {
|
if (activeSubTab === 'scenario') {
|
||||||
return <HNSScenarioView />
|
return <HNSScenarioView />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeSubTab === 'manual') {
|
if (activeSubTab === 'manual') {
|
||||||
return <HNSManualViewer />
|
return <HNSManualViewer />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeSubTab === 'theory') {
|
if (activeSubTab === 'theory') {
|
||||||
return <HNSTheoryView />
|
return <HNSTheoryView />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeSubTab === 'substance') {
|
if (activeSubTab === 'substance') {
|
||||||
return <HNSSubstanceView />
|
return <HNSSubstanceView />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -327,14 +681,18 @@ export function HNSView() {
|
|||||||
onMapSelectClick={() => setIsSelectingLocation(true)}
|
onMapSelectClick={() => setIsSelectingLocation(true)}
|
||||||
onRunPrediction={handleRunPrediction}
|
onRunPrediction={handleRunPrediction}
|
||||||
isRunningPrediction={isRunningPrediction}
|
isRunningPrediction={isRunningPrediction}
|
||||||
|
onParamsChange={handleParamsChange}
|
||||||
|
onReset={handleReset}
|
||||||
|
loadedParams={loadedParams}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Center - Map/Content Area */}
|
{/* Center - Map/Content Area */}
|
||||||
<div className="flex-1 relative overflow-hidden">
|
<div className="flex-1 relative overflow-hidden">
|
||||||
{activeSubTab === 'list' ? (
|
{activeSubTab === 'list' ? (
|
||||||
<HNSAnalysisListTable onTabChange={(v) => setActiveSubTab(typeof v === 'function' ? v(activeSubTab as 'analysis' | 'list') : v)} />
|
<HNSAnalysisListTable onTabChange={(v) => setActiveSubTab(typeof v === 'function' ? v(activeSubTab as 'analysis' | 'list') : v)} onSelectAnalysis={handleLoadAnalysis} />
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<MapView
|
<MapView
|
||||||
incidentCoord={incidentCoord}
|
incidentCoord={incidentCoord}
|
||||||
isSelectingLocation={isSelectingLocation}
|
isSelectingLocation={isSelectingLocation}
|
||||||
@ -342,7 +700,21 @@ export function HNSView() {
|
|||||||
oilTrajectory={[]}
|
oilTrajectory={[]}
|
||||||
enabledLayers={new Set()}
|
enabledLayers={new Set()}
|
||||||
dispersionResult={dispersionResult}
|
dispersionResult={dispersionResult}
|
||||||
|
dispersionHeatmap={heatmapData}
|
||||||
|
mapCaptureRef={mapCaptureRef}
|
||||||
/>
|
/>
|
||||||
|
{/* 시간 슬라이더 (puff/dense_gas 모델용) */}
|
||||||
|
{allTimeFrames.length > 1 && (
|
||||||
|
<DispersionTimeSlider
|
||||||
|
currentFrame={currentFrame}
|
||||||
|
totalFrames={allTimeFrames.length}
|
||||||
|
isPlaying={isPuffPlaying}
|
||||||
|
onFrameChange={handleFrameChange}
|
||||||
|
onPlayPause={() => setIsPuffPlaying(!isPuffPlaying)}
|
||||||
|
dt={30}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -350,8 +722,11 @@ export function HNSView() {
|
|||||||
{activeSubTab === 'analysis' && (
|
{activeSubTab === 'analysis' && (
|
||||||
<HNSRightPanel
|
<HNSRightPanel
|
||||||
dispersionResult={dispersionResult}
|
dispersionResult={dispersionResult}
|
||||||
|
computedResult={computedResult}
|
||||||
|
weatherData={inputParams?.weather ?? null}
|
||||||
onOpenRecalc={() => setRecalcModalOpen(true)}
|
onOpenRecalc={() => setRecalcModalOpen(true)}
|
||||||
onOpenReport={() => { setReportGenCategory(1); navigateToTab('reports', 'generate') }}
|
onOpenReport={handleOpenReport}
|
||||||
|
onSave={handleSave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -359,8 +734,32 @@ export function HNSView() {
|
|||||||
<HNSRecalcModal
|
<HNSRecalcModal
|
||||||
isOpen={recalcModalOpen}
|
isOpen={recalcModalOpen}
|
||||||
onClose={() => setRecalcModalOpen(false)}
|
onClose={() => setRecalcModalOpen(false)}
|
||||||
onSubmit={() => handleRunPrediction()}
|
currentParams={inputParams ? {
|
||||||
|
substance: inputParams.substance,
|
||||||
|
releaseType: inputParams.releaseType,
|
||||||
|
emissionRate: inputParams.emissionRate,
|
||||||
|
totalRelease: inputParams.totalRelease,
|
||||||
|
algorithm: inputParams.algorithm,
|
||||||
|
predictionTime: inputParams.predictionTime,
|
||||||
|
} : null}
|
||||||
|
onSubmit={(recalcP: RecalcParams) => {
|
||||||
|
// 재계산 파라미터를 현재 inputParams에 병합하여 즉시 실행
|
||||||
|
const merged: HNSInputParams = {
|
||||||
|
...(inputParams as HNSInputParams),
|
||||||
|
substance: recalcP.substance,
|
||||||
|
releaseType: recalcP.releaseType,
|
||||||
|
emissionRate: recalcP.emissionRate,
|
||||||
|
totalRelease: recalcP.totalRelease,
|
||||||
|
algorithm: recalcP.algorithm,
|
||||||
|
predictionTime: recalcP.predictionTime,
|
||||||
|
};
|
||||||
|
// 좌측 패널 UI도 동기화
|
||||||
|
setLoadedParams(merged);
|
||||||
|
setInputParams(merged);
|
||||||
|
// 병합된 파라미터로 즉시 예측 실행
|
||||||
|
handleRunPrediction(merged);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
125
frontend/src/tabs/hns/hooks/useWeatherFetch.ts
Normal file
125
frontend/src/tabs/hns/hooks/useWeatherFetch.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
convertToGridCoords,
|
||||||
|
getUltraShortForecast,
|
||||||
|
getCurrentBaseDateTime,
|
||||||
|
} from '@tabs/weather/services/weatherApi';
|
||||||
|
import type { StabilityClass, WeatherFetchResult } from '../utils/dispersionTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turner 간이법으로 Pasquill-Gifford 안정도 산출
|
||||||
|
* 풍속 + 시간대(주간/야간) 기반
|
||||||
|
*/
|
||||||
|
function deriveStabilityClass(windSpeed: number, hour: number): StabilityClass {
|
||||||
|
const isNight = hour < 6 || hour >= 18;
|
||||||
|
if (isNight) {
|
||||||
|
if (windSpeed < 2) return 'F';
|
||||||
|
if (windSpeed < 3) return 'E';
|
||||||
|
return 'D';
|
||||||
|
}
|
||||||
|
// 주간 (중간 수준 일사량 가정)
|
||||||
|
if (windSpeed < 2) return 'A';
|
||||||
|
if (windSpeed < 3) return 'B';
|
||||||
|
if (windSpeed < 5) return 'C';
|
||||||
|
return 'D';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 풍향(도) → 16방위 문자열 */
|
||||||
|
export function windDirToCompass(deg: number): string {
|
||||||
|
const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
|
||||||
|
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
|
||||||
|
const idx = Math.round(((deg % 360) + 360) % 360 / 22.5) % 16;
|
||||||
|
return dirs[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_WEATHER: WeatherFetchResult = {
|
||||||
|
windSpeed: 5.0,
|
||||||
|
windDirection: 270,
|
||||||
|
temperature: 15,
|
||||||
|
humidity: 60,
|
||||||
|
stability: 'D',
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdate: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 위치 기반 기상정보 자동조회 훅
|
||||||
|
* KMA 초단기실황 API 활용, 500ms 디바운스
|
||||||
|
* baseDate: 'YYYY-MM-DD' (선택), baseTime: 'HH:mm' (선택)
|
||||||
|
* 미제공 시 getCurrentBaseDateTime()으로 현재 시각 사용
|
||||||
|
*/
|
||||||
|
export function useWeatherFetch(lat: number, lon: number, baseDate?: string, baseTime?: string): WeatherFetchResult {
|
||||||
|
const [weather, setWeather] = useState<WeatherFetchResult>({
|
||||||
|
...DEFAULT_WEATHER,
|
||||||
|
isLoading: true,
|
||||||
|
});
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
|
||||||
|
timerRef.current = setTimeout(async () => {
|
||||||
|
setWeather(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { nx, ny } = convertToGridCoords(lat, lon);
|
||||||
|
|
||||||
|
let apiBaseDate: string;
|
||||||
|
let apiBaseTime: string;
|
||||||
|
let stabilityHour: number;
|
||||||
|
|
||||||
|
if (baseDate && baseTime) {
|
||||||
|
// 'YYYY-MM-DD' → 'YYYYMMDD'
|
||||||
|
apiBaseDate = baseDate.replace(/-/g, '');
|
||||||
|
// 'HH:mm' → 'HH00'
|
||||||
|
apiBaseTime = baseTime.slice(0, 2) + '00';
|
||||||
|
stabilityHour = parseInt(baseTime.slice(0, 2), 10);
|
||||||
|
} else {
|
||||||
|
const current = getCurrentBaseDateTime();
|
||||||
|
apiBaseDate = current.baseDate;
|
||||||
|
apiBaseTime = current.baseTime;
|
||||||
|
stabilityHour = new Date().getHours();
|
||||||
|
}
|
||||||
|
|
||||||
|
const forecasts = await getUltraShortForecast(nx, ny, apiBaseDate, apiBaseTime);
|
||||||
|
|
||||||
|
if (forecasts.length > 0) {
|
||||||
|
const f = forecasts[0];
|
||||||
|
const stability = deriveStabilityClass(f.windSpeed, stabilityHour);
|
||||||
|
|
||||||
|
setWeather({
|
||||||
|
windSpeed: f.windSpeed,
|
||||||
|
windDirection: f.windDirection,
|
||||||
|
temperature: f.temperature,
|
||||||
|
humidity: f.humidity,
|
||||||
|
stability,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdate: new Date(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setWeather({
|
||||||
|
...DEFAULT_WEATHER,
|
||||||
|
isLoading: false,
|
||||||
|
error: 'API 응답 데이터 없음',
|
||||||
|
lastUpdate: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setWeather({
|
||||||
|
...DEFAULT_WEATHER,
|
||||||
|
isLoading: false,
|
||||||
|
error: 'KMA API 조회 실패 (기본값 사용)',
|
||||||
|
lastUpdate: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, [lat, lon, baseDate, baseTime]);
|
||||||
|
|
||||||
|
return weather;
|
||||||
|
}
|
||||||
@ -65,6 +65,14 @@ export async function createHnsAnalysis(input: CreateHnsAnalysisInput): Promise<
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveHnsAnalysis(sn: number, data: {
|
||||||
|
rsltData: Record<string, unknown>;
|
||||||
|
execSttsCd?: string;
|
||||||
|
riskCd?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await api.post(`/hns/analyses/${sn}/save`, data);
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteHnsAnalysis(sn: number): Promise<void> {
|
export async function deleteHnsAnalysis(sn: number): Promise<void> {
|
||||||
await api.delete(`/hns/analyses/${sn}`);
|
await api.delete(`/hns/analyses/${sn}`);
|
||||||
}
|
}
|
||||||
|
|||||||
373
frontend/src/tabs/hns/utils/dispersionEngine.ts
Normal file
373
frontend/src/tabs/hns/utils/dispersionEngine.ts
Normal file
@ -0,0 +1,373 @@
|
|||||||
|
/**
|
||||||
|
* HNS 대기확산 모델 엔진 (TypeScript)
|
||||||
|
* ──────────────────────────────────────
|
||||||
|
* hns_dispersion.py 직접 포팅
|
||||||
|
*
|
||||||
|
* 지원 모델:
|
||||||
|
* 1. Gaussian Plume — 연속 누출 (정상상태)
|
||||||
|
* 2. Gaussian Puff — 순간 누출 (시간변화)
|
||||||
|
* 3. Dense Gas — 고밀도 가스 (Britter-McQuaid)
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
StabilityClass, MeteoParams, SourceParams, SimParams,
|
||||||
|
DispersionPoint, DispersionGridResult, ComputeDispersionParams,
|
||||||
|
AeglDistances, AeglAreas, AlgorithmType,
|
||||||
|
} from './dispersionTypes';
|
||||||
|
import { getSubstanceToxicity } from './toxicityData';
|
||||||
|
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
// 알고리즘별 Pasquill-Gifford 확산계수
|
||||||
|
// [ay, by, az, bz]
|
||||||
|
// σy = ay * x / sqrt(1 + by * x)
|
||||||
|
// σz = az * x / sqrt(1 + bz * x)
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
|
||||||
|
/** ALOHA (EPA) — Briggs (1973) open-country */
|
||||||
|
const PG_ALOHA: Record<StabilityClass, [number, number, number, number]> = {
|
||||||
|
A: [0.22, 0.0001, 0.20, 0.000],
|
||||||
|
B: [0.16, 0.0001, 0.12, 0.000],
|
||||||
|
C: [0.11, 0.0001, 0.08, 0.0002],
|
||||||
|
D: [0.08, 0.0001, 0.06, 0.0015],
|
||||||
|
E: [0.06, 0.0001, 0.03, 0.0003],
|
||||||
|
F: [0.04, 0.0001, 0.016, 0.0003],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Gaussian Plume — Turner (1970) Pasquill-Gifford 표준 계수 (더 넓은 확산) */
|
||||||
|
const PG_GAUSSIAN: Record<StabilityClass, [number, number, number, number]> = {
|
||||||
|
A: [0.25, 0.0001, 0.23, 0.000],
|
||||||
|
B: [0.19, 0.0001, 0.16, 0.000],
|
||||||
|
C: [0.13, 0.0001, 0.10, 0.0002],
|
||||||
|
D: [0.09, 0.0001, 0.07, 0.0015],
|
||||||
|
E: [0.07, 0.0001, 0.04, 0.0003],
|
||||||
|
F: [0.05, 0.0001, 0.02, 0.0003],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** CAMEO — ALOHA와 동일 엔진 (NOAA/EPA CAMEO Suite) */
|
||||||
|
const PG_CAMEO = PG_ALOHA;
|
||||||
|
|
||||||
|
/** AERMOD — Schulman-Scire urban 계수 (도시 지역, 더 넓은 확산) */
|
||||||
|
const PG_AERMOD: Record<StabilityClass, [number, number, number, number]> = {
|
||||||
|
A: [0.32, 0.0004, 0.24, 0.001],
|
||||||
|
B: [0.22, 0.0004, 0.18, 0.001],
|
||||||
|
C: [0.16, 0.0004, 0.13, 0.001],
|
||||||
|
D: [0.11, 0.0004, 0.09, 0.001],
|
||||||
|
E: [0.08, 0.0004, 0.05, 0.001],
|
||||||
|
F: [0.06, 0.0004, 0.03, 0.001],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 알고리즘별 PG 파라미터 맵 */
|
||||||
|
const PG_BY_ALGO: Record<string, Record<StabilityClass, [number, number, number, number]>> = {
|
||||||
|
'ALOHA (EPA)': PG_ALOHA,
|
||||||
|
'CAMEO': PG_CAMEO,
|
||||||
|
'Gaussian Plume': PG_GAUSSIAN,
|
||||||
|
'AERMOD': PG_AERMOD,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPgParams(algorithm?: AlgorithmType): Record<StabilityClass, [number, number, number, number]> {
|
||||||
|
return PG_BY_ALGO[algorithm || 'ALOHA (EPA)'] || PG_ALOHA;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 수평 확산계수 σy (m) */
|
||||||
|
function sigmaY(x: number, stability: StabilityClass, algorithm?: AlgorithmType): number {
|
||||||
|
const pg = getPgParams(algorithm);
|
||||||
|
const [ay, by] = pg[stability];
|
||||||
|
const xc = Math.max(x, 1.0);
|
||||||
|
return ay * xc / Math.sqrt(1.0 + by * xc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 수직 확산계수 σz (m) */
|
||||||
|
function sigmaZ(x: number, stability: StabilityClass, algorithm?: AlgorithmType): number {
|
||||||
|
const pg = getPgParams(algorithm);
|
||||||
|
const [, , az, bz] = pg[stability];
|
||||||
|
const xc = Math.max(x, 1.0);
|
||||||
|
return az * xc / Math.sqrt(1.0 + bz * xc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기상 풍향 → 수학 좌표 회전각 변환
|
||||||
|
* 기상 풍향 270° (서풍) → 바람이 동쪽(+x)으로 진행
|
||||||
|
*/
|
||||||
|
function windRotation(windDirDeg: number): [number, number] {
|
||||||
|
const mathAngle = (270.0 - windDirDeg) * Math.PI / 180.0;
|
||||||
|
return [Math.cos(mathAngle), Math.sin(mathAngle)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 절대 좌표 → 바람 중심축 좌표 (xw=풍하, yw=횡풍) */
|
||||||
|
function rotateToWind(
|
||||||
|
x: number, y: number,
|
||||||
|
x0: number, y0: number,
|
||||||
|
cosT: number, sinT: number,
|
||||||
|
): [number, number] {
|
||||||
|
const dx = x - x0;
|
||||||
|
const dy = y - y0;
|
||||||
|
const xw = dx * cosT + dy * sinT;
|
||||||
|
const yw = -dx * sinT + dy * cosT;
|
||||||
|
return [xw, yw];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 미터 오프셋 → 경위도 변환 */
|
||||||
|
function metersToLonLat(
|
||||||
|
xMeters: number, yMeters: number,
|
||||||
|
originLon: number, originLat: number,
|
||||||
|
): [number, number] {
|
||||||
|
const latRad = originLat * Math.PI / 180;
|
||||||
|
const dLat = yMeters / 111320;
|
||||||
|
const dLon = xMeters / (111320 * Math.cos(latRad));
|
||||||
|
return [originLon + dLon, originLat + dLat];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** g/m³ → ppm 변환 */
|
||||||
|
function gm3ToPpm(cGm3: number, mw: number, tempK: number, pressurePa: number): number {
|
||||||
|
return cGm3 * (8.314 * tempK) / (mw * pressurePa) * 1e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
// Model 1: Gaussian Plume (연속 누출, 정상상태)
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
function gaussianPlume(
|
||||||
|
xArr: Float64Array, yArr: Float64Array,
|
||||||
|
meteo: MeteoParams, source: SourceParams, sim: SimParams,
|
||||||
|
algorithm?: AlgorithmType,
|
||||||
|
): Float64Array {
|
||||||
|
const nx = xArr.length;
|
||||||
|
const ny = yArr.length;
|
||||||
|
const C = new Float64Array(ny * nx);
|
||||||
|
|
||||||
|
const [cosT, sinT] = windRotation(meteo.windDirDeg);
|
||||||
|
const u = Math.max(meteo.windSpeed, 0.5);
|
||||||
|
const H = source.z0;
|
||||||
|
const z = sim.zRef;
|
||||||
|
const TWO_PI = 2.0 * Math.PI;
|
||||||
|
|
||||||
|
for (let j = 0; j < ny; j++) {
|
||||||
|
for (let i = 0; i < nx; i++) {
|
||||||
|
const [xw, yw] = rotateToWind(xArr[i], yArr[j], source.x0, source.y0, cosT, sinT);
|
||||||
|
|
||||||
|
if (xw <= 0) continue;
|
||||||
|
|
||||||
|
const sy = sigmaY(xw, meteo.stability, algorithm);
|
||||||
|
let sz = sigmaZ(xw, meteo.stability, algorithm);
|
||||||
|
sz = Math.min(sz, meteo.mixingHeight);
|
||||||
|
|
||||||
|
const termY = Math.exp(-0.5 * (yw / sy) ** 2);
|
||||||
|
const termZ1 = Math.exp(-0.5 * ((z - H) / sz) ** 2);
|
||||||
|
const termZ2 = Math.exp(-0.5 * ((z + H) / sz) ** 2);
|
||||||
|
|
||||||
|
C[j * nx + i] = (source.Q / (TWO_PI * sy * sz * u)) * termY * (termZ1 + termZ2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return C;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
// Model 2: Gaussian Puff (순간 누출, 시간변화)
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
function gaussianPuff(
|
||||||
|
xArr: Float64Array, yArr: Float64Array,
|
||||||
|
meteo: MeteoParams, source: SourceParams, sim: SimParams,
|
||||||
|
t: number, algorithm?: AlgorithmType,
|
||||||
|
): Float64Array {
|
||||||
|
const nx = xArr.length;
|
||||||
|
const ny = yArr.length;
|
||||||
|
const C = new Float64Array(ny * nx);
|
||||||
|
|
||||||
|
if (t <= 0) return C;
|
||||||
|
|
||||||
|
const u = Math.max(meteo.windSpeed, 0.5);
|
||||||
|
const [cosT, sinT] = windRotation(meteo.windDirDeg);
|
||||||
|
|
||||||
|
// 퍼프 중심 이동
|
||||||
|
const xc = source.x0 + u * t * cosT;
|
||||||
|
const yc = source.y0 + u * t * sinT;
|
||||||
|
|
||||||
|
// 이동거리 기준으로 σ 계산
|
||||||
|
const travelDist = u * t;
|
||||||
|
const sy = sigmaY(travelDist, meteo.stability, algorithm);
|
||||||
|
let sz = sigmaZ(travelDist, meteo.stability, algorithm);
|
||||||
|
const sx = sy; // 풍하 방향 확산 ≈ 횡풍 방향
|
||||||
|
sz = Math.min(sz, meteo.mixingHeight);
|
||||||
|
|
||||||
|
const H = source.z0;
|
||||||
|
const z = sim.zRef;
|
||||||
|
const norm = Math.pow(2 * Math.PI, 1.5) * sx * sy * sz;
|
||||||
|
|
||||||
|
for (let j = 0; j < ny; j++) {
|
||||||
|
for (let i = 0; i < nx; i++) {
|
||||||
|
const dx = xArr[i] - xc;
|
||||||
|
const dy = yArr[j] - yc;
|
||||||
|
|
||||||
|
const termX = Math.exp(-0.5 * (dx / sx) ** 2);
|
||||||
|
const termY = Math.exp(-0.5 * (dy / sy) ** 2);
|
||||||
|
const termZ = Math.exp(-0.5 * ((z - H) / sz) ** 2) +
|
||||||
|
Math.exp(-0.5 * ((z + H) / sz) ** 2);
|
||||||
|
|
||||||
|
C[j * nx + i] = (source.QTotal / norm) * termX * termY * termZ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return C;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
// Model 3: Dense Gas (Britter-McQuaid)
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
function denseGasBritterMcquaid(
|
||||||
|
xArr: Float64Array, yArr: Float64Array,
|
||||||
|
meteo: MeteoParams, source: SourceParams, _sim: SimParams,
|
||||||
|
t: number,
|
||||||
|
): Float64Array {
|
||||||
|
const nx = xArr.length;
|
||||||
|
const ny = yArr.length;
|
||||||
|
const C = new Float64Array(ny * nx);
|
||||||
|
|
||||||
|
const rhoAir = meteo.pressure * 0.02897 / (8.314 * meteo.temperature);
|
||||||
|
const g = 9.81;
|
||||||
|
const u = Math.max(meteo.windSpeed, 0.5);
|
||||||
|
const rhoG = source.densityGas;
|
||||||
|
const C0Vol = rhoG / (rhoG + rhoAir);
|
||||||
|
const gPrime0 = g * (rhoG - rhoAir) / rhoAir;
|
||||||
|
|
||||||
|
const [cosT, sinT] = windRotation(meteo.windDirDeg);
|
||||||
|
|
||||||
|
for (let j = 0; j < ny; j++) {
|
||||||
|
for (let i = 0; i < nx; i++) {
|
||||||
|
const [xw, yw] = rotateToWind(xArr[i], yArr[j], source.x0, source.y0, cosT, sinT);
|
||||||
|
|
||||||
|
if (xw <= 0) continue;
|
||||||
|
|
||||||
|
let cVol: number;
|
||||||
|
|
||||||
|
if (source.releaseDuration === 0) {
|
||||||
|
// 순간 누출 (Puff)
|
||||||
|
const xCenter = u * t;
|
||||||
|
const V0 = Math.PI * source.poolRadius ** 2 * 1.0;
|
||||||
|
const r0 = source.poolRadius;
|
||||||
|
const rT = r0 + 1.1 * Math.pow(gPrime0 * V0, 0.25) * Math.pow(Math.max(t, 0.1), 0.5);
|
||||||
|
const sigmaCloud = rT / 2.15;
|
||||||
|
const distXr = xw - xCenter;
|
||||||
|
|
||||||
|
cVol = C0Vol * Math.exp(-0.5 * (distXr / sigmaCloud) ** 2)
|
||||||
|
* Math.exp(-0.5 * (yw / sigmaCloud) ** 2);
|
||||||
|
} else {
|
||||||
|
// 연속 누출 (Plume)
|
||||||
|
const qv = source.Q / (rhoG * 1000);
|
||||||
|
const gPrimeLine = gPrime0 * qv / u;
|
||||||
|
const b0 = source.poolRadius;
|
||||||
|
const bX = Math.max(
|
||||||
|
b0 + 2.5 * Math.pow(gPrimeLine / (u ** 2), 0.333) * Math.pow(xw, 0.6),
|
||||||
|
b0,
|
||||||
|
);
|
||||||
|
|
||||||
|
cVol = C0Vol * (b0 / bX) * Math.exp(-0.5 * (yw / (bX / 2)) ** 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부피분율 → g/m³
|
||||||
|
const MW = source.molecularWeight;
|
||||||
|
C[j * nx + i] = cVol * (MW * meteo.pressure) / (8.314 * meteo.temperature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return C;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
// 통합 계산 함수
|
||||||
|
// ──────────────────────────────────────
|
||||||
|
export function computeDispersion(params: ComputeDispersionParams): DispersionGridResult {
|
||||||
|
const { meteo, source, sim, modelType, originLon, originLat, substanceName, t, algorithm } = params;
|
||||||
|
|
||||||
|
// 격자 생성
|
||||||
|
const xArr = new Float64Array(sim.nx);
|
||||||
|
const yArr = new Float64Array(sim.ny);
|
||||||
|
for (let i = 0; i < sim.nx; i++) {
|
||||||
|
xArr[i] = sim.xRange[0] + (sim.xRange[1] - sim.xRange[0]) * i / (sim.nx - 1);
|
||||||
|
}
|
||||||
|
for (let j = 0; j < sim.ny; j++) {
|
||||||
|
yArr[j] = sim.yRange[0] + (sim.yRange[1] - sim.yRange[0]) * j / (sim.ny - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모델별 농도 계산 (g/m³)
|
||||||
|
let cGm3: Float64Array;
|
||||||
|
switch (modelType) {
|
||||||
|
case 'plume':
|
||||||
|
cGm3 = gaussianPlume(xArr, yArr, meteo, source, sim, algorithm);
|
||||||
|
break;
|
||||||
|
case 'puff':
|
||||||
|
cGm3 = gaussianPuff(xArr, yArr, meteo, source, sim, t, algorithm);
|
||||||
|
break;
|
||||||
|
case 'dense_gas':
|
||||||
|
cGm3 = denseGasBritterMcquaid(xArr, yArr, meteo, source, sim, t);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 독성 데이터
|
||||||
|
const tox = getSubstanceToxicity(substanceName);
|
||||||
|
|
||||||
|
// g/m³ → ppm 변환 + 지도 좌표 변환
|
||||||
|
const points: DispersionPoint[] = [];
|
||||||
|
let maxConcentration = 0;
|
||||||
|
|
||||||
|
// AEGL 거리/면적 계산용
|
||||||
|
const cellAreaM2 = ((sim.xRange[1] - sim.xRange[0]) / sim.nx) *
|
||||||
|
((sim.yRange[1] - sim.yRange[0]) / sim.ny);
|
||||||
|
let aegl1Cells = 0, aegl2Cells = 0, aegl3Cells = 0;
|
||||||
|
let aegl1MaxDist = 0, aegl2MaxDist = 0, aegl3MaxDist = 0;
|
||||||
|
|
||||||
|
const [cosT, sinT] = windRotation(meteo.windDirDeg);
|
||||||
|
|
||||||
|
for (let j = 0; j < sim.ny; j++) {
|
||||||
|
for (let i = 0; i < sim.nx; i++) {
|
||||||
|
const idx = j * sim.nx + i;
|
||||||
|
const ppm = gm3ToPpm(cGm3[idx], tox.mw, meteo.temperature, meteo.pressure);
|
||||||
|
|
||||||
|
if (ppm < 0.01) continue;
|
||||||
|
|
||||||
|
if (ppm > maxConcentration) maxConcentration = ppm;
|
||||||
|
|
||||||
|
// 미터 좌표 → 경위도
|
||||||
|
const [lon, lat] = metersToLonLat(xArr[i], yArr[j], originLon, originLat);
|
||||||
|
points.push({ lon, lat, concentration: ppm });
|
||||||
|
|
||||||
|
// 풍하 거리 계산
|
||||||
|
const [xw] = rotateToWind(xArr[i], yArr[j], source.x0, source.y0, cosT, sinT);
|
||||||
|
const dist = Math.max(xw, 0);
|
||||||
|
|
||||||
|
// AEGL 거리/면적 집계
|
||||||
|
if (ppm >= tox.aegl1) {
|
||||||
|
aegl1Cells++;
|
||||||
|
if (dist > aegl1MaxDist) aegl1MaxDist = dist;
|
||||||
|
}
|
||||||
|
if (ppm >= tox.aegl2) {
|
||||||
|
aegl2Cells++;
|
||||||
|
if (dist > aegl2MaxDist) aegl2MaxDist = dist;
|
||||||
|
}
|
||||||
|
if (ppm >= tox.aegl3) {
|
||||||
|
aegl3Cells++;
|
||||||
|
if (dist > aegl3MaxDist) aegl3MaxDist = dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const aeglDistances: AeglDistances = {
|
||||||
|
aegl1: Math.round(aegl1MaxDist),
|
||||||
|
aegl2: Math.round(aegl2MaxDist),
|
||||||
|
aegl3: Math.round(aegl3MaxDist),
|
||||||
|
};
|
||||||
|
|
||||||
|
const aeglAreas: AeglAreas = {
|
||||||
|
aegl1: parseFloat((aegl1Cells * cellAreaM2 / 1e6).toFixed(2)),
|
||||||
|
aegl2: parseFloat((aegl2Cells * cellAreaM2 / 1e6).toFixed(2)),
|
||||||
|
aegl3: parseFloat((aegl3Cells * cellAreaM2 / 1e6).toFixed(2)),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
points,
|
||||||
|
maxConcentration: parseFloat(maxConcentration.toFixed(2)),
|
||||||
|
aeglDistances,
|
||||||
|
aeglAreas,
|
||||||
|
modelType,
|
||||||
|
timeStep: t,
|
||||||
|
substance: substanceName,
|
||||||
|
};
|
||||||
|
}
|
||||||
120
frontend/src/tabs/hns/utils/dispersionTypes.ts
Normal file
120
frontend/src/tabs/hns/utils/dispersionTypes.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
/** Pasquill-Gifford 대기안정도 등급 */
|
||||||
|
export type StabilityClass = 'A' | 'B' | 'C' | 'D' | 'E' | 'F';
|
||||||
|
|
||||||
|
/** 확산 모델 타입 */
|
||||||
|
export type DispersionModel = 'plume' | 'puff' | 'dense_gas';
|
||||||
|
|
||||||
|
/** 예측 알고리즘 */
|
||||||
|
export type AlgorithmType = 'ALOHA (EPA)' | 'CAMEO' | 'Gaussian Plume' | 'AERMOD';
|
||||||
|
|
||||||
|
/** 유출 형태 (UI 선택값) */
|
||||||
|
export type ReleaseType = '연속 유출' | '순간 유출' | '풀(Pool) 증발';
|
||||||
|
|
||||||
|
/** 기상 조건 */
|
||||||
|
export interface MeteoParams {
|
||||||
|
windSpeed: number; // 풍속 (m/s)
|
||||||
|
windDirDeg: number; // 풍향 (기상 기준, 0=북, 90=동)
|
||||||
|
stability: StabilityClass;
|
||||||
|
temperature: number; // 기온 (K)
|
||||||
|
pressure: number; // 기압 (Pa)
|
||||||
|
mixingHeight: number; // 혼합층 높이 (m)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 누출원 정보 */
|
||||||
|
export interface SourceParams {
|
||||||
|
Q: number; // 배출률 (g/s) — Plume용
|
||||||
|
QTotal: number; // 총 누출량 (g) — Puff용
|
||||||
|
x0: number; // 누출 위치 X (m)
|
||||||
|
y0: number; // 누출 위치 Y (m)
|
||||||
|
z0: number; // 누출 높이 (m)
|
||||||
|
releaseDuration: number; // 누출 지속시간 (s), 0=순간
|
||||||
|
molecularWeight: number; // 분자량 (g/mol)
|
||||||
|
vaporPressure: number; // 증기압 (mmHg)
|
||||||
|
densityGas: number; // 가스 밀도 (kg/m³)
|
||||||
|
poolRadius: number; // 액체풀 반경 (m)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 시뮬레이션 격자 및 시간 설정 */
|
||||||
|
export interface SimParams {
|
||||||
|
xRange: [number, number]; // X 범위 (m)
|
||||||
|
yRange: [number, number]; // Y 범위 (m)
|
||||||
|
nx: number; // X 격자 수
|
||||||
|
ny: number; // Y 격자 수
|
||||||
|
zRef: number; // 농도 계산 기준 높이 (m, 호흡선)
|
||||||
|
tStart: number; // 시작 시간 (s)
|
||||||
|
tEnd: number; // 종료 시간 (s)
|
||||||
|
dt: number; // 시간 간격 (s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 지도 위 농도 점 (HeatmapLayer 입력) */
|
||||||
|
export interface DispersionPoint {
|
||||||
|
lon: number;
|
||||||
|
lat: number;
|
||||||
|
concentration: number; // ppm
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AEGL 거리 결과 */
|
||||||
|
export interface AeglDistances {
|
||||||
|
aegl1: number; // m (풍하 최대 도달 거리)
|
||||||
|
aegl2: number;
|
||||||
|
aegl3: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AEGL 면적 결과 */
|
||||||
|
export interface AeglAreas {
|
||||||
|
aegl1: number; // km²
|
||||||
|
aegl2: number;
|
||||||
|
aegl3: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 확산 계산 전체 결과 */
|
||||||
|
export interface DispersionGridResult {
|
||||||
|
points: DispersionPoint[];
|
||||||
|
maxConcentration: number; // ppm
|
||||||
|
aeglDistances: AeglDistances;
|
||||||
|
aeglAreas: AeglAreas;
|
||||||
|
modelType: DispersionModel;
|
||||||
|
timeStep: number; // 현재 시간 (s)
|
||||||
|
substance: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** computeDispersion 입력 파라미터 */
|
||||||
|
export interface ComputeDispersionParams {
|
||||||
|
meteo: MeteoParams;
|
||||||
|
source: SourceParams;
|
||||||
|
sim: SimParams;
|
||||||
|
modelType: DispersionModel;
|
||||||
|
originLon: number; // 누출원 경도
|
||||||
|
originLat: number; // 누출원 위도
|
||||||
|
substanceName: string;
|
||||||
|
t: number; // 계산 시점 (s)
|
||||||
|
algorithm?: AlgorithmType; // 예측 알고리즘 (sigma 파라미터 결정)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 물질 독성 정보 */
|
||||||
|
export interface SubstanceToxicity {
|
||||||
|
nameKr: string;
|
||||||
|
nameEn: string;
|
||||||
|
aegl1: number; // ppm (1시간)
|
||||||
|
aegl2: number;
|
||||||
|
aegl3: number;
|
||||||
|
idlh: number;
|
||||||
|
mw: number; // g/mol
|
||||||
|
densityGas: number; // kg/m³
|
||||||
|
vaporPressure: number; // mmHg
|
||||||
|
poolRadius: number; // 기본 풀 반경 (m)
|
||||||
|
Q: number; // 기본 배출률 (g/s)
|
||||||
|
QTotal: number; // 기본 총 누출량 (g)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기상 자동조회 결과 */
|
||||||
|
export interface WeatherFetchResult {
|
||||||
|
windSpeed: number; // m/s
|
||||||
|
windDirection: number; // degrees
|
||||||
|
temperature: number; // °C
|
||||||
|
humidity: number; // %
|
||||||
|
stability: StabilityClass;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
lastUpdate: Date | null;
|
||||||
|
}
|
||||||
99
frontend/src/tabs/hns/utils/toxicityData.ts
Normal file
99
frontend/src/tabs/hns/utils/toxicityData.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import type { SubstanceToxicity } from './dispersionTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주요 HNS 물질 독성 및 물리 데이터
|
||||||
|
* 출처: EPA AEGL, NIOSH IDLH, ALOHA Chemical Library
|
||||||
|
*/
|
||||||
|
const SUBSTANCE_DATA: Record<string, SubstanceToxicity> = {
|
||||||
|
'톨루엔 (Toluene)': {
|
||||||
|
nameKr: '톨루엔', nameEn: 'Toluene',
|
||||||
|
aegl1: 37, aegl2: 150, aegl3: 500, idlh: 500, mw: 92.14,
|
||||||
|
densityGas: 3.18, vaporPressure: 28.4, poolRadius: 5.0,
|
||||||
|
Q: 15.0, QTotal: 8000.0,
|
||||||
|
},
|
||||||
|
'벤젠 (Benzene)': {
|
||||||
|
nameKr: '벤젠', nameEn: 'Benzene',
|
||||||
|
aegl1: 52, aegl2: 800, aegl3: 4000, idlh: 500, mw: 78.11,
|
||||||
|
densityGas: 2.77, vaporPressure: 95.2, poolRadius: 5.0,
|
||||||
|
Q: 20.0, QTotal: 10000.0,
|
||||||
|
},
|
||||||
|
'자일렌 (Xylene)': {
|
||||||
|
nameKr: '자일렌', nameEn: 'Xylene',
|
||||||
|
aegl1: 130, aegl2: 920, aegl3: 2500, idlh: 900, mw: 106.16,
|
||||||
|
densityGas: 3.66, vaporPressure: 8.8, poolRadius: 5.0,
|
||||||
|
Q: 10.0, QTotal: 6000.0,
|
||||||
|
},
|
||||||
|
'스티렌 (Styrene)': {
|
||||||
|
nameKr: '스티렌', nameEn: 'Styrene',
|
||||||
|
aegl1: 20, aegl2: 130, aegl3: 1100, idlh: 700, mw: 104.15,
|
||||||
|
densityGas: 3.60, vaporPressure: 6.4, poolRadius: 5.0,
|
||||||
|
Q: 8.0, QTotal: 5000.0,
|
||||||
|
},
|
||||||
|
'메탄올 (Methanol)': {
|
||||||
|
nameKr: '메탄올', nameEn: 'Methanol',
|
||||||
|
aegl1: 530, aegl2: 2100, aegl3: 14000, idlh: 6000, mw: 32.04,
|
||||||
|
densityGas: 1.11, vaporPressure: 128.0, poolRadius: 5.0,
|
||||||
|
Q: 25.0, QTotal: 15000.0,
|
||||||
|
},
|
||||||
|
'아세톤 (Acetone)': {
|
||||||
|
nameKr: '아세톤', nameEn: 'Acetone',
|
||||||
|
aegl1: 200, aegl2: 3200, aegl3: 12000, idlh: 2500, mw: 58.08,
|
||||||
|
densityGas: 2.00, vaporPressure: 231.0, poolRadius: 5.0,
|
||||||
|
Q: 30.0, QTotal: 20000.0,
|
||||||
|
},
|
||||||
|
'염소 (Chlorine)': {
|
||||||
|
nameKr: '염소', nameEn: 'Chlorine',
|
||||||
|
aegl1: 0.5, aegl2: 2.8, aegl3: 50, idlh: 10, mw: 70.91,
|
||||||
|
densityGas: 3.17, vaporPressure: 5168.0, poolRadius: 3.0,
|
||||||
|
Q: 20.0, QTotal: 10000.0,
|
||||||
|
},
|
||||||
|
'암모니아 (Ammonia)': {
|
||||||
|
nameKr: '암모니아', nameEn: 'Ammonia',
|
||||||
|
aegl1: 30, aegl2: 160, aegl3: 1100, idlh: 300, mw: 17.03,
|
||||||
|
densityGas: 0.73, vaporPressure: 7510.0, poolRadius: 3.0,
|
||||||
|
Q: 25.0, QTotal: 12000.0,
|
||||||
|
},
|
||||||
|
'염화수소 (HCl)': {
|
||||||
|
nameKr: '염화수소', nameEn: 'HCl',
|
||||||
|
aegl1: 1.8, aegl2: 22, aegl3: 100, idlh: 50, mw: 36.46,
|
||||||
|
densityGas: 1.49, vaporPressure: 42080.0, poolRadius: 3.0,
|
||||||
|
Q: 15.0, QTotal: 8000.0,
|
||||||
|
},
|
||||||
|
'황화수소 (H2S)': {
|
||||||
|
nameKr: '황화수소', nameEn: 'H2S',
|
||||||
|
aegl1: 0.51, aegl2: 17, aegl3: 50, idlh: 50, mw: 34.08,
|
||||||
|
densityGas: 1.36, vaporPressure: 15600.0, poolRadius: 3.0,
|
||||||
|
Q: 10.0, QTotal: 5000.0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 물질명으로 독성 데이터 조회 (한/영 퍼지 매칭)
|
||||||
|
*/
|
||||||
|
export function getSubstanceToxicity(name: string): SubstanceToxicity {
|
||||||
|
// 정확한 키 매칭
|
||||||
|
if (SUBSTANCE_DATA[name]) return SUBSTANCE_DATA[name];
|
||||||
|
|
||||||
|
// 부분 문자열 매칭 (한글명 또는 영문명)
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
for (const [, tox] of Object.entries(SUBSTANCE_DATA)) {
|
||||||
|
if (
|
||||||
|
tox.nameKr === name ||
|
||||||
|
tox.nameEn.toLowerCase() === lower ||
|
||||||
|
lower.includes(tox.nameEn.toLowerCase()) ||
|
||||||
|
name.includes(tox.nameKr)
|
||||||
|
) {
|
||||||
|
return tox;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본값: 톨루엔
|
||||||
|
return SUBSTANCE_DATA['톨루엔 (Toluene)'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 등록된 물질 목록 반환
|
||||||
|
*/
|
||||||
|
export function getSubstanceList(): string[] {
|
||||||
|
return Object.keys(SUBSTANCE_DATA);
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import {
|
import {
|
||||||
createEmptyReport,
|
createEmptyReport,
|
||||||
} from './OilSpillReportTemplate';
|
} from './OilSpillReportTemplate';
|
||||||
import { consumeReportGenCategory } from '@common/hooks/useSubMenu';
|
import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload } from '@common/hooks/useSubMenu';
|
||||||
import { saveReport } from '../services/reportsApi';
|
import { saveReport } from '../services/reportsApi';
|
||||||
import {
|
import {
|
||||||
CATEGORIES,
|
CATEGORIES,
|
||||||
@ -30,6 +30,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
2: CATEGORIES[2].sections.map(s => ({ ...s })),
|
2: CATEGORIES[2].sections.map(s => ({ ...s })),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// HNS 실 데이터 (없으면 sampleHnsData fallback)
|
||||||
|
const [hnsPayload, setHnsPayload] = useState<HnsReportPayload | null>(null)
|
||||||
|
|
||||||
// 외부에서 카테고리 힌트가 변경되면 반영
|
// 외부에서 카테고리 힌트가 변경되면 반영
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hint = consumeReportGenCategory()
|
const hint = consumeReportGenCategory()
|
||||||
@ -38,6 +41,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
setActiveCat(hint)
|
setActiveCat(hint)
|
||||||
setSelectedTemplate(0)
|
setSelectedTemplate(0)
|
||||||
}
|
}
|
||||||
|
// HNS 데이터 소비
|
||||||
|
const payload = consumeHnsReportPayload()
|
||||||
|
if (payload) setHnsPayload(payload)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const cat = CATEGORIES[activeCat]
|
const cat = CATEGORIES[activeCat]
|
||||||
@ -72,10 +78,29 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
|
const secColor = cat.color === 'var(--cyan)' ? '#06b6d4' : cat.color === 'var(--orange)' ? '#f97316' : '#ef4444';
|
||||||
const sectionHTML = activeSections.map(sec => {
|
const sectionHTML = activeSections.map(sec => {
|
||||||
return `<h3 style="color:${cat.color === 'var(--cyan)' ? '#06b6d4' : cat.color === 'var(--orange)' ? '#f97316' : '#ef4444'};font-size:14px;margin:20px 0 8px;">${sec.icon} ${sec.title}</h3><p style="font-size:12px;color:#666;">${sec.desc}</p>`
|
let content = `<p style="font-size:12px;color:#666;">${sec.desc}</p>`;
|
||||||
|
|
||||||
|
// HNS 섹션에 실 데이터 삽입
|
||||||
|
if (activeCat === 1 && hnsPayload) {
|
||||||
|
if (sec.id === 'hns-atm') {
|
||||||
|
const mapImg = hnsPayload.mapImageDataUrl
|
||||||
|
? `<img src="${hnsPayload.mapImageDataUrl}" style="width:100%;max-height:300px;object-fit:contain;border:1px solid #ddd;border-radius:8px;margin-bottom:12px;" />`
|
||||||
|
: '<div style="height:100px;background:#f5f5f5;border:1px solid #ddd;border-radius:8px;display:flex;align-items:center;justify-content:center;color:#999;margin-bottom:12px;">[대기확산 예측 지도]</div>';
|
||||||
|
content = `${mapImg}<table style="width:100%;border-collapse:collapse;font-size:12px;"><tr><td style="padding:8px;border:1px solid #ddd;text-align:center;"><b>${hnsPayload.atm.model}</b><br/>${hnsPayload.atm.maxDistance}</td><td style="padding:8px;border:1px solid #ddd;text-align:center;"><b>최대 농도</b><br/>${hnsPayload.maxConcentration}</td><td style="padding:8px;border:1px solid #ddd;text-align:center;"><b>AEGL-1 면적</b><br/>${hnsPayload.aeglAreas.aegl1}</td></tr></table>`;
|
||||||
|
} else if (sec.id === 'hns-hazard') {
|
||||||
|
content = `<table style="width:100%;border-collapse:collapse;font-size:12px;"><tr><td style="padding:8px;border:1px solid #ddd;text-align:center;color:#ef4444;"><b>AEGL-3</b><br/>${hnsPayload.hazard.aegl3} (${hnsPayload.aeglAreas.aegl3})</td><td style="padding:8px;border:1px solid #ddd;text-align:center;color:#f97316;"><b>AEGL-2</b><br/>${hnsPayload.hazard.aegl2} (${hnsPayload.aeglAreas.aegl2})</td><td style="padding:8px;border:1px solid #ddd;text-align:center;color:#eab308;"><b>AEGL-1</b><br/>${hnsPayload.hazard.aegl1} (${hnsPayload.aeglAreas.aegl1})</td></tr></table>`;
|
||||||
|
} else if (sec.id === 'hns-substance') {
|
||||||
|
content = `<table style="width:100%;border-collapse:collapse;font-size:12px;"><tr><td style="padding:6px 8px;border:1px solid #ddd;color:#888;">물질명</td><td style="padding:6px 8px;border:1px solid #ddd;font-weight:bold;">${hnsPayload.substance.name}</td></tr><tr><td style="padding:6px 8px;border:1px solid #ddd;color:#888;">독성기준</td><td style="padding:6px 8px;border:1px solid #ddd;color:#ef4444;font-weight:bold;">${hnsPayload.substance.toxicity}</td></tr></table>`;
|
||||||
|
} else if (sec.id === 'hns-weather') {
|
||||||
|
content = `<table style="width:100%;border-collapse:collapse;font-size:12px;"><tr><td style="padding:8px;border:1px solid #ddd;text-align:center;"><b>풍향</b><br/>${hnsPayload.weather.windDir}</td><td style="padding:8px;border:1px solid #ddd;text-align:center;"><b>풍속</b><br/>${hnsPayload.weather.windSpeed}</td><td style="padding:8px;border:1px solid #ddd;text-align:center;"><b>안정도</b><br/>${hnsPayload.weather.stability}</td><td style="padding:8px;border:1px solid #ddd;text-align:center;"><b>기온</b><br/>${hnsPayload.weather.temperature}</td></tr></table>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<h3 style="color:${secColor};font-size:14px;margin:20px 0 8px;">${sec.icon} ${sec.title}</h3>${content}`;
|
||||||
}).join('')
|
}).join('')
|
||||||
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${cat.reportName}</title><style>@page{size:A4;margin:20mm}body{font-family:'맑은 고딕',sans-serif;color:#1a1a1a;max-width:800px;margin:0 auto;padding:40px}</style></head><body><div style="text-align:center;margin-bottom:30px"><h1 style="font-size:20px;margin:0">해양환경 위기대응 통합지원시스템</h1><h2 style="font-size:16px;color:#0891b2;margin:8px 0">${cat.reportName}</h2></div>${sectionHTML}</body></html>`
|
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${cat.reportName}</title><style>@page{size:A4;margin:20mm}body{font-family:'맑은 고딕',sans-serif;color:#1a1a1a;max-width:800px;margin:0 auto;padding:40px}table{margin:8px 0}</style></head><body><div style="text-align:center;margin-bottom:30px"><h1 style="font-size:20px;margin:0">해양환경 위기대응 통합지원시스템</h1><h2 style="font-size:16px;color:#0891b2;margin:8px 0">${cat.reportName}</h2></div>${sectionHTML}</body></html>`
|
||||||
exportAsPDF(html, cat.reportName)
|
exportAsPDF(html, cat.reportName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -303,17 +328,28 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
{/* ── HNS 대기확산 섹션들 ── */}
|
{/* ── HNS 대기확산 섹션들 ── */}
|
||||||
{sec.id === 'hns-atm' && (
|
{sec.id === 'hns-atm' && (
|
||||||
<>
|
<>
|
||||||
|
{hnsPayload?.mapImageDataUrl ? (
|
||||||
|
<img
|
||||||
|
src={hnsPayload.mapImageDataUrl}
|
||||||
|
alt="대기확산 예측 지도"
|
||||||
|
className="w-full h-auto rounded-lg border border-border mb-4"
|
||||||
|
style={{ maxHeight: '300px', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div className="w-full h-[140px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean mb-4">
|
<div className="w-full h-[140px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean mb-4">
|
||||||
[대기확산 예측 지도]
|
[대기확산 예측 지도]
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
)}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{[
|
{[
|
||||||
{ label: 'ALOHA', value: sampleHnsData.atm.aloha, color: '#f97316' },
|
{ label: hnsPayload?.atm.model || 'ALOHA', value: hnsPayload?.atm.maxDistance || sampleHnsData.atm.aloha, color: '#f97316', desc: '최대 확산거리' },
|
||||||
{ label: 'WRF-Chem', value: sampleHnsData.atm.wrfChem, color: '#22c55e' },
|
{ label: '최대 농도', value: hnsPayload?.maxConcentration || '—', color: '#ef4444', desc: '지상 1.5m 기준' },
|
||||||
|
{ label: 'AEGL-1 면적', value: hnsPayload?.aeglAreas.aegl1 || '—', color: '#06b6d4', desc: '확산 영향 면적' },
|
||||||
].map((m, i) => (
|
].map((m, i) => (
|
||||||
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
|
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
|
||||||
<p className="text-[10px] text-text-3 font-korean mb-1">{m.label} 최대 확산거리</p>
|
<p className="text-[10px] text-text-3 font-korean mb-1">{m.label}</p>
|
||||||
<p className="text-[20px] font-bold font-mono" style={{ color: m.color }}>{m.value}</p>
|
<p className="text-[20px] font-bold font-mono" style={{ color: m.color }}>{m.value}</p>
|
||||||
|
<p className="text-[8px] text-text-3 font-korean mt-1">{m.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -322,13 +358,14 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
{sec.id === 'hns-hazard' && (
|
{sec.id === 'hns-hazard' && (
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{[
|
{[
|
||||||
{ label: 'ERPG-2 구역', value: sampleHnsData.hazard.erpg2, color: '#f97316', desc: '건강 영향' },
|
{ label: 'AEGL-3 구역', value: hnsPayload?.hazard.aegl3 || sampleHnsData.hazard.erpg3, area: hnsPayload?.aeglAreas.aegl3, color: '#ef4444', desc: '생명 위협' },
|
||||||
{ label: 'ERPG-3 구역', value: sampleHnsData.hazard.erpg3, color: '#ef4444', desc: '생명 위협' },
|
{ label: 'AEGL-2 구역', value: hnsPayload?.hazard.aegl2 || sampleHnsData.hazard.erpg2, area: hnsPayload?.aeglAreas.aegl2, color: '#f97316', desc: '건강 피해' },
|
||||||
{ label: '대피 권고 범위', value: sampleHnsData.hazard.evacuation, color: '#a855f7', desc: '안전거리' },
|
{ label: 'AEGL-1 구역', value: hnsPayload?.hazard.aegl1 || sampleHnsData.hazard.evacuation, area: hnsPayload?.aeglAreas.aegl1, color: '#eab308', desc: '불쾌감' },
|
||||||
].map((h, i) => (
|
].map((h, i) => (
|
||||||
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
|
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
|
||||||
<p className="text-[9px] font-bold font-korean mb-1" style={{ color: h.color }}>{h.label}</p>
|
<p className="text-[9px] font-bold font-korean mb-1" style={{ color: h.color }}>{h.label}</p>
|
||||||
<p className="text-[18px] font-bold font-mono" style={{ color: h.color }}>{h.value}</p>
|
<p className="text-[18px] font-bold font-mono" style={{ color: h.color }}>{h.value}</p>
|
||||||
|
{h.area && <p className="text-[10px] text-text-3 font-mono mt-0.5">{h.area}</p>}
|
||||||
<p className="text-[8px] text-text-3 font-korean mt-1">{h.desc}</p>
|
<p className="text-[8px] text-text-3 font-korean mt-1">{h.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -337,10 +374,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
{sec.id === 'hns-substance' && (
|
{sec.id === 'hns-substance' && (
|
||||||
<div className="grid grid-cols-2 gap-2 text-[11px]">
|
<div className="grid grid-cols-2 gap-2 text-[11px]">
|
||||||
{[
|
{[
|
||||||
{ k: '물질명', v: sampleHnsData.substance.name },
|
{ k: '물질명', v: hnsPayload?.substance.name || sampleHnsData.substance.name },
|
||||||
{ k: 'UN번호', v: sampleHnsData.substance.un },
|
{ k: 'UN번호', v: hnsPayload?.substance.un || sampleHnsData.substance.un },
|
||||||
{ k: 'CAS번호', v: sampleHnsData.substance.cas },
|
{ k: 'CAS번호', v: hnsPayload?.substance.cas || sampleHnsData.substance.cas },
|
||||||
{ k: '위험등급', v: sampleHnsData.substance.class },
|
{ k: '위험등급', v: hnsPayload?.substance.class || sampleHnsData.substance.class },
|
||||||
].map((r, i) => (
|
].map((r, i) => (
|
||||||
<div key={i} className="flex justify-between px-3 py-2 bg-bg-1 rounded border border-border">
|
<div key={i} className="flex justify-between px-3 py-2 bg-bg-1 rounded border border-border">
|
||||||
<span className="text-text-3 font-korean">{r.k}</span>
|
<span className="text-text-3 font-korean">{r.k}</span>
|
||||||
@ -349,7 +386,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
))}
|
))}
|
||||||
<div className="col-span-2 flex justify-between px-3 py-2 bg-bg-1 rounded border border-[rgba(239,68,68,0.3)]">
|
<div className="col-span-2 flex justify-between px-3 py-2 bg-bg-1 rounded border border-[rgba(239,68,68,0.3)]">
|
||||||
<span className="text-text-3 font-korean">독성기준</span>
|
<span className="text-text-3 font-korean">독성기준</span>
|
||||||
<span className="text-[var(--red)] font-semibold font-mono text-[10px]">{sampleHnsData.substance.toxicity}</span>
|
<span className="text-[var(--red)] font-semibold font-mono text-[10px]">{hnsPayload?.substance.toxicity || sampleHnsData.substance.toxicity}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -385,10 +422,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
{sec.id === 'hns-weather' && (
|
{sec.id === 'hns-weather' && (
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
{[
|
{[
|
||||||
{ label: '풍향', value: 'NE 42°', icon: '🌬' },
|
{ label: '풍향', value: hnsPayload?.weather.windDir || 'NE 42°', icon: '🌬' },
|
||||||
{ label: '풍속', value: '5.2 m/s', icon: '💨' },
|
{ label: '풍속', value: hnsPayload?.weather.windSpeed || '5.2 m/s', icon: '💨' },
|
||||||
{ label: '대기안정도', value: 'D (중립)', icon: '🌡' },
|
{ label: '대기안정도', value: hnsPayload?.weather.stability || 'D (중립)', icon: '🌡' },
|
||||||
{ label: '기온', value: '8.5°C', icon: '☀️' },
|
{ label: '기온', value: hnsPayload?.weather.temperature || '8.5°C', icon: '☀️' },
|
||||||
].map((w, i) => (
|
].map((w, i) => (
|
||||||
<div key={i} className="bg-bg-1 border border-border rounded-lg p-3 text-center">
|
<div key={i} className="bg-bg-1 border border-border rounded-lg p-3 text-center">
|
||||||
<div className="text-[16px] mb-0.5">{w.icon}</div>
|
<div className="text-[16px] mb-0.5">{w.icon}</div>
|
||||||
|
|||||||
@ -5,6 +5,15 @@ import path from 'path'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
// HLS 스트림 프록시 등 상대 경로 API 요청을 백엔드로 전달
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@common': path.resolve(__dirname, 'src/common'),
|
'@common': path.resolve(__dirname, 'src/common'),
|
||||||
|
|||||||
565
hns_dispersion.py
Normal file
565
hns_dispersion.py
Normal file
@ -0,0 +1,565 @@
|
|||||||
|
"""
|
||||||
|
WING HNS 대기확산 모델 알고리즘
|
||||||
|
=====================================
|
||||||
|
지원 모델:
|
||||||
|
1. Gaussian Plume - 연속 누출 (Continuous Release)
|
||||||
|
2. Gaussian Puff - 순간 누출 (Instantaneous Release)
|
||||||
|
3. Dense Gas Model - 고밀도 가스 (ALOHA 방식, Britter-McQuaid)
|
||||||
|
|
||||||
|
출력:
|
||||||
|
- 시간별 농도장 2D 히트맵 애니메이션 (MP4 / GIF)
|
||||||
|
|
||||||
|
의존 라이브러리:
|
||||||
|
pip install numpy matplotlib scipy
|
||||||
|
|
||||||
|
작성: WING 프로젝트 / HNS 대응 모듈
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import matplotlib.animation as animation
|
||||||
|
from matplotlib.colors import LogNorm
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Literal, Optional
|
||||||
|
import warnings
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 1. 입력 파라미터 데이터 클래스
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MeteoParams:
|
||||||
|
"""기상 조건"""
|
||||||
|
wind_speed: float = 5.0 # 풍속 (m/s)
|
||||||
|
wind_dir_deg: float = 270.0 # 풍향 (기상 기준, 0=북, 90=동, ...)
|
||||||
|
stability: str = "D" # Pasquill-Gifford 안정도 (A~F)
|
||||||
|
temperature: float = 293.15 # 기온 (K)
|
||||||
|
pressure: float = 101325.0 # 기압 (Pa)
|
||||||
|
mixing_height: float = 800.0 # 혼합층 높이 (m)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SourceParams:
|
||||||
|
"""누출원 정보"""
|
||||||
|
Q: float = 10.0 # 배출률 (g/s) - Plume용
|
||||||
|
Q_total: float = 5000.0 # 총 누출량 (g) - Puff용
|
||||||
|
x0: float = 0.0 # 누출 위치 X (m)
|
||||||
|
y0: float = 0.0 # 누출 위치 Y (m)
|
||||||
|
z0: float = 0.5 # 누출 높이 (m) (해상 = 수면 근처)
|
||||||
|
release_duration: float = 0.0 # 누출 지속시간 (s), 0=순간
|
||||||
|
# Dense Gas 전용
|
||||||
|
molecular_weight: float = 71.9 # 분자량 (g/mol) - 기본: Chlorine
|
||||||
|
vapor_pressure: float = 670.0 # 증기압 (mmHg)
|
||||||
|
density_gas: float = 3.2 # 가스 밀도 (kg/m³) at 누출 조건
|
||||||
|
pool_radius: float = 5.0 # 액체풀 반경 (m)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SimParams:
|
||||||
|
"""시뮬레이션 격자 및 시간 설정"""
|
||||||
|
x_range: tuple = (-50, 3000) # X 범위 (m)
|
||||||
|
y_range: tuple = (-800, 800) # Y 범위 (m)
|
||||||
|
nx: int = 200 # X 격자 수
|
||||||
|
ny: int = 160 # Y 격자 수
|
||||||
|
z_ref: float = 1.5 # 농도 계산 기준 높이 (m, 호흡선)
|
||||||
|
t_start: float = 0.0 # 시작 시간 (s)
|
||||||
|
t_end: float = 600.0 # 종료 시간 (s)
|
||||||
|
dt: float = 30.0 # 시간 간격 (s)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 2. 확산계수 (Pasquill-Gifford σy, σz)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Briggs (1973) open-country 파라미터
|
||||||
|
_PG_PARAMS = {
|
||||||
|
# stability: (ay, by, az, bz) → σy = ay*x/(1+by*x)^0.5, σz = az*x/(1+bz*x)^0.5
|
||||||
|
"A": (0.22, 0.0001, 0.20, 0.000),
|
||||||
|
"B": (0.16, 0.0001, 0.12, 0.000),
|
||||||
|
"C": (0.11, 0.0001, 0.08, 0.0002),
|
||||||
|
"D": (0.08, 0.0001, 0.06, 0.0015),
|
||||||
|
"E": (0.06, 0.0001, 0.03, 0.0003),
|
||||||
|
"F": (0.04, 0.0001, 0.016, 0.0003),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sigma_y(x: np.ndarray, stability: str) -> np.ndarray:
|
||||||
|
"""수평 확산계수 σy (m)"""
|
||||||
|
ay, by, _, _ = _PG_PARAMS[stability]
|
||||||
|
x = np.maximum(x, 1.0)
|
||||||
|
return ay * x / np.sqrt(1.0 + by * x)
|
||||||
|
|
||||||
|
|
||||||
|
def sigma_z(x: np.ndarray, stability: str) -> np.ndarray:
|
||||||
|
"""수직 확산계수 σz (m)"""
|
||||||
|
_, _, az, bz = _PG_PARAMS[stability]
|
||||||
|
x = np.maximum(x, 1.0)
|
||||||
|
return az * x / np.sqrt(1.0 + bz * x)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 3. 바람 방향 회전 유틸리티
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def wind_rotation(wind_dir_deg: float):
|
||||||
|
"""
|
||||||
|
기상 풍향 → 수학 좌표 회전각 변환
|
||||||
|
Returns: (cos_theta, sin_theta)
|
||||||
|
기상 풍향 270° (서풍) → 바람이 동쪽(+x)으로 진행
|
||||||
|
"""
|
||||||
|
math_angle = np.radians(270.0 - wind_dir_deg)
|
||||||
|
return np.cos(math_angle), np.sin(math_angle)
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_to_wind(X, Y, x0, y0, cos_t, sin_t):
|
||||||
|
"""절대 좌표 → 바람 중심축 좌표 (x'=풍하, y'=횡풍)"""
|
||||||
|
dx = X - x0
|
||||||
|
dy = Y - y0
|
||||||
|
xw = dx * cos_t + dy * sin_t # 풍하거리
|
||||||
|
yw = -dx * sin_t + dy * cos_t # 횡풍거리
|
||||||
|
return xw, yw
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 4. Model 1: Gaussian Plume (연속 누출)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def gaussian_plume(
|
||||||
|
X: np.ndarray, Y: np.ndarray,
|
||||||
|
meteo: MeteoParams, src: SourceParams, sim: SimParams,
|
||||||
|
t: float = None # Plume은 정상 상태; t 인수는 인터페이스 통일용
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
정상 상태 가우시안 플룸 농도 (g/m³)
|
||||||
|
- 지면 반사 포함 (image method)
|
||||||
|
- 혼합층 상한 반사 포함 (선택적)
|
||||||
|
|
||||||
|
C(x,y,z) = Q / (2π σy σz u)
|
||||||
|
× exp(-y²/2σy²)
|
||||||
|
× [exp(-(z-H)²/2σz²) + exp(-(z+H)²/2σz²)]
|
||||||
|
"""
|
||||||
|
cos_t, sin_t = wind_rotation(meteo.wind_dir_deg)
|
||||||
|
xw, yw = rotate_to_wind(X, Y, src.x0, src.y0, cos_t, sin_t)
|
||||||
|
|
||||||
|
# 풍하 양수 구역만 계산
|
||||||
|
mask = xw > 0
|
||||||
|
C = np.zeros_like(X)
|
||||||
|
|
||||||
|
sy = sigma_y(xw[mask], meteo.stability)
|
||||||
|
sz = sigma_z(xw[mask], meteo.stability)
|
||||||
|
sz = np.minimum(sz, meteo.mixing_height) # 혼합층 상한 클리핑
|
||||||
|
|
||||||
|
u = max(meteo.wind_speed, 0.5)
|
||||||
|
H = src.z0
|
||||||
|
z = sim.z_ref
|
||||||
|
|
||||||
|
# 지면 반사항
|
||||||
|
term_y = np.exp(-0.5 * (yw[mask] / sy)**2)
|
||||||
|
term_z1 = np.exp(-0.5 * ((z - H) / sz)**2)
|
||||||
|
term_z2 = np.exp(-0.5 * ((z + H) / sz)**2)
|
||||||
|
|
||||||
|
C[mask] = (src.Q / (2 * np.pi * sy * sz * u)) * term_y * (term_z1 + term_z2)
|
||||||
|
return C
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 5. Model 2: Gaussian Puff (순간 누출)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def gaussian_puff(
|
||||||
|
X: np.ndarray, Y: np.ndarray,
|
||||||
|
meteo: MeteoParams, src: SourceParams, sim: SimParams,
|
||||||
|
t: float
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
이동하는 가우시안 퍼프 농도 (g/m³)
|
||||||
|
|
||||||
|
C(x,y,z,t) = Q_total / [(2π)^(3/2) σx σy σz]
|
||||||
|
× exp(-x_r²/2σx²)
|
||||||
|
× exp(-y_r²/2σy²)
|
||||||
|
× [exp(-(z-H)²/2σz²) + exp(-(z+H)²/2σz²)]
|
||||||
|
여기서 퍼프 중심 = (u·t·cos, u·t·sin)
|
||||||
|
"""
|
||||||
|
if t <= 0:
|
||||||
|
return np.zeros_like(X)
|
||||||
|
|
||||||
|
u = max(meteo.wind_speed, 0.5)
|
||||||
|
cos_t, sin_t = wind_rotation(meteo.wind_dir_deg)
|
||||||
|
|
||||||
|
# 퍼프 중심 이동
|
||||||
|
xc = src.x0 + u * t * cos_t
|
||||||
|
yc = src.y0 + u * t * sin_t
|
||||||
|
|
||||||
|
# 퍼프에서의 상대 거리
|
||||||
|
dx = X - xc
|
||||||
|
dy = Y - yc
|
||||||
|
|
||||||
|
# 이동거리 기준으로 σ 계산
|
||||||
|
travel_dist = u * t
|
||||||
|
sy = sigma_y(np.array([travel_dist]), meteo.stability)[0]
|
||||||
|
sz = sigma_z(np.array([travel_dist]), meteo.stability)[0]
|
||||||
|
sx = sy # 풍하 방향 확산 ≈ 횡풍 방향
|
||||||
|
|
||||||
|
sz = min(sz, meteo.mixing_height)
|
||||||
|
H = src.z0
|
||||||
|
z = sim.z_ref
|
||||||
|
|
||||||
|
norm = (2 * np.pi)**1.5 * sx * sy * sz
|
||||||
|
term_x = np.exp(-0.5 * (dx / sx)**2)
|
||||||
|
term_y = np.exp(-0.5 * (dy / sy)**2)
|
||||||
|
term_z = (np.exp(-0.5 * ((z - H) / sz)**2) +
|
||||||
|
np.exp(-0.5 * ((z + H) / sz)**2))
|
||||||
|
|
||||||
|
C = (src.Q_total / norm) * term_x * term_y * term_z
|
||||||
|
return C
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 6. Model 3: Dense Gas (ALOHA 방식, Britter-McQuaid)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def dense_gas_britter_mcquaid(
|
||||||
|
X: np.ndarray, Y: np.ndarray,
|
||||||
|
meteo: MeteoParams, src: SourceParams, sim: SimParams,
|
||||||
|
t: float
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Britter & McQuaid (1988) Dense Gas 모델
|
||||||
|
- 무거운 가스의 중력 침강(gravity spreading) 효과 반영
|
||||||
|
- ALOHA에서 채용하는 방식과 동일한 기본 구조
|
||||||
|
|
||||||
|
적용 조건: ρ_gas / ρ_air > 1.1 이상
|
||||||
|
출력 단위: g/m³ (ppm 변환은 convert_to_ppm 함수 사용)
|
||||||
|
|
||||||
|
1) 부력 flux: g0' = g(ρg-ρa)/ρa × qv
|
||||||
|
2) 연속 누출: plume width = f(g0', u, x)
|
||||||
|
3) 순간 누출: cloud radius = f(g0, t)
|
||||||
|
"""
|
||||||
|
rho_air = meteo.pressure * 0.02897 / (8.314 * meteo.temperature) # kg/m³
|
||||||
|
g = 9.81 # m/s²
|
||||||
|
u = max(meteo.wind_speed, 0.5)
|
||||||
|
|
||||||
|
# 초기 농도 (부피 분율)
|
||||||
|
rho_g = src.density_gas
|
||||||
|
C0_vol = rho_g / (rho_g + rho_air) # 초기 부피 분율
|
||||||
|
|
||||||
|
# 단위 부력 flux (m²/s³)
|
||||||
|
g_prime0 = g * (rho_g - rho_air) / rho_air
|
||||||
|
|
||||||
|
cos_t, sin_t = wind_rotation(meteo.wind_dir_deg)
|
||||||
|
xw, yw = rotate_to_wind(X, Y, src.x0, src.y0, cos_t, sin_t)
|
||||||
|
mask = xw > 0
|
||||||
|
C = np.zeros_like(X)
|
||||||
|
|
||||||
|
if not np.any(mask):
|
||||||
|
return C
|
||||||
|
|
||||||
|
xd = xw[mask]
|
||||||
|
yd = yw[mask]
|
||||||
|
|
||||||
|
if src.release_duration == 0:
|
||||||
|
# ── 순간 누출 (Puff) ──
|
||||||
|
# 이동 중심
|
||||||
|
xc = u * t
|
||||||
|
# 클라우드 반경: r(t) = r0 + α·(g_prime0·V0)^(1/4)·t^(1/2)
|
||||||
|
V0 = np.pi * src.pool_radius**2 * 1.0 # 초기 체적 (1m 두께 가정)
|
||||||
|
r0 = src.pool_radius
|
||||||
|
r_t = r0 + 1.1 * (g_prime0 * V0)**0.25 * max(t, 0.1)**0.5
|
||||||
|
|
||||||
|
# 클라우드 높이: h(t) = V0 / (π r²)
|
||||||
|
h_t = max(V0 / (np.pi * r_t**2), 0.1)
|
||||||
|
|
||||||
|
# Gaussian 농도 분포 내 클라우드
|
||||||
|
dist_xr = xd - xc
|
||||||
|
sigma_cloud = r_t / 2.15 # r_t ≈ 2.15σ
|
||||||
|
C_vol = C0_vol * np.exp(-0.5 * (dist_xr / sigma_cloud)**2) \
|
||||||
|
* np.exp(-0.5 * (yd / sigma_cloud)**2)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# ── 연속 누출 (Plume) ──
|
||||||
|
# 풍하거리별 플룸 폭: b(x) = b0 × [1 + α(g_prime_x / u³)^(1/3)]
|
||||||
|
qv = src.Q / (rho_g * 1000) # 체적 유량 (m³/s)
|
||||||
|
g_prime_line = g_prime0 * qv / u # 단위 길이 부력
|
||||||
|
|
||||||
|
b0 = src.pool_radius
|
||||||
|
# Britter-McQuaid: b ∝ x^0.6 for gravity-dominated
|
||||||
|
b_x = b0 + 2.5 * (g_prime_line / u**2)**0.333 * xd**0.6
|
||||||
|
b_x = np.maximum(b_x, b0)
|
||||||
|
|
||||||
|
# 플룸 높이 (중력 침강 → 낮게 유지)
|
||||||
|
h_x = qv / (u * b_x)
|
||||||
|
h_x = np.maximum(h_x, 0.1)
|
||||||
|
|
||||||
|
# 횡풍 Gaussian × 풍하 top-hat 근사
|
||||||
|
C_vol = C0_vol * (b0 / b_x) * np.exp(-0.5 * (yd / (b_x / 2))**2)
|
||||||
|
|
||||||
|
# 부피 분율 → g/m³ 변환
|
||||||
|
MW = src.molecular_weight
|
||||||
|
C[mask] = C_vol * (MW * meteo.pressure) / (8.314 * meteo.temperature) # g/m³
|
||||||
|
|
||||||
|
return C
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 7. 독성 임계값 (AEGL / IDLH)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
# 주요 HNS 물질 독성 기준 (ppm)
|
||||||
|
TOXICITY_LEVELS = {
|
||||||
|
"Chlorine": {"AEGL1": 0.5, "AEGL2": 2.8, "AEGL3": 50.0, "IDLH": 10.0, "MW": 71.0},
|
||||||
|
"Ammonia": {"AEGL1": 1.1, "AEGL2": 16.0, "AEGL3": 50.0, "IDLH": 300.0, "MW": 17.0},
|
||||||
|
"HCl": {"AEGL1": 1.8, "AEGL2": 22.0, "AEGL3": 100.0, "IDLH": 50.0, "MW": 36.5},
|
||||||
|
"Benzene": {"AEGL1": 52.0, "AEGL2": 800.0,"AEGL3": 4000.0,"IDLH": 500.0, "MW": 78.1},
|
||||||
|
"H2S": {"AEGL1": 0.51, "AEGL2": 17.0, "AEGL3": 50.0, "IDLH": 50.0, "MW": 34.1},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def gm3_to_ppm(C_gm3: np.ndarray, MW: float, T_K: float = 293.15, P_Pa: float = 101325) -> np.ndarray:
|
||||||
|
"""g/m³ → ppm 변환"""
|
||||||
|
return C_gm3 * (8.314 * T_K) / (MW * P_Pa) * 1e6
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 8. 애니메이션 생성
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def run_animation(
|
||||||
|
model: Literal["plume", "puff", "dense_gas"],
|
||||||
|
meteo: MeteoParams,
|
||||||
|
src: SourceParams,
|
||||||
|
sim: SimParams,
|
||||||
|
substance: str = "Chlorine",
|
||||||
|
save_path: Optional[str] = None,
|
||||||
|
show: bool = True
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
시간별 대기확산 농도 애니메이션 생성
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
model : 사용할 모델 ("plume" | "puff" | "dense_gas")
|
||||||
|
save_path : 저장 경로 (.gif 또는 .mp4), None이면 저장 안 함
|
||||||
|
show : plt.show() 호출 여부
|
||||||
|
"""
|
||||||
|
MODEL_FUNC = {
|
||||||
|
"plume": gaussian_plume,
|
||||||
|
"puff": gaussian_puff,
|
||||||
|
"dense_gas": dense_gas_britter_mcquaid,
|
||||||
|
}
|
||||||
|
assert model in MODEL_FUNC, f"지원하지 않는 모델: {model}"
|
||||||
|
func = MODEL_FUNC[model]
|
||||||
|
|
||||||
|
# 격자 생성
|
||||||
|
x_lin = np.linspace(*sim.x_range, sim.nx)
|
||||||
|
y_lin = np.linspace(*sim.y_range, sim.ny)
|
||||||
|
X, Y = np.meshgrid(x_lin, y_lin)
|
||||||
|
|
||||||
|
# 시간 배열
|
||||||
|
times = np.arange(sim.t_start + sim.dt, sim.t_end + sim.dt, sim.dt)
|
||||||
|
|
||||||
|
# 독성 기준
|
||||||
|
tox = TOXICITY_LEVELS.get(substance, TOXICITY_LEVELS["Chlorine"])
|
||||||
|
MW = tox["MW"]
|
||||||
|
|
||||||
|
# 농도 프레임 사전 계산
|
||||||
|
print(f"[{model.upper()}] 농도 계산 중... ({len(times)} 프레임)")
|
||||||
|
frames_C = []
|
||||||
|
for t in times:
|
||||||
|
C_gm3 = func(X, Y, meteo, src, sim, t)
|
||||||
|
C_ppm = gm3_to_ppm(C_gm3, MW, meteo.temperature, meteo.pressure)
|
||||||
|
frames_C.append(C_ppm)
|
||||||
|
|
||||||
|
C_max_global = max(f.max() for f in frames_C)
|
||||||
|
C_min_plot = max(tox["AEGL1"] * 0.01, 1e-4)
|
||||||
|
C_max_plot = C_max_global * 1.0
|
||||||
|
|
||||||
|
# ── 그림 설정 ──
|
||||||
|
fig, ax = plt.subplots(figsize=(12, 7))
|
||||||
|
fig.patch.set_facecolor("#0a1628")
|
||||||
|
ax.set_facecolor("#0d1f3c")
|
||||||
|
|
||||||
|
cmap = plt.cm.get_cmap("RdYlGn_r")
|
||||||
|
cmap.set_under("#0d1f3c")
|
||||||
|
|
||||||
|
im = ax.pcolormesh(
|
||||||
|
X, Y, frames_C[0],
|
||||||
|
cmap=cmap,
|
||||||
|
norm=LogNorm(vmin=C_min_plot, vmax=C_max_plot),
|
||||||
|
shading="auto"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 독성 등고선
|
||||||
|
AEGL_COLORS = {
|
||||||
|
"AEGL1": ("#00ff88", f'AEGL-1 ({tox["AEGL1"]} ppm)'),
|
||||||
|
"AEGL2": ("#ffcc00", f'AEGL-2 ({tox["AEGL2"]} ppm)'),
|
||||||
|
"AEGL3": ("#ff4444", f'AEGL-3 ({tox["AEGL3"]} ppm)'),
|
||||||
|
}
|
||||||
|
|
||||||
|
contour_handles = {}
|
||||||
|
for key, (color, label) in AEGL_COLORS.items():
|
||||||
|
level = tox[key]
|
||||||
|
if C_max_global >= level:
|
||||||
|
cs = ax.contour(X, Y, frames_C[0], levels=[level],
|
||||||
|
colors=[color], linewidths=1.5, linestyles="--")
|
||||||
|
contour_handles[key] = cs
|
||||||
|
|
||||||
|
# 누출원 마커
|
||||||
|
ax.plot(src.x0, src.y0, "w*", ms=14, zorder=10, label="누출원")
|
||||||
|
|
||||||
|
# 컬러바
|
||||||
|
cbar = plt.colorbar(im, ax=ax, fraction=0.03, pad=0.02)
|
||||||
|
cbar.set_label("농도 (ppm)", color="white", fontsize=11)
|
||||||
|
cbar.ax.yaxis.set_tick_params(color="white")
|
||||||
|
plt.setp(cbar.ax.yaxis.get_ticklabels(), color="white")
|
||||||
|
|
||||||
|
# 범례 패치
|
||||||
|
from matplotlib.lines import Line2D
|
||||||
|
legend_elements = [
|
||||||
|
Line2D([0],[0], color=c, ls="--", lw=1.5, label=l)
|
||||||
|
for _, (c, l) in AEGL_COLORS.items()
|
||||||
|
if C_max_global >= tox[k.replace(c,"")[-5:]] if False else True
|
||||||
|
] + [Line2D([0],[0], marker="*", color="white", ms=10, ls="none", label="누출원")]
|
||||||
|
ax.legend(handles=legend_elements, loc="upper right",
|
||||||
|
facecolor="#1a2a4a", edgecolor="#446688", labelcolor="white", fontsize=9)
|
||||||
|
|
||||||
|
# 축 설정
|
||||||
|
ax.set_xlabel("X (m) - 동서", color="white")
|
||||||
|
ax.set_ylabel("Y (m) - 남북", color="white")
|
||||||
|
ax.tick_params(colors="white")
|
||||||
|
for spine in ax.spines.values():
|
||||||
|
spine.set_edgecolor("#446688")
|
||||||
|
|
||||||
|
model_labels = {
|
||||||
|
"plume": "Gaussian Plume (연속누출)",
|
||||||
|
"puff": "Gaussian Puff (순간누출)",
|
||||||
|
"dense_gas": "Dense Gas / ALOHA 방식",
|
||||||
|
}
|
||||||
|
title = ax.set_title(
|
||||||
|
f"[WING] HNS 대기확산 - {model_labels[model]} | {substance} | t=0s",
|
||||||
|
color="white", fontsize=12, fontweight="bold"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 풍향 화살표
|
||||||
|
cos_t, sin_t = wind_rotation(meteo.wind_dir_deg)
|
||||||
|
ax_w = fig.add_axes([0.01, 0.88, 0.08, 0.08])
|
||||||
|
ax_w.set_xlim(-1.5, 1.5)
|
||||||
|
ax_w.set_ylim(-1.5, 1.5)
|
||||||
|
ax_w.set_aspect("equal")
|
||||||
|
ax_w.axis("off")
|
||||||
|
ax_w.set_facecolor("#0a1628")
|
||||||
|
ax_w.annotate("", xy=(cos_t, sin_t), xytext=(0, 0),
|
||||||
|
arrowprops=dict(arrowstyle="->", color="cyan", lw=2))
|
||||||
|
ax_w.text(0, -1.4, f"{meteo.wind_speed}m/s\n{meteo.wind_dir_deg}°",
|
||||||
|
color="cyan", fontsize=7, ha="center")
|
||||||
|
|
||||||
|
def _update(frame_idx):
|
||||||
|
C_ppm = frames_C[frame_idx]
|
||||||
|
t_now = times[frame_idx]
|
||||||
|
im.set_array(C_ppm.ravel())
|
||||||
|
|
||||||
|
# 등고선 업데이트 (이전 제거 후 재생성)
|
||||||
|
for coll in ax.collections[1:]:
|
||||||
|
coll.remove()
|
||||||
|
for key, (color, _) in AEGL_COLORS.items():
|
||||||
|
level = tox[key]
|
||||||
|
if C_ppm.max() >= level:
|
||||||
|
ax.contour(X, Y, C_ppm, levels=[level],
|
||||||
|
colors=[color], linewidths=1.5, linestyles="--")
|
||||||
|
|
||||||
|
title.set_text(
|
||||||
|
f"[WING] HNS 대기확산 - {model_labels[model]} | {substance} | t={t_now:.0f}s"
|
||||||
|
)
|
||||||
|
return [im]
|
||||||
|
|
||||||
|
ani = animation.FuncAnimation(
|
||||||
|
fig, _update, frames=len(times), interval=300, blit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if save_path:
|
||||||
|
ext = save_path.split(".")[-1].lower()
|
||||||
|
print(f"애니메이션 저장 중: {save_path}")
|
||||||
|
if ext == "gif":
|
||||||
|
ani.save(save_path, writer="pillow", fps=5, dpi=120)
|
||||||
|
else:
|
||||||
|
ani.save(save_path, writer="ffmpeg", fps=5, dpi=120)
|
||||||
|
print("저장 완료!")
|
||||||
|
|
||||||
|
if show:
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
plt.close(fig)
|
||||||
|
return ani
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 9. 다중 모델 비교 실행
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def run_all_models(
|
||||||
|
meteo: MeteoParams,
|
||||||
|
src: SourceParams,
|
||||||
|
sim: SimParams,
|
||||||
|
substance: str = "Chlorine",
|
||||||
|
save_dir: str = "."
|
||||||
|
):
|
||||||
|
"""세 가지 모델 모두 GIF로 저장"""
|
||||||
|
for model in ["plume", "puff", "dense_gas"]:
|
||||||
|
path = f"{save_dir}/wing_hns_{model}_{substance.lower()}.gif"
|
||||||
|
run_animation(model, meteo, src, sim, substance,
|
||||||
|
save_path=path, show=False)
|
||||||
|
print(f" ✓ {model} → {path}")
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 10. 메인 실행 예시 (WING 기본값)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
|
||||||
|
# ── 기상 조건 (서해 해상 기준)
|
||||||
|
meteo = MeteoParams(
|
||||||
|
wind_speed = 6.0, # m/s
|
||||||
|
wind_dir_deg = 270.0, # 서풍 (동쪽으로 이동)
|
||||||
|
stability = "D", # 중립 (해상 대부분)
|
||||||
|
temperature = 288.15, # 15°C
|
||||||
|
mixing_height= 600.0, # m
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 누출원 (선박 탱크 파손 시나리오)
|
||||||
|
src = SourceParams(
|
||||||
|
Q = 20.0, # g/s (연속 누출)
|
||||||
|
Q_total = 10000.0, # g (순간 누출)
|
||||||
|
z0 = 1.0, # m (갑판 높이)
|
||||||
|
release_duration = 300.0, # s
|
||||||
|
molecular_weight = 71.0, # Chlorine
|
||||||
|
density_gas = 3.17, # kg/m³
|
||||||
|
pool_radius = 3.0, # m
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 시뮬레이션 범위
|
||||||
|
sim = SimParams(
|
||||||
|
x_range = (-100, 4000),
|
||||||
|
y_range = (-1000, 1000),
|
||||||
|
nx=220, ny=160,
|
||||||
|
t_end = 600.0,
|
||||||
|
dt = 30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
substance = "Chlorine"
|
||||||
|
|
||||||
|
print("=" * 55)
|
||||||
|
print(" WING HNS 대기확산 시뮬레이터")
|
||||||
|
print(f" 물질: {substance} | 안정도: {meteo.stability}")
|
||||||
|
print(f" 풍속: {meteo.wind_speed}m/s | 풍향: {meteo.wind_dir_deg}°")
|
||||||
|
print("=" * 55)
|
||||||
|
|
||||||
|
# 모델 선택 실행 (개별 실행 가능)
|
||||||
|
# run_animation("plume", meteo, src, sim, substance, save_path="wing_plume.gif")
|
||||||
|
# run_animation("puff", meteo, src, sim, substance, save_path="wing_puff.gif")
|
||||||
|
# run_animation("dense_gas", meteo, src, sim, substance, save_path="wing_dense.gif")
|
||||||
|
|
||||||
|
# 전체 모델 일괄 저장
|
||||||
|
run_all_models(meteo, src, sim, substance, save_dir=".")
|
||||||
불러오는 중...
Reference in New Issue
Block a user