diff --git a/.claude/settings.json b/.claude/settings.json index 6c2b037..caf8b0f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -87,5 +87,7 @@ }, "enabledPlugins": { "frontend-design@claude-plugins-official": true - } + }, + "deny": [], + "allow": [] } \ No newline at end of file diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index 2019a88..9d8304f 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,7 +1,7 @@ { "applied_global_version": "1.6.1", - "applied_date": "2026-04-17", + "applied_date": "2026-04-20", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true -} +} \ No newline at end of file diff --git a/.githooks/commit-msg b/.githooks/commit-msg index b020ed5..93bb350 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -3,7 +3,6 @@ # commit-msg hook # 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=$(cat "$COMMIT_MSG_FILE") diff --git a/.gitignore b/.gitignore index 114eb43..812b8ea 100755 --- a/.gitignore +++ b/.gitignore @@ -106,6 +106,7 @@ backend/scripts/hns-import/out/ # mcp .mcp.json +.playwright-mcp/ # python .venv \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 3220db9..40bb9e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ ```bash 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 lint # ESLint diff --git a/backend/src/hns/hnsRouter.ts b/backend/src/hns/hnsRouter.ts index 7985d72..ec4542d 100644 --- a/backend/src/hns/hnsRouter.ts +++ b/backend/src/hns/hnsRouter.ts @@ -14,13 +14,23 @@ router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (re try { const { status, substance, search, acdntSn } = req.query 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, substance: substance as string | undefined, search: search as string | undefined, acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined, + page, + limit, }) - res.json(items) + res.json(result) } catch (err) { console.error('[hns] 분석 목록 오류:', err) res.status(500).json({ error: 'HNS 분석 목록 조회 실패' }) diff --git a/backend/src/hns/hnsService.ts b/backend/src/hns/hnsService.ts index ff10c2e..70b75d3 100644 --- a/backend/src/hns/hnsService.ts +++ b/backend/src/hns/hnsService.ts @@ -120,6 +120,8 @@ interface ListAnalysesInput { substance?: string search?: string acdntSn?: number + page?: number + limit?: number } function rowToAnalysis(r: Record): HnsAnalysisItem { @@ -147,7 +149,12 @@ function rowToAnalysis(r: Record): HnsAnalysisItem { } } -export async function listAnalyses(input: ListAnalysesInput): Promise { +export async function listAnalyses(input: ListAnalysesInput): Promise<{ + items: HnsAnalysisItem[] + total: number + page: number + limit: number +}> { const conditions: string[] = ["USE_YN = 'Y'"] const params: (string | number)[] = [] let idx = 1 @@ -170,18 +177,36 @@ export async function listAnalyses(input: ListAnalysesInput): Promise) => rowToAnalysis(r)) + return { + items: rows.map((r: Record) => rowToAnalysis(r)), + total, + page, + limit, + } } export async function getAnalysis(sn: number): Promise { diff --git a/backend/src/vessels/vesselRouter.ts b/backend/src/vessels/vesselRouter.ts index 601c970..d7ca059 100644 --- a/backend/src/vessels/vesselRouter.ts +++ b/backend/src/vessels/vesselRouter.ts @@ -1,12 +1,13 @@ 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'; const vesselRouter = Router(); // POST /api/vessels/in-area // 현재 뷰포트 bbox 안의 선박 목록 반환 (메모리 캐시에서 필터링) -vesselRouter.post('/in-area', (req, res) => { +vesselRouter.post('/in-area', requireAuth, (req, res) => { const { bounds } = req.body as { bounds?: BoundingBox }; if ( @@ -24,8 +25,14 @@ vesselRouter.post('/in-area', (req, res) => { res.json(vessels); }); +// GET /api/vessels/all — 캐시된 전체 선박 목록 반환 (검색용) +vesselRouter.get('/all', requireAuth, (_req, res) => { + const vessels = getAllVessels(); + res.json(vessels); +}); + // GET /api/vessels/status — 캐시 상태 확인 (디버그용) -vesselRouter.get('/status', (_req, res) => { +vesselRouter.get('/status', requireAuth, (_req, res) => { const status = getCacheStatus(); res.json(status); }); diff --git a/backend/src/vessels/vesselService.ts b/backend/src/vessels/vesselService.ts index 5b2640c..596d581 100644 --- a/backend/src/vessels/vesselService.ts +++ b/backend/src/vessels/vesselService.ts @@ -42,6 +42,10 @@ export function getVesselsInBounds(bounds: BoundingBox): VesselPosition[] { return result; } +export function getAllVessels(): VesselPosition[] { + return Array.from(cachedVessels.values()); +} + export function getCacheStatus(): { count: number; bangjeCount: number; diff --git a/database/migration/033_spil_qty_expand.sql b/database/migration/033_spil_qty_expand.sql new file mode 100644 index 0000000..5571b5b --- /dev/null +++ b/database/migration/033_spil_qty_expand.sql @@ -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)'; diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 4bf4279..3dd0591 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,9 +4,31 @@ ## [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] ### 추가 +- HNS: 분석 목록 서버사이드 페이지네이션 추가 및 대기확산 히트맵 렌더링 개선 - HNS: 물질 DB 데이터 확장 및 데이터 구조 개선 (PDF 추출 스크립트, 병합 스크립트 개선, 물질 상세 패널 업데이트) ### 변경 diff --git a/frontend/src/common/hooks/useVesselSignals.ts b/frontend/src/common/hooks/useVesselSignals.ts index 709b7ac..d9fb3e1 100644 --- a/frontend/src/common/hooks/useVesselSignals.ts +++ b/frontend/src/common/hooks/useVesselSignals.ts @@ -14,16 +14,21 @@ import type { VesselPosition, MapBounds } from '@/types/vessel'; * * 개발환경(VITE_VESSEL_SIGNAL_MODE=polling): * - 60초마다 백엔드 REST API(/api/vessels/in-area)를 현재 뷰포트 bbox로 호출 + * - 3분마다 /api/vessels/all 호출하여 전체 선박 검색 풀 갱신 * * 운영환경(VITE_VESSEL_SIGNAL_MODE=websocket): * - 운영 WebSocket 서버(VITE_VESSEL_WS_URL)에 직접 연결하여 실시간 수신 * - 수신된 전체 데이터를 현재 뷰포트 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([]); + const [allVessels, setAllVessels] = useState([]); const boundsRef = useRef(mapBounds); const clientRef = useRef(null); @@ -55,11 +60,12 @@ export function useVesselSignals(mapBounds: MapBounds | null): VesselPosition[] : initial; // WS 첫 메시지가 먼저 도착해 이미 채워졌다면 덮어쓰지 않음 setVessels((prev) => (prev.length === 0 ? filtered : prev)); + setAllVessels((prev) => (prev.length === 0 ? initial : prev)); }) .catch((e) => console.warn('[useVesselSignals] 초기 스냅샷 실패', e)); } - client.start(setVessels, getViewportBounds); + client.start(setVessels, getViewportBounds, setAllVessels); return () => { client.stop(); clientRef.current = null; @@ -75,5 +81,5 @@ export function useVesselSignals(mapBounds: MapBounds | null): VesselPosition[] } }, [mapBounds]); - return vessels; + return { vessels, allVessels }; } diff --git a/frontend/src/common/services/vesselApi.ts b/frontend/src/common/services/vesselApi.ts index 00c9ce5..2bdbbd5 100644 --- a/frontend/src/common/services/vesselApi.ts +++ b/frontend/src/common/services/vesselApi.ts @@ -6,6 +6,11 @@ export async function getVesselsInArea(bounds: MapBounds): Promise { + const res = await api.get('/vessels/all'); + return res.data; +} + /** * 로그인/새로고침 직후 1회 호출하는 초기 스냅샷 API. * 운영 환경의 별도 REST 서버가 현재 시각 기준 최근 10분치 선박 신호를 반환한다. diff --git a/frontend/src/common/services/vesselSignalClient.ts b/frontend/src/common/services/vesselSignalClient.ts index 8a47fe2..9114886 100644 --- a/frontend/src/common/services/vesselSignalClient.ts +++ b/frontend/src/common/services/vesselSignalClient.ts @@ -1,10 +1,11 @@ import type { VesselPosition, MapBounds } from '@/types/vessel'; -import { getVesselsInArea } from './vesselApi'; +import { getVesselsInArea, getAllVessels } from './vesselApi'; export interface VesselSignalClient { start( onVessels: (vessels: VesselPosition[]) => void, getViewportBounds: () => MapBounds | null, + onAllVessels?: (vessels: VesselPosition[]) => void, ): void; stop(): void; /** @@ -16,8 +17,10 @@ export interface VesselSignalClient { // 개발환경: setInterval(60s) → 백엔드 REST API 호출 class PollingVesselClient implements VesselSignalClient { - private intervalId: ReturnType | null = null; + private intervalId: ReturnType | undefined = undefined; + private allIntervalId: ReturnType | undefined = undefined; private onVessels: ((vessels: VesselPosition[]) => void) | null = null; + private onAllVessels: ((vessels: VesselPosition[]) => void) | undefined = undefined; private getViewportBounds: (() => MapBounds | null) | null = null; private async poll(): Promise { @@ -31,24 +34,38 @@ class PollingVesselClient implements VesselSignalClient { } } + private async pollAll(): Promise { + if (!this.onAllVessels) return; + try { + const vessels = await getAllVessels(); + this.onAllVessels(vessels); + } catch { + // 무시 + } + } + start( onVessels: (vessels: VesselPosition[]) => void, getViewportBounds: () => MapBounds | null, + onAllVessels?: (vessels: VesselPosition[]) => void, ): void { this.onVessels = onVessels; + this.onAllVessels = onAllVessels; this.getViewportBounds = getViewportBounds; - // 즉시 1회 실행 후 60초 간격으로 반복 this.poll(); + this.pollAll(); this.intervalId = setInterval(() => this.poll(), 60_000); + this.allIntervalId = setInterval(() => this.pollAll(), 3 * 60_000); } stop(): void { - if (this.intervalId !== null) { - clearInterval(this.intervalId); - this.intervalId = null; - } + clearInterval(this.intervalId); + clearInterval(this.allIntervalId); + this.intervalId = undefined; + this.allIntervalId = undefined; this.onVessels = null; + this.onAllVessels = undefined; this.getViewportBounds = null; } @@ -69,12 +86,16 @@ class DirectWebSocketVesselClient implements VesselSignalClient { start( onVessels: (vessels: VesselPosition[]) => void, getViewportBounds: () => MapBounds | null, + onAllVessels?: (vessels: VesselPosition[]) => void, ): void { this.ws = new WebSocket(this.wsUrl); this.ws.onmessage = (event) => { try { const allVessels = JSON.parse(event.data as string) as VesselPosition[]; + + onAllVessels?.(allVessels); + const bounds = getViewportBounds(); if (!bounds) { diff --git a/frontend/src/common/styles/components.css b/frontend/src/common/styles/components.css index c8a810f..6204334 100644 --- a/frontend/src/common/styles/components.css +++ b/frontend/src/common/styles/components.css @@ -1544,4 +1544,169 @@ [data-theme='light'] .combo-list { 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; + } + } } diff --git a/frontend/src/components/common/layer/InfoLayerSection.tsx b/frontend/src/components/common/layer/InfoLayerSection.tsx new file mode 100644 index 0000000..a906a5c --- /dev/null +++ b/frontend/src/components/common/layer/InfoLayerSection.tsx @@ -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; + onToggleLayer: (layerId: string, enabled: boolean) => void; + layerOpacity: number; + onLayerOpacityChange: (val: number) => void; + layerBrightness: number; + onLayerBrightnessChange: (val: number) => void; + layerColors: Record; + 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 ( +
+
+

