feat(situational-awareness): 添加双地图对比和增强视频监控功能
新增支持双地图对比模式,显示灾害前后场景, 新的视频模态框用于全屏监控并带有方向控制, 位置面板显示地理信息, 地图工具提示显示实体详情,以及用于3D瓦片管理的可组合组件, 地图标记和模型对比功能。包括新的共享组件 如DecorativePanel和MapTooltip,以及Cesium数据 和模型对比设置的配置文件。
This commit is contained in:
parent
0b2f389770
commit
536b00fab4
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
@ -1,20 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="disaster-analysis">
|
<div class="disaster-analysis">
|
||||||
<!-- <div class="disaster-analysis__location">
|
|
||||||
<img
|
|
||||||
src="../../assets/images/SketchPng6b1bc7da82dd750ccb3b0a21a6fb46e0173d2fbabde3c21d83e92a2bc004dcfc.png"
|
|
||||||
alt="location"
|
|
||||||
class="location-icon"
|
|
||||||
/>
|
|
||||||
<span class="location-text">地理位置</span>
|
|
||||||
<img
|
|
||||||
src="../../assets/images/SketchPng52c0048cd3e760896c35ba9be5433c689d4c5063439ad54d40a678a1b2b20ca0.png"
|
|
||||||
alt="arrow"
|
|
||||||
class="arrow-icon"
|
|
||||||
/>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<div class="disaster-analysis__content">
|
|
||||||
<div class="disaster-analysis__row">
|
<div class="disaster-analysis__row">
|
||||||
<DataField
|
<DataField
|
||||||
label="灾害类型"
|
label="灾害类型"
|
||||||
@ -61,71 +46,35 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { inject } from "vue";
|
import { inject } from 'vue'
|
||||||
import DataField from "../shared/DataField.vue";
|
import DataField from '../shared/DataField.vue'
|
||||||
|
|
||||||
import DataFieldIcon1 from "../../assets/images/DataField/icon-1.png";
|
import DataFieldIcon1 from '../../assets/images/DataField/icon-1.png'
|
||||||
import DataFieldIcon2 from "../../assets/images/DataField/icon-2.png";
|
import DataFieldIcon2 from '../../assets/images/DataField/icon-2.png'
|
||||||
import DataFieldIcon3 from "../../assets/images/DataField/icon-3.png";
|
import DataFieldIcon3 from '../../assets/images/DataField/icon-3.png'
|
||||||
import DataFieldIcon4 from "../../assets/images/DataField/icon-4.png";
|
import DataFieldIcon4 from '../../assets/images/DataField/icon-4.png'
|
||||||
import DataFieldIcon5 from "../../assets/images/DataField/icon-5.png";
|
import DataFieldIcon5 from '../../assets/images/DataField/icon-5.png'
|
||||||
import DataFieldIcon6 from "../../assets/images/DataField/icon-6.png";
|
import DataFieldIcon6 from '../../assets/images/DataField/icon-6.png'
|
||||||
|
|
||||||
const { disasterInfo } = inject("disasterData");
|
const { disasterInfo } = inject('disasterData')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@use "@/styles/mixins.scss" as *;
|
@use '@/styles/mixins.scss' as *;
|
||||||
@use "../../assets/styles/common.scss" as *;
|
@use '../../assets/styles/common.scss' as *;
|
||||||
|
|
||||||
.disaster-analysis {
|
.disaster-analysis {
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
&__location {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: vw(8);
|
|
||||||
padding: vh(8) vw(12);
|
|
||||||
background: rgba(20, 53, 118, 0.5);
|
|
||||||
border-radius: vw(4);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(20, 53, 118, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.location-icon {
|
|
||||||
width: vw(14);
|
|
||||||
height: vh(14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.location-text {
|
|
||||||
color: var(--text-white);
|
|
||||||
font-size: fs(14);
|
|
||||||
font-family: SourceHanSansCN-Medium, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow-icon {
|
|
||||||
width: vw(10);
|
|
||||||
height: vh(10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__content {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: vh(12);
|
gap: vh(12);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__row {
|
.disaster-analysis__row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: vw(16);
|
gap: vw(16);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,9 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="force-preset">
|
<div class="force-preset">
|
||||||
<div class="force-preset__filter">
|
<el-dropdown
|
||||||
|
class="force-preset__filter"
|
||||||
|
trigger="click"
|
||||||
|
@command="handleDistanceChange"
|
||||||
|
>
|
||||||
|
<div class="filter-content">
|
||||||
<span class="filter-text">距离灾害点{{ forcePreset.searchRadius }}km范围内</span>
|
<span class="filter-text">距离灾害点{{ forcePreset.searchRadius }}km范围内</span>
|
||||||
<img src="../../assets/images/SketchPng8063f445fba047c290a9620343b62ea51d767b8cdcd86769502b5b160998aacc.png" alt="dropdown" class="filter-icon" />
|
<img src="../../assets/images/SketchPng8063f445fba047c290a9620343b62ea51d767b8cdcd86769502b5b160998aacc.png" alt="dropdown" class="filter-icon" />
|
||||||
</div>
|
</div>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item :command="10" :class="{ 'is-active': forcePreset.searchRadius === 10 }">10km</el-dropdown-item>
|
||||||
|
<el-dropdown-item :command="30" :class="{ 'is-active': forcePreset.searchRadius === 30 }">30km</el-dropdown-item>
|
||||||
|
<el-dropdown-item :command="50" :class="{ 'is-active': forcePreset.searchRadius === 50 }">50km</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
|
||||||
<div class="force-preset__summary">
|
<div class="force-preset__summary">
|
||||||
<!-- <img src="../../assets/images/SketchPnga96e6ce64e80f6d935217d64400481f3e0361d9e60a7425f6f09c8287716904d.png" alt="background" class="summary-bg" /> -->
|
<!-- <img src="../../assets/images/SketchPnga96e6ce64e80f6d935217d64400481f3e0361d9e60a7425f6f09c8287716904d.png" alt="background" class="summary-bg" /> -->
|
||||||
@ -58,6 +71,17 @@
|
|||||||
import { inject } from 'vue'
|
import { inject } from 'vue'
|
||||||
|
|
||||||
const { forcePreset } = inject('disasterData')
|
const { forcePreset } = inject('disasterData')
|
||||||
|
const onDistanceChange = inject('onDistanceChange')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理距离范围选择变更
|
||||||
|
* @param {number} distance - 选中的距离范围(km)
|
||||||
|
*/
|
||||||
|
const handleDistanceChange = (distance) => {
|
||||||
|
if (onDistanceChange) {
|
||||||
|
onDistanceChange(distance)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@ -78,6 +102,13 @@ const { forcePreset } = inject('disasterData')
|
|||||||
margin-bottom: vh(16);
|
margin-bottom: vh(16);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
.filter-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: vw(8);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-text {
|
.filter-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
color: var(--text-white);
|
color: var(--text-white);
|
||||||
@ -174,6 +205,18 @@ const { forcePreset } = inject('disasterData')
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: vh(8);
|
gap: vh(8);
|
||||||
|
max-height: 100px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
// 滚动条
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: vw(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(20, 53, 118, 0.5);
|
||||||
|
border-radius: vw(2);
|
||||||
|
}
|
||||||
|
|
||||||
.station-item {
|
.station-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -214,4 +257,29 @@ const { forcePreset } = inject('disasterData')
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dropdown菜单样式覆盖
|
||||||
|
:deep(.el-dropdown-menu) {
|
||||||
|
background: rgba(20, 53, 118, 0.95);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding: vh(4) 0;
|
||||||
|
|
||||||
|
.el-dropdown-menu__item {
|
||||||
|
color: var(--text-white);
|
||||||
|
font-size: fs(14);
|
||||||
|
font-family: SourceHanSansCN-Medium, sans-serif;
|
||||||
|
padding: vh(8) vw(16);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -0,0 +1,161 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="location-panel">
|
||||||
|
<section
|
||||||
|
v-if="expanded"
|
||||||
|
:id="panelId"
|
||||||
|
class="location-panel"
|
||||||
|
role="region"
|
||||||
|
:aria-labelledby="labelId || undefined"
|
||||||
|
:aria-label="!labelId ? '地理位置信息' : undefined"
|
||||||
|
>
|
||||||
|
<div class="location-grid">
|
||||||
|
<article
|
||||||
|
v-for="item in locationInfo"
|
||||||
|
:key="item.label"
|
||||||
|
class="info-card"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="info-icon"
|
||||||
|
:src="item.icon"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div class="info-content">
|
||||||
|
<p class="info-label">{{ item.label }}</p>
|
||||||
|
<p class="info-value" :title="item.value">{{ item.value }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import lineCodeIcon from '../../assets/images/LocationPanel/线路编号icon.png'
|
||||||
|
import classNumberIcon from '../../assets/images/LocationPanel/路线桩号icon.png'
|
||||||
|
import inspectorIcon from '../../assets/images/LocationPanel/路段巡查员icon.png'
|
||||||
|
import phoneIcon from '../../assets/images/LocationPanel/联系电话icon.png'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
expanded: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
panelId: {
|
||||||
|
type: String,
|
||||||
|
default: 'location-panel',
|
||||||
|
},
|
||||||
|
labelId: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 地理位置信息数据
|
||||||
|
const locationInfo = [
|
||||||
|
{ label: '线路编号', value: 'S710', icon: lineCodeIcon },
|
||||||
|
{ label: '路线班号', value: 'S199', icon: classNumberIcon },
|
||||||
|
{ label: '路段巡查员', value: '张强', icon: inspectorIcon },
|
||||||
|
{ label: '联系电话', value: '13987657892', icon: phoneIcon },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use '@/styles/mixins.scss' as *;
|
||||||
|
@use '../../assets/styles/common.scss' as *;
|
||||||
|
|
||||||
|
.location-panel {
|
||||||
|
width: 100%;
|
||||||
|
padding: clamp(12px, vh(16), 20px) clamp(16px, vw(20), 28px);
|
||||||
|
background: url('../../assets/images/LocationPanel/地理位置内容背景.png') no-repeat center center;
|
||||||
|
background-size: 100% 100%;
|
||||||
|
border-radius: vw(8);
|
||||||
|
box-shadow: 0 0 vw(12) rgba(0, 0, 0, 0.35);
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 240px;
|
||||||
|
max-width: 100%;
|
||||||
|
// min-height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2x2 网格布局
|
||||||
|
.location-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: clamp(10px, vh(14), 18px) clamp(12px, vw(16), 20px);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 信息卡片
|
||||||
|
.info-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: clamp(8px, vw(12), 14px);
|
||||||
|
// padding: clamp(10px, vh(12), 16px) clamp(12px, vw(14), 18px);
|
||||||
|
// background: rgba(10, 95, 165, 0.15);
|
||||||
|
border-radius: clamp(6px, vw(8), 10px);
|
||||||
|
// border: 1px solid rgba(135, 206, 250, 0.12);
|
||||||
|
min-width: 0;
|
||||||
|
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(10, 95, 165, 0.22);
|
||||||
|
border-color: rgba(135, 206, 250, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图标样式
|
||||||
|
.info-icon {
|
||||||
|
width: clamp(24px, vw(32), 40px);
|
||||||
|
height: clamp(24px, vw(32), 40px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本内容区域
|
||||||
|
.info-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: clamp(3px, vh(4), 6px);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签样式
|
||||||
|
.info-label {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(12px, vw(14), 16px);
|
||||||
|
color: #87ceeb;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 值样式
|
||||||
|
.info-value {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(15px, vw(18), 22px);
|
||||||
|
color: #e8f4ff;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过渡动画
|
||||||
|
.location-panel-enter-active,
|
||||||
|
.location-panel-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-panel-enter-from,
|
||||||
|
.location-panel-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(vh(-10));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="left-panel-wrapper">
|
||||||
<div class="left-panel">
|
<div class="left-panel">
|
||||||
<CollapsiblePanel title="快速感知" subtitle="「灾害分析」">
|
<CollapsiblePanel title="快速感知" subtitle="「灾害分析」">
|
||||||
<DisasterAnalysis />
|
<DisasterAnalysis />
|
||||||
@ -12,19 +13,69 @@
|
|||||||
<ForceDispatch />
|
<ForceDispatch />
|
||||||
</CollapsiblePanel>
|
</CollapsiblePanel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 地理位置独立浮层 -->
|
||||||
|
<div class="location-entry">
|
||||||
|
<button
|
||||||
|
id="location-toggle-btn"
|
||||||
|
type="button"
|
||||||
|
class="location-toggle"
|
||||||
|
:class="{ 'is-open': isLocationOpen }"
|
||||||
|
:aria-expanded="isLocationOpen"
|
||||||
|
aria-controls="location-panel"
|
||||||
|
@click="toggleLocation"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="../../assets/images/LocationPanel/locationIcon.png"
|
||||||
|
alt=""
|
||||||
|
class="location-icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="location-text">地理位置</span>
|
||||||
|
<img
|
||||||
|
src="../../assets/images/LocationPanel/箭头.png"
|
||||||
|
alt=""
|
||||||
|
class="arrow-icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<LocationPanel
|
||||||
|
:expanded="isLocationOpen"
|
||||||
|
panel-id="location-panel"
|
||||||
|
label-id="location-toggle-btn"
|
||||||
|
>
|
||||||
|
<DisasterAnalysis />
|
||||||
|
</LocationPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
import CollapsiblePanel from '../shared/CollapsiblePanel.vue'
|
import CollapsiblePanel from '../shared/CollapsiblePanel.vue'
|
||||||
import DisasterAnalysis from './DisasterAnalysis.vue'
|
import DisasterAnalysis from './DisasterAnalysis.vue'
|
||||||
import ForcePreset from './ForcePreset.vue'
|
import ForcePreset from './ForcePreset.vue'
|
||||||
import ForceDispatch from './ForceDispatch.vue'
|
import ForceDispatch from './ForceDispatch.vue'
|
||||||
|
import LocationPanel from './LocationPanel.vue'
|
||||||
|
|
||||||
|
const isLocationOpen = ref(true)
|
||||||
|
|
||||||
|
const toggleLocation = () => {
|
||||||
|
isLocationOpen.value = !isLocationOpen.value
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@use '@/styles/mixins.scss' as *;
|
@use '@/styles/mixins.scss' as *;
|
||||||
@use '../../assets/styles/common.scss' as *;
|
@use '../../assets/styles/common.scss' as *;
|
||||||
|
|
||||||
|
.left-panel-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.left-panel {
|
.left-panel {
|
||||||
// width: vw(464);
|
// width: vw(464);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -50,4 +101,71 @@ import ForceDispatch from './ForceDispatch.vue'
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.location-entry {
|
||||||
|
position: absolute;
|
||||||
|
top: vh(32);
|
||||||
|
left: 100%;
|
||||||
|
margin-left: vw(12);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: vh(12);
|
||||||
|
width: vw(368);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: vw(8);
|
||||||
|
padding: vh(6) vw(12);
|
||||||
|
width: vw(133);
|
||||||
|
height: vh(29);
|
||||||
|
background: url('../../assets/images/LocationPanel/地理位置按钮背景.png') no-repeat center center;
|
||||||
|
background-size: 100% 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: vw(4);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-icon {
|
||||||
|
width: vw(19);
|
||||||
|
height: vw(17);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-text {
|
||||||
|
color: var(--text-white);
|
||||||
|
font-size: fs(16);
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: SourceHanSansCN-Medium, sans-serif;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-icon {
|
||||||
|
width: vw(10);
|
||||||
|
height: vh(10);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-open .arrow-icon {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -6,7 +6,10 @@
|
|||||||
<!-- 地图控制工具 - 使用 Teleport 传送到更高层级 -->
|
<!-- 地图控制工具 - 使用 Teleport 传送到更高层级 -->
|
||||||
<!-- 延迟渲染确保目标元素已存在 -->
|
<!-- 延迟渲染确保目标元素已存在 -->
|
||||||
<Teleport to="#sa-controls" v-if="isMounted">
|
<Teleport to="#sa-controls" v-if="isMounted">
|
||||||
<MapControls />
|
<MapControls
|
||||||
|
@tool-change="handleToolChange"
|
||||||
|
@device-watch="handleDeviceWatch"
|
||||||
|
/>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -16,9 +19,38 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { MapViewport } from '@/map'
|
import { MapViewport } from '@/map'
|
||||||
import MapControls from './MapControls.vue'
|
import MapControls from './MapControls.vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向外抛出的事件
|
||||||
|
* @event tool-change - 地图工具变化事件,包含 { tool: string, active: boolean }
|
||||||
|
* @event device-watch - 卫星设备观看状态变化事件,包含 boolean
|
||||||
|
*/
|
||||||
|
const emit = defineEmits(['tool-change', 'device-watch'])
|
||||||
|
|
||||||
// 延迟标志,确保 Teleport 目标元素已存在
|
// 延迟标志,确保 Teleport 目标元素已存在
|
||||||
const isMounted = ref(false)
|
const isMounted = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理地图工具变化
|
||||||
|
* 将 MapControls 的工具变化事件向上传递给父组件
|
||||||
|
*
|
||||||
|
* @param {Object} payload - 工具变化事件载荷
|
||||||
|
* @param {string} payload.tool - 工具标识(如 'modelCompare', 'measure' 等)
|
||||||
|
* @param {boolean} payload.active - 工具是否激活
|
||||||
|
*/
|
||||||
|
const handleToolChange = (payload) => {
|
||||||
|
emit('tool-change', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理卫星设备观看状态变化
|
||||||
|
* 将 MapControls 的设备观看事件向上传递给父组件
|
||||||
|
*
|
||||||
|
* @param {boolean} isWatching - 是否正在观看卫星设备
|
||||||
|
*/
|
||||||
|
const handleDeviceWatch = (isWatching) => {
|
||||||
|
emit('device-watch', isWatching)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 使用 nextTick 确保 DOM 完全渲染
|
// 使用 nextTick 确保 DOM 完全渲染
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@ -0,0 +1,432 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal-fade">
|
||||||
|
<div v-if="visible" class="video-modal" @click="handleClose">
|
||||||
|
<Transition name="modal-scale">
|
||||||
|
<DecorativePanel
|
||||||
|
v-if="visible"
|
||||||
|
custom-class="video-modal__content"
|
||||||
|
corner-size="clamp(35px, 3.5vw, 55px)"
|
||||||
|
:corner-offset="-20"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<!-- 标题栏 -->
|
||||||
|
<div class="video-modal__header">
|
||||||
|
<div class="video-modal__title">
|
||||||
|
<img
|
||||||
|
src="../../assets/images/modal/弹窗title.png"
|
||||||
|
alt="标题图标"
|
||||||
|
class="video-modal__title-icon"
|
||||||
|
/>
|
||||||
|
<span class="video-modal__title-text">{{ monitor.title }}</span>
|
||||||
|
<span class="video-modal__title-date">{{ monitor.dateRange }}</span>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
src="../../assets/images/video-modal-close.png"
|
||||||
|
alt="关闭"
|
||||||
|
class="video-modal__close-btn"
|
||||||
|
@click="handleClose"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 视频区域 -->
|
||||||
|
<div class="video-modal__video-wrapper">
|
||||||
|
<video
|
||||||
|
ref="videoRef"
|
||||||
|
class="video-modal__video"
|
||||||
|
:src="monitor.videoSrc"
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作台(仅无人机等有方向控制的监控) -->
|
||||||
|
<div v-if="monitor.hasDirectionControl" class="video-modal__console">
|
||||||
|
<img
|
||||||
|
src="../../assets/images/video-control-console.png"
|
||||||
|
alt="操作台背景"
|
||||||
|
class="video-modal__console-bg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="video-modal__control-bar">
|
||||||
|
<!-- 喊话 -->
|
||||||
|
<div
|
||||||
|
v-if="monitor.hasMegaphone"
|
||||||
|
class="video-modal__control-item"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="$emit('megaphone', monitor.id)"
|
||||||
|
@keydown.enter.prevent="$emit('megaphone', monitor.id)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="../../assets/images/video-control-megaphone.png"
|
||||||
|
alt="喊话"
|
||||||
|
class="video-modal__control-icon"
|
||||||
|
/>
|
||||||
|
<span class="video-modal__control-label">喊话</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 声音 -->
|
||||||
|
<div
|
||||||
|
v-if="monitor.hasAudio"
|
||||||
|
class="video-modal__control-item"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="$emit('audio', monitor.id)"
|
||||||
|
@keydown.enter.prevent="$emit('audio', monitor.id)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="../../assets/images/video-control-sound.png"
|
||||||
|
alt="声音"
|
||||||
|
class="video-modal__control-icon"
|
||||||
|
/>
|
||||||
|
<span class="video-modal__control-label">声音</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 视觉左移 -->
|
||||||
|
<div
|
||||||
|
class="video-modal__control-item"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="handleMove('left')"
|
||||||
|
@keydown.enter.prevent="handleMove('left')"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="../../assets/images/video-control-left.png"
|
||||||
|
alt="左移"
|
||||||
|
class="video-modal__control-icon"
|
||||||
|
/>
|
||||||
|
<span class="video-modal__control-label">视觉左移</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 视觉右移 -->
|
||||||
|
<div
|
||||||
|
class="video-modal__control-item"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="handleMove('right')"
|
||||||
|
@keydown.enter.prevent="handleMove('right')"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="../../assets/images/video-control-right.png"
|
||||||
|
alt="右移"
|
||||||
|
class="video-modal__control-icon"
|
||||||
|
/>
|
||||||
|
<span class="video-modal__control-label">视觉右移</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 视觉上移 -->
|
||||||
|
<div
|
||||||
|
class="video-modal__control-item"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="handleMove('up')"
|
||||||
|
@keydown.enter.prevent="handleMove('up')"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="../../assets/images/video-control-up.png"
|
||||||
|
alt="上移"
|
||||||
|
class="video-modal__control-icon"
|
||||||
|
/>
|
||||||
|
<span class="video-modal__control-label">视觉上移</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 视觉下移 -->
|
||||||
|
<div
|
||||||
|
class="video-modal__control-item"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="handleMove('down')"
|
||||||
|
@keydown.enter.prevent="handleMove('down')"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="../../assets/images/video-control-down.png"
|
||||||
|
alt="下移"
|
||||||
|
class="video-modal__control-icon"
|
||||||
|
/>
|
||||||
|
<span class="video-modal__control-label">视觉下移</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 缩小 -->
|
||||||
|
<div
|
||||||
|
class="video-modal__control-item"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="handleClose"
|
||||||
|
@keydown.enter.prevent="handleClose"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="../../assets/images/video-control-shrink.png"
|
||||||
|
alt="缩小"
|
||||||
|
class="video-modal__control-icon"
|
||||||
|
/>
|
||||||
|
<span class="video-modal__control-label">缩小</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DecorativePanel>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import DecorativePanel from '../shared/DecorativePanel.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
monitor: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'megaphone', 'audio', 'move'])
|
||||||
|
|
||||||
|
const videoRef = ref(null)
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMove = (direction) => {
|
||||||
|
emit('move', { monitorId: props.monitor.id, direction })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC 键关闭
|
||||||
|
const handleKeydown = (e) => {
|
||||||
|
if (e.key === 'Escape' && props.visible) {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止滚动穿透
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
document.body.classList.add('body--no-scroll')
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('body--no-scroll')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
document.body.classList.remove('body--no-scroll')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '@/styles/mixins.scss' as *;
|
||||||
|
|
||||||
|
// 全局样式:防止滚动穿透
|
||||||
|
body.body--no-scroll {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遮罩淡入动画
|
||||||
|
.modal-fade-enter-active,
|
||||||
|
.modal-fade-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-fade-enter-from,
|
||||||
|
.modal-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内容缩放动画
|
||||||
|
.modal-scale-enter-active,
|
||||||
|
.modal-scale-leave-active {
|
||||||
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-scale-enter-from,
|
||||||
|
.modal-scale-leave-to {
|
||||||
|
transform: scale(0.9);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹窗容器
|
||||||
|
.video-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2vh 2vw;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
// 内容区
|
||||||
|
&__content {
|
||||||
|
position: relative;
|
||||||
|
width: clamp(800px, 90vw, 1400px);
|
||||||
|
height: clamp(500px, 80vh, 900px);
|
||||||
|
background: rgba(10, 31, 57, 0.95);
|
||||||
|
// border-radius: vw(12);
|
||||||
|
// border: 2px solid rgba(135, 206, 250, 0.3);
|
||||||
|
box-shadow: 0 0 vw(30) rgba(0, 150, 255, 0.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标题栏
|
||||||
|
&__header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: clamp(50px, 6vh, 70px);
|
||||||
|
// background: url('../../assets/images/video-modal-title-bg.png') no-repeat center center;
|
||||||
|
// background-size: 100% 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 clamp(20px, 2vw, 40px);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
// flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: clamp(2px, 0.3vh, 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title-text {
|
||||||
|
font-size: clamp(16px, 1.5vw, 20px);
|
||||||
|
font-family: SourceHanSansCN-Medium, sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title-date {
|
||||||
|
font-size: clamp(12px, 1vw, 14px);
|
||||||
|
font-family: SourceHanSansCN-Regular, sans-serif;
|
||||||
|
color: #87ceeb;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__close-btn {
|
||||||
|
width: clamp(24px, 2vw, 32px);
|
||||||
|
height: clamp(24px, 2vw, 32px);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频区域
|
||||||
|
&__video-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
padding: clamp(10px, 1.5vh, 20px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__video {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: vw(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 操作台
|
||||||
|
&__console {
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 80px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
height: clamp(80px, 10vh, 120px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__console-bg {
|
||||||
|
// position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
// width: 100%;
|
||||||
|
height: 85%;
|
||||||
|
object-fit: cover;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__control-bar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: clamp(15px, 2vw, 30px);
|
||||||
|
padding: 0 clamp(20px, 2vw, 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__control-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: clamp(4px, 0.5vh, 8px);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid rgba(135, 206, 250, 0.8);
|
||||||
|
outline-offset: 4px;
|
||||||
|
border-radius: vw(4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__control-icon {
|
||||||
|
width: clamp(24px, 2.5vw, 36px);
|
||||||
|
height: clamp(24px, 2.5vw, 36px);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__control-label {
|
||||||
|
font-size: clamp(11px, 1vw, 13px);
|
||||||
|
font-family: SourceHanSansCN-Regular, sans-serif;
|
||||||
|
color: #ffffff;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -8,28 +8,58 @@
|
|||||||
@audio="handleAudio"
|
@audio="handleAudio"
|
||||||
@zoom="handleZoom"
|
@zoom="handleZoom"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 全屏视频弹窗 -->
|
||||||
|
<VideoModal
|
||||||
|
v-if="zoomedMonitor"
|
||||||
|
:visible="!!zoomedMonitor"
|
||||||
|
:monitor="zoomedMonitor"
|
||||||
|
@close="handleCloseModal"
|
||||||
|
@megaphone="handleMegaphone"
|
||||||
|
@audio="handleAudio"
|
||||||
|
@move="handleMove"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import VideoMonitorItem from './VideoMonitorItem.vue'
|
import VideoMonitorItem from './VideoMonitorItem.vue'
|
||||||
|
import VideoModal from './VideoModal.vue'
|
||||||
import { useVideoMonitor } from '../../composables/useVideoMonitor'
|
import { useVideoMonitor } from '../../composables/useVideoMonitor'
|
||||||
|
|
||||||
const { monitors, toggleMegaphone, zoomMonitor } = useVideoMonitor()
|
const {
|
||||||
|
monitors,
|
||||||
|
zoomedMonitor,
|
||||||
|
toggleMegaphone,
|
||||||
|
zoomMonitor,
|
||||||
|
closeZoom,
|
||||||
|
toggleAudio,
|
||||||
|
moveView
|
||||||
|
} = useVideoMonitor()
|
||||||
|
|
||||||
const handleMegaphone = (monitorId) => {
|
const handleMegaphone = (monitorId) => {
|
||||||
console.log('喊话:', monitorId)
|
console.log('喊话:', monitorId)
|
||||||
toggleMegaphone()
|
toggleMegaphone(monitorId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAudio = (monitorId) => {
|
const handleAudio = (monitorId) => {
|
||||||
console.log('音频控制:', monitorId)
|
console.log('音频控制:', monitorId)
|
||||||
|
toggleAudio(monitorId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleZoom = (monitorId) => {
|
const handleZoom = (monitorId) => {
|
||||||
console.log('放大窗口:', monitorId)
|
console.log('放大窗口:', monitorId)
|
||||||
zoomMonitor(monitorId)
|
zoomMonitor(monitorId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
closeZoom()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMove = ({ monitorId, direction }) => {
|
||||||
|
console.log(`视角移动 - 监控ID: ${monitorId}, 方向: ${direction}`)
|
||||||
|
moveView(monitorId, direction)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@ -13,35 +13,60 @@
|
|||||||
<div class="video-placeholder">
|
<div class="video-placeholder">
|
||||||
<!-- 这里放置实际的视频流组件 -->
|
<!-- 这里放置实际的视频流组件 -->
|
||||||
<div class="video-time">{{ currentTime }}</div>
|
<div class="video-time">{{ currentTime }}</div>
|
||||||
|
|
||||||
|
<!-- 控制条:叠加在视频底部 -->
|
||||||
|
<div class="video-controls">
|
||||||
|
<div
|
||||||
|
v-if="monitor.hasMegaphone"
|
||||||
|
class="video-control-item"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="$emit('megaphone', monitor.id)"
|
||||||
|
@keydown.enter.prevent="$emit('megaphone', monitor.id)"
|
||||||
|
@keydown.space.prevent="$emit('megaphone', monitor.id)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="../../assets/images/SketchPngf116f6395148799bd03097ba5211a0556d6199219712f4a99a018194f34186a6.png"
|
||||||
|
alt="megaphone"
|
||||||
|
class="video-control-item__icon"
|
||||||
|
/>
|
||||||
|
<span class="video-control-item__label">喊话</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="video-controls">
|
<div
|
||||||
<button
|
|
||||||
v-if="monitor.hasMegaphone"
|
|
||||||
class="control-btn"
|
|
||||||
@click="$emit('megaphone', monitor.id)"
|
|
||||||
>
|
|
||||||
<img src="../../assets/images/SketchPngf116f6395148799bd03097ba5211a0556d6199219712f4a99a018194f34186a6.png" alt="megaphone" />
|
|
||||||
<span>喊话</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="monitor.hasAudio"
|
v-if="monitor.hasAudio"
|
||||||
class="control-btn"
|
class="video-control-item"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
@click="$emit('audio', monitor.id)"
|
@click="$emit('audio', monitor.id)"
|
||||||
|
@keydown.enter.prevent="$emit('audio', monitor.id)"
|
||||||
|
@keydown.space.prevent="$emit('audio', monitor.id)"
|
||||||
>
|
>
|
||||||
<img src="../../assets/images/SketchPng04633c2ccf22607c20a4803d536908398c2953405e089cd296b106e601f793e0.png" alt="audio" />
|
<img
|
||||||
<span>声音</span>
|
src="../../assets/images/SketchPng04633c2ccf22607c20a4803d536908398c2953405e089cd296b106e601f793e0.png"
|
||||||
</button>
|
alt="audio"
|
||||||
|
class="video-control-item__icon"
|
||||||
|
/>
|
||||||
|
<span class="video-control-item__label">声音</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<div
|
||||||
v-if="monitor.hasZoom"
|
v-if="monitor.hasZoom"
|
||||||
class="control-btn"
|
class="video-control-item"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
@click="$emit('zoom', monitor.id)"
|
@click="$emit('zoom', monitor.id)"
|
||||||
|
@keydown.enter.prevent="$emit('zoom', monitor.id)"
|
||||||
|
@keydown.space.prevent="$emit('zoom', monitor.id)"
|
||||||
>
|
>
|
||||||
<img src="../../assets/images/SketchPnga801740c6a6435fc300fc58878fc7da23921eae9c45eaff4ad9c40cc80d6706b.png" alt="zoom" />
|
<img
|
||||||
<span>窗口放大</span>
|
src="../../assets/images/SketchPnga801740c6a6435fc300fc58878fc7da23921eae9c45eaff4ad9c40cc80d6706b.png"
|
||||||
</button>
|
alt="zoom"
|
||||||
|
class="video-control-item__icon"
|
||||||
|
/>
|
||||||
|
<span class="video-control-item__label">窗口放大</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -95,7 +120,8 @@ onUnmounted(() => {
|
|||||||
.video-monitor-item {
|
.video-monitor-item {
|
||||||
// background: rgba(20, 53, 118, 0.3);
|
// background: rgba(20, 53, 118, 0.3);
|
||||||
background: url('../../assets/images/视频面板bg.png') no-repeat center center;
|
background: url('../../assets/images/视频面板bg.png') no-repeat center center;
|
||||||
background-size: cover;
|
background-size: 100% 100%;
|
||||||
|
padding: 2px;
|
||||||
// border-radius: vw(8);
|
// border-radius: vw(8);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
@ -153,48 +179,82 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.video-time {
|
.video-time {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
// top: vh(8);
|
top: -1px;
|
||||||
// left: vw(8);
|
left: -3px;
|
||||||
padding: vh(4) vw(8);
|
z-index: 2;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
width: clamp(140px, 10.1vw, 194px);
|
||||||
border-radius: vw(4);
|
height: clamp(28px, vh(32), 36px);
|
||||||
|
background: url('../../assets/images/video-time-bg.png') no-repeat center center;
|
||||||
|
background-size: 100% 100%;
|
||||||
color: var(--text-white);
|
color: var(--text-white);
|
||||||
font-size: fs(12);
|
font-size: clamp(10px, fs(12), 12px);
|
||||||
font-family: monospace;
|
// font-family: 'PingFangSC-Semibold', 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, 'Microsoft YaHei', sans-serif;
|
||||||
}
|
// font-weight: 600;
|
||||||
}
|
|
||||||
|
|
||||||
.video-controls {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
// gap: vw(16);
|
|
||||||
|
|
||||||
.control-btn {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: vw(6);
|
justify-content: flex-start;
|
||||||
padding: vh(6) vw(12);
|
padding: 0 clamp(4px, 1vw, 8px);
|
||||||
background: transparent;
|
box-sizing: border-box;
|
||||||
border: 1px solid var(--border-color);
|
}
|
||||||
border-radius: vw(4);
|
// 控制条:叠加在视频底部
|
||||||
color: var(--text-white);
|
.video-controls {
|
||||||
font-size: fs(12);
|
position: absolute;
|
||||||
font-family: SourceHanSansCN-Regular, sans-serif;
|
left: 0;
|
||||||
cursor: pointer;
|
right: 0;
|
||||||
transition: all 0.3s;
|
bottom: 0;
|
||||||
|
z-index: 1;
|
||||||
|
// height: clamp(40px, 8vh, 60px);
|
||||||
|
background: rgba(10, 31, 57, 0.84);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 clamp(10px, 2vw, 20px);
|
||||||
|
// gap: clamp(12px, 1.5vw, 20px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
img {
|
.video-control-item {
|
||||||
width: vw(14);
|
display: inline-flex;
|
||||||
height: vh(14);
|
flex-direction: row; // 保持左图标右文字布局
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: clamp(2px, vh(6), 8px) clamp(3px, vw(6), 6px);
|
||||||
|
background: transparent;
|
||||||
|
// border: 1px solid rgba(135, 206, 250, 0.3);
|
||||||
|
// border-radius: vw(4);
|
||||||
|
color: var(--text-white);
|
||||||
|
font-size: clamp(10px, fs(10), 11px);
|
||||||
|
// font-family: SourceHanSansCN-Regular, sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease-out, background 0.2s ease, border-color 0.2s ease;
|
||||||
|
transform-origin: center;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: clamp(12px, vw(14), 18px);
|
||||||
|
height: clamp(12px, vh(14), 18px);
|
||||||
|
margin-right: clamp(4px, vw(6), 8px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--primary-light);
|
transform: scale(1.05);
|
||||||
border-color: var(--primary-color);
|
background: rgba(135, 206, 250, 0.1);
|
||||||
|
border-color: rgba(135, 206, 250, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid rgba(135, 206, 250, 0.6);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,185 @@
|
|||||||
|
<template>
|
||||||
|
<div class="decorative-panel" :class="customClass" :style="customStyle">
|
||||||
|
<!-- 四个角的装饰图片 -->
|
||||||
|
<img
|
||||||
|
v-if="showCorners"
|
||||||
|
class="decorative-panel__corner decorative-panel__corner--top-left"
|
||||||
|
:src="topLeftImg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-if="showCorners"
|
||||||
|
class="decorative-panel__corner decorative-panel__corner--top-right"
|
||||||
|
:src="topRightImg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-if="showCorners"
|
||||||
|
class="decorative-panel__corner decorative-panel__corner--bottom-left"
|
||||||
|
:src="bottomLeftImg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-if="showCorners"
|
||||||
|
class="decorative-panel__corner decorative-panel__corner--bottom-right"
|
||||||
|
:src="bottomRightImg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 主内容插槽 -->
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
// 导入装饰图片资源
|
||||||
|
import topLeftImg from '../../assets/images/Tooltip/topLeft.png'
|
||||||
|
import topRightImg from '../../assets/images/Tooltip/topRight.png'
|
||||||
|
import bottomLeftImg from '../../assets/images/Tooltip/bottomLeft.png'
|
||||||
|
import bottomRightImg from '../../assets/images/Tooltip/bottomRight.png'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用装饰面板组件
|
||||||
|
*
|
||||||
|
* 用于为弹窗、卡片等容器添加四角装饰效果
|
||||||
|
* 支持自定义角装饰尺寸、偏移量、背景等
|
||||||
|
*/
|
||||||
|
const props = defineProps({
|
||||||
|
/**
|
||||||
|
* 自定义 CSS 类名
|
||||||
|
* 用于扩展样式
|
||||||
|
*/
|
||||||
|
customClass: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否显示四角装饰
|
||||||
|
*/
|
||||||
|
showCorners: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角装饰的尺寸(像素或 CSS 单位字符串)
|
||||||
|
* 例如:'30px', 'clamp(20px, 2vw, 40px)'
|
||||||
|
*/
|
||||||
|
cornerSize: {
|
||||||
|
type: String,
|
||||||
|
default: 'clamp(30px, 3vw, 50px)'
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角装饰的偏移量(百分比)
|
||||||
|
* 控制装饰探出容器边框的程度
|
||||||
|
* 例如:-25 表示向外偏移 25%(装饰探出边框)
|
||||||
|
*/
|
||||||
|
cornerOffset: {
|
||||||
|
type: Number,
|
||||||
|
default: -25
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 背景图片路径
|
||||||
|
* 如果提供,将使用图片背景;否则使用透明背景
|
||||||
|
*/
|
||||||
|
backgroundImage: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 背景颜色
|
||||||
|
* 可以与背景图片叠加使用
|
||||||
|
*/
|
||||||
|
backgroundColor: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算容器的内联样式
|
||||||
|
*/
|
||||||
|
const customStyle = computed(() => {
|
||||||
|
const style = {}
|
||||||
|
|
||||||
|
// 设置背景
|
||||||
|
if (props.backgroundImage && props.backgroundColor) {
|
||||||
|
style.background = `url('${props.backgroundImage}') no-repeat center center, ${props.backgroundColor}`
|
||||||
|
style.backgroundSize = 'cover'
|
||||||
|
} else if (props.backgroundImage) {
|
||||||
|
style.background = `url('${props.backgroundImage}') no-repeat center center`
|
||||||
|
style.backgroundSize = 'cover'
|
||||||
|
} else if (props.backgroundColor) {
|
||||||
|
style.backgroundColor = props.backgroundColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 CSS 变量供子元素使用
|
||||||
|
style['--corner-size'] = props.cornerSize
|
||||||
|
style['--corner-offset'] = `${props.cornerOffset}%`
|
||||||
|
|
||||||
|
return style
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use '@/styles/mixins.scss' as *;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 装饰面板容器
|
||||||
|
* 作为一个相对定位的容器,让角装饰可以绝对定位
|
||||||
|
*/
|
||||||
|
.decorative-panel {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 四角装饰图片
|
||||||
|
* 使用 CSS 变量控制尺寸和偏移,方便通过 props 自定义
|
||||||
|
*/
|
||||||
|
.decorative-panel__corner {
|
||||||
|
position: absolute;
|
||||||
|
// width: var(--corner-size, clamp(30px, 3vw, 50px));
|
||||||
|
// height: var(--corner-size, clamp(30px, 3vw, 50px));
|
||||||
|
pointer-events: none; // 装饰元素不响应鼠标事件
|
||||||
|
z-index: 10; // 确保显示在内容之上
|
||||||
|
object-fit: contain;
|
||||||
|
|
||||||
|
// 左上角
|
||||||
|
&--top-left {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
// transform: translate(var(--corner-offset, -25%), var(--corner-offset, -25%));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右上角
|
||||||
|
&--top-right {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
// transform: translate(calc(-1 * var(--corner-offset, -25%)), var(--corner-offset, -25%));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左下角
|
||||||
|
&--bottom-left {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
// transform: translate(var(--corner-offset, -25%), calc(-1 * var(--corner-offset, -25%)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右下角
|
||||||
|
&--bottom-right {
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
// transform: translate(calc(-1 * var(--corner-offset, -25%)), calc(-1 * var(--corner-offset, -25%)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,455 @@
|
|||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
地图标记点提示框组件
|
||||||
|
|
||||||
|
用于在地图上以屏幕坐标锚定显示的轻量级信息提示框。
|
||||||
|
父组件负责:
|
||||||
|
- 将地图实体的经纬度转换为屏幕坐标 (x, y)
|
||||||
|
- 通过 visible prop 控制显示/隐藏
|
||||||
|
|
||||||
|
本组件专注于:
|
||||||
|
- 提供视觉外壳(背景、四角装饰、标题区、关闭按钮)
|
||||||
|
- 淡入淡出动画效果
|
||||||
|
- 通过插槽支持灵活的内容定制
|
||||||
|
-->
|
||||||
|
<transition name="tooltip-fade">
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="map-tooltip"
|
||||||
|
:style="wrapperStyle"
|
||||||
|
role="dialog"
|
||||||
|
:aria-label="title || '地图标记提示框'"
|
||||||
|
>
|
||||||
|
<div class="map-tooltip__panel">
|
||||||
|
<!-- 四个角的装饰图片 -->
|
||||||
|
<img
|
||||||
|
class="map-tooltip__corner map-tooltip__corner--top-left"
|
||||||
|
:src="topLeftImg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
class="map-tooltip__corner map-tooltip__corner--top-right"
|
||||||
|
:src="topRightImg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
class="map-tooltip__corner map-tooltip__corner--bottom-left"
|
||||||
|
:src="bottomLeftImg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
class="map-tooltip__corner map-tooltip__corner--bottom-right"
|
||||||
|
:src="bottomRightImg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 主体背景和内容区域 -->
|
||||||
|
<div class="map-tooltip__background">
|
||||||
|
<!-- 标题区域 -->
|
||||||
|
<div class="map-tooltip__header">
|
||||||
|
<div class="map-tooltip__header-main">
|
||||||
|
<!--
|
||||||
|
标题插槽优先级高于 props
|
||||||
|
默认展示:icon + title
|
||||||
|
可通过具名插槽 #title 完全自定义
|
||||||
|
-->
|
||||||
|
<slot name="title">
|
||||||
|
<div class="map-tooltip__title-inner">
|
||||||
|
<span
|
||||||
|
v-if="title"
|
||||||
|
class="map-tooltip__title-text"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 关闭按钮 -->
|
||||||
|
<button
|
||||||
|
v-if="closable"
|
||||||
|
type="button"
|
||||||
|
class="map-tooltip__close-btn"
|
||||||
|
aria-label="关闭提示框"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
<span class="map-tooltip__close-icon" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标题下方的分割线 -->
|
||||||
|
<div class="map-tooltip__title-divider">
|
||||||
|
<img
|
||||||
|
:src="titleSpliteImg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区域:完全由插槽控制 -->
|
||||||
|
<div class="map-tooltip__content">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
// 导入装饰图片资源
|
||||||
|
import topLeftImg from '../../assets/images/Tooltip/topLeft.png'
|
||||||
|
import topRightImg from '../../assets/images/Tooltip/topRight.png'
|
||||||
|
import bottomLeftImg from '../../assets/images/Tooltip/bottomLeft.png'
|
||||||
|
import bottomRightImg from '../../assets/images/Tooltip/bottomRight.png'
|
||||||
|
import titleSpliteImg from '../../assets/images/Tooltip/titleSplite.png'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props 定义
|
||||||
|
*/
|
||||||
|
const props = defineProps({
|
||||||
|
/**
|
||||||
|
* 控制提示框的显示/隐藏
|
||||||
|
* 支持 v-model:visible 双向绑定
|
||||||
|
*/
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 屏幕 X 坐标(像素),相对于地图容器左上角
|
||||||
|
* 提示框将以底部中心点锚定到此坐标
|
||||||
|
*/
|
||||||
|
x: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 屏幕 Y 坐标(像素),相对于地图容器左上角
|
||||||
|
* 提示框将以底部中心点锚定到此坐标
|
||||||
|
*/
|
||||||
|
y: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X 轴偏移量(像素)
|
||||||
|
* 用于微调提示框相对于标记点的位置
|
||||||
|
*/
|
||||||
|
offsetX: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Y 轴偏移量(像素)
|
||||||
|
* 默认向上偏移 16px,使提示框稍微悬浮在标记点上方
|
||||||
|
*/
|
||||||
|
offsetY: {
|
||||||
|
type: Number,
|
||||||
|
default: -16
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标题文本
|
||||||
|
* 当使用 #title 插槽时,此 prop 会被忽略
|
||||||
|
*/
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标题左侧图标的图片路径
|
||||||
|
* 通常由父组件通过 import 引入后传递
|
||||||
|
*/
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提示框的 z-index 值
|
||||||
|
* 确保提示框显示在地图图层之上,但低于全屏弹窗
|
||||||
|
*/
|
||||||
|
zIndex: {
|
||||||
|
type: Number,
|
||||||
|
default: 20
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否显示关闭按钮
|
||||||
|
*/
|
||||||
|
closable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events 定义
|
||||||
|
*/
|
||||||
|
const emit = defineEmits([
|
||||||
|
/**
|
||||||
|
* 用户点击关闭按钮时触发
|
||||||
|
*/
|
||||||
|
'close',
|
||||||
|
/**
|
||||||
|
* 支持 v-model:visible 双向绑定
|
||||||
|
*/
|
||||||
|
'update:visible'
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算提示框的定位样式
|
||||||
|
* 使用 transform: translate(-50%, -100%) 实现底部中心点锚定
|
||||||
|
*/
|
||||||
|
const wrapperStyle = computed(() => {
|
||||||
|
const left = props.x + props.offsetX
|
||||||
|
const top = props.y + props.offsetY
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: `${left}px`,
|
||||||
|
top: `${top}px`,
|
||||||
|
zIndex: props.zIndex
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理关闭操作
|
||||||
|
* 同时触发 close 事件和 update:visible 事件
|
||||||
|
*/
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close')
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use '@/styles/mixins.scss' as *;
|
||||||
|
@use '../../assets/styles/common.scss' as *;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提示框根容器
|
||||||
|
* 使用绝对定位 + transform 实现锚定效果
|
||||||
|
*/
|
||||||
|
.map-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
// 底部中心点对齐到 (x, y) 坐标
|
||||||
|
transform: translate(-50%, -100%);
|
||||||
|
// 确保提示框可交互(覆盖父容器可能的 pointer-events: none)
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部面板容器
|
||||||
|
* 用于控制提示框宽度和缩放动画
|
||||||
|
*/
|
||||||
|
.map-tooltip__panel {
|
||||||
|
position: relative;
|
||||||
|
width: vw(320);
|
||||||
|
// 确保四角装饰不会被裁剪
|
||||||
|
// padding: vw(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主体背景区域
|
||||||
|
* 使用设计提供的背景图
|
||||||
|
*/
|
||||||
|
.map-tooltip__background {
|
||||||
|
position: relative;
|
||||||
|
padding: vh(14) vw(18);
|
||||||
|
background: url('../../assets/images/Tooltip/tooltipBg.png') no-repeat;
|
||||||
|
background-size: 100% 100%;
|
||||||
|
border-radius: vw(8);
|
||||||
|
box-shadow: 0 vw(8) vw(24) rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 四角装饰图片
|
||||||
|
* 使用绝对定位 + transform 贴在主体背景的四个角
|
||||||
|
*/
|
||||||
|
.map-tooltip__corner {
|
||||||
|
position: absolute;
|
||||||
|
width: vw(20);
|
||||||
|
height: vw(20); // 使用 vw 保持正方形
|
||||||
|
pointer-events: none; // 装饰元素不响应鼠标事件
|
||||||
|
z-index: 1; // 确保显示在背景之上
|
||||||
|
|
||||||
|
&--top-left {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
// transform: translate(-30%, -30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--top-right {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
// transform: translate(30%, -30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--bottom-left {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
// transform: translate(-30%, 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--bottom-right {
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
// transform: translate(30%, 30%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标题区域
|
||||||
|
* 包含标题内容和关闭按钮
|
||||||
|
*/
|
||||||
|
.map-tooltip__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: vw(12);
|
||||||
|
min-height: vh(24); // 确保即使没有标题也有最小高度
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-tooltip__header-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0; // 允许文本溢出处理
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-tooltip__title-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: vw(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-tooltip__icon {
|
||||||
|
width: vw(20);
|
||||||
|
height: vw(20); // 使用 vw 保持正方形
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-tooltip__title-text {
|
||||||
|
color: var(--text-white);
|
||||||
|
font-size: fs(16);
|
||||||
|
font-family: SourceHanSansCN-Bold, sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭按钮
|
||||||
|
* 使用纯 CSS 实现 X 图标,避免额外的图片资源
|
||||||
|
*/
|
||||||
|
.map-tooltip__close-btn {
|
||||||
|
width: vw(24);
|
||||||
|
height: vw(24); // 使用 vw 保持正方形
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.9) rotate(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭按钮的 X 图标
|
||||||
|
* 使用伪元素实现两条交叉的线
|
||||||
|
*/
|
||||||
|
.map-tooltip__close-icon {
|
||||||
|
position: relative;
|
||||||
|
width: 60%;
|
||||||
|
height: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-tooltip__close-icon::before,
|
||||||
|
.map-tooltip__close-icon::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 100%;
|
||||||
|
height: vw(2);
|
||||||
|
background-color: var(--text-white);
|
||||||
|
transform-origin: center;
|
||||||
|
border-radius: vw(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-tooltip__close-icon::before {
|
||||||
|
transform: translate(-50%, -50%) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-tooltip__close-icon::after {
|
||||||
|
transform: translate(-50%, -50%) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标题下方的分割线
|
||||||
|
*/
|
||||||
|
.map-tooltip__title-divider {
|
||||||
|
margin: vh(8) 0 vh(12);
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: vh(2);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内容区域
|
||||||
|
* 完全由插槽控制,这里只提供基础布局
|
||||||
|
*/
|
||||||
|
.map-tooltip__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: vh(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 淡入淡出动画
|
||||||
|
* 结合缩放效果,提升视觉体验
|
||||||
|
*/
|
||||||
|
.tooltip-fade-enter-active,
|
||||||
|
.tooltip-fade-leave-active {
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
|
||||||
|
.map-tooltip__panel {
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-fade-enter-from,
|
||||||
|
.tooltip-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
.map-tooltip__panel {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,176 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import * as Cesium from 'cesium'
|
||||||
|
import { getModelCompareConfig } from '../config/modelCompare.config'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3D Tiles加载管理 Composable
|
||||||
|
* 负责加载灾前/灾后3D模型数据,并支持分屏对比功能
|
||||||
|
*/
|
||||||
|
export function use3DTiles() {
|
||||||
|
const beforeTileset = ref(null)
|
||||||
|
const afterTileset = ref(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载3D Tileset
|
||||||
|
* @param {Cesium.Viewer} viewer - Cesium Viewer 实例
|
||||||
|
* @param {string} sceneType - 场景类型:'before' 或 'after'
|
||||||
|
* @param {boolean} autoZoom - 是否自动缩放到模型(默认false,保持用户设置的相机位置)
|
||||||
|
* @param {Cesium.SplitDirection} splitDirection - 分割方向(默认为 NONE)
|
||||||
|
*/
|
||||||
|
const load3DTileset = async (viewer, sceneType = 'after', autoZoom = false, splitDirection = Cesium.SplitDirection.NONE) => {
|
||||||
|
if (!viewer) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[use3DTiles] 正在加载${sceneType === 'after' ? '灾后' : '灾前'}3D模型...`)
|
||||||
|
|
||||||
|
// 从配置中获取 URL
|
||||||
|
const config = getModelCompareConfig()
|
||||||
|
const tilesetConfig = sceneType === 'after' ? config.after3DTiles : config.before3DTiles
|
||||||
|
const url = tilesetConfig.url
|
||||||
|
|
||||||
|
const tileset = await Cesium.Cesium3DTileset.fromUrl(url, {
|
||||||
|
skipLevelOfDetail: true,
|
||||||
|
baseScreenSpaceError: 100,
|
||||||
|
skipScreenSpaceErrorFactor: 16,
|
||||||
|
skipLevels: 1,
|
||||||
|
immediatelyLoadDesiredLevelOfDetail: false,
|
||||||
|
loadSiblings: false,
|
||||||
|
maximumScreenSpaceError: 16.0, // 进一步增大,最大限度减少瓦片细化(之前是8.0)
|
||||||
|
dynamicScreenSpaceError: false, // 禁用动态屏幕空间误差调整
|
||||||
|
dynamicScreenSpaceErrorDensity: 0, // 禁用密度调整
|
||||||
|
dynamicScreenSpaceErrorFactor: 1, // 禁用动态因子
|
||||||
|
foveatedScreenSpaceError: false, // 禁用视锥细化
|
||||||
|
foveatedConeSize: 0.1, // 减小视锥大小
|
||||||
|
foveatedMinimumScreenSpaceErrorRelaxation: 0 // 禁用放松
|
||||||
|
})
|
||||||
|
|
||||||
|
// 将tileset添加到viewer的primitives
|
||||||
|
viewer.scene.primitives.add(tileset)
|
||||||
|
|
||||||
|
// 设置splitDirection(用于对比模式)
|
||||||
|
tileset.splitDirection = splitDirection
|
||||||
|
|
||||||
|
if (sceneType === 'after') {
|
||||||
|
afterTileset.value = tileset
|
||||||
|
} else {
|
||||||
|
beforeTileset.value = tileset
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[use3DTiles] ${sceneType === 'after' ? '灾后' : '灾前'}3D模型加载成功,splitDirection: ${splitDirection}`)
|
||||||
|
|
||||||
|
// 只有明确要求时才自动缩放到tileset
|
||||||
|
if (autoZoom) {
|
||||||
|
await viewer.zoomTo(tileset)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tileset
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[use3DTiles] 加载${sceneType === 'after' ? '灾后' : '灾前'}3D模型失败:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待 Tileset 完全就绪(包括首批瓦片加载)
|
||||||
|
*
|
||||||
|
* 这个函数确保:
|
||||||
|
* 1. Tileset 本身已就绪(readyPromise)
|
||||||
|
* 2. 初始视图的所有瓦片已加载完成(initialTilesLoaded)
|
||||||
|
*
|
||||||
|
* @param {Cesium.Cesium3DTileset} tileset - 要等待的 Tileset
|
||||||
|
* @param {number} timeout - 超时时间(毫秒),默认10秒
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const waitForTilesetReady = async (tileset, timeout = 10000) => {
|
||||||
|
if (!tileset) {
|
||||||
|
console.warn('[use3DTiles] waitForTilesetReady: tileset 为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 步骤1:等待 Tileset 基础就绪
|
||||||
|
await tileset.readyPromise
|
||||||
|
console.log('[use3DTiles] Tileset readyPromise 已完成')
|
||||||
|
|
||||||
|
// 步骤2:等待初始瓦片加载完成(带超时)
|
||||||
|
console.log('[use3DTiles] 等待初始瓦片加载...')
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
// 等待initialTilesLoaded事件
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const handleInitialTilesLoaded = () => {
|
||||||
|
console.log('[use3DTiles] 初始瓦片加载完成(事件触发)')
|
||||||
|
tileset.initialTilesLoaded.removeEventListener(handleInitialTilesLoaded)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
tileset.initialTilesLoaded.addEventListener(handleInitialTilesLoaded)
|
||||||
|
|
||||||
|
// 如果已经加载完成,可能事件已经触发过了,直接resolve
|
||||||
|
// 通过检查tileset.tilesLoaded来判断
|
||||||
|
if (tileset.tilesLoaded) {
|
||||||
|
console.log('[use3DTiles] 瓦片已加载,直接继续')
|
||||||
|
tileset.initialTilesLoaded.removeEventListener(handleInitialTilesLoaded)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
// 超时机制
|
||||||
|
new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
console.warn(`[use3DTiles] 等待瓦片加载超时(${timeout}ms),继续执行`)
|
||||||
|
resolve()
|
||||||
|
}, timeout)
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log('[use3DTiles] Tileset 已完全就绪')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[use3DTiles] 等待 Tileset 就绪失败:', error)
|
||||||
|
// 即使失败也不抛出异常,允许程序继续执行
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除3D Tileset
|
||||||
|
*/
|
||||||
|
const remove3DTileset = (viewer, sceneType) => {
|
||||||
|
if (!viewer) return
|
||||||
|
|
||||||
|
const tileset = sceneType === 'after' ? afterTileset.value : beforeTileset.value
|
||||||
|
if (tileset) {
|
||||||
|
viewer.scene.primitives.remove(tileset)
|
||||||
|
if (sceneType === 'after') {
|
||||||
|
afterTileset.value = null
|
||||||
|
} else {
|
||||||
|
beforeTileset.value = null
|
||||||
|
}
|
||||||
|
console.log(`[use3DTiles] ${sceneType === 'after' ? '灾后' : '灾前'}3D模型已移除`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新 Tileset 的 splitDirection
|
||||||
|
* @param {string} sceneType - 场景类型:'before' 或 'after'
|
||||||
|
* @param {Cesium.SplitDirection} splitDirection - 新的分割方向
|
||||||
|
*/
|
||||||
|
const updateTilesetSplitDirection = (sceneType, splitDirection) => {
|
||||||
|
const tileset = sceneType === 'after' ? afterTileset.value : beforeTileset.value
|
||||||
|
if (tileset) {
|
||||||
|
tileset.splitDirection = splitDirection
|
||||||
|
console.log(`[use3DTiles] ${sceneType === 'after' ? '灾后' : '灾前'}3D模型 splitDirection 已更新为: ${splitDirection}`)
|
||||||
|
} else {
|
||||||
|
console.warn(`[use3DTiles] ${sceneType === 'after' ? '灾后' : '灾前'}3D模型不存在,无法更新 splitDirection`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
beforeTileset,
|
||||||
|
afterTileset,
|
||||||
|
load3DTileset,
|
||||||
|
waitForTilesetReady,
|
||||||
|
remove3DTileset,
|
||||||
|
updateTilesetSplitDirection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default use3DTiles
|
||||||
@ -74,14 +74,30 @@ export function useDisasterData() {
|
|||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
// 调度力量建议
|
// 调度力量建议(根据力量预置数据动态计算)
|
||||||
const dispatchSuggestion = ref({
|
const dispatchSuggestion = computed(() => {
|
||||||
supplies: 23,
|
// 根据实际资源计算建议调度力量
|
||||||
personnel: 124,
|
const stationsCount = forcePreset.value.stations?.length || 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 应急物资建议数:基于装备数量
|
||||||
|
supplies: forcePreset.value.equipment,
|
||||||
|
|
||||||
|
// 应急人员建议数:取总人员的一部分(约5-10%)作为调度建议
|
||||||
|
personnel: Math.min(Math.ceil(forcePreset.value.personnel * 0.06), forcePreset.value.personnel),
|
||||||
|
|
||||||
|
// 养护站建议数:使用实际可用养护站数量
|
||||||
|
stations: stationsCount,
|
||||||
|
|
||||||
|
// 挖掘机建议数:每2个养护站配置1台挖掘机
|
||||||
|
excavators: Math.max(1, Math.ceil(stationsCount / 2)),
|
||||||
|
|
||||||
|
// 阻断信息:固定建议
|
||||||
blockInfo: '需发布',
|
blockInfo: '需发布',
|
||||||
stations: 4,
|
|
||||||
excavators: 2,
|
// 交通管制:固定建议
|
||||||
trafficControl: '需要'
|
trafficControl: '需要'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
@ -93,12 +109,68 @@ export function useDisasterData() {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新搜索半径
|
||||||
|
* @param {number} radius - 新的搜索半径(km)
|
||||||
|
*/
|
||||||
|
const updateSearchRadius = (radius) => {
|
||||||
|
if (typeof radius === 'number' && radius > 0) {
|
||||||
|
forcePreset.value.searchRadius = radius
|
||||||
|
console.log(`[useDisasterData] 搜索半径已更新为: ${radius}km`)
|
||||||
|
} else {
|
||||||
|
console.warn('[useDisasterData] 无效的搜索半径值:', radius)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新力量预置数据
|
||||||
|
* @param {Object} emergencyResourcesData - 接口返回的应急资源数据
|
||||||
|
* @param {number} emergencyResourcesData.equipmentCount - 应急装备数量
|
||||||
|
* @param {number} emergencyResourcesData.personnelCount - 应急人员数量
|
||||||
|
* @param {Array} emergencyResourcesData.stations - 养护站列表
|
||||||
|
* @param {string} emergencyResourcesData.stations[].stationId - 养护站ID
|
||||||
|
* @param {string} emergencyResourcesData.stations[].stationName - 养护站名称
|
||||||
|
* @param {number} emergencyResourcesData.stations[].distance - 距离(km)
|
||||||
|
*/
|
||||||
|
const updateForcePreset = (emergencyResourcesData) => {
|
||||||
|
if (!emergencyResourcesData) {
|
||||||
|
console.warn('[useDisasterData] 应急资源数据为空,跳过更新')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { equipmentCount, personnelCount, stations } = emergencyResourcesData
|
||||||
|
|
||||||
|
// 更新装备和人员数量
|
||||||
|
if (typeof equipmentCount === 'number') {
|
||||||
|
forcePreset.value.equipment = equipmentCount
|
||||||
|
}
|
||||||
|
if (typeof personnelCount === 'number') {
|
||||||
|
forcePreset.value.personnel = personnelCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新养护站列表
|
||||||
|
if (Array.isArray(stations)) {
|
||||||
|
forcePreset.value.stations = stations.map((station) => ({
|
||||||
|
id: station.stationId,
|
||||||
|
name: station.stationName?.trim() || '未命名养护站',
|
||||||
|
distance: typeof station.distance === 'number'
|
||||||
|
? Number(station.distance.toFixed(2))
|
||||||
|
: 0,
|
||||||
|
type: 'maintenance'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[useDisasterData] 力量预置数据已更新:', forcePreset.value)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
disasterInfo,
|
disasterInfo,
|
||||||
forcePreset,
|
forcePreset,
|
||||||
forceDispatch,
|
forceDispatch,
|
||||||
collaborationInfo,
|
collaborationInfo,
|
||||||
dispatchSuggestion,
|
dispatchSuggestion,
|
||||||
totalResources
|
totalResources,
|
||||||
|
updateForcePreset,
|
||||||
|
updateSearchRadius
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,258 @@
|
|||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
import * as Cesium from 'cesium'
|
||||||
|
import { use3DTiles } from './use3DTiles'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 双地图对比模式
|
||||||
|
* 使用两个独立的Cesium Viewer实现并排对比
|
||||||
|
* 左侧显示灾前场景,右侧显示灾后场景
|
||||||
|
*/
|
||||||
|
export function useDualMapCompare() {
|
||||||
|
/** 左侧Viewer引用 */
|
||||||
|
const leftViewer = ref(null)
|
||||||
|
|
||||||
|
/** 右侧Viewer引用 */
|
||||||
|
const rightViewer = ref(null)
|
||||||
|
|
||||||
|
/** 对比模式是否激活 */
|
||||||
|
const isCompareMode = ref(false)
|
||||||
|
|
||||||
|
/** 相机同步监听器移除函数 */
|
||||||
|
let cameraSyncRemover = null
|
||||||
|
|
||||||
|
/** 左侧3D Tileset(灾前) */
|
||||||
|
let leftTileset = null
|
||||||
|
|
||||||
|
/** 右侧3D Tileset(灾后,主地图的tileset) */
|
||||||
|
let rightTileset = null
|
||||||
|
|
||||||
|
const { load3DTileset } = use3DTiles()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化左侧Viewer(灾前场景)
|
||||||
|
* @param {HTMLElement} container - 容器元素
|
||||||
|
* @returns {Cesium.Viewer}
|
||||||
|
*/
|
||||||
|
const initLeftViewer = (container) => {
|
||||||
|
if (!container) {
|
||||||
|
console.error('[useDualMapCompare] 左侧容器元素不存在')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建左侧viewer
|
||||||
|
const viewer = new Cesium.Viewer(container, {
|
||||||
|
animation: false,
|
||||||
|
baseLayerPicker: false,
|
||||||
|
fullscreenButton: false,
|
||||||
|
geocoder: false,
|
||||||
|
homeButton: false,
|
||||||
|
infoBox: false,
|
||||||
|
sceneModePicker: false,
|
||||||
|
selectionIndicator: false,
|
||||||
|
timeline: false,
|
||||||
|
navigationHelpButton: false,
|
||||||
|
scene3DOnly: true,
|
||||||
|
shouldAnimate: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 移除默认的Cesium logo和版权信息
|
||||||
|
viewer.cesiumWidget.creditContainer.style.display = 'none'
|
||||||
|
|
||||||
|
leftViewer.value = viewer
|
||||||
|
console.log('[useDualMapCompare] 左侧Viewer初始化成功')
|
||||||
|
|
||||||
|
return viewer
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置相机同步
|
||||||
|
* 右侧相机移动时,左侧相机跟随
|
||||||
|
* @param {Cesium.Viewer} rightViewerInstance - 右侧Viewer(主地图)
|
||||||
|
* @param {Cesium.Viewer} leftViewerInstance - 左侧Viewer(对比地图)
|
||||||
|
*/
|
||||||
|
const setupCameraSync = (rightViewerInstance, leftViewerInstance) => {
|
||||||
|
if (!rightViewerInstance || !leftViewerInstance) {
|
||||||
|
console.warn('[useDualMapCompare] Viewer未初始化,无法设置相机同步')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[useDualMapCompare] 设置相机同步(右侧主地图 → 左侧对比地图)...')
|
||||||
|
|
||||||
|
// 监听右侧相机变化
|
||||||
|
const syncCamera = () => {
|
||||||
|
if (!leftViewerInstance || leftViewerInstance.isDestroyed()) return
|
||||||
|
|
||||||
|
const rightCamera = rightViewerInstance.camera
|
||||||
|
const leftCamera = leftViewerInstance.camera
|
||||||
|
|
||||||
|
// 同步位置和方向
|
||||||
|
leftCamera.setView({
|
||||||
|
destination: rightCamera.position.clone(),
|
||||||
|
orientation: {
|
||||||
|
heading: rightCamera.heading,
|
||||||
|
pitch: rightCamera.pitch,
|
||||||
|
roll: rightCamera.roll
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加监听器
|
||||||
|
rightViewerInstance.camera.changed.addEventListener(syncCamera)
|
||||||
|
|
||||||
|
// 保存移除函数
|
||||||
|
cameraSyncRemover = () => {
|
||||||
|
rightViewerInstance.camera.changed.removeEventListener(syncCamera)
|
||||||
|
console.log('[useDualMapCompare] 相机同步已移除')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[useDualMapCompare] 相机同步设置完成')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用对比模式
|
||||||
|
* @param {Cesium.Viewer} rightViewerInstance - 右侧Viewer实例(主地图,灾后场景)
|
||||||
|
*/
|
||||||
|
const enableCompareMode = async (rightViewerInstance) => {
|
||||||
|
if (!rightViewerInstance) {
|
||||||
|
console.error('[useDualMapCompare] 右侧主地图Viewer未初始化')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[useDualMapCompare] 启用对比模式...')
|
||||||
|
|
||||||
|
rightViewer.value = rightViewerInstance
|
||||||
|
|
||||||
|
// 查找左侧容器(容器已存在于DOM中)
|
||||||
|
const leftContainer = document.getElementById('leftCesiumContainer')
|
||||||
|
if (!leftContainer) {
|
||||||
|
console.error('[useDualMapCompare] 找不到左侧容器元素')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先设置状态,触发CSS动画
|
||||||
|
isCompareMode.value = true
|
||||||
|
|
||||||
|
// 等待一小段时间让CSS过渡开始
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
// 初始化左侧Viewer
|
||||||
|
const leftViewerInstance = initLeftViewer(leftContainer)
|
||||||
|
if (!leftViewerInstance) {
|
||||||
|
console.error('[useDualMapCompare] 左侧Viewer初始化失败')
|
||||||
|
isCompareMode.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即同步右侧相机的当前位置到左侧
|
||||||
|
console.log('[useDualMapCompare] 同步初始相机位置...')
|
||||||
|
const rightCamera = rightViewerInstance.camera
|
||||||
|
leftViewerInstance.camera.setView({
|
||||||
|
destination: rightCamera.position.clone(),
|
||||||
|
orientation: {
|
||||||
|
heading: rightCamera.heading,
|
||||||
|
pitch: rightCamera.pitch,
|
||||||
|
roll: rightCamera.roll
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置相机同步(右侧主地图 → 左侧对比地图)
|
||||||
|
setupCameraSync(rightViewerInstance, leftViewerInstance)
|
||||||
|
|
||||||
|
// 异步加载灾前模型到左侧(不阻塞对比模式启用)
|
||||||
|
// 让模型在后台加载,用户可以立即看到对比效果
|
||||||
|
console.log('[useDualMapCompare] 开始异步加载左侧灾前模型...')
|
||||||
|
load3DTileset(leftViewerInstance, 'before', false)
|
||||||
|
.then(tileset => {
|
||||||
|
if (tileset) {
|
||||||
|
leftTileset = tileset
|
||||||
|
console.log('[useDualMapCompare] 左侧灾前模型加载完成')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('[useDualMapCompare] 左侧模型加载失败:', error)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 右侧保持灾后模型(已加载)
|
||||||
|
|
||||||
|
// 触发左侧viewer resize
|
||||||
|
setTimeout(() => {
|
||||||
|
if (leftViewerInstance && leftViewerInstance.canvas) {
|
||||||
|
leftViewerInstance.resize()
|
||||||
|
leftViewerInstance.camera.changed.raiseEvent()
|
||||||
|
}
|
||||||
|
// 同时触发右侧viewer resize
|
||||||
|
if (rightViewerInstance && rightViewerInstance.canvas) {
|
||||||
|
rightViewerInstance.resize()
|
||||||
|
}
|
||||||
|
}, 350)
|
||||||
|
|
||||||
|
console.log('[useDualMapCompare] 对比模式已启用')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禁用对比模式
|
||||||
|
*/
|
||||||
|
const disableCompareMode = () => {
|
||||||
|
console.log('[useDualMapCompare] 禁用对比模式...')
|
||||||
|
|
||||||
|
// 移除相机同步
|
||||||
|
if (cameraSyncRemover) {
|
||||||
|
cameraSyncRemover()
|
||||||
|
cameraSyncRemover = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 销毁左侧Viewer
|
||||||
|
if (leftViewer.value && !leftViewer.value.isDestroyed()) {
|
||||||
|
// 清理左侧tileset
|
||||||
|
if (leftTileset) {
|
||||||
|
leftViewer.value.scene.primitives.remove(leftTileset)
|
||||||
|
leftTileset = null
|
||||||
|
}
|
||||||
|
|
||||||
|
leftViewer.value.destroy()
|
||||||
|
leftViewer.value = null
|
||||||
|
console.log('[useDualMapCompare] 左侧Viewer已销毁')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发右侧viewer resize恢复全屏
|
||||||
|
if (rightViewer.value && !rightViewer.value.isDestroyed()) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (rightViewer.value && rightViewer.value.canvas) {
|
||||||
|
rightViewer.value.resize()
|
||||||
|
}
|
||||||
|
}, 350)
|
||||||
|
}
|
||||||
|
|
||||||
|
isCompareMode.value = false
|
||||||
|
console.log('[useDualMapCompare] 对比模式已禁用')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换对比模式
|
||||||
|
* @param {boolean} active - true启用,false禁用
|
||||||
|
* @param {Cesium.Viewer} rightViewerInstance - 右侧Viewer实例(主地图)
|
||||||
|
*/
|
||||||
|
const toggleCompareMode = async (active, rightViewerInstance) => {
|
||||||
|
if (active) {
|
||||||
|
await enableCompareMode(rightViewerInstance)
|
||||||
|
} else {
|
||||||
|
disableCompareMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
onUnmounted(() => {
|
||||||
|
disableCompareMode()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
leftViewer,
|
||||||
|
rightViewer,
|
||||||
|
isCompareMode,
|
||||||
|
enableCompareMode,
|
||||||
|
disableCompareMode,
|
||||||
|
toggleCompareMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useDualMapCompare
|
||||||
@ -0,0 +1,236 @@
|
|||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
import * as Cesium from 'cesium'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 双 Viewer 管理 Composable
|
||||||
|
* 创建和管理两个独立的 Cesium Viewer,用于并排对比显示
|
||||||
|
*
|
||||||
|
* 参考:WuRenJi 项目的双地图实现
|
||||||
|
*/
|
||||||
|
export function useDualViewers() {
|
||||||
|
/** @type {import('vue').Ref<Cesium.Viewer | null>} */
|
||||||
|
const leftViewer = ref(null)
|
||||||
|
|
||||||
|
/** @type {import('vue').Ref<Cesium.Viewer | null>} */
|
||||||
|
const rightViewer = ref(null)
|
||||||
|
|
||||||
|
/** 是否启用相机同步 */
|
||||||
|
let cameraSyncEnabled = false
|
||||||
|
|
||||||
|
/** 相机同步监听器的移除函数 */
|
||||||
|
let removeCameraSync = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化左侧 Viewer
|
||||||
|
* @param {string} containerId - 容器 DOM ID
|
||||||
|
* @param {Object} options - Cesium.Viewer 配置选项
|
||||||
|
* @returns {Cesium.Viewer}
|
||||||
|
*/
|
||||||
|
const initLeftViewer = (containerId, options = {}) => {
|
||||||
|
if (leftViewer.value) {
|
||||||
|
console.warn('[useDualViewers] 左侧 Viewer 已存在')
|
||||||
|
return leftViewer.value
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[useDualViewers] 初始化左侧 Viewer...')
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
animation: false,
|
||||||
|
baseLayerPicker: false,
|
||||||
|
fullscreenButton: false,
|
||||||
|
geocoder: false,
|
||||||
|
homeButton: false,
|
||||||
|
infoBox: false,
|
||||||
|
sceneModePicker: false,
|
||||||
|
selectionIndicator: false,
|
||||||
|
timeline: false,
|
||||||
|
navigationHelpButton: false,
|
||||||
|
scene3DOnly: true,
|
||||||
|
requestRenderMode: true, // 启用按需渲染
|
||||||
|
maximumRenderTimeChange: Infinity
|
||||||
|
}
|
||||||
|
|
||||||
|
leftViewer.value = new Cesium.Viewer(containerId, {
|
||||||
|
...defaultOptions,
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[useDualViewers] 左侧 Viewer 初始化完成')
|
||||||
|
return leftViewer.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化右侧 Viewer
|
||||||
|
* @param {string} containerId - 容器 DOM ID
|
||||||
|
* @param {Object} options - Cesium.Viewer 配置选项
|
||||||
|
* @returns {Cesium.Viewer}
|
||||||
|
*/
|
||||||
|
const initRightViewer = (containerId, options = {}) => {
|
||||||
|
if (rightViewer.value) {
|
||||||
|
console.warn('[useDualViewers] 右侧 Viewer 已存在')
|
||||||
|
return rightViewer.value
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[useDualViewers] 初始化右侧 Viewer...')
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
animation: false,
|
||||||
|
baseLayerPicker: false,
|
||||||
|
fullscreenButton: false,
|
||||||
|
geocoder: false,
|
||||||
|
homeButton: false,
|
||||||
|
infoBox: false,
|
||||||
|
sceneModePicker: false,
|
||||||
|
selectionIndicator: false,
|
||||||
|
timeline: false,
|
||||||
|
navigationHelpButton: false,
|
||||||
|
scene3DOnly: true,
|
||||||
|
requestRenderMode: true, // 启用按需渲染
|
||||||
|
maximumRenderTimeChange: Infinity
|
||||||
|
}
|
||||||
|
|
||||||
|
rightViewer.value = new Cesium.Viewer(containerId, {
|
||||||
|
...defaultOptions,
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[useDualViewers] 右侧 Viewer 初始化完成')
|
||||||
|
return rightViewer.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置相机到指定位置
|
||||||
|
* @param {Cesium.Viewer} viewer
|
||||||
|
* @param {Object} view - 相机视图配置
|
||||||
|
*/
|
||||||
|
const setCameraView = (viewer, view) => {
|
||||||
|
if (!viewer) return
|
||||||
|
|
||||||
|
viewer.camera.setView({
|
||||||
|
destination: Cesium.Cartesian3.fromDegrees(
|
||||||
|
view.lon || 106.5516,
|
||||||
|
view.lat || 29.5630,
|
||||||
|
view.height || 5000
|
||||||
|
),
|
||||||
|
orientation: {
|
||||||
|
heading: Cesium.Math.toRadians(view.heading || 0),
|
||||||
|
pitch: Cesium.Math.toRadians(view.pitch || -45),
|
||||||
|
roll: view.roll || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用相机同步
|
||||||
|
* 左侧相机移动时,右侧相机自动跟随
|
||||||
|
*/
|
||||||
|
const enableCameraSync = () => {
|
||||||
|
if (!leftViewer.value || !rightViewer.value) {
|
||||||
|
console.warn('[useDualViewers] 无法启用相机同步:Viewer 未初始化')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cameraSyncEnabled) {
|
||||||
|
console.warn('[useDualViewers] 相机同步已启用')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[useDualViewers] 启用相机同步')
|
||||||
|
|
||||||
|
// 监听左侧相机变化
|
||||||
|
const syncHandler = () => {
|
||||||
|
if (!rightViewer.value) return
|
||||||
|
|
||||||
|
const leftCamera = leftViewer.value.camera
|
||||||
|
const rightCamera = rightViewer.value.camera
|
||||||
|
|
||||||
|
// 同步右侧相机到左侧相机位置
|
||||||
|
rightCamera.setView({
|
||||||
|
destination: leftCamera.position.clone(),
|
||||||
|
orientation: {
|
||||||
|
heading: leftCamera.heading,
|
||||||
|
pitch: leftCamera.pitch,
|
||||||
|
roll: leftCamera.roll
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
leftViewer.value.camera.changed.addEventListener(syncHandler)
|
||||||
|
cameraSyncEnabled = true
|
||||||
|
|
||||||
|
// 保存移除函数
|
||||||
|
removeCameraSync = () => {
|
||||||
|
if (leftViewer.value) {
|
||||||
|
leftViewer.value.camera.changed.removeEventListener(syncHandler)
|
||||||
|
}
|
||||||
|
cameraSyncEnabled = false
|
||||||
|
console.log('[useDualViewers] 相机同步已禁用')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禁用相机同步
|
||||||
|
*/
|
||||||
|
const disableCameraSync = () => {
|
||||||
|
if (removeCameraSync) {
|
||||||
|
removeCameraSync()
|
||||||
|
removeCameraSync = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调整 Viewer 大小(在容器尺寸变化时调用)
|
||||||
|
*/
|
||||||
|
const resizeViewers = () => {
|
||||||
|
if (leftViewer.value?.canvas) {
|
||||||
|
leftViewer.value.resize()
|
||||||
|
}
|
||||||
|
if (rightViewer.value?.canvas) {
|
||||||
|
rightViewer.value.resize()
|
||||||
|
// 触发相机变化事件,确保同步
|
||||||
|
if (cameraSyncEnabled) {
|
||||||
|
rightViewer.value.camera.changed.raiseEvent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁 Viewer
|
||||||
|
*/
|
||||||
|
const destroyViewers = () => {
|
||||||
|
console.log('[useDualViewers] 销毁 Viewers...')
|
||||||
|
|
||||||
|
disableCameraSync()
|
||||||
|
|
||||||
|
if (leftViewer.value) {
|
||||||
|
leftViewer.value.destroy()
|
||||||
|
leftViewer.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rightViewer.value) {
|
||||||
|
rightViewer.value.destroy()
|
||||||
|
rightViewer.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[useDualViewers] Viewers 已销毁')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件卸载时自动清理
|
||||||
|
onUnmounted(() => {
|
||||||
|
destroyViewers()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
leftViewer,
|
||||||
|
rightViewer,
|
||||||
|
initLeftViewer,
|
||||||
|
initRightViewer,
|
||||||
|
setCameraView,
|
||||||
|
enableCameraSync,
|
||||||
|
disableCameraSync,
|
||||||
|
resizeViewers,
|
||||||
|
destroyViewers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useDualViewers
|
||||||
@ -0,0 +1,650 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import * as Cesium from 'cesium'
|
||||||
|
import { cesiumDataConfig } from '../config/cesiumData'
|
||||||
|
|
||||||
|
// 图标导入
|
||||||
|
import soldierIcon from '../assets/images/SketchPngfbec927027ff9e49207749ebaafd229429315341fda199251b6dfb1723ff17fb.png'
|
||||||
|
import deviceIcon from '../assets/images/SketchPng860d54f2a31f5f441fc6a88081224f1e98534bf6d5ca1246e420983bdf690380.png'
|
||||||
|
import emergencyBaseIcon from '../assets/images/应急基地.png'
|
||||||
|
|
||||||
|
// 默认高度偏移(米)
|
||||||
|
const DEFAULT_HEIGHT_OFFSET = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地图标记管理 Composable
|
||||||
|
* 负责添加单兵、设备、应急基地标记以及塌陷区域
|
||||||
|
*/
|
||||||
|
export function useMapMarkers() {
|
||||||
|
const collapseAreaEntities = ref([])
|
||||||
|
const markerEntities = ref([])
|
||||||
|
const emergencyResourceEntities = ref([]) // 应急资源标记(由API数据动态生成)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取塌陷区域的所有位置点
|
||||||
|
* @returns {Cesium.Cartesian3[]}
|
||||||
|
*/
|
||||||
|
const getCollapsePositions = () => {
|
||||||
|
if (!Array.isArray(cesiumDataConfig) || !cesiumDataConfig.length) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return cesiumDataConfig.map(point =>
|
||||||
|
new Cesium.Cartesian3(point.x, point.y, point.z)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算塌陷区域中心点
|
||||||
|
* 这个函数不依赖地形数据,可以在初始化早期调用
|
||||||
|
* @returns {Cesium.Cartesian3 | null}
|
||||||
|
*/
|
||||||
|
const calculateCollapseCenter = () => {
|
||||||
|
const positions = getCollapsePositions()
|
||||||
|
if (!positions.length) {
|
||||||
|
console.warn('[useMapMarkers] 无法计算塌陷区域中心:配置数据为空')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return Cesium.BoundingSphere.fromPoints(positions).center
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待地形提供者就绪
|
||||||
|
* 用于确保地形数据已加载,以便准确采样地面高度
|
||||||
|
* @param {Cesium.Viewer} viewer
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const waitForTerrainReady = async (viewer) => {
|
||||||
|
if (!viewer?.terrainProvider?.readyPromise) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await viewer.terrainProvider.readyPromise
|
||||||
|
console.log('[useMapMarkers] 地形提供者已就绪')
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[useMapMarkers] 地形加载失败,将使用默认高度:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析经纬度坐标
|
||||||
|
* 对于3D Tiles场景,直接返回经纬度坐标,不进行高度采样
|
||||||
|
* 配合 RELATIVE_TO_GROUND 让Cesium自动处理高度
|
||||||
|
*
|
||||||
|
* @param {Cesium.Viewer} viewer
|
||||||
|
* @param {number} lon - 经度(度)
|
||||||
|
* @param {number} lat - 纬度(度)
|
||||||
|
* @param {number} heightOffset - 相对地面的高度偏移(米)
|
||||||
|
* @param {boolean} useSampledHeights - 是否使用采样高度(3D Tiles场景忽略此参数)
|
||||||
|
* @returns {Promise<{ position: Cesium.Cartesian3, samplingSucceeded: boolean }>}
|
||||||
|
*/
|
||||||
|
const resolveCartesianFromDegrees = async (viewer, lon, lat, heightOffset, useSampledHeights) => {
|
||||||
|
// 对于3D Tiles场景,直接使用经纬度坐标
|
||||||
|
// heightOffset 会通过 RELATIVE_TO_GROUND 自动应用
|
||||||
|
return {
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(lon, lat, heightOffset),
|
||||||
|
samplingSucceeded: false // 标记为未采样,使用 RELATIVE_TO_GROUND
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回统一的高度参考模式
|
||||||
|
* 所有标记统一使用 CLAMP_TO_GROUND,让 Cesium 自动处理贴地
|
||||||
|
* 这样标记会自动跟随地形变化,但由于已禁用 3D Tiles 的动态细化,变化会很小
|
||||||
|
*
|
||||||
|
* @param {boolean} samplingSucceeded - 采样是否成功(保留参数用于日志)
|
||||||
|
* @returns {Cesium.HeightReference}
|
||||||
|
*/
|
||||||
|
const resolveBillboardHeightReference = (samplingSucceeded) => {
|
||||||
|
// 统一使用 CLAMP_TO_GROUND 让 Cesium 自动贴地
|
||||||
|
// 配合禁用的 3D Tiles 动态细化,标记位置会保持稳定
|
||||||
|
return Cesium.HeightReference.CLAMP_TO_GROUND
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制塌陷区域多边形
|
||||||
|
* 这个函数不涉及高度采样,直接使用配置中的绝对坐标
|
||||||
|
* @param {Cesium.Viewer} viewer
|
||||||
|
* @returns {Cesium.Cartesian3 | null} 返回塌陷区域中心点
|
||||||
|
*/
|
||||||
|
const drawCollapseArea = (viewer) => {
|
||||||
|
if (!viewer) {
|
||||||
|
console.warn('[useMapMarkers] drawCollapseArea: viewer 为空')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions = getCollapsePositions()
|
||||||
|
if (!positions.length) {
|
||||||
|
console.warn('[useMapMarkers] drawCollapseArea: 配置数据为空')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const entities = []
|
||||||
|
|
||||||
|
// 创建红色多边形
|
||||||
|
const polygonEntity = viewer.entities.add({
|
||||||
|
polygon: {
|
||||||
|
hierarchy: positions,
|
||||||
|
material: Cesium.Color.RED.withAlpha(0.35),
|
||||||
|
perPositionHeight: true,
|
||||||
|
outline: true,
|
||||||
|
outlineColor: Cesium.Color.RED.withAlpha(0.8),
|
||||||
|
classificationType: Cesium.ClassificationType.TERRAIN
|
||||||
|
}
|
||||||
|
})
|
||||||
|
entities.push(polygonEntity)
|
||||||
|
|
||||||
|
// 计算中心点
|
||||||
|
const center = Cesium.BoundingSphere.fromPoints(positions).center
|
||||||
|
|
||||||
|
// 添加标签
|
||||||
|
const labelEntity = viewer.entities.add({
|
||||||
|
position: center,
|
||||||
|
label: {
|
||||||
|
text: '模拟塌陷区域',
|
||||||
|
font: '18px "Microsoft YaHei", sans-serif',
|
||||||
|
fillColor: Cesium.Color.WHITE,
|
||||||
|
outlineColor: Cesium.Color.BLACK,
|
||||||
|
outlineWidth: 2,
|
||||||
|
showBackground: true,
|
||||||
|
backgroundColor: Cesium.Color.fromAlpha(Cesium.Color.BLACK, 0.4),
|
||||||
|
pixelOffset: new Cesium.Cartesian2(0, -20),
|
||||||
|
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
||||||
|
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||||
|
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY
|
||||||
|
}
|
||||||
|
})
|
||||||
|
entities.push(labelEntity)
|
||||||
|
|
||||||
|
// 添加中心点标记
|
||||||
|
const pointEntity = viewer.entities.add({
|
||||||
|
position: center,
|
||||||
|
point: {
|
||||||
|
color: Cesium.Color.ORANGE,
|
||||||
|
pixelSize: 12,
|
||||||
|
outlineColor: Cesium.Color.WHITE,
|
||||||
|
outlineWidth: 2,
|
||||||
|
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND
|
||||||
|
}
|
||||||
|
})
|
||||||
|
entities.push(pointEntity)
|
||||||
|
|
||||||
|
collapseAreaEntities.value = entities
|
||||||
|
console.log('[useMapMarkers] 塌陷区域绘制完成')
|
||||||
|
return center
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加固定标记点(单兵、设备、应急基地)
|
||||||
|
* 支持地面高度采样,确保标记在地形加载后位置正确
|
||||||
|
*
|
||||||
|
* @param {Cesium.Viewer} viewer
|
||||||
|
* @param {Object} options - 配置选项
|
||||||
|
* @param {number} [options.heightOffset=10] - 相对地面的高度偏移(米)
|
||||||
|
* @param {boolean} [options.useSampledHeights=true] - 是否使用采样高度
|
||||||
|
* @returns {Promise<Cesium.Entity[]>}
|
||||||
|
*/
|
||||||
|
const addFixedMarkers = async (viewer, options = {}) => {
|
||||||
|
if (!viewer) {
|
||||||
|
console.warn('[useMapMarkers] addFixedMarkers: viewer 为空')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const markerOptions = {
|
||||||
|
heightOffset: options.heightOffset ?? DEFAULT_HEIGHT_OFFSET,
|
||||||
|
useSampledHeights: options.useSampledHeights !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
const entities = []
|
||||||
|
|
||||||
|
// 单兵标记
|
||||||
|
const soldierResult = await resolveCartesianFromDegrees(
|
||||||
|
viewer,
|
||||||
|
106.398030213,
|
||||||
|
29.7099573,
|
||||||
|
markerOptions.heightOffset,
|
||||||
|
markerOptions.useSampledHeights
|
||||||
|
)
|
||||||
|
|
||||||
|
const soldierEntity = viewer.entities.add({
|
||||||
|
position: soldierResult.position,
|
||||||
|
billboard: {
|
||||||
|
image: soldierIcon,
|
||||||
|
width: 36,
|
||||||
|
height: 40,
|
||||||
|
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||||
|
heightReference: resolveBillboardHeightReference(soldierResult.samplingSucceeded),
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
type: 'soldier',
|
||||||
|
name: '黄政强',
|
||||||
|
department: '安全生产部',
|
||||||
|
location: '目前为止距离现场0.6公里'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
entities.push(soldierEntity)
|
||||||
|
|
||||||
|
// 设备标记
|
||||||
|
const deviceResult = await resolveCartesianFromDegrees(
|
||||||
|
viewer,
|
||||||
|
106.402018756,
|
||||||
|
29.7061436,
|
||||||
|
markerOptions.heightOffset,
|
||||||
|
markerOptions.useSampledHeights
|
||||||
|
)
|
||||||
|
|
||||||
|
const deviceEntity = viewer.entities.add({
|
||||||
|
position: deviceResult.position,
|
||||||
|
billboard: {
|
||||||
|
image: deviceIcon,
|
||||||
|
width: 36,
|
||||||
|
height: 40,
|
||||||
|
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||||
|
heightReference: resolveBillboardHeightReference(deviceResult.samplingSucceeded),
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
type: 'device',
|
||||||
|
name: '无人机A',
|
||||||
|
deviceType: 'DJI',
|
||||||
|
location: '目前为止距离现场0.5公里'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
entities.push(deviceEntity)
|
||||||
|
|
||||||
|
// 应急基地标记(距中心点北12公里)
|
||||||
|
const center = calculateCollapseCenter()
|
||||||
|
if (center) {
|
||||||
|
const cartographic = Cesium.Cartographic.fromCartesian(center)
|
||||||
|
const centerLat = Cesium.Math.toDegrees(cartographic.latitude)
|
||||||
|
const centerLon = Cesium.Math.toDegrees(cartographic.longitude)
|
||||||
|
const emergencyBaseLat = centerLat + 0.11 // 约12km
|
||||||
|
|
||||||
|
const emergencyBaseResult = await resolveCartesianFromDegrees(
|
||||||
|
viewer,
|
||||||
|
centerLon,
|
||||||
|
emergencyBaseLat,
|
||||||
|
markerOptions.heightOffset,
|
||||||
|
markerOptions.useSampledHeights
|
||||||
|
)
|
||||||
|
|
||||||
|
const emergencyBaseEntity = viewer.entities.add({
|
||||||
|
position: emergencyBaseResult.position,
|
||||||
|
billboard: {
|
||||||
|
image: emergencyBaseIcon,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||||
|
heightReference: resolveBillboardHeightReference(emergencyBaseResult.samplingSucceeded),
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
type: 'emergencyBase',
|
||||||
|
name: '应急指挥中心',
|
||||||
|
address: 'XX区应急管理局',
|
||||||
|
distance: '距离现场12.0公里'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
entities.push(emergencyBaseEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
markerEntities.value.push(...entities)
|
||||||
|
console.log(`[useMapMarkers] 添加固定标记 ${entities.length} 个`)
|
||||||
|
return entities
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随机标记(在中心点周围5km半径)
|
||||||
|
* 支持地面高度采样,确保标记在地形加载后位置正确
|
||||||
|
*
|
||||||
|
* @param {Cesium.Viewer} viewer
|
||||||
|
* @param {number} count - 要生成的随机标记数量
|
||||||
|
* @param {Object} options - 配置选项
|
||||||
|
* @param {number} [options.heightOffset=10] - 相对地面的高度偏移(米)
|
||||||
|
* @param {boolean} [options.useSampledHeights=true] - 是否使用采样高度
|
||||||
|
* @returns {Promise<Cesium.Entity[]>}
|
||||||
|
*/
|
||||||
|
const addRandomMarkers = async (viewer, count = 10, options = {}) => {
|
||||||
|
if (!viewer) {
|
||||||
|
console.warn('[useMapMarkers] addRandomMarkers: viewer 为空')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = calculateCollapseCenter()
|
||||||
|
if (!center) {
|
||||||
|
console.warn('[useMapMarkers] addRandomMarkers: 无法计算中心点')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const cartographic = Cesium.Cartographic.fromCartesian(center)
|
||||||
|
const centerLat = Cesium.Math.toDegrees(cartographic.latitude)
|
||||||
|
const centerLon = Cesium.Math.toDegrees(cartographic.longitude)
|
||||||
|
|
||||||
|
const radiusMeters = 5000
|
||||||
|
const soldierNames = ['张三', '李四', '王五', '赵六', '刘七']
|
||||||
|
const soldierDepartments = ['安全生产部', '应急管理部', '消防救援队']
|
||||||
|
const deviceNames = ['无人机B', '无人机C', '监控设备A', '监控设备B', '应急车辆A']
|
||||||
|
const deviceTypes = ['DJI']
|
||||||
|
|
||||||
|
const latInRadians = Cesium.Math.toRadians(centerLat)
|
||||||
|
const metersPerDegreeLat = 111320
|
||||||
|
const metersPerDegreeLon = Math.max(1e-6, metersPerDegreeLat * Math.cos(latInRadians))
|
||||||
|
|
||||||
|
const markerOptions = {
|
||||||
|
heightOffset: options.heightOffset ?? DEFAULT_HEIGHT_OFFSET,
|
||||||
|
useSampledHeights: options.useSampledHeights !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
const entities = []
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const angle = Math.random() * Math.PI * 2
|
||||||
|
const distance = Math.random() * radiusMeters
|
||||||
|
const deltaLat = (distance * Math.cos(angle)) / metersPerDegreeLat
|
||||||
|
const deltaLon = (distance * Math.sin(angle)) / metersPerDegreeLon
|
||||||
|
const locationDistance = (0.3 + Math.random() * 1.7).toFixed(1)
|
||||||
|
const isSoldier = Math.random() < 0.5
|
||||||
|
|
||||||
|
const lon = centerLon + deltaLon
|
||||||
|
const lat = centerLat + deltaLat
|
||||||
|
|
||||||
|
const result = await resolveCartesianFromDegrees(
|
||||||
|
viewer,
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
markerOptions.heightOffset,
|
||||||
|
markerOptions.useSampledHeights
|
||||||
|
)
|
||||||
|
|
||||||
|
const entity = viewer.entities.add({
|
||||||
|
position: result.position,
|
||||||
|
billboard: {
|
||||||
|
image: isSoldier ? soldierIcon : deviceIcon,
|
||||||
|
width: 36,
|
||||||
|
height: 40,
|
||||||
|
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||||
|
heightReference: resolveBillboardHeightReference(result.samplingSucceeded),
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY
|
||||||
|
},
|
||||||
|
properties: isSoldier
|
||||||
|
? {
|
||||||
|
type: 'soldier',
|
||||||
|
name: soldierNames[Math.floor(Math.random() * soldierNames.length)],
|
||||||
|
department: soldierDepartments[Math.floor(Math.random() * soldierDepartments.length)],
|
||||||
|
location: `目前为止距离现场${locationDistance}公里`
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: 'device',
|
||||||
|
name: deviceNames[Math.floor(Math.random() * deviceNames.length)],
|
||||||
|
deviceType: deviceTypes[Math.floor(Math.random() * deviceTypes.length)],
|
||||||
|
location: `目前为止距离现场${locationDistance}公里`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
entities.push(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
markerEntities.value.push(...entities)
|
||||||
|
console.log(`[useMapMarkers] 添加随机标记 ${entities.length} 个`)
|
||||||
|
return entities
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化所有标记
|
||||||
|
* 按顺序绘制塌陷区域、添加固定标记和随机标记
|
||||||
|
*
|
||||||
|
* @param {Cesium.Viewer} viewer
|
||||||
|
* @param {Object} options - 配置选项
|
||||||
|
* @param {number} [options.heightOffset=10] - 相对地面的高度偏移(米)
|
||||||
|
* @param {boolean} [options.useSampledHeights=true] - 是否使用采样高度
|
||||||
|
* @param {number} [options.randomCount=10] - 随机标记数量
|
||||||
|
* @returns {Promise<Cesium.Cartesian3 | null>} 返回塌陷区域中心点
|
||||||
|
*/
|
||||||
|
const initializeMarkers = async (viewer, options = {}) => {
|
||||||
|
if (!viewer) {
|
||||||
|
console.warn('[useMapMarkers] initializeMarkers: viewer 为空')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const markerOptions = {
|
||||||
|
heightOffset: options.heightOffset ?? DEFAULT_HEIGHT_OFFSET,
|
||||||
|
useSampledHeights: options.useSampledHeights !== false,
|
||||||
|
randomCount: options.randomCount ?? 10
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[useMapMarkers] 开始初始化地图标记...', markerOptions)
|
||||||
|
|
||||||
|
// 绘制塌陷区域(不涉及采样)
|
||||||
|
const center = drawCollapseArea(viewer)
|
||||||
|
|
||||||
|
// 添加固定标记(支持采样)
|
||||||
|
await addFixedMarkers(viewer, markerOptions)
|
||||||
|
|
||||||
|
// 添加随机标记(支持采样)
|
||||||
|
await addRandomMarkers(viewer, markerOptions.randomCount, markerOptions)
|
||||||
|
|
||||||
|
console.log('[useMapMarkers] 标记初始化完成')
|
||||||
|
return center
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有标记
|
||||||
|
*/
|
||||||
|
const clearMarkers = (viewer) => {
|
||||||
|
if (!viewer) return
|
||||||
|
|
||||||
|
// 清除塌陷区域
|
||||||
|
collapseAreaEntities.value.forEach(entity => {
|
||||||
|
if (entity) viewer.entities.remove(entity)
|
||||||
|
})
|
||||||
|
collapseAreaEntities.value = []
|
||||||
|
|
||||||
|
// 清除标记点
|
||||||
|
markerEntities.value.forEach(entity => {
|
||||||
|
if (entity) viewer.entities.remove(entity)
|
||||||
|
})
|
||||||
|
markerEntities.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置所有标记点的 splitDirection
|
||||||
|
* 用于模型对比模式,控制标记点在左侧/右侧/全屏显示
|
||||||
|
*
|
||||||
|
* 注意:Cesium 需要同时在 entity 和其图形属性(billboard/polygon)上设置 splitDirection
|
||||||
|
*
|
||||||
|
* @param {Cesium.SplitDirection} splitDirection - 分割方向
|
||||||
|
*/
|
||||||
|
const setMarkersSplitDirection = (splitDirection) => {
|
||||||
|
console.log(`[useMapMarkers] 设置所有标记点的 splitDirection 为: ${splitDirection}`)
|
||||||
|
|
||||||
|
let updatedCount = 0
|
||||||
|
|
||||||
|
// 更新塌陷区域的 splitDirection
|
||||||
|
collapseAreaEntities.value.forEach(entity => {
|
||||||
|
if (entity) {
|
||||||
|
// 设置 entity 级别的 splitDirection
|
||||||
|
entity.splitDirection = splitDirection
|
||||||
|
|
||||||
|
// 如果有 polygon,也需要设置
|
||||||
|
if (entity.polygon) {
|
||||||
|
// 使用 Cesium 的 ConstantProperty 确保正确设置
|
||||||
|
if (typeof entity.polygon.splitDirection === 'object' && entity.polygon.splitDirection.setValue) {
|
||||||
|
entity.polygon.splitDirection.setValue(splitDirection)
|
||||||
|
} else {
|
||||||
|
entity.polygon.splitDirection = new Cesium.ConstantProperty(splitDirection)
|
||||||
|
}
|
||||||
|
console.log(`[useMapMarkers] 塌陷区域 polygon.splitDirection 已设置为:`, entity.polygon.splitDirection)
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedCount++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新所有标记点的 splitDirection
|
||||||
|
markerEntities.value.forEach((entity, index) => {
|
||||||
|
if (entity) {
|
||||||
|
// 设置 entity 级别的 splitDirection
|
||||||
|
entity.splitDirection = splitDirection
|
||||||
|
|
||||||
|
// 如果有 billboard,也需要设置(这是关键!)
|
||||||
|
if (entity.billboard) {
|
||||||
|
// 使用 Cesium 的 ConstantProperty 确保正确设置
|
||||||
|
if (typeof entity.billboard.splitDirection === 'object' && entity.billboard.splitDirection.setValue) {
|
||||||
|
entity.billboard.splitDirection.setValue(splitDirection)
|
||||||
|
} else {
|
||||||
|
entity.billboard.splitDirection = new Cesium.ConstantProperty(splitDirection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调试输出前3个标记点的设置情况
|
||||||
|
if (index < 3) {
|
||||||
|
console.log(`[useMapMarkers] 标记点 ${index} (${entity.properties?.type?.getValue() || 'unknown'}) billboard.splitDirection 已设置为:`, entity.billboard.splitDirection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedCount++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[useMapMarkers] 已更新 ${updatedCount} 个实体的 splitDirection(包括图形属性)`)
|
||||||
|
console.log(`[useMapMarkers] 塌陷区域数量: ${collapseAreaEntities.value.length}, 标记点数量: ${markerEntities.value.length}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 隐藏所有标记点(用于模型对比模式)
|
||||||
|
* 如果 splitDirection 不起作用,可以使用这个方法直接隐藏标记点
|
||||||
|
*/
|
||||||
|
const hideMarkers = () => {
|
||||||
|
console.log('[useMapMarkers] 隐藏所有标记点')
|
||||||
|
|
||||||
|
markerEntities.value.forEach(entity => {
|
||||||
|
if (entity) {
|
||||||
|
entity.show = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示所有标记点(退出模型对比模式)
|
||||||
|
*/
|
||||||
|
const showMarkers = () => {
|
||||||
|
console.log('[useMapMarkers] 显示所有标记点')
|
||||||
|
|
||||||
|
markerEntities.value.forEach(entity => {
|
||||||
|
if (entity) {
|
||||||
|
entity.show = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除应急资源标记
|
||||||
|
* @param {Cesium.Viewer} viewer
|
||||||
|
*/
|
||||||
|
const clearEmergencyResourceMarkers = (viewer) => {
|
||||||
|
if (!viewer) return
|
||||||
|
|
||||||
|
console.log(`[useMapMarkers] 清除 ${emergencyResourceEntities.value.length} 个应急资源标记`)
|
||||||
|
|
||||||
|
emergencyResourceEntities.value.forEach(entity => {
|
||||||
|
if (entity) viewer.entities.remove(entity)
|
||||||
|
})
|
||||||
|
emergencyResourceEntities.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据API数据添加应急资源标记(养护站)
|
||||||
|
* @param {Cesium.Viewer} viewer
|
||||||
|
* @param {Object} emergencyData - API返回的应急资源数据
|
||||||
|
* @param {Array} emergencyData.stations - 养护站列表
|
||||||
|
* @param {string} emergencyData.stations[].stationId - 养护站ID
|
||||||
|
* @param {string} emergencyData.stations[].stationName - 养护站名称
|
||||||
|
* @param {number} emergencyData.stations[].longitude - 经度
|
||||||
|
* @param {number} emergencyData.stations[].latitude - 纬度
|
||||||
|
* @param {number} emergencyData.stations[].distance - 距离灾害点的距离(km)
|
||||||
|
* @param {number} emergencyData.equipmentCount - 应急装备数量
|
||||||
|
* @param {number} emergencyData.personnelCount - 应急人员数量
|
||||||
|
* @param {Object} disasterPoint - 灾害点坐标 { longitude, latitude }
|
||||||
|
* @param {Object} options - 配置选项
|
||||||
|
* @param {number} [options.heightOffset=10] - 相对地面的高度偏移(米)
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const addEmergencyResourceMarkers = async (viewer, emergencyData, disasterPoint, options = {}) => {
|
||||||
|
if (!viewer) {
|
||||||
|
console.warn('[useMapMarkers] addEmergencyResourceMarkers: viewer 为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!emergencyData) {
|
||||||
|
console.warn('[useMapMarkers] addEmergencyResourceMarkers: emergencyData 为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const markerOptions = {
|
||||||
|
heightOffset: options.heightOffset ?? DEFAULT_HEIGHT_OFFSET
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[useMapMarkers] 开始添加应急资源标记(养护站)...', emergencyData)
|
||||||
|
|
||||||
|
const entities = []
|
||||||
|
|
||||||
|
// 添加养护站标记
|
||||||
|
if (Array.isArray(emergencyData.stations)) {
|
||||||
|
for (const station of emergencyData.stations) {
|
||||||
|
if (!station.latitude || !station.longitude) {
|
||||||
|
console.warn('[useMapMarkers] 养护站缺少坐标信息:', station)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await resolveCartesianFromDegrees(
|
||||||
|
viewer,
|
||||||
|
station.longitude,
|
||||||
|
station.latitude,
|
||||||
|
markerOptions.heightOffset,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
const entity = viewer.entities.add({
|
||||||
|
position: result.position,
|
||||||
|
billboard: {
|
||||||
|
image: emergencyBaseIcon,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||||
|
heightReference: resolveBillboardHeightReference(result.samplingSucceeded),
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
type: 'station',
|
||||||
|
stationId: station.stationId,
|
||||||
|
name: station.stationName || '养护站',
|
||||||
|
distance: station.distance || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
entities.push(entity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emergencyResourceEntities.value = entities
|
||||||
|
console.log(`[useMapMarkers] 添加养护站标记 ${entities.length} 个`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
collapseAreaEntities,
|
||||||
|
markerEntities,
|
||||||
|
emergencyResourceEntities,
|
||||||
|
initializeMarkers,
|
||||||
|
clearMarkers,
|
||||||
|
setMarkersSplitDirection,
|
||||||
|
hideMarkers,
|
||||||
|
showMarkers,
|
||||||
|
drawCollapseArea,
|
||||||
|
addFixedMarkers,
|
||||||
|
addRandomMarkers,
|
||||||
|
addEmergencyResourceMarkers,
|
||||||
|
clearEmergencyResourceMarkers,
|
||||||
|
getCollapseCenter: calculateCollapseCenter // 提前获取中心点,不依赖标记初始化
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useMapMarkers
|
||||||
@ -0,0 +1,572 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import * as Cesium from 'cesium'
|
||||||
|
import { useMapStore } from '@/map'
|
||||||
|
import {
|
||||||
|
BEFORE_IMAGERY_CONFIG,
|
||||||
|
AFTER_IMAGERY_CONFIG,
|
||||||
|
SPLIT_CONFIG,
|
||||||
|
getModelCompareConfig
|
||||||
|
} from '../config/modelCompare.config'
|
||||||
|
import { use3DTiles } from './use3DTiles'
|
||||||
|
import { useMapMarkers } from './useMapMarkers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调试模式开关
|
||||||
|
* 生产环境自动关闭详细日志
|
||||||
|
*/
|
||||||
|
const DEBUG = import.meta.env.DEV
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图层标识常量
|
||||||
|
* @constant {string} BEFORE_LAYER_ID - 灾前现场实景图层 ID
|
||||||
|
* @constant {string} AFTER_LAYER_ID - 灾后现场实景图层 ID
|
||||||
|
*/
|
||||||
|
const BEFORE_LAYER_ID = BEFORE_IMAGERY_CONFIG.id
|
||||||
|
const AFTER_LAYER_ID = AFTER_IMAGERY_CONFIG.id
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型对比(灾前/灾后影像对比)业务逻辑
|
||||||
|
*
|
||||||
|
* 技术方案:
|
||||||
|
* - 使用单个 Cesium 实例,通过 imagery split(影像分屏)实现左右对比视图
|
||||||
|
* - 左侧显示灾前现场实景,右侧显示灾后现场实景
|
||||||
|
* - 默认只显示灾后影像,启用对比模式后同时显示两套影像
|
||||||
|
*
|
||||||
|
* 使用示例:
|
||||||
|
* ```js
|
||||||
|
* const { isModelCompareActive, initModelCompareLayers, toggleModelCompare } = useModelCompare()
|
||||||
|
*
|
||||||
|
* // 在地图就绪后初始化图层
|
||||||
|
* mapStore.onReady(async () => {
|
||||||
|
* await initModelCompareLayers()
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* // 切换对比模式
|
||||||
|
* await toggleModelCompare(true) // 启用
|
||||||
|
* await toggleModelCompare(false) // 禁用
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @returns {Object} 模型对比相关状态和方法
|
||||||
|
* @returns {Ref<boolean>} isModelCompareActive - 模型对比模式是否激活
|
||||||
|
* @returns {Function} initModelCompareLayers - 初始化灾前/灾后图层
|
||||||
|
* @returns {Function} enableModelCompare - 启用模型对比模式
|
||||||
|
* @returns {Function} disableModelCompare - 禁用模型对比模式
|
||||||
|
* @returns {Function} toggleModelCompare - 切换模型对比模式
|
||||||
|
*/
|
||||||
|
export function useModelCompare() {
|
||||||
|
const mapStore = useMapStore()
|
||||||
|
|
||||||
|
// 初始化 3D Tiles 管理
|
||||||
|
const {
|
||||||
|
load3DTileset,
|
||||||
|
waitForTilesetReady,
|
||||||
|
remove3DTileset
|
||||||
|
} = use3DTiles()
|
||||||
|
|
||||||
|
/** 模型对比模式是否激活 */
|
||||||
|
const isModelCompareActive = ref(false)
|
||||||
|
|
||||||
|
/** 图层是否已初始化 */
|
||||||
|
const initialized = ref(false)
|
||||||
|
|
||||||
|
/** 是否正在执行切换操作(防止并发) */
|
||||||
|
const isToggling = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 其他影像图层的原始可见性状态
|
||||||
|
* 用于在禁用对比模式后恢复原始状态,而非强制全部打开
|
||||||
|
* Map<layerId: string, visible: boolean>
|
||||||
|
*/
|
||||||
|
const originalLayerVisibility = new Map()
|
||||||
|
|
||||||
|
/** 灾前 Tileset 引用(用于在禁用时移除) */
|
||||||
|
let beforeTilesetRef = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 viewer 中查找指定配置的 3D Tileset
|
||||||
|
* @param {Cesium.Viewer} viewer
|
||||||
|
* @param {string} configId - 配置ID,'before' 或 'after'
|
||||||
|
* @returns {Cesium.Cesium3DTileset | null}
|
||||||
|
*/
|
||||||
|
const findTilesetByConfig = (viewer, configId) => {
|
||||||
|
if (!viewer?.scene?.primitives) return null
|
||||||
|
|
||||||
|
const config = getModelCompareConfig()
|
||||||
|
const targetUrl = configId === 'after' ? config.after3DTiles.url : config.before3DTiles.url
|
||||||
|
|
||||||
|
// 遍历所有 primitives 查找匹配的 tileset
|
||||||
|
for (let i = 0; i < viewer.scene.primitives.length; i++) {
|
||||||
|
const primitive = viewer.scene.primitives.get(i)
|
||||||
|
if (primitive instanceof Cesium.Cesium3DTileset) {
|
||||||
|
// 比较 URL(去除查询参数和尾部斜杠)
|
||||||
|
const primitiveUrl = primitive.resource?.url || primitive._url || ''
|
||||||
|
const normalizedPrimitiveUrl = primitiveUrl.split('?')[0].replace(/\/$/, '')
|
||||||
|
const normalizedTargetUrl = targetUrl.split('?')[0].replace(/\/$/, '')
|
||||||
|
|
||||||
|
if (normalizedPrimitiveUrl === normalizedTargetUrl) {
|
||||||
|
return primitive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置所有 entities 的 splitDirection
|
||||||
|
* @param {Cesium.Viewer} viewer
|
||||||
|
* @param {Cesium.SplitDirection} splitDirection
|
||||||
|
*/
|
||||||
|
const setEntitiesSplitDirection = (viewer, splitDirection) => {
|
||||||
|
if (!viewer?.entities) return
|
||||||
|
|
||||||
|
console.log(`[useModelCompare] 设置所有 entities 的 splitDirection 为: ${splitDirection}`)
|
||||||
|
let updatedCount = 0
|
||||||
|
|
||||||
|
const entities = viewer.entities.values
|
||||||
|
for (let i = 0; i < entities.length; i++) {
|
||||||
|
const entity = entities[i]
|
||||||
|
|
||||||
|
// 设置 entity 级别的 splitDirection
|
||||||
|
entity.splitDirection = splitDirection
|
||||||
|
|
||||||
|
// 设置图形属性的 splitDirection
|
||||||
|
if (entity.billboard) {
|
||||||
|
if (typeof entity.billboard.splitDirection === 'object' && entity.billboard.splitDirection.setValue) {
|
||||||
|
entity.billboard.splitDirection.setValue(splitDirection)
|
||||||
|
} else {
|
||||||
|
entity.billboard.splitDirection = new Cesium.ConstantProperty(splitDirection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity.polygon) {
|
||||||
|
if (typeof entity.polygon.splitDirection === 'object' && entity.polygon.splitDirection.setValue) {
|
||||||
|
entity.polygon.splitDirection.setValue(splitDirection)
|
||||||
|
} else {
|
||||||
|
entity.polygon.splitDirection = new Cesium.ConstantProperty(splitDirection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[useModelCompare] 已更新 ${updatedCount} 个 entities 的 splitDirection`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化灾前/灾后影像图层
|
||||||
|
*
|
||||||
|
* - 仅在首次调用时创建图层
|
||||||
|
* - 如果地图未就绪,会自动等待地图就绪后再执行
|
||||||
|
* - 使用占位符 URL,需在接入真实数据时替换为实际影像服务地址
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @throws {Error} 当图层创建失败时抛出错误
|
||||||
|
*/
|
||||||
|
const initModelCompareLayers = async () => {
|
||||||
|
// 防止重复初始化
|
||||||
|
if (initialized.value) {
|
||||||
|
console.log('[useModelCompare] 图层已初始化,跳过重复初始化')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实际的图层初始化逻辑
|
||||||
|
* @async
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const doInit = async () => {
|
||||||
|
try {
|
||||||
|
const { layer } = mapStore.services()
|
||||||
|
|
||||||
|
// 获取当前环境的配置
|
||||||
|
const config = getModelCompareConfig()
|
||||||
|
|
||||||
|
// 检查图层是否已存在(可能被其他模块创建)
|
||||||
|
const beforeExists = layer.getLayer(BEFORE_LAYER_ID)
|
||||||
|
const afterExists = layer.getLayer(AFTER_LAYER_ID)
|
||||||
|
|
||||||
|
// 创建灾前影像图层
|
||||||
|
if (!beforeExists) {
|
||||||
|
if (DEBUG) console.log('[useModelCompare] 创建灾前影像图层...')
|
||||||
|
await layer.addLayer({
|
||||||
|
id: config.before.id,
|
||||||
|
type: 'WebTileLayer',
|
||||||
|
url: config.before.url,
|
||||||
|
options: {
|
||||||
|
visible: config.before.visible,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
title: config.before.name,
|
||||||
|
sceneType: 'before',
|
||||||
|
description: config.before.description
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (DEBUG) console.log('[useModelCompare] 灾前影像图层创建成功')
|
||||||
|
} else {
|
||||||
|
if (DEBUG) console.log('[useModelCompare] 灾前影像图层已存在')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建灾后影像图层
|
||||||
|
if (!afterExists) {
|
||||||
|
if (DEBUG) console.log('[useModelCompare] 创建灾后影像图层...')
|
||||||
|
await layer.addLayer({
|
||||||
|
id: config.after.id,
|
||||||
|
type: 'WebTileLayer',
|
||||||
|
url: config.after.url,
|
||||||
|
options: {
|
||||||
|
visible: config.after.visible,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
title: config.after.name,
|
||||||
|
sceneType: 'after',
|
||||||
|
description: config.after.description
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (DEBUG) console.log('[useModelCompare] 灾后影像图层创建成功')
|
||||||
|
} else {
|
||||||
|
if (DEBUG) console.log('[useModelCompare] 灾后影像图层已存在')
|
||||||
|
}
|
||||||
|
|
||||||
|
initialized.value = true
|
||||||
|
if (DEBUG) console.log('[useModelCompare] 图层初始化完成')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useModelCompare] 图层初始化失败:', error)
|
||||||
|
throw new Error(`模型对比图层初始化失败: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果地图已就绪,直接执行初始化
|
||||||
|
if (mapStore.isReady()) {
|
||||||
|
await doInit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则等待地图就绪后再执行
|
||||||
|
console.log('[useModelCompare] 等待地图就绪...')
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
mapStore.onReady(async () => {
|
||||||
|
try {
|
||||||
|
await doInit()
|
||||||
|
resolve()
|
||||||
|
} catch (error) {
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用模型对比模式
|
||||||
|
*
|
||||||
|
* 启用后:
|
||||||
|
* - 左半屏显示灾前影像
|
||||||
|
* - 右半屏显示灾后影像
|
||||||
|
* - 分割线位置默认为中心(0.5)
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
*/
|
||||||
|
const enableModelCompare = async () => {
|
||||||
|
if (DEBUG) console.log('[useModelCompare] 启用模型对比模式...')
|
||||||
|
|
||||||
|
// 确保图层已初始化
|
||||||
|
await initModelCompareLayers()
|
||||||
|
|
||||||
|
// 如果地图未就绪,无法操作
|
||||||
|
if (!mapStore.isReady()) {
|
||||||
|
console.warn('[useModelCompare] 地图未就绪,无法启用对比模式')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { layer } = mapStore.services()
|
||||||
|
|
||||||
|
// 检查图层是否存在
|
||||||
|
const beforeLayer = layer.getLayer(BEFORE_LAYER_ID)
|
||||||
|
const afterLayer = layer.getLayer(AFTER_LAYER_ID)
|
||||||
|
|
||||||
|
if (!beforeLayer || !afterLayer) {
|
||||||
|
console.error('[useModelCompare] 图层不存在,无法启用对比模式')
|
||||||
|
throw new Error('模型对比图层不存在')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 修复:保存其他影像图层的原始可见性状态
|
||||||
|
// 在隐藏图层前先记录它们的状态,以便后续恢复
|
||||||
|
originalLayerVisibility.clear() // 清空旧状态
|
||||||
|
const allLayers = layer.listLayers()
|
||||||
|
|
||||||
|
allLayers.forEach(layerRecord => {
|
||||||
|
if (layerRecord.type === 'imagery' &&
|
||||||
|
layerRecord.id !== BEFORE_LAYER_ID &&
|
||||||
|
layerRecord.id !== AFTER_LAYER_ID) {
|
||||||
|
// 保存原始可见性状态
|
||||||
|
originalLayerVisibility.set(layerRecord.id, layerRecord.show)
|
||||||
|
// 隐藏图层,避免遮挡分屏效果
|
||||||
|
layer.showLayer(layerRecord.id, false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log('[useModelCompare] 已保存图层状态:',
|
||||||
|
Array.from(originalLayerVisibility.entries()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左侧:灾前影像;右侧:灾后影<E5908E><E5BDB1><EFBFBD>
|
||||||
|
console.log('[useModelCompare] 设置灾前图层为左侧...')
|
||||||
|
layer.setSplit(BEFORE_LAYER_ID, 'left')
|
||||||
|
|
||||||
|
console.log('[useModelCompare] 设置灾后图层为右侧...')
|
||||||
|
layer.setSplit(AFTER_LAYER_ID, 'right')
|
||||||
|
|
||||||
|
// 设置分割位置为中心(可以后续扩展为可拖动调整)
|
||||||
|
console.log('[useModelCompare] 设置分割位置为 0.5...')
|
||||||
|
layer.setSplitPosition(0.5)
|
||||||
|
|
||||||
|
// 直接使用新 API 设置分割位置(绕过可能的旧 API 问题)
|
||||||
|
const viewer = mapStore.viewer
|
||||||
|
if (viewer) {
|
||||||
|
// 尝试新 API
|
||||||
|
if ('splitPosition' in viewer.scene) {
|
||||||
|
viewer.scene.splitPosition = 0.5
|
||||||
|
console.log('[useModelCompare] 使用新 API: scene.splitPosition = 0.5')
|
||||||
|
}
|
||||||
|
// 兼容旧 API
|
||||||
|
if ('imagerySplitPosition' in viewer.scene) {
|
||||||
|
viewer.scene.imagerySplitPosition = 0.5
|
||||||
|
console.log('[useModelCompare] 使用旧 API: scene.imagerySplitPosition = 0.5')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[useModelCompare] viewer.scene.splitPosition:', viewer.scene.splitPosition)
|
||||||
|
console.log('[useModelCompare] viewer.scene.imagerySplitPosition:', viewer.scene.imagerySplitPosition)
|
||||||
|
|
||||||
|
// 检查所有影像图层
|
||||||
|
const imageryLayers = viewer.imageryLayers
|
||||||
|
console.log('[useModelCompare] 影像图层总数:', imageryLayers.length)
|
||||||
|
|
||||||
|
for (let i = 0; i < imageryLayers.length; i++) {
|
||||||
|
const imgLayer = imageryLayers.get(i)
|
||||||
|
console.log(`[useModelCompare] 图层 ${i}:`, {
|
||||||
|
show: imgLayer.show,
|
||||||
|
alpha: imgLayer.alpha,
|
||||||
|
splitDirection: imgLayer.splitDirection
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保两个图层都可见
|
||||||
|
console.log('[useModelCompare] 显示灾前图层...')
|
||||||
|
layer.showLayer(BEFORE_LAYER_ID, true)
|
||||||
|
|
||||||
|
console.log('[useModelCompare] 显示灾后图层...')
|
||||||
|
layer.showLayer(AFTER_LAYER_ID, true)
|
||||||
|
|
||||||
|
// 调试:检查设置后的状态
|
||||||
|
const beforeLayerAfter = layer.getLayer(BEFORE_LAYER_ID)
|
||||||
|
const afterLayerAfter = layer.getLayer(AFTER_LAYER_ID)
|
||||||
|
|
||||||
|
console.log('[useModelCompare] 设置后的灾前图层:', beforeLayerAfter)
|
||||||
|
console.log('[useModelCompare] 灾前图层 splitDirection (设置后):', beforeLayerAfter?.obj?.splitDirection)
|
||||||
|
console.log('[useModelCompare] 灾前图层 show:', beforeLayerAfter?.obj?.show)
|
||||||
|
|
||||||
|
console.log('[useModelCompare] 设置后的灾后图层:', afterLayerAfter)
|
||||||
|
console.log('[useModelCompare] 灾后图层 splitDirection (设置后):', afterLayerAfter?.obj?.splitDirection)
|
||||||
|
console.log('[useModelCompare] 灾后图层 show:', afterLayerAfter?.obj?.show)
|
||||||
|
|
||||||
|
// 再次检查所有图层的最终状态
|
||||||
|
if (viewer) {
|
||||||
|
console.log('[useModelCompare] === 最终影像图层状态 ===')
|
||||||
|
const imageryLayers = viewer.imageryLayers
|
||||||
|
for (let i = 0; i < imageryLayers.length; i++) {
|
||||||
|
const imgLayer = imageryLayers.get(i)
|
||||||
|
console.log(`[useModelCompare] 最终图层 ${i}:`, {
|
||||||
|
show: imgLayer.show,
|
||||||
|
alpha: imgLayer.alpha,
|
||||||
|
splitDirection: imgLayer.splitDirection
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 处理 3D Tiles 模型 ============
|
||||||
|
console.log('[useModelCompare] 开始处理 3D Tiles 模型分割...')
|
||||||
|
|
||||||
|
// 查找灾后模型
|
||||||
|
const afterTileset = findTilesetByConfig(viewer, 'after')
|
||||||
|
if (afterTileset) {
|
||||||
|
console.log('[useModelCompare] 找到灾后3D模型,设置为右侧显示')
|
||||||
|
afterTileset.splitDirection = Cesium.SplitDirection.RIGHT
|
||||||
|
} else {
|
||||||
|
console.warn('[useModelCompare] 未找到灾后3D模型')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找或加载灾前3D模型
|
||||||
|
let beforeTileset = findTilesetByConfig(viewer, 'before')
|
||||||
|
if (!beforeTileset) {
|
||||||
|
console.log('[useModelCompare] 加载灾前3D模型...')
|
||||||
|
beforeTileset = await load3DTileset(
|
||||||
|
viewer,
|
||||||
|
'before',
|
||||||
|
false, // 不自动缩放
|
||||||
|
Cesium.SplitDirection.LEFT // 左侧显示
|
||||||
|
)
|
||||||
|
|
||||||
|
if (beforeTileset) {
|
||||||
|
// 保存引用,用于禁用时移除
|
||||||
|
beforeTilesetRef = beforeTileset
|
||||||
|
console.log('[useModelCompare] 灾前3D模型加载成功,等待就绪...')
|
||||||
|
await waitForTilesetReady(beforeTileset)
|
||||||
|
console.log('[useModelCompare] 灾前3D模型已就绪')
|
||||||
|
} else {
|
||||||
|
console.warn('[useModelCompare] 灾前3D模型加载失败')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[useModelCompare] 找到已存在的灾前3D模型,设置为左侧显示')
|
||||||
|
beforeTileset.splitDirection = Cesium.SplitDirection.LEFT
|
||||||
|
beforeTilesetRef = beforeTileset
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 处理标记点和实体 ============
|
||||||
|
console.log('[useModelCompare] 设置所有实体为右侧显示(灾后场景)...')
|
||||||
|
setEntitiesSplitDirection(viewer, Cesium.SplitDirection.RIGHT)
|
||||||
|
|
||||||
|
isModelCompareActive.value = true
|
||||||
|
console.log('[useModelCompare] 模型对比模式已启用(包含3D模型分割和标记点)')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useModelCompare] 启用模型对比模式失败:', error)
|
||||||
|
throw new Error(`启用模型对比模式失败: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禁用模型对比模式
|
||||||
|
*
|
||||||
|
* 禁用后:
|
||||||
|
* - 取消影像分屏
|
||||||
|
* - 隐藏灾前影像
|
||||||
|
* - 保留灾后影像作为默认视图
|
||||||
|
* - 恢复其他图层的原始可见性状态
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
*/
|
||||||
|
const disableModelCompare = async () => {
|
||||||
|
if (DEBUG) console.log('[useModelCompare] 禁用模型对比模式...')
|
||||||
|
|
||||||
|
// 如果地图未就绪,仅更新状态即可
|
||||||
|
if (!mapStore.isReady()) {
|
||||||
|
isModelCompareActive.value = false
|
||||||
|
console.warn('[useModelCompare] 地图未就绪,仅更新状态')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { layer } = mapStore.services()
|
||||||
|
const viewer = mapStore.viewer
|
||||||
|
|
||||||
|
// ============ 处理影像图层 ============
|
||||||
|
// 取消影像分屏
|
||||||
|
layer.setSplit(BEFORE_LAYER_ID, 'none')
|
||||||
|
layer.setSplit(AFTER_LAYER_ID, 'none')
|
||||||
|
|
||||||
|
// 隐藏灾前图层,保留灾后图层
|
||||||
|
layer.showLayer(BEFORE_LAYER_ID, false)
|
||||||
|
layer.showLayer(AFTER_LAYER_ID, true)
|
||||||
|
|
||||||
|
// 🔧 修复:恢复其他影像图层的原始可见性状态
|
||||||
|
// 而非强制全部打开
|
||||||
|
if (originalLayerVisibility.size > 0) {
|
||||||
|
originalLayerVisibility.forEach((visible, layerId) => {
|
||||||
|
layer.showLayer(layerId, visible)
|
||||||
|
})
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log('[useModelCompare] 已恢复影像图层状态:',
|
||||||
|
Array.from(originalLayerVisibility.entries()))
|
||||||
|
}
|
||||||
|
// 清空已恢复的状态记录
|
||||||
|
originalLayerVisibility.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 处理 3D Tiles 模型 ============
|
||||||
|
console.log('[useModelCompare] 开始恢复 3D Tiles 模型状态...')
|
||||||
|
|
||||||
|
// 查找并恢复灾后模型为全屏显示
|
||||||
|
const afterTileset = findTilesetByConfig(viewer, 'after')
|
||||||
|
if (afterTileset) {
|
||||||
|
console.log('[useModelCompare] 恢复灾后3D模型为全屏显示')
|
||||||
|
afterTileset.splitDirection = Cesium.SplitDirection.NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除灾前模型
|
||||||
|
if (beforeTilesetRef) {
|
||||||
|
console.log('[useModelCompare] 移除灾前3D模型')
|
||||||
|
viewer.scene.primitives.remove(beforeTilesetRef)
|
||||||
|
beforeTilesetRef = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 处理标记点和实体 ============
|
||||||
|
console.log('[useModelCompare] 恢复所有实体为全屏显示...')
|
||||||
|
setEntitiesSplitDirection(viewer, Cesium.SplitDirection.NONE)
|
||||||
|
|
||||||
|
isModelCompareActive.value = false
|
||||||
|
if (DEBUG) console.log('[useModelCompare] 模型对比模式已禁用(包含3D模型和标记点恢复)')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useModelCompare] 禁用模型对比模式失败:', error)
|
||||||
|
throw new Error(`禁用模型对比模式失败: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换模型对比模式
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {boolean} active - true 启用,false 禁用
|
||||||
|
*/
|
||||||
|
const toggleModelCompare = async (active) => {
|
||||||
|
if (DEBUG) console.log(`[useModelCompare] 切换模型对比模式: ${active ? '启用' : '禁用'}`)
|
||||||
|
|
||||||
|
// 防止并发切换
|
||||||
|
if (isToggling.value) {
|
||||||
|
console.warn('[useModelCompare] 正在执行切换操作,忽略本次请求')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果地图未就绪,延迟执行
|
||||||
|
if (!mapStore.isReady()) {
|
||||||
|
console.warn('[useModelCompare] 地图未就绪,将在地图就绪后执行切换')
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
mapStore.onReady(() => resolve())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存之前的状态,用于错误回滚
|
||||||
|
const previousState = isModelCompareActive.value
|
||||||
|
|
||||||
|
isToggling.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (active) {
|
||||||
|
await enableModelCompare()
|
||||||
|
} else {
|
||||||
|
await disableModelCompare()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useModelCompare] 切换模型对比模式失败:', error)
|
||||||
|
// 在发生错误时恢复到之前的状态
|
||||||
|
isModelCompareActive.value = previousState
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
isToggling.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
isModelCompareActive,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
initModelCompareLayers,
|
||||||
|
enableModelCompare,
|
||||||
|
disableModelCompare,
|
||||||
|
toggleModelCompare
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useModelCompare
|
||||||
@ -11,6 +11,9 @@ export function useVideoMonitor() {
|
|||||||
// 当前选中的视频
|
// 当前选中的视频
|
||||||
const activeMonitor = ref(null)
|
const activeMonitor = ref(null)
|
||||||
|
|
||||||
|
// 全屏显示的监控(弹窗)
|
||||||
|
const zoomedMonitor = ref(null)
|
||||||
|
|
||||||
// 视频控制状态
|
// 视频控制状态
|
||||||
const controlState = ref({
|
const controlState = ref({
|
||||||
volume: 50,
|
volume: 50,
|
||||||
@ -27,8 +30,10 @@ export function useVideoMonitor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 切换喊话
|
// 切换喊话
|
||||||
const toggleMegaphone = () => {
|
const toggleMegaphone = (monitorId) => {
|
||||||
|
console.log(`Toggle megaphone for monitor: ${monitorId}`)
|
||||||
controlState.value.isMegaphoneActive = !controlState.value.isMegaphoneActive
|
controlState.value.isMegaphoneActive = !controlState.value.isMegaphoneActive
|
||||||
|
// 未来接入真实喊话系统
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换录制
|
// 切换录制
|
||||||
@ -41,20 +46,43 @@ export function useVideoMonitor() {
|
|||||||
controlState.value.volume = Math.max(0, Math.min(100, volume))
|
controlState.value.volume = Math.max(0, Math.min(100, volume))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 窗口放大
|
// 窗口放大 - 打开全屏弹窗
|
||||||
const zoomMonitor = (monitorId) => {
|
const zoomMonitor = (monitorId) => {
|
||||||
console.log(`Zoom monitor: ${monitorId}`)
|
const monitor = monitors.value.find((m) => m.id === monitorId)
|
||||||
// 实际实现可以打开全屏弹窗
|
if (monitor) {
|
||||||
|
zoomedMonitor.value = monitor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭全屏弹窗
|
||||||
|
const closeZoom = () => {
|
||||||
|
zoomedMonitor.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换音频
|
||||||
|
const toggleAudio = (monitorId) => {
|
||||||
|
console.log(`Toggle audio for monitor: ${monitorId}`)
|
||||||
|
// 未来接入真实音频控制
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视角移动(用于无人机等设备的方向控制)
|
||||||
|
const moveView = (monitorId, direction) => {
|
||||||
|
console.log(`Move view for monitor ${monitorId} to ${direction}`)
|
||||||
|
// 未来接入 3D 引擎的视角控制 API
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
monitors,
|
monitors,
|
||||||
activeMonitor,
|
activeMonitor,
|
||||||
|
zoomedMonitor,
|
||||||
controlState,
|
controlState,
|
||||||
selectMonitor,
|
selectMonitor,
|
||||||
toggleMegaphone,
|
toggleMegaphone,
|
||||||
toggleRecording,
|
toggleRecording,
|
||||||
setVolume,
|
setVolume,
|
||||||
zoomMonitor
|
zoomMonitor,
|
||||||
|
closeZoom,
|
||||||
|
toggleAudio,
|
||||||
|
moveView
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* 塌陷区域Cartesian3坐标数据
|
||||||
|
* 用于绘制模拟塌陷区域的多边形
|
||||||
|
*/
|
||||||
|
export const cesiumDataConfig = [
|
||||||
|
{
|
||||||
|
x: -1565772.4265176335,
|
||||||
|
y: 5319228.03152635,
|
||||||
|
z: 3142351.0919108186
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565776.5645109434,
|
||||||
|
y: 5319228.5289852405,
|
||||||
|
z: 3142353.766318518
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565781.4485059043,
|
||||||
|
y: 5319229.160348873,
|
||||||
|
z: 3142357.04634814
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565781.4485611639,
|
||||||
|
y: 5319229.160408792,
|
||||||
|
z: 3142357.0462827436
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565783.9295213544,
|
||||||
|
y: 5319230.931857044,
|
||||||
|
z: 3142360.517359832
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565785.9171999271,
|
||||||
|
y: 5319231.45550327,
|
||||||
|
z: 3142364.0015105833
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565788.9314495095,
|
||||||
|
y: 5319232.502729838,
|
||||||
|
z: 3142364.0468923403
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565791.8230618741,
|
||||||
|
y: 5319233.637126957,
|
||||||
|
z: 3142364.373063175
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565801.014346924,
|
||||||
|
y: 5319221.870723008,
|
||||||
|
z: 3142368.911162937
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565806.7826964892,
|
||||||
|
y: 5319216.223631968,
|
||||||
|
z: 3142369.714556013
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565806.8694812602,
|
||||||
|
y: 5319213.791221007,
|
||||||
|
z: 3142366.9345683237
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565810.4232652474,
|
||||||
|
y: 5319214.520435988,
|
||||||
|
z: 3142365.8812019615
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565818.9044999545,
|
||||||
|
y: 5319206.471469724,
|
||||||
|
z: 3142365.46267672
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565824.132179385,
|
||||||
|
y: 5319205.5100860335,
|
||||||
|
z: 3142364.2181716943
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565828.6987746847,
|
||||||
|
y: 5319206.642060814,
|
||||||
|
z: 3142363.2934198803
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565831.2842223754,
|
||||||
|
y: 5319207.347425584,
|
||||||
|
z: 3142362.910084223
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565833.813719043,
|
||||||
|
y: 5319200.401086703,
|
||||||
|
z: 3142360.085581532
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565832.4401977719,
|
||||||
|
y: 5319198.175732172,
|
||||||
|
z: 3142355.2529904097
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565824.6420061626,
|
||||||
|
y: 5319200.200059024,
|
||||||
|
z: 3142355.81880074
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565816.4714012512,
|
||||||
|
y: 5319202.30023816,
|
||||||
|
z: 3142355.237709711
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565808.698851924,
|
||||||
|
y: 5319204.528581392,
|
||||||
|
z: 3142353.8670113366
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565799.9900720564,
|
||||||
|
y: 5319207.449676356,
|
||||||
|
z: 3142352.1399200936
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565793.8226583572,
|
||||||
|
y: 5319209.362596558,
|
||||||
|
z: 3142349.7282765335
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565786.7522616626,
|
||||||
|
y: 5319212.540199659,
|
||||||
|
z: 3142346.4677125285
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565782.3578206634,
|
||||||
|
y: 5319214.380562645,
|
||||||
|
z: 3142344.6796676633
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565777.7605888362,
|
||||||
|
y: 5319216.58142275,
|
||||||
|
z: 3142342.3438176387
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565772.2429871338,
|
||||||
|
y: 5319219.072527765,
|
||||||
|
z: 3142339.9103942616
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565772.2000471132,
|
||||||
|
y: 5319222.72989916,
|
||||||
|
z: 3142343.610127874
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: -1565772.2100830332,
|
||||||
|
y: 5319225.49623822,
|
||||||
|
z: 3142347.3184587173
|
||||||
|
}
|
||||||
|
]
|
||||||
@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* 模型对比功能配置
|
||||||
|
*
|
||||||
|
* 用于配置灾前/灾后影像数据源和 3D Tiles 模型
|
||||||
|
* 支持不同环境使用不同的影像服务和 3D 模型
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 灾前 3D Tiles 配置
|
||||||
|
*
|
||||||
|
* 当前使用灾后模型作为占位数据(因为缺少真实的灾前模型)
|
||||||
|
* 实际部署时应替换为真实的灾前 3D Tiles 模型
|
||||||
|
*/
|
||||||
|
export const BEFORE_3DTILES_CONFIG = {
|
||||||
|
// 模型唯一标识
|
||||||
|
id: 'model-compare-before-3dtiles',
|
||||||
|
|
||||||
|
// 模型名称
|
||||||
|
name: '灾前3D模型',
|
||||||
|
|
||||||
|
// 3D Tiles 服务 URL
|
||||||
|
// TODO: 替换为实际的灾前模型 URL
|
||||||
|
url: 'http://222.212.85.86:9000/300bdf2b-a150-406e-be63-d28bd29b409f/model/S107/terra_b3dms/tileset.json',
|
||||||
|
|
||||||
|
// 默认可见性
|
||||||
|
visible: false,
|
||||||
|
|
||||||
|
// 模型说明
|
||||||
|
description: '灾害发生前的 3D 模型数据,用于对比展示灾害造成的地形变化'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 灾后 3D Tiles 配置
|
||||||
|
*
|
||||||
|
* 当前使用实际的灾后模型数据
|
||||||
|
*/
|
||||||
|
export const AFTER_3DTILES_CONFIG = {
|
||||||
|
// 模型唯一标识
|
||||||
|
id: 'model-compare-after-3dtiles',
|
||||||
|
|
||||||
|
// 模型名称
|
||||||
|
name: '灾后3D模型',
|
||||||
|
|
||||||
|
// 3D Tiles 服务 URL
|
||||||
|
url: 'http://222.212.85.86:9000/300bdf2b-a150-406e-be63-d28bd29b409f/model/S107/terra_b3dms/tileset.json',
|
||||||
|
|
||||||
|
// 默认可见性(初始化时灾后模型默认显示)
|
||||||
|
visible: true,
|
||||||
|
|
||||||
|
// 模型说明
|
||||||
|
description: '灾害发生后的 3D 模型数据,展示灾害现场实际情况'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 灾前影像配置
|
||||||
|
*
|
||||||
|
* 当前使用 OpenStreetMap 作为占位数据
|
||||||
|
* 实际部署时应替换为真实的灾前影像服务
|
||||||
|
*/
|
||||||
|
export const BEFORE_IMAGERY_CONFIG = {
|
||||||
|
// 图层唯一标识
|
||||||
|
id: 'model-compare-before',
|
||||||
|
|
||||||
|
// 图层名称
|
||||||
|
name: '灾前影像',
|
||||||
|
|
||||||
|
// 影像服务URL
|
||||||
|
// 格式:支持标准瓦片服务的URL模板,{z}/{x}/{y} 为瓦片坐标占位符
|
||||||
|
// url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
|
||||||
|
// 图层类型
|
||||||
|
type: 'UrlTemplate',
|
||||||
|
|
||||||
|
// 默认可见性
|
||||||
|
visible: false,
|
||||||
|
|
||||||
|
// 投影信息
|
||||||
|
projection: 'EPSG:3857', // Web Mercator
|
||||||
|
|
||||||
|
// 最大缩放级别
|
||||||
|
maximumLevel: 18,
|
||||||
|
|
||||||
|
// 图层说明
|
||||||
|
description: '灾害发生前的影像数据,用于对比展示灾害造成的变化'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 灾后影像配置
|
||||||
|
*
|
||||||
|
* 当前使用 Esri World Imagery 作为占位数据
|
||||||
|
* 实际部署时应替换为真实的灾后影像服务
|
||||||
|
*/
|
||||||
|
export const AFTER_IMAGERY_CONFIG = {
|
||||||
|
// 图层唯一标识
|
||||||
|
id: 'model-compare-after',
|
||||||
|
|
||||||
|
// 图层名称
|
||||||
|
name: '灾后影像',
|
||||||
|
|
||||||
|
// 影像服务URL
|
||||||
|
// Esri World Imagery 服务
|
||||||
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
|
||||||
|
// 图层类型
|
||||||
|
type: 'UrlTemplate',
|
||||||
|
|
||||||
|
// 默认可见性(初始化时灾后影像默认显示)
|
||||||
|
visible: true,
|
||||||
|
|
||||||
|
// 投影信息
|
||||||
|
projection: 'EPSG:3857', // Web Mercator
|
||||||
|
|
||||||
|
// 最大缩放级别
|
||||||
|
maximumLevel: 19,
|
||||||
|
|
||||||
|
// 图层说明
|
||||||
|
description: '灾害发生后的影像数据,展示灾害现场实际情况'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分屏配置
|
||||||
|
*/
|
||||||
|
export const SPLIT_CONFIG = {
|
||||||
|
// 默认分割位置(0-1之间,0.5表示屏幕中央)
|
||||||
|
defaultPosition: 0.5,
|
||||||
|
|
||||||
|
// 分割线最小位置(防止完全遮挡)
|
||||||
|
minPosition: 0.05,
|
||||||
|
|
||||||
|
// 分割线最大位置
|
||||||
|
maxPosition: 0.95
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 环境特定配置
|
||||||
|
*
|
||||||
|
* 可根据不同环境(开发/测试/生产)使用不同的影像服务和 3D 模型
|
||||||
|
*/
|
||||||
|
const ENV_CONFIGS = {
|
||||||
|
// 开发环境
|
||||||
|
development: {
|
||||||
|
before: BEFORE_IMAGERY_CONFIG,
|
||||||
|
after: AFTER_IMAGERY_CONFIG,
|
||||||
|
before3DTiles: BEFORE_3DTILES_CONFIG,
|
||||||
|
after3DTiles: AFTER_3DTILES_CONFIG
|
||||||
|
},
|
||||||
|
|
||||||
|
// 生产环境(示例:使用自有服务器的影像数据和 3D 模型)
|
||||||
|
production: {
|
||||||
|
before: {
|
||||||
|
...BEFORE_IMAGERY_CONFIG,
|
||||||
|
// 生产环境可以覆盖URL
|
||||||
|
// url: 'https://your-server.com/tiles/before/{z}/{x}/{y}.png'
|
||||||
|
},
|
||||||
|
after: {
|
||||||
|
...AFTER_IMAGERY_CONFIG,
|
||||||
|
// url: 'https://your-server.com/tiles/after/{z}/{x}/{y}.png'
|
||||||
|
},
|
||||||
|
before3DTiles: {
|
||||||
|
...BEFORE_3DTILES_CONFIG,
|
||||||
|
// url: 'https://your-server.com/3dtiles/before/tileset.json'
|
||||||
|
},
|
||||||
|
after3DTiles: {
|
||||||
|
...AFTER_3DTILES_CONFIG,
|
||||||
|
// url: 'https://your-server.com/3dtiles/after/tileset.json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前环境的配置
|
||||||
|
*/
|
||||||
|
export function getModelCompareConfig() {
|
||||||
|
const env = import.meta.env.MODE || 'development'
|
||||||
|
return ENV_CONFIGS[env] || ENV_CONFIGS.development
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据源替换指南
|
||||||
|
*
|
||||||
|
* 1. 准备影像数据:
|
||||||
|
* - 灾前影像:历史卫星影像或航拍影像
|
||||||
|
* - 灾后影像:事故发生后的实时影像
|
||||||
|
*
|
||||||
|
* 2. 发布瓦片服务:
|
||||||
|
* - 使用 GeoServer / ArcGIS Server / TileServer 等发布瓦片服务
|
||||||
|
* - 确保服务支持 CORS 跨域访问
|
||||||
|
* - 推荐使用 EPSG:3857 投影(Web Mercator)
|
||||||
|
*
|
||||||
|
* 3. 更新配置:
|
||||||
|
* - 修改上述 BEFORE_IMAGERY_CONFIG 和 AFTER_IMAGERY_CONFIG 中的 url 字段
|
||||||
|
* - 根据实际服务调整 maximumLevel 等参数
|
||||||
|
*
|
||||||
|
* 4. 性能优化:
|
||||||
|
* - 使用 CDN 加速瓦片服务
|
||||||
|
* - 预生成常用缩放级别的瓦片
|
||||||
|
* - 添加瓦片缓存机制
|
||||||
|
*/
|
||||||
@ -16,36 +16,56 @@ export const VIDEO_MONITORS = [
|
|||||||
id: 1,
|
id: 1,
|
||||||
type: VIDEO_TYPES.PERSONNEL,
|
type: VIDEO_TYPES.PERSONNEL,
|
||||||
title: '单兵(张三三)设备视角',
|
title: '单兵(张三三)设备视角',
|
||||||
|
videoSrc: '/videos/personnel-001.mp4', // 视频源路径
|
||||||
|
dateRange: '2025/9/1-2025/12/1', // 日期范围
|
||||||
hasAudio: true,
|
hasAudio: true,
|
||||||
hasMegaphone: true,
|
hasMegaphone: true,
|
||||||
hasZoom: true
|
hasZoom: true,
|
||||||
|
hasDirectionControl: false // 是否显示方向控制(操作台)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
type: VIDEO_TYPES.DRONE,
|
type: VIDEO_TYPES.DRONE,
|
||||||
title: '无人机(001)视角',
|
title: '无人机(001)视角',
|
||||||
|
videoSrc: '/videos/drone-001.mp4',
|
||||||
|
dateRange: '2025/9/1-2025/12/1',
|
||||||
hasAudio: false,
|
hasAudio: false,
|
||||||
hasMegaphone: true,
|
hasMegaphone: true,
|
||||||
hasZoom: true
|
hasZoom: true,
|
||||||
|
hasDirectionControl: true // 无人机有方向控制
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
type: VIDEO_TYPES.VEHICLE_EXTERNAL,
|
type: VIDEO_TYPES.VEHICLE_EXTERNAL,
|
||||||
title: '指挥车外部视角',
|
title: '指挥车外部视角',
|
||||||
|
videoSrc: '/videos/vehicle-external-001.mp4',
|
||||||
|
dateRange: '2025/9/1-2025/12/1',
|
||||||
hasAudio: true,
|
hasAudio: true,
|
||||||
hasMegaphone: true,
|
hasMegaphone: true,
|
||||||
hasZoom: true
|
hasZoom: true,
|
||||||
|
hasDirectionControl: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
type: VIDEO_TYPES.VEHICLE_MEETING,
|
type: VIDEO_TYPES.VEHICLE_MEETING,
|
||||||
title: '指挥车会议视角',
|
title: '指挥车会议视角',
|
||||||
|
videoSrc: '/videos/vehicle-meeting-001.mp4',
|
||||||
|
dateRange: '2025/9/1-2025/12/1',
|
||||||
hasAudio: true,
|
hasAudio: true,
|
||||||
hasMegaphone: true,
|
hasMegaphone: true,
|
||||||
hasZoom: true
|
hasZoom: true,
|
||||||
|
hasDirectionControl: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 方向枚举(用于视角控制)
|
||||||
|
export const DIRECTIONS = {
|
||||||
|
LEFT: 'left',
|
||||||
|
RIGHT: 'right',
|
||||||
|
UP: 'up',
|
||||||
|
DOWN: 'down'
|
||||||
|
}
|
||||||
|
|
||||||
// 现场设备标签页
|
// 现场设备标签页
|
||||||
export const DISPATCH_TABS = [
|
export const DISPATCH_TABS = [
|
||||||
{ key: 'personnel', label: '现场单兵', count: 23 },
|
{ key: 'personnel', label: '现场单兵', count: 23 },
|
||||||
|
|||||||
@ -6,8 +6,26 @@
|
|||||||
<!-- 主内容区域 -->
|
<!-- 主内容区域 -->
|
||||||
<div class="situational-awareness__main">
|
<div class="situational-awareness__main">
|
||||||
<!-- 地图底层 -->
|
<!-- 地图底层 -->
|
||||||
<div class="situational-awareness__map-layer">
|
<div
|
||||||
<MapViewer />
|
class="situational-awareness__map-layer"
|
||||||
|
:class="{ 'is-compare-mode': isCompareMode }"
|
||||||
|
>
|
||||||
|
<!-- 左侧地图容器(对比模式下显示灾前场景) -->
|
||||||
|
<div
|
||||||
|
id="leftCesiumContainer"
|
||||||
|
class="situational-awareness__left-map"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- 中间分割线 -->
|
||||||
|
<div
|
||||||
|
v-if="isCompareMode"
|
||||||
|
class="situational-awareness__center-divider"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- 右侧地图(主地图 - 灾后场景) -->
|
||||||
|
<div class="situational-awareness__right-map">
|
||||||
|
<MapViewer @tool-change="handleMapToolChange" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 地图遮罩层 -->
|
<!-- 地图遮罩层 -->
|
||||||
@ -15,11 +33,18 @@
|
|||||||
|
|
||||||
<!-- 浮动面板层 -->
|
<!-- 浮动面板层 -->
|
||||||
<div class="situational-awareness__panels-layer">
|
<div class="situational-awareness__panels-layer">
|
||||||
<div class="situational-awareness__panel-column situational-awareness__panel-column--left">
|
<div
|
||||||
|
class="situational-awareness__panel-column situational-awareness__panel-column--left"
|
||||||
|
>
|
||||||
<LeftPanel />
|
<LeftPanel />
|
||||||
</div>
|
</div>
|
||||||
<div class="situational-awareness__center-spacer" aria-hidden="true"></div>
|
<div
|
||||||
<div class="situational-awareness__panel-column situational-awareness__panel-column--right">
|
class="situational-awareness__center-spacer"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="situational-awareness__panel-column situational-awareness__panel-column--right"
|
||||||
|
>
|
||||||
<RightPanel />
|
<RightPanel />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -28,6 +53,31 @@
|
|||||||
<div class="situational-awareness__controls-layer">
|
<div class="situational-awareness__controls-layer">
|
||||||
<div id="sa-controls" class="situational-awareness__controls"></div>
|
<div id="sa-controls" class="situational-awareness__controls"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 地图 Tooltip 层 - 用于显示地图标记点的轻量级信息提示框 -->
|
||||||
|
<div class="situational-awareness__tooltip-layer">
|
||||||
|
<MapTooltip
|
||||||
|
v-model:visible="mapTooltip.visible"
|
||||||
|
:x="mapTooltip.x"
|
||||||
|
:y="mapTooltip.y"
|
||||||
|
:title="mapTooltip.title"
|
||||||
|
:icon="mapTooltip.icon"
|
||||||
|
:z-index="mapTooltip.zIndex"
|
||||||
|
@close="handleMapTooltipClose"
|
||||||
|
>
|
||||||
|
<!-- Tooltip 内容插槽 - 根据实际业务数据渲染 -->
|
||||||
|
<template v-if="mapTooltip.data && mapTooltip.data.fields">
|
||||||
|
<div
|
||||||
|
v-for="(field, index) in mapTooltip.data.fields"
|
||||||
|
:key="index"
|
||||||
|
class="tooltip-field-item"
|
||||||
|
>
|
||||||
|
<span class="tooltip-field-label">{{ field.label }}:</span>
|
||||||
|
<span class="tooltip-field-value">{{ field.value }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</MapTooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 弹窗组件 -->
|
<!-- 弹窗组件 -->
|
||||||
@ -47,96 +97,463 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, provide, onMounted } from 'vue'
|
import { ref, provide, onMounted } from "vue";
|
||||||
import PageHeader from './components/PageHeader.vue'
|
import * as Cesium from "cesium";
|
||||||
import LeftPanel from './components/LeftPanel/index.vue'
|
import { ElMessage } from "element-plus";
|
||||||
import MapViewer from './components/MapViewer/index.vue'
|
import PageHeader from "./components/PageHeader.vue";
|
||||||
import RightPanel from './components/RightPanel/index.vue'
|
import LeftPanel from "./components/LeftPanel/index.vue";
|
||||||
import PersonnelDetail from './components/Popups/PersonnelDetail.vue'
|
import MapViewer from "./components/MapViewer/index.vue";
|
||||||
import EmergencyCenterDetail from './components/Popups/EmergencyCenterDetail.vue'
|
import RightPanel from "./components/RightPanel/index.vue";
|
||||||
import { useDisasterData } from './composables/useDisasterData'
|
import PersonnelDetail from "./components/Popups/PersonnelDetail.vue";
|
||||||
import { useMapStore } from '@/map'
|
import EmergencyCenterDetail from "./components/Popups/EmergencyCenterDetail.vue";
|
||||||
|
import MapTooltip from "./components/shared/MapTooltip.vue";
|
||||||
|
import { useDisasterData } from "./composables/useDisasterData";
|
||||||
|
import { useDualMapCompare } from "./composables/useDualMapCompare";
|
||||||
|
import { useMapMarkers } from "./composables/useMapMarkers";
|
||||||
|
import { use3DTiles } from "./composables/use3DTiles";
|
||||||
|
import { useMapStore } from "@/map";
|
||||||
|
import { request } from "@shared/utils/request";
|
||||||
|
|
||||||
|
// 标记点
|
||||||
|
import emergencyCenterIcon from "./assets/images/应急中心.png";
|
||||||
|
|
||||||
// 使用灾害数据
|
// 使用灾害数据
|
||||||
const disasterData = useDisasterData()
|
const disasterData = useDisasterData();
|
||||||
|
|
||||||
|
// 处理距离范围变更
|
||||||
|
const handleDistanceChange = async (newDistance) => {
|
||||||
|
console.log(`[index.vue] 距离范围改变为: ${newDistance}km`);
|
||||||
|
|
||||||
|
// 更新搜索半径
|
||||||
|
disasterData.updateSearchRadius(newDistance);
|
||||||
|
|
||||||
|
// 重新加载应急资源数据并更新地图标记
|
||||||
|
await loadEmergencyResources(108.011506, 30.175827);
|
||||||
|
};
|
||||||
|
|
||||||
// 提供给子组件使用
|
// 提供给子组件使用
|
||||||
provide('disasterData', disasterData)
|
provide("disasterData", disasterData);
|
||||||
|
provide("onDistanceChange", handleDistanceChange);
|
||||||
|
|
||||||
// 地图 store
|
// 地图 store
|
||||||
const mapStore = useMapStore()
|
const mapStore = useMapStore();
|
||||||
|
|
||||||
|
// 双地图对比功能
|
||||||
|
const { isCompareMode, toggleCompareMode } = useDualMapCompare();
|
||||||
|
|
||||||
|
// 地图标记功能
|
||||||
|
const {
|
||||||
|
initializeMarkers,
|
||||||
|
clearMarkers,
|
||||||
|
getCollapseCenter,
|
||||||
|
addEmergencyResourceMarkers,
|
||||||
|
clearEmergencyResourceMarkers,
|
||||||
|
} = useMapMarkers();
|
||||||
|
|
||||||
|
// 3D Tiles加载功能
|
||||||
|
const { load3DTileset, waitForTilesetReady } = use3DTiles();
|
||||||
|
|
||||||
// 初始化地图
|
// 初始化地图
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 等待地图就绪后配置初始视图
|
// 等待地图就绪后配置初始视图和模型对比图层
|
||||||
mapStore.onReady(() => {
|
mapStore.onReady(async () => {
|
||||||
const { camera } = mapStore.services()
|
const { camera } = mapStore.services();
|
||||||
|
const viewer = mapStore.viewer;
|
||||||
|
|
||||||
// 设置初始相机位置(以重庆忠县为例,可根据实际需求调整)
|
console.log("3D态势感知地图已就绪");
|
||||||
// 经度: 108.0, 纬度: 30.3, 高度: 50000 米
|
|
||||||
camera.setCenter(108.0, 30.3, 50000)
|
|
||||||
|
|
||||||
console.log('3D态势感知地图已就绪')
|
// 默认相机配置
|
||||||
})
|
const DEFAULT_CAMERA_VIEW = {
|
||||||
})
|
lon: 108.011506,
|
||||||
|
lat: 30.175827,
|
||||||
|
height: 5000,
|
||||||
|
heading: 0,
|
||||||
|
pitch: -45,
|
||||||
|
roll: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 默认点加上图标标记,使用图片图标,图标路径为 packages\screen\src\views\3DSituationalAwarenessRefactor\assets\images\应急基地.png
|
||||||
|
const defaultPoint = new Cesium.Entity({
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(
|
||||||
|
DEFAULT_CAMERA_VIEW.lon,
|
||||||
|
DEFAULT_CAMERA_VIEW.lat,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
billboard: {
|
||||||
|
image: emergencyCenterIcon,
|
||||||
|
width: 36,
|
||||||
|
height: 40,
|
||||||
|
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||||
|
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
viewer.entities.add(defaultPoint);
|
||||||
|
|
||||||
|
// camera.setView({
|
||||||
|
// ...DEFAULT_CAMERA_VIEW,
|
||||||
|
// });
|
||||||
|
camera.flyTo({
|
||||||
|
destination: defaultPoint.position,
|
||||||
|
duration: 1,
|
||||||
|
});
|
||||||
|
// 延迟 1000ms 后设置相机到默认位置
|
||||||
|
setTimeout(() => {
|
||||||
|
camera.flyTo({
|
||||||
|
destination: defaultPoint.position,
|
||||||
|
duration: 1,
|
||||||
|
});
|
||||||
|
}, 5000);
|
||||||
|
return;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置相机到指定的笛卡尔坐标
|
||||||
|
* @param {Cesium.Cartesian3 | null} cartesian - 目标位置
|
||||||
|
* @returns {boolean} 是否成功设置
|
||||||
|
*/
|
||||||
|
const focusCameraOnCartesian = (cartesian) => {
|
||||||
|
if (!cartesian) return false;
|
||||||
|
|
||||||
|
const cartographic = Cesium.Cartographic.fromCartesian(cartesian);
|
||||||
|
const centerLon = Cesium.Math.toDegrees(cartographic.longitude);
|
||||||
|
const centerLat = Cesium.Math.toDegrees(cartographic.latitude);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`设置相机对准塌陷区域中心: 经度 ${centerLon.toFixed(
|
||||||
|
6
|
||||||
|
)}, 纬度 ${centerLat.toFixed(6)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
camera.setView({
|
||||||
|
...DEFAULT_CAMERA_VIEW,
|
||||||
|
lon: centerLon,
|
||||||
|
lat: centerLat,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 步骤1: 提前获取塌陷区域中心点(不依赖地形加载)
|
||||||
|
// 如果成功获取,先设置相机对准该区域,让用户看到目标区域
|
||||||
|
const collapseCenter = getCollapseCenter();
|
||||||
|
let hasCameraTarget = focusCameraOnCartesian(collapseCenter);
|
||||||
|
|
||||||
|
if (!hasCameraTarget) {
|
||||||
|
console.warn("无法获取塌陷区域中心,使用默认相机位置");
|
||||||
|
camera.setView(DEFAULT_CAMERA_VIEW);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤2: 加载3D Tiles(灾后场景)并等待完全就绪
|
||||||
|
// 这一步至关重要,必须等待瓦片加载完成后才能准确采样地面高度
|
||||||
|
try {
|
||||||
|
console.log("[index.vue] 开始加载3D模型...");
|
||||||
|
const afterTileset = await load3DTileset(viewer, "after", false);
|
||||||
|
|
||||||
|
if (afterTileset) {
|
||||||
|
console.log("[index.vue] 等待3D模型完全就绪(包括首批瓦片)...");
|
||||||
|
await waitForTilesetReady(afterTileset);
|
||||||
|
console.log("[index.vue] 3D模型已完全就绪");
|
||||||
|
} else {
|
||||||
|
console.warn("[index.vue] 3D模型加载返回 null");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[index.vue] 3D模型加载失败:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤3: 初始化地图标记(单兵、设备、应急基地等)
|
||||||
|
// 此时3D Tiles已加载完成,可以安全添加标记
|
||||||
|
try {
|
||||||
|
console.log("[index.vue] 开始初始化地图标记...");
|
||||||
|
const sampledCollapseCenter = await initializeMarkers(viewer, {
|
||||||
|
useSampledHeights: true, // 使用采样高度,确保标记位置准确
|
||||||
|
heightOffset: 10, // 标记相对地面10米
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果之前没有设置相机(配置数据缺失),现在再次尝试
|
||||||
|
if (!hasCameraTarget && sampledCollapseCenter) {
|
||||||
|
hasCameraTarget = focusCameraOnCartesian(sampledCollapseCenter);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[index.vue] 地图标记初始化完成");
|
||||||
|
// camera.setView({
|
||||||
|
// ...DEFAULT_CAMERA_VIEW,
|
||||||
|
// })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[index.vue] 地图标记初始化失败:", error);
|
||||||
|
// 即使标记初始化失败,也要确保相机位置正确
|
||||||
|
if (!hasCameraTarget) {
|
||||||
|
console.warn("[index.vue] 标记初始化失败且无相机目标,使用默认位置");
|
||||||
|
// camera.setView(DEFAULT_CAMERA_VIEW)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 根据经纬度加载养护站数据 /disaster/matchEmergencyResources
|
||||||
|
const loadEmergencyResources = async (longitude, latitude) => {
|
||||||
|
try {
|
||||||
|
const response = await request({
|
||||||
|
url: `/snow-ops-platform/disaster/matchEmergencyResources`,
|
||||||
|
method: "GET",
|
||||||
|
params: {
|
||||||
|
longitude,
|
||||||
|
latitude,
|
||||||
|
maxDistance: disasterData.forcePreset.value.searchRadius,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.data) {
|
||||||
|
// 更新力量预置数据
|
||||||
|
disasterData.updateForcePreset(response.data);
|
||||||
|
console.log("[index.vue] 应急资源数据加载成功:", response.data);
|
||||||
|
|
||||||
|
// 更新地图标记
|
||||||
|
if (mapStore.viewer) {
|
||||||
|
console.log("[index.vue] 更新地图应急资源标记...");
|
||||||
|
|
||||||
|
// 清除旧的应急资源标记
|
||||||
|
clearEmergencyResourceMarkers(mapStore.viewer);
|
||||||
|
|
||||||
|
// 添加新的应急资源标记
|
||||||
|
await addEmergencyResourceMarkers(
|
||||||
|
mapStore.viewer,
|
||||||
|
response.data,
|
||||||
|
{ longitude, latitude },
|
||||||
|
{ heightOffset: 10 }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.warn("[index.vue] 地图viewer未就绪,跳过标记更新");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("[index.vue] 应急资源接口返回数据为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[index.vue] 加载应急资源数据失败:", error);
|
||||||
|
ElMessage.warning({
|
||||||
|
message: "应急资源数据加载失败,使用默认数据",
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 加载应急资源数据(使用默认灾害点坐标)
|
||||||
|
// await loadEmergencyResources(108.011506, 30.175827);
|
||||||
|
const response = await loadEmergencyResources(108.011506, 30.175827);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理地图工具变化事件
|
||||||
|
* 目前主要处理"模型对比"工具的切换
|
||||||
|
*
|
||||||
|
* @param {Object} payload - 工具变化事件载荷
|
||||||
|
* @param {string} payload.tool - 工具标识
|
||||||
|
* @param {boolean} payload.active - 工具是否激活
|
||||||
|
*/
|
||||||
|
const handleMapToolChange = async ({ tool, active }) => {
|
||||||
|
console.log(`地图工具变化: ${tool}, 激活状态: ${active}`);
|
||||||
|
|
||||||
|
if (tool === "modelCompare") {
|
||||||
|
try {
|
||||||
|
// 显示加载提示
|
||||||
|
const loadingMessage = ElMessage({
|
||||||
|
message: active ? "正在启用模型对比..." : "正在关闭模型对比...",
|
||||||
|
type: "info",
|
||||||
|
duration: 0,
|
||||||
|
showClose: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用新的双地图对比模式
|
||||||
|
await toggleCompareMode(active, mapStore.viewer);
|
||||||
|
|
||||||
|
// 关闭加载提示
|
||||||
|
loadingMessage.close();
|
||||||
|
|
||||||
|
// 显示成功提示
|
||||||
|
ElMessage.success(active ? "模型对比已启用" : "模型对比已关闭");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("切换模型对比模式失败:", error);
|
||||||
|
|
||||||
|
// 显示错误提示
|
||||||
|
ElMessage.error({
|
||||||
|
message: `切换模型对比失败: ${error.message || "未知错误"}`,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他工具的处理可以在这里扩展
|
||||||
|
// if (tool === 'measure') { ... }
|
||||||
|
};
|
||||||
|
|
||||||
// 弹窗状态
|
// 弹窗状态
|
||||||
const showPersonnelDetail = ref(false)
|
const showPersonnelDetail = ref(false);
|
||||||
const showCenterDetail = ref(false)
|
const showCenterDetail = ref(false);
|
||||||
|
|
||||||
// 选中的数据
|
// 选中的数据
|
||||||
const selectedPersonnel = ref({
|
const selectedPersonnel = ref({
|
||||||
name: '张强',
|
name: "张强",
|
||||||
department: '安全生产部',
|
department: "安全生产部",
|
||||||
distance: 0.6,
|
distance: 0.6,
|
||||||
estimatedArrival: 10,
|
estimatedArrival: 10,
|
||||||
avatar: null
|
avatar: null,
|
||||||
})
|
});
|
||||||
|
|
||||||
const selectedCenter = ref({
|
const selectedCenter = ref({
|
||||||
name: '忠县应急中心',
|
name: "忠县应急中心",
|
||||||
adminLevel: '国道',
|
adminLevel: "国道",
|
||||||
department: '交通公路部门',
|
department: "交通公路部门",
|
||||||
distance: 0.6,
|
distance: 0.6,
|
||||||
image: null
|
image: null,
|
||||||
})
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地图 Tooltip 状态管理
|
||||||
|
* 用于显示地图标记点的轻量级信息提示框
|
||||||
|
*/
|
||||||
|
const mapTooltip = ref({
|
||||||
|
visible: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
title: "",
|
||||||
|
icon: "",
|
||||||
|
zIndex: 20, // 高于地图控件层,低于全屏弹窗
|
||||||
|
data: null, // 业务数据,用于内容插槽渲染
|
||||||
|
});
|
||||||
|
|
||||||
// 返回驾驶舱
|
// 返回驾驶舱
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
console.log('返回驾驶舱')
|
console.log("返回驾驶舱");
|
||||||
// 实际实现:路由跳转
|
// 实际实现:路由跳转
|
||||||
// router.push('/cockpit')
|
// router.push('/cockpit')
|
||||||
}
|
};
|
||||||
|
|
||||||
// 处理人员联动
|
// 处理人员联动
|
||||||
const handlePersonnelLink = (personnel) => {
|
const handlePersonnelLink = (personnel) => {
|
||||||
console.log('联动人员:', personnel)
|
console.log("联动人员:", personnel);
|
||||||
// 实际实现:使用 mapStore 的 camera service 飞行到人员位置
|
// 实际实现:使用 mapStore 的 camera service 飞行到人员位置
|
||||||
// const { camera } = mapStore.services()
|
// const { camera } = mapStore.services()
|
||||||
// camera.flyTo({ destination: [lon, lat, height] })
|
// camera.flyTo({ destination: [lon, lat, height] })
|
||||||
showPersonnelDetail.value = false
|
showPersonnelDetail.value = false;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭地图 Tooltip
|
||||||
|
* 统一的关闭入口,便于后续扩展埋点或联动逻辑
|
||||||
|
*/
|
||||||
|
const handleMapTooltipClose = () => {
|
||||||
|
mapTooltip.value.visible = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在指定屏幕坐标显示地图 Tooltip
|
||||||
|
*
|
||||||
|
* 使用场景:
|
||||||
|
* - 当用户点击地图上的标记点时调用此方法
|
||||||
|
* - 调用方需要将地图实体的经纬度转换为屏幕坐标
|
||||||
|
*
|
||||||
|
* @typedef {Object} MapTooltipField
|
||||||
|
* @property {string} label - 字段标签
|
||||||
|
* @property {string} value - 字段值
|
||||||
|
*
|
||||||
|
* @typedef {Object} MapTooltipData
|
||||||
|
* @property {MapTooltipField[]} fields - 字段列表
|
||||||
|
*
|
||||||
|
* @param {Object} options - Tooltip 配置选项
|
||||||
|
* @param {number} options.x - 屏幕 X 坐标(像素),相对于地图容器左上角
|
||||||
|
* @param {number} options.y - 屏幕 Y 坐标(像素),相对于地图容器左上角
|
||||||
|
* @param {string} [options.title=''] - Tooltip 标题文本
|
||||||
|
* @param {string} [options.icon=''] - 标题左侧图标的图片路径
|
||||||
|
* @param {MapTooltipData} [options.data=null] - 业务数据,用于内容插槽渲染
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 当点击地图实体时
|
||||||
|
* const screenPos = mapStore.worldToScreen(entity.position)
|
||||||
|
* showMapTooltip({
|
||||||
|
* x: screenPos.x,
|
||||||
|
* y: screenPos.y,
|
||||||
|
* title: '应急中心',
|
||||||
|
* icon: emergencyCenterIcon,
|
||||||
|
* data: {
|
||||||
|
* fields: [
|
||||||
|
* { label: '名称', value: '忠县应急中心' },
|
||||||
|
* { label: '行政等级', value: '国道' },
|
||||||
|
* { label: '隶属单位', value: '交通公路部门' }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
*/
|
||||||
|
const showMapTooltip = ({ x, y, title = "", icon = "", data = null }) => {
|
||||||
|
const state = mapTooltip.value;
|
||||||
|
state.visible = true;
|
||||||
|
state.x = x;
|
||||||
|
state.y = y;
|
||||||
|
state.title = title;
|
||||||
|
state.icon = icon;
|
||||||
|
state.data = data;
|
||||||
|
// zIndex 保持不变,无需重新赋值
|
||||||
|
};
|
||||||
|
|
||||||
// TODO: 实现地图实体点击事件监听
|
// TODO: 实现地图实体点击事件监听
|
||||||
// 当用户点击地图上的标记点时,显示对应的详情弹窗
|
// 当用户点击地图上的标记点时,显示 Tooltip 或详情弹窗
|
||||||
|
//
|
||||||
|
// 集成示例(需要先在文件顶部导入图标):
|
||||||
|
// import personnelIcon from './assets/images/personnel-icon.png'
|
||||||
|
// import centerIcon from './assets/images/center-icon.png'
|
||||||
|
//
|
||||||
// mapStore.onReady(() => {
|
// mapStore.onReady(() => {
|
||||||
// const { query } = mapStore.services()
|
// const { query } = mapStore.services()
|
||||||
|
//
|
||||||
// // 监听实体点击事件
|
// // 监听实体点击事件
|
||||||
// query.onEntityClick((entity) => {
|
// query.onEntityClick((entity) => {
|
||||||
|
// // 1. 将实体位置转换为屏幕坐标
|
||||||
|
// // 注意:具体 API 取决于你使用的地图引擎(Cesium/Mapbox/etc.)
|
||||||
|
// const screenPos = mapStore.worldToScreen(entity.position)
|
||||||
|
//
|
||||||
|
// // 2. 显示 Tooltip 展示基本信息
|
||||||
// if (entity.type === 'personnel') {
|
// if (entity.type === 'personnel') {
|
||||||
// selectedPersonnel.value = entity.properties
|
// showMapTooltip({
|
||||||
// showPersonnelDetail.value = true
|
// x: screenPos.x,
|
||||||
// } else if (entity.type === 'center') {
|
// y: screenPos.y,
|
||||||
// selectedCenter.value = entity.properties
|
// title: '应急人员',
|
||||||
// showCenterDetail.value = true
|
// icon: personnelIcon,
|
||||||
|
// data: {
|
||||||
|
// fields: [
|
||||||
|
// { label: '姓名', value: entity.properties.name },
|
||||||
|
// { label: '部门', value: entity.properties.department },
|
||||||
|
// { label: '距离', value: `${entity.properties.distance}公里` }
|
||||||
|
// ]
|
||||||
// }
|
// }
|
||||||
// })
|
// })
|
||||||
|
// } else if (entity.type === 'center') {
|
||||||
|
// showMapTooltip({
|
||||||
|
// x: screenPos.x,
|
||||||
|
// y: screenPos.y,
|
||||||
|
// title: '应急中心',
|
||||||
|
// icon: centerIcon,
|
||||||
|
// data: {
|
||||||
|
// fields: [
|
||||||
|
// { label: '名称', value: entity.properties.name },
|
||||||
|
// { label: '行政等级', value: entity.properties.adminLevel },
|
||||||
|
// { label: '隶属单位', value: entity.properties.department }
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // 3. 如果需要打开详情弹窗,可以在 Tooltip 中添加按钮
|
||||||
|
// // 或者直接在这里同时打开详情弹窗(根据业务需求)
|
||||||
|
// // selectedPersonnel.value = entity.properties
|
||||||
|
// // showPersonnelDetail.value = true
|
||||||
|
// })
|
||||||
// })
|
// })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@use '@/styles/mixins.scss' as *;
|
@use "@/styles/mixins.scss" as *;
|
||||||
@use './assets/styles/common.scss' as *;
|
@use "./assets/styles/common.scss" as *;
|
||||||
|
|
||||||
.situational-awareness {
|
.situational-awareness {
|
||||||
// 容器查询设置,用于嵌入场景的自适应缩放
|
// 容器查询设置,用于嵌入场景的自适应缩放
|
||||||
@ -160,7 +577,9 @@ const handlePersonnelLink = (personnel) => {
|
|||||||
--sa-right-width: calc(486 / 1920 * var(--cq-inline-100, 100vw));
|
--sa-right-width: calc(486 / 1920 * var(--cq-inline-100, 100vw));
|
||||||
--sa-gap: calc(16 / 1920 * var(--cq-inline-100, 100vw));
|
--sa-gap: calc(16 / 1920 * var(--cq-inline-100, 100vw));
|
||||||
--sa-padding: calc(16 / 1920 * var(--cq-inline-100, 100vw));
|
--sa-padding: calc(16 / 1920 * var(--cq-inline-100, 100vw));
|
||||||
--sa-header-height: calc(121 / 1080 * var(--cq-block-100, 100vh)); // Header 高度
|
--sa-header-height: calc(
|
||||||
|
121 / 1080 * var(--cq-block-100, 100vh)
|
||||||
|
); // Header 高度
|
||||||
--sa-min-width: 1280px;
|
--sa-min-width: 1280px;
|
||||||
--sa-min-height: 720px;
|
--sa-min-height: 720px;
|
||||||
|
|
||||||
@ -193,6 +612,63 @@ const handlePersonnelLink = (personnel) => {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
// 默认单地图模式
|
||||||
|
&:not(.is-compare-mode) {
|
||||||
|
.situational-awareness__right-map {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.situational-awareness__left-map {
|
||||||
|
width: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 双地图对比模式
|
||||||
|
&.is-compare-mode {
|
||||||
|
.situational-awareness__left-map {
|
||||||
|
width: 50%;
|
||||||
|
visibility: visible;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.situational-awareness__right-map {
|
||||||
|
width: 50%;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左侧地图容器(灾前场景 - 对比时显示)
|
||||||
|
&__left-map {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右侧地图容器(灾后场景 - 主地图)
|
||||||
|
&__right-map {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中间分割线
|
||||||
|
&__center-divider {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(255, 255, 255, 0.1),
|
||||||
|
rgba(255, 255, 255, 0.5),
|
||||||
|
rgba(255, 255, 255, 0.1)
|
||||||
|
);
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 地图遮罩层 - 覆盖在地图之上,增强视觉效果
|
// 地图遮罩层 - 覆盖在地图之上,增强视觉效果
|
||||||
@ -202,7 +678,8 @@ const handlePersonnelLink = (personnel) => {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
pointer-events: none; // 不阻挡交互
|
pointer-events: none; // 不阻挡交互
|
||||||
// 使用 cockpit 的遮罩层图片,保持视觉一致性
|
// 使用 cockpit 的遮罩层图片,保持视觉一致性
|
||||||
background: url(@/views/cockpit/assets/img/遮罩层.png) center/cover no-repeat;
|
background: url(@/views/cockpit/assets/img/遮罩层.png) center/cover
|
||||||
|
no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 浮动面板层 - grid 与 pointer-events 结合保证中间透明
|
// 浮动面板层 - grid 与 pointer-events 结合保证中间透明
|
||||||
@ -253,6 +730,40 @@ const handlePersonnelLink = (personnel) => {
|
|||||||
// 调试:确保控件容器可见
|
// 调试:确保控件容器可见
|
||||||
min-height: 56px; // MapControls 的高度
|
min-height: 56px; // MapControls 的高度
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 地图 Tooltip 层 - 覆盖地图和面板,仅 Tooltip 自身可交互
|
||||||
|
&__tooltip-layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 4; // 高于控件层
|
||||||
|
pointer-events: none; // 容器不拦截事件,点击穿透到地图
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tooltip 内容字段样式
|
||||||
|
// 用于在 Tooltip 插槽中展示字段列表
|
||||||
|
.tooltip-field-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: vw(8);
|
||||||
|
padding: vh(6) 0;
|
||||||
|
|
||||||
|
.tooltip-field-label {
|
||||||
|
color: var(--text-gray);
|
||||||
|
font-size: fs(13);
|
||||||
|
font-family: SourceHanSansCN-Regular, sans-serif;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-field-value {
|
||||||
|
color: var(--text-white);
|
||||||
|
font-size: fs(13);
|
||||||
|
font-family: SourceHanSansCN-Medium, sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 窄容器嵌入的紧凑布局(<1100px 宽度)
|
// 窄容器嵌入的紧凑布局(<1100px 宽度)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user