Merge pull request 'release: 2026-04-20 (346�� Ŀ��)' (#197) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 39s
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 39s
This commit is contained in:
커밋
b5f6eae1c2
@ -87,5 +87,7 @@
|
|||||||
},
|
},
|
||||||
"enabledPlugins": {
|
"enabledPlugins": {
|
||||||
"frontend-design@claude-plugins-official": true
|
"frontend-design@claude-plugins-official": true
|
||||||
}
|
},
|
||||||
|
"deny": [],
|
||||||
|
"allow": []
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"applied_global_version": "1.6.1",
|
"applied_global_version": "1.6.1",
|
||||||
"applied_date": "2026-04-17",
|
"applied_date": "2026-04-20",
|
||||||
"project_type": "react-ts",
|
"project_type": "react-ts",
|
||||||
"gitea_url": "https://gitea.gc-si.dev",
|
"gitea_url": "https://gitea.gc-si.dev",
|
||||||
"custom_pre_commit": true
|
"custom_pre_commit": true
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
# commit-msg hook
|
# commit-msg hook
|
||||||
# Conventional Commits 형식 검증 (한/영 혼용 지원)
|
# Conventional Commits 형식 검증 (한/영 혼용 지원)
|
||||||
#==============================================================================
|
#==============================================================================
|
||||||
export LC_ALL=en_US.UTF-8 2>/dev/null || export LC_ALL=C.UTF-8 2>/dev/null || true
|
|
||||||
|
|
||||||
COMMIT_MSG_FILE="$1"
|
COMMIT_MSG_FILE="$1"
|
||||||
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
|
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -106,6 +106,7 @@ backend/scripts/hns-import/out/
|
|||||||
|
|
||||||
# mcp
|
# mcp
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
# python
|
# python
|
||||||
.venv
|
.venv
|
||||||
@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend && npm install # Frontend
|
cd frontend && npm install # Frontend
|
||||||
npm run dev # Vite dev (localhost:5173)
|
npm run dev # Vite dev (localhost:5174)
|
||||||
npm run build # tsc -b && vite build
|
npm run build # tsc -b && vite build
|
||||||
npm run lint # ESLint
|
npm run lint # ESLint
|
||||||
|
|
||||||
|
|||||||
@ -14,13 +14,23 @@ router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (re
|
|||||||
try {
|
try {
|
||||||
const { status, substance, search, acdntSn } = req.query
|
const { status, substance, search, acdntSn } = req.query
|
||||||
const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined
|
const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined
|
||||||
const items = await listAnalyses({
|
const page = parseInt(req.query.page as string, 10) || 1
|
||||||
|
const limit = parseInt(req.query.limit as string, 10) || 10
|
||||||
|
|
||||||
|
if (!isValidNumber(page, 1, 10000) || !isValidNumber(limit, 1, 100)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 페이지네이션 파라미터' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await listAnalyses({
|
||||||
status: status as string | undefined,
|
status: status as string | undefined,
|
||||||
substance: substance as string | undefined,
|
substance: substance as string | undefined,
|
||||||
search: search as string | undefined,
|
search: search as string | undefined,
|
||||||
acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
|
acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
})
|
})
|
||||||
res.json(items)
|
res.json(result)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[hns] 분석 목록 오류:', err)
|
console.error('[hns] 분석 목록 오류:', err)
|
||||||
res.status(500).json({ error: 'HNS 분석 목록 조회 실패' })
|
res.status(500).json({ error: 'HNS 분석 목록 조회 실패' })
|
||||||
|
|||||||
@ -120,6 +120,8 @@ interface ListAnalysesInput {
|
|||||||
substance?: string
|
substance?: string
|
||||||
search?: string
|
search?: string
|
||||||
acdntSn?: number
|
acdntSn?: number
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
|
function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
|
||||||
@ -147,7 +149,12 @@ function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysisItem[]> {
|
export async function listAnalyses(input: ListAnalysesInput): Promise<{
|
||||||
|
items: HnsAnalysisItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
}> {
|
||||||
const conditions: string[] = ["USE_YN = 'Y'"]
|
const conditions: string[] = ["USE_YN = 'Y'"]
|
||||||
const params: (string | number)[] = []
|
const params: (string | number)[] = []
|
||||||
let idx = 1
|
let idx = 1
|
||||||
@ -170,18 +177,36 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysi
|
|||||||
params.push(input.acdntSn)
|
params.push(input.acdntSn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(' AND ')
|
||||||
|
|
||||||
|
const countResult = await wingPool.query(
|
||||||
|
`SELECT COUNT(*) AS cnt FROM HNS_ANALYSIS WHERE ${whereClause}`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
const total = parseInt(countResult.rows[0].cnt as string, 10)
|
||||||
|
|
||||||
|
const page = input.page ?? 1
|
||||||
|
const limit = input.limit ?? 10
|
||||||
|
const offset = (page - 1) * limit
|
||||||
|
|
||||||
const { rows } = await wingPool.query(
|
const { rows } = await wingPool.query(
|
||||||
`SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
`SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
||||||
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
||||||
WIND_SPD, WIND_DIR, EXEC_STTS_CD, RISK_CD, ANALYST_NM,
|
WIND_SPD, WIND_DIR, EXEC_STTS_CD, RISK_CD, ANALYST_NM,
|
||||||
RSLT_DATA, REG_DTM
|
RSLT_DATA, REG_DTM
|
||||||
FROM HNS_ANALYSIS
|
FROM HNS_ANALYSIS
|
||||||
WHERE ${conditions.join(' AND ')}
|
WHERE ${whereClause}
|
||||||
ORDER BY ACDNT_DTM DESC NULLS LAST`,
|
ORDER BY ACDNT_DTM DESC NULLS LAST
|
||||||
params
|
LIMIT $${idx++} OFFSET $${idx}`,
|
||||||
|
[...params, limit, offset]
|
||||||
)
|
)
|
||||||
|
|
||||||
return rows.map((r: Record<string, unknown>) => rowToAnalysis(r))
|
return {
|
||||||
|
items: rows.map((r: Record<string, unknown>) => rowToAnalysis(r)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
|
export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { getVesselsInBounds, getCacheStatus } from './vesselService.js';
|
import { requireAuth } from '../auth/authMiddleware.js';
|
||||||
|
import { getVesselsInBounds, getAllVessels, getCacheStatus } from './vesselService.js';
|
||||||
import type { BoundingBox } from './vesselTypes.js';
|
import type { BoundingBox } from './vesselTypes.js';
|
||||||
|
|
||||||
const vesselRouter = Router();
|
const vesselRouter = Router();
|
||||||
|
|
||||||
// POST /api/vessels/in-area
|
// POST /api/vessels/in-area
|
||||||
// 현재 뷰포트 bbox 안의 선박 목록 반환 (메모리 캐시에서 필터링)
|
// 현재 뷰포트 bbox 안의 선박 목록 반환 (메모리 캐시에서 필터링)
|
||||||
vesselRouter.post('/in-area', (req, res) => {
|
vesselRouter.post('/in-area', requireAuth, (req, res) => {
|
||||||
const { bounds } = req.body as { bounds?: BoundingBox };
|
const { bounds } = req.body as { bounds?: BoundingBox };
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -24,8 +25,14 @@ vesselRouter.post('/in-area', (req, res) => {
|
|||||||
res.json(vessels);
|
res.json(vessels);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/vessels/all — 캐시된 전체 선박 목록 반환 (검색용)
|
||||||
|
vesselRouter.get('/all', requireAuth, (_req, res) => {
|
||||||
|
const vessels = getAllVessels();
|
||||||
|
res.json(vessels);
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/vessels/status — 캐시 상태 확인 (디버그용)
|
// GET /api/vessels/status — 캐시 상태 확인 (디버그용)
|
||||||
vesselRouter.get('/status', (_req, res) => {
|
vesselRouter.get('/status', requireAuth, (_req, res) => {
|
||||||
const status = getCacheStatus();
|
const status = getCacheStatus();
|
||||||
res.json(status);
|
res.json(status);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -42,6 +42,10 @@ export function getVesselsInBounds(bounds: BoundingBox): VesselPosition[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAllVessels(): VesselPosition[] {
|
||||||
|
return Array.from(cachedVessels.values());
|
||||||
|
}
|
||||||
|
|
||||||
export function getCacheStatus(): {
|
export function getCacheStatus(): {
|
||||||
count: number;
|
count: number;
|
||||||
bangjeCount: number;
|
bangjeCount: number;
|
||||||
|
|||||||
10
database/migration/033_spil_qty_expand.sql
Normal file
10
database/migration/033_spil_qty_expand.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
-- 033: SPIL_QTY 정수부 확대 — HNS 대용량 유출량 지원
|
||||||
|
-- 031에서 NUMERIC(14,10)으로 변경된 결과 정수부가 4자리(|x| < 10^4)로 좁아져
|
||||||
|
-- HNS 기본 유출량(5000~20000 g) 입력 시 22003 오버플로우 발생.
|
||||||
|
-- 소수 10자리는 유지하여 이미지 분석 1e-7 정밀도와 호환 유지.
|
||||||
|
|
||||||
|
ALTER TABLE wing.SPIL_DATA ALTER COLUMN SPIL_QTY TYPE NUMERIC(22,10);
|
||||||
|
ALTER TABLE wing.HNS_ANALYSIS ALTER COLUMN SPIL_QTY TYPE NUMERIC(22,10);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN wing.SPIL_DATA.SPIL_QTY IS '유출량 (정수 12자리 + 소수 10자리, |x| < 10^12)';
|
||||||
|
COMMENT ON COLUMN wing.HNS_ANALYSIS.SPIL_QTY IS '유출량 (정수 12자리 + 소수 10자리, |x| < 10^12)';
|
||||||
@ -4,9 +4,31 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2026-04-20]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 선박: 선박 검색 시 지도에 하이라이트 링 애니메이션 표시 (MapView, IncidentsView)
|
||||||
|
- 선박: 선박 검색 범위를 전체 캐시 대상으로 확대
|
||||||
|
- HNS: 정보 레이어 패널 통합 (레이어 표시/불투명도/밝기/색상 제어)
|
||||||
|
- HNS: 분석 생성 시 유출량·단위·예측 시간·알고리즘·기준 모델 파라미터 전달
|
||||||
|
- HNS/사건사고: 분석 전용 뷰 모드에서 지도 오버레이 UI 요소 조건부 숨김 처리
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- InfoLayerSection을 공통 컴포넌트로 이동 (prediction → common/layer)
|
||||||
|
- 기상 탭: 지도 오버레이 컨트롤 위치 우측 상단으로 조정
|
||||||
|
- 지도 공통: 컨트롤 버튼 패널(줌 등) 위치 우측 → 좌측으로 변경
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- 선박: 라우터 전체에 requireAuth 미들웨어 추가
|
||||||
|
- 기상정보 탭 로딩 지연 개선: KHOA API 관측소 요청을 병렬 처리로 전환 및 API 키 미설정 시 즉시 fallback 처리
|
||||||
|
|
||||||
|
### 기타
|
||||||
|
- DB migration 033: SPIL_QTY NUMERIC(22,10) 확장 (대용량 HNS 유출량 지원)
|
||||||
|
|
||||||
## [2026-04-17]
|
## [2026-04-17]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
- HNS: 분석 목록 서버사이드 페이지네이션 추가 및 대기확산 히트맵 렌더링 개선
|
||||||
- HNS: 물질 DB 데이터 확장 및 데이터 구조 개선 (PDF 추출 스크립트, 병합 스크립트 개선, 물질 상세 패널 업데이트)
|
- HNS: 물질 DB 데이터 확장 및 데이터 구조 개선 (PDF 추출 스크립트, 병합 스크립트 개선, 물질 상세 패널 업데이트)
|
||||||
|
|
||||||
### 변경
|
### 변경
|
||||||
|
|||||||
@ -14,16 +14,21 @@ import type { VesselPosition, MapBounds } from '@/types/vessel';
|
|||||||
*
|
*
|
||||||
* 개발환경(VITE_VESSEL_SIGNAL_MODE=polling):
|
* 개발환경(VITE_VESSEL_SIGNAL_MODE=polling):
|
||||||
* - 60초마다 백엔드 REST API(/api/vessels/in-area)를 현재 뷰포트 bbox로 호출
|
* - 60초마다 백엔드 REST API(/api/vessels/in-area)를 현재 뷰포트 bbox로 호출
|
||||||
|
* - 3분마다 /api/vessels/all 호출하여 전체 선박 검색 풀 갱신
|
||||||
*
|
*
|
||||||
* 운영환경(VITE_VESSEL_SIGNAL_MODE=websocket):
|
* 운영환경(VITE_VESSEL_SIGNAL_MODE=websocket):
|
||||||
* - 운영 WebSocket 서버(VITE_VESSEL_WS_URL)에 직접 연결하여 실시간 수신
|
* - 운영 WebSocket 서버(VITE_VESSEL_WS_URL)에 직접 연결하여 실시간 수신
|
||||||
* - 수신된 전체 데이터를 현재 뷰포트 bbox로 프론트에서 필터링
|
* - 수신된 전체 데이터를 현재 뷰포트 bbox로 프론트에서 필터링
|
||||||
*
|
*
|
||||||
* @param mapBounds MapView의 onBoundsChange로 전달받은 현재 뷰포트 bbox
|
* @param mapBounds MapView의 onBoundsChange로 전달받은 현재 뷰포트 bbox
|
||||||
* @returns 현재 뷰포트 내 선박 목록
|
* @returns { vessels: 뷰포트 내 선박, allVessels: 전체 선박 (검색용) }
|
||||||
*/
|
*/
|
||||||
export function useVesselSignals(mapBounds: MapBounds | null): VesselPosition[] {
|
export function useVesselSignals(mapBounds: MapBounds | null): {
|
||||||
|
vessels: VesselPosition[];
|
||||||
|
allVessels: VesselPosition[];
|
||||||
|
} {
|
||||||
const [vessels, setVessels] = useState<VesselPosition[]>([]);
|
const [vessels, setVessels] = useState<VesselPosition[]>([]);
|
||||||
|
const [allVessels, setAllVessels] = useState<VesselPosition[]>([]);
|
||||||
const boundsRef = useRef<MapBounds | null>(mapBounds);
|
const boundsRef = useRef<MapBounds | null>(mapBounds);
|
||||||
const clientRef = useRef<VesselSignalClient | null>(null);
|
const clientRef = useRef<VesselSignalClient | null>(null);
|
||||||
|
|
||||||
@ -55,11 +60,12 @@ export function useVesselSignals(mapBounds: MapBounds | null): VesselPosition[]
|
|||||||
: initial;
|
: initial;
|
||||||
// WS 첫 메시지가 먼저 도착해 이미 채워졌다면 덮어쓰지 않음
|
// WS 첫 메시지가 먼저 도착해 이미 채워졌다면 덮어쓰지 않음
|
||||||
setVessels((prev) => (prev.length === 0 ? filtered : prev));
|
setVessels((prev) => (prev.length === 0 ? filtered : prev));
|
||||||
|
setAllVessels((prev) => (prev.length === 0 ? initial : prev));
|
||||||
})
|
})
|
||||||
.catch((e) => console.warn('[useVesselSignals] 초기 스냅샷 실패', e));
|
.catch((e) => console.warn('[useVesselSignals] 초기 스냅샷 실패', e));
|
||||||
}
|
}
|
||||||
|
|
||||||
client.start(setVessels, getViewportBounds);
|
client.start(setVessels, getViewportBounds, setAllVessels);
|
||||||
return () => {
|
return () => {
|
||||||
client.stop();
|
client.stop();
|
||||||
clientRef.current = null;
|
clientRef.current = null;
|
||||||
@ -75,5 +81,5 @@ export function useVesselSignals(mapBounds: MapBounds | null): VesselPosition[]
|
|||||||
}
|
}
|
||||||
}, [mapBounds]);
|
}, [mapBounds]);
|
||||||
|
|
||||||
return vessels;
|
return { vessels, allVessels };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,11 @@ export async function getVesselsInArea(bounds: MapBounds): Promise<VesselPositio
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAllVessels(): Promise<VesselPosition[]> {
|
||||||
|
const res = await api.get<VesselPosition[]>('/vessels/all');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 로그인/새로고침 직후 1회 호출하는 초기 스냅샷 API.
|
* 로그인/새로고침 직후 1회 호출하는 초기 스냅샷 API.
|
||||||
* 운영 환경의 별도 REST 서버가 현재 시각 기준 최근 10분치 선박 신호를 반환한다.
|
* 운영 환경의 별도 REST 서버가 현재 시각 기준 최근 10분치 선박 신호를 반환한다.
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import type { VesselPosition, MapBounds } from '@/types/vessel';
|
import type { VesselPosition, MapBounds } from '@/types/vessel';
|
||||||
import { getVesselsInArea } from './vesselApi';
|
import { getVesselsInArea, getAllVessels } from './vesselApi';
|
||||||
|
|
||||||
export interface VesselSignalClient {
|
export interface VesselSignalClient {
|
||||||
start(
|
start(
|
||||||
onVessels: (vessels: VesselPosition[]) => void,
|
onVessels: (vessels: VesselPosition[]) => void,
|
||||||
getViewportBounds: () => MapBounds | null,
|
getViewportBounds: () => MapBounds | null,
|
||||||
|
onAllVessels?: (vessels: VesselPosition[]) => void,
|
||||||
): void;
|
): void;
|
||||||
stop(): void;
|
stop(): void;
|
||||||
/**
|
/**
|
||||||
@ -16,8 +17,10 @@ export interface VesselSignalClient {
|
|||||||
|
|
||||||
// 개발환경: setInterval(60s) → 백엔드 REST API 호출
|
// 개발환경: setInterval(60s) → 백엔드 REST API 호출
|
||||||
class PollingVesselClient implements VesselSignalClient {
|
class PollingVesselClient implements VesselSignalClient {
|
||||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
private intervalId: ReturnType<typeof setInterval> | undefined = undefined;
|
||||||
|
private allIntervalId: ReturnType<typeof setInterval> | undefined = undefined;
|
||||||
private onVessels: ((vessels: VesselPosition[]) => void) | null = null;
|
private onVessels: ((vessels: VesselPosition[]) => void) | null = null;
|
||||||
|
private onAllVessels: ((vessels: VesselPosition[]) => void) | undefined = undefined;
|
||||||
private getViewportBounds: (() => MapBounds | null) | null = null;
|
private getViewportBounds: (() => MapBounds | null) | null = null;
|
||||||
|
|
||||||
private async poll(): Promise<void> {
|
private async poll(): Promise<void> {
|
||||||
@ -31,24 +34,38 @@ class PollingVesselClient implements VesselSignalClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async pollAll(): Promise<void> {
|
||||||
|
if (!this.onAllVessels) return;
|
||||||
|
try {
|
||||||
|
const vessels = await getAllVessels();
|
||||||
|
this.onAllVessels(vessels);
|
||||||
|
} catch {
|
||||||
|
// 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
start(
|
start(
|
||||||
onVessels: (vessels: VesselPosition[]) => void,
|
onVessels: (vessels: VesselPosition[]) => void,
|
||||||
getViewportBounds: () => MapBounds | null,
|
getViewportBounds: () => MapBounds | null,
|
||||||
|
onAllVessels?: (vessels: VesselPosition[]) => void,
|
||||||
): void {
|
): void {
|
||||||
this.onVessels = onVessels;
|
this.onVessels = onVessels;
|
||||||
|
this.onAllVessels = onAllVessels;
|
||||||
this.getViewportBounds = getViewportBounds;
|
this.getViewportBounds = getViewportBounds;
|
||||||
|
|
||||||
// 즉시 1회 실행 후 60초 간격으로 반복
|
|
||||||
this.poll();
|
this.poll();
|
||||||
|
this.pollAll();
|
||||||
this.intervalId = setInterval(() => this.poll(), 60_000);
|
this.intervalId = setInterval(() => this.poll(), 60_000);
|
||||||
|
this.allIntervalId = setInterval(() => this.pollAll(), 3 * 60_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
if (this.intervalId !== null) {
|
|
||||||
clearInterval(this.intervalId);
|
clearInterval(this.intervalId);
|
||||||
this.intervalId = null;
|
clearInterval(this.allIntervalId);
|
||||||
}
|
this.intervalId = undefined;
|
||||||
|
this.allIntervalId = undefined;
|
||||||
this.onVessels = null;
|
this.onVessels = null;
|
||||||
|
this.onAllVessels = undefined;
|
||||||
this.getViewportBounds = null;
|
this.getViewportBounds = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,12 +86,16 @@ class DirectWebSocketVesselClient implements VesselSignalClient {
|
|||||||
start(
|
start(
|
||||||
onVessels: (vessels: VesselPosition[]) => void,
|
onVessels: (vessels: VesselPosition[]) => void,
|
||||||
getViewportBounds: () => MapBounds | null,
|
getViewportBounds: () => MapBounds | null,
|
||||||
|
onAllVessels?: (vessels: VesselPosition[]) => void,
|
||||||
): void {
|
): void {
|
||||||
this.ws = new WebSocket(this.wsUrl);
|
this.ws = new WebSocket(this.wsUrl);
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
this.ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const allVessels = JSON.parse(event.data as string) as VesselPosition[];
|
const allVessels = JSON.parse(event.data as string) as VesselPosition[];
|
||||||
|
|
||||||
|
onAllVessels?.(allVessels);
|
||||||
|
|
||||||
const bounds = getViewportBounds();
|
const bounds = getViewportBounds();
|
||||||
|
|
||||||
if (!bounds) {
|
if (!bounds) {
|
||||||
|
|||||||
@ -1544,4 +1544,169 @@
|
|||||||
[data-theme='light'] .combo-list {
|
[data-theme='light'] .combo-list {
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── VesselSearchBar ── */
|
||||||
|
.vsb-wrap {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 320px;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsb-input-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsb-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 9px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--fg-disabled);
|
||||||
|
pointer-events: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsb-input {
|
||||||
|
padding-left: 30px !important;
|
||||||
|
background: rgba(18, 20, 24, 0.88) !important;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsb-list {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--stroke-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 200;
|
||||||
|
max-height: 208px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--stroke-light) transparent;
|
||||||
|
animation: comboIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsb-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid rgba(30, 42, 66, 0.5);
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsb-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsb-item:hover {
|
||||||
|
background: rgba(6, 182, 212, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsb-info {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsb-name {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-family: var(--font-korean);
|
||||||
|
color: var(--fg-default);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsb-meta {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-family: var(--font-korean);
|
||||||
|
color: var(--fg-sub);
|
||||||
|
margin-top: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsb-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-family: var(--font-korean);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, var(--color-accent), var(--color-info));
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsb-btn:hover {
|
||||||
|
box-shadow: 0 0 12px rgba(6, 182, 212, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsb-empty {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: var(--font-korean);
|
||||||
|
color: var(--fg-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .vsb-input {
|
||||||
|
background: rgba(255, 255, 255, 0.92) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .vsb-list {
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .vsb-item {
|
||||||
|
border-bottom: 1px solid var(--stroke-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 선박 검색 하이라이트 링 */
|
||||||
|
.vsb-highlight-ring {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsb-highlight-ring::before,
|
||||||
|
.vsb-highlight-ring::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2.5px solid #38bdf8;
|
||||||
|
animation: vsb-ring-pulse 2s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsb-highlight-ring::after {
|
||||||
|
animation-delay: 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vsb-ring-pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.5);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(2.8);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
176
frontend/src/components/common/layer/InfoLayerSection.tsx
Normal file
176
frontend/src/components/common/layer/InfoLayerSection.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { LayerTree } from '@components/common/layer/LayerTree';
|
||||||
|
import { useLayerTree } from '@common/hooks/useLayers';
|
||||||
|
import type { Layer } from '@common/services/layerService';
|
||||||
|
|
||||||
|
interface InfoLayerSectionProps {
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
enabledLayers: Set<string>;
|
||||||
|
onToggleLayer: (layerId: string, enabled: boolean) => void;
|
||||||
|
layerOpacity: number;
|
||||||
|
onLayerOpacityChange: (val: number) => void;
|
||||||
|
layerBrightness: number;
|
||||||
|
onLayerBrightnessChange: (val: number) => void;
|
||||||
|
layerColors: Record<string, string>;
|
||||||
|
onLayerColorChange: (layerId: string, color: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfoLayerSection = ({
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
enabledLayers,
|
||||||
|
onToggleLayer,
|
||||||
|
layerOpacity,
|
||||||
|
onLayerOpacityChange,
|
||||||
|
layerBrightness,
|
||||||
|
onLayerBrightnessChange,
|
||||||
|
layerColors,
|
||||||
|
onLayerColorChange,
|
||||||
|
}: InfoLayerSectionProps) => {
|
||||||
|
// API에서 레이어 트리 데이터 가져오기 (관리자 설정 USE_YN='Y' 레이어만 반환)
|
||||||
|
const { data: layerTree, isLoading } = useLayerTree();
|
||||||
|
|
||||||
|
// 관리자에서 사용여부가 ON인 레이어만 표시 (정적 폴백 없음)
|
||||||
|
const effectiveLayers: Layer[] = layerTree ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-stroke">
|
||||||
|
<div className="flex items-center justify-between p-4 hover:bg-[rgba(255,255,255,0.02)]">
|
||||||
|
<h3
|
||||||
|
onClick={onToggle}
|
||||||
|
className="text-title-4 font-bold text-fg-default font-korean cursor-pointer"
|
||||||
|
>
|
||||||
|
정보 레이어
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// Get all layer IDs from layerTree recursively
|
||||||
|
const getAllLayerIds = (layers: Layer[]): string[] => {
|
||||||
|
const ids: string[] = [];
|
||||||
|
layers?.forEach((layer) => {
|
||||||
|
ids.push(layer.id);
|
||||||
|
if (layer.children) {
|
||||||
|
ids.push(...getAllLayerIds(layer.children));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return ids;
|
||||||
|
};
|
||||||
|
const allIds = getAllLayerIds(effectiveLayers);
|
||||||
|
allIds.forEach((id) => onToggleLayer(id, true));
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: 'var(--font-size-label-2)',
|
||||||
|
fontWeight: 500,
|
||||||
|
border: '1px solid var(--stroke-default)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--fg-sub)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: '0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'var(--bg-surface-hover)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
전체 켜기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// Get all layer IDs from layerTree recursively
|
||||||
|
const getAllLayerIds = (layers: Layer[]): string[] => {
|
||||||
|
const ids: string[] = [];
|
||||||
|
layers?.forEach((layer) => {
|
||||||
|
ids.push(layer.id);
|
||||||
|
if (layer.children) {
|
||||||
|
ids.push(...getAllLayerIds(layer.children));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return ids;
|
||||||
|
};
|
||||||
|
const allIds = getAllLayerIds(effectiveLayers);
|
||||||
|
allIds.forEach((id) => onToggleLayer(id, false));
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: 'var(--font-size-label-2)',
|
||||||
|
fontWeight: 500,
|
||||||
|
border: '1px solid var(--stroke-default)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--fg-sub)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: '0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'var(--bg-surface-hover)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
전체 끄기
|
||||||
|
</button>
|
||||||
|
<span onClick={onToggle} className="text-label-2 text-fg-default cursor-pointer">
|
||||||
|
{expanded ? '▼' : '▶'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-4 pb-2">
|
||||||
|
{isLoading && effectiveLayers.length === 0 ? (
|
||||||
|
<p className="text-label-2 text-fg-default py-2">레이어 로딩 중...</p>
|
||||||
|
) : effectiveLayers.length === 0 ? (
|
||||||
|
<p className="text-label-2 text-fg-default py-2">레이어 데이터가 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
<LayerTree
|
||||||
|
layers={effectiveLayers}
|
||||||
|
enabledLayers={enabledLayers}
|
||||||
|
onToggleLayer={onToggleLayer}
|
||||||
|
layerColors={layerColors}
|
||||||
|
onColorChange={onLayerColorChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 레이어 스타일 조절 */}
|
||||||
|
<div className="lyr-style-box">
|
||||||
|
<div className="lyr-style-label">레이어 스타일</div>
|
||||||
|
<div className="lyr-style-row">
|
||||||
|
<span className="lyr-style-name">투명도</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="lyr-style-slider"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={layerOpacity}
|
||||||
|
onChange={(e) => onLayerOpacityChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span className="lyr-style-val">{layerOpacity}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="lyr-style-row">
|
||||||
|
<span className="lyr-style-name">밝기</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="lyr-style-slider"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={layerBrightness}
|
||||||
|
onChange={(e) => onLayerBrightnessChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span className="lyr-style-val">{layerBrightness}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InfoLayerSection;
|
||||||
@ -140,7 +140,7 @@ function MapOverlayControls({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 좌측 컨트롤 컬럼 */}
|
{/* 좌측 컨트롤 컬럼 */}
|
||||||
<div className="absolute top-[80px] left-[10px] z-10 flex flex-col gap-1">
|
<div className="absolute top-[10px] left-[10px] z-10 flex flex-col gap-1">
|
||||||
{/* 줌 */}
|
{/* 줌 */}
|
||||||
<button title="줌 인" onClick={() => map?.zoomIn()} className={btn}>
|
<button title="줌 인" onClick={() => map?.zoomIn()} className={btn}>
|
||||||
+
|
+
|
||||||
|
|||||||
@ -41,6 +41,7 @@ import {
|
|||||||
VesselDetailModal,
|
VesselDetailModal,
|
||||||
type VesselHoverInfo,
|
type VesselHoverInfo,
|
||||||
} from './VesselInteraction';
|
} from './VesselInteraction';
|
||||||
|
import { VesselSearchBar } from './VesselSearchBar';
|
||||||
import type { VesselPosition, MapBounds } from '@/types/vessel';
|
import type { VesselPosition, MapBounds } from '@/types/vessel';
|
||||||
/* eslint-disable react-refresh/only-export-components */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
|
||||||
@ -187,6 +188,8 @@ interface MapViewProps {
|
|||||||
showOverlays?: boolean;
|
showOverlays?: boolean;
|
||||||
/** 선박 신호 목록 (실시간 표출) */
|
/** 선박 신호 목록 (실시간 표출) */
|
||||||
vessels?: VesselPosition[];
|
vessels?: VesselPosition[];
|
||||||
|
/** 전체 선박 목록 (뷰포트 무관 검색용, 없으면 vessels 사용) */
|
||||||
|
allVessels?: VesselPosition[];
|
||||||
/** 지도 뷰포트 bounds 변경 콜백 (선박 신호 필터링에 사용) */
|
/** 지도 뷰포트 bounds 변경 콜백 (선박 신호 필터링에 사용) */
|
||||||
onBoundsChange?: (bounds: MapBounds) => void;
|
onBoundsChange?: (bounds: MapBounds) => void;
|
||||||
}
|
}
|
||||||
@ -233,12 +236,10 @@ function MapCenterTracker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
update();
|
update();
|
||||||
map.on('move', update);
|
map.on('moveend', update);
|
||||||
map.on('zoom', update);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
map.off('move', update);
|
map.off('moveend', update);
|
||||||
map.off('zoom', update);
|
|
||||||
};
|
};
|
||||||
}, [map, onCenterChange]);
|
}, [map, onCenterChange]);
|
||||||
|
|
||||||
@ -374,6 +375,7 @@ export function MapView({
|
|||||||
analysisCircleRadiusM = 0,
|
analysisCircleRadiusM = 0,
|
||||||
showOverlays = true,
|
showOverlays = true,
|
||||||
vessels = [],
|
vessels = [],
|
||||||
|
allVessels,
|
||||||
onBoundsChange,
|
onBoundsChange,
|
||||||
}: MapViewProps) {
|
}: MapViewProps) {
|
||||||
const lightMode = true;
|
const lightMode = true;
|
||||||
@ -397,8 +399,22 @@ export function MapView({
|
|||||||
const [vesselHover, setVesselHover] = useState<VesselHoverInfo | null>(null);
|
const [vesselHover, setVesselHover] = useState<VesselHoverInfo | null>(null);
|
||||||
const [selectedVessel, setSelectedVessel] = useState<VesselPosition | null>(null);
|
const [selectedVessel, setSelectedVessel] = useState<VesselPosition | null>(null);
|
||||||
const [detailVessel, setDetailVessel] = useState<VesselPosition | null>(null);
|
const [detailVessel, setDetailVessel] = useState<VesselPosition | null>(null);
|
||||||
|
const [searchedVesselMmsi, setSearchedVesselMmsi] = useState<string | null>(null);
|
||||||
|
const [vesselSearchFlyTarget, setVesselSearchFlyTarget] = useState<{
|
||||||
|
lng: number;
|
||||||
|
lat: number;
|
||||||
|
zoom: number;
|
||||||
|
} | null>(null);
|
||||||
const currentTime = isControlled ? externalCurrentTime : internalCurrentTime;
|
const currentTime = isControlled ? externalCurrentTime : internalCurrentTime;
|
||||||
|
|
||||||
|
const searchHighlightVessel = useMemo(
|
||||||
|
() =>
|
||||||
|
searchedVesselMmsi
|
||||||
|
? ((allVessels ?? vessels).find((v) => v.mmsi === searchedVesselMmsi) ?? null)
|
||||||
|
: null,
|
||||||
|
[searchedVesselMmsi, allVessels, vessels],
|
||||||
|
);
|
||||||
|
|
||||||
const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => {
|
const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => {
|
||||||
setMapCenter([lat, lng]);
|
setMapCenter([lat, lng]);
|
||||||
setMapZoom(zoom);
|
setMapZoom(zoom);
|
||||||
@ -507,6 +523,87 @@ export function MapView({
|
|||||||
const wmsBrightnessMin = wmsBrightnessMax > 1 ? wmsBrightnessMax - 1 : 0;
|
const wmsBrightnessMin = wmsBrightnessMax > 1 ? wmsBrightnessMax - 1 : 0;
|
||||||
const wmsOpacity = layerOpacity / 100;
|
const wmsOpacity = layerOpacity / 100;
|
||||||
|
|
||||||
|
// HNS 대기확산 히트맵 이미지 (Canvas 렌더링 + PNG 변환은 고비용이므로 별도 메모이제이션)
|
||||||
|
const heatmapImage = useMemo(() => {
|
||||||
|
if (!dispersionHeatmap || dispersionHeatmap.length === 0) return null;
|
||||||
|
|
||||||
|
const maxConc = Math.max(...dispersionHeatmap.map((p) => p.concentration));
|
||||||
|
const minConc = Math.min(
|
||||||
|
...dispersionHeatmap.filter((p) => p.concentration > 0.001).map((p) => p.concentration),
|
||||||
|
);
|
||||||
|
const filtered = dispersionHeatmap.filter((p) => p.concentration > 0.001);
|
||||||
|
|
||||||
|
if (filtered.length === 0) return null;
|
||||||
|
|
||||||
|
// 경위도 바운드 계산
|
||||||
|
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, 12, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canvas → data URL 변환 (interleaved MapboxOverlay에서 HTMLCanvasElement 직접 전달 시 렌더링 안 되는 문제 회피)
|
||||||
|
const imageUrl = canvas.toDataURL('image/png');
|
||||||
|
return {
|
||||||
|
imageUrl,
|
||||||
|
bounds: [minLon, minLat, maxLon, maxLat] as [number, number, number, number],
|
||||||
|
};
|
||||||
|
}, [dispersionHeatmap]);
|
||||||
|
|
||||||
// deck.gl 레이어 구축
|
// deck.gl 레이어 구축
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const deckLayers = useMemo((): any[] => {
|
const deckLayers = useMemo((): any[] => {
|
||||||
@ -806,97 +903,18 @@ export function MapView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) ---
|
// --- HNS 대기확산 히트맵 (BitmapLayer, 캐싱된 이미지 사용) ---
|
||||||
if (dispersionHeatmap && dispersionHeatmap.length > 0) {
|
if (heatmapImage) {
|
||||||
const maxConc = Math.max(...dispersionHeatmap.map((p) => p.concentration));
|
|
||||||
const minConc = Math.min(
|
|
||||||
...dispersionHeatmap.filter((p) => p.concentration > 0.001).map((p) => p.concentration),
|
|
||||||
);
|
|
||||||
const filtered = dispersionHeatmap.filter((p) => p.concentration > 0.001);
|
|
||||||
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, 12, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Canvas → data URL 변환 (interleaved MapboxOverlay에서 HTMLCanvasElement 직접 전달 시 렌더링 안 되는 문제 회피)
|
|
||||||
const imageUrl = canvas.toDataURL('image/png');
|
|
||||||
result.push(
|
result.push(
|
||||||
new BitmapLayer({
|
new BitmapLayer({
|
||||||
id: 'hns-dispersion-bitmap',
|
id: 'hns-dispersion-bitmap',
|
||||||
image: imageUrl,
|
image: heatmapImage.imageUrl,
|
||||||
bounds: [minLon, minLat, maxLon, maxLat],
|
bounds: heatmapImage.bounds,
|
||||||
opacity: 1.0,
|
opacity: 1.0,
|
||||||
pickable: false,
|
pickable: false,
|
||||||
}) as unknown as DeckLayer,
|
}) as unknown as DeckLayer,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) ---
|
// --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) ---
|
||||||
if (dispersionResult && incidentCoord) {
|
if (dispersionResult && incidentCoord) {
|
||||||
@ -1281,6 +1299,7 @@ export function MapView({
|
|||||||
onClick: (vessel) => {
|
onClick: (vessel) => {
|
||||||
setSelectedVessel(vessel);
|
setSelectedVessel(vessel);
|
||||||
setDetailVessel(null);
|
setDetailVessel(null);
|
||||||
|
setSearchedVesselMmsi(null);
|
||||||
},
|
},
|
||||||
onHover: (vessel, x, y) => {
|
onHover: (vessel, x, y) => {
|
||||||
setVesselHover(vessel ? { x, y, vessel } : null);
|
setVesselHover(vessel ? { x, y, vessel } : null);
|
||||||
@ -1300,7 +1319,7 @@ export function MapView({
|
|||||||
isDrawingBoom,
|
isDrawingBoom,
|
||||||
drawingPoints,
|
drawingPoints,
|
||||||
dispersionResult,
|
dispersionResult,
|
||||||
dispersionHeatmap,
|
heatmapImage,
|
||||||
incidentCoord,
|
incidentCoord,
|
||||||
backtrackReplay,
|
backtrackReplay,
|
||||||
sensitiveResources,
|
sensitiveResources,
|
||||||
@ -1353,6 +1372,8 @@ export function MapView({
|
|||||||
<MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} />
|
<MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} />
|
||||||
{/* 외부에서 flyTo 트리거 */}
|
{/* 외부에서 flyTo 트리거 */}
|
||||||
<FlyToController target={flyToTarget} duration={1200} />
|
<FlyToController target={flyToTarget} duration={1200} />
|
||||||
|
{/* 선박 검색 결과로 flyTo */}
|
||||||
|
<FlyToController target={vesselSearchFlyTarget} duration={1200} />
|
||||||
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
|
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
|
||||||
<FitBoundsController fitBoundsTarget={fitBoundsTarget} />
|
<FitBoundsController fitBoundsTarget={fitBoundsTarget} />
|
||||||
{/* 선박 신호 뷰포트 bounds 추적 */}
|
{/* 선박 신호 뷰포트 bounds 추적 */}
|
||||||
@ -1395,6 +1416,19 @@ export function MapView({
|
|||||||
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} />
|
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 선박 검색 하이라이트 링 */}
|
||||||
|
{searchHighlightVessel && !isDrawingBoom && measureMode === null && drawAnalysisMode === null && (
|
||||||
|
<Marker
|
||||||
|
key={searchHighlightVessel.mmsi}
|
||||||
|
longitude={searchHighlightVessel.lon}
|
||||||
|
latitude={searchHighlightVessel.lat}
|
||||||
|
anchor="center"
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
<div className="vsb-highlight-ring" />
|
||||||
|
</Marker>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 사고 위치 마커 (MapLibre Marker) */}
|
{/* 사고 위치 마커 (MapLibre Marker) */}
|
||||||
{incidentCoord &&
|
{incidentCoord &&
|
||||||
!isNaN(incidentCoord.lat) &&
|
!isNaN(incidentCoord.lat) &&
|
||||||
@ -1435,6 +1469,17 @@ export function MapView({
|
|||||||
<MapControls center={center} zoom={zoom} />
|
<MapControls center={center} zoom={zoom} />
|
||||||
</Map>
|
</Map>
|
||||||
|
|
||||||
|
{/* 선박 검색 */}
|
||||||
|
{(allVessels ?? vessels).length > 0 && !isDrawingBoom && measureMode === null && drawAnalysisMode === null && (
|
||||||
|
<VesselSearchBar
|
||||||
|
vessels={allVessels ?? vessels}
|
||||||
|
onFlyTo={(v) => {
|
||||||
|
setVesselSearchFlyTarget({ lng: v.lon, lat: v.lat, zoom: 16 });
|
||||||
|
setSearchedVesselMmsi(v.mmsi);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 드로잉 모드 안내 */}
|
{/* 드로잉 모드 안내 */}
|
||||||
{isDrawingBoom && (
|
{isDrawingBoom && (
|
||||||
<div className="boom-drawing-indicator">
|
<div className="boom-drawing-indicator">
|
||||||
|
|||||||
86
frontend/src/components/common/map/VesselSearchBar.tsx
Normal file
86
frontend/src/components/common/map/VesselSearchBar.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import type { VesselPosition } from '@/types/vessel';
|
||||||
|
|
||||||
|
interface VesselSearchBarProps {
|
||||||
|
vessels: VesselPosition[];
|
||||||
|
onFlyTo: (vessel: VesselPosition) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VesselSearchBar({ vessels, onFlyTo }: VesselSearchBarProps) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const results = query.trim().length > 0
|
||||||
|
? vessels.filter((v) => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
v.mmsi.toLowerCase().includes(q) ||
|
||||||
|
String(v.imo ?? '').includes(q) ||
|
||||||
|
(v.shipNm?.toLowerCase().includes(q) ?? false)
|
||||||
|
);
|
||||||
|
}).slice(0, 7)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const handleSelect = useCallback((vessel: VesselPosition) => {
|
||||||
|
onFlyTo(vessel);
|
||||||
|
setQuery('');
|
||||||
|
setOpen(false);
|
||||||
|
}, [onFlyTo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vsb-wrap" ref={wrapRef}>
|
||||||
|
<div className="vsb-input-wrap">
|
||||||
|
<svg className="vsb-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="9" cy="9" r="5.5" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<path d="M13.5 13.5L17 17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
className="wing-input vsb-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="선박명 또는 MMSI 검색…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => query.trim().length > 0 && setOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && query.trim().length > 0 && (
|
||||||
|
<div className="vsb-list">
|
||||||
|
{results.length > 0 ? (
|
||||||
|
results.map((vessel) => (
|
||||||
|
<div key={vessel.mmsi} className="vsb-item">
|
||||||
|
<div className="vsb-info">
|
||||||
|
<div className="vsb-name">{vessel.shipNm || '선박명 없음'}</div>
|
||||||
|
<div className="vsb-meta">
|
||||||
|
MMSI {vessel.mmsi}
|
||||||
|
{vessel.imo ? ` · IMO ${vessel.imo}` : ''}
|
||||||
|
{vessel.shipTy ? ` · ${vessel.shipTy}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="vsb-btn" onClick={() => handleSelect(vessel)}>
|
||||||
|
위치로 이동
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="vsb-empty">검색 결과 없음</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -8,6 +8,8 @@ interface HNSAnalysisListTableProps {
|
|||||||
onSelectAnalysis?: (sn: number, localRsltData?: Record<string, unknown>) => void;
|
onSelectAnalysis?: (sn: number, localRsltData?: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
const RISK_LABEL: Record<string, string> = {
|
const RISK_LABEL: Record<string, string> = {
|
||||||
CRITICAL: '심각',
|
CRITICAL: '심각',
|
||||||
HIGH: '위험',
|
HIGH: '위험',
|
||||||
@ -39,12 +41,17 @@ function substanceTag(sbstNm: string | null): string {
|
|||||||
export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnalysisListTableProps) {
|
export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnalysisListTableProps) {
|
||||||
const [analyses, setAnalyses] = useState<HnsAnalysisItem[]>([]);
|
const [analyses, setAnalyses] = useState<HnsAnalysisItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const items = await fetchHnsAnalyses();
|
const res = await fetchHnsAnalyses({ page, limit: PAGE_SIZE });
|
||||||
setAnalyses(items);
|
setAnalyses(res.items);
|
||||||
|
setTotal(res.total);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[hns] 분석 목록 조회 실패, localStorage fallback:', err);
|
console.error('[hns] 분석 목록 조회 실패, localStorage fallback:', err);
|
||||||
// DB 실패 시 localStorage에서 불러오기
|
// DB 실패 시 localStorage에서 불러오기
|
||||||
@ -93,7 +100,7 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
@ -105,7 +112,7 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
|
|||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-stroke">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-stroke">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-heading-3 font-bold text-fg">HNS 대기확산 분석 목록</h1>
|
<h1 className="text-heading-3 font-bold text-fg">HNS 대기확산 분석 목록</h1>
|
||||||
<p className="text-title-3 text-fg-disabled mt-1">총 {analyses.length}건</p>
|
<p className="text-title-3 text-fg-disabled mt-1">총 {total}건</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -299,6 +306,66 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-1 px-5 py-3 border-t border-stroke">
|
||||||
|
<button
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage(1)}
|
||||||
|
className="px-2.5 py-1.5 text-label-1 rounded-sm bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
«
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
className="px-2.5 py-1.5 text-label-1 rounded-sm bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
|
.filter((p) => p === 1 || p === totalPages || Math.abs(p - page) <= 2)
|
||||||
|
.reduce<number[]>((acc, p) => {
|
||||||
|
if (acc.length > 0 && p - acc[acc.length - 1] > 1) acc.push(-acc.length);
|
||||||
|
acc.push(p);
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
.map((p) =>
|
||||||
|
p < 0 ? (
|
||||||
|
<span key={`ellipsis-${p}`} className="px-1.5 text-fg-disabled text-label-1">
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPage(p)}
|
||||||
|
className={`min-w-[32px] px-2.5 py-1.5 text-label-1 rounded-sm ${
|
||||||
|
p === page
|
||||||
|
? 'bg-color-accent text-white font-semibold'
|
||||||
|
: 'bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
className="px-2.5 py-1.5 text-label-1 rounded-sm bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage(totalPages)}
|
||||||
|
className="px-2.5 py-1.5 text-label-1 rounded-sm bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
»
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import type {
|
|||||||
import type { ReleaseType } from '@/types/hns/HnsType';
|
import type { ReleaseType } from '@/types/hns/HnsType';
|
||||||
import { fetchGscAccidents } from '@components/prediction/services/predictionApi';
|
import { fetchGscAccidents } from '@components/prediction/services/predictionApi';
|
||||||
import type { GscAccidentListItem } from '@interfaces/prediction/PredictionInterface';
|
import type { GscAccidentListItem } from '@interfaces/prediction/PredictionInterface';
|
||||||
|
import InfoLayerSection from '@components/common/layer/InfoLayerSection';
|
||||||
|
|
||||||
interface HNSLeftPanelProps {
|
interface HNSLeftPanelProps {
|
||||||
activeSubTab: 'analysis' | 'list';
|
activeSubTab: 'analysis' | 'list';
|
||||||
@ -21,6 +22,14 @@ interface HNSLeftPanelProps {
|
|||||||
onReset?: () => void;
|
onReset?: () => void;
|
||||||
loadedParams?: Partial<HNSInputParams> | null;
|
loadedParams?: Partial<HNSInputParams> | null;
|
||||||
onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
|
onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
|
||||||
|
enabledLayers: Set<string>;
|
||||||
|
onToggleLayer: (layerId: string, enabled: boolean) => void;
|
||||||
|
layerOpacity: number;
|
||||||
|
onLayerOpacityChange: (val: number) => void;
|
||||||
|
layerBrightness: number;
|
||||||
|
onLayerBrightnessChange: (val: number) => void;
|
||||||
|
layerColors: Record<string, string>;
|
||||||
|
onLayerColorChange: (layerId: string, color: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 십진 좌표 → 도분초 변환 */
|
/** 십진 좌표 → 도분초 변환 */
|
||||||
@ -45,12 +54,20 @@ export function HNSLeftPanel({
|
|||||||
onReset,
|
onReset,
|
||||||
loadedParams,
|
loadedParams,
|
||||||
onFlyToCoord,
|
onFlyToCoord,
|
||||||
|
enabledLayers,
|
||||||
|
onToggleLayer,
|
||||||
|
layerOpacity,
|
||||||
|
onLayerOpacityChange,
|
||||||
|
layerBrightness,
|
||||||
|
onLayerBrightnessChange,
|
||||||
|
layerColors,
|
||||||
|
onLayerColorChange,
|
||||||
}: HNSLeftPanelProps) {
|
}: HNSLeftPanelProps) {
|
||||||
const [incidents, setIncidents] = useState<GscAccidentListItem[]>([]);
|
const [incidents, setIncidents] = useState<GscAccidentListItem[]>([]);
|
||||||
const [selectedIncidentSn, setSelectedIncidentSn] = useState('');
|
const [selectedIncidentSn, setSelectedIncidentSn] = useState('');
|
||||||
const [selectedAcdntSn, setSelectedAcdntSn] = useState<number | undefined>(undefined);
|
const [selectedAcdntSn, setSelectedAcdntSn] = useState<number | undefined>(undefined);
|
||||||
const [expandedSections, setExpandedSections] = useState({ accident: true, params: true });
|
const [expandedSections, setExpandedSections] = useState({ accident: true, params: true, infoLayer: false });
|
||||||
const toggleSection = (key: 'accident' | 'params') =>
|
const toggleSection = (key: 'accident' | 'params' | 'infoLayer') =>
|
||||||
setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
|
||||||
const [accidentName, setAccidentName] = useState('');
|
const [accidentName, setAccidentName] = useState('');
|
||||||
@ -691,6 +708,19 @@ export function HNSLeftPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<InfoLayerSection
|
||||||
|
expanded={expandedSections.infoLayer}
|
||||||
|
onToggle={() => toggleSection('infoLayer')}
|
||||||
|
enabledLayers={enabledLayers}
|
||||||
|
onToggleLayer={onToggleLayer}
|
||||||
|
layerOpacity={layerOpacity}
|
||||||
|
onLayerOpacityChange={onLayerOpacityChange}
|
||||||
|
layerBrightness={layerBrightness}
|
||||||
|
onLayerBrightnessChange={onLayerBrightnessChange}
|
||||||
|
layerColors={layerColors}
|
||||||
|
onLayerColorChange={onLayerColorChange}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 실행 버튼 */}
|
{/* 실행 버튼 */}
|
||||||
<div className="flex flex-col gap-1 px-4 py-3">
|
<div className="flex flex-col gap-1 px-4 py-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -288,8 +288,8 @@ export function HNSScenarioView() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchHnsAnalyses()
|
fetchHnsAnalyses()
|
||||||
.then((items) => {
|
.then((res) => {
|
||||||
if (!cancelled) setIncidents(items);
|
if (!cancelled) setIncidents(res.items);
|
||||||
})
|
})
|
||||||
.catch((err) => console.error('[hns] 사고 목록 조회 실패:', err));
|
.catch((err) => console.error('[hns] 사고 목록 조회 실패:', err));
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@ -76,8 +76,12 @@ export function HNSView() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
||||||
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
||||||
const vessels = useVesselSignals(mapBounds);
|
const { vessels, allVessels } = useVesselSignals(mapBounds);
|
||||||
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
|
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
|
||||||
|
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set());
|
||||||
|
const [layerOpacity, setLayerOpacity] = useState(50);
|
||||||
|
const [layerBrightness, setLayerBrightness] = useState(50);
|
||||||
|
const [layerColors, setLayerColors] = useState<Record<string, string>>({});
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const [dispersionResult, setDispersionResult] = useState<any>(null);
|
const [dispersionResult, setDispersionResult] = useState<any>(null);
|
||||||
const [recalcModalOpen, setRecalcModalOpen] = useState(false);
|
const [recalcModalOpen, setRecalcModalOpen] = useState(false);
|
||||||
@ -107,6 +111,18 @@ export function HNSView() {
|
|||||||
hasRunOnce.current = false;
|
hasRunOnce.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleLayer = (layerId: string, enabled: boolean) => {
|
||||||
|
setEnabledLayers((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (enabled) next.add(layerId);
|
||||||
|
else next.delete(layerId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLayerColorChange = (layerId: string, color: string) =>
|
||||||
|
setLayerColors((prev) => ({ ...prev, [layerId]: color }));
|
||||||
|
|
||||||
const handleParamsChange = useCallback((params: HNSInputParams) => {
|
const handleParamsChange = useCallback((params: HNSInputParams) => {
|
||||||
setInputParams(params);
|
setInputParams(params);
|
||||||
}, []);
|
}, []);
|
||||||
@ -341,7 +357,10 @@ export function HNSView() {
|
|||||||
params?.accidentDate && params?.accidentTime
|
params?.accidentDate && params?.accidentTime
|
||||||
? `${params.accidentDate}T${params.accidentTime}:00`
|
? `${params.accidentDate}T${params.accidentTime}:00`
|
||||||
: params?.accidentDate || undefined;
|
: params?.accidentDate || undefined;
|
||||||
const result = await createHnsAnalysis({
|
const fcstHrNum = parseInt(params?.predictionTime ?? '') || 24;
|
||||||
|
const spilQtyVal =
|
||||||
|
params?.releaseType === '순간 유출' ? params?.totalRelease : params?.emissionRate;
|
||||||
|
const created = await createHnsAnalysis({
|
||||||
anlysNm: params?.accidentName || `HNS 분석 ${new Date().toLocaleDateString('ko-KR')}`,
|
anlysNm: params?.accidentName || `HNS 분석 ${new Date().toLocaleDateString('ko-KR')}`,
|
||||||
acdntSn: params?.selectedAcdntSn,
|
acdntSn: params?.selectedAcdntSn,
|
||||||
acdntDtm,
|
acdntDtm,
|
||||||
@ -349,6 +368,9 @@ export function HNSView() {
|
|||||||
lat: incidentCoord.lat,
|
lat: incidentCoord.lat,
|
||||||
locNm: `${incidentCoord.lat.toFixed(4)} / ${incidentCoord.lon.toFixed(4)}`,
|
locNm: `${incidentCoord.lat.toFixed(4)} / ${incidentCoord.lon.toFixed(4)}`,
|
||||||
sbstNm: params?.substance,
|
sbstNm: params?.substance,
|
||||||
|
spilQty: spilQtyVal,
|
||||||
|
spilUnitCd: params?.releaseType === '순간 유출' ? 'g' : 'g/s',
|
||||||
|
fcstHr: fcstHrNum,
|
||||||
windSpd: params?.weather?.windSpeed,
|
windSpd: params?.weather?.windSpeed,
|
||||||
windDir:
|
windDir:
|
||||||
params?.weather?.windDirection != null
|
params?.weather?.windDirection != null
|
||||||
@ -357,14 +379,66 @@ export function HNSView() {
|
|||||||
temp: params?.weather?.temperature,
|
temp: params?.weather?.temperature,
|
||||||
humid: params?.weather?.humidity,
|
humid: params?.weather?.humidity,
|
||||||
atmStblCd: params?.weather?.stability,
|
atmStblCd: params?.weather?.stability,
|
||||||
|
algoCd: params?.algorithm,
|
||||||
|
critMdlCd: params?.criteriaModel,
|
||||||
analystNm: user?.name || undefined,
|
analystNm: user?.name || undefined,
|
||||||
});
|
});
|
||||||
// DB 저장 성공 시 SN 업데이트
|
|
||||||
|
// 실행 결과를 즉시 DB에 저장하여 목록에 바로 반영
|
||||||
|
const runZones = [
|
||||||
|
{ 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);
|
||||||
|
const runRsltData: Record<string, unknown> = {
|
||||||
|
inputParams: {
|
||||||
|
substance: params?.substance,
|
||||||
|
releaseType: params?.releaseType,
|
||||||
|
emissionRate: params?.emissionRate,
|
||||||
|
totalRelease: params?.totalRelease,
|
||||||
|
releaseHeight: params?.releaseHeight,
|
||||||
|
releaseDuration: params?.releaseDuration,
|
||||||
|
poolRadius: params?.poolRadius,
|
||||||
|
algorithm: params?.algorithm,
|
||||||
|
criteriaModel: params?.criteriaModel,
|
||||||
|
accidentDate: params?.accidentDate,
|
||||||
|
accidentTime: params?.accidentTime,
|
||||||
|
predictionTime: params?.predictionTime,
|
||||||
|
accidentName: params?.accidentName,
|
||||||
|
},
|
||||||
|
coord: { lon: incidentCoord.lon, lat: incidentCoord.lat },
|
||||||
|
zones: runZones,
|
||||||
|
aeglDistances: resultForZones.aeglDistances,
|
||||||
|
aeglAreas: resultForZones.aeglAreas,
|
||||||
|
maxConcentration: resultForZones.maxConcentration,
|
||||||
|
modelType: resultForZones.modelType,
|
||||||
|
weather: {
|
||||||
|
windSpeed: params?.weather?.windSpeed,
|
||||||
|
windDirection: params?.weather?.windDirection,
|
||||||
|
temperature: params?.weather?.temperature,
|
||||||
|
humidity: params?.weather?.humidity,
|
||||||
|
stability: params?.weather?.stability,
|
||||||
|
},
|
||||||
|
aegl3: (resultForZones.aeglDistances?.aegl3 ?? 0) > 0,
|
||||||
|
aegl2: (resultForZones.aeglDistances?.aegl2 ?? 0) > 0,
|
||||||
|
aegl1: (resultForZones.aeglDistances?.aegl1 ?? 0) > 0,
|
||||||
|
damageRadius: `${((resultForZones.aeglDistances?.aegl1 ?? 0) / 1000).toFixed(1)} km`,
|
||||||
|
};
|
||||||
|
let runRiskCd = 'LOW';
|
||||||
|
if ((resultForZones.aeglDistances?.aegl3 ?? 0) > 0) runRiskCd = 'CRITICAL';
|
||||||
|
else if ((resultForZones.aeglDistances?.aegl2 ?? 0) > 0) runRiskCd = 'HIGH';
|
||||||
|
else if ((resultForZones.aeglDistances?.aegl1 ?? 0) > 0) runRiskCd = 'MEDIUM';
|
||||||
|
// 생성 성공 즉시 SN 기록 — saveHnsAnalysis 실패 시에도 중복 생성 방지
|
||||||
setDispersionResult((prev: Record<string, unknown> | null) =>
|
setDispersionResult((prev: Record<string, unknown> | null) =>
|
||||||
prev ? { ...prev, hnsAnlysSn: result.hnsAnlysSn } : prev,
|
prev ? { ...prev, hnsAnlysSn: created.hnsAnlysSn } : prev,
|
||||||
);
|
);
|
||||||
} catch {
|
await saveHnsAnalysis(created.hnsAnlysSn, {
|
||||||
// API 실패 시 무시 (히트맵은 이미 표시됨)
|
rsltData: runRsltData,
|
||||||
|
execSttsCd: 'COMPLETED',
|
||||||
|
riskCd: runRiskCd,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[HNS] 분석 DB 저장 실패 (히트맵은 유지됨):', err);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('대기확산 예측 오류:', error);
|
console.error('대기확산 예측 오류:', error);
|
||||||
@ -760,6 +834,14 @@ export function HNSView() {
|
|||||||
onReset={handleReset}
|
onReset={handleReset}
|
||||||
loadedParams={loadedParams}
|
loadedParams={loadedParams}
|
||||||
onFlyToCoord={(c) => setFlyToCoord({ lat: c.lat, lon: c.lon })}
|
onFlyToCoord={(c) => setFlyToCoord({ lat: c.lat, lon: c.lon })}
|
||||||
|
enabledLayers={enabledLayers}
|
||||||
|
onToggleLayer={handleToggleLayer}
|
||||||
|
layerOpacity={layerOpacity}
|
||||||
|
onLayerOpacityChange={setLayerOpacity}
|
||||||
|
layerBrightness={layerBrightness}
|
||||||
|
onLayerBrightnessChange={setLayerBrightness}
|
||||||
|
layerColors={layerColors}
|
||||||
|
onLayerColorChange={handleLayerColorChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -825,11 +907,15 @@ export function HNSView() {
|
|||||||
isSelectingLocation={isSelectingLocation}
|
isSelectingLocation={isSelectingLocation}
|
||||||
onMapClick={handleMapClick}
|
onMapClick={handleMapClick}
|
||||||
oilTrajectory={[]}
|
oilTrajectory={[]}
|
||||||
enabledLayers={new Set()}
|
enabledLayers={enabledLayers}
|
||||||
|
layerOpacity={layerOpacity}
|
||||||
|
layerBrightness={layerBrightness}
|
||||||
|
layerColors={layerColors}
|
||||||
dispersionResult={dispersionResult}
|
dispersionResult={dispersionResult}
|
||||||
dispersionHeatmap={heatmapData}
|
dispersionHeatmap={heatmapData}
|
||||||
mapCaptureRef={mapCaptureRef}
|
mapCaptureRef={mapCaptureRef}
|
||||||
vessels={vessels}
|
vessels={vessels}
|
||||||
|
allVessels={allVessels}
|
||||||
onBoundsChange={setMapBounds}
|
onBoundsChange={setMapBounds}
|
||||||
/>
|
/>
|
||||||
{/* 시간 슬라이더 (puff/dense_gas 모델용) */}
|
{/* 시간 슬라이더 (puff/dense_gas 모델용) */}
|
||||||
|
|||||||
@ -6,13 +6,22 @@ import type { HnsAnalysisItem, CreateHnsAnalysisInput } from '@interfaces/hns/Hn
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
interface HnsAnalysesResponse {
|
||||||
|
items: HnsAnalysisItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchHnsAnalyses(params?: {
|
export async function fetchHnsAnalyses(params?: {
|
||||||
status?: string;
|
status?: string;
|
||||||
substance?: string;
|
substance?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
acdntSn?: number;
|
acdntSn?: number;
|
||||||
}): Promise<HnsAnalysisItem[]> {
|
page?: number;
|
||||||
const response = await api.get<HnsAnalysisItem[]>('/hns/analyses', { params });
|
limit?: number;
|
||||||
|
}): Promise<HnsAnalysesResponse> {
|
||||||
|
const response = await api.get<HnsAnalysesResponse>('/hns/analyses', { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -156,8 +156,8 @@ export function AnalysisSelectModal({
|
|||||||
const items = await fetchPredictionAnalyses();
|
const items = await fetchPredictionAnalyses();
|
||||||
setPredItems(items);
|
setPredItems(items);
|
||||||
} else if (type === 'hns') {
|
} else if (type === 'hns') {
|
||||||
const items = await fetchHnsAnalyses();
|
const res = await fetchHnsAnalyses();
|
||||||
setHnsItems(items);
|
setHnsItems(res.items);
|
||||||
} else {
|
} else {
|
||||||
const items = await fetchRescueOps();
|
const items = await fetchRescueOps();
|
||||||
setRescueItems(items);
|
setRescueItems(items);
|
||||||
|
|||||||
@ -175,12 +175,12 @@ export function IncidentsRightPanel({
|
|||||||
})
|
})
|
||||||
.catch(() => setPredItems([]));
|
.catch(() => setPredItems([]));
|
||||||
fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' })
|
fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' })
|
||||||
.then((items) => {
|
.then((res) => {
|
||||||
setHnsItems(items);
|
setHnsItems(res.items);
|
||||||
const allIds = new Set(items.map((i) => String(i.hnsAnlysSn)));
|
const allIds = new Set(res.items.map((i) => String(i.hnsAnlysSn)));
|
||||||
setCheckedHnsIds(allIds);
|
setCheckedHnsIds(allIds);
|
||||||
onCheckedHnsChange?.(
|
onCheckedHnsChange?.(
|
||||||
items.map((h) => ({
|
res.items.map((h) => ({
|
||||||
id: String(h.hnsAnlysSn),
|
id: String(h.hnsAnlysSn),
|
||||||
hnsAnlysSn: h.hnsAnlysSn,
|
hnsAnlysSn: h.hnsAnlysSn,
|
||||||
acdntSn: h.acdntSn,
|
acdntSn: h.acdntSn,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { Map as MapLibreMap, Popup } from '@vis.gl/react-maplibre';
|
import { Map as MapLibreMap, Marker, Popup } from '@vis.gl/react-maplibre';
|
||||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||||||
import { ScatterplotLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers';
|
import { ScatterplotLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers';
|
||||||
import { PathStyleExtension } from '@deck.gl/extensions';
|
import { PathStyleExtension } from '@deck.gl/extensions';
|
||||||
@ -42,6 +42,8 @@ import {
|
|||||||
} from '../utils/dischargeZoneData';
|
} from '../utils/dischargeZoneData';
|
||||||
import { useMapStore } from '@common/store/mapStore';
|
import { useMapStore } from '@common/store/mapStore';
|
||||||
import { FlyToController } from './contents/FlyToController';
|
import { FlyToController } from './contents/FlyToController';
|
||||||
|
import { FlyToController as VesselFlyToController } from '@components/common/map/FlyToController';
|
||||||
|
import { VesselSearchBar } from '@components/common/map/VesselSearchBar';
|
||||||
import { VesselPopupPanel } from './contents/VesselPopupPanel';
|
import { VesselPopupPanel } from './contents/VesselPopupPanel';
|
||||||
import { IncidentPopupContent } from './contents/IncidentPopupContent';
|
import { IncidentPopupContent } from './contents/IncidentPopupContent';
|
||||||
import { VesselDetailModal } from './contents/VesselDetailModal';
|
import { VesselDetailModal } from './contents/VesselDetailModal';
|
||||||
@ -125,7 +127,23 @@ export function IncidentsView() {
|
|||||||
|
|
||||||
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
||||||
const [mapZoom, setMapZoom] = useState<number>(10);
|
const [mapZoom, setMapZoom] = useState<number>(10);
|
||||||
const realVessels = useVesselSignals(mapBounds);
|
const { vessels: realVessels, allVessels: allRealVessels } = useVesselSignals(mapBounds);
|
||||||
|
const [vesselSearchFlyTarget, setVesselSearchFlyTarget] = useState<{
|
||||||
|
lng: number;
|
||||||
|
lat: number;
|
||||||
|
zoom: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [searchedVesselMmsi, setSearchedVesselMmsi] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const searchHighlightVessel = useMemo(
|
||||||
|
() =>
|
||||||
|
searchedVesselMmsi
|
||||||
|
? ((allRealVessels.length > 0 ? allRealVessels : realVessels).find(
|
||||||
|
(v) => v.mmsi === searchedVesselMmsi,
|
||||||
|
) ?? null)
|
||||||
|
: null,
|
||||||
|
[searchedVesselMmsi, allRealVessels, realVessels],
|
||||||
|
);
|
||||||
|
|
||||||
const [vesselStatus, setVesselStatus] = useState<VesselCacheStatus | null>(null);
|
const [vesselStatus, setVesselStatus] = useState<VesselCacheStatus | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -165,6 +183,7 @@ export function IncidentsView() {
|
|||||||
// Analysis view mode
|
// Analysis view mode
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('overlay');
|
const [viewMode, setViewMode] = useState<ViewMode>('overlay');
|
||||||
const [analysisActive, setAnalysisActive] = useState(true);
|
const [analysisActive, setAnalysisActive] = useState(true);
|
||||||
|
const isOverlayMode = !analysisActive || viewMode === 'overlay';
|
||||||
|
|
||||||
// 분할 뷰에서 사용할 체크된 원본 아이템들 (우측 패널에서 주입)
|
// 분할 뷰에서 사용할 체크된 원본 아이템들 (우측 패널에서 주입)
|
||||||
const [checkedPredItems, setCheckedPredItems] = useState<PredictionAnalysis[]>([]);
|
const [checkedPredItems, setCheckedPredItems] = useState<PredictionAnalysis[]>([]);
|
||||||
@ -221,7 +240,7 @@ export function IncidentsView() {
|
|||||||
const acdntSn = parseInt(selectedIncidentId, 10);
|
const acdntSn = parseInt(selectedIncidentId, 10);
|
||||||
if (Number.isNaN(acdntSn)) return;
|
if (Number.isNaN(acdntSn)) return;
|
||||||
fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' })
|
fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' })
|
||||||
.then((items) => setHnsAnalyses(items))
|
.then((res) => setHnsAnalyses(res.items))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [selectedIncidentId]);
|
}, [selectedIncidentId]);
|
||||||
|
|
||||||
@ -617,6 +636,7 @@ export function IncidentsView() {
|
|||||||
});
|
});
|
||||||
setIncidentPopup(null);
|
setIncidentPopup(null);
|
||||||
setDetailVessel(null);
|
setDetailVessel(null);
|
||||||
|
setSearchedVesselMmsi(null);
|
||||||
},
|
},
|
||||||
onHover: (vessel, x, y) => {
|
onHover: (vessel, x, y) => {
|
||||||
if (vessel) {
|
if (vessel) {
|
||||||
@ -778,6 +798,7 @@ export function IncidentsView() {
|
|||||||
<BaseMap
|
<BaseMap
|
||||||
center={[35.0, 127.8]}
|
center={[35.0, 127.8]}
|
||||||
zoom={7}
|
zoom={7}
|
||||||
|
showOverlays={isOverlayMode}
|
||||||
cursor={measureMode !== null || dischargeMode ? 'crosshair' : undefined}
|
cursor={measureMode !== null || dischargeMode ? 'crosshair' : undefined}
|
||||||
onMapClick={(lon, lat) => {
|
onMapClick={(lon, lat) => {
|
||||||
if (dischargeMode) {
|
if (dischargeMode) {
|
||||||
@ -790,6 +811,20 @@ export function IncidentsView() {
|
|||||||
<DeckGLOverlay layers={deckLayers} />
|
<DeckGLOverlay layers={deckLayers} />
|
||||||
<MapBoundsTracker onBoundsChange={setMapBounds} onZoomChange={setMapZoom} />
|
<MapBoundsTracker onBoundsChange={setMapBounds} onZoomChange={setMapZoom} />
|
||||||
<FlyToController incident={selectedIncident} />
|
<FlyToController incident={selectedIncident} />
|
||||||
|
<VesselFlyToController target={vesselSearchFlyTarget} duration={1200} />
|
||||||
|
|
||||||
|
{/* 선박 검색 하이라이트 링 */}
|
||||||
|
{searchHighlightVessel && !dischargeMode && measureMode === null && (
|
||||||
|
<Marker
|
||||||
|
key={searchHighlightVessel.mmsi}
|
||||||
|
longitude={searchHighlightVessel.lon}
|
||||||
|
latitude={searchHighlightVessel.lat}
|
||||||
|
anchor="center"
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
<div className="vsb-highlight-ring" />
|
||||||
|
</Marker>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 사고 팝업 */}
|
{/* 사고 팝업 */}
|
||||||
{incidentPopup && (
|
{incidentPopup && (
|
||||||
@ -811,8 +846,19 @@ export function IncidentsView() {
|
|||||||
)}
|
)}
|
||||||
</BaseMap>
|
</BaseMap>
|
||||||
|
|
||||||
|
{/* 선박 검색 */}
|
||||||
|
{isOverlayMode && (allRealVessels.length > 0 || realVessels.length > 0) && !dischargeMode && measureMode === null && (
|
||||||
|
<VesselSearchBar
|
||||||
|
vessels={allRealVessels.length > 0 ? allRealVessels : realVessels}
|
||||||
|
onFlyTo={(v) => {
|
||||||
|
setVesselSearchFlyTarget({ lng: v.lon, lat: v.lat, zoom: 13 });
|
||||||
|
setSearchedVesselMmsi(v.mmsi);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 호버 툴팁 */}
|
{/* 호버 툴팁 */}
|
||||||
{hoverInfo && (
|
{isOverlayMode && hoverInfo && (
|
||||||
<div
|
<div
|
||||||
className="absolute z-[1000] pointer-events-none rounded-md"
|
className="absolute z-[1000] pointer-events-none rounded-md"
|
||||||
style={{
|
style={{
|
||||||
@ -834,7 +880,7 @@ export function IncidentsView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 오염물 배출 규정 토글 */}
|
{/* 오염물 배출 규정 토글 */}
|
||||||
<button
|
{isOverlayMode && <button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDischargeMode(!dischargeMode);
|
setDischargeMode(!dischargeMode);
|
||||||
if (dischargeMode) setDischargeInfo(null);
|
if (dischargeMode) setDischargeInfo(null);
|
||||||
@ -842,7 +888,7 @@ export function IncidentsView() {
|
|||||||
className="absolute z-[500] cursor-pointer rounded-md text-caption font-bold font-korean"
|
className="absolute z-[500] cursor-pointer rounded-md text-caption font-bold font-korean"
|
||||||
style={{
|
style={{
|
||||||
top: 10,
|
top: 10,
|
||||||
right: 180,
|
right: 230,
|
||||||
padding: '6px 10px',
|
padding: '6px 10px',
|
||||||
background: 'var(--bg-base)',
|
background: 'var(--bg-base)',
|
||||||
border: '1px solid var(--stroke-default)',
|
border: '1px solid var(--stroke-default)',
|
||||||
@ -852,10 +898,10 @@ export function IncidentsView() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
배출규정 {dischargeMode ? 'ON' : 'OFF'}
|
배출규정 {dischargeMode ? 'ON' : 'OFF'}
|
||||||
</button>
|
</button>}
|
||||||
|
|
||||||
{/* 오염물 배출 규정 패널 */}
|
{/* 오염물 배출 규정 패널 */}
|
||||||
{dischargeMode && dischargeInfo && (
|
{isOverlayMode && dischargeMode && dischargeInfo && (
|
||||||
<DischargeZonePanel
|
<DischargeZonePanel
|
||||||
lat={dischargeInfo.lat}
|
lat={dischargeInfo.lat}
|
||||||
lon={dischargeInfo.lon}
|
lon={dischargeInfo.lon}
|
||||||
@ -866,7 +912,7 @@ export function IncidentsView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 배출규정 모드 안내 */}
|
{/* 배출규정 모드 안내 */}
|
||||||
{dischargeMode && !dischargeInfo && (
|
{isOverlayMode && dischargeMode && !dischargeInfo && (
|
||||||
<div
|
<div
|
||||||
className="absolute z-[500] rounded-md text-label-2 font-korean font-semibold"
|
className="absolute z-[500] rounded-md text-label-2 font-korean font-semibold"
|
||||||
style={{
|
style={{
|
||||||
@ -886,7 +932,7 @@ export function IncidentsView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AIS Live Badge */}
|
{/* AIS Live Badge */}
|
||||||
<div
|
{isOverlayMode && <div
|
||||||
className="absolute top-[10px] right-[10px] z-[500] rounded-md"
|
className="absolute top-[10px] right-[10px] z-[500] rounded-md"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--bg-base)',
|
background: 'var(--bg-base)',
|
||||||
@ -912,11 +958,11 @@ export function IncidentsView() {
|
|||||||
<div className="text-fg-sub">사고 {filteredIncidents.length}</div>
|
<div className="text-fg-sub">사고 {filteredIncidents.length}</div>
|
||||||
<div className="text-fg-sub">방제선 {vesselStatus?.bangjeCount ?? 0}</div>
|
<div className="text-fg-sub">방제선 {vesselStatus?.bangjeCount ?? 0}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div
|
{isOverlayMode && <div
|
||||||
className="absolute bottom-[10px] left-[10px] z-[500] rounded-md flex flex-col gap-1.5"
|
className="absolute bottom-[10px] right-[10px] z-[500] rounded-md flex flex-col gap-1.5"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--bg-base)',
|
background: 'var(--bg-base)',
|
||||||
border: '1px solid var(--stroke-default)',
|
border: '1px solid var(--stroke-default)',
|
||||||
@ -954,10 +1000,10 @@ export function IncidentsView() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* 선박 팝업 패널 */}
|
{/* 선박 팝업 패널 */}
|
||||||
{vesselPopup && selectedVessel && !detailVessel && (
|
{isOverlayMode && vesselPopup && selectedVessel && !detailVessel && (
|
||||||
<VesselPopupPanel
|
<VesselPopupPanel
|
||||||
vessel={selectedVessel}
|
vessel={selectedVessel}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@ -971,7 +1017,7 @@ export function IncidentsView() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{detailVessel && (
|
{isOverlayMode && detailVessel && (
|
||||||
<VesselDetailModal vessel={detailVessel} onClose={() => setDetailVessel(null)} />
|
<VesselDetailModal vessel={detailVessel} onClose={() => setDetailVessel(null)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,176 +1 @@
|
|||||||
import { LayerTree } from '@components/common/layer/LayerTree';
|
export { default } from '@components/common/layer/InfoLayerSection';
|
||||||
import { useLayerTree } from '@common/hooks/useLayers';
|
|
||||||
import type { Layer } from '@common/services/layerService';
|
|
||||||
|
|
||||||
interface InfoLayerSectionProps {
|
|
||||||
expanded: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
enabledLayers: Set<string>;
|
|
||||||
onToggleLayer: (layerId: string, enabled: boolean) => void;
|
|
||||||
layerOpacity: number;
|
|
||||||
onLayerOpacityChange: (val: number) => void;
|
|
||||||
layerBrightness: number;
|
|
||||||
onLayerBrightnessChange: (val: number) => void;
|
|
||||||
layerColors: Record<string, string>;
|
|
||||||
onLayerColorChange: (layerId: string, color: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const InfoLayerSection = ({
|
|
||||||
expanded,
|
|
||||||
onToggle,
|
|
||||||
enabledLayers,
|
|
||||||
onToggleLayer,
|
|
||||||
layerOpacity,
|
|
||||||
onLayerOpacityChange,
|
|
||||||
layerBrightness,
|
|
||||||
onLayerBrightnessChange,
|
|
||||||
layerColors,
|
|
||||||
onLayerColorChange,
|
|
||||||
}: InfoLayerSectionProps) => {
|
|
||||||
// API에서 레이어 트리 데이터 가져오기 (관리자 설정 USE_YN='Y' 레이어만 반환)
|
|
||||||
const { data: layerTree, isLoading } = useLayerTree();
|
|
||||||
|
|
||||||
// 관리자에서 사용여부가 ON인 레이어만 표시 (정적 폴백 없음)
|
|
||||||
const effectiveLayers: Layer[] = layerTree ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border-b border-stroke">
|
|
||||||
<div className="flex items-center justify-between p-4 hover:bg-[rgba(255,255,255,0.02)]">
|
|
||||||
<h3
|
|
||||||
onClick={onToggle}
|
|
||||||
className="text-title-4 font-bold text-fg-default font-korean cursor-pointer"
|
|
||||||
>
|
|
||||||
정보 레이어
|
|
||||||
</h3>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
// Get all layer IDs from layerTree recursively
|
|
||||||
const getAllLayerIds = (layers: Layer[]): string[] => {
|
|
||||||
const ids: string[] = [];
|
|
||||||
layers?.forEach((layer) => {
|
|
||||||
ids.push(layer.id);
|
|
||||||
if (layer.children) {
|
|
||||||
ids.push(...getAllLayerIds(layer.children));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return ids;
|
|
||||||
};
|
|
||||||
const allIds = getAllLayerIds(effectiveLayers);
|
|
||||||
allIds.forEach((id) => onToggleLayer(id, true));
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: '4px 8px',
|
|
||||||
fontSize: 'var(--font-size-label-2)',
|
|
||||||
fontWeight: 500,
|
|
||||||
border: '1px solid var(--stroke-default)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
background: 'transparent',
|
|
||||||
color: 'var(--fg-sub)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: '0.15s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = 'var(--bg-surface-hover)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
전체 켜기
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
// Get all layer IDs from layerTree recursively
|
|
||||||
const getAllLayerIds = (layers: Layer[]): string[] => {
|
|
||||||
const ids: string[] = [];
|
|
||||||
layers?.forEach((layer) => {
|
|
||||||
ids.push(layer.id);
|
|
||||||
if (layer.children) {
|
|
||||||
ids.push(...getAllLayerIds(layer.children));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return ids;
|
|
||||||
};
|
|
||||||
const allIds = getAllLayerIds(effectiveLayers);
|
|
||||||
allIds.forEach((id) => onToggleLayer(id, false));
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: '4px 8px',
|
|
||||||
fontSize: 'var(--font-size-label-2)',
|
|
||||||
fontWeight: 500,
|
|
||||||
border: '1px solid var(--stroke-default)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
background: 'transparent',
|
|
||||||
color: 'var(--fg-sub)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: '0.15s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = 'var(--bg-surface-hover)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
전체 끄기
|
|
||||||
</button>
|
|
||||||
<span onClick={onToggle} className="text-label-2 text-fg-default cursor-pointer">
|
|
||||||
{expanded ? '▼' : '▶'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expanded && (
|
|
||||||
<div className="px-4 pb-2">
|
|
||||||
{isLoading && effectiveLayers.length === 0 ? (
|
|
||||||
<p className="text-label-2 text-fg-default py-2">레이어 로딩 중...</p>
|
|
||||||
) : effectiveLayers.length === 0 ? (
|
|
||||||
<p className="text-label-2 text-fg-default py-2">레이어 데이터가 없습니다.</p>
|
|
||||||
) : (
|
|
||||||
<LayerTree
|
|
||||||
layers={effectiveLayers}
|
|
||||||
enabledLayers={enabledLayers}
|
|
||||||
onToggleLayer={onToggleLayer}
|
|
||||||
layerColors={layerColors}
|
|
||||||
onColorChange={onLayerColorChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 레이어 스타일 조절 */}
|
|
||||||
<div className="lyr-style-box">
|
|
||||||
<div className="lyr-style-label">레이어 스타일</div>
|
|
||||||
<div className="lyr-style-row">
|
|
||||||
<span className="lyr-style-name">투명도</span>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
className="lyr-style-slider"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={layerOpacity}
|
|
||||||
onChange={(e) => onLayerOpacityChange(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
<span className="lyr-style-val">{layerOpacity}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="lyr-style-row">
|
|
||||||
<span className="lyr-style-name">밝기</span>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
className="lyr-style-slider"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={layerBrightness}
|
|
||||||
onChange={(e) => onLayerBrightnessChange(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
<span className="lyr-style-val">{layerBrightness}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default InfoLayerSection;
|
|
||||||
|
|||||||
@ -59,7 +59,7 @@ const CATEGORY_ICON_MAP: Record<string, CategoryMeta> = {
|
|||||||
|
|
||||||
const FALLBACK_META: CategoryMeta = { icon: '🌊', bg: 'rgba(148,163,184,0.15)' };
|
const FALLBACK_META: CategoryMeta = { icon: '🌊', bg: 'rgba(148,163,184,0.15)' };
|
||||||
import PredictionInputSection from './PredictionInputSection';
|
import PredictionInputSection from './PredictionInputSection';
|
||||||
import InfoLayerSection from './InfoLayerSection';
|
import InfoLayerSection from '@components/common/layer/InfoLayerSection';
|
||||||
import OilBoomSection from './OilBoomSection';
|
import OilBoomSection from './OilBoomSection';
|
||||||
|
|
||||||
export type { LeftPanelProps };
|
export type { LeftPanelProps };
|
||||||
|
|||||||
@ -170,7 +170,7 @@ export function OilSpillView() {
|
|||||||
const flyToTarget = null;
|
const flyToTarget = null;
|
||||||
const fitBoundsTarget = null;
|
const fitBoundsTarget = null;
|
||||||
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
||||||
const vessels = useVesselSignals(mapBounds);
|
const { vessels, allVessels } = useVesselSignals(mapBounds);
|
||||||
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
||||||
const [oilTrajectory, setOilTrajectory] = useState<OilParticle[]>([]);
|
const [oilTrajectory, setOilTrajectory] = useState<OilParticle[]>([]);
|
||||||
const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([]);
|
const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([]);
|
||||||
@ -1329,6 +1329,7 @@ export function OilSpillView() {
|
|||||||
showTimeLabel={displayControls.showTimeLabel}
|
showTimeLabel={displayControls.showTimeLabel}
|
||||||
simulationStartTime={accidentTime || undefined}
|
simulationStartTime={accidentTime || undefined}
|
||||||
vessels={vessels}
|
vessels={vessels}
|
||||||
|
allVessels={allVessels}
|
||||||
onBoundsChange={setMapBounds}
|
onBoundsChange={setMapBounds}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -182,7 +182,7 @@ export function RescueView() {
|
|||||||
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
|
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
|
||||||
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
||||||
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
||||||
const vessels = useVesselSignals(mapBounds);
|
const { vessels, allVessels } = useVesselSignals(mapBounds);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchGscAccidents()
|
fetchGscAccidents()
|
||||||
@ -249,6 +249,7 @@ export function RescueView() {
|
|||||||
enabledLayers={new Set()}
|
enabledLayers={new Set()}
|
||||||
showOverlays={false}
|
showOverlays={false}
|
||||||
vessels={vessels}
|
vessels={vessels}
|
||||||
|
allVessels={allVessels}
|
||||||
onBoundsChange={setMapBounds}
|
onBoundsChange={setMapBounds}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -324,7 +324,7 @@ export function WeatherView() {
|
|||||||
</BaseMap>
|
</BaseMap>
|
||||||
|
|
||||||
{/* 레이어 컨트롤 */}
|
{/* 레이어 컨트롤 */}
|
||||||
<div className="absolute top-4 left-4 bg-bg-surface border border-stroke rounded-md shadow-md z-10 px-2.5 py-1.5">
|
<div className="absolute top-4 right-4 bg-bg-surface border border-stroke rounded-md shadow-md z-10 px-2.5 py-1.5">
|
||||||
<div className="text-caption font-semibold text-fg mb-1.5 font-korean">기상 레이어</div>
|
<div className="text-caption font-semibold text-fg mb-1.5 font-korean">기상 레이어</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
@ -385,7 +385,7 @@ export function WeatherView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 범례 */}
|
{/* 범례 */}
|
||||||
<div className="absolute bottom-4 left-4 bg-bg-surface border border-stroke rounded-md shadow-md z-10 px-2.5 py-1.5 max-w-[180px]">
|
<div className="absolute bottom-4 right-4 bg-bg-surface border border-stroke rounded-md shadow-md z-10 px-2.5 py-1.5 max-w-[180px]">
|
||||||
<div className="text-caption text-fg mb-1.5 font-korean">기상 범례</div>
|
<div className="text-caption text-fg mb-1.5 font-korean">기상 범례</div>
|
||||||
<div className="flex flex-col gap-1.5 text-[8px]">
|
<div className="flex flex-col gap-1.5 text-[8px]">
|
||||||
{/* 바람 */}
|
{/* 바람 */}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getRecentObservation, OBS_STATION_CODES } from '../services/khoaApi';
|
import { getRecentObservation, OBS_STATION_CODES, API_KEY } from '../services/khoaApi';
|
||||||
|
|
||||||
interface WeatherStation {
|
interface WeatherStation {
|
||||||
id: string;
|
id: string;
|
||||||
@ -68,21 +68,21 @@ export function useWeatherData(stations: WeatherStation[]) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const enrichedStations: EnrichedWeatherStation[] = [];
|
if (!API_KEY) {
|
||||||
let apiFailed = false;
|
console.warn('KHOA API 키 미설정 — fallback 데이터를 사용합니다.');
|
||||||
|
if (isMounted) {
|
||||||
for (const station of stations) {
|
setWeatherStations(stations.map(generateFallbackStation));
|
||||||
if (apiFailed) {
|
setLastUpdate(new Date());
|
||||||
enrichedStations.push(generateFallbackStation(station));
|
setLoading(false);
|
||||||
continue;
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const enrichedStations = await Promise.all(
|
||||||
|
stations.map(async (station): Promise<EnrichedWeatherStation> => {
|
||||||
try {
|
try {
|
||||||
const obsCode = OBS_STATION_CODES[station.id];
|
const obsCode = OBS_STATION_CODES[station.id];
|
||||||
if (!obsCode) {
|
if (!obsCode) return generateFallbackStation(station);
|
||||||
enrichedStations.push(generateFallbackStation(station));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const obs = await getRecentObservation(obsCode);
|
const obs = await getRecentObservation(obsCode);
|
||||||
|
|
||||||
@ -93,7 +93,7 @@ export function useWeatherData(stations: WeatherStation[]) {
|
|||||||
const waterTemp = r(obs.water_temp ?? 8.0);
|
const waterTemp = r(obs.water_temp ?? 8.0);
|
||||||
const airPres = Math.round(obs.air_pres ?? 1016);
|
const airPres = Math.round(obs.air_pres ?? 1016);
|
||||||
|
|
||||||
enrichedStations.push({
|
return {
|
||||||
...station,
|
...station,
|
||||||
wind: {
|
wind: {
|
||||||
speed: windSpeed,
|
speed: windSpeed,
|
||||||
@ -111,28 +111,21 @@ export function useWeatherData(stations: WeatherStation[]) {
|
|||||||
},
|
},
|
||||||
pressure: airPres,
|
pressure: airPres,
|
||||||
visibility: airPres > 1010 ? 15 + Math.floor(Math.random() * 5) : 10,
|
visibility: airPres > 1010 ? 15 + Math.floor(Math.random() * 5) : 10,
|
||||||
});
|
};
|
||||||
} else {
|
|
||||||
enrichedStations.push(generateFallbackStation(station));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
return generateFallbackStation(station);
|
||||||
} catch (stationError) {
|
} catch (stationError) {
|
||||||
if (!apiFailed) {
|
console.warn(`관측소 ${station.id} fallback 처리:`, stationError);
|
||||||
console.warn('KHOA API 연결 실패, fallback 데이터를 사용합니다:', stationError);
|
return generateFallbackStation(station);
|
||||||
apiFailed = true;
|
|
||||||
}
|
|
||||||
enrichedStations.push(generateFallbackStation(station));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setWeatherStations(enrichedStations);
|
setWeatherStations(enrichedStations);
|
||||||
setLastUpdate(new Date());
|
setLastUpdate(new Date());
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
if (apiFailed) {
|
|
||||||
setError('KHOA API 연결 실패 — fallback 데이터 사용 중');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('기상 데이터 가져오기 오류:', err);
|
console.error('기상 데이터 가져오기 오류:', err);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// KHOA (국립해양조사원) API 서비스
|
// KHOA (국립해양조사원) API 서비스
|
||||||
|
|
||||||
// API Key를 환경변수에서 로드 (소스코드 노출 방지)
|
// API Key를 환경변수에서 로드 (소스코드 노출 방지)
|
||||||
const API_KEY = import.meta.env.VITE_DATA_GO_KR_API_KEY || '';
|
export const API_KEY = import.meta.env.VITE_DATA_GO_KR_API_KEY || '';
|
||||||
const BASE_URL = 'https://apis.data.go.kr/1192136/oceanCondition/GetOceanConditionApiService';
|
const BASE_URL = 'https://apis.data.go.kr/1192136/oceanCondition/GetOceanConditionApiService';
|
||||||
const RECENT_OBS_URL = 'https://apis.data.go.kr/1192136/dtRecent/GetDTRecentApiService';
|
const RECENT_OBS_URL = 'https://apis.data.go.kr/1192136/dtRecent/GetDTRecentApiService';
|
||||||
|
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user