386 lines
10 KiB
Vue
386 lines
10 KiB
Vue
|
|
<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>
|