+ 정보 레이어 +

+
+ + + + {expanded ? '▼' : '▶'} + +
+
+ + {expanded && ( +
+ {isLoading && effectiveLayers.length === 0 ? ( +

레이어 로딩 중...

+ ) : effectiveLayers.length === 0 ? ( +

레이어 데이터가 없습니다.

+ ) : ( + + )} + + {/* 레이어 스타일 조절 */} +
+
레이어 스타일
+
+ 투명도 + onLayerOpacityChange(Number(e.target.value))} + /> + {layerOpacity}% +
+
+ 밝기 + onLayerBrightnessChange(Number(e.target.value))} + /> + {layerBrightness}% +
+
+
+ )} +
+ ); +}; + +export default InfoLayerSection; diff --git a/frontend/src/components/common/map/BaseMap.tsx b/frontend/src/components/common/map/BaseMap.tsx index 4464e38..a18e126 100644 --- a/frontend/src/components/common/map/BaseMap.tsx +++ b/frontend/src/components/common/map/BaseMap.tsx @@ -140,7 +140,7 @@ function MapOverlayControls({ return ( <> {/* 좌측 컨트롤 컬럼 */} -
+
{/* 줌 */} +
+ )) + ) : ( +
검색 결과 없음
+ )} +
+ )} + + ); +} diff --git a/frontend/src/components/hns/components/HNSAnalysisListTable.tsx b/frontend/src/components/hns/components/HNSAnalysisListTable.tsx index 2952be4..b6277fd 100644 --- a/frontend/src/components/hns/components/HNSAnalysisListTable.tsx +++ b/frontend/src/components/hns/components/HNSAnalysisListTable.tsx @@ -8,6 +8,8 @@ interface HNSAnalysisListTableProps { onSelectAnalysis?: (sn: number, localRsltData?: Record) => void; } +const PAGE_SIZE = 10; + const RISK_LABEL: Record = { CRITICAL: '심각', HIGH: '위험', @@ -39,12 +41,17 @@ function substanceTag(sbstNm: string | null): string { export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnalysisListTableProps) { const [analyses, setAnalyses] = useState([]); 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 () => { setLoading(true); try { - const items = await fetchHnsAnalyses(); - setAnalyses(items); + const res = await fetchHnsAnalyses({ page, limit: PAGE_SIZE }); + setAnalyses(res.items); + setTotal(res.total); } catch (err) { console.error('[hns] 분석 목록 조회 실패, localStorage fallback:', err); // DB 실패 시 localStorage에서 불러오기 @@ -93,7 +100,7 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly } finally { setLoading(false); } - }, []); + }, [page]); useEffect(() => { loadData(); @@ -105,7 +112,7 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly

HNS 대기확산 분석 목록

-

총 {analyses.length}건

+

총 {total}건

@@ -299,6 +306,66 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
)}
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + {Array.from({ length: totalPages }, (_, i) => i + 1) + .filter((p) => p === 1 || p === totalPages || Math.abs(p - page) <= 2) + .reduce((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 ? ( + + … + + ) : ( + + ), + )} + + +
+ )}
); } diff --git a/frontend/src/components/hns/components/HNSLeftPanel.tsx b/frontend/src/components/hns/components/HNSLeftPanel.tsx index 13a56bc..2a670c1 100644 --- a/frontend/src/components/hns/components/HNSLeftPanel.tsx +++ b/frontend/src/components/hns/components/HNSLeftPanel.tsx @@ -8,6 +8,7 @@ import type { import type { ReleaseType } from '@/types/hns/HnsType'; import { fetchGscAccidents } from '@components/prediction/services/predictionApi'; import type { GscAccidentListItem } from '@interfaces/prediction/PredictionInterface'; +import InfoLayerSection from '@components/common/layer/InfoLayerSection'; interface HNSLeftPanelProps { activeSubTab: 'analysis' | 'list'; @@ -21,6 +22,14 @@ interface HNSLeftPanelProps { onReset?: () => void; loadedParams?: Partial | null; onFlyToCoord?: (coord: { lon: number; lat: number }) => void; + enabledLayers: Set; + onToggleLayer: (layerId: string, enabled: boolean) => void; + layerOpacity: number; + onLayerOpacityChange: (val: number) => void; + layerBrightness: number; + onLayerBrightnessChange: (val: number) => void; + layerColors: Record; + onLayerColorChange: (layerId: string, color: string) => void; } /** 십진 좌표 → 도분초 변환 */ @@ -45,12 +54,20 @@ export function HNSLeftPanel({ onReset, loadedParams, onFlyToCoord, + enabledLayers, + onToggleLayer, + layerOpacity, + onLayerOpacityChange, + layerBrightness, + onLayerBrightnessChange, + layerColors, + onLayerColorChange, }: HNSLeftPanelProps) { const [incidents, setIncidents] = useState([]); const [selectedIncidentSn, setSelectedIncidentSn] = useState(''); const [selectedAcdntSn, setSelectedAcdntSn] = useState(undefined); - const [expandedSections, setExpandedSections] = useState({ accident: true, params: true }); - const toggleSection = (key: 'accident' | 'params') => + const [expandedSections, setExpandedSections] = useState({ accident: true, params: true, infoLayer: false }); + const toggleSection = (key: 'accident' | 'params' | 'infoLayer') => setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] })); const [accidentName, setAccidentName] = useState(''); @@ -691,6 +708,19 @@ export function HNSLeftPanel({ )} + toggleSection('infoLayer')} + enabledLayers={enabledLayers} + onToggleLayer={onToggleLayer} + layerOpacity={layerOpacity} + onLayerOpacityChange={onLayerOpacityChange} + layerBrightness={layerBrightness} + onLayerBrightnessChange={onLayerBrightnessChange} + layerColors={layerColors} + onLayerColorChange={onLayerColorChange} + /> + {/* 실행 버튼 */}
)} @@ -825,11 +907,15 @@ export function HNSView() { isSelectingLocation={isSelectingLocation} onMapClick={handleMapClick} oilTrajectory={[]} - enabledLayers={new Set()} + enabledLayers={enabledLayers} + layerOpacity={layerOpacity} + layerBrightness={layerBrightness} + layerColors={layerColors} dispersionResult={dispersionResult} dispersionHeatmap={heatmapData} mapCaptureRef={mapCaptureRef} vessels={vessels} + allVessels={allVessels} onBoundsChange={setMapBounds} /> {/* 시간 슬라이더 (puff/dense_gas 모델용) */} diff --git a/frontend/src/components/hns/services/hnsApi.ts b/frontend/src/components/hns/services/hnsApi.ts index f626dec..c8afa5e 100644 --- a/frontend/src/components/hns/services/hnsApi.ts +++ b/frontend/src/components/hns/services/hnsApi.ts @@ -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?: { status?: string; substance?: string; search?: string; acdntSn?: number; -}): Promise { - const response = await api.get('/hns/analyses', { params }); + page?: number; + limit?: number; +}): Promise { + const response = await api.get('/hns/analyses', { params }); return response.data; } diff --git a/frontend/src/components/incidents/components/AnalysisSelectModal.tsx b/frontend/src/components/incidents/components/AnalysisSelectModal.tsx index a228f82..9e9b43a 100644 --- a/frontend/src/components/incidents/components/AnalysisSelectModal.tsx +++ b/frontend/src/components/incidents/components/AnalysisSelectModal.tsx @@ -156,8 +156,8 @@ export function AnalysisSelectModal({ const items = await fetchPredictionAnalyses(); setPredItems(items); } else if (type === 'hns') { - const items = await fetchHnsAnalyses(); - setHnsItems(items); + const res = await fetchHnsAnalyses(); + setHnsItems(res.items); } else { const items = await fetchRescueOps(); setRescueItems(items); diff --git a/frontend/src/components/incidents/components/IncidentsRightPanel.tsx b/frontend/src/components/incidents/components/IncidentsRightPanel.tsx index e46cc9a..9ade69b 100644 --- a/frontend/src/components/incidents/components/IncidentsRightPanel.tsx +++ b/frontend/src/components/incidents/components/IncidentsRightPanel.tsx @@ -175,12 +175,12 @@ export function IncidentsRightPanel({ }) .catch(() => setPredItems([])); fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' }) - .then((items) => { - setHnsItems(items); - const allIds = new Set(items.map((i) => String(i.hnsAnlysSn))); + .then((res) => { + setHnsItems(res.items); + const allIds = new Set(res.items.map((i) => String(i.hnsAnlysSn))); setCheckedHnsIds(allIds); onCheckedHnsChange?.( - items.map((h) => ({ + res.items.map((h) => ({ id: String(h.hnsAnlysSn), hnsAnlysSn: h.hnsAnlysSn, acdntSn: h.acdntSn, diff --git a/frontend/src/components/incidents/components/IncidentsView.tsx b/frontend/src/components/incidents/components/IncidentsView.tsx index c8806ef..c7c8e42 100644 --- a/frontend/src/components/incidents/components/IncidentsView.tsx +++ b/frontend/src/components/incidents/components/IncidentsView.tsx @@ -1,5 +1,5 @@ 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 { ScatterplotLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers'; import { PathStyleExtension } from '@deck.gl/extensions'; @@ -42,6 +42,8 @@ import { } from '../utils/dischargeZoneData'; import { useMapStore } from '@common/store/mapStore'; 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 { IncidentPopupContent } from './contents/IncidentPopupContent'; import { VesselDetailModal } from './contents/VesselDetailModal'; @@ -125,7 +127,23 @@ export function IncidentsView() { const [mapBounds, setMapBounds] = useState(null); const [mapZoom, setMapZoom] = useState(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(null); + + const searchHighlightVessel = useMemo( + () => + searchedVesselMmsi + ? ((allRealVessels.length > 0 ? allRealVessels : realVessels).find( + (v) => v.mmsi === searchedVesselMmsi, + ) ?? null) + : null, + [searchedVesselMmsi, allRealVessels, realVessels], + ); const [vesselStatus, setVesselStatus] = useState(null); useEffect(() => { @@ -165,6 +183,7 @@ export function IncidentsView() { // Analysis view mode const [viewMode, setViewMode] = useState('overlay'); const [analysisActive, setAnalysisActive] = useState(true); + const isOverlayMode = !analysisActive || viewMode === 'overlay'; // 분할 뷰에서 사용할 체크된 원본 아이템들 (우측 패널에서 주입) const [checkedPredItems, setCheckedPredItems] = useState([]); @@ -221,7 +240,7 @@ export function IncidentsView() { const acdntSn = parseInt(selectedIncidentId, 10); if (Number.isNaN(acdntSn)) return; fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' }) - .then((items) => setHnsAnalyses(items)) + .then((res) => setHnsAnalyses(res.items)) .catch(() => {}); }, [selectedIncidentId]); @@ -617,6 +636,7 @@ export function IncidentsView() { }); setIncidentPopup(null); setDetailVessel(null); + setSearchedVesselMmsi(null); }, onHover: (vessel, x, y) => { if (vessel) { @@ -778,6 +798,7 @@ export function IncidentsView() { { if (dischargeMode) { @@ -790,6 +811,20 @@ export function IncidentsView() { + + + {/* 선박 검색 하이라이트 링 */} + {searchHighlightVessel && !dischargeMode && measureMode === null && ( + +
+ + )} {/* 사고 팝업 */} {incidentPopup && ( @@ -811,8 +846,19 @@ export function IncidentsView() { )} + {/* 선박 검색 */} + {isOverlayMode && (allRealVessels.length > 0 || realVessels.length > 0) && !dischargeMode && measureMode === null && ( + 0 ? allRealVessels : realVessels} + onFlyTo={(v) => { + setVesselSearchFlyTarget({ lng: v.lon, lat: v.lat, zoom: 13 }); + setSearchedVesselMmsi(v.mmsi); + }} + /> + )} + {/* 호버 툴팁 */} - {hoverInfo && ( + {isOverlayMode && hoverInfo && (
{ setDischargeMode(!dischargeMode); 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" style={{ top: 10, - right: 180, + right: 230, padding: '6px 10px', background: 'var(--bg-base)', border: '1px solid var(--stroke-default)', @@ -852,10 +898,10 @@ export function IncidentsView() { }} > 배출규정 {dischargeMode ? 'ON' : 'OFF'} - + } {/* 오염물 배출 규정 패널 */} - {dischargeMode && dischargeInfo && ( + {isOverlayMode && dischargeMode && dischargeInfo && ( 사고 {filteredIncidents.length}
방제선 {vesselStatus?.bangjeCount ?? 0}
- + } {/* Legend */} -
))}
- + } {/* 선박 팝업 패널 */} - {vesselPopup && selectedVessel && !detailVessel && ( + {isOverlayMode && vesselPopup && selectedVessel && !detailVessel && ( { @@ -971,7 +1017,7 @@ export function IncidentsView() { }} /> )} - {detailVessel && ( + {isOverlayMode && detailVessel && ( setDetailVessel(null)} /> )} diff --git a/frontend/src/components/prediction/components/InfoLayerSection.tsx b/frontend/src/components/prediction/components/InfoLayerSection.tsx index a906a5c..d80a3f5 100644 --- a/frontend/src/components/prediction/components/InfoLayerSection.tsx +++ b/frontend/src/components/prediction/components/InfoLayerSection.tsx @@ -1,176 +1 @@ -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; - onToggleLayer: (layerId: string, enabled: boolean) => void; - layerOpacity: number; - onLayerOpacityChange: (val: number) => void; - layerBrightness: number; - onLayerBrightnessChange: (val: number) => void; - layerColors: Record; - 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 ( -
-
-

- 정보 레이어 -

-
- - - - {expanded ? '▼' : '▶'} - -
-
- - {expanded && ( -
- {isLoading && effectiveLayers.length === 0 ? ( -

레이어 로딩 중...

- ) : effectiveLayers.length === 0 ? ( -

레이어 데이터가 없습니다.

- ) : ( - - )} - - {/* 레이어 스타일 조절 */} -
-
레이어 스타일
-
- 투명도 - onLayerOpacityChange(Number(e.target.value))} - /> - {layerOpacity}% -
-
- 밝기 - onLayerBrightnessChange(Number(e.target.value))} - /> - {layerBrightness}% -
-
-
- )} -
- ); -}; - -export default InfoLayerSection; +export { default } from '@components/common/layer/InfoLayerSection'; diff --git a/frontend/src/components/prediction/components/LeftPanel.tsx b/frontend/src/components/prediction/components/LeftPanel.tsx index ba5c114..9abdc6d 100644 --- a/frontend/src/components/prediction/components/LeftPanel.tsx +++ b/frontend/src/components/prediction/components/LeftPanel.tsx @@ -59,7 +59,7 @@ const CATEGORY_ICON_MAP: Record = { const FALLBACK_META: CategoryMeta = { icon: '🌊', bg: 'rgba(148,163,184,0.15)' }; import PredictionInputSection from './PredictionInputSection'; -import InfoLayerSection from './InfoLayerSection'; +import InfoLayerSection from '@components/common/layer/InfoLayerSection'; import OilBoomSection from './OilBoomSection'; export type { LeftPanelProps }; diff --git a/frontend/src/components/prediction/components/OilSpillView.tsx b/frontend/src/components/prediction/components/OilSpillView.tsx index a84ed54..e13b597 100644 --- a/frontend/src/components/prediction/components/OilSpillView.tsx +++ b/frontend/src/components/prediction/components/OilSpillView.tsx @@ -170,7 +170,7 @@ export function OilSpillView() { const flyToTarget = null; const fitBoundsTarget = null; const [mapBounds, setMapBounds] = useState(null); - const vessels = useVesselSignals(mapBounds); + const { vessels, allVessels } = useVesselSignals(mapBounds); const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [oilTrajectory, setOilTrajectory] = useState([]); const [centerPoints, setCenterPoints] = useState([]); @@ -1329,6 +1329,7 @@ export function OilSpillView() { showTimeLabel={displayControls.showTimeLabel} simulationStartTime={accidentTime || undefined} vessels={vessels} + allVessels={allVessels} onBoundsChange={setMapBounds} /> diff --git a/frontend/src/components/rescue/components/RescueView.tsx b/frontend/src/components/rescue/components/RescueView.tsx index f833d81..7f46ab4 100644 --- a/frontend/src/components/rescue/components/RescueView.tsx +++ b/frontend/src/components/rescue/components/RescueView.tsx @@ -182,7 +182,7 @@ export function RescueView() { const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined); const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [mapBounds, setMapBounds] = useState(null); - const vessels = useVesselSignals(mapBounds); + const { vessels, allVessels } = useVesselSignals(mapBounds); useEffect(() => { fetchGscAccidents() @@ -249,6 +249,7 @@ export function RescueView() { enabledLayers={new Set()} showOverlays={false} vessels={vessels} + allVessels={allVessels} onBoundsChange={setMapBounds} /> diff --git a/frontend/src/components/weather/components/WeatherView.tsx b/frontend/src/components/weather/components/WeatherView.tsx index b041c46..7bb3add 100644 --- a/frontend/src/components/weather/components/WeatherView.tsx +++ b/frontend/src/components/weather/components/WeatherView.tsx @@ -324,7 +324,7 @@ export function WeatherView() {
{/* 레이어 컨트롤 */} -
+
기상 레이어
{/* 범례 */} -
+
기상 범례
{/* 바람 */} diff --git a/frontend/src/components/weather/hooks/useWeatherData.ts b/frontend/src/components/weather/hooks/useWeatherData.ts index 4c4ae08..c8ce08b 100644 --- a/frontend/src/components/weather/hooks/useWeatherData.ts +++ b/frontend/src/components/weather/hooks/useWeatherData.ts @@ -1,5 +1,5 @@ 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 { id: string; @@ -68,71 +68,64 @@ export function useWeatherData(stations: WeatherStation[]) { setLoading(true); setError(null); - const enrichedStations: EnrichedWeatherStation[] = []; - let apiFailed = false; - - for (const station of stations) { - if (apiFailed) { - enrichedStations.push(generateFallbackStation(station)); - 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)); + if (!API_KEY) { + console.warn('KHOA API 키 미설정 — fallback 데이터를 사용합니다.'); + if (isMounted) { + setWeatherStations(stations.map(generateFallbackStation)); + setLastUpdate(new Date()); + setLoading(false); } + return; } + const enrichedStations = await Promise.all( + stations.map(async (station): Promise => { + 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) { setWeatherStations(enrichedStations); setLastUpdate(new Date()); setLoading(false); - if (apiFailed) { - setError('KHOA API 연결 실패 — fallback 데이터 사용 중'); - } } } catch (err) { console.error('기상 데이터 가져오기 오류:', err); diff --git a/frontend/src/components/weather/services/khoaApi.ts b/frontend/src/components/weather/services/khoaApi.ts index 722455e..118a9bb 100644 --- a/frontend/src/components/weather/services/khoaApi.ts +++ b/frontend/src/components/weather/services/khoaApi.ts @@ -1,7 +1,7 @@ // KHOA (국립해양조사원) API 서비스 // 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 RECENT_OBS_URL = 'https://apis.data.go.kr/1192136/dtRecent/GetDTRecentApiService';