From b432d8d6b74a31a45cf69c0993b56ae48429d5b2 Mon Sep 17 00:00:00 2001 From: Zzc <1373857752@qq.com> Date: Fri, 7 Nov 2025 15:04:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(map):=20=E9=9B=86=E6=88=90Cesium=203D?= =?UTF-8?q?=E5=9C=B0=E5=9B=BE=E7=B3=BB=E7=BB=9F=E4=B8=8E=E6=8E=A7=E4=BB=B6?= =?UTF-8?q?=E5=92=8C=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用Cesium添加全面的3D地图功能,包括: - 地图视口和控件组件 - 图层管理,含底图切换器和目录控制 - 相机、实体和查询服务 - 罗盘和场景模式切换UI组件 - 支持工具、存储和数据配置 更新构建配置以支持Cesium集成和SVG图标。 --- .env.development | 1 + .env.production | 1 + packages/screen/package.json | 15 +- .../src/assets/icons/svg/GisLandcoverMap.svg | 1 + .../screen/src/assets/icons/svg/GisLayers.svg | 1 + .../screen/src/assets/icons/svg/compass.svg | 14 + .../src/assets/icons/svg/compass_bg.svg | 21 + .../screen/src/components/SvgIcon/index.vue | 55 + packages/screen/src/main.js | 4 + .../src/map/components/BaseMapSwitcher.vue | 370 +++ .../map/components/LayerDirectoryControl.vue | 588 ++++ .../screen/src/map/components/MapCompass.vue | 332 +++ .../screen/src/map/components/MapControls.vue | 385 +++ .../screen/src/map/components/MapViewport.vue | 169 ++ .../src/map/components/SceneModeToggle.vue | 536 ++++ .../src/map/composables/useMapViewSnapshot.js | 97 + packages/screen/src/map/data/baseMap.json | 164 ++ packages/screen/src/map/data/layerMap.json | 260 ++ .../screen/src/map/data/mapBaseConfig.json | 37 + packages/screen/src/map/index.js | 12 + .../src/map/services/createCameraService.js | 374 +++ .../src/map/services/createEntityService.js | 134 + .../src/map/services/createLayerService.js | 421 +++ .../src/map/services/createQueryService.js | 60 + packages/screen/src/map/stores/mapStore.js | 430 +++ packages/screen/src/map/stores/mapUiStore.js | 40 + packages/screen/src/map/utils/pickPosition.js | 111 + packages/screen/src/map/utils/utils.js | 59 + .../views/cockpit/components/MapCenter.vue | 22 +- packages/screen/vite.config.js | 17 +- pnpm-lock.yaml | 2522 ++++++++++++++++- 31 files changed, 7226 insertions(+), 27 deletions(-) create mode 100644 packages/screen/src/assets/icons/svg/GisLandcoverMap.svg create mode 100644 packages/screen/src/assets/icons/svg/GisLayers.svg create mode 100644 packages/screen/src/assets/icons/svg/compass.svg create mode 100644 packages/screen/src/assets/icons/svg/compass_bg.svg create mode 100644 packages/screen/src/components/SvgIcon/index.vue create mode 100644 packages/screen/src/map/components/BaseMapSwitcher.vue create mode 100644 packages/screen/src/map/components/LayerDirectoryControl.vue create mode 100644 packages/screen/src/map/components/MapCompass.vue create mode 100644 packages/screen/src/map/components/MapControls.vue create mode 100644 packages/screen/src/map/components/MapViewport.vue create mode 100644 packages/screen/src/map/components/SceneModeToggle.vue create mode 100644 packages/screen/src/map/composables/useMapViewSnapshot.js create mode 100644 packages/screen/src/map/data/baseMap.json create mode 100644 packages/screen/src/map/data/layerMap.json create mode 100644 packages/screen/src/map/data/mapBaseConfig.json create mode 100644 packages/screen/src/map/index.js create mode 100644 packages/screen/src/map/services/createCameraService.js create mode 100644 packages/screen/src/map/services/createEntityService.js create mode 100644 packages/screen/src/map/services/createLayerService.js create mode 100644 packages/screen/src/map/services/createQueryService.js create mode 100644 packages/screen/src/map/stores/mapStore.js create mode 100644 packages/screen/src/map/stores/mapUiStore.js create mode 100644 packages/screen/src/map/utils/pickPosition.js create mode 100644 packages/screen/src/map/utils/utils.js diff --git a/.env.development b/.env.development index 55323d7..bd2005c 100644 --- a/.env.development +++ b/.env.development @@ -2,3 +2,4 @@ # 开发环境 VITE_API_BASE_URL=http://localhost:3000/api +VITE_CESIUM_ION_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI3ZWYyYWYyZi05YmQxLTQzODQtYTIyZi1mMTg2NTAxZGY4NGIiLCJpZCI6MTgzNTU5LCJpYXQiOjE3MDIyMTA3NDZ9.ngQ_4Jd-HsbK_MpofsFs9lUnpRcYCdOcObRVqoOS56U diff --git a/.env.production b/.env.production index 9179e4e..ef716ff 100644 --- a/.env.production +++ b/.env.production @@ -2,3 +2,4 @@ # 生产环境 VITE_API_BASE_URL=https://api.example.com +VITE_CESIUM_ION_TOKEN= diff --git a/packages/screen/package.json b/packages/screen/package.json index daa96f5..d109810 100644 --- a/packages/screen/package.json +++ b/packages/screen/package.json @@ -9,17 +9,20 @@ "preview": "vite preview" }, "dependencies": { - "vue": "^3.5.18", - "vue-router": "^4.6.3", - "pinia": "^3.0.3", "axios": "^1.13.2", + "cesium": "^1.135.0", "echarts": "^6.0.0", - "vue-echarts": "^8.0.1" + "pinia": "^3.0.3", + "vue": "^3.5.18", + "vue-echarts": "^8.0.1", + "vue-router": "^4.6.3" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.1", - "vite": "^7.2.0", + "less": "^4.4.2", "sass": "^1.93.3", - "less": "^4.4.2" + "vite": "^7.2.0", + "vite-plugin-cesium": "^1.2.23", + "vite-plugin-svg-icons": "^2.0.1" } } diff --git a/packages/screen/src/assets/icons/svg/GisLandcoverMap.svg b/packages/screen/src/assets/icons/svg/GisLandcoverMap.svg new file mode 100644 index 0000000..77c0854 --- /dev/null +++ b/packages/screen/src/assets/icons/svg/GisLandcoverMap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/screen/src/assets/icons/svg/GisLayers.svg b/packages/screen/src/assets/icons/svg/GisLayers.svg new file mode 100644 index 0000000..e9af476 --- /dev/null +++ b/packages/screen/src/assets/icons/svg/GisLayers.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/screen/src/assets/icons/svg/compass.svg b/packages/screen/src/assets/icons/svg/compass.svg new file mode 100644 index 0000000..8393f8a --- /dev/null +++ b/packages/screen/src/assets/icons/svg/compass.svg @@ -0,0 +1,14 @@ + + + 编组 44 + + + + + + + + + + + \ No newline at end of file diff --git a/packages/screen/src/assets/icons/svg/compass_bg.svg b/packages/screen/src/assets/icons/svg/compass_bg.svg new file mode 100644 index 0000000..07f5baf --- /dev/null +++ b/packages/screen/src/assets/icons/svg/compass_bg.svg @@ -0,0 +1,21 @@ + + + 编组 43 + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/screen/src/components/SvgIcon/index.vue b/packages/screen/src/components/SvgIcon/index.vue new file mode 100644 index 0000000..057e310 --- /dev/null +++ b/packages/screen/src/components/SvgIcon/index.vue @@ -0,0 +1,55 @@ + + + + + + + + + diff --git a/packages/screen/src/main.js b/packages/screen/src/main.js index f3727f2..71c6a5d 100644 --- a/packages/screen/src/main.js +++ b/packages/screen/src/main.js @@ -5,6 +5,9 @@ import App from './App.vue' import './styles/index.scss' import ElementPlus from 'element-plus' import zhCn from 'element-plus/es/locale/lang/zh-cn' +import 'cesium/Build/Cesium/Widgets/widgets.css' +import 'virtual:svg-icons-register' +import SvgIcon from './components/SvgIcon/index.vue' const app = createApp(App) @@ -13,5 +16,6 @@ app.use(ElementPlus, { locale: zhCn, }) app.use(router) +app.component('svg-icon', SvgIcon) app.mount('#app') diff --git a/packages/screen/src/map/components/BaseMapSwitcher.vue b/packages/screen/src/map/components/BaseMapSwitcher.vue new file mode 100644 index 0000000..977184b --- /dev/null +++ b/packages/screen/src/map/components/BaseMapSwitcher.vue @@ -0,0 +1,370 @@ + + + + + + + + + + + + + + {{ group.name }} + {{ group.layerIds.length }} 个图层 + + + + + + + + + + + + + + + + + diff --git a/packages/screen/src/map/components/LayerDirectoryControl.vue b/packages/screen/src/map/components/LayerDirectoryControl.vue new file mode 100644 index 0000000..8dc8cea --- /dev/null +++ b/packages/screen/src/map/components/LayerDirectoryControl.vue @@ -0,0 +1,588 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ item.meta?.title || item.id }} + + + + + + + + + + + + + + {{ item.show ? '隐藏' : '显示' }} + + + + + + + + + + + + + + + + + diff --git a/packages/screen/src/map/components/MapCompass.vue b/packages/screen/src/map/components/MapCompass.vue new file mode 100644 index 0000000..1c0bf94 --- /dev/null +++ b/packages/screen/src/map/components/MapCompass.vue @@ -0,0 +1,332 @@ + + + + + + + + + + {{ directionText }} + + + {{ displayHeading }}° + + + + + + diff --git a/packages/screen/src/map/components/MapControls.vue b/packages/screen/src/map/components/MapControls.vue new file mode 100644 index 0000000..264d86c --- /dev/null +++ b/packages/screen/src/map/components/MapControls.vue @@ -0,0 +1,385 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/screen/src/map/components/MapViewport.vue b/packages/screen/src/map/components/MapViewport.vue new file mode 100644 index 0000000..9491356 --- /dev/null +++ b/packages/screen/src/map/components/MapViewport.vue @@ -0,0 +1,169 @@ + + + + + + + diff --git a/packages/screen/src/map/components/SceneModeToggle.vue b/packages/screen/src/map/components/SceneModeToggle.vue new file mode 100644 index 0000000..1276210 --- /dev/null +++ b/packages/screen/src/map/components/SceneModeToggle.vue @@ -0,0 +1,536 @@ + + + {{ buttonLabel }} + + + + + + diff --git a/packages/screen/src/map/composables/useMapViewSnapshot.js b/packages/screen/src/map/composables/useMapViewSnapshot.js new file mode 100644 index 0000000..8a5ee64 --- /dev/null +++ b/packages/screen/src/map/composables/useMapViewSnapshot.js @@ -0,0 +1,97 @@ +import { ref } from 'vue' +import useMapStore from '@/map/stores/mapStore' + +/** + * @function useMapViewSnapshot + * @description 提供捕获与恢复地图视角的组合式函数,适用于在页面间切换时保持用户视角。 + * @returns {{ + * viewSnapshot: import('vue').Ref|null>, + * captureViewSnapshot: (force?: boolean) => Record|null, + * restoreViewSnapshot: (options?: { duration?: number, clearAfterRestore?: boolean }) => Promise, + * clearViewSnapshot: () => void + * }} + */ +export function useMapViewSnapshot() { + const mapStore = useMapStore() + const viewSnapshot = ref(null) + + /** + * @function captureViewSnapshot + * @description 捕获当前地图视角,可选择是否强制覆盖已有快照。 + * @param {boolean} [force=false] 是否强制覆盖已有快照 + * @returns {Record|null} 记录的视角信息 + */ + const captureViewSnapshot = (force = false) => { + if (!force && viewSnapshot.value) { + return viewSnapshot.value + } + + if (!mapStore.isReady()) { + return null + } + + try { + const { camera } = mapStore.services() + const currentView = camera.getCurrentView() + if (currentView) { + viewSnapshot.value = { ...currentView } + } + return viewSnapshot.value + } catch (error) { + console.warn('捕获地图视角失败:', error) + return null + } + } + + /** + * @function restoreViewSnapshot + * @description 恢复此前捕获的地图视角,默认恢复后清空快照。 + * @param {{ duration?: number, clearAfterRestore?: boolean }} [options] 恢复参数 + * @returns {Promise} 是否成功发起恢复 + */ + const restoreViewSnapshot = async (options = {}) => { + if (!viewSnapshot.value || !mapStore.isReady()) { + return false + } + + const { duration = 1, clearAfterRestore = true } = options + const snapshot = viewSnapshot.value + + try { + const { camera } = mapStore.services() + await camera.flyTo({ + lon: snapshot.lon, + lat: snapshot.lat, + height: snapshot.height, + heading: snapshot.heading ?? 0, + pitch: snapshot.pitch ?? -45, + roll: snapshot.roll ?? 0, + duration + }) + if (clearAfterRestore) { + viewSnapshot.value = null + } + return true + } catch (error) { + console.warn('恢复地图视角失败:', error) + return false + } + } + + /** + * @function clearViewSnapshot + * @description 主动清除已缓存的地图视角快照。 + */ + const clearViewSnapshot = () => { + viewSnapshot.value = null + } + + return { + viewSnapshot, + captureViewSnapshot, + restoreViewSnapshot, + clearViewSnapshot + } +} + +export default useMapViewSnapshot diff --git a/packages/screen/src/map/data/baseMap.json b/packages/screen/src/map/data/baseMap.json new file mode 100644 index 0000000..c019a09 --- /dev/null +++ b/packages/screen/src/map/data/baseMap.json @@ -0,0 +1,164 @@ +[ + { + "Rid": "110", + "Name": "天地图", + "Attribute": { + "rid": 110, + "name": "天地图", + "layerId": 0, + "internalService": 1, + "serviceTypeId": "", + "serviceTypeName": "", + "servicePath": "", + "domainService": "", + "status": 0, + "parentId": "#", + "sortValue": 1, + "createTime": "2025-05-26T14:44:05.926Z", + "dataType": "1", + "bootLoad": 0, + "internalServiceName": "", + "historyServicePath": "", + "expandParam": "", + "thumbnail": "", + "showDirectory": 0, + "selectSubLayer": "", + "accessInfo": "", + "pcatalog": "DDT", + "orgCode": "bdzl" + }, + "Children": [ + { + "Rid": "87", + "Name": "天地图卫星底图", + "Attribute": { + "rid": 87, + "name": "天地图卫星底图", + "layerId": 0, + "internalService": 2, + "serviceTypeId": "", + "serviceTypeName": "TiandituImgLayer", + "servicePath": "http://t{s}.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=b78ed71126d03ee82ce658731344a897", + "domainService": "", + "status": 0, + "parentId": "110", + "sortValue": 1, + "createTime": "2025-09-15T18:12:06.276754Z", + "dataType": "2", + "bootLoad": 0, + "internalServiceName": "", + "historyServicePath": "", + "expandParam": "{\"data\":[],\"editUrl\":\"\",\"isUseExpandParam\":false,\"expandedNode\":false,\"mapUseRange\":[\"2D\"]}", + "thumbnail": "", + "showDirectory": 0, + "selectSubLayer": "", + "accessInfo": "{\"verifyType\":\"usrpwd\",\"tokenName\":\"token\",\"isUseVerification\":false,\"isUseToken\":false,\"verifyParams\":{\"url\":\"\",\"username\":\"\",\"password\":\"\",\"token\":\"\",\"tokenLoginUrl\":\"\",\"requestType\":\"Get\",\"requestParam\":\"\",\"interval\":60,\"rule\":\"\",\"tokenName\":\"\"}}", + "pcatalog": "DDT", + "orgCode": "bdzl" + }, + "Children": [], + "SortValue": 0, + "ParentId": "" + }, + { + "Rid": "112", + "Name": "注记", + "Attribute": { + "rid": 112, + "name": "注记", + "layerId": 0, + "internalService": 2, + "serviceTypeId": "", + "serviceTypeName": "TiandituCvaLayer", + "servicePath": "http://t{s}.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=b78ed71126d03ee82ce658731344a897", + "domainService": "", + "status": 0, + "parentId": "110", + "sortValue": 10, + "createTime": "2025-05-07T11:25:15.845Z", + "dataType": "2", + "bootLoad": 0, + "internalServiceName": "", + "historyServicePath": "", + "expandParam": "{\"data\":[],\"editUrl\":\"\",\"isUseExpandParam\":false,\"expandedNode\":false,\"mapUseRange\":[\"2D\"]}", + "thumbnail": "", + "showDirectory": 0, + "selectSubLayer": "", + "accessInfo": "{\"verifyType\":\"usrpwd\",\"tokenName\":\"token\",\"isUseVerification\":false,\"isUseToken\":false,\"verifyParams\":{\"url\":\"\",\"username\":\"\",\"password\":\"\",\"token\":\"\",\"tokenLoginUrl\":\"\",\"requestType\":\"Get\",\"requestParam\":\"\",\"interval\":60,\"rule\":\"\",\"tokenName\":\"\"}}", + "pcatalog": "DDT", + "orgCode": "bdzl" + }, + "Children": [], + "SortValue": 0, + "ParentId": "" + } + ], + "SortValue": 0, + "ParentId": "" + }, + { + "Rid": "95", + "Name": "arcgis", + "Attribute": { + "rid": 95, + "name": "arcgis", + "layerId": 0, + "internalService": 0, + "serviceTypeId": "", + "serviceTypeName": "", + "servicePath": "", + "domainService": "", + "status": 0, + "parentId": "#", + "sortValue": 1, + "createTime": "2025-04-29T16:02:53.178812Z", + "dataType": "1", + "bootLoad": 0, + "internalServiceName": "", + "historyServicePath": "", + "expandParam": "", + "thumbnail": "", + "showDirectory": 0, + "selectSubLayer": "", + "accessInfo": "", + "pcatalog": "", + "orgCode": "bdzl" + }, + "Children": [ + { + "Rid": "101", + "Name": "arcgis瓦片影像", + "Attribute": { + "rid": 101, + "name": "arcgis瓦片影像", + "layerId": 0, + "internalService": 2, + "serviceTypeId": "", + "serviceTypeName": "ArcGISTiledMapServiceLayer", + "servicePath": "https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + "domainService": "", + "status": 0, + "parentId": "95", + "sortValue": 1, + "createTime": "2025-04-29T16:18:30.010701Z", + "dataType": "2", + "bootLoad": 0, + "internalServiceName": "", + "historyServicePath": "", + "expandParam": "{\"data\":[],\"editUrl\":\"\",\"isUseExpandParam\":false,\"expandedNode\":false,\"mapUseRange\":[\"2D\"]}", + "thumbnail": "", + "showDirectory": 0, + "selectSubLayer": "", + "accessInfo": "{\"verifyType\":\"usrpwd\",\"tokenName\":\"token\",\"isUseVerification\":false,\"isUseToken\":false,\"verifyParams\":{\"url\":\"\",\"username\":\"\",\"password\":\"\",\"token\":\"\",\"tokenLoginUrl\":\"\",\"requestType\":\"Get\",\"requestParam\":\"\",\"interval\":60,\"rule\":\"\",\"tokenName\":\"\"}}", + "pcatalog": "", + "orgCode": "bdzl" + }, + "Children": [], + "SortValue": 0, + "ParentId": "" + } + ], + "SortValue": 2, + "ParentId": "" + } +] \ No newline at end of file diff --git a/packages/screen/src/map/data/layerMap.json b/packages/screen/src/map/data/layerMap.json new file mode 100644 index 0000000..2f75f5a --- /dev/null +++ b/packages/screen/src/map/data/layerMap.json @@ -0,0 +1,260 @@ +[ + { + "Rid": "104", + "Name": "正射图层", + "Attribute": { + "rid": 104, + "name": "正射图层", + "layerId": 0, + "internalService": 1, + "serviceTypeId": "", + "serviceTypeName": "", + "servicePath": "", + "domainService": "", + "status": 0, + "parentId": "#", + "sortValue": 2, + "createTime": "2025-07-25T16:58:32.71969Z", + "dataType": "1", + "bootLoad": 0, + "internalServiceName": "", + "historyServicePath": "", + "expandParam": "", + "thumbnail": "", + "showDirectory": 0, + "selectSubLayer": "", + "accessInfo": "", + "pcatalog": "", + "orgCode": "bdzl" + }, + "Children": [ + { + "Rid": "105", + "Name": "2024年3月", + "Attribute": { + "rid": 105, + "name": "2024年3月", + "layerId": 0, + "internalService": 2, + "serviceTypeId": "", + "serviceTypeName": "TMSServiceLayer", + "servicePath": "https://e48e14d9-068b-42e1-8d46-b9e0befd2e70.oss-cn-chengdu.aliyuncs.com/filezip2/mapDT_8b_202403/{z}/{x}/{y}.png", + "domainService": "", + "status": 0, + "parentId": "104", + "sortValue": 2, + "createTime": "2025-07-31T14:17:51.715054Z", + "dataType": "2", + "bootLoad": 0, + "internalServiceName": "", + "historyServicePath": "", + "expandParam": "{\"data\":[],\"editUrl\":\"\",\"isUseExpandParam\":false,\"expandedNode\":false,\"mapUseRange\":[\"2D\",\"3D\"]}", + "thumbnail": "", + "showDirectory": 0, + "selectSubLayer": "", + "accessInfo": "{\"verifyType\":\"usrpwd\",\"tokenName\":\"token\",\"isUseVerification\":false,\"isUseToken\":false,\"verifyParams\":{\"url\":\"\",\"username\":\"\",\"password\":\"\",\"token\":\"\",\"tokenLoginUrl\":\"\",\"requestType\":\"Get\",\"requestParam\":\"\",\"interval\":60,\"rule\":\"\",\"tokenName\":\"\"}}", + "pcatalog": "", + "orgCode": "bdzl" + }, + "Children": [], + "SortValue": 0, + "ParentId": "" + } + ], + "SortValue": 0, + "ParentId": "" + }, + { + "Rid": "96", + "Name": "三维数据", + "Attribute": { + "rid": 96, + "name": "三维数据", + "layerId": 0, + "internalService": 1, + "serviceTypeId": "", + "serviceTypeName": "", + "servicePath": "", + "domainService": "", + "status": 0, + "parentId": "#", + "sortValue": 3, + "createTime": "2025-07-25T16:58:37.246909Z", + "dataType": "1", + "bootLoad": 0, + "internalServiceName": "", + "historyServicePath": "", + "expandParam": "", + "thumbnail": "", + "showDirectory": 0, + "selectSubLayer": "", + "accessInfo": "", + "pcatalog": "", + "orgCode": "bdzl" + }, + "Children": [ + { + "Rid": "103", + "Name": "沙西线模型", + "Attribute": { + "rid": 103, + "name": "沙西线模型", + "layerId": 0, + "internalService": 2, + "serviceTypeId": "", + "serviceTypeName": "Cesium3DTileService", + "servicePath": "https://300bdf2b-a150-406e-be63-d28bd29b409f.oss-cn-chengdu.aliyuncs.com/3dModels/15c167c49b554749baa01d9941c74071/tileset.json", + "domainService": "", + "status": 0, + "parentId": "96", + "sortValue": 1, + "createTime": "2025-04-29T17:11:57.162487Z", + "dataType": "2", + "bootLoad": 0, + "internalServiceName": "", + "historyServicePath": "", + "expandParam": "{\"data\":[],\"editUrl\":\"\",\"isUseExpandParam\":false,\"expandedNode\":false,\"mapUseRange\":[\"3D\"]}", + "thumbnail": "", + "showDirectory": 0, + "selectSubLayer": "", + "accessInfo": "{\"verifyType\":\"usrpwd\",\"tokenName\":\"token\",\"isUseVerification\":false,\"isUseToken\":false,\"verifyParams\":{\"url\":\"\",\"username\":\"\",\"password\":\"\",\"token\":\"\",\"tokenLoginUrl\":\"\",\"requestType\":\"Get\",\"requestParam\":\"\",\"interval\":60,\"rule\":\"\",\"tokenName\":\"\"}}", + "pcatalog": "", + "orgCode": "bdzl" + }, + "Children": [], + "SortValue": 0, + "ParentId": "" + }, + { + "Rid": "109", + "Name": "叶家沟", + "Attribute": { + "rid": 109, + "name": "叶家沟", + "layerId": 0, + "internalService": 2, + "serviceTypeId": "", + "serviceTypeName": "Cesium3DTileService", + "servicePath": "https://300bdf2b-a150-406e-be63-d28bd29b409f.oss-cn-chengdu.aliyuncs.com/3dModels/hd/3Dtiles/tileset.json", + "domainService": "", + "status": 0, + "parentId": "96", + "sortValue": 1, + "createTime": "2025-04-30T17:52:43.877267Z", + "dataType": "2", + "bootLoad": 0, + "internalServiceName": "", + "historyServicePath": "", + "expandParam": "{\"data\":[],\"editUrl\":\"\",\"isUseExpandParam\":false,\"expandedNode\":false,\"mapUseRange\":[\"3D\"]}", + "thumbnail": "", + "showDirectory": 0, + "selectSubLayer": "", + "accessInfo": "{\"verifyType\":\"usrpwd\",\"tokenName\":\"token\",\"isUseVerification\":false,\"isUseToken\":false,\"verifyParams\":{\"url\":\"\",\"username\":\"\",\"password\":\"\",\"token\":\"\",\"tokenLoginUrl\":\"\",\"requestType\":\"Get\",\"requestParam\":\"\",\"interval\":60,\"rule\":\"\",\"tokenName\":\"\"}}", + "pcatalog": "", + "orgCode": "bdzl" + }, + "Children": [], + "SortValue": 0, + "ParentId": "" + }, + { + "Rid": "142", + "Name": "叶家沟0501", + "Attribute": { + "rid": 142, + "name": "叶家沟0501", + "layerId": 0, + "internalService": 2, + "serviceTypeId": "", + "serviceTypeName": "Cesium3DTileService", + "servicePath": "http://8.137.54.85:9000/300bdf2b-a150-406e-be63-d28bd29b409f/dszh/1748396319817718351_OUT/B3DM/tileset.json", + "domainService": "", + "status": 0, + "parentId": "96", + "sortValue": 3, + "createTime": "2025-06-27T16:39:01.3189Z", + "dataType": "2", + "bootLoad": 0, + "internalServiceName": "", + "historyServicePath": "", + "expandParam": "{\"data\":[],\"editUrl\":\"\",\"isUseExpandParam\":false,\"expandedNode\":false,\"mapUseRange\":[\"3D\"]}", + "thumbnail": "", + "showDirectory": 0, + "selectSubLayer": "", + "accessInfo": "{\"verifyType\":\"usrpwd\",\"tokenName\":\"token\",\"isUseVerification\":false,\"isUseToken\":false,\"verifyParams\":{\"url\":\"\",\"username\":\"\",\"password\":\"\",\"token\":\"\",\"tokenLoginUrl\":\"\",\"requestType\":\"Get\",\"requestParam\":\"\",\"interval\":60,\"rule\":\"\",\"tokenName\":\"\"}}", + "pcatalog": "", + "orgCode": "bdzl" + }, + "Children": [], + "SortValue": 0, + "ParentId": "" + }, + { + "Rid": "141", + "Name": "叶家沟0528", + "Attribute": { + "rid": 141, + "name": "叶家沟0528", + "layerId": 0, + "internalService": 2, + "serviceTypeId": "", + "serviceTypeName": "Cesium3DTileService", + "servicePath": "http://8.137.54.85:9000/300bdf2b-a150-406e-be63-d28bd29b409f/dszh/1748398014403562192_OUT/B3DM/tileset.json", + "domainService": "", + "status": 0, + "parentId": "96", + "sortValue": 4, + "createTime": "2025-06-27T16:39:06.368861Z", + "dataType": "2", + "bootLoad": 0, + "internalServiceName": "", + "historyServicePath": "", + "expandParam": "{\"data\":[],\"editUrl\":\"\",\"isUseExpandParam\":false,\"expandedNode\":false,\"mapUseRange\":[\"3D\"]}", + "thumbnail": "", + "showDirectory": 0, + "selectSubLayer": "", + "accessInfo": "{\"verifyType\":\"usrpwd\",\"tokenName\":\"token\",\"isUseVerification\":false,\"isUseToken\":false,\"verifyParams\":{\"url\":\"\",\"username\":\"\",\"password\":\"\",\"token\":\"\",\"tokenLoginUrl\":\"\",\"requestType\":\"Get\",\"requestParam\":\"\",\"interval\":60,\"rule\":\"\",\"tokenName\":\"\"}}", + "pcatalog": "", + "orgCode": "bdzl" + }, + "Children": [], + "SortValue": 0, + "ParentId": "" + }, + { + "Rid": "140", + "Name": "叶家沟0627", + "Attribute": { + "rid": 140, + "name": "叶家沟0627", + "layerId": 0, + "internalService": 2, + "serviceTypeId": "", + "serviceTypeName": "Cesium3DTileService", + "servicePath": "http://222.212.85.86:9000/300bdf2b-a150-406e-be63-d28bd29b409f/dszh/1751005013908225179_OUT/B3DM/tileset.json", + "domainService": "", + "status": 0, + "parentId": "96", + "sortValue": 5, + "createTime": "2025-06-27T16:39:10.920175Z", + "dataType": "2", + "bootLoad": 0, + "internalServiceName": "", + "historyServicePath": "", + "expandParam": "{\"data\":[],\"editUrl\":\"\",\"isUseExpandParam\":false,\"expandedNode\":false,\"mapUseRange\":[\"3D\"]}", + "thumbnail": "", + "showDirectory": 0, + "selectSubLayer": "", + "accessInfo": "{\"verifyType\":\"usrpwd\",\"tokenName\":\"token\",\"isUseVerification\":false,\"isUseToken\":false,\"verifyParams\":{\"url\":\"\",\"username\":\"\",\"password\":\"\",\"token\":\"\",\"tokenLoginUrl\":\"\",\"requestType\":\"Get\",\"requestParam\":\"\",\"interval\":60,\"rule\":\"\",\"tokenName\":\"\"}}", + "pcatalog": "", + "orgCode": "bdzl" + }, + "Children": [], + "SortValue": 0, + "ParentId": "" + } + ], + "SortValue": 0, + "ParentId": "" + } + ] \ No newline at end of file diff --git a/packages/screen/src/map/data/mapBaseConfig.json b/packages/screen/src/map/data/mapBaseConfig.json new file mode 100644 index 0000000..29cf17e --- /dev/null +++ b/packages/screen/src/map/data/mapBaseConfig.json @@ -0,0 +1,37 @@ +[ + { + "rid": 6, + "configName": "InitLevel", + "configValue": "10", + "configDescrition": "默认地图缩放级别", + "orgCode": "bdzl" + }, + { + "rid": 7, + "configName": "Extent", + "configValue": "[100.5, 19.9, 109.1, 25.7]", + "configDescrition": "默认地图边界范围", + "orgCode": "bdzl" + }, + { + "rid": 8, + "configName": "Srs", + "configValue": "{\"wkid\": 4490}", + "configDescrition": "默认地图投影", + "orgCode": "bdzl" + }, + { + "rid": 9, + "configName": "InitHeight", + "configValue": "1200", + "configDescrition": "三维地图初始相机高度", + "orgCode": "bdzl" + }, + { + "rid": 2, + "configName": "InitCenter", + "configValue": "[30.76290247800022, 103.99185896969377]", + "configDescrition": "默认地图中心", + "orgCode": "bdzl" + } + ] diff --git a/packages/screen/src/map/index.js b/packages/screen/src/map/index.js new file mode 100644 index 0000000..9bfd875 --- /dev/null +++ b/packages/screen/src/map/index.js @@ -0,0 +1,12 @@ +export { default as MapViewport } from './components/MapViewport.vue' +export { default as MapControls } from './components/MapControls.vue' + +export { default as BaseMapSwitcher } from './components/BaseMapSwitcher.vue' +export { default as SceneModeToggle } from './components/SceneModeToggle.vue' +export { default as MapCompass } from './components/MapCompass.vue' +export { default as LayerDirectoryControl } from './components/LayerDirectoryControl.vue' + +export { default as useMapStore } from './stores/mapStore' +export { default as useMapUiStore } from './stores/mapUiStore' + +export { useMapViewSnapshot } from './composables/useMapViewSnapshot' diff --git a/packages/screen/src/map/services/createCameraService.js b/packages/screen/src/map/services/createCameraService.js new file mode 100644 index 0000000..6a72f1b --- /dev/null +++ b/packages/screen/src/map/services/createCameraService.js @@ -0,0 +1,374 @@ +import * as Cesium from 'cesium' +import { toRad, heightToZoom, zoomToHeight } from '@/map/utils/utils' + +// 轨迹聚焦配置常量 +const TRAJECTORY_FOCUS_CONFIG = Object.freeze({ + MIN_SPAN_DEG: 0.0005, // 最小经纬度跨度 + MIN_SPAN_METERS: 120, // 最小米制跨度 + MARGIN_RATIO: 0.35, // 边距比例 + MIN_HEIGHT_OFFSET: 300, // 最小高度偏移 + DEFAULT_FOV: 60, // 默认视场角(度) + MIN_FOV: 15, // 最小视场角(度) + DEFAULT_PITCH: -90, // 默认俯视角度(度) + DEFAULT_DURATION: 1.2 // 默认飞行时长(秒) +}) + +/** + * 相机服务:对 Cesium 相机的常用操作做语义封装。 + * 依赖:{ viewerOrThrow, store } + */ +export function createCameraService(deps) { + const { viewerOrThrow, store } = deps + + const hasHomeApi = + !!store && typeof store.setHomeView === 'function' && typeof store.getHomeView === 'function' + + const snapshotFromCamera = (camera) => { + if (!camera) return null + const carto = camera.positionCartographic + if (!carto) return null + return { + lon: Cesium.Math.toDegrees(carto.longitude), + lat: Cesium.Math.toDegrees(carto.latitude), + height: carto.height, + heading: Cesium.Math.toDegrees(camera.heading), + pitch: Cesium.Math.toDegrees(camera.pitch), + roll: Cesium.Math.toDegrees(camera.roll), + } + } + + const flyToAsync = (camera, options) => { + if (!camera) return Promise.resolve(null) + return new Promise((resolve, reject) => { + const opts = options ? { ...options } : {} + const userComplete = typeof opts.complete === 'function' ? opts.complete : null + const userCancel = typeof opts.cancel === 'function' ? opts.cancel : null + + opts.complete = (...args) => { + try { + if (userComplete) userComplete(...args) + } finally { + resolve(true) + } + } + + opts.cancel = (...args) => { + try { + if (userCancel) userCancel(...args) + } finally { + resolve(false) + } + } + + try { + camera.flyTo(opts) + } catch (err) { + reject(err) + } + }) + } + + return { + /** + * 设置相机中心点(经度、纬度、高度)。 + */ + async setCenter(lon, lat, height) { + const viewer = viewerOrThrow() + const targetHeight = typeof height === 'number' ? height : 1500 + viewer.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(lon, lat, targetHeight) }) + }, + + /** + * 设置相机视图与姿态。 + * 参数:{ lon, lat, height, heading, pitch, roll } + */ + setView(options) { + const viewer = viewerOrThrow() + const opts = options || {} + const { lon, lat, height } = opts + const heading = toRad(opts.heading || 0) + const pitch = toRad(opts.pitch != null ? opts.pitch : -45) + const roll = toRad(opts.roll || 0) + viewer.camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(lon, lat, height), + orientation: { heading, pitch, roll }, + }) + }, + + /** + * 相机飞行到目标视图。 + * 参数:{ lon, lat, height, heading, pitch, roll, duration } + */ + async flyTo(options) { + const viewer = viewerOrThrow() + const opts = options || {} + return flyToAsync(viewer.camera, { + destination: Cesium.Cartesian3.fromDegrees(opts.lon, opts.lat, opts.height), + orientation: { + heading: toRad(opts.heading || 0), + pitch: toRad(opts.pitch != null ? opts.pitch : -45), + roll: toRad(opts.roll || 0), + }, + duration: opts.duration || 1.5, + }) + }, + + /** + * 将相机适配到经纬度范围 [minLon, minLat, maxLon, maxLat]。 + */ + async fitBounds(bounds) { + const viewer = viewerOrThrow() + const b = bounds || [0, 0, 0, 0] + const rectangle = Cesium.Rectangle.fromDegrees(b[0], b[1], b[2], b[3]) + return flyToAsync(viewer.camera, { destination: rectangle }) + }, + + /** + * 智能聚焦到轨迹,考虑3D高度和视场角计算安全高度。 + * @param {Array<{lon:number, lat:number, height?:number}>} points - 轨迹点集合 + * @param {Object} options - 配置选项 + * @param {number} [options.pitch] - 俯仰角(度),默认-90度垂直向下 + * @param {number} [options.duration] - 飞行时长(秒),默认1.2秒 + * @param {number} [options.marginRatio] - 边距比例,默认0.35 + * @param {number} [options.minHeightOffset] - 最小高度偏移,默认300米 + * @returns {Promise} 飞行是否成功 + */ + async fitBoundsWithTrajectory(points, options = {}) { + const viewer = viewerOrThrow() + + if (!Array.isArray(points) || points.length === 0) { + console.warn('轨迹点集合为空,无法执行聚焦') + return false + } + + const config = { ...TRAJECTORY_FOCUS_CONFIG, ...options } + const bounds = this._calculateBounds(points) + + if (!bounds) { + console.warn('无法计算轨迹边界范围') + return false + } + + const [minLon, minLat, maxLon, maxLat] = bounds + const centerLon = (minLon + maxLon) / 2 + const centerLat = (minLat + maxLat) / 2 + + // 计算智能高度 + const altitude = this._calculateSmartAltitude(points, bounds, config) + + return flyToAsync(viewer.camera, { + destination: Cesium.Cartesian3.fromDegrees(centerLon, centerLat, altitude), + orientation: { + heading: viewer.camera.heading, + pitch: toRad(config.pitch ?? config.DEFAULT_PITCH), + roll: 0 + }, + duration: config.duration ?? config.DEFAULT_DURATION + }) + }, + + /** + * 设置缩放级别(近似)。可指定固定中心 center: { lon, lat }。 + * options: { animate?: boolean, duration?: number, easing?: Function, orientation?: { heading, pitch, roll } } + */ + async setZoom(zoom, center, options) { + const viewer = viewerOrThrow() + const camera = viewer.camera + const cameraCartographic = camera.positionCartographic + const lat = center && typeof center.lat === 'number' ? center.lat : Cesium.Math.toDegrees(cameraCartographic.latitude) + const lon = center && typeof center.lon === 'number' ? center.lon : Cesium.Math.toDegrees(cameraCartographic.longitude) + const height = zoomToHeight(zoom, lat) + const destination = Cesium.Cartesian3.fromDegrees(lon, lat, height) + + const opts = options || {} + const animate = opts.animate !== false + + if (animate) { + const duration = typeof opts.duration === 'number' ? Math.max(0.01, opts.duration) : 0.6 + const orientation = opts.orientation || { + heading: camera.heading, + pitch: camera.pitch, + roll: camera.roll, + } + const easing = + typeof opts.easing === 'function' + ? opts.easing + : Cesium.EasingFunction?.QUADRATIC_OUT || Cesium.EasingFunction?.LINEAR_NONE + + return flyToAsync(camera, { + destination, + orientation, + duration, + easingFunction: easing, + }) + } + + camera.setView({ destination }) + return null + }, + + /** + * 获取当前缩放级别(近似)。 + */ + getZoom() { + const viewer = viewerOrThrow() + const carto = viewer.camera.positionCartographic + return heightToZoom(carto.height, Cesium.Math.toDegrees(carto.latitude)) + }, + + /** + * 放大(步长默认为 1)。 + */ + async zoomIn(step) { + const stepSize = typeof step === 'number' ? step : 1 + const currentZoom = this.getZoom() + return this.setZoom(currentZoom + stepSize) + }, + + /** + * 缩小(步长默认为 1)。 + */ + async zoomOut(step) { + const stepSize = typeof step === 'number' ? step : 1 + const currentZoom = this.getZoom() + return this.setZoom(Math.max(0, currentZoom - stepSize)) + }, + + /** + * 获取当前相机视图快照(经纬度 + 姿态,角度制)。 + */ + getCurrentView() { + const viewer = viewerOrThrow() + return snapshotFromCamera(viewer.camera) + }, + + /** + * 覆盖 store 中的 home 视图。 + */ + setHomeView(view) { + if (!hasHomeApi) return + store.setHomeView(view) + }, + + /** + * 使用当前相机姿态记住 Home 视图。 + */ + rememberHomeFromCurrent() { + if (!hasHomeApi) return null + const viewer = viewerOrThrow() + const snapshot = snapshotFromCamera(viewer.camera) + store.setHomeView(snapshot) + return snapshot + }, + + /** + * 读取 store 中缓存的 Home 视图。 + */ + getHomeView() { + if (!hasHomeApi) return null + return store.getHomeView() + }, + + /** + * 飞回 Home 视图,可覆写部分参数。 + */ + async flyToHome(options) { + if (!hasHomeApi) return null + const viewer = viewerOrThrow() + const home = store.getHomeView() + if (!home) return null + const overrides = options || {} + const target = { + lon: overrides.lon != null ? overrides.lon : home.lon, + lat: overrides.lat != null ? overrides.lat : home.lat, + height: overrides.height != null ? overrides.height : home.height, + heading: overrides.heading != null ? overrides.heading : home.heading, + pitch: overrides.pitch != null ? overrides.pitch : home.pitch, + roll: overrides.roll != null ? overrides.roll : home.roll, + duration: overrides.duration != null ? overrides.duration : 1.5, + } + return flyToAsync(viewer.camera, { + destination: Cesium.Cartesian3.fromDegrees(target.lon, target.lat, target.height), + orientation: { + heading: toRad(target.heading || 0), + pitch: toRad(target.pitch != null ? target.pitch : -45), + roll: toRad(target.roll || 0), + }, + duration: target.duration, + }) + }, + + /** + * 计算轨迹点的经纬度边界范围 + * @private + * @param {Array<{lon:number, lat:number, height?:number}>} points - 轨迹点集合 + * @returns {[number, number, number, number] | null} [minLon, minLat, maxLon, maxLat] + */ + _calculateBounds(points) { + if (!Array.isArray(points) || points.length === 0) return null + + let minLon = Number.POSITIVE_INFINITY + let maxLon = Number.NEGATIVE_INFINITY + let minLat = Number.POSITIVE_INFINITY + let maxLat = Number.NEGATIVE_INFINITY + + points.forEach(({ lon, lat }) => { + if (!Number.isFinite(lon) || !Number.isFinite(lat)) return + minLon = Math.min(minLon, lon) + maxLon = Math.max(maxLon, lon) + minLat = Math.min(minLat, lat) + maxLat = Math.max(maxLat, lat) + }) + + if (!Number.isFinite(minLon) || !Number.isFinite(minLat)) return null + return [minLon, minLat, maxLon, maxLat] + }, + + /** + * 计算智能高度,考虑视场角、边距和轨迹点高度 + * @private + * @param {Array<{lon:number, lat:number, height?:number}>} points - 轨迹点集合 + * @param {[number, number, number, number]} bounds - 边界范围 + * @param {Object} config - 配置对象 + * @returns {number} 计算得出的相机高度 + */ + _calculateSmartAltitude(points, bounds, config) { + const [minLon, minLat, maxLon, maxLat] = bounds + const centerLat = (minLat + maxLat) / 2 + + // 确保最小跨度 + const lonSpanDeg = Math.max(maxLon - minLon, config.MIN_SPAN_DEG) + const latSpanDeg = Math.max(maxLat - minLat, config.MIN_SPAN_DEG) + + // 转换为弧度 + const centerLatRad = Cesium.Math.toRadians(centerLat) + const lonSpanRad = Cesium.Math.toRadians(lonSpanDeg) + const latSpanRad = Cesium.Math.toRadians(latSpanDeg) + + // 计算实际距离(米) + const equatorialRadius = Cesium.Ellipsoid.WGS84.maximumRadius + const lonSpanMeters = Math.abs(lonSpanRad * equatorialRadius * Math.cos(centerLatRad)) + const latSpanMeters = Math.abs(latSpanRad * equatorialRadius) + const horizontalSpan = Math.max(lonSpanMeters, latSpanMeters, config.MIN_SPAN_METERS) + + // 添加边距 + const spanWithMargin = horizontalSpan * (1 + config.MARGIN_RATIO) + + // 根据视场角计算所需高度 + const viewer = viewerOrThrow() + const camera = viewer.camera + const verticalFov = camera?.frustum?.fov ?? Cesium.Math.toRadians(config.DEFAULT_FOV) + const halfFov = Math.max(verticalFov / 2, Cesium.Math.toRadians(config.MIN_FOV)) + const requiredHeight = spanWithMargin / Math.tan(halfFov) + + // 计算轨迹点的最大高度 + const maxPointHeight = points.reduce((max, item) => { + const value = Number.isFinite(item.height) ? item.height : 0 + return Math.max(max, value) + }, 0) + + // 返回安全高度 + return Math.max(requiredHeight + maxPointHeight, maxPointHeight + config.MIN_HEIGHT_OFFSET) + }, + } +} diff --git a/packages/screen/src/map/services/createEntityService.js b/packages/screen/src/map/services/createEntityService.js new file mode 100644 index 0000000..c5ac8dc --- /dev/null +++ b/packages/screen/src/map/services/createEntityService.js @@ -0,0 +1,134 @@ +import * as Cesium from 'cesium' +import { uid, degToCartesian, degsToCartesians, toCesiumColor, DEFAULT_VECTOR_LAYER_ID } from '@/map/utils/utils' + +// deps: { store, layerService } +export function createEntityService(deps) { + const { store, layerService } = deps + + const svc = { + _ensureVectorLayer(layerId) { + const id = layerId || DEFAULT_VECTOR_LAYER_ID + if (!store.layers[id]) { + return layerService + .addLayer({ id, type: 'vector', source: null, options: { visible: true } }) + .then(() => store.layers[id].obj) + } + return Promise.resolve(store.layers[id].obj) + }, + + async addPoint(opts) { + const o = opts || {} + const ds = await this._ensureVectorLayer(o.layerId) + const id = o.id || uid('point') + const ent = new Cesium.Entity({ + id, + position: degToCartesian(o.position), + point: { + pixelSize: o.pixelSize || 8, + color: toCesiumColor(o.color || '#1E90FF', 1), + heightReference: o.clampToGround + ? Cesium.HeightReference.CLAMP_TO_GROUND + : Cesium.HeightReference.NONE, + }, + properties: o.properties || {}, + }) + ds.entities.add(ent) + return id + }, + + async addPolyline(opts) { + const o = opts || {} + const ds = await this._ensureVectorLayer(o.layerId) + const id = o.id || uid('line') + const ent = new Cesium.Entity({ + id, + polyline: { + positions: degsToCartesians(o.positions || []), + width: o.width || 3, + material: toCesiumColor(o.color || '#FF4500', 1), + clampToGround: !!o.clampToGround, + }, + properties: o.properties || {}, + }) + ds.entities.add(ent) + return id + }, + + async addPolygon(opts) { + const o = opts || {} + const ds = await this._ensureVectorLayer(o.layerId) + const id = o.id || uid('polygon') + const ent = new Cesium.Entity({ + id, + polygon: { + hierarchy: new Cesium.PolygonHierarchy(degsToCartesians(o.positions || [])), + material: toCesiumColor(o.fillColor || 'rgba(0,191,255,0.2)'), + outline: true, + outlineColor: toCesiumColor(o.outlineColor || '#00BFFF', 1), + outlineWidth: o.outlineWidth || 1, + perPositionHeight: !(o.clampToGround === undefined ? true : o.clampToGround), + }, + properties: o.properties || {}, + }) + ds.entities.add(ent) + return id + }, + + async addLabel(opts) { + const o = opts || {} + const ds = await this._ensureVectorLayer(o.layerId) + const id = o.id || uid('label') + const ent = new Cesium.Entity({ + id, + position: degToCartesian(o.position), + label: { + text: o.text || '', + font: o.font || '14px sans-serif', + fillColor: toCesiumColor(o.fillColor || '#ffffff', 1), + outlineColor: toCesiumColor(o.outlineColor || '#000000', 1), + outlineWidth: o.outlineWidth || 2, + pixelOffset: o.pixelOffset || new Cesium.Cartesian2(0, -10), + }, + properties: o.properties || {}, + }) + ds.entities.add(ent) + return id + }, + + removeEntity(entityId) { + if (!entityId) return false + for (const id in store.layers) { + const rec = store.layers[id] + if ((rec.type === 'vector' || rec.type === 'datasource') && rec.obj.entities) { + const e = rec.obj.entities.getById(entityId) + if (e) { + rec.obj.entities.remove(e) + return true + } + } + } + return false + }, + + getEntity(entityId) { + if (!entityId) return undefined + for (const id in store.layers) { + const rec = store.layers[id] + if ((rec.type === 'vector' || rec.type === 'datasource') && rec.obj.entities) { + const e = rec.obj.entities.getById(entityId) + if (e) return e + } + } + return undefined + }, + + clearLayerEntities(layerId) { + const id = layerId || DEFAULT_VECTOR_LAYER_ID + const rec = store.layers[id] + if (rec && rec.obj && rec.obj.entities) rec.obj.entities.removeAll() + }, + } + + return svc +} + diff --git a/packages/screen/src/map/services/createLayerService.js b/packages/screen/src/map/services/createLayerService.js new file mode 100644 index 0000000..c3fadd2 --- /dev/null +++ b/packages/screen/src/map/services/createLayerService.js @@ -0,0 +1,421 @@ +import * as Cesium from 'cesium' +import { SplitDirection } from 'cesium' + +// 依赖:{ viewerOrThrow, store } +export function createLayerService(deps) { + const { viewerOrThrow, store } = deps + + // 影像图层 zIndex 辅助函数 + function nextZIndex() { + const zIndexValues = Object.values(store.layers) + .filter((record) => record && record.type === 'imagery') + .map((record) => (record.meta && typeof record.meta.zIndex === 'number' ? record.meta.zIndex : 0)) + return (zIndexValues.length ? Math.max(...zIndexValues) : 0) + 1 + } + + // 按 zIndex 重新整理影像图层的叠放顺序 + function adjustImageryOrder(viewer) { + try { + const imageryRecords = Object.values(store.layers) + .filter((record) => record && record.type === 'imagery' && record.obj) + .sort((a, b) => { + const aZ = a.meta && typeof a.meta.zIndex === 'number' ? a.meta.zIndex : 0 + const bZ = b.meta && typeof b.meta.zIndex === 'number' ? b.meta.zIndex : 0 + return aZ - bZ + }) + // raiseToTop in ascending order so the highest ends top-most + imageryRecords.forEach((record) => { + try { + if (viewer.imageryLayers.contains(record.obj)) viewer.imageryLayers.raiseToTop(record.obj) + } catch (e) {} + }) + syncImageryOrderMeta(viewer) + } catch (e) {} + } + + /** + * @description 同步影像图层元数据里的排序索引,保持与 Cesium 实际顺序一致 + */ + function syncImageryOrderMeta(viewer) { + try { + const imageryLayers = viewer.imageryLayers + const imageryRecords = Object.values(store.layers) + .filter((record) => record && record.type === 'imagery' && record.obj) + const lookup = new Map() + imageryRecords.forEach((record) => lookup.set(record.obj, record)) + const count = imageryLayers.length + for (let i = 0; i < count; i += 1) { + const layer = imageryLayers.get(i) + const record = lookup.get(layer) + if (!record) continue + if (!record.meta) record.meta = {} + record.meta.zIndex = i + } + } catch (e) {} + } + + /** + * @description 同步矢量数据源图层顺序,记录在 meta.vectorOrder 中 + */ + function syncVectorOrderMeta(viewer) { + try { + const dataSources = viewer.dataSources + const vectorRecords = Object.values(store.layers) + .filter((record) => record && (record.type === 'vector' || record.type === 'datasource') && record.obj) + const lookup = new Map() + vectorRecords.forEach((record) => lookup.set(record.obj, record)) + const count = dataSources.length + for (let i = 0; i < count; i += 1) { + const ds = dataSources.get(i) + const record = lookup.get(ds) + if (!record) continue + if (!record.meta) record.meta = {} + record.meta.vectorOrder = i + } + } catch (e) {} + } + + return { + async addLayer(spec) { + const viewer = viewerOrThrow() + + // 兼容新旧两种参数风格(新的 serviceConfig 与旧的直传 spec) + const layerSpec = spec + const layerType = layerSpec.type + const layerId = layerSpec.id || (layerType ? `${layerType}:${Date.now().toString(36)}` : `layer:${Date.now().toString(36)}`) + if (store.layers[layerId]) return layerId + + const metadata = { ...(layerSpec.meta || {}) } + if (layerSpec.zIndex != null) metadata.zIndex = Number(layerSpec.zIndex) + if (metadata.zIndex == null && (layerType && layerType !== 'terrain' && layerType !== 'primitive' && layerType !== 'vector' && layerType !== 'datasource')) { + metadata.zIndex = nextZIndex() + } + + const layerOptions = layerSpec.options || {} + const sourceUrl = layerSpec.url + + let layerRecord = null + + // 注册影像图层(ImageryLayer)的辅助方法 + const registerImageryLayer = (provider, extraProps = {}) => { + const imageryLayer = viewer.imageryLayers.addImageryProvider(provider) + if (typeof layerOptions.opacity === 'number') imageryLayer.alpha = layerOptions.opacity + if (typeof layerOptions.visible === 'boolean') imageryLayer.show = layerOptions.visible + imageryLayer.splitDirection = SplitDirection.NONE + const record = { + id: layerId, + type: 'imagery', + obj: imageryLayer, + owned: true, + show: imageryLayer.show, + opacity: imageryLayer.alpha, + meta: metadata, + ...extraProps, + } + store.layers[layerId] = record + adjustImageryOrder(viewer) + return record + } + + // 注册矢量数据源的辅助方法 + const registerVectorLayer = async (dataSource) => { + await viewer.dataSources.add(dataSource) + dataSource.show = layerOptions.visible !== false + const record = { + id: layerId, + type: 'vector', + obj: dataSource, + owned: true, + show: dataSource.show, + opacity: typeof layerOptions.opacity === 'number' ? layerOptions.opacity : 1, + meta: metadata, + } + store.layers[layerId] = record + syncVectorOrderMeta(viewer) + return record + } + + // 旧版直映射类型分支 + if (layerType === 'imagery' || layerType === 'baseImagery') { + const provider = layerSpec.source + layerRecord = registerImageryLayer(provider) + if (layerType === 'baseImagery') viewer.imageryLayers.lowerToBottom(layerRecord.obj) + return layerId + } + if (layerType === 'vector' || layerType === 'datasource') { + const dataSource = new Cesium.CustomDataSource(layerId) + layerRecord = await registerVectorLayer(dataSource) + return layerId + } + if (layerType === 'terrain') { + if ('terrain' in viewer) viewer.terrain = layerSpec.source + else viewer.scene.terrainProvider = layerSpec.source + layerRecord = { + id: layerId, + type: 'terrain', + obj: layerSpec.source, + owned: true, + show: true, + opacity: 1, + meta: metadata, + } + store.layers[layerId] = layerRecord + return layerId + } + if (layerType === 'primitive') { + const primitive = layerSpec.source + viewer.scene.primitives.add(primitive) + layerRecord = { + id: layerId, + type: 'primitive', + obj: primitive, + owned: true, + show: true, + opacity: 1, + meta: metadata, + } + store.layers[layerId] = layerRecord + return layerId + } + + // React serviceConfig-style types + switch (layerType) { + case 'ArcGISTiledMapServiceLayer': { + const haveTemplateXYZ = typeof sourceUrl === 'string' && sourceUrl.includes('{z}/{y}/{x}') + if (!haveTemplateXYZ) { + const provider = await Cesium.ArcGisMapServerImageryProvider.fromUrl(sourceUrl, layerOptions) + registerImageryLayer(provider) + } else { + const provider = new Cesium.UrlTemplateImageryProvider({ + url: sourceUrl, + tilingScheme: new Cesium.WebMercatorTilingScheme(), + maximumLevel: 18, + ...layerOptions, + }) + registerImageryLayer(provider) + } + break + } + case 'ArcGISDynamicMapServiceLayer': + case 'ArcGISImageMapServiceLayer': { + const provider = await Cesium.ArcGisMapServerImageryProvider.fromUrl(sourceUrl, { + enablePickFeatures: true, + ...layerOptions, + }) + registerImageryLayer(provider) + break + } + case 'GeoJSONServiceLayer': { + // Accept url or raw data in options.data + const data = layerOptions.data || sourceUrl + const dataSource = await Cesium.GeoJsonDataSource.load(data, layerOptions) + await registerVectorLayer(dataSource) + break + } + case 'WmsServiceLayer': { + const base = (sourceUrl || '').split('?')[0] + const queryParams = (sourceUrl || '').includes('?') ? new URLSearchParams((sourceUrl || '').split('?')[1]) : new URLSearchParams() + const provider = new Cesium.WebMapServiceImageryProvider({ + url: base, + layers: queryParams.get('layers') || layerOptions.layers, + parameters: { + service: 'WMS', + version: queryParams.get('version') || layerOptions.version || '1.1.1', + request: 'GetMap', + format: queryParams.get('format') || layerOptions.format || 'image/png', + transparent: true, + ...layerOptions.parameters, + }, + enablePickFeatures: true, + ...layerOptions, + }) + registerImageryLayer(provider) + break + } + case 'WmtsServiceLayer': + case 'TiandituVecLayer': + case 'TiandituImgLayer': + case 'TiandituCvaLayer': { + // Try to honor tk from url or options + const urlBase = (sourceUrl || '').split('?')[0] + const queryParams = (sourceUrl || '').includes('?') ? new URLSearchParams((sourceUrl || '').split('?')[1]) : new URLSearchParams() + const tk = queryParams.get('tk') || layerOptions.tk + const wmtsUrl = tk ? `${urlBase}?tk=${tk}` : urlBase + const provider = new Cesium.WebMapTileServiceImageryProvider({ + url: wmtsUrl, + layer: queryParams.get('LAYER') || queryParams.get('layer') || layerOptions.layer || 'img', + style: 'default', + format: 'tiles', + tileMatrixSetID: layerOptions.tileMatrixSetID || 'w', + tilingScheme: new Cesium.WebMercatorTilingScheme(), + maximumLevel: layerOptions.maximumLevel || 18, + subdomains: layerOptions.subdomains || ['0', '1', '2', '3', '4', '5', '6', '7'], + ...layerOptions, + }) + registerImageryLayer(provider) + break + } + case 'WebTileLayer': { + const provider = new Cesium.UrlTemplateImageryProvider({ + url: sourceUrl, + tilingScheme: new Cesium.WebMercatorTilingScheme(), + subdomains: layerOptions.subdomains || ['0', '1', '2', '3', '4', '5', '6', '7'], + ...layerOptions, + }) + registerImageryLayer(provider) + break + } + case 'TMSServiceLayer': { // TMS z/x/{reverseY} + const templateUrl = typeof sourceUrl === 'string' ? sourceUrl.replace('{y}', '{reverseY}') : sourceUrl + const providerOptions = { + url: templateUrl, + tilingScheme: new Cesium.WebMercatorTilingScheme(), + maximumLevel: layerOptions.maximumLevel || layerOptions.maxZoom || 22, + ...layerOptions, + } + if (layerOptions.bounds) { + const bounds = layerOptions.bounds + providerOptions.rectangle = Cesium.Rectangle.fromDegrees(bounds.west, bounds.south, bounds.east, bounds.north) + } + const provider = new Cesium.UrlTemplateImageryProvider({ ...providerOptions, url: templateUrl }) + registerImageryLayer(provider) + break + } + case 'TmsServiceLayer': { // XYZ z/x/y + const providerOptions = { + url: sourceUrl, + tilingScheme: new Cesium.WebMercatorTilingScheme(), + maximumLevel: layerOptions.maximumLevel || layerOptions.maxZoom || 22, + ...layerOptions, + } + if (layerOptions.bounds) { + const bounds = layerOptions.bounds + providerOptions.rectangle = Cesium.Rectangle.fromDegrees(bounds.west, bounds.south, bounds.east, bounds.north) + } + const provider = new Cesium.UrlTemplateImageryProvider(providerOptions) + registerImageryLayer(provider) + break + } + case 'Cesium3DTileService': { + const tileset = await Cesium.Cesium3DTileset.fromUrl(sourceUrl, { + ...layerOptions, + }) + viewer.scene.primitives.add(tileset) + layerRecord = { + id: layerId, + type: 'primitive', + obj: tileset, + owned: true, + show: true, + opacity: 1, + meta: metadata, + } + store.layers[layerId] = layerRecord + break + } + default: + throw new Error('不支持的图层类型: ' + layerType) + } + + return layerId + }, + + // 移除图层 + removeLayer(id) { + const viewer = viewerOrThrow() + const record = store.layers[id] + if (!record) return false + try { + if (record.type === 'imagery') { + viewer.imageryLayers.remove(record.obj, true) + syncImageryOrderMeta(viewer) + } else if (record.type === 'vector' || record.type === 'datasource') { + viewer.dataSources.remove(record.obj, true) + syncVectorOrderMeta(viewer) + } else if (record.type === 'primitive') { + viewer.scene.primitives.remove(record.obj) + } else if (record.type === 'terrain') { + if ('terrain' in viewer) viewer.terrain = new Cesium.EllipsoidTerrain() + else viewer.scene.terrainProvider = new Cesium.EllipsoidTerrain() + } + } catch (e) {} + delete store.layers[id] + return true + }, + + // 显隐图层 + showLayer(id, visible) { + const record = store.layers[id] + if (!record) return + if (record.type === 'imagery') record.obj.show = !!visible + else if (record.type === 'vector' || record.type === 'datasource') record.obj.show = !!visible + else if (record.type === 'primitive') record.obj.show = !!visible + record.show = !!visible + }, + + // 设置透明度 + setOpacity(id, alpha) { + const record = store.layers[id] + if (!record) return + if (record.type === 'imagery') { + record.obj.alpha = alpha + record.opacity = alpha + } else { + record.opacity = alpha /* TODO: walk entities/materials */ + } + }, + + // 调整图层顺序(上/下/置顶/置底) + moveLayer(id, direction) { + const viewer = viewerOrThrow() + const record = store.layers[id] + if (!record) return + if (record.type === 'imagery') { + const imageryLayers = viewer.imageryLayers + if (direction === 'up') imageryLayers.raise(record.obj) + else if (direction === 'down') imageryLayers.lower(record.obj) + else if (direction === 'top') imageryLayers.raiseToTop(record.obj) + else if (direction === 'bottom') imageryLayers.lowerToBottom(record.obj) + syncImageryOrderMeta(viewer) + } else if (record.type === 'vector' || record.type === 'datasource') { + const dataSources = viewer.dataSources + if (direction === 'up') dataSources.raise(record.obj) + else if (direction === 'down') dataSources.lower(record.obj) + else if (direction === 'top') dataSources.raiseToTop(record.obj) + else if (direction === 'bottom') dataSources.lowerToBottom(record.obj) + syncVectorOrderMeta(viewer) + } + }, + + // 设置卷帘(左右分屏)位置 + setSplit(id, side) { + const record = store.layers[id] + if (!record || record.type !== 'imagery') return + const splitDirectionMap = { + left: SplitDirection.LEFT, + right: SplitDirection.RIGHT, + none: SplitDirection.NONE, + } + record.obj.splitDirection = splitDirectionMap[side] || SplitDirection.NONE + }, + + // 设置全局卷帘分割位置 [0,1] + setSplitPosition(position) { + const viewer = viewerOrThrow() + store.imagerySplitPosition = Math.min(1, Math.max(0, position)) + try { + viewer.scene.imagerySplitPosition = store.imagerySplitPosition + } catch (e) {} + }, + + // 获取图层记录 + getLayer(id) { + return store.layers[id] + }, + + // 列出所有图层记录 + listLayers() { + return Object.values(store.layers) + }, + } +} diff --git a/packages/screen/src/map/services/createQueryService.js b/packages/screen/src/map/services/createQueryService.js new file mode 100644 index 0000000..466e8d4 --- /dev/null +++ b/packages/screen/src/map/services/createQueryService.js @@ -0,0 +1,60 @@ +import * as Cesium from 'cesium' +import { cartesianToDegrees } from '@/map/utils/utils' + +// deps: { viewerOrThrow, getLayers } +export function createQueryService(deps) { + const { viewerOrThrow, getLayers } = deps + + return { + /** + * 获取屏幕像素位置对应的地理坐标(经纬度/高度)。 + * clamp: 默认 true,优先使用 pickPosition(贴地),失败时回退到椭球体。 + */ + getCoordinateAtScreenPosition(x, y, clamp) { + const viewer = viewerOrThrow() + const scene = viewer.scene + const screenPoint = new Cesium.Cartesian2(x, y) + let cartesian = null + const clampToSurface = clamp !== false + if (clampToSurface && scene.pickPositionSupported) { + try { + cartesian = scene.pickPosition(screenPoint) + } catch (e) {} + } + if (!cartesian) cartesian = viewer.camera.pickEllipsoid(screenPoint, scene.globe.ellipsoid) + if (!cartesian) return null + return cartesianToDegrees(cartesian) + }, + + /** + * 在屏幕坐标拾取实体,并返回实体 id 与所在图层 id(若可判定)。 + */ + pickEntityAt(x, y) { + const viewer = viewerOrThrow() + const picked = viewer.scene.pick(new Cesium.Cartesian2(x, y)) + if (!picked || !picked.id) return null + const entity = picked.id + let layerId = null + const layers = getLayers() + for (const id in layers) { + const layerRecord = layers[id] + if ( + (layerRecord.type === 'vector' || layerRecord.type === 'datasource') && + layerRecord.obj.entities && + layerRecord.obj.entities.contains && + layerRecord.obj.entities.contains(entity) + ) { + layerId = id + break + } + } + return { entityId: entity.id || entity.name || null, layerId } + }, + + cartesianToDegrees, + degreesToCartesian(lon, lat, height) { + const h = typeof height === 'number' ? height : 0 + return Cesium.Cartesian3.fromDegrees(lon, lat, h) + }, + } +} diff --git a/packages/screen/src/map/stores/mapStore.js b/packages/screen/src/map/stores/mapStore.js new file mode 100644 index 0000000..a35cb93 --- /dev/null +++ b/packages/screen/src/map/stores/mapStore.js @@ -0,0 +1,430 @@ +/* + 地图 Store + - 统一管理 Cesium Viewer 生命周期(init/destroy/isReady/onReady/getViewer) + - 暴露 services() 获取子服务: + - layer 图层管理(影像/矢量/地形/原语):添加/移除/显隐/透明度/顺序/卷帘 + - camera 相机视角:中心/视图/飞行/范围/缩放 + - query 查询拾取与坐标转换:屏幕坐标 -> 地理坐标、实体拾取 + - entity 实体:点/线/面/标签 增删查与分层管理 + 注意: + - Store 不主动销毁外部传入的 viewer,仅管理自身所添加的资源。 +*/ + +import * as Cesium from 'cesium' +import { defineStore } from 'pinia' +import { DEFAULT_VECTOR_LAYER_ID } from '@/map/utils/utils' +import { createLayerService } from '@/map/services/createLayerService' +import { createCameraService } from '@/map/services/createCameraService' +import { createQueryService } from '@/map/services/createQueryService' +import { createEntityService } from '@/map/services/createEntityService' +import baseMap from '@/map/data/baseMap.json' +import mapBaseConfig from '@/map/data/mapBaseConfig.json' + +const DEFAULT_HOME_VIEW = { + lon: 0, + lat: 0, + height: 1500, + heading: 0, + pitch: -45, + roll: 0, +} + +const DEFAULT_MAP_SIZE = Object.freeze({ + top:0, + left:0, + width: '100%', + height: '100%', + zIndex: 1 +}) + +function normalizeMapDimension(value, fallback) { + if (typeof value === 'number' && Number.isFinite(value)) { + return `${value}px` + } + if (typeof value === 'string' && value.trim()) { + return value.trim() + } + return fallback +} + +function normalizeHomeView(view) { + if (!view || typeof view !== 'object') return null + const toNumber = (value, fallback) => { + const num = Number(value) + return Number.isFinite(num) ? num : fallback + } + return { + lon: toNumber(view.lon, DEFAULT_HOME_VIEW.lon), + lat: toNumber(view.lat, DEFAULT_HOME_VIEW.lat), + height: toNumber(view.height, DEFAULT_HOME_VIEW.height), + heading: toNumber(view.heading, DEFAULT_HOME_VIEW.heading), + pitch: toNumber(view.pitch, DEFAULT_HOME_VIEW.pitch), + roll: toNumber(view.roll, DEFAULT_HOME_VIEW.roll), + } +} + + +const useMapStore = defineStore('map', { + state: () => ({ + ready: false, + viewer: null, + showMap: true, + cameraPosition: null, // 相机位置(Cesium.Cartesian3 快照) + cameraPosture: { + heading: 0, + pitch: (-90) * (Math.PI / 180), + roll: 0, + }, + homeView: null, + imagerySplitPosition: 0.5, + layers: {}, // 图层表:id -> record {id,type,obj,owned,show,opacity,meta} + _readyQueue: [], // 延迟到 viewer 就绪后执行的回调队列 + handlers: {}, // 事件处理器注册表 + _svcs: null, // services 缓存(避免重复创建) + // 底图配置 + baseMapGroups: baseMap || [], // 底图组配置 + baseMapConfig: mapBaseConfig || [], // 地图基础配置 + currentBaseMapGroupId: null, // 当前激活的底图组ID + defaultImageryProvider: null, // 默认影像提供者配置 + mapSize: { ...DEFAULT_MAP_SIZE }, + }), + + actions: { + // 使用外部已创建的 Cesium.Viewer 进行初始化 + init(viewer) { + if (!viewer) throw new Error('MapStore.init requires a Cesium.Viewer instance') + if (this.ready && this.viewer === viewer) return + this.viewer = viewer + this.ready = true + try { + this.viewer.scene.imagerySplitPosition = this.imagerySplitPosition + } catch (e) { } + // 绑定相机状态快照监听(debounced) + try { + if (this.handlers._cameraChangedCb) { + this.viewer.camera.changed.removeEventListener(this.handlers._cameraChangedCb) + } + const cb = () => { + if (this.handlers._camDebounce) clearTimeout(this.handlers._camDebounce) + this.handlers._camDebounce = setTimeout(() => { + try { + const cam = this.viewer.scene.camera + // 记录相机笛卡尔位置与姿态(弧度) + this.cameraPosition = new Cesium.Cartesian3(cam.position.x, cam.position.y, cam.position.z) + this.cameraPosture = { + heading: cam.heading, + pitch: cam.pitch, + roll: cam.roll, + } + } catch (e) { } + }, 200) + } + this.handlers._cameraChangedCb = cb + this.viewer.camera.changed.addEventListener(cb) + } catch (e) { } + // 确保存在默认矢量数据源图层 + if (!this.layers[DEFAULT_VECTOR_LAYER_ID]) { + const ds = new Cesium.CustomDataSource(DEFAULT_VECTOR_LAYER_ID) + this.viewer.dataSources.add(ds) + this.layers[DEFAULT_VECTOR_LAYER_ID] = { + id: DEFAULT_VECTOR_LAYER_ID, + type: 'vector', + obj: ds, + owned: true, + show: true, + opacity: 1, + meta: { title: 'Default Vector Layer' }, + } + } + // 触发所有 onReady 回调 + const queue = this._readyQueue.slice() + this._readyQueue = [] + queue.forEach((cb) => { + try { + cb(this.viewer) + } catch (e) { } + }) + }, + + // 清理由 Store 管理的资源(不会销毁外部传入的 Viewer 实例本身) + destroy() { + if (!this.viewer) { + this.ready = false + this.homeView = null + this.layers = {} + this._readyQueue = [] + this.handlers = {} + return + } + const viewer = this.viewer + try { + if (this.handlers._cameraChangedCb) viewer.camera.changed.removeEventListener(this.handlers._cameraChangedCb) + if (this.handlers._camDebounce) clearTimeout(this.handlers._camDebounce) + } catch (e) { } + this.homeView = null + Object.keys(this.layers).forEach((id) => { + const rec = this.layers[id] + if (!rec || !rec.owned) return + try { + if (rec.type === 'imagery') { + viewer.imageryLayers.remove(rec.obj, true) + } else if (rec.type === 'vector' || rec.type === 'datasource') { + viewer.dataSources.remove(rec.obj, true) + } else if (rec.type === 'primitive') { + viewer.scene.primitives.remove(rec.obj) + } else if (rec.type === 'terrain') { + if ('terrain' in viewer) viewer.terrain = new Cesium.EllipsoidTerrain() + else viewer.scene.terrainProvider = new Cesium.EllipsoidTerrain() + } + } catch (e) { } + delete this.layers[id] + }) + this.handlers = {} + this.viewer = null + this.ready = false + this._readyQueue = [] + }, + setShowMap(v) { + this.showMap = !!v + }, + getShowMap() { + return this.showMap + }, + setCameraPosition(cameraPosition) { + this.cameraPosition = cameraPosition ? new Cesium.Cartesian3(cameraPosition.x, cameraPosition.y, cameraPosition.z) : null + }, + getCameraPosition() { + return this.cameraPosition + }, + setCameraPosture(posture) { + const p = posture || {} + this.cameraPosture = { + heading: typeof p.heading === 'number' ? p.heading : 0, + pitch: typeof p.pitch === 'number' ? p.pitch : (-90) * (Math.PI / 180), + roll: typeof p.roll === 'number' ? p.roll : 0, + } + }, + getCameraPosture() { + return this.cameraPosture + }, + + setHomeView(view) { + this.homeView = view ? normalizeHomeView(view) : null + }, + getHomeView() { + return this.homeView + }, + clearHomeView() { + this.homeView = null + }, + + // 底图配置管理 + getConfig(configName) { + const rec = this.baseMapConfig.find(i => i.configName === configName) + return rec ? rec.configValue : undefined + }, + + parseJSONSafe(text) { + try { return JSON.parse(text) } catch { return undefined } + }, + + getDefaultBaseMapGroup() { + return Array.isArray(this.baseMapGroups) && this.baseMapGroups.length ? this.baseMapGroups[0] : null + }, + + getCurrentBaseMapGroupId() { + return this.currentBaseMapGroupId || (this.getDefaultBaseMapGroup()?.Attribute?.rid || this.getDefaultBaseMapGroup()?.Rid) + }, + + setCurrentBaseMapGroup(groupId) { + this.currentBaseMapGroupId = groupId + }, + + getBaseMapLayersForGroup(groupId) { + const group = this.baseMapGroups.find(g => (g.Attribute?.rid || g.Rid) === groupId) + if (!group) return [] + + const children = Array.isArray(group.Children) ? group.Children : [] + const groupAttr = group.Attribute || {} + const groupName = groupAttr.name || group.Name + const groupThumb = groupAttr.thumbnail || '' + const groupSortValue = typeof groupAttr.sortValue === 'number' ? groupAttr.sortValue : Number(groupAttr.sortValue) || 0 + + return children.map(item => { + const attr = item.Attribute || {} + const url = attr.servicePath || '' + const serviceTypeName = attr.serviceTypeName || '' + const rid = attr.rid || item.Rid + const zIndex = typeof attr.sortValue === 'number' ? attr.sortValue : Number(attr.sortValue) || 0 + + return { + id: `basemap:${rid}`, + rid, + type: this.resolveLayerType(serviceTypeName, url), + url, + zIndex, + meta: { + title: attr.name || item.Name, + zIndex, + isBaseMap: true, + baseGroupId: groupId, + baseGroupName: groupName, + baseGroupThumbnail: attr.thumbnail || groupThumb, + baseGroupSortValue: groupSortValue, + baseLayerRid: rid, + baseLayerSortValue: zIndex, + } + } + }).filter(layer => layer.type && layer.url) + }, + + resolveLayerType(serviceTypeName, url) { + let nextType = serviceTypeName || '' + if (!nextType) { + if (/(wmts|TILEMATRIXSET)/i.test(url)) { + nextType = 'WmtsServiceLayer' + } else if (/(\{z\}|\{x\}|\{y\})/i.test(url)) { + nextType = 'WebTileLayer' + } + } + return nextType + }, + + async getInitialCameraView() { + // 1) Extent 优先 + const extentStr = this.getConfig('Extent') + const extentArr = extentStr ? this.parseJSONSafe(extentStr) : undefined + if (Array.isArray(extentArr) && extentArr.length === 4) { + const bbox = extentArr.map(Number) // [minLon, minLat, maxLon, maxLat] + return { type: 'extent', value: bbox } + } + + // 2) 其次 InitCenter + InitHeight + const centerStr = this.getConfig('InitCenter') + const heightStr = this.getConfig('InitHeight') + const centerArr = centerStr ? this.parseJSONSafe(centerStr) : undefined + const height = heightStr ? Number(heightStr) : 1500 + if (Array.isArray(centerArr) && centerArr.length >= 2) { + // 注意 mapBaseConfig 里中心的顺序 [lat, lon] + const lat = Number(centerArr[0]) + const lon = Number(centerArr[1]) + return { type: 'center', value: { lon, lat, height } } + } + + // 3) 兜底 + return { type: 'center', value: { lon: 0, lat: 0, height: 20000000 } } + }, + + getDefaultImageryProvider() { + if (this.defaultImageryProvider) { + return this.defaultImageryProvider + } + // 尝试从当前底图组获取第一个图层作为默认底图 + const currentGroupId = this.getCurrentBaseMapGroupId() + if (currentGroupId) { + const layers = this.getBaseMapLayersForGroup(currentGroupId) + if (layers.length > 0) { + const firstLayer = layers[0] + // 这里可以根据图层类型创建相应的ImageryProvider + // 为了简化,我们先返回配置,实际创建在GetCesiumViewer中处理 + return { + type: firstLayer.type, + url: firstLayer.url, + meta: firstLayer.meta + } + } + } + return null + }, + + isReady() { + return !!this.ready && !!this.viewer + }, + + onReady(cb) { + if (this.isReady()) { + try { + cb(this.viewer) + } catch (e) { } + return () => { } + } + this._readyQueue.push(cb) + return () => { + const i = this._readyQueue.indexOf(cb) + if (i >= 0) this._readyQueue.splice(i, 1) + } + }, + + getViewer() { + if (!this.isReady()) throw new Error('MapStore not ready') + return this.viewer + }, + /** + * 设置地图容器尺寸 + * @param {Object} size + * @param {string|number} size.width + * @param {string|number} size.height + * @param {string|number} size.zIndex + */ + setMapSize(size) { + const nextSize = typeof size === 'object' && size !== null ? size : {} + const nextTop = normalizeMapDimension(nextSize.top, DEFAULT_MAP_SIZE.top) + const nextLeft = normalizeMapDimension(nextSize.left, DEFAULT_MAP_SIZE.left) + const nextWidth = normalizeMapDimension(nextSize.width, DEFAULT_MAP_SIZE.width) + const nextHeight = normalizeMapDimension(nextSize.height, DEFAULT_MAP_SIZE.height) + const nextZIndex = nextSize.zIndex + const prevSize = this.mapSize || DEFAULT_MAP_SIZE + const unchanged = + prevSize.top === nextTop && + prevSize.left === nextLeft && + prevSize.width === nextWidth && + prevSize.height === nextHeight && + prevSize.zIndex === nextZIndex + if (unchanged) return + this.mapSize = { + top: nextTop, + left: nextLeft, + width: nextWidth, + height: nextHeight, + zIndex: nextZIndex + } + try { + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('resize')) + } + } catch (error) { } + }, + + /** + * 重置地图容器尺寸 + */ + resetMapSize() { + this.mapSize = { ...DEFAULT_MAP_SIZE } + try { + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('resize')) + } + } catch (error) { } + }, + + // 获取服务集合(懒加载并缓存) + services() { + const store = this + if (store._svcs) return store._svcs + const viewerOrThrow = () => { + if (!store.isReady()) throw new Error('MapStore not ready') + return store.viewer + } + + const layer = createLayerService({ viewerOrThrow, store }) + const camera = createCameraService({ viewerOrThrow, store }) + const query = createQueryService({ viewerOrThrow, getLayers: () => store.layers }) + const entity = createEntityService({ store, layerService: layer }) + + store._svcs = { layer, camera, query, entity } + return store._svcs + }, + }, +}) + +export default useMapStore diff --git a/packages/screen/src/map/stores/mapUiStore.js b/packages/screen/src/map/stores/mapUiStore.js new file mode 100644 index 0000000..004a745 --- /dev/null +++ b/packages/screen/src/map/stores/mapUiStore.js @@ -0,0 +1,40 @@ +import { defineStore } from 'pinia' + +const hasOwn = (target, key) => Object.prototype.hasOwnProperty.call(target, key) + +// 控件运行态 Store:仅处理 UI 控件的显隐等运行时状态 +const useMapUiStore = defineStore('mapUi', { + state: () => ({ + controlVisibility: {}, + }), + + getters: { + isControlVisible: (state) => (id) => { + if (!id) return true + const flag = state.controlVisibility[id] + return flag !== false + }, + hasControlOverride: (state) => (id) => { + if (!id) return false + return hasOwn(state.controlVisibility, id) + }, + }, + + actions: { + setControlVisibility(id, visible) { + if (!id) return + this.controlVisibility[id] = visible !== false + }, + + resetControlVisibility(id) { + if (!id) return + delete this.controlVisibility[id] + }, + + clearControlVisibility() { + this.controlVisibility = {} + }, + }, +}) + +export default useMapUiStore diff --git a/packages/screen/src/map/utils/pickPosition.js b/packages/screen/src/map/utils/pickPosition.js new file mode 100644 index 0000000..2013492 --- /dev/null +++ b/packages/screen/src/map/utils/pickPosition.js @@ -0,0 +1,111 @@ +/** + * 地图点击位置拾取工具 + * 解决标记位置偏移问题 + */ +import * as Cesium from 'cesium' + +/** + * 更准确的地图位置拾取方法 + * @param {Cesium.Viewer} viewer - Cesium viewer实例 + * @param {Cesium.Cartesian2} clickPosition - 屏幕点击位置 + * @returns {Object|null} 返回 {cartesian3: Cesium.Cartesian3, cartographic: Object} 或 null + */ +export function pickMapPosition(viewer, clickPosition) { + if (!viewer || !clickPosition) { + return null + } + + let pickedPosition = null + + try { + // 方法1: 尝试使用场景拾取(最准确,考虑地形) + const ray = viewer.camera.getPickRay(clickPosition) + if (ray) { + // 先尝试拾取地形表面 + pickedPosition = viewer.scene.globe.pick(ray, viewer.scene) + + if (!pickedPosition) { + // 如果地形拾取失败,使用椭球面拾取作为fallback + pickedPosition = viewer.camera.pickEllipsoid(clickPosition, viewer.scene.globe.ellipsoid) + } + } + + // 方法2: 如果上述方法都失败,使用椭球面拾取 + if (!pickedPosition) { + pickedPosition = viewer.camera.pickEllipsoid(clickPosition, viewer.scene.globe.ellipsoid) + } + + if (pickedPosition) { + // 转换为地理坐标 + const cartographic = Cesium.Cartographic.fromCartesian(pickedPosition) + const longitude = Cesium.Math.toDegrees(cartographic.longitude) + const latitude = Cesium.Math.toDegrees(cartographic.latitude) + const height = cartographic.height + + return { + cartesian3: pickedPosition, + cartographic: { + longitude, + latitude, + height, + lon: longitude, // 兼容现有代码 + lat: latitude // 兼容现有代码 + } + } + } + } catch (error) { + console.warn('Position picking failed:', error) + } + + return null +} + +/** + * 创建标记实体的统一配置 + * @param {Object} coordinates - 地理坐标 {lat, lng} + * @param {Object} options - 标记选项 + * @returns {Object} Cesium实体配置 + */ +export function createMarkerEntityConfig(coordinates, options = {}) { + const { + id = 'map-marker', + imageUrl = '/src/assets/images/marker-red.svg', + width = 32, + height = 32, + showLabel = true, + labelOffset = 40, + fontSize = '12pt' + } = options + + const config = { + id, + billboard: { + image: imageUrl, + width, + height, + verticalOrigin: Cesium.VerticalOrigin.BOTTOM, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + // 添加像素偏移补偿 + pixelOffset: new Cesium.Cartesian2(0, 0), + // 禁用深度测试以确保标记总是可见 + disableDepthTestDistance: Number.POSITIVE_INFINITY + } + } + + if (showLabel) { + config.label = { + text: `${coordinates.lat.toFixed(6)}, ${coordinates.lng.toFixed(6)}`, + font: `${fontSize} sans-serif`, + fillColor: Cesium.Color.WHITE, + outlineColor: Cesium.Color.BLACK, + outlineWidth: 2, + verticalOrigin: Cesium.VerticalOrigin.TOP, + pixelOffset: new Cesium.Cartesian2(0, labelOffset), + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + // 确保标签在标记上方 + eyeOffset: new Cesium.Cartesian3(0, 0, -100) + } + } + + return config +} \ No newline at end of file diff --git a/packages/screen/src/map/utils/utils.js b/packages/screen/src/map/utils/utils.js new file mode 100644 index 0000000..f220714 --- /dev/null +++ b/packages/screen/src/map/utils/utils.js @@ -0,0 +1,59 @@ +import * as Cesium from 'cesium' + +// id generator +export function uid(prefix) { + const p = prefix || 'id' + return p + ':' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 7) +} + +// angle helpers +export const toRad = Cesium.Math.toRadians + +// color normalization +export function toCesiumColor(input, alpha) { + const a = typeof alpha === 'number' ? alpha : 1 + if (!input) return Cesium.Color.WHITE.withAlpha(a) + if (input instanceof Cesium.Color) return input.withAlpha(a) + if (typeof input === 'string') return Cesium.Color.fromCssColorString(input).withAlpha(a) + if (Array.isArray(input)) { + const r = input[0] || 1, + g = input[1] || 1, + b = input[2] || 1, + al = input[3] || a + return new Cesium.Color(r, g, b, al) + } + return Cesium.Color.WHITE.withAlpha(a) +} + +// coordinate conversions +export function degToCartesian(arr) { + return Cesium.Cartesian3.fromDegrees(arr[0], arr[1], arr[2] || 0) +} +export function degsToCartesians(positions) { + return (positions || []).map(degToCartesian) +} +export function cartesianToDegrees(cartesian) { + const c = Cesium.Cartographic.fromCartesian(cartesian) + return { + lon: Cesium.Math.toDegrees(c.longitude), + lat: Cesium.Math.toDegrees(c.latitude), + height: c.height || 0, + } +} + +// simple zoom-height heuristic +const EARTH = 40075016.68557849 +const TILE = 256 +export function heightToZoom(height, lat) { + const la = typeof lat === 'number' ? lat : 0 + const z = Math.log2((EARTH * Math.cos(toRad(la))) / (TILE * Math.max(height || 1, 1))) + 8 + return Math.max(0, z) +} +export function zoomToHeight(zoom, lat) { + const la = typeof lat === 'number' ? lat : 0 + const h = (EARTH * Math.cos(toRad(la))) / (TILE * Math.pow(2, Math.max((zoom || 0) - 8, 0))) + return Math.max(1, h) +} + +export const DEFAULT_VECTOR_LAYER_ID = 'vector:default' + diff --git a/packages/screen/src/views/cockpit/components/MapCenter.vue b/packages/screen/src/views/cockpit/components/MapCenter.vue index 0b28600..8520dd6 100644 --- a/packages/screen/src/views/cockpit/components/MapCenter.vue +++ b/packages/screen/src/views/cockpit/components/MapCenter.vue @@ -1,5 +1,9 @@ + + + + - +