diff --git a/.claude/settings.json b/.claude/settings.json index 868df2d..441dc35 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,29 +5,7 @@ }, "permissions": { "allow": [ - "Bash(npm run *)", - "Bash(npm install *)", - "Bash(npm test *)", - "Bash(npx *)", - "Bash(node *)", - "Bash(git status)", - "Bash(git diff *)", - "Bash(git log *)", - "Bash(git branch *)", - "Bash(git checkout *)", - "Bash(git add *)", - "Bash(git commit *)", - "Bash(git pull *)", - "Bash(git fetch *)", - "Bash(git merge *)", - "Bash(git stash *)", - "Bash(git remote *)", - "Bash(git config *)", - "Bash(git rev-parse *)", - "Bash(git show *)", - "Bash(git tag *)", - "Bash(curl -s *)", - "Bash(fnm *)" + "Bash(*)" ], "deny": [ "Bash(git push --force*)", diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index 7af3ce8..03381a9 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,6 +1,6 @@ { "applied_global_version": "1.6.1", - "applied_date": "2026-03-30", + "applied_date": "2026-03-31", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true diff --git a/backend/src/routes/tiles.ts b/backend/src/routes/tiles.ts index 3b46048..0494823 100644 --- a/backend/src/routes/tiles.ts +++ b/backend/src/routes/tiles.ts @@ -3,6 +3,35 @@ import { Router } from 'express'; const router = Router(); const VWORLD_API_KEY = process.env.VWORLD_API_KEY || ''; +const ENC_UPSTREAM = 'https://tiles.gcnautical.com'; + +// ─── 공통 프록시 헬퍼 ─── + +async function proxyUpstream(upstreamUrl: string, res: import('express').Response, fallbackContentType = 'application/octet-stream') { + try { + const upstream = await fetch(upstreamUrl, { + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' }, + }); + + if (!upstream.ok) { + res.status(upstream.status).end(); + return; + } + + const contentType = upstream.headers.get('content-type') || fallbackContentType; + const cacheControl = upstream.headers.get('cache-control') || 'public, max-age=86400'; + + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', cacheControl); + + const buffer = await upstream.arrayBuffer(); + res.end(Buffer.from(buffer)); + } catch { + res.status(502).json({ error: '타일 서버 연결 실패' }); + } +} + +// ─── VWorld 위성타일 ─── // GET /api/tiles/vworld/:z/:y/:x — VWorld WMTS 위성타일 프록시 (CORS 우회) // VWorld는 브라우저 직접 요청에 CORS 헤더를 반환하지 않으므로 서버에서 중계 @@ -22,28 +51,56 @@ router.get('/vworld/:z/:y/:x', async (req, res) => { } const tileUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Satellite/${z}/${y}/${x}.jpeg`; + await proxyUpstream(tileUrl, res, 'image/jpeg'); +}); - try { - const upstream = await fetch(tileUrl, { - headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' }, - }); +// ─── S-57 전자해도 (ENC) ─── +// tiles.gcnautical.com CORS 제한 우회를 위한 프록시 엔드포인트 그룹 - if (!upstream.ok) { - res.status(upstream.status).end(); - return; - } +// GET /api/tiles/enc/style — 공식 style.json 프록시 +router.get('/enc/style', async (_req, res) => { + await proxyUpstream(`${ENC_UPSTREAM}/style/nautical`, res, 'application/json'); +}); - const contentType = upstream.headers.get('content-type') || 'image/jpeg'; - const cacheControl = upstream.headers.get('cache-control') || 'public, max-age=86400'; - - res.setHeader('Content-Type', contentType); - res.setHeader('Cache-Control', cacheControl); - - const buffer = await upstream.arrayBuffer(); - res.end(Buffer.from(buffer)); - } catch { - res.status(502).json({ error: 'VWorld 타일 서버 연결 실패' }); +// GET /api/tiles/enc/sprite/:file — sprite JSON/PNG 프록시 (sprite.json, sprite.png, sprite@2x.json, sprite@2x.png) +router.get('/enc/sprite/:file', async (req, res) => { + const { file } = req.params; + if (!/^sprite(@2x)?\.(json|png)$/.test(file)) { + res.status(400).json({ error: '잘못된 sprite 파일명' }); + return; } + const fallbackCt = file.endsWith('.png') ? 'image/png' : 'application/json'; + await proxyUpstream(`${ENC_UPSTREAM}/sprite/${file}`, res, fallbackCt); +}); + +// GET /api/tiles/enc/font/:fontstack/:range — glyphs(PBF) 프록시 +router.get('/enc/font/:fontstack/:range', async (req, res) => { + const { fontstack, range } = req.params; + if (!/^[\w\s%-]+$/.test(fontstack) || !/^\d+-\d+$/.test(range)) { + res.status(400).json({ error: '잘못된 폰트 요청' }); + return; + } + await proxyUpstream(`${ENC_UPSTREAM}/font/${fontstack}/${range}`, res, 'application/x-protobuf'); +}); + +// GET /api/tiles/enc/globe/:z/:x/:y — globe 벡터타일 프록시 (저줌 레벨용) +router.get('/enc/globe/:z/:x/:y', async (req, res) => { + const { z, x, y } = req.params; + if (!/^\d+$/.test(z) || !/^\d+$/.test(x) || !/^\d+$/.test(y)) { + res.status(400).json({ error: '잘못된 타일 좌표' }); + return; + } + await proxyUpstream(`${ENC_UPSTREAM}/globe/${z}/${x}/${y}`, res, 'application/x-protobuf'); +}); + +// GET /api/tiles/enc/:z/:x/:y — ENC 벡터타일 프록시 (표준 XYZ 순서) +router.get('/enc/:z/:x/:y', async (req, res) => { + const { z, x, y } = req.params; + if (!/^\d+$/.test(z) || !/^\d+$/.test(x) || !/^\d+$/.test(y)) { + res.status(400).json({ error: '잘못된 타일 좌표' }); + return; + } + await proxyUpstream(`${ENC_UPSTREAM}/enc/${z}/${x}/${y}`, res, 'application/x-protobuf'); }); export default router; diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 970b89f..de21d58 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -11,13 +11,25 @@ - 구조 탭: RescueView - 하드코딩 색상(#hex, rgba) → CSS 변수 전환, 그라데이션 → 단색, fontFamily/fontSize → Tailwind 토큰 +## [2026-04-01] + +### 수정 +- 지도: S57 ENC 오버레이 스타일 로드 완료 대기 후 레이어 추가 +- 지도: S57EncOverlay API URL을 공유 API_BASE_URL로 통합 +- 지도: S57 ENC sprite URL에 상대경로일 때 origin 프리픽스 추가 +- 지도: S57 ENC 오버레이 타일/sprite/glyphs URL을 절대경로로 변환 (운영환경 상대경로 대응) + ## [2026-03-31] ### 추가 +- 지도: S-57 전자해도(ENC) 오버레이 레이어 추가 +- 지도: 전체 탭 지도 배경 토글 통합 (S-57/3D/밝은테마/기본지도) +- 공통: useBaseMapStyle 훅 및 mapStyles 공유 모듈 추가 - 다크/라이트 테마 전환 기능 (TopBar 퀵메뉴에서 토글) - themeStore (Zustand) 테마 상태 관리 + localStorage 영속화 ### 변경 +- 지도: 초기 접속 시 기본지도(CartoDB Dark Matter) 표시로 변경 (S-57 기본 off) - 디자인 시스템 토큰 시맨틱 네이밍 전환 (하드코딩 색상 → CSS 변수) - PretendardGOV 폰트 적용 - 라이트 테마 CSS 변수 오버라이드 및 컴포넌트별 스타일 대응 diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 7255be6..7ecf92e 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -1,303 +1,54 @@ -import { useState, useMemo, useEffect, useCallback, useRef } from 'react'; -import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'; -import { MapboxOverlay } from '@deck.gl/mapbox'; -import { - ScatterplotLayer, - PathLayer, - TextLayer, - BitmapLayer, - PolygonLayer, - GeoJsonLayer, -} from '@deck.gl/layers'; -import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'; -import type { StyleSpecification } from 'maplibre-gl'; -import type { MapLayerMouseEvent } from 'maplibre-gl'; -import 'maplibre-gl/dist/maplibre-gl.css'; -import { layerDatabase } from '@common/services/layerService'; -import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView'; -import type { - HydrDataStep, - SensitiveResourceFeatureCollection, -} from '@tabs/prediction/services/predictionApi'; -import HydrParticleOverlay from './HydrParticleOverlay'; -import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'; -import type { ReplayShip, CollisionEvent, BackwardParticleStep } from '@common/types/backtrack'; -import { createBacktrackLayers } from './BacktrackReplayOverlay'; -import { buildMeasureLayers } from './measureLayers'; -import { MeasureOverlay } from './MeasureOverlay'; -import { useMeasureTool } from '@common/hooks/useMeasureTool'; -import { hexToRgba } from './mapUtils'; -import { useMapStore } from '@common/store/mapStore'; +import { useState, useMemo, useEffect, useCallback, useRef } from 'react' +import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre' +import { MapboxOverlay } from '@deck.gl/mapbox' +import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer, GeoJsonLayer } from '@deck.gl/layers' +import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core' +import type { MapLayerMouseEvent } from 'maplibre-gl' +import 'maplibre-gl/dist/maplibre-gl.css' +import { layerDatabase } from '@common/services/layerService' +import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView' +import type { HydrDataStep, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi' +import HydrParticleOverlay from './HydrParticleOverlay' +import type { BoomLine, BoomLineCoord } from '@common/types/boomLine' +import type { ReplayShip, CollisionEvent, BackwardParticleStep } from '@common/types/backtrack' +import { createBacktrackLayers } from './BacktrackReplayOverlay' +import { buildMeasureLayers } from './measureLayers' +import { MeasureOverlay } from './MeasureOverlay' +import { useMeasureTool } from '@common/hooks/useMeasureTool' +import { hexToRgba } from './mapUtils' +import { S57EncOverlay } from './S57EncOverlay' +import { useMapStore } from '@common/store/mapStore' +import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle' -const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080'; +const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080' // 인천 송도 국제도시 -const DEFAULT_CENTER: [number, number] = [37.39, 126.64]; -const DEFAULT_ZOOM = 10; - -// CartoDB Dark Matter 스타일 -const BASE_STYLE: StyleSpecification = { - version: 8, - sources: { - 'carto-dark': { - type: 'raster', - tiles: [ - 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - ], - tileSize: 256, - attribution: - '© OpenStreetMap © CARTO', - }, - }, - layers: [ - { - id: 'carto-dark-layer', - type: 'raster', - source: 'carto-dark', - minzoom: 0, - maxzoom: 22, - }, - ], -}; - -// MarineTraffic 스타일 — 깔끔한 연한 회색 육지 + 흰색 바다 + 한글 라벨 -const LIGHT_STYLE: StyleSpecification = { - version: 8, - glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', - sources: { - 'ofm-chart': { - type: 'vector', - url: 'https://tiles.openfreemap.org/planet', - }, - }, - layers: [ - // ── 배경 = 육지 (연한 회색) ── - { - id: 'land-bg', - type: 'background', - paint: { 'background-color': '#e8e8e8' }, - }, - // ── 바다/호수/강 = water 레이어 (파란색) ── - { - id: 'water', - type: 'fill', - source: 'ofm-chart', - 'source-layer': 'water', - paint: { 'fill-color': '#a8cce0' }, - }, - // ── 주요 도로 (zoom 9+) ── - { - id: 'roads-major', - type: 'line', - source: 'ofm-chart', - 'source-layer': 'transportation', - minzoom: 9, - filter: ['in', 'class', 'motorway', 'trunk', 'primary'], - paint: { - 'line-color': '#c0c0c0', - 'line-width': ['interpolate', ['linear'], ['zoom'], 9, 0.4, 14, 1.5], - }, - }, - // ── 보조 도로 (zoom 12+) ── - { - id: 'roads-secondary', - type: 'line', - source: 'ofm-chart', - 'source-layer': 'transportation', - minzoom: 12, - filter: ['in', 'class', 'secondary', 'tertiary'], - paint: { - 'line-color': '#cccccc', - 'line-width': ['interpolate', ['linear'], ['zoom'], 12, 0.3, 14, 1], - }, - }, - // ── 건물 (zoom 14+) ── - { - id: 'buildings', - type: 'fill', - source: 'ofm-chart', - 'source-layer': 'building', - minzoom: 14, - paint: { 'fill-color': '#cbcbcb', 'fill-opacity': 0.5 }, - }, - // ── 국경선 ── - { - id: 'boundaries-country', - type: 'line', - source: 'ofm-chart', - 'source-layer': 'boundary', - filter: ['==', 'admin_level', 2], - paint: { 'line-color': '#999999', 'line-width': 1, 'line-dasharray': [4, 2] }, - }, - // ── 시도 경계 (zoom 5+) ── - { - id: 'boundaries-province', - type: 'line', - source: 'ofm-chart', - 'source-layer': 'boundary', - minzoom: 5, - filter: ['==', 'admin_level', 4], - paint: { 'line-color': '#bbbbbb', 'line-width': 0.5, 'line-dasharray': [3, 2] }, - }, - // ── 국가/시도 라벨 (한글) ── - { - id: 'place-labels-major', - type: 'symbol', - source: 'ofm-chart', - 'source-layer': 'place', - minzoom: 3, - filter: ['in', 'class', 'country', 'state'], - layout: { - 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], - 'text-font': ['Open Sans Bold'], - 'text-size': ['interpolate', ['linear'], ['zoom'], 3, 11, 8, 16], - 'text-max-width': 8, - }, - paint: { - 'text-color': '#555555', - 'text-halo-color': '#ffffff', - 'text-halo-width': 2, - }, - }, - { - id: 'place-labels-city', - type: 'symbol', - source: 'ofm-chart', - 'source-layer': 'place', - minzoom: 5, - filter: ['in', 'class', 'city', 'town'], - layout: { - 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], - 'text-font': ['Open Sans Regular'], - 'text-size': ['interpolate', ['linear'], ['zoom'], 5, 10, 12, 14], - 'text-max-width': 7, - }, - paint: { - 'text-color': '#666666', - 'text-halo-color': '#ffffff', - 'text-halo-width': 1.5, - }, - }, - // ── 해양 지명 (water_name) ── - { - id: 'water-labels', - type: 'symbol', - source: 'ofm-chart', - 'source-layer': 'water_name', - layout: { - 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], - 'text-font': ['Open Sans Italic'], - 'text-size': ['interpolate', ['linear'], ['zoom'], 3, 10, 8, 14], - 'text-max-width': 10, - 'text-letter-spacing': 0.15, - }, - paint: { - 'text-color': '#8899aa', - 'text-halo-color': 'rgba(168,204,224,0.7)', - 'text-halo-width': 1, - }, - }, - // ── 마을/소지명 (zoom 10+) ── - { - id: 'place-labels-village', - type: 'symbol', - source: 'ofm-chart', - 'source-layer': 'place', - minzoom: 10, - filter: ['in', 'class', 'village', 'suburb', 'hamlet'], - layout: { - 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], - 'text-font': ['Open Sans Regular'], - 'text-size': ['interpolate', ['linear'], ['zoom'], 10, 9, 14, 12], - 'text-max-width': 6, - }, - paint: { - 'text-color': '#777777', - 'text-halo-color': '#ffffff', - 'text-halo-width': 1, - }, - }, - ], -}; - -// 3D 위성 스타일 (VWorld 위성 배경 + OpenFreeMap 건물 extrusion) -// VWorld WMTS: {z}/{y}/{x} (row/col 순서) -// OpenFreeMap: CORS 허용, 전 세계 OSM 벡터타일 (building: render_height 포함) -const SATELLITE_3D_STYLE: StyleSpecification = { - version: 8, - sources: { - 'vworld-satellite': { - type: 'raster', - tiles: ['/api/tiles/vworld/{z}/{y}/{x}.jpeg'], - tileSize: 256, - attribution: '© 국토지리정보원 VWorld', - }, - ofm: { - type: 'vector', - url: 'https://tiles.openfreemap.org/planet', - }, - }, - layers: [ - { - id: 'satellite-base', - type: 'raster', - source: 'vworld-satellite', - minzoom: 0, - maxzoom: 22, - }, - { - id: 'roads-3d', - type: 'line', - source: 'ofm', - 'source-layer': 'transportation', - filter: ['in', ['get', 'class'], ['literal', ['motorway', 'trunk', 'primary', 'secondary']]], - paint: { - 'line-color': 'rgba(255,255,200,0.3)', - 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1, 16, 3], - }, - }, - { - id: '3d-buildings', - type: 'fill-extrusion', - source: 'ofm', - 'source-layer': 'building', - minzoom: 13, - filter: ['!=', ['get', 'hide_3d'], true], - paint: { - 'fill-extrusion-color': '#c8b99a', - 'fill-extrusion-height': ['max', ['coalesce', ['get', 'render_height'], 0], 3], - 'fill-extrusion-base': ['coalesce', ['get', 'render_min_height'], 0], - 'fill-extrusion-opacity': 0.85, - }, - }, - ], -}; +const DEFAULT_CENTER: [number, number] = [37.39, 126.64] +const DEFAULT_ZOOM = 10 // 모델별 색상 매핑 const MODEL_COLORS: Record = { - KOSPS: '#06b6d4', - POSEIDON: '#ef4444', - OpenDrift: '#3b82f6', -}; + 'KOSPS': '#06b6d4', + 'POSEIDON': '#ef4444', + 'OpenDrift': '#3b82f6', +} // 오일펜스 우선순위별 색상/두께 const PRIORITY_COLORS: Record = { - CRITICAL: '#ef4444', - HIGH: '#f97316', - MEDIUM: '#eab308', -}; + 'CRITICAL': '#ef4444', + 'HIGH': '#f97316', + 'MEDIUM': '#eab308', +} const PRIORITY_WEIGHTS: Record = { - CRITICAL: 4, - HIGH: 3, - MEDIUM: 2, -}; + 'CRITICAL': 4, + 'HIGH': 3, + 'MEDIUM': 2, +} const PRIORITY_LABELS: Record = { - CRITICAL: '긴급', - HIGH: '중요', - MEDIUM: '보통', -}; + 'CRITICAL': '긴급', + 'HIGH': '중요', + 'MEDIUM': '보통', +} function hslToRgb(h: number, s: number, l: number): [number, number, number] { const a = s * Math.min(l, 1 - l); @@ -318,136 +69,118 @@ function categoryToRgb(category: string): [number, number, number] { } const SENSITIVE_COLORS: Record = { - aquaculture: '#22c55e', - beach: '#0ea5e9', - ecology: '#eab308', - intake: '#a855f7', -}; + 'aquaculture': '#22c55e', + 'beach': '#0ea5e9', + 'ecology': '#eab308', + 'intake': '#a855f7', +} const SENSITIVE_ICONS: Record = { - aquaculture: '🐟', - beach: '🏖', - ecology: '🦅', - intake: '🚰', -}; + 'aquaculture': '🐟', + 'beach': '🏖', + 'ecology': '🦅', + 'intake': '🚰', +} interface DispersionZone { - level: string; - color: string; - radius: number; - angle: number; + level: string + color: string + radius: number + angle: number } interface DispersionResult { - zones: DispersionZone[]; - timestamp: string; - windDirection: number; - substance: string; - concentration: Record; + zones: DispersionZone[] + timestamp: string + windDirection: number + substance: string + concentration: Record } interface MapViewProps { - center?: [number, number]; - zoom?: number; - enabledLayers?: Set; - incidentCoord?: { lon: number; lat: number }; - isSelectingLocation?: boolean; - onMapClick?: (lon: number, lat: number) => void; - oilTrajectory?: Array<{ - lat: number; - lon: number; - time: number; - particle?: number; - model?: string; - stranded?: 0 | 1; - }>; - selectedModels?: Set; - dispersionResult?: DispersionResult | null; - dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>; - boomLines?: BoomLine[]; - isDrawingBoom?: boolean; - drawingPoints?: BoomLineCoord[]; - layerOpacity?: number; - layerBrightness?: number; + center?: [number, number] + zoom?: number + enabledLayers?: Set + incidentCoord?: { lon: number; lat: number } + isSelectingLocation?: boolean + onMapClick?: (lon: number, lat: number) => void + oilTrajectory?: Array<{ lat: number; lon: number; time: number; particle?: number; model?: string; stranded?: 0 | 1 }> + selectedModels?: Set + dispersionResult?: DispersionResult | null + dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }> + boomLines?: BoomLine[] + isDrawingBoom?: boolean + drawingPoints?: BoomLineCoord[] + layerOpacity?: number + layerBrightness?: number backtrackReplay?: { - isActive: boolean; - ships: ReplayShip[]; - collisionEvent: CollisionEvent | null; - replayFrame: number; - totalFrames: number; - incidentCoord: { lat: number; lon: number }; - backwardParticles?: BackwardParticleStep[]; - }; - sensitiveResources?: SensitiveResource[]; - sensitiveResourceGeojson?: SensitiveResourceFeatureCollection | null; - flyToTarget?: { lng: number; lat: number; zoom?: number } | null; - fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null; - centerPoints?: Array<{ lat: number; lon: number; time: number; model?: string }>; - windData?: Array>; - hydrData?: (HydrDataStep | null)[]; + isActive: boolean + ships: ReplayShip[] + collisionEvent: CollisionEvent | null + replayFrame: number + totalFrames: number + incidentCoord: { lat: number; lon: number } + backwardParticles?: BackwardParticleStep[] + } + sensitiveResources?: SensitiveResource[] + sensitiveResourceGeojson?: SensitiveResourceFeatureCollection | null + flyToTarget?: { lng: number; lat: number; zoom?: number } | null + fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null + centerPoints?: Array<{ lat: number; lon: number; time: number; model?: string }> + windData?: Array> + hydrData?: (HydrDataStep | null)[] // 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용) - externalCurrentTime?: number; - mapCaptureRef?: React.MutableRefObject<(() => Promise) | null>; - onIncidentFlyEnd?: () => void; - flyToIncident?: { lon: number; lat: number }; - showCurrent?: boolean; - showWind?: boolean; - showBeached?: boolean; - showTimeLabel?: boolean; - simulationStartTime?: string; - drawAnalysisMode?: 'polygon' | 'circle' | null; - analysisPolygonPoints?: Array<{ lat: number; lon: number }>; - analysisCircleCenter?: { lat: number; lon: number } | null; - analysisCircleRadiusM?: number; + externalCurrentTime?: number + mapCaptureRef?: React.MutableRefObject<(() => Promise) | null> + onIncidentFlyEnd?: () => void + flyToIncident?: { lon: number; lat: number } + showCurrent?: boolean + showWind?: boolean + showBeached?: boolean + showTimeLabel?: boolean + simulationStartTime?: string + drawAnalysisMode?: 'polygon' | 'circle' | null + analysisPolygonPoints?: Array<{ lat: number; lon: number }> + analysisCircleCenter?: { lat: number; lon: number } | null + analysisCircleRadiusM?: number /** 밝은 톤 지도 스타일 사용 (CartoDB Positron) */ - lightMode?: boolean; + lightMode?: boolean /** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */ - showOverlays?: boolean; + showOverlays?: boolean } // deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved) // eslint-disable-next-line @typescript-eslint/no-explicit-any function DeckGLOverlay({ layers }: { layers: any[] }) { - const overlay = useControl(() => new MapboxOverlay({ interleaved: true })); - overlay.setProps({ layers }); - return null; + const overlay = useControl(() => new MapboxOverlay({ interleaved: true })) + overlay.setProps({ layers }) + return null } // flyTo 트리거 컴포넌트 (Map 내부에서 useMap() 사용) -function FlyToController({ - flyToTarget, -}: { - flyToTarget?: { lng: number; lat: number; zoom?: number } | null; -}) { - const { current: map } = useMap(); +function FlyToController({ flyToTarget }: { flyToTarget?: { lng: number; lat: number; zoom?: number } | null }) { + const { current: map } = useMap() useEffect(() => { - if (!map || !flyToTarget) return; + if (!map || !flyToTarget) return map.flyTo({ center: [flyToTarget.lng, flyToTarget.lat], zoom: flyToTarget.zoom ?? 10, duration: 1200, - }); - }, [flyToTarget, map]); - return null; + }) + }, [flyToTarget, map]) + return null } // fitBounds 트리거 컴포넌트 (Map 내부에서 useMap() 사용) -function FitBoundsController({ - fitBoundsTarget, -}: { - fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null; -}) { - const { current: map } = useMap(); +function FitBoundsController({ fitBoundsTarget }: { fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null }) { + const { current: map } = useMap() useEffect(() => { - if (!map || !fitBoundsTarget) return; + if (!map || !fitBoundsTarget) return map.fitBounds( - [ - [fitBoundsTarget.west, fitBoundsTarget.south], - [fitBoundsTarget.east, fitBoundsTarget.north], - ], - { padding: 80, duration: 1200, maxZoom: 12 }, - ); - }, [fitBoundsTarget, map]); - return null; + [[fitBoundsTarget.west, fitBoundsTarget.south], [fitBoundsTarget.east, fitBoundsTarget.north]], + { padding: 80, duration: 1200, maxZoom: 12 } + ) + }, [fitBoundsTarget, map]) + return null } // Map 중앙 좌표 + 줌 추적 컴포넌트 (Map 내부에서 useMap() 사용) @@ -456,81 +189,71 @@ function MapCenterTracker({ }: { onCenterChange: (lat: number, lng: number, zoom: number) => void; }) { - const { current: map } = useMap(); + const { current: map } = useMap() useEffect(() => { - if (!map) return; + if (!map) return const update = () => { - const center = map.getCenter(); - const zoom = map.getZoom(); - onCenterChange(center.lat, center.lng, zoom); - }; + const center = map.getCenter() + const zoom = map.getZoom() + onCenterChange(center.lat, center.lng, zoom) + } - update(); - map.on('move', update); - map.on('zoom', update); + update() + map.on('move', update) + map.on('zoom', update) return () => { - map.off('move', update); - map.off('zoom', update); - }; - }, [map, onCenterChange]); + map.off('move', update) + map.off('zoom', update) + } + }, [map, onCenterChange]) - return null; + return null } // 3D 모드 pitch/bearing 제어 컴포넌트 (Map 내부에서 useMap() 사용) function MapPitchController({ threeD }: { threeD: boolean }) { - const { current: map } = useMap(); + const { current: map } = useMap() useEffect(() => { - if (!map) return; + if (!map) return map.easeTo( - threeD ? { pitch: 45, bearing: -17, duration: 800 } : { pitch: 0, bearing: 0, duration: 800 }, - ); - }, [threeD, map]); - return null; + threeD + ? { pitch: 45, bearing: -17, duration: 800 } + : { pitch: 0, bearing: 0, duration: 800 } + ) + }, [threeD, map]) + return null } // 사고 지점 변경 시 지도 이동 (Map 내부 컴포넌트) -function MapFlyToIncident({ - coord, - onFlyEnd, -}: { - coord?: { lon: number; lat: number }; - onFlyEnd?: () => void; -}) { - const { current: map } = useMap(); - const onFlyEndRef = useRef(onFlyEnd); - useEffect(() => { - onFlyEndRef.current = onFlyEnd; - }, [onFlyEnd]); +function MapFlyToIncident({ coord, onFlyEnd }: { coord?: { lon: number; lat: number }; onFlyEnd?: () => void }) { + const { current: map } = useMap() + const onFlyEndRef = useRef(onFlyEnd) + useEffect(() => { onFlyEndRef.current = onFlyEnd }, [onFlyEnd]) useEffect(() => { - if (!map || !coord) return; + if (!map || !coord) return - const { lon, lat } = coord; + const { lon, lat } = coord const doFly = () => { - map.flyTo({ center: [lon, lat], zoom: 11, duration: 1200 }); - map.once('moveend', () => onFlyEndRef.current?.()); - }; + map.flyTo({ center: [lon, lat], zoom: 11, duration: 1200 }) + map.once('moveend', () => onFlyEndRef.current?.()) + } if (map.loaded()) { - doFly(); + doFly() } else { - map.once('load', doFly); + map.once('load', doFly) } - }, [coord, map]); // 객체 참조 추적: 같은 좌표라도 새 객체면 effect 재실행 + }, [coord, map]) // 객체 참조 추적: 같은 좌표라도 새 객체면 effect 재실행 - return null; + return null } // 지도 캡처 지원 (map.once('render') 후 캡처로 빈 캔버스 문제 방지) -function MapCaptureSetup({ - captureRef, -}: { - captureRef: React.MutableRefObject<(() => Promise) | null>; -}) { +function MapCaptureSetup({ captureRef }: { captureRef: React.MutableRefObject<(() => Promise) | null> }) { const { current: map } = useMap(); useEffect(() => { if (!map) return; @@ -547,9 +270,7 @@ function MapCaptureSetup({ composite.width = Math.round(src.width * scale); composite.height = Math.round(src.height * scale); const ctx = composite.getContext('2d')!; - ctx.fillStyle = - getComputedStyle(document.documentElement).getPropertyValue('--bg-base').trim() || - '#0f1117'; + ctx.fillStyle = '#0f1117'; ctx.fillRect(0, 0, composite.width, composite.height); ctx.drawImage(src, 0, 0, composite.width, composite.height); resolve(composite.toDataURL('image/jpeg', 0.82)); @@ -565,9 +286,9 @@ function MapCaptureSetup({ // 팝업 정보 interface PopupInfo { - longitude: number; - latitude: number; - content: React.ReactNode; + longitude: number + latitude: number + content: React.ReactNode } export function MapView({ @@ -610,139 +331,135 @@ export function MapView({ lightMode = false, showOverlays = true, }: MapViewProps) { - const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore(); - const { handleMeasureClick } = useMeasureTool(); - const isControlled = externalCurrentTime !== undefined; - const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER); - const [mapCenter, setMapCenter] = useState<[number, number]>(DEFAULT_CENTER); - const [mapZoom, setMapZoom] = useState(DEFAULT_ZOOM); - const [internalCurrentTime, setInternalCurrentTime] = useState(0); - const [isPlaying, setIsPlaying] = useState(false); - const [playbackSpeed, setPlaybackSpeed] = useState(1); - const [popupInfo, setPopupInfo] = useState(null); + const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore() + const { handleMeasureClick } = useMeasureTool() + const isControlled = externalCurrentTime !== undefined + const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER) + const [mapCenter, setMapCenter] = useState<[number, number]>(DEFAULT_CENTER) + const [mapZoom, setMapZoom] = useState(DEFAULT_ZOOM) + const [internalCurrentTime, setInternalCurrentTime] = useState(0) + const [isPlaying, setIsPlaying] = useState(false) + const [playbackSpeed, setPlaybackSpeed] = useState(1) + const [popupInfo, setPopupInfo] = useState(null) // deck.gl 레이어 클릭 시 MapLibre 맵 클릭 핸들러 차단용 플래그 (민감자원 등) - const deckClickHandledRef = useRef(false); + const deckClickHandledRef = useRef(false) // 클릭으로 열린 팝업(닫기 전까지 유지) 추적 — 호버 핸들러가 닫지 않도록 방지 - const persistentPopupRef = useRef(false); + const persistentPopupRef = useRef(false) // 현재 호버 중인 민감자원 feature properties (handleMapClick에서 팝업 생성에 사용) - const hoveredSensitiveRef = useRef | null>(null); - const currentTime = isControlled ? externalCurrentTime : internalCurrentTime; + const hoveredSensitiveRef = useRef | null>(null) + const currentTime = isControlled ? externalCurrentTime : internalCurrentTime const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => { - setMapCenter([lat, lng]); - setMapZoom(zoom); - }, []); + setMapCenter([lat, lng]) + setMapZoom(zoom) + }, []) - const handleMapClick = useCallback( - (e: MapLayerMouseEvent) => { - const { lng, lat } = e.lngLat; - setCurrentPosition([lat, lng]); - // deck.gl 다른 레이어 onClick이 처리한 클릭 — 팝업 유지 - if (deckClickHandledRef.current) { - deckClickHandledRef.current = false; - return; - } - // 민감자원 hover 중이면 팝업 표시 - if (hoveredSensitiveRef.current) { - const props = hoveredSensitiveRef.current; - const { category, ...rest } = props; - const entries = Object.entries(rest).filter( - ([k, v]) => k !== 'srId' && v !== null && v !== undefined && v !== '', - ); - persistentPopupRef.current = true; - setPopupInfo({ - longitude: lng, - latitude: lat, - content: ( -
-
- {String(category ?? '민감자원')} -
- {entries.length > 0 ? ( -
- {entries.map(([key, val]) => ( -
- {key} - - {typeof val === 'object' ? JSON.stringify(val) : String(val)} - -
- ))} -
- ) : ( -

상세 정보 없음

- )} + const handleMapClick = useCallback((e: MapLayerMouseEvent) => { + const { lng, lat } = e.lngLat + setCurrentPosition([lat, lng]) + // deck.gl 다른 레이어 onClick이 처리한 클릭 — 팝업 유지 + if (deckClickHandledRef.current) { + deckClickHandledRef.current = false + return + } + // 민감자원 hover 중이면 팝업 표시 + if (hoveredSensitiveRef.current) { + const props = hoveredSensitiveRef.current + const { category, ...rest } = props + const entries = Object.entries(rest).filter(([k, v]) => k !== 'srId' && v !== null && v !== undefined && v !== '') + persistentPopupRef.current = true + setPopupInfo({ + longitude: lng, + latitude: lat, + content: ( +
+
+ {String(category ?? '민감자원')}
- ), - }); - return; - } - if (measureMode !== null) { - handleMeasureClick(lng, lat); - return; - } - if (onMapClick) { - onMapClick(lng, lat); - } - setPopupInfo(null); - }, - [onMapClick, measureMode, handleMeasureClick], - ); + {entries.length > 0 ? ( +
+ {entries.map(([key, val]) => ( +
+ {key} + + {typeof val === 'object' ? JSON.stringify(val) : String(val)} + +
+ ))} +
+ ) : ( +

상세 정보 없음

+ )} +
+ ), + }) + return + } + if (measureMode !== null) { + handleMeasureClick(lng, lat) + return + } + if (onMapClick) { + onMapClick(lng, lat) + } + setPopupInfo(null) + }, [onMapClick, measureMode, handleMeasureClick]) // 애니메이션 재생 로직 (외부 제어 모드에서는 비활성) useEffect(() => { - if (isControlled || !isPlaying || oilTrajectory.length === 0) return; + if (isControlled || !isPlaying || oilTrajectory.length === 0) return - const maxTime = Math.max(...oilTrajectory.map((p) => p.time)); + const maxTime = Math.max(...oilTrajectory.map(p => p.time)) if (internalCurrentTime >= maxTime) { - setIsPlaying(false); - return; + setIsPlaying(false) + return } const interval = setInterval(() => { - setInternalCurrentTime((prev) => { - const next = prev + 1 * playbackSpeed; - return next > maxTime ? maxTime : next; - }); - }, 200); + setInternalCurrentTime(prev => { + const next = prev + (1 * playbackSpeed) + return next > maxTime ? maxTime : next + }) + }, 200) - return () => clearInterval(interval); - }, [isControlled, isPlaying, internalCurrentTime, playbackSpeed, oilTrajectory]); + return () => clearInterval(interval) + }, [isControlled, isPlaying, internalCurrentTime, playbackSpeed, oilTrajectory]) // 시뮬레이션 시작 시 자동으로 애니메이션 재생 (외부 제어 모드에서는 비활성) useEffect(() => { - if (isControlled) return; + if (isControlled) return if (oilTrajectory.length > 0) { - setInternalCurrentTime(0); - setIsPlaying(true); + setInternalCurrentTime(0) + setIsPlaying(true) } - }, [isControlled, oilTrajectory.length]); + }, [isControlled, oilTrajectory.length]) // WMS 레이어 목록 const wmsLayers = useMemo(() => { return Array.from(enabledLayers) - .map((layerId) => { - const layer = layerDatabase.find((l) => l.id === layerId); - return layer?.wmsLayer ? { id: layerId, wmsLayer: layer.wmsLayer } : null; + .map(layerId => { + const layer = layerDatabase.find(l => l.id === layerId) + return layer?.wmsLayer ? { id: layerId, wmsLayer: layer.wmsLayer } : null }) - .filter((l): l is { id: string; wmsLayer: string } => l !== null); - }, [enabledLayers]); + .filter((l): l is { id: string; wmsLayer: string } => l !== null) + }, [enabledLayers]) // WMS 밝기 값 (MapLibre raster paint) - const wmsBrightnessMax = Math.min(layerBrightness / 50, 2); - const wmsBrightnessMin = wmsBrightnessMax > 1 ? wmsBrightnessMax - 1 : 0; - const wmsOpacity = layerOpacity / 100; + const wmsBrightnessMax = Math.min(layerBrightness / 50, 2) + const wmsBrightnessMin = wmsBrightnessMax > 1 ? wmsBrightnessMax - 1 : 0 + const wmsOpacity = layerOpacity / 100 // deck.gl 레이어 구축 // eslint-disable-next-line @typescript-eslint/no-explicit-any const deckLayers = useMemo((): any[] => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result: any[] = []; + const result: any[] = [] // --- 유류 확산 입자 (ScatterplotLayer) --- - const visibleParticles = oilTrajectory.filter((p) => p.time <= currentTime); - const activeStep = - visibleParticles.length > 0 ? Math.max(...visibleParticles.map((p) => p.time)) : -1; + const visibleParticles = oilTrajectory.filter(p => p.time <= currentTime) + const activeStep = visibleParticles.length > 0 + ? Math.max(...visibleParticles.map(p => p.time)) + : -1 if (visibleParticles.length > 0) { result.push( new ScatterplotLayer({ @@ -751,34 +468,29 @@ export function MapView({ getPosition: (d: (typeof visibleParticles)[0]) => [d.lon, d.lat], getRadius: 3, getFillColor: (d: (typeof visibleParticles)[0]) => { - const modelKey = (d.model || - Array.from(selectedModels)[0] || - 'OpenDrift') as PredictionModel; + const modelKey = (d.model || Array.from(selectedModels)[0] || 'OpenDrift') as PredictionModel // 1순위: stranded 입자 → showBeached=true 시 모델 색, false 시 회색 - if (d.stranded === 1) - return showBeached - ? hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180) - : ([130, 130, 130, 70] as [number, number, number, number]); + if (d.stranded === 1) return showBeached + ? hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180) + : [130, 130, 130, 70] as [number, number, number, number] // 2순위: 현재 활성 스텝 → 모델 기본 색상 - if (d.time === activeStep) return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180); + if (d.time === activeStep) return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180) // 3순위: 과거 스텝 → 회색 + 투명 - return [130, 130, 130, 70] as [number, number, number, number]; + return [130, 130, 130, 70] as [number, number, number, number] }, radiusMinPixels: 2.5, radiusMaxPixels: 5, pickable: true, onClick: (info: PickingInfo) => { if (info.object) { - const d = info.object as (typeof visibleParticles)[0]; - const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift'; + const d = info.object as (typeof visibleParticles)[0] + const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift' setPopupInfo({ longitude: d.lon, latitude: d.lat, content: (
- - {modelKey} 입자 #{(d.particle ?? 0) + 1} - + {modelKey} 입자 #{(d.particle ?? 0) + 1} {d.stranded === 1 && (육지 부착)}
시간: +{d.time}h @@ -786,18 +498,18 @@ export function MapView({ 위치: {d.lat.toFixed(4)}°, {d.lon.toFixed(4)}°
), - }); + }) } }, updateTriggers: { getFillColor: [selectedModels, currentTime, showBeached], }, - }), - ); + }) + ) } // --- 육지부착 hollow ring (stranded 모양 구분) --- - const strandedParticles = showBeached ? visibleParticles.filter((p) => p.stranded === 1) : []; + const strandedParticles = showBeached ? visibleParticles.filter(p => p.stranded === 1) : [] if (strandedParticles.length > 0) { result.push( new ScatterplotLayer({ @@ -807,10 +519,8 @@ export function MapView({ stroked: true, filled: false, getLineColor: (d: (typeof strandedParticles)[0]) => { - const modelKey = (d.model || - Array.from(selectedModels)[0] || - 'OpenDrift') as PredictionModel; - return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 255); + const modelKey = (d.model || Array.from(selectedModels)[0] || 'OpenDrift') as PredictionModel + return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 255) }, lineWidthMinPixels: 2, getRadius: 4, @@ -819,8 +529,8 @@ export function MapView({ updateTriggers: { getLineColor: [selectedModels], }, - }), - ); + }) + ) } // --- 오일펜스 라인 (PathLayer) --- @@ -829,17 +539,17 @@ export function MapView({ new PathLayer({ id: 'boom-lines', data: boomLines, - getPath: (d: BoomLine) => d.coords.map((c) => [c.lon, c.lat] as [number, number]), + getPath: (d: BoomLine) => d.coords.map(c => [c.lon, c.lat] as [number, number]), getColor: (d: BoomLine) => hexToRgba(PRIORITY_COLORS[d.priority] || '#f59e0b', 230), getWidth: (d: BoomLine) => PRIORITY_WEIGHTS[d.priority] || 2, - getDashArray: (d: BoomLine) => (d.status === 'PLANNED' ? [10, 5] : [0, 0]), + getDashArray: (d: BoomLine) => d.status === 'PLANNED' ? [10, 5] : [0, 0], dashJustified: true, widthMinPixels: 2, widthMaxPixels: 6, pickable: true, onClick: (info: PickingInfo) => { if (info.object) { - const d = info.object as BoomLine; + const d = info.object as BoomLine setPopupInfo({ longitude: info.coordinate?.[0] ?? 0, latitude: info.coordinate?.[1] ?? 0, @@ -856,30 +566,21 @@ export function MapView({ 차단 효율: {d.efficiency}%
), - }); + }) } }, - }), - ); + }) + ) // 오일펜스 끝점 마커 - const endpoints: Array<{ - position: [number, number]; - color: [number, number, number, number]; - }> = []; - boomLines.forEach((line) => { + const endpoints: Array<{ position: [number, number]; color: [number, number, number, number] }> = [] + boomLines.forEach(line => { if (line.coords.length >= 2) { - const c = hexToRgba(PRIORITY_COLORS[line.priority] || '#f59e0b', 230); - endpoints.push({ position: [line.coords[0].lon, line.coords[0].lat], color: c }); - endpoints.push({ - position: [ - line.coords[line.coords.length - 1].lon, - line.coords[line.coords.length - 1].lat, - ], - color: c, - }); + const c = hexToRgba(PRIORITY_COLORS[line.priority] || '#f59e0b', 230) + endpoints.push({ position: [line.coords[0].lon, line.coords[0].lat], color: c }) + endpoints.push({ position: [line.coords[line.coords.length - 1].lon, line.coords[line.coords.length - 1].lat], color: c }) } - }); + }) if (endpoints.length > 0) { result.push( new ScatterplotLayer({ @@ -893,8 +594,8 @@ export function MapView({ stroked: true, radiusMinPixels: 5, radiusMaxPixels: 8, - }), - ); + }) + ) } } @@ -903,19 +604,19 @@ export function MapView({ result.push( new PathLayer({ id: 'drawing-preview', - data: [{ path: drawingPoints.map((c) => [c.lon, c.lat] as [number, number]) }], + data: [{ path: drawingPoints.map(c => [c.lon, c.lat] as [number, number]) }], getPath: (d: { path: [number, number][] }) => d.path, getColor: [245, 158, 11, 200], getWidth: 3, getDashArray: [10, 6], dashJustified: true, widthMinPixels: 3, - }), - ); + }) + ) result.push( new ScatterplotLayer({ id: 'drawing-points', - data: drawingPoints.map((c) => ({ position: [c.lon, c.lat] as [number, number] })), + data: drawingPoints.map(c => ({ position: [c.lon, c.lat] as [number, number] })), getPosition: (d: { position: [number, number] }) => d.position, getRadius: 4, getFillColor: [245, 158, 11, 255], @@ -924,8 +625,8 @@ export function MapView({ stroked: true, radiusMinPixels: 4, radiusMaxPixels: 6, - }), - ); + }) + ) } // --- 오염분석 다각형 그리기 --- @@ -934,9 +635,7 @@ export function MapView({ result.push( new PolygonLayer({ id: 'analysis-polygon-fill', - data: [ - { polygon: analysisPolygonPoints.map((p) => [p.lon, p.lat] as [number, number]) }, - ], + data: [{ polygon: analysisPolygonPoints.map(p => [p.lon, p.lat] as [number, number]) }], getPolygon: (d: { polygon: [number, number][] }) => d.polygon, getFillColor: [168, 85, 247, 40], getLineColor: [168, 85, 247, 220], @@ -944,41 +643,30 @@ export function MapView({ stroked: true, filled: true, lineWidthMinPixels: 2, - }), - ); + }) + ) } result.push( new PathLayer({ id: 'analysis-polygon-outline', - data: [ - { - path: [ - ...analysisPolygonPoints.map((p) => [p.lon, p.lat] as [number, number]), - ...(analysisPolygonPoints.length >= 3 - ? [ - [analysisPolygonPoints[0].lon, analysisPolygonPoints[0].lat] as [ - number, - number, - ], - ] - : []), - ], - }, - ], + data: [{ + path: [ + ...analysisPolygonPoints.map(p => [p.lon, p.lat] as [number, number]), + ...(analysisPolygonPoints.length >= 3 ? [[analysisPolygonPoints[0].lon, analysisPolygonPoints[0].lat] as [number, number]] : []), + ], + }], getPath: (d: { path: [number, number][] }) => d.path, getColor: [168, 85, 247, 220], getWidth: 2, getDashArray: [8, 4], dashJustified: true, widthMinPixels: 2, - }), - ); + }) + ) result.push( new ScatterplotLayer({ id: 'analysis-polygon-points', - data: analysisPolygonPoints.map((p) => ({ - position: [p.lon, p.lat] as [number, number], - })), + data: analysisPolygonPoints.map(p => ({ position: [p.lon, p.lat] as [number, number] })), getPosition: (d: { position: [number, number] }) => d.position, getRadius: 5, getFillColor: [168, 85, 247, 255], @@ -987,8 +675,8 @@ export function MapView({ stroked: true, radiusMinPixels: 5, radiusMaxPixels: 8, - }), - ); + }) + ) } // --- 오염분석 원 그리기 --- @@ -996,9 +684,7 @@ export function MapView({ result.push( new ScatterplotLayer({ id: 'analysis-circle-center', - data: [ - { position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }, - ], + data: [{ position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }], getPosition: (d: { position: [number, number] }) => d.position, getRadius: 6, getFillColor: [168, 85, 247, 255], @@ -1007,17 +693,13 @@ export function MapView({ stroked: true, radiusMinPixels: 6, radiusMaxPixels: 9, - }), - ); + }) + ) if (analysisCircleRadiusM > 0) { result.push( new ScatterplotLayer({ id: 'analysis-circle-area', - data: [ - { - position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number], - }, - ], + data: [{ position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }], getPosition: (d: { position: [number, number] }) => d.position, getRadius: analysisCircleRadiusM, radiusUnits: 'meters', @@ -1027,25 +709,21 @@ export function MapView({ stroked: true, filled: true, lineWidthMinPixels: 2, - }), - ); + }) + ) } } // --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) --- if (dispersionHeatmap && dispersionHeatmap.length > 0) { - const maxConc = Math.max(...dispersionHeatmap.map((p) => p.concentration)); - const minConc = Math.min( - ...dispersionHeatmap.filter((p) => p.concentration > 0.01).map((p) => p.concentration), - ); - const filtered = dispersionHeatmap.filter((p) => p.concentration > 0.01); + const maxConc = Math.max(...dispersionHeatmap.map(p => p.concentration)); + const minConc = Math.min(...dispersionHeatmap.filter(p => p.concentration > 0.01).map(p => p.concentration)); + const filtered = dispersionHeatmap.filter(p => p.concentration > 0.01); + console.log('[MapView] HNS 히트맵:', dispersionHeatmap.length, '→ filtered:', filtered.length, 'maxConc:', maxConc.toFixed(2)); if (filtered.length > 0) { // 경위도 바운드 계산 - let minLon = Infinity, - maxLon = -Infinity, - minLat = Infinity, - maxLat = -Infinity; + 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; @@ -1054,14 +732,11 @@ export function MapView({ } const padLon = (maxLon - minLon) * 0.02; const padLat = (maxLat - minLat) * 0.02; - minLon -= padLon; - maxLon += padLon; - minLat -= padLat; - maxLat += padLat; + minLon -= padLon; maxLon += padLon; + minLat -= padLat; maxLat += padLat; // 캔버스에 농도 이미지 렌더링 - const W = 1200, - H = 960; + const W = 1200, H = 960; const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = H; @@ -1074,11 +749,11 @@ export function MapView({ 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 (초고농도) + [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) { @@ -1102,11 +777,10 @@ export function MapView({ ctx.fill(); } - const imageUrl = canvas.toDataURL(); result.push( new BitmapLayer({ id: 'hns-dispersion-bitmap', - image: imageUrl, + image: canvas, bounds: [minLon, minLat, maxLon, maxLat], opacity: 1.0, pickable: false, @@ -1124,7 +798,7 @@ export function MapView({ lineColor: hexToRgba(zone.color, 180), level: zone.level, idx, - })); + })) result.push( new ScatterplotLayer({ @@ -1141,51 +815,28 @@ export function MapView({ autoHighlight: true, onHover: (info: PickingInfo) => { if (info.object && info.coordinate) { - const zoneAreas = zones.map((z) => ({ + const zoneAreas = zones.map(z => ({ level: z.level, - area: (Math.PI * z.radius * z.radius) / 1e6, + area: Math.PI * z.radius * z.radius / 1e6, })); - const totalArea = (Math.PI * Math.max(...zones.map((z) => z.radius)) ** 2) / 1e6; + const totalArea = Math.PI * Math.max(...zones.map(z => z.radius)) ** 2 / 1e6; setPopupInfo({ longitude: info.coordinate[0], latitude: info.coordinate[1], content: (
- - {dispersionResult.substance} 대기확산 면적 - + {dispersionResult.substance} 대기확산 면적 - {zoneAreas.map((z) => ( + {zoneAreas.map(z => ( - + ))} - - + +
{z.level} - {z.area.toFixed(3)} km² - {z.area.toFixed(3)} km²
- 총 면적 - - {totalArea.toFixed(3)} km² - 총 면적{totalArea.toFixed(3)} km²
@@ -1199,22 +850,20 @@ export function MapView({ } } }, - }), - ); + }) + ) } // --- 역추적 리플레이 --- if (backtrackReplay?.isActive) { - result.push( - ...createBacktrackLayers({ - replayShips: backtrackReplay.ships, - collisionEvent: backtrackReplay.collisionEvent, - replayFrame: backtrackReplay.replayFrame, - totalFrames: backtrackReplay.totalFrames, - incidentCoord: backtrackReplay.incidentCoord, - backwardParticles: backtrackReplay.backwardParticles, - }), - ); + result.push(...createBacktrackLayers({ + replayShips: backtrackReplay.ships, + collisionEvent: backtrackReplay.collisionEvent, + replayFrame: backtrackReplay.replayFrame, + totalFrames: backtrackReplay.totalFrames, + incidentCoord: backtrackReplay.incidentCoord, + backwardParticles: backtrackReplay.backwardParticles, + })) } // --- 민감자원 영역 (ScatterplotLayer) --- @@ -1225,17 +874,15 @@ export function MapView({ data: sensitiveResources, getPosition: (d: SensitiveResource) => [d.lon, d.lat], getRadius: (d: SensitiveResource) => d.radiusM, - getFillColor: (d: SensitiveResource) => - hexToRgba(SENSITIVE_COLORS[d.type] || '#22c55e', 40), - getLineColor: (d: SensitiveResource) => - hexToRgba(SENSITIVE_COLORS[d.type] || '#22c55e', 150), + getFillColor: (d: SensitiveResource) => hexToRgba(SENSITIVE_COLORS[d.type] || '#22c55e', 40), + getLineColor: (d: SensitiveResource) => hexToRgba(SENSITIVE_COLORS[d.type] || '#22c55e', 150), getLineWidth: 2, stroked: true, radiusUnits: 'meters' as const, pickable: true, onClick: (info: PickingInfo) => { if (info.object) { - const d = info.object as SensitiveResource; + const d = info.object as SensitiveResource setPopupInfo({ longitude: d.lon, latitude: d.lat, @@ -1247,18 +894,15 @@ export function MapView({
반경: {d.radiusM}m
- 도달 예상:{' '} - - {d.arrivalTimeH}h - + 도달 예상: {d.arrivalTimeH}h
), - }); + }) } }, - }), - ); + }) + ) // 민감자원 중심 마커 result.push( @@ -1267,15 +911,14 @@ export function MapView({ data: sensitiveResources, getPosition: (d: SensitiveResource) => [d.lon, d.lat], getRadius: 6, - getFillColor: (d: SensitiveResource) => - hexToRgba(SENSITIVE_COLORS[d.type] || '#22c55e', 220), + getFillColor: (d: SensitiveResource) => hexToRgba(SENSITIVE_COLORS[d.type] || '#22c55e', 220), getLineColor: [255, 255, 255, 200], getLineWidth: 2, stroked: true, radiusMinPixels: 6, radiusMaxPixels: 10, - }), - ); + }) + ) // 민감자원 라벨 result.push( @@ -1283,15 +926,9 @@ export function MapView({ id: 'sensitive-labels', data: sensitiveResources, getPosition: (d: SensitiveResource) => [d.lon, d.lat], - getText: (d: SensitiveResource) => - `${SENSITIVE_ICONS[d.type]} ${d.name} (${d.arrivalTimeH}h)`, + getText: (d: SensitiveResource) => `${SENSITIVE_ICONS[d.type]} ${d.name} (${d.arrivalTimeH}h)`, getSize: 12, - getColor: (lightMode ? [20, 40, 100, 240] : [255, 255, 255, 200]) as [ - number, - number, - number, - number, - ], + getColor: (lightMode ? [20, 40, 100, 240] : [255, 255, 255, 200]) as [number, number, number, number], getPixelOffset: [0, -20], fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif', fontWeight: 'bold', @@ -1300,8 +937,8 @@ export function MapView({ outlineColor: [15, 21, 36, 200], billboard: true, sizeUnits: 'pixels' as const, - }), - ); + }) + ) } // --- 민감자원 GeoJSON 레이어 --- @@ -1330,47 +967,40 @@ export function MapView({ }, onHover: (info: PickingInfo) => { if (info.object) { - hoveredSensitiveRef.current = - (info.object as { properties: Record | null }).properties ?? {}; + hoveredSensitiveRef.current = (info.object as { properties: Record | null }).properties ?? {} } else { - hoveredSensitiveRef.current = null; + hoveredSensitiveRef.current = null } }, - }) as unknown as DeckLayer, + }) as unknown as DeckLayer ); } // --- 입자 중심점 이동 경로 (모델별 PathLayer + ScatterplotLayer) --- - const visibleCenters = centerPoints.filter((p) => p.time <= currentTime); + const visibleCenters = centerPoints.filter(p => p.time <= currentTime) if (visibleCenters.length > 0) { // 모델별 그룹핑 (Record 사용 — Map 컴포넌트와 이름 충돌 회피) - const modelGroups: Record = {}; - visibleCenters.forEach((p) => { - const key = p.model || 'OpenDrift'; - if (!modelGroups[key]) modelGroups[key] = []; - modelGroups[key].push(p); - }); + const modelGroups: Record = {} + visibleCenters.forEach(p => { + const key = p.model || 'OpenDrift' + if (!modelGroups[key]) modelGroups[key] = [] + modelGroups[key].push(p) + }) Object.entries(modelGroups).forEach(([model, points]) => { - const modelColor = hexToRgba(MODEL_COLORS[model as PredictionModel] || '#06b6d4', 210); + const modelColor = hexToRgba(MODEL_COLORS[model as PredictionModel] || '#06b6d4', 210) if (points.length >= 2) { result.push( new PathLayer({ id: `center-path-${model}`, - data: [ - { - path: points.map( - (p: { lon: number; lat: number }) => [p.lon, p.lat] as [number, number], - ), - }, - ], + data: [{ path: points.map((p: { lon: number; lat: number }) => [p.lon, p.lat] as [number, number]) }], getPath: (d: { path: [number, number][] }) => d.path, getColor: modelColor, getWidth: 2, widthMinPixels: 2, widthMaxPixels: 4, - }), - ); + }) + ) } result.push( new ScatterplotLayer({ @@ -1382,8 +1012,8 @@ export function MapView({ radiusMinPixels: 4, radiusMaxPixels: 8, pickable: false, - }), - ); + }) + ) if (showTimeLabel) { const baseTime = simulationStartTime ? new Date(simulationStartTime) : null; const pad = (n: number) => String(n).padStart(2, '0'); @@ -1404,28 +1034,23 @@ export function MapView({ getPixelOffset: [0, 16] as [number, number], fontWeight: 'bold', outlineWidth: 2, - outlineColor: (lightMode ? [255, 255, 255, 180] : [15, 21, 36, 200]) as [ - number, - number, - number, - number, - ], + outlineColor: (lightMode ? [255, 255, 255, 180] : [15, 21, 36, 200]) as [number, number, number, number], billboard: true, sizeUnits: 'pixels' as const, updateTriggers: { getText: [simulationStartTime, currentTime], }, - }), - ); + }) + ) } - }); + }) } // --- 바람 화살표 (TextLayer) --- if (incidentCoord && windData.length > 0 && showWind) { - type ArrowPoint = { lon: number; lat: number; bearing: number; speed: number }; + type ArrowPoint = { lon: number; lat: number; bearing: number; speed: number } - const activeWindStep = windData[currentTime] ?? windData[0] ?? []; + const activeWindStep = windData[currentTime] ?? windData[0] ?? [] const currentArrows: ArrowPoint[] = activeWindStep .filter((d) => d.wind_speed != null && d.wind_direction != null) .map((d) => ({ @@ -1433,7 +1058,7 @@ export function MapView({ lat: d.lat, bearing: d.wind_direction, speed: d.wind_speed, - })); + })) result.push( new TextLayer({ @@ -1444,12 +1069,12 @@ export function MapView({ getAngle: (d: ArrowPoint) => -d.bearing + 90, getSize: 22, getColor: (d: ArrowPoint): [number, number, number, number] => { - const s = d.speed; - if (s < 3) return [6, 182, 212, 130]; // cyan-500: calm - if (s < 7) return [34, 197, 94, 150]; // green-500: light - if (s < 12) return [234, 179, 8, 170]; // yellow-500: moderate - if (s < 17) return [249, 115, 22, 190]; // orange-500: fresh - return [239, 68, 68, 210]; // red-500: strong + const s = d.speed + if (s < 3) return [6, 182, 212, 130] // cyan-500: calm + if (s < 7) return [34, 197, 94, 150] // green-500: light + if (s < 12) return [234, 179, 8, 170] // yellow-500: moderate + if (s < 17) return [249, 115, 22, 190] // orange-500: fresh + return [239, 68, 68, 210] // red-500: strong }, characterSet: 'auto', sizeUnits: 'pixels' as const, @@ -1458,45 +1083,25 @@ export function MapView({ getColor: [currentTime, windData], getAngle: [currentTime, windData], }, - }), - ); + }) + ) } // 거리/면적 측정 레이어 - result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements)); + result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements)) - return result.filter(Boolean); + return result.filter(Boolean) }, [ - oilTrajectory, - currentTime, - selectedModels, - boomLines, - isDrawingBoom, - drawingPoints, - dispersionResult, - dispersionHeatmap, - incidentCoord, - backtrackReplay, - sensitiveResources, - sensitiveResourceGeojson, - centerPoints, - windData, - showWind, - showBeached, - showTimeLabel, - simulationStartTime, - analysisPolygonPoints, - analysisCircleCenter, - analysisCircleRadiusM, - lightMode, - ]); + oilTrajectory, currentTime, selectedModels, + boomLines, isDrawingBoom, drawingPoints, + dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay, + sensitiveResources, sensitiveResourceGeojson, centerPoints, windData, + showWind, showBeached, showTimeLabel, simulationStartTime, + analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode, + ]) // 3D 모드 / 밝은 톤에 따른 지도 스타일 전환 - const currentMapStyle = mapToggles['threeD'] - ? SATELLITE_3D_STYLE - : lightMode - ? LIGHT_STYLE - : BASE_STYLE; + const currentMapStyle = useBaseMapStyle(lightMode) return (
@@ -1508,12 +1113,7 @@ export function MapView({ }} mapStyle={currentMapStyle} className="w-full h-full" - style={{ - cursor: - isSelectingLocation || drawAnalysisMode !== null || measureMode !== null - ? 'crosshair' - : 'grab', - }} + style={{ cursor: (isSelectingLocation || drawAnalysisMode !== null || measureMode !== null) ? 'crosshair' : 'grab' }} onClick={handleMapClick} attributionControl={false} preserveDrawingBuffer={true} @@ -1531,14 +1131,17 @@ export function MapView({ {/* 예측 완료 시 궤적 전체 범위로 fitBounds */} + {/* S-57 전자해도 오버레이 (공식 style.json 기반) */} + + {/* WMS 레이어 */} - {wmsLayers.map((layer) => ( + {wmsLayers.map(layer => ( @@ -1563,22 +1166,19 @@ export function MapView({ )} {/* 사고 위치 마커 (MapLibre Marker) */} - {incidentCoord && - !isNaN(incidentCoord.lat) && - !isNaN(incidentCoord.lon) && - !(dispersionHeatmap && dispersionHeatmap.length > 0) && ( - -
- - )} + {incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && !(dispersionHeatmap && dispersionHeatmap.length > 0) && ( + +
+ + )} {/* deck.gl 객체 클릭 팝업 */} {popupInfo && ( @@ -1587,8 +1187,8 @@ export function MapView({ latitude={popupInfo.latitude} anchor="bottom" onClose={() => { - persistentPopupRef.current = false; - setPopupInfo(null); + persistentPopupRef.current = false + setPopupInfo(null) }} >
{popupInfo.content}
@@ -1609,18 +1209,12 @@ export function MapView({
)} {drawAnalysisMode === 'polygon' && ( -
+
다각형 분석 모드 — 지도를 클릭하여 꼭짓점을 추가하세요 ({analysisPolygonPoints.length}개)
)} {drawAnalysisMode === 'circle' && ( -
+
{!analysisCircleCenter ? '원 분석 모드 — 중심점을 클릭하세요' : '반경 지점을 클릭하세요'}
)} @@ -1631,8 +1225,7 @@ export function MapView({ )} {measureMode === 'area' && (
- 면적 재기 — 꼭짓점을 클릭하세요 ({measureInProgress.length}개) - {measureInProgress.length >= 3 && ' → 첫 번째 점 근처를 클릭하면 닫힙니다'} + 면적 재기 — 꼭짓점을 클릭하세요 ({measureInProgress.length}개){measureInProgress.length >= 3 && ' → 첫 번째 점 근처를 클릭하면 닫힙니다'}
)} @@ -1640,24 +1233,19 @@ export function MapView({ {showOverlays && } {/* 범례 */} - {showOverlays && ( - - )} + {showOverlays && } {/* 좌표 표시 */} - {showOverlays && } + {showOverlays && } {/* 타임라인 컨트롤 (외부 제어 모드에서는 숨김 — 하단 플레이어가 대신 담당) */} {!isControlled && oilTrajectory.length > 0 && ( p.time))} + maxTime={Math.max(...oilTrajectory.map(p => p.time))} isPlaying={isPlaying} playbackSpeed={playbackSpeed} onTimeChange={setInternalCurrentTime} @@ -1675,172 +1263,141 @@ export function MapView({ /> )}
- ); + ) } // 지도 컨트롤 (줌, 위치 초기화) function MapControls({ center, zoom }: { center: [number, number]; zoom: number }) { - const { current: map } = useMap(); + const { current: map } = useMap() return (
- -
- ); + ) } // 지도 범례 interface MapLegendProps { - dispersionResult?: DispersionResult | null; - incidentCoord?: { lon: number; lat: number }; - oilTrajectory?: Array<{ lat: number; lon: number; time: number }>; - boomLines?: BoomLine[]; - selectedModels?: Set; + dispersionResult?: DispersionResult | null + incidentCoord?: { lon: number; lat: number } + oilTrajectory?: Array<{ lat: number; lon: number; time: number }> + boomLines?: BoomLine[] + selectedModels?: Set } -function MapLegend({ - dispersionResult, - incidentCoord, - oilTrajectory = [], - selectedModels = new Set(['OpenDrift'] as PredictionModel[]), -}: MapLegendProps) { - const [minimized, setMinimized] = useState(true); +function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) { + const [minimized, setMinimized] = useState(true) if (dispersionResult && incidentCoord) { return ( -
+
{/* 헤더 + 최소화 버튼 */} -
setMinimized(!minimized)} - > - - 범례 - - - {minimized ? '▶' : '▼'} - +
setMinimized(!minimized)}> + 범례 + {minimized ? '▶' : '▼'}
{!minimized && (
📍
-

사고 위치

-
+

사고 위치

+
{incidentCoord.lat.toFixed(4)}°N, {incidentCoord.lon.toFixed(4)}°E
-
+
물질 - {dispersionResult.substance} + {dispersionResult.substance}
풍향 - SW {dispersionResult.windDirection}° + SW {dispersionResult.windDirection}°
확산 구역 - - {dispersionResult.zones.length}개 - + {dispersionResult.zones.length}개
-
위험 구역
+
위험 구역
-
-
+
+
치명적 위험 구역 (AEGL-3)
-
-
+
+
높은 위험 구역 (AEGL-2)
-
-
+
+
중간 위험 구역 (AEGL-1)
-
-
🧭
- 풍향 (방사형) +
+
🧭
+ 풍향 (방사형)
)}
- ); + ) } if (oilTrajectory.length > 0) { return ( -
+
{/* 헤더 + 접기/펼치기 */}
setMinimized(!minimized)} > - 범례 - - {minimized ? '▶' : '▼'} - + 범례 + {minimized ? '▶' : '▼'}
{!minimized && (
{/* 모델별 색상 */} - {Array.from(selectedModels).map((model) => ( -
-
+ {Array.from(selectedModels).map(model => ( +
+
{model}
))} {/* 앙상블 */} -
+
앙상블
{/* 오일펜스 라인 */}
-
+
@@ -1851,142 +1408,109 @@ function MapLegend({ {/* 도달시간별 선종 */}
-
+
위험 (<6h)
-
+
경고 (6~12h)
-
+
주의 (12~24h)
-
+
안전
)}
- ); + ) } - return null; + return null } // 좌표 표시 function CoordinateDisplay({ position, zoom }: { position: [number, number]; zoom: number }) { - const [lat, lng] = position; - const latDirection = lat >= 0 ? 'N' : 'S'; - const lngDirection = lng >= 0 ? 'E' : 'W'; + const [lat, lng] = position + const latDirection = lat >= 0 ? 'N' : 'S' + const lngDirection = lng >= 0 ? 'E' : 'W' // MapLibre 줌 → 축척 변환 (96 DPI 기준) - const metersPerPixel = - (40075016.686 * Math.cos((lat * Math.PI) / 180)) / (256 * Math.pow(2, zoom)); - const scaleRatio = Math.round(metersPerPixel * (96 / 0.0254)); - const scaleLabel = - scaleRatio >= 1000000 - ? `1:${(scaleRatio / 1000000).toFixed(1)}M` - : `1:${scaleRatio.toLocaleString()}`; + const metersPerPixel = (40075016.686 * Math.cos((lat * Math.PI) / 180)) / (256 * Math.pow(2, zoom)) + const scaleRatio = Math.round(metersPerPixel * (96 / 0.0254)) + const scaleLabel = scaleRatio >= 1000000 + ? `1:${(scaleRatio / 1000000).toFixed(1)}M` + : `1:${scaleRatio.toLocaleString()}` return (
- - 위도{' '} - - {Math.abs(lat).toFixed(4)}°{latDirection} - - - - 경도{' '} - - {Math.abs(lng).toFixed(4)}°{lngDirection} - - - - 축척 {scaleLabel} - + 위도 {Math.abs(lat).toFixed(4)}°{latDirection} + 경도 {Math.abs(lng).toFixed(4)}°{lngDirection} + 축척 {scaleLabel}
- ); + ) } // 타임라인 컨트롤 interface TimelineControlProps { - currentTime: number; - maxTime: number; - isPlaying: boolean; - playbackSpeed: number; - onTimeChange: (time: number) => void; - onPlayPause: () => void; - onSpeedChange: (speed: number) => void; + currentTime: number + maxTime: number + isPlaying: boolean + playbackSpeed: number + onTimeChange: (time: number) => void + onPlayPause: () => void + onSpeedChange: (speed: number) => void } function TimelineControl({ - currentTime, - maxTime, - isPlaying, - playbackSpeed, - onTimeChange, - onPlayPause, - onSpeedChange, + currentTime, maxTime, isPlaying, playbackSpeed, + onTimeChange, onPlayPause, onSpeedChange }: TimelineControlProps) { - const progressPercent = (currentTime / maxTime) * 100; + const progressPercent = (currentTime / maxTime) * 100 - const handleRewind = () => onTimeChange(Math.max(0, currentTime - 6)); - const handleForward = () => onTimeChange(Math.min(maxTime, currentTime + 6)); - const handleStart = () => onTimeChange(0); - const handleEnd = () => onTimeChange(maxTime); + const handleRewind = () => onTimeChange(Math.max(0, currentTime - 6)) + const handleForward = () => onTimeChange(Math.min(maxTime, currentTime + 6)) + const handleStart = () => onTimeChange(0) + const handleEnd = () => onTimeChange(maxTime) const toggleSpeed = () => { - const speeds = [1, 2, 4]; - const currentIndex = speeds.indexOf(playbackSpeed); - onSpeedChange(speeds[(currentIndex + 1) % speeds.length]); - }; + const speeds = [1, 2, 4] + const currentIndex = speeds.indexOf(playbackSpeed) + onSpeedChange(speeds[(currentIndex + 1) % speeds.length]) + } const handleTimelineClick = (e: React.MouseEvent) => { - const rect = e.currentTarget.getBoundingClientRect(); - const percent = (e.clientX - rect.left) / rect.width; - onTimeChange(Math.max(0, Math.min(maxTime, Math.round(percent * maxTime)))); - }; + const rect = e.currentTarget.getBoundingClientRect() + const percent = (e.clientX - rect.left) / rect.width + onTimeChange(Math.max(0, Math.min(maxTime, Math.round(percent * maxTime)))) + } - const timeLabels = []; + const timeLabels = [] for (let t = 0; t <= maxTime; t += 6) { - timeLabels.push(t); + timeLabels.push(t) } return (
-
- ⏮ -
-
- ◀ -
+
+
{isPlaying ? '⏸' : '▶'}
-
- ▶▶ -
-
- ⏭ -
+
▶▶
+
-
- {playbackSpeed}× -
+
{playbackSpeed}×
- {timeLabels.map((t) => ( - + {timeLabels.map(t => ( + {t}h ))} @@ -1994,12 +1518,8 @@ function TimelineControl({
- {timeLabels.map((t) => ( -
+ {timeLabels.map(t => ( +
))}
@@ -2007,41 +1527,27 @@ function TimelineControl({
{/* eslint-disable-next-line react-hooks/purity */} -
- +{currentTime.toFixed(0)}h —{' '} - {(() => { - const base = simulationStartTime ? new Date(simulationStartTime) : new Date(); - const d = new Date(base.getTime() + currentTime * 3600 * 1000); - return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')} KST`; - })()} -
+
+{currentTime.toFixed(0)}h — {(() => { + const base = simulationStartTime ? new Date(simulationStartTime) : new Date(); + const d = new Date(base.getTime() + currentTime * 3600 * 1000); + return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')} KST`; + })()}
-
- 진행률 - {progressPercent.toFixed(0)}% -
-
- 속도 - {playbackSpeed}× -
-
- 시간 - - {currentTime.toFixed(0)}/{maxTime}h - -
+
진행률{progressPercent.toFixed(0)}%
+
속도{playbackSpeed}×
+
시간{currentTime.toFixed(0)}/{maxTime}h
- ); + ) } // 기상 데이터 Mock function getWeatherData(position: [number, number]) { - const [lat, lng] = position; - const latSeed = Math.abs(lat * 100) % 10; - const lngSeed = Math.abs(lng * 100) % 10; - const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + const [lat, lng] = position + const latSeed = Math.abs(lat * 100) % 10 + const lngSeed = Math.abs(lng * 100) % 10 + const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] return { windSpeed: Number((5 + latSeed).toFixed(1)), windDirection: directions[Math.floor(lngSeed * 0.8)], @@ -2049,11 +1555,11 @@ function getWeatherData(position: [number, number]) { waterTemp: Number((8 + (lngSeed - 5) * 0.5).toFixed(1)), currentSpeed: Number((0.3 + lngSeed * 0.05).toFixed(2)), currentDirection: directions[Math.floor(latSeed * 0.8)], - }; + } } function WeatherInfoPanel({ position }: { position: [number, number] }) { - const weather = getWeatherData(position); + const weather = getWeatherData(position) return (
@@ -2077,49 +1583,34 @@ function WeatherInfoPanel({ position }: { position: [number, number] }) {
해류 ({weather.currentDirection})
- ); + ) } // 역추적 리플레이 컨트롤 바 (HTML 오버레이) -function BacktrackReplayBar({ - replayFrame, - totalFrames, - ships, -}: { - replayFrame: number; - totalFrames: number; - ships: ReplayShip[]; -}) { - const progress = (replayFrame / totalFrames) * 100; +function BacktrackReplayBar({ replayFrame, totalFrames, ships }: { replayFrame: number; totalFrames: number; ships: ReplayShip[] }) { + const progress = (replayFrame / totalFrames) * 100 return (
-
{progress.toFixed(0)}%
+
+ {progress.toFixed(0)}% +
- {ships.map((s) => ( + {ships.map(s => (
- ); + ) } diff --git a/frontend/src/common/components/map/S57EncOverlay.tsx b/frontend/src/common/components/map/S57EncOverlay.tsx new file mode 100644 index 0000000..2149de5 --- /dev/null +++ b/frontend/src/common/components/map/S57EncOverlay.tsx @@ -0,0 +1,274 @@ +import { useEffect, useRef } from 'react'; +import { useMap } from '@vis.gl/react-maplibre'; +import { API_BASE_URL } from '../../services/api'; + +const PROXY_PREFIX = `${API_BASE_URL}/tiles/enc`; +// MapLibre 내부 요청(sprite, tiles, glyphs)은 절대 URL이 필요 +const ABSOLUTE_PREFIX = API_BASE_URL.startsWith('http') + ? `${API_BASE_URL}/tiles/enc` + : `${window.location.origin}${API_BASE_URL}/tiles/enc`; + +const ENC_SPRITE_ID = 'enc-s57'; +const ENC_SOURCE_ID = 'enc-s57'; +const GLOBE_SOURCE_ID = 'enc-globe'; + +// sprite JSON에 정의된 아이콘 이름 캐시 (프리픽스 판별용) +let spriteIconNames: Set | null = null; + +interface EncStyleLayer { + id: string; + type: string; + source?: string; + 'source-layer'?: string; + filter?: unknown; + layout?: Record; + paint?: Record; + minzoom?: number; + maxzoom?: number; +} + +interface EncStyle { + layers: EncStyleLayer[]; + sources: Record< + string, + { + type: string; + tiles: string[]; + minzoom?: number; + maxzoom?: number; + } + >; +} + +// style.json 캐시 +let cachedStyle: EncStyle | null = null; +async function loadEncStyle(): Promise { + if (cachedStyle) return cachedStyle; + const res = await fetch(`${PROXY_PREFIX}/style`); + if (!res.ok) throw new Error(`ENC style fetch failed: ${res.status}`); + cachedStyle = await res.json(); + return cachedStyle!; +} + +// sprite JSON 로드 → 아이콘 이름 세트 +async function loadSpriteNames(): Promise> { + if (spriteIconNames) return spriteIconNames; + const res = await fetch(`${PROXY_PREFIX}/sprite/sprite@2x.json`); + if (!res.ok) throw new Error(`Sprite JSON fetch failed: ${res.status}`); + const json = await res.json(); + spriteIconNames = new Set(Object.keys(json)); + return spriteIconNames; +} + +// sprite 존재 여부 확인 +function hasSprite(map: maplibregl.Map, id: string): boolean { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sprites = (map.style as any)?.getSprite?.(); + return Array.isArray(sprites) && sprites.some((s: { id: string }) => s.id === id); + } catch { + return false; + } +} + +// ─── 아이콘/패턴 이름에 sprite 프리픽스 부착 ─── +// addSprite('enc-s57', url)로 등록하면 이미지는 'enc-s57:NAME' 으로 참조해야 함 +// style.json은 'NAME' 으로 참조하므로 변환 필요 + +const SPRITE_PREFIX = `${ENC_SPRITE_ID}:`; + +function prefixIconValue(value: unknown, iconNames: Set): unknown { + if (typeof value === 'string') { + // sprite에 정의된 아이콘 이름이면 프리픽스 부착 + if (iconNames.has(value)) return `${SPRITE_PREFIX}${value}`; + return value; + } + if (Array.isArray(value)) { + // MapLibre expression: 재귀적으로 문자열 요소 변환 + return value.map((item) => prefixIconValue(item, iconNames)); + } + return value; +} + +function prefixLayerIcons(layer: EncStyleLayer, iconNames: Set): EncStyleLayer { + const result = { ...layer }; + + // layout: icon-image + if (result.layout?.['icon-image']) { + result.layout = { + ...result.layout, + 'icon-image': prefixIconValue(result.layout['icon-image'], iconNames), + }; + } + + // paint: fill-pattern + if (result.paint?.['fill-pattern']) { + result.paint = { + ...result.paint, + 'fill-pattern': prefixIconValue(result.paint['fill-pattern'], iconNames), + }; + } + + // paint: background-pattern + if (result.paint?.['background-pattern']) { + result.paint = { + ...result.paint, + 'background-pattern': prefixIconValue(result.paint['background-pattern'], iconNames), + }; + } + + return result; +} + +// ─── 컴포넌트 ─── + +interface S57EncOverlayProps { + visible: boolean; +} + +export function S57EncOverlay({ visible }: S57EncOverlayProps) { + const { current: mapRef } = useMap(); + const addedLayersRef = useRef([]); + const sourcesAddedRef = useRef(false); + const originalGlyphsRef = useRef(undefined); + + useEffect(() => { + const map = mapRef?.getMap(); + if (!map) return; + + if (visible) { + const doAdd = () => addEncLayers(map); + + if (map.isStyleLoaded()) { + doAdd(); + } else { + map.once('style.load', doAdd); + } + + return () => { + map.off('style.load', doAdd); + removeEncLayers(map); + }; + } else { + removeEncLayers(map); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible, mapRef]); + + async function addEncLayers(map: maplibregl.Map) { + if (sourcesAddedRef.current) return; + + try { + const [style, iconNames] = await Promise.all([loadEncStyle(), loadSpriteNames()]); + + // glyphs URL을 ENC 프록시로 교체 (ENC symbol 레이어용 폰트) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const styleObj = map.style as any; + originalGlyphsRef.current = styleObj.glyphs; + styleObj.glyphs = `${ABSOLUTE_PREFIX}/font/{fontstack}/{range}`; + + // sprite 등록 (중복 방지) + if (!hasSprite(map, ENC_SPRITE_ID)) { + map.addSprite(ENC_SPRITE_ID, `${ABSOLUTE_PREFIX}/sprite/sprite`); + } + + // sources 등록 (타일 URL을 프록시로 치환) + if (!map.getSource(GLOBE_SOURCE_ID)) { + const globeSrc = style.sources['globe']; + map.addSource(GLOBE_SOURCE_ID, { + type: 'vector', + tiles: [`${ABSOLUTE_PREFIX}/globe/{z}/{x}/{y}`], + minzoom: globeSrc?.minzoom ?? 0, + maxzoom: globeSrc?.maxzoom ?? 4, + }); + } + + if (!map.getSource(ENC_SOURCE_ID)) { + const encSrc = style.sources['enc']; + map.addSource(ENC_SOURCE_ID, { + type: 'vector', + tiles: [`${ABSOLUTE_PREFIX}/{z}/{x}/{y}`], + minzoom: encSrc?.minzoom ?? 4, + maxzoom: encSrc?.maxzoom ?? 17, + }); + } + + // layers 등록 (background 포함 — ENC_EMPTY_STYLE 사용 시 배경 필요) + const layerIds: string[] = []; + for (const rawLayer of style.layers) { + if (map.getLayer(rawLayer.id)) continue; + + // 아이콘/패턴 참조에 sprite 프리픽스 부착 + const layer = prefixLayerIcons(rawLayer, iconNames); + + // source 이름을 프록시 source ID로 매핑 (background 타입은 source 없음) + const mappedSource = + layer.source === 'globe' + ? GLOBE_SOURCE_ID + : layer.source === 'enc' + ? ENC_SOURCE_ID + : layer.source; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const layerSpec: any = { + id: layer.id, + type: layer.type, + }; + if (mappedSource) layerSpec.source = mappedSource; + if (layer['source-layer']) layerSpec['source-layer'] = layer['source-layer']; + if (layer.filter) layerSpec.filter = layer.filter; + if (layer.layout) layerSpec.layout = layer.layout; + if (layer.paint) layerSpec.paint = layer.paint; + if (layer.minzoom !== undefined) layerSpec.minzoom = layer.minzoom; + if (layer.maxzoom !== undefined) layerSpec.maxzoom = layer.maxzoom; + + map.addLayer(layerSpec as maplibregl.AddLayerObject); + layerIds.push(layer.id); + } + + addedLayersRef.current = layerIds; + sourcesAddedRef.current = true; + } catch (err) { + console.error('[S57EncOverlay] ENC 레이어 추가 실패:', err); + } + } + + function removeEncLayers(map: maplibregl.Map) { + if (!sourcesAddedRef.current) return; + + // 맵 스타일이 이미 파괴된 경우 (탭 전환 등 언마운트 시) ref만 정리 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!(map as any).style) { + addedLayersRef.current = []; + sourcesAddedRef.current = false; + return; + } + + // layers 제거 (역순) + for (const id of [...addedLayersRef.current].reverse()) { + if (map.getLayer(id)) { + map.removeLayer(id); + } + } + addedLayersRef.current = []; + + // sources 제거 + if (map.getSource(ENC_SOURCE_ID)) map.removeSource(ENC_SOURCE_ID); + if (map.getSource(GLOBE_SOURCE_ID)) map.removeSource(GLOBE_SOURCE_ID); + + // sprite 제거 + if (hasSprite(map, ENC_SPRITE_ID)) { + map.removeSprite(ENC_SPRITE_ID); + } + + // glyphs URL 복원 + if (originalGlyphsRef.current !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (map.style as any).glyphs = originalGlyphsRef.current; + } + + sourcesAddedRef.current = false; + } + + return null; +} diff --git a/frontend/src/common/components/map/mapStyles.ts b/frontend/src/common/components/map/mapStyles.ts new file mode 100644 index 0000000..640a2d5 --- /dev/null +++ b/frontend/src/common/components/map/mapStyles.ts @@ -0,0 +1,248 @@ +import type { StyleSpecification } from 'maplibre-gl'; + +// CartoDB Dark Matter 스타일 +export const BASE_STYLE: StyleSpecification = { + version: 8, + sources: { + 'carto-dark': { + type: 'raster', + tiles: [ + 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + ], + tileSize: 256, + attribution: + '© OpenStreetMap © CARTO', + }, + }, + layers: [ + { + id: 'carto-dark-layer', + type: 'raster', + source: 'carto-dark', + minzoom: 0, + maxzoom: 22, + }, + ], +}; + +// MarineTraffic 스타일 — 깔끔한 연한 회색 육지 + 흰색 바다 + 한글 라벨 +export const LIGHT_STYLE: StyleSpecification = { + version: 8, + glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', + sources: { + 'ofm-chart': { + type: 'vector', + url: 'https://tiles.openfreemap.org/planet', + }, + }, + layers: [ + // ── 배경 = 육지 (연한 회색) ── + { + id: 'land-bg', + type: 'background', + paint: { 'background-color': '#e8e8e8' }, + }, + // ── 바다/호수/강 = water 레이어 (파란색) ── + { + id: 'water', + type: 'fill', + source: 'ofm-chart', + 'source-layer': 'water', + paint: { 'fill-color': '#a8cce0' }, + }, + // ── 주요 도로 (zoom 9+) ── + { + id: 'roads-major', + type: 'line', + source: 'ofm-chart', + 'source-layer': 'transportation', + minzoom: 9, + filter: ['in', 'class', 'motorway', 'trunk', 'primary'], + paint: { + 'line-color': '#c0c0c0', + 'line-width': ['interpolate', ['linear'], ['zoom'], 9, 0.4, 14, 1.5], + }, + }, + // ── 보조 도로 (zoom 12+) ── + { + id: 'roads-secondary', + type: 'line', + source: 'ofm-chart', + 'source-layer': 'transportation', + minzoom: 12, + filter: ['in', 'class', 'secondary', 'tertiary'], + paint: { + 'line-color': '#cccccc', + 'line-width': ['interpolate', ['linear'], ['zoom'], 12, 0.3, 14, 1], + }, + }, + // ── 건물 (zoom 14+) ── + { + id: 'buildings', + type: 'fill', + source: 'ofm-chart', + 'source-layer': 'building', + minzoom: 14, + paint: { 'fill-color': '#cbcbcb', 'fill-opacity': 0.5 }, + }, + // ── 국경선 ── + { + id: 'boundaries-country', + type: 'line', + source: 'ofm-chart', + 'source-layer': 'boundary', + filter: ['==', 'admin_level', 2], + paint: { 'line-color': '#999999', 'line-width': 1, 'line-dasharray': [4, 2] }, + }, + // ── 시도 경계 (zoom 5+) ── + { + id: 'boundaries-province', + type: 'line', + source: 'ofm-chart', + 'source-layer': 'boundary', + minzoom: 5, + filter: ['==', 'admin_level', 4], + paint: { 'line-color': '#bbbbbb', 'line-width': 0.5, 'line-dasharray': [3, 2] }, + }, + // ── 국가/시도 라벨 (한글) ── + { + id: 'place-labels-major', + type: 'symbol', + source: 'ofm-chart', + 'source-layer': 'place', + minzoom: 3, + filter: ['in', 'class', 'country', 'state'], + layout: { + 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], + 'text-font': ['Open Sans Bold'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 3, 11, 8, 16], + 'text-max-width': 8, + }, + paint: { + 'text-color': '#555555', + 'text-halo-color': '#ffffff', + 'text-halo-width': 2, + }, + }, + { + id: 'place-labels-city', + type: 'symbol', + source: 'ofm-chart', + 'source-layer': 'place', + minzoom: 5, + filter: ['in', 'class', 'city', 'town'], + layout: { + 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], + 'text-font': ['Open Sans Regular'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 5, 10, 12, 14], + 'text-max-width': 7, + }, + paint: { + 'text-color': '#666666', + 'text-halo-color': '#ffffff', + 'text-halo-width': 1.5, + }, + }, + // ── 해양 지명 (water_name) ── + { + id: 'water-labels', + type: 'symbol', + source: 'ofm-chart', + 'source-layer': 'water_name', + layout: { + 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], + 'text-font': ['Open Sans Italic'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 3, 10, 8, 14], + 'text-max-width': 10, + 'text-letter-spacing': 0.15, + }, + paint: { + 'text-color': '#8899aa', + 'text-halo-color': 'rgba(168,204,224,0.7)', + 'text-halo-width': 1, + }, + }, + // ── 마을/소지명 (zoom 10+) ── + { + id: 'place-labels-village', + type: 'symbol', + source: 'ofm-chart', + 'source-layer': 'place', + minzoom: 10, + filter: ['in', 'class', 'village', 'suburb', 'hamlet'], + layout: { + 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], + 'text-font': ['Open Sans Regular'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 10, 9, 14, 12], + 'text-max-width': 6, + }, + paint: { + 'text-color': '#777777', + 'text-halo-color': '#ffffff', + 'text-halo-width': 1, + }, + }, + ], +}; + +// 3D 위성 스타일 (VWorld 위성 배경 + OpenFreeMap 건물 extrusion) +// VWorld WMTS: {z}/{y}/{x} (row/col 순서) +// OpenFreeMap: CORS 허용, 전 세계 OSM 벡터타일 (building: render_height 포함) +export const SATELLITE_3D_STYLE: StyleSpecification = { + version: 8, + sources: { + 'vworld-satellite': { + type: 'raster', + tiles: ['/api/tiles/vworld/{z}/{y}/{x}.jpeg'], + tileSize: 256, + attribution: '© 국토지리정보원 VWorld', + }, + ofm: { + type: 'vector', + url: 'https://tiles.openfreemap.org/planet', + }, + }, + layers: [ + { + id: 'satellite-base', + type: 'raster', + source: 'vworld-satellite', + minzoom: 0, + maxzoom: 22, + }, + { + id: 'roads-3d', + type: 'line', + source: 'ofm', + 'source-layer': 'transportation', + filter: ['in', ['get', 'class'], ['literal', ['motorway', 'trunk', 'primary', 'secondary']]], + paint: { + 'line-color': 'rgba(255,255,200,0.3)', + 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1, 16, 3], + }, + }, + { + id: '3d-buildings', + type: 'fill-extrusion', + source: 'ofm', + 'source-layer': 'building', + minzoom: 13, + filter: ['!=', ['get', 'hide_3d'], true], + paint: { + 'fill-extrusion-color': '#c8b99a', + 'fill-extrusion-height': ['max', ['coalesce', ['get', 'render_height'], 0], 3], + 'fill-extrusion-base': ['coalesce', ['get', 'render_min_height'], 0], + 'fill-extrusion-opacity': 0.85, + }, + }, + ], +}; + +// S-57 전자해도 전용 빈 스타일 (ENC 레이어가 자체적으로 육지/해안/수심 모두 포함) +export const ENC_EMPTY_STYLE: StyleSpecification = { + version: 8, + sources: {}, + layers: [], +}; diff --git a/frontend/src/common/hooks/useBaseMapStyle.ts b/frontend/src/common/hooks/useBaseMapStyle.ts new file mode 100644 index 0000000..63322a4 --- /dev/null +++ b/frontend/src/common/hooks/useBaseMapStyle.ts @@ -0,0 +1,17 @@ +import type { StyleSpecification } from 'maplibre-gl'; +import { useMapStore } from '@common/store/mapStore'; +import { + BASE_STYLE, + LIGHT_STYLE, + SATELLITE_3D_STYLE, + ENC_EMPTY_STYLE, +} from '@common/components/map/mapStyles'; + +export function useBaseMapStyle(lightMode = false): StyleSpecification { + const mapToggles = useMapStore((s) => s.mapToggles); + + if (mapToggles.s57) return ENC_EMPTY_STYLE; + if (mapToggles.threeD) return SATELLITE_3D_STYLE; + if (lightMode) return LIGHT_STYLE; + return BASE_STYLE; +} diff --git a/frontend/src/common/store/mapStore.ts b/frontend/src/common/store/mapStore.ts index fdee5cb..09cf81a 100644 --- a/frontend/src/common/store/mapStore.ts +++ b/frontend/src/common/store/mapStore.ts @@ -1,6 +1,6 @@ -import { create } from 'zustand'; -import { api } from '../services/api'; -import { haversineDistance, polygonAreaKm2 } from '../utils/geo'; +import { create } from 'zustand' +import { api } from '../services/api' +import { haversineDistance, polygonAreaKm2 } from '../utils/geo' export interface MapTypeItem { mapKey: string; @@ -46,37 +46,38 @@ interface MapState { } const DEFAULT_MAP_TYPES: MapTypeItem[] = [ - { mapKey: 's57', mapNm: 'S-57 전자해도', mapLevelCd: 'S-57' }, - { mapKey: 's101', mapNm: 'S-101 전자해도', mapLevelCd: 'S-101' }, - { mapKey: 'threeD', mapNm: '3D 지도', mapLevelCd: '3D' }, - { mapKey: 'satellite', mapNm: '위성 영상', mapLevelCd: 'SAT' }, -]; + { mapKey: 's57', mapNm: 'S-57 전자해도', mapLevelCd: 'S-57' }, + { mapKey: 's101', mapNm: 'S-101 전자해도', mapLevelCd: 'S-101' }, + { mapKey: 'threeD', mapNm: '3D 지도', mapLevelCd: '3D' }, + { mapKey: 'satellite', mapNm: '위성 영상', mapLevelCd: 'SAT' }, +] let measureIdCounter = 0; export const useMapStore = create((set, get) => ({ - mapToggles: { s57: true, s101: false, threeD: false, satellite: false }, + mapToggles: { s57: false, s101: false, threeD: false, satellite: false }, mapTypes: DEFAULT_MAP_TYPES, toggleMap: (key) => - set((s) => ({ - mapToggles: { ...s.mapToggles, [key]: !s.mapToggles[key] }, - })), + set((s) => { + const isCurrentlyOn = s.mapToggles[key]; + const allOff: MapToggles = { s57: false, s101: false, threeD: false, satellite: false }; + return { + mapToggles: isCurrentlyOn ? allOff : { ...allOff, [key]: true }, + }; + }), loadMapTypes: async () => { try { - const res = await api.get('/map-base/active'); - const types = res.data; - const current = get().mapToggles; - const newToggles: Partial = {}; + const res = await api.get('/map-base/active') + const types = res.data + const current = get().mapToggles + const newToggles: Partial = {} for (const t of types) { if (t.mapKey in current) { - newToggles[t.mapKey as keyof MapToggles] = current[t.mapKey as keyof MapToggles] ?? false; + newToggles[t.mapKey as keyof MapToggles] = current[t.mapKey as keyof MapToggles] ?? false } } - // s57 기본값 유지 - if (newToggles['s57'] === undefined && types.find((t) => t.mapKey === 's57')) { - newToggles['s57'] = true; - } - set({ mapTypes: types, mapToggles: { ...current, ...newToggles } }); + // 모든 토글 기본 off (기본지도 표시) + set({ mapTypes: types, mapToggles: { ...current, ...newToggles } }) } catch { // API 실패 시 fallback 유지 } @@ -87,7 +88,8 @@ export const useMapStore = create((set, get) => ({ measureInProgress: [], measurements: [], - setMeasureMode: (mode) => set({ measureMode: mode, measureInProgress: [] }), + setMeasureMode: (mode) => + set({ measureMode: mode, measureInProgress: [] }), addMeasurePoint: (pt) => { const { measureMode, measureInProgress } = get(); @@ -97,10 +99,7 @@ export const useMapStore = create((set, get) => ({ const dist = haversineDistance(next[0], next[1]); const id = `measure-${++measureIdCounter}`; set((s) => ({ - measurements: [ - ...s.measurements, - { id, mode: 'distance', points: [next[0], next[1]], value: dist }, - ], + measurements: [...s.measurements, { id, mode: 'distance', points: [next[0], next[1]], value: dist }], measureInProgress: [], })); } else { @@ -117,10 +116,7 @@ export const useMapStore = create((set, get) => ({ const area = polygonAreaKm2(measureInProgress); const id = `measure-${++measureIdCounter}`; set((s) => ({ - measurements: [ - ...s.measurements, - { id, mode: 'area', points: [...measureInProgress], value: area }, - ], + measurements: [...s.measurements, { id, mode: 'area', points: [...measureInProgress], value: area }], measureInProgress: [], })); }, @@ -128,5 +124,6 @@ export const useMapStore = create((set, get) => ({ removeMeasurement: (id) => set((s) => ({ measurements: s.measurements.filter((m) => m.id !== id) })), - clearAllMeasurements: () => set({ measurements: [], measureInProgress: [], measureMode: null }), -})); + clearAllMeasurements: () => + set({ measurements: [], measureInProgress: [], measureMode: null }), +})) diff --git a/frontend/src/tabs/admin/components/DispersingZonePanel.tsx b/frontend/src/tabs/admin/components/DispersingZonePanel.tsx index bb588e5..ccc4882 100644 --- a/frontend/src/tabs/admin/components/DispersingZonePanel.tsx +++ b/frontend/src/tabs/admin/components/DispersingZonePanel.tsx @@ -3,35 +3,10 @@ import { Map, useControl } from '@vis.gl/react-maplibre'; import { MapboxOverlay } from '@deck.gl/mapbox'; import { GeoJsonLayer } from '@deck.gl/layers'; import type { Layer } from '@deck.gl/core'; -import type { StyleSpecification } from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; - -// CartoDB Dark Matter 스타일 -const MAP_STYLE: StyleSpecification = { - version: 8, - sources: { - 'carto-dark': { - type: 'raster', - tiles: [ - 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', - 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', - 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', - ], - tileSize: 256, - attribution: - '© OpenStreetMap © CARTO', - }, - }, - layers: [ - { - id: 'carto-dark-layer', - type: 'raster', - source: 'carto-dark', - minzoom: 0, - maxzoom: 22, - }, - ], -}; +import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; +import { S57EncOverlay } from '@common/components/map/S57EncOverlay'; +import { useMapStore } from '@common/store/mapStore'; const MAP_CENTER: [number, number] = [127.5, 36.0]; const MAP_ZOOM = 5.5; @@ -82,6 +57,8 @@ const ZONE_INFO: Record { + const currentMapStyle = useBaseMapStyle(); + const mapToggles = useMapStore((s) => s.mapToggles); const [showConsider, setShowConsider] = useState(true); const [showRestrict, setShowRestrict] = useState(true); const [expandedZone, setExpandedZone] = useState(null); @@ -209,8 +186,9 @@ const DispersingZonePanel = () => { zoom: MAP_ZOOM, }} style={{ width: '100%', height: '100%' }} - mapStyle={MAP_STYLE} + mapStyle={currentMapStyle} > + diff --git a/frontend/src/tabs/aerial/components/CctvView.tsx b/frontend/src/tabs/aerial/components/CctvView.tsx index ca14098..843ee5c 100644 --- a/frontend/src/tabs/aerial/components/CctvView.tsx +++ b/frontend/src/tabs/aerial/components/CctvView.tsx @@ -1,11 +1,13 @@ -import { useState, useCallback, useEffect, useRef } from 'react'; -import { Map, Marker, Popup } from '@vis.gl/react-maplibre'; -import type { StyleSpecification } from 'maplibre-gl'; -import 'maplibre-gl/dist/maplibre-gl.css'; -import { fetchCctvCameras } from '../services/aerialApi'; -import type { CctvCameraItem } from '../services/aerialApi'; -import { CCTVPlayer } from './CCTVPlayer'; -import type { CCTVPlayerHandle } from './CCTVPlayer'; +import { useState, useCallback, useEffect, useRef } from 'react' +import { Map, Marker, Popup } from '@vis.gl/react-maplibre' +import 'maplibre-gl/dist/maplibre-gl.css' +import { fetchCctvCameras } from '../services/aerialApi' +import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle' +import { S57EncOverlay } from '@common/components/map/S57EncOverlay' +import { useMapStore } from '@common/store/mapStore' +import type { CctvCameraItem } from '../services/aerialApi' +import { CCTVPlayer } from './CCTVPlayer' +import type { CCTVPlayerHandle } from './CCTVPlayer' /** KHOA HLS 스트림 베이스 URL */ const KHOA_HLS = 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa'; @@ -20,595 +22,133 @@ function kbsCctvUrl(cctvId: number): string { return `/api/aerial/cctv/kbs-hls/${cctvId}/stream.m3u8`; } -/** 지도 스타일 (CartoDB Dark Matter) */ -const MAP_STYLE: StyleSpecification = { - version: 8, - sources: { - 'carto-dark': { - type: 'raster', - tiles: [ - 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - ], - tileSize: 256, - }, - }, - layers: [ - { id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 }, - ], -}; const cctvFavorites = [ { name: '여수항 해무관측', reason: '유출 사고 인접' }, { name: '부산항 조위관측소', reason: '주요 방제 거점' }, { name: '목포항 해무관측', reason: '서해 모니터링' }, -]; +] /** badatime.com 실제 해안 CCTV 데이터 (API 미연결 시 폴백) */ const FALLBACK_CAMERAS: CctvCameraItem[] = [ // 서해 - { - cctvSn: 29, - cameraNm: '인천항 조위관측소', - regionNm: '서해', - lon: 126.5922, - lat: 37.4519, - locDc: '인천광역시 중구 항동', - coordDc: '37.45°N 126.59°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KHOA', - streamUrl: khoaHlsUrl('Incheon'), - }, - { - cctvSn: 30, - cameraNm: '인천항 해무관측', - regionNm: '서해', - lon: 126.6161, - lat: 37.3797, - locDc: '인천광역시 중구 항동', - coordDc: 'N 37°22\'47" E 126°36\'58"', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KHOA', - streamUrl: khoaHlsUrl('SeaFog_Incheon'), - }, - { - cctvSn: 31, - cameraNm: '대산항 해무관측', - regionNm: '서해', - lon: 126.3526, - lat: 37.0058, - locDc: '충남 서산시 대산읍', - coordDc: '37.01°N 126.35°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KHOA', - streamUrl: khoaHlsUrl('SeaFog_Daesan'), - }, - { - cctvSn: 32, - cameraNm: '평택·당진항 해무관측', - regionNm: '서해', - lon: 126.3936, - lat: 37.1131, - locDc: '충남 당진시 송악읍', - coordDc: 'N 37°06\'47" E 126°23\'37"', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KHOA', - streamUrl: khoaHlsUrl('SeaFog_PTDJ'), - }, - { - cctvSn: 100, - cameraNm: '인천 연안부두', - regionNm: '서해', - lon: 126.5986, - lat: 37.4541, - locDc: '인천광역시 중구 연안부두', - coordDc: '37.45°N 126.60°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9981), - }, - { - cctvSn: 200, - cameraNm: '연평도', - regionNm: '서해', - lon: 125.6945, - lat: 37.662, - locDc: '인천 옹진군 연평면', - coordDc: '37.66°N 125.69°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9958), - }, - { - cctvSn: 201, - cameraNm: '군산 비응항', - regionNm: '서해', - lon: 126.5265, - lat: 35.9353, - locDc: '전북 군산시 비응도동', - coordDc: '35.94°N 126.53°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9979), - }, - { - cctvSn: 202, - cameraNm: '태안 신진항', - regionNm: '서해', - lon: 126.1365, - lat: 36.6779, - locDc: '충남 태안군 근흥면', - coordDc: '36.68°N 126.14°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9980), - }, + { cctvSn: 29, cameraNm: '인천항 조위관측소', regionNm: '서해', lon: 126.5922, lat: 37.4519, locDc: '인천광역시 중구 항동', coordDc: '37.45°N 126.59°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Incheon') }, + { cctvSn: 30, cameraNm: '인천항 해무관측', regionNm: '서해', lon: 126.6161, lat: 37.3797, locDc: '인천광역시 중구 항동', coordDc: 'N 37°22\'47" E 126°36\'58"', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Incheon') }, + { cctvSn: 31, cameraNm: '대산항 해무관측', regionNm: '서해', lon: 126.3526, lat: 37.0058, locDc: '충남 서산시 대산읍', coordDc: '37.01°N 126.35°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Daesan') }, + { cctvSn: 32, cameraNm: '평택·당진항 해무관측', regionNm: '서해', lon: 126.3936, lat: 37.1131, locDc: '충남 당진시 송악읍', coordDc: 'N 37°06\'47" E 126°23\'37"', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_PTDJ') }, + { cctvSn: 100, cameraNm: '인천 연안부두', regionNm: '서해', lon: 126.5986, lat: 37.4541, locDc: '인천광역시 중구 연안부두', coordDc: '37.45°N 126.60°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9981) }, + { cctvSn: 200, cameraNm: '연평도', regionNm: '서해', lon: 125.6945, lat: 37.6620, locDc: '인천 옹진군 연평면', coordDc: '37.66°N 125.69°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9958) }, + { cctvSn: 201, cameraNm: '군산 비응항', regionNm: '서해', lon: 126.5265, lat: 35.9353, locDc: '전북 군산시 비응도동', coordDc: '35.94°N 126.53°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9979) }, + { cctvSn: 202, cameraNm: '태안 신진항', regionNm: '서해', lon: 126.1365, lat: 36.6779, locDc: '충남 태안군 근흥면', coordDc: '36.68°N 126.14°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9980) }, // 남해 - { - cctvSn: 35, - cameraNm: '목포항 해무관측', - regionNm: '남해', - lon: 126.378, - lat: 34.778, - locDc: '전남 목포시 항동', - coordDc: '34.78°N 126.38°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KHOA', - streamUrl: khoaHlsUrl('SeaFog_Mokpo'), - }, - { - cctvSn: 36, - cameraNm: '진도항 조위관측소', - regionNm: '남해', - lon: 126.3085, - lat: 34.471, - locDc: '전남 진도군 진도읍', - coordDc: '34.47°N 126.31°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KHOA', - streamUrl: khoaHlsUrl('Jindo'), - }, - { - cctvSn: 37, - cameraNm: '여수항 해무관측', - regionNm: '남해', - lon: 127.7669, - lat: 34.7384, - locDc: '전남 여수시 종화동', - coordDc: '34.74°N 127.77°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KHOA', - streamUrl: khoaHlsUrl('SeaFog_Yeosu'), - }, - { - cctvSn: 38, - cameraNm: '여수항 조위관측소', - regionNm: '남해', - lon: 127.765, - lat: 34.737, - locDc: '전남 여수시 종화동', - coordDc: '34.74°N 127.77°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KHOA', - streamUrl: khoaHlsUrl('Yeosu'), - }, - { - cctvSn: 39, - cameraNm: '부산항 조위관측소', - regionNm: '남해', - lon: 129.0756, - lat: 35.0969, - locDc: '부산광역시 중구 중앙동', - coordDc: '35.10°N 129.08°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KHOA', - streamUrl: khoaHlsUrl('Busan'), - }, - { - cctvSn: 40, - cameraNm: '부산항 해무관측', - regionNm: '남해', - lon: 129.078, - lat: 35.098, - locDc: '부산광역시 중구', - coordDc: '35.10°N 129.08°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KHOA', - streamUrl: khoaHlsUrl('SeaFog_Busan'), - }, - { - cctvSn: 41, - cameraNm: '해운대 해무관측', - regionNm: '남해', - lon: 129.1718, - lat: 35.1587, - locDc: '부산광역시 해운대구', - coordDc: '35.16°N 129.17°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KHOA', - streamUrl: khoaHlsUrl('SeaFog_Haeundae'), - }, - { - cctvSn: 97, - cameraNm: '여수 오동도 앞', - regionNm: '남해', - lon: 127.7557, - lat: 34.741, - locDc: '전남 여수시 수정동', - coordDc: '34.74°N 127.76°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9994), - }, - { - cctvSn: 108, - cameraNm: '완도항', - regionNm: '남해', - lon: 126.7489, - lat: 34.3209, - locDc: '전남 완도군 완도읍', - coordDc: '34.32°N 126.75°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9984), - }, - { - cctvSn: 203, - cameraNm: '창원 마산항', - regionNm: '남해', - lon: 128.576, - lat: 35.1979, - locDc: '경남 창원시 마산합포구', - coordDc: '35.20°N 128.58°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9985), - }, - { - cctvSn: 204, - cameraNm: '부산 민락항', - regionNm: '남해', - lon: 129.1312, - lat: 35.1538, - locDc: '부산 수영구 민락동', - coordDc: '35.15°N 129.13°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9991), - }, - { - cctvSn: 205, - cameraNm: '목포 북항', - regionNm: '남해', - lon: 126.3652, - lat: 34.8042, - locDc: '전남 목포시 죽교동', - coordDc: '34.80°N 126.37°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9992), - }, - { - cctvSn: 206, - cameraNm: '신안 가거도', - regionNm: '남해', - lon: 125.1293, - lat: 34.0529, - locDc: '전남 신안군 흑산면', - coordDc: '34.05°N 125.13°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9983), - }, - { - cctvSn: 207, - cameraNm: '여수 거문도', - regionNm: '남해', - lon: 127.3074, - lat: 34.0232, - locDc: '전남 여수시 삼산면', - coordDc: '34.02°N 127.31°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9993), - }, + { cctvSn: 35, cameraNm: '목포항 해무관측', regionNm: '남해', lon: 126.3780, lat: 34.7780, locDc: '전남 목포시 항동', coordDc: '34.78°N 126.38°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Mokpo') }, + { cctvSn: 36, cameraNm: '진도항 조위관측소', regionNm: '남해', lon: 126.3085, lat: 34.4710, locDc: '전남 진도군 진도읍', coordDc: '34.47°N 126.31°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Jindo') }, + { cctvSn: 37, cameraNm: '여수항 해무관측', regionNm: '남해', lon: 127.7669, lat: 34.7384, locDc: '전남 여수시 종화동', coordDc: '34.74°N 127.77°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Yeosu') }, + { cctvSn: 38, cameraNm: '여수항 조위관측소', regionNm: '남해', lon: 127.7650, lat: 34.7370, locDc: '전남 여수시 종화동', coordDc: '34.74°N 127.77°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Yeosu') }, + { cctvSn: 39, cameraNm: '부산항 조위관측소', regionNm: '남해', lon: 129.0756, lat: 35.0969, locDc: '부산광역시 중구 중앙동', coordDc: '35.10°N 129.08°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Busan') }, + { cctvSn: 40, cameraNm: '부산항 해무관측', regionNm: '남해', lon: 129.0780, lat: 35.0980, locDc: '부산광역시 중구', coordDc: '35.10°N 129.08°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Busan') }, + { cctvSn: 41, cameraNm: '해운대 해무관측', regionNm: '남해', lon: 129.1718, lat: 35.1587, locDc: '부산광역시 해운대구', coordDc: '35.16°N 129.17°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Haeundae') }, + { cctvSn: 97, cameraNm: '여수 오동도 앞', regionNm: '남해', lon: 127.7557, lat: 34.7410, locDc: '전남 여수시 수정동', coordDc: '34.74°N 127.76°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9994) }, + { cctvSn: 108, cameraNm: '완도항', regionNm: '남해', lon: 126.7489, lat: 34.3209, locDc: '전남 완도군 완도읍', coordDc: '34.32°N 126.75°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9984) }, + { cctvSn: 203, cameraNm: '창원 마산항', regionNm: '남해', lon: 128.5760, lat: 35.1979, locDc: '경남 창원시 마산합포구', coordDc: '35.20°N 128.58°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9985) }, + { cctvSn: 204, cameraNm: '부산 민락항', regionNm: '남해', lon: 129.1312, lat: 35.1538, locDc: '부산 수영구 민락동', coordDc: '35.15°N 129.13°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9991) }, + { cctvSn: 205, cameraNm: '목포 북항', regionNm: '남해', lon: 126.3652, lat: 34.8042, locDc: '전남 목포시 죽교동', coordDc: '34.80°N 126.37°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9992) }, + { cctvSn: 206, cameraNm: '신안 가거도', regionNm: '남해', lon: 125.1293, lat: 34.0529, locDc: '전남 신안군 흑산면', coordDc: '34.05°N 125.13°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9983) }, + { cctvSn: 207, cameraNm: '여수 거문도', regionNm: '남해', lon: 127.3074, lat: 34.0232, locDc: '전남 여수시 삼산면', coordDc: '34.02°N 127.31°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9993) }, // 동해 - { - cctvSn: 42, - cameraNm: '울산항 해무관측', - regionNm: '동해', - lon: 129.387, - lat: 35.5, - locDc: '울산광역시 남구', - coordDc: '35.50°N 129.39°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KHOA', - streamUrl: khoaHlsUrl('SeaFog_Ulsan'), - }, - { - cctvSn: 43, - cameraNm: '포항항 해무관측', - regionNm: '동해', - lon: 129.3798, - lat: 36.0323, - locDc: '경북 포항시 북구', - coordDc: '36.03°N 129.38°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KHOA', - streamUrl: khoaHlsUrl('SeaFog_Pohang'), - }, - { - cctvSn: 44, - cameraNm: '묵호항 조위관측소', - regionNm: '동해', - lon: 129.1146, - lat: 37.55, - locDc: '강원 동해시 묵호동', - coordDc: '37.55°N 129.11°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KHOA', - streamUrl: khoaHlsUrl('Mukho'), - }, - { - cctvSn: 113, - cameraNm: '속초 등대전망대', - regionNm: '동해', - lon: 128.6001, - lat: 38.2134, - locDc: '강원 속초시 영랑동', - coordDc: '38.21°N 128.60°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9986), - }, - { - cctvSn: 115, - cameraNm: '독도', - regionNm: '동해', - lon: 131.8686, - lat: 37.2394, - locDc: '경북 울릉군 울릉읍 독도리', - coordDc: '37.24°N 131.87°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9957), - }, - { - cctvSn: 208, - cameraNm: '강릉 용강동', - regionNm: '동해', - lon: 128.8912, - lat: 37.7521, - locDc: '강원 강릉시 용강동', - coordDc: '37.75°N 128.89°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9952), - }, - { - cctvSn: 209, - cameraNm: '강릉 주문진방파제', - regionNm: '동해', - lon: 128.8335, - lat: 37.8934, - locDc: '강원 강릉시 주문진읍', - coordDc: '37.89°N 128.83°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9995), - }, - { - cctvSn: 210, - cameraNm: '대관령', - regionNm: '동해', - lon: 128.7553, - lat: 37.698, - locDc: '강원 평창군 대관령면', - coordDc: '37.70°N 128.76°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9989), - }, - { - cctvSn: 211, - cameraNm: '울릉 저동항', - regionNm: '동해', - lon: 130.9122, - lat: 37.4913, - locDc: '경북 울릉군 울릉읍', - coordDc: '37.49°N 130.91°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9987), - }, - { - cctvSn: 212, - cameraNm: '포항 두호동 해안로', - regionNm: '동해', - lon: 129.3896, - lat: 36.0627, - locDc: '경북 포항시 북구 두호동', - coordDc: '36.06°N 129.39°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9988), - }, - { - cctvSn: 213, - cameraNm: '울산 달동', - regionNm: '동해', - lon: 129.3265, - lat: 35.5442, - locDc: '울산 남구 달동', - coordDc: '35.54°N 129.33°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9955), - }, + { cctvSn: 42, cameraNm: '울산항 해무관측', regionNm: '동해', lon: 129.3870, lat: 35.5000, locDc: '울산광역시 남구', coordDc: '35.50°N 129.39°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Ulsan') }, + { cctvSn: 43, cameraNm: '포항항 해무관측', regionNm: '동해', lon: 129.3798, lat: 36.0323, locDc: '경북 포항시 북구', coordDc: '36.03°N 129.38°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Pohang') }, + { cctvSn: 44, cameraNm: '묵호항 조위관측소', regionNm: '동해', lon: 129.1146, lat: 37.5500, locDc: '강원 동해시 묵호동', coordDc: '37.55°N 129.11°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Mukho') }, + { cctvSn: 113, cameraNm: '속초 등대전망대', regionNm: '동해', lon: 128.6001, lat: 38.2134, locDc: '강원 속초시 영랑동', coordDc: '38.21°N 128.60°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9986) }, + { cctvSn: 115, cameraNm: '독도', regionNm: '동해', lon: 131.8686, lat: 37.2394, locDc: '경북 울릉군 울릉읍 독도리', coordDc: '37.24°N 131.87°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9957) }, + { cctvSn: 208, cameraNm: '강릉 용강동', regionNm: '동해', lon: 128.8912, lat: 37.7521, locDc: '강원 강릉시 용강동', coordDc: '37.75°N 128.89°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9952) }, + { cctvSn: 209, cameraNm: '강릉 주문진방파제', regionNm: '동해', lon: 128.8335, lat: 37.8934, locDc: '강원 강릉시 주문진읍', coordDc: '37.89°N 128.83°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9995) }, + { cctvSn: 210, cameraNm: '대관령', regionNm: '동해', lon: 128.7553, lat: 37.6980, locDc: '강원 평창군 대관령면', coordDc: '37.70°N 128.76°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9989) }, + { cctvSn: 211, cameraNm: '울릉 저동항', regionNm: '동해', lon: 130.9122, lat: 37.4913, locDc: '경북 울릉군 울릉읍', coordDc: '37.49°N 130.91°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9987) }, + { cctvSn: 212, cameraNm: '포항 두호동 해안로', regionNm: '동해', lon: 129.3896, lat: 36.0627, locDc: '경북 포항시 북구 두호동', coordDc: '36.06°N 129.39°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9988) }, + { cctvSn: 213, cameraNm: '울산 달동', regionNm: '동해', lon: 129.3265, lat: 35.5442, locDc: '울산 남구 달동', coordDc: '35.54°N 129.33°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9955) }, // 제주 - { - cctvSn: 45, - cameraNm: '모슬포항 조위관측소', - regionNm: '제주', - lon: 126.2519, - lat: 33.2136, - locDc: '제주 서귀포시 대정읍', - coordDc: '33.21°N 126.25°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KHOA', - streamUrl: khoaHlsUrl('Moseulpo'), - }, - { - cctvSn: 116, - cameraNm: '마라도', - regionNm: '제주', - lon: 126.2684, - lat: 33.1139, - locDc: '제주 서귀포시 대정읍 마라리', - coordDc: '33.11°N 126.27°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9982), - }, - { - cctvSn: 214, - cameraNm: '제주 도남동', - regionNm: '제주', - lon: 126.5195, - lat: 33.4891, - locDc: '제주시 도남동', - coordDc: '33.49°N 126.52°E', - sttsCd: 'LIVE', - ptzYn: 'N', - sourceNm: 'KBS', - streamUrl: kbsCctvUrl(9954), - }, -]; + { cctvSn: 45, cameraNm: '모슬포항 조위관측소', regionNm: '제주', lon: 126.2519, lat: 33.2136, locDc: '제주 서귀포시 대정읍', coordDc: '33.21°N 126.25°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Moseulpo') }, + { cctvSn: 116, cameraNm: '마라도', regionNm: '제주', lon: 126.2684, lat: 33.1139, locDc: '제주 서귀포시 대정읍 마라리', coordDc: '33.11°N 126.27°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9982) }, + { cctvSn: 214, cameraNm: '제주 도남동', regionNm: '제주', lon: 126.5195, lat: 33.4891, locDc: '제주시 도남동', coordDc: '33.49°N 126.52°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9954) }, +] export function CctvView() { - const [cameras, setCameras] = useState([]); - const [loading, setLoading] = useState(true); - const [searchTerm, setSearchTerm] = useState(''); - const [regionFilter, setRegionFilter] = useState('전체'); - const [selectedCamera, setSelectedCamera] = useState(null); - const [gridMode, setGridMode] = useState(1); - const [activeCells, setActiveCells] = useState([]); - const [oilDetectionEnabled, setOilDetectionEnabled] = useState(false); - const [vesselDetectionEnabled, setVesselDetectionEnabled] = useState(false); - const [intrusionDetectionEnabled, setIntrusionDetectionEnabled] = useState(false); - const [mapPopup, setMapPopup] = useState(null); - const [viewMode, setViewMode] = useState<'list' | 'map'>('map'); - const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([]); + const [cameras, setCameras] = useState([]) + const [loading, setLoading] = useState(true) + const [searchTerm, setSearchTerm] = useState('') + const [regionFilter, setRegionFilter] = useState('전체') + const [selectedCamera, setSelectedCamera] = useState(null) + const [gridMode, setGridMode] = useState(1) + const [activeCells, setActiveCells] = useState([]) + const [oilDetectionEnabled, setOilDetectionEnabled] = useState(false) + const [vesselDetectionEnabled, setVesselDetectionEnabled] = useState(false) + const [intrusionDetectionEnabled, setIntrusionDetectionEnabled] = useState(false) + const [mapPopup, setMapPopup] = useState(null) + const [viewMode, setViewMode] = useState<'list' | 'map'>('map') + const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([]) + const currentMapStyle = useBaseMapStyle() + const mapToggles = useMapStore((s) => s.mapToggles) /** 지도 모드이거나, 리스트 모드에서 카메라 미선택 시 지도 표시 */ - const showMap = viewMode === 'map' && activeCells.length === 0; + const showMap = viewMode === 'map' && activeCells.length === 0 const loadData = useCallback(async () => { - setLoading(true); + setLoading(true) try { - const items = await fetchCctvCameras(); + const items = await fetchCctvCameras() if (items.length > 0) { // DB 데이터에 폴백 전용 카메라(DB 미등록분) 병합 - const dbIds = new Set(items.map((i) => i.cctvSn)); - const missing = FALLBACK_CAMERAS.filter((f) => !dbIds.has(f.cctvSn)); - setCameras([...items, ...missing]); + const dbIds = new Set(items.map(i => i.cctvSn)) + const missing = FALLBACK_CAMERAS.filter(f => !dbIds.has(f.cctvSn)) + setCameras([...items, ...missing]) } else { - setCameras(FALLBACK_CAMERAS); + setCameras(FALLBACK_CAMERAS) } } catch { - setCameras(FALLBACK_CAMERAS); + setCameras(FALLBACK_CAMERAS) } finally { - setLoading(false); + setLoading(false) } - }, []); + }, []) useEffect(() => { - loadData(); - }, [loadData]); + loadData() + }, [loadData]) - const regions = ['전체', '제주', '남해', '서해', '동해']; - const regionIcons: Record = { - 전체: '', - 제주: '🌊', - 남해: '⚓', - 서해: '🐟', - 동해: '🌅', - }; + const regions = ['전체', '제주', '남해', '서해', '동해'] + const regionIcons: Record = { '전체': '', '제주': '🌊', '남해': '⚓', '서해': '🐟', '동해': '🌅' } - const filtered = cameras.filter((c) => { - if (regionFilter !== '전체' && c.regionNm !== regionFilter) return false; - if (searchTerm && !c.cameraNm.includes(searchTerm) && !(c.locDc ?? '').includes(searchTerm)) - return false; - return true; - }); + const filtered = cameras.filter(c => { + if (regionFilter !== '전체' && c.regionNm !== regionFilter) return false + if (searchTerm && !c.cameraNm.includes(searchTerm) && !(c.locDc ?? '').includes(searchTerm)) return false + return true + }) const handleSelectCamera = (cam: CctvCameraItem) => { - setSelectedCamera(cam); + setSelectedCamera(cam) if (gridMode === 1) { - setActiveCells([cam]); + setActiveCells([cam]) } else { - setActiveCells((prev) => { - if (prev.length < gridMode && !prev.find((c) => c.cctvSn === cam.cctvSn)) - return [...prev, cam]; - return prev; - }); + setActiveCells(prev => { + if (prev.length < gridMode && !prev.find(c => c.cctvSn === cam.cctvSn)) return [...prev, cam] + return prev + }) } - }; + } - const gridCols = gridMode === 1 ? 1 : gridMode === 4 ? 2 : 3; - const totalCells = gridMode; + const gridCols = gridMode === 1 ? 1 : gridMode === 4 ? 2 : 3 + const totalCells = gridMode return ( -
+
{/* 왼쪽: 목록 패널 */}
{/* 헤더 */}
- + 실시간 해안 CCTV
@@ -617,33 +157,24 @@ export function CctvView() { + >🗺 지도 + >☰ 리스트
API - +
{/* 검색 */} @@ -653,133 +184,71 @@ export function CctvView() { type="text" placeholder="지점명 또는 지역 검색..." value={searchTerm} - onChange={(e) => setSearchTerm(e.target.value)} + onChange={e => setSearchTerm(e.target.value)} className="flex-1 bg-transparent border-none text-fg text-[11px] font-korean outline-none" />
{/* 지역 필터 */}
- {regions.map((r) => ( + {regions.map(r => ( + >{regionIcons[r] ? `${regionIcons[r]} ` : ''}{r} ))}
{/* 상태 바 */}
-
- 출처: 국립해양조사원 · KBS 재난안전포털 -
-
- {filtered.length}개 -
+
출처: 국립해양조사원 · KBS 재난안전포털
+
{filtered.length}
{/* 카메라 목록 */} -
+
{loading ? ( -
- 불러오는 중... -
- ) : ( - filtered.map((cam) => ( -
handleSelectCamera(cam)} - className="flex items-center gap-2.5 px-3.5 py-2.5 border-b cursor-pointer transition-colors" - style={{ - borderColor: 'rgba(255,255,255,.04)', - background: - selectedCamera?.cctvSn === cam.cctvSn ? 'rgba(6,182,212,.08)' : 'transparent', - }} - > -
-
- - - - - - -
-
-
-
-
- {cam.cameraNm} -
-
- {cam.locDc ?? ''} -
-
-
- {cam.sttsCd === 'LIVE' ? ( - - LIVE - - ) : ( - - OFF - - )} - {cam.ptzYn === 'Y' && ( - PTZ - )} +
불러오는 중...
+ ) : filtered.map(cam => ( +
handleSelectCamera(cam)} + className="flex items-center gap-2.5 px-3.5 py-2.5 border-b cursor-pointer transition-colors" + style={{ + borderColor: 'rgba(255,255,255,.04)', + background: selectedCamera?.cctvSn === cam.cctvSn ? 'rgba(6,182,212,.08)' : 'transparent', + }} + > +
+
+ + + + + +
+
- )) - )} +
+
{cam.cameraNm}
+
{cam.locDc ?? ''}
+
+
+ {cam.sttsCd === 'LIVE' ? ( + LIVE + ) : ( + OFF + )} + {cam.ptzYn === 'Y' && PTZ} +
+
+ ))}
@@ -792,19 +261,8 @@ export function CctvView() { {selectedCamera ? `📹 ${selectedCamera.cameraNm}` : '📹 카메라를 선택하세요'}
{selectedCamera?.sttsCd === 'LIVE' && ( -
- - LIVE +
+ LIVE
)}
@@ -814,21 +272,11 @@ export function CctvView() {
PTZ {['◀', '▲', '▼', '▶'].map((d, i) => ( - + ))}
{['+', '−'].map((z, i) => ( - + ))}
)} @@ -838,80 +286,47 @@ export function CctvView() { { mode: 1, icon: '▣', label: '1화면' }, { mode: 4, icon: '⊞', label: '4분할' }, { mode: 9, icon: '⊟', label: '9분할' }, - ].map((g) => ( + ].map(g => ( + >{g.icon} ))}
+ >📷 캡처
{/* 영상 그리드 / CCTV 위치 지도 / 리스트 뷰 */} {viewMode === 'list' && activeCells.length === 0 ? ( /* ── 리스트 뷰: 출처별 · 지역별 그리드 ── */ -
+
{(() => { // 출처별 그룹핑 - const sourceGroups: Record< - string, - { label: string; icon: string; cameras: CctvCameraItem[] } - > = {}; + const sourceGroups: Record = {} for (const cam of filtered) { - const src = cam.sourceNm ?? '기타'; + const src = cam.sourceNm ?? '기타' if (!sourceGroups[src]) { sourceGroups[src] = { - label: - src === 'KHOA' - ? '국립해양조사원 (KHOA)' - : src === 'KBS' - ? 'KBS 재난안전포털' - : src, + label: src === 'KHOA' ? '국립해양조사원 (KHOA)' : src === 'KBS' ? 'KBS 재난안전포털' : src, icon: src === 'KHOA' ? '🌊' : src === 'KBS' ? '📡' : '📹', cameras: [], - }; + } } - sourceGroups[src].cameras.push(cam); + sourceGroups[src].cameras.push(cam) } - const now = new Date().toLocaleString('ko-KR', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }); + const now = new Date().toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) return Object.entries(sourceGroups).map(([srcKey, group]) => { // 출처 내에서 지역별 그룹핑 - const regionGroups: Record = {}; + const regionGroups: Record = {} for (const cam of group.cameras) { - const rgn = cam.regionNm ?? '기타'; - if (!regionGroups[rgn]) regionGroups[rgn] = []; - regionGroups[rgn].push(cam); + const rgn = cam.regionNm ?? '기타' + if (!regionGroups[rgn]) regionGroups[rgn] = [] + regionGroups[rgn].push(cam) } return ( @@ -979,183 +375,94 @@ export function CctvView() { {/* 출처 헤더 */}
{group.icon} - - {group.label} - - - {group.cameras.length}개 - + {group.label} + {group.cameras.length}개
{Object.entries(regionGroups).map(([rgn, cams]) => (
{/* 지역 소제목 */}
- - {rgn} - + {rgn} ({cams.length})
{/* 테이블 헤더 */} -
+
카메라명 위치 상태 최종갱신
{/* 테이블 행 */} - {cams.map((cam) => ( + {cams.map(cam => (
{ - handleSelectCamera(cam); - setViewMode('map'); - }} + onClick={() => { handleSelectCamera(cam); setViewMode('map') }} className="grid px-2 py-1.5 border-b border-x border-stroke cursor-pointer transition-colors hover:bg-bg-surface-hover" style={{ gridTemplateColumns: '1fr 1.2fr 70px 130px', - background: - selectedCamera?.cctvSn === cam.cctvSn - ? 'rgba(6,182,212,.08)' - : 'transparent', + background: selectedCamera?.cctvSn === cam.cctvSn ? 'rgba(6,182,212,.08)' : 'transparent', }} > - - {cam.cameraNm} - - - {cam.locDc ?? '—'} - + {cam.cameraNm} + {cam.locDc ?? '—'} {cam.sttsCd === 'LIVE' ? ( - - ● LIVE - + ● LIVE ) : ( - - ● OFF - + ● OFF )} - - {now} - + {now}
))}
))}
- ); - }); + ) + }) })()}
) : showMap ? (
- {filtered - .filter((c) => c.lon && c.lat) - .map((cam) => ( - { - e.originalEvent.stopPropagation(); - setMapPopup(cam); - }} - > -
- {/* CCTV 아이콘 */} - - {/* 카메라 본체 */} - - {/* 렌즈 */} - - - {/* 마운트 기둥 */} - - - {/* LIVE 표시등 */} - {cam.sttsCd === 'LIVE' && ( - - - - )} - - {/* 이름 라벨 */} -
- {cam.cameraNm} -
+ + {filtered.filter(c => c.lon && c.lat).map(cam => ( + { e.originalEvent.stopPropagation(); setMapPopup(cam) }} + > +
+ {/* CCTV 아이콘 */} + + {/* 카메라 본체 */} + + {/* 렌즈 */} + + + {/* 마운트 기둥 */} + + + {/* LIVE 표시등 */} + {cam.sttsCd === 'LIVE' && } + + {/* 이름 라벨 */} +
+ {cam.cameraNm}
- - ))} +
+
+ ))} {mapPopup && mapPopup.lon && mapPopup.lat && ( -
+
{mapPopup.cameraNm}
{mapPopup.locDc ?? ''}
- - {mapPopup.sttsCd === 'LIVE' ? '● LIVE' : '● OFF'} - + >{mapPopup.sttsCd === 'LIVE' ? '● LIVE' : '● OFF'} {mapPopup.sourceNm}
+ style={{ background: 'rgba(6,182,212,.15)', borderColor: 'rgba(6,182,212,.3)', color: '#67e8f9' }} + >▶ 영상 보기
)} {/* 지도 위 안내 배지 */} -
+
📹 CCTV 마커를 클릭하여 영상을 선택하세요 ({filtered.length}개)
) : ( -
+ }}> {Array.from({ length: totalCells }).map((_, i) => { - const cam = activeCells[i]; + const cam = activeCells[i] return ( -
+
{cam ? ( { - playerRefs.current[i] = el; - }} + ref={el => { playerRefs.current[i] = el }} cameraNm={cam.cameraNm} streamUrl={cam.streamUrl} sttsCd={cam.sttsCd} @@ -1247,33 +524,20 @@ export function CctvView() { intrusionDetectionEnabled={intrusionDetectionEnabled && gridMode !== 9} /> ) : ( -
- 카메라를 선택하세요 -
+
카메라를 선택하세요
)}
- ); + ) })}
)} {/* 하단 정보 바 */}
-
- 선택: {selectedCamera?.cameraNm ?? '–'} -
-
- 위치: {selectedCamera?.locDc ?? '–'} -
-
- 좌표:{' '} - - {selectedCamera?.coordDc ?? '–'} - -
-
- API: 국립해양조사원 TAGO 해양 CCTV -
+
선택: {selectedCamera?.cameraNm ?? '–'}
+
위치: {selectedCamera?.locDc ?? '–'}
+
좌표: {selectedCamera?.coordDc ?? '–'}
+
API: 국립해양조사원 TAGO 해양 CCTV
@@ -1288,53 +552,38 @@ export function CctvView() {
- {cameras - .filter((c) => c.lon && c.lat) - .map((cam) => ( - { - e.originalEvent.stopPropagation(); - handleSelectCamera(cam); + + {cameras.filter(c => c.lon && c.lat).map(cam => ( + { e.originalEvent.stopPropagation(); handleSelectCamera(cam) }} + > +
-
- - ))} + /> + + ))}
{/* 카메라 정보 */} -
+
📋 카메라 정보
{selectedCamera ? (
@@ -1347,10 +596,7 @@ export function CctvView() { ['PTZ', selectedCamera.ptzYn === 'Y' ? '지원' : '미지원'], ['출처', selectedCamera.sourceNm ?? '—'], ].map(([k, v], i) => ( -
+
{k} {v}
@@ -1362,24 +608,20 @@ export function CctvView() { {/* 방제 즐겨찾기 */}
-
- ⭐ 방제 핵심 지점 -
+
⭐ 방제 핵심 지점
{cctvFavorites.map((fav, i) => (
{ - const found = cameras.find((c) => c.cameraNm === fav.name); - if (found) handleSelectCamera(found); + const found = cameras.find(c => c.cameraNm === fav.name) + if (found) handleSelectCamera(found) }} >
-
- {fav.name} -
+
{fav.name}
{fav.reason}
@@ -1389,39 +631,26 @@ export function CctvView() { {/* API 연동 현황 */}
-
- 🔌 API 연동 현황 -
+
🔌 API 연동 현황
{[ { name: '해양조사원 TAGO', status: '● 연결', color: 'var(--color-success)' }, { name: 'KBS 재난안전포털', status: '● 연결', color: 'var(--color-success)' }, ].map((api, i) => ( -
+
{api.name} - - {api.status} - + {api.status}
))} -
+
갱신 주기 1 fps
-
- 최종갱신: {new Date().toLocaleTimeString('ko-KR')} -
+
최종갱신: {new Date().toLocaleTimeString('ko-KR')}
- ); + ) } diff --git a/frontend/src/tabs/aerial/components/RealtimeDrone.tsx b/frontend/src/tabs/aerial/components/RealtimeDrone.tsx index adb88a9..2284c1b 100644 --- a/frontend/src/tabs/aerial/components/RealtimeDrone.tsx +++ b/frontend/src/tabs/aerial/components/RealtimeDrone.tsx @@ -1,314 +1,200 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { Map, Marker, Popup } from '@vis.gl/react-maplibre'; -import type { StyleSpecification } from 'maplibre-gl'; -import 'maplibre-gl/dist/maplibre-gl.css'; -import { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi'; -import type { DroneStreamItem } from '../services/aerialApi'; -import { CCTVPlayer } from './CCTVPlayer'; -import type { CCTVPlayerHandle } from './CCTVPlayer'; +import { useState, useEffect, useCallback, useRef } from 'react' +import { Map, Marker, Popup } from '@vis.gl/react-maplibre' +import 'maplibre-gl/dist/maplibre-gl.css' +import { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi' +import type { DroneStreamItem } from '../services/aerialApi' +import { CCTVPlayer } from './CCTVPlayer' +import type { CCTVPlayerHandle } from './CCTVPlayer' +import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle' +import { S57EncOverlay } from '@common/components/map/S57EncOverlay' +import { useMapStore } from '@common/store/mapStore' /** 함정 위치 + 드론 비행 위치 */ -const DRONE_POSITIONS: Record< - string, - { ship: { lat: number; lon: number }; drone: { lat: number; lon: number } } -> = { - 'busan-1501': { ship: { lat: 35.0796, lon: 129.0756 }, drone: { lat: 35.11, lon: 129.11 } }, - 'incheon-3008': { ship: { lat: 37.4541, lon: 126.5986 }, drone: { lat: 37.48, lon: 126.56 } }, - 'mokpo-3015': { ship: { lat: 34.778, lon: 126.378 }, drone: { lat: 34.805, lon: 126.41 } }, -}; - -const DRONE_MAP_STYLE: StyleSpecification = { - version: 8, - sources: { - 'carto-dark': { - type: 'raster', - tiles: [ - 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - ], - tileSize: 256, - }, - }, - layers: [ - { id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 }, - ], -}; +const DRONE_POSITIONS: Record = { + 'busan-1501': { ship: { lat: 35.0796, lon: 129.0756 }, drone: { lat: 35.1100, lon: 129.1100 } }, + 'incheon-3008': { ship: { lat: 37.4541, lon: 126.5986 }, drone: { lat: 37.4800, lon: 126.5600 } }, + 'mokpo-3015': { ship: { lat: 34.7780, lon: 126.3780 }, drone: { lat: 34.8050, lon: 126.4100 } }, +} export function RealtimeDrone() { - const [streams, setStreams] = useState([]); - const [loading, setLoading] = useState(true); - const [selectedStream, setSelectedStream] = useState(null); - const [gridMode, setGridMode] = useState(1); - const [activeCells, setActiveCells] = useState([]); - const [mapPopup, setMapPopup] = useState(null); - const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([]); + const [streams, setStreams] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedStream, setSelectedStream] = useState(null) + const [gridMode, setGridMode] = useState(1) + const [activeCells, setActiveCells] = useState([]) + const [mapPopup, setMapPopup] = useState(null) + const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([]) + const currentMapStyle = useBaseMapStyle() + const mapToggles = useMapStore((s) => s.mapToggles) - const showMap = activeCells.length === 0; + const showMap = activeCells.length === 0 const loadStreams = useCallback(async () => { try { - const items = await fetchDroneStreams(); - setStreams(items); + const items = await fetchDroneStreams() + setStreams(items) // Update selected stream and active cells with latest status - setSelectedStream((prev) => (prev ? (items.find((s) => s.id === prev.id) ?? prev) : prev)); - setActiveCells((prev) => prev.map((cell) => items.find((s) => s.id === cell.id) ?? cell)); + setSelectedStream(prev => prev ? items.find(s => s.id === prev.id) ?? prev : prev) + setActiveCells(prev => prev.map(cell => items.find(s => s.id === cell.id) ?? cell)) } catch { // Fallback: show configured streams as idle setStreams([ - { - id: 'busan-1501', - name: '1501함 드론', - shipName: '부산서 1501함', - droneModel: 'DJI M300 RTK', - ip: '10.26.7.213', - rtspUrl: 'rtsp://10.26.7.213:554/stream0', - region: '부산', - status: 'idle', - hlsUrl: null, - error: null, - }, - { - id: 'incheon-3008', - name: '3008함 드론', - shipName: '인천서 3008함', - droneModel: 'DJI M30T', - ip: '10.26.5.21', - rtspUrl: 'rtsp://10.26.5.21:554/stream0', - region: '인천', - status: 'idle', - hlsUrl: null, - error: null, - }, - { - id: 'mokpo-3015', - name: '3015함 드론', - shipName: '목포서 3015함', - droneModel: 'DJI Mavic 3E', - ip: '10.26.7.85', - rtspUrl: 'rtsp://10.26.7.85:554/stream0', - region: '목포', - status: 'idle', - hlsUrl: null, - error: null, - }, - ]); + { id: 'busan-1501', name: '1501함 드론', shipName: '부산서 1501함', droneModel: 'DJI M300 RTK', ip: '10.26.7.213', rtspUrl: 'rtsp://10.26.7.213:554/stream0', region: '부산', status: 'idle', hlsUrl: null, error: null }, + { id: 'incheon-3008', name: '3008함 드론', shipName: '인천서 3008함', droneModel: 'DJI M30T', ip: '10.26.5.21', rtspUrl: 'rtsp://10.26.5.21:554/stream0', region: '인천', status: 'idle', hlsUrl: null, error: null }, + { id: 'mokpo-3015', name: '3015함 드론', shipName: '목포서 3015함', droneModel: 'DJI Mavic 3E', ip: '10.26.7.85', rtspUrl: 'rtsp://10.26.7.85:554/stream0', region: '목포', status: 'idle', hlsUrl: null, error: null }, + ]) } finally { - setLoading(false); + setLoading(false) } - }, []); + }, []) useEffect(() => { - loadStreams(); - }, [loadStreams]); + loadStreams() + }, [loadStreams]) // Poll status every 3 seconds when any stream is starting useEffect(() => { - const hasStarting = streams.some((s) => s.status === 'starting'); - if (!hasStarting) return; - const timer = setInterval(loadStreams, 3000); - return () => clearInterval(timer); - }, [streams, loadStreams]); + const hasStarting = streams.some(s => s.status === 'starting') + if (!hasStarting) return + const timer = setInterval(loadStreams, 3000) + return () => clearInterval(timer) + }, [streams, loadStreams]) const handleStartStream = async (id: string) => { try { - await startDroneStreamApi(id); + await startDroneStreamApi(id) // Immediately update to 'starting' state - setStreams((prev) => - prev.map((s) => (s.id === id ? { ...s, status: 'starting' as const, error: null } : s)), - ); + setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'starting' as const, error: null } : s)) // Poll for status update - setTimeout(loadStreams, 2000); + setTimeout(loadStreams, 2000) } catch { - setStreams((prev) => - prev.map((s) => - s.id === id ? { ...s, status: 'error' as const, error: '스트림 시작 요청 실패' } : s, - ), - ); + setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'error' as const, error: '스트림 시작 요청 실패' } : s)) } - }; + } const handleStopStream = async (id: string) => { try { - await stopDroneStreamApi(id); - setStreams((prev) => - prev.map((s) => - s.id === id ? { ...s, status: 'idle' as const, hlsUrl: null, error: null } : s, - ), - ); - setActiveCells((prev) => prev.filter((c) => c.id !== id)); + await stopDroneStreamApi(id) + setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'idle' as const, hlsUrl: null, error: null } : s)) + setActiveCells(prev => prev.filter(c => c.id !== id)) } catch { // ignore } - }; + } const handleSelectStream = (stream: DroneStreamItem) => { - setSelectedStream(stream); + setSelectedStream(stream) if (stream.status === 'streaming' && stream.hlsUrl) { if (gridMode === 1) { - setActiveCells([stream]); + setActiveCells([stream]) } else { - setActiveCells((prev) => { - if (prev.length < gridMode && !prev.find((c) => c.id === stream.id)) - return [...prev, stream]; - return prev; - }); + setActiveCells(prev => { + if (prev.length < gridMode && !prev.find(c => c.id === stream.id)) return [...prev, stream] + return prev + }) } } - }; + } const statusInfo = (status: string) => { switch (status) { - case 'streaming': - return { label: '송출중', color: 'var(--color-success)', bg: 'rgba(34,197,94,.12)' }; - case 'starting': - return { label: '연결중', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.12)' }; - case 'error': - return { label: '오류', color: 'var(--color-danger)', bg: 'rgba(239,68,68,.12)' }; - default: - return { label: '대기', color: 'var(--fg-disabled)', bg: 'rgba(255,255,255,.06)' }; + case 'streaming': return { label: '송출중', color: 'var(--color-success)', bg: 'rgba(34,197,94,.12)' } + case 'starting': return { label: '연결중', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.12)' } + case 'error': return { label: '오류', color: 'var(--color-danger)', bg: 'rgba(239,68,68,.12)' } + default: return { label: '대기', color: 'var(--fg-disabled)', bg: 'rgba(255,255,255,.06)' } } - }; + } - const gridCols = gridMode === 1 ? 1 : 2; - const totalCells = gridMode; + const gridCols = gridMode === 1 ? 1 : 2 + const totalCells = gridMode return ( -
+
{/* 좌측: 드론 스트림 목록 */}
{/* 헤더 */}
- s.status === 'streaming') - ? 'var(--color-success)' - : 'var(--fg-disabled)', - }} - /> + s.status === 'streaming') ? 'var(--color-success)' : 'var(--fg-disabled)' }} /> 실시간 드론 영상
-
-
- ViewLink RTSP 스트림 · 내부망 전용 + >새로고침
+
ViewLink RTSP 스트림 · 내부망 전용
{/* 드론 스트림 카드 */} -
+
{loading ? ( -
- 불러오는 중... -
- ) : ( - streams.map((stream) => { - const si = statusInfo(stream.status); - const isSelected = selectedStream?.id === stream.id; - return ( -
handleSelectStream(stream)} - className="px-3.5 py-3 border-b cursor-pointer transition-colors" - style={{ - borderColor: 'rgba(255,255,255,.04)', - background: isSelected ? 'rgba(6,182,212,.08)' : 'transparent', - }} - > -
-
-
🚁
-
-
- {stream.shipName}{' '} - - ({stream.droneModel}) - -
-
{stream.ip}
-
+
불러오는 중...
+ ) : streams.map(stream => { + const si = statusInfo(stream.status) + const isSelected = selectedStream?.id === stream.id + return ( +
handleSelectStream(stream)} + className="px-3.5 py-3 border-b cursor-pointer transition-colors" + style={{ + borderColor: 'rgba(255,255,255,.04)', + background: isSelected ? 'rgba(6,182,212,.08)' : 'transparent', + }} + > +
+
+
🚁
+
+
{stream.shipName} ({stream.droneModel})
+
{stream.ip}
- - {si.label} - -
- -
- - {stream.region} - - - RTSP :554 - -
- - {stream.error && ( -
- {stream.error} -
- )} - - {/* 시작/중지 버튼 */} -
- {stream.status === 'idle' || stream.status === 'error' ? ( - - ) : ( - - )}
+ {si.label}
- ); - }) - )} + +
+ {stream.region} + RTSP :554 +
+ + {stream.error && ( +
+ {stream.error} +
+ )} + + {/* 시작/중지 버튼 */} +
+ {stream.status === 'idle' || stream.status === 'error' ? ( + + ) : ( + + )} +
+
+ ) + })}
{/* 하단 안내 */}
- RTSP 스트림은 해양경찰 내부망에서만 접속 가능합니다. ViewLink 프로그램과 연동됩니다. + RTSP 스트림은 해양경찰 내부망에서만 접속 가능합니다. + ViewLink 프로그램과 연동됩니다.
@@ -322,35 +208,13 @@ export function RealtimeDrone() { {selectedStream ? `🚁 ${selectedStream.shipName}` : '🚁 드론 스트림을 선택하세요'}
{selectedStream?.status === 'streaming' && ( -
- - LIVE +
+ LIVE
)} {selectedStream?.status === 'starting' && ( -
- - 연결중 +
+ 연결중
)}
@@ -360,31 +224,23 @@ export function RealtimeDrone() { {[ { mode: 1, icon: '▣', label: '1화면' }, { mode: 4, icon: '⊞', label: '4분할' }, - ].map((g) => ( + ].map(g => ( + >{g.icon} ))}
+ >📷 캡처
@@ -393,145 +249,53 @@ export function RealtimeDrone() {
- {streams.map((stream) => { - const pos = DRONE_POSITIONS[stream.id]; - if (!pos) return null; - const statusColor = - stream.status === 'streaming' - ? '#22c55e' - : stream.status === 'starting' - ? '#06b6d4' - : stream.status === 'error' - ? '#ef4444' - : '#94a3b8'; + + {streams.map(stream => { + const pos = DRONE_POSITIONS[stream.id] + if (!pos) return null + const statusColor = stream.status === 'streaming' ? '#22c55e' : stream.status === 'starting' ? '#06b6d4' : stream.status === 'error' ? '#ef4444' : '#94a3b8' return ( { - e.originalEvent.stopPropagation(); - setMapPopup(stream); - }} + onClick={e => { e.originalEvent.stopPropagation(); setMapPopup(stream) }} >
- + {/* 연결선 (점선) */} - + {/* ── 함정: MarineTraffic 스타일 삼각형 (선수 방향 위) ── */} - + {/* 함정명 라벨 */} - - {stream.shipName.replace(/서 /, ' ')} - + {stream.shipName.replace(/서 /, ' ')} {/* ── 드론: 쿼드콥터 아이콘 ── */} {/* 외곽 원 */} - + {/* X자 팔 */} - - + + {/* 프로펠러 4개 (회전 애니메이션) */} - + - + - + - + {/* 본체 */} @@ -541,31 +305,16 @@ export function RealtimeDrone() { {/* 송출중 REC LED */} {stream.status === 'streaming' && ( - + )} {/* 드론 모델명 */} - - {stream.droneModel.split(' ').slice(-1)[0]} - + {stream.droneModel.split(' ').slice(-1)[0]}
- ); + ) })} {/* 드론 클릭 팝업 */} {mapPopup && DRONE_POSITIONS[mapPopup.id] && ( @@ -578,102 +327,56 @@ export function RealtimeDrone() { offset={36} className="cctv-dark-popup" > -
+
🚁
{mapPopup.shipName}
{mapPopup.droneModel}
-
- {mapPopup.ip} · {mapPopup.region} -
+
{mapPopup.ip} · {mapPopup.region}
- - ● {statusInfo(mapPopup.status).label} - + ● {statusInfo(mapPopup.status).label}
{mapPopup.status === 'idle' || mapPopup.status === 'error' ? ( + style={{ background: 'rgba(34,197,94,.15)', borderColor: 'rgba(34,197,94,.3)', color: '#4ade80' }} + >▶ 스트림 시작 ) : mapPopup.status === 'streaming' ? ( + style={{ background: 'rgba(6,182,212,.15)', borderColor: 'rgba(6,182,212,.3)', color: '#67e8f9' }} + >▶ 영상 보기 ) : ( -
- 연결 중... -
+
연결 중...
)}
)} {/* 지도 위 안내 배지 */} -
+
🚁 드론 위치를 클릭하여 스트림을 시작하세요 ({streams.length}대)
) : ( -
+ }}> {Array.from({ length: totalCells }).map((_, i) => { - const stream = activeCells[i]; + const stream = activeCells[i] return ( -
+
{stream && stream.status === 'streaming' && stream.hlsUrl ? ( { - playerRefs.current[i] = el; - }} + ref={el => { playerRefs.current[i] = el }} cameraNm={stream.shipName} streamUrl={stream.hlsUrl} sttsCd="LIVE" @@ -684,55 +387,36 @@ export function RealtimeDrone() { ) : stream && stream.status === 'starting' ? (
🚁
-
- RTSP 스트림 연결 중... -
+
RTSP 스트림 연결 중...
{stream.ip}:554
) : stream && stream.status === 'error' ? (
⚠️
연결 실패
-
- {stream.error} -
+
{stream.error}
+ >재시도
) : (
- {streams.length > 0 - ? '스트림을 시작하고 선택하세요' - : '드론 스트림을 선택하세요'} + {streams.length > 0 ? '스트림을 시작하고 선택하세요' : '드론 스트림을 선택하세요'}
)}
- ); + ) })}
)} {/* 하단 정보 바 */}
-
- 선택: {selectedStream?.shipName ?? '–'} -
-
- IP:{' '} - - {selectedStream?.ip ?? '–'} - -
-
- 지역: {selectedStream?.region ?? '–'} -
-
- RTSP → HLS · ViewLink 연동 -
+
선택: {selectedStream?.shipName ?? '–'}
+
IP: {selectedStream?.ip ?? '–'}
+
지역: {selectedStream?.region ?? '–'}
+
RTSP → HLS · ViewLink 연동
@@ -743,10 +427,7 @@ export function RealtimeDrone() { 📋 스트림 정보
-
+
{selectedStream ? (
{[ @@ -759,10 +440,7 @@ export function RealtimeDrone() { ['프로토콜', 'RTSP → HLS'], ['상태', statusInfo(selectedStream.status).label], ].map(([k, v], i) => ( -
+
{k} {v}
@@ -770,9 +448,7 @@ export function RealtimeDrone() { {selectedStream.hlsUrl && (
HLS URL
-
- {selectedStream.hlsUrl} -
+
{selectedStream.hlsUrl}
)}
@@ -784,23 +460,13 @@ export function RealtimeDrone() {
🔗 연동 시스템
-
+
ViewLink 3.5 - - ● RTSP - + ● RTSP
-
+
FFmpeg 변환 - - RTSP→HLS - + RTSP→HLS
@@ -811,21 +477,9 @@ export function RealtimeDrone() {
{[ { label: '전체', value: streams.length, color: 'text-fg' }, - { - label: '송출중', - value: streams.filter((s) => s.status === 'streaming').length, - color: 'text-color-success', - }, - { - label: '연결중', - value: streams.filter((s) => s.status === 'starting').length, - color: 'text-color-accent', - }, - { - label: '오류', - value: streams.filter((s) => s.status === 'error').length, - color: 'text-color-danger', - }, + { label: '송출중', value: streams.filter(s => s.status === 'streaming').length, color: 'text-color-success' }, + { label: '연결중', value: streams.filter(s => s.status === 'starting').length, color: 'text-color-accent' }, + { label: '오류', value: streams.filter(s => s.status === 'error').length, color: 'text-color-danger' }, ].map((item, i) => (
{item.label}
@@ -837,5 +491,5 @@ export function RealtimeDrone() {
- ); + ) } diff --git a/frontend/src/tabs/aerial/components/SatelliteRequest.tsx b/frontend/src/tabs/aerial/components/SatelliteRequest.tsx index a9d24ff..b8987a4 100644 --- a/frontend/src/tabs/aerial/components/SatelliteRequest.tsx +++ b/frontend/src/tabs/aerial/components/SatelliteRequest.tsx @@ -1,1228 +1,589 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; -import { Map, Source, Layer } from '@vis.gl/react-maplibre'; -import type { StyleSpecification } from 'maplibre-gl'; -import 'maplibre-gl/dist/maplibre-gl.css'; -import { Marker } from '@vis.gl/react-maplibre'; -import { fetchSatellitePasses } from '../services/aerialApi'; +import { useState, useRef, useEffect, useCallback } from 'react' +import { Map, Source, Layer } from '@vis.gl/react-maplibre' +import 'maplibre-gl/dist/maplibre-gl.css' +import { Marker } from '@vis.gl/react-maplibre' +import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle' +import { S57EncOverlay } from '@common/components/map/S57EncOverlay' +import { useMapStore } from '@common/store/mapStore' +import { fetchSatellitePasses } from '../services/aerialApi' -const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY || ''; -import type { SatellitePass } from '../services/aerialApi'; +const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY || '' +import type { SatellitePass } from '../services/aerialApi' interface SatRequest { - id: string; - zone: string; - zoneCoord: string; - zoneArea: string; - satellite: string; - requestDate: string; - expectedReceive: string; - resolution: string; - status: '촬영중' | '대기' | '완료' | '취소'; - provider?: string; - purpose?: string; - requester?: string; + id: string + zone: string + zoneCoord: string + zoneArea: string + satellite: string + requestDate: string + expectedReceive: string + resolution: string + status: '촬영중' | '대기' | '완료' | '취소' + provider?: string + purpose?: string + requester?: string /** ISO 날짜 (필터용) */ - dateKey?: string; + dateKey?: string } const satRequests: SatRequest[] = [ - { - id: 'SAT-004', - zone: '제주 서귀포 해상 (유출 해역 중심)', - zoneCoord: '33.24°N 126.50°E', - zoneArea: '15km²', - satellite: 'KOMPSAT-3A', - requestDate: '03-17 08:14', - expectedReceive: '03-17 14:30', - resolution: '0.5m', - status: '촬영중', - provider: 'KARI', - purpose: '유출유 확산 모니터링', - requester: '방제과 김해양', - dateKey: '2026-03-17', - }, - { - id: 'SAT-005', - zone: '가파도 북쪽 해안선', - zoneCoord: '33.17°N 126.27°E', - zoneArea: '8km²', - satellite: 'KOMPSAT-3', - requestDate: '03-17 09:02', - expectedReceive: '03-18 09:00', - resolution: '1.0m', - status: '대기', - provider: 'KARI', - purpose: '해안선 오염 확인', - requester: '방제과 이민수', - dateKey: '2026-03-17', - }, - { - id: 'SAT-006', - zone: '마라도 주변 해역', - zoneCoord: '33.11°N 126.27°E', - zoneArea: '12km²', - satellite: 'Sentinel-2', - requestDate: '03-16 09:30', - expectedReceive: '03-16 23:00', - resolution: '10m', - status: '완료', - provider: 'ESA Copernicus', - purpose: '수질 분석용 다분광 촬영', - requester: '환경분석팀 박수진', - dateKey: '2026-03-16', - }, - { - id: 'SAT-007', - zone: '대정읍 해안 오염 확산 구역', - zoneCoord: '33.21°N 126.10°E', - zoneArea: '20km²', - satellite: 'KOMPSAT-3A', - requestDate: '03-16 10:05', - expectedReceive: '03-17 08:00', - resolution: '0.5m', - status: '완료', - provider: 'KARI', - purpose: '확산 예측 모델 검증', - requester: '방제과 김해양', - dateKey: '2026-03-16', - }, - { - id: 'SAT-003', - zone: '제주 남방 100해리 해상', - zoneCoord: '33.00°N 126.50°E', - zoneArea: '25km²', - satellite: 'Sentinel-1', - requestDate: '03-15 14:00', - expectedReceive: '03-15 23:00', - resolution: '20m', - status: '완료', - provider: 'ESA Copernicus', - purpose: 'SAR 유막 탐지', - requester: '환경분석팀 박수진', - dateKey: '2026-03-15', - }, - { - id: 'SAT-002', - zone: '여수 오동도 인근 해역', - zoneCoord: '34.73°N 127.68°E', - zoneArea: '18km²', - satellite: 'KOMPSAT-3A', - requestDate: '03-14 11:30', - expectedReceive: '03-14 17:45', - resolution: '0.5m', - status: '완료', - provider: 'KARI', - purpose: '유출 초기 범위 확인', - requester: '방제과 김해양', - dateKey: '2026-03-14', - }, - { - id: 'SAT-001', - zone: '통영 해역 남측', - zoneCoord: '34.85°N 128.43°E', - zoneArea: '30km²', - satellite: 'Sentinel-1', - requestDate: '03-13 09:00', - expectedReceive: '03-13 21:00', - resolution: '20m', - status: '완료', - provider: 'ESA Copernicus', - purpose: '야간 SAR 유막 모니터링', - requester: '환경분석팀 박수진', - dateKey: '2026-03-13', - }, -]; + { id: 'SAT-004', zone: '제주 서귀포 해상 (유출 해역 중심)', zoneCoord: '33.24°N 126.50°E', zoneArea: '15km²', satellite: 'KOMPSAT-3A', requestDate: '03-17 08:14', expectedReceive: '03-17 14:30', resolution: '0.5m', status: '촬영중', provider: 'KARI', purpose: '유출유 확산 모니터링', requester: '방제과 김해양', dateKey: '2026-03-17' }, + { id: 'SAT-005', zone: '가파도 북쪽 해안선', zoneCoord: '33.17°N 126.27°E', zoneArea: '8km²', satellite: 'KOMPSAT-3', requestDate: '03-17 09:02', expectedReceive: '03-18 09:00', resolution: '1.0m', status: '대기', provider: 'KARI', purpose: '해안선 오염 확인', requester: '방제과 이민수', dateKey: '2026-03-17' }, + { id: 'SAT-006', zone: '마라도 주변 해역', zoneCoord: '33.11°N 126.27°E', zoneArea: '12km²', satellite: 'Sentinel-2', requestDate: '03-16 09:30', expectedReceive: '03-16 23:00', resolution: '10m', status: '완료', provider: 'ESA Copernicus', purpose: '수질 분석용 다분광 촬영', requester: '환경분석팀 박수진', dateKey: '2026-03-16' }, + { id: 'SAT-007', zone: '대정읍 해안 오염 확산 구역', zoneCoord: '33.21°N 126.10°E', zoneArea: '20km²', satellite: 'KOMPSAT-3A', requestDate: '03-16 10:05', expectedReceive: '03-17 08:00', resolution: '0.5m', status: '완료', provider: 'KARI', purpose: '확산 예측 모델 검증', requester: '방제과 김해양', dateKey: '2026-03-16' }, + { id: 'SAT-003', zone: '제주 남방 100해리 해상', zoneCoord: '33.00°N 126.50°E', zoneArea: '25km²', satellite: 'Sentinel-1', requestDate: '03-15 14:00', expectedReceive: '03-15 23:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: 'SAR 유막 탐지', requester: '환경분석팀 박수진', dateKey: '2026-03-15' }, + { id: 'SAT-002', zone: '여수 오동도 인근 해역', zoneCoord: '34.73°N 127.68°E', zoneArea: '18km²', satellite: 'KOMPSAT-3A', requestDate: '03-14 11:30', expectedReceive: '03-14 17:45', resolution: '0.5m', status: '완료', provider: 'KARI', purpose: '유출 초기 범위 확인', requester: '방제과 김해양', dateKey: '2026-03-14' }, + { id: 'SAT-001', zone: '통영 해역 남측', zoneCoord: '34.85°N 128.43°E', zoneArea: '30km²', satellite: 'Sentinel-1', requestDate: '03-13 09:00', expectedReceive: '03-13 21:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: '야간 SAR 유막 모니터링', requester: '환경분석팀 박수진', dateKey: '2026-03-13' }, +] const satellites = [ - { - name: 'KOMPSAT-3A', - desc: '해상도 0.5m · 광학 / IR · 촬영 가능', - status: '가용', - statusColor: 'var(--color-success)', - borderColor: 'rgba(34,197,94,.2)', - pulse: true, - }, - { - name: 'KOMPSAT-3', - desc: '해상도 1.0m · 광학 · 임무 중', - status: '임무중', - statusColor: 'var(--color-caution)', - borderColor: 'rgba(234,179,8,.2)', - pulse: true, - }, - { - name: 'Sentinel-1 (ESA)', - desc: '해상도 20m · SAR · 야간/우천 가능', - status: '가용', - statusColor: 'var(--color-success)', - borderColor: 'var(--stroke-default)', - pulse: false, - }, - { - name: 'Sentinel-2 (ESA)', - desc: '해상도 10m · 다분광 · 수질 분석 적합', - status: '가용', - statusColor: 'var(--color-success)', - borderColor: 'var(--stroke-default)', - pulse: false, - }, -]; + { name: 'KOMPSAT-3A', desc: '해상도 0.5m · 광학 / IR · 촬영 가능', status: '가용', statusColor: 'var(--color-success)', borderColor: 'rgba(34,197,94,.2)', pulse: true }, + { name: 'KOMPSAT-3', desc: '해상도 1.0m · 광학 · 임무 중', status: '임무중', statusColor: 'var(--color-caution)', borderColor: 'rgba(234,179,8,.2)', pulse: true }, + { name: 'Sentinel-1 (ESA)', desc: '해상도 20m · SAR · 야간/우천 가능', status: '가용', statusColor: 'var(--color-success)', borderColor: 'var(--stroke-default)', pulse: false }, + { name: 'Sentinel-2 (ESA)', desc: '해상도 10m · 다분광 · 수질 분석 적합', status: '가용', statusColor: 'var(--color-success)', borderColor: 'var(--stroke-default)', pulse: false }, +] const passSchedules = [ { time: '14:10 – 14:24', desc: 'KOMPSAT-3A 패스 (제주 남방)', today: true }, { time: '16:55 – 17:08', desc: 'Sentinel-1 패스 (제주 전역)', today: true }, { time: '내일 09:12', desc: 'KOMPSAT-3 패스 (가파도~마라도)', today: false }, { time: '내일 10:40', desc: 'Sentinel-2 패스 (제주 서측)', today: false }, -]; +] // UP42 위성 카탈로그 데이터 const up42Satellites = [ - { - id: 'mwl-hd15', - name: 'Maxar WorldView Legion HD15', - res: '0.3m', - type: 'optical' as const, - color: '#3b82f6', - cloud: 15, - }, - { - id: 'pneo-hd15', - name: 'Pléiades Neo HD15', - res: '0.3m', - type: 'optical' as const, - color: '#06b6d4', - cloud: 10, - }, - { - id: 'mwl', - name: 'Maxar WorldView Legion', - res: '0.5m', - type: 'optical' as const, - color: '#3b82f6', - cloud: 20, - }, - { - id: 'mwv3', - name: 'Maxar WorldView-3', - res: '0.5m', - type: 'optical' as const, - color: '#3b82f6', - cloud: 20, - }, - { - id: 'pneo', - name: 'Pléiades Neo', - res: '0.5m', - type: 'optical' as const, - color: '#06b6d4', - cloud: 15, - }, - { - id: 'bj3n', - name: 'Beijing-3N', - res: '0.5m', - type: 'optical' as const, - color: '#f97316', - cloud: 20, - delay: true, - }, - { - id: 'skysat', - name: 'SkySat', - res: '0.7m', - type: 'optical' as const, - color: '#22c55e', - cloud: 15, - }, - { - id: 'kmp3a', - name: 'KOMPSAT-3A', - res: '0.5m', - type: 'optical' as const, - color: '#a855f7', - cloud: 10, - }, - { - id: 'kmp3', - name: 'KOMPSAT-3', - res: '1.0m', - type: 'optical' as const, - color: '#a855f7', - cloud: 15, - }, - { - id: 'spot7', - name: 'SPOT 7', - res: '1.5m', - type: 'optical' as const, - color: '#eab308', - cloud: 20, - }, - { - id: 's2', - name: 'Sentinel-2', - res: '10m', - type: 'optical' as const, - color: '#ec4899', - cloud: 20, - }, - { - id: 's1', - name: 'Sentinel-1 SAR', - res: '20m', - type: 'sar' as const, - color: '#f59e0b', - cloud: 0, - }, - { - id: 'alos2', - name: 'ALOS-2 PALSAR-2', - res: '3m', - type: 'sar' as const, - color: '#f59e0b', - cloud: 0, - }, - { - id: 'rcm', - name: 'RCM (Radarsat)', - res: '3m', - type: 'sar' as const, - color: '#f59e0b', - cloud: 0, - }, - { - id: 'srtm', - name: 'SRTM DEM', - res: '30m', - type: 'elevation' as const, - color: '#64748b', - cloud: 0, - }, - { - id: 'cop-dem', - name: 'Copernicus DEM', - res: '10m', - type: 'elevation' as const, - color: '#64748b', - cloud: 0, - }, -]; + { id: 'mwl-hd15', name: 'Maxar WorldView Legion HD15', res: '0.3m', type: 'optical' as const, color: '#3b82f6', cloud: 15 }, + { id: 'pneo-hd15', name: 'Pléiades Neo HD15', res: '0.3m', type: 'optical' as const, color: '#06b6d4', cloud: 10 }, + { id: 'mwl', name: 'Maxar WorldView Legion', res: '0.5m', type: 'optical' as const, color: '#3b82f6', cloud: 20 }, + { id: 'mwv3', name: 'Maxar WorldView-3', res: '0.5m', type: 'optical' as const, color: '#3b82f6', cloud: 20 }, + { id: 'pneo', name: 'Pléiades Neo', res: '0.5m', type: 'optical' as const, color: '#06b6d4', cloud: 15 }, + { id: 'bj3n', name: 'Beijing-3N', res: '0.5m', type: 'optical' as const, color: '#f97316', cloud: 20, delay: true }, + { id: 'skysat', name: 'SkySat', res: '0.7m', type: 'optical' as const, color: '#22c55e', cloud: 15 }, + { id: 'kmp3a', name: 'KOMPSAT-3A', res: '0.5m', type: 'optical' as const, color: '#a855f7', cloud: 10 }, + { id: 'kmp3', name: 'KOMPSAT-3', res: '1.0m', type: 'optical' as const, color: '#a855f7', cloud: 15 }, + { id: 'spot7', name: 'SPOT 7', res: '1.5m', type: 'optical' as const, color: '#eab308', cloud: 20 }, + { id: 's2', name: 'Sentinel-2', res: '10m', type: 'optical' as const, color: '#ec4899', cloud: 20 }, + { id: 's1', name: 'Sentinel-1 SAR', res: '20m', type: 'sar' as const, color: '#f59e0b', cloud: 0 }, + { id: 'alos2', name: 'ALOS-2 PALSAR-2', res: '3m', type: 'sar' as const, color: '#f59e0b', cloud: 0 }, + { id: 'rcm', name: 'RCM (Radarsat)', res: '3m', type: 'sar' as const, color: '#f59e0b', cloud: 0 }, + { id: 'srtm', name: 'SRTM DEM', res: '30m', type: 'elevation' as const, color: '#64748b', cloud: 0 }, + { id: 'cop-dem', name: 'Copernicus DEM', res: '10m', type: 'elevation' as const, color: '#64748b', cloud: 0 }, +] // up42Passes — 실시간 패스로 대체됨 (satPasses from API) -const SAT_MAP_STYLE: StyleSpecification = { - version: 8, - sources: { - 'carto-dark': { - type: 'raster', - tiles: [ - 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - ], - tileSize: 256, - }, - }, - layers: [ - { id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 }, - ], -}; /** 좌표 문자열 파싱 ("33.24°N 126.50°E" → {lat, lon}) */ function parseCoord(coordStr: string): { lat: number; lon: number } | null { - const m = coordStr.match(/([\d.]+)°N\s+([\d.]+)°E/); - if (!m) return null; - return { lat: parseFloat(m[1]), lon: parseFloat(m[2]) }; + const m = coordStr.match(/([\d.]+)°N\s+([\d.]+)°E/) + if (!m) return null + return { lat: parseFloat(m[1]), lon: parseFloat(m[2]) } } -type SatModalPhase = 'none' | 'provider' | 'blacksky' | 'up42'; +type SatModalPhase = 'none' | 'provider' | 'blacksky' | 'up42' export function SatelliteRequest() { - const [requests, setRequests] = useState(satRequests); - const [mainTab, setMainTab] = useState<'list' | 'map'>('list'); - const [statusFilter, setStatusFilter] = useState('전체'); - const [modalPhase, setModalPhase] = useState('none'); - const [selectedRequest, setSelectedRequest] = useState(null); - const [currentPage, setCurrentPage] = useState(1); - const PAGE_SIZE = 5; + const [requests, setRequests] = useState(satRequests) + const [mainTab, setMainTab] = useState<'list' | 'map'>('list') + const [statusFilter, setStatusFilter] = useState('전체') + const [modalPhase, setModalPhase] = useState('none') + const [selectedRequest, setSelectedRequest] = useState(null) + const [currentPage, setCurrentPage] = useState(1) + const PAGE_SIZE = 5 // UP42 sub-tab - const [up42SubTab, setUp42SubTab] = useState<'optical' | 'sar' | 'elevation'>('optical'); - const [up42SelSat, setUp42SelSat] = useState(null); - const [up42SelPass, setUp42SelPass] = useState(null); - const [satPasses, setSatPasses] = useState([]); - const [satPassesLoading, setSatPassesLoading] = useState(false); + const [up42SubTab, setUp42SubTab] = useState<'optical' | 'sar' | 'elevation'>('optical') + const [up42SelSat, setUp42SelSat] = useState(null) + const [up42SelPass, setUp42SelPass] = useState(null) + const [satPasses, setSatPasses] = useState([]) + const [satPassesLoading, setSatPassesLoading] = useState(false) // 히스토리 지도 — 캘린더 + 선택 항목 const [mapSelectedDate, setMapSelectedDate] = useState(() => { - const d = new Date(); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; - }); - const [mapSelectedItem, setMapSelectedItem] = useState(null); - const satImgOpacity = 90; - const satImgBrightness = 100; - const satShowOverlay = true; + const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` + }) + const [mapSelectedItem, setMapSelectedItem] = useState(null) + const satImgOpacity = 90 + const satImgBrightness = 100 + const satShowOverlay = true - const modalRef = useRef(null); + const currentMapStyle = useBaseMapStyle() + const mapToggles = useMapStore((s) => s.mapToggles) + const modalRef = useRef(null) const loadSatPasses = useCallback(async () => { - setSatPassesLoading(true); + setSatPassesLoading(true) try { - const passes = await fetchSatellitePasses(); - setSatPasses(passes); + const passes = await fetchSatellitePasses() + setSatPasses(passes) } catch { - setSatPasses([]); + setSatPasses([]) } finally { - setSatPassesLoading(false); + setSatPassesLoading(false) } - }, []); + }, []) useEffect(() => { const handler = (e: MouseEvent) => { if (modalRef.current && !modalRef.current.contains(e.target as Node)) { - setModalPhase('none'); + setModalPhase('none') } - }; - if (modalPhase !== 'none') document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [modalPhase]); + } + if (modalPhase !== 'none') document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [modalPhase]) // UP42 모달 열릴 때 위성 패스 로드 useEffect(() => { - if (modalPhase === 'up42') loadSatPasses(); - }, [modalPhase, loadSatPasses]); + if (modalPhase === 'up42') loadSatPasses() + }, [modalPhase, loadSatPasses]) - const filtered = requests.filter((r) => { - if (statusFilter === '전체') return true; - if (statusFilter === '대기') return r.status === '대기'; - if (statusFilter === '진행') return r.status === '촬영중'; - if (statusFilter === '완료') return r.status === '완료'; - if (statusFilter === '취소') return r.status === '취소'; - return true; - }); - const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); - const pagedItems = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); + const filtered = requests.filter(r => { + if (statusFilter === '전체') return true + if (statusFilter === '대기') return r.status === '대기' + if (statusFilter === '진행') return r.status === '촬영중' + if (statusFilter === '완료') return r.status === '완료' + if (statusFilter === '취소') return r.status === '취소' + return true + }) + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)) + const pagedItems = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE) const statusBadge = (s: SatRequest['status']) => { - if (s === '촬영중') - return ( - - - 촬영중 - - ); - if (s === '대기') - return ( - - ⏳ 대기 - - ); - if (s === '취소') - return ( - - ✕ 취소 - - ); - return ( - - ✅ 완료 + if (s === '촬영중') return ( + + 촬영중 - ); - }; + ) + if (s === '대기') return ( + ⏳ 대기 + ) + if (s === '취소') return ( + ✕ 취소 + ) + return ( + ✅ 완료 + ) + } const stats = [ { value: '3', label: '요청 대기', color: 'var(--color-info)' }, { value: '1', label: '촬영 진행 중', color: 'var(--color-caution)' }, { value: '7', label: '수신 완료', color: 'var(--color-success)' }, { value: '0.5m', label: '최고 해상도', color: 'var(--color-accent)' }, - ]; + ] - const filters = ['전체', '대기', '진행', '완료', '취소']; + const filters = ['전체', '대기', '진행', '완료', '취소'] - const up42Filtered = up42Satellites.filter((s) => s.type === up42SubTab); + const up42Filtered = up42Satellites.filter(s => s.type === up42SubTab) // ── 섹션 헤더 헬퍼 (BlackSky 폼) ── const sectionHeader = (num: number, label: string) => (
-
- {num} -
+
{num}
{label}
- ); + ) - const bsInput = 'w-full px-3 py-2 rounded-md text-[11px] font-korean outline-none box-border'; - const bsInputStyle = { - border: '1px solid var(--stroke-default)', - background: 'var(--bg-surface)', - color: 'var(--fg-default)', - }; + const bsInput = "w-full px-3 py-2 rounded-md text-[11px] font-korean outline-none box-border" + const bsInputStyle = { border: '1px solid var(--stroke-default)', background: 'var(--bg-surface)', color: 'var(--fg-default)' } return ( -
+
{/* 헤더 + 탭 + 새요청 한 줄 (높이 통일) */}
-
- 🛰 -
+
🛰
위성 촬영 요청
+ >📋 요청 목록 + >🗺 히스토리 지도
- +
- {mainTab === 'list' && ( - <> - {/* 요약 통계 */} -
- {stats.map((s, i) => ( + {mainTab === 'list' && (<> + {/* 요약 통계 */} +
+ {stats.map((s, i) => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+ + {/* 요청 목록 */} +
+
+
📋 위성 요청 목록
+
+ {filters.map(f => ( + + ))} +
+
+ + {/* 헤더 행 */} +
+ {['번호', '촬영 구역', '위성', '요청일시', '예상수신', '해상도', '상태'].map(h => ( +
{h}
+ ))} +
+ + {/* 데이터 행 (페이징) */} + {pagedItems.map(r => ( +
+
setSelectedRequest(selectedRequest?.id === r.id ? null : r)} + className="grid gap-0 px-4 py-3 border-b items-center cursor-pointer hover:bg-bg-surface-hover/30 transition-colors" + style={{ + gridTemplateColumns: '60px 1fr 100px 100px 120px 80px 90px', + borderColor: 'rgba(255,255,255,.04)', + background: selectedRequest?.id === r.id ? 'rgba(99,102,241,.06)' : r.status === '촬영중' ? 'rgba(234,179,8,.03)' : 'transparent', + opacity: (r.status === '완료' || r.status === '취소') ? 0.6 : 1, + }} + > +
{r.id}
+
+
{r.zone}
+
{r.zoneCoord} · {r.zoneArea}
+
+
{r.satellite}
+
{r.requestDate}
+
{r.expectedReceive}
+
{r.resolution}
+
{statusBadge(r.status)}
+
+ {/* 상세 정보 패널 */} + {selectedRequest?.id === r.id && ( +
+
+ {[ + ['제공자', r.provider || '-'], + ['요청 목적', r.purpose || '-'], + ['요청자', r.requester || '-'], + ['촬영 면적', r.zoneArea], + ].map(([k, v], i) => ( +
+
{k}
+
{v}
+
+ ))} +
+
+ + {r.status === '완료' && ( + + )} + {(r.status === '대기' || r.status === '촬영중') && ( + + )} +
+
+ )} +
+ ))} + + {/* 페이징 */} +
+
+ 총 {filtered.length}건 중 {(currentPage - 1) * PAGE_SIZE + 1}–{Math.min(currentPage * PAGE_SIZE, filtered.length)} +
+
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).map(p => ( + + ))} + +
+
+
+ + {/* 위성 궤도 정보 */} +
+ {/* 가용 위성 현황 */} +
+
🛰 가용 위성 현황
+
+ {satellites.map((sat, i) => ( +
+
+
+
{sat.name}
+
{sat.desc}
+
+
{sat.status}
+
+ ))} +
+
+ + {/* 오늘 촬영 가능 시간 */} +
+
⏰ 오늘 촬영 가능 시간 (KST)
+
+ {passSchedules.map((ps, i) => (
-
- {s.value} -
-
{s.label}
+ {ps.time} + {ps.desc}
))}
- - {/* 요청 목록 */} -
-
-
📋 위성 요청 목록
-
- {filters.map((f) => ( - - ))} -
-
- - {/* 헤더 행 */} -
- {['번호', '촬영 구역', '위성', '요청일시', '예상수신', '해상도', '상태'].map((h) => ( -
- {h} -
- ))} -
- - {/* 데이터 행 (페이징) */} - {pagedItems.map((r) => ( -
-
setSelectedRequest(selectedRequest?.id === r.id ? null : r)} - className="grid gap-0 px-4 py-3 border-b items-center cursor-pointer hover:bg-bg-surface-hover/30 transition-colors" - style={{ - gridTemplateColumns: '60px 1fr 100px 100px 120px 80px 90px', - borderColor: 'rgba(255,255,255,.04)', - background: - selectedRequest?.id === r.id - ? 'rgba(99,102,241,.06)' - : r.status === '촬영중' - ? 'rgba(234,179,8,.03)' - : 'transparent', - opacity: r.status === '완료' || r.status === '취소' ? 0.6 : 1, - }} - > -
{r.id}
-
-
{r.zone}
-
- {r.zoneCoord} · {r.zoneArea} -
-
-
{r.satellite}
-
{r.requestDate}
-
- {r.expectedReceive} -
-
- {r.resolution} -
-
{statusBadge(r.status)}
-
- {/* 상세 정보 패널 */} - {selectedRequest?.id === r.id && ( -
-
- {[ - ['제공자', r.provider || '-'], - ['요청 목적', r.purpose || '-'], - ['요청자', r.requester || '-'], - ['촬영 면적', r.zoneArea], - ].map(([k, v], i) => ( -
-
- {k} -
-
{v}
-
- ))} -
-
- - {r.status === '완료' && ( - - )} - {(r.status === '대기' || r.status === '촬영중') && ( - - )} -
-
- )} -
- ))} - - {/* 페이징 */} -
-
- 총 {filtered.length}건 중 {(currentPage - 1) * PAGE_SIZE + 1}– - {Math.min(currentPage * PAGE_SIZE, filtered.length)} -
-
- - {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => ( - - ))} - -
-
-
- - {/* 위성 궤도 정보 */} -
- {/* 가용 위성 현황 */} -
-
🛰 가용 위성 현황
-
- {satellites.map((sat, i) => ( -
-
-
-
- {sat.name} -
-
{sat.desc}
-
-
- {sat.status} -
-
- ))} -
-
- - {/* 오늘 촬영 가능 시간 */} -
-
- ⏰ 오늘 촬영 가능 시간 (KST) -
-
- {passSchedules.map((ps, i) => ( -
- - {ps.time} - - {ps.desc} -
- ))} -
-
-
- - )} +
+
+ )} {/* ═══ 촬영 히스토리 지도 뷰 ═══ */} - {mainTab === 'map' && - (() => { - const dateFiltered = requests.filter((r) => r.dateKey === mapSelectedDate); - const dateHasDots = [...new Set(requests.map((r) => r.dateKey).filter(Boolean))]; - return ( -
- { + const dateFiltered = requests.filter(r => r.dateKey === mapSelectedDate) + const dateHasDots = [...new Set(requests.map(r => r.dateKey).filter(Boolean))] + return ( +
+ + + {/* 선택된 날짜의 촬영 구역 폴리곤 */} + {dateFiltered.map(r => { + const coord = parseCoord(r.zoneCoord) + if (!coord) return null + const areaKm = parseFloat(r.zoneArea) || 10 + const delta = Math.sqrt(areaKm) * 0.005 + const isSelected = mapSelectedItem?.id === r.id + const statusColor = r.status === '촬영중' ? '#eab308' : r.status === '완료' ? '#22c55e' : r.status === '취소' ? '#ef4444' : '#3b82f6' + return ( + + + + + ) + })} + + {/* 선택된 완료 항목: VWorld 위성 영상 오버레이 (BlackSky 스타일) */} + {mapSelectedItem && mapSelectedItem.status === '완료' && satShowOverlay && VWORLD_API_KEY && ( + - {/* 선택된 날짜의 촬영 구역 폴리곤 */} - {dateFiltered.map((r) => { - const coord = parseCoord(r.zoneCoord); - if (!coord) return null; - const areaKm = parseFloat(r.zoneArea) || 10; - const delta = Math.sqrt(areaKm) * 0.005; - const isSelected = mapSelectedItem?.id === r.id; - const statusColor = - r.status === '촬영중' - ? '#eab308' - : r.status === '완료' - ? '#22c55e' - : r.status === '취소' - ? '#ef4444' - : '#3b82f6'; - return ( - - - - - ); - })} + + + )} - {/* 선택된 완료 항목: VWorld 위성 영상 오버레이 (BlackSky 스타일) */} - {mapSelectedItem && - mapSelectedItem.status === '완료' && - satShowOverlay && - VWORLD_API_KEY && ( - - - - )} - - {/* 선택된 항목 마커 */} - {mapSelectedItem && - (() => { - const coord = parseCoord(mapSelectedItem.zoneCoord); - if (!coord) return null; - return ( - -
-
-
-
- - ); - })()} - - - {/* 좌상단: 캘린더 + 날짜별 리스트 */} -
- {/* 캘린더 헤더 */} -
-
- 📅 촬영 날짜 선택 + {/* 선택된 항목 마커 */} + {mapSelectedItem && (() => { + const coord = parseCoord(mapSelectedItem.zoneCoord) + if (!coord) return null + return ( + +
+
+
- { - setMapSelectedDate(e.target.value); - setMapSelectedItem(null); - }} - className="w-full px-2.5 py-1.5 bg-bg-card border border-stroke rounded text-[11px] font-mono text-fg outline-none focus:border-[var(--color-accent)] transition-colors" - /> - {/* 촬영 이력 있는 날짜 점 표시 */} -
- {dateHasDots.map((d) => ( - - ))} -
-
+ + ) + })()} + - {/* 날짜별 촬영 리스트 */} -
-
- {mapSelectedDate} · {dateFiltered.length}건 -
- {dateFiltered.length === 0 ? ( -
- 이 날짜에 촬영 이력이 없습니다 -
- ) : ( - dateFiltered.map((r) => { - const statusColor = - r.status === '촬영중' - ? '#eab308' - : r.status === '완료' - ? '#22c55e' - : r.status === '취소' - ? '#ef4444' - : '#3b82f6'; - const isSelected = mapSelectedItem?.id === r.id; - return ( -
setMapSelectedItem(isSelected ? null : r)} - className="px-3 py-2 border-b cursor-pointer transition-colors" - style={{ - borderColor: 'rgba(255,255,255,.04)', - background: isSelected ? 'rgba(6,182,212,.1)' : 'transparent', - }} - > -
- {r.id} - - {r.status} - -
-
{r.zone}
-
- {r.satellite} · {r.resolution} -
- {r.status === '완료' && ( -
- 📷 클릭하여 영상 보기 -
- )} -
- ); - }) - )} -
-
- - {/* 우상단: 범례 */} -
-
촬영 이력
- {[ - { label: '촬영중', color: '#eab308' }, - { label: '대기', color: '#3b82f6' }, - { label: '완료', color: '#22c55e' }, - { label: '취소', color: '#ef4444' }, - ].map((item) => ( -
-
- {item.label} -
+ {/* 좌상단: 캘린더 + 날짜별 리스트 */} +
+ {/* 캘린더 헤더 */} +
+
📅 촬영 날짜 선택
+ { setMapSelectedDate(e.target.value); setMapSelectedItem(null) }} + className="w-full px-2.5 py-1.5 bg-bg-card border border-stroke rounded text-[11px] font-mono text-fg outline-none focus:border-[var(--color-accent)] transition-colors" + /> + {/* 촬영 이력 있는 날짜 점 표시 */} +
+ {dateHasDots.map(d => ( + ))} -
- 총 {requests.length}건 -
- - {/* 선택된 항목 상세 (하단) */} - {mapSelectedItem && ( -
-
-
-
- {mapSelectedItem.zone} -
-
- {mapSelectedItem.satellite} · {mapSelectedItem.resolution} ·{' '} - {mapSelectedItem.zoneCoord} -
-
-
-
요청
-
- {mapSelectedItem.requestDate} -
-
- {mapSelectedItem.status === '완료' && ( -
- 📷 영상 표출중 -
- )} - -
-
- )}
- ); - })()} + + {/* 날짜별 촬영 리스트 */} +
+
+ {mapSelectedDate} · {dateFiltered.length}건 +
+ {dateFiltered.length === 0 ? ( +
이 날짜에 촬영 이력이 없습니다
+ ) : dateFiltered.map(r => { + const statusColor = r.status === '촬영중' ? '#eab308' : r.status === '완료' ? '#22c55e' : r.status === '취소' ? '#ef4444' : '#3b82f6' + const isSelected = mapSelectedItem?.id === r.id + return ( +
setMapSelectedItem(isSelected ? null : r)} + className="px-3 py-2 border-b cursor-pointer transition-colors" + style={{ + borderColor: 'rgba(255,255,255,.04)', + background: isSelected ? 'rgba(6,182,212,.1)' : 'transparent', + }} + > +
+ {r.id} + {r.status} +
+
{r.zone}
+
{r.satellite} · {r.resolution}
+ {r.status === '완료' && ( +
📷 클릭하여 영상 보기
+ )} +
+ ) + })} +
+
+ + {/* 우상단: 범례 */} +
+
촬영 이력
+ {[ + { label: '촬영중', color: '#eab308' }, + { label: '대기', color: '#3b82f6' }, + { label: '완료', color: '#22c55e' }, + { label: '취소', color: '#ef4444' }, + ].map(item => ( +
+
+ {item.label} +
+ ))} +
총 {requests.length}건
+
+ + {/* 선택된 항목 상세 (하단) */} + {mapSelectedItem && ( +
+
+
+
{mapSelectedItem.zone}
+
{mapSelectedItem.satellite} · {mapSelectedItem.resolution} · {mapSelectedItem.zoneCoord}
+
+
+
요청
+
{mapSelectedItem.requestDate}
+
+ {mapSelectedItem.status === '완료' && ( +
+ 📷 영상 표출중 +
+ )} + +
+
+ )} +
+ ) + })()} {/* ═══ 모달: 제공자 선택 ═══ */} {modalPhase !== 'none' && ( -
+
+ {/* ── 제공자 선택 ── */} {modalPhase === 'provider' && ( -
+
{/* 헤더 */}
-
+
-
- 🛰 -
+
🛰
-
- 위성 촬영 요청 — 제공자 선택 -
-
- 요청할 위성 서비스 제공자를 선택하세요 -
+
위성 촬영 요청 — 제공자 선택
+
요청할 위성 서비스 제공자를 선택하세요
- +
@@ -1235,31 +596,16 @@ export function SatelliteRequest() { >
-
- - BSky - +
+ BSky
BlackSky
-
- Maxar Electro-Optical API -
+
Maxar Electro-Optical API
- - API 연결됨 - + API 연결됨
@@ -1272,19 +618,12 @@ export function SatelliteRequest() { ].map(([k, v, c], i) => (
{k}
-
- {v} -
+
{v}
))}
-
- 고빈도 소형위성 군집 기반 긴급 촬영. 해양 사고 현장 신속 모니터링에 최적화. - Dawn-to-Dusk 촬영 가능. -
-
- API: eapi.maxar.com/e1so/rapidoc -
+
고빈도 소형위성 군집 기반 긴급 촬영. 해양 사고 현장 신속 모니터링에 최적화. Dawn-to-Dusk 촬영 가능.
+
API: eapi.maxar.com/e1so/rapidoc
{/* UP42 (EO + SAR) */} @@ -1294,33 +633,16 @@ export function SatelliteRequest() { >
-
- - up42 - +
+ up42
-
- UP42 — EO + SAR -
-
- Optical · SAR · Elevation 통합 마켓플레이스 -
+
UP42 — EO + SAR
+
Optical · SAR · Elevation 통합 마켓플레이스
- - API 연결됨 - + API 연결됨
@@ -1333,152 +655,61 @@ export function SatelliteRequest() { ].map(([k, v, c], i) => (
{k}
-
- {v} -
+
{v}
))}
{['Pléiades Neo', 'SPOT 6/7'].map((t, i) => ( - - {t} - + {t} ))} {['TerraSAR-X', 'Capella SAR', 'ICEYE'].map((t, i) => ( - - {t} - + {t} ))} - - +11 more - -
-
- 광학(EO) + 합성개구레이더(SAR) 통합 마켓. 야간·악천후 시 SAR 활용. 다중 위성 - 소스 자동 최적 선택. -
-
- API: up42.com + +11 more
+
광학(EO) + 합성개구레이더(SAR) 통합 마켓. 야간·악천후 시 SAR 활용. 다중 위성 소스 자동 최적 선택.
+
API: up42.com
{/* 하단 */}
-
- 💡 긴급 촬영: BlackSky 권장 (90분 납기) · 야간/악천후: UP42 SAR 권장 -
- +
💡 긴급 촬영: BlackSky 권장 (90분 납기) · 야간/악천후: UP42 SAR 권장
+
)} {/* ── BlackSky 긴급 촬영 요청 ── */} {modalPhase === 'blacksky' && ( -
+
{/* 헤더 */}
-
+
-
- - BSky - +
+ BSky
-
- BlackSky — 긴급 위성 촬영 요청 -
-
- Maxar E1SO RapiDoc API · 고빈도 긴급 태스킹 -
+
BlackSky — 긴급 위성 촬영 요청
+
Maxar E1SO RapiDoc API · 고빈도 긴급 태스킹
- - API Docs ↗ - - + API Docs ↗ +
{/* 본문 */} -
+
{/* API 상태 */} -
-
- - API Connected - - - eapi.maxar.com/e1so/rapidoc · Latency: 142ms - - - Quota: 47/50 요청 잔여 - +
+
+ API Connected + eapi.maxar.com/e1so/rapidoc · Latency: 142ms + Quota: 47/50 요청 잔여
{/* ① 태스킹 유형 */} @@ -1486,9 +717,7 @@ export function SatelliteRequest() { {sectionHeader(1, '태스킹 유형 · 우선순위')}
- +
- +
- + + +
- - + +
- +
- - + +
- - + +
- - + +
@@ -1604,31 +778,15 @@ export function SatelliteRequest() { {sectionHeader(3, '촬영 기간 · 반복')}
- - + +
- - + +
- + @@ -1655,9 +811,7 @@ export function SatelliteRequest() {
- + {' '} - {opt.label} + ))}
@@ -1692,9 +838,7 @@ export function SatelliteRequest() { {sectionHeader(5, '연계 사고 · 비고')}
- +
- - + +