From 536b00fab4bbd015909f5b65e58083c9e144c061 Mon Sep 17 00:00:00 2001 From: Zzc <1373857752@qq.com> Date: Tue, 18 Nov 2025 21:24:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(situational-awareness):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=8F=8C=E5=9C=B0=E5=9B=BE=E5=AF=B9=E6=AF=94=E5=92=8C?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E8=A7=86=E9=A2=91=E7=9B=91=E6=8E=A7=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增支持双地图对比模式,显示灾害前后场景, 新的视频模态框用于全屏监控并带有方向控制, 位置面板显示地理信息, 地图工具提示显示实体详情,以及用于3D瓦片管理的可组合组件, 地图标记和模型对比功能。包括新的共享组件 如DecorativePanel和MapTooltip,以及Cesium数据 和模型对比设置的配置文件。 --- .../assets/images/应急中心.png | Bin 0 -> 2753 bytes ...08c989826a8c382c5cb9f.png => 应急人员.png} | Bin ...c08564067b7845d11b124.png => 应急装备.png} | Bin .../components/LeftPanel/DisasterAnalysis.vue | 169 ++--- .../components/LeftPanel/ForcePreset.vue | 76 +- .../components/LeftPanel/LocationPanel.vue | 161 +++++ .../components/LeftPanel/index.vue | 138 +++- .../components/MapViewer/index.vue | 34 +- .../components/RightPanel/VideoModal.vue | 432 ++++++++++++ .../RightPanel/VideoMonitorGrid.vue | 34 +- .../RightPanel/VideoMonitorItem.vue | 186 +++-- .../components/shared/DecorativePanel.vue | 185 +++++ .../components/shared/MapTooltip.vue | 455 ++++++++++++ .../composables/use3DTiles.js | 176 +++++ .../composables/useDisasterData.js | 90 ++- .../composables/useDualMapCompare.js | 258 +++++++ .../composables/useDualViewers.js | 236 +++++++ .../composables/useMapMarkers.js | 650 ++++++++++++++++++ .../composables/useModelCompare.js | 572 +++++++++++++++ .../composables/useVideoMonitor.js | 38 +- .../config/cesiumData.js | 151 ++++ .../config/modelCompare.config.js | 199 ++++++ .../constants.js | 28 +- .../3DSituationalAwarenessRefactor/index.vue | 613 +++++++++++++++-- 24 files changed, 4622 insertions(+), 259 deletions(-) create mode 100644 packages/screen/src/views/3DSituationalAwarenessRefactor/assets/images/应急中心.png rename packages/screen/src/views/3DSituationalAwarenessRefactor/assets/images/{SketchPng9eb481bdb1aa555bcf1e817c3db9af492e273f88d5808c989826a8c382c5cb9f.png => 应急人员.png} (100%) rename packages/screen/src/views/3DSituationalAwarenessRefactor/assets/images/{SketchPng3992df008169f438b4eab0a5f08b6d39b14f1387a18c08564067b7845d11b124.png => 应急装备.png} (100%) create mode 100644 packages/screen/src/views/3DSituationalAwarenessRefactor/components/LeftPanel/LocationPanel.vue create mode 100644 packages/screen/src/views/3DSituationalAwarenessRefactor/components/RightPanel/VideoModal.vue create mode 100644 packages/screen/src/views/3DSituationalAwarenessRefactor/components/shared/DecorativePanel.vue create mode 100644 packages/screen/src/views/3DSituationalAwarenessRefactor/components/shared/MapTooltip.vue create mode 100644 packages/screen/src/views/3DSituationalAwarenessRefactor/composables/use3DTiles.js create mode 100644 packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useDualMapCompare.js create mode 100644 packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useDualViewers.js create mode 100644 packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useMapMarkers.js create mode 100644 packages/screen/src/views/3DSituationalAwarenessRefactor/composables/useModelCompare.js create mode 100644 packages/screen/src/views/3DSituationalAwarenessRefactor/config/cesiumData.js create mode 100644 packages/screen/src/views/3DSituationalAwarenessRefactor/config/modelCompare.config.js diff --git a/packages/screen/src/views/3DSituationalAwarenessRefactor/assets/images/应急中心.png b/packages/screen/src/views/3DSituationalAwarenessRefactor/assets/images/应急中心.png new file mode 100644 index 0000000000000000000000000000000000000000..830298d06661043960610518b6dce6dcee92c9af GIT binary patch literal 2753 zcmV;y3O@CTP)r#c z0qRMM!ev~^T{LqohI#73MHTZ~JJ~15*lje1;19@?V2IF%W=LB)k`FA`%k$`=K zy!}26csSSQ;4Sc{n!e#7Zw3G_ZiidaomqHm&)0!x%^ z9aBVc`33M2Y4%Z2-}% z(VM~Oi66SX4}L3la_yGbsf7}hD(N#5RwmjCH`9u--(@>|3YNwKg+wDf5x-(4S{Z3l!Sel<;yp= zm+4sV@}c3pFMoY;QzqMSXhIIFq&uLy+aEmBvg1X!u=NS2H+s@9Gpc08DU?(w3nfJ2 z{9KeAVo3VuZGp$!p4Ej>XCF^C)e_-#EOjC=pT)b%hjuQGDaZ6NO~CG170oB|Wf@+s z%HJVP53jY}{Zsec*PdsHG0!OZ`9vd1;98e2SJ$xpl4iEGF5vT(RS-le>RO1=vZ2#2 z@mRFGsaQ7u#eXyy#co$|EBG!IiZL3?E6Ms_!?Aj(PQsACIp z;@UWX)3I~0OV_IsnIRHqdAa3=z0tywk-&0&vXX7}jeK=RC9_p6B_CVz&Z8_j#I#QG zdoyK^2HF03wvzj68@V=F&5MyCQ?`+N@bp}iIYSjK?Xi+)wHKUZO|3%@4L~2mOxnQQ zc)*N?xmX=f6IZpPQ)TWs-Oo2q4)CvCL!gRZIXA{Pj`#D86aBn$Zd5v-hr7@5PpA6% z=QI6;J~8=T8BH;0eU3-QSM#Fe?CEbxH4QT~@aaj*sC5h7M;u z!nw#72@qS-B7uqf#%Z5;+hWKusSEAdmR?gz`PSg;)^05f(mOdtR=4Bv`aDm~%Jc1- z3@G^8M2x2!@;u#`XKgYzt-G%}!w+WWd9=2YtZ(U?DzR;3a5~nzrUb@(nm8qq@buk? zJFW;6iy13LOdTj;Y~6_Qu^^Qo=Aw_M4jdjU(IFffEisKy4)OjvoV5;Zj%W^jtlB8< zgb2F|ww~y8=qb&~%F)0${rxo-qdz6Zn5Y^&O(0xaox&gmwfis57Pz^mzIf zbfTn}kf$okgz_Q--x(a`mc9bF4Gd9G`>hu!!ui89ie^pp{7NJOoZ+(PIO{uhg&pwJ zfe1DzWNd`cR`GB`Aq*mfYvm=2!O<Q z0zEjY-o-FwTy=nZuu>I;B)QSfDjT&Cy&WP75+c0cA;O>`dVQY`ZL-tq%Ilin&>YH# zC3gn_p--4_I=M(i6({slT!=VF$tgX=%_c_8#ISK>Y_aIsBF~z>!hDYI)FCeGGM6> z*J{%)*RvnmhFvov9>%CpoYo-qHV+@|AfQI*vpb0p8PmH`&gL!(Qbt4|M|$ds)qcJh zSH*FW2owY%5!IK^^Qcwz6T;cRC#^Z5HutIZ6M6%N*M@bALR%x^R^5xsugzx2+sMDL zUl}ZOh;y7F(hL^I9X=RGR8%u-PmavB@og(~JrT9+Pk8tukd%CD*5{g(&$^_~_u>Jo zDty7?8Ohg5_oa-_*K$6gZAkq8lOB0(iq&5JIG;N9?_^ZHgE1Q!mn3x7xs}UR_Y_O%=9v9!a{lP~eQ<$*0js;R4~I*+p)XM}V{*f-<@%FN5qCOpm^e(5 zjaf2El!ohY%44P9gTeEEuvrP;5xnKT(y76#9_kd3_uB@g9XxPDKc7P&c*D1YP4DlQF@ z*K_-^T}nP*m`f)vEBU5+aTQS-ESL)&;JsnKPKQX0h*}%ho$)GKhQ4r9srH^b{Mp52 zPJ(=xOo?+YDacp6|{cUtH1kOrFr}R%CEB`-Tv4;{jbzXT%iki!V>>sW1@}IxjpK;ZtCbgh;!#nf1`RHagbsnOYMOZ#d zz@*v4^JXJu13o|HG+FlYB5#^knFtd*;px{
- +
-
-
- - -
+
+ + +
-
- - -
- -
- - -
+
+ +
diff --git a/packages/screen/src/views/3DSituationalAwarenessRefactor/components/LeftPanel/ForcePreset.vue b/packages/screen/src/views/3DSituationalAwarenessRefactor/components/LeftPanel/ForcePreset.vue index e69db59..5d763e1 100644 --- a/packages/screen/src/views/3DSituationalAwarenessRefactor/components/LeftPanel/ForcePreset.vue +++ b/packages/screen/src/views/3DSituationalAwarenessRefactor/components/LeftPanel/ForcePreset.vue @@ -1,9 +1,22 @@ @@ -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 @@