feat(situational-awareness): 添加双地图对比和增强视频监控功能

新增支持双地图对比模式,显示灾害前后场景,
新的视频模态框用于全屏监控并带有方向控制,
位置面板显示地理信息,
地图工具提示显示实体详情,以及用于3D瓦片管理的可组合组件,
地图标记和模型对比功能。包括新的共享组件
如DecorativePanel和MapTooltip,以及Cesium数据
和模型对比设置的配置文件。
This commit is contained in:
Zzc 2025-11-18 21:24:31 +08:00
parent 0b2f389770
commit 536b00fab4
24 changed files with 4622 additions and 259 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -1,20 +1,5 @@
<template>
<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">
<DataField
label="灾害类型"
@ -61,71 +46,35 @@
/>
</div>
</div>
</div>
</template>
<script setup>
import { inject } from "vue";
import DataField from "../shared/DataField.vue";
import { inject } from 'vue'
import DataField from '../shared/DataField.vue'
import DataFieldIcon1 from "../../assets/images/DataField/icon-1.png";
import DataFieldIcon2 from "../../assets/images/DataField/icon-2.png";
import DataFieldIcon3 from "../../assets/images/DataField/icon-3.png";
import DataFieldIcon4 from "../../assets/images/DataField/icon-4.png";
import DataFieldIcon5 from "../../assets/images/DataField/icon-5.png";
import DataFieldIcon6 from "../../assets/images/DataField/icon-6.png";
import DataFieldIcon1 from '../../assets/images/DataField/icon-1.png'
import DataFieldIcon2 from '../../assets/images/DataField/icon-2.png'
import DataFieldIcon3 from '../../assets/images/DataField/icon-3.png'
import DataFieldIcon4 from '../../assets/images/DataField/icon-4.png'
import DataFieldIcon5 from '../../assets/images/DataField/icon-5.png'
import DataFieldIcon6 from '../../assets/images/DataField/icon-6.png'
const { disasterInfo } = inject("disasterData");
const { disasterInfo } = inject('disasterData')
</script>
<style scoped lang="scss">
@use "@/styles/mixins.scss" as *;
@use "../../assets/styles/common.scss" as *;
@use '@/styles/mixins.scss' as *;
@use '../../assets/styles/common.scss' as *;
.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;
flex-direction: column;
gap: vh(12);
}
&__row {
.disaster-analysis__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: vw(16);
}
}
</style>

View File

