Compare commits

...

3 Commits

Author SHA1 Message Date
Zzc
06c462a577 feat(screen): 为3D态势感知添加人员移动动画
- 引入 `useEntityAnimation` 可组合项,用于在Cesium查看器中沿预定义路径为紧急人员制作动画
- 添加脉冲广告牌效果、发光路径轨迹和相机跟踪选项
- 将动画启动与调度事件集成,支持可配置的时长和实体属性
- 增强地图工具提示,添加人员关联和基地连接的操作按钮
- 更新模拟点,显示预计到达时间,以提高态势感知能力
2025-11-20 18:09:04 +08:00
Zzc
0d2b9b27d0 feat(screen): 为地图工具提示添加操作槽
为 MapTooltip 组件添加一个新的操作区域,用于显示“连线”和“联动”等交互式按钮。包含相应的 CSS 样式,用于布局和视觉分隔。
2025-11-20 18:08:24 +08:00
Zzc
dadfa1b1cc chore(screen): update tooltip images 2025-11-20 18:07:47 +08:00
7 changed files with 428 additions and 29 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 B

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 B

After

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 B

After

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 B

After

Width:  |  Height:  |  Size: 136 B

View File

@ -94,6 +94,11 @@
<div class="map-tooltip__content">
<slot />
</div>
<!-- 操作按钮区域 -->
<div v-if="$slots.actions" class="map-tooltip__actions">
<slot name="actions" />
</div>
</div>
</div>
</div>
@ -280,8 +285,8 @@ const handleClose = () => {
*/
.map-tooltip__corner {
position: absolute;
width: vw(20);
height: vw(20); // 使 vw
// width: vw(20);
// height: vw(20); // 使 vw
pointer-events: none; //
z-index: 1; //
@ -431,6 +436,19 @@ const handleClose = () => {
gap: vh(6);
}
/**
* 操作按钮区域
* 用于显示"连线""联动"等交互按钮
*/
.map-tooltip__actions {
display: flex;
justify-content: center;
align-items: center;
margin-top: vh(16);
padding-top: vh(12);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
/**
* 淡入淡出动画
* 结合缩放效果提升视觉体验

View File

@ -0,0 +1,261 @@
import { ref } from 'vue'
import * as Cesium from 'cesium'
import soldierIcon from '../assets/images/SketchPngfbec927027ff9e49207749ebaafd229429315341fda199251b6dfb1723ff17fb.png'
/**
* 实体动画管理 Composable
* 负责管理地图实体的路径动画,特别是应急人员沿路径移动
*/
export function useEntityAnimation() {
// 动画实体引用
const animatedEntity = ref(null)
// 动画是否正在运行
const isAnimating = ref(false)
/**
* 应急人员路径坐标 (Cartesian3 格式)
* 这些坐标定义了人员从起点到终点的移动路径
*/
const PERSONNEL_PATH_COORDINATES = [
{ x: -1705480.2641142386, y: 5247435.146513505, z: 3189090.667137187 },
{ x: -1705494.4820981335, y: 5247449.285793587, z: 3189060.452114544 },
{ x: -1705510.0809808762, y: 5247465.767274618, z: 3189023.050648535 },
{ x: -1705511.0338125327, y: 5247476.4118378535, z: 3188998.3643177846 },
{ x: -1705515.8155320068, y: 5247491.504151518, z: 3188970.3225750974 },
{ x: -1705512.9523099929, y: 5247504.710873116, z: 3188943.7936982675 },
{ x: -1705519.7649184526, y: 5247519.060354441, z: 3188915.1883725724 },
{ x: -1705528.241912857, y: 5247539.302819527, z: 3188872.220619207 },
{ x: -1705530.7649293465, y: 5247548.26353356, z: 3188852.4565014304 },
{ x: -1705536.847870567, y: 5247562.107401437, z: 3188816.1164027476 },
{ x: -1705554.2817406887, y: 5247571.234068825, z: 3188789.6105980803 },
{ x: -1705573.026007999, y: 5247580.50225183, z: 3188770.244426234 },
{ x: -1705602.018256302, y: 5247597.236114229, z: 3188743.7470805836 }
]
/**
* 启动应急人员沿路径移动动画
* @param {Cesium.Viewer} viewer - Cesium viewer 实例
* @param {Object} options - 配置选项
* @param {number} [options.duration=36] - 动画总时长()
* @param {boolean} [options.trackEntity=false] - 是否相机跟随实体
* @param {string} [options.personnelName='应急人员'] - 人员名称
* @param {string} [options.department='应急救援队'] - 所属部门
* @returns {Cesium.Entity} 返回创建的动画实体
*/
const startPersonnelMovement = (viewer, options = {}) => {
if (!viewer) {
console.warn('[useEntityAnimation] startPersonnelMovement: viewer 为空')
return null
}
if (isAnimating.value) {
console.warn('[useEntityAnimation] 动画已在运行中,请先停止当前动画')
return animatedEntity.value
}
const config = {
duration: options.duration ?? 60, // 默认 60 秒,让移动更清晰可见
trackEntity: options.trackEntity ?? false,
personnelName: options.personnelName ?? '应急人员',
department: options.department ?? '应急救援队'
}
console.log('[useEntityAnimation] 开始启动人员移动动画...', config)
// 设置动画时间范围
const startTime = Cesium.JulianDate.now()
const stopTime = Cesium.JulianDate.addSeconds(
startTime,
config.duration,
new Cesium.JulianDate()
)
// 配置 viewer 时钟
viewer.clock.startTime = startTime.clone()
viewer.clock.stopTime = stopTime.clone()
viewer.clock.currentTime = startTime.clone()
viewer.clock.clockRange = Cesium.ClockRange.LOOP_STOP // 动画结束后停止
viewer.clock.multiplier = 1 // 实时速度
viewer.clock.shouldAnimate = true
// 创建 SampledPositionProperty 定义路径
const positionProperty = new Cesium.SampledPositionProperty()
// 计算每个路径点的时间间隔
const numberOfPoints = PERSONNEL_PATH_COORDINATES.length
const timeInterval = config.duration / (numberOfPoints - 1)
// 添加路径采样点
PERSONNEL_PATH_COORDINATES.forEach((coord, index) => {
const time = Cesium.JulianDate.addSeconds(
startTime,
index * timeInterval,
new Cesium.JulianDate()
)
const position = new Cesium.Cartesian3(coord.x, coord.y, coord.z)
positionProperty.addSample(time, position)
})
// 创建脉冲缩放效果 - 让图标闪烁更醒目
const pulseScale = new Cesium.CallbackProperty((time) => {
const elapsed = Cesium.JulianDate.secondsDifference(time, startTime)
// 使用正弦波产生脉冲效果,频率 3Hz幅度 ±30%
return 1.0 + Math.sin(elapsed * 3) * 0.3
}, false)
// 创建动画实体
const entity = viewer.entities.add({
availability: new Cesium.TimeIntervalCollection([
new Cesium.TimeInterval({
start: startTime,
stop: stopTime
})
]),
position: positionProperty,
orientation: new Cesium.VelocityOrientationProperty(positionProperty), // 自动朝向移动方向
billboard: {
image: soldierIcon,
width: 48, // 增大尺寸,从 36 增加到 48
height: 56, // 从 40 增加到 56
scale: pulseScale, // 使用脉冲缩放效果
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
// 添加距离缩放,让图标在不同距离下都清晰可见
scaleByDistance: new Cesium.NearFarScalar(
1000, 1.5, // 近距离1000米放大到 1.5 倍
50000, 0.8 // 远距离50000米缩小到 0.8 倍
)
},
// 添加发光轨迹线
path: {
resolution: 1,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.4, // 发光强度
taperPower: 0.5, // 渐变效果
color: Cesium.Color.CYAN // 青色轨迹
}),
width: 8,
leadTime: 0, // 前方不显示轨迹
trailTime: config.duration // 后方显示完整轨迹
},
properties: {
type: 'animatedSoldier',
name: config.personnelName,
department: config.department,
isAnimating: true
}
})
animatedEntity.value = entity
isAnimating.value = true
// 可选: 相机跟随实体
if (config.trackEntity) {
viewer.trackedEntity = entity
// 设置相机视角偏移
entity.viewFrom = new Cesium.Cartesian3(-100.0, -100.0, 50.0)
}
console.log('[useEntityAnimation] 人员移动动画已启动')
// 监听动画结束事件
const removeListener = viewer.clock.onStop.addEventListener(() => {
console.log('[useEntityAnimation] 动画已结束')
isAnimating.value = false
removeListener() // 移除监听器
})
return entity
}
/**
* 停止人员移动动画
* @param {Cesium.Viewer} viewer - Cesium viewer 实例
* @param {boolean} [removeEntity=true] - 是否移除动画实体
*/
const stopPersonnelMovement = (viewer, removeEntity = true) => {
if (!viewer) {
console.warn('[useEntityAnimation] stopPersonnelMovement: viewer 为空')
return
}
console.log('[useEntityAnimation] 停止人员移动动画')
// 停止时钟动画
viewer.clock.shouldAnimate = false
// 取消相机跟随
if (viewer.trackedEntity === animatedEntity.value) {
viewer.trackedEntity = undefined
}
// 移除实体
if (removeEntity && animatedEntity.value) {
viewer.entities.remove(animatedEntity.value)
animatedEntity.value = null
}
isAnimating.value = false
}
/**
* 暂停动画
* @param {Cesium.Viewer} viewer - Cesium viewer 实例
*/
const pauseAnimation = (viewer) => {
if (!viewer) return
viewer.clock.shouldAnimate = false
console.log('[useEntityAnimation] 动画已暂停')
}
/**
* 恢复动画
* @param {Cesium.Viewer} viewer - Cesium viewer 实例
*/
const resumeAnimation = (viewer) => {
if (!viewer) return
if (isAnimating.value) {
viewer.clock.shouldAnimate = true
console.log('[useEntityAnimation] 动画已恢复')
}
}
/**
* 获取路径的起点坐标 (用于初始标记点)
* @returns {Cesium.Cartesian3} 起点坐标
*/
const getStartPosition = () => {
const firstCoord = PERSONNEL_PATH_COORDINATES[0]
return new Cesium.Cartesian3(firstCoord.x, firstCoord.y, firstCoord.z)
}
/**
* Cartesian3 坐标转换为经纬度
* @param {Cesium.Cartesian3} cartesian - Cartesian3 坐标
* @returns {Object} { longitude, latitude, height }
*/
const cartesianToLonLat = (cartesian) => {
const cartographic = Cesium.Cartographic.fromCartesian(cartesian)
return {
longitude: Cesium.Math.toDegrees(cartographic.longitude),
latitude: Cesium.Math.toDegrees(cartographic.latitude),
height: cartographic.height
}
}
return {
animatedEntity,
isAnimating,
startPersonnelMovement,
stopPersonnelMovement,
pauseAnimation,
resumeAnimation,
getStartPosition,
cartesianToLonLat,
PERSONNEL_PATH_COORDINATES
}
}
export default useEntityAnimation

View File

@ -126,6 +126,18 @@
<span class="tooltip-field-value">{{ field.value }}</span>
</div>
</template>
<!-- 操作按钮插槽 -->
<template v-if="mapTooltip.data && mapTooltip.data.actions" #actions>
<button
v-for="(action, index) in mapTooltip.data.actions"
:key="index"
class="tooltip-action-btn"
@click="handleTooltipAction(action)"
>
{{ action.label }}
</button>
</template>
</MapTooltip>
</div>
@ -172,6 +184,7 @@ import { useDualMapCompare } from "./composables/useDualMapCompare";
import { useMapMarkers } from "./composables/useMapMarkers";
import { use3DTiles } from "./composables/use3DTiles";
import { useMapTooltip } from "./composables/useMapTooltip";
import { useEntityAnimation } from "./composables/useEntityAnimation";
import { useMapStore } from "@/map";
import { request } from "@shared/utils/request";
@ -233,6 +246,9 @@ const {
// Tooltip
const { tooltipState: mapTooltip, showTooltip, hideTooltip, updateTooltipPosition } = useMapTooltip();
//
const { startPersonnelMovement, stopPersonnelMovement, isAnimating } = useEntityAnimation();
// tooltip
const currentTooltipEntity = ref(null);
@ -403,28 +419,45 @@ const showMarkerTooltip = (viewer, entity, screenPosition, icon) => {
// Tooltip
let title = '';
const fields = [];
const actions = [];
if (type === 'soldier') {
title = '单兵信息';
//
title = '应急人员';
fields.push(
{ label: '姓名', value: properties.name?.getValue() || '-' },
{ label: '部门', value: properties.department?.getValue() || '-' },
{ label: '位置', value: properties.location?.getValue() || '-' }
{ label: '位置信息', value: properties.location?.getValue() || '-' },
{ label: '预计到达时间', value: properties.estimatedArrival?.getValue() || '-' }
);
actions.push({
label: '联动',
type: 'link',
data: entity
});
} else if (type === 'device') {
title = '设备信息';
//
title = '应急装备';
fields.push(
{ label: '设备名称', value: properties.name?.getValue() || '-' },
{ label: '设备类型', value: properties.deviceType?.getValue() || '-' },
{ label: '位置', value: properties.location?.getValue() || '-' }
{ label: '位置信息', value: properties.location?.getValue() || '-' },
{ label: '预计到达时间', value: properties.estimatedArrival?.getValue() || '-' }
);
} else if (type === 'emergencyBase') {
//
title = '应急基地';
fields.push(
{ label: '名称', value: properties.name?.getValue() || '-' },
{ label: '地址', value: properties.address?.getValue() || '-' },
{ label: '距离', value: properties.distance?.getValue() || '-' }
{ label: '基地名称', value: properties.name?.getValue() || '-' },
{ label: '路线编号', value: properties.routeNumber?.getValue() || '-' },
{ label: '隶属单位', value: properties.department?.getValue() || '-' },
{ label: '位置信息', value: properties.location?.getValue() || '-' }
);
actions.push({
label: '连线',
type: 'connect',
data: entity
});
} else if (type === 'station') {
const stationName = properties.name?.getValue() || '';
const distance = properties.distance?.getValue() || 0;
@ -451,17 +484,14 @@ const showMarkerTooltip = (viewer, entity, screenPosition, icon) => {
title = '储备中心';
fields.push(
{ label: '名称', value: properties.name?.getValue() || '-' },
{ label: '区县', value: properties.district?.getValue() || '-' },
{ label: '人员数量', value: properties.personnelCount?.getValue() || '0' },
{ label: '占地面积', value: properties.area?.getValue() ? `${properties.area?.getValue()}` : '-' }
{ label: '位置信息', value: properties.location?.getValue() || '-' }
);
} else if (type === 'presetPoint') {
//
title = '预置点';
fields.push(
{ label: '名称', value: properties.name?.getValue() || '-' },
{ label: '区县', value: properties.district?.getValue() || '-' },
{ label: '人员数量', value: properties.personnelCount?.getValue() || '0' }
{ label: '位置信息', value: properties.location?.getValue() || '-' }
);
}
@ -471,7 +501,7 @@ const showMarkerTooltip = (viewer, entity, screenPosition, icon) => {
y: canvasPosition.y,
title,
icon,
data: { fields }
data: { fields, actions: actions.length > 0 ? actions : undefined }
});
//
@ -554,17 +584,17 @@ onMounted(() => {
// 10
const simulatedPoints = [
// (6)
{ type: 'soldier', name: '张三', department: '应急救援队', lon: 107.97, lat: 30.25, distance: 2.5, icon: soldierIcon },
{ type: 'soldier', name: '李四', department: '消防队', lon: 107.971901, lat: 30.251428, distance: 2.3, icon: soldierIcon },
{ type: 'soldier', name: '王五', department: '医疗队', lon: 107.974901, lat: 30.241428, distance: 3.1, icon: soldierIcon },
{ type: 'soldier', name: '赵六', department: '应急救援队', lon: 108.047344, lat: 30.164313, distance: 4.2, icon: soldierIcon },
{ type: 'soldier', name: '刘七', department: '消防队', lon: 108.046344, lat: 30.168313, distance: 3.8, icon: soldierIcon },
{ type: 'soldier', name: '陈八', department: '医疗队', lon: 108.050344, lat: 30.170313, distance: 4.5, icon: soldierIcon },
{ type: 'soldier', name: '张三', department: '应急救援队', lon: 107.97, lat: 30.25, distance: 2.5, estimatedArrival: '10分钟', icon: soldierIcon },
{ type: 'soldier', name: '李四', department: '消防队', lon: 107.971901, lat: 30.251428, distance: 2.3, estimatedArrival: '8分钟', icon: soldierIcon },
{ type: 'soldier', name: '王五', department: '医疗队', lon: 107.974901, lat: 30.241428, distance: 3.1, estimatedArrival: '12分钟', icon: soldierIcon },
{ type: 'soldier', name: '赵六', department: '应急救援队', lon: 108.047344, lat: 30.164313, distance: 4.2, estimatedArrival: '15分钟', icon: soldierIcon },
{ type: 'soldier', name: '刘七', department: '消防队', lon: 108.046344, lat: 30.168313, distance: 3.8, estimatedArrival: '14分钟', icon: soldierIcon },
{ type: 'soldier', name: '陈八', department: '医疗队', lon: 108.050344, lat: 30.170313, distance: 4.5, estimatedArrival: '16分钟', icon: soldierIcon },
// (4)
{ type: 'device', name: '救援车辆A', deviceType: '消防车', lon: 107.98088, lat: 30.2487, distance: 2.8, icon: deviceIcon },
{ type: 'device', name: '救援车辆B', deviceType: '救护车', lon: 107.97898, lat: 30.2502, distance: 2.6, icon: deviceIcon },
{ type: 'device', name: '无人机A', deviceType: 'DJI', lon: 108.049344, lat: 30.160313, distance: 4.8, icon: deviceIcon },
{ type: 'device', name: '无人机B', deviceType: 'DJI', lon: 108.043344, lat: 30.169313, distance: 3.6, icon: deviceIcon }
{ type: 'device', name: '救援车辆A', deviceType: '消防车', lon: 107.98088, lat: 30.2487, distance: 2.8, estimatedArrival: '11分钟', icon: deviceIcon },
{ type: 'device', name: '救援车辆B', deviceType: '救护车', lon: 107.97898, lat: 30.2502, distance: 2.6, estimatedArrival: '9分钟', icon: deviceIcon },
{ type: 'device', name: '无人机A', deviceType: 'DJI', lon: 108.049344, lat: 30.160313, distance: 4.8, estimatedArrival: '17分钟', icon: deviceIcon },
{ type: 'device', name: '无人机B', deviceType: 'DJI', lon: 108.043344, lat: 30.169313, distance: 3.6, estimatedArrival: '13分钟', icon: deviceIcon }
];
simulatedPoints.forEach(point => {
@ -583,13 +613,15 @@ onMounted(() => {
type: 'soldier',
name: point.name,
department: point.department,
location: `目前为止距离现场${point.distance}公里`
location: `目前为止距离现场${point.distance}公里`,
estimatedArrival: point.estimatedArrival
}
: {
type: 'device',
name: point.name,
deviceType: point.deviceType,
location: `目前为止距离现场${point.distance}公里`
location: `目前为止距离现场${point.distance}公里`,
estimatedArrival: point.estimatedArrival
}
});
});
@ -888,6 +920,55 @@ const handlePersonnelLink = (personnel) => {
showPersonnelDetail.value = false;
};
/**
* 处理 Tooltip 操作按钮点击事件
* @param {Object} action - 操作对象包含 type data
*/
const handleTooltipAction = (action) => {
console.log('[index.vue] Tooltip 操作按钮点击:', action);
if (action.type === 'link') {
// ""
const entity = action.data;
const properties = entity.properties;
console.log('[index.vue] 应急人员联动:', {
name: properties.name?.getValue(),
department: properties.department?.getValue()
});
//
// 1.
// 2.
// 3.
ElMessage.success(`已联动应急人员: ${properties.name?.getValue() || '未知'}`);
// tooltip
hideTooltip();
currentTooltipEntity.value = null;
} else if (action.type === 'connect') {
// "线"
const entity = action.data;
const properties = entity.properties;
console.log('[index.vue] 应急基地连线:', {
name: properties.name?.getValue(),
routeNumber: properties.routeNumber?.getValue()
});
// 线
// 1. 线
// 2.
// 3.
ElMessage.success(`已连线应急基地: ${properties.name?.getValue() || '未知'}`);
// tooltip
hideTooltip();
currentTooltipEntity.value = null;
}
};
/**
* 关闭地图 Tooltip
* 统一的关闭入口便于后续扩展埋点或联动逻辑
@ -898,7 +979,7 @@ const handleMapTooltipClose = () => {
/**
* 处理力量调度启动事件
* 显示加载动画3秒后自动隐藏
* 显示加载动画3秒后自动隐藏然后启动人员移动动画
*/
const handleStartDispatch = (payload) => {
console.log('[index.vue] 启动力量调度:', payload);
@ -906,9 +987,22 @@ const handleStartDispatch = (payload) => {
//
showLoading.value = true;
// 3
// 3
setTimeout(() => {
showLoading.value = false;
// 沿
if (mapStore.viewer) {
console.log('[index.vue] 启动应急人员移动动画...');
startPersonnelMovement(mapStore.viewer, {
duration: 60, // 60
trackEntity: false, // true
personnelName: '应急救援人员',
department: '应急救援队'
});
} else {
console.warn('[index.vue] 地图viewer未就绪无法启动动画');
}
}, 3000);
};
@ -1401,6 +1495,32 @@ const showMapTooltip = ({ x, y, title = "", icon = "", data = null }) => {
}
}
// Tooltip
// "线"""
.tooltip-action-btn {
min-width: vw(100);
height: vh(36);
padding: 0 vw(24);
background: url('./assets/images/地图tooltip-button.png') no-repeat center/100% 100%;
border: none;
color: var(--text-white);
font-size: fs(14);
font-family: SourceHanSansCN-Medium, sans-serif;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
filter: brightness(1.2);
transform: translateY(vh(-2));
}
&:active {
filter: brightness(0.9);
transform: translateY(0);
}
}
// <1100px
.situational-awareness.is-compact {
--sa-left-width: calc(380 / 1920 * var(--cq-inline-100, 100vw));