bxztApp/packages/screen/src/map/components/SceneModeToggle.vue
Zzc b432d8d6b7 feat(map): 集成Cesium 3D地图系统与控件和服务
使用Cesium添加全面的3D地图功能,包括:
- 地图视口和控件组件
- 图层管理,含底图切换器和目录控制
- 相机、实体和查询服务
- 罗盘和场景模式切换UI组件
- 支持工具、存储和数据配置

更新构建配置以支持Cesium集成和SVG图标。
2025-11-07 15:04:37 +08:00

537 lines
14 KiB
Vue

<template>
<button
class="scene-mode-toggle"
type="button"
:disabled="!canToggle"
:title="buttonTitle"
@click="toggleSceneMode"
aria-live="polite"
>
{{ buttonLabel }}
</button>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import * as Cesium from 'cesium'
import useMapStore from '@/map/stores/mapStore'
const mapStore = useMapStore()
const sceneRef = shallowRef(null)
const cameraService = shallowRef(null)
const layerService = shallowRef(null)
const is3DMode = ref(true)
const isMorphing = ref(false)
const pendingRestoreState = shallowRef(null)
let detachReadyListener = null
let detachMorphStartListener = null
let detachMorphCompleteListener = null
/**
* 清理由 Cesium 场景注册的监听。
* @returns {void} 无返回值
*/
function cleanupScene() {
if (typeof detachMorphStartListener === 'function') {
detachMorphStartListener()
}
if (typeof detachMorphCompleteListener === 'function') {
detachMorphCompleteListener()
}
detachMorphStartListener = null
detachMorphCompleteListener = null
sceneRef.value = null
is3DMode.value = true
isMorphing.value = false
pendingRestoreState.value = null
}
/**
* 同步场景模式状态,更新按钮显示。
* @param {Cesium.Scene | null} scene Cesium 场景
* @returns {void} 无返回值
*/
function syncSceneMode(scene) {
if (!scene) {
is3DMode.value = true
return
}
const mode = scene.mode
is3DMode.value = mode !== Cesium.SceneMode.SCENE2D
}
/**
* 解析并缓存地图相关服务。
* @returns {void} 无返回值
*/
function resolveServices() {
if (!mapStore.ready) {
cameraService.value = null
layerService.value = null
return
}
try {
const { camera, layer } = mapStore.services()
cameraService.value = camera
layerService.value = layer
} catch (error) {
console.warn('解析地图服务失败', error)
cameraService.value = null
layerService.value = null
}
}
/**
* 构造相机快照。
* @param {Cesium.Camera | null} camera Cesium 相机
* @returns {object | null} 相机视角参数
*/
function buildCameraSnapshot(camera) {
if (!camera) return null
const cartographic = camera.positionCartographic
if (!cartographic) return null
return {
lon: Cesium.Math.toDegrees(cartographic.longitude),
lat: Cesium.Math.toDegrees(cartographic.latitude),
height: cartographic.height,
heading: Cesium.Math.toDegrees(camera.heading || 0),
pitch: Cesium.Math.toDegrees(camera.pitch || 0),
roll: Cesium.Math.toDegrees(camera.roll || 0),
}
}
/**
* 矩形转经纬度对象。
* @param {Cesium.Rectangle} rectangle Cesium 矩形
* @returns {{ west:number, south:number, east:number, north:number }} 经纬度范围
*/
function rectangleToDegrees(rectangle) {
return {
west: Cesium.Math.toDegrees(rectangle.west),
south: Cesium.Math.toDegrees(rectangle.south),
east: Cesium.Math.toDegrees(rectangle.east),
north: Cesium.Math.toDegrees(rectangle.north),
}
}
/**
* 记录当前场景状态(相机、可视范围、图层)。
* @param {Cesium.Viewer} viewer Cesium Viewer
* @returns {object | null} 场景快照
*/
function captureSceneSnapshot(viewer) {
if (!viewer) return null
const snapshot = {
cameraView: null,
viewRectangle: null,
imageryOrder: [],
vectorOrder: [],
layerStates: {},
}
try {
if (cameraService.value && typeof cameraService.value.getCurrentView === 'function') {
snapshot.cameraView = cameraService.value.getCurrentView()
} else {
snapshot.cameraView = buildCameraSnapshot(viewer.camera)
}
} catch (error) {
console.warn('获取相机视角失败', error)
snapshot.cameraView = buildCameraSnapshot(viewer.camera)
}
try {
const rectangle = viewer.camera.computeViewRectangle(viewer.scene?.globe?.ellipsoid)
if (rectangle) {
snapshot.viewRectangle = rectangleToDegrees(rectangle)
}
} catch (error) {
console.warn('计算可视范围失败', error)
}
const layerEntries = Object.entries(mapStore.layers || {})
const objectLookup = new Map()
layerEntries.forEach(([id, record]) => {
if (!record) return
if (record.obj) {
objectLookup.set(record.obj, { id, record })
}
snapshot.layerStates[id] = {
show: record.show,
opacity: typeof record.opacity === 'number' ? record.opacity : null,
type: record.type,
splitDirection: record.type === 'imagery' && record.obj ? record.obj.splitDirection : undefined,
}
})
try {
const imageryLayers = viewer.imageryLayers
const imageryOrder = []
for (let i = 0; i < imageryLayers.length; i += 1) {
const layer = imageryLayers.get(i)
const info = objectLookup.get(layer)
if (info) imageryOrder.push(info.id)
}
snapshot.imageryOrder = imageryOrder
} catch (error) {
console.warn('记录影像图层顺序失败', error)
}
try {
const dataSources = viewer.dataSources
const vectorOrder = []
for (let i = 0; i < dataSources.length; i += 1) {
const dataSource = dataSources.get(i)
const info = objectLookup.get(dataSource)
if (info) vectorOrder.push(info.id)
}
snapshot.vectorOrder = vectorOrder
} catch (error) {
console.warn('记录矢量图层顺序失败', error)
}
return snapshot
}
/**
* 还原图层显隐、顺序等状态。
* @param {Cesium.Viewer} viewer Cesium Viewer
* @param {object | null} snapshot 场景快照
* @returns {void} 无返回值
*/
function applyLayerState(viewer, snapshot) {
if (!snapshot) return
const stateMap = snapshot.layerStates || {}
if (layerService.value) {
Object.entries(stateMap).forEach(([id, info]) => {
if (!info) return
try {
if (info.show != null) layerService.value.showLayer(id, info.show)
if (info.type === 'imagery' && typeof info.opacity === 'number') {
layerService.value.setOpacity(id, info.opacity)
}
} catch (error) {
console.warn(`恢复图层状态失败: ${id}`, error)
}
})
} else {
Object.entries(stateMap).forEach(([id, info]) => {
if (!info) return
const record = mapStore.layers?.[id]
if (!record || !record.obj) return
if (info.show != null) {
record.obj.show = !!info.show
record.show = !!info.show
}
if (info.type === 'imagery' && typeof info.opacity === 'number') {
record.obj.alpha = info.opacity
record.opacity = info.opacity
}
})
}
const imageryOrder = Array.isArray(snapshot.imageryOrder) ? snapshot.imageryOrder : []
if (imageryOrder.length) {
imageryOrder.slice().reverse().forEach((id) => {
const info = stateMap[id]
const record = mapStore.layers?.[id]
if (!record || record.type !== 'imagery' || !record.obj) return
try {
if (layerService.value && typeof layerService.value.moveLayer === 'function') {
layerService.value.moveLayer(id, 'bottom')
} else if (viewer?.imageryLayers?.contains(record.obj)) {
viewer.imageryLayers.lowerToBottom(record.obj)
}
if (info && info.splitDirection != null) {
record.obj.splitDirection = info.splitDirection
}
} catch (error) {
console.warn(`恢复影像图层顺序失败: ${id}`, error)
}
})
} else {
Object.entries(stateMap).forEach(([id, info]) => {
if (!info || info.splitDirection == null) return
const record = mapStore.layers?.[id]
if (record?.type === 'imagery' && record.obj) {
record.obj.splitDirection = info.splitDirection
}
})
}
const vectorOrder = Array.isArray(snapshot.vectorOrder) ? snapshot.vectorOrder : []
if (vectorOrder.length) {
vectorOrder.slice().reverse().forEach((id) => {
const record = mapStore.layers?.[id]
if (!record || !(record.type === 'vector' || record.type === 'datasource') || !record.obj) return
try {
if (layerService.value && typeof layerService.value.moveLayer === 'function') {
layerService.value.moveLayer(id, 'bottom')
} else if (viewer?.dataSources?.contains(record.obj)) {
viewer.dataSources.lowerToBottom(record.obj)
}
} catch (error) {
console.warn(`恢复矢量图层顺序失败: ${id}`, error)
}
})
}
}
/**
* 还原相机视角与可视范围。
* @param {Cesium.Scene} scene Cesium 场景
* @param {Cesium.Viewer} viewer Cesium Viewer
* @param {object | null} snapshot 场景快照
* @returns {void} 无返回值
*/
function applyCameraState(scene, viewer, snapshot) {
if (!snapshot) return
const cameraView = snapshot.cameraView
try {
if (scene.mode === Cesium.SceneMode.SCENE2D) {
if (snapshot.viewRectangle) {
const rect = Cesium.Rectangle.fromDegrees(
snapshot.viewRectangle.west,
snapshot.viewRectangle.south,
snapshot.viewRectangle.east,
snapshot.viewRectangle.north,
)
viewer.camera.setView({ destination: rect })
} else if (cameraView) {
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(
Number(cameraView.lon) || 0,
Number(cameraView.lat) || 0,
Number(cameraView.height) || 1500,
),
})
}
} else if (cameraView) {
if (cameraService.value && typeof cameraService.value.setView === 'function') {
cameraService.value.setView(cameraView)
} else {
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(
Number(cameraView.lon) || 0,
Number(cameraView.lat) || 0,
Number(cameraView.height) || 1500,
),
orientation: {
heading: Cesium.Math.toRadians(Number(cameraView.heading) || 0),
pitch: Cesium.Math.toRadians(Number(cameraView.pitch) || 0),
roll: Cesium.Math.toRadians(Number(cameraView.roll) || 0),
},
})
}
}
if (viewer?.scene?.requestRender) {
viewer.scene.requestRender()
}
} catch (error) {
console.warn('恢复相机视角失败', error)
}
}
/**
* 场景模式切换完成后恢复快照状态。
* @param {Cesium.Scene} scene Cesium 场景
* @returns {void} 无返回值
*/
function restoreSceneAfterMorph(scene) {
const payload = pendingRestoreState.value
pendingRestoreState.value = null
if (!payload || !mapStore.ready) return
resolveServices()
let viewer = null
try {
viewer = mapStore.getViewer()
} catch (error) {
console.warn('恢复场景失败,未获取到 Viewer', error)
return
}
if (!viewer) return
applyLayerState(viewer, payload.snapshot)
applyCameraState(scene, viewer, payload.snapshot)
}
/**
* 挂载场景监听,感知模式切换。
* @param {Cesium.Scene | null} scene Cesium 场景
* @returns {void} 无返回值
*/
function attachScene(scene) {
if (sceneRef.value === scene) {
syncSceneMode(scene)
return
}
if (typeof detachMorphStartListener === 'function') {
detachMorphStartListener()
}
if (typeof detachMorphCompleteListener === 'function') {
detachMorphCompleteListener()
}
sceneRef.value = scene
if (!scene) {
syncSceneMode(null)
return
}
syncSceneMode(scene)
const handleMorphStart = () => {
isMorphing.value = true
}
const handleMorphComplete = () => {
isMorphing.value = false
syncSceneMode(scene)
restoreSceneAfterMorph(scene)
}
scene.morphStart.addEventListener(handleMorphStart)
scene.morphComplete.addEventListener(handleMorphComplete)
detachMorphStartListener = () => {
scene.morphStart.removeEventListener(handleMorphStart)
}
detachMorphCompleteListener = () => {
scene.morphComplete.removeEventListener(handleMorphComplete)
}
}
/**
* 从 Store 中解析并挂载场景及依赖服务。
* @returns {void} 无返回值
*/
function resolveSceneFromStore() {
if (!mapStore.ready) {
cleanupScene()
resolveServices()
return
}
try {
resolveServices()
const viewer = mapStore.getViewer()
attachScene(viewer?.scene ?? null)
} catch (error) {
console.warn('解析 Cesium 场景失败', error)
cleanupScene()
}
}
/**
* 切换 Cesium 二维与三维模式,并记录快照。
* @returns {void} 无返回值
*/
function toggleSceneMode() {
if (!mapStore.ready || isMorphing.value) return
let viewer = null
try {
viewer = mapStore.getViewer()
} catch (error) {
console.warn('切换模式失败,未获取到 Viewer', error)
return
}
if (!viewer) return
resolveServices()
const scene = viewer.scene
const targetIs3D = !is3DMode.value
const snapshot = captureSceneSnapshot(viewer)
pendingRestoreState.value = { snapshot }
is3DMode.value = targetIs3D
isMorphing.value = true
try {
if (targetIs3D) {
scene.morphTo3D(0.6)
} else {
scene.morphTo2D(0.6)
}
} catch (error) {
console.error('切换场景模式失败', error)
isMorphing.value = false
syncSceneMode(scene)
restoreSceneAfterMorph(scene)
}
}
const buttonLabel = computed(() => (is3DMode.value ? '2D' : '3D'))
const buttonTitle = computed(() => (is3DMode.value ? '切换到二维模式' : '切换到三维模式'))
const canToggle = computed(() => !!sceneRef.value && !isMorphing.value)
onMounted(() => {
if (mapStore.ready) {
resolveSceneFromStore()
}
detachReadyListener = mapStore.onReady(() => {
resolveSceneFromStore()
})
})
onBeforeUnmount(() => {
cleanupScene()
if (typeof detachReadyListener === 'function') {
detachReadyListener()
}
detachReadyListener = null
})
watch(
() => mapStore.ready,
(ready) => {
if (ready) {
resolveSceneFromStore()
} else {
cleanupScene()
resolveServices()
}
}
)
</script>
<style scoped lang="scss">
.scene-mode-toggle {
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: 14px;
font-weight: 600;
cursor: pointer;
pointer-events: auto;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.scene-mode-toggle:not(:disabled):hover {
background: #ffffff;
transform: translateY(-1px);
}
.scene-mode-toggle:disabled {
cursor: not-allowed;
opacity: 0.5;
}
@media (max-width: 768px) {
.scene-mode-toggle {
width: 36px;
height: 36px;
font-size: 13px;
}
}
</style>