release: 2026-04-20 (346�� Ŀ��) #197

병합
jhkang develop 에서 main 로 19 commits 를 머지했습니다 2026-04-20 16:50:25 +09:00
34개의 변경된 파일1043개의 추가작업 그리고 401개의 파일을 삭제

파일 보기

@ -87,5 +87,7 @@
}, },
"enabledPlugins": { "enabledPlugins": {
"frontend-design@claude-plugins-official": true "frontend-design@claude-plugins-official": true
} },
"deny": [],
"allow": []
} }

파일 보기

@ -1,7 +1,7 @@
{ {
"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
파일 보기

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

파일 보기

@ -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); clearInterval(this.allIntervalId);
this.intervalId = null; 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;
}
}
} }

파일 보기

@ -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,96 +903,17 @@ export function MapView({
} }
} }
// --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) --- // --- HNS 대기확산 히트맵 (BitmapLayer, 캐싱된 이미지 사용) ---
if (dispersionHeatmap && dispersionHeatmap.length > 0) { if (heatmapImage) {
const maxConc = Math.max(...dispersionHeatmap.map((p) => p.concentration)); result.push(
const minConc = Math.min( new BitmapLayer({
...dispersionHeatmap.filter((p) => p.concentration > 0.001).map((p) => p.concentration), id: 'hns-dispersion-bitmap',
image: heatmapImage.imageUrl,
bounds: heatmapImage.bounds,
opacity: 1.0,
pickable: false,
}) as unknown as DeckLayer,
); );
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(
new BitmapLayer({
id: 'hns-dispersion-bitmap',
image: imageUrl,
bounds: [minLon, minLat, maxLon, maxLat],
opacity: 1.0,
pickable: false,
}) as unknown as DeckLayer,
);
}
} }
// --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) --- // --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) ---
@ -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">

파일 보기

@ -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,71 +68,64 @@ 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;
}
try {
const obsCode = OBS_STATION_CODES[station.id];
if (!obsCode) {
enrichedStations.push(generateFallbackStation(station));
continue;
}
const obs = await getRecentObservation(obsCode);
if (obs) {
const r = (n: number) => Math.round(n * 10) / 10;
const windSpeed = r(obs.wind_speed ?? 8.5);
const windDir = obs.wind_dir ?? 315;
const waterTemp = r(obs.water_temp ?? 8.0);
const airPres = Math.round(obs.air_pres ?? 1016);
enrichedStations.push({
...station,
wind: {
speed: windSpeed,
direction: windDir,
speed_1k: r(windSpeed * 0.8),
speed_3k: r(windSpeed * 1.2),
},
wave: {
height: r(1.0 + windSpeed * 0.1),
period: Math.floor(4 + windSpeed * 0.3),
},
temperature: {
current: waterTemp,
feelsLike: r((obs.air_temp ?? waterTemp) - windSpeed * 0.3),
},
pressure: airPres,
visibility: airPres > 1010 ? 15 + Math.floor(Math.random() * 5) : 10,
});
} else {
enrichedStations.push(generateFallbackStation(station));
}
await new Promise((resolve) => setTimeout(resolve, 100));
} catch (stationError) {
if (!apiFailed) {
console.warn('KHOA API 연결 실패, fallback 데이터를 사용합니다:', stationError);
apiFailed = true;
}
enrichedStations.push(generateFallbackStation(station));
} }
return;
} }
const enrichedStations = await Promise.all(
stations.map(async (station): Promise<EnrichedWeatherStation> => {
try {
const obsCode = OBS_STATION_CODES[station.id];
if (!obsCode) return generateFallbackStation(station);
const obs = await getRecentObservation(obsCode);
if (obs) {
const r = (n: number) => Math.round(n * 10) / 10;
const windSpeed = r(obs.wind_speed ?? 8.5);
const windDir = obs.wind_dir ?? 315;
const waterTemp = r(obs.water_temp ?? 8.0);
const airPres = Math.round(obs.air_pres ?? 1016);
return {
...station,
wind: {
speed: windSpeed,
direction: windDir,
speed_1k: r(windSpeed * 0.8),
speed_3k: r(windSpeed * 1.2),
},
wave: {
height: r(1.0 + windSpeed * 0.1),
period: Math.floor(4 + windSpeed * 0.3),
},
temperature: {
current: waterTemp,
feelsLike: r((obs.air_temp ?? waterTemp) - windSpeed * 0.3),
},
pressure: airPres,
visibility: airPres > 1010 ? 15 + Math.floor(Math.random() * 5) : 10,
};
}
return generateFallbackStation(station);
} catch (stationError) {
console.warn(`관측소 ${station.id} fallback 처리:`, stationError);
return 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';