Merge pull request 'develop' (#71) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 45s

Reviewed-on: #71
This commit is contained in:
nankyung 2026-03-06 07:38:45 +09:00
커밋 dc4be29cfc
43개의 변경된 파일87988개의 추가작업 그리고 1171개의 파일을 삭제

파일 보기

@ -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"
}

파일 보기

@ -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`,

파일 보기

@ -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));

파일 보기

@ -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대 장비

파일 보기

@ -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);

파일 보기

@ -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 }),

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

파일 보기

@ -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>
)}

파일 보기

@ -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="flex flex-col gap-2">
{/* 기관 기본 정보 */}
<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">
<div className="text-[10px] font-bold text-text-3 mb-2 font-korean"> </div>
{[
['기관/업체', c.name],
['연락처', c.phone],
['기관명', 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 text-[11px]">
<span className="text-text-3 font-korean">{k}</span>
<span className="font-mono text-text-1">{v}</span>
<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 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])
const getStatus = (expiry: string) => {
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>
))}
{/* 에러 */}
{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,35 +221,130 @@ 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>
<label className="hns-lbl text-[8px] text-text-3 block mb-1"></label>
<div className="flex flex-col gap-[6px]">
{/* 사고명 직접 입력 */}
<input
className="hns-inp w-full px-[10px] py-2 bg-bg-0 border border-border rounded text-[11px]"
className="prd-i w-full"
value={accidentName}
onChange={(e) => setAccidentName(e.target.value)}
placeholder="사고명 직접 입력"
/>
{/* 또는 사고 리스트에서 선택 */}
<ComboBox
className="prd-i"
value={selectedIncidentSn}
onChange={handleSelectIncident}
placeholder="또는 사고 리스트에서 선택"
options={incidents.map(inc => ({
value: String(inc.acdntSn),
label: `${inc.acdntNm} (${new Date(inc.occrnDtm).toLocaleDateString('ko-KR')})`,
}))}
/>
{/* 사고 발생 일시 */}
<div>
<label className="text-[10px] text-text-3 block mb-0.5"> </label>
<div className="grid grid-cols-2 gap-1">
<input
className="prd-i"
type="date"
value={accidentDate}
onChange={(e) => setAccidentDate(e.target.value)}
/>
<input
className="prd-i"
type="time"
value={accidentTime}
onChange={(e) => setAccidentTime(e.target.value)}
/>
</div>
</div>
{/* 좌표 + 지도 버튼 */}
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 1fr auto' }}>
<input
className="prd-i flex-1 font-mono"
type="number"
step="0.0001"
value={incidentCoord.lat.toFixed(4)}
onChange={(e) => onCoordChange({ ...incidentCoord, lat: parseFloat(e.target.value) || 0 })}
/>
<input
className="prd-i flex-1 font-mono"
type="number"
step="0.0001"
value={incidentCoord.lon.toFixed(4)}
onChange={(e) => onCoordChange({ ...incidentCoord, lon: parseFloat(e.target.value) || 0 })}
/>
<button className="prd-map-btn" onClick={onMapSelectClick}>
📍
</button>
</div>
{/* DMS 표시 */}
<div className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
style={{ padding: '4px 8px', borderRadius: 'var(--rS)' }}>
{toDMS(incidentCoord.lat, 'lat')} / {toDMS(incidentCoord.lon, 'lon')}
</div>
{/* 유출형태 + 물질명 */}
<div className="grid grid-cols-2 gap-1">
<ComboBox
className="prd-i"
value={releaseType}
onChange={(v) => setReleaseType(v as ReleaseType)}
options={[
{ value: '연속 유출', label: '연속' },
{ value: '순간 유출', label: '순간' },
{ value: '풀(Pool) 증발', label: '풀 증발' },
]}
/>
<ComboBox
className="prd-i"
value={substance}
onChange={handleSubstanceChange}
options={[
{ value: '톨루엔 (Toluene)', label: '톨루엔' },
{ value: '벤젠 (Benzene)', label: '벤젠' },
{ value: '자일렌 (Xylene)', label: '자일렌' },
{ value: '스티렌 (Styrene)', label: '스티렌' },
{ value: '메탄올 (Methanol)', label: '메탄올' },
{ value: '아세톤 (Acetone)', label: '아세톤' },
{ value: '염소 (Chlorine)', label: '염소' },
{ value: '암모니아 (Ammonia)', label: '암모니아' },
{ value: '염화수소 (HCl)', label: '염화수소' },
{ value: '황화수소 (H2S)', label: '황화수소' },
]}
/>
</div>
{/* 사고일시 + 예측시간 */}
<div className="grid grid-cols-2 gap-1.5">
<div>
<label className="hns-lbl text-[8px] text-text-3 block mb-1"></label>
{/* 유출량 + 단위 + 예측시간 */}
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 65px 1fr' }}>
<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"
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'}
/>
</div>
<div>
<label className="hns-lbl text-[8px] text-text-3 block mb-1"></label>
<ComboBox
className="hns-inp"
className="prd-i"
value={releaseType === '순간 유출' ? 'g' : 'g/s'}
onChange={() => {}}
options={
releaseType === '순간 유출'
? [{ value: 'g', label: 'g' }, { value: 'kg', label: 'kg' }]
: [{ value: 'g/s', label: 'g/s' }, { value: 'kg/s', label: 'kg/s' }]
}
/>
<ComboBox
className="prd-i"
value={predictionTime}
onChange={setPredictionTime}
options={[
@ -115,107 +352,18 @@ export function HNSLeftPanel({
{ value: '12시간', label: '12시간' },
{ value: '24시간', label: '24시간' },
{ value: '48시간', label: '48시간' },
{ value: '72시간', label: '72시간' }
]}
/>
</div>
</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="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 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-[6px]">
{/* 모델별 입력 파라미터 */}
<div
className="p-[10px] rounded-md"
style={{ background: 'rgba(6,182,212,0.04)', border: '1px solid rgba(6,182,212,0.15)' }}
>
{/* 물질명 */}
{/* 연속 유출 (Plume): 배출률, 누출지속시간, 누출높이 */}
{releaseType === '연속 유출' && (
<div className="flex flex-col gap-[6px]">
<div className="grid grid-cols-2 gap-1">
<div>
<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>
{/* 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">
<label className="text-[10px] text-text-3 block mb-0.5"> (g/s)</label>
<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
/>
</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]"
className="prd-i w-full font-mono"
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
value={emissionRate}
onChange={(e) => setEmissionRate(e.target.value)}
step="0.1"
min="0"
/>
</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: '배럴' }
]}
<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>
)}
{/* 유출 형태 */}
{/* 순간 유출 (Puff): 총 누출량, 누출높이 */}
{releaseType === '순간 유출' && (
<div className="flex flex-col gap-[6px]">
<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) 증발' }
]}
<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="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>
</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>
<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>
{/* 유출 위치 */}
<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) => (
{/* AEGL-3 */}
<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')}`
}}
style={{ borderLeft: '3px solid rgba(239,68,68,1)' }}
>
<div className="flex justify-between items-center mb-1">
<span className="text-[11px] font-semibold">
{zone.level}
</span>
<span className="text-[11px] font-semibold">AEGL-3 ()</span>
<span className="text-[10px] font-mono text-text-3">
{zone.radius}m
{computedResult?.aeglDistances.aegl3 || 0}m
</span>
</div>
<div className="text-[10px] text-text-3">
{dispersionResult.concentration[zone.level as keyof typeof dispersionResult.concentration]}
<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,15 +85,18 @@ export function HNSSubstanceView() {
const params: Record<string, string | number> = { page: hmsPage, limit: 10 }
if (hmsSearchInput.trim()) {
params.q = hmsSearchInput.trim()
if (hmsSearchType !== 'all') {
params.type = searchTypeMap[hmsSearchType] || 'abbreviation'
}
}
if (hmsFilterSebc !== '전체 거동분류') {
params.sebc = hmsFilterSebc.split(' ')[0]
}
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> &nbsp;|&nbsp; / <b className="text-status-orange">, </b> &nbsp;|&nbsp; <b className="text-primary-cyan">1,316</b> ( 277 / 56 / 983)
· <b className="text-status-orange"> </b> &nbsp;|&nbsp; / <b className="text-status-orange">, </b> &nbsp;|&nbsp; <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);
}
};
// 시간 애니메이션 (puff/dense_gas)
useEffect(() => {
if (!isPuffPlaying || allTimeFrames.length === 0) return;
const interval = setInterval(() => {
setCurrentFrame(prev => {
const next = prev + 1;
if (next >= allTimeFrames.length) {
setIsPuffPlaying(false);
return prev;
}
setHeatmapData(allTimeFrames[next].points);
setComputedResult(allTimeFrames[next]);
return next;
});
}, 300);
return () => clearInterval(interval);
}, [isPuffPlaying, allTimeFrames]);
const handleFrameChange = (frame: number) => {
setCurrentFrame(frame);
if (allTimeFrames[frame]) {
setHeatmapData(allTimeFrames[frame].points);
setComputedResult(allTimeFrames[frame]);
}
};
/** 확산 계산 핵심 로직 (버튼 클릭 + 파라미터 변경 시 공유) */
const runComputation = useCallback((params: HNSInputParams | null, coord: { lon: number; lat: number }) => {
const substanceName = params?.substance || '톨루엔 (Toluene)';
const tox = getSubstanceToxicity(substanceName);
const weather = params?.weather;
const meteo: MeteoParams = {
windSpeed: weather?.windSpeed ?? 5.0,
windDirDeg: weather?.windDirection ?? 270,
stability: weather?.stability ?? 'D',
temperature: ((weather?.temperature ?? 15) + 273.15),
pressure: 101325,
mixingHeight: 800,
};
const releaseType = params?.releaseType || '연속 유출';
const source: SourceParams = {
Q: params?.emissionRate ?? tox.Q,
QTotal: params?.totalRelease ?? tox.QTotal,
x0: 0, y0: 0,
z0: params?.releaseHeight ?? 0.5,
releaseDuration: releaseType === '연속 유출' ? (params?.releaseDuration ?? 300) : 0,
molecularWeight: tox.mw,
vaporPressure: tox.vaporPressure,
densityGas: tox.densityGas,
poolRadius: params?.poolRadius ?? tox.poolRadius,
};
const sim: SimParams = {
xRange: [-100, 10000],
yRange: [-2000, 2000],
nx: 300,
ny: 200,
zRef: 1.5,
tStart: 0,
tEnd: 600,
dt: 30,
};
const modelType: DispersionModel =
releaseType === '연속 유출' ? 'plume' :
releaseType === '순간 유출' ? 'puff' : 'dense_gas';
const algo = (params?.algorithm || 'ALOHA (EPA)') as AlgorithmType;
if (modelType === 'plume') {
const result = computeDispersion({
meteo, source, sim, modelType,
originLon: coord.lon, originLat: coord.lat,
substanceName, t: sim.dt, algorithm: algo,
});
console.log('[HNS] plume 계산 완료:', result.points.length, '포인트, maxConc:', result.maxConcentration.toFixed(2), 'ppm');
setComputedResult(result);
setHeatmapData(result.points);
setAllTimeFrames([result]);
setCurrentFrame(0);
setIsPuffPlaying(false);
} else {
const times: number[] = [];
for (let t = sim.dt; t <= sim.tEnd; t += sim.dt) times.push(t);
const frames = times.map(t => computeDispersion({
meteo, source, sim, modelType,
originLon: coord.lon, originLat: coord.lat,
substanceName, t, algorithm: algo,
}));
console.log('[HNS]', modelType, '계산 완료:', frames.length, '프레임, 첫 프레임:', frames[0].points.length, '포인트');
setAllTimeFrames(frames);
setComputedResult(frames[0]);
setHeatmapData(frames[0].points);
setCurrentFrame(0);
setIsPuffPlaying(true);
}
const handleRunPrediction = async () => {
setIsRunningPrediction(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')}`,
const params = paramsOverride ?? inputParams;
// 1. 계산 먼저 실행 (동기, 히트맵 즉시 표시)
const { tox, meteo, resultForZones, substanceName } = runComputation(params, incidentCoord);
hasRunOnce.current = true;
setDispersionResult({
hnsAnlysSn: 0,
zones: [
{ level: 'AEGL-3', color: '#ef4444', radius: resultForZones.aeglDistances.aegl3, angle: meteo.windDirDeg },
{ level: 'AEGL-2', color: '#f97316', radius: resultForZones.aeglDistances.aegl2, angle: meteo.windDirDeg },
{ level: 'AEGL-1', color: '#eab308', radius: resultForZones.aeglDistances.aegl1, angle: meteo.windDirDeg },
].filter(z => z.radius > 0),
timestamp: new Date().toISOString(),
windDirection: meteo.windDirDeg,
substance: substanceName,
concentration: {
'AEGL-3': `${tox.aegl3} ppm`,
'AEGL-2': `${tox.aegl2} ppm`,
'AEGL-1': `${tox.aegl1} ppm`,
},
maxConcentration: resultForZones.maxConcentration,
});
// 2. 분석 레코드 DB 저장 (비동기, 실패해도 무시)
try {
const acdntDtm = params?.accidentDate && params?.accidentTime
? `${params.accidentDate}T${params.accidentTime}:00`
: params?.accidentDate || undefined;
const result = await createHnsAnalysis({
anlysNm: params?.accidentName || `HNS 분석 ${new Date().toLocaleDateString('ko-KR')}`,
acdntDtm,
lon: incidentCoord.lon,
lat: incidentCoord.lat,
locNm: `${incidentCoord.lon.toFixed(4)}, ${incidentCoord.lat.toFixed(4)}`,
})
// 시뮬레이션 엔진 미구현 — 프론트 임시 결과 생성
const windAngle = 225
const result = {
hnsAnlysSn,
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 },
],
timestamp: new Date().toISOString(),
windDirection: windAngle,
substance: 'Toluene',
concentration: { 'AEGL-3': '500 ppm', 'AEGL-2': '150 ppm', 'AEGL-1': '37 ppm' }
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,14 +681,18 @@ 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}
@ -342,7 +700,21 @@ export function HNSView() {
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>
)
);
}

파일 보기

@ -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}`);
}

파일 보기

@ -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,
};
}

파일 보기

@ -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;
}

파일 보기

@ -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' && (
<>
{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-2 gap-3">
)}
<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>

파일 보기

@ -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;

파일 보기

@ -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>
);
}

파일 보기

@ -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
파일 보기

@ -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/)
- 지면 반사 포함 (image method)
- 혼합층 상한 반사 포함 (선택적)
C(x,y,z) = Q / (2π σy σz u)
× exp(-/2σ)
× [exp(-(z-H)²/2σ) + exp(-(z+H)²/2σ)]
"""
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/)
C(x,y,z,t) = Q_total / [(2π)^(3/2) σx σy σz]
× exp(-x_r²/2σ)
× exp(-y_r²/2σ)
× [exp(-(z-H)²/2σ) + exp(-(z+H)²/2σ)]
여기서 퍼프 중심 = (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/ (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=".")