feat(map): 集成Cesium 3D地图系统与控件和服务

使用Cesium添加全面的3D地图功能,包括:
- 地图视口和控件组件
- 图层管理,含底图切换器和目录控制
- 相机、实体和查询服务
- 罗盘和场景模式切换UI组件
- 支持工具、存储和数据配置

更新构建配置以支持Cesium集成和SVG图标。
This commit is contained in:
Zzc 2025-11-07 15:04:37 +08:00
parent 71454c47a3
commit b432d8d6b7
31 changed files with 7226 additions and 27 deletions

View File

@ -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

View File

@ -2,3 +2,4 @@
# 生产环境 # 生产环境
VITE_API_BASE_URL=https://api.example.com VITE_API_BASE_URL=https://api.example.com
VITE_CESIUM_ION_TOKEN=

View File

@ -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"
} }
} }

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

View 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

View 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="全景图&amp;超解析照片" 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

View 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="全景图&amp;超解析照片" 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

View 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>

View File

@ -5,6 +5,9 @@ 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'
import SvgIcon from './components/SvgIcon/index.vue'
const app = createApp(App) const app = createApp(App)
@ -13,5 +16,6 @@ app.use(ElementPlus, {
locale: zhCn, locale: zhCn,
}) })
app.use(router) app.use(router)
app.component('svg-icon', SvgIcon)
app.mount('#app') app.mount('#app')

View File

@ -0,0 +1,370 @@
<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">
<svg-icon
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"
>
<svg-icon 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 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>

View File

@ -0,0 +1,588 @@
<template>
<div class="layer-directory-control">
<el-tooltip content="图层目录" placement="right">
<el-button
class="layer-directory-control__toggle"
type="primary"
@click="togglePanel"
>
<svg-icon 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 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>

View File

@ -0,0 +1,332 @@
<template>
<div
v-if="showCompass"
:class="compassClasses"
:style="compassStyle"
@click="recoverHeading"
title="点击恢复正北方向"
>
<!-- 指南针背景 - 根据相机朝向旋转 -->
<img
src="@/assets/icons/svg/compass_bg.svg"
alt="指南针背景"
class="compass-bg"
:style="{ transform: `rotate(${-heading}deg)` }"
/>
<!-- 指南针指针 - 固定指向北方 -->
<img
src="@/assets/icons/svg/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>

View 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>

View 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>

View 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>

View 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

View 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": ""
}
]

View 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": ""
}
]

View 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"
}
]

View File

@ -0,0 +1,12 @@
export { default as MapViewport } from './components/MapViewport.vue'
export { default as MapControls } from './components/MapControls.vue'
export { default as BaseMapSwitcher } from './components/BaseMapSwitcher.vue'
export { default as SceneModeToggle } from './components/SceneModeToggle.vue'
export { default as MapCompass } from './components/MapCompass.vue'
export { default as LayerDirectoryControl } from './components/LayerDirectoryControl.vue'
export { default as useMapStore } from './stores/mapStore'
export { default as useMapUiStore } from './stores/mapUiStore'
export { useMapViewSnapshot } from './composables/useMapViewSnapshot'

View 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)
},
}
}

View 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
}

View 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)
},
}
}

View 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)
},
}
}

View 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

View 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

View 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
}

View 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'

View File

@ -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);

View File

@ -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 =
process.env.NODE_ENV === 'production' ? normalizeBasePath(baseCandidate) : '/'
const cesiumBaseUrl = resolvedBase === '/' ? '/cesium' : `${resolvedBase}cesium`
return { return {
base: process.env.NODE_ENV === 'production' ? normalizeBasePath(baseCandidate) : '/', base: resolvedBase,
plugins: [ plugins: [
vue(), vue(),
AutoImport({ AutoImport({
@ -63,7 +68,15 @@ export default defineConfig(({ command, mode }) => {
Components({ Components({
resolvers: [ElementPlusResolver()], resolvers: [ElementPlusResolver()],
}), }),
createSvgIconsPlugin({
iconDirs: [resolve(__dirname, 'src/assets/icons/svg')],
symbolId: 'icon-[dir]-[name]',
}),
cesium(),
], ],
define: {
CESIUM_BASE_URL: JSON.stringify(cesiumBaseUrl),
},
resolve: { resolve: {
alias: { alias: {
'@': resolve(__dirname, 'src'), '@': resolve(__dirname, 'src'),
@ -93,6 +106,6 @@ export default defineConfig(({ command, mode }) => {
assetFileNames: '[ext]/[name]-[hash].[ext]' assetFileNames: '[ext]/[name]-[hash].[ext]'
} }
} }
} },
} }
}) })

2522
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff