Compare commits
9 커밋
ad69d960a2
...
bc3e9d3c59
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| bc3e9d3c59 | |||
|
|
f974162e3a | ||
|
|
91d0832963 | ||
|
|
c3d3b82b60 | ||
|
|
618d898a6c | ||
|
|
29686f9476 | ||
|
|
8f98f63aa5 | ||
| 9faf928e40 | |||
| 7bd7797c6d |
@ -1,6 +1,6 @@
|
||||
{
|
||||
"applied_global_version": "1.4.0",
|
||||
"applied_date": "2026-02-28",
|
||||
"applied_global_version": "1.5.0",
|
||||
"applied_date": "2026-03-03",
|
||||
"project_type": "react-ts",
|
||||
"gitea_url": "https://gitea.gc-si.dev"
|
||||
}
|
||||
|
||||
2
backend/package-lock.json
generated
2
backend/package-lock.json
generated
@ -558,6 +558,7 @@
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
@ -1991,6 +1992,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
||||
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.11.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 라우트
|
||||
// ============================================================
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../auth/authMiddleware.js';
|
||||
import { listOrganizations, getOrganization, listUploadLogs } from './assetsService.js';
|
||||
import { listOrganizations, getOrganization, listUploadLogs, listInsurance } from './assetsService.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -44,6 +44,34 @@ router.get('/orgs/:sn', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET /api/assets/insurance — 선박보험(유류오염보장계약) 목록
|
||||
// ============================================================
|
||||
router.get('/insurance', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { search, shipTp, issueOrg, insurer, page, limit } = req.query as {
|
||||
search?: string;
|
||||
shipTp?: string;
|
||||
issueOrg?: string;
|
||||
insurer?: string;
|
||||
page?: string;
|
||||
limit?: string;
|
||||
};
|
||||
const data = await listInsurance({
|
||||
search,
|
||||
shipTp,
|
||||
issueOrg,
|
||||
insurer,
|
||||
page: page ? parseInt(page, 10) : undefined,
|
||||
limit: limit ? parseInt(limit, 10) : undefined,
|
||||
});
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
console.error('[assets] 선박보험 조회 오류:', err);
|
||||
res.status(500).json({ error: '선박보험 조회 중 오류가 발생했습니다.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET /api/assets/upload-logs — 업로드 이력
|
||||
// ============================================================
|
||||
|
||||
@ -162,6 +162,106 @@ export async function getOrganization(orgSn: number): Promise<OrgDetail | null>
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 선박보험(유류오염보장계약) 조회
|
||||
// ============================================================
|
||||
|
||||
interface InsuranceListItem {
|
||||
insSn: number;
|
||||
shipNo: string;
|
||||
shipNm: string;
|
||||
callSign: string;
|
||||
imoNo: string;
|
||||
shipTp: string;
|
||||
shipTpDetail: string;
|
||||
ownerNm: string;
|
||||
grossTon: string;
|
||||
insurerNm: string;
|
||||
liabilityYn: string;
|
||||
oilPollutionYn: string;
|
||||
fuelOilYn: string;
|
||||
wreckRemovalYn: string;
|
||||
validStart: string;
|
||||
validEnd: string;
|
||||
issueOrg: string;
|
||||
}
|
||||
|
||||
export async function listInsurance(filters: {
|
||||
search?: string;
|
||||
shipTp?: string;
|
||||
issueOrg?: string;
|
||||
insurer?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}): Promise<{ rows: InsuranceListItem[]; total: number }> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (filters.search) {
|
||||
conditions.push(`(ship_nm LIKE '%' || $${idx} || '%' OR call_sign LIKE '%' || $${idx} || '%' OR imo_no LIKE '%' || $${idx} || '%' OR owner_nm LIKE '%' || $${idx} || '%')`);
|
||||
params.push(filters.search);
|
||||
idx++;
|
||||
}
|
||||
if (filters.shipTp) {
|
||||
conditions.push(`ship_tp = $${idx++}`);
|
||||
params.push(filters.shipTp);
|
||||
}
|
||||
if (filters.issueOrg) {
|
||||
conditions.push(`issue_org LIKE '%' || $${idx++} || '%'`);
|
||||
params.push(filters.issueOrg);
|
||||
}
|
||||
if (filters.insurer) {
|
||||
conditions.push(`insurer_nm LIKE '%' || $${idx++} || '%'`);
|
||||
params.push(filters.insurer);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const limit = Math.min(filters.limit || 50, 200);
|
||||
const offset = ((filters.page || 1) - 1) * limit;
|
||||
|
||||
const countSql = `SELECT COUNT(*) AS cnt FROM wing.SHIP_INSURANCE ${where}`;
|
||||
const { rows: countRows } = await wingPool.query(countSql, params);
|
||||
const total = parseInt(countRows[0].cnt as string, 10);
|
||||
|
||||
const sql = `
|
||||
SELECT ins_sn, ship_no, ship_nm, call_sign, imo_no, ship_tp, ship_tp_detail,
|
||||
owner_nm, gross_ton, insurer_nm,
|
||||
liability_yn, oil_pollution_yn, fuel_oil_yn, wreck_removal_yn,
|
||||
valid_start, valid_end, issue_org
|
||||
FROM wing.SHIP_INSURANCE
|
||||
${where}
|
||||
ORDER BY valid_end DESC, ins_sn
|
||||
LIMIT $${idx++} OFFSET $${idx++}
|
||||
`;
|
||||
params.push(limit, offset);
|
||||
|
||||
const { rows } = await wingPool.query(sql, params);
|
||||
|
||||
return {
|
||||
total,
|
||||
rows: rows.map((r: Record<string, unknown>) => ({
|
||||
insSn: r.ins_sn as number,
|
||||
shipNo: r.ship_no as string,
|
||||
shipNm: r.ship_nm as string,
|
||||
callSign: r.call_sign as string,
|
||||
imoNo: r.imo_no as string,
|
||||
shipTp: r.ship_tp as string,
|
||||
shipTpDetail: r.ship_tp_detail as string,
|
||||
ownerNm: r.owner_nm as string,
|
||||
grossTon: r.gross_ton as string,
|
||||
insurerNm: r.insurer_nm as string,
|
||||
liabilityYn: r.liability_yn as string,
|
||||
oilPollutionYn: r.oil_pollution_yn as string,
|
||||
fuelOilYn: r.fuel_oil_yn as string,
|
||||
wreckRemovalYn: r.wreck_removal_yn as string,
|
||||
validStart: (r.valid_start as Date)?.toISOString().slice(0, 10) ?? '',
|
||||
validEnd: (r.valid_end as Date)?.toISOString().slice(0, 10) ?? '',
|
||||
issueOrg: r.issue_org as string,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 업로드 이력 조회
|
||||
// ============================================================
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
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 (tsx로 실행)
|
||||
import { HNS_SEARCH_DB } from '../../../frontend/src/data/hnsSubstanceSearchData.js'
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// 통합 물질 데이터 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() {
|
||||
console.log('HNS 물질정보 시드 시작...')
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { 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 — 분석 삭제
|
||||
router.delete('/analyses/:sn', requireAuth, requirePermission('hns', 'DELETE'), async (req, res) => {
|
||||
try {
|
||||
|
||||
@ -9,7 +9,7 @@ interface 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 values: (string | number)[] = []
|
||||
let paramIdx = 1
|
||||
@ -42,7 +42,7 @@ export async function searchSubstances(params: HnsSearchParams) {
|
||||
values.push(JSON.stringify([{ code: keyword }]))
|
||||
break
|
||||
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}%`)
|
||||
}
|
||||
paramIdx++
|
||||
@ -238,6 +238,19 @@ export async function createAnalysis(input: {
|
||||
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> {
|
||||
await wingPool.query(
|
||||
`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));
|
||||
163
database/migration/018_assets_excel_import.sql
Normal file
163
database/migration/018_assets_excel_import.sql
Normal file
@ -0,0 +1,163 @@
|
||||
-- 방제장비 현황 (24년 하반기) 엑셀 데이터 임포트
|
||||
SET search_path TO wing, public;
|
||||
|
||||
-- 기존 데이터 삭제 후 재입력
|
||||
DELETE FROM ASSET_EQUIP;
|
||||
DELETE FROM ASSET_CONTACT;
|
||||
DELETE FROM ASSET_ORG;
|
||||
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)경원마린서비스', '부산시 중구 충장대로 5번길 37, 403호', '051-242-0622', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '울산', '(주)골든씨', '울산광역시 남구 장생포 고래로 100', '010-5499-7401', 'md', 1, 0, 0, 0, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)뉴삼우마린', '부산 동구 중앙대로 502,601호', '051-412-4424', 'md', 3, 0, 0, 0, 0, 3);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)다원마린', '부산시 영도구 태종로 107, 801호', '010-5576-5005', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)대창그린피아', '부산시 사하구 감천항로 190번길 54', '051-246-9242', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)더블유케이에너지', '부산광역시 사상구 백양대로 477, 2층', '010-3579-2228', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)동승종합', '부산시 영도구 대교로2번길 31 302호', '010-2730-2658', 'md', 3, 0, 0, 0, 0, 3);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)동화교역상사', '부산시 영도구 태종로 50번길 35, 4층', '010-7551-985', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)마루산업', '부산광역시 중구 충장대로 9번길 14,', '010-2867-2611', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)미래이엔티', '부산광역시 중구 충장대로 5번길 40,', '010-4598-7282', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)부산항업', '부산시 중구 중앙대로70 동원산업빌딩 11층', '051) 467-0091~2', 'md', 3, 0, 0, 0, 0, 3);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)부영티엠에스', '부산광역시 동구 중앙대로 332번길 8, 602A호', '010-8494-7383', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '울산', '(주)블루엔텍', '울산광역시 남구 장생포 고래로 100', '010-3648-7161', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '울산', '(주)블루코리아', '울산광역시 남구 장생포 고래로 100', '010-9375-3672', 'md', 5, 2, 0, 0, 0, 7);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)선경종합환경', '부산시 영도구 태종로 83번길 36', '010-3597-3025', 'md', 3, 0, 0, 0, 0, 3);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)성원그린', '부산시 동구 충장대로 314', '010-2422-4502', 'md', 3, 0, 0, 0, 0, 3);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '울산', '(주)세청', '울산광역시 남구 장생포 고래로 100', '010-3576-1982', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)센텀환경', '부산시 중구 중앙대로 136', '010-4119-0888', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)수림환경', '부산시 중구 중앙대로 46번길 4 동흥빌딩 201', '010-3887-0911', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)에스에스환경', '부산시 동구 충장대로 314', '051-900-5790', 'md', 3, 1, 0, 0, 0, 4);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)에스피진성', '부산시 중구 자갈치로 42, 406호', '010-2144-4343', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)엠앤에스', '부산광역시 강서구 명지오션시티 12로 120,', '051-432-9135', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)월드환경', '부산광역시 영도구 태종로 73번길 31', '010-5775-5059', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)육일해상', '부산시 서구 해안새벽시장길 36', '051-246-6180', 'md', 3, 0, 0, 0, 0, 3);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)인터오션마리타임', '부산광역시 중구 광복로97번길 18, 701호', '010-4878-4873', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)제원산업', '부산 영도구 대교동 1가 39번지', '010-3582-0769', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)지앤비마린서비스', '부산시 중구 광복로 97번길 18,', '010-7695-6000', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)진명마리타임', '부산시 동구 중앙대로 308번길 3-5', '010-8640-6977', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)청해환경', '부산시 영도구 태종로 50번길, 35', '051-418-2272', 'md', 4, 0, 0, 0, 0, 4);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)케이오씨종합환경', '부산시 영도구 태종로 83번길 36', '010-9598-9696', 'md', 3, 0, 0, 0, 0, 3);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '울산', '(주)클리어씨', '울산광역시 남구 장생포 고래로 100', '052-256-9126', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)태창마린', '부산 영도구 대교로46번길 49,', '010-9565-2223', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)하이마린', '부산광역시 중구 광복로 37번길 20', '010-3501-5337', 'md', 3, 0, 0, 0, 0, 3);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '울산', '(주)한유마린서비스', '울산광역시 남구 신여천로 2', '052-976-6500', 'md', 5, 1, 0, 0, 0, 6);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(주)해룡산업', '부산 영도구 봉래나루로 170', '010-3882-4240', 'md', 3, 0, 0, 0, 0, 3);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '통영', '(합)경남마린', '거제시 옥포동1922-1', '055-681-3473', 'md', 1, 0, 0, 0, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '(합)금호산업', '부산시 영도구 중리북로 21번길 26', '010-3566-4556', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기타', '남해청', '울산', 'HD현대미포', '울산광역시 동구 방어진순환도로100', '052-250-3551', 'md', 1, 0, 0, 0, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('정유사', '남해청', '울산', 'S-OIL㈜', '울산광역시 울주군 온산읍 온산로 68', '052-231-2318', 'md', 0, 3, 0, 0, 0, 3);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('정유사', '남해청', '울산', 'SK에너지㈜', '2018-08-17 00:00:00', '052-208-2932', 'md', 0, 0, 0, 1, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('정유사', '남해청', '울산', 'SK엔텀(주)', '울산광역시 남구 고사동 110-64(SK엔텀 내항)', '052-208-2851', 'md', 3, 6, 0, 0, 0, 9);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기름저장시설', '남해청', '울산', 'SK엔텀㈜', '2018-08-17 00:00:00', '052-208-3357', 'md', 0, 0, 0, 2, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기름저장시설', '남해청', '울산', 'SK지오센트릭', '울산광역시 남구 신여천로 2(고사동)', '052-208-3362', 'md', 0, 2, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '울산', '㈜골든씨', '울산광역시 남구 장생포고래로 100', '052-261-2606', 'md', 0, 1, 1, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '㈜그린-씨', '부산광역시 동구 충장대로 314', '010-3856-9161', 'md', 4, 1, 9, 2, 0, 16);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '㈜다원마린', '부산 영도구 대교로 40번길 8, 1407호', '051-417-0032', 'md', 0, 0, 2, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '㈜동화교역상사', '2021년', '010-7551-9885', 'md', 0, 0, 0, 1, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기름저장시설', '남해청', '부산', '㈜모든', '2010.1.27', '051-262-0771', 'md', 0, 0, 0, 1, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '㈜선화산업', '부산시 영도구 태평북로', '051-405-8500', 'md', 3, 1, 3, 2, 0, 9);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '㈜성원그린', '', '051-266-0695', 'md', 0, 1, 1, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '울산', '㈜세청', '울산광역시 남구 장생포고래로 100', '052-257-1120', 'md', 0, 1, 1, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '㈜청해쉽핑', '부산시 중구 광복로97번길 18 603호', '1045885560', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '㈜태평양해양산업', '부산 영도구 해양로 117번길 32', '051-414-9300', 'md', 5, 3, 1, 0, 0, 9);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '울산', '㈜한유마린서비스', '울산광역시 남구 신여천로2', '052-976-6500', 'md', 0, 0, 1, 0, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '울산', '글로벌마린서비스', '울산광역시 울주군 온산읍 산암리 143-23', '052-238-0597', 'md', 0, 1, 0, 0, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '울산', '글로벌마린서비스(주)', '울산광역시 울주군 온산읍 온산로 68', '052-238-0597', 'md', 5, 0, 0, 0, 0, 5);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '울산', '대상해운(주)', '울산광역시 울주군 온산읍 신항로 666', '052-265-9517', 'md', 6, 2, 0, 0, 0, 8);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '대용환경㈜', '부산광역시 영도구 대평북로23', '051-413-4040', 'md', 4, 0, 0, 0, 0, 4);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '창원', '동화교역상사㈜', '창원시 마산합포구 오동동323-6', '010-3912-1112', 'md', 2, 0, 2, 0, 0, 4);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해양환경공단', '남해청', '창원', '마산지사', '창원시마산합포구드림베이대로59', '010-3576-0603', 'lg', 6, 24, 9, 9, 1, 49);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '보성항업(주)', '부산광역시 중구 중앙대로 70, 11층', '051-243-1601', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '부산마리타임(주)', '부산광역시 중구 해관로 44, 201호', '010-3849-3366', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해양환경공단', '남해청', '부산', '부산지사', '창원시 진해구 안골동', '051-466-3944', 'lg', 10, 1, 4, 0, 6, 21);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '부산', '부산항업㈜', '부산시 사하구 감천항로31', '051-467-0091', 'md', 0, 1, 0, 0, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해경관서', '남해청', '사천서', '사천해경', '사천시 팔포3길 56-71', '055-830-2291', 'md', 1, 4, 6, 1, 0, 12);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기타', '남해청', '통영', '삼성중공업', '거제시 장평3로 80', '055-630-5373', 'md', 1, 0, 0, 0, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '울산', '우주환경(주)', '울산광역시 남구 장생포 고래로 100', '010-8522-3287', 'md', 3, 0, 0, 0, 0, 3);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '남해청', '울산', '우주환경㈜', '울산 남구 장생포고래로179번길 69', '010-8522-3287', 'md', 0, 1, 0, 0, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해양환경공단', '남해청', '울산', '울산지사', '울산광역시 남구 장생포고래로 276번길 27', '052-261-3413', 'lg', 8, 33, 10, 1, 1, 53);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해군', '남해청', '창원', '진해기지사령부', '창원시 진해구 현동', '010-4128-3648', 'md', 2, 2, 0, 0, 0, 4);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기타', '남해청', '통영', '한화오션', '거제시 거제대로 3370(아주동)', '055-735-9191', 'md', 2, 0, 0, 1, 0, 3);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해경관서', '남해청', '부산', '해경', '부산시 영도구 해양로 293', '051-664-2392', 'hq', 10, 39, 21, 15, 66, 151);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해경관서', '남해청', '울산', '해경(울산광역방제지원센터)', '울산광역시 울주군 온산읍 산암로 203-7', '052-230-2396', 'md', 0, 4, 8, 0, 0, 12);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해군', '남해청', '부산', '해군작전사령부', '부산시 남구 백운포로95-7', '051-679-2197', 'md', 1, 0, 0, 0, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해양환경공단', '남해청', '사천', '해양환경공단 마산지사', '경상남도 사천시 신항만1길 23(향촌동)', '055-835-7034', 'md', 1, 5, 0, 1, 0, 7);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기름저장시설', '남해청', '울산', '현대오일터미널(주)', '울산광역시 울주군 온산읍 산암로 311-96', '052-240-7900', 'md', 0, 0, 1, 0, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기름저장시설', '남해청', '울산', '현대오일터미널㈜', '2019-12-03 00:00:00', '052-240-7900', 'md', 0, 0, 0, 1, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기타', '동해청', '포항', '(주)블루씨', '포항시 남구 청림서길 35번길 4-18', '054-278-8200', 'md', 0, 1, 8, 0, 0, 9);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해군', '동해청', '동해', '1함대 사령부', '2014-08-19 00:00:00', '033-539-7323', 'md', 0, 0, 0, 1, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기름저장시설', '동해청', '동해', 'HD현대오일뱅크㈜옥계물류센터', '강릉시 옥계면 동해대로 206', '033-534-2093', 'md', 0, 0, 2, 1, 0, 3);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기름저장시설', '동해청', '포항', 'OCI(주)', '포항시 남구 청림서길 54(청림동)', '054-290-8222', 'md', 0, 0, 0, 0, 1, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '동해청', '포항', '㈜씨앤지', '포항시 남구 동해안로 6262', '054-274-2040', 'md', 3, 2, 1, 0, 1, 7);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해양환경공단', '동해청', '동해', '동해지사', '동해시 대동로 210', '010-8796-1789', 'lg', 4, 16, 3, 1, 2, 26);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '동해청', '포항', '블루씨', '포항시 남구 동해안로 6262', '054-278-8200', 'md', 3, 0, 0, 0, 0, 3);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기름저장시설', '동해청', '동해', '영풍 석포제련소', '2023-07-04 00:00:00', '033-521-9087', 'md', 0, 0, 0, 1, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해양환경공단', '동해청', '포항', '포항지사', '포항시 북구 해안로 44-10', '054-273-5595', 'md', 2, 5, 3, 3, 2, 15);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기름저장시설', '동해청', '동해', '한국석유공사 동해지사', '2017-05-23 00:00:00', '033-520-7822', 'md', 0, 0, 0, 1, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기타', '동해청', '울진', '한울원전', '울진군 북면 울진북로 2040(부구리)', '054-785-4833', 'md', 0, 1, 0, 0, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해경관서', '동해청', '속초', '해경', '속초시 설악금강대교로 206', '033-634-2986', 'hq', 8, 22, 22, 4, 54, 110);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해군', '동해청', '동해', '해군1함대사령부', '동해시 대동로 430', '033-539-6325', 'md', 0, 1, 1, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '서해청', '여수', '(유)피케이엘', '여수산단 4로 166-29 (중흥동)', '061-654-9603', 'md', 3, 3, 2, 0, 0, 8);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '서해청', '여수', '(주)하나환경', '여수시 이순신광장로 188', '061-643-0523', 'md', 5, 0, 0, 0, 0, 5);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('정유사', '서해청', '여수', 'GS칼텍스㈜', '여수시 낙포단지길 251', '061-680-2121', 'lg', 2, 4, 0, 1, 20, 27);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기타', '서해청', '여수', '㈜HR-PORT, 포스코', '', '061-792-3437', 'md', 0, 0, 0, 1, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '서해청', '여수', '㈜광양해양환경', '광양시 중동 1893', '061-793-2420', 'md', 4, 1, 5, 0, 0, 10);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '서해청', '여수', '㈜동양', '여수시 이순신광장로 188', '061-666-4181', 'md', 4, 5, 8, 0, 0, 17);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '서해청', '목포', '㈜아라', '전남 영암군 삼호읍 대불로 629', '010-8615-4326', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '서해청', '여수', '㈜우진실업', '여수시 중앙동 247', '061-666-4333', 'md', 0, 1, 3, 0, 2, 6);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '서해청', '여수', '㈜하나환경', '여수시 이순신광장로 188', '061-643-0523', 'md', 0, 1, 3, 0, 0, 4);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '서해청', '여수', '㈜하버마린서비스', '여수시 국포1로', '061-651-8550', 'md', 3, 1, 1, 0, 0, 5);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '서해청', '여수', '㈜한국해운', '여수시 낙포동 1306', '061-688-9271', 'md', 3, 1, 1, 0, 0, 5);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '서해청', '목포', '㈜한국해운 목포지사', '목포시 삼학로 107', '061-242-5311', 'md', 1, 1, 2, 0, 0, 4);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해양환경공단', '서해청', '군산', '군산지사', '군산시 임해로 452', '063-443-4813', 'lg', 5, 16, 6, 0, 4, 31);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '서해청', '여수', '마로해운', '광양 포스코 원료부두 5번석', '010-4195-4862', 'md', 4, 1, 1, 0, 0, 6);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해양환경공단', '서해청', '목포', '목포지사', '전남 목포시 죽교동 683', '061-242-9664', 'md', 2, 8, 4, 0, 0, 14);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해양환경공단', '서해청', '완도', '목포지사 완도사업소', '완도군 완도읍 해변공원로 20-1', '061-552-1403', 'md', 1, 3, 0, 0, 0, 4);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '서해청', '여수', '서진환경', '전남 여수시 신월5길 55', '061-651-9057', 'md', 3, 0, 0, 0, 0, 3);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '서해청', '여수', '서진환경㈜', '전남 여수시 신월5길 55', '061-651-9057', 'md', 0, 1, 2, 1, 0, 4);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '서해청', '여수', '수일종합환경㈜', '여수시 돌산읍 신추길 52', '010-9333-2837', 'md', 3, 1, 9, 0, 0, 13);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해양환경공단', '서해청', '여수', '여수지사', '여수시 덕충동', '061-654-6434', 'lg', 5, 43, 6, 0, 3, 57);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '서해청', '여수', '우리환경', '', '010-4195-4862', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '서해청', '여수', '우진실업㈜', '여수시 좌수영로 57', '061-666-4333', 'md', 5, 0, 0, 0, 0, 5);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기타', '서해청', '여수', '한국남동발전㈜ 여수발전본부', '', '070-8898-5625', 'md', 0, 0, 0, 1, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해경관서', '서해청', '군산', '해경', '전북 군산시 오식도동 506(군산해경전용부두)', '061-539-2491', 'hq', 12, 41, 49, 16, 67, 185);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해경관서', '서해청', '여수', '해경(광양광역방제지원센터)', '광양시 항만9로 89', '061-840-2494', 'md', 0, 5, 3, 3, 0, 11);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해군', '서해청', '목포', '해군3함대사령부', '영암군 삼호읍 사서함 300-4호', '061-263-4416', 'md', 1, 1, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해양환경공단', '서해청', '여수', '해양환경공단(방제기획처)', '여수시 덕충동', '02-3498-8604', 'md', 0, 4, 0, 0, 0, 4);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기타', '서해청', '목포', '현대삼호', '전남 영암군 삼호읍 대불로 93', '', 'md', 1, 1, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '서해청', '여수', '효동항업㈜', '여수시 여수산단로 923-28', '061-665-7968', 'md', 5, 7, 2, 0, 0, 14);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해군', '제주청', '서귀포', '제주기지전대', '서귀포시 이어도로 662', '064-905-5535', 'md', 1, 1, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해양환경공단', '제주청', '제주', '제주지사', '제주시 임항로97', '064-753-4356', 'lg', 4, 21, 3, 0, 4, 32);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해경관서', '제주청', '제주', '해경', '제주시 임항로 85', '064-766-2691', 'hq', 2, 8, 29, 6, 26, 71);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '중부청', '평택', '(주)소스코리아', '경기도 평택시 포승읍 하만호길277', '031-683-2389', 'md', 2, 0, 7, 0, 0, 9);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '중부청', '평택', '(주)씨앤', '경기 평택 포승읍 평택항만길 34-9', '031-683-8661', 'md', 3, 1, 0, 0, 0, 4);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해경관서', '중부청', '보령서', '209정', '보령시 해안로 740', '041-402-2284', 'md', 0, 0, 0, 0, 1, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기름저장시설', '중부청', '인천', 'GS칼텍스㈜ 인천물류센터', '인천광역시 중구
|
||||
월미로 182', '010-8777-6922', 'md', 1, 1, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('정유사', '중부청', '태안', 'HD현대오일뱅크㈜', '서산시 대산읍 평신2로 182', '010-2050-5291', 'md', 0, 1, 0, 0, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기름저장시설', '중부청', '태안', 'HD현대케미칼', '충남 서산시 대산읍 평신2로 26', '041-924-1068', 'md', 0, 2, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기름저장시설', '중부청', '인천', 'SK인천석유화학', '인천광역시 서구 봉수대로 415', '032-570-5771', 'md', 3, 0, 0, 0, 0, 3);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '중부청', '평택', '㈜그린이앤씨', '경기 평택시 포승읍 평택항만길 86-54', '031-686-6796', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기타', '중부청', '인천', '㈜서해그린', '인천 중구 축항대로 106, 화인통상빌딩 3층', '032-882-4666', 'md', 1, 0, 0, 0, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '중부청', '평택', '㈜서해환경', '경기도 평택시 포승읍 내기새싹길 12-10, 가동 6호', '031-684-0551', 'md', 1, 0, 0, 0, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '중부청', '평택', '㈜씨앤', '경기도 평택시 포승읍 평택항만길 34-9', '031-683-8661', 'md', 0, 0, 10, 0, 0, 10);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '중부청', '인천', '㈜클린포트', '인천광역시 중구 북성동1가 80-27', '032-882-8279', 'md', 3, 3, 1, 0, 0, 7);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해경관서', '중부청', '태안', '광역방제지원센터', '2010-04-02 00:00:00', '041-950-2291', 'md', 0, 0, 0, 2, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해양환경공단', '중부청', '태안', '대산지사', '서산시 대산읍 대죽1로 325', '041-664-9101', 'lg', 6, 13, 6, 0, 1, 26);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기름저장시설', '중부청', '태안', '롯데케미칼㈜ 대산공장', '충남 서산시 대산읍 독곶1로 82', '041-689-5332', 'md', 0, 0, 1, 0, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기타', '중부청', '인천', '송도항업㈜', '인천시 중구 제물량로 189, 4층', '010-4729-4738', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('지자체', '중부청', '평택', '안산시청', '경기도 안산시 단원구 진두길 97', '031-481-3696', 'md', 0, 1, 0, 0, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('업체', '중부청', '태안', '우진해운㈜', '서산시 대산읍 대죽1로 325', '010-4384-6817', 'md', 3, 0, 4, 0, 0, 7);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기타', '중부청', '인천', '주원환경㈜', '인천시 중구 연안부두로 21번길 13(유한빌딩 301-A)', '010-4024-8288', 'md', 2, 0, 0, 0, 0, 2);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해양환경공단', '중부청', '평택', '평택지사', '당진시 송악읍 고대공단2길', '031-683-7973', 'md', 3, 12, 3, 1, 1, 20);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기름저장시설', '중부청', '태안', '한국석유공사', '대산읍 평신2로 119번지', '041-660-4125', 'md', 0, 0, 3, 0, 0, 3);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기타', '중부청', '인천', '한국수자원공사 아라뱃길지사', '인천광역시 서구 정서진1로 41', '032-590-2314', 'md', 1, 0, 0, 0, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기름저장시설', '중부청', '태안', '한화토탈에너지스㈜', '충남 서산시 대산읍 독곶2로 103', '041-670-7507', 'md', 0, 1, 0, 0, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해경관서', '중부청', '인천', '해경', '인천광역시 중구 북성동1가 80-8', '032-650-2798', 'hq', 9, 36, 23, 13, 60, 141);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해경관서', '중부청', '태안', '해경(대산광역방제지원센터)', '충남 서산시 대산읍 대죽1로 301-16', '041-950-2394', 'md', 0, 4, 4, 0, 0, 8);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해군', '중부청', '인천', '해병대 제9518부대', '', '032-830-3425', 'md', 0, 1, 0, 0, 0, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해양환경공단', '중부청', '인천', '해양환경공단 인천지사', '인천광역시 중구 연안부두로 128번길 35', '010-7133-2167', 'lg', 4, 16, 10, 0, 0, 30);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기름저장시설', '중부청', '평택', '현대오일터미널(주)', '평택시 포승읍 포승공단순환로 11', '031-683-8195', 'md', 0, 0, 5, 0, 0, 5);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('기름저장시설', '중부청', '평택', '현대제철', '당진시 송악읍 북부산업로 1480', '041-680-1543', 'md', 0, 0, 0, 0, 1, 1);
|
||||
INSERT INTO ASSET_ORG (ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, PIN_SIZE, VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS) VALUES ('해경관서', '중부청', '중특단', '해경', '부산광역시 영도구 해양로 301,3', '051-580-2044', 'md', 1, 0, 5, 2, 0, 8);
|
||||
|
||||
-- 총 152개 기관, 1546대 장비
|
||||
42
database/migration/019_ship_insurance.sql
Normal file
42
database/migration/019_ship_insurance.sql
Normal file
@ -0,0 +1,42 @@
|
||||
-- 019_ship_insurance.sql
|
||||
-- 유류오염보장계약 테이블 (해양수산부 공공데이터)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS SHIP_INSURANCE (
|
||||
ins_sn SERIAL PRIMARY KEY,
|
||||
ship_no VARCHAR(30),
|
||||
nation_tp VARCHAR(20),
|
||||
ship_tp VARCHAR(30),
|
||||
ship_tp_detail VARCHAR(50),
|
||||
hns_yn VARCHAR(2),
|
||||
call_sign VARCHAR(20),
|
||||
imo_no VARCHAR(20),
|
||||
oper_tp VARCHAR(20),
|
||||
ship_nm VARCHAR(200),
|
||||
owner_nm VARCHAR(200),
|
||||
gross_ton VARCHAR(30),
|
||||
intl_gross_ton VARCHAR(30),
|
||||
deadweight_ton VARCHAR(30),
|
||||
insurer_nm VARCHAR(200),
|
||||
liability_yn VARCHAR(2),
|
||||
oil_pollution_yn VARCHAR(2),
|
||||
fuel_oil_yn VARCHAR(2),
|
||||
wreck_removal_yn VARCHAR(2),
|
||||
crew_damage_yn VARCHAR(2),
|
||||
pax_damage_yn VARCHAR(2),
|
||||
hull_damage_yn VARCHAR(2),
|
||||
dock_damage_yn VARCHAR(2),
|
||||
valid_start DATE,
|
||||
valid_end DATE,
|
||||
issue_country VARCHAR(50),
|
||||
issue_org VARCHAR(100),
|
||||
reg_dtm TIMESTAMP,
|
||||
mod_dtm TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ship_ins_ship_nm ON SHIP_INSURANCE(ship_nm);
|
||||
CREATE INDEX idx_ship_ins_imo ON SHIP_INSURANCE(imo_no);
|
||||
CREATE INDEX idx_ship_ins_call_sign ON SHIP_INSURANCE(call_sign);
|
||||
CREATE INDEX idx_ship_ins_insurer ON SHIP_INSURANCE(insurer_nm);
|
||||
CREATE INDEX idx_ship_ins_issue_org ON SHIP_INSURANCE(issue_org);
|
||||
CREATE INDEX idx_ship_ins_ship_tp ON SHIP_INSURANCE(ship_tp);
|
||||
111
frontend/package-lock.json
generated
111
frontend/package-lock.json
generated
@ -25,12 +25,14 @@
|
||||
"@vis.gl/react-maplibre": "^8.1.0",
|
||||
"axios": "^1.13.5",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.564.0",
|
||||
"maplibre-gl": "^5.19.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -2846,6 +2848,15 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
@ -3200,6 +3211,19 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@ -3264,6 +3288,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@ -3339,6 +3372,18 @@
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@ -4018,6 +4063,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@ -4245,6 +4299,12 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@ -5737,6 +5797,18 @@
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
@ -6205,6 +6277,24 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@ -6236,6 +6326,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
|
||||
@ -27,12 +27,14 @@
|
||||
"@vis.gl/react-maplibre": "^8.1.0",
|
||||
"axios": "^1.13.5",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.564.0",
|
||||
"maplibre-gl": "^5.19.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -16,7 +16,7 @@ import { BoardView } from '@tabs/board'
|
||||
import { WeatherView } from '@tabs/weather'
|
||||
import { IncidentsView } from '@tabs/incidents'
|
||||
import { AdminView } from '@tabs/admin'
|
||||
import { PreScatView } from '@tabs/scat'
|
||||
import { ScatView } from '@tabs/scat'
|
||||
import { RescueView } from '@tabs/rescue'
|
||||
|
||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || ''
|
||||
@ -92,7 +92,7 @@ function App() {
|
||||
case 'incidents':
|
||||
return <IncidentsView />
|
||||
case 'scat':
|
||||
return <PreScatView />
|
||||
return <ScatView />
|
||||
case 'admin':
|
||||
return <AdminView />
|
||||
case 'rescue':
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react'
|
||||
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||
import { ScatterplotLayer, PathLayer, TextLayer } from '@deck.gl/layers'
|
||||
import type { PickingInfo } from '@deck.gl/core'
|
||||
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer } from '@deck.gl/layers'
|
||||
import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'
|
||||
import type { StyleSpecification } from 'maplibre-gl'
|
||||
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { layerDatabase } from '@common/services/layerService'
|
||||
import { decimalToDMS } from '@common/utils/coordinates'
|
||||
import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView'
|
||||
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
|
||||
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 }>
|
||||
selectedModels?: Set<PredictionModel>
|
||||
dispersionResult?: DispersionResult | null
|
||||
dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>
|
||||
boomLines?: BoomLine[]
|
||||
isDrawingBoom?: boolean
|
||||
drawingPoints?: BoomLineCoord[]
|
||||
@ -177,6 +177,7 @@ interface MapViewProps {
|
||||
incidentCoord: { lat: number; lon: number }
|
||||
}
|
||||
sensitiveResources?: SensitiveResource[]
|
||||
mapCaptureRef?: React.MutableRefObject<(() => string | null) | null>
|
||||
}
|
||||
|
||||
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
|
||||
@ -201,6 +202,40 @@ function MapPitchController({ threeD }: { threeD: boolean }) {
|
||||
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 {
|
||||
longitude: number
|
||||
@ -218,6 +253,7 @@ export function MapView({
|
||||
oilTrajectory = [],
|
||||
selectedModels = new Set(['OpenDrift'] as PredictionModel[]),
|
||||
dispersionResult = null,
|
||||
dispersionHeatmap = [],
|
||||
boomLines = [],
|
||||
isDrawingBoom = false,
|
||||
drawingPoints = [],
|
||||
@ -225,6 +261,7 @@ export function MapView({
|
||||
layerBrightness = 50,
|
||||
backtrackReplay,
|
||||
sensitiveResources = [],
|
||||
mapCaptureRef,
|
||||
}: MapViewProps) {
|
||||
const { mapToggles } = useMapStore()
|
||||
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 단위) ---
|
||||
if (dispersionResult && incidentCoord) {
|
||||
const zones = dispersionResult.zones.map((zone, idx) => ({
|
||||
@ -452,24 +564,39 @@ export function MapView({
|
||||
stroked: true,
|
||||
radiusUnits: 'meters' as const,
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo) => {
|
||||
if (info.object) {
|
||||
const d = info.object as (typeof zones)[0]
|
||||
autoHighlight: true,
|
||||
onHover: (info: PickingInfo) => {
|
||||
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({
|
||||
longitude: incidentCoord.lon,
|
||||
latitude: incidentCoord.lat,
|
||||
longitude: info.coordinate[0],
|
||||
latitude: info.coordinate[1],
|
||||
content: (
|
||||
<div className="text-xs">
|
||||
<strong className="text-status-orange">{d.level}</strong>
|
||||
<br />
|
||||
물질: {dispersionResult.substance}
|
||||
<br />
|
||||
농도: {dispersionResult.concentration[d.level]}
|
||||
<br />
|
||||
반경: {d.radius}m
|
||||
<div className="text-xs leading-relaxed" style={{ minWidth: 180 }}>
|
||||
<strong className="text-status-orange">{dispersionResult.substance} 대기확산 면적</strong>
|
||||
<table style={{ width: '100%', marginTop: 4, borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{zoneAreas.map(z => (
|
||||
<tr key={z.level} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
|
||||
<td style={{ padding: '2px 0', fontSize: 10 }}>{z.level}</td>
|
||||
<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>
|
||||
),
|
||||
})
|
||||
});
|
||||
} else if (!info.object) {
|
||||
setPopupInfo(null);
|
||||
}
|
||||
},
|
||||
})
|
||||
@ -601,7 +728,7 @@ export function MapView({
|
||||
}, [
|
||||
oilTrajectory, currentTime, selectedModels,
|
||||
boomLines, isDrawingBoom, drawingPoints,
|
||||
dispersionResult, incidentCoord, backtrackReplay,
|
||||
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
|
||||
sensitiveResources,
|
||||
])
|
||||
|
||||
@ -621,9 +748,14 @@ export function MapView({
|
||||
style={{ cursor: isSelectingLocation ? 'crosshair' : 'grab' }}
|
||||
onClick={handleMapClick}
|
||||
attributionControl={false}
|
||||
preserveDrawingBuffer={true}
|
||||
>
|
||||
{/* 지도 캡처 셋업 */}
|
||||
{mapCaptureRef && <MapCaptureSetup captureRef={mapCaptureRef} />}
|
||||
{/* 3D 모드 pitch 제어 */}
|
||||
<MapPitchController threeD={mapToggles.threeD} />
|
||||
{/* 사고 지점 변경 시 지도 이동 */}
|
||||
<MapFlyToIncident lon={incidentCoord?.lon} lat={incidentCoord?.lat} />
|
||||
|
||||
{/* WMS 레이어 */}
|
||||
{wmsLayers.map(layer => (
|
||||
@ -652,9 +784,10 @@ export function MapView({
|
||||
<DeckGLOverlay layers={deckLayers} />
|
||||
|
||||
{/* 사고 위치 마커 (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">
|
||||
<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"
|
||||
style={{
|
||||
borderRadius: '50% 50% 50% 0',
|
||||
@ -665,25 +798,6 @@ export function MapView({
|
||||
</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 객체 클릭 팝업 */}
|
||||
{popupInfo && (
|
||||
<Popup
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
/** hex 색상(#rrggbb)을 deck.gl용 RGBA 배열로 변환 */
|
||||
export function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return [r, g, b, alpha]
|
||||
/** 색상 문자열(#rrggbb 또는 rgba(...))을 deck.gl용 RGBA 배열로 변환 */
|
||||
export function hexToRgba(color: string, alpha = 255): [number, number, number, number] {
|
||||
// rgba(r,g,b,a) 형식 처리
|
||||
const rgbaMatch = color.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/)
|
||||
if (rgbaMatch) {
|
||||
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]
|
||||
}
|
||||
|
||||
@ -46,7 +46,11 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
|
||||
{ id: 'theory', label: '항공탐색 이론', icon: '📐' }
|
||||
],
|
||||
assets: null,
|
||||
scat: null,
|
||||
scat: [
|
||||
{ id: 'survey', label: '해안오염 조사 평가', icon: '📋' },
|
||||
{ id: 'distribution', label: '해양오염분포도', icon: '🗺' },
|
||||
{ id: 'pre-scat', label: 'Pre-SCAT', icon: '🔍' }
|
||||
],
|
||||
incidents: null,
|
||||
board: [
|
||||
{ id: 'all', label: '전체', icon: '📋' },
|
||||
@ -72,7 +76,7 @@ const subMenuState: Record<MainTab, string> = {
|
||||
reports: 'report-list',
|
||||
aerial: 'media',
|
||||
assets: '',
|
||||
scat: '',
|
||||
scat: 'survey',
|
||||
incidents: '',
|
||||
board: 'all',
|
||||
weather: '',
|
||||
@ -157,4 +161,23 @@ export function consumeReportGenCategory(): number | null {
|
||||
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 }
|
||||
|
||||
@ -73,9 +73,18 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
hasPermission: (resource: string, operation?: string) => {
|
||||
const { user } = get()
|
||||
if (!user) return false
|
||||
const op = operation ?? 'READ'
|
||||
// 정확한 리소스 권한 확인
|
||||
const ops = user.permissions[resource]
|
||||
if (!ops) return false
|
||||
return ops.includes(operation ?? 'READ')
|
||||
if (ops) return ops.includes(op)
|
||||
// 'scat:survey' → 부모 'scat' 권한으로 fallback
|
||||
const colonIdx = resource.indexOf(':')
|
||||
if (colonIdx > 0) {
|
||||
const parent = resource.substring(0, colonIdx)
|
||||
const parentOps = user.permissions[parent]
|
||||
if (parentOps) return parentOps.includes(op)
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null, pendingMessage: null }),
|
||||
|
||||
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 { fetchCctvCameras } 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 = [
|
||||
{ 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() {
|
||||
@ -21,9 +59,9 @@ export function CctvView() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await fetchCctvCameras()
|
||||
setCameras(items)
|
||||
} catch (err) {
|
||||
console.error('[aerial] CCTV 목록 조회 실패:', err)
|
||||
setCameras(items.length > 0 ? items : FALLBACK_CAMERAS)
|
||||
} catch {
|
||||
setCameras(FALLBACK_CAMERAS)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@ -203,21 +241,14 @@ export function CctvView() {
|
||||
return (
|
||||
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-[#0a0e18]" style={{ border: '1px solid rgba(255,255,255,.06)' }}>
|
||||
{cam ? (
|
||||
<>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-4xl opacity-20">📹</div>
|
||||
</div>
|
||||
<div className="absolute top-2 left-2 flex items-center gap-1.5">
|
||||
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-black/70">{cam.cameraNm}</span>
|
||||
<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">
|
||||
{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>
|
||||
</>
|
||||
<CCTVPlayer
|
||||
cameraNm={cam.cameraNm}
|
||||
streamUrl={cam.streamUrl}
|
||||
sttsCd={cam.sttsCd}
|
||||
coordDc={cam.coordDc}
|
||||
sourceNm={cam.sourceNm}
|
||||
cellIndex={i}
|
||||
/>
|
||||
) : (
|
||||
<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';
|
||||
}
|
||||
@ -12,6 +12,7 @@ function AssetManagement() {
|
||||
const [regionFilter, setRegionFilter] = useState('all')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [typeFilterVal, setTypeFilterVal] = useState('all')
|
||||
const [equipFilter, setEquipFilter] = useState('all')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const pageSize = 15
|
||||
@ -44,6 +45,16 @@ function AssetManagement() {
|
||||
const filtered = organizations.filter(o => {
|
||||
if (regionFilter !== 'all' && !o.jurisdiction.includes(regionFilter)) return false
|
||||
if (typeFilterVal !== 'all' && o.type !== typeFilterVal) return false
|
||||
if (equipFilter !== 'all') {
|
||||
const equipMap: Record<string, (org: AssetOrgCompat) => boolean> = {
|
||||
vessel: org => org.vessel > 0,
|
||||
skimmer: org => org.skimmer > 0,
|
||||
pump: org => org.pump > 0,
|
||||
vehicle: org => org.vehicle > 0,
|
||||
sprayer: org => org.sprayer > 0,
|
||||
}
|
||||
if (equipMap[equipFilter] && !equipMap[equipFilter](o)) return false
|
||||
}
|
||||
if (searchTerm && !o.name.includes(searchTerm) && !o.address.includes(searchTerm)) return false
|
||||
return true
|
||||
})
|
||||
@ -54,7 +65,7 @@ function AssetManagement() {
|
||||
|
||||
// 필터 변경 시 첫 페이지로
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { setCurrentPage(1) }, [regionFilter, typeFilterVal, searchTerm])
|
||||
useEffect(() => { setCurrentPage(1) }, [regionFilter, typeFilterVal, equipFilter, searchTerm])
|
||||
|
||||
const regionShort = (j: string) => {
|
||||
if (j.includes('중부')) return '중부청'
|
||||
@ -129,6 +140,14 @@ function AssetManagement() {
|
||||
<option value="해군">해군</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
<select value={equipFilter} onChange={e => setEquipFilter(e.target.value)} className="prd-i w-[100px] py-1.5 px-2">
|
||||
<option value="all">전체 장비</option>
|
||||
<option value="vessel">방제선</option>
|
||||
<option value="skimmer">유회수기</option>
|
||||
<option value="pump">이송펌프</option>
|
||||
<option value="vehicle">방제차량</option>
|
||||
<option value="sprayer">살포장치</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -152,11 +171,15 @@ function AssetManagement() {
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-0">
|
||||
{['번호', '유형', '관할청', '기관명', '주소', '방제선', '유회수기', '이송펌프', '방제차량', '살포장치', '총자산'].map((h, i) => (
|
||||
<th key={i} className={`px-2.5 py-2.5 text-[10px] font-bold text-text-2 font-korean border-b border-border ${[0,5,6,7,8,9,10].includes(i) ? 'text-center' : ''}`}>
|
||||
{['번호', '유형', '관할청', '기관명', '주소', '방제선', '유회수기', '이송펌프', '방제차량', '살포장치', '총자산'].map((h, i) => {
|
||||
const equipColMap: Record<string, number> = { vessel: 5, skimmer: 6, pump: 7, vehicle: 8, sprayer: 9 }
|
||||
const isHighlight = equipFilter !== 'all' && equipColMap[equipFilter] === i
|
||||
return (
|
||||
<th key={i} className={`px-2.5 py-2.5 text-[10px] font-bold font-korean border-b border-border ${[0,5,6,7,8,9,10].includes(i) ? 'text-center' : ''} ${isHighlight ? 'text-primary-cyan bg-primary-cyan/5' : 'text-text-2'}`}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -175,11 +198,11 @@ function AssetManagement() {
|
||||
<td className="px-2.5 py-2 text-[10px] font-semibold font-korean">{regionShort(org.jurisdiction)}</td>
|
||||
<td className="px-2.5 py-2 text-[10px] font-semibold text-primary-cyan font-korean cursor-pointer truncate">{org.name}</td>
|
||||
<td className="px-2.5 py-2 text-[10px] text-text-3 font-korean truncate">{org.address}</td>
|
||||
<td className="px-2.5 py-2 text-center font-mono text-[10px] font-semibold">{org.vessel}척</td>
|
||||
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.skimmer}대</td>
|
||||
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.pump}대</td>
|
||||
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.vehicle}대</td>
|
||||
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.sprayer}대</td>
|
||||
<td className={`px-2.5 py-2 text-center font-mono text-[10px] font-semibold ${equipFilter === 'vessel' ? 'text-primary-cyan bg-primary-cyan/5' : ''}`}>{org.vessel}척</td>
|
||||
<td className={`px-2.5 py-2 text-center font-mono text-[10px] ${equipFilter === 'skimmer' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : ''}`}>{org.skimmer}대</td>
|
||||
<td className={`px-2.5 py-2 text-center font-mono text-[10px] ${equipFilter === 'pump' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : ''}`}>{org.pump}대</td>
|
||||
<td className={`px-2.5 py-2 text-center font-mono text-[10px] ${equipFilter === 'vehicle' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : ''}`}>{org.vehicle}대</td>
|
||||
<td className={`px-2.5 py-2 text-center font-mono text-[10px] ${equipFilter === 'sprayer' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : ''}`}>{org.sprayer}대</td>
|
||||
<td className="px-2.5 py-2 text-center font-bold text-primary-cyan font-mono text-[10px]">{org.totalAssets}</td>
|
||||
</tr>
|
||||
))}
|
||||
@ -187,6 +210,29 @@ function AssetManagement() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Totals Summary */}
|
||||
<div className="flex items-center justify-end gap-4 px-4 py-2 border-t border-border bg-bg-0/80">
|
||||
<span className="text-[10px] text-text-3 font-korean font-semibold mr-auto">
|
||||
합계 ({filtered.length}개 기관)
|
||||
</span>
|
||||
{[
|
||||
{ key: 'vessel', label: '방제선', value: filtered.reduce((s, o) => s + o.vessel, 0), unit: '척' },
|
||||
{ key: 'skimmer', label: '유회수기', value: filtered.reduce((s, o) => s + o.skimmer, 0), unit: '대' },
|
||||
{ key: 'pump', label: '이송펌프', value: filtered.reduce((s, o) => s + o.pump, 0), unit: '대' },
|
||||
{ key: 'vehicle', label: '방제차량', value: filtered.reduce((s, o) => s + o.vehicle, 0), unit: '대' },
|
||||
{ key: 'sprayer', label: '살포장치', value: filtered.reduce((s, o) => s + o.sprayer, 0), unit: '대' },
|
||||
{ key: 'total', label: '총자산', value: filtered.reduce((s, o) => s + o.totalAssets, 0), unit: '' },
|
||||
].map((t) => {
|
||||
const isActive = equipFilter === t.key || t.key === 'total'
|
||||
return (
|
||||
<div key={t.key} className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${equipFilter === t.key ? 'bg-primary-cyan/10' : ''}`}>
|
||||
<span className={`text-[9px] font-korean ${isActive ? 'text-primary-cyan' : 'text-text-3'}`}>{t.label}</span>
|
||||
<span className={`text-[10px] font-mono font-bold ${isActive ? 'text-primary-cyan' : 'text-text-1'}`}>{t.value.toLocaleString()}{t.unit}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-center gap-4 px-4 py-2.5 border-t border-border bg-bg-0">
|
||||
<span className="text-[10px] text-text-3 font-korean">
|
||||
@ -328,22 +374,44 @@ function AssetManagement() {
|
||||
)}
|
||||
|
||||
{detailTab === 'contact' && (
|
||||
<div className="bg-bg-3 border border-border rounded-sm p-3">
|
||||
{selectedOrg.contacts.length > 0 ? selectedOrg.contacts.map((c, i) => (
|
||||
<div key={i} className="flex flex-col gap-1 mb-3 last:mb-0">
|
||||
{[
|
||||
['기관/업체', c.name],
|
||||
['연락처', c.phone],
|
||||
].map(([k, v], j) => (
|
||||
<div key={j} className="flex justify-between py-1 text-[11px]">
|
||||
<span className="text-text-3 font-korean">{k}</span>
|
||||
<span className="font-mono text-text-1">{v}</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 기관 기본 정보 */}
|
||||
<div className="bg-bg-3 border border-border rounded-sm p-3">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-2 font-korean">기관 정보</div>
|
||||
{[
|
||||
['기관명', selectedOrg.name],
|
||||
['유형', selectedOrg.type],
|
||||
['관할청', selectedOrg.jurisdiction],
|
||||
['주소', selectedOrg.address],
|
||||
...(selectedOrg.phone ? [['대표 연락처', selectedOrg.phone]] : []),
|
||||
].map(([k, v], j) => (
|
||||
<div key={j} className="flex justify-between py-1.5 text-[11px] border-b border-border/30 last:border-b-0">
|
||||
<span className="text-text-3 font-korean shrink-0 mr-2">{k}</span>
|
||||
<span className={`text-text-1 text-right ${k === '대표 연락처' ? 'font-mono font-semibold text-primary-cyan' : 'font-korean'}`}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 담당자 목록 */}
|
||||
{selectedOrg.contacts.length > 0 && (
|
||||
<div className="bg-bg-3 border border-border rounded-sm p-3">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-2 font-korean">담당자</div>
|
||||
{selectedOrg.contacts.map((c, i) => (
|
||||
<div key={i} className="mb-2.5 last:mb-0">
|
||||
{[
|
||||
['직책', c.role],
|
||||
['담당자', c.name],
|
||||
['연락처', c.phone],
|
||||
].filter(([, v]) => v).map(([k, v], j) => (
|
||||
<div key={j} className="flex justify-between py-1.5 text-[11px] border-b border-border/30 last:border-b-0">
|
||||
<span className="text-text-3 font-korean">{k}</span>
|
||||
<span className={`text-text-1 ${k === '연락처' ? 'font-mono font-semibold text-primary-cyan' : 'font-korean'}`}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
{i < selectedOrg.contacts.length - 1 && <div className="border-t border-border mt-1" />}
|
||||
</div>
|
||||
))}
|
||||
{i < selectedOrg.contacts.length - 1 && <div className="border-t border-border my-1" />}
|
||||
</div>
|
||||
)) : (
|
||||
<div className="text-center text-text-3 text-xs py-4 font-korean">연락처 정보가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,285 +1,272 @@
|
||||
import { useState } from 'react'
|
||||
import type { InsuranceRow } from './assetTypes'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { fetchInsurance } from '../services/assetsApi'
|
||||
import type { ShipInsuranceItem } from '../services/assetsApi'
|
||||
|
||||
const DEFAULT_HAEWOON_API = import.meta.env.VITE_HAEWOON_API_URL || 'https://api.haewoon.or.kr/v1/insurance'
|
||||
|
||||
// 샘플 데이터 (외부 한국해운조합 API 연동 전 데모용)
|
||||
const INSURANCE_DEMO_DATA: InsuranceRow[] = [
|
||||
{ shipName: '유조선 한라호', mmsi: '440123456', imo: '9876001', insType: 'P&I 보험', insurer: '한국P&I클럽', policyNo: 'PI-2025-1234', start: '2025-07-01', expiry: '2026-06-30', limit: '50억' },
|
||||
{ shipName: '화학물질운반선 제주호', mmsi: '440345678', imo: '9876002', insType: '선주책임보험', insurer: '삼성화재', policyNo: 'SF-2025-9012', start: '2025-09-16', expiry: '2026-09-15', limit: '80억' },
|
||||
{ shipName: '방제선 OCEAN STAR', mmsi: '440123789', imo: '9876003', insType: 'P&I 보험', insurer: '한국P&I클럽', policyNo: 'PI-2025-3456', start: '2025-11-21', expiry: '2026-11-20', limit: '120억' },
|
||||
{ shipName: 'LNG운반선 부산호', mmsi: '440567890', imo: '9876004', insType: '해상보험', insurer: 'DB손해보험', policyNo: 'DB-2025-7890', start: '2025-08-02', expiry: '2026-08-01', limit: '200억' },
|
||||
{ shipName: '유조선 백두호', mmsi: '440789012', imo: '9876005', insType: 'P&I 보험', insurer: 'SK해운보험', policyNo: 'MH-2025-5678', start: '2025-01-01', expiry: '2025-12-31', limit: '30억' },
|
||||
]
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
function ShipInsurance() {
|
||||
const [apiConnected, setApiConnected] = useState(false)
|
||||
const [showConfig, setShowConfig] = useState(false)
|
||||
const [configEndpoint, setConfigEndpoint] = useState(DEFAULT_HAEWOON_API)
|
||||
const [configApiKey, setConfigApiKey] = useState('')
|
||||
const [configKeyType, setConfigKeyType] = useState('mmsi')
|
||||
const [configRespType, setConfigRespType] = useState('json')
|
||||
const [searchType, setSearchType] = useState('mmsi')
|
||||
const [searchVal, setSearchVal] = useState('')
|
||||
const [insTypeFilter, setInsTypeFilter] = useState('전체')
|
||||
const [viewState, setViewState] = useState<'empty' | 'loading' | 'result'>('empty')
|
||||
const [resultData, setResultData] = useState<InsuranceRow[]>([])
|
||||
const [lastSync, setLastSync] = useState('—')
|
||||
const [rows, setRows] = useState<ShipInsuranceItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const placeholderMap: Record<string, string> = {
|
||||
mmsi: 'MMSI 번호 입력 (예: 440123456)',
|
||||
imo: 'IMO 번호 입력 (예: 9876543)',
|
||||
shipname: '선박명 입력 (예: 한라호)',
|
||||
callsign: '호출부호 입력 (예: HLXX1)',
|
||||
}
|
||||
// 필터
|
||||
const [search, setSearch] = useState('')
|
||||
const [shipTpFilter, setShipTpFilter] = useState('')
|
||||
const [issueOrgFilter, setIssueOrgFilter] = useState('')
|
||||
|
||||
const getStatus = (expiry: string) => {
|
||||
const loadData = useCallback(async (p: number) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetchInsurance({
|
||||
search: search || undefined,
|
||||
shipTp: shipTpFilter || undefined,
|
||||
issueOrg: issueOrgFilter || undefined,
|
||||
page: p,
|
||||
limit: PAGE_SIZE,
|
||||
})
|
||||
setRows(res.rows)
|
||||
setTotal(res.total)
|
||||
setPage(p)
|
||||
} catch (err) {
|
||||
setError((err as { message?: string })?.message || '조회 실패')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [search, shipTpFilter, issueOrgFilter])
|
||||
|
||||
useEffect(() => { loadData(1) }, [loadData])
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||
|
||||
const getStatus = (endDate: string) => {
|
||||
const now = new Date()
|
||||
const exp = new Date(expiry)
|
||||
const daysLeft = Math.ceil((exp.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (exp < now) return 'expired' as const
|
||||
const end = new Date(endDate)
|
||||
const daysLeft = Math.ceil((end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (end < now) return 'expired' as const
|
||||
if (daysLeft <= 30) return 'soon' as const
|
||||
return 'valid' as const
|
||||
}
|
||||
|
||||
const handleSaveConfig = () => {
|
||||
if (!configApiKey) { alert('API Key를 입력하세요.'); return }
|
||||
setShowConfig(false)
|
||||
alert('API 설정이 저장되었습니다.')
|
||||
const ynBadge = (yn: string) => {
|
||||
const isY = yn === 'Y'
|
||||
return (
|
||||
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold" style={{
|
||||
background: isY ? 'rgba(34,197,94,.15)' : 'rgba(100,116,139,.1)',
|
||||
color: isY ? 'var(--green)' : 'var(--text-3)',
|
||||
}}>
|
||||
{isY ? 'Y' : 'N'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const handleTestConnect = async () => {
|
||||
await new Promise(r => setTimeout(r, 1200))
|
||||
alert('⚠ API Key가 설정되지 않았습니다.\n[API 설정] 버튼에서 한국해운조합 API Key를 먼저 등록하세요.')
|
||||
const handleSearch = () => loadData(1)
|
||||
const handleReset = () => {
|
||||
setSearch('')
|
||||
setShipTpFilter('')
|
||||
setIssueOrgFilter('')
|
||||
}
|
||||
|
||||
const loadDemoData = () => {
|
||||
setResultData(INSURANCE_DEMO_DATA)
|
||||
setViewState('result')
|
||||
setApiConnected(false)
|
||||
setLastSync(new Date().toLocaleString('ko-KR'))
|
||||
}
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const all = await fetchInsurance({
|
||||
search: search || undefined,
|
||||
shipTp: shipTpFilter || undefined,
|
||||
issueOrg: issueOrgFilter || undefined,
|
||||
page: 1,
|
||||
limit: 200,
|
||||
})
|
||||
// 200건 초과 시 추가 페이지 로드
|
||||
let allRows = [...all.rows]
|
||||
const pages = Math.ceil(all.total / 200)
|
||||
for (let p = 2; p <= pages; p++) {
|
||||
const res = await fetchInsurance({
|
||||
search: search || undefined,
|
||||
shipTp: shipTpFilter || undefined,
|
||||
issueOrg: issueOrgFilter || undefined,
|
||||
page: p,
|
||||
limit: 200,
|
||||
})
|
||||
allRows = allRows.concat(res.rows)
|
||||
}
|
||||
|
||||
const handleQuery = async () => {
|
||||
if (!searchVal.trim()) { alert('조회값을 입력하세요.'); return }
|
||||
setViewState('loading')
|
||||
await new Promise(r => setTimeout(r, 900))
|
||||
loadDemoData()
|
||||
}
|
||||
const excelRows = allRows.map((r, i) => ({
|
||||
'No': i + 1,
|
||||
'선박명': r.shipNm,
|
||||
'호출부호': r.callSign,
|
||||
'IMO': r.imoNo,
|
||||
'선박종류': r.shipTp,
|
||||
'선박종류상세': r.shipTpDetail,
|
||||
'선주': r.ownerNm,
|
||||
'총톤수': r.grossTon ? Number(r.grossTon) : '',
|
||||
'보험사': r.insurerNm,
|
||||
'책임보험': r.liabilityYn,
|
||||
'유류오염': r.oilPollutionYn,
|
||||
'연료유오염': r.fuelOilYn,
|
||||
'난파물제거': r.wreckRemovalYn,
|
||||
'유효시작': r.validStart,
|
||||
'유효종료': r.validEnd,
|
||||
'발급기관': r.issueOrg,
|
||||
}))
|
||||
|
||||
const handleBatchQuery = async () => {
|
||||
setViewState('loading')
|
||||
await new Promise(r => setTimeout(r, 1400))
|
||||
loadDemoData()
|
||||
const ws = XLSX.utils.json_to_sheet(excelRows)
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, '유류오염보장계약')
|
||||
XLSX.writeFile(wb, `유류오염보장계약_${new Date().toISOString().slice(0, 10)}.xlsx`)
|
||||
} catch {
|
||||
alert('다운로드 중 오류가 발생했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFullSync = async () => {
|
||||
setLastSync('동기화 중...')
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
setLastSync(new Date().toLocaleString('ko-KR'))
|
||||
alert('전체 동기화는 API 연동 후 활성화됩니다.')
|
||||
}
|
||||
|
||||
// summary computation
|
||||
const validCount = resultData.filter(r => getStatus(r.expiry) !== 'expired').length
|
||||
const soonList = resultData.filter(r => getStatus(r.expiry) === 'soon')
|
||||
const expiredList = resultData.filter(r => getStatus(r.expiry) === 'expired')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-auto">
|
||||
|
||||
{/* ── 헤더 ── */}
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-start justify-between mb-5">
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5 mb-1">
|
||||
<div className="text-[18px] font-bold">🛡 선박 보험정보 조회</div>
|
||||
<div className="text-[18px] font-bold">유류오염보장계약 관리</div>
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-bold"
|
||||
style={{
|
||||
background: apiConnected ? 'rgba(34,197,94,.12)' : 'rgba(239,68,68,.12)',
|
||||
color: apiConnected ? 'var(--green)' : 'var(--red)',
|
||||
border: `1px solid ${apiConnected ? 'rgba(34,197,94,.25)' : 'rgba(239,68,68,.25)'}`,
|
||||
background: total > 0 ? 'rgba(34,197,94,.12)' : 'rgba(239,68,68,.12)',
|
||||
color: total > 0 ? 'var(--green)' : 'var(--red)',
|
||||
border: `1px solid ${total > 0 ? 'rgba(34,197,94,.25)' : 'rgba(239,68,68,.25)'}`,
|
||||
}}>
|
||||
<span className="w-1.5 h-1.5 rounded-full inline-block" style={{ background: apiConnected ? 'var(--green)' : 'var(--red)' }} />
|
||||
{apiConnected ? 'API 연결됨' : 'API 미연결'}
|
||||
<span className="w-1.5 h-1.5 rounded-full inline-block" style={{ background: total > 0 ? 'var(--green)' : 'var(--red)' }} />
|
||||
{total > 0 ? `${total.toLocaleString()}건` : '데이터 없음'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-text-3">한국해운조합(KSA) Open API 연동 · 선박 P&I 보험 및 선주 책임보험 실시간 조회</div>
|
||||
<div className="text-xs text-text-3">해양수산부 해운항만물류정보 공공데이터 기반</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleTestConnect} className="px-4 py-2 text-xs font-semibold cursor-pointer rounded-sm" style={{ background: 'rgba(6,182,212,.12)', color: 'var(--cyan)', border: '1px solid rgba(6,182,212,.3)' }}>🔌 연결 테스트</button>
|
||||
<button onClick={() => setShowConfig(v => !v)} className="px-4 py-2 text-xs font-semibold cursor-pointer rounded-sm bg-bg-3 text-text-2 border border-border">⚙ API 설정</button>
|
||||
<button
|
||||
onClick={() => window.open('https://www.haewoon.or.kr', '_blank', 'noopener')}
|
||||
className="px-4 py-2 text-[11px] font-bold cursor-pointer rounded-sm"
|
||||
style={{ background: 'rgba(59,130,246,.12)', color: 'var(--blue)', border: '1px solid rgba(59,130,246,.3)' }}
|
||||
>
|
||||
한국해운조합 API
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open('https://new.portmis.go.kr', '_blank', 'noopener')}
|
||||
className="px-4 py-2 text-[11px] font-bold cursor-pointer rounded-sm"
|
||||
style={{ background: 'rgba(168,85,247,.12)', color: 'var(--purple)', border: '1px solid rgba(168,85,247,.3)' }}
|
||||
>
|
||||
PortMIS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── API 설정 패널 ── */}
|
||||
{showConfig && (
|
||||
<div className="bg-bg-3 border border-border rounded-md p-5 mb-5">
|
||||
<div className="text-[13px] font-bold mb-3.5 text-primary-cyan">⚙ 한국해운조합 API 연동 설정</div>
|
||||
<div className="grid gap-3 mb-4" style={{ gridTemplateColumns: '1fr 1fr' }}>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">API Endpoint URL</label>
|
||||
<input type="text" value={configEndpoint} onChange={e => setConfigEndpoint(e.target.value)} placeholder="https://api.haewoon.or.kr/v1/..."
|
||||
className="w-full px-3 py-2 bg-bg-0 border border-border rounded-sm font-mono text-xs outline-none box-border" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">API Key</label>
|
||||
<input type="password" value={configApiKey} onChange={e => setConfigApiKey(e.target.value)} placeholder="발급받은 API Key 입력"
|
||||
className="w-full px-3 py-2 bg-bg-0 border border-border rounded-sm font-mono text-xs outline-none box-border" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">조회 기본값 — 조회 키 타입</label>
|
||||
<select value={configKeyType} onChange={e => setConfigKeyType(e.target.value)} className="prd-i w-full border-border">
|
||||
<option value="mmsi">MMSI</option>
|
||||
<option value="imo">IMO 번호</option>
|
||||
<option value="shipname">선박명</option>
|
||||
<option value="callsign">호출부호</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">응답 형식</label>
|
||||
<select value={configRespType} onChange={e => setConfigRespType(e.target.value)} className="prd-i w-full border-border">
|
||||
<option value="json">JSON</option>
|
||||
<option value="xml">XML</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleSaveConfig} className="px-5 py-2 text-white border-none rounded-sm text-xs font-bold cursor-pointer" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>💾 저장</button>
|
||||
<button onClick={() => setShowConfig(false)} className="px-4 py-2 bg-bg-0 text-text-2 border border-border rounded-sm text-xs cursor-pointer">취소</button>
|
||||
</div>
|
||||
{/* API 연동 안내 */}
|
||||
<div className="mt-4 px-4 py-3 rounded-sm text-[10px] text-text-3 leading-[1.8]" style={{ background: 'rgba(6,182,212,.05)', border: '1px solid rgba(6,182,212,.15)' }}>
|
||||
<span className="text-primary-cyan font-bold">📋 한국해운조합 API 발급 안내</span><br />
|
||||
• 한국해운조합 공공데이터포털 또는 해운조합 IT지원팀에 API 키 신청<br />
|
||||
• 해양경찰청 기관 계정으로 신청 시 전용 엔드포인트 및 키 발급<br />
|
||||
• 조회 가능 데이터: P&I 보험, 선주책임보험, 해상보험 가입 여부, 증권번호, 보험기간, 보상한도
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 검색 영역 ── */}
|
||||
{/* 필터 */}
|
||||
<div className="bg-bg-3 border border-border rounded-md px-5 py-4 mb-4">
|
||||
<div className="text-xs font-bold mb-3 text-text-2">🔍 보험정보 조회</div>
|
||||
<div className="flex gap-2 items-end flex-wrap">
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-text-3 mb-1">조회 키 타입</label>
|
||||
<select value={searchType} onChange={e => setSearchType(e.target.value)} className="prd-i min-w-[120px] border-border">
|
||||
<option value="mmsi">MMSI</option>
|
||||
<option value="imo">IMO 번호</option>
|
||||
<option value="shipname">선박명</option>
|
||||
<option value="callsign">호출부호</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[220px]">
|
||||
<label className="block text-[10px] font-semibold text-text-3 mb-1">조회값</label>
|
||||
<input type="text" value={searchVal} onChange={e => setSearchVal(e.target.value)} placeholder={placeholderMap[searchType]}
|
||||
className="w-full px-3.5 py-2 bg-bg-0 border border-border rounded-sm font-mono text-[13px] outline-none box-border" />
|
||||
<div className="flex gap-2.5 items-end flex-wrap">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-[10px] font-semibold text-text-3 mb-1">검색 (선박명/호출부호/IMO/선주)</label>
|
||||
<input
|
||||
type="text" value={search} onChange={e => setSearch(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="선박명, 호출부호, IMO, 선주명"
|
||||
className="w-full px-3.5 py-2 bg-bg-0 border border-border rounded-sm text-xs outline-none box-border"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-text-3 mb-1">보험 종류</label>
|
||||
<select value={insTypeFilter} onChange={e => setInsTypeFilter(e.target.value)} className="prd-i min-w-[140px] border-border">
|
||||
<option>전체</option>
|
||||
<option>P&I 보험</option>
|
||||
<option>선주책임보험</option>
|
||||
<option>해상보험(선박)</option>
|
||||
<option>방제보증보험</option>
|
||||
<label className="block text-[10px] font-semibold text-text-3 mb-1">선박종류</label>
|
||||
<select value={shipTpFilter} onChange={e => setShipTpFilter(e.target.value)} className="prd-i min-w-[120px] border-border">
|
||||
<option value="">전체</option>
|
||||
<option value="일반선박">일반선박</option>
|
||||
<option value="유조선">유조선</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={handleQuery} className="px-6 py-2 text-white border-none rounded-sm text-[13px] font-bold cursor-pointer shrink-0" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>🔍 조회</button>
|
||||
<button onClick={handleBatchQuery} className="px-4 py-2 text-[12px] font-semibold cursor-pointer shrink-0 rounded-sm" style={{ background: 'rgba(168,85,247,.12)', color: 'var(--purple)', border: '1px solid rgba(168,85,247,.3)' }}>📋 자산목록 일괄조회</button>
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-text-3 mb-1">발급기관</label>
|
||||
<select value={issueOrgFilter} onChange={e => setIssueOrgFilter(e.target.value)} className="prd-i min-w-[160px] border-border">
|
||||
<option value="">전체</option>
|
||||
<option>부산지방해양수산청</option>
|
||||
<option>인천지방해양수산청</option>
|
||||
<option>여수지방해양수산청</option>
|
||||
<option>울산지방해양수산청</option>
|
||||
<option>동해지방해양수산청</option>
|
||||
<option>목포지방해양수산청</option>
|
||||
<option>군산지방해양수산청</option>
|
||||
<option>마산지방해양수산청</option>
|
||||
<option>대산지방해양수산청</option>
|
||||
<option>평택지방해양수산청</option>
|
||||
<option>제주지방해양수산청</option>
|
||||
<option>포항지방해양수산청</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={handleSearch} className="px-5 py-2 text-white border-none rounded-sm text-xs font-bold cursor-pointer" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>조회</button>
|
||||
<button onClick={handleReset} className="px-4 py-2 bg-bg-0 text-text-2 border border-border rounded-sm text-xs cursor-pointer">초기화</button>
|
||||
<button onClick={handleDownload} disabled={total === 0} className="px-4 py-2 text-xs font-bold cursor-pointer rounded-sm disabled:opacity-30 disabled:cursor-default" style={{ background: 'rgba(34,197,94,.12)', color: 'var(--green)', border: '1px solid rgba(34,197,94,.3)' }}>엑셀 다운로드</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 결과 영역 ── */}
|
||||
|
||||
{/* 초기 안내 상태 */}
|
||||
{viewState === 'empty' && (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-5 bg-bg-3 border border-border rounded-md">
|
||||
<div className="text-[48px] mb-4 opacity-30">🛡</div>
|
||||
<div className="text-sm font-bold text-text-2 mb-2">한국해운조합 API 연동 대기 중</div>
|
||||
<div className="text-xs text-text-3 text-center leading-[1.8]">
|
||||
API 설정에서 한국해운조합 API Key를 등록하거나<br />
|
||||
MMSI·IMO·선박명으로 직접 조회하세요.<br />
|
||||
<span className="text-primary-cyan">자산목록 일괄조회</span> 시 등록된 방제자산 전체의 보험 현황을 한번에 확인할 수 있습니다.
|
||||
</div>
|
||||
<div className="mt-5 flex gap-2.5">
|
||||
<button onClick={() => setShowConfig(true)} className="px-5 py-2.5 text-xs font-semibold cursor-pointer rounded-sm" style={{ background: 'rgba(6,182,212,.12)', color: 'var(--cyan)', border: '1px solid rgba(6,182,212,.3)' }}>⚙ API 설정</button>
|
||||
<button onClick={loadDemoData} className="px-5 py-2.5 bg-bg-0 text-text-2 border border-border rounded-sm text-xs font-semibold cursor-pointer">📊 샘플 데이터 보기</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 */}
|
||||
{viewState === 'loading' && (
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center p-16 bg-bg-3 border border-border rounded-md">
|
||||
<div className="w-9 h-9 rounded-full mb-3.5" style={{ border: '3px solid var(--bd)', borderTopColor: 'var(--cyan)', animation: 'spin 0.8s linear infinite' }} />
|
||||
<div className="text-[13px] text-text-2">한국해운조합 API 조회 중...</div>
|
||||
<div className="text-[13px] text-text-2">보험 데이터 조회 중...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 결과 테이블 */}
|
||||
{viewState === 'result' && (
|
||||
<>
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid gap-2.5 mb-3.5" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
|
||||
{[
|
||||
{ label: '전체', val: resultData.length, color: 'var(--cyan)', bg: 'rgba(6,182,212,.08)' },
|
||||
{ label: '유효', val: validCount, color: 'var(--green)', bg: 'rgba(34,197,94,.08)' },
|
||||
{ label: '만료임박(30일)', val: soonList.length, color: 'var(--yellow)', bg: 'rgba(234,179,8,.08)' },
|
||||
{ label: '만료/미가입', val: resultData.length - validCount, color: 'var(--red)', bg: 'rgba(239,68,68,.08)' },
|
||||
].map((c, i) => (
|
||||
<div key={i} className="px-4 py-3.5 text-center rounded-sm" style={{ background: c.bg, border: `1px solid ${c.color}33` }}>
|
||||
<div className="text-[22px] font-extrabold font-mono" style={{ color: c.color }}>{c.val}</div>
|
||||
<div className="text-[10px] text-text-3 mt-0.5">{c.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 에러 */}
|
||||
{error && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-5 bg-bg-3 border border-border rounded-md">
|
||||
<div className="text-sm font-bold text-status-red mb-2">조회 실패</div>
|
||||
<div className="text-xs text-text-3">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 */}
|
||||
{/* 테이블 */}
|
||||
{!isLoading && !error && (
|
||||
<>
|
||||
<div className="bg-bg-3 border border-border rounded-md overflow-hidden mb-3">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div className="text-xs font-bold">조회 결과 <span className="text-primary-cyan">{resultData.length}</span>건</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button onClick={() => alert('엑셀 내보내기 기능은 실제 API 연동 후 활성화됩니다.')} className="px-3 py-1 text-[11px] font-semibold cursor-pointer rounded-sm" style={{ background: 'rgba(34,197,94,.1)', color: 'var(--green)', border: '1px solid rgba(34,197,94,.25)' }}>📥 엑셀 내보내기</button>
|
||||
<button onClick={handleQuery} className="px-3 py-1 bg-bg-0 text-text-2 border border-border rounded-sm text-[11px] cursor-pointer">🔄 새로고침</button>
|
||||
<div className="text-xs font-bold">
|
||||
조회 결과 <span className="text-primary-cyan">{total.toLocaleString()}</span>건
|
||||
{totalPages > 1 && <span className="text-text-3 font-normal ml-2">({page}/{totalPages} 페이지)</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px] border-collapse">
|
||||
<table className="w-full text-[11px] border-collapse whitespace-nowrap">
|
||||
<thead>
|
||||
<tr className="bg-bg-0">
|
||||
{[
|
||||
{ label: '선박명', align: 'left' },
|
||||
{ label: 'MMSI', align: 'center' },
|
||||
{ label: 'IMO', align: 'center' },
|
||||
{ label: '보험종류', align: 'center' },
|
||||
{ label: '보험사', align: 'center' },
|
||||
{ label: '증권번호', align: 'center' },
|
||||
{ label: '보험기간', align: 'center' },
|
||||
{ label: '보상한도', align: 'right' },
|
||||
{ label: '상태', align: 'center' },
|
||||
].map((h, i) => (
|
||||
<th key={i} className="px-3.5 py-2.5 font-bold text-text-2 border-b border-border whitespace-nowrap" style={{ textAlign: h.align as 'left' | 'center' | 'right' }}>{h.label}</th>
|
||||
{['No', '선박명', '호출부호', 'IMO', '선박종류', '선주', '총톤수', '보험사', '책임', '유류', '연료유', '난파물', '유효기간', '발급기관', '상태'].map((h, i) => (
|
||||
<th key={i} className="px-3 py-2.5 font-bold text-text-2 border-b border-border text-center">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resultData.map((r, i) => {
|
||||
const st = getStatus(r.expiry)
|
||||
{rows.map((r, i) => {
|
||||
const st = getStatus(r.validEnd)
|
||||
const isExp = st === 'expired'
|
||||
const isSoon = st === 'soon'
|
||||
const rowNum = (page - 1) * PAGE_SIZE + i + 1
|
||||
return (
|
||||
<tr key={i} className="border-b border-border" style={{ background: isExp ? 'rgba(239,68,68,.03)' : undefined }}>
|
||||
<td className="px-3.5 py-2.5 font-semibold">{r.shipName}</td>
|
||||
<td className="px-3.5 py-2.5 text-center font-mono text-[11px]">{r.mmsi || '—'}</td>
|
||||
<td className="px-3.5 py-2.5 text-center font-mono text-[11px]">{r.imo || '—'}</td>
|
||||
<td className="px-3.5 py-2.5 text-center">{r.insType}</td>
|
||||
<td className="px-3.5 py-2.5 text-center">{r.insurer}</td>
|
||||
<td className="px-3.5 py-2.5 text-center font-mono text-[10px] text-text-3">{r.policyNo}</td>
|
||||
<td className="px-3.5 py-2.5 text-center font-mono text-[11px]" style={{ color: isExp ? 'var(--red)' : isSoon ? 'var(--yellow)' : undefined, fontWeight: isExp || isSoon ? 700 : undefined }}>{r.start} ~ {r.expiry}</td>
|
||||
<td className="px-3.5 py-2.5 text-right font-bold font-mono">{r.limit}</td>
|
||||
<td className="px-3.5 py-2.5 text-center">
|
||||
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-semibold" style={{
|
||||
<tr key={r.insSn} className="border-b border-border" style={{ background: isExp ? 'rgba(239,68,68,.03)' : undefined }}>
|
||||
<td className="px-3 py-2 text-center text-text-3 font-mono">{rowNum}</td>
|
||||
<td className="px-3 py-2 font-semibold">{r.shipNm}</td>
|
||||
<td className="px-3 py-2 text-center font-mono">{r.callSign || '—'}</td>
|
||||
<td className="px-3 py-2 text-center font-mono">{r.imoNo || '—'}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className="text-[10px]">{r.shipTp}</span>
|
||||
{r.shipTpDetail && <span className="text-text-3 text-[9px] ml-1">({r.shipTpDetail})</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 max-w-[150px] truncate">{r.ownerNm}</td>
|
||||
<td className="px-3 py-2 text-right font-mono">{r.grossTon ? Number(r.grossTon).toLocaleString() : '—'}</td>
|
||||
<td className="px-3 py-2 max-w-[180px] truncate">{r.insurerNm}</td>
|
||||
<td className="px-3 py-2 text-center">{ynBadge(r.liabilityYn)}</td>
|
||||
<td className="px-3 py-2 text-center">{ynBadge(r.oilPollutionYn)}</td>
|
||||
<td className="px-3 py-2 text-center">{ynBadge(r.fuelOilYn)}</td>
|
||||
<td className="px-3 py-2 text-center">{ynBadge(r.wreckRemovalYn)}</td>
|
||||
<td className="px-3 py-2 text-center font-mono text-[10px]" style={{ color: isExp ? 'var(--red)' : isSoon ? 'var(--yellow)' : undefined, fontWeight: isExp || isSoon ? 700 : undefined }}>
|
||||
{r.validStart} ~ {r.validEnd}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center text-[10px]">{r.issueOrg}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className="px-2 py-0.5 rounded-full text-[9px] font-semibold" style={{
|
||||
background: isExp ? 'rgba(239,68,68,.15)' : isSoon ? 'rgba(234,179,8,.15)' : 'rgba(34,197,94,.15)',
|
||||
color: isExp ? 'var(--red)' : isSoon ? 'var(--yellow)' : 'var(--green)',
|
||||
}}>
|
||||
@ -294,30 +281,50 @@ function ShipInsurance() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 경고 */}
|
||||
{(expiredList.length > 0 || soonList.length > 0) && (
|
||||
<div className="px-4 py-3 text-xs text-text-2 mb-3 rounded-sm" style={{ background: 'rgba(234,179,8,.06)', border: '1px solid rgba(234,179,8,.25)' }}>
|
||||
{expiredList.length > 0 && (
|
||||
<><span className="text-status-red font-bold">⛔ 만료 {expiredList.length}건:</span> {expiredList.map(r => r.shipName).join(', ')}<br /></>
|
||||
)}
|
||||
{soonList.length > 0 && (
|
||||
<><span className="font-bold text-status-yellow">⚠ 만료임박(30일) {soonList.length}건:</span> {soonList.map(r => r.shipName).join(', ')}</>
|
||||
)}
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-1.5 mb-4">
|
||||
<button
|
||||
onClick={() => loadData(page - 1)} disabled={page <= 1}
|
||||
className="px-3 py-1.5 text-[11px] rounded-sm border border-border bg-bg-0 cursor-pointer disabled:opacity-30 disabled:cursor-default"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => {
|
||||
const startPage = Math.max(1, Math.min(page - 4, totalPages - 9))
|
||||
const p = startPage + i
|
||||
if (p > totalPages) return null
|
||||
return (
|
||||
<button
|
||||
key={p} onClick={() => loadData(p)}
|
||||
className="w-8 h-8 text-[11px] rounded-sm border cursor-pointer font-mono"
|
||||
style={{
|
||||
background: p === page ? 'var(--cyan)' : 'var(--bg-0)',
|
||||
color: p === page ? '#fff' : 'var(--text-2)',
|
||||
borderColor: p === page ? 'var(--cyan)' : 'var(--bd)',
|
||||
fontWeight: p === page ? 700 : 400,
|
||||
}}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
onClick={() => loadData(page + 1)} disabled={page >= totalPages}
|
||||
className="px-3 py-1.5 text-[11px] rounded-sm border border-border bg-bg-0 cursor-pointer disabled:opacity-30 disabled:cursor-default"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── API 연동 정보 푸터 ── */}
|
||||
<div className="mt-4 px-4 py-3 bg-bg-3 border border-border rounded-sm flex items-center justify-between">
|
||||
{/* 푸터 */}
|
||||
<div className="mt-auto px-4 py-3 bg-bg-3 border border-border rounded-sm">
|
||||
<div className="text-[10px] text-text-3 leading-[1.7]">
|
||||
<span className="text-text-2 font-bold">데이터 출처:</span> 한국해운조합(KSA) · haewoon.or.kr<br />
|
||||
<span className="text-text-2 font-bold">연동 방식:</span> REST API (JSON) · 실시간 조회 · 캐시 TTL 1시간
|
||||
</div>
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<span className="text-[10px] text-text-3">마지막 동기화:</span>
|
||||
<span className="text-[10px] text-text-2 font-mono">{lastSync}</span>
|
||||
<button onClick={handleFullSync} className="px-2.5 py-1 bg-bg-0 text-text-2 border border-border rounded text-[10px] cursor-pointer">전체 동기화</button>
|
||||
<span className="text-text-2 font-bold">데이터 출처:</span> 해양수산부 해운항만물류정보 · 유류오염보장계약관리 공공데이터<br />
|
||||
<span className="text-text-2 font-bold">보장항목:</span> 책임보험, 유류오염, 연료유오염, 난파물제거비용, 선원손해, 여객손해, 선체손해, 부두손상
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -113,3 +113,52 @@ export async function fetchUploadLogs(limit = 20): Promise<UploadLogItem[]> {
|
||||
const { data } = await api.get<UploadLogItem[]>(`/assets/upload-logs?limit=${limit}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 선박보험(유류오염보장계약)
|
||||
// ============================================================
|
||||
|
||||
export interface ShipInsuranceItem {
|
||||
insSn: number;
|
||||
shipNo: string;
|
||||
shipNm: string;
|
||||
callSign: string;
|
||||
imoNo: string;
|
||||
shipTp: string;
|
||||
shipTpDetail: string;
|
||||
ownerNm: string;
|
||||
grossTon: string;
|
||||
insurerNm: string;
|
||||
liabilityYn: string;
|
||||
oilPollutionYn: string;
|
||||
fuelOilYn: string;
|
||||
wreckRemovalYn: string;
|
||||
validStart: string;
|
||||
validEnd: string;
|
||||
issueOrg: string;
|
||||
}
|
||||
|
||||
interface InsuranceResponse {
|
||||
rows: ShipInsuranceItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function fetchInsurance(filters?: {
|
||||
search?: string;
|
||||
shipTp?: string;
|
||||
issueOrg?: string;
|
||||
insurer?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}): Promise<InsuranceResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.search) params.set('search', filters.search);
|
||||
if (filters?.shipTp) params.set('shipTp', filters.shipTp);
|
||||
if (filters?.issueOrg) params.set('issueOrg', filters.issueOrg);
|
||||
if (filters?.insurer) params.set('insurer', filters.insurer);
|
||||
if (filters?.page) params.set('page', String(filters.page));
|
||||
if (filters?.limit) params.set('limit', String(filters.limit));
|
||||
const qs = params.toString();
|
||||
const { data } = await api.get<InsuranceResponse>(`/assets/insurance${qs ? '?' + qs : ''}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { fetchHnsAnalyses, type HnsAnalysisItem } from '../services/hnsApi'
|
||||
|
||||
interface HNSAnalysisListTableProps {
|
||||
onTabChange: Dispatch<SetStateAction<'analysis' | 'list'>>
|
||||
onSelectAnalysis?: (sn: number, localRsltData?: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps) {
|
||||
export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnalysisListTableProps) {
|
||||
const [analyses, setAnalyses] = useState<HnsAnalysisItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@ -44,7 +45,49 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
|
||||
const items = await fetchHnsAnalyses()
|
||||
setAnalyses(items)
|
||||
} 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 {
|
||||
setLoading(false)
|
||||
}
|
||||
@ -129,6 +172,7 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
|
||||
<tbody>
|
||||
{analyses.map((item, index) => {
|
||||
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 riskStyle = RISK_STYLE[item.riskCd || ''] || { bg: 'rgba(100,100,100,0.1)', color: 'var(--t3)' }
|
||||
const aegl3 = rslt?.aegl3 as boolean | undefined
|
||||
@ -141,6 +185,7 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
|
||||
<tr
|
||||
key={item.hnsAnlysSn}
|
||||
className="border-b border-border cursor-pointer"
|
||||
onClick={() => onSelectAnalysis?.(item.hnsAnlysSn, isLocal && rslt ? rslt : undefined)}
|
||||
style={{
|
||||
transition: 'background 0.15s',
|
||||
background: index % 2 === 0 ? 'transparent' : 'rgba(255,255,255,0.01)'
|
||||
|
||||
@ -1,55 +1,197 @@
|
||||
import { useState } from 'react'
|
||||
import { ComboBox } from '@common/components/ui/ComboBox'
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
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 {
|
||||
activeSubTab: 'analysis' | 'list'
|
||||
onSubTabChange: (tab: 'analysis' | 'list') => void
|
||||
incidentCoord: { lon: number; lat: number }
|
||||
onCoordChange: (coord: { lon: number; lat: number }) => void
|
||||
onMapSelectClick: () => void
|
||||
onRunPrediction: () => void
|
||||
isRunningPrediction: boolean
|
||||
activeSubTab: 'analysis' | 'list';
|
||||
onSubTabChange: (tab: 'analysis' | 'list') => void;
|
||||
incidentCoord: { lon: number; lat: number };
|
||||
onCoordChange: (coord: { lon: number; lat: number }) => void;
|
||||
onMapSelectClick: () => void;
|
||||
onRunPrediction: () => void;
|
||||
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({
|
||||
activeSubTab,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onSubTabChange,
|
||||
onSubTabChange: _onSubTabChange, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
incidentCoord,
|
||||
onCoordChange,
|
||||
onMapSelectClick,
|
||||
onRunPrediction,
|
||||
isRunningPrediction
|
||||
isRunningPrediction,
|
||||
onParamsChange,
|
||||
onReset,
|
||||
loadedParams,
|
||||
}: HNSLeftPanelProps) {
|
||||
const [accidentName, setAccidentName] = useState('울산 온산항 톨루엔 누출')
|
||||
const [predictionTime, setPredictionTime] = useState('24시간')
|
||||
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 [incidents, setIncidents] = useState<IncidentListItem[]>([]);
|
||||
const [selectedIncidentSn, setSelectedIncidentSn] = useState('');
|
||||
|
||||
const handleReset = () => {
|
||||
setAccidentName('울산 온산항 톨루엔 누출')
|
||||
setPredictionTime('24시간')
|
||||
setLocationName('울산 온산항 제3부두')
|
||||
setMaterialCategory('인화성 액체')
|
||||
setSubstance('톨루엔 (Toluene)')
|
||||
setAmount('12.0')
|
||||
setUnit('kL')
|
||||
setReleaseType('연속 유출')
|
||||
setAlgorithm('ALOHA (EPA)')
|
||||
setCriteriaModel('AEGL')
|
||||
onCoordChange({ lon: 129.3542, lat: 35.4215 })
|
||||
const [accidentName, setAccidentName] = useState('');
|
||||
const [accidentDate, setAccidentDate] = useState<string>(() => {
|
||||
const now = new Date();
|
||||
return now.toISOString().slice(0, 10);
|
||||
});
|
||||
const [accidentTime, setAccidentTime] = useState<string>(() => {
|
||||
const now = new Date();
|
||||
return now.toTimeString().slice(0, 5);
|
||||
});
|
||||
const [predictionTime, setPredictionTime] = useState('24시간');
|
||||
const [substance, setSubstance] = useState('톨루엔 (Toluene)');
|
||||
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 (
|
||||
<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 */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border-light scrollbar-track-transparent bg-bg-0">
|
||||
{activeSubTab === 'analysis' && (
|
||||
@ -65,10 +207,10 @@ export function HNSLeftPanel({
|
||||
}}
|
||||
>🧪</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold">
|
||||
<div className="text-[13px] font-bold text-text-2 font-korean">
|
||||
HNS 대기확산 예측
|
||||
</div>
|
||||
<div className="text-[9px] text-text-3">
|
||||
<div className="text-[10px] text-text-3">
|
||||
ALOHA/CAMEO 기반 대기확산 시뮬레이션
|
||||
</div>
|
||||
</div>
|
||||
@ -79,143 +221,149 @@ export function HNSLeftPanel({
|
||||
<div className="flex flex-col gap-3">
|
||||
|
||||
{/* 사고 기본정보 */}
|
||||
<div className="bg-bg-3 border border-border rounded-md p-[14px]">
|
||||
<div className="text-[11px] font-bold text-primary-cyan mb-3 flex items-center gap-1.5">
|
||||
<div>
|
||||
<div className="text-[13px] font-bold text-text-2 font-korean mb-3 flex items-center gap-1.5">
|
||||
📋 사고 기본정보
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 사고명 */}
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
|
||||
{/* 사고명 직접 입력 */}
|
||||
<input
|
||||
className="prd-i w-full"
|
||||
value={accidentName}
|
||||
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="hns-lbl text-[8px] text-text-3 block mb-1">사고명</label>
|
||||
<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="hns-inp w-full px-[10px] py-2 bg-bg-0 border border-border rounded text-[11px]"
|
||||
value={accidentName}
|
||||
onChange={(e) => setAccidentName(e.target.value)}
|
||||
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 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="datetime-local"
|
||||
defaultValue="2025-02-11T05:02"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="hns-lbl text-[8px] text-text-3 block mb-1">예측시간</label>
|
||||
<ComboBox
|
||||
className="hns-inp"
|
||||
value={predictionTime}
|
||||
onChange={setPredictionTime}
|
||||
options={[
|
||||
{ value: '6시간', label: '6시간' },
|
||||
{ value: '12시간', label: '12시간' },
|
||||
{ value: '24시간', label: '24시간' },
|
||||
{ value: '48시간', label: '48시간' },
|
||||
{ value: '72시간', label: '72시간' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{/* 유출량 + 단위 + 예측시간 */}
|
||||
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 65px 1fr' }}>
|
||||
<input
|
||||
className="prd-i font-mono"
|
||||
type="number"
|
||||
value={releaseType === '순간 유출' ? totalRelease : emissionRate}
|
||||
onChange={(e) => releaseType === '순간 유출' ? setTotalRelease(e.target.value) : setEmissionRate(e.target.value)}
|
||||
placeholder={releaseType === '순간 유출' ? 'g' : 'g/s'}
|
||||
/>
|
||||
<ComboBox
|
||||
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}
|
||||
onChange={setPredictionTime}
|
||||
options={[
|
||||
{ value: '6시간', label: '6시간' },
|
||||
{ value: '12시간', label: '12시간' },
|
||||
{ value: '24시간', label: '24시간' },
|
||||
{ value: '48시간', label: '48시간' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 사고지점 */}
|
||||
<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>
|
||||
{/* 기상 정보 (자동조회) — 내부적으로 사용, UI 숨김 */}
|
||||
|
||||
{/* 알고리즘 선택 */}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<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
|
||||
className="hns-inp"
|
||||
className="prd-i"
|
||||
value={algorithm}
|
||||
onChange={setAlgorithm}
|
||||
options={[
|
||||
@ -227,9 +375,9 @@ export function HNSLeftPanel({
|
||||
/>
|
||||
</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
|
||||
className="hns-inp"
|
||||
className="prd-i"
|
||||
value={criteriaModel}
|
||||
onChange={setCriteriaModel}
|
||||
options={[
|
||||
@ -244,140 +392,160 @@ export function HNSLeftPanel({
|
||||
</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="text-[13px] font-bold text-text-2 font-korean mb-3 flex items-center gap-1.5">
|
||||
🧪 {releaseType === '연속 유출' ? 'Plume' : releaseType === '순간 유출' ? 'Puff' : 'Dense Gas'} 파라미터
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 물질 분류 */}
|
||||
<div>
|
||||
<label className="hns-lbl text-[8px] text-text-3 block mb-1">물질 분류</label>
|
||||
<ComboBox
|
||||
className="hns-inp"
|
||||
value={materialCategory}
|
||||
onChange={setMaterialCategory}
|
||||
options={[
|
||||
{ value: '유독성 액체', label: '유독성 액체' },
|
||||
{ value: '유독성 기체', label: '유독성 기체' },
|
||||
{ value: '인화성 액체', label: '인화성 액체' },
|
||||
{ value: '인화성 기체', label: '인화성 기체' },
|
||||
{ value: '부식성 물질', label: '부식성 물질' },
|
||||
{ value: '친환경 연료', label: '친환경 연료' }
|
||||
]}
|
||||
/>
|
||||
</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)' }}
|
||||
>
|
||||
|
||||
{/* 물질명 */}
|
||||
<div>
|
||||
<label className="hns-lbl text-[8px] text-text-3 block mb-1">물질명</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>
|
||||
{/* 연속 유출 (Plume): 배출률, 누출지속시간, 누출높이 */}
|
||||
{releaseType === '연속 유출' && (
|
||||
<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">지속시간 (s)</label>
|
||||
<input
|
||||
className="prd-i w-full font-mono"
|
||||
type="number"
|
||||
value={releaseDuration}
|
||||
onChange={(e) => setReleaseDuration(e.target.value)}
|
||||
step="10"
|
||||
min="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>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
className="hns-inp w-full px-[10px] py-2 bg-bg-0 border border-border rounded text-[11px]"
|
||||
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
|
||||
/>
|
||||
{/* 순간 유출 (Puff): 총 누출량, 누출높이 */}
|
||||
{releaseType === '순간 유출' && (
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">총 누출량 (g)</label>
|
||||
<input
|
||||
className="prd-i w-full font-mono"
|
||||
type="number"
|
||||
value={totalRelease}
|
||||
onChange={(e) => setTotalRelease(e.target.value)}
|
||||
step="100"
|
||||
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={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 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"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="hns-lbl text-[8px] text-text-3 block mb-1">단위</label>
|
||||
<ComboBox
|
||||
className="hns-inp"
|
||||
value={unit}
|
||||
onChange={setUnit}
|
||||
options={[
|
||||
{ value: 'kL', label: 'kL' },
|
||||
{ value: '톤', label: '톤' },
|
||||
{ value: 'kg', label: 'kg' },
|
||||
{ value: '배럴', label: '배럴' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 유출 형태 */}
|
||||
<div>
|
||||
<label className="hns-lbl text-[8px] text-text-3 block mb-1">유출 형태</label>
|
||||
<ComboBox
|
||||
className="hns-inp"
|
||||
value={releaseType}
|
||||
onChange={setReleaseType}
|
||||
options={[
|
||||
{ value: '연속 유출', label: '연속 유출' },
|
||||
{ value: '순간 유출', label: '순간 유출' },
|
||||
{ value: '풀(Pool) 증발', label: '풀(Pool) 증발' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 물질 위험 특성 */}
|
||||
<div
|
||||
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)' }}
|
||||
>
|
||||
<div className="text-[8px] font-bold text-status-orange mb-1">
|
||||
<div className="text-[10px] font-bold text-status-orange mb-1">
|
||||
⚠ 물질 위험 특성
|
||||
</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">
|
||||
<span className="text-text-3">인화점</span>
|
||||
<span className="text-status-red font-semibold font-mono">4°C</span>
|
||||
<span className="text-text-3">분자량</span>
|
||||
<span className="font-mono">{tox.mw} g/mol</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-3">비중</span>
|
||||
<span className="font-mono">0.867</span>
|
||||
<span className="text-text-3">가스밀도</span>
|
||||
<span className="font-mono">{tox.densityGas} kg/m³</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-3">증기압</span>
|
||||
<span className="font-mono">22 mmHg</span>
|
||||
<span className="font-mono">{tox.vaporPressure} mmHg</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-3">IDLH</span>
|
||||
<span className="text-status-red font-semibold font-mono">500 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>
|
||||
<span className="text-status-red font-semibold font-mono">{tox.idlh} ppm</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -387,21 +555,21 @@ export function HNSLeftPanel({
|
||||
className="p-2 rounded-sm"
|
||||
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)
|
||||
</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="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 className="flex items-center gap-1">
|
||||
<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 className="flex items-center gap-1">
|
||||
<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>
|
||||
@ -410,28 +578,21 @@ export function HNSLeftPanel({
|
||||
</div>
|
||||
|
||||
{/* 실행 버튼 */}
|
||||
<div className="flex gap-2 mt-[14px] justify-center">
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
<button
|
||||
className="prd-btn pri"
|
||||
style={{ padding: '7px', fontSize: '11px' }}
|
||||
onClick={onRunPrediction}
|
||||
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
|
||||
className="prd-btn sec"
|
||||
style={{ padding: '7px', fontSize: '11px' }}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@ -449,10 +610,10 @@ export function HNSLeftPanel({
|
||||
}}
|
||||
>📋</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold">
|
||||
<div className="text-[13px] font-bold text-text-2 font-korean">
|
||||
분석 목록
|
||||
</div>
|
||||
<div className="text-[9px] text-text-3">
|
||||
<div className="text-[10px] text-text-3">
|
||||
저장된 대기확산 예측 결과
|
||||
</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="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 className="flex flex-col gap-2">
|
||||
{/* 기간 선택 */}
|
||||
<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
|
||||
value="최근 7일"
|
||||
onChange={() => {}}
|
||||
@ -481,7 +642,7 @@ export function HNSLeftPanel({
|
||||
|
||||
{/* 물질 분류 */}
|
||||
<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
|
||||
value="전체"
|
||||
onChange={() => {}}
|
||||
@ -497,7 +658,7 @@ export function HNSLeftPanel({
|
||||
|
||||
{/* 위험도 */}
|
||||
<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
|
||||
value="전체"
|
||||
onChange={() => {}}
|
||||
@ -514,7 +675,7 @@ export function HNSLeftPanel({
|
||||
|
||||
{/* 통계 요약 */}
|
||||
<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 className="flex flex-col gap-2">
|
||||
@ -536,5 +697,5 @@ export function HNSLeftPanel({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,80 +1,73 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { createHnsAnalysis } from '../services/hnsApi'
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { ComboBox } from '@common/components/ui/ComboBox';
|
||||
|
||||
interface HNSRecalcModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: () => void
|
||||
export interface RecalcParams {
|
||||
substance: string;
|
||||
releaseType: '연속 유출' | '순간 유출' | '밀도가스 유출';
|
||||
emissionRate: number;
|
||||
totalRelease: number;
|
||||
algorithm: string;
|
||||
predictionTime: string;
|
||||
}
|
||||
|
||||
type RecalcPhase = 'editing' | 'running' | 'done'
|
||||
interface HNSRecalcModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (params: RecalcParams) => void;
|
||||
currentParams?: Partial<RecalcParams> | null;
|
||||
}
|
||||
|
||||
const HNS_SUBSTANCES = ['톨루엔', '암모니아', '메탄올', '수소', '벤젠', '스티렌', 'LNG', '염소', '황화수소']
|
||||
const RELEASE_TYPES = ['순간 유출', '연속 유출', '반연속']
|
||||
const MODELS = ['ALOHA', 'PHAST', 'CALPUFF', 'Lagrangian']
|
||||
const STABILITIES = ['A (매우 불안정)', 'B (불안정)', 'C (약간 불안정)', 'D (중립)', 'E (약간 안정)', 'F (안정)']
|
||||
const PRED_TIMES = [1, 3, 6, 12, 24, 48]
|
||||
export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNSRecalcModalProps) {
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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')
|
||||
const [substance, setSubstance] = useState('톨루엔 (Toluene)');
|
||||
const [releaseType, setReleaseType] = useState<RecalcParams['releaseType']>('연속 유출');
|
||||
const [amount, setAmount] = useState('10');
|
||||
const [algorithm, setAlgorithm] = useState('ALOHA (EPA)');
|
||||
const [predictionTime, setPredictionTime] = useState('24시간');
|
||||
|
||||
// 모달 열릴 때 현재 파라미터로 초기화
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (isOpen) setPhase('editing')
|
||||
}, [isOpen])
|
||||
if (!isOpen || !currentParams) return;
|
||||
queueMicrotask(() => {
|
||||
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(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (e.target === backdropRef.current) onClose()
|
||||
}
|
||||
if (isOpen) document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [isOpen, onClose])
|
||||
if (e.target === backdropRef.current) onClose();
|
||||
};
|
||||
if (isOpen) document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleRun = async () => {
|
||||
setPhase('running')
|
||||
try {
|
||||
await createHnsAnalysis({
|
||||
anlysNm: `HNS 재계산 — ${substance}`,
|
||||
lon,
|
||||
lat,
|
||||
sbstNm: substance,
|
||||
spilQty: amount,
|
||||
spilUnitCd: unit,
|
||||
fcstHr: predTime,
|
||||
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('재계산 실행에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
const handleRun = () => {
|
||||
const numAmount = parseFloat(amount) || 10;
|
||||
onSubmit({
|
||||
substance,
|
||||
releaseType,
|
||||
emissionRate: releaseType !== '순간 유출' ? numAmount : 10,
|
||||
totalRelease: releaseType === '순간 유출' ? numAmount : 5000,
|
||||
algorithm,
|
||||
predictionTime,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null
|
||||
if (!isOpen) return null;
|
||||
|
||||
const amountLabel = releaseType === '순간 유출' ? '총 누출량' : '배출률';
|
||||
const amountUnit = releaseType === '순간 유출' ? 'g' : 'g/s';
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -82,168 +75,142 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit }: HNSRecalcModalProp
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
||||
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"
|
||||
style={{ boxShadow: '0 20px 60px rgba(0,0,0,0.5)' }}>
|
||||
<div
|
||||
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 */}
|
||||
<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"
|
||||
style={{ background: 'linear-gradient(135deg, rgba(249,115,22,0.2), rgba(239,68,68,0.15))' }}>🔄</div>
|
||||
<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">
|
||||
<h2 className="text-[15px] font-bold m-0">
|
||||
HNS 대기확산 재계산
|
||||
</h2>
|
||||
<h2 className="text-[15px] font-bold m-0">대기확산 재계산</h2>
|
||||
<div className="text-[10px] text-text-3 mt-0.5">
|
||||
물질·기상조건을 수정하여 대기확산 예측을 재실행합니다
|
||||
조건을 변경하여 재계산합니다
|
||||
</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>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 flex flex-col gap-[14px]"
|
||||
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>
|
||||
|
||||
<div
|
||||
className="px-5 py-4 flex flex-col gap-3"
|
||||
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}
|
||||
>
|
||||
{/* HNS 물질 */}
|
||||
<FG label="HNS 물질">
|
||||
<select className="prd-i" value={substance} onChange={e => setSubstance(e.target.value)}>
|
||||
{HNS_SUBSTANCES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
value={substance}
|
||||
onChange={setSubstance}
|
||||
options={[
|
||||
{ value: '톨루엔 (Toluene)', label: '톨루엔' },
|
||||
{ value: '벤젠 (Benzene)', label: '벤젠' },
|
||||
{ value: '메탄올 (Methanol)', label: '메탄올' },
|
||||
{ value: '암모니아 (Ammonia)', label: '암모니아' },
|
||||
{ value: '염화수소 (HCl)', label: '염화수소' },
|
||||
{ value: '황화수소 (H2S)', label: '황화수소' },
|
||||
]}
|
||||
/>
|
||||
</FG>
|
||||
|
||||
{/* 유출 유형 + 유출량 */}
|
||||
<div className="grid grid-cols-2 gap-[10px]">
|
||||
<FG label="유출 유형">
|
||||
<select className="prd-i" value={releaseType} onChange={e => setReleaseType(e.target.value)}>
|
||||
{RELEASE_TYPES.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
value={releaseType}
|
||||
onChange={(v) => setReleaseType(v as RecalcParams['releaseType'])}
|
||||
options={[
|
||||
{ value: '연속 유출', label: '연속 유출' },
|
||||
{ value: '순간 유출', label: '순간 유출' },
|
||||
{ value: '밀도가스 유출', label: '밀도가스 유출' },
|
||||
]}
|
||||
/>
|
||||
</FG>
|
||||
<FG label="유출량">
|
||||
<div className="flex gap-1">
|
||||
<input className="prd-i flex-1" type="number" value={amount} onChange={e => setAmount(Number(e.target.value))} step={0.1} />
|
||||
<select className="prd-i w-[55px]" value={unit} onChange={e => setUnit(e.target.value as typeof unit)}>
|
||||
{['t', 'kg', 'm³', 'L'].map(u => <option key={u} value={u}>{u}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<FG label={`${amountLabel} (${amountUnit})`}>
|
||||
<input
|
||||
className="prd-i font-mono"
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder={amountUnit}
|
||||
/>
|
||||
</FG>
|
||||
</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]">
|
||||
<FG label="대기안정도 (Pasquill)">
|
||||
<select className="prd-i" value={stability} onChange={e => setStability(e.target.value)}>
|
||||
{STABILITIES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<FG label="예측 알고리즘">
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
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 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 label="예측 시간">
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
value={predictionTime}
|
||||
onChange={setPredictionTime}
|
||||
options={[
|
||||
{ value: '6시간', label: '6시간' },
|
||||
{ value: '12시간', label: '12시간' },
|
||||
{ value: '24시간', label: '24시간' },
|
||||
{ value: '48시간', label: '48시간' },
|
||||
]}
|
||||
/>
|
||||
</FG>
|
||||
</div>
|
||||
|
||||
{/* 예측 시간 */}
|
||||
<FG label="예측 시간">
|
||||
<select className="prd-i" value={predTime} onChange={e => setPredTime(Number(e.target.value))}>
|
||||
{PRED_TIMES.map(h => <option key={h} value={h}>{h}시간</option>)}
|
||||
</select>
|
||||
</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 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>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-[14px] border-t border-border flex gap-2">
|
||||
<button
|
||||
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"
|
||||
style={{ opacity: phase !== 'editing' ? 0.5 : 1 }}
|
||||
>취소</button>
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={phase !== 'editing'}
|
||||
className="flex-[2] py-2.5 text-xs font-bold rounded-md"
|
||||
className="flex-[2] py-2.5 text-xs font-bold rounded-md text-white"
|
||||
style={{
|
||||
cursor: phase === 'editing' ? 'pointer' : 'wait',
|
||||
background: phase === 'done'
|
||||
? 'rgba(34,197,94,0.15)'
|
||||
: 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',
|
||||
cursor: 'pointer',
|
||||
background: 'linear-gradient(135deg, var(--orange), #ef4444)',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
{phase === 'done' ? '✅ 재계산 완료!' : phase === 'running' ? '⏳ 재계산 실행중...' : '🔄 재계산 실행'}
|
||||
🔄 재계산 실행
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FG({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<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}
|
||||
</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 {
|
||||
dispersionResult: {
|
||||
zones: Array<{
|
||||
level: string
|
||||
color: string
|
||||
radius: number
|
||||
angle: number
|
||||
}>
|
||||
timestamp: string
|
||||
windDirection: number
|
||||
substance: string
|
||||
level: string;
|
||||
color: string;
|
||||
radius: number;
|
||||
angle: number;
|
||||
}>;
|
||||
timestamp: string;
|
||||
windDirection: number;
|
||||
substance: string;
|
||||
concentration: {
|
||||
'AEGL-3': string
|
||||
'AEGL-2': string
|
||||
'AEGL-1': string
|
||||
}
|
||||
} | null
|
||||
onOpenRecalc?: () => void
|
||||
onOpenReport?: () => void
|
||||
'AEGL-3': string;
|
||||
'AEGL-2': string;
|
||||
'AEGL-1': string;
|
||||
};
|
||||
} | null;
|
||||
computedResult?: DispersionGridResult | null;
|
||||
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) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="w-[300px] bg-bg-1 border-l border-border p-4 overflow-auto flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
@ -48,79 +70,125 @@ export function HNSRightPanel({ dispersionResult, onOpenRecalc, onOpenReport }:
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-[10px] text-text-3 font-mono">
|
||||
{dispersionResult.substance} · ALOHA v5.4.7
|
||||
{dispersionResult.substance} · {modelLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<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="text-[10px] text-text-3 mb-1.5">
|
||||
평균 확산 면적
|
||||
AEGL-1 확산 면적
|
||||
</div>
|
||||
<div className="text-[20px] font-bold font-mono text-primary-cyan">
|
||||
8.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
|
||||
{area > 0 ? area.toFixed(2) : '—'} <span className="text-[10px] font-medium">km²</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 풍속 */}
|
||||
<div className="p-3 bg-bg-3 border border-border rounded-[var(--rS)]">
|
||||
<div className="text-[10px] text-text-3 mb-1.5">
|
||||
평균 풍속
|
||||
풍속
|
||||
</div>
|
||||
<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 className="p-3 bg-bg-3 border border-border rounded-[var(--rS)]">
|
||||
<div className="text-[10px] text-text-3 mb-1.5">
|
||||
풍향
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Zone Details */}
|
||||
{/* AEGL Zone Details */}
|
||||
<div>
|
||||
<h4 className="text-[11px] font-semibold text-text-2 mt-0 mb-2.5">
|
||||
확산 구역 상세
|
||||
AEGL 구역 상세
|
||||
</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{dispersionResult.zones.map((zone, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="py-2.5 px-3 bg-bg-2 rounded-[var(--rS)]"
|
||||
style={{
|
||||
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">
|
||||
<span className="text-[11px] font-semibold">
|
||||
{zone.level}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-text-3">
|
||||
{zone.radius}m
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-text-3">
|
||||
{dispersionResult.concentration[zone.level as keyof typeof dispersionResult.concentration]}
|
||||
</div>
|
||||
{/* AEGL-3 */}
|
||||
<div
|
||||
className="py-2.5 px-3 bg-bg-2 rounded-[var(--rS)]"
|
||||
style={{ borderLeft: '3px solid rgba(239,68,68,1)' }}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-[11px] font-semibold">AEGL-3 (생명위협)</span>
|
||||
<span className="text-[10px] font-mono text-text-3">
|
||||
{computedResult?.aeglDistances.aegl3 || 0}m
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between text-[10px] text-text-3">
|
||||
<span>{dispersionResult.concentration['AEGL-3']}</span>
|
||||
<span className="font-mono">{computedResult?.aeglAreas.aegl3 ?? 0} km²</span>
|
||||
</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>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="mt-auto pt-3 border-t border-border text-[10px] text-text-3 font-mono">
|
||||
예측 시각: {new Date(dispersionResult.timestamp).toLocaleString('ko-KR')}
|
||||
@ -128,7 +196,7 @@ export function HNSRightPanel({ dispersionResult, onOpenRecalc, onOpenReport }:
|
||||
|
||||
{/* Bottom Action Buttons */}
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -61,7 +61,7 @@ export function HNSSubstanceView() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [detailSearchSebc, setDetailSearchSebc] = useState('전체 거동분류')
|
||||
/* 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 [hmsFilterSebc, setHmsFilterSebc] = useState('전체 거동분류')
|
||||
const [hmsSelectedId, setHmsSelectedId] = useState<number | null>(null)
|
||||
@ -85,7 +85,9 @@ export function HNSSubstanceView() {
|
||||
const params: Record<string, string | number> = { page: hmsPage, limit: 10 }
|
||||
if (hmsSearchInput.trim()) {
|
||||
params.q = hmsSearchInput.trim()
|
||||
params.type = searchTypeMap[hmsSearchType] || 'abbreviation'
|
||||
if (hmsSearchType !== 'all') {
|
||||
params.type = searchTypeMap[hmsSearchType] || 'abbreviation'
|
||||
}
|
||||
}
|
||||
if (hmsFilterSebc !== '전체 거동분류') {
|
||||
params.sebc = hmsFilterSebc.split(' ')[0]
|
||||
@ -93,7 +95,8 @@ export function HNSSubstanceView() {
|
||||
const { data } = await api.get('/hns', { params })
|
||||
setHmsResults(data.items)
|
||||
setHmsTotal(data.total)
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[HNS] 물질 검색 오류:', err)
|
||||
setHmsResults([])
|
||||
setHmsTotal(0)
|
||||
} finally {
|
||||
@ -600,42 +603,43 @@ ${styles}
|
||||
<div className="flex gap-2 mb-[10px] items-center">
|
||||
<div className="shrink-0 flex items-center gap-1">
|
||||
<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 }}>
|
||||
<option value="abbr">❶ 약자/제품명</option>
|
||||
<option value="korName">❷ 국문명 (동의어)</option>
|
||||
<option value="engName">❸ 영문명 (동의어)</option>
|
||||
<option value="cas">❹ CAS번호</option>
|
||||
<option value="un">❺ UN번호</option>
|
||||
<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="all">전체 통합검색</option>
|
||||
<option value="abbr">약자/제품명</option>
|
||||
<option value="korName">국문명</option>
|
||||
<option value="engName">영문명</option>
|
||||
<option value="cas">CAS번호</option>
|
||||
<option value="un">UN번호</option>
|
||||
</select>
|
||||
</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" />
|
||||
<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>
|
||||
<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-[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 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 className="rounded-[10px] p-4 mb-4 border border-border bg-bg-3">
|
||||
<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>
|
||||
<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]">
|
||||
<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-[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>
|
||||
</select>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[9px]">
|
||||
<table className="w-full border-collapse text-[11px]">
|
||||
<thead>
|
||||
<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: '7px 6px', 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: '7px 6px', 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-text-2 text-left text-[8px]" style={{ padding: '7px 6px', 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: '7px 6px', borderBottom: '2px solid var(--bdL)', width: 72 }}>CAS번호</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: '8px 8px', 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: '8px 8px', 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-[10px]" style={{ padding: '8px 8px', borderBottom: '2px solid var(--bdL)' }}>국문 동의어 / 주요 사용처</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: '8px 8px', borderBottom: '2px solid var(--bdL)', width: 80 }}>CAS번호</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -649,12 +653,12 @@ ${styles}
|
||||
onMouseOver={e => { if (!isSel) e.currentTarget.style.background = 'rgba(249,115,22,.03)' }}
|
||||
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-semibold font-mono text-status-orange p-1.5">{s.abbreviation}</td>
|
||||
<td className="p-1.5">{s.nameEn}</td>
|
||||
<td className="text-text-3 text-[8px] p-1.5">{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="text-text-3 text-[8px] p-1.5">{s.synonymsKr}</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 px-2 py-2">{s.abbreviation}</td>
|
||||
<td className="px-2 py-2">{s.nameEn}</td>
|
||||
<td className="text-text-3 text-[10px] px-2 py-2">{s.synonymsEn}</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-[10px] px-2 py-2">{s.synonymsKr}</td>
|
||||
<td className="text-center font-mono">{s.unNumber}</td>
|
||||
<td className="text-center font-mono">{s.casNumber}</td>
|
||||
</tr>
|
||||
@ -665,15 +669,29 @@ ${styles}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-[10px] flex items-center justify-between text-[9px] text-text-3">
|
||||
<span>총 <b className="text-status-orange">{hmsTotal.toLocaleString()}</b>종 등록 · Port-MIS 화물적부도 연동 · 해경청 물질정보집 · IBC CODE 692종</span>
|
||||
<div className="flex gap-1">
|
||||
<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>
|
||||
{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>
|
||||
))}
|
||||
<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>
|
||||
<div className="mt-[10px] text-center text-[11px] text-text-3">
|
||||
<div className="flex items-center justify-center gap-1 mb-1.5">
|
||||
<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-[11px] text-text-2 bg-bg-0 px-3 py-1" style={{ opacity: hmsPage <= 1 ? 0.4 : 1 }}>◀</button>
|
||||
{(() => {
|
||||
const range = 2;
|
||||
let start = Math.max(1, hmsPage - range);
|
||||
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>
|
||||
<span>총 <b className="text-status-orange">{hmsTotal.toLocaleString()}</b>종 등록</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,14 +1,24 @@
|
||||
import { useState } from 'react'
|
||||
import { HNSLeftPanel } from './HNSLeftPanel'
|
||||
import { HNSRightPanel } from './HNSRightPanel'
|
||||
import { MapView } from '@common/components/map/MapView'
|
||||
import { HNSAnalysisListTable } from './HNSAnalysisListTable'
|
||||
import { HNSTheoryView } from './HNSTheoryView'
|
||||
import { HNSSubstanceView } from './HNSSubstanceView'
|
||||
import { HNSScenarioView } from './HNSScenarioView'
|
||||
import { HNSRecalcModal } from './HNSRecalcModal'
|
||||
import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/useSubMenu'
|
||||
import { createHnsAnalysis } from '../services/hnsApi'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { HNSLeftPanel } from './HNSLeftPanel';
|
||||
import type { HNSInputParams } from './HNSLeftPanel';
|
||||
import { HNSRightPanel } from './HNSRightPanel';
|
||||
import { MapView } from '@common/components/map/MapView';
|
||||
import { HNSAnalysisListTable } from './HNSAnalysisListTable';
|
||||
import { HNSTheoryView } from './HNSTheoryView';
|
||||
import { HNSSubstanceView } from './HNSSubstanceView';
|
||||
import { HNSScenarioView } from './HNSScenarioView';
|
||||
import { HNSRecalcModal } from './HNSRecalcModal';
|
||||
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 매뉴얼 뷰어 컴포넌트 ─── */
|
||||
function HNSManualViewer() {
|
||||
@ -47,7 +57,7 @@ function HNSManualViewer() {
|
||||
</div>
|
||||
|
||||
{/* 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-[9px] text-text-3 mb-2.5 leading-normal">물질의 물리적·화학적 특성(용해도, 밀도, 증기압, 점도)에 따라 이론적 거동을 5가지 주요 범주 + 7가지 하위 범주로 분류</div>
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(5,1fr)' }}>
|
||||
@ -67,175 +77,6 @@ function HNSManualViewer() {
|
||||
</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]">
|
||||
<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 ─── */
|
||||
export function HNSView() {
|
||||
const { activeSubTab, setActiveSubTab } = useSubMenu('hns')
|
||||
const [incidentCoord, setIncidentCoord] = useState({ lon: 129.3542, lat: 35.4215 })
|
||||
const [isSelectingLocation, setIsSelectingLocation] = useState(false)
|
||||
const [isRunningPrediction, setIsRunningPrediction] = useState(false)
|
||||
const { activeSubTab, setActiveSubTab } = useSubMenu('hns');
|
||||
const { user } = useAuthStore();
|
||||
const [incidentCoord, setIncidentCoord] = useState({ lon: 129.3542, lat: 35.4215 });
|
||||
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
||||
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [dispersionResult, setDispersionResult] = useState<any>(null)
|
||||
const [recalcModalOpen, setRecalcModalOpen] = useState(false)
|
||||
const [dispersionResult, setDispersionResult] = useState<any>(null);
|
||||
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) => {
|
||||
if (isSelectingLocation) {
|
||||
setIncidentCoord({ lon, lat })
|
||||
setIsSelectingLocation(false)
|
||||
setIncidentCoord({ lon, lat });
|
||||
setIsSelectingLocation(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunPrediction = async () => {
|
||||
setIsRunningPrediction(true)
|
||||
// 시간 애니메이션 (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);
|
||||
}
|
||||
|
||||
// AEGL 구역 (MapView 레거시 호환)
|
||||
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 {
|
||||
const { hnsAnlysSn } = await createHnsAnalysis({
|
||||
anlysNm: `HNS 분석 ${new Date().toLocaleDateString('ko-KR')}`,
|
||||
lon: incidentCoord.lon,
|
||||
lat: incidentCoord.lat,
|
||||
locNm: `${incidentCoord.lon.toFixed(4)}, ${incidentCoord.lat.toFixed(4)}`,
|
||||
})
|
||||
const params = paramsOverride ?? inputParams;
|
||||
|
||||
// 시뮬레이션 엔진 미구현 — 프론트 임시 결과 생성
|
||||
const windAngle = 225
|
||||
const result = {
|
||||
hnsAnlysSn,
|
||||
// 1. 계산 먼저 실행 (동기, 히트맵 즉시 표시)
|
||||
const { tox, meteo, resultForZones, substanceName } = runComputation(params, incidentCoord);
|
||||
hasRunOnce.current = true;
|
||||
|
||||
setDispersionResult({
|
||||
hnsAnlysSn: 0,
|
||||
zones: [
|
||||
{ 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 },
|
||||
{ level: 'AEGL-1', color: 'rgba(234,179,8,0.25)', radius: 1500, angle: windAngle },
|
||||
],
|
||||
{ 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: windAngle,
|
||||
substance: 'Toluene',
|
||||
concentration: { 'AEGL-3': '500 ppm', 'AEGL-2': '150 ppm', 'AEGL-1': '37 ppm' }
|
||||
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,
|
||||
lat: incidentCoord.lat,
|
||||
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,
|
||||
temp: params?.weather?.temperature,
|
||||
humid: params?.weather?.humidity,
|
||||
atmStblCd: params?.weather?.stability,
|
||||
analystNm: user?.name || undefined,
|
||||
});
|
||||
// DB 저장 성공 시 SN 업데이트
|
||||
setDispersionResult((prev: Record<string, unknown> | null) => prev ? { ...prev, hnsAnlysSn: result.hnsAnlysSn } : prev);
|
||||
} catch {
|
||||
// API 실패 시 무시 (히트맵은 이미 표시됨)
|
||||
}
|
||||
|
||||
setDispersionResult(result)
|
||||
} catch (error) {
|
||||
console.error('대기확산 예측 오류:', error)
|
||||
alert('대기확산 예측 중 오류가 발생했습니다.')
|
||||
console.error('대기확산 예측 오류:', error);
|
||||
alert('대기확산 예측 중 오류가 발생했습니다.');
|
||||
} 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') {
|
||||
return <HNSScenarioView />
|
||||
return <HNSScenarioView />;
|
||||
}
|
||||
|
||||
if (activeSubTab === 'manual') {
|
||||
return <HNSManualViewer />
|
||||
return <HNSManualViewer />;
|
||||
}
|
||||
|
||||
if (activeSubTab === 'theory') {
|
||||
return <HNSTheoryView />
|
||||
return <HNSTheoryView />;
|
||||
}
|
||||
|
||||
if (activeSubTab === 'substance') {
|
||||
return <HNSSubstanceView />
|
||||
return <HNSSubstanceView />;
|
||||
}
|
||||
|
||||
return (
|
||||
@ -327,22 +681,40 @@ export function HNSView() {
|
||||
onMapSelectClick={() => setIsSelectingLocation(true)}
|
||||
onRunPrediction={handleRunPrediction}
|
||||
isRunningPrediction={isRunningPrediction}
|
||||
onParamsChange={handleParamsChange}
|
||||
onReset={handleReset}
|
||||
loadedParams={loadedParams}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Center - Map/Content Area */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
{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
|
||||
incidentCoord={incidentCoord}
|
||||
isSelectingLocation={isSelectingLocation}
|
||||
onMapClick={handleMapClick}
|
||||
oilTrajectory={[]}
|
||||
enabledLayers={new Set()}
|
||||
dispersionResult={dispersionResult}
|
||||
/>
|
||||
<>
|
||||
<MapView
|
||||
incidentCoord={incidentCoord}
|
||||
isSelectingLocation={isSelectingLocation}
|
||||
onMapClick={handleMapClick}
|
||||
oilTrajectory={[]}
|
||||
enabledLayers={new Set()}
|
||||
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>
|
||||
|
||||
@ -350,8 +722,11 @@ export function HNSView() {
|
||||
{activeSubTab === 'analysis' && (
|
||||
<HNSRightPanel
|
||||
dispersionResult={dispersionResult}
|
||||
computedResult={computedResult}
|
||||
weatherData={inputParams?.weather ?? null}
|
||||
onOpenRecalc={() => setRecalcModalOpen(true)}
|
||||
onOpenReport={() => { setReportGenCategory(1); navigateToTab('reports', 'generate') }}
|
||||
onOpenReport={handleOpenReport}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -359,8 +734,32 @@ export function HNSView() {
|
||||
<HNSRecalcModal
|
||||
isOpen={recalcModalOpen}
|
||||
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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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> {
|
||||
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 {
|
||||
createEmptyReport,
|
||||
} from './OilSpillReportTemplate';
|
||||
import { consumeReportGenCategory } from '@common/hooks/useSubMenu';
|
||||
import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload } from '@common/hooks/useSubMenu';
|
||||
import { saveReport } from '../services/reportsApi';
|
||||
import {
|
||||
CATEGORIES,
|
||||
@ -30,6 +30,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
2: CATEGORIES[2].sections.map(s => ({ ...s })),
|
||||
}))
|
||||
|
||||
// HNS 실 데이터 (없으면 sampleHnsData fallback)
|
||||
const [hnsPayload, setHnsPayload] = useState<HnsReportPayload | null>(null)
|
||||
|
||||
// 외부에서 카테고리 힌트가 변경되면 반영
|
||||
useEffect(() => {
|
||||
const hint = consumeReportGenCategory()
|
||||
@ -38,6 +41,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
setActiveCat(hint)
|
||||
setSelectedTemplate(0)
|
||||
}
|
||||
// HNS 데이터 소비
|
||||
const payload = consumeHnsReportPayload()
|
||||
if (payload) setHnsPayload(payload)
|
||||
}, [])
|
||||
|
||||
const cat = CATEGORIES[activeCat]
|
||||
@ -72,10 +78,29 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
const secColor = cat.color === 'var(--cyan)' ? '#06b6d4' : cat.color === 'var(--orange)' ? '#f97316' : '#ef4444';
|
||||
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('')
|
||||
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)
|
||||
}
|
||||
|
||||
@ -303,17 +328,28 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
{/* ── HNS 대기확산 섹션들 ── */}
|
||||
{sec.id === 'hns-atm' && (
|
||||
<>
|
||||
<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 className="grid grid-cols-2 gap-3">
|
||||
{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>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'ALOHA', value: sampleHnsData.atm.aloha, color: '#f97316' },
|
||||
{ label: 'WRF-Chem', value: sampleHnsData.atm.wrfChem, color: '#22c55e' },
|
||||
{ label: hnsPayload?.atm.model || 'ALOHA', value: hnsPayload?.atm.maxDistance || sampleHnsData.atm.aloha, color: '#f97316', desc: '최대 확산거리' },
|
||||
{ label: '최대 농도', value: hnsPayload?.maxConcentration || '—', color: '#ef4444', desc: '지상 1.5m 기준' },
|
||||
{ label: 'AEGL-1 면적', value: hnsPayload?.aeglAreas.aegl1 || '—', color: '#06b6d4', desc: '확산 영향 면적' },
|
||||
].map((m, i) => (
|
||||
<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-[8px] text-text-3 font-korean mt-1">{m.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -322,13 +358,14 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
{sec.id === 'hns-hazard' && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'ERPG-2 구역', value: sampleHnsData.hazard.erpg2, color: '#f97316', desc: '건강 영향' },
|
||||
{ label: 'ERPG-3 구역', value: sampleHnsData.hazard.erpg3, color: '#ef4444', desc: '생명 위협' },
|
||||
{ label: '대피 권고 범위', value: sampleHnsData.hazard.evacuation, color: '#a855f7', desc: '안전거리' },
|
||||
{ label: 'AEGL-3 구역', value: hnsPayload?.hazard.aegl3 || sampleHnsData.hazard.erpg3, area: hnsPayload?.aeglAreas.aegl3, color: '#ef4444', desc: '생명 위협' },
|
||||
{ label: 'AEGL-2 구역', value: hnsPayload?.hazard.aegl2 || sampleHnsData.hazard.erpg2, area: hnsPayload?.aeglAreas.aegl2, color: '#f97316', desc: '건강 피해' },
|
||||
{ label: 'AEGL-1 구역', value: hnsPayload?.hazard.aegl1 || sampleHnsData.hazard.evacuation, area: hnsPayload?.aeglAreas.aegl1, color: '#eab308', desc: '불쾌감' },
|
||||
].map((h, i) => (
|
||||
<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-[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>
|
||||
</div>
|
||||
))}
|
||||
@ -337,10 +374,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
{sec.id === 'hns-substance' && (
|
||||
<div className="grid grid-cols-2 gap-2 text-[11px]">
|
||||
{[
|
||||
{ k: '물질명', v: sampleHnsData.substance.name },
|
||||
{ k: 'UN번호', v: sampleHnsData.substance.un },
|
||||
{ k: 'CAS번호', v: sampleHnsData.substance.cas },
|
||||
{ k: '위험등급', v: sampleHnsData.substance.class },
|
||||
{ k: '물질명', v: hnsPayload?.substance.name || sampleHnsData.substance.name },
|
||||
{ k: 'UN번호', v: hnsPayload?.substance.un || sampleHnsData.substance.un },
|
||||
{ k: 'CAS번호', v: hnsPayload?.substance.cas || sampleHnsData.substance.cas },
|
||||
{ k: '위험등급', v: hnsPayload?.substance.class || sampleHnsData.substance.class },
|
||||
].map((r, i) => (
|
||||
<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>
|
||||
@ -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)]">
|
||||
<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>
|
||||
)}
|
||||
@ -385,10 +422,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
{sec.id === 'hns-weather' && (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: '풍향', value: 'NE 42°', icon: '🌬' },
|
||||
{ label: '풍속', value: '5.2 m/s', icon: '💨' },
|
||||
{ label: '대기안정도', value: 'D (중립)', icon: '🌡' },
|
||||
{ label: '기온', value: '8.5°C', icon: '☀️' },
|
||||
{ label: '풍향', value: hnsPayload?.weather.windDir || 'NE 42°', icon: '🌬' },
|
||||
{ label: '풍속', value: hnsPayload?.weather.windSpeed || '5.2 m/s', icon: '💨' },
|
||||
{ label: '대기안정도', value: hnsPayload?.weather.stability || 'D (중립)', icon: '🌡' },
|
||||
{ label: '기온', value: hnsPayload?.weather.temperature || '8.5°C', icon: '☀️' },
|
||||
].map((w, i) => (
|
||||
<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>
|
||||
|
||||
13
frontend/src/tabs/scat/components/DistributionView.tsx
Normal file
13
frontend/src/tabs/scat/components/DistributionView.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
function DistributionView() {
|
||||
return (
|
||||
<div className="flex w-full h-full bg-bg-0 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl opacity-20 mb-3">🗺</div>
|
||||
<div className="text-sm font-bold text-text-2 font-korean mb-1">해양오염분포도</div>
|
||||
<div className="text-xs text-text-3 font-korean">해양오염 분포도 기능이 준비 중입니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DistributionView;
|
||||
27
frontend/src/tabs/scat/components/ScatView.tsx
Normal file
27
frontend/src/tabs/scat/components/ScatView.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { useSubMenu } from '@common/hooks/useSubMenu';
|
||||
import { PreScatView } from './PreScatView';
|
||||
import SurveyView from './SurveyView';
|
||||
import DistributionView from './DistributionView';
|
||||
|
||||
export function ScatView() {
|
||||
const { activeSubTab } = useSubMenu('scat');
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeSubTab) {
|
||||
case 'survey':
|
||||
return <SurveyView />;
|
||||
case 'distribution':
|
||||
return <DistributionView />;
|
||||
case 'pre-scat':
|
||||
return <PreScatView />;
|
||||
default:
|
||||
return <SurveyView />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-bg-0">
|
||||
<div className="flex-1 overflow-auto">{renderContent()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
frontend/src/tabs/scat/components/SurveyView.tsx
Normal file
13
frontend/src/tabs/scat/components/SurveyView.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
function SurveyView() {
|
||||
return (
|
||||
<div className="flex w-full h-full bg-bg-0 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl opacity-20 mb-3">📋</div>
|
||||
<div className="text-sm font-bold text-text-2 font-korean mb-1">해안오염 조사 평가</div>
|
||||
<div className="text-xs text-text-3 font-korean">해안오염 조사 및 평가 기능이 준비 중입니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SurveyView;
|
||||
@ -1 +1,2 @@
|
||||
export { PreScatView } from './components/PreScatView'
|
||||
export { ScatView } from './components/ScatView'
|
||||
|
||||
@ -5,6 +5,15 @@ import path from 'path'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
// HLS 스트림 프록시 등 상대 경로 API 요청을 백엔드로 전달
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@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