386 lines
10 KiB
Vue
Raw Normal View History

<template>
<div class="map-controls-root">
<div v-if="showLayerDirectory" class="map-controls-anchor map-controls-anchor--top-left" aria-live="polite">
<LayerDirectoryControl />
</div>
<div :class="['map-controls-anchor map-controls-anchor--bottom-right', bottomRightClass]" :style="bottomRightStyle"
aria-live="polite">
<div class="map-controls__stack">
<div v-if="hasBottomRightControls" class="map-controls">
<button v-if="showZoomControl" class="map-controls__btn" type="button" title="放大" aria-label="放大"
@click="zoomIn" :disabled="isMapIdle">
<el-icon>
<Plus />
</el-icon>
</button>
<button v-if="showZoomControl" class="map-controls__btn" type="button" title="缩小" aria-label="缩小"
@click="zoomOut" :disabled="isMapIdle">
<el-icon>
<Minus />
</el-icon>
</button>
<button v-if="showHomeControl" class="map-controls__btn map-controls__btn--home" type="button" title="返回初始视图"
aria-label="返回初始视图" @click="goHome" :disabled="!canGoHome">
<el-icon>
<HomeFilled />
</el-icon>
</button>
</div>
<div v-if="showBaseMapSwitcher" class="map-controls">
<BaseMapSwitcher />
</div>
<div v-if="showSceneModeToggle" class="map-controls">
<SceneModeToggle />
</div>
<!-- 地图指南针 - 与其他控件垂直对齐 -->
<div v-if="showCompass" class="map-controls">
<MapCompass :visible="showCompass" :theme="compassTheme" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
import { useRoute } from 'vue-router'
import { HomeFilled, Minus, Plus } from '@element-plus/icons-vue'
import useMapStore from '@/map/stores/mapStore'
import useMapUiStore from '@/map/stores/mapUiStore'
import LayerDirectoryControl from './LayerDirectoryControl.vue'
import BaseMapSwitcher from './BaseMapSwitcher.vue'
import SceneModeToggle from './SceneModeToggle.vue'
import MapCompass from './MapCompass.vue'
const route = useRoute()
const mapStore = useMapStore()
const mapUiStore = useMapUiStore()
const DEFAULT_MAP_CONTROLS = Object.freeze({
layout: {
bottomRight: {
style: {},
},
},
bottomRight: [
{ id: 'zoom', order: 1 },
{ id: 'home', order: 2 },
],
components: {
layerDirectory: { visible: true },
baseMapSwitcher: { visible: true },
sceneModeToggle: { visible: true },
compass: { visible: true, theme: 'light' },
},
})
const SUPPORTED_CONTROLS = new Set(['zoom', 'home'])
const camera = shallowRef(null)
let detachReadyListener = null
const resolveCamera = () => {
try {
camera.value = mapStore.services().camera
} catch (err) {
camera.value = null
}
}
const mergePositionControls = (baseList = [], overrideList) => {
const map = new Map()
baseList.forEach((item, idx) => {
if (!item || typeof item.id !== 'string') return
map.set(item.id, {
...item,
order: typeof item.order === 'number' ? item.order : idx + 1,
})
})
if (Array.isArray(overrideList)) {
overrideList.forEach((item, idx) => {
if (!item || typeof item.id !== 'string') return
if (item.visible === false) {
map.delete(item.id)
return
}
const existing = map.get(item.id)
const nextOrder = typeof item.order === 'number'
? item.order
: existing && typeof existing.order === 'number'
? existing.order
: baseList.length + idx + 1
map.set(item.id, {
...existing,
...item,
id: item.id,
order: nextOrder,
})
})
}
return Array.from(map.values())
.filter((item) => item.visible !== false)
.sort((a, b) => {
const aOrder = typeof a.order === 'number' ? a.order : 0
const bOrder = typeof b.order === 'number' ? b.order : 0
return aOrder - bOrder
})
}
const mergeLayouts = (baseLayout = {}, overrideLayout = {}) => {
const result = {}
const positions = new Set([
...Object.keys(baseLayout || {}),
...Object.keys(overrideLayout || {}),
])
positions.forEach((position) => {
const baseEntry = baseLayout?.[position] || {}
const overrideEntry = overrideLayout?.[position] || {}
const combinedClass = [baseEntry.class, overrideEntry.class].filter(Boolean).join(' ')
const style = { ...(baseEntry.style || {}), ...(overrideEntry.style || {}) }
const entry = {}
if (combinedClass) entry.class = combinedClass
if (Object.keys(style).length) entry.style = style
if (Object.keys(entry).length) {
result[position] = entry
} else if (baseLayout?.[position] || overrideLayout?.[position]) {
result[position] = {}
}
})
return result
}
const mergeMapControlsConfig = (baseConfig = {}, overrideConfig = {}) => {
const result = {}
// Merge layout configuration
const baseLayout = baseConfig.layout || {}
const overrideLayout = overrideConfig.layout || {}
const layout = mergeLayouts(baseLayout, overrideLayout)
if (Object.keys(layout).length) {
result.layout = layout
}
// Merge components configuration (generic approach)
const baseComponents = baseConfig.components || {}
const overrideComponents = overrideConfig.components || {}
if (Object.keys(baseComponents).length || Object.keys(overrideComponents).length) {
result.components = {}
const componentKeys = new Set([...Object.keys(baseComponents), ...Object.keys(overrideComponents)])
componentKeys.forEach((key) => {
result.components[key] = {
...baseComponents[key],
...overrideComponents[key],
}
})
}
// Handle position-based controls (bottomRight, topLeft, etc.)
const excludedKeys = new Set(['layout', 'components'])
const baseKeys = Object.keys(baseConfig || {}).filter((key) => !excludedKeys.has(key))
const overrideKeys = Object.keys(overrideConfig || {}).filter((key) => !excludedKeys.has(key))
const positions = new Set([...baseKeys, ...overrideKeys])
positions.forEach((position) => {
result[position] = mergePositionControls(baseConfig?.[position], overrideConfig?.[position])
})
return result
}
const cloneConfig = (config) => mergeMapControlsConfig(config, {})
const resolvedConfig = computed(() => {
const matchedConfigs = route.matched
.map((record) => record.meta?.mapControls)
.filter(Boolean)
if (!matchedConfigs.length) {
return cloneConfig(DEFAULT_MAP_CONTROLS)
}
return matchedConfigs.reduce(
(acc, config) => mergeMapControlsConfig(acc, config),
cloneConfig(DEFAULT_MAP_CONTROLS)
)
})
const bottomRightControls = computed(() => {
const list = resolvedConfig.value.bottomRight || []
return list
.filter((item) => SUPPORTED_CONTROLS.has(item.id))
.filter((item) => mapUiStore.isControlVisible(item.id))
})
const bottomRightLayout = computed(() => resolvedConfig.value.layout?.bottomRight || {})
const bottomRightClass = computed(() => bottomRightLayout.value.class)
const bottomRightStyle = computed(() => bottomRightLayout.value.style)
const showZoomControl = computed(() => bottomRightControls.value.some((item) => item.id === 'zoom'))
const showHomeControl = computed(() => bottomRightControls.value.some((item) => item.id === 'home'))
const hasBottomRightControls = computed(() => bottomRightControls.value.length > 0)
// 控件显隐判定
const isComponentEnabledInRoute = (componentName) => {
return resolvedConfig.value.components?.[componentName]?.visible !== false
}
const resolveComponentVisibility = (componentName) => {
if (!isComponentEnabledInRoute(componentName)) return false
return mapUiStore.isControlVisible(componentName)
}
const showLayerDirectory = computed(() => resolveComponentVisibility('layerDirectory'))
const showBaseMapSwitcher = computed(() => resolveComponentVisibility('baseMapSwitcher'))
const showSceneModeToggle = computed(() => resolveComponentVisibility('sceneModeToggle'))
const showCompass = computed(() => resolveComponentVisibility('compass'))
// 指南针主题配置
const compassTheme = computed(() => {
const compassConfig = resolvedConfig.value.components?.compass || {}
return compassConfig.theme || 'light'
})
onMounted(() => {
resolveCamera()
detachReadyListener = mapStore.onReady(() => {
resolveCamera()
})
})
onBeforeUnmount(() => {
if (typeof detachReadyListener === 'function') {
detachReadyListener()
}
})
watch(
() => mapStore.ready,
(ready) => {
if (ready) {
resolveCamera()
} else {
camera.value = null
}
}
)
const isMapReady = computed(() => mapStore.ready && !!camera.value)
const isMapIdle = computed(() => !isMapReady.value)
const canGoHome = computed(() => isMapReady.value && !!mapStore.homeView)
function zoomIn() {
if (!isMapReady.value) return
camera.value.zoomIn()
}
function zoomOut() {
if (!isMapReady.value) return
camera.value.zoomOut()
}
async function goHome() {
if (!canGoHome.value) return
try {
await camera.value.flyToHome()
} catch (err) {
console.warn('flyToHome failed', err)
}
}
</script>
<style scoped lang="scss">
.map-controls-root {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 5;
}
.map-controls-anchor {
position: absolute;
pointer-events: none;
}
.map-controls-anchor--bottom-right {
right: 24px;
bottom: 24px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
}
.map-controls-anchor--top-left {
left: 16px;
top: 60px;
}
.map-controls__stack {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
pointer-events: none;
}
.map-controls__stack>* {
pointer-events: auto;
}
.map-controls {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
border-radius: 8px;
pointer-events: auto;
}
.map-controls__btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.92);
color: #1f1f1f;
font-size: 18px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.map-controls__btn:not(:disabled):hover {
background: #ffffff;
transform: translateY(-1px);
}
.map-controls__btn:disabled {
cursor: not-allowed;
opacity: 0.4;
}
.map-controls__btn--home {
font-size: 20px;
}
@media (max-width: 768px) {
.map-controls-anchor--bottom-right {
right: 12px;
bottom: 16px;
}
.map-controls__btn {
width: 36px;
height: 36px;
}
}
</style>