@ -1,9 +1,22 @@
<template>
<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>
<img src="../../assets/images/SketchPng8063f445fba047c290a9620343b62ea51d767b8cdcd86769502b5b160998aacc.png" alt="dropdown" class="filter-icon" />
</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">
<!-- <img src="../../assets/images/SketchPnga96e6ce64e80f6d935217d64400481f3e0361d9e60a7425f6f09c8287716904d.png" alt="background" class="summary-bg" /> -->
@ -58,6 +71,17 @@
import { inject } from 'vue'
const { forcePreset } = inject('disasterData')
const onDistanceChange = inject('onDistanceChange')
/**
* 处理距离范围选择变更
* @param {number} distance - 选中的距离范围(km)
*/
const handleDistanceChange = (distance) => {
if (onDistanceChange) {
onDistanceChange(distance)
}
}
</script>
<style scoped lang="scss">
@ -78,6 +102,13 @@ const { forcePreset } = inject('disasterData')
margin-bottom: vh(16);
cursor: pointer;
.filter-content {
display: flex;
align-items: center;
gap: vw(8);
width: 100%;
}
.filter-text {
flex: 1;
color: var(--text-white);
@ -174,6 +205,18 @@ const { forcePreset } = inject('disasterData')
display: flex;
flex-direction: column;
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 {
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>

View File

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

View File

@ -1,4 +1,5 @@
<template>
<div class="left-panel-wrapper">
<div class="left-panel">
<CollapsiblePanel title="快速感知" subtitle="「灾害分析」">
<DisasterAnalysis />
@ -12,19 +13,69 @@
<ForceDispatch />
</CollapsiblePanel>
</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>
<script setup>
import { ref } from 'vue'
import CollapsiblePanel from '../shared/CollapsiblePanel.vue'
import DisasterAnalysis from './DisasterAnalysis.vue'
import ForcePreset from './ForcePreset.vue'
import ForceDispatch from './ForceDispatch.vue'
import LocationPanel from './LocationPanel.vue'
const isLocationOpen = ref(true)
const toggleLocation = () => {
isLocationOpen.value = !isLocationOpen.value
}
</script>
<style scoped lang="scss">
@use '@/styles/mixins.scss' as *;
@use '../../assets/styles/common.scss' as *;
.left-panel-wrapper {
position: relative;
display: inline-block;
height: 100%;
}
.left-panel {
// width: vw(464);
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>

View File

@ -6,7 +6,10 @@
<!-- 地图控制工具 - 使用 Teleport 传送到更高层级 -->
<!-- 延迟渲染确保目标元素已存在 -->
<Teleport to="#sa-controls" v-if="isMounted">
<MapControls />
<MapControls
@tool-change="handleToolChange"
@device-watch="handleDeviceWatch"
/>
</Teleport>
</div>
</template>
@ -16,9 +19,38 @@ import { ref, onMounted } from 'vue'
import { MapViewport } from '@/map'
import MapControls from './MapControls.vue'
/**
* 向外抛出的事件
* @event tool-change - 地图工具变化事件包含 { tool: string, active: boolean }
* @event device-watch - 卫星设备观看状态变化事件包含 boolean
*/
const emit = defineEmits(['tool-change', 'device-watch'])
// Teleport
const isMounted = ref(false)
/**
* 处理地图工具变化
* MapControls 的工具变化事件向上传递给父组件
*
* @param {Object} payload - 工具变化事件载荷
* @param {string} payload.tool - 工具标识 'modelCompare', 'measure'
* @param {boolean} payload.active - 工具是否激活
*/
const handleToolChange = (payload) => {
emit('tool-change', payload)
}
/**
* 处理卫星设备观看状态变化
* MapControls 的设备观看事件向上传递给父组件
*
* @param {boolean} isWatching - 是否正在观看卫星设备
*/
const handleDeviceWatch = (isWatching) => {
emit('device-watch', isWatching)
}
onMounted(() => {
// 使 nextTick DOM
setTimeout(() => {

View File

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

View File

@ -8,28 +8,58 @@
@audio="handleAudio"
@zoom="handleZoom"
/>
<!-- 全屏视频弹窗 -->
<VideoModal
v-if="zoomedMonitor"
:visible="!!zoomedMonitor"
:monitor="zoomedMonitor"
@close="handleCloseModal"
@megaphone="handleMegaphone"
@audio="handleAudio"
@move="handleMove"
/>
</div>
</template>
<script setup>
import VideoMonitorItem from './VideoMonitorItem.vue'
import VideoModal from './VideoModal.vue'
import { useVideoMonitor } from '../../composables/useVideoMonitor'
const { monitors, toggleMegaphone, zoomMonitor } = useVideoMonitor()
const {
monitors,
zoomedMonitor,
toggleMegaphone,
zoomMonitor,
closeZoom,
toggleAudio,
moveView
} = useVideoMonitor()
const handleMegaphone = (monitorId) => {
console.log('喊话:', monitorId)
toggleMegaphone()
toggleMegaphone(monitorId)
}
const handleAudio = (monitorId) => {
console.log('音频控制:', monitorId)
toggleAudio(monitorId)
}
const handleZoom = (monitorId) => {
console.log('放大窗口:', monitorId)
zoomMonitor(monitorId)
}
const handleCloseModal = () => {
closeZoom()
}
const handleMove = ({ monitorId, direction }) => {
console.log(`视角移动 - 监控ID: ${monitorId}, 方向: ${direction}`)
moveView(monitorId, direction)
}
</script>
<style scoped lang="scss">

View File

@ -13,35 +13,60 @@
<div class="video-placeholder">
<!-- 这里放置实际的视频流组件 -->
<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 class="video-controls">
<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
<div
v-if="monitor.hasAudio"
class="control-btn"
class="video-control-item"
role="button"
tabindex="0"
@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" />
<span>声音</span>
</button>
<img
src="../../assets/images/SketchPng04633c2ccf22607c20a4803d536908398c2953405e089cd296b106e601f793e0.png"
alt="audio"
class="video-control-item__icon"
/>
<span class="video-control-item__label">声音</span>
</div>
<button
<div
v-if="monitor.hasZoom"
class="control-btn"
class="video-control-item"
role="button"
tabindex="0"
@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" />
<span>窗口放大</span>
</button>
<img
src="../../assets/images/SketchPnga801740c6a6435fc300fc58878fc7da23921eae9c45eaff4ad9c40cc80d6706b.png"
alt="zoom"
class="video-control-item__icon"
/>
<span class="video-control-item__label">窗口放大</span>
</div>
</div>
</div>
</div>
</div>
@ -95,7 +120,8 @@ onUnmounted(() => {
.video-monitor-item {
// background: rgba(20, 53, 118, 0.3);
background: url('../../assets/images/视频面板bg.png') no-repeat center center;
background-size: cover;
background-size: 100% 100%;
padding: 2px;
// border-radius: vw(8);
overflow: hidden;
@ -153,48 +179,82 @@ onUnmounted(() => {
.video-time {
position: absolute;
// top: vh(8);
// left: vw(8);
padding: vh(4) vw(8);
background: rgba(0, 0, 0, 0.6);
border-radius: vw(4);
top: -1px;
left: -3px;
z-index: 2;
width: clamp(140px, 10.1vw, 194px);
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);
font-size: fs(12);
font-family: monospace;
}
}
.video-controls {
display: flex;
justify-content: center;
// gap: vw(16);
.control-btn {
font-size: clamp(10px, fs(12), 12px);
// font-family: 'PingFangSC-Semibold', 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, 'Microsoft YaHei', sans-serif;
// font-weight: 600;
display: flex;
align-items: center;
gap: vw(6);
padding: vh(6) vw(12);
background: transparent;
border: 1px solid var(--border-color);
border-radius: vw(4);
color: var(--text-white);
font-size: fs(12);
font-family: SourceHanSansCN-Regular, sans-serif;
cursor: pointer;
transition: all 0.3s;
justify-content: flex-start;
padding: 0 clamp(4px, 1vw, 8px);
box-sizing: border-box;
}
//
.video-controls {
position: absolute;
left: 0;
right: 0;
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 {
width: vw(14);
height: vh(14);
.video-control-item {
display: inline-flex;
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 {
background: var(--primary-light);
border-color: var(--primary-color);
transform: scale(1.05);
background: rgba(135, 206, 250, 0.1);
border-color: rgba(135, 206, 250, 0.5);
}
&:active {
transform: scale(0.98);
transform: scale(0.95);
}
&:focus-visible {
outline: 2px solid rgba(135, 206, 250, 0.6);
outline-offset: 2px;
}
}
}
}

View File

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

View File

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

View File

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

View File

@ -74,14 +74,30 @@ export function useDisasterData() {
}
])
// 调度力量建议
const dispatchSuggestion = ref({
supplies: 23,
personnel: 124,
// 调度力量建议(根据力量预置数据动态计算)
const dispatchSuggestion = computed(() => {
// 根据实际资源计算建议调度力量
const stationsCount = forcePreset.value.stations?.length || 0
return {
// 应急物资建议数:基于装备数量
supplies: forcePreset.value.equipment,
// 应急人员建议数取总人员的一部分约5-10%)作为调度建议
personnel: Math.min(Math.ceil(forcePreset.value.personnel * 0.06), forcePreset.value.personnel),
// 养护站建议数:使用实际可用养护站数量
stations: stationsCount,
// 挖掘机建议数每2个养护站配置1台挖掘机
excavators: Math.max(1, Math.ceil(stationsCount / 2)),
// 阻断信息:固定建议
blockInfo: '需发布',
stations: 4,
excavators: 2,
// 交通管制:固定建议
trafficControl: '需要'
}
})
// 计算属性
@ -93,12 +109,68 @@ export function useDisasterData() {
)
})
/**
* 更新搜索半径
* @param {number} radius - 新的搜索半径(km)
*/
const updateSearchRadius = (radius) => {
if (typeof radius === 'number' && radius > 0) {
forcePreset.value.searchRadius = radius
console.log(`[useDisasterData] 搜索半径已更新为: ${radius}km`)
} else {
console.warn('[useDisasterData] 无效的搜索半径值:', radius)
}
}
/**
* 更新力量预置数据
* @param {Object} emergencyResourcesData - 接口返回的应急资源数据
* @param {number} emergencyResourcesData.equipmentCount - 应急装备数量
* @param {number} emergencyResourcesData.personnelCount - 应急人员数量
* @param {Array} emergencyResourcesData.stations - 养护站列表
* @param {string} emergencyResourcesData.stations[].stationId - 养护站ID
* @param {string} emergencyResourcesData.stations[].stationName - 养护站名称
* @param {number} emergencyResourcesData.stations[].distance - 距离(km)
*/
const updateForcePreset = (emergencyResourcesData) => {
if (!emergencyResourcesData) {
console.warn('[useDisasterData] 应急资源数据为空,跳过更新')
return
}
const { equipmentCount, personnelCount, stations } = emergencyResourcesData
// 更新装备和人员数量
if (typeof equipmentCount === 'number') {
forcePreset.value.equipment = equipmentCount
}
if (typeof personnelCount === 'number') {
forcePreset.value.personnel = personnelCount
}
// 更新养护站列表
if (Array.isArray(stations)) {
forcePreset.value.stations = stations.map((station) => ({
id: station.stationId,
name: station.stationName?.trim() || '未命名养护站',
distance: typeof station.distance === 'number'
? Number(station.distance.toFixed(2))
: 0,
type: 'maintenance'
}))
}
console.log('[useDisasterData] 力量预置数据已更新:', forcePreset.value)
}
return {
disasterInfo,
forcePreset,
forceDispatch,
collaborationInfo,
dispatchSuggestion,
totalResources
totalResources,
updateForcePreset,
updateSearchRadius
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,9 @@ export function useVideoMonitor() {
// 当前选中的视频
const activeMonitor = ref(null)
// 全屏显示的监控(弹窗)
const zoomedMonitor = ref(null)
// 视频控制状态
const controlState = ref({
volume: 50,
@ -27,8 +30,10 @@ export function useVideoMonitor() {
}
// 切换喊话
const toggleMegaphone = () => {
const toggleMegaphone = (monitorId) => {
console.log(`Toggle megaphone for monitor: ${monitorId}`)
controlState.value.isMegaphoneActive = !controlState.value.isMegaphoneActive
// 未来接入真实喊话系统
}
// 切换录制
@ -41,20 +46,43 @@ export function useVideoMonitor() {
controlState.value.volume = Math.max(0, Math.min(100, volume))
}
// 窗口放大
// 窗口放大 - 打开全屏弹窗
const zoomMonitor = (monitorId) => {
console.log(`Zoom monitor: ${monitorId}`)
// 实际实现可以打开全屏弹窗
const monitor = monitors.value.find((m) => m.id === monitorId)
if (monitor) {
zoomedMonitor.value = monitor
}
}
// 关闭全屏弹窗
const closeZoom = () => {
zoomedMonitor.value = null
}
// 切换音频
const toggleAudio = (monitorId) => {
console.log(`Toggle audio for monitor: ${monitorId}`)
// 未来接入真实音频控制
}
// 视角移动(用于无人机等设备的方向控制)
const moveView = (monitorId, direction) => {
console.log(`Move view for monitor ${monitorId} to ${direction}`)
// 未来接入 3D 引擎的视角控制 API
}
return {
monitors,
activeMonitor,
zoomedMonitor,
controlState,
selectMonitor,
toggleMegaphone,
toggleRecording,
setVolume,
zoomMonitor
zoomMonitor,
closeZoom,
toggleAudio,
moveView
}
}

View File

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

View File

@ -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 加速瓦片服务
* - 预生成常用缩放级别的瓦片
* - 添加瓦片缓存机制
*/

View File

@ -16,36 +16,56 @@ export const VIDEO_MONITORS = [
id: 1,
type: VIDEO_TYPES.PERSONNEL,
title: '单兵(张三三)设备视角',
videoSrc: '/videos/personnel-001.mp4', // 视频源路径
dateRange: '2025/9/1-2025/12/1', // 日期范围
hasAudio: true,
hasMegaphone: true,
hasZoom: true
hasZoom: true,
hasDirectionControl: false // 是否显示方向控制(操作台)
},
{
id: 2,
type: VIDEO_TYPES.DRONE,
title: '无人机(001)视角',
videoSrc: '/videos/drone-001.mp4',
dateRange: '2025/9/1-2025/12/1',
hasAudio: false,
hasMegaphone: true,
hasZoom: true
hasZoom: true,
hasDirectionControl: true // 无人机有方向控制
},
{
id: 3,
type: VIDEO_TYPES.VEHICLE_EXTERNAL,
title: '指挥车外部视角',
videoSrc: '/videos/vehicle-external-001.mp4',
dateRange: '2025/9/1-2025/12/1',
hasAudio: true,
hasMegaphone: true,
hasZoom: true
hasZoom: true,
hasDirectionControl: false
},
{
id: 4,
type: VIDEO_TYPES.VEHICLE_MEETING,
title: '指挥车会议视角',
videoSrc: '/videos/vehicle-meeting-001.mp4',
dateRange: '2025/9/1-2025/12/1',
hasAudio: true,
hasMegaphone: true,
hasZoom: true
hasZoom: true,
hasDirectionControl: false
}
]
// 方向枚举(用于视角控制)
export const DIRECTIONS = {
LEFT: 'left',
RIGHT: 'right',
UP: 'up',
DOWN: 'down'
}
// 现场设备标签页
export const DISPATCH_TABS = [
{ key: 'personnel', label: '现场单兵', count: 23 },

View File

@ -6,8 +6,26 @@
<!-- 主内容区域 -->
<div class="situational-awareness__main">
<!-- 地图底层 -->
<div class="situational-awareness__map-layer">
<MapViewer />
<div
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>
<!-- 地图遮罩层 -->
@ -15,11 +33,18 @@
<!-- 浮动面板层 -->
<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 />
</div>
<div class="situational-awareness__center-spacer" aria-hidden="true"></div>
<div class="situational-awareness__panel-column situational-awareness__panel-column--right">
<div
class="situational-awareness__center-spacer"
aria-hidden="true"
></div>
<div
class="situational-awareness__panel-column situational-awareness__panel-column--right"
>
<RightPanel />
</div>
</div>
@ -28,6 +53,31 @@
<div class="situational-awareness__controls-layer">
<div id="sa-controls" class="situational-awareness__controls"></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>
<!-- 弹窗组件 -->
@ -47,96 +97,463 @@
</template>
<script setup>
import { ref, provide, onMounted } from 'vue'
import PageHeader from './components/PageHeader.vue'
import LeftPanel from './components/LeftPanel/index.vue'
import MapViewer from './components/MapViewer/index.vue'
import RightPanel from './components/RightPanel/index.vue'
import PersonnelDetail from './components/Popups/PersonnelDetail.vue'
import EmergencyCenterDetail from './components/Popups/EmergencyCenterDetail.vue'
import { useDisasterData } from './composables/useDisasterData'
import { useMapStore } from '@/map'
import { ref, provide, onMounted } from "vue";
import * as Cesium from "cesium";
import { ElMessage } from "element-plus";
import PageHeader from "./components/PageHeader.vue";
import LeftPanel from "./components/LeftPanel/index.vue";
import MapViewer from "./components/MapViewer/index.vue";
import RightPanel from "./components/RightPanel/index.vue";
import PersonnelDetail from "./components/Popups/PersonnelDetail.vue";
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
const mapStore = useMapStore()
const mapStore = useMapStore();
//
const { isCompareMode, toggleCompareMode } = useDualMapCompare();
//
const {
initializeMarkers,
clearMarkers,
getCollapseCenter,
addEmergencyResourceMarkers,
clearEmergencyResourceMarkers,
} = useMapMarkers();
// 3D Tiles
const { load3DTileset, waitForTilesetReady } = use3DTiles();
//
onMounted(() => {
//
mapStore.onReady(() => {
const { camera } = mapStore.services()
//
mapStore.onReady(async () => {
const { camera } = mapStore.services();
const viewer = mapStore.viewer;
//
// : 108.0, : 30.3, : 50000
camera.setCenter(108.0, 30.3, 50000)
console.log("3D态势感知地图已就绪");
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 showCenterDetail = ref(false)
const showPersonnelDetail = ref(false);
const showCenterDetail = ref(false);
//
const selectedPersonnel = ref({
name: '张强',
department: '安全生产部',
name: "张强",
department: "安全生产部",
distance: 0.6,
estimatedArrival: 10,
avatar: null
})
avatar: null,
});
const selectedCenter = ref({
name: '忠县应急中心',
adminLevel: '国道',
department: '交通公路部门',
name: "忠县应急中心",
adminLevel: "国道",
department: "交通公路部门",
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 = () => {
console.log('返回驾驶舱')
console.log("返回驾驶舱");
//
// router.push('/cockpit')
}
};
//
const handlePersonnelLink = (personnel) => {
console.log('联动人员:', personnel)
console.log("联动人员:", personnel);
// 使 mapStore camera service
// const { camera } = mapStore.services()
// 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:
//
// Tooltip
//
//
// import personnelIcon from './assets/images/personnel-icon.png'
// import centerIcon from './assets/images/center-icon.png'
//
// mapStore.onReady(() => {
// const { query } = mapStore.services()
//
// //
// query.onEntityClick((entity) => {
// // 1.
// // API 使Cesium/Mapbox/etc.
// const screenPos = mapStore.worldToScreen(entity.position)
//
// // 2. Tooltip
// if (entity.type === 'personnel') {
// selectedPersonnel.value = entity.properties
// showPersonnelDetail.value = true
// } else if (entity.type === 'center') {
// selectedCenter.value = entity.properties
// showCenterDetail.value = true
// showMapTooltip({
// x: screenPos.x,
// y: screenPos.y,
// title: '',
// 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>
<style scoped lang="scss">
@use '@/styles/mixins.scss' as *;
@use './assets/styles/common.scss' as *;
@use "@/styles/mixins.scss" as *;
@use "./assets/styles/common.scss" as *;
.situational-awareness {
//
@ -160,7 +577,9 @@ const handlePersonnelLink = (personnel) => {
--sa-right-width: calc(486 / 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-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-height: 720px;
@ -193,6 +612,63 @@ const handlePersonnelLink = (personnel) => {
position: absolute;
inset: 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;
pointer-events: none; //
// 使 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
@ -253,6 +730,40 @@ const handlePersonnelLink = (personnel) => {
//
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