Merge branch 'develop' of https://gitea.gc-si.dev/gc/wing-ops into feature/wing-disign-system
This commit is contained in:
커밋
51da334e7a
@ -5,29 +5,29 @@
|
|||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npm run *)",
|
"Bash(curl -s *)",
|
||||||
"Bash(npm install *)",
|
"Bash(fnm *)",
|
||||||
"Bash(npm test *)",
|
"Bash(git add *)",
|
||||||
"Bash(npx *)",
|
|
||||||
"Bash(node *)",
|
|
||||||
"Bash(git status)",
|
|
||||||
"Bash(git diff *)",
|
|
||||||
"Bash(git log *)",
|
|
||||||
"Bash(git branch *)",
|
"Bash(git branch *)",
|
||||||
"Bash(git checkout *)",
|
"Bash(git checkout *)",
|
||||||
"Bash(git add *)",
|
|
||||||
"Bash(git commit *)",
|
"Bash(git commit *)",
|
||||||
"Bash(git pull *)",
|
|
||||||
"Bash(git fetch *)",
|
|
||||||
"Bash(git merge *)",
|
|
||||||
"Bash(git stash *)",
|
|
||||||
"Bash(git remote *)",
|
|
||||||
"Bash(git config *)",
|
"Bash(git config *)",
|
||||||
|
"Bash(git diff *)",
|
||||||
|
"Bash(git fetch *)",
|
||||||
|
"Bash(git log *)",
|
||||||
|
"Bash(git merge *)",
|
||||||
|
"Bash(git pull *)",
|
||||||
|
"Bash(git remote *)",
|
||||||
"Bash(git rev-parse *)",
|
"Bash(git rev-parse *)",
|
||||||
"Bash(git show *)",
|
"Bash(git show *)",
|
||||||
|
"Bash(git stash *)",
|
||||||
|
"Bash(git status)",
|
||||||
"Bash(git tag *)",
|
"Bash(git tag *)",
|
||||||
"Bash(curl -s *)",
|
"Bash(node *)",
|
||||||
"Bash(fnm *)"
|
"Bash(npm install *)",
|
||||||
|
"Bash(npm run *)",
|
||||||
|
"Bash(npm test *)",
|
||||||
|
"Bash(npx *)"
|
||||||
],
|
],
|
||||||
"deny": [
|
"deny": [
|
||||||
"Bash(git push --force*)",
|
"Bash(git push --force*)",
|
||||||
@ -83,5 +83,7 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"deny": [],
|
||||||
|
"allow": []
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"applied_global_version": "1.6.1",
|
"applied_global_version": "1.6.1",
|
||||||
"applied_date": "2026-03-11",
|
"applied_date": "2026-03-13",
|
||||||
"project_type": "react-ts",
|
"project_type": "react-ts",
|
||||||
"gitea_url": "https://gitea.gc-si.dev",
|
"gitea_url": "https://gitea.gc-si.dev",
|
||||||
"custom_pre_commit": true
|
"custom_pre_commit": true
|
||||||
|
|||||||
@ -18,6 +18,7 @@ interface PredictionAnalysis {
|
|||||||
backtrackStatus: string;
|
backtrackStatus: string;
|
||||||
analyst: string;
|
analyst: string;
|
||||||
officeName: string;
|
officeName: string;
|
||||||
|
acdntSttsCd: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PredictionDetail {
|
interface PredictionDetail {
|
||||||
@ -129,6 +130,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
|
|||||||
SELECT
|
SELECT
|
||||||
A.ACDNT_SN,
|
A.ACDNT_SN,
|
||||||
A.ACDNT_NM,
|
A.ACDNT_NM,
|
||||||
|
A.ACDNT_STTS_CD,
|
||||||
A.OCCRN_DTM,
|
A.OCCRN_DTM,
|
||||||
A.LAT,
|
A.LAT,
|
||||||
A.LNG,
|
A.LNG,
|
||||||
@ -186,6 +188,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
|
|||||||
backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(),
|
backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(),
|
||||||
analyst: String(row['analyst_nm'] ?? ''),
|
analyst: String(row['analyst_nm'] ?? ''),
|
||||||
officeName: String(row['office_nm'] ?? ''),
|
officeName: String(row['office_nm'] ?? ''),
|
||||||
|
acdntSttsCd: String(row['acdnt_stts_cd'] ?? 'ACTIVE'),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -47,6 +47,30 @@ const UNIT_MAP: Record<string, string> = {
|
|||||||
'kL': 'KL', 'ton': 'TON', 'barrel': 'BBL',
|
'kL': 'KL', 'ton': 'TON', 'barrel': 'BBL',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 신규 생성된 ACDNT/SPIL_DATA/PRED_EXEC 롤백 헬퍼
|
||||||
|
// Python 호출 실패 시 이번 요청에서 생성된 레코드만 삭제한다.
|
||||||
|
// ============================================================
|
||||||
|
async function rollbackNewRecords(
|
||||||
|
predExecSn: number | null,
|
||||||
|
newSpilDataSn: number | null,
|
||||||
|
newAcdntSn: number | null
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (predExecSn !== null) {
|
||||||
|
await wingPool.query('DELETE FROM wing.PRED_EXEC WHERE PRED_EXEC_SN=$1', [predExecSn])
|
||||||
|
}
|
||||||
|
if (newSpilDataSn !== null) {
|
||||||
|
await wingPool.query('DELETE FROM wing.SPIL_DATA WHERE SPIL_DATA_SN=$1', [newSpilDataSn])
|
||||||
|
}
|
||||||
|
if (newAcdntSn !== null) {
|
||||||
|
await wingPool.query('DELETE FROM wing.ACDNT WHERE ACDNT_SN=$1', [newAcdntSn])
|
||||||
|
}
|
||||||
|
} catch (cleanupErr) {
|
||||||
|
console.error('[simulation] 롤백 실패:', cleanupErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// POST /api/simulation/run
|
// POST /api/simulation/run
|
||||||
// 확산 시뮬레이션 실행 (OpenDrift)
|
// 확산 시뮬레이션 실행 (OpenDrift)
|
||||||
@ -92,9 +116,30 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
|||||||
return res.status(400).json({ error: '사고명은 200자 이내여야 합니다.' })
|
return res.status(400).json({ error: '사고명은 200자 이내여야 합니다.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Python NC 파일 존재 여부 확인 (ACDNT 생성 전에 수행하여 고아 레코드 방지)
|
||||||
|
try {
|
||||||
|
const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ lat, lon, startTime }),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
})
|
||||||
|
if (!checkRes.ok) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: '해당 좌표의 해양 기상 데이터가 없습니다.',
|
||||||
|
message: 'NC 파일이 준비되지 않았습니다.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Python 서버 미기동 — 5번에서 처리
|
||||||
|
}
|
||||||
|
|
||||||
// 1-B. acdntSn 미제공 시 ACDNT + SPIL_DATA 생성
|
// 1-B. acdntSn 미제공 시 ACDNT + SPIL_DATA 생성
|
||||||
let resolvedAcdntSn: number | null = rawAcdntSn ? Number(rawAcdntSn) : null
|
let resolvedAcdntSn: number | null = rawAcdntSn ? Number(rawAcdntSn) : null
|
||||||
let resolvedSpilDataSn: number | null = null
|
let resolvedSpilDataSn: number | null = null
|
||||||
|
// 이번 요청에서 신규 생성된 레코드 추적 (Python 실패 시 롤백 대상)
|
||||||
|
let newlyCreatedAcdntSn: number | null = null
|
||||||
|
let newlyCreatedSpilDataSn: number | null = null
|
||||||
|
|
||||||
if (!resolvedAcdntSn && acdntNm) {
|
if (!resolvedAcdntSn && acdntNm) {
|
||||||
try {
|
try {
|
||||||
@ -116,6 +161,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
|||||||
[acdntNm.trim(), occrn, lat, lon]
|
[acdntNm.trim(), occrn, lat, lon]
|
||||||
)
|
)
|
||||||
resolvedAcdntSn = acdntRes.rows[0].acdnt_sn as number
|
resolvedAcdntSn = acdntRes.rows[0].acdnt_sn as number
|
||||||
|
newlyCreatedAcdntSn = resolvedAcdntSn
|
||||||
|
|
||||||
const spilRes = await wingPool.query(
|
const spilRes = await wingPool.query(
|
||||||
`INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM)
|
`INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM)
|
||||||
@ -131,30 +177,13 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number
|
resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number
|
||||||
|
newlyCreatedSpilDataSn = resolvedSpilDataSn
|
||||||
} catch (dbErr) {
|
} catch (dbErr) {
|
||||||
console.error('[simulation] ACDNT/SPIL_DATA INSERT 실패:', dbErr)
|
console.error('[simulation] ACDNT/SPIL_DATA INSERT 실패:', dbErr)
|
||||||
return res.status(500).json({ error: '사고 정보 생성 실패' })
|
return res.status(500).json({ error: '사고 정보 생성 실패' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Python NC 파일 존재 여부 확인
|
|
||||||
try {
|
|
||||||
const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ lat, lon, startTime }),
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
})
|
|
||||||
if (!checkRes.ok) {
|
|
||||||
return res.status(409).json({
|
|
||||||
error: '해당 좌표의 해양 기상 데이터가 없습니다.',
|
|
||||||
message: 'NC 파일이 준비되지 않았습니다.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Python 서버 미기동 — 5번에서 처리
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 기존 사고의 경우 SPIL_DATA_SN 조회
|
// 3. 기존 사고의 경우 SPIL_DATA_SN 조회
|
||||||
if (resolvedAcdntSn && !resolvedSpilDataSn) {
|
if (resolvedAcdntSn && !resolvedSpilDataSn) {
|
||||||
try {
|
try {
|
||||||
@ -215,6 +244,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
|||||||
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`,
|
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`,
|
||||||
[errData.error || '분석 서버 포화', predExecSn]
|
[errData.error || '분석 서버 포화', predExecSn]
|
||||||
)
|
)
|
||||||
|
await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
|
||||||
return res.status(503).json({ error: errData.error || '분석 서버가 사용 중입니다. 잠시 후 재시도해 주세요.' })
|
return res.status(503).json({ error: errData.error || '분석 서버가 사용 중입니다. 잠시 후 재시도해 주세요.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,6 +259,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
|||||||
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='Python 분석 서버에 연결할 수 없습니다.', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`,
|
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='Python 분석 서버에 연결할 수 없습니다.', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`,
|
||||||
[predExecSn]
|
[predExecSn]
|
||||||
)
|
)
|
||||||
|
await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
|
||||||
return res.status(503).json({ error: 'Python 분석 서버에 연결할 수 없습니다.' })
|
return res.status(503).json({ error: 'Python 분석 서버에 연결할 수 없습니다.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,27 +4,21 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2026-03-13]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
- 오염분석 다각형/원 분석 기능 구현
|
||||||
|
- 시뮬레이션 에러 모달 추가
|
||||||
- 해류 캔버스 파티클 레이어 추가
|
- 해류 캔버스 파티클 레이어 추가
|
||||||
|
|
||||||
## [2026-03-11.2]
|
### 수정
|
||||||
|
- useSubMenu useEffect import 누락 수정
|
||||||
### 추가
|
|
||||||
- OpenDrift 유류 확산 시뮬레이션 통합 (비동기 폴링 구조)
|
|
||||||
- flyTo 완료 후 자동 재생 기능
|
|
||||||
- 이미지 분석 서버 Docker 패키징 (CPU 전용 환경)
|
|
||||||
- SPIL_DATA 이미지 분석 결과 컬럼 인라인 통합
|
|
||||||
- CPU 전용 Docker 환경 구축 (Dockerfile.cpu, docker-compose.cpu.yml)
|
|
||||||
|
|
||||||
### 변경
|
### 변경
|
||||||
- 이미지 분석/보고서/항공 UI 개선
|
- 보고서 해안부착 현황 개선
|
||||||
- CCTV/관리자 고도화
|
|
||||||
|
|
||||||
### 기타
|
### 기타
|
||||||
- 팀 워크플로우 v1.6.1 적용일 갱신
|
- 팀 워크플로우 동기화 (v1.6.1)
|
||||||
- 팀 워크플로우 v1.6.1 동기화 (custom_pre_commit 프로젝트 해시 불일치 해결)
|
|
||||||
- 팀 워크플로우 v1.6.0 동기화 (해시 기반 자동 최신화, push/mr/release 워크플로우 체크, 팀 관리 파일 gitignore 처리)
|
|
||||||
- 팀 워크플로우 v1.5.0 동기화 (스킬 7종 업데이트, version 스킬 신규, release-notes-guide 추가)
|
|
||||||
|
|
||||||
## [2026-03-11]
|
## [2026-03-11]
|
||||||
|
|
||||||
@ -36,18 +30,27 @@
|
|||||||
- 관리자 화면 고도화 — 사용자/권한/게시판/선박신호 패널
|
- 관리자 화면 고도화 — 사용자/권한/게시판/선박신호 패널
|
||||||
- CCTV 오일 감지 GPU 추론 연동 및 HNS 초기 핀 제거
|
- CCTV 오일 감지 GPU 추론 연동 및 HNS 초기 핀 제거
|
||||||
- 유류오염보장계약 시드 데이터 추가 (1391건)
|
- 유류오염보장계약 시드 데이터 추가 (1391건)
|
||||||
|
- OpenDrift 유류 확산 시뮬레이션 통합 (비동기 폴링 구조)
|
||||||
|
- flyTo 완료 후 자동 재생 기능
|
||||||
|
- 이미지 분석 서버 Docker 패키징 (CPU 전용 환경)
|
||||||
|
- SPIL_DATA 이미지 분석 결과 컬럼 인라인 통합
|
||||||
|
- CPU 전용 Docker 환경 구축 (Dockerfile.cpu, docker-compose.cpu.yml)
|
||||||
|
|
||||||
### 수정
|
### 수정
|
||||||
- /orgs 라우트를 /:id 앞에 등록하여 라우트 매칭 수정
|
- /orgs 라우트를 /:id 앞에 등록하여 라우트 매칭 수정
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 이미지 분석/보고서/항공 UI 개선
|
||||||
|
- CCTV/관리자 고도화
|
||||||
|
|
||||||
### 문서
|
### 문서
|
||||||
- 프로젝트 문서 최신화 (KHOA API, Vite 프록시)
|
- 프로젝트 문서 최신화 (KHOA API, Vite 프록시)
|
||||||
|
|
||||||
### 기타
|
### 기타
|
||||||
- CLAUDE_BOT_TOKEN 갱신
|
- CLAUDE_BOT_TOKEN 갱신
|
||||||
- 팀 워크플로우 v1.6.1 동기화
|
- 팀 워크플로우 v1.6.1 동기화 (custom_pre_commit 프로젝트 해시 불일치 해결, 적용일 갱신)
|
||||||
- 팀 워크플로우 v1.6.0 동기화
|
- 팀 워크플로우 v1.6.0 동기화 (해시 기반 자동 최신화, push/mr/release 워크플로우 체크, 팀 관리 파일 gitignore 처리)
|
||||||
- 팀 워크플로우 v1.5.0 동기화
|
- 팀 워크플로우 v1.5.0 동기화 (스킬 7종 업데이트, version 스킬 신규, release-notes-guide 추가)
|
||||||
|
|
||||||
## [2026-03-01]
|
## [2026-03-01]
|
||||||
|
|
||||||
|
|||||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@ -25,6 +25,7 @@
|
|||||||
"@vis.gl/react-maplibre": "^8.1.0",
|
"@vis.gl/react-maplibre": "^8.1.0",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
|
"exifr": "^7.1.3",
|
||||||
"hls.js": "^1.6.15",
|
"hls.js": "^1.6.15",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.564.0",
|
||||||
@ -3848,6 +3849,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/exifr": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/extend-shallow": {
|
"node_modules/extend-shallow": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
"@vis.gl/react-maplibre": "^8.1.0",
|
"@vis.gl/react-maplibre": "^8.1.0",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
|
"exifr": "^7.1.3",
|
||||||
"hls.js": "^1.6.15",
|
"hls.js": "^1.6.15",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.564.0",
|
||||||
|
|||||||
@ -73,6 +73,25 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* 실시간 상황관리 */}
|
||||||
|
<button
|
||||||
|
onClick={() => window.open(import.meta.env.VITE_SITUATIONAL_URL ?? 'http://localhost:5174', '_blank')}
|
||||||
|
className={`
|
||||||
|
px-2.5 xl:px-4 py-2 rounded-sm text-[13px] transition-all duration-200
|
||||||
|
font-korean tracking-[0.2px] font-semibold
|
||||||
|
border-l border-l-[rgba(239,68,68,0.25)] ml-1
|
||||||
|
text-[#f87171] hover:text-[#fca5a5] hover:bg-[rgba(239,68,68,0.1)]
|
||||||
|
flex items-center gap-1.5
|
||||||
|
`}
|
||||||
|
title="실시간 상황관리"
|
||||||
|
>
|
||||||
|
<span className="hidden xl:flex items-center gap-1.5">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-[#f87171] animate-pulse inline-block" />
|
||||||
|
실시간 상황관리
|
||||||
|
</span>
|
||||||
|
<span className="xl:hidden text-[16px] leading-none">🛰</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
|
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
|
||||||
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
|
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||||
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer } from '@deck.gl/layers'
|
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer } from '@deck.gl/layers'
|
||||||
import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'
|
import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'
|
||||||
import type { StyleSpecification } from 'maplibre-gl'
|
import type { StyleSpecification } from 'maplibre-gl'
|
||||||
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
||||||
@ -189,6 +189,15 @@ interface MapViewProps {
|
|||||||
mapCaptureRef?: React.MutableRefObject<(() => string | null) | null>
|
mapCaptureRef?: React.MutableRefObject<(() => string | null) | null>
|
||||||
onIncidentFlyEnd?: () => void
|
onIncidentFlyEnd?: () => void
|
||||||
flyToIncident?: { lon: number; lat: number }
|
flyToIncident?: { lon: number; lat: number }
|
||||||
|
showCurrent?: boolean
|
||||||
|
showWind?: boolean
|
||||||
|
showBeached?: boolean
|
||||||
|
showTimeLabel?: boolean
|
||||||
|
simulationStartTime?: string
|
||||||
|
drawAnalysisMode?: 'polygon' | 'circle' | null
|
||||||
|
analysisPolygonPoints?: Array<{ lat: number; lon: number }>
|
||||||
|
analysisCircleCenter?: { lat: number; lon: number } | null
|
||||||
|
analysisCircleRadiusM?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
|
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
|
||||||
@ -311,6 +320,15 @@ export function MapView({
|
|||||||
mapCaptureRef,
|
mapCaptureRef,
|
||||||
onIncidentFlyEnd,
|
onIncidentFlyEnd,
|
||||||
flyToIncident,
|
flyToIncident,
|
||||||
|
showCurrent = true,
|
||||||
|
showWind = true,
|
||||||
|
showBeached = false,
|
||||||
|
showTimeLabel = false,
|
||||||
|
simulationStartTime,
|
||||||
|
drawAnalysisMode = null,
|
||||||
|
analysisPolygonPoints = [],
|
||||||
|
analysisCircleCenter,
|
||||||
|
analysisCircleRadiusM = 0,
|
||||||
}: MapViewProps) {
|
}: MapViewProps) {
|
||||||
const { mapToggles } = useMapStore()
|
const { mapToggles } = useMapStore()
|
||||||
const isControlled = externalCurrentTime !== undefined
|
const isControlled = externalCurrentTime !== undefined
|
||||||
@ -393,8 +411,10 @@ export function MapView({
|
|||||||
getPosition: (d: (typeof visibleParticles)[0]) => [d.lon, d.lat],
|
getPosition: (d: (typeof visibleParticles)[0]) => [d.lon, d.lat],
|
||||||
getRadius: 3,
|
getRadius: 3,
|
||||||
getFillColor: (d: (typeof visibleParticles)[0]) => {
|
getFillColor: (d: (typeof visibleParticles)[0]) => {
|
||||||
// 1순위: stranded 입자 → 빨간색
|
// 1순위: stranded 입자 → showBeached=true 시 빨간색, false 시 회색
|
||||||
if (d.stranded === 1) return [239, 68, 68, 220] as [number, number, number, number]
|
if (d.stranded === 1) return showBeached
|
||||||
|
? [239, 68, 68, 220] as [number, number, number, number]
|
||||||
|
: [130, 130, 130, 70] as [number, number, number, number]
|
||||||
// 2순위: 현재 활성 스텝 → 모델 기본 색상
|
// 2순위: 현재 활성 스텝 → 모델 기본 색상
|
||||||
if (d.time === activeStep) {
|
if (d.time === activeStep) {
|
||||||
const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift'
|
const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift'
|
||||||
@ -427,7 +447,7 @@ export function MapView({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateTriggers: {
|
updateTriggers: {
|
||||||
getFillColor: [selectedModels, currentTime],
|
getFillColor: [selectedModels, currentTime, showBeached],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@ -529,6 +549,91 @@ export function MapView({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 오염분석 다각형 그리기 ---
|
||||||
|
if (analysisPolygonPoints.length > 0) {
|
||||||
|
if (analysisPolygonPoints.length >= 3) {
|
||||||
|
result.push(
|
||||||
|
new PolygonLayer({
|
||||||
|
id: 'analysis-polygon-fill',
|
||||||
|
data: [{ polygon: analysisPolygonPoints.map(p => [p.lon, p.lat] as [number, number]) }],
|
||||||
|
getPolygon: (d: { polygon: [number, number][] }) => d.polygon,
|
||||||
|
getFillColor: [168, 85, 247, 40],
|
||||||
|
getLineColor: [168, 85, 247, 220],
|
||||||
|
getLineWidth: 2,
|
||||||
|
stroked: true,
|
||||||
|
filled: true,
|
||||||
|
lineWidthMinPixels: 2,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result.push(
|
||||||
|
new PathLayer({
|
||||||
|
id: 'analysis-polygon-outline',
|
||||||
|
data: [{
|
||||||
|
path: [
|
||||||
|
...analysisPolygonPoints.map(p => [p.lon, p.lat] as [number, number]),
|
||||||
|
...(analysisPolygonPoints.length >= 3 ? [[analysisPolygonPoints[0].lon, analysisPolygonPoints[0].lat] as [number, number]] : []),
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
getPath: (d: { path: [number, number][] }) => d.path,
|
||||||
|
getColor: [168, 85, 247, 220],
|
||||||
|
getWidth: 2,
|
||||||
|
getDashArray: [8, 4],
|
||||||
|
dashJustified: true,
|
||||||
|
widthMinPixels: 2,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
result.push(
|
||||||
|
new ScatterplotLayer({
|
||||||
|
id: 'analysis-polygon-points',
|
||||||
|
data: analysisPolygonPoints.map(p => ({ position: [p.lon, p.lat] as [number, number] })),
|
||||||
|
getPosition: (d: { position: [number, number] }) => d.position,
|
||||||
|
getRadius: 5,
|
||||||
|
getFillColor: [168, 85, 247, 255],
|
||||||
|
getLineColor: [255, 255, 255, 255],
|
||||||
|
getLineWidth: 2,
|
||||||
|
stroked: true,
|
||||||
|
radiusMinPixels: 5,
|
||||||
|
radiusMaxPixels: 8,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 오염분석 원 그리기 ---
|
||||||
|
if (analysisCircleCenter) {
|
||||||
|
result.push(
|
||||||
|
new ScatterplotLayer({
|
||||||
|
id: 'analysis-circle-center',
|
||||||
|
data: [{ position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }],
|
||||||
|
getPosition: (d: { position: [number, number] }) => d.position,
|
||||||
|
getRadius: 6,
|
||||||
|
getFillColor: [168, 85, 247, 255],
|
||||||
|
getLineColor: [255, 255, 255, 255],
|
||||||
|
getLineWidth: 2,
|
||||||
|
stroked: true,
|
||||||
|
radiusMinPixels: 6,
|
||||||
|
radiusMaxPixels: 9,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
if (analysisCircleRadiusM > 0) {
|
||||||
|
result.push(
|
||||||
|
new ScatterplotLayer({
|
||||||
|
id: 'analysis-circle-area',
|
||||||
|
data: [{ position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }],
|
||||||
|
getPosition: (d: { position: [number, number] }) => d.position,
|
||||||
|
getRadius: analysisCircleRadiusM,
|
||||||
|
radiusUnits: 'meters',
|
||||||
|
getFillColor: [168, 85, 247, 35],
|
||||||
|
getLineColor: [168, 85, 247, 200],
|
||||||
|
getLineWidth: 2,
|
||||||
|
stroked: true,
|
||||||
|
filled: true,
|
||||||
|
lineWidthMinPixels: 2,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) ---
|
// --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) ---
|
||||||
if (dispersionHeatmap && dispersionHeatmap.length > 0) {
|
if (dispersionHeatmap && dispersionHeatmap.length > 0) {
|
||||||
const maxConc = Math.max(...dispersionHeatmap.map(p => p.concentration));
|
const maxConc = Math.max(...dispersionHeatmap.map(p => p.concentration));
|
||||||
@ -782,8 +887,39 @@ export function MapView({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 시간 표시 라벨 (TextLayer) ---
|
||||||
|
if (visibleCenters.length > 0 && showTimeLabel) {
|
||||||
|
const baseTime = simulationStartTime ? new Date(simulationStartTime) : null;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
result.push(
|
||||||
|
new TextLayer({
|
||||||
|
id: 'time-labels',
|
||||||
|
data: visibleCenters,
|
||||||
|
getPosition: (d: (typeof visibleCenters)[0]) => [d.lon, d.lat],
|
||||||
|
getText: (d: (typeof visibleCenters)[0]) => {
|
||||||
|
if (baseTime) {
|
||||||
|
const dt = new Date(baseTime.getTime() + d.time * 3600 * 1000);
|
||||||
|
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
|
||||||
|
}
|
||||||
|
return `+${d.time}h`;
|
||||||
|
},
|
||||||
|
getSize: 12,
|
||||||
|
getColor: [255, 220, 50, 220] as [number, number, number, number],
|
||||||
|
getPixelOffset: [0, 16] as [number, number],
|
||||||
|
fontWeight: 'bold',
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [15, 21, 36, 200] as [number, number, number, number],
|
||||||
|
billboard: true,
|
||||||
|
sizeUnits: 'pixels' as const,
|
||||||
|
updateTriggers: {
|
||||||
|
getText: [simulationStartTime, currentTime],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- 바람 화살표 (TextLayer) ---
|
// --- 바람 화살표 (TextLayer) ---
|
||||||
if (incidentCoord && windData.length > 0) {
|
if (incidentCoord && windData.length > 0 && showWind) {
|
||||||
type ArrowPoint = { lon: number; lat: number; bearing: number; speed: number }
|
type ArrowPoint = { lon: number; lat: number; bearing: number; speed: number }
|
||||||
|
|
||||||
const activeWindStep = windData[currentTime] ?? windData[0] ?? []
|
const activeWindStep = windData[currentTime] ?? windData[0] ?? []
|
||||||
@ -829,6 +965,8 @@ export function MapView({
|
|||||||
boomLines, isDrawingBoom, drawingPoints,
|
boomLines, isDrawingBoom, drawingPoints,
|
||||||
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
|
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
|
||||||
sensitiveResources, centerPoints, windData,
|
sensitiveResources, centerPoints, windData,
|
||||||
|
showWind, showBeached, showTimeLabel, simulationStartTime,
|
||||||
|
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM,
|
||||||
])
|
])
|
||||||
|
|
||||||
// 3D 모드에 따른 지도 스타일 전환
|
// 3D 모드에 따른 지도 스타일 전환
|
||||||
@ -844,7 +982,7 @@ export function MapView({
|
|||||||
}}
|
}}
|
||||||
mapStyle={currentMapStyle}
|
mapStyle={currentMapStyle}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
style={{ cursor: isSelectingLocation ? 'crosshair' : 'grab' }}
|
style={{ cursor: (isSelectingLocation || drawAnalysisMode !== null) ? 'crosshair' : 'grab' }}
|
||||||
onClick={handleMapClick}
|
onClick={handleMapClick}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
preserveDrawingBuffer={true}
|
preserveDrawingBuffer={true}
|
||||||
@ -887,7 +1025,7 @@ export function MapView({
|
|||||||
<DeckGLOverlay layers={deckLayers} />
|
<DeckGLOverlay layers={deckLayers} />
|
||||||
|
|
||||||
{/* 해류 파티클 오버레이 */}
|
{/* 해류 파티클 오버레이 */}
|
||||||
{hydrData.length > 0 && (
|
{hydrData.length > 0 && showCurrent && (
|
||||||
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} />
|
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -928,6 +1066,16 @@ export function MapView({
|
|||||||
오일펜스 배치 모드 — 지도를 클릭하여 포인트를 추가하세요 ({drawingPoints.length}개 포인트)
|
오일펜스 배치 모드 — 지도를 클릭하여 포인트를 추가하세요 ({drawingPoints.length}개 포인트)
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{drawAnalysisMode === 'polygon' && (
|
||||||
|
<div className="boom-drawing-indicator" style={{ background: 'rgba(168,85,247,0.15)', borderColor: 'rgba(168,85,247,0.4)' }}>
|
||||||
|
다각형 분석 모드 — 지도를 클릭하여 꼭짓점을 추가하세요 ({analysisPolygonPoints.length}개)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{drawAnalysisMode === 'circle' && (
|
||||||
|
<div className="boom-drawing-indicator" style={{ background: 'rgba(168,85,247,0.15)', borderColor: 'rgba(168,85,247,0.4)' }}>
|
||||||
|
{!analysisCircleCenter ? '원 분석 모드 — 중심점을 클릭하세요' : '반경 지점을 클릭하세요'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 기상청 연계 정보 */}
|
{/* 기상청 연계 정보 */}
|
||||||
<WeatherInfoPanel position={currentPosition} />
|
<WeatherInfoPanel position={currentPosition} />
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useSyncExternalStore, useCallback } from 'react'
|
import { useEffect, useSyncExternalStore } from 'react'
|
||||||
import type { MainTab } from '../types/navigation'
|
import type { MainTab } from '../types/navigation'
|
||||||
import { useAuthStore } from '@common/store/authStore'
|
import { useAuthStore } from '@common/store/authStore'
|
||||||
import { API_BASE_URL } from '@common/services/api'
|
import { API_BASE_URL } from '@common/services/api'
|
||||||
@ -38,7 +38,7 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
|
|||||||
],
|
],
|
||||||
aerial: [
|
aerial: [
|
||||||
{ id: 'media', label: '영상사진관리', icon: '📷' },
|
{ id: 'media', label: '영상사진관리', icon: '📷' },
|
||||||
{ id: 'analysis', label: '유출유면적분석', icon: '🧩' },
|
{ id: 'analysis', label: '영상사진합성', icon: '🧩' },
|
||||||
{ id: 'realtime', label: '실시간드론', icon: '🛸' },
|
{ id: 'realtime', label: '실시간드론', icon: '🛸' },
|
||||||
{ id: 'sensor', label: '오염/선박3D분석', icon: '🔍' },
|
{ id: 'sensor', label: '오염/선박3D분석', icon: '🔍' },
|
||||||
{ id: 'satellite', label: '위성요청', icon: '🛰' },
|
{ id: 'satellite', label: '위성요청', icon: '🛰' },
|
||||||
@ -91,8 +91,7 @@ function subscribe(listener: () => void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSubMenu(mainTab: MainTab) {
|
export function useSubMenu(mainTab: MainTab) {
|
||||||
const getSnapshot = useCallback(() => subMenuState[mainTab], [mainTab])
|
const activeSubTab = useSyncExternalStore(subscribe, () => subMenuState[mainTab])
|
||||||
const activeSubTab = useSyncExternalStore(subscribe, getSnapshot)
|
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||||
|
|
||||||
@ -106,13 +105,6 @@ export function useSubMenu(mainTab: MainTab) {
|
|||||||
hasPermission(`${mainTab}:${item.id}`)
|
hasPermission(`${mainTab}:${item.id}`)
|
||||||
) ?? null
|
) ?? null
|
||||||
|
|
||||||
// activeSubTab이 필터링된 목록에 없으면 첫 번째 항목 자동 선택
|
|
||||||
useEffect(() => {
|
|
||||||
if (filteredConfig && filteredConfig.length > 0 && !filteredConfig.some(item => item.id === activeSubTab)) {
|
|
||||||
setSubTab(mainTab, filteredConfig[0].id)
|
|
||||||
}
|
|
||||||
}, [filteredConfig, activeSubTab, mainTab])
|
|
||||||
|
|
||||||
// 서브탭 전환 시 자동 감사 로그 (N-depth 지원: 콜론 구분 경로)
|
// 서브탭 전환 시 자동 감사 로그 (N-depth 지원: 콜론 구분 경로)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated || !activeSubTab) return
|
if (!isAuthenticated || !activeSubTab) return
|
||||||
|
|||||||
@ -186,6 +186,47 @@ export function generateAIBoomLines(
|
|||||||
return boomLines
|
return boomLines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ray casting — 점이 다각형 내부인지 판정 */
|
||||||
|
export function pointInPolygon(
|
||||||
|
point: { lat: number; lon: number },
|
||||||
|
polygon: { lat: number; lon: number }[]
|
||||||
|
): boolean {
|
||||||
|
if (polygon.length < 3) return false
|
||||||
|
let inside = false
|
||||||
|
const x = point.lon
|
||||||
|
const y = point.lat
|
||||||
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||||
|
const xi = polygon[i].lon, yi = polygon[i].lat
|
||||||
|
const xj = polygon[j].lon, yj = polygon[j].lat
|
||||||
|
const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)
|
||||||
|
if (intersect) inside = !inside
|
||||||
|
}
|
||||||
|
return inside
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 다각형 면적 (km²) — Shoelace formula, 구면 보정 포함 */
|
||||||
|
export function polygonAreaKm2(polygon: { lat: number; lon: number }[]): number {
|
||||||
|
if (polygon.length < 3) return 0
|
||||||
|
const n = polygon.length
|
||||||
|
const latCenter = polygon.reduce((s, p) => s + p.lat, 0) / n
|
||||||
|
const cosLat = Math.cos(latCenter * DEG2RAD)
|
||||||
|
let area = 0
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const j = (i + 1) % n
|
||||||
|
const x1 = polygon[i].lon * cosLat * EARTH_RADIUS * DEG2RAD / 1000
|
||||||
|
const y1 = polygon[i].lat * EARTH_RADIUS * DEG2RAD / 1000
|
||||||
|
const x2 = polygon[j].lon * cosLat * EARTH_RADIUS * DEG2RAD / 1000
|
||||||
|
const y2 = polygon[j].lat * EARTH_RADIUS * DEG2RAD / 1000
|
||||||
|
area += x1 * y2 - x2 * y1
|
||||||
|
}
|
||||||
|
return Math.abs(area) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원 면적 (km²) */
|
||||||
|
export function circleAreaKm2(radiusM: number): number {
|
||||||
|
return Math.PI * (radiusM / 1000) ** 2
|
||||||
|
}
|
||||||
|
|
||||||
/** 차단 시뮬레이션 실행 */
|
/** 차단 시뮬레이션 실행 */
|
||||||
export function runContainmentAnalysis(
|
export function runContainmentAnalysis(
|
||||||
trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>,
|
trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>,
|
||||||
|
|||||||
@ -1,20 +1,29 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import * as exifr from 'exifr';
|
||||||
import { stitchImages } from '../services/aerialApi';
|
import { stitchImages } from '../services/aerialApi';
|
||||||
import { analyzeImage } from '@tabs/prediction/services/predictionApi';
|
import { analyzeImage } from '@tabs/prediction/services/predictionApi';
|
||||||
import { setPendingImageAnalysis } from '@common/utils/imageAnalysisSignal';
|
import { setPendingImageAnalysis } from '@common/utils/imageAnalysisSignal';
|
||||||
import { navigateToTab } from '@common/hooks/useSubMenu';
|
import { navigateToTab } from '@common/hooks/useSubMenu';
|
||||||
|
import { decimalToDMS } from '@common/utils/coordinates';
|
||||||
|
|
||||||
const MAX_IMAGES = 6;
|
const MAX_IMAGES = 6;
|
||||||
|
|
||||||
|
interface GpsInfo {
|
||||||
|
lat: number | null;
|
||||||
|
lon: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function OilAreaAnalysis() {
|
export function OilAreaAnalysis() {
|
||||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
|
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
|
||||||
|
const [imageGpsInfos, setImageGpsInfos] = useState<(GpsInfo | undefined)[]>([]);
|
||||||
const [stitchedBlob, setStitchedBlob] = useState<Blob | null>(null);
|
const [stitchedBlob, setStitchedBlob] = useState<Blob | null>(null);
|
||||||
const [stitchedPreviewUrl, setStitchedPreviewUrl] = useState<string | null>(null);
|
const [stitchedPreviewUrl, setStitchedPreviewUrl] = useState<string | null>(null);
|
||||||
const [isStitching, setIsStitching] = useState(false);
|
const [isStitching, setIsStitching] = useState(false);
|
||||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const processedFilesRef = useRef<Set<File>>(new Set());
|
||||||
|
|
||||||
// Object URL 메모리 누수 방지 — 언마운트 시 전체 revoke
|
// Object URL 메모리 누수 방지 — 언마운트 시 전체 revoke
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -25,6 +34,34 @@ export function OilAreaAnalysis() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 선택된 파일이 바뀔 때마다 새 파일의 EXIF GPS 추출
|
||||||
|
useEffect(() => {
|
||||||
|
selectedFiles.forEach((file, i) => {
|
||||||
|
if (processedFilesRef.current.has(file)) return;
|
||||||
|
processedFilesRef.current.add(file);
|
||||||
|
|
||||||
|
exifr.gps(file)
|
||||||
|
.then(gps => {
|
||||||
|
setImageGpsInfos(prev => {
|
||||||
|
const updated = [...prev];
|
||||||
|
while (updated.length <= i) updated.push(undefined);
|
||||||
|
updated[i] = gps
|
||||||
|
? { lat: gps.latitude, lon: gps.longitude }
|
||||||
|
: { lat: null, lon: null };
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setImageGpsInfos(prev => {
|
||||||
|
const updated = [...prev];
|
||||||
|
while (updated.length <= i) updated.push(undefined);
|
||||||
|
updated[i] = { lat: null, lon: null };
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [selectedFiles]);
|
||||||
|
|
||||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
const incoming = Array.from(e.target.files ?? []);
|
const incoming = Array.from(e.target.files ?? []);
|
||||||
@ -56,6 +93,7 @@ export function OilAreaAnalysis() {
|
|||||||
URL.revokeObjectURL(prev[idx]);
|
URL.revokeObjectURL(prev[idx]);
|
||||||
return prev.filter((_, i) => i !== idx);
|
return prev.filter((_, i) => i !== idx);
|
||||||
});
|
});
|
||||||
|
setImageGpsInfos(prev => prev.filter((_, i) => i !== idx));
|
||||||
// 합성 결과 초기화 (선택 파일이 바뀌었으므로)
|
// 합성 결과 초기화 (선택 파일이 바뀌었으므로)
|
||||||
setStitchedBlob(null);
|
setStitchedBlob(null);
|
||||||
if (stitchedPreviewUrl) {
|
if (stitchedPreviewUrl) {
|
||||||
@ -112,7 +150,7 @@ export function OilAreaAnalysis() {
|
|||||||
<div className="flex gap-5 h-full overflow-hidden">
|
<div className="flex gap-5 h-full overflow-hidden">
|
||||||
{/* ── Left Panel ── */}
|
{/* ── Left Panel ── */}
|
||||||
<div className="w-[280px] min-w-[280px] flex flex-col overflow-y-auto scrollbar-thin">
|
<div className="w-[280px] min-w-[280px] flex flex-col overflow-y-auto scrollbar-thin">
|
||||||
<div className="text-sm font-bold mb-1 font-korean">🧩 유출유면적분석</div>
|
<div className="text-sm font-bold mb-1 font-korean">🧩 영상사진합성</div>
|
||||||
<div className="text-[11px] text-text-3 mb-4 font-korean">
|
<div className="text-[11px] text-text-3 mb-4 font-korean">
|
||||||
드론 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.
|
드론 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.
|
||||||
</div>
|
</div>
|
||||||
@ -201,15 +239,34 @@ export function OilAreaAnalysis() {
|
|||||||
{Array.from({ length: MAX_IMAGES }).map((_, i) => (
|
{Array.from({ length: MAX_IMAGES }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="bg-bg-3 border border-border rounded-sm overflow-hidden"
|
className="bg-bg-3 border border-border rounded-sm overflow-hidden flex flex-col"
|
||||||
style={{ height: '300px' }}
|
style={{ height: '300px' }}
|
||||||
>
|
>
|
||||||
{previewUrls[i] ? (
|
{previewUrls[i] ? (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={previewUrls[i]}
|
src={previewUrls[i]}
|
||||||
alt={selectedFiles[i]?.name ?? ''}
|
alt={selectedFiles[i]?.name ?? ''}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1 bg-bg-0 border-t border-border shrink-0 flex items-start justify-between gap-1">
|
||||||
|
<div className="text-[10px] text-text-2 truncate font-korean flex-1 min-w-0">
|
||||||
|
{selectedFiles[i]?.name}
|
||||||
|
</div>
|
||||||
|
{imageGpsInfos[i] === undefined ? (
|
||||||
|
<div className="text-[10px] text-text-3 font-korean shrink-0">GPS 읽는 중...</div>
|
||||||
|
) : imageGpsInfos[i]?.lat !== null ? (
|
||||||
|
<div className="text-[10px] text-primary-cyan font-mono leading-tight text-right shrink-0">
|
||||||
|
{decimalToDMS(imageGpsInfos[i]!.lat!, true)}<br />
|
||||||
|
{decimalToDMS(imageGpsInfos[i]!.lon!, false)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] text-text-3 font-korean shrink-0">GPS 정보 없음</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-full text-text-3 text-lg font-mono opacity-20">
|
<div className="flex items-center justify-center h-full text-text-3 text-lg font-mono opacity-20">
|
||||||
{i + 1}
|
{i + 1}
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export function LeftPanel({
|
|||||||
predictionInput: true,
|
predictionInput: true,
|
||||||
incident: false,
|
incident: false,
|
||||||
impactResources: false,
|
impactResources: false,
|
||||||
infoLayer: true,
|
infoLayer: false,
|
||||||
oilBoom: false,
|
oilBoom: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -112,45 +112,73 @@ export function LeftPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expandedSections.incident && (
|
{expandedSections.incident && (
|
||||||
|
selectedAnalysis ? (
|
||||||
<div className="px-4 pb-4 space-y-3">
|
<div className="px-4 pb-4 space-y-3">
|
||||||
{/* Status Badge */}
|
{/* Status Badge */}
|
||||||
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-semibold bg-[rgba(239,68,68,0.15)] text-status-red border border-[rgba(239,68,68,0.3)]">
|
{(() => {
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-status-red animate-pulse" />
|
const statusMap: Record<string, { label: string; style: string; dot: string }> = {
|
||||||
진행중
|
ACTIVE: {
|
||||||
|
label: '진행중',
|
||||||
|
style: 'bg-[rgba(239,68,68,0.15)] text-status-red border border-[rgba(239,68,68,0.3)]',
|
||||||
|
dot: 'bg-status-red animate-pulse',
|
||||||
|
},
|
||||||
|
INVESTIGATING: {
|
||||||
|
label: '조사중',
|
||||||
|
style: 'bg-[rgba(249,115,22,0.15)] text-status-orange border border-[rgba(249,115,22,0.3)]',
|
||||||
|
dot: 'bg-status-orange animate-pulse',
|
||||||
|
},
|
||||||
|
CLOSED: {
|
||||||
|
label: '종료',
|
||||||
|
style: 'bg-[rgba(100,116,139,0.15)] text-text-3 border border-[rgba(100,116,139,0.3)]',
|
||||||
|
dot: 'bg-text-3',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const s = statusMap[selectedAnalysis.acdntSttsCd] ?? statusMap['ACTIVE']
|
||||||
|
return (
|
||||||
|
<div className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-semibold ${s.style}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${s.dot}`} />
|
||||||
|
{s.label}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Info Grid */}
|
{/* Info Grid */}
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고코드</span>
|
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고코드</span>
|
||||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis ? `INC-2025-${String(selectedAnalysis.id).padStart(4, '0')}` : 'INC-2025-0042'}</span>
|
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.acdntSn}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고명</span>
|
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고명</span>
|
||||||
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.name || '씨프린스호'}</span>
|
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.acdntNm || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고일시</span>
|
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고일시</span>
|
||||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis?.occurredAt || '2025-02-10 06:30'}</span>
|
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16) : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유종</span>
|
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유종</span>
|
||||||
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.oilType || 'BUNKER_C'}</span>
|
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.oilType || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유출량</span>
|
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유출량</span>
|
||||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis ? `${selectedAnalysis.volume.toFixed(2)} kl` : '350.00 kl'}</span>
|
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.volume != null ? `${selectedAnalysis.volume.toFixed(2)} kl` : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">담당자</span>
|
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">담당자</span>
|
||||||
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.analyst || '남해청, 방재과'}</span>
|
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.analyst || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">위치</span>
|
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">위치</span>
|
||||||
<span className="text-[11px] text-status-orange font-semibold font-korean">{selectedAnalysis?.location || '여수 돌산 남방 5NM'}</span>
|
<span className="text-[11px] text-status-orange font-semibold font-korean">{selectedAnalysis.location || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<p className="text-[11px] text-text-3 font-korean text-center py-2">선택된 사고정보가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -16,8 +16,9 @@ import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAna
|
|||||||
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, SimulationRunResponse, SimulationSummary, WindPoint } from '../services/predictionApi'
|
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, SimulationRunResponse, SimulationSummary, WindPoint } from '../services/predictionApi'
|
||||||
import { useSimulationStatus } from '../hooks/useSimulationStatus'
|
import { useSimulationStatus } from '../hooks/useSimulationStatus'
|
||||||
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
|
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
|
||||||
|
import SimulationErrorModal from './SimulationErrorModal'
|
||||||
import { api } from '@common/services/api'
|
import { api } from '@common/services/api'
|
||||||
import { generateAIBoomLines } from '@common/utils/geo'
|
import { generateAIBoomLines, haversineDistance, pointInPolygon, polygonAreaKm2, circleAreaKm2 } from '@common/utils/geo'
|
||||||
import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal'
|
import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal'
|
||||||
|
|
||||||
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
|
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
|
||||||
@ -35,6 +36,13 @@ export interface SensitiveResource {
|
|||||||
arrivalTimeH: number
|
arrivalTimeH: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DisplayControls {
|
||||||
|
showCurrent: boolean; // 유향/유속
|
||||||
|
showWind: boolean; // 풍향/풍속
|
||||||
|
showBeached: boolean; // 해안부착
|
||||||
|
showTimeLabel: boolean; // 시간 표시
|
||||||
|
}
|
||||||
|
|
||||||
const DEMO_SENSITIVE_RESOURCES: SensitiveResource[] = [
|
const DEMO_SENSITIVE_RESOURCES: SensitiveResource[] = [
|
||||||
{ id: 'bc-1', name: '종포 해수욕장', type: 'beach', lat: 34.728, lon: 127.679, radiusM: 350, arrivalTimeH: 1 },
|
{ id: 'bc-1', name: '종포 해수욕장', type: 'beach', lat: 34.728, lon: 127.679, radiusM: 350, arrivalTimeH: 1 },
|
||||||
{ id: 'aq-1', name: '국동 전복 양식장', type: 'aquaculture', lat: 34.718, lon: 127.672, radiusM: 500, arrivalTimeH: 3 },
|
{ id: 'aq-1', name: '국동 전복 양식장', type: 'aquaculture', lat: 34.718, lon: 127.672, radiusM: 500, arrivalTimeH: 3 },
|
||||||
@ -114,7 +122,8 @@ export function OilSpillView() {
|
|||||||
const [windData, setWindData] = useState<WindPoint[][]>([])
|
const [windData, setWindData] = useState<WindPoint[][]>([])
|
||||||
const [hydrData, setHydrData] = useState<(HydrDataStep | null)[]>([])
|
const [hydrData, setHydrData] = useState<(HydrDataStep | null)[]>([])
|
||||||
const [isRunningSimulation, setIsRunningSimulation] = useState(false)
|
const [isRunningSimulation, setIsRunningSimulation] = useState(false)
|
||||||
const [selectedModels, setSelectedModels] = useState<Set<PredictionModel>>(new Set(['KOSPS']))
|
const [simulationError, setSimulationError] = useState<string | null>(null)
|
||||||
|
const [selectedModels, setSelectedModels] = useState<Set<PredictionModel>>(new Set(['OpenDrift']))
|
||||||
const [predictionTime, setPredictionTime] = useState(48)
|
const [predictionTime, setPredictionTime] = useState(48)
|
||||||
const [accidentTime, setAccidentTime] = useState<string>('')
|
const [accidentTime, setAccidentTime] = useState<string>('')
|
||||||
const [spillType, setSpillType] = useState('연속')
|
const [spillType, setSpillType] = useState('연속')
|
||||||
@ -142,6 +151,14 @@ export function OilSpillView() {
|
|||||||
const [layerOpacity, setLayerOpacity] = useState(50)
|
const [layerOpacity, setLayerOpacity] = useState(50)
|
||||||
const [layerBrightness, setLayerBrightness] = useState(50)
|
const [layerBrightness, setLayerBrightness] = useState(50)
|
||||||
|
|
||||||
|
// 표시 정보 제어
|
||||||
|
const [displayControls, setDisplayControls] = useState<DisplayControls>({
|
||||||
|
showCurrent: true,
|
||||||
|
showWind: true,
|
||||||
|
showBeached: false,
|
||||||
|
showTimeLabel: false,
|
||||||
|
})
|
||||||
|
|
||||||
// 타임라인 플레이어 상태
|
// 타임라인 플레이어 상태
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
const [currentStep, setCurrentStep] = useState(0) // 현재 시간값 (시간 단위)
|
const [currentStep, setCurrentStep] = useState(0) // 현재 시간값 (시간 단위)
|
||||||
@ -175,6 +192,16 @@ export function OilSpillView() {
|
|||||||
const [simulationSummary, setSimulationSummary] = useState<SimulationSummary | null>(null)
|
const [simulationSummary, setSimulationSummary] = useState<SimulationSummary | null>(null)
|
||||||
const { data: simStatus } = useSimulationStatus(currentExecSn)
|
const { data: simStatus } = useSimulationStatus(currentExecSn)
|
||||||
|
|
||||||
|
// 오염분석 상태
|
||||||
|
const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon')
|
||||||
|
const [drawAnalysisMode, setDrawAnalysisMode] = useState<'polygon' | null>(null)
|
||||||
|
const [analysisPolygonPoints, setAnalysisPolygonPoints] = useState<{ lat: number; lon: number }[]>([])
|
||||||
|
const [circleRadiusNm, setCircleRadiusNm] = useState<number>(5)
|
||||||
|
const [analysisResult, setAnalysisResult] = useState<{ area: number; particleCount: number; particlePercent: number; sensitiveCount: number } | null>(null)
|
||||||
|
|
||||||
|
// 원 분석용 derived 값 (state 아님)
|
||||||
|
const analysisCircleCenter = analysisTab === 'circle' && incidentCoord ? incidentCoord : null
|
||||||
|
const analysisCircleRadiusM = circleRadiusNm * 1852
|
||||||
|
|
||||||
const handleToggleLayer = (layerId: string, enabled: boolean) => {
|
const handleToggleLayer = (layerId: string, enabled: boolean) => {
|
||||||
setEnabledLayers(prev => {
|
setEnabledLayers(prev => {
|
||||||
@ -373,6 +400,7 @@ export function OilSpillView() {
|
|||||||
if (simStatus.status === 'ERROR') {
|
if (simStatus.status === 'ERROR') {
|
||||||
setIsRunningSimulation(false);
|
setIsRunningSimulation(false);
|
||||||
setCurrentExecSn(null);
|
setCurrentExecSn(null);
|
||||||
|
setSimulationError(simStatus.error ?? '시뮬레이션 처리 중 오류가 발생했습니다.');
|
||||||
}
|
}
|
||||||
}, [simStatus, incidentCoord, algorithmSettings]);
|
}, [simStatus, incidentCoord, algorithmSettings]);
|
||||||
|
|
||||||
@ -465,8 +493,7 @@ export function OilSpillView() {
|
|||||||
setCenterPoints(cp ?? [])
|
setCenterPoints(cp ?? [])
|
||||||
setWindData(wd ?? [])
|
setWindData(wd ?? [])
|
||||||
setHydrData(hd ?? [])
|
setHydrData(hd ?? [])
|
||||||
const booms = generateAIBoomLines(trajectory, coord, algorithmSettings)
|
if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings))
|
||||||
setBoomLines(booms)
|
|
||||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||||
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
||||||
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
||||||
@ -482,10 +509,9 @@ export function OilSpillView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 데모 궤적 생성 (fallback)
|
// 데모 궤적 생성 (fallback)
|
||||||
const demoTrajectory = generateDemoTrajectory(coord, demoModels, parseInt(analysis.duration) || 48)
|
const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48)
|
||||||
setOilTrajectory(demoTrajectory)
|
setOilTrajectory(demoTrajectory)
|
||||||
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
|
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings))
|
||||||
setBoomLines(demoBooms)
|
|
||||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||||
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
||||||
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
||||||
@ -498,12 +524,65 @@ export function OilSpillView() {
|
|||||||
const handleMapClick = (lon: number, lat: number) => {
|
const handleMapClick = (lon: number, lat: number) => {
|
||||||
if (isDrawingBoom) {
|
if (isDrawingBoom) {
|
||||||
setDrawingPoints(prev => [...prev, { lat, lon }])
|
setDrawingPoints(prev => [...prev, { lat, lon }])
|
||||||
|
} else if (drawAnalysisMode === 'polygon') {
|
||||||
|
setAnalysisPolygonPoints(prev => [...prev, { lat, lon }])
|
||||||
} else {
|
} else {
|
||||||
setIncidentCoord({ lon, lat })
|
setIncidentCoord({ lon, lat })
|
||||||
setIsSelectingLocation(false)
|
setIsSelectingLocation(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleStartPolygonDraw = () => {
|
||||||
|
setDrawAnalysisMode('polygon')
|
||||||
|
setAnalysisPolygonPoints([])
|
||||||
|
setAnalysisResult(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRunPolygonAnalysis = () => {
|
||||||
|
if (analysisPolygonPoints.length < 3) return
|
||||||
|
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
|
||||||
|
const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1
|
||||||
|
const inside = currentParticles.filter(p => pointInPolygon({ lat: p.lat, lon: p.lon }, analysisPolygonPoints)).length
|
||||||
|
const sensitiveCount = sensitiveResources.filter(r => pointInPolygon({ lat: r.lat, lon: r.lon }, analysisPolygonPoints)).length
|
||||||
|
setAnalysisResult({
|
||||||
|
area: polygonAreaKm2(analysisPolygonPoints),
|
||||||
|
particleCount: inside,
|
||||||
|
particlePercent: Math.round((inside / totalIds) * 100),
|
||||||
|
sensitiveCount,
|
||||||
|
})
|
||||||
|
setDrawAnalysisMode(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRunCircleAnalysis = () => {
|
||||||
|
if (!incidentCoord) return
|
||||||
|
const radiusM = circleRadiusNm * 1852
|
||||||
|
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
|
||||||
|
const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1
|
||||||
|
const inside = currentParticles.filter(p =>
|
||||||
|
haversineDistance({ lat: incidentCoord.lat, lon: incidentCoord.lon }, { lat: p.lat, lon: p.lon }) <= radiusM
|
||||||
|
).length
|
||||||
|
const sensitiveCount = sensitiveResources.filter(r =>
|
||||||
|
haversineDistance({ lat: incidentCoord.lat, lon: incidentCoord.lon }, { lat: r.lat, lon: r.lon }) <= radiusM
|
||||||
|
).length
|
||||||
|
setAnalysisResult({
|
||||||
|
area: circleAreaKm2(radiusM),
|
||||||
|
particleCount: inside,
|
||||||
|
particlePercent: Math.round((inside / totalIds) * 100),
|
||||||
|
sensitiveCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelAnalysis = () => {
|
||||||
|
setDrawAnalysisMode(null)
|
||||||
|
setAnalysisPolygonPoints([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearAnalysis = () => {
|
||||||
|
setDrawAnalysisMode(null)
|
||||||
|
setAnalysisPolygonPoints([])
|
||||||
|
setAnalysisResult(null)
|
||||||
|
}
|
||||||
|
|
||||||
const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => {
|
const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => {
|
||||||
setIncidentCoord({ lat: result.lat, lon: result.lon })
|
setIncidentCoord({ lat: result.lat, lon: result.lon })
|
||||||
setFlyToCoord({ lat: result.lat, lon: result.lon })
|
setFlyToCoord({ lat: result.lat, lon: result.lon })
|
||||||
@ -598,9 +677,12 @@ export function OilSpillView() {
|
|||||||
setIncidentName('');
|
setIncidentName('');
|
||||||
}
|
}
|
||||||
// setIsRunningSimulation(false)는 폴링 결과 useEffect에서 처리
|
// setIsRunningSimulation(false)는 폴링 결과 useEffect에서 처리
|
||||||
} catch {
|
} catch (err) {
|
||||||
setIsRunningSimulation(false);
|
setIsRunningSimulation(false);
|
||||||
// 503 등 에러 시 상태 복원 (에러 메시지 표시는 향후 토스트로 처리)
|
const msg =
|
||||||
|
(err as { message?: string })?.message
|
||||||
|
?? '시뮬레이션 실행 중 오류가 발생했습니다.';
|
||||||
|
setSimulationError(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -723,7 +805,7 @@ export function OilSpillView() {
|
|||||||
enabledLayers={enabledLayers}
|
enabledLayers={enabledLayers}
|
||||||
incidentCoord={incidentCoord ?? undefined}
|
incidentCoord={incidentCoord ?? undefined}
|
||||||
flyToIncident={flyToCoord}
|
flyToIncident={flyToCoord}
|
||||||
isSelectingLocation={isSelectingLocation || isDrawingBoom}
|
isSelectingLocation={isSelectingLocation || isDrawingBoom || drawAnalysisMode === 'polygon'}
|
||||||
onMapClick={handleMapClick}
|
onMapClick={handleMapClick}
|
||||||
oilTrajectory={oilTrajectory}
|
oilTrajectory={oilTrajectory}
|
||||||
selectedModels={selectedModels}
|
selectedModels={selectedModels}
|
||||||
@ -739,6 +821,10 @@ export function OilSpillView() {
|
|||||||
flyToTarget={flyToTarget}
|
flyToTarget={flyToTarget}
|
||||||
fitBoundsTarget={fitBoundsTarget}
|
fitBoundsTarget={fitBoundsTarget}
|
||||||
onIncidentFlyEnd={handleFlyEnd}
|
onIncidentFlyEnd={handleFlyEnd}
|
||||||
|
drawAnalysisMode={drawAnalysisMode}
|
||||||
|
analysisPolygonPoints={analysisPolygonPoints}
|
||||||
|
analysisCircleCenter={analysisCircleCenter}
|
||||||
|
analysisCircleRadiusM={analysisCircleRadiusM}
|
||||||
externalCurrentTime={oilTrajectory.length > 0 ? currentStep : undefined}
|
externalCurrentTime={oilTrajectory.length > 0 ? currentStep : undefined}
|
||||||
backtrackReplay={isReplayActive && replayShips.length > 0 && incidentCoord ? {
|
backtrackReplay={isReplayActive && replayShips.length > 0 && incidentCoord ? {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@ -748,6 +834,11 @@ export function OilSpillView() {
|
|||||||
totalFrames: TOTAL_REPLAY_FRAMES,
|
totalFrames: TOTAL_REPLAY_FRAMES,
|
||||||
incidentCoord,
|
incidentCoord,
|
||||||
} : undefined}
|
} : undefined}
|
||||||
|
showCurrent={displayControls.showCurrent}
|
||||||
|
showWind={displayControls.showWind}
|
||||||
|
showBeached={displayControls.showBeached}
|
||||||
|
showTimeLabel={displayControls.showTimeLabel}
|
||||||
|
simulationStartTime={accidentTime || undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 타임라인 플레이어 (리플레이 비활성 시) */}
|
{/* 타임라인 플레이어 (리플레이 비활성 시) */}
|
||||||
@ -932,7 +1023,30 @@ export function OilSpillView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Panel */}
|
{/* Right Panel */}
|
||||||
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={handleOpenReport} detail={analysisDetail} summary={simulationSummary} />}
|
{activeSubTab === 'analysis' && (
|
||||||
|
<RightPanel
|
||||||
|
onOpenBacktrack={handleOpenBacktrack}
|
||||||
|
onOpenRecalc={() => setRecalcModalOpen(true)}
|
||||||
|
onOpenReport={handleOpenReport}
|
||||||
|
detail={analysisDetail}
|
||||||
|
summary={simulationSummary}
|
||||||
|
displayControls={displayControls}
|
||||||
|
onDisplayControlsChange={setDisplayControls}
|
||||||
|
analysisTab={analysisTab}
|
||||||
|
onSwitchAnalysisTab={setAnalysisTab}
|
||||||
|
drawAnalysisMode={drawAnalysisMode}
|
||||||
|
analysisPolygonPoints={analysisPolygonPoints}
|
||||||
|
circleRadiusNm={circleRadiusNm}
|
||||||
|
onCircleRadiusChange={setCircleRadiusNm}
|
||||||
|
analysisResult={analysisResult}
|
||||||
|
incidentCoord={incidentCoord}
|
||||||
|
onStartPolygonDraw={handleStartPolygonDraw}
|
||||||
|
onRunPolygonAnalysis={handleRunPolygonAnalysis}
|
||||||
|
onRunCircleAnalysis={handleRunCircleAnalysis}
|
||||||
|
onCancelAnalysis={handleCancelAnalysis}
|
||||||
|
onClearAnalysis={handleClearAnalysis}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 확산 예측 실행 중 로딩 오버레이 */}
|
{/* 확산 예측 실행 중 로딩 오버레이 */}
|
||||||
{isRunningSimulation && (
|
{isRunningSimulation && (
|
||||||
@ -942,6 +1056,14 @@ export function OilSpillView() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 확산 예측 에러 팝업 */}
|
||||||
|
{simulationError && (
|
||||||
|
<SimulationErrorModal
|
||||||
|
message={simulationError}
|
||||||
|
onClose={() => setSimulationError(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 재계산 모달 */}
|
{/* 재계산 모달 */}
|
||||||
<RecalcModal
|
<RecalcModal
|
||||||
isOpen={recalcModalOpen}
|
isOpen={recalcModalOpen}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { useState, useRef } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { decimalToDMS } from '@common/utils/coordinates'
|
import { decimalToDMS } from '@common/utils/coordinates'
|
||||||
import { ComboBox } from '@common/components/ui/ComboBox'
|
import { ComboBox } from '@common/components/ui/ComboBox'
|
||||||
import { ALL_MODELS } from './OilSpillView'
|
|
||||||
import type { PredictionModel } from './OilSpillView'
|
import type { PredictionModel } from './OilSpillView'
|
||||||
import { analyzeImage } from '../services/predictionApi'
|
import { analyzeImage } from '../services/predictionApi'
|
||||||
import type { ImageAnalyzeResult } from '../services/predictionApi'
|
import type { ImageAnalyzeResult } from '../services/predictionApi'
|
||||||
@ -379,10 +378,13 @@ const PredictionInputSection = ({
|
|||||||
<div className="h-px bg-border my-0.5" />
|
<div className="h-px bg-border my-0.5" />
|
||||||
|
|
||||||
{/* Model Selection (다중 선택) */}
|
{/* Model Selection (다중 선택) */}
|
||||||
|
{/* TODO: 현재 OpenDrift만 구동 가능. KOSPS·POSEIDON·앙상블은 엔진 연동 완료 후 활성화 예정 */}
|
||||||
<div className="flex flex-wrap gap-[3px]">
|
<div className="flex flex-wrap gap-[3px]">
|
||||||
|
{/* 임시 비활성화 — OpenDrift만 구동 가능 (KOSPS 엔진 미연동)
|
||||||
|
{ id: 'KOSPS' as PredictionModel, color: 'var(--cyan)' }, */}
|
||||||
|
{/* 임시 비활성화 — OpenDrift만 구동 가능 (POSEIDON 엔진 미연동)
|
||||||
|
{ id: 'POSEIDON' as PredictionModel, color: 'var(--red)' }, */}
|
||||||
{([
|
{([
|
||||||
{ id: 'KOSPS' as PredictionModel, color: 'var(--cyan)' },
|
|
||||||
{ id: 'POSEIDON' as PredictionModel, color: 'var(--red)' },
|
|
||||||
{ id: 'OpenDrift' as PredictionModel, color: 'var(--blue)' },
|
{ id: 'OpenDrift' as PredictionModel, color: 'var(--blue)' },
|
||||||
] as const).map(m => (
|
] as const).map(m => (
|
||||||
<div
|
<div
|
||||||
@ -402,6 +404,7 @@ const PredictionInputSection = ({
|
|||||||
{m.id}
|
{m.id}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{/* 임시 비활성화 — OpenDrift만 구동 가능 (앙상블은 모든 모델 연동 후 활성화 예정)
|
||||||
<div
|
<div
|
||||||
className={`prd-mc ${selectedModels.size === ALL_MODELS.length ? 'on' : ''} cursor-pointer`}
|
className={`prd-mc ${selectedModels.size === ALL_MODELS.length ? 'on' : ''} cursor-pointer`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -415,8 +418,16 @@ const PredictionInputSection = ({
|
|||||||
<span className="prd-md" style={{ background: 'var(--purple)' }} />
|
<span className="prd-md" style={{ background: 'var(--purple)' }} />
|
||||||
앙상블
|
앙상블
|
||||||
</div>
|
</div>
|
||||||
|
*/}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 모델 미선택 경고 */}
|
||||||
|
{selectedModels.size === 0 && (
|
||||||
|
<p className="text-[10px] text-status-red font-korean">
|
||||||
|
⚠ 예측 모델을 하나 이상 선택하세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Run Button */}
|
{/* Run Button */}
|
||||||
<button
|
<button
|
||||||
className="wing-btn wing-btn-primary mt-0.5"
|
className="wing-btn wing-btn-primary mt-0.5"
|
||||||
|
|||||||
@ -1,7 +1,47 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { PredictionDetail, SimulationSummary } from '../services/predictionApi'
|
import type { PredictionDetail, SimulationSummary } from '../services/predictionApi'
|
||||||
|
import type { DisplayControls } from './OilSpillView'
|
||||||
|
|
||||||
export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary }: { onOpenBacktrack?: () => void; onOpenRecalc?: () => void; onOpenReport?: () => void; detail?: PredictionDetail | null; summary?: SimulationSummary | null }) {
|
interface AnalysisResult {
|
||||||
|
area: number
|
||||||
|
particleCount: number
|
||||||
|
particlePercent: number
|
||||||
|
sensitiveCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RightPanelProps {
|
||||||
|
onOpenBacktrack?: () => void
|
||||||
|
onOpenRecalc?: () => void
|
||||||
|
onOpenReport?: () => void
|
||||||
|
detail?: PredictionDetail | null
|
||||||
|
summary?: SimulationSummary | null
|
||||||
|
displayControls?: DisplayControls
|
||||||
|
onDisplayControlsChange?: (controls: DisplayControls) => void
|
||||||
|
analysisTab?: 'polygon' | 'circle'
|
||||||
|
onSwitchAnalysisTab?: (tab: 'polygon' | 'circle') => void
|
||||||
|
drawAnalysisMode?: 'polygon' | null
|
||||||
|
analysisPolygonPoints?: Array<{ lat: number; lon: number }>
|
||||||
|
circleRadiusNm?: number
|
||||||
|
onCircleRadiusChange?: (nm: number) => void
|
||||||
|
analysisResult?: AnalysisResult | null
|
||||||
|
incidentCoord?: { lat: number; lon: number } | null
|
||||||
|
onStartPolygonDraw?: () => void
|
||||||
|
onRunPolygonAnalysis?: () => void
|
||||||
|
onRunCircleAnalysis?: () => void
|
||||||
|
onCancelAnalysis?: () => void
|
||||||
|
onClearAnalysis?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RightPanel({
|
||||||
|
onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary,
|
||||||
|
displayControls, onDisplayControlsChange,
|
||||||
|
analysisTab = 'polygon', onSwitchAnalysisTab,
|
||||||
|
drawAnalysisMode, analysisPolygonPoints = [],
|
||||||
|
circleRadiusNm = 5, onCircleRadiusChange,
|
||||||
|
analysisResult,
|
||||||
|
onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis,
|
||||||
|
onCancelAnalysis, onClearAnalysis,
|
||||||
|
}: RightPanelProps) {
|
||||||
const vessel = detail?.vessels?.[0]
|
const vessel = detail?.vessels?.[0]
|
||||||
const vessel2 = detail?.vessels?.[1]
|
const vessel2 = detail?.vessels?.[1]
|
||||||
const spill = detail?.spill
|
const spill = detail?.spill
|
||||||
@ -24,20 +64,140 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail
|
|||||||
{/* 표시 정보 제어 */}
|
{/* 표시 정보 제어 */}
|
||||||
<Section title="표시 정보 제어">
|
<Section title="표시 정보 제어">
|
||||||
<div className="grid grid-cols-2 gap-x-2.5 gap-y-1">
|
<div className="grid grid-cols-2 gap-x-2.5 gap-y-1">
|
||||||
<CheckboxLabel checked>유향/유속</CheckboxLabel>
|
<ControlledCheckbox
|
||||||
<CheckboxLabel checked>풍향/풍속</CheckboxLabel>
|
checked={displayControls?.showCurrent ?? true}
|
||||||
<CheckboxLabel>해안부착</CheckboxLabel>
|
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showCurrent: v })}
|
||||||
<CheckboxLabel>민감자원</CheckboxLabel>
|
>유향/유속</ControlledCheckbox>
|
||||||
<CheckboxLabel>시간 표시</CheckboxLabel>
|
<ControlledCheckbox
|
||||||
<CheckboxLabel>날짜시간</CheckboxLabel>
|
checked={displayControls?.showWind ?? true}
|
||||||
|
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showWind: v })}
|
||||||
|
>풍향/풍속</ControlledCheckbox>
|
||||||
|
<ControlledCheckbox
|
||||||
|
checked={displayControls?.showBeached ?? false}
|
||||||
|
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showBeached: v })}
|
||||||
|
>해안부착</ControlledCheckbox>
|
||||||
|
<ControlledCheckbox checked={false} onChange={() => {}} disabled>
|
||||||
|
민감자원
|
||||||
|
</ControlledCheckbox>
|
||||||
|
<ControlledCheckbox
|
||||||
|
checked={displayControls?.showTimeLabel ?? false}
|
||||||
|
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showTimeLabel: v })}
|
||||||
|
>시간 표시</ControlledCheckbox>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* 오염분석 */}
|
{/* 오염분석 */}
|
||||||
<Section title="오염분석">
|
<Section title="오염분석">
|
||||||
<button className="w-full py-2 px-3 bg-gradient-to-r from-purple-500 to-primary-cyan text-white rounded text-[10px] font-bold font-korean">
|
{/* 탭 전환 */}
|
||||||
|
<div className="flex gap-[3px] mb-2">
|
||||||
|
{(['polygon', 'circle'] as const).map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => { onSwitchAnalysisTab?.(tab); onClearAnalysis?.() }}
|
||||||
|
className={`flex-1 py-1.5 px-1 rounded text-[9px] font-semibold font-korean border transition-colors ${
|
||||||
|
analysisTab === tab
|
||||||
|
? 'border-primary-cyan bg-[rgba(6,182,212,0.08)] text-primary-cyan'
|
||||||
|
: 'border-border bg-bg-3 text-text-3 hover:text-text-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab === 'polygon' ? '다각형 분석' : '원 분석'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 다각형 패널 */}
|
||||||
|
{analysisTab === 'polygon' && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[9px] text-text-3 font-korean mb-2 leading-relaxed">
|
||||||
|
지도에서 다각형 영역을 지정하여 해당 범위 내 오염도를 분석합니다.
|
||||||
|
</p>
|
||||||
|
{!drawAnalysisMode && !analysisResult && (
|
||||||
|
<button
|
||||||
|
onClick={onStartPolygonDraw}
|
||||||
|
className="w-full py-2 rounded text-[10px] font-bold font-korean text-white mb-0 transition-opacity hover:opacity-90"
|
||||||
|
style={{ background: 'linear-gradient(135deg, var(--purple), var(--cyan))' }}
|
||||||
|
>
|
||||||
📐 다각형 분석수행
|
📐 다각형 분석수행
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{drawAnalysisMode === 'polygon' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[9px] text-purple-400 font-korean bg-[rgba(168,85,247,0.08)] rounded px-2 py-1.5 leading-relaxed">
|
||||||
|
지도를 클릭하여 꼭짓점을 추가하세요<br />
|
||||||
|
<span className="text-text-3">현재 {analysisPolygonPoints.length}개 선택됨</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={onRunPolygonAnalysis}
|
||||||
|
disabled={analysisPolygonPoints.length < 3}
|
||||||
|
className="flex-1 py-1.5 rounded text-[10px] font-bold font-korean text-white disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
|
||||||
|
style={{ background: 'linear-gradient(135deg, var(--purple), var(--cyan))' }}
|
||||||
|
>
|
||||||
|
분석 실행
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onCancelAnalysis}
|
||||||
|
className="py-1.5 px-2 rounded text-[10px] font-semibold font-korean border border-border text-text-3 hover:text-text-2 transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{analysisResult && !drawAnalysisMode && (
|
||||||
|
<PollResult result={analysisResult} summary={summary} onClear={onClearAnalysis} onRerun={onStartPolygonDraw} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 원 분석 패널 */}
|
||||||
|
{analysisTab === 'circle' && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[9px] text-text-3 font-korean mb-2 leading-relaxed">
|
||||||
|
반경(NM)을 지정하면 사고지점 기준 원형 영역의 오염도를 분석합니다.
|
||||||
|
</p>
|
||||||
|
<div className="text-[9px] font-semibold text-text-2 font-korean mb-1.5">반경 선택 (NM)</div>
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
{[1, 3, 5, 10, 15, 20, 30, 50].map((nm) => (
|
||||||
|
<button
|
||||||
|
key={nm}
|
||||||
|
onClick={() => onCircleRadiusChange?.(nm)}
|
||||||
|
className={`w-8 h-7 rounded text-[10px] font-semibold font-mono border transition-all ${
|
||||||
|
circleRadiusNm === nm
|
||||||
|
? 'border-primary-cyan bg-[rgba(6,182,212,0.1)] text-primary-cyan'
|
||||||
|
: 'border-border bg-bg-0 text-text-3 hover:text-text-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{nm}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mb-2.5">
|
||||||
|
<span className="text-[9px] text-text-3 font-korean whitespace-nowrap">직접 입력</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.1"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
value={circleRadiusNm}
|
||||||
|
onChange={(e) => onCircleRadiusChange?.(parseFloat(e.target.value) || 0.1)}
|
||||||
|
className="w-14 text-center py-1 px-1 bg-bg-0 border border-border rounded text-[11px] font-mono text-text-1 outline-none focus:border-primary-cyan"
|
||||||
|
style={{ colorScheme: 'dark' }}
|
||||||
|
/>
|
||||||
|
<span className="text-[9px] text-text-3 font-korean">NM</span>
|
||||||
|
<button
|
||||||
|
onClick={onRunCircleAnalysis}
|
||||||
|
className="ml-auto py-1 px-3 rounded text-[9px] font-bold font-korean text-white transition-opacity hover:opacity-90"
|
||||||
|
style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}
|
||||||
|
>
|
||||||
|
분석 실행
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{analysisResult && (
|
||||||
|
<PollResult result={analysisResult} summary={summary} onClear={onClearAnalysis} radiusNm={circleRadiusNm} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* 오염 종합 상황 */}
|
{/* 오염 종합 상황 */}
|
||||||
@ -220,18 +380,33 @@ function Section({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CheckboxLabel({ checked, children }: { checked?: boolean; children: string }) {
|
function ControlledCheckbox({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
children: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<label className="flex items-center gap-1.5 text-[10px] text-text-2 font-korean cursor-pointer">
|
<label
|
||||||
|
className={`flex items-center gap-1.5 text-[10px] font-korean cursor-pointer ${
|
||||||
|
disabled ? 'text-text-3 cursor-not-allowed opacity-40' : 'text-text-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
defaultChecked={checked}
|
checked={checked}
|
||||||
className="w-[13px] h-[13px]"
|
disabled={disabled}
|
||||||
className="accent-[var(--cyan)]"
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
className="w-[13px] h-[13px] accent-[var(--cyan)]"
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
</label>
|
</label>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatBox({
|
function StatBox({
|
||||||
@ -425,3 +600,78 @@ function InsuranceCard({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PollResult({
|
||||||
|
result,
|
||||||
|
summary,
|
||||||
|
onClear,
|
||||||
|
onRerun,
|
||||||
|
radiusNm,
|
||||||
|
}: {
|
||||||
|
result: AnalysisResult
|
||||||
|
summary?: SimulationSummary | null
|
||||||
|
onClear?: () => void
|
||||||
|
onRerun?: () => void
|
||||||
|
radiusNm?: number
|
||||||
|
}) {
|
||||||
|
const pollutedArea = (result.area * result.particlePercent / 100).toFixed(2)
|
||||||
|
return (
|
||||||
|
<div className="mt-1 p-2.5 bg-bg-0 border border-[rgba(168,85,247,0.2)] rounded-md" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, var(--purple), var(--cyan))' }} />
|
||||||
|
{radiusNm && (
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-[10px] font-semibold text-text-1 font-korean">분석 결과</span>
|
||||||
|
<span className="text-[9px] font-semibold text-primary-cyan font-mono">반경 {radiusNm} NM</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-3 gap-1 mb-2">
|
||||||
|
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
|
||||||
|
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--red)' }}>{result.area.toFixed(2)}</div>
|
||||||
|
<div className="text-[7px] text-text-3 font-korean mt-0.5">분석면적(km²)</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
|
||||||
|
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--orange)' }}>{result.particlePercent}%</div>
|
||||||
|
<div className="text-[7px] text-text-3 font-korean mt-0.5">오염비율</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
|
||||||
|
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--cyan)' }}>{pollutedArea}</div>
|
||||||
|
<div className="text-[7px] text-text-3 font-korean mt-0.5">오염면적(km²)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-[9px] font-korean">
|
||||||
|
{summary && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-3">해상잔존량</span>
|
||||||
|
<span className="font-semibold font-mono" style={{ color: 'var(--blue)' }}>{summary.remainingVolume.toFixed(2)} kL</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{summary && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-3">연안부착량</span>
|
||||||
|
<span className="font-semibold font-mono" style={{ color: 'var(--red)' }}>{summary.beachedVolume.toFixed(2)} kL</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-3">민감자원 포함</span>
|
||||||
|
<span className="font-semibold font-mono" style={{ color: 'var(--orange)' }}>{result.sensitiveCount}개소</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
className="flex-1 py-1.5 rounded text-[9px] font-semibold font-korean border border-border text-text-3 hover:text-text-2 transition-colors"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
{onRerun && (
|
||||||
|
<button
|
||||||
|
onClick={onRerun}
|
||||||
|
className="flex-1 py-1.5 rounded text-[9px] font-semibold font-korean border border-[rgba(168,85,247,0.3)] text-purple-400 hover:bg-[rgba(168,85,247,0.08)] transition-colors"
|
||||||
|
>
|
||||||
|
재분석
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
110
frontend/src/tabs/prediction/components/SimulationErrorModal.tsx
Normal file
110
frontend/src/tabs/prediction/components/SimulationErrorModal.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
interface SimulationErrorModalProps {
|
||||||
|
message: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SimulationErrorModal = ({ message, onClose }: SimulationErrorModalProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 50,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'rgba(10, 14, 26, 0.75)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 360,
|
||||||
|
background: 'var(--bg1)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.35)',
|
||||||
|
borderRadius: 'var(--rM)',
|
||||||
|
padding: '28px 24px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 아이콘 + 제목 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(239, 68, 68, 0.12)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"
|
||||||
|
fill="rgb(239, 68, 68)"
|
||||||
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--t1)', fontSize: 14, fontWeight: 600 }}>
|
||||||
|
확산 예측 실패
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--t3)', fontSize: 12, marginTop: 2 }}>
|
||||||
|
시뮬레이션 실행 중 오류가 발생했습니다
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'rgba(239, 68, 68, 0.06)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||||
|
borderRadius: 'var(--rS)',
|
||||||
|
padding: '10px 14px',
|
||||||
|
color: 'rgb(252, 165, 165)',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 확인 버튼 */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
padding: '8px 0',
|
||||||
|
background: 'rgba(239, 68, 68, 0.15)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.35)',
|
||||||
|
borderRadius: 'var(--rS)',
|
||||||
|
color: 'rgb(252, 165, 165)',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(239, 68, 68, 0.25)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(239, 68, 68, 0.15)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SimulationErrorModal;
|
||||||
@ -18,6 +18,7 @@ export interface PredictionAnalysis {
|
|||||||
backtrackStatus: string;
|
backtrackStatus: string;
|
||||||
analyst: string;
|
analyst: string;
|
||||||
officeName: string;
|
officeName: string;
|
||||||
|
acdntSttsCd: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PredictionDetail {
|
export interface PredictionDetail {
|
||||||
|
|||||||
@ -343,13 +343,26 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{sec.id === 'oil-coastal' && (
|
{sec.id === 'oil-coastal' && (() => {
|
||||||
|
const coastLength = oilPayload?.pollution.coastLength;
|
||||||
|
const hasNoCoastal = oilPayload && (
|
||||||
|
!coastLength || coastLength === '—' || coastLength.startsWith('0.00')
|
||||||
|
);
|
||||||
|
if (hasNoCoastal) {
|
||||||
|
return (
|
||||||
|
<p className="text-[12px] text-text-2 font-korean">
|
||||||
|
시뮬레이션 결과 유출유의 <span className="font-semibold text-text-1">해안 부착이 없습니다</span>.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
<p className="text-[12px] text-text-2 font-korean">
|
<p className="text-[12px] text-text-2 font-korean">
|
||||||
최초 부착시간: <span className="font-semibold text-text-1">{oilPayload?.coastal?.firstTime ?? sampleOilData.coastal.firstTime}</span>
|
최초 부착시간: <span className="font-semibold text-text-1">{oilPayload?.coastal?.firstTime ?? sampleOilData.coastal.firstTime}</span>
|
||||||
{' / '}
|
{' / '}
|
||||||
부착 해안길이: <span className="font-semibold text-text-1">{oilPayload?.pollution.coastLength || sampleOilData.coastal.coastLength}</span>
|
부착 해안길이: <span className="font-semibold text-text-1">{coastLength || sampleOilData.coastal.coastLength}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
{sec.id === 'oil-defense' && (
|
{sec.id === 'oil-defense' && (
|
||||||
<div className="text-[12px] text-text-3 font-korean">
|
<div className="text-[12px] text-text-3 font-korean">
|
||||||
<p className="mb-2">방제자원 배치 계획에 따른 전략을 수립합니다.</p>
|
<p className="mb-2">방제자원 배치 계획에 따른 전략을 수립합니다.</p>
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user