-
-
距离灾害点{{ forcePreset.searchRadius }}km范围内
-

-
+
+
+
距离灾害点{{ forcePreset.searchRadius }}km范围内
+

+
+
+
+ 10km
+ 30km
+ 50km
+
+
+
@@ -58,6 +71,17 @@
import { inject } from 'vue'
const { forcePreset } = inject('disasterData')
+const onDistanceChange = inject('onDistanceChange')
+
+/**
+ * 处理距离范围选择变更
+ * @param {number} distance - 选中的距离范围(km)
+ */
+const handleDistanceChange = (distance) => {
+ if (onDistanceChange) {
+ onDistanceChange(distance)
+ }
+}
diff --git a/packages/screen/src/views/3DSituationalAwarenessRefactor/components/LeftPanel/LocationPanel.vue b/packages/screen/src/views/3DSituationalAwarenessRefactor/components/LeftPanel/LocationPanel.vue
new file mode 100644
index 0000000..f3e6b42
--- /dev/null
+++ b/packages/screen/src/views/3DSituationalAwarenessRefactor/components/LeftPanel/LocationPanel.vue
@@ -0,0 +1,161 @@
+
+
+
+
+
+
+
+
{{ item.label }}
+
{{ item.value }}
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/screen/src/views/3DSituationalAwarenessRefactor/components/LeftPanel/index.vue b/packages/screen/src/views/3DSituationalAwarenessRefactor/components/LeftPanel/index.vue
index 9ae2148..ed722b7 100644
--- a/packages/screen/src/views/3DSituationalAwarenessRefactor/components/LeftPanel/index.vue
+++ b/packages/screen/src/views/3DSituationalAwarenessRefactor/components/LeftPanel/index.vue
@@ -1,30 +1,81 @@
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/screen/src/views/3DSituationalAwarenessRefactor/components/MapViewer/index.vue b/packages/screen/src/views/3DSituationalAwarenessRefactor/components/MapViewer/index.vue
index 8997b3e..748a8de 100644
--- a/packages/screen/src/views/3DSituationalAwarenessRefactor/components/MapViewer/index.vue
+++ b/packages/screen/src/views/3DSituationalAwarenessRefactor/components/MapViewer/index.vue
@@ -6,7 +6,10 @@
-
+
@@ -16,9 +19,38 @@ import { ref, onMounted } from 'vue'
import { MapViewport } from '@/map'
import MapControls from './MapControls.vue'
+/**
+ * 向外抛出的事件
+ * @event tool-change - 地图工具变化事件,包含 { tool: string, active: boolean }
+ * @event device-watch - 卫星设备观看状态变化事件,包含 boolean
+ */
+const emit = defineEmits(['tool-change', 'device-watch'])
+
// 延迟标志,确保 Teleport 目标元素已存在
const isMounted = ref(false)
+/**
+ * 处理地图工具变化
+ * 将 MapControls 的工具变化事件向上传递给父组件
+ *
+ * @param {Object} payload - 工具变化事件载荷
+ * @param {string} payload.tool - 工具标识(如 'modelCompare', 'measure' 等)
+ * @param {boolean} payload.active - 工具是否激活
+ */
+const handleToolChange = (payload) => {
+ emit('tool-change', payload)
+}
+
+/**
+ * 处理卫星设备观看状态变化
+ * 将 MapControls 的设备观看事件向上传递给父组件
+ *
+ * @param {boolean} isWatching - 是否正在观看卫星设备
+ */
+const handleDeviceWatch = (isWatching) => {
+ emit('device-watch', isWatching)
+}
+
onMounted(() => {
// 使用 nextTick 确保 DOM 完全渲染
setTimeout(() => {
diff --git a/packages/screen/src/views/3DSituationalAwarenessRefactor/components/RightPanel/VideoModal.vue b/packages/screen/src/views/3DSituationalAwarenessRefactor/components/RightPanel/VideoModal.vue
new file mode 100644
index 0000000..09774af
--- /dev/null
+++ b/packages/screen/src/views/3DSituationalAwarenessRefactor/components/RightPanel/VideoModal.vue
@@ -0,0 +1,432 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+

+
喊话
+
+
+
+
+

+
声音
+
+
+
+
+

+
视觉左移
+
+
+
+
+

+
视觉右移
+
+
+
+
+

+
视觉上移
+
+
+
+
+

+
视觉下移
+
+
+
+
+

+
缩小
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/screen/src/views/3DSituationalAwarenessRefactor/components/RightPanel/VideoMonitorGrid.vue b/packages/screen/src/views/3DSituationalAwarenessRefactor/components/RightPanel/VideoMonitorGrid.vue
index 1b3a5bc..71113a7 100644
--- a/packages/screen/src/views/3DSituationalAwarenessRefactor/components/RightPanel/VideoMonitorGrid.vue
+++ b/packages/screen/src/views/3DSituationalAwarenessRefactor/components/RightPanel/VideoMonitorGrid.vue
@@ -8,28 +8,58 @@
@audio="handleAudio"
@zoom="handleZoom"
/>
+
+
+
diff --git a/packages/screen/src/views/3DSituationalAwarenessRefactor/components/shared/MapTooltip.vue b/packages/screen/src/views/3DSituationalAwarenessRefactor/components/shared/MapTooltip.vue
new file mode 100644
index 0000000..aa815c8
--- /dev/null
+++ b/packages/screen/src/views/3DSituationalAwarenessRefactor/components/shared/MapTooltip.vue
@@ -0,0 +1,455 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/use3DTiles.js b/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/use3DTiles.js
new file mode 100644
index 0000000..b2423c8
--- /dev/null
+++ b/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/use3DTiles.js
@@ -0,0 +1,176 @@
+import { ref } from 'vue'
+import * as Cesium from 'cesium'
+import { getModelCompareConfig } from '../config/modelCompare.config'
+
+/**
+ * 3D Tiles加载管理 Composable
+ * 负责加载灾前/灾后3D模型数据,并支持分屏对比功能
+ */
+export function use3DTiles() {
+ const beforeTileset = ref(null)
+ const afterTileset = ref(null)
+
+ /**
+ * 加载3D Tileset
+ * @param {Cesium.Viewer} viewer - Cesium Viewer 实例
+ * @param {string} sceneType - 场景类型:'before' 或 'after'
+ * @param {boolean} autoZoom - 是否自动缩放到模型(默认false,保持用户设置的相机位置)
+ * @param {Cesium.SplitDirection} splitDirection - 分割方向(默认为 NONE)
+ */
+ const load3DTileset = async (viewer, sceneType = 'after', autoZoom = false, splitDirection = Cesium.SplitDirection.NONE) => {
+ if (!viewer) return null
+
+ try {
+ console.log(`[use3DTiles] 正在加载${sceneType === 'after' ? '灾后' : '灾前'}3D模型...`)
+
+ // 从配置中获取 URL
+ const config = getModelCompareConfig()
+ const tilesetConfig = sceneType === 'after' ? config.after3DTiles : config.before3DTiles
+ const url = tilesetConfig.url
+
+ const tileset = await Cesium.Cesium3DTileset.fromUrl(url, {
+ skipLevelOfDetail: true,
+ baseScreenSpaceError: 100,
+ skipScreenSpaceErrorFactor: 16,
+ skipLevels: 1,
+ immediatelyLoadDesiredLevelOfDetail: false,
+ loadSiblings: false,
+ maximumScreenSpaceError: 16.0, // 进一步增大,最大限度减少瓦片细化(之前是8.0)
+ dynamicScreenSpaceError: false, // 禁用动态屏幕空间误差调整
+ dynamicScreenSpaceErrorDensity: 0, // 禁用密度调整
+ dynamicScreenSpaceErrorFactor: 1, // 禁用动态因子
+ foveatedScreenSpaceError: false, // 禁用视锥细化
+ foveatedConeSize: 0.1, // 减小视锥大小
+ foveatedMinimumScreenSpaceErrorRelaxation: 0 // 禁用放松
+ })
+
+ // 将tileset添加到viewer的primitives
+ viewer.scene.primitives.add(tileset)
+
+ // 设置splitDirection(用于对比模式)
+ tileset.splitDirection = splitDirection
+
+ if (sceneType === 'after') {
+ afterTileset.value = tileset
+ } else {
+ beforeTileset.value = tileset
+ }
+
+ console.log(`[use3DTiles] ${sceneType === 'after' ? '灾后' : '灾前'}3D模型加载成功,splitDirection: ${splitDirection}`)
+
+ // 只有明确要求时才自动缩放到tileset
+ if (autoZoom) {
+ await viewer.zoomTo(tileset)
+ }
+
+ return tileset
+ } catch (error) {
+ console.error(`[use3DTiles] 加载${sceneType === 'after' ? '灾后' : '灾前'}3D模型失败:`, error)
+ return null
+ }
+ }
+
+ /**
+ * 等待 Tileset 完全就绪(包括首批瓦片加载)
+ *
+ * 这个函数确保:
+ * 1. Tileset 本身已就绪(readyPromise)
+ * 2. 初始视图的所有瓦片已加载完成(initialTilesLoaded)
+ *
+ * @param {Cesium.Cesium3DTileset} tileset - 要等待的 Tileset
+ * @param {number} timeout - 超时时间(毫秒),默认10秒
+ * @returns {Promise
}
+ */
+ const waitForTilesetReady = async (tileset, timeout = 10000) => {
+ if (!tileset) {
+ console.warn('[use3DTiles] waitForTilesetReady: tileset 为空')
+ return
+ }
+
+ try {
+ // 步骤1:等待 Tileset 基础就绪
+ await tileset.readyPromise
+ console.log('[use3DTiles] Tileset readyPromise 已完成')
+
+ // 步骤2:等待初始瓦片加载完成(带超时)
+ console.log('[use3DTiles] 等待初始瓦片加载...')
+
+ await Promise.race([
+ // 等待initialTilesLoaded事件
+ new Promise((resolve) => {
+ const handleInitialTilesLoaded = () => {
+ console.log('[use3DTiles] 初始瓦片加载完成(事件触发)')
+ tileset.initialTilesLoaded.removeEventListener(handleInitialTilesLoaded)
+ resolve()
+ }
+
+ tileset.initialTilesLoaded.addEventListener(handleInitialTilesLoaded)
+
+ // 如果已经加载完成,可能事件已经触发过了,直接resolve
+ // 通过检查tileset.tilesLoaded来判断
+ if (tileset.tilesLoaded) {
+ console.log('[use3DTiles] 瓦片已加载,直接继续')
+ tileset.initialTilesLoaded.removeEventListener(handleInitialTilesLoaded)
+ resolve()
+ }
+ }),
+ // 超时机制
+ new Promise((resolve) => {
+ setTimeout(() => {
+ console.warn(`[use3DTiles] 等待瓦片加载超时(${timeout}ms),继续执行`)
+ resolve()
+ }, timeout)
+ })
+ ])
+
+ console.log('[use3DTiles] Tileset 已完全就绪')
+ } catch (error) {
+ console.error('[use3DTiles] 等待 Tileset 就绪失败:', error)
+ // 即使失败也不抛出异常,允许程序继续执行
+ }
+ }
+
+ /**
+ * 移除3D Tileset
+ */
+ const remove3DTileset = (viewer, sceneType) => {
+ if (!viewer) return
+
+ const tileset = sceneType === 'after' ? afterTileset.value : beforeTileset.value
+ if (tileset) {
+ viewer.scene.primitives.remove(tileset)
+ if (sceneType === 'after') {
+ afterTileset.value = null
+ } else {
+ beforeTileset.value = null
+ }
+ console.log(`[use3DTiles] ${sceneType === 'after' ? '灾后' : '灾前'}3D模型已移除`)
+ }
+ }
+
+ /**
+ * 更新 Tileset 的 splitDirection
+ * @param {string} sceneType - 场景类型:'before' 或 'after'
+ * @param {Cesium.SplitDirection} splitDirection - 新的分割方向
+ */
+ const updateTilesetSplitDirection = (sceneType, splitDirection) => {
+ const tileset = sceneType === 'after' ? afterTileset.value : beforeTileset.value
+ if (tileset) {
+ tileset.splitDirection = splitDirection
+ console.log(`[use3DTiles] ${sceneType === 'after' ? '灾后' : '灾前'}3D模型 splitDirection 已更新为: ${splitDirection}`)
+ } else {
+ console.warn(`[use3DTiles] ${sceneType === 'after' ? '灾后' : '灾前'}3D模型不存在,无法更新 splitDirection`)
+ }
+ }
+
+ return {
+ beforeTileset,
+ afterTileset,
+ load3DTileset,
+ waitForTilesetReady,
+ remove3DTileset,
+ updateTilesetSplitDirection
+ }
+}
+
+export default use3DTiles
diff --git a/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useDisasterData.js b/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useDisasterData.js
index 85b9554..f5c5908 100644
--- a/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useDisasterData.js
+++ b/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useDisasterData.js
@@ -74,14 +74,30 @@ export function useDisasterData() {
}
])
- // 调度力量建议
- const dispatchSuggestion = ref({
- supplies: 23,
- personnel: 124,
- blockInfo: '需发布',
- stations: 4,
- excavators: 2,
- trafficControl: '需要'
+ // 调度力量建议(根据力量预置数据动态计算)
+ const dispatchSuggestion = computed(() => {
+ // 根据实际资源计算建议调度力量
+ const stationsCount = forcePreset.value.stations?.length || 0
+
+ return {
+ // 应急物资建议数:基于装备数量
+ supplies: forcePreset.value.equipment,
+
+ // 应急人员建议数:取总人员的一部分(约5-10%)作为调度建议
+ personnel: Math.min(Math.ceil(forcePreset.value.personnel * 0.06), forcePreset.value.personnel),
+
+ // 养护站建议数:使用实际可用养护站数量
+ stations: stationsCount,
+
+ // 挖掘机建议数:每2个养护站配置1台挖掘机
+ excavators: Math.max(1, Math.ceil(stationsCount / 2)),
+
+ // 阻断信息:固定建议
+ blockInfo: '需发布',
+
+ // 交通管制:固定建议
+ trafficControl: '需要'
+ }
})
// 计算属性
@@ -93,12 +109,68 @@ export function useDisasterData() {
)
})
+ /**
+ * 更新搜索半径
+ * @param {number} radius - 新的搜索半径(km)
+ */
+ const updateSearchRadius = (radius) => {
+ if (typeof radius === 'number' && radius > 0) {
+ forcePreset.value.searchRadius = radius
+ console.log(`[useDisasterData] 搜索半径已更新为: ${radius}km`)
+ } else {
+ console.warn('[useDisasterData] 无效的搜索半径值:', radius)
+ }
+ }
+
+ /**
+ * 更新力量预置数据
+ * @param {Object} emergencyResourcesData - 接口返回的应急资源数据
+ * @param {number} emergencyResourcesData.equipmentCount - 应急装备数量
+ * @param {number} emergencyResourcesData.personnelCount - 应急人员数量
+ * @param {Array} emergencyResourcesData.stations - 养护站列表
+ * @param {string} emergencyResourcesData.stations[].stationId - 养护站ID
+ * @param {string} emergencyResourcesData.stations[].stationName - 养护站名称
+ * @param {number} emergencyResourcesData.stations[].distance - 距离(km)
+ */
+ const updateForcePreset = (emergencyResourcesData) => {
+ if (!emergencyResourcesData) {
+ console.warn('[useDisasterData] 应急资源数据为空,跳过更新')
+ return
+ }
+
+ const { equipmentCount, personnelCount, stations } = emergencyResourcesData
+
+ // 更新装备和人员数量
+ if (typeof equipmentCount === 'number') {
+ forcePreset.value.equipment = equipmentCount
+ }
+ if (typeof personnelCount === 'number') {
+ forcePreset.value.personnel = personnelCount
+ }
+
+ // 更新养护站列表
+ if (Array.isArray(stations)) {
+ forcePreset.value.stations = stations.map((station) => ({
+ id: station.stationId,
+ name: station.stationName?.trim() || '未命名养护站',
+ distance: typeof station.distance === 'number'
+ ? Number(station.distance.toFixed(2))
+ : 0,
+ type: 'maintenance'
+ }))
+ }
+
+ console.log('[useDisasterData] 力量预置数据已更新:', forcePreset.value)
+ }
+
return {
disasterInfo,
forcePreset,
forceDispatch,
collaborationInfo,
dispatchSuggestion,
- totalResources
+ totalResources,
+ updateForcePreset,
+ updateSearchRadius
}
}
diff --git a/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useDualMapCompare.js b/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useDualMapCompare.js
new file mode 100644
index 0000000..1dfd86d
--- /dev/null
+++ b/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useDualMapCompare.js
@@ -0,0 +1,258 @@
+import { ref, onUnmounted } from 'vue'
+import * as Cesium from 'cesium'
+import { use3DTiles } from './use3DTiles'
+
+/**
+ * 双地图对比模式
+ * 使用两个独立的Cesium Viewer实现并排对比
+ * 左侧显示灾前场景,右侧显示灾后场景
+ */
+export function useDualMapCompare() {
+ /** 左侧Viewer引用 */
+ const leftViewer = ref(null)
+
+ /** 右侧Viewer引用 */
+ const rightViewer = ref(null)
+
+ /** 对比模式是否激活 */
+ const isCompareMode = ref(false)
+
+ /** 相机同步监听器移除函数 */
+ let cameraSyncRemover = null
+
+ /** 左侧3D Tileset(灾前) */
+ let leftTileset = null
+
+ /** 右侧3D Tileset(灾后,主地图的tileset) */
+ let rightTileset = null
+
+ const { load3DTileset } = use3DTiles()
+
+ /**
+ * 初始化左侧Viewer(灾前场景)
+ * @param {HTMLElement} container - 容器元素
+ * @returns {Cesium.Viewer}
+ */
+ const initLeftViewer = (container) => {
+ if (!container) {
+ console.error('[useDualMapCompare] 左侧容器元素不存在')
+ return null
+ }
+
+ // 创建左侧viewer
+ const viewer = new Cesium.Viewer(container, {
+ animation: false,
+ baseLayerPicker: false,
+ fullscreenButton: false,
+ geocoder: false,
+ homeButton: false,
+ infoBox: false,
+ sceneModePicker: false,
+ selectionIndicator: false,
+ timeline: false,
+ navigationHelpButton: false,
+ scene3DOnly: true,
+ shouldAnimate: false,
+ })
+
+ // 移除默认的Cesium logo和版权信息
+ viewer.cesiumWidget.creditContainer.style.display = 'none'
+
+ leftViewer.value = viewer
+ console.log('[useDualMapCompare] 左侧Viewer初始化成功')
+
+ return viewer
+ }
+
+ /**
+ * 设置相机同步
+ * 右侧相机移动时,左侧相机跟随
+ * @param {Cesium.Viewer} rightViewerInstance - 右侧Viewer(主地图)
+ * @param {Cesium.Viewer} leftViewerInstance - 左侧Viewer(对比地图)
+ */
+ const setupCameraSync = (rightViewerInstance, leftViewerInstance) => {
+ if (!rightViewerInstance || !leftViewerInstance) {
+ console.warn('[useDualMapCompare] Viewer未初始化,无法设置相机同步')
+ return
+ }
+
+ console.log('[useDualMapCompare] 设置相机同步(右侧主地图 → 左侧对比地图)...')
+
+ // 监听右侧相机变化
+ const syncCamera = () => {
+ if (!leftViewerInstance || leftViewerInstance.isDestroyed()) return
+
+ const rightCamera = rightViewerInstance.camera
+ const leftCamera = leftViewerInstance.camera
+
+ // 同步位置和方向
+ leftCamera.setView({
+ destination: rightCamera.position.clone(),
+ orientation: {
+ heading: rightCamera.heading,
+ pitch: rightCamera.pitch,
+ roll: rightCamera.roll
+ }
+ })
+ }
+
+ // 添加监听器
+ rightViewerInstance.camera.changed.addEventListener(syncCamera)
+
+ // 保存移除函数
+ cameraSyncRemover = () => {
+ rightViewerInstance.camera.changed.removeEventListener(syncCamera)
+ console.log('[useDualMapCompare] 相机同步已移除')
+ }
+
+ console.log('[useDualMapCompare] 相机同步设置完成')
+ }
+
+ /**
+ * 启用对比模式
+ * @param {Cesium.Viewer} rightViewerInstance - 右侧Viewer实例(主地图,灾后场景)
+ */
+ const enableCompareMode = async (rightViewerInstance) => {
+ if (!rightViewerInstance) {
+ console.error('[useDualMapCompare] 右侧主地图Viewer未初始化')
+ return
+ }
+
+ console.log('[useDualMapCompare] 启用对比模式...')
+
+ rightViewer.value = rightViewerInstance
+
+ // 查找左侧容器(容器已存在于DOM中)
+ const leftContainer = document.getElementById('leftCesiumContainer')
+ if (!leftContainer) {
+ console.error('[useDualMapCompare] 找不到左侧容器元素')
+ return
+ }
+
+ // 先设置状态,触发CSS动画
+ isCompareMode.value = true
+
+ // 等待一小段时间让CSS过渡开始
+ await new Promise(resolve => setTimeout(resolve, 50))
+
+ // 初始化左侧Viewer
+ const leftViewerInstance = initLeftViewer(leftContainer)
+ if (!leftViewerInstance) {
+ console.error('[useDualMapCompare] 左侧Viewer初始化失败')
+ isCompareMode.value = false
+ return
+ }
+
+ // 立即同步右侧相机的当前位置到左侧
+ console.log('[useDualMapCompare] 同步初始相机位置...')
+ const rightCamera = rightViewerInstance.camera
+ leftViewerInstance.camera.setView({
+ destination: rightCamera.position.clone(),
+ orientation: {
+ heading: rightCamera.heading,
+ pitch: rightCamera.pitch,
+ roll: rightCamera.roll
+ }
+ })
+
+ // 设置相机同步(右侧主地图 → 左侧对比地图)
+ setupCameraSync(rightViewerInstance, leftViewerInstance)
+
+ // 异步加载灾前模型到左侧(不阻塞对比模式启用)
+ // 让模型在后台加载,用户可以立即看到对比效果
+ console.log('[useDualMapCompare] 开始异步加载左侧灾前模型...')
+ load3DTileset(leftViewerInstance, 'before', false)
+ .then(tileset => {
+ if (tileset) {
+ leftTileset = tileset
+ console.log('[useDualMapCompare] 左侧灾前模型加载完成')
+ }
+ })
+ .catch(error => {
+ console.error('[useDualMapCompare] 左侧模型加载失败:', error)
+ })
+
+ // 右侧保持灾后模型(已加载)
+
+ // 触发左侧viewer resize
+ setTimeout(() => {
+ if (leftViewerInstance && leftViewerInstance.canvas) {
+ leftViewerInstance.resize()
+ leftViewerInstance.camera.changed.raiseEvent()
+ }
+ // 同时触发右侧viewer resize
+ if (rightViewerInstance && rightViewerInstance.canvas) {
+ rightViewerInstance.resize()
+ }
+ }, 350)
+
+ console.log('[useDualMapCompare] 对比模式已启用')
+ }
+
+ /**
+ * 禁用对比模式
+ */
+ const disableCompareMode = () => {
+ console.log('[useDualMapCompare] 禁用对比模式...')
+
+ // 移除相机同步
+ if (cameraSyncRemover) {
+ cameraSyncRemover()
+ cameraSyncRemover = null
+ }
+
+ // 销毁左侧Viewer
+ if (leftViewer.value && !leftViewer.value.isDestroyed()) {
+ // 清理左侧tileset
+ if (leftTileset) {
+ leftViewer.value.scene.primitives.remove(leftTileset)
+ leftTileset = null
+ }
+
+ leftViewer.value.destroy()
+ leftViewer.value = null
+ console.log('[useDualMapCompare] 左侧Viewer已销毁')
+ }
+
+ // 触发右侧viewer resize恢复全屏
+ if (rightViewer.value && !rightViewer.value.isDestroyed()) {
+ setTimeout(() => {
+ if (rightViewer.value && rightViewer.value.canvas) {
+ rightViewer.value.resize()
+ }
+ }, 350)
+ }
+
+ isCompareMode.value = false
+ console.log('[useDualMapCompare] 对比模式已禁用')
+ }
+
+ /**
+ * 切换对比模式
+ * @param {boolean} active - true启用,false禁用
+ * @param {Cesium.Viewer} rightViewerInstance - 右侧Viewer实例(主地图)
+ */
+ const toggleCompareMode = async (active, rightViewerInstance) => {
+ if (active) {
+ await enableCompareMode(rightViewerInstance)
+ } else {
+ disableCompareMode()
+ }
+ }
+
+ // 清理
+ onUnmounted(() => {
+ disableCompareMode()
+ })
+
+ return {
+ leftViewer,
+ rightViewer,
+ isCompareMode,
+ enableCompareMode,
+ disableCompareMode,
+ toggleCompareMode
+ }
+}
+
+export default useDualMapCompare
diff --git a/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useDualViewers.js b/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useDualViewers.js
new file mode 100644
index 0000000..ad45167
--- /dev/null
+++ b/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useDualViewers.js
@@ -0,0 +1,236 @@
+import { ref, onUnmounted } from 'vue'
+import * as Cesium from 'cesium'
+
+/**
+ * 双 Viewer 管理 Composable
+ * 创建和管理两个独立的 Cesium Viewer,用于并排对比显示
+ *
+ * 参考:WuRenJi 项目的双地图实现
+ */
+export function useDualViewers() {
+ /** @type {import('vue').Ref} */
+ const leftViewer = ref(null)
+
+ /** @type {import('vue').Ref} */
+ const rightViewer = ref(null)
+
+ /** 是否启用相机同步 */
+ let cameraSyncEnabled = false
+
+ /** 相机同步监听器的移除函数 */
+ let removeCameraSync = null
+
+ /**
+ * 初始化左侧 Viewer
+ * @param {string} containerId - 容器 DOM ID
+ * @param {Object} options - Cesium.Viewer 配置选项
+ * @returns {Cesium.Viewer}
+ */
+ const initLeftViewer = (containerId, options = {}) => {
+ if (leftViewer.value) {
+ console.warn('[useDualViewers] 左侧 Viewer 已存在')
+ return leftViewer.value
+ }
+
+ console.log('[useDualViewers] 初始化左侧 Viewer...')
+
+ const defaultOptions = {
+ animation: false,
+ baseLayerPicker: false,
+ fullscreenButton: false,
+ geocoder: false,
+ homeButton: false,
+ infoBox: false,
+ sceneModePicker: false,
+ selectionIndicator: false,
+ timeline: false,
+ navigationHelpButton: false,
+ scene3DOnly: true,
+ requestRenderMode: true, // 启用按需渲染
+ maximumRenderTimeChange: Infinity
+ }
+
+ leftViewer.value = new Cesium.Viewer(containerId, {
+ ...defaultOptions,
+ ...options
+ })
+
+ console.log('[useDualViewers] 左侧 Viewer 初始化完成')
+ return leftViewer.value
+ }
+
+ /**
+ * 初始化右侧 Viewer
+ * @param {string} containerId - 容器 DOM ID
+ * @param {Object} options - Cesium.Viewer 配置选项
+ * @returns {Cesium.Viewer}
+ */
+ const initRightViewer = (containerId, options = {}) => {
+ if (rightViewer.value) {
+ console.warn('[useDualViewers] 右侧 Viewer 已存在')
+ return rightViewer.value
+ }
+
+ console.log('[useDualViewers] 初始化右侧 Viewer...')
+
+ const defaultOptions = {
+ animation: false,
+ baseLayerPicker: false,
+ fullscreenButton: false,
+ geocoder: false,
+ homeButton: false,
+ infoBox: false,
+ sceneModePicker: false,
+ selectionIndicator: false,
+ timeline: false,
+ navigationHelpButton: false,
+ scene3DOnly: true,
+ requestRenderMode: true, // 启用按需渲染
+ maximumRenderTimeChange: Infinity
+ }
+
+ rightViewer.value = new Cesium.Viewer(containerId, {
+ ...defaultOptions,
+ ...options
+ })
+
+ console.log('[useDualViewers] 右侧 Viewer 初始化完成')
+ return rightViewer.value
+ }
+
+ /**
+ * 设置相机到指定位置
+ * @param {Cesium.Viewer} viewer
+ * @param {Object} view - 相机视图配置
+ */
+ const setCameraView = (viewer, view) => {
+ if (!viewer) return
+
+ viewer.camera.setView({
+ destination: Cesium.Cartesian3.fromDegrees(
+ view.lon || 106.5516,
+ view.lat || 29.5630,
+ view.height || 5000
+ ),
+ orientation: {
+ heading: Cesium.Math.toRadians(view.heading || 0),
+ pitch: Cesium.Math.toRadians(view.pitch || -45),
+ roll: view.roll || 0
+ }
+ })
+ }
+
+ /**
+ * 启用相机同步
+ * 左侧相机移动时,右侧相机自动跟随
+ */
+ const enableCameraSync = () => {
+ if (!leftViewer.value || !rightViewer.value) {
+ console.warn('[useDualViewers] 无法启用相机同步:Viewer 未初始化')
+ return
+ }
+
+ if (cameraSyncEnabled) {
+ console.warn('[useDualViewers] 相机同步已启用')
+ return
+ }
+
+ console.log('[useDualViewers] 启用相机同步')
+
+ // 监听左侧相机变化
+ const syncHandler = () => {
+ if (!rightViewer.value) return
+
+ const leftCamera = leftViewer.value.camera
+ const rightCamera = rightViewer.value.camera
+
+ // 同步右侧相机到左侧相机位置
+ rightCamera.setView({
+ destination: leftCamera.position.clone(),
+ orientation: {
+ heading: leftCamera.heading,
+ pitch: leftCamera.pitch,
+ roll: leftCamera.roll
+ }
+ })
+ }
+
+ leftViewer.value.camera.changed.addEventListener(syncHandler)
+ cameraSyncEnabled = true
+
+ // 保存移除函数
+ removeCameraSync = () => {
+ if (leftViewer.value) {
+ leftViewer.value.camera.changed.removeEventListener(syncHandler)
+ }
+ cameraSyncEnabled = false
+ console.log('[useDualViewers] 相机同步已禁用')
+ }
+ }
+
+ /**
+ * 禁用相机同步
+ */
+ const disableCameraSync = () => {
+ if (removeCameraSync) {
+ removeCameraSync()
+ removeCameraSync = null
+ }
+ }
+
+ /**
+ * 调整 Viewer 大小(在容器尺寸变化时调用)
+ */
+ const resizeViewers = () => {
+ if (leftViewer.value?.canvas) {
+ leftViewer.value.resize()
+ }
+ if (rightViewer.value?.canvas) {
+ rightViewer.value.resize()
+ // 触发相机变化事件,确保同步
+ if (cameraSyncEnabled) {
+ rightViewer.value.camera.changed.raiseEvent()
+ }
+ }
+ }
+
+ /**
+ * 销毁 Viewer
+ */
+ const destroyViewers = () => {
+ console.log('[useDualViewers] 销毁 Viewers...')
+
+ disableCameraSync()
+
+ if (leftViewer.value) {
+ leftViewer.value.destroy()
+ leftViewer.value = null
+ }
+
+ if (rightViewer.value) {
+ rightViewer.value.destroy()
+ rightViewer.value = null
+ }
+
+ console.log('[useDualViewers] Viewers 已销毁')
+ }
+
+ // 组件卸载时自动清理
+ onUnmounted(() => {
+ destroyViewers()
+ })
+
+ return {
+ leftViewer,
+ rightViewer,
+ initLeftViewer,
+ initRightViewer,
+ setCameraView,
+ enableCameraSync,
+ disableCameraSync,
+ resizeViewers,
+ destroyViewers
+ }
+}
+
+export default useDualViewers
diff --git a/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useMapMarkers.js b/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useMapMarkers.js
new file mode 100644
index 0000000..b30c5c7
--- /dev/null
+++ b/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useMapMarkers.js
@@ -0,0 +1,650 @@
+import { ref } from 'vue'
+import * as Cesium from 'cesium'
+import { cesiumDataConfig } from '../config/cesiumData'
+
+// 图标导入
+import soldierIcon from '../assets/images/SketchPngfbec927027ff9e49207749ebaafd229429315341fda199251b6dfb1723ff17fb.png'
+import deviceIcon from '../assets/images/SketchPng860d54f2a31f5f441fc6a88081224f1e98534bf6d5ca1246e420983bdf690380.png'
+import emergencyBaseIcon from '../assets/images/应急基地.png'
+
+// 默认高度偏移(米)
+const DEFAULT_HEIGHT_OFFSET = 10
+
+/**
+ * 地图标记管理 Composable
+ * 负责添加单兵、设备、应急基地标记以及塌陷区域
+ */
+export function useMapMarkers() {
+ const collapseAreaEntities = ref([])
+ const markerEntities = ref([])
+ const emergencyResourceEntities = ref([]) // 应急资源标记(由API数据动态生成)
+
+ /**
+ * 获取塌陷区域的所有位置点
+ * @returns {Cesium.Cartesian3[]}
+ */
+ const getCollapsePositions = () => {
+ if (!Array.isArray(cesiumDataConfig) || !cesiumDataConfig.length) {
+ return []
+ }
+ return cesiumDataConfig.map(point =>
+ new Cesium.Cartesian3(point.x, point.y, point.z)
+ )
+ }
+
+ /**
+ * 计算塌陷区域中心点
+ * 这个函数不依赖地形数据,可以在初始化早期调用
+ * @returns {Cesium.Cartesian3 | null}
+ */
+ const calculateCollapseCenter = () => {
+ const positions = getCollapsePositions()
+ if (!positions.length) {
+ console.warn('[useMapMarkers] 无法计算塌陷区域中心:配置数据为空')
+ return null
+ }
+ return Cesium.BoundingSphere.fromPoints(positions).center
+ }
+
+ /**
+ * 等待地形提供者就绪
+ * 用于确保地形数据已加载,以便准确采样地面高度
+ * @param {Cesium.Viewer} viewer
+ * @returns {Promise}
+ */
+ const waitForTerrainReady = async (viewer) => {
+ if (!viewer?.terrainProvider?.readyPromise) {
+ return
+ }
+
+ try {
+ await viewer.terrainProvider.readyPromise
+ console.log('[useMapMarkers] 地形提供者已就绪')
+ } catch (error) {
+ console.warn('[useMapMarkers] 地形加载失败,将使用默认高度:', error)
+ }
+ }
+
+ /**
+ * 解析经纬度坐标
+ * 对于3D Tiles场景,直接返回经纬度坐标,不进行高度采样
+ * 配合 RELATIVE_TO_GROUND 让Cesium自动处理高度
+ *
+ * @param {Cesium.Viewer} viewer
+ * @param {number} lon - 经度(度)
+ * @param {number} lat - 纬度(度)
+ * @param {number} heightOffset - 相对地面的高度偏移(米)
+ * @param {boolean} useSampledHeights - 是否使用采样高度(3D Tiles场景忽略此参数)
+ * @returns {Promise<{ position: Cesium.Cartesian3, samplingSucceeded: boolean }>}
+ */
+ const resolveCartesianFromDegrees = async (viewer, lon, lat, heightOffset, useSampledHeights) => {
+ // 对于3D Tiles场景,直接使用经纬度坐标
+ // heightOffset 会通过 RELATIVE_TO_GROUND 自动应用
+ return {
+ position: Cesium.Cartesian3.fromDegrees(lon, lat, heightOffset),
+ samplingSucceeded: false // 标记为未采样,使用 RELATIVE_TO_GROUND
+ }
+ }
+
+ /**
+ * 返回统一的高度参考模式
+ * 所有标记统一使用 CLAMP_TO_GROUND,让 Cesium 自动处理贴地
+ * 这样标记会自动跟随地形变化,但由于已禁用 3D Tiles 的动态细化,变化会很小
+ *
+ * @param {boolean} samplingSucceeded - 采样是否成功(保留参数用于日志)
+ * @returns {Cesium.HeightReference}
+ */
+ const resolveBillboardHeightReference = (samplingSucceeded) => {
+ // 统一使用 CLAMP_TO_GROUND 让 Cesium 自动贴地
+ // 配合禁用的 3D Tiles 动态细化,标记位置会保持稳定
+ return Cesium.HeightReference.CLAMP_TO_GROUND
+ }
+
+ /**
+ * 绘制塌陷区域多边形
+ * 这个函数不涉及高度采样,直接使用配置中的绝对坐标
+ * @param {Cesium.Viewer} viewer
+ * @returns {Cesium.Cartesian3 | null} 返回塌陷区域中心点
+ */
+ const drawCollapseArea = (viewer) => {
+ if (!viewer) {
+ console.warn('[useMapMarkers] drawCollapseArea: viewer 为空')
+ return null
+ }
+
+ const positions = getCollapsePositions()
+ if (!positions.length) {
+ console.warn('[useMapMarkers] drawCollapseArea: 配置数据为空')
+ return null
+ }
+
+ const entities = []
+
+ // 创建红色多边形
+ const polygonEntity = viewer.entities.add({
+ polygon: {
+ hierarchy: positions,
+ material: Cesium.Color.RED.withAlpha(0.35),
+ perPositionHeight: true,
+ outline: true,
+ outlineColor: Cesium.Color.RED.withAlpha(0.8),
+ classificationType: Cesium.ClassificationType.TERRAIN
+ }
+ })
+ entities.push(polygonEntity)
+
+ // 计算中心点
+ const center = Cesium.BoundingSphere.fromPoints(positions).center
+
+ // 添加标签
+ const labelEntity = viewer.entities.add({
+ position: center,
+ label: {
+ text: '模拟塌陷区域',
+ font: '18px "Microsoft YaHei", sans-serif',
+ fillColor: Cesium.Color.WHITE,
+ outlineColor: Cesium.Color.BLACK,
+ outlineWidth: 2,
+ showBackground: true,
+ backgroundColor: Cesium.Color.fromAlpha(Cesium.Color.BLACK, 0.4),
+ pixelOffset: new Cesium.Cartesian2(0, -20),
+ horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
+ verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
+ heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
+ disableDepthTestDistance: Number.POSITIVE_INFINITY
+ }
+ })
+ entities.push(labelEntity)
+
+ // 添加中心点标记
+ const pointEntity = viewer.entities.add({
+ position: center,
+ point: {
+ color: Cesium.Color.ORANGE,
+ pixelSize: 12,
+ outlineColor: Cesium.Color.WHITE,
+ outlineWidth: 2,
+ heightReference: Cesium.HeightReference.CLAMP_TO_GROUND
+ }
+ })
+ entities.push(pointEntity)
+
+ collapseAreaEntities.value = entities
+ console.log('[useMapMarkers] 塌陷区域绘制完成')
+ return center
+ }
+
+ /**
+ * 添加固定标记点(单兵、设备、应急基地)
+ * 支持地面高度采样,确保标记在地形加载后位置正确
+ *
+ * @param {Cesium.Viewer} viewer
+ * @param {Object} options - 配置选项
+ * @param {number} [options.heightOffset=10] - 相对地面的高度偏移(米)
+ * @param {boolean} [options.useSampledHeights=true] - 是否使用采样高度
+ * @returns {Promise}
+ */
+ const addFixedMarkers = async (viewer, options = {}) => {
+ if (!viewer) {
+ console.warn('[useMapMarkers] addFixedMarkers: viewer 为空')
+ return []
+ }
+
+ const markerOptions = {
+ heightOffset: options.heightOffset ?? DEFAULT_HEIGHT_OFFSET,
+ useSampledHeights: options.useSampledHeights !== false
+ }
+
+ const entities = []
+
+ // 单兵标记
+ const soldierResult = await resolveCartesianFromDegrees(
+ viewer,
+ 106.398030213,
+ 29.7099573,
+ markerOptions.heightOffset,
+ markerOptions.useSampledHeights
+ )
+
+ const soldierEntity = viewer.entities.add({
+ position: soldierResult.position,
+ billboard: {
+ image: soldierIcon,
+ width: 36,
+ height: 40,
+ verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
+ heightReference: resolveBillboardHeightReference(soldierResult.samplingSucceeded),
+ disableDepthTestDistance: Number.POSITIVE_INFINITY
+ },
+ properties: {
+ type: 'soldier',
+ name: '黄政强',
+ department: '安全生产部',
+ location: '目前为止距离现场0.6公里'
+ }
+ })
+ entities.push(soldierEntity)
+
+ // 设备标记
+ const deviceResult = await resolveCartesianFromDegrees(
+ viewer,
+ 106.402018756,
+ 29.7061436,
+ markerOptions.heightOffset,
+ markerOptions.useSampledHeights
+ )
+
+ const deviceEntity = viewer.entities.add({
+ position: deviceResult.position,
+ billboard: {
+ image: deviceIcon,
+ width: 36,
+ height: 40,
+ verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
+ heightReference: resolveBillboardHeightReference(deviceResult.samplingSucceeded),
+ disableDepthTestDistance: Number.POSITIVE_INFINITY
+ },
+ properties: {
+ type: 'device',
+ name: '无人机A',
+ deviceType: 'DJI',
+ location: '目前为止距离现场0.5公里'
+ }
+ })
+ entities.push(deviceEntity)
+
+ // 应急基地标记(距中心点北12公里)
+ const center = calculateCollapseCenter()
+ if (center) {
+ const cartographic = Cesium.Cartographic.fromCartesian(center)
+ const centerLat = Cesium.Math.toDegrees(cartographic.latitude)
+ const centerLon = Cesium.Math.toDegrees(cartographic.longitude)
+ const emergencyBaseLat = centerLat + 0.11 // 约12km
+
+ const emergencyBaseResult = await resolveCartesianFromDegrees(
+ viewer,
+ centerLon,
+ emergencyBaseLat,
+ markerOptions.heightOffset,
+ markerOptions.useSampledHeights
+ )
+
+ const emergencyBaseEntity = viewer.entities.add({
+ position: emergencyBaseResult.position,
+ billboard: {
+ image: emergencyBaseIcon,
+ width: 48,
+ height: 48,
+ verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
+ heightReference: resolveBillboardHeightReference(emergencyBaseResult.samplingSucceeded),
+ disableDepthTestDistance: Number.POSITIVE_INFINITY
+ },
+ properties: {
+ type: 'emergencyBase',
+ name: '应急指挥中心',
+ address: 'XX区应急管理局',
+ distance: '距离现场12.0公里'
+ }
+ })
+ entities.push(emergencyBaseEntity)
+ }
+
+ markerEntities.value.push(...entities)
+ console.log(`[useMapMarkers] 添加固定标记 ${entities.length} 个`)
+ return entities
+ }
+
+ /**
+ * 生成随机标记(在中心点周围5km半径)
+ * 支持地面高度采样,确保标记在地形加载后位置正确
+ *
+ * @param {Cesium.Viewer} viewer
+ * @param {number} count - 要生成的随机标记数量
+ * @param {Object} options - 配置选项
+ * @param {number} [options.heightOffset=10] - 相对地面的高度偏移(米)
+ * @param {boolean} [options.useSampledHeights=true] - 是否使用采样高度
+ * @returns {Promise}
+ */
+ const addRandomMarkers = async (viewer, count = 10, options = {}) => {
+ if (!viewer) {
+ console.warn('[useMapMarkers] addRandomMarkers: viewer 为空')
+ return []
+ }
+
+ const center = calculateCollapseCenter()
+ if (!center) {
+ console.warn('[useMapMarkers] addRandomMarkers: 无法计算中心点')
+ return []
+ }
+
+ const cartographic = Cesium.Cartographic.fromCartesian(center)
+ const centerLat = Cesium.Math.toDegrees(cartographic.latitude)
+ const centerLon = Cesium.Math.toDegrees(cartographic.longitude)
+
+ const radiusMeters = 5000
+ const soldierNames = ['张三', '李四', '王五', '赵六', '刘七']
+ const soldierDepartments = ['安全生产部', '应急管理部', '消防救援队']
+ const deviceNames = ['无人机B', '无人机C', '监控设备A', '监控设备B', '应急车辆A']
+ const deviceTypes = ['DJI']
+
+ const latInRadians = Cesium.Math.toRadians(centerLat)
+ const metersPerDegreeLat = 111320
+ const metersPerDegreeLon = Math.max(1e-6, metersPerDegreeLat * Math.cos(latInRadians))
+
+ const markerOptions = {
+ heightOffset: options.heightOffset ?? DEFAULT_HEIGHT_OFFSET,
+ useSampledHeights: options.useSampledHeights !== false
+ }
+
+ const entities = []
+
+ for (let i = 0; i < count; i++) {
+ const angle = Math.random() * Math.PI * 2
+ const distance = Math.random() * radiusMeters
+ const deltaLat = (distance * Math.cos(angle)) / metersPerDegreeLat
+ const deltaLon = (distance * Math.sin(angle)) / metersPerDegreeLon
+ const locationDistance = (0.3 + Math.random() * 1.7).toFixed(1)
+ const isSoldier = Math.random() < 0.5
+
+ const lon = centerLon + deltaLon
+ const lat = centerLat + deltaLat
+
+ const result = await resolveCartesianFromDegrees(
+ viewer,
+ lon,
+ lat,
+ markerOptions.heightOffset,
+ markerOptions.useSampledHeights
+ )
+
+ const entity = viewer.entities.add({
+ position: result.position,
+ billboard: {
+ image: isSoldier ? soldierIcon : deviceIcon,
+ width: 36,
+ height: 40,
+ verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
+ heightReference: resolveBillboardHeightReference(result.samplingSucceeded),
+ disableDepthTestDistance: Number.POSITIVE_INFINITY
+ },
+ properties: isSoldier
+ ? {
+ type: 'soldier',
+ name: soldierNames[Math.floor(Math.random() * soldierNames.length)],
+ department: soldierDepartments[Math.floor(Math.random() * soldierDepartments.length)],
+ location: `目前为止距离现场${locationDistance}公里`
+ }
+ : {
+ type: 'device',
+ name: deviceNames[Math.floor(Math.random() * deviceNames.length)],
+ deviceType: deviceTypes[Math.floor(Math.random() * deviceTypes.length)],
+ location: `目前为止距离现场${locationDistance}公里`
+ }
+ })
+
+ entities.push(entity)
+ }
+
+ markerEntities.value.push(...entities)
+ console.log(`[useMapMarkers] 添加随机标记 ${entities.length} 个`)
+ return entities
+ }
+
+ /**
+ * 初始化所有标记
+ * 按顺序绘制塌陷区域、添加固定标记和随机标记
+ *
+ * @param {Cesium.Viewer} viewer
+ * @param {Object} options - 配置选项
+ * @param {number} [options.heightOffset=10] - 相对地面的高度偏移(米)
+ * @param {boolean} [options.useSampledHeights=true] - 是否使用采样高度
+ * @param {number} [options.randomCount=10] - 随机标记数量
+ * @returns {Promise} 返回塌陷区域中心点
+ */
+ const initializeMarkers = async (viewer, options = {}) => {
+ if (!viewer) {
+ console.warn('[useMapMarkers] initializeMarkers: viewer 为空')
+ return null
+ }
+
+ const markerOptions = {
+ heightOffset: options.heightOffset ?? DEFAULT_HEIGHT_OFFSET,
+ useSampledHeights: options.useSampledHeights !== false,
+ randomCount: options.randomCount ?? 10
+ }
+
+ console.log('[useMapMarkers] 开始初始化地图标记...', markerOptions)
+
+ // 绘制塌陷区域(不涉及采样)
+ const center = drawCollapseArea(viewer)
+
+ // 添加固定标记(支持采样)
+ await addFixedMarkers(viewer, markerOptions)
+
+ // 添加随机标记(支持采样)
+ await addRandomMarkers(viewer, markerOptions.randomCount, markerOptions)
+
+ console.log('[useMapMarkers] 标记初始化完成')
+ return center
+ }
+
+ /**
+ * 清除所有标记
+ */
+ const clearMarkers = (viewer) => {
+ if (!viewer) return
+
+ // 清除塌陷区域
+ collapseAreaEntities.value.forEach(entity => {
+ if (entity) viewer.entities.remove(entity)
+ })
+ collapseAreaEntities.value = []
+
+ // 清除标记点
+ markerEntities.value.forEach(entity => {
+ if (entity) viewer.entities.remove(entity)
+ })
+ markerEntities.value = []
+ }
+
+ /**
+ * 设置所有标记点的 splitDirection
+ * 用于模型对比模式,控制标记点在左侧/右侧/全屏显示
+ *
+ * 注意:Cesium 需要同时在 entity 和其图形属性(billboard/polygon)上设置 splitDirection
+ *
+ * @param {Cesium.SplitDirection} splitDirection - 分割方向
+ */
+ const setMarkersSplitDirection = (splitDirection) => {
+ console.log(`[useMapMarkers] 设置所有标记点的 splitDirection 为: ${splitDirection}`)
+
+ let updatedCount = 0
+
+ // 更新塌陷区域的 splitDirection
+ collapseAreaEntities.value.forEach(entity => {
+ if (entity) {
+ // 设置 entity 级别的 splitDirection
+ entity.splitDirection = splitDirection
+
+ // 如果有 polygon,也需要设置
+ if (entity.polygon) {
+ // 使用 Cesium 的 ConstantProperty 确保正确设置
+ if (typeof entity.polygon.splitDirection === 'object' && entity.polygon.splitDirection.setValue) {
+ entity.polygon.splitDirection.setValue(splitDirection)
+ } else {
+ entity.polygon.splitDirection = new Cesium.ConstantProperty(splitDirection)
+ }
+ console.log(`[useMapMarkers] 塌陷区域 polygon.splitDirection 已设置为:`, entity.polygon.splitDirection)
+ }
+
+ updatedCount++
+ }
+ })
+
+ // 更新所有标记点的 splitDirection
+ markerEntities.value.forEach((entity, index) => {
+ if (entity) {
+ // 设置 entity 级别的 splitDirection
+ entity.splitDirection = splitDirection
+
+ // 如果有 billboard,也需要设置(这是关键!)
+ if (entity.billboard) {
+ // 使用 Cesium 的 ConstantProperty 确保正确设置
+ if (typeof entity.billboard.splitDirection === 'object' && entity.billboard.splitDirection.setValue) {
+ entity.billboard.splitDirection.setValue(splitDirection)
+ } else {
+ entity.billboard.splitDirection = new Cesium.ConstantProperty(splitDirection)
+ }
+
+ // 调试输出前3个标记点的设置情况
+ if (index < 3) {
+ console.log(`[useMapMarkers] 标记点 ${index} (${entity.properties?.type?.getValue() || 'unknown'}) billboard.splitDirection 已设置为:`, entity.billboard.splitDirection)
+ }
+ }
+
+ updatedCount++
+ }
+ })
+
+ console.log(`[useMapMarkers] 已更新 ${updatedCount} 个实体的 splitDirection(包括图形属性)`)
+ console.log(`[useMapMarkers] 塌陷区域数量: ${collapseAreaEntities.value.length}, 标记点数量: ${markerEntities.value.length}`)
+ }
+
+ /**
+ * 隐藏所有标记点(用于模型对比模式)
+ * 如果 splitDirection 不起作用,可以使用这个方法直接隐藏标记点
+ */
+ const hideMarkers = () => {
+ console.log('[useMapMarkers] 隐藏所有标记点')
+
+ markerEntities.value.forEach(entity => {
+ if (entity) {
+ entity.show = false
+ }
+ })
+ }
+
+ /**
+ * 显示所有标记点(退出模型对比模式)
+ */
+ const showMarkers = () => {
+ console.log('[useMapMarkers] 显示所有标记点')
+
+ markerEntities.value.forEach(entity => {
+ if (entity) {
+ entity.show = true
+ }
+ })
+ }
+
+ /**
+ * 清除应急资源标记
+ * @param {Cesium.Viewer} viewer
+ */
+ const clearEmergencyResourceMarkers = (viewer) => {
+ if (!viewer) return
+
+ console.log(`[useMapMarkers] 清除 ${emergencyResourceEntities.value.length} 个应急资源标记`)
+
+ emergencyResourceEntities.value.forEach(entity => {
+ if (entity) viewer.entities.remove(entity)
+ })
+ emergencyResourceEntities.value = []
+ }
+
+ /**
+ * 根据API数据添加应急资源标记(养护站)
+ * @param {Cesium.Viewer} viewer
+ * @param {Object} emergencyData - API返回的应急资源数据
+ * @param {Array} emergencyData.stations - 养护站列表
+ * @param {string} emergencyData.stations[].stationId - 养护站ID
+ * @param {string} emergencyData.stations[].stationName - 养护站名称
+ * @param {number} emergencyData.stations[].longitude - 经度
+ * @param {number} emergencyData.stations[].latitude - 纬度
+ * @param {number} emergencyData.stations[].distance - 距离灾害点的距离(km)
+ * @param {number} emergencyData.equipmentCount - 应急装备数量
+ * @param {number} emergencyData.personnelCount - 应急人员数量
+ * @param {Object} disasterPoint - 灾害点坐标 { longitude, latitude }
+ * @param {Object} options - 配置选项
+ * @param {number} [options.heightOffset=10] - 相对地面的高度偏移(米)
+ * @returns {Promise}
+ */
+ const addEmergencyResourceMarkers = async (viewer, emergencyData, disasterPoint, options = {}) => {
+ if (!viewer) {
+ console.warn('[useMapMarkers] addEmergencyResourceMarkers: viewer 为空')
+ return
+ }
+
+ if (!emergencyData) {
+ console.warn('[useMapMarkers] addEmergencyResourceMarkers: emergencyData 为空')
+ return
+ }
+
+ const markerOptions = {
+ heightOffset: options.heightOffset ?? DEFAULT_HEIGHT_OFFSET
+ }
+
+ console.log('[useMapMarkers] 开始添加应急资源标记(养护站)...', emergencyData)
+
+ const entities = []
+
+ // 添加养护站标记
+ if (Array.isArray(emergencyData.stations)) {
+ for (const station of emergencyData.stations) {
+ if (!station.latitude || !station.longitude) {
+ console.warn('[useMapMarkers] 养护站缺少坐标信息:', station)
+ continue
+ }
+
+ const result = await resolveCartesianFromDegrees(
+ viewer,
+ station.longitude,
+ station.latitude,
+ markerOptions.heightOffset,
+ false
+ )
+
+ const entity = viewer.entities.add({
+ position: result.position,
+ billboard: {
+ image: emergencyBaseIcon,
+ width: 48,
+ height: 48,
+ verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
+ heightReference: resolveBillboardHeightReference(result.samplingSucceeded),
+ disableDepthTestDistance: Number.POSITIVE_INFINITY
+ },
+ properties: {
+ type: 'station',
+ stationId: station.stationId,
+ name: station.stationName || '养护站',
+ distance: station.distance || 0
+ }
+ })
+ entities.push(entity)
+ }
+ }
+
+ emergencyResourceEntities.value = entities
+ console.log(`[useMapMarkers] 添加养护站标记 ${entities.length} 个`)
+ }
+
+ return {
+ collapseAreaEntities,
+ markerEntities,
+ emergencyResourceEntities,
+ initializeMarkers,
+ clearMarkers,
+ setMarkersSplitDirection,
+ hideMarkers,
+ showMarkers,
+ drawCollapseArea,
+ addFixedMarkers,
+ addRandomMarkers,
+ addEmergencyResourceMarkers,
+ clearEmergencyResourceMarkers,
+ getCollapseCenter: calculateCollapseCenter // 提前获取中心点,不依赖标记初始化
+ }
+}
+
+export default useMapMarkers
diff --git a/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useModelCompare.js b/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useModelCompare.js
new file mode 100644
index 0000000..880f9e0
--- /dev/null
+++ b/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useModelCompare.js
@@ -0,0 +1,572 @@
+import { ref } from 'vue'
+import * as Cesium from 'cesium'
+import { useMapStore } from '@/map'
+import {
+ BEFORE_IMAGERY_CONFIG,
+ AFTER_IMAGERY_CONFIG,
+ SPLIT_CONFIG,
+ getModelCompareConfig
+} from '../config/modelCompare.config'
+import { use3DTiles } from './use3DTiles'
+import { useMapMarkers } from './useMapMarkers'
+
+/**
+ * 调试模式开关
+ * 生产环境自动关闭详细日志
+ */
+const DEBUG = import.meta.env.DEV
+
+/**
+ * 图层标识常量
+ * @constant {string} BEFORE_LAYER_ID - 灾前现场实景图层 ID
+ * @constant {string} AFTER_LAYER_ID - 灾后现场实景图层 ID
+ */
+const BEFORE_LAYER_ID = BEFORE_IMAGERY_CONFIG.id
+const AFTER_LAYER_ID = AFTER_IMAGERY_CONFIG.id
+
+/**
+ * 模型对比(灾前/灾后影像对比)业务逻辑
+ *
+ * 技术方案:
+ * - 使用单个 Cesium 实例,通过 imagery split(影像分屏)实现左右对比视图
+ * - 左侧显示灾前现场实景,右侧显示灾后现场实景
+ * - 默认只显示灾后影像,启用对比模式后同时显示两套影像
+ *
+ * 使用示例:
+ * ```js
+ * const { isModelCompareActive, initModelCompareLayers, toggleModelCompare } = useModelCompare()
+ *
+ * // 在地图就绪后初始化图层
+ * mapStore.onReady(async () => {
+ * await initModelCompareLayers()
+ * })
+ *
+ * // 切换对比模式
+ * await toggleModelCompare(true) // 启用
+ * await toggleModelCompare(false) // 禁用
+ * ```
+ *
+ * @returns {Object} 模型对比相关状态和方法
+ * @returns {Ref} isModelCompareActive - 模型对比模式是否激活
+ * @returns {Function} initModelCompareLayers - 初始化灾前/灾后图层
+ * @returns {Function} enableModelCompare - 启用模型对比模式
+ * @returns {Function} disableModelCompare - 禁用模型对比模式
+ * @returns {Function} toggleModelCompare - 切换模型对比模式
+ */
+export function useModelCompare() {
+ const mapStore = useMapStore()
+
+ // 初始化 3D Tiles 管理
+ const {
+ load3DTileset,
+ waitForTilesetReady,
+ remove3DTileset
+ } = use3DTiles()
+
+ /** 模型对比模式是否激活 */
+ const isModelCompareActive = ref(false)
+
+ /** 图层是否已初始化 */
+ const initialized = ref(false)
+
+ /** 是否正在执行切换操作(防止并发) */
+ const isToggling = ref(false)
+
+ /**
+ * 其他影像图层的原始可见性状态
+ * 用于在禁用对比模式后恢复原始状态,而非强制全部打开
+ * Map
+ */
+ const originalLayerVisibility = new Map()
+
+ /** 灾前 Tileset 引用(用于在禁用时移除) */
+ let beforeTilesetRef = null
+
+ /**
+ * 从 viewer 中查找指定配置的 3D Tileset
+ * @param {Cesium.Viewer} viewer
+ * @param {string} configId - 配置ID,'before' 或 'after'
+ * @returns {Cesium.Cesium3DTileset | null}
+ */
+ const findTilesetByConfig = (viewer, configId) => {
+ if (!viewer?.scene?.primitives) return null
+
+ const config = getModelCompareConfig()
+ const targetUrl = configId === 'after' ? config.after3DTiles.url : config.before3DTiles.url
+
+ // 遍历所有 primitives 查找匹配的 tileset
+ for (let i = 0; i < viewer.scene.primitives.length; i++) {
+ const primitive = viewer.scene.primitives.get(i)
+ if (primitive instanceof Cesium.Cesium3DTileset) {
+ // 比较 URL(去除查询参数和尾部斜杠)
+ const primitiveUrl = primitive.resource?.url || primitive._url || ''
+ const normalizedPrimitiveUrl = primitiveUrl.split('?')[0].replace(/\/$/, '')
+ const normalizedTargetUrl = targetUrl.split('?')[0].replace(/\/$/, '')
+
+ if (normalizedPrimitiveUrl === normalizedTargetUrl) {
+ return primitive
+ }
+ }
+ }
+
+ return null
+ }
+
+ /**
+ * 设置所有 entities 的 splitDirection
+ * @param {Cesium.Viewer} viewer
+ * @param {Cesium.SplitDirection} splitDirection
+ */
+ const setEntitiesSplitDirection = (viewer, splitDirection) => {
+ if (!viewer?.entities) return
+
+ console.log(`[useModelCompare] 设置所有 entities 的 splitDirection 为: ${splitDirection}`)
+ let updatedCount = 0
+
+ const entities = viewer.entities.values
+ for (let i = 0; i < entities.length; i++) {
+ const entity = entities[i]
+
+ // 设置 entity 级别的 splitDirection
+ entity.splitDirection = splitDirection
+
+ // 设置图形属性的 splitDirection
+ if (entity.billboard) {
+ if (typeof entity.billboard.splitDirection === 'object' && entity.billboard.splitDirection.setValue) {
+ entity.billboard.splitDirection.setValue(splitDirection)
+ } else {
+ entity.billboard.splitDirection = new Cesium.ConstantProperty(splitDirection)
+ }
+ }
+
+ if (entity.polygon) {
+ if (typeof entity.polygon.splitDirection === 'object' && entity.polygon.splitDirection.setValue) {
+ entity.polygon.splitDirection.setValue(splitDirection)
+ } else {
+ entity.polygon.splitDirection = new Cesium.ConstantProperty(splitDirection)
+ }
+ }
+
+ updatedCount++
+ }
+
+ console.log(`[useModelCompare] 已更新 ${updatedCount} 个 entities 的 splitDirection`)
+ }
+
+ /**
+ * 初始化灾前/灾后影像图层
+ *
+ * - 仅在首次调用时创建图层
+ * - 如果地图未就绪,会自动等待地图就绪后再执行
+ * - 使用占位符 URL,需在接入真实数据时替换为实际影像服务地址
+ *
+ * @async
+ * @throws {Error} 当图层创建失败时抛出错误
+ */
+ const initModelCompareLayers = async () => {
+ // 防止重复初始化
+ if (initialized.value) {
+ console.log('[useModelCompare] 图层已初始化,跳过重复初始化')
+ return
+ }
+
+ /**
+ * 实际的图层初始化逻辑
+ * @async
+ * @private
+ */
+ const doInit = async () => {
+ try {
+ const { layer } = mapStore.services()
+
+ // 获取当前环境的配置
+ const config = getModelCompareConfig()
+
+ // 检查图层是否已存在(可能被其他模块创建)
+ const beforeExists = layer.getLayer(BEFORE_LAYER_ID)
+ const afterExists = layer.getLayer(AFTER_LAYER_ID)
+
+ // 创建灾前影像图层
+ if (!beforeExists) {
+ if (DEBUG) console.log('[useModelCompare] 创建灾前影像图层...')
+ await layer.addLayer({
+ id: config.before.id,
+ type: 'WebTileLayer',
+ url: config.before.url,
+ options: {
+ visible: config.before.visible,
+ },
+ meta: {
+ title: config.before.name,
+ sceneType: 'before',
+ description: config.before.description
+ }
+ })
+ if (DEBUG) console.log('[useModelCompare] 灾前影像图层创建成功')
+ } else {
+ if (DEBUG) console.log('[useModelCompare] 灾前影像图层已存在')
+ }
+
+ // 创建灾后影像图层
+ if (!afterExists) {
+ if (DEBUG) console.log('[useModelCompare] 创建灾后影像图层...')
+ await layer.addLayer({
+ id: config.after.id,
+ type: 'WebTileLayer',
+ url: config.after.url,
+ options: {
+ visible: config.after.visible,
+ },
+ meta: {
+ title: config.after.name,
+ sceneType: 'after',
+ description: config.after.description
+ }
+ })
+ if (DEBUG) console.log('[useModelCompare] 灾后影像图层创建成功')
+ } else {
+ if (DEBUG) console.log('[useModelCompare] 灾后影像图层已存在')
+ }
+
+ initialized.value = true
+ if (DEBUG) console.log('[useModelCompare] 图层初始化完成')
+ } catch (error) {
+ console.error('[useModelCompare] 图层初始化失败:', error)
+ throw new Error(`模型对比图层初始化失败: ${error.message}`)
+ }
+ }
+
+ // 如果地图已就绪,直接执行初始化
+ if (mapStore.isReady()) {
+ await doInit()
+ return
+ }
+
+ // 否则等待地图就绪后再执行
+ console.log('[useModelCompare] 等待地图就绪...')
+ await new Promise((resolve, reject) => {
+ mapStore.onReady(async () => {
+ try {
+ await doInit()
+ resolve()
+ } catch (error) {
+ reject(error)
+ }
+ })
+ })
+ }
+
+ /**
+ * 启用模型对比模式
+ *
+ * 启用后:
+ * - 左半屏显示灾前影像
+ * - 右半屏显示灾后影像
+ * - 分割线位置默认为中心(0.5)
+ *
+ * @async
+ */
+ const enableModelCompare = async () => {
+ if (DEBUG) console.log('[useModelCompare] 启用模型对比模式...')
+
+ // 确保图层已初始化
+ await initModelCompareLayers()
+
+ // 如果地图未就绪,无法操作
+ if (!mapStore.isReady()) {
+ console.warn('[useModelCompare] 地图未就绪,无法启用对比模式')
+ return
+ }
+
+ try {
+ const { layer } = mapStore.services()
+
+ // 检查图层是否存在
+ const beforeLayer = layer.getLayer(BEFORE_LAYER_ID)
+ const afterLayer = layer.getLayer(AFTER_LAYER_ID)
+
+ if (!beforeLayer || !afterLayer) {
+ console.error('[useModelCompare] 图层不存在,无法启用对比模式')
+ throw new Error('模型对比图层不存在')
+ }
+
+ // 🔧 修复:保存其他影像图层的原始可见性状态
+ // 在隐藏图层前先记录它们的状态,以便后续恢复
+ originalLayerVisibility.clear() // 清空旧状态
+ const allLayers = layer.listLayers()
+
+ allLayers.forEach(layerRecord => {
+ if (layerRecord.type === 'imagery' &&
+ layerRecord.id !== BEFORE_LAYER_ID &&
+ layerRecord.id !== AFTER_LAYER_ID) {
+ // 保存原始可见性状态
+ originalLayerVisibility.set(layerRecord.id, layerRecord.show)
+ // 隐藏图层,避免遮挡分屏效果
+ layer.showLayer(layerRecord.id, false)
+ }
+ })
+
+ if (DEBUG) {
+ console.log('[useModelCompare] 已保存图层状态:',
+ Array.from(originalLayerVisibility.entries()))
+ }
+
+ // 左侧:灾前影像;右侧:灾后影���
+ console.log('[useModelCompare] 设置灾前图层为左侧...')
+ layer.setSplit(BEFORE_LAYER_ID, 'left')
+
+ console.log('[useModelCompare] 设置灾后图层为右侧...')
+ layer.setSplit(AFTER_LAYER_ID, 'right')
+
+ // 设置分割位置为中心(可以后续扩展为可拖动调整)
+ console.log('[useModelCompare] 设置分割位置为 0.5...')
+ layer.setSplitPosition(0.5)
+
+ // 直接使用新 API 设置分割位置(绕过可能的旧 API 问题)
+ const viewer = mapStore.viewer
+ if (viewer) {
+ // 尝试新 API
+ if ('splitPosition' in viewer.scene) {
+ viewer.scene.splitPosition = 0.5
+ console.log('[useModelCompare] 使用新 API: scene.splitPosition = 0.5')
+ }
+ // 兼容旧 API
+ if ('imagerySplitPosition' in viewer.scene) {
+ viewer.scene.imagerySplitPosition = 0.5
+ console.log('[useModelCompare] 使用旧 API: scene.imagerySplitPosition = 0.5')
+ }
+
+ console.log('[useModelCompare] viewer.scene.splitPosition:', viewer.scene.splitPosition)
+ console.log('[useModelCompare] viewer.scene.imagerySplitPosition:', viewer.scene.imagerySplitPosition)
+
+ // 检查所有影像图层
+ const imageryLayers = viewer.imageryLayers
+ console.log('[useModelCompare] 影像图层总数:', imageryLayers.length)
+
+ for (let i = 0; i < imageryLayers.length; i++) {
+ const imgLayer = imageryLayers.get(i)
+ console.log(`[useModelCompare] 图层 ${i}:`, {
+ show: imgLayer.show,
+ alpha: imgLayer.alpha,
+ splitDirection: imgLayer.splitDirection
+ })
+ }
+ }
+
+ // 确保两个图层都可见
+ console.log('[useModelCompare] 显示灾前图层...')
+ layer.showLayer(BEFORE_LAYER_ID, true)
+
+ console.log('[useModelCompare] 显示灾后图层...')
+ layer.showLayer(AFTER_LAYER_ID, true)
+
+ // 调试:检查设置后的状态
+ const beforeLayerAfter = layer.getLayer(BEFORE_LAYER_ID)
+ const afterLayerAfter = layer.getLayer(AFTER_LAYER_ID)
+
+ console.log('[useModelCompare] 设置后的灾前图层:', beforeLayerAfter)
+ console.log('[useModelCompare] 灾前图层 splitDirection (设置后):', beforeLayerAfter?.obj?.splitDirection)
+ console.log('[useModelCompare] 灾前图层 show:', beforeLayerAfter?.obj?.show)
+
+ console.log('[useModelCompare] 设置后的灾后图层:', afterLayerAfter)
+ console.log('[useModelCompare] 灾后图层 splitDirection (设置后):', afterLayerAfter?.obj?.splitDirection)
+ console.log('[useModelCompare] 灾后图层 show:', afterLayerAfter?.obj?.show)
+
+ // 再次检查所有图层的最终状态
+ if (viewer) {
+ console.log('[useModelCompare] === 最终影像图层状态 ===')
+ const imageryLayers = viewer.imageryLayers
+ for (let i = 0; i < imageryLayers.length; i++) {
+ const imgLayer = imageryLayers.get(i)
+ console.log(`[useModelCompare] 最终图层 ${i}:`, {
+ show: imgLayer.show,
+ alpha: imgLayer.alpha,
+ splitDirection: imgLayer.splitDirection
+ })
+ }
+ }
+
+ // ============ 处理 3D Tiles 模型 ============
+ console.log('[useModelCompare] 开始处理 3D Tiles 模型分割...')
+
+ // 查找灾后模型
+ const afterTileset = findTilesetByConfig(viewer, 'after')
+ if (afterTileset) {
+ console.log('[useModelCompare] 找到灾后3D模型,设置为右侧显示')
+ afterTileset.splitDirection = Cesium.SplitDirection.RIGHT
+ } else {
+ console.warn('[useModelCompare] 未找到灾后3D模型')
+ }
+
+ // 查找或加载灾前3D模型
+ let beforeTileset = findTilesetByConfig(viewer, 'before')
+ if (!beforeTileset) {
+ console.log('[useModelCompare] 加载灾前3D模型...')
+ beforeTileset = await load3DTileset(
+ viewer,
+ 'before',
+ false, // 不自动缩放
+ Cesium.SplitDirection.LEFT // 左侧显示
+ )
+
+ if (beforeTileset) {
+ // 保存引用,用于禁用时移除
+ beforeTilesetRef = beforeTileset
+ console.log('[useModelCompare] 灾前3D模型加载成功,等待就绪...')
+ await waitForTilesetReady(beforeTileset)
+ console.log('[useModelCompare] 灾前3D模型已就绪')
+ } else {
+ console.warn('[useModelCompare] 灾前3D模型加载失败')
+ }
+ } else {
+ console.log('[useModelCompare] 找到已存在的灾前3D模型,设置为左侧显示')
+ beforeTileset.splitDirection = Cesium.SplitDirection.LEFT
+ beforeTilesetRef = beforeTileset
+ }
+
+ // ============ 处理标记点和实体 ============
+ console.log('[useModelCompare] 设置所有实体为右侧显示(灾后场景)...')
+ setEntitiesSplitDirection(viewer, Cesium.SplitDirection.RIGHT)
+
+ isModelCompareActive.value = true
+ console.log('[useModelCompare] 模型对比模式已启用(包含3D模型分割和标记点)')
+ } catch (error) {
+ console.error('[useModelCompare] 启用模型对比模式失败:', error)
+ throw new Error(`启用模型对比模式失败: ${error.message}`)
+ }
+ }
+
+ /**
+ * 禁用模型对比模式
+ *
+ * 禁用后:
+ * - 取消影像分屏
+ * - 隐藏灾前影像
+ * - 保留灾后影像作为默认视图
+ * - 恢复其他图层的原始可见性状态
+ *
+ * @async
+ */
+ const disableModelCompare = async () => {
+ if (DEBUG) console.log('[useModelCompare] 禁用模型对比模式...')
+
+ // 如果地图未就绪,仅更新状态即可
+ if (!mapStore.isReady()) {
+ isModelCompareActive.value = false
+ console.warn('[useModelCompare] 地图未就绪,仅更新状态')
+ return
+ }
+
+ try {
+ const { layer } = mapStore.services()
+ const viewer = mapStore.viewer
+
+ // ============ 处理影像图层 ============
+ // 取消影像分屏
+ layer.setSplit(BEFORE_LAYER_ID, 'none')
+ layer.setSplit(AFTER_LAYER_ID, 'none')
+
+ // 隐藏灾前图层,保留灾后图层
+ layer.showLayer(BEFORE_LAYER_ID, false)
+ layer.showLayer(AFTER_LAYER_ID, true)
+
+ // 🔧 修复:恢复其他影像图层的原始可见性状态
+ // 而非强制全部打开
+ if (originalLayerVisibility.size > 0) {
+ originalLayerVisibility.forEach((visible, layerId) => {
+ layer.showLayer(layerId, visible)
+ })
+ if (DEBUG) {
+ console.log('[useModelCompare] 已恢复影像图层状态:',
+ Array.from(originalLayerVisibility.entries()))
+ }
+ // 清空已恢复的状态记录
+ originalLayerVisibility.clear()
+ }
+
+ // ============ 处理 3D Tiles 模型 ============
+ console.log('[useModelCompare] 开始恢复 3D Tiles 模型状态...')
+
+ // 查找并恢复灾后模型为全屏显示
+ const afterTileset = findTilesetByConfig(viewer, 'after')
+ if (afterTileset) {
+ console.log('[useModelCompare] 恢复灾后3D模型为全屏显示')
+ afterTileset.splitDirection = Cesium.SplitDirection.NONE
+ }
+
+ // 移除灾前模型
+ if (beforeTilesetRef) {
+ console.log('[useModelCompare] 移除灾前3D模型')
+ viewer.scene.primitives.remove(beforeTilesetRef)
+ beforeTilesetRef = null
+ }
+
+ // ============ 处理标记点和实体 ============
+ console.log('[useModelCompare] 恢复所有实体为全屏显示...')
+ setEntitiesSplitDirection(viewer, Cesium.SplitDirection.NONE)
+
+ isModelCompareActive.value = false
+ if (DEBUG) console.log('[useModelCompare] 模型对比模式已禁用(包含3D模型和标记点恢复)')
+ } catch (error) {
+ console.error('[useModelCompare] 禁用模型对比模式失败:', error)
+ throw new Error(`禁用模型对比模式失败: ${error.message}`)
+ }
+ }
+
+ /**
+ * 切换模型对比模式
+ *
+ * @async
+ * @param {boolean} active - true 启用,false 禁用
+ */
+ const toggleModelCompare = async (active) => {
+ if (DEBUG) console.log(`[useModelCompare] 切换模型对比模式: ${active ? '启用' : '禁用'}`)
+
+ // 防止并发切换
+ if (isToggling.value) {
+ console.warn('[useModelCompare] 正在执行切换操作,忽略本次请求')
+ return
+ }
+
+ // 如果地图未就绪,延迟执行
+ if (!mapStore.isReady()) {
+ console.warn('[useModelCompare] 地图未就绪,将在地图就绪后执行切换')
+ await new Promise((resolve) => {
+ mapStore.onReady(() => resolve())
+ })
+ }
+
+ // 保存之前的状态,用于错误回滚
+ const previousState = isModelCompareActive.value
+
+ isToggling.value = true
+
+ try {
+ if (active) {
+ await enableModelCompare()
+ } else {
+ await disableModelCompare()
+ }
+ } catch (error) {
+ console.error('[useModelCompare] 切换模型对比模式失败:', error)
+ // 在发生错误时恢复到之前的状态
+ isModelCompareActive.value = previousState
+ throw error
+ } finally {
+ isToggling.value = false
+ }
+ }
+
+ return {
+ // 状态
+ isModelCompareActive,
+
+ // 方法
+ initModelCompareLayers,
+ enableModelCompare,
+ disableModelCompare,
+ toggleModelCompare
+ }
+}
+
+export default useModelCompare
diff --git a/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useVideoMonitor.js b/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useVideoMonitor.js
index 33642b7..0864c66 100644
--- a/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useVideoMonitor.js
+++ b/packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useVideoMonitor.js
@@ -11,6 +11,9 @@ export function useVideoMonitor() {
// 当前选中的视频
const activeMonitor = ref(null)
+ // 全屏显示的监控(弹窗)
+ const zoomedMonitor = ref(null)
+
// 视频控制状态
const controlState = ref({
volume: 50,
@@ -27,8 +30,10 @@ export function useVideoMonitor() {
}
// 切换喊话
- const toggleMegaphone = () => {
+ const toggleMegaphone = (monitorId) => {
+ console.log(`Toggle megaphone for monitor: ${monitorId}`)
controlState.value.isMegaphoneActive = !controlState.value.isMegaphoneActive
+ // 未来接入真实喊话系统
}
// 切换录制
@@ -41,20 +46,43 @@ export function useVideoMonitor() {
controlState.value.volume = Math.max(0, Math.min(100, volume))
}
- // 窗口放大
+ // 窗口放大 - 打开全屏弹窗
const zoomMonitor = (monitorId) => {
- console.log(`Zoom monitor: ${monitorId}`)
- // 实际实现可以打开全屏弹窗
+ const monitor = monitors.value.find((m) => m.id === monitorId)
+ if (monitor) {
+ zoomedMonitor.value = monitor
+ }
+ }
+
+ // 关闭全屏弹窗
+ const closeZoom = () => {
+ zoomedMonitor.value = null
+ }
+
+ // 切换音频
+ const toggleAudio = (monitorId) => {
+ console.log(`Toggle audio for monitor: ${monitorId}`)
+ // 未来接入真实音频控制
+ }
+
+ // 视角移动(用于无人机等设备的方向控制)
+ const moveView = (monitorId, direction) => {
+ console.log(`Move view for monitor ${monitorId} to ${direction}`)
+ // 未来接入 3D 引擎的视角控制 API
}
return {
monitors,
activeMonitor,
+ zoomedMonitor,
controlState,
selectMonitor,
toggleMegaphone,
toggleRecording,
setVolume,
- zoomMonitor
+ zoomMonitor,
+ closeZoom,
+ toggleAudio,
+ moveView
}
}
diff --git a/packages/screen/src/views/3DSituationalAwarenessRefactor/config/cesiumData.js b/packages/screen/src/views/3DSituationalAwarenessRefactor/config/cesiumData.js
new file mode 100644
index 0000000..5bda5ee
--- /dev/null
+++ b/packages/screen/src/views/3DSituationalAwarenessRefactor/config/cesiumData.js
@@ -0,0 +1,151 @@
+/**
+ * 塌陷区域Cartesian3坐标数据
+ * 用于绘制模拟塌陷区域的多边形
+ */
+export const cesiumDataConfig = [
+ {
+ x: -1565772.4265176335,
+ y: 5319228.03152635,
+ z: 3142351.0919108186
+ },
+ {
+ x: -1565776.5645109434,
+ y: 5319228.5289852405,
+ z: 3142353.766318518
+ },
+ {
+ x: -1565781.4485059043,
+ y: 5319229.160348873,
+ z: 3142357.04634814
+ },
+ {
+ x: -1565781.4485611639,
+ y: 5319229.160408792,
+ z: 3142357.0462827436
+ },
+ {
+ x: -1565783.9295213544,
+ y: 5319230.931857044,
+ z: 3142360.517359832
+ },
+ {
+ x: -1565785.9171999271,
+ y: 5319231.45550327,
+ z: 3142364.0015105833
+ },
+ {
+ x: -1565788.9314495095,
+ y: 5319232.502729838,
+ z: 3142364.0468923403
+ },
+ {
+ x: -1565791.8230618741,
+ y: 5319233.637126957,
+ z: 3142364.373063175
+ },
+ {
+ x: -1565801.014346924,
+ y: 5319221.870723008,
+ z: 3142368.911162937
+ },
+ {
+ x: -1565806.7826964892,
+ y: 5319216.223631968,
+ z: 3142369.714556013
+ },
+ {
+ x: -1565806.8694812602,
+ y: 5319213.791221007,
+ z: 3142366.9345683237
+ },
+ {
+ x: -1565810.4232652474,
+ y: 5319214.520435988,
+ z: 3142365.8812019615
+ },
+ {
+ x: -1565818.9044999545,
+ y: 5319206.471469724,
+ z: 3142365.46267672
+ },
+ {
+ x: -1565824.132179385,
+ y: 5319205.5100860335,
+ z: 3142364.2181716943
+ },
+ {
+ x: -1565828.6987746847,
+ y: 5319206.642060814,
+ z: 3142363.2934198803
+ },
+ {
+ x: -1565831.2842223754,
+ y: 5319207.347425584,
+ z: 3142362.910084223
+ },
+ {
+ x: -1565833.813719043,
+ y: 5319200.401086703,
+ z: 3142360.085581532
+ },
+ {
+ x: -1565832.4401977719,
+ y: 5319198.175732172,
+ z: 3142355.2529904097
+ },
+ {
+ x: -1565824.6420061626,
+ y: 5319200.200059024,
+ z: 3142355.81880074
+ },
+ {
+ x: -1565816.4714012512,
+ y: 5319202.30023816,
+ z: 3142355.237709711
+ },
+ {
+ x: -1565808.698851924,
+ y: 5319204.528581392,
+ z: 3142353.8670113366
+ },
+ {
+ x: -1565799.9900720564,
+ y: 5319207.449676356,
+ z: 3142352.1399200936
+ },
+ {
+ x: -1565793.8226583572,
+ y: 5319209.362596558,
+ z: 3142349.7282765335
+ },
+ {
+ x: -1565786.7522616626,
+ y: 5319212.540199659,
+ z: 3142346.4677125285
+ },
+ {
+ x: -1565782.3578206634,
+ y: 5319214.380562645,
+ z: 3142344.6796676633
+ },
+ {
+ x: -1565777.7605888362,
+ y: 5319216.58142275,
+ z: 3142342.3438176387
+ },
+ {
+ x: -1565772.2429871338,
+ y: 5319219.072527765,
+ z: 3142339.9103942616
+ },
+ {
+ x: -1565772.2000471132,
+ y: 5319222.72989916,
+ z: 3142343.610127874
+ },
+ {
+ x: -1565772.2100830332,
+ y: 5319225.49623822,
+ z: 3142347.3184587173
+ }
+]
diff --git a/packages/screen/src/views/3DSituationalAwarenessRefactor/config/modelCompare.config.js b/packages/screen/src/views/3DSituationalAwarenessRefactor/config/modelCompare.config.js
new file mode 100644
index 0000000..2ade4d6
--- /dev/null
+++ b/packages/screen/src/views/3DSituationalAwarenessRefactor/config/modelCompare.config.js
@@ -0,0 +1,199 @@
+/**
+ * 模型对比功能配置
+ *
+ * 用于配置灾前/灾后影像数据源和 3D Tiles 模型
+ * 支持不同环境使用不同的影像服务和 3D 模型
+ */
+
+/**
+ * 灾前 3D Tiles 配置
+ *
+ * 当前使用灾后模型作为占位数据(因为缺少真实的灾前模型)
+ * 实际部署时应替换为真实的灾前 3D Tiles 模型
+ */
+export const BEFORE_3DTILES_CONFIG = {
+ // 模型唯一标识
+ id: 'model-compare-before-3dtiles',
+
+ // 模型名称
+ name: '灾前3D模型',
+
+ // 3D Tiles 服务 URL
+ // TODO: 替换为实际的灾前模型 URL
+ url: 'http://222.212.85.86:9000/300bdf2b-a150-406e-be63-d28bd29b409f/model/S107/terra_b3dms/tileset.json',
+
+ // 默认可见性
+ visible: false,
+
+ // 模型说明
+ description: '灾害发生前的 3D 模型数据,用于对比展示灾害造成的地形变化'
+}
+
+/**
+ * 灾后 3D Tiles 配置
+ *
+ * 当前使用实际的灾后模型数据
+ */
+export const AFTER_3DTILES_CONFIG = {
+ // 模型唯一标识
+ id: 'model-compare-after-3dtiles',
+
+ // 模型名称
+ name: '灾后3D模型',
+
+ // 3D Tiles 服务 URL
+ url: 'http://222.212.85.86:9000/300bdf2b-a150-406e-be63-d28bd29b409f/model/S107/terra_b3dms/tileset.json',
+
+ // 默认可见性(初始化时灾后模型默认显示)
+ visible: true,
+
+ // 模型说明
+ description: '灾害发生后的 3D 模型数据,展示灾害现场实际情况'
+}
+
+/**
+ * 灾前影像配置
+ *
+ * 当前使用 OpenStreetMap 作为占位数据
+ * 实际部署时应替换为真实的灾前影像服务
+ */
+export const BEFORE_IMAGERY_CONFIG = {
+ // 图层唯一标识
+ id: 'model-compare-before',
+
+ // 图层名称
+ name: '灾前影像',
+
+ // 影像服务URL
+ // 格式:支持标准瓦片服务的URL模板,{z}/{x}/{y} 为瓦片坐标占位符
+ // url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
+ url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
+
+ // 图层类型
+ type: 'UrlTemplate',
+
+ // 默认可见性
+ visible: false,
+
+ // 投影信息
+ projection: 'EPSG:3857', // Web Mercator
+
+ // 最大缩放级别
+ maximumLevel: 18,
+
+ // 图层说明
+ description: '灾害发生前的影像数据,用于对比展示灾害造成的变化'
+}
+
+/**
+ * 灾后影像配置
+ *
+ * 当前使用 Esri World Imagery 作为占位数据
+ * 实际部署时应替换为真实的灾后影像服务
+ */
+export const AFTER_IMAGERY_CONFIG = {
+ // 图层唯一标识
+ id: 'model-compare-after',
+
+ // 图层名称
+ name: '灾后影像',
+
+ // 影像服务URL
+ // Esri World Imagery 服务
+ url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
+
+ // 图层类型
+ type: 'UrlTemplate',
+
+ // 默认可见性(初始化时灾后影像默认显示)
+ visible: true,
+
+ // 投影信息
+ projection: 'EPSG:3857', // Web Mercator
+
+ // 最大缩放级别
+ maximumLevel: 19,
+
+ // 图层说明
+ description: '灾害发生后的影像数据,展示灾害现场实际情况'
+}
+
+/**
+ * 分屏配置
+ */
+export const SPLIT_CONFIG = {
+ // 默认分割位置(0-1之间,0.5表示屏幕中央)
+ defaultPosition: 0.5,
+
+ // 分割线最小位置(防止完全遮挡)
+ minPosition: 0.05,
+
+ // 分割线最大位置
+ maxPosition: 0.95
+}
+
+/**
+ * 环境特定配置
+ *
+ * 可根据不同环境(开发/测试/生产)使用不同的影像服务和 3D 模型
+ */
+const ENV_CONFIGS = {
+ // 开发环境
+ development: {
+ before: BEFORE_IMAGERY_CONFIG,
+ after: AFTER_IMAGERY_CONFIG,
+ before3DTiles: BEFORE_3DTILES_CONFIG,
+ after3DTiles: AFTER_3DTILES_CONFIG
+ },
+
+ // 生产环境(示例:使用自有服务器的影像数据和 3D 模型)
+ production: {
+ before: {
+ ...BEFORE_IMAGERY_CONFIG,
+ // 生产环境可以覆盖URL
+ // url: 'https://your-server.com/tiles/before/{z}/{x}/{y}.png'
+ },
+ after: {
+ ...AFTER_IMAGERY_CONFIG,
+ // url: 'https://your-server.com/tiles/after/{z}/{x}/{y}.png'
+ },
+ before3DTiles: {
+ ...BEFORE_3DTILES_CONFIG,
+ // url: 'https://your-server.com/3dtiles/before/tileset.json'
+ },
+ after3DTiles: {
+ ...AFTER_3DTILES_CONFIG,
+ // url: 'https://your-server.com/3dtiles/after/tileset.json'
+ }
+ }
+}
+
+/**
+ * 获取当前环境的配置
+ */
+export function getModelCompareConfig() {
+ const env = import.meta.env.MODE || 'development'
+ return ENV_CONFIGS[env] || ENV_CONFIGS.development
+}
+
+/**
+ * 数据源替换指南
+ *
+ * 1. 准备影像数据:
+ * - 灾前影像:历史卫星影像或航拍影像
+ * - 灾后影像:事故发生后的实时影像
+ *
+ * 2. 发布瓦片服务:
+ * - 使用 GeoServer / ArcGIS Server / TileServer 等发布瓦片服务
+ * - 确保服务支持 CORS 跨域访问
+ * - 推荐使用 EPSG:3857 投影(Web Mercator)
+ *
+ * 3. 更新配置:
+ * - 修改上述 BEFORE_IMAGERY_CONFIG 和 AFTER_IMAGERY_CONFIG 中的 url 字段
+ * - 根据实际服务调整 maximumLevel 等参数
+ *
+ * 4. 性能优化:
+ * - 使用 CDN 加速瓦片服务
+ * - 预生成常用缩放级别的瓦片
+ * - 添加瓦片缓存机制
+ */
diff --git a/packages/screen/src/views/3DSituationalAwarenessRefactor/constants.js b/packages/screen/src/views/3DSituationalAwarenessRefactor/constants.js
index 92cb1c8..3a08f09 100644
--- a/packages/screen/src/views/3DSituationalAwarenessRefactor/constants.js
+++ b/packages/screen/src/views/3DSituationalAwarenessRefactor/constants.js
@@ -16,36 +16,56 @@ export const VIDEO_MONITORS = [
id: 1,
type: VIDEO_TYPES.PERSONNEL,
title: '单兵(张三三)设备视角',
+ videoSrc: '/videos/personnel-001.mp4', // 视频源路径
+ dateRange: '2025/9/1-2025/12/1', // 日期范围
hasAudio: true,
hasMegaphone: true,
- hasZoom: true
+ hasZoom: true,
+ hasDirectionControl: false // 是否显示方向控制(操作台)
},
{
id: 2,
type: VIDEO_TYPES.DRONE,
title: '无人机(001)视角',
+ videoSrc: '/videos/drone-001.mp4',
+ dateRange: '2025/9/1-2025/12/1',
hasAudio: false,
hasMegaphone: true,
- hasZoom: true
+ hasZoom: true,
+ hasDirectionControl: true // 无人机有方向控制
},
{
id: 3,
type: VIDEO_TYPES.VEHICLE_EXTERNAL,
title: '指挥车外部视角',
+ videoSrc: '/videos/vehicle-external-001.mp4',
+ dateRange: '2025/9/1-2025/12/1',
hasAudio: true,
hasMegaphone: true,
- hasZoom: true
+ hasZoom: true,
+ hasDirectionControl: false
},
{
id: 4,
type: VIDEO_TYPES.VEHICLE_MEETING,
title: '指挥车会议视角',
+ videoSrc: '/videos/vehicle-meeting-001.mp4',
+ dateRange: '2025/9/1-2025/12/1',
hasAudio: true,
hasMegaphone: true,
- hasZoom: true
+ hasZoom: true,
+ hasDirectionControl: false
}
]
+// 方向枚举(用于视角控制)
+export const DIRECTIONS = {
+ LEFT: 'left',
+ RIGHT: 'right',
+ UP: 'up',
+ DOWN: 'down'
+}
+
// 现场设备标签页
export const DISPATCH_TABS = [
{ key: 'personnel', label: '现场单兵', count: 23 },
diff --git a/packages/screen/src/views/3DSituationalAwarenessRefactor/index.vue b/packages/screen/src/views/3DSituationalAwarenessRefactor/index.vue
index 8c538b7..2aee13b 100644
--- a/packages/screen/src/views/3DSituationalAwarenessRefactor/index.vue
+++ b/packages/screen/src/views/3DSituationalAwarenessRefactor/index.vue
@@ -6,8 +6,26 @@
-
-
+
@@ -15,11 +33,18 @@
-
+
-
-
@@ -28,6 +53,31 @@
+
+
+
@@ -47,96 +97,463 @@