feat(map): 3D 지도 토글 구현 (VWorld 위성 + OSM 건물 extrusion)
- mapStore.ts(신규): Zustand 기반 mapToggles 전역 상태 (s57/s101/threeD/satellite) - TopBar.tsx: 로컬 상태 → mapStore 전환 (3D 토글 전역 공유) - MapView.tsx: - SATELLITE_3D_STYLE 추가 (VWorld WMTS 위성 + OpenFreeMap 벡터타일) - MapLibre fill-extrusion으로 3D 건물 렌더링 (zoom 13+, render_height 사용) - MapPitchController: 3D ON → pitch 45°/bearing -17°, OFF → 0° 복귀 - mapToggles.threeD 상태에 따라 지도 스타일 전환 (BASE_STYLE ↔ SATELLITE_3D_STYLE) - deps: @deck.gl/mesh-layers, @deck.gl/extensions 추가 (관련 기능용) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
999e5307c4
커밋
aba58b2227
37
frontend/package-lock.json
generated
37
frontend/package-lock.json
generated
@ -10,9 +10,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deck.gl/aggregation-layers": "^9.2.10",
|
"@deck.gl/aggregation-layers": "^9.2.10",
|
||||||
"@deck.gl/core": "^9.2.10",
|
"@deck.gl/core": "^9.2.10",
|
||||||
|
"@deck.gl/extensions": "^9.2.10",
|
||||||
"@deck.gl/geo-layers": "^9.2.10",
|
"@deck.gl/geo-layers": "^9.2.10",
|
||||||
"@deck.gl/layers": "^9.2.10",
|
"@deck.gl/layers": "^9.2.10",
|
||||||
"@deck.gl/mapbox": "^9.2.10",
|
"@deck.gl/mapbox": "^9.2.10",
|
||||||
|
"@deck.gl/mesh-layers": "^9.2.10",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@ -387,6 +389,22 @@
|
|||||||
"mjolnir.js": "^3.0.0"
|
"mjolnir.js": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@deck.gl/extensions": {
|
||||||
|
"version": "9.2.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@deck.gl/extensions/-/extensions-9.2.10.tgz",
|
||||||
|
"integrity": "sha512-GMKmps67kX2d4nMbEZYDxGDZWmHDQJkFa9YbL/kSbTyt8OpFe9H5zTuqNog3l75F9Fyop/nq7bQYD2pKOUGPBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@luma.gl/constants": "^9.2.6",
|
||||||
|
"@luma.gl/shadertools": "^9.2.6",
|
||||||
|
"@math.gl/core": "^4.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@deck.gl/core": "~9.2.0",
|
||||||
|
"@luma.gl/core": "~9.2.6",
|
||||||
|
"@luma.gl/engine": "~9.2.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@deck.gl/geo-layers": {
|
"node_modules/@deck.gl/geo-layers": {
|
||||||
"version": "9.2.10",
|
"version": "9.2.10",
|
||||||
"resolved": "https://registry.npmjs.org/@deck.gl/geo-layers/-/geo-layers-9.2.10.tgz",
|
"resolved": "https://registry.npmjs.org/@deck.gl/geo-layers/-/geo-layers-9.2.10.tgz",
|
||||||
@ -459,6 +477,25 @@
|
|||||||
"@math.gl/web-mercator": "^4.1.0"
|
"@math.gl/web-mercator": "^4.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@deck.gl/mesh-layers": {
|
||||||
|
"version": "9.2.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@deck.gl/mesh-layers/-/mesh-layers-9.2.10.tgz",
|
||||||
|
"integrity": "sha512-YeVIWnODdFkz5yNYzpCtzF6wRhBpe2Gg+oc1hNNrRwdQUV3bsRLJUO13SKNagUKWIbey9K/c4ps2Ns0Dg2AzGg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@loaders.gl/gltf": "^4.3.4",
|
||||||
|
"@loaders.gl/schema": "^4.3.4",
|
||||||
|
"@luma.gl/gltf": "^9.2.6",
|
||||||
|
"@luma.gl/shadertools": "^9.2.6"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@deck.gl/core": "~9.2.0",
|
||||||
|
"@luma.gl/core": "~9.2.6",
|
||||||
|
"@luma.gl/engine": "~9.2.6",
|
||||||
|
"@luma.gl/gltf": "~9.2.6",
|
||||||
|
"@luma.gl/shadertools": "~9.2.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@dnd-kit/accessibility": {
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
|||||||
@ -12,9 +12,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deck.gl/aggregation-layers": "^9.2.10",
|
"@deck.gl/aggregation-layers": "^9.2.10",
|
||||||
"@deck.gl/core": "^9.2.10",
|
"@deck.gl/core": "^9.2.10",
|
||||||
|
"@deck.gl/extensions": "^9.2.10",
|
||||||
"@deck.gl/geo-layers": "^9.2.10",
|
"@deck.gl/geo-layers": "^9.2.10",
|
||||||
"@deck.gl/layers": "^9.2.10",
|
"@deck.gl/layers": "^9.2.10",
|
||||||
"@deck.gl/mapbox": "^9.2.10",
|
"@deck.gl/mapbox": "^9.2.10",
|
||||||
|
"@deck.gl/mesh-layers": "^9.2.10",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useMemo } from 'react'
|
|||||||
import type { MainTab } from '../../types/navigation'
|
import type { MainTab } from '../../types/navigation'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useMenuStore } from '../../store/menuStore'
|
import { useMenuStore } from '../../store/menuStore'
|
||||||
|
import { useMapStore } from '../../store/mapStore'
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
activeTab: MainTab
|
activeTab: MainTab
|
||||||
@ -10,10 +11,10 @@ interface TopBarProps {
|
|||||||
|
|
||||||
export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
||||||
const [showQuickMenu, setShowQuickMenu] = useState(false)
|
const [showQuickMenu, setShowQuickMenu] = useState(false)
|
||||||
const [mapToggles, setMapToggles] = useState({ s57: true, s101: false, threeD: false, satellite: false })
|
|
||||||
const quickMenuRef = useRef<HTMLDivElement>(null)
|
const quickMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const { hasPermission, user, logout } = useAuthStore()
|
const { hasPermission, user, logout } = useAuthStore()
|
||||||
const { menuConfig, isLoaded } = useMenuStore()
|
const { menuConfig, isLoaded } = useMenuStore()
|
||||||
|
const { mapToggles, toggleMap } = useMapStore()
|
||||||
|
|
||||||
const tabs = useMemo(() => {
|
const tabs = useMemo(() => {
|
||||||
if (!isLoaded || menuConfig.length === 0) return []
|
if (!isLoaded || menuConfig.length === 0) return []
|
||||||
@ -31,8 +32,6 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
|||||||
return () => document.removeEventListener('mousedown', handler)
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
}, [showQuickMenu])
|
}, [showQuickMenu])
|
||||||
|
|
||||||
const toggleMap = (key: keyof typeof mapToggles) => setMapToggles(p => ({ ...p, [key]: !p[key] }))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[52px] bg-bg-1 border-b border-border flex items-center justify-between px-5 relative z-[100]">
|
<div className="h-[52px] bg-bg-1 border-b border-border flex items-center justify-between px-5 relative z-[100]">
|
||||||
{/* Left Section */}
|
{/* Left Section */}
|
||||||
|
|||||||
@ -13,8 +13,10 @@ import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
|
|||||||
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||||
import { createBacktrackLayers } from './BacktrackReplayOverlay'
|
import { createBacktrackLayers } from './BacktrackReplayOverlay'
|
||||||
import { hexToRgba } from './mapUtils'
|
import { hexToRgba } from './mapUtils'
|
||||||
|
import { useMapStore } from '@common/store/mapStore'
|
||||||
|
|
||||||
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 VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY || ''
|
||||||
|
|
||||||
// 남해안 중심 좌표 (여수 앞바다)
|
// 남해안 중심 좌표 (여수 앞바다)
|
||||||
const DEFAULT_CENTER: [number, number] = [34.5, 127.8]
|
const DEFAULT_CENTER: [number, number] = [34.5, 127.8]
|
||||||
@ -46,6 +48,59 @@ const BASE_STYLE: StyleSpecification = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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: [`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Satellite/{z}/{y}/{x}.jpeg`],
|
||||||
|
tileSize: 256,
|
||||||
|
attribution: '© <a href="https://www.vworld.kr">국토지리정보원 VWorld</a>',
|
||||||
|
},
|
||||||
|
'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 MODEL_COLORS: Record<PredictionModel, string> = {
|
const MODEL_COLORS: Record<PredictionModel, string> = {
|
||||||
'KOSPS': '#06b6d4',
|
'KOSPS': '#06b6d4',
|
||||||
@ -124,7 +179,7 @@ interface MapViewProps {
|
|||||||
sensitiveResources?: SensitiveResource[]
|
sensitiveResources?: SensitiveResource[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록)
|
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
||||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
|
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
|
||||||
@ -132,6 +187,20 @@ function DeckGLOverlay({ layers }: { layers: any[] }) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3D 모드 pitch/bearing 제어 컴포넌트 (Map 내부에서 useMap() 사용)
|
||||||
|
function MapPitchController({ threeD }: { threeD: boolean }) {
|
||||||
|
const { current: map } = useMap()
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return
|
||||||
|
map.easeTo(
|
||||||
|
threeD
|
||||||
|
? { pitch: 45, bearing: -17, duration: 800 }
|
||||||
|
: { pitch: 0, bearing: 0, duration: 800 }
|
||||||
|
)
|
||||||
|
}, [threeD, map])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// 팝업 정보
|
// 팝업 정보
|
||||||
interface PopupInfo {
|
interface PopupInfo {
|
||||||
longitude: number
|
longitude: number
|
||||||
@ -157,6 +226,7 @@ export function MapView({
|
|||||||
backtrackReplay,
|
backtrackReplay,
|
||||||
sensitiveResources = [],
|
sensitiveResources = [],
|
||||||
}: MapViewProps) {
|
}: MapViewProps) {
|
||||||
|
const { mapToggles } = useMapStore()
|
||||||
const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER)
|
const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER)
|
||||||
const [currentTime, setCurrentTime] = useState(0)
|
const [currentTime, setCurrentTime] = useState(0)
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
@ -535,6 +605,9 @@ export function MapView({
|
|||||||
sensitiveResources,
|
sensitiveResources,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// 3D 모드에 따른 지도 스타일 전환
|
||||||
|
const currentMapStyle = mapToggles.threeD ? SATELLITE_3D_STYLE : BASE_STYLE
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full relative">
|
<div className="w-full h-full relative">
|
||||||
<Map
|
<Map
|
||||||
@ -543,12 +616,15 @@ export function MapView({
|
|||||||
latitude: center[0],
|
latitude: center[0],
|
||||||
zoom: zoom,
|
zoom: zoom,
|
||||||
}}
|
}}
|
||||||
mapStyle={BASE_STYLE}
|
mapStyle={currentMapStyle}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
style={{ cursor: isSelectingLocation ? 'crosshair' : 'grab' }}
|
style={{ cursor: isSelectingLocation ? 'crosshair' : 'grab' }}
|
||||||
onClick={handleMapClick}
|
onClick={handleMapClick}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
>
|
>
|
||||||
|
{/* 3D 모드 pitch 제어 */}
|
||||||
|
<MapPitchController threeD={mapToggles.threeD} />
|
||||||
|
|
||||||
{/* WMS 레이어 */}
|
{/* WMS 레이어 */}
|
||||||
{wmsLayers.map(layer => (
|
{wmsLayers.map(layer => (
|
||||||
<Source
|
<Source
|
||||||
@ -572,7 +648,7 @@ export function MapView({
|
|||||||
</Source>
|
</Source>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* deck.gl 오버레이 */}
|
{/* deck.gl 오버레이 (인터리브드: 일반 레이어) */}
|
||||||
<DeckGLOverlay layers={deckLayers} />
|
<DeckGLOverlay layers={deckLayers} />
|
||||||
|
|
||||||
{/* 사고 위치 마커 (MapLibre Marker) */}
|
{/* 사고 위치 마커 (MapLibre Marker) */}
|
||||||
|
|||||||
21
frontend/src/common/store/mapStore.ts
Normal file
21
frontend/src/common/store/mapStore.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
interface MapToggles {
|
||||||
|
s57: boolean;
|
||||||
|
s101: boolean;
|
||||||
|
threeD: boolean;
|
||||||
|
satellite: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapState {
|
||||||
|
mapToggles: MapToggles;
|
||||||
|
toggleMap: (key: keyof MapToggles) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMapStore = create<MapState>((set) => ({
|
||||||
|
mapToggles: { s57: true, s101: false, threeD: false, satellite: false },
|
||||||
|
toggleMap: (key) =>
|
||||||
|
set((s) => ({
|
||||||
|
mapToggles: { ...s.mapToggles, [key]: !s.mapToggles[key] },
|
||||||
|
})),
|
||||||
|
}))
|
||||||
불러오는 중...
Reference in New Issue
Block a user