wing-ops/frontend/src/common/components/layout/TopBar.tsx
Nan Kyung Lee aba58b2227 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>
2026-03-01 21:28:30 +09:00

183 lines
8.7 KiB
TypeScript
Executable File
Raw Blame 히스토리

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useRef, useEffect, useMemo } from 'react'
import type { MainTab } from '../../types/navigation'
import { useAuthStore } from '../../store/authStore'
import { useMenuStore } from '../../store/menuStore'
import { useMapStore } from '../../store/mapStore'
interface TopBarProps {
activeTab: MainTab
onTabChange: (tab: MainTab) => void
}
export function TopBar({ activeTab, onTabChange }: TopBarProps) {
const [showQuickMenu, setShowQuickMenu] = useState(false)
const quickMenuRef = useRef<HTMLDivElement>(null)
const { hasPermission, user, logout } = useAuthStore()
const { menuConfig, isLoaded } = useMenuStore()
const { mapToggles, toggleMap } = useMapStore()
const tabs = useMemo(() => {
if (!isLoaded || menuConfig.length === 0) return []
return menuConfig
.filter((m) => m.enabled && hasPermission(m.id))
.sort((a, b) => a.order - b.order)
}, [hasPermission, user?.permissions, menuConfig, isLoaded])
useEffect(() => {
const handler = (e: MouseEvent) => {
if (quickMenuRef.current && !quickMenuRef.current.contains(e.target as Node)) setShowQuickMenu(false)
}
if (showQuickMenu) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [showQuickMenu])
return (
<div className="h-[52px] bg-bg-1 border-b border-border flex items-center justify-between px-5 relative z-[100]">
{/* Left Section */}
<div className="flex items-center gap-4">
{/* Logo */}
<div className="flex items-center">
<img src="/wing_logo_white.svg" alt="WING 해양환경 위기대응" className="h-3.5" />
</div>
{/* Divider */}
<div className="w-px h-6 bg-border-light" />
{/* Tabs */}
<div className="flex gap-0.5">
{tabs.map((tab) => {
const isIncident = tab.id === 'incidents'
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id as MainTab)}
title={tab.label}
className={`
px-2.5 xl:px-4 py-2 rounded-sm text-[13px] transition-all duration-200
font-korean tracking-[0.2px]
${isIncident ? 'font-extrabold border-l border-l-[rgba(99,102,241,0.2)] ml-1' : 'font-semibold'}
${
activeTab === tab.id
? isIncident
? 'text-[#a5b4fc] bg-[rgba(99,102,241,0.18)] shadow-[0_0_8px_rgba(99,102,241,0.3)]'
: 'text-[#22d3ee] bg-[rgba(6,182,212,0.15)] shadow-[0_0_8px_rgba(6,182,212,0.3)]'
: isIncident
? 'text-[#818cf8] hover:text-[#a5b4fc] hover:bg-[rgba(99,102,241,0.1)]'
: 'text-[#c8d6e5] hover:text-white hover:bg-[rgba(255,255,255,0.08)]'
}
`}
>
<span className="xl:hidden text-[16px] leading-none">{tab.icon}</span>
<span className="hidden xl:inline">{tab.label}</span>
</button>
)
})}
</div>
</div>
{/* Right Section */}
<div className="flex items-center gap-3">
{/* Status Badge */}
<div className="flex items-center gap-2 px-3 py-1.5 bg-[rgba(239,68,68,0.1)] border border-[rgba(239,68,68,0.2)] rounded-sm text-xs font-medium text-status-red animate-pulse">
<div className="w-1.5 h-1.5 rounded-full bg-status-red animate-pulse" />
</div>
{/* Icon Buttons */}
<button className="w-9 h-9 rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all">
🔔
</button>
{hasPermission('admin') && (
<button
onClick={() => onTabChange('admin')}
className={`w-9 h-9 rounded-sm border flex items-center justify-center transition-all ${
activeTab === 'admin'
? 'border-primary-cyan bg-[rgba(6,182,212,0.15)] text-primary-cyan'
: 'border-border bg-bg-3 text-text-2 hover:bg-bg-hover hover:text-text-1'
}`}
>
</button>
)}
{user && (
<div className="flex items-center gap-2 pl-2 border-l border-border">
<span className="text-[11px] text-text-2 font-korean">{user.name}</span>
<button
onClick={() => logout()}
className="px-2 py-1 text-[10px] font-semibold text-text-3 border border-border rounded hover:bg-bg-hover hover:text-text-1 transition-all font-korean"
title="로그아웃"
>
</button>
</div>
)}
{/* Quick Menu */}
<div ref={quickMenuRef} className="relative">
<button
onClick={() => setShowQuickMenu(!showQuickMenu)}
className={`w-9 h-9 rounded-sm border flex items-center justify-center transition-all ${
showQuickMenu
? 'border-primary-cyan bg-[rgba(6,182,212,0.15)] text-primary-cyan'
: 'border-border bg-bg-3 text-text-2 hover:bg-bg-hover hover:text-text-1'
}`}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><line x1="2" y1="4" x2="14" y2="4" /><line x1="2" y1="8" x2="14" y2="8" /><line x1="2" y1="12" x2="14" y2="12" /></svg>
</button>
{showQuickMenu && (
<div className="absolute top-[44px] right-0 w-[220px] bg-[rgba(18,25,41,0.97)] backdrop-blur-xl border border-border rounded-lg shadow-2xl z-[200] py-2 font-korean">
{/* 거리·면적 계산 */}
<div className="px-3 py-1.5 flex items-center gap-2 text-[11px] font-bold text-text-3">
<span>📐</span> ·
</div>
<button className="w-full px-3 py-2 flex items-center gap-2.5 text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] hover:text-text-1 transition-all">
<span className="text-[13px]"></span>
</button>
<button className="w-full px-3 py-2 flex items-center gap-2.5 text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] hover:text-text-1 transition-all">
<span className="text-[13px]"></span>
</button>
<div className="my-1.5 border-t border-border" />
{/* 출력 */}
<div className="px-3 py-1.5 flex items-center gap-2 text-[11px] font-bold text-text-3">
<span>🖨</span>
</div>
<button className="w-full px-3 py-2 flex items-center gap-2.5 text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] hover:text-text-1 transition-all">
<span className="text-[13px]">📸</span>
</button>
<button onClick={() => window.print()} className="w-full px-3 py-2 flex items-center gap-2.5 text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] hover:text-text-1 transition-all">
<span className="text-[13px]">🖨</span>
</button>
<div className="my-1.5 border-t border-border" />
{/* 지도 유형 */}
<div className="px-3 py-1.5 flex items-center gap-2 text-[11px] font-bold text-text-3">
<span>🗺</span>
</div>
{([
{ key: 's57' as const, label: 'S-57 전자해도', icon: '🗺' },
{ key: 's101' as const, label: 'S-101 전자해도', icon: '🗺' },
{ key: 'threeD' as const, label: '3D 지도', icon: '🗺' },
{ key: 'satellite' as const, label: '위성 영상', icon: '🛰' },
]).map(item => (
<button key={item.key} onClick={() => toggleMap(item.key)} className="w-full px-3 py-2 flex items-center justify-between text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] transition-all">
<span className="flex items-center gap-2.5">
<span className="text-[13px]">{item.icon}</span> {item.label}
</span>
<div className={`w-[34px] h-[18px] rounded-full transition-all relative ${mapToggles[item.key] ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'}`}>
<div className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow transition-all ${mapToggles[item.key] ? 'left-[16px]' : 'left-[2px]'}`} />
</div>
</button>
))}
</div>
)}
</div>
</div>
</div>
)
}