Merge branch 'dev' of http://222.212.85.86:8222/bdzl2/bxztApp into dev
This commit is contained in:
commit
36fd9cc17c
@ -2,3 +2,4 @@
|
|||||||
|
|
||||||
# 开发环境
|
# 开发环境
|
||||||
VITE_API_BASE_URL=http://localhost:3000/api
|
VITE_API_BASE_URL=http://localhost:3000/api
|
||||||
|
VITE_CESIUM_ION_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI3ZWYyYWYyZi05YmQxLTQzODQtYTIyZi1mMTg2NTAxZGY4NGIiLCJpZCI6MTgzNTU5LCJpYXQiOjE3MDIyMTA3NDZ9.ngQ_4Jd-HsbK_MpofsFs9lUnpRcYCdOcObRVqoOS56U
|
||||||
|
|||||||
@ -2,3 +2,4 @@
|
|||||||
|
|
||||||
# 生产环境
|
# 生产环境
|
||||||
VITE_API_BASE_URL=https://api.example.com
|
VITE_API_BASE_URL=https://api.example.com
|
||||||
|
VITE_CESIUM_ION_TOKEN=
|
||||||
|
|||||||
@ -9,17 +9,20 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.18",
|
|
||||||
"vue-router": "^4.6.3",
|
|
||||||
"pinia": "^3.0.3",
|
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"cesium": "^1.135.0",
|
||||||
"echarts": "^6.0.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": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"vite": "^7.2.0",
|
"less": "^4.4.2",
|
||||||
"sass": "^1.93.3",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import App from './App.vue'
|
|||||||
import './styles/index.scss'
|
import './styles/index.scss'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import 'cesium/Build/Cesium/Widgets/widgets.css'
|
||||||
|
import 'virtual:svg-icons-register'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
|
|||||||
389
packages/screen/src/map/README.md
Normal file
389
packages/screen/src/map/README.md
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
# 🗺️ Cesium 地图 SDK
|
||||||
|
|
||||||
|
基于 Cesium 的 Vue 3 地图组件库,提供完整的地图交互、图层管理、底图切换等功能。
|
||||||
|
|
||||||
|
## ✨ 特性
|
||||||
|
|
||||||
|
- 🎯 **完全自包含** - 所有依赖集成在 `/map` 目录内
|
||||||
|
- 🚀 **开箱即用** - 复制目录即可使用,无需额外配置
|
||||||
|
- 📦 **模块化设计** - 组件化架构,灵活组合
|
||||||
|
- 🎨 **UI 集成** - 内置精美的地图控件 UI
|
||||||
|
- 🔧 **TypeScript 友好** - 完整的类型支持(计划中)
|
||||||
|
- 🌍 **多底图支持** - 天地图、ArcGIS、Cesium Ion 等
|
||||||
|
|
||||||
|
## 📦 核心组件
|
||||||
|
|
||||||
|
### 地图容器
|
||||||
|
- **MapViewport** - 地图视口容器,Cesium Viewer 初始化
|
||||||
|
- **MapControls** - 地图控制面板容器
|
||||||
|
|
||||||
|
### 交互控件
|
||||||
|
- **BaseMapSwitcher** - 底图切换器
|
||||||
|
- **LayerDirectoryControl** - 图层目录管理
|
||||||
|
- **MapCompass** - 指南针导航
|
||||||
|
- **SceneModeToggle** - 2D/3D 场景切换
|
||||||
|
|
||||||
|
### 工具组件
|
||||||
|
- **MapIcon** - SVG 图标组件
|
||||||
|
|
||||||
|
### 状态管理
|
||||||
|
- **useMapStore** - 地图核心状态管理
|
||||||
|
- **useMapUiStore** - 地图 UI 状态管理
|
||||||
|
|
||||||
|
### Composables
|
||||||
|
- **useMapViewSnapshot** - 地图视图快照管理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 1. 复制模块
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 复制整个地图模块到你的项目
|
||||||
|
cp -r src/map /your-project/src/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 核心依赖
|
||||||
|
npm install cesium@^1.135.0
|
||||||
|
npm install vite-plugin-cesium@^1.2.22
|
||||||
|
npm install vite-plugin-svg-icons@^2.0.1
|
||||||
|
|
||||||
|
# Vue 生态(如果项目中没有)
|
||||||
|
npm install vue@^3.5.0 pinia@^3.0.0
|
||||||
|
npm install element-plus@^2.0.0
|
||||||
|
npm install vue-router@^4.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Vite 配置
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// vite.config.js
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import cesium from 'vite-plugin-cesium'
|
||||||
|
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
cesium(),
|
||||||
|
createSvgIconsPlugin({
|
||||||
|
iconDirs: [resolve(__dirname, 'src/map/assets/icons')],
|
||||||
|
symbolId: 'icon-[name]'
|
||||||
|
})
|
||||||
|
],
|
||||||
|
define: {
|
||||||
|
CESIUM_BASE_URL: JSON.stringify('/cesium')
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 主入口配置
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// main.js
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import 'cesium/Build/Cesium/Widgets/widgets.css'
|
||||||
|
import 'virtual:svg-icons-register'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(ElementPlus)
|
||||||
|
app.mount('#app')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 使用示例
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="map-container">
|
||||||
|
<MapViewport />
|
||||||
|
<MapControls />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { MapViewport, MapControls } from '@/map'
|
||||||
|
import { useMapStore } from '@/map'
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
|
||||||
|
const mapStore = useMapStore()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 等待地图加载完成
|
||||||
|
mapStore.onReady(() => {
|
||||||
|
console.log('地图已就绪')
|
||||||
|
|
||||||
|
// 获取地图服务
|
||||||
|
const { camera, layer } = mapStore.services()
|
||||||
|
|
||||||
|
// 飞行到指定位置
|
||||||
|
camera.setCenter(116.4074, 39.9042, 10000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.map-container {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 API 文档
|
||||||
|
|
||||||
|
### MapViewport
|
||||||
|
|
||||||
|
地图视口容器,负责初始化 Cesium Viewer。
|
||||||
|
|
||||||
|
**Props:** 无
|
||||||
|
|
||||||
|
**Events:** 无
|
||||||
|
|
||||||
|
**说明:**
|
||||||
|
- 自动初始化 Cesium Viewer
|
||||||
|
- 配置地图基础参数(地形、场景模式等)
|
||||||
|
- 加载默认底图
|
||||||
|
- 注入 mapStore 实例
|
||||||
|
|
||||||
|
### MapControls
|
||||||
|
|
||||||
|
地图控制面板容器,包含所有交互控件。
|
||||||
|
|
||||||
|
**Props:** 无
|
||||||
|
|
||||||
|
**使用示例:**
|
||||||
|
```vue
|
||||||
|
<MapControls />
|
||||||
|
```
|
||||||
|
|
||||||
|
### useMapStore()
|
||||||
|
|
||||||
|
地图核心状态管理 Store。
|
||||||
|
|
||||||
|
**主要方法:**
|
||||||
|
- `init(viewer)` - 初始化地图实例
|
||||||
|
- `onReady(callback)` - 地图就绪回调
|
||||||
|
- `services()` - 获取地图服务(camera, layer, entity, query)
|
||||||
|
- `destroy()` - 销毁地图实例
|
||||||
|
|
||||||
|
**使用示例:**
|
||||||
|
```javascript
|
||||||
|
import { useMapStore } from '@/map'
|
||||||
|
|
||||||
|
const mapStore = useMapStore()
|
||||||
|
|
||||||
|
// 等待地图就绪
|
||||||
|
mapStore.onReady(() => {
|
||||||
|
const { camera, layer } = mapStore.services()
|
||||||
|
|
||||||
|
// 添加图层
|
||||||
|
layer.addLayer({
|
||||||
|
id: 'my-layer',
|
||||||
|
type: 'WmtsServiceLayer',
|
||||||
|
url: 'https://...',
|
||||||
|
options: { visible: true }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 高级配置
|
||||||
|
|
||||||
|
### 底图配置
|
||||||
|
|
||||||
|
编辑 `src/map/data/baseMap.json` 配置底图服务:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Groups": [
|
||||||
|
{
|
||||||
|
"Attribute": {
|
||||||
|
"rid": "tianditu-group",
|
||||||
|
"name": "天地图",
|
||||||
|
"sortValue": 1
|
||||||
|
},
|
||||||
|
"Children": [
|
||||||
|
{
|
||||||
|
"Attribute": {
|
||||||
|
"rid": "tianditu-img",
|
||||||
|
"name": "天地图影像",
|
||||||
|
"serviceTypeName": "TiandituImgLayer",
|
||||||
|
"servicePath": "http://t{s}.tianditu.gov.cn/img_w/wmts?...",
|
||||||
|
"sortValue": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 图层目录配置
|
||||||
|
|
||||||
|
编辑 `src/map/data/layerMap.json` 配置图层目录:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"Name": "业务图层",
|
||||||
|
"Rid": "business-layers",
|
||||||
|
"Children": [
|
||||||
|
{
|
||||||
|
"Name": "我的图层",
|
||||||
|
"Attribute": {
|
||||||
|
"rid": "my-layer-001",
|
||||||
|
"serviceTypeName": "WmtsServiceLayer",
|
||||||
|
"servicePath": "https://..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
VITE_CESIUM_ION_TOKEN=your_cesium_ion_token
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/map/
|
||||||
|
├── components/ # 地图组件
|
||||||
|
│ ├── MapViewport.vue # 地图视口
|
||||||
|
│ ├── MapControls.vue # 控制面板
|
||||||
|
│ ├── BaseMapSwitcher.vue # 底图切换器
|
||||||
|
│ ├── LayerDirectoryControl.vue # 图层目录
|
||||||
|
│ ├── MapCompass.vue # 指南针
|
||||||
|
│ └── SceneModeToggle.vue # 场景切换
|
||||||
|
├── services/ # 地图服务
|
||||||
|
│ ├── createCameraService.js # 相机服务
|
||||||
|
│ ├── createLayerService.js # 图层服务
|
||||||
|
│ ├── createEntityService.js # 实体服务
|
||||||
|
│ └── createQueryService.js # 查询服务
|
||||||
|
├── stores/ # 状态管理
|
||||||
|
│ ├── mapStore.js # 地图状态
|
||||||
|
│ └── mapUiStore.js # UI 状态
|
||||||
|
├── composables/ # 组合式函数
|
||||||
|
│ └── useMapViewSnapshot.js
|
||||||
|
├── shared/ # 共享组件
|
||||||
|
│ └── SvgIcon/ # 图标组件
|
||||||
|
├── assets/ # 资源文件
|
||||||
|
│ └── icons/ # SVG 图标
|
||||||
|
├── data/ # 配置数据
|
||||||
|
│ ├── baseMap.json # 底图配置
|
||||||
|
│ ├── mapBaseConfig.json # 地图配置
|
||||||
|
│ └── layerMap.json # 图层目录
|
||||||
|
├── utils/ # 工具函数
|
||||||
|
│ ├── pickPosition.js
|
||||||
|
│ └── utils.js
|
||||||
|
├── index.js # 导出入口
|
||||||
|
└── README.md # 本文档
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌍 浏览器支持
|
||||||
|
|
||||||
|
- Chrome >= 90
|
||||||
|
- Firefox >= 88
|
||||||
|
- Safari >= 14
|
||||||
|
- Edge >= 90
|
||||||
|
|
||||||
|
**注意:** Cesium 需要 WebGL 2.0 支持。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 依赖清单
|
||||||
|
|
||||||
|
### PeerDependencies
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cesium": "^1.135.0",
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"pinia": "^3.0.0",
|
||||||
|
"element-plus": "^2.0.0",
|
||||||
|
"vue-router": "^4.0.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DevDependencies
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vite-plugin-cesium": "^1.2.22",
|
||||||
|
"vite-plugin-svg-icons": "^2.0.1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔨 开发指南
|
||||||
|
|
||||||
|
### 添加新组件
|
||||||
|
|
||||||
|
1. 在 `components/` 创建新组件
|
||||||
|
2. 在 `index.js` 导出组件
|
||||||
|
3. 更新本 README 文档
|
||||||
|
|
||||||
|
### 添加新服务
|
||||||
|
|
||||||
|
1. 在 `services/` 创建服务文件
|
||||||
|
2. 在 `mapStore.js` 中注册服务
|
||||||
|
3. 提供完整的 JSDoc 注释
|
||||||
|
|
||||||
|
### 添加新图标
|
||||||
|
|
||||||
|
1. 将 SVG 文件放到 `assets/icons/`
|
||||||
|
2. 使用 `<MapIcon icon-class="your-icon" />` 引用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 贡献
|
||||||
|
|
||||||
|
欢迎提交 Issue 和 Pull Request!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📮 联系方式
|
||||||
|
|
||||||
|
如有问题或建议,请通过以下方式联系:
|
||||||
|
|
||||||
|
- Issue: [GitHub Issues](#)
|
||||||
|
- Email: [your-email@example.com](#)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Generated with ❤️ by Cesium Map SDK Team**
|
||||||
1
packages/screen/src/map/assets/icons/GisLandcoverMap.svg
Normal file
1
packages/screen/src/map/assets/icons/GisLandcoverMap.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.3 KiB |
1
packages/screen/src/map/assets/icons/GisLayers.svg
Normal file
1
packages/screen/src/map/assets/icons/GisLayers.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 100 100"><!-- Icon from Font-GIS by Jean-Marc Viglino - https://github.com/Viglino/font-gis/blob/main/LICENSE-CC-BY.md --><path fill="currentColor" d="M28.135 10.357a3.5 3.5 0 0 0-2.668 1.235L.832 40.607a3.5 3.5 0 0 0 2.67 5.766l93-.064a3.5 3.5 0 0 0 2.666-5.766L74.59 11.592a3.5 3.5 0 0 0-2.668-1.235zM89.91 51.313l-9.178.007l8.211 9.67l-77.875.053l8.22-9.682l-9.188.008L.832 62.283a3.5 3.5 0 0 0 2.67 5.766l93-.065a3.5 3.5 0 0 0 2.666-5.765zm0 21.593l-9.178.008l8.211 9.67l-77.875.053l8.22-9.682l-9.188.008L.832 83.877a3.5 3.5 0 0 0 2.67 5.766l93-.065a3.5 3.5 0 0 0 2.666-5.766z" color="currentColor"/></svg>
|
||||||
|
After Width: | Height: | Size: 686 B |
14
packages/screen/src/map/assets/icons/compass.svg
Normal file
14
packages/screen/src/map/assets/icons/compass.svg
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="40px" height="43px" viewBox="0 0 40 43" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>编组 44</title>
|
||||||
|
<g id="M1-添加" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="全景图&超解析照片" transform="translate(-873.000000, -2462.000000)">
|
||||||
|
<g id="2图标/map/compass备份-12" transform="translate(863.000000, 2455.000000)">
|
||||||
|
<g id="编组-44" transform="translate(10.000000, 7.000000)">
|
||||||
|
<circle id="椭圆形备份-2" stroke="#A2A2A2" stroke-width="1.5" fill="#FFFFFF" cx="20" cy="23" r="19.25"></circle>
|
||||||
|
<polygon id="三角形" fill="#E02020" points="20 0 33 8 7 8"></polygon>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 867 B |
21
packages/screen/src/map/assets/icons/compass_bg.svg
Normal file
21
packages/screen/src/map/assets/icons/compass_bg.svg
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="60px" height="60px" viewBox="0 0 60 60" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>编组 43</title>
|
||||||
|
<defs>
|
||||||
|
<circle id="path-1" cx="30" cy="30" r="30"></circle>
|
||||||
|
</defs>
|
||||||
|
<g id="M1-添加" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="全景图&超解析照片" transform="translate(-863.000000, -2455.000000)">
|
||||||
|
<g id="编组-43" transform="translate(863.000000, 2455.000000)">
|
||||||
|
<mask id="mask-2" fill="white">
|
||||||
|
<use xlink:href="#path-1"></use>
|
||||||
|
</mask>
|
||||||
|
<use id="椭圆形" fill="#FFFFFF" xlink:href="#path-1"></use>
|
||||||
|
<polygon id="N" fill="#4E4E4E" fill-rule="nonzero" mask="url(#mask-2)" points="29.0742188 6.7265625 29.0742188 2.9921875 31.3828125 6.7265625 32.5429688 6.7265625 32.5429688 1 31.46875 1 31.46875 4.82421875 29.125 1 28 1 28 6.7265625"></polygon>
|
||||||
|
<path d="M30.3632812,57.9257812 C30.8606771,57.9257812 31.2760417,57.8561198 31.609375,57.7167969 C31.9427083,57.577474 32.2005208,57.3645833 32.3828125,57.078125 C32.5651042,56.7916667 32.65625,56.484375 32.65625,56.15625 C32.65625,55.7942708 32.5800781,55.4902344 32.4277344,55.2441406 C32.2753906,54.9980469 32.0644531,54.8040365 31.7949219,54.6621094 C31.5253906,54.5201823 31.109375,54.3828125 30.546875,54.25 C29.984375,54.1171875 29.6302083,53.9895833 29.484375,53.8671875 C29.3697917,53.7708333 29.3125,53.6549479 29.3125,53.5195312 C29.3125,53.3710938 29.3736979,53.2526042 29.4960938,53.1640625 C29.6861979,53.0260417 29.9492188,52.9570312 30.2851562,52.9570312 C30.6106771,52.9570312 30.8548177,53.0214844 31.0175781,53.1503906 C31.1803385,53.2792969 31.2864583,53.4908854 31.3359375,53.7851562 L31.3359375,53.7851562 L32.4921875,53.734375 C32.4739583,53.2083333 32.2832031,52.7877604 31.9199219,52.4726562 C31.5566406,52.1575521 31.015625,52 30.296875,52 C29.8567708,52 29.4811198,52.0664062 29.1699219,52.1992188 C28.858724,52.3320312 28.6204427,52.5253906 28.4550781,52.7792969 C28.2897135,53.0332031 28.2070312,53.3059896 28.2070312,53.5976562 C28.2070312,54.0507812 28.3828125,54.4348958 28.734375,54.75 C28.984375,54.9739583 29.4192708,55.1627604 30.0390625,55.3164062 C30.5208333,55.4361979 30.8294271,55.5195312 30.9648438,55.5664062 C31.1627604,55.6367188 31.3014323,55.719401 31.3808594,55.8144531 C31.4602865,55.9095052 31.5,56.0247396 31.5,56.1601562 C31.5,56.3710938 31.405599,56.5553385 31.2167969,56.7128906 C31.0279948,56.8704427 30.7473958,56.9492188 30.375,56.9492188 C30.0234375,56.9492188 29.7441406,56.8606771 29.5371094,56.6835938 C29.3300781,56.5065104 29.1927083,56.2291667 29.125,55.8515625 L29.125,55.8515625 L28,55.9609375 C28.0755208,56.6015625 28.3072917,57.0891927 28.6953125,57.4238281 C29.0833333,57.7584635 29.6393229,57.9257812 30.3632812,57.9257812 Z" id="S" fill="#4E4E4E" fill-rule="nonzero" mask="url(#mask-2)" transform="translate(30.328125, 54.962891) rotate(180.000000) translate(-30.328125, -54.962891) "></path>
|
||||||
|
<polygon id="W" fill="#4E4E4E" fill-rule="nonzero" mask="url(#mask-2)" transform="translate(5.757812, 29.863281) rotate(-90.000000) translate(-5.757812, -29.863281) " points="4.62109375 32.7265625 5.7578125 28.4453125 6.8984375 32.7265625 8.125 32.7265625 9.515625 27 8.3515625 27 7.47265625 31 6.46875 27 5.09375 27 4.046875 30.9335938 3.18359375 27 2 27 3.3671875 32.7265625"></polygon>
|
||||||
|
<polygon id="E" fill="#4E4E4E" fill-rule="nonzero" mask="url(#mask-2)" transform="translate(55.177734, 29.863281) rotate(90.000000) translate(-55.177734, -29.863281) " points="57.3554688 32.7265625 57.3554688 31.7617188 54.15625 31.7617188 54.15625 30.203125 57.03125 30.203125 57.03125 29.2382812 54.15625 29.2382812 54.15625 27.96875 57.2460938 27.96875 57.2460938 27 53 27 53 32.7265625"></polygon>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
371
packages/screen/src/map/components/BaseMapSwitcher.vue
Normal file
371
packages/screen/src/map/components/BaseMapSwitcher.vue
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="switcherRef"
|
||||||
|
class="base-map-switcher"
|
||||||
|
:class="{ 'is-open': panelVisible }"
|
||||||
|
>
|
||||||
|
<transition name="base-map-panel">
|
||||||
|
<section
|
||||||
|
v-if="panelVisible"
|
||||||
|
:id="panelId"
|
||||||
|
class="base-map-switcher__panel"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="false"
|
||||||
|
aria-label="底图切换"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<el-scrollbar class="base-map-switcher__scroll">
|
||||||
|
<ul v-if="baseMapGroups.length" class="base-map-switcher__group-list">
|
||||||
|
<li
|
||||||
|
v-for="group in baseMapGroups"
|
||||||
|
:key="group.id"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="base-map-switcher__group"
|
||||||
|
:class="{ 'is-active': group.id === activeGroupId }"
|
||||||
|
@click="selectBaseGroup(group)"
|
||||||
|
>
|
||||||
|
<div class="base-map-switcher__thumb">
|
||||||
|
<MapIcon
|
||||||
|
icon-class="GisLandcoverMap"
|
||||||
|
class="base-map-switcher__icon"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="group.id === activeGroupId"
|
||||||
|
class="base-map-switcher__check"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="base-map-switcher__meta">
|
||||||
|
<span class="base-map-switcher__name">{{ group.name }}</span>
|
||||||
|
<span class="base-map-switcher__count">{{ group.layerIds.length }} 个图层</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<el-empty v-else description="暂无底图" />
|
||||||
|
</el-scrollbar>
|
||||||
|
</section>
|
||||||
|
</transition>
|
||||||
|
<button
|
||||||
|
class="base-map-switcher__trigger"
|
||||||
|
|
||||||
|
type="button"
|
||||||
|
:aria-expanded="panelVisible"
|
||||||
|
:aria-controls="panelId"
|
||||||
|
@click.stop="togglePanel"
|
||||||
|
>
|
||||||
|
<MapIcon icon-class="GisLandcoverMap" class="base-map-switcher__trigger-icon" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import MapIcon from '@/map/shared/SvgIcon/index.vue'
|
||||||
|
import useMapStore from '@/map/stores/mapStore'
|
||||||
|
|
||||||
|
const panelId = 'base-map-switcher-panel'
|
||||||
|
|
||||||
|
const mapStore = useMapStore()
|
||||||
|
const { layers: layerTable } = storeToRefs(mapStore)
|
||||||
|
|
||||||
|
const switcherRef = ref(null)
|
||||||
|
const panelVisible = ref(false)
|
||||||
|
const layerService = shallowRef(null)
|
||||||
|
let detachReadyListener = null
|
||||||
|
|
||||||
|
const baseMapGroups = computed(() => {
|
||||||
|
const grouped = new Map()
|
||||||
|
const records = Object.values(layerTable.value || {})
|
||||||
|
.filter((record) => record && record.meta?.isBaseMap)
|
||||||
|
records.forEach((record) => {
|
||||||
|
const meta = record.meta || {}
|
||||||
|
const groupId = meta.baseGroupId || 'default'
|
||||||
|
if (!grouped.has(groupId)) {
|
||||||
|
grouped.set(groupId, {
|
||||||
|
id: groupId,
|
||||||
|
name: meta.baseGroupName || '底图',
|
||||||
|
thumbnail: meta.baseGroupThumbnail || '',
|
||||||
|
sortValue: normalizeNumber(meta.baseGroupSortValue),
|
||||||
|
layerIds: [],
|
||||||
|
layers: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const group = grouped.get(groupId)
|
||||||
|
group.layerIds.push(record.id)
|
||||||
|
group.layers.push(record)
|
||||||
|
})
|
||||||
|
const list = Array.from(grouped.values())
|
||||||
|
list.forEach((group) => {
|
||||||
|
group.isActive = group.layers.some((layer) => layer.show)
|
||||||
|
})
|
||||||
|
return list.sort((a, b) => a.sortValue - b.sortValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeGroupId = computed(() => {
|
||||||
|
const activeGroup = baseMapGroups.value.find((group) => group.isActive)
|
||||||
|
return activeGroup ? activeGroup.id : (baseMapGroups.value[0]?.id ?? null)
|
||||||
|
})
|
||||||
|
|
||||||
|
function togglePanel() {
|
||||||
|
panelVisible.value = !panelVisible.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLayerService() {
|
||||||
|
try {
|
||||||
|
layerService.value = mapStore.services().layer
|
||||||
|
} catch (err) {
|
||||||
|
layerService.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOutsideClick(event) {
|
||||||
|
if (!panelVisible.value) return
|
||||||
|
const el = switcherRef.value
|
||||||
|
if (!el) return
|
||||||
|
if (!el.contains(event.target)) {
|
||||||
|
panelVisible.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
resolveLayerService()
|
||||||
|
document.addEventListener('click', handleOutsideClick)
|
||||||
|
detachReadyListener = mapStore.onReady(() => {
|
||||||
|
resolveLayerService()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', handleOutsideClick)
|
||||||
|
if (typeof detachReadyListener === 'function') {
|
||||||
|
detachReadyListener()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => mapStore.ready,
|
||||||
|
(ready) => {
|
||||||
|
if (ready) resolveLayerService()
|
||||||
|
else layerService.value = null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function normalizeNumber(input) {
|
||||||
|
if (typeof input === 'number' && Number.isFinite(input)) return input
|
||||||
|
const num = Number(input)
|
||||||
|
return Number.isFinite(num) ? num : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function createThumbStyle(group) {
|
||||||
|
if (group.thumbnail) {
|
||||||
|
return {
|
||||||
|
backgroundImage: `url(${group.thumbnail})`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const key = String(group.id ?? '')
|
||||||
|
const seed = Math.abs(key.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) || 1)
|
||||||
|
const hue = (seed * 37) % 360
|
||||||
|
return {
|
||||||
|
backgroundImage: `linear-gradient(135deg, hsl(${hue}, 68%, 68%), hsl(${(hue + 32) % 360}, 64%, 58%))`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBaseGroup(group) {
|
||||||
|
if (!group || !layerService.value) {
|
||||||
|
ElMessage.warning('地图尚未就绪')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
baseMapGroups.value.forEach((candidate) => {
|
||||||
|
const visible = candidate.id === group.id
|
||||||
|
candidate.layerIds.forEach((layerId) => {
|
||||||
|
layerService.value.showLayer(layerId, visible)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
panelVisible.value = false
|
||||||
|
} catch (err) {
|
||||||
|
console.error('底图切换失败', err)
|
||||||
|
ElMessage.error('底图切换失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.base-map-switcher {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher.is-open .base-map-switcher__trigger {
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher__trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #1f1f1f;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher__trigger:hover {
|
||||||
|
background: #ffffff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher__trigger:focus-visible {
|
||||||
|
outline: 2px solid rgba(79, 233, 255, 0.6);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher__trigger-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher__panel {
|
||||||
|
position: absolute;
|
||||||
|
right: calc(100% + 12px);
|
||||||
|
bottom: 0;
|
||||||
|
width: 150px;
|
||||||
|
max-height: 240px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(33, 33, 33, 0.45);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
color: #ffffff;
|
||||||
|
pointer-events: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher__scroll {
|
||||||
|
max-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher__group-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher__group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher__group:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher__group.is-active {
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #1f1f1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher__group.is-active .base-map-switcher__count {
|
||||||
|
color: rgba(31, 31, 31, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher__group.is-active .base-map-switcher__thumb {
|
||||||
|
background: rgba(31, 31, 31, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher__group.is-active .base-map-switcher__icon {
|
||||||
|
color: #1f1f1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher__thumb {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher__icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher__check {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
right: -2px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid #1f1f1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher__name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-switcher__count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-panel-enter-active,
|
||||||
|
.base-map-panel-leave-active {
|
||||||
|
transition: opacity 0.24s ease, transform 0.24s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-map-panel-enter-from,
|
||||||
|
.base-map-panel-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(12px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
589
packages/screen/src/map/components/LayerDirectoryControl.vue
Normal file
589
packages/screen/src/map/components/LayerDirectoryControl.vue
Normal file
@ -0,0 +1,589 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layer-directory-control">
|
||||||
|
<el-tooltip content="图层目录" placement="right">
|
||||||
|
<el-button
|
||||||
|
class="layer-directory-control__toggle"
|
||||||
|
type="primary"
|
||||||
|
@click="togglePanel"
|
||||||
|
>
|
||||||
|
<MapIcon icon-class="GisLayers" />
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
<transition name="layer-directory-fade">
|
||||||
|
<div
|
||||||
|
v-if="panelVisible"
|
||||||
|
class="layer-directory-control__panel"
|
||||||
|
>
|
||||||
|
<el-card class="layer-directory-control__card" shadow="always">
|
||||||
|
<div class="layer-directory-control__card-header">
|
||||||
|
<el-tabs v-model="activeTab" stretch>
|
||||||
|
<el-tab-pane label="目录视图" name="catalog" />
|
||||||
|
<el-tab-pane label="图层视图" name="loaded" />
|
||||||
|
</el-tabs>
|
||||||
|
<el-button
|
||||||
|
type="text"
|
||||||
|
:icon="Close"
|
||||||
|
class="layer-directory-control__close"
|
||||||
|
@click="panelVisible = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="activeTab === 'catalog'" class="layer-directory-control__body">
|
||||||
|
<el-input
|
||||||
|
v-model="filterText"
|
||||||
|
class="layer-directory-control__search"
|
||||||
|
clearable
|
||||||
|
:prefix-icon="Search"
|
||||||
|
placeholder="搜索图层"
|
||||||
|
/>
|
||||||
|
<el-scrollbar class="layer-directory-control__scroll">
|
||||||
|
<el-tree
|
||||||
|
ref="treeRef"
|
||||||
|
:data="treeData"
|
||||||
|
:props="treeProps"
|
||||||
|
node-key="id"
|
||||||
|
show-checkbox
|
||||||
|
highlight-current
|
||||||
|
:expand-on-click-node="false"
|
||||||
|
:default-expanded-keys="defaultExpandedKeys"
|
||||||
|
@check-change="handleTreeCheckChange"
|
||||||
|
:filter-node-method="filterTreeNode"
|
||||||
|
/>
|
||||||
|
</el-scrollbar>
|
||||||
|
</div>
|
||||||
|
<div v-else class="layer-directory-control__body">
|
||||||
|
<el-scrollbar class="layer-directory-control__scroll">
|
||||||
|
<div
|
||||||
|
v-if="layerItems.length"
|
||||||
|
class="layer-directory-control__layer-list"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="item in layerItems"
|
||||||
|
:key="item.id"
|
||||||
|
class="layer-directory-control__layer-item"
|
||||||
|
>
|
||||||
|
<div class="layer-directory-control__layer-main">
|
||||||
|
<el-icon class="layer-directory-control__layer-icon">
|
||||||
|
<CollectionTag v-if="item.type === 'imagery'" />
|
||||||
|
<DataAnalysis v-else-if="item.type === 'vector' || item.type === 'datasource'" />
|
||||||
|
<Operation v-else />
|
||||||
|
</el-icon>
|
||||||
|
<span class="layer-directory-control__layer-title">
|
||||||
|
{{ item.meta?.title || item.id }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="layer-directory-control__layer-actions">
|
||||||
|
<el-button-group>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
:icon="ArrowUp"
|
||||||
|
@click="moveLayer(item.id, 'up')"
|
||||||
|
:disabled="!layerService"
|
||||||
|
/>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
:icon="ArrowDown"
|
||||||
|
@click="moveLayer(item.id, 'down')"
|
||||||
|
:disabled="!layerService"
|
||||||
|
/>
|
||||||
|
</el-button-group>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
@click="toggleLayerVisibility(item)"
|
||||||
|
>
|
||||||
|
<el-icon>
|
||||||
|
<View v-if="!item.show" />
|
||||||
|
<Hide v-else />
|
||||||
|
</el-icon>
|
||||||
|
<span class="layer-directory-control__layer-action-text">
|
||||||
|
{{ item.show ? '隐藏' : '显示' }}
|
||||||
|
</span>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else description="暂无已加载图层" />
|
||||||
|
</el-scrollbar>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { ArrowDown, ArrowUp, Close, DataAnalysis, Hide, Operation, Search, View, CollectionTag } from '@element-plus/icons-vue'
|
||||||
|
import MapIcon from '@/map/shared/SvgIcon/index.vue'
|
||||||
|
import useMapStore from '@/map/stores/mapStore'
|
||||||
|
import layerCatalog from '@/map/data/layerMap.json'
|
||||||
|
import { DEFAULT_VECTOR_LAYER_ID } from '@/map/utils/utils'
|
||||||
|
|
||||||
|
const mapStore = useMapStore()
|
||||||
|
const { layers: layerTable } = storeToRefs(mapStore)
|
||||||
|
|
||||||
|
const panelVisible = ref(false)
|
||||||
|
const activeTab = ref('catalog')
|
||||||
|
const filterText = ref('')
|
||||||
|
const treeRef = ref(null)
|
||||||
|
const layerService = shallowRef(null)
|
||||||
|
let detachReadyListener = null
|
||||||
|
let syncingTree = false
|
||||||
|
|
||||||
|
const treeProps = {
|
||||||
|
label: 'label',
|
||||||
|
children: 'children',
|
||||||
|
disabled: 'disableCheckbox',
|
||||||
|
}
|
||||||
|
|
||||||
|
const { treeData, defaultExpandedKeys, serviceNodeMap } = buildCatalogTree(layerCatalog)
|
||||||
|
|
||||||
|
const loadedCatalogKeys = computed(() => {
|
||||||
|
const keys = Object.keys(layerTable.value || {})
|
||||||
|
const serviceKeys = keys.filter((key) => key.startsWith('catalog:'))
|
||||||
|
|
||||||
|
// 同时计算应该选中的分组节点
|
||||||
|
const groupKeys = []
|
||||||
|
treeData.forEach(node => {
|
||||||
|
checkGroupNodeState(node, serviceKeys, groupKeys)
|
||||||
|
})
|
||||||
|
|
||||||
|
return [...serviceKeys, ...groupKeys]
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 检查分组节点状态,如果所有子服务都已加载则添加到选中列表
|
||||||
|
*/
|
||||||
|
function checkGroupNodeState(node, loadedServiceKeys, groupKeys) {
|
||||||
|
if (node.nodeType === 'group' && node.children) {
|
||||||
|
const serviceNodes = getAllServiceNodesFromGroup(node)
|
||||||
|
const allServicesLoaded = serviceNodes.length > 0 &&
|
||||||
|
serviceNodes.every(serviceNode => loadedServiceKeys.includes(serviceNode.id))
|
||||||
|
|
||||||
|
if (allServicesLoaded) {
|
||||||
|
groupKeys.push(node.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归检查子分组
|
||||||
|
node.children.forEach(child => {
|
||||||
|
if (child.nodeType === 'group') {
|
||||||
|
checkGroupNodeState(child, loadedServiceKeys, groupKeys)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const layerItems = computed(() => {
|
||||||
|
const items = Object.values(layerTable.value || {})
|
||||||
|
.filter((item) => item && item.meta?.isBaseMap !== true && item.id !== DEFAULT_VECTOR_LAYER_ID)
|
||||||
|
const getOrder = (record) => {
|
||||||
|
if (!record) return 0
|
||||||
|
if (record.type === 'imagery') return (record.meta?.zIndex ?? 0) + 1000
|
||||||
|
if (record.type === 'vector' || record.type === 'datasource') return (record.meta?.vectorOrder ?? 0) + 500
|
||||||
|
return record.meta?.zIndex ?? 0
|
||||||
|
}
|
||||||
|
return items.sort((a, b) => getOrder(b) - getOrder(a))
|
||||||
|
})
|
||||||
|
|
||||||
|
function togglePanel() {
|
||||||
|
panelVisible.value = !panelVisible.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLayerService() {
|
||||||
|
try {
|
||||||
|
layerService.value = mapStore.services().layer
|
||||||
|
} catch (err) {
|
||||||
|
layerService.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
resolveLayerService()
|
||||||
|
detachReadyListener = mapStore.onReady(() => {
|
||||||
|
resolveLayerService()
|
||||||
|
})
|
||||||
|
nextTick(() => {
|
||||||
|
syncTreeCheckedKeys()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (typeof detachReadyListener === 'function') {
|
||||||
|
detachReadyListener()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => mapStore.ready,
|
||||||
|
(ready) => {
|
||||||
|
if (ready) resolveLayerService()
|
||||||
|
else layerService.value = null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(filterText, (value) => {
|
||||||
|
if (!treeRef.value) return
|
||||||
|
treeRef.value.filter(value)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(loadedCatalogKeys, () => {
|
||||||
|
syncTreeCheckedKeys()
|
||||||
|
})
|
||||||
|
|
||||||
|
function syncTreeCheckedKeys() {
|
||||||
|
if (!treeRef.value) return
|
||||||
|
syncingTree = true
|
||||||
|
treeRef.value.setCheckedKeys(loadedCatalogKeys.value, true)
|
||||||
|
nextTick(() => {
|
||||||
|
syncingTree = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterTreeNode(value, data) {
|
||||||
|
if (!value) return true
|
||||||
|
const keyword = String(value).trim().toLowerCase()
|
||||||
|
return data.label.toLowerCase().includes(keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 递归获取分组下的所有服务节点
|
||||||
|
*/
|
||||||
|
function getAllServiceNodesFromGroup(groupNode) {
|
||||||
|
const serviceNodes = []
|
||||||
|
|
||||||
|
function collectServiceNodes(node) {
|
||||||
|
if (node.nodeType === 'service') {
|
||||||
|
serviceNodes.push(node)
|
||||||
|
} else if (node.nodeType === 'group' && node.children) {
|
||||||
|
node.children.forEach(child => collectServiceNodes(child))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupNode.children) {
|
||||||
|
groupNode.children.forEach(child => collectServiceNodes(child))
|
||||||
|
}
|
||||||
|
|
||||||
|
return serviceNodes
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTreeCheckChange(data, checked) {
|
||||||
|
if (syncingTree) return
|
||||||
|
if (!data) return
|
||||||
|
if (!layerService.value) {
|
||||||
|
treeRef.value.setChecked(data.id, false, true)
|
||||||
|
ElMessage.warning('地图尚未就绪,稍后再试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (data.nodeType === 'service') {
|
||||||
|
// 处理服务节点
|
||||||
|
const nodeSpec = serviceNodeMap.get(data.id)
|
||||||
|
if (!nodeSpec) return
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
await layerService.value.addLayer(createLayerSpec(nodeSpec))
|
||||||
|
} else {
|
||||||
|
await layerService.value.removeLayer(data.id)
|
||||||
|
}
|
||||||
|
} else if (data.nodeType === 'group') {
|
||||||
|
// 处理分组节点 - 递归处理所有子服务节点
|
||||||
|
const serviceNodes = getAllServiceNodesFromGroup(data)
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
// 依次加载所有子服务
|
||||||
|
for (const serviceNode of serviceNodes) {
|
||||||
|
const nodeSpec = serviceNodeMap.get(serviceNode.id)
|
||||||
|
if (nodeSpec) {
|
||||||
|
try {
|
||||||
|
await layerService.value.addLayer(createLayerSpec(nodeSpec))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`图层 ${serviceNode.label} 加载失败`, err)
|
||||||
|
// 继续加载其他图层,不中断整个过程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ElMessage.success(`已加载 ${data.label} 分组下的 ${serviceNodes.length} 个图层`)
|
||||||
|
} else {
|
||||||
|
// 移除所有子服务
|
||||||
|
for (const serviceNode of serviceNodes) {
|
||||||
|
try {
|
||||||
|
await layerService.value.removeLayer(serviceNode.id)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`图层 ${serviceNode.label} 移除失败`, err)
|
||||||
|
// 继续移除其他图层
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ElMessage.success(`已移除 ${data.label} 分组下的 ${serviceNodes.length} 个图层`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('图层操作失败', err)
|
||||||
|
ElMessage.error('图层操作失败:' + (err?.message || '未知错误'))
|
||||||
|
syncingTree = true
|
||||||
|
treeRef.value.setChecked(data.id, !checked, true)
|
||||||
|
nextTick(() => {
|
||||||
|
syncingTree = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveLayer(id, direction) {
|
||||||
|
if (!layerService.value) return
|
||||||
|
try {
|
||||||
|
layerService.value.moveLayer(id, direction)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('图层顺序调整失败', err)
|
||||||
|
ElMessage.error('图层顺序调整失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLayerVisibility(record) {
|
||||||
|
if (!layerService.value || !record) return
|
||||||
|
try {
|
||||||
|
layerService.value.showLayer(record.id, !record.show)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('图层显隐失败', err)
|
||||||
|
ElMessage.error('图层显隐失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 构造图层加载参数
|
||||||
|
*/
|
||||||
|
function createLayerSpec(nodeSpec) {
|
||||||
|
const url = nodeSpec.url
|
||||||
|
const serviceTypeName = nodeSpec.serviceType
|
||||||
|
const options = buildLayerOptions(nodeSpec)
|
||||||
|
const layerType = resolveNodeLayerType(serviceTypeName, url)
|
||||||
|
return {
|
||||||
|
id: nodeSpec.id,
|
||||||
|
type: layerType,
|
||||||
|
url,
|
||||||
|
options: {
|
||||||
|
visible: true,
|
||||||
|
...options,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
title: nodeSpec.label,
|
||||||
|
sourceRid: nodeSpec.rid,
|
||||||
|
sourceType: 'catalog',
|
||||||
|
groupName: nodeSpec.parentName,
|
||||||
|
zIndex: typeof nodeSpec.sortValue === 'number' ? nodeSpec.sortValue : Number(nodeSpec.sortValue) || undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 推断节点对应的图层类型
|
||||||
|
*/
|
||||||
|
function resolveNodeLayerType(serviceTypeName, url) {
|
||||||
|
if (serviceTypeName) return serviceTypeName
|
||||||
|
if (!url) return ''
|
||||||
|
if (/(wmts|TILEMATRIXSET)/i.test(url)) return 'WmtsServiceLayer'
|
||||||
|
if (/(\{z\}|\{x\}|\{y\})/i.test(url)) return 'WebTileLayer'
|
||||||
|
if (/geojson/i.test(url)) return 'GeoJSONServiceLayer'
|
||||||
|
return 'WebTileLayer'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 构造附加的图层加载配置
|
||||||
|
*/
|
||||||
|
function buildLayerOptions(nodeSpec) {
|
||||||
|
const options = {}
|
||||||
|
const { rawAttribute } = nodeSpec
|
||||||
|
const expandOptions = safeParse(rawAttribute?.expandParam)
|
||||||
|
if (expandOptions && typeof expandOptions === 'object') {
|
||||||
|
Object.assign(options, expandOptions?.options || {})
|
||||||
|
}
|
||||||
|
const accessInfo = safeParse(rawAttribute?.accessInfo)
|
||||||
|
if (accessInfo && typeof accessInfo === 'object' && accessInfo.token) {
|
||||||
|
options.token = accessInfo.token
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 构造目录树
|
||||||
|
*/
|
||||||
|
function buildCatalogTree(rawList) {
|
||||||
|
const serviceNodeMap = new Map()
|
||||||
|
const expandedKeys = []
|
||||||
|
const transformNode = (node, parentInfo = null) => {
|
||||||
|
const attr = node.Attribute || {}
|
||||||
|
const children = Array.isArray(node.Children) ? node.Children : []
|
||||||
|
const rid = attr.rid || node.Rid
|
||||||
|
const label = attr.name || node.Name || '未命名图层'
|
||||||
|
if (children.length) {
|
||||||
|
const id = `group:${rid || label}`
|
||||||
|
expandedKeys.push(id)
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
rid,
|
||||||
|
nodeType: 'group',
|
||||||
|
children: children.map((child) => transformNode(child, { rid, name: label })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const serviceId = `catalog:${rid}`
|
||||||
|
const serviceNode = {
|
||||||
|
id: serviceId,
|
||||||
|
label,
|
||||||
|
rid,
|
||||||
|
nodeType: 'service',
|
||||||
|
parentRid: parentInfo?.rid,
|
||||||
|
parentName: parentInfo?.name,
|
||||||
|
serviceType: attr.serviceTypeName || '',
|
||||||
|
url: attr.servicePath || '',
|
||||||
|
rawAttribute: attr,
|
||||||
|
sortValue: attr.sortValue,
|
||||||
|
}
|
||||||
|
serviceNodeMap.set(serviceId, serviceNode)
|
||||||
|
return serviceNode
|
||||||
|
}
|
||||||
|
const tree = Array.isArray(rawList) ? rawList.map((item) => transformNode(item)) : []
|
||||||
|
return {
|
||||||
|
treeData: tree,
|
||||||
|
defaultExpandedKeys: expandedKeys,
|
||||||
|
serviceNodeMap,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 安全解析 JSON 字符串
|
||||||
|
*/
|
||||||
|
function safeParse(text) {
|
||||||
|
if (!text || typeof text !== 'string') return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(text)
|
||||||
|
} catch (err) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.layer-directory-control {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
pointer-events: auto;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-directory-control__toggle {
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #1f1f1f;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-directory-control__toggle:hover {
|
||||||
|
background: #ffffff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-directory-control__panel {
|
||||||
|
width: 320px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-directory-control__card {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-directory-control__card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-directory-control__close {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-directory-control__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-directory-control__search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-directory-control__scroll {
|
||||||
|
max-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-directory-control__layer-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-directory-control__layer-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(240, 248, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-directory-control__layer-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-directory-control__layer-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-directory-control__layer-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-directory-control__layer-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-directory-control__layer-action-text {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-directory-fade-enter-active,
|
||||||
|
.layer-directory-fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-directory-fade-enter-from,
|
||||||
|
.layer-directory-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
332
packages/screen/src/map/components/MapCompass.vue
Normal file
332
packages/screen/src/map/components/MapCompass.vue
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="showCompass"
|
||||||
|
:class="compassClasses"
|
||||||
|
:style="compassStyle"
|
||||||
|
@click="recoverHeading"
|
||||||
|
title="点击恢复正北方向"
|
||||||
|
>
|
||||||
|
<!-- 指南针背景 - 根据相机朝向旋转 -->
|
||||||
|
<img
|
||||||
|
src="@/map/assets/icons/compass_bg.svg"
|
||||||
|
alt="指南针背景"
|
||||||
|
class="compass-bg"
|
||||||
|
:style="{ transform: `rotate(${-heading}deg)` }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 指南针指针 - 固定指向北方 -->
|
||||||
|
<img
|
||||||
|
src="@/map/assets/icons/compass.svg"
|
||||||
|
alt="指南针指针"
|
||||||
|
class="compass-needle"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 方向文字显示 -->
|
||||||
|
<div class="direction-text">{{ directionText }}</div>
|
||||||
|
|
||||||
|
<!-- 角度数值显示 -->
|
||||||
|
<div class="degree-text">{{ displayHeading }}°</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import * as Cesium from 'cesium'
|
||||||
|
import useMapStore from '@/map/stores/mapStore'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// 是否显示指南针
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
// 主题 ('dark' | 'light')
|
||||||
|
theme: {
|
||||||
|
type: String,
|
||||||
|
default: 'light',
|
||||||
|
validator: (value) => ['dark', 'light'].includes(value)
|
||||||
|
},
|
||||||
|
// 自定义样式
|
||||||
|
customStyle: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapStore = useMapStore()
|
||||||
|
const heading = ref(0)
|
||||||
|
let postRenderListener = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将角度转换为方向文字
|
||||||
|
* @param {number} num - 角度值 (0-360)
|
||||||
|
* @returns {string} 方向缩写 (N, NE, E, SE, S, SW, W, NW)
|
||||||
|
*/
|
||||||
|
const directionToString = (num) => {
|
||||||
|
const n = parseFloat(num)
|
||||||
|
const directions = [
|
||||||
|
{ min: 0, max: 22.5, dir: 'N' },
|
||||||
|
{ min: 22.5, max: 67.5, dir: 'NE' },
|
||||||
|
{ min: 67.5, max: 112.5, dir: 'E' },
|
||||||
|
{ min: 112.5, max: 157.5, dir: 'SE' },
|
||||||
|
{ min: 157.5, max: 202.5, dir: 'S' },
|
||||||
|
{ min: 202.5, max: 247.5, dir: 'SW' },
|
||||||
|
{ min: 247.5, max: 292.5, dir: 'W' },
|
||||||
|
{ min: 292.5, max: 337.5, dir: 'NW' },
|
||||||
|
{ min: 337.5, max: 360, dir: 'N' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const direction = directions.find(d => n >= d.min && n <= d.max)
|
||||||
|
return direction ? direction.dir : 'N'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复指北方向
|
||||||
|
*/
|
||||||
|
const recoverHeading = async () => {
|
||||||
|
if (!mapStore.isReady()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const camera = mapStore.services().camera
|
||||||
|
const currentView = camera.getCurrentView()
|
||||||
|
|
||||||
|
// 保持当前位置,只改变朝向为正北
|
||||||
|
await camera.flyTo({
|
||||||
|
lon: currentView.lon,
|
||||||
|
lat: currentView.lat,
|
||||||
|
height: currentView.height,
|
||||||
|
heading: 0, // 正北方向
|
||||||
|
pitch: currentView.pitch,
|
||||||
|
roll: currentView.roll,
|
||||||
|
duration: 1.0
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to recover heading:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const showCompass = computed(() => props.visible && mapStore.isReady())
|
||||||
|
|
||||||
|
const directionText = computed(() => directionToString(heading.value))
|
||||||
|
|
||||||
|
const displayHeading = computed(() => {
|
||||||
|
const h = heading.value === 360 ? 0 : heading.value
|
||||||
|
return h.toString().padStart(3, '0')
|
||||||
|
})
|
||||||
|
|
||||||
|
const compassClasses = computed(() => [
|
||||||
|
'map-compass',
|
||||||
|
`map-compass--${props.theme}`
|
||||||
|
])
|
||||||
|
|
||||||
|
const compassStyle = computed(() => ({
|
||||||
|
...props.customStyle
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 初始化指南针监听器
|
||||||
|
const initCompassListener = () => {
|
||||||
|
if (!mapStore.isReady()) return
|
||||||
|
|
||||||
|
const viewer = mapStore.getViewer()
|
||||||
|
|
||||||
|
postRenderListener = () => {
|
||||||
|
if (!viewer?.scene?.camera) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rawHeading = viewer.scene.camera.heading
|
||||||
|
if (typeof rawHeading !== 'number' || Number.isNaN(rawHeading)) {
|
||||||
|
heading.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentHeading = Cesium.Math.toDegrees(rawHeading)
|
||||||
|
const normalizedHeading = ((currentHeading % 360) + 360) % 360
|
||||||
|
heading.value = Math.round(normalizedHeading)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer.scene.postRender.addEventListener(postRenderListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理监听器
|
||||||
|
const cleanupListener = () => {
|
||||||
|
if (postRenderListener && mapStore.isReady()) {
|
||||||
|
try {
|
||||||
|
const viewer = mapStore.getViewer()
|
||||||
|
if (viewer?.scene && !viewer.isDestroyed()) {
|
||||||
|
viewer.scene.postRender.removeEventListener(postRenderListener)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to cleanup compass listener:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
postRenderListener = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生命周期钩子
|
||||||
|
onMounted(() => {
|
||||||
|
if (mapStore.isReady()) {
|
||||||
|
initCompassListener()
|
||||||
|
} else {
|
||||||
|
// 等待地图就绪
|
||||||
|
const detachReadyListener = mapStore.onReady(() => {
|
||||||
|
initCompassListener()
|
||||||
|
detachReadyListener()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
cleanupListener()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听地图就绪状态变化
|
||||||
|
watch(
|
||||||
|
() => mapStore.ready,
|
||||||
|
(ready) => {
|
||||||
|
if (ready) {
|
||||||
|
initCompassListener()
|
||||||
|
} else {
|
||||||
|
cleanupListener()
|
||||||
|
heading.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.map-compass {
|
||||||
|
cursor: pointer;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 40px;
|
||||||
|
width: 40px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
background-color: rgba(255, 255, 255, 0.92);
|
||||||
|
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
// 悬停效果
|
||||||
|
&:hover {
|
||||||
|
background-color: #ffffff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 浅色主题 (默认)
|
||||||
|
&--light {
|
||||||
|
background-color: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #333333;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-text {
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.degree-text {
|
||||||
|
color: #555555;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 深色主题
|
||||||
|
&--dark {
|
||||||
|
background-color: rgba(28, 49, 58, 0.92);
|
||||||
|
color: #ffffff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(28, 49, 58, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-text {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.degree-text {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指南针背景图片样式
|
||||||
|
.compass-bg {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
transition: transform 0.2s ease-out;
|
||||||
|
|
||||||
|
// 深色主题下的背景颜色调整
|
||||||
|
.darkTheme & {
|
||||||
|
filter: invert(1) sepia(1) saturate(2) hue-rotate(180deg) brightness(0.3) contrast(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指南针指针样式
|
||||||
|
.compass-needle {
|
||||||
|
width: 40px;
|
||||||
|
height: 43px;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
|
||||||
|
// 深色主题下的指针颜色调整
|
||||||
|
.darkTheme & {
|
||||||
|
filter: invert(1) brightness(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方向文字样式 (N, NE, E, SE, S, SW, W, NW)
|
||||||
|
.direction-text {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 30%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
user-select: none;
|
||||||
|
z-index: 3;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 角度数值样式 (000°-360°)
|
||||||
|
.degree-text {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 65%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 9px;
|
||||||
|
user-select: none;
|
||||||
|
z-index: 3;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.map-compass {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compass-bg {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compass-needle {
|
||||||
|
width: 34px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-text {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.degree-text {
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
385
packages/screen/src/map/components/MapControls.vue
Normal file
385
packages/screen/src/map/components/MapControls.vue
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
<template>
|
||||||
|
<div class="map-controls-root">
|
||||||
|
<div v-if="showLayerDirectory" class="map-controls-anchor map-controls-anchor--top-left" aria-live="polite">
|
||||||
|
<LayerDirectoryControl />
|
||||||
|
</div>
|
||||||
|
<div :class="['map-controls-anchor map-controls-anchor--bottom-right', bottomRightClass]" :style="bottomRightStyle"
|
||||||
|
aria-live="polite">
|
||||||
|
<div class="map-controls__stack">
|
||||||
|
<div v-if="hasBottomRightControls" class="map-controls">
|
||||||
|
<button v-if="showZoomControl" class="map-controls__btn" type="button" title="放大" aria-label="放大"
|
||||||
|
@click="zoomIn" :disabled="isMapIdle">
|
||||||
|
<el-icon>
|
||||||
|
<Plus />
|
||||||
|
</el-icon>
|
||||||
|
</button>
|
||||||
|
<button v-if="showZoomControl" class="map-controls__btn" type="button" title="缩小" aria-label="缩小"
|
||||||
|
@click="zoomOut" :disabled="isMapIdle">
|
||||||
|
<el-icon>
|
||||||
|
<Minus />
|
||||||
|
</el-icon>
|
||||||
|
</button>
|
||||||
|
<button v-if="showHomeControl" class="map-controls__btn map-controls__btn--home" type="button" title="返回初始视图"
|
||||||
|
aria-label="返回初始视图" @click="goHome" :disabled="!canGoHome">
|
||||||
|
<el-icon>
|
||||||
|
<HomeFilled />
|
||||||
|
</el-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="showBaseMapSwitcher" class="map-controls">
|
||||||
|
<BaseMapSwitcher />
|
||||||
|
</div>
|
||||||
|
<div v-if="showSceneModeToggle" class="map-controls">
|
||||||
|
<SceneModeToggle />
|
||||||
|
</div>
|
||||||
|
<!-- 地图指南针 - 与其他控件垂直对齐 -->
|
||||||
|
<div v-if="showCompass" class="map-controls">
|
||||||
|
<MapCompass :visible="showCompass" :theme="compassTheme" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { HomeFilled, Minus, Plus } from '@element-plus/icons-vue'
|
||||||
|
import useMapStore from '@/map/stores/mapStore'
|
||||||
|
import useMapUiStore from '@/map/stores/mapUiStore'
|
||||||
|
import LayerDirectoryControl from './LayerDirectoryControl.vue'
|
||||||
|
import BaseMapSwitcher from './BaseMapSwitcher.vue'
|
||||||
|
import SceneModeToggle from './SceneModeToggle.vue'
|
||||||
|
import MapCompass from './MapCompass.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const mapStore = useMapStore()
|
||||||
|
const mapUiStore = useMapUiStore()
|
||||||
|
|
||||||
|
const DEFAULT_MAP_CONTROLS = Object.freeze({
|
||||||
|
layout: {
|
||||||
|
bottomRight: {
|
||||||
|
style: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bottomRight: [
|
||||||
|
{ id: 'zoom', order: 1 },
|
||||||
|
{ id: 'home', order: 2 },
|
||||||
|
],
|
||||||
|
components: {
|
||||||
|
layerDirectory: { visible: true },
|
||||||
|
baseMapSwitcher: { visible: true },
|
||||||
|
sceneModeToggle: { visible: true },
|
||||||
|
compass: { visible: true, theme: 'light' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const SUPPORTED_CONTROLS = new Set(['zoom', 'home'])
|
||||||
|
|
||||||
|
const camera = shallowRef(null)
|
||||||
|
let detachReadyListener = null
|
||||||
|
|
||||||
|
const resolveCamera = () => {
|
||||||
|
try {
|
||||||
|
camera.value = mapStore.services().camera
|
||||||
|
} catch (err) {
|
||||||
|
camera.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergePositionControls = (baseList = [], overrideList) => {
|
||||||
|
const map = new Map()
|
||||||
|
|
||||||
|
baseList.forEach((item, idx) => {
|
||||||
|
if (!item || typeof item.id !== 'string') return
|
||||||
|
map.set(item.id, {
|
||||||
|
...item,
|
||||||
|
order: typeof item.order === 'number' ? item.order : idx + 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (Array.isArray(overrideList)) {
|
||||||
|
overrideList.forEach((item, idx) => {
|
||||||
|
if (!item || typeof item.id !== 'string') return
|
||||||
|
if (item.visible === false) {
|
||||||
|
map.delete(item.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const existing = map.get(item.id)
|
||||||
|
const nextOrder = typeof item.order === 'number'
|
||||||
|
? item.order
|
||||||
|
: existing && typeof existing.order === 'number'
|
||||||
|
? existing.order
|
||||||
|
: baseList.length + idx + 1
|
||||||
|
map.set(item.id, {
|
||||||
|
...existing,
|
||||||
|
...item,
|
||||||
|
id: item.id,
|
||||||
|
order: nextOrder,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.values())
|
||||||
|
.filter((item) => item.visible !== false)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aOrder = typeof a.order === 'number' ? a.order : 0
|
||||||
|
const bOrder = typeof b.order === 'number' ? b.order : 0
|
||||||
|
return aOrder - bOrder
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeLayouts = (baseLayout = {}, overrideLayout = {}) => {
|
||||||
|
const result = {}
|
||||||
|
const positions = new Set([
|
||||||
|
...Object.keys(baseLayout || {}),
|
||||||
|
...Object.keys(overrideLayout || {}),
|
||||||
|
])
|
||||||
|
|
||||||
|
positions.forEach((position) => {
|
||||||
|
const baseEntry = baseLayout?.[position] || {}
|
||||||
|
const overrideEntry = overrideLayout?.[position] || {}
|
||||||
|
const combinedClass = [baseEntry.class, overrideEntry.class].filter(Boolean).join(' ')
|
||||||
|
const style = { ...(baseEntry.style || {}), ...(overrideEntry.style || {}) }
|
||||||
|
const entry = {}
|
||||||
|
if (combinedClass) entry.class = combinedClass
|
||||||
|
if (Object.keys(style).length) entry.style = style
|
||||||
|
if (Object.keys(entry).length) {
|
||||||
|
result[position] = entry
|
||||||
|
} else if (baseLayout?.[position] || overrideLayout?.[position]) {
|
||||||
|
result[position] = {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeMapControlsConfig = (baseConfig = {}, overrideConfig = {}) => {
|
||||||
|
const result = {}
|
||||||
|
|
||||||
|
// Merge layout configuration
|
||||||
|
const baseLayout = baseConfig.layout || {}
|
||||||
|
const overrideLayout = overrideConfig.layout || {}
|
||||||
|
const layout = mergeLayouts(baseLayout, overrideLayout)
|
||||||
|
if (Object.keys(layout).length) {
|
||||||
|
result.layout = layout
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge components configuration (generic approach)
|
||||||
|
const baseComponents = baseConfig.components || {}
|
||||||
|
const overrideComponents = overrideConfig.components || {}
|
||||||
|
if (Object.keys(baseComponents).length || Object.keys(overrideComponents).length) {
|
||||||
|
result.components = {}
|
||||||
|
const componentKeys = new Set([...Object.keys(baseComponents), ...Object.keys(overrideComponents)])
|
||||||
|
componentKeys.forEach((key) => {
|
||||||
|
result.components[key] = {
|
||||||
|
...baseComponents[key],
|
||||||
|
...overrideComponents[key],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle position-based controls (bottomRight, topLeft, etc.)
|
||||||
|
const excludedKeys = new Set(['layout', 'components'])
|
||||||
|
const baseKeys = Object.keys(baseConfig || {}).filter((key) => !excludedKeys.has(key))
|
||||||
|
const overrideKeys = Object.keys(overrideConfig || {}).filter((key) => !excludedKeys.has(key))
|
||||||
|
const positions = new Set([...baseKeys, ...overrideKeys])
|
||||||
|
|
||||||
|
positions.forEach((position) => {
|
||||||
|
result[position] = mergePositionControls(baseConfig?.[position], overrideConfig?.[position])
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloneConfig = (config) => mergeMapControlsConfig(config, {})
|
||||||
|
|
||||||
|
const resolvedConfig = computed(() => {
|
||||||
|
const matchedConfigs = route.matched
|
||||||
|
.map((record) => record.meta?.mapControls)
|
||||||
|
.filter(Boolean)
|
||||||
|
if (!matchedConfigs.length) {
|
||||||
|
return cloneConfig(DEFAULT_MAP_CONTROLS)
|
||||||
|
}
|
||||||
|
return matchedConfigs.reduce(
|
||||||
|
(acc, config) => mergeMapControlsConfig(acc, config),
|
||||||
|
cloneConfig(DEFAULT_MAP_CONTROLS)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const bottomRightControls = computed(() => {
|
||||||
|
const list = resolvedConfig.value.bottomRight || []
|
||||||
|
return list
|
||||||
|
.filter((item) => SUPPORTED_CONTROLS.has(item.id))
|
||||||
|
.filter((item) => mapUiStore.isControlVisible(item.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
const bottomRightLayout = computed(() => resolvedConfig.value.layout?.bottomRight || {})
|
||||||
|
const bottomRightClass = computed(() => bottomRightLayout.value.class)
|
||||||
|
const bottomRightStyle = computed(() => bottomRightLayout.value.style)
|
||||||
|
|
||||||
|
const showZoomControl = computed(() => bottomRightControls.value.some((item) => item.id === 'zoom'))
|
||||||
|
const showHomeControl = computed(() => bottomRightControls.value.some((item) => item.id === 'home'))
|
||||||
|
const hasBottomRightControls = computed(() => bottomRightControls.value.length > 0)
|
||||||
|
|
||||||
|
// 控件显隐判定
|
||||||
|
const isComponentEnabledInRoute = (componentName) => {
|
||||||
|
return resolvedConfig.value.components?.[componentName]?.visible !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveComponentVisibility = (componentName) => {
|
||||||
|
if (!isComponentEnabledInRoute(componentName)) return false
|
||||||
|
return mapUiStore.isControlVisible(componentName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showLayerDirectory = computed(() => resolveComponentVisibility('layerDirectory'))
|
||||||
|
const showBaseMapSwitcher = computed(() => resolveComponentVisibility('baseMapSwitcher'))
|
||||||
|
const showSceneModeToggle = computed(() => resolveComponentVisibility('sceneModeToggle'))
|
||||||
|
const showCompass = computed(() => resolveComponentVisibility('compass'))
|
||||||
|
|
||||||
|
// 指南针主题配置
|
||||||
|
const compassTheme = computed(() => {
|
||||||
|
const compassConfig = resolvedConfig.value.components?.compass || {}
|
||||||
|
return compassConfig.theme || 'light'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
resolveCamera()
|
||||||
|
detachReadyListener = mapStore.onReady(() => {
|
||||||
|
resolveCamera()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (typeof detachReadyListener === 'function') {
|
||||||
|
detachReadyListener()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => mapStore.ready,
|
||||||
|
(ready) => {
|
||||||
|
if (ready) {
|
||||||
|
resolveCamera()
|
||||||
|
} else {
|
||||||
|
camera.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const isMapReady = computed(() => mapStore.ready && !!camera.value)
|
||||||
|
const isMapIdle = computed(() => !isMapReady.value)
|
||||||
|
const canGoHome = computed(() => isMapReady.value && !!mapStore.homeView)
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
if (!isMapReady.value) return
|
||||||
|
camera.value.zoomIn()
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
if (!isMapReady.value) return
|
||||||
|
camera.value.zoomOut()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goHome() {
|
||||||
|
if (!canGoHome.value) return
|
||||||
|
try {
|
||||||
|
await camera.value.flyToHome()
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('flyToHome failed', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.map-controls-root {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls-anchor {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls-anchor--bottom-right {
|
||||||
|
right: 24px;
|
||||||
|
bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls-anchor--top-left {
|
||||||
|
left: 16px;
|
||||||
|
top: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls__stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls__stack>* {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls__btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #1f1f1f;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls__btn:not(:disabled):hover {
|
||||||
|
background: #ffffff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls__btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls__btn--home {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.map-controls-anchor--bottom-right {
|
||||||
|
right: 12px;
|
||||||
|
bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls__btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
169
packages/screen/src/map/components/MapViewport.vue
Normal file
169
packages/screen/src/map/components/MapViewport.vue
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<div id="map_container"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup name="MapViewport">
|
||||||
|
import * as Cesium from 'cesium'
|
||||||
|
import { onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import useMapStore from '@/map/stores/mapStore'
|
||||||
|
|
||||||
|
const mapStore = useMapStore()
|
||||||
|
const route = useRoute()
|
||||||
|
let viewer = null
|
||||||
|
|
||||||
|
const ionToken = import.meta.env.VITE_CESIUM_ION_TOKEN
|
||||||
|
if (ionToken) {
|
||||||
|
Cesium.Ion.defaultAccessToken = ionToken
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initViewer()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
try {
|
||||||
|
mapStore.destroy()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('销毁地图实例失败', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function initViewer() {
|
||||||
|
viewer = new Cesium.Viewer('map_container', {
|
||||||
|
terrain: Cesium.Terrain.fromWorldTerrain(),
|
||||||
|
infoBox: false,
|
||||||
|
imageryProvider: false,
|
||||||
|
baseLayerPicker: false,
|
||||||
|
sceneModePicker: false,
|
||||||
|
homeButton: false,
|
||||||
|
fullscreenButton: false,
|
||||||
|
timeline: false,
|
||||||
|
navigationHelpButton: false,
|
||||||
|
navigationInstructionsInitiallyVisible: false,
|
||||||
|
animation: false,
|
||||||
|
geocoder: false,
|
||||||
|
sceneMode: Cesium.SceneMode.SCENE3D,
|
||||||
|
selectionIndicator: false,
|
||||||
|
shouldAnimate: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
viewer.scene.sun.show = false
|
||||||
|
viewer.scene.moon.show = false
|
||||||
|
viewer.scene.skyBox.show = false // 关闭天空盒
|
||||||
|
viewer.scene.globe.show = true
|
||||||
|
|
||||||
|
// 隐藏版权信息
|
||||||
|
viewer.cesiumWidget.creditContainer.style.display = 'none'
|
||||||
|
try {
|
||||||
|
if (viewer.animation?.container) {
|
||||||
|
viewer.animation.container.style.display = 'none'
|
||||||
|
}
|
||||||
|
if (viewer.timeline?.container) {
|
||||||
|
viewer.timeline.container.style.display = 'none'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('隐藏时间轴/动画控件失败', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Cesium.FeatureDetection.supportsImageRenderingPixelated()) {
|
||||||
|
viewer.resolutionScale = window.devicePixelRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注入全局地图 Store
|
||||||
|
mapStore.init(viewer)
|
||||||
|
const { camera } = mapStore.services()
|
||||||
|
|
||||||
|
const skipInitialView = route.meta?.skipInitialCameraView
|
||||||
|
if (mapStore.cameraPosition) {
|
||||||
|
viewer.camera.setView({
|
||||||
|
destination: mapStore.cameraPosition,
|
||||||
|
orientation: {
|
||||||
|
heading: mapStore.cameraPosture.heading,
|
||||||
|
pitch: mapStore.cameraPosture.pitch,
|
||||||
|
roll: mapStore.cameraPosture.roll,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!mapStore.homeView) {
|
||||||
|
camera.rememberHomeFromCurrent()
|
||||||
|
}
|
||||||
|
} else if (!skipInitialView) {
|
||||||
|
await applyInitialCameraView()
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadBaseMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyInitialCameraView() {
|
||||||
|
const { camera } = mapStore.services()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const viewConfig = await mapStore.getInitialCameraView()
|
||||||
|
|
||||||
|
if (viewConfig.type === 'extent') {
|
||||||
|
await camera.fitBounds(viewConfig.value)
|
||||||
|
} else if (viewConfig.type === 'center') {
|
||||||
|
const { lon, lat, height } = viewConfig.value
|
||||||
|
await camera.setCenter(lon, lat, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
camera.rememberHomeFromCurrent()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('应用初始相机视图失败:', error)
|
||||||
|
await camera.setCenter(0, 0, 20000000)
|
||||||
|
camera.rememberHomeFromCurrent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBaseMap() {
|
||||||
|
const { layer } = mapStore.services()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentGroupId = mapStore.getCurrentBaseMapGroupId()
|
||||||
|
if (!currentGroupId) {
|
||||||
|
console.warn('未找到默认底图组')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载所有底图组的图层,但只显示当前激活组
|
||||||
|
for (const group of mapStore.baseMapGroups) {
|
||||||
|
const groupId = group.Attribute?.rid || group.Rid
|
||||||
|
const shouldShow = groupId === currentGroupId
|
||||||
|
const layers = mapStore.getBaseMapLayersForGroup(groupId)
|
||||||
|
|
||||||
|
for (const layerConfig of layers) {
|
||||||
|
await layer.addLayer({
|
||||||
|
id: layerConfig.id,
|
||||||
|
type: layerConfig.type,
|
||||||
|
url: layerConfig.url,
|
||||||
|
options: { visible: shouldShow },
|
||||||
|
meta: layerConfig.meta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载底图失败:', error)
|
||||||
|
|
||||||
|
if (!viewer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imageryLayers = viewer.imageryLayers
|
||||||
|
imageryLayers?.removeAll()
|
||||||
|
const fallbackProvider = await Cesium.IonImageryProvider.fromAssetId(2)
|
||||||
|
imageryLayers?.addImageryProvider(fallbackProvider)
|
||||||
|
console.info('已回退到 Cesium Ion Bing Maps 底图')
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.error('Cesium Ion 底图回退失败:', fallbackError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
#map_container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
536
packages/screen/src/map/components/SceneModeToggle.vue
Normal file
536
packages/screen/src/map/components/SceneModeToggle.vue
Normal file
@ -0,0 +1,536 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="scene-mode-toggle"
|
||||||
|
|
||||||
|
type="button"
|
||||||
|
:disabled="!canToggle"
|
||||||
|
:title="buttonTitle"
|
||||||
|
@click="toggleSceneMode"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{{ buttonLabel }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
||||||
|
import * as Cesium from 'cesium'
|
||||||
|
import useMapStore from '@/map/stores/mapStore'
|
||||||
|
|
||||||
|
|
||||||
|
const mapStore = useMapStore()
|
||||||
|
const sceneRef = shallowRef(null)
|
||||||
|
const cameraService = shallowRef(null)
|
||||||
|
const layerService = shallowRef(null)
|
||||||
|
const is3DMode = ref(true)
|
||||||
|
const isMorphing = ref(false)
|
||||||
|
const pendingRestoreState = shallowRef(null)
|
||||||
|
let detachReadyListener = null
|
||||||
|
let detachMorphStartListener = null
|
||||||
|
let detachMorphCompleteListener = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理由 Cesium 场景注册的监听。
|
||||||
|
* @returns {void} 无返回值
|
||||||
|
*/
|
||||||
|
function cleanupScene() {
|
||||||
|
if (typeof detachMorphStartListener === 'function') {
|
||||||
|
detachMorphStartListener()
|
||||||
|
}
|
||||||
|
if (typeof detachMorphCompleteListener === 'function') {
|
||||||
|
detachMorphCompleteListener()
|
||||||
|
}
|
||||||
|
detachMorphStartListener = null
|
||||||
|
detachMorphCompleteListener = null
|
||||||
|
sceneRef.value = null
|
||||||
|
is3DMode.value = true
|
||||||
|
isMorphing.value = false
|
||||||
|
pendingRestoreState.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步场景模式状态,更新按钮显示。
|
||||||
|
* @param {Cesium.Scene | null} scene Cesium 场景
|
||||||
|
* @returns {void} 无返回值
|
||||||
|
*/
|
||||||
|
function syncSceneMode(scene) {
|
||||||
|
if (!scene) {
|
||||||
|
is3DMode.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const mode = scene.mode
|
||||||
|
is3DMode.value = mode !== Cesium.SceneMode.SCENE2D
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析并缓存地图相关服务。
|
||||||
|
* @returns {void} 无返回值
|
||||||
|
*/
|
||||||
|
function resolveServices() {
|
||||||
|
if (!mapStore.ready) {
|
||||||
|
cameraService.value = null
|
||||||
|
layerService.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { camera, layer } = mapStore.services()
|
||||||
|
cameraService.value = camera
|
||||||
|
layerService.value = layer
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('解析地图服务失败', error)
|
||||||
|
cameraService.value = null
|
||||||
|
layerService.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造相机快照。
|
||||||
|
* @param {Cesium.Camera | null} camera Cesium 相机
|
||||||
|
* @returns {object | null} 相机视角参数
|
||||||
|
*/
|
||||||
|
function buildCameraSnapshot(camera) {
|
||||||
|
if (!camera) return null
|
||||||
|
const cartographic = camera.positionCartographic
|
||||||
|
if (!cartographic) return null
|
||||||
|
return {
|
||||||
|
lon: Cesium.Math.toDegrees(cartographic.longitude),
|
||||||
|
lat: Cesium.Math.toDegrees(cartographic.latitude),
|
||||||
|
height: cartographic.height,
|
||||||
|
heading: Cesium.Math.toDegrees(camera.heading || 0),
|
||||||
|
pitch: Cesium.Math.toDegrees(camera.pitch || 0),
|
||||||
|
roll: Cesium.Math.toDegrees(camera.roll || 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 矩形转经纬度对象。
|
||||||
|
* @param {Cesium.Rectangle} rectangle Cesium 矩形
|
||||||
|
* @returns {{ west:number, south:number, east:number, north:number }} 经纬度范围
|
||||||
|
*/
|
||||||
|
function rectangleToDegrees(rectangle) {
|
||||||
|
return {
|
||||||
|
west: Cesium.Math.toDegrees(rectangle.west),
|
||||||
|
south: Cesium.Math.toDegrees(rectangle.south),
|
||||||
|
east: Cesium.Math.toDegrees(rectangle.east),
|
||||||
|
north: Cesium.Math.toDegrees(rectangle.north),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录当前场景状态(相机、可视范围、图层)。
|
||||||
|
* @param {Cesium.Viewer} viewer Cesium Viewer
|
||||||
|
* @returns {object | null} 场景快照
|
||||||
|
*/
|
||||||
|
function captureSceneSnapshot(viewer) {
|
||||||
|
if (!viewer) return null
|
||||||
|
const snapshot = {
|
||||||
|
cameraView: null,
|
||||||
|
viewRectangle: null,
|
||||||
|
imageryOrder: [],
|
||||||
|
vectorOrder: [],
|
||||||
|
layerStates: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (cameraService.value && typeof cameraService.value.getCurrentView === 'function') {
|
||||||
|
snapshot.cameraView = cameraService.value.getCurrentView()
|
||||||
|
} else {
|
||||||
|
snapshot.cameraView = buildCameraSnapshot(viewer.camera)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('获取相机视角失败', error)
|
||||||
|
snapshot.cameraView = buildCameraSnapshot(viewer.camera)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rectangle = viewer.camera.computeViewRectangle(viewer.scene?.globe?.ellipsoid)
|
||||||
|
if (rectangle) {
|
||||||
|
snapshot.viewRectangle = rectangleToDegrees(rectangle)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('计算可视范围失败', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const layerEntries = Object.entries(mapStore.layers || {})
|
||||||
|
const objectLookup = new Map()
|
||||||
|
layerEntries.forEach(([id, record]) => {
|
||||||
|
if (!record) return
|
||||||
|
if (record.obj) {
|
||||||
|
objectLookup.set(record.obj, { id, record })
|
||||||
|
}
|
||||||
|
snapshot.layerStates[id] = {
|
||||||
|
show: record.show,
|
||||||
|
opacity: typeof record.opacity === 'number' ? record.opacity : null,
|
||||||
|
type: record.type,
|
||||||
|
splitDirection: record.type === 'imagery' && record.obj ? record.obj.splitDirection : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imageryLayers = viewer.imageryLayers
|
||||||
|
const imageryOrder = []
|
||||||
|
for (let i = 0; i < imageryLayers.length; i += 1) {
|
||||||
|
const layer = imageryLayers.get(i)
|
||||||
|
const info = objectLookup.get(layer)
|
||||||
|
if (info) imageryOrder.push(info.id)
|
||||||
|
}
|
||||||
|
snapshot.imageryOrder = imageryOrder
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('记录影像图层顺序失败', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dataSources = viewer.dataSources
|
||||||
|
const vectorOrder = []
|
||||||
|
for (let i = 0; i < dataSources.length; i += 1) {
|
||||||
|
const dataSource = dataSources.get(i)
|
||||||
|
const info = objectLookup.get(dataSource)
|
||||||
|
if (info) vectorOrder.push(info.id)
|
||||||
|
}
|
||||||
|
snapshot.vectorOrder = vectorOrder
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('记录矢量图层顺序失败', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 还原图层显隐、顺序等状态。
|
||||||
|
* @param {Cesium.Viewer} viewer Cesium Viewer
|
||||||
|
* @param {object | null} snapshot 场景快照
|
||||||
|
* @returns {void} 无返回值
|
||||||
|
*/
|
||||||
|
function applyLayerState(viewer, snapshot) {
|
||||||
|
if (!snapshot) return
|
||||||
|
const stateMap = snapshot.layerStates || {}
|
||||||
|
|
||||||
|
if (layerService.value) {
|
||||||
|
Object.entries(stateMap).forEach(([id, info]) => {
|
||||||
|
if (!info) return
|
||||||
|
try {
|
||||||
|
if (info.show != null) layerService.value.showLayer(id, info.show)
|
||||||
|
if (info.type === 'imagery' && typeof info.opacity === 'number') {
|
||||||
|
layerService.value.setOpacity(id, info.opacity)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`恢复图层状态失败: ${id}`, error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Object.entries(stateMap).forEach(([id, info]) => {
|
||||||
|
if (!info) return
|
||||||
|
const record = mapStore.layers?.[id]
|
||||||
|
if (!record || !record.obj) return
|
||||||
|
if (info.show != null) {
|
||||||
|
record.obj.show = !!info.show
|
||||||
|
record.show = !!info.show
|
||||||
|
}
|
||||||
|
if (info.type === 'imagery' && typeof info.opacity === 'number') {
|
||||||
|
record.obj.alpha = info.opacity
|
||||||
|
record.opacity = info.opacity
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageryOrder = Array.isArray(snapshot.imageryOrder) ? snapshot.imageryOrder : []
|
||||||
|
if (imageryOrder.length) {
|
||||||
|
imageryOrder.slice().reverse().forEach((id) => {
|
||||||
|
const info = stateMap[id]
|
||||||
|
const record = mapStore.layers?.[id]
|
||||||
|
if (!record || record.type !== 'imagery' || !record.obj) return
|
||||||
|
try {
|
||||||
|
if (layerService.value && typeof layerService.value.moveLayer === 'function') {
|
||||||
|
layerService.value.moveLayer(id, 'bottom')
|
||||||
|
} else if (viewer?.imageryLayers?.contains(record.obj)) {
|
||||||
|
viewer.imageryLayers.lowerToBottom(record.obj)
|
||||||
|
}
|
||||||
|
if (info && info.splitDirection != null) {
|
||||||
|
record.obj.splitDirection = info.splitDirection
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`恢复影像图层顺序失败: ${id}`, error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Object.entries(stateMap).forEach(([id, info]) => {
|
||||||
|
if (!info || info.splitDirection == null) return
|
||||||
|
const record = mapStore.layers?.[id]
|
||||||
|
if (record?.type === 'imagery' && record.obj) {
|
||||||
|
record.obj.splitDirection = info.splitDirection
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const vectorOrder = Array.isArray(snapshot.vectorOrder) ? snapshot.vectorOrder : []
|
||||||
|
if (vectorOrder.length) {
|
||||||
|
vectorOrder.slice().reverse().forEach((id) => {
|
||||||
|
const record = mapStore.layers?.[id]
|
||||||
|
if (!record || !(record.type === 'vector' || record.type === 'datasource') || !record.obj) return
|
||||||
|
try {
|
||||||
|
if (layerService.value && typeof layerService.value.moveLayer === 'function') {
|
||||||
|
layerService.value.moveLayer(id, 'bottom')
|
||||||
|
} else if (viewer?.dataSources?.contains(record.obj)) {
|
||||||
|
viewer.dataSources.lowerToBottom(record.obj)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`恢复矢量图层顺序失败: ${id}`, error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 还原相机视角与可视范围。
|
||||||
|
* @param {Cesium.Scene} scene Cesium 场景
|
||||||
|
* @param {Cesium.Viewer} viewer Cesium Viewer
|
||||||
|
* @param {object | null} snapshot 场景快照
|
||||||
|
* @returns {void} 无返回值
|
||||||
|
*/
|
||||||
|
function applyCameraState(scene, viewer, snapshot) {
|
||||||
|
if (!snapshot) return
|
||||||
|
const cameraView = snapshot.cameraView
|
||||||
|
try {
|
||||||
|
if (scene.mode === Cesium.SceneMode.SCENE2D) {
|
||||||
|
if (snapshot.viewRectangle) {
|
||||||
|
const rect = Cesium.Rectangle.fromDegrees(
|
||||||
|
snapshot.viewRectangle.west,
|
||||||
|
snapshot.viewRectangle.south,
|
||||||
|
snapshot.viewRectangle.east,
|
||||||
|
snapshot.viewRectangle.north,
|
||||||
|
)
|
||||||
|
viewer.camera.setView({ destination: rect })
|
||||||
|
} else if (cameraView) {
|
||||||
|
viewer.camera.setView({
|
||||||
|
destination: Cesium.Cartesian3.fromDegrees(
|
||||||
|
Number(cameraView.lon) || 0,
|
||||||
|
Number(cameraView.lat) || 0,
|
||||||
|
Number(cameraView.height) || 1500,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (cameraView) {
|
||||||
|
if (cameraService.value && typeof cameraService.value.setView === 'function') {
|
||||||
|
cameraService.value.setView(cameraView)
|
||||||
|
} else {
|
||||||
|
viewer.camera.setView({
|
||||||
|
destination: Cesium.Cartesian3.fromDegrees(
|
||||||
|
Number(cameraView.lon) || 0,
|
||||||
|
Number(cameraView.lat) || 0,
|
||||||
|
Number(cameraView.height) || 1500,
|
||||||
|
),
|
||||||
|
orientation: {
|
||||||
|
heading: Cesium.Math.toRadians(Number(cameraView.heading) || 0),
|
||||||
|
pitch: Cesium.Math.toRadians(Number(cameraView.pitch) || 0),
|
||||||
|
roll: Cesium.Math.toRadians(Number(cameraView.roll) || 0),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewer?.scene?.requestRender) {
|
||||||
|
viewer.scene.requestRender()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('恢复相机视角失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 场景模式切换完成后恢复快照状态。
|
||||||
|
* @param {Cesium.Scene} scene Cesium 场景
|
||||||
|
* @returns {void} 无返回值
|
||||||
|
*/
|
||||||
|
function restoreSceneAfterMorph(scene) {
|
||||||
|
const payload = pendingRestoreState.value
|
||||||
|
pendingRestoreState.value = null
|
||||||
|
if (!payload || !mapStore.ready) return
|
||||||
|
|
||||||
|
resolveServices()
|
||||||
|
|
||||||
|
let viewer = null
|
||||||
|
try {
|
||||||
|
viewer = mapStore.getViewer()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('恢复场景失败,未获取到 Viewer', error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!viewer) return
|
||||||
|
applyLayerState(viewer, payload.snapshot)
|
||||||
|
applyCameraState(scene, viewer, payload.snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挂载场景监听,感知模式切换。
|
||||||
|
* @param {Cesium.Scene | null} scene Cesium 场景
|
||||||
|
* @returns {void} 无返回值
|
||||||
|
*/
|
||||||
|
function attachScene(scene) {
|
||||||
|
if (sceneRef.value === scene) {
|
||||||
|
syncSceneMode(scene)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof detachMorphStartListener === 'function') {
|
||||||
|
detachMorphStartListener()
|
||||||
|
}
|
||||||
|
if (typeof detachMorphCompleteListener === 'function') {
|
||||||
|
detachMorphCompleteListener()
|
||||||
|
}
|
||||||
|
|
||||||
|
sceneRef.value = scene
|
||||||
|
if (!scene) {
|
||||||
|
syncSceneMode(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncSceneMode(scene)
|
||||||
|
|
||||||
|
const handleMorphStart = () => {
|
||||||
|
isMorphing.value = true
|
||||||
|
}
|
||||||
|
const handleMorphComplete = () => {
|
||||||
|
isMorphing.value = false
|
||||||
|
syncSceneMode(scene)
|
||||||
|
restoreSceneAfterMorph(scene)
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.morphStart.addEventListener(handleMorphStart)
|
||||||
|
scene.morphComplete.addEventListener(handleMorphComplete)
|
||||||
|
detachMorphStartListener = () => {
|
||||||
|
scene.morphStart.removeEventListener(handleMorphStart)
|
||||||
|
}
|
||||||
|
detachMorphCompleteListener = () => {
|
||||||
|
scene.morphComplete.removeEventListener(handleMorphComplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Store 中解析并挂载场景及依赖服务。
|
||||||
|
* @returns {void} 无返回值
|
||||||
|
*/
|
||||||
|
function resolveSceneFromStore() {
|
||||||
|
if (!mapStore.ready) {
|
||||||
|
cleanupScene()
|
||||||
|
resolveServices()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolveServices()
|
||||||
|
const viewer = mapStore.getViewer()
|
||||||
|
attachScene(viewer?.scene ?? null)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('解析 Cesium 场景失败', error)
|
||||||
|
cleanupScene()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换 Cesium 二维与三维模式,并记录快照。
|
||||||
|
* @returns {void} 无返回值
|
||||||
|
*/
|
||||||
|
function toggleSceneMode() {
|
||||||
|
if (!mapStore.ready || isMorphing.value) return
|
||||||
|
|
||||||
|
let viewer = null
|
||||||
|
try {
|
||||||
|
viewer = mapStore.getViewer()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('切换模式失败,未获取到 Viewer', error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!viewer) return
|
||||||
|
|
||||||
|
resolveServices()
|
||||||
|
|
||||||
|
const scene = viewer.scene
|
||||||
|
const targetIs3D = !is3DMode.value
|
||||||
|
const snapshot = captureSceneSnapshot(viewer)
|
||||||
|
pendingRestoreState.value = { snapshot }
|
||||||
|
|
||||||
|
is3DMode.value = targetIs3D
|
||||||
|
isMorphing.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (targetIs3D) {
|
||||||
|
scene.morphTo3D(0.6)
|
||||||
|
} else {
|
||||||
|
scene.morphTo2D(0.6)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('切换场景模式失败', error)
|
||||||
|
isMorphing.value = false
|
||||||
|
syncSceneMode(scene)
|
||||||
|
restoreSceneAfterMorph(scene)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonLabel = computed(() => (is3DMode.value ? '2D' : '3D'))
|
||||||
|
const buttonTitle = computed(() => (is3DMode.value ? '切换到二维模式' : '切换到三维模式'))
|
||||||
|
const canToggle = computed(() => !!sceneRef.value && !isMorphing.value)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (mapStore.ready) {
|
||||||
|
resolveSceneFromStore()
|
||||||
|
}
|
||||||
|
detachReadyListener = mapStore.onReady(() => {
|
||||||
|
resolveSceneFromStore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
cleanupScene()
|
||||||
|
if (typeof detachReadyListener === 'function') {
|
||||||
|
detachReadyListener()
|
||||||
|
}
|
||||||
|
detachReadyListener = null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => mapStore.ready,
|
||||||
|
(ready) => {
|
||||||
|
if (ready) {
|
||||||
|
resolveSceneFromStore()
|
||||||
|
} else {
|
||||||
|
cleanupScene()
|
||||||
|
resolveServices()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.scene-mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #1f1f1f;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-mode-toggle:not(:disabled):hover {
|
||||||
|
background: #ffffff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-mode-toggle:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.scene-mode-toggle {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
97
packages/screen/src/map/composables/useMapViewSnapshot.js
Normal file
97
packages/screen/src/map/composables/useMapViewSnapshot.js
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import useMapStore from '@/map/stores/mapStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @function useMapViewSnapshot
|
||||||
|
* @description 提供捕获与恢复地图视角的组合式函数,适用于在页面间切换时保持用户视角。
|
||||||
|
* @returns {{
|
||||||
|
* viewSnapshot: import('vue').Ref<Record<string, number>|null>,
|
||||||
|
* captureViewSnapshot: (force?: boolean) => Record<string, number>|null,
|
||||||
|
* restoreViewSnapshot: (options?: { duration?: number, clearAfterRestore?: boolean }) => Promise<boolean>,
|
||||||
|
* clearViewSnapshot: () => void
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
export function useMapViewSnapshot() {
|
||||||
|
const mapStore = useMapStore()
|
||||||
|
const viewSnapshot = ref(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @function captureViewSnapshot
|
||||||
|
* @description 捕获当前地图视角,可选择是否强制覆盖已有快照。
|
||||||
|
* @param {boolean} [force=false] 是否强制覆盖已有快照
|
||||||
|
* @returns {Record<string, number>|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<boolean>} 是否成功发起恢复
|
||||||
|
*/
|
||||||
|
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
|
||||||
164
packages/screen/src/map/data/baseMap.json
Normal file
164
packages/screen/src/map/data/baseMap.json
Normal file
@ -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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
260
packages/screen/src/map/data/layerMap.json
Normal file
260
packages/screen/src/map/data/layerMap.json
Normal file
@ -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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
37
packages/screen/src/map/data/mapBaseConfig.json
Normal file
37
packages/screen/src/map/data/mapBaseConfig.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
14
packages/screen/src/map/index.js
Normal file
14
packages/screen/src/map/index.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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 MapIcon } from './shared/SvgIcon/index.vue'
|
||||||
|
|
||||||
|
export { default as useMapStore } from './stores/mapStore'
|
||||||
|
export { default as useMapUiStore } from './stores/mapUiStore'
|
||||||
|
|
||||||
|
export { useMapViewSnapshot } from './composables/useMapViewSnapshot'
|
||||||
374
packages/screen/src/map/services/createCameraService.js
Normal file
374
packages/screen/src/map/services/createCameraService.js
Normal file
@ -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<boolean>} 飞行是否成功
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
134
packages/screen/src/map/services/createEntityService.js
Normal file
134
packages/screen/src/map/services/createEntityService.js
Normal file
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
421
packages/screen/src/map/services/createLayerService.js
Normal file
421
packages/screen/src/map/services/createLayerService.js
Normal file
@ -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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
60
packages/screen/src/map/services/createQueryService.js
Normal file
60
packages/screen/src/map/services/createQueryService.js
Normal file
@ -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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
55
packages/screen/src/map/shared/SvgIcon/index.vue
Normal file
55
packages/screen/src/map/shared/SvgIcon/index.vue
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<svg :class="svgClass" aria-hidden="true">
|
||||||
|
<use :xlink:href="iconName" :fill="color" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { defineComponent, computed } from 'vue'
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
iconClass: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
className: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
return {
|
||||||
|
iconName: computed(() => `#icon-${props.iconClass}`),
|
||||||
|
svgClass: computed(() => {
|
||||||
|
if (props.className) {
|
||||||
|
return `svg-icon ${props.className}`
|
||||||
|
}
|
||||||
|
return 'svg-icon'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scope lang="scss">
|
||||||
|
.sub-el-icon,
|
||||||
|
.nav-icon {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 15px;
|
||||||
|
margin-right: 12px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-icon {
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
position: relative;
|
||||||
|
fill: currentColor;
|
||||||
|
vertical-align: -2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
430
packages/screen/src/map/stores/mapStore.js
Normal file
430
packages/screen/src/map/stores/mapStore.js
Normal file
@ -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
|
||||||
40
packages/screen/src/map/stores/mapUiStore.js
Normal file
40
packages/screen/src/map/stores/mapUiStore.js
Normal file
@ -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
|
||||||
111
packages/screen/src/map/utils/pickPosition.js
Normal file
111
packages/screen/src/map/utils/pickPosition.js
Normal file
@ -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
|
||||||
|
}
|
||||||
59
packages/screen/src/map/utils/utils.js
Normal file
59
packages/screen/src/map/utils/utils.js
Normal file
@ -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'
|
||||||
|
|
||||||
@ -46,8 +46,8 @@ import YearStatistics from './YearStatistics.vue'
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: vw(580) 1fr vw(580);
|
grid-template-columns: vw(580) 1fr vw(580);
|
||||||
gap: vw(20);
|
gap: vw(20);
|
||||||
padding: vw(20);
|
padding: 0 vw(10);
|
||||||
padding-top: 0;
|
// padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-panel,
|
.left-panel,
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="map-center">
|
<div class="map-center">
|
||||||
|
<div class="map-container">
|
||||||
|
<MapViewport />
|
||||||
|
<MapControls />
|
||||||
|
</div>
|
||||||
<!-- 顶部功能按钮 -->
|
<!-- 顶部功能按钮 -->
|
||||||
<div class="top-buttons">
|
<div class="top-buttons">
|
||||||
<button
|
<button
|
||||||
@ -14,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 地图标记点 (这里应该集成实际地图,现在用占位符) -->
|
<!-- 地图标记点 (这里应该集成实际地图,现在用占位符) -->
|
||||||
<div class="map-markers">
|
<!-- <div class="map-markers">
|
||||||
<div
|
<div
|
||||||
v-for="marker in markers"
|
v-for="marker in markers"
|
||||||
:key="marker.id"
|
:key="marker.id"
|
||||||
@ -23,7 +27,7 @@
|
|||||||
>
|
>
|
||||||
<img :src="marker.icon" :alt="marker.type" />
|
<img :src="marker.icon" :alt="marker.type" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
<!-- 底部菜单 -->
|
<!-- 底部菜单 -->
|
||||||
<!--
|
<!--
|
||||||
@ -72,6 +76,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { MapViewport, MapControls } from '@/map'
|
||||||
|
|
||||||
// 导入图片
|
// 导入图片
|
||||||
import btnServiceIcon from '../assets/img/map-btn-service.png'
|
import btnServiceIcon from '../assets/img/map-btn-service.png'
|
||||||
@ -120,6 +125,19 @@ const markers = ref([
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-buttons,
|
||||||
|
.map-markers,
|
||||||
|
.bottom-menu {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.top-buttons {
|
.top-buttons {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: vh(20);
|
top: vh(20);
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import Components from 'unplugin-vue-components/vite'
|
|||||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
|
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
|
||||||
|
import cesium from 'vite-plugin-cesium'
|
||||||
|
|
||||||
const DEFAULT_BUILD_BASE = '/bxztpc/'
|
const DEFAULT_BUILD_BASE = '/bxztpc/'
|
||||||
|
|
||||||
@ -52,9 +54,12 @@ export default defineConfig(({ command, mode }) => {
|
|||||||
process.env.BASE_PATH ??
|
process.env.BASE_PATH ??
|
||||||
DEFAULT_BUILD_BASE
|
DEFAULT_BUILD_BASE
|
||||||
: '/'
|
: '/'
|
||||||
|
const resolvedBase =
|
||||||
|
command === 'build' ? normalizeBasePath(baseCandidate) : '/'
|
||||||
|
const cesiumBaseUrl = resolvedBase === '/' ? '/cesium' : `/cesium`
|
||||||
|
|
||||||
return {
|
return {
|
||||||
base: process.env.NODE_ENV === 'production' ? normalizeBasePath(baseCandidate) : '/',
|
base: resolvedBase,
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
AutoImport({
|
AutoImport({
|
||||||
@ -63,7 +68,18 @@ export default defineConfig(({ command, mode }) => {
|
|||||||
Components({
|
Components({
|
||||||
resolvers: [ElementPlusResolver()],
|
resolvers: [ElementPlusResolver()],
|
||||||
}),
|
}),
|
||||||
|
createSvgIconsPlugin({
|
||||||
|
iconDirs: [
|
||||||
|
resolve(__dirname, 'src/assets/icons/svg'),
|
||||||
|
resolve(__dirname, 'src/map/assets/icons')
|
||||||
|
],
|
||||||
|
symbolId: 'icon-[dir]-[name]',
|
||||||
|
}),
|
||||||
|
cesium(),
|
||||||
],
|
],
|
||||||
|
define: {
|
||||||
|
CESIUM_BASE_URL: JSON.stringify(cesiumBaseUrl),
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, 'src'),
|
'@': resolve(__dirname, 'src'),
|
||||||
@ -93,6 +109,6 @@ export default defineConfig(({ command, mode }) => {
|
|||||||
assetFileNames: '[ext]/[name]-[hash].[ext]'
|
assetFileNames: '[ext]/[name]-[hash].[ext]'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
2522
pnpm-lock.yaml
generated
2522
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user