2025-11-16 14:43:35 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="situational-awareness">
|
|
|
|
|
|
<!-- 顶部导航栏 -->
|
|
|
|
|
|
<PageHeader @back="handleBack" />
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 主内容区域 -->
|
|
|
|
|
|
<div class="situational-awareness__main">
|
2025-11-17 11:12:56 +08:00
|
|
|
|
<!-- 地图底层 -->
|
2025-11-18 21:24:31 +08:00
|
|
|
|
<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">
|
2025-11-19 09:29:47 +08:00
|
|
|
|
<MapViewer @tool-change="handleMapToolChange" />
|
2025-11-18 21:24:31 +08:00
|
|
|
|
</div>
|
2025-11-19 14:04:48 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 场景标签层 -->
|
|
|
|
|
|
<!-- 灾前现场实景标签 - 在中间分割线左侧 -->
|
|
|
|
|
|
<SceneLabel
|
|
|
|
|
|
v-if="isCompareMode"
|
|
|
|
|
|
text="灾前现场实景"
|
|
|
|
|
|
position="center-left"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 灾后现场实景标签 - 在右侧面板左边 -->
|
|
|
|
|
|
<SceneLabel
|
|
|
|
|
|
text="灾后现场实景"
|
|
|
|
|
|
position="right-left"
|
|
|
|
|
|
/>
|
2025-11-17 11:12:56 +08:00
|
|
|
|
</div>
|
2025-11-16 14:43:35 +08:00
|
|
|
|
|
2025-11-17 11:12:56 +08:00
|
|
|
|
<!-- 地图遮罩层 -->
|
2025-11-19 14:16:49 +08:00
|
|
|
|
<div class="situational-awareness__map-mask" aria-hidden="true"></div>
|
2025-11-16 14:43:35 +08:00
|
|
|
|
|
2025-11-17 11:12:56 +08:00
|
|
|
|
<!-- 浮动面板层 -->
|
|
|
|
|
|
<div class="situational-awareness__panels-layer">
|
2025-11-18 21:24:31 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="situational-awareness__panel-column situational-awareness__panel-column--left"
|
|
|
|
|
|
>
|
2025-11-19 14:04:48 +08:00
|
|
|
|
<LeftPanel @start-dispatch="handleStartDispatch" />
|
2025-11-17 11:12:56 +08:00
|
|
|
|
</div>
|
2025-11-18 21:24:31 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="situational-awareness__center-spacer"
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
></div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="situational-awareness__panel-column situational-awareness__panel-column--right"
|
|
|
|
|
|
>
|
2025-11-17 11:12:56 +08:00
|
|
|
|
<RightPanel />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 地图控件层 - 高于遮罩和面板 -->
|
|
|
|
|
|
<div class="situational-awareness__controls-layer">
|
|
|
|
|
|
<div id="sa-controls" class="situational-awareness__controls"></div>
|
|
|
|
|
|
</div>
|
2025-11-18 21:24:31 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 地图 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>
|
2025-11-19 14:04:48 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 加载动画层 - 一键启动后显示 -->
|
|
|
|
|
|
<div v-if="showLoading" class="situational-awareness__loading-layer">
|
|
|
|
|
|
<img
|
|
|
|
|
|
src="./assets/images/加载gif.gif"
|
|
|
|
|
|
alt="加载中"
|
|
|
|
|
|
class="situational-awareness__loading-gif"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-11-16 14:43:35 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 弹窗组件 -->
|
|
|
|
|
|
<PersonnelDetail
|
|
|
|
|
|
:visible="showPersonnelDetail"
|
|
|
|
|
|
:personnel-data="selectedPersonnel"
|
|
|
|
|
|
@close="showPersonnelDetail = false"
|
|
|
|
|
|
@link="handlePersonnelLink"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<EmergencyCenterDetail
|
|
|
|
|
|
:visible="showCenterDetail"
|
|
|
|
|
|
:center-data="selectedCenter"
|
|
|
|
|
|
@close="showCenterDetail = false"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
2025-11-18 21:24:31 +08:00
|
|
|
|
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";
|
2025-11-19 14:04:48 +08:00
|
|
|
|
import SceneLabel from "./components/SceneLabel.vue";
|
2025-11-18 21:24:31 +08:00
|
|
|
|
import { useDisasterData } from "./composables/useDisasterData";
|
|
|
|
|
|
import { useDualMapCompare } from "./composables/useDualMapCompare";
|
|
|
|
|
|
import { useMapMarkers } from "./composables/useMapMarkers";
|
|
|
|
|
|
import { use3DTiles } from "./composables/use3DTiles";
|
2025-11-19 11:13:29 +08:00
|
|
|
|
import { useMapTooltip } from "./composables/useMapTooltip";
|
2025-11-18 21:24:31 +08:00
|
|
|
|
import { useMapStore } from "@/map";
|
|
|
|
|
|
import { request } from "@shared/utils/request";
|
|
|
|
|
|
|
2025-11-19 11:13:29 +08:00
|
|
|
|
// 标记点图标
|
2025-11-18 21:24:31 +08:00
|
|
|
|
import emergencyCenterIcon from "./assets/images/应急中心.png";
|
2025-11-19 15:23:17 +08:00
|
|
|
|
import eventIcon from "./assets/images/事件icon.png";
|
2025-11-19 11:13:29 +08:00
|
|
|
|
import soldierIcon from "./assets/images/SketchPngfbec927027ff9e49207749ebaafd229429315341fda199251b6dfb1723ff17fb.png";
|
|
|
|
|
|
import deviceIcon from "./assets/images/SketchPng860d54f2a31f5f441fc6a88081224f1e98534bf6d5ca1246e420983bdf690380.png";
|
|
|
|
|
|
import emergencyBaseIcon from "./assets/images/应急基地.png";
|
2025-11-16 14:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
// 使用灾害数据
|
2025-11-18 21:24:31 +08:00
|
|
|
|
const disasterData = useDisasterData();
|
|
|
|
|
|
|
|
|
|
|
|
// 处理距离范围变更
|
|
|
|
|
|
const handleDistanceChange = async (newDistance) => {
|
|
|
|
|
|
console.log(`[index.vue] 距离范围改变为: ${newDistance}km`);
|
|
|
|
|
|
|
|
|
|
|
|
// 更新搜索半径
|
|
|
|
|
|
disasterData.updateSearchRadius(newDistance);
|
|
|
|
|
|
|
2025-11-19 15:23:17 +08:00
|
|
|
|
// 更新范围圈
|
|
|
|
|
|
if (mapStore.viewer) {
|
|
|
|
|
|
createOrUpdateRangeCircle(mapStore.viewer, newDistance);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-18 21:24:31 +08:00
|
|
|
|
// 重新加载应急资源数据并更新地图标记
|
|
|
|
|
|
await loadEmergencyResources(108.011506, 30.175827);
|
|
|
|
|
|
};
|
2025-11-16 14:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
// 提供给子组件使用
|
2025-11-18 21:24:31 +08:00
|
|
|
|
provide("disasterData", disasterData);
|
|
|
|
|
|
provide("onDistanceChange", handleDistanceChange);
|
2025-11-16 14:43:35 +08:00
|
|
|
|
|
2025-11-17 11:12:56 +08:00
|
|
|
|
// 地图 store
|
2025-11-18 21:24:31 +08:00
|
|
|
|
const mapStore = useMapStore();
|
|
|
|
|
|
|
|
|
|
|
|
// 双地图对比功能
|
|
|
|
|
|
const { isCompareMode, toggleCompareMode } = useDualMapCompare();
|
|
|
|
|
|
|
|
|
|
|
|
// 地图标记功能
|
|
|
|
|
|
const {
|
|
|
|
|
|
initializeMarkers,
|
|
|
|
|
|
clearMarkers,
|
|
|
|
|
|
getCollapseCenter,
|
|
|
|
|
|
addEmergencyResourceMarkers,
|
|
|
|
|
|
clearEmergencyResourceMarkers,
|
|
|
|
|
|
} = useMapMarkers();
|
|
|
|
|
|
|
2025-11-19 11:13:29 +08:00
|
|
|
|
// 地图 Tooltip 功能
|
|
|
|
|
|
const { tooltipState: mapTooltip, showTooltip, hideTooltip, updateTooltipPosition } = useMapTooltip();
|
|
|
|
|
|
|
|
|
|
|
|
// 当前显示 tooltip 的实体(用于相机移动时更新位置)
|
|
|
|
|
|
const currentTooltipEntity = ref(null);
|
|
|
|
|
|
|
2025-11-19 14:04:48 +08:00
|
|
|
|
// 加载动画状态
|
|
|
|
|
|
const showLoading = ref(false);
|
|
|
|
|
|
|
2025-11-19 15:23:17 +08:00
|
|
|
|
// 范围圈实体
|
|
|
|
|
|
const rangeCircleEntity = ref(null);
|
|
|
|
|
|
|
2025-11-18 21:24:31 +08:00
|
|
|
|
// 3D Tiles加载功能
|
|
|
|
|
|
const { load3DTileset, waitForTilesetReady } = use3DTiles();
|
2025-11-17 11:12:56 +08:00
|
|
|
|
|
2025-11-19 11:13:29 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 设置地图点击事件处理器
|
|
|
|
|
|
* 当用户点击地图标记点时,显示 Tooltip
|
|
|
|
|
|
*/
|
|
|
|
|
|
const setupMapClickHandler = (viewer) => {
|
|
|
|
|
|
if (!viewer) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 创建 ScreenSpaceEventHandler 监听点击事件
|
|
|
|
|
|
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
|
|
|
|
|
|
|
|
|
|
|
handler.setInputAction((click) => {
|
|
|
|
|
|
// 获取点击位置的实体
|
|
|
|
|
|
const pickedObject = viewer.scene.pick(click.position);
|
|
|
|
|
|
|
|
|
|
|
|
if (Cesium.defined(pickedObject) && Cesium.defined(pickedObject.id)) {
|
|
|
|
|
|
const entity = pickedObject.id;
|
|
|
|
|
|
|
|
|
|
|
|
// 检查实体是否有 properties(标记点才有)
|
|
|
|
|
|
if (entity.properties) {
|
|
|
|
|
|
const type = entity.properties.type?.getValue();
|
|
|
|
|
|
|
|
|
|
|
|
// 根据标记类型显示不同的 Tooltip
|
|
|
|
|
|
if (type === 'soldier') {
|
|
|
|
|
|
showMarkerTooltip(viewer, entity, click.position, soldierIcon);
|
|
|
|
|
|
} else if (type === 'device') {
|
|
|
|
|
|
showMarkerTooltip(viewer, entity, click.position, deviceIcon);
|
|
|
|
|
|
} else if (type === 'emergencyBase' || type === 'station') {
|
2025-11-19 14:04:48 +08:00
|
|
|
|
// 对于养护站,根据名称判断使用哪个图标
|
|
|
|
|
|
const stationName = entity.properties.name?.getValue() || '';
|
|
|
|
|
|
const icon = stationName === '忠县公路交通应急物资储备中心'
|
|
|
|
|
|
? emergencyCenterIcon
|
|
|
|
|
|
: emergencyBaseIcon;
|
|
|
|
|
|
showMarkerTooltip(viewer, entity, click.position, icon);
|
2025-11-19 11:13:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 点击空白区域,隐藏 Tooltip
|
|
|
|
|
|
hideTooltip();
|
|
|
|
|
|
currentTooltipEntity.value = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
|
|
|
|
|
|
|
|
|
|
|
// 监听相机移动事件,更新 tooltip 位置
|
|
|
|
|
|
viewer.scene.postRender.addEventListener(() => {
|
|
|
|
|
|
if (currentTooltipEntity.value && mapTooltip.value.visible) {
|
|
|
|
|
|
updateTooltipPositionForEntity(viewer, currentTooltipEntity.value);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 显示标记点 Tooltip
|
|
|
|
|
|
* @param {Cesium.Viewer} viewer - Cesium viewer 实例
|
|
|
|
|
|
* @param {Cesium.Entity} entity - 被点击的实体
|
|
|
|
|
|
* @param {Cesium.Cartesian2} screenPosition - 点击的屏幕坐标(备用)
|
|
|
|
|
|
* @param {string} icon - 图标路径
|
|
|
|
|
|
*/
|
|
|
|
|
|
const showMarkerTooltip = (viewer, entity, screenPosition, icon) => {
|
|
|
|
|
|
const properties = entity.properties;
|
|
|
|
|
|
const type = properties.type?.getValue();
|
|
|
|
|
|
|
|
|
|
|
|
// 获取实体的 3D 位置
|
|
|
|
|
|
const position = entity.position?.getValue(Cesium.JulianDate.now());
|
|
|
|
|
|
|
|
|
|
|
|
if (!position) {
|
|
|
|
|
|
console.warn('[Tooltip] 无法获取实体位置');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 对于使用 CLAMP_TO_GROUND 的 billboard,需要获取实际的地形高度
|
|
|
|
|
|
const cartographic = Cesium.Cartographic.fromCartesian(position);
|
|
|
|
|
|
let clampedPosition = position;
|
|
|
|
|
|
|
|
|
|
|
|
// 使用 globe.getHeight 获取地形高度
|
|
|
|
|
|
if (viewer.scene.globe) {
|
|
|
|
|
|
const height = viewer.scene.globe.getHeight(cartographic);
|
|
|
|
|
|
if (Cesium.defined(height)) {
|
|
|
|
|
|
cartographic.height = height;
|
|
|
|
|
|
clampedPosition = Cesium.Cartographic.toCartesian(cartographic);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 将贴地后的 3D 坐标转换为屏幕坐标
|
|
|
|
|
|
const canvasPosition = viewer.scene.cartesianToCanvasCoordinates(clampedPosition);
|
|
|
|
|
|
|
|
|
|
|
|
if (!Cesium.defined(canvasPosition)) {
|
|
|
|
|
|
console.warn('[Tooltip] 无法转换坐标到屏幕位置');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 构建 Tooltip 数据
|
|
|
|
|
|
let title = '';
|
|
|
|
|
|
const fields = [];
|
|
|
|
|
|
|
|
|
|
|
|
if (type === 'soldier') {
|
|
|
|
|
|
title = '单兵信息';
|
|
|
|
|
|
fields.push(
|
|
|
|
|
|
{ label: '姓名', value: properties.name?.getValue() || '-' },
|
|
|
|
|
|
{ label: '部门', value: properties.department?.getValue() || '-' },
|
|
|
|
|
|
{ label: '位置', value: properties.location?.getValue() || '-' }
|
|
|
|
|
|
);
|
|
|
|
|
|
} else if (type === 'device') {
|
|
|
|
|
|
title = '设备信息';
|
|
|
|
|
|
fields.push(
|
|
|
|
|
|
{ label: '设备名称', value: properties.name?.getValue() || '-' },
|
|
|
|
|
|
{ label: '设备类型', value: properties.deviceType?.getValue() || '-' },
|
|
|
|
|
|
{ label: '位置', value: properties.location?.getValue() || '-' }
|
|
|
|
|
|
);
|
|
|
|
|
|
} else if (type === 'emergencyBase') {
|
|
|
|
|
|
title = '应急基地';
|
|
|
|
|
|
fields.push(
|
|
|
|
|
|
{ label: '名称', value: properties.name?.getValue() || '-' },
|
|
|
|
|
|
{ label: '地址', value: properties.address?.getValue() || '-' },
|
|
|
|
|
|
{ label: '距离', value: properties.distance?.getValue() || '-' }
|
|
|
|
|
|
);
|
|
|
|
|
|
} else if (type === 'station') {
|
2025-11-19 14:04:48 +08:00
|
|
|
|
const stationName = properties.name?.getValue() || '';
|
|
|
|
|
|
const distance = properties.distance?.getValue() || 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 如果是应急中心,显示应急中心信息
|
|
|
|
|
|
if (stationName === '忠县公路交通应急物资储备中心') {
|
|
|
|
|
|
title = '应急中心';
|
|
|
|
|
|
fields.push(
|
|
|
|
|
|
{ label: '名称', value: '忠县应急中心' },
|
|
|
|
|
|
{ label: '行政等级', value: '国道' },
|
|
|
|
|
|
{ label: '隶属单位', value: '交通公路部门' },
|
|
|
|
|
|
{ label: '位置信息', value: `目前为止距离现场${distance}公里` }
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 其他养护站显示 tooltip
|
|
|
|
|
|
title = '养护站';
|
|
|
|
|
|
fields.push(
|
|
|
|
|
|
{ label: '名称', value: stationName || '-' },
|
|
|
|
|
|
{ label: '距离', value: `${distance}公里` }
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-11-19 11:13:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 显示 Tooltip,使用实体的屏幕坐标
|
|
|
|
|
|
showTooltip({
|
|
|
|
|
|
x: canvasPosition.x,
|
|
|
|
|
|
y: canvasPosition.y,
|
|
|
|
|
|
title,
|
|
|
|
|
|
icon,
|
|
|
|
|
|
data: { fields }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 保存当前实体,用于相机移动时更新位置
|
|
|
|
|
|
currentTooltipEntity.value = entity;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 更新 Tooltip 位置(当相机移动时)
|
|
|
|
|
|
* @param {Cesium.Viewer} viewer - Cesium viewer 实例
|
|
|
|
|
|
* @param {Cesium.Entity} entity - 实体对象
|
|
|
|
|
|
*/
|
|
|
|
|
|
const updateTooltipPositionForEntity = (viewer, entity) => {
|
|
|
|
|
|
const position = entity.position?.getValue(Cesium.JulianDate.now());
|
|
|
|
|
|
|
|
|
|
|
|
if (!position) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 对于使用 CLAMP_TO_GROUND 的 billboard,需要获取实际的地形高度
|
|
|
|
|
|
const cartographic = Cesium.Cartographic.fromCartesian(position);
|
|
|
|
|
|
let clampedPosition = position;
|
|
|
|
|
|
|
|
|
|
|
|
// 使用 globe.getHeight 获取地形高度
|
|
|
|
|
|
if (viewer.scene.globe) {
|
|
|
|
|
|
const height = viewer.scene.globe.getHeight(cartographic);
|
|
|
|
|
|
if (Cesium.defined(height)) {
|
|
|
|
|
|
cartographic.height = height;
|
|
|
|
|
|
clampedPosition = Cesium.Cartographic.toCartesian(cartographic);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 将贴地后的 3D 坐标转换为屏幕坐标
|
|
|
|
|
|
const canvasPosition = viewer.scene.cartesianToCanvasCoordinates(clampedPosition);
|
|
|
|
|
|
|
|
|
|
|
|
// 如果标记点在视野外,隐藏 tooltip
|
|
|
|
|
|
if (!Cesium.defined(canvasPosition)) {
|
|
|
|
|
|
hideTooltip();
|
|
|
|
|
|
currentTooltipEntity.value = null;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateTooltipPosition(canvasPosition.x, canvasPosition.y);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-17 11:12:56 +08:00
|
|
|
|
// 初始化地图
|
|
|
|
|
|
onMounted(() => {
|
2025-11-18 21:24:31 +08:00
|
|
|
|
// 等待地图就绪后配置初始视图和模型对比图层
|
|
|
|
|
|
mapStore.onReady(async () => {
|
|
|
|
|
|
const { camera } = mapStore.services();
|
|
|
|
|
|
const viewer = mapStore.viewer;
|
|
|
|
|
|
|
|
|
|
|
|
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: {
|
2025-11-19 15:23:17 +08:00
|
|
|
|
image: eventIcon,
|
2025-11-18 21:24:31 +08:00
|
|
|
|
width: 36,
|
2025-11-19 14:04:48 +08:00
|
|
|
|
height: 36,
|
2025-11-18 21:24:31 +08:00
|
|
|
|
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
|
|
|
|
|
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
|
|
|
|
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
viewer.entities.add(defaultPoint);
|
|
|
|
|
|
|
2025-11-19 15:23:17 +08:00
|
|
|
|
// 在默认点附近添加10个模拟点位(应急人员和应急装备),分散在10km范围内
|
|
|
|
|
|
// 1度纬度约等于111km,1度经度在30度纬度约等于96km
|
|
|
|
|
|
// 10km约等于0.09度纬度,0.104度经度
|
|
|
|
|
|
const simulatedPoints = [
|
|
|
|
|
|
// 应急人员 - 分散在不同方向
|
|
|
|
|
|
{ type: 'soldier', name: '张三', department: '应急救援队', lon: 108.051, lat: 30.205, distance: 4.2, icon: soldierIcon },
|
|
|
|
|
|
{ type: 'soldier', name: '李四', department: '消防队', lon: 107.975, lat: 30.195, distance: 5.8, icon: soldierIcon },
|
|
|
|
|
|
{ type: 'soldier', name: '王五', department: '医疗队', lon: 108.025, lat: 30.155, distance: 3.5, icon: soldierIcon },
|
|
|
|
|
|
{ type: 'soldier', name: '赵六', department: '应急救援队', lon: 108.085, lat: 30.168, distance: 7.2, icon: soldierIcon },
|
|
|
|
|
|
{ type: 'soldier', name: '刘七', department: '消防队', lon: 107.945, lat: 30.182, distance: 8.5, icon: soldierIcon },
|
|
|
|
|
|
// 应急装备 - 分散在不同方向
|
|
|
|
|
|
{ type: 'device', name: '救援车辆A', deviceType: '消防车', lon: 108.065, lat: 30.185, distance: 6.3, icon: deviceIcon },
|
|
|
|
|
|
{ type: 'device', name: '救援车辆B', deviceType: '救护车', lon: 107.960, lat: 30.165, distance: 6.8, icon: deviceIcon },
|
|
|
|
|
|
{ type: 'device', name: '无人机A', deviceType: 'DJI', lon: 108.035, lat: 30.225, distance: 5.5, icon: deviceIcon },
|
|
|
|
|
|
{ type: 'device', name: '无人机B', deviceType: 'DJI', lon: 108.095, lat: 30.195, distance: 9.2, icon: deviceIcon },
|
|
|
|
|
|
{ type: 'device', name: '通讯设备', deviceType: '卫星电话', lon: 107.930, lat: 30.175, distance: 9.8, icon: deviceIcon }
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
simulatedPoints.forEach(point => {
|
|
|
|
|
|
const entity = viewer.entities.add({
|
|
|
|
|
|
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat, 0),
|
|
|
|
|
|
billboard: {
|
|
|
|
|
|
image: point.icon,
|
|
|
|
|
|
width: 36,
|
|
|
|
|
|
height: 40,
|
|
|
|
|
|
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
|
|
|
|
|
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
|
|
|
|
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
|
|
|
|
},
|
|
|
|
|
|
properties: point.type === 'soldier'
|
|
|
|
|
|
? {
|
|
|
|
|
|
type: 'soldier',
|
|
|
|
|
|
name: point.name,
|
|
|
|
|
|
department: point.department,
|
|
|
|
|
|
location: `目前为止距离现场${point.distance}公里`
|
|
|
|
|
|
}
|
|
|
|
|
|
: {
|
|
|
|
|
|
type: 'device',
|
|
|
|
|
|
name: point.name,
|
|
|
|
|
|
deviceType: point.deviceType,
|
|
|
|
|
|
location: `目前为止距离现场${point.distance}公里`
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`[index.vue] 已添加 ${simulatedPoints.length} 个模拟点位`);
|
|
|
|
|
|
|
2025-11-18 21:24:31 +08:00
|
|
|
|
// camera.setView({
|
|
|
|
|
|
// ...DEFAULT_CAMERA_VIEW,
|
|
|
|
|
|
// });
|
|
|
|
|
|
camera.flyTo({
|
2025-11-19 09:26:43 +08:00
|
|
|
|
...DEFAULT_CAMERA_VIEW,
|
2025-11-18 21:24:31 +08:00
|
|
|
|
duration: 1,
|
|
|
|
|
|
});
|
2025-11-19 11:13:29 +08:00
|
|
|
|
|
|
|
|
|
|
// 设置地图点击事件监听 - 显示 Tooltip
|
|
|
|
|
|
setupMapClickHandler(viewer);
|
|
|
|
|
|
|
2025-11-18 21:24:31 +08:00
|
|
|
|
// 延迟 1000ms 后设置相机到默认位置
|
2025-11-19 09:29:47 +08:00
|
|
|
|
// setTimeout(() => {
|
|
|
|
|
|
// camera.flyTo({
|
|
|
|
|
|
// ...DEFAULT_CAMERA_VIEW,
|
|
|
|
|
|
// duration: 1,
|
|
|
|
|
|
// });
|
|
|
|
|
|
// }, 5000);
|
2025-11-19 11:13:29 +08:00
|
|
|
|
// return;
|
2025-11-18 21:24:31 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 设置相机到指定的笛卡尔坐标
|
|
|
|
|
|
* @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, // 使用采样高度,确保标记位置准确
|
2025-11-19 11:13:29 +08:00
|
|
|
|
heightOffset: 100, // 标记相对地面100米(与 WuRenJi 保持一致)
|
2025-11-18 21:24:31 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 如果之前没有设置相机(配置数据缺失),现在再次尝试
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-19 15:23:17 +08:00
|
|
|
|
|
|
|
|
|
|
// 创建初始范围圈(使用当前搜索半径)
|
|
|
|
|
|
createOrUpdateRangeCircle(viewer, disasterData.forcePreset.value.searchRadius);
|
2025-11-18 21:24:31 +08:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 根据经纬度加载养护站数据 /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,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-17 11:12:56 +08:00
|
|
|
|
|
2025-11-18 21:24:31 +08:00
|
|
|
|
// 其他工具的处理可以在这里扩展
|
|
|
|
|
|
// if (tool === 'measure') { ... }
|
|
|
|
|
|
};
|
2025-11-17 11:12:56 +08:00
|
|
|
|
|
2025-11-16 14:43:35 +08:00
|
|
|
|
// 弹窗状态
|
2025-11-18 21:24:31 +08:00
|
|
|
|
const showPersonnelDetail = ref(false);
|
|
|
|
|
|
const showCenterDetail = ref(false);
|
2025-11-16 14:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
// 选中的数据
|
|
|
|
|
|
const selectedPersonnel = ref({
|
2025-11-18 21:24:31 +08:00
|
|
|
|
name: "张强",
|
|
|
|
|
|
department: "安全生产部",
|
2025-11-16 14:43:35 +08:00
|
|
|
|
distance: 0.6,
|
|
|
|
|
|
estimatedArrival: 10,
|
2025-11-18 21:24:31 +08:00
|
|
|
|
avatar: null,
|
|
|
|
|
|
});
|
2025-11-16 14:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
const selectedCenter = ref({
|
2025-11-18 21:24:31 +08:00
|
|
|
|
name: "忠县应急中心",
|
|
|
|
|
|
adminLevel: "国道",
|
|
|
|
|
|
department: "交通公路部门",
|
2025-11-16 14:43:35 +08:00
|
|
|
|
distance: 0.6,
|
2025-11-18 21:24:31 +08:00
|
|
|
|
image: null,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-16 14:43:35 +08:00
|
|
|
|
// 返回驾驶舱
|
|
|
|
|
|
const handleBack = () => {
|
2025-11-18 21:24:31 +08:00
|
|
|
|
console.log("返回驾驶舱");
|
2025-11-16 14:43:35 +08:00
|
|
|
|
// 实际实现:路由跳转
|
|
|
|
|
|
// router.push('/cockpit')
|
2025-11-18 21:24:31 +08:00
|
|
|
|
};
|
2025-11-16 14:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
// 处理人员联动
|
|
|
|
|
|
const handlePersonnelLink = (personnel) => {
|
2025-11-18 21:24:31 +08:00
|
|
|
|
console.log("联动人员:", personnel);
|
2025-11-17 11:12:56 +08:00
|
|
|
|
// 实际实现:使用 mapStore 的 camera service 飞行到人员位置
|
|
|
|
|
|
// const { camera } = mapStore.services()
|
|
|
|
|
|
// camera.flyTo({ destination: [lon, lat, height] })
|
2025-11-18 21:24:31 +08:00
|
|
|
|
showPersonnelDetail.value = false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 关闭地图 Tooltip
|
|
|
|
|
|
* 统一的关闭入口,便于后续扩展埋点或联动逻辑
|
|
|
|
|
|
*/
|
|
|
|
|
|
const handleMapTooltipClose = () => {
|
|
|
|
|
|
mapTooltip.value.visible = false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-19 14:04:48 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 处理力量调度启动事件
|
|
|
|
|
|
* 显示加载动画,3秒后自动隐藏
|
|
|
|
|
|
*/
|
|
|
|
|
|
const handleStartDispatch = (payload) => {
|
|
|
|
|
|
console.log('[index.vue] 启动力量调度:', payload);
|
|
|
|
|
|
|
|
|
|
|
|
// 显示加载动画
|
|
|
|
|
|
showLoading.value = true;
|
|
|
|
|
|
|
|
|
|
|
|
// 3秒后自动隐藏加载动画
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
showLoading.value = false;
|
|
|
|
|
|
}, 3000);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-19 15:23:17 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 创建或更新范围圈
|
|
|
|
|
|
* @param {Cesium.Viewer} viewer - Cesium viewer 实例
|
|
|
|
|
|
* @param {number} radiusKm - 半径(公里)
|
|
|
|
|
|
*/
|
|
|
|
|
|
const createOrUpdateRangeCircle = (viewer, radiusKm) => {
|
|
|
|
|
|
if (!viewer) return;
|
|
|
|
|
|
|
|
|
|
|
|
const centerLon = 108.011506;
|
|
|
|
|
|
const centerLat = 30.175827;
|
|
|
|
|
|
const radiusMeters = radiusKm * 1000;
|
|
|
|
|
|
|
|
|
|
|
|
// 如果已存在范围圈,先移除
|
|
|
|
|
|
if (rangeCircleEntity.value) {
|
|
|
|
|
|
viewer.entities.remove(rangeCircleEntity.value);
|
|
|
|
|
|
rangeCircleEntity.value = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建新的范围圈
|
|
|
|
|
|
rangeCircleEntity.value = viewer.entities.add({
|
|
|
|
|
|
position: Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0),
|
|
|
|
|
|
ellipse: {
|
|
|
|
|
|
semiMinorAxis: radiusMeters,
|
|
|
|
|
|
semiMajorAxis: radiusMeters,
|
|
|
|
|
|
height: 0,
|
|
|
|
|
|
material: Cesium.Color.fromCssColorString('#1CA1FF').withAlpha(0.2),
|
|
|
|
|
|
outline: true,
|
|
|
|
|
|
outlineColor: Cesium.Color.fromCssColorString('#1CA1FF').withAlpha(0.8),
|
|
|
|
|
|
outlineWidth: 2,
|
|
|
|
|
|
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`[index.vue] 已创建/更新范围圈: ${radiusKm}km`);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-18 21:24:31 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 在指定屏幕坐标显示地图 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 保持不变,无需重新赋值
|
|
|
|
|
|
};
|
2025-11-17 11:12:56 +08:00
|
|
|
|
|
|
|
|
|
|
// TODO: 实现地图实体点击事件监听
|
2025-11-18 21:24:31 +08:00
|
|
|
|
// 当用户点击地图上的标记点时,显示 Tooltip 或详情弹窗
|
|
|
|
|
|
//
|
|
|
|
|
|
// 集成示例(需要先在文件顶部导入图标):
|
|
|
|
|
|
// import personnelIcon from './assets/images/personnel-icon.png'
|
|
|
|
|
|
// import centerIcon from './assets/images/center-icon.png'
|
|
|
|
|
|
//
|
2025-11-17 11:12:56 +08:00
|
|
|
|
// mapStore.onReady(() => {
|
|
|
|
|
|
// const { query } = mapStore.services()
|
2025-11-18 21:24:31 +08:00
|
|
|
|
//
|
2025-11-17 11:12:56 +08:00
|
|
|
|
// // 监听实体点击事件
|
|
|
|
|
|
// query.onEntityClick((entity) => {
|
2025-11-18 21:24:31 +08:00
|
|
|
|
// // 1. 将实体位置转换为屏幕坐标
|
|
|
|
|
|
// // 注意:具体 API 取决于你使用的地图引擎(Cesium/Mapbox/etc.)
|
|
|
|
|
|
// const screenPos = mapStore.worldToScreen(entity.position)
|
|
|
|
|
|
//
|
|
|
|
|
|
// // 2. 显示 Tooltip 展示基本信息
|
2025-11-17 11:12:56 +08:00
|
|
|
|
// if (entity.type === 'personnel') {
|
2025-11-18 21:24:31 +08:00
|
|
|
|
// 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}公里` }
|
|
|
|
|
|
// ]
|
|
|
|
|
|
// }
|
|
|
|
|
|
// })
|
2025-11-17 11:12:56 +08:00
|
|
|
|
// } else if (entity.type === 'center') {
|
2025-11-18 21:24:31 +08:00
|
|
|
|
// 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 }
|
|
|
|
|
|
// ]
|
|
|
|
|
|
// }
|
|
|
|
|
|
// })
|
2025-11-17 11:12:56 +08:00
|
|
|
|
// }
|
2025-11-18 21:24:31 +08:00
|
|
|
|
//
|
|
|
|
|
|
// // 3. 如果需要打开详情弹窗,可以在 Tooltip 中添加按钮
|
|
|
|
|
|
// // 或者直接在这里同时打开详情弹窗(根据业务需求)
|
|
|
|
|
|
// // selectedPersonnel.value = entity.properties
|
|
|
|
|
|
// // showPersonnelDetail.value = true
|
2025-11-17 11:12:56 +08:00
|
|
|
|
// })
|
|
|
|
|
|
// })
|
2025-11-16 14:43:35 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
2025-11-18 21:24:31 +08:00
|
|
|
|
@use "@/styles/mixins.scss" as *;
|
|
|
|
|
|
@use "./assets/styles/common.scss" as *;
|
2025-11-16 14:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
.situational-awareness {
|
2025-11-17 11:12:56 +08:00
|
|
|
|
// 容器查询设置,用于嵌入场景的自适应缩放
|
|
|
|
|
|
container-name: situational-awareness;
|
|
|
|
|
|
container-type: size;
|
|
|
|
|
|
|
2025-11-19 09:13:06 +08:00
|
|
|
|
// 为旧版浏览器提供视口单位回退,封顶 1920×1080
|
|
|
|
|
|
--cq-inline-100: clamp(0px, 100vw, 1920px);
|
|
|
|
|
|
--cq-block-100: clamp(0px, 100vh, 1080px);
|
2025-11-17 11:12:56 +08:00
|
|
|
|
|
2025-11-19 09:13:06 +08:00
|
|
|
|
// 当支持容器单位时覆盖为容器单位,同样封顶
|
2025-11-17 11:12:56 +08:00
|
|
|
|
@supports (width: 1cqw) {
|
2025-11-19 09:13:06 +08:00
|
|
|
|
--cq-inline-100: min(100cqw, 1920px);
|
2025-11-17 11:12:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
@supports (height: 1cqh) {
|
2025-11-19 09:13:06 +08:00
|
|
|
|
--cq-block-100: min(100cqh, 1080px);
|
2025-11-17 11:12:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 可配置的布局变量(使用 calc 直接计算,避免函数嵌套)
|
|
|
|
|
|
--sa-left-width: calc(464 / 1920 * var(--cq-inline-100, 100vw));
|
|
|
|
|
|
--sa-right-width: calc(486 / 1920 * var(--cq-inline-100, 100vw));
|
|
|
|
|
|
--sa-gap: calc(16 / 1920 * var(--cq-inline-100, 100vw));
|
|
|
|
|
|
--sa-padding: calc(16 / 1920 * var(--cq-inline-100, 100vw));
|
2025-11-18 21:24:31 +08:00
|
|
|
|
--sa-header-height: calc(
|
2025-11-19 14:04:48 +08:00
|
|
|
|
131 / 1080 * var(--cq-block-100, 100vh)
|
2025-11-18 21:24:31 +08:00
|
|
|
|
); // Header 高度
|
2025-11-17 11:12:56 +08:00
|
|
|
|
--sa-min-width: 1280px;
|
|
|
|
|
|
--sa-min-height: 720px;
|
|
|
|
|
|
|
2025-11-17 17:59:25 +08:00
|
|
|
|
position: relative;
|
2025-11-17 11:12:56 +08:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
2025-11-19 09:13:06 +08:00
|
|
|
|
// max-width: 1920px;
|
|
|
|
|
|
// max-height: 1080px;
|
2025-11-17 11:12:56 +08:00
|
|
|
|
min-width: var(--sa-min-width);
|
|
|
|
|
|
min-height: var(--sa-min-height);
|
2025-11-16 14:43:35 +08:00
|
|
|
|
background-color: var(--bg-dark);
|
2025-11-19 09:13:06 +08:00
|
|
|
|
overflow-x: hidden;
|
|
|
|
|
|
overflow-y: auto; // 当宿主尺寸 < 最小尺寸时允许滚动,达到上限时不放大
|
2025-11-17 17:59:25 +08:00
|
|
|
|
|
|
|
|
|
|
// PageHeader 浮在顶部
|
|
|
|
|
|
> :first-child {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 0;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
right: 0;
|
|
|
|
|
|
z-index: 10;
|
|
|
|
|
|
}
|
2025-11-16 14:43:35 +08:00
|
|
|
|
|
|
|
|
|
|
&__main {
|
2025-11-17 17:59:25 +08:00
|
|
|
|
position: absolute;
|
|
|
|
|
|
inset: 0; // 铺满整个容器
|
2025-11-16 14:43:35 +08:00
|
|
|
|
background: url(./assets/images/main-bg.png) center/cover no-repeat;
|
2025-11-17 11:12:56 +08:00
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 地图底层 - 填满整个容器
|
|
|
|
|
|
&__map-layer {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
z-index: 0;
|
2025-11-18 21:24:31 +08:00
|
|
|
|
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;
|
2025-11-17 11:12:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 地图遮罩层 - 覆盖在地图之上,增强视觉效果
|
|
|
|
|
|
&__map-mask {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
z-index: 1;
|
|
|
|
|
|
pointer-events: none; // 不阻挡交互
|
|
|
|
|
|
// 使用 cockpit 的遮罩层图片,保持视觉一致性
|
2025-11-18 21:24:31 +08:00
|
|
|
|
background: url(@/views/cockpit/assets/img/遮罩层.png) center/cover
|
|
|
|
|
|
no-repeat;
|
2025-11-17 11:12:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 浮动面板层 - grid 与 pointer-events 结合保证中间透明
|
|
|
|
|
|
&__panels-layer {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
z-index: 2;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: var(--sa-left-width) 1fr var(--sa-right-width);
|
|
|
|
|
|
grid-auto-rows: 1fr;
|
|
|
|
|
|
gap: var(--sa-gap); // 列之间的间距
|
|
|
|
|
|
height: 100%;
|
2025-11-17 17:59:25 +08:00
|
|
|
|
padding-top: var(--sa-header-height); // 预留 Header 高度
|
2025-11-17 11:12:56 +08:00
|
|
|
|
pointer-events: none; // 容器不拦截事件,让中间区域透明
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 左右面板列 - 浮动卡片样式
|
|
|
|
|
|
&__panel-column {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: var(--sa-gap); // 列内子面板之间的间距
|
|
|
|
|
|
min-width: 0; // 防止在窄容器中溢出
|
|
|
|
|
|
min-height: 0; // 允许 flex 子元素收缩并启用滚动
|
|
|
|
|
|
pointer-events: auto; // 恢复面板的交互能力
|
|
|
|
|
|
}
|
2025-11-16 14:43:35 +08:00
|
|
|
|
|
2025-11-17 11:12:56 +08:00
|
|
|
|
// 中间占位区域 - 透明且不可交互,点击穿透到地图
|
|
|
|
|
|
&__center-spacer {
|
|
|
|
|
|
pointer-events: none;
|
2025-11-16 14:43:35 +08:00
|
|
|
|
}
|
2025-11-17 11:12:56 +08:00
|
|
|
|
|
|
|
|
|
|
// 地图控件层 - 高于遮罩和面板,用于放置地图控制工具
|
|
|
|
|
|
&__controls-layer {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
z-index: 3;
|
|
|
|
|
|
pointer-events: none; // 容器不拦截事件
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
align-items: flex-end;
|
|
|
|
|
|
padding-bottom: 30px; // 临时使用固定值,确保控件显示
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 控件容器 - 恢复交互能力
|
|
|
|
|
|
&__controls {
|
|
|
|
|
|
pointer-events: auto;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
// 调试:确保控件容器可见
|
|
|
|
|
|
min-height: 56px; // MapControls 的高度
|
|
|
|
|
|
}
|
2025-11-18 21:24:31 +08:00
|
|
|
|
|
|
|
|
|
|
// 地图 Tooltip 层 - 覆盖地图和面板,仅 Tooltip 自身可交互
|
|
|
|
|
|
&__tooltip-layer {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
z-index: 4; // 高于控件层
|
|
|
|
|
|
pointer-events: none; // 容器不拦截事件,点击穿透到地图
|
|
|
|
|
|
}
|
2025-11-19 14:04:48 +08:00
|
|
|
|
|
|
|
|
|
|
// 加载动画层 - 一键启动后显示
|
|
|
|
|
|
&__loading-layer {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: calc(var(--sa-header-height) + vh(20));
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
right: 0;
|
|
|
|
|
|
// bottom: 0;
|
|
|
|
|
|
z-index: 5; // 高于 Tooltip 层
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
pointer-events: none; // 不阻止点击穿透
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 加载 GIF 图片
|
|
|
|
|
|
&__loading-gif {
|
|
|
|
|
|
width: auto;
|
|
|
|
|
|
height: auto;
|
|
|
|
|
|
max-width: 80%;
|
|
|
|
|
|
max-height: 60%;
|
|
|
|
|
|
object-fit: contain;
|
|
|
|
|
|
}
|
2025-11-18 21:24:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2025-11-17 11:12:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 窄容器嵌入的紧凑布局(<1100px 宽度)
|
|
|
|
|
|
.situational-awareness.is-compact {
|
|
|
|
|
|
--sa-left-width: calc(380 / 1920 * var(--cq-inline-100, 100vw));
|
|
|
|
|
|
--sa-right-width: calc(400 / 1920 * var(--cq-inline-100, 100vw));
|
|
|
|
|
|
--sa-gap: calc(12 / 1920 * var(--cq-inline-100, 100vw));
|
|
|
|
|
|
--sa-padding: calc(12 / 1920 * var(--cq-inline-100, 100vw));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 嵌入模式 - 使用更保守的最小尺寸
|
|
|
|
|
|
.situational-awareness.is-embedded {
|
|
|
|
|
|
--sa-min-width: 1024px;
|
|
|
|
|
|
--sa-min-height: 600px;
|
2025-11-16 14:43:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
</style>
|