| | |
| | | <template> |
| | | <div :id="mapId" class="common-cesium-map" @contextmenu.prevent></div> |
| | | <div :id="mapId" class="common-cesium-map" @contextmenu.prevent> |
| | | <template v-if="props.list && props.list.length"> |
| | | <div class="card"> |
| | | <div class="timeline-prefix"> |
| | | <slot name="timeline-prefix"></slot> |
| | | </div> |
| | | <div |
| | | ref="timelineRef" |
| | | class="card-content" |
| | | :class="{ 'is-dragging': timelineDragging }" |
| | | @mousedown="handleTimelineMouseDown" |
| | | @mousemove="handleTimelineMouseMove" |
| | | @mouseup="stopTimelineDrag" |
| | | @mouseleave="stopTimelineDrag" |
| | | > |
| | | <div class="timeline-track"> |
| | | <div class="time-line"></div> |
| | | <div |
| | | class="card-item" |
| | | v-for="(item, index) in props.list" |
| | | @click="handleTimelineItemClick(item, index)" |
| | | > |
| | | <div class="time-point"> |
| | | <img |
| | | class="active" |
| | | v-if="index === selectedImgIndex" |
| | | src="@/assets/images/common/point-active.png" |
| | | alt="" |
| | | /> |
| | | <img v-else src="@/assets/images/common/point.png" alt="" /> |
| | | </div> |
| | | <div class="time-bottom">{{ item.createTime.slice(11) }}</div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | |
| | | import { onBeforeUnmount, onMounted, watch } from 'vue' |
| | | import { PublicCesium } from '@/utils/cesium/publicCesium' |
| | | import { loadJaAdminBoundary, removeJaAdminBoundary } from '@/utils/cesium/adminBoundary' |
| | | import positionIcon from '@/assets/images/dataCenter/positionicon.png' |
| | | |
| | | const props = defineProps({ |
| | | active: { |
| | |
| | | type: Number, |
| | | default: 10000, |
| | | }, |
| | | list: { |
| | | type: Array, |
| | | default: () => [], |
| | | }, |
| | | }) |
| | | |
| | | const emit = defineEmits(['ready', 'height-change', 'stage-change']) |
| | | const emit = defineEmits(['ready', 'height-change', 'stage-change', 'point-change']) |
| | | const mapId = props.domId || `common-cesium-map-${Math.random().toString(36).slice(2, 10)}` |
| | | const timelineRef = ref(null) |
| | | const timelineDragging = ref(false) |
| | | let timelineStartX = 0 |
| | | let timelineStartScrollLeft = 0 |
| | | let timelineDidDrag = false |
| | | let viewInstance = null |
| | | let viewer = null |
| | | const ADMIN_BOUNDARY_NAMES = { |
| | |
| | | removeCameraListener = () => viewer?.camera?.changed?.removeEventListener(onCameraChanged) |
| | | } |
| | | emit('ready', { viewer, publicCesium: viewInstance }) |
| | | // 地图就绪时,若列表已加载则默认标记选中项(首个) |
| | | showSelectedMarker() |
| | | } |
| | | |
| | | const selectedImgIndex = ref(0) |
| | | // 航线高度(与 TrajectoryDiaLog 里 MOCK_FLIGHT_HEIGHT 保持一致),让标记悬浮在轨迹线上 |
| | | const FLIGHT_HEIGHT = 120 |
| | | const POSITION_MOVE_DURATION = 1000 |
| | | // 当前经纬度位置标记 |
| | | let positionEntity = null |
| | | let positionAnimationFrame = null |
| | | |
| | | // 停止位置图标移动动画 |
| | | const stopPositionAnimation = () => { |
| | | if (positionAnimationFrame !== null) { |
| | | cancelAnimationFrame(positionAnimationFrame) |
| | | positionAnimationFrame = null |
| | | } |
| | | } |
| | | |
| | | // 平滑移动位置图标 |
| | | const animatePositionMarker = (targetPosition, duration) => { |
| | | const currentPosition = positionEntity?.position?.getValue(viewer.clock.currentTime, new Cesium.Cartesian3()) |
| | | if (!currentPosition) { |
| | | positionEntity.position = targetPosition |
| | | return |
| | | } |
| | | stopPositionAnimation() |
| | | const startTime = performance.now() |
| | | const nextPosition = new Cesium.Cartesian3() |
| | | |
| | | // 更新位置图标移动进度 |
| | | const updatePosition = currentTime => { |
| | | const progress = Math.min((currentTime - startTime) / duration, 1) |
| | | Cesium.Cartesian3.lerp(currentPosition, targetPosition, progress, nextPosition) |
| | | positionEntity.position.setValue(nextPosition) |
| | | viewer.scene.requestRender() |
| | | if (progress < 1) { |
| | | positionAnimationFrame = requestAnimationFrame(updatePosition) |
| | | } else { |
| | | positionAnimationFrame = null |
| | | } |
| | | } |
| | | positionAnimationFrame = requestAnimationFrame(updatePosition) |
| | | } |
| | | |
| | | // 点击周期 |
| | | const clickWeekTime = (item, index, options = {}) => { |
| | | // clickImgSrc.value = item.url |
| | | selectedImgIndex.value = index |
| | | showPositionMarker(item, options) |
| | | // 通知父组件当前选中的轨迹点,用于右侧表格同步高亮 |
| | | emit('point-change', { item, index }) |
| | | } |
| | | |
| | | // 开始拖动时间轴 |
| | | const handleTimelineMouseDown = event => { |
| | | if (event.button !== 0) return |
| | | timelineDragging.value = true |
| | | timelineDidDrag = false |
| | | timelineStartX = event.clientX |
| | | timelineStartScrollLeft = timelineRef.value?.scrollLeft ?? 0 |
| | | } |
| | | |
| | | // 横向拖动时间轴 |
| | | const handleTimelineMouseMove = event => { |
| | | if (!timelineDragging.value || !timelineRef.value) return |
| | | const offsetX = event.clientX - timelineStartX |
| | | if (Math.abs(offsetX) > 3) timelineDidDrag = true |
| | | if (!timelineDidDrag) return |
| | | timelineRef.value.scrollLeft = timelineStartScrollLeft - offsetX |
| | | event.preventDefault() |
| | | } |
| | | |
| | | // 停止拖动时间轴 |
| | | const stopTimelineDrag = () => { |
| | | timelineDragging.value = false |
| | | setTimeout(() => { |
| | | timelineDidDrag = false |
| | | }, 0) |
| | | } |
| | | |
| | | // 点击时间轴节点 |
| | | const handleTimelineItemClick = (item, index) => { |
| | | if (timelineDidDrag) return |
| | | clickWeekTime(item, index) |
| | | } |
| | | |
| | | // 在地图上通过经纬度显示位置图标 |
| | | const showPositionMarker = (item, { animate = false, duration = POSITION_MOVE_DURATION } = {}) => { |
| | | if (!viewer) return |
| | | const longitude = Number(item?.longitude) |
| | | const latitude = Number(item?.latitude) |
| | | if (Number.isNaN(longitude) || Number.isNaN(latitude)) return |
| | | |
| | | // 与航线相同的高度,让标记悬浮在轨迹线上,而不是贴地 |
| | | const position = Cesium.Cartesian3.fromDegrees(longitude, latitude, FLIGHT_HEIGHT) |
| | | if (positionEntity) { |
| | | if (animate) { |
| | | animatePositionMarker(position, duration) |
| | | } else { |
| | | stopPositionAnimation() |
| | | positionEntity.position = position |
| | | } |
| | | } else { |
| | | positionEntity = viewer.entities.add({ |
| | | position, |
| | | billboard: { |
| | | image: positionIcon, |
| | | width: 36, |
| | | height: 36, |
| | | verticalOrigin: Cesium.VerticalOrigin.BOTTOM, |
| | | disableDepthTestDistance: Number.POSITIVE_INFINITY, |
| | | }, |
| | | }) |
| | | } |
| | | } |
| | | |
| | | // 显示当前选中项的标记(默认首个),地图就绪和列表加载后都会调用 |
| | | const showSelectedMarker = () => { |
| | | const item = props.list?.[selectedImgIndex.value] |
| | | if (item) showPositionMarker(item) |
| | | } |
| | | |
| | | // 列表数据到位后,默认选中并标记首个点 |
| | | watch( |
| | | () => props.list, |
| | | list => { |
| | | if (list && list.length) { |
| | | selectedImgIndex.value = 0 |
| | | showSelectedMarker() |
| | | // 默认选中首个,同步通知父组件高亮表格首行 |
| | | emit('point-change', { item: list[0], index: 0 }) |
| | | } |
| | | } |
| | | ) |
| | | |
| | | watch( |
| | | () => props.active, |
| | |
| | | }) |
| | | |
| | | onBeforeUnmount(() => { |
| | | stopPositionAnimation() |
| | | if (removeCameraListener) removeCameraListener() |
| | | removeJaAdminBoundary(viewer, adminBoundarySources) |
| | | adminBoundarySources = null |
| | | positionEntity = null |
| | | viewInstance?.viewerDestroy?.() |
| | | }) |
| | | |
| | |
| | | }) |
| | | } |
| | | |
| | | defineExpose({ getMap, getViewer, getPublicCesium, flyToPoint, setAdminBoundaryVisible, zoomToAdminBoundary }) |
| | | defineExpose({ getMap, getViewer, getPublicCesium, flyToPoint, clickWeekTime, setAdminBoundaryVisible, zoomToAdminBoundary }) |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | .common-cesium-map { |
| | | width: 100%; |
| | | height: 100%; |
| | | position: relative; |
| | | .card { |
| | | position: absolute; |
| | | bottom: 0px; |
| | | width: 100%; |
| | | height: 67px; |
| | | box-sizing: border-box; |
| | | background: rgba(0, 0, 0, 0.42); |
| | | border-radius: 0; |
| | | |
| | | color: #d5d5d5; |
| | | font-size: 14px; |
| | | |
| | | .timeline-prefix { |
| | | position: absolute; |
| | | left: 7.5%; |
| | | top: 50%; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | z-index: 2; |
| | | transform: translate(-50%, -50%); |
| | | } |
| | | |
| | | .card-content { |
| | | display: flex; |
| | | position: absolute; |
| | | left: 15%; |
| | | width: 70%; |
| | | height: 100%; |
| | | overflow-x: auto; |
| | | overflow-y: hidden; |
| | | white-space: nowrap; |
| | | cursor: grab; |
| | | scrollbar-width: none; |
| | | |
| | | &::-webkit-scrollbar { |
| | | display: none; |
| | | } |
| | | |
| | | &.is-dragging { |
| | | cursor: grabbing; |
| | | user-select: none; |
| | | } |
| | | |
| | | .timeline-track { |
| | | position: relative; |
| | | display: flex; |
| | | flex: 0 0 auto; |
| | | height: 100%; |
| | | margin: 0 auto; |
| | | } |
| | | |
| | | .card-item { |
| | | position: relative; |
| | | flex: 0 0 82px; |
| | | width: 82px; |
| | | text-align: center; |
| | | cursor: pointer; |
| | | } |
| | | |
| | | &.is-dragging .card-item { |
| | | cursor: grabbing; |
| | | } |
| | | |
| | | .time-top { |
| | | margin-top: 10px; |
| | | line-height: 1; |
| | | text-align: center; |
| | | cursor: pointer; |
| | | } |
| | | |
| | | .active { |
| | | font-weight: 400; |
| | | color: #ffffff; |
| | | } |
| | | |
| | | .time-point { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | position: absolute; |
| | | top: 22px; |
| | | left: 50%; |
| | | width: 18px; |
| | | height: 18px; |
| | | z-index: 1; |
| | | transform: translate(-50%, 0); |
| | | |
| | | img { |
| | | width: 11.2px; |
| | | height: 11.2px; |
| | | } |
| | | |
| | | img.active { |
| | | width: 16.89px; |
| | | height: 16.89px; |
| | | } |
| | | } |
| | | |
| | | .time-bottom { |
| | | margin-top: 44px; |
| | | } |
| | | |
| | | .time-line { |
| | | position: absolute; |
| | | left: 41px; |
| | | right: 41px; |
| | | bottom: 36px; |
| | | height: 1px; |
| | | background: rgba(237, 237, 237, 0.6); |
| | | z-index: 0; |
| | | pointer-events: none; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | </style> |