需要实现三个功能: 室外多机器人实时轨迹展示(高德) 搜索选点(高德) 室内图轨迹展示(canvas)
高德地图基本概念:
地图 Amap和实例map
AMap 对象提供了加载地图、创建地图、添加覆盖物、进行地图操作,加载插件等等的方法和属性。可以使用AMap创建地图实例。
const map = new AMap.Map(mapcontainer, {
center: [121.412577, 31.218804], // Center coordinates
zoom: 14, // Initial zoom level
});
地图实例是通过 AMap.Map
创建的一个具体的地图对象。它代表了一个可视化的地图窗口,你可以在上面添加标记、覆盖物、控件等等。地图实例具有与地图相关的属性和方法,例如中心点坐标、缩放级别、平移、添加覆盖物等等。
AMap 是高德地图 JavaScript API 的主要入口,提供了加载地图和各种功能的方法,而地图实例则是你在页面上创建的具体的地图对象,你可以通过它进行地图的操作和交互。
点
就是点。可以通过调用API进行添加,修改内容,移动,设置点击事件等。这里的逻辑很简单,就是把路径数据的第一个点的位置初始化为一个Marker,后续都是这个Marker的移动,设置点击事件和样式,获取坐标等。
但是动画效果需要自己写,在这个案例中,我设置了自动计算角度也是无效的,同样需要自己计算(这个暂时还没搞清楚为什么)。 路径没用到,就不说了。
室外多机器人实时轨迹展示(高德)
整体分为三个部分:
- 初始化地图 (useInitMap)
- 获取路径数据(可以从webSocket中获取) (usePathData)
- 绘制点 (useMarker)
- 让点动起来 计算动画和角度 使之看起来平滑移动 (useMarkAnimations)
这里有两个方案,一个是巡航(主要是路径,DEMO就不放这里了),一个是Marker(需要自己移动点),巡航能支持更复杂的交互展示,两个方案都写了DEMO。这里因为不涉及轨迹展示,所以用Marker实现比较简单。客服回复如下:
index.vue
<template>
<div class="page-tenant-map">
<RobotInfoCardComponent :robotId="selectRobotId" />
<div id="mapcontainer" style="width: 100%; height: 800px"></div>
</div>
</template>
<script setup lang="ts">
import RobotInfoCardComponent from '../RobotInfoCardComponent/index.vue';
import ReviewRobotDataCardComponent from './components/ReviewRobotDataCardComponent/index.vue';
import useInitMap from './compositions/useInitMap';
import { onMounted, ref } from 'vue';
import AMapLoader from '@amap/amap-jsapi-loader';
import usePathData from '@/views/tenant/map/components/OutSideMapComponent/compositions/usePathData';
import useMarker from '@/views/tenant/map/components/OutSideMapComponent/compositions/useMarker';
import useMarkAnimations from '@/views/tenant/map/components/OutSideMapComponent/compositions/useMarkAnimations';
const selectRobotId = ref('');
const AMapKey = '';
onMounted(async () => {
// Load AMap API
const AMap = await AMapLoader.load({
key: AMapKey,
version: '2.0',
});
const { map } = useInitMap(AMap, 'mapcontainer');
const { pathData } = usePathData();
const { markers } = useMarker(map, AMap, pathData, selectRobotId);
useMarkAnimations(map, AMap, pathData, markers);
});
</script>
<style scoped src="./index.less" lang="less" />
useInitMap 初始化地图 加载一些插件
const useInitMap = (AMap, mapcontainer: string) => {
const map = new AMap.Map(mapcontainer, {
center: [121.412577, 31.218804], // Center coordinates
zoom: 14, // Initial zoom level
});
AMap.plugin(
[
'AMap.ToolBar',
'AMap.Scale',
'AMap.Geolocation',
'AMap.PlaceSearch',
'AMap.Geocoder',
'AMap.MoveAnimation',
],
() => {
// 缩放条
const toolbar = new AMap.ToolBar();
// 比例尺
const scale = new AMap.Scale();
map.addControl(toolbar);
map.addControl(scale);
},
);
return { map };
};
export default useInitMap;
usePathData 随便生成的两个路径数据,一个圆的一个方的,测试角度用,注释中是从webSocket连接中获取数据
import { useWebSocket } from '@vueuse/core';
import { reactive, watchEffect } from 'vue';
const usePathData = () => {
// const state = reactive({
// server: 'ws://localhost:3300/test',
// sendValue: '',
// recordList: [] as { id: number; time: number; res: string }[],
// });
//
// const { status, data, send, close, open } = useWebSocket(state.server, {
// autoReconnect: false,
// heartbeat: true,
// });
//
// // 解码数据 返回
// watchEffect(() => {
// if (data.value) {
// try {
// const res = JSON.parse(data.value);
// state.recordList.push(res);
// } catch (error) {
// state.recordList.push({
// res: data.value,
// id: Math.ceil(Math.random() * 1000),
// time: new Date().getTime(),
// });
// }
// }
// });
// 初始的经纬度
const center = { lng: 121.422635, lat: 31.216688 };
// 生成缓慢前进的四方形路径的经纬度数据数组
function generateSlowSquarePath(center, sideLength, numPoints, distancePerStep) {
const halfSide = sideLength / 2;
const path = [];
for (let i = 0; i < numPoints; i++) {
const lng = center.lng + (i % 2 === 0 ? halfSide : -halfSide);
const lat = center.lat + (i < 2 ? halfSide : -halfSide);
path.push({
lng,
lat,
extData: {
markerId: 234,
},
});
}
const slowPath = [];
for (let i = 0; i < path.length - 1; i++) {
const start = path[i];
const end = path[i + 1];
for (let j = 0; j < distancePerStep; j++) {
const lng = start.lng + ((end.lng - start.lng) * j) / distancePerStep;
const lat = start.lat + ((end.lat - start.lat) * j) / distancePerStep;
slowPath.push({
lng,
lat,
extData: {
markerId: 567,
},
});
}
}
slowPath.push(path[path.length - 1]);
return slowPath;
}
const slowSquarePath = generateSlowSquarePath(center, 0.001, 4, 10);
// 生成缓慢前进的圆形路径的经纬度数据数组
function generateSlowCircularPath(center, radius, numPoints, distancePerStep) {
const path = [];
for (let i = 0; i < numPoints; i++) {
const angle = (i / numPoints) * 2 * Math.PI;
const lng = center.lng + radius * Math.cos(angle);
const lat = center.lat + radius * Math.sin(angle);
path.push({
lng,
lat,
extData: {
markerId: i,
},
});
}
const slowPath = [];
for (let i = 0; i < path.length - 1; i++) {
const start = path[i];
const end = path[i + 1];
for (let j = 0; j < distancePerStep; j++) {
const lng = start.lng + ((end.lng - start.lng) * j) / distancePerStep;
const lat = start.lat + ((end.lat - start.lat) * j) / distancePerStep;
slowPath.push({
lng,
lat,
extData: {
markerId: i,
},
});
}
}
slowPath.push(path[path.length - 1]);
return slowPath;
}
const slowCircularPath = generateSlowCircularPath(center, 0.01, 50, 50);
const pathData = [[...slowSquarePath], [...slowCircularPath]];
return {
pathData,
};
};
export default usePathData;
useMarker 主要是初始化点,设置一些点击事件和样式效果,setFitView
是让地图正好包下所有的点。
import { ref } from 'vue';
const useMarker = (map, AMap, pathData, selectRobotId) => {
let currentSelectMarker = null;
const markers = ref([]);
// Create and add markers to the map based on pathData
pathData.forEach((path) => {
const marker = new AMap.Marker({
title: '配送001\n' + '\n' + '室外,离线\n' + '\n' + '62%,10km/h',
position: [path[0].lng, path[0].lat],
map,
icon: new AMap.Icon({
imageSize: new AMap.Size(60, 60),
image:
'https://files.axshare.com/gsc/NNGZ7Q/32/bd/f0/32bdf06cae754929be938453c4494f91/images/%E7%A7%9F%E6%88%B7%E5%85%A8%E5%B1%80%E5%9C%B0%E5%9B%BE/u72.svg?pageId=126b2c69-7f1f-4de1-bc41-3f0f0487c1d6',
}),
autoRotation: true,
animation: 'AMAP_ANIMATION_DROP',
extData: {
a: 1,
...path[0].extData,
},
});
marker.on('click', (data) => {
console.log('click', data, marker.getExtData());
selectRobotId.value = marker.getExtData()?.markerId;
const noSelectIcon = new AMap.Icon({
imageSize: new AMap.Size(60, 60),
image:
'https://files.axshare.com/gsc/NNGZ7Q/32/bd/f0/32bdf06cae754929be938453c4494f91/images/%E7%A7%9F%E6%88%B7%E5%85%A8%E5%B1%80%E5%9C%B0%E5%9B%BE/u72.svg?pageId=126b2c69-7f1f-4de1-bc41-3f0f0487c1d6',
});
currentSelectMarker && currentSelectMarker.setIcon(noSelectIcon);
const selectIcon = new AMap.Icon({
imageSize: new AMap.Size(60, 60),
image:
'https://files.axshare.com/gsc/NNGZ7Q/32/bd/f0/32bdf06cae754929be938453c4494f91/images/%E7%A7%9F%E6%88%B7%E5%85%A8%E5%B1%80%E5%9C%B0%E5%9B%BE/u75.svg?pageId=126b2c69-7f1f-4de1-bc41-3f0f0487c1d6',
});
marker.setIcon(selectIcon);
currentSelectMarker = marker;
});
markers.value.push(marker);
});
map.setFitView(null, true, [220, 220, 220, 220]);
return {
markers,
};
};
export default useMarker;
useMarkAnimations 根据路径 让点平滑移动 (其实有点担心,如果是实时数据获取,网络不稳定的情况下,不处理数据的话,会不会出现很离谱的延迟,放在二期做吧)
const useMarkAnimations = (map, AMap, pathData, markers) => {
const animationInterval = 1000;
let currentIndex = 0;
setInterval(() => {
markers.value.forEach((marker, markerIndex) => {
const path = pathData[markerIndex];
const nextIndex = (currentIndex + 1) % path.length;
const nextPoint = path[nextIndex];
const startPosition = marker.getPosition();
const endPosition = new AMap.LngLat(nextPoint.lng, nextPoint.lat);
animateMarker(AMap, marker, startPosition, endPosition);
});
currentIndex = (currentIndex + 1) % pathData[0].length;
}, animationInterval);
function animateMarker(AMap, marker, startPosition, endPosition) {
const startTime = new Date().getTime();
const startRotation = marker.getAngle(); // 获取起始角度
const getAngle = (startPoint, endPoint) => {
if (!(startPoint && endPoint)) {
return 0;
}
let dRotateAngle = Math.atan2(
Math.abs(startPoint.lng - endPoint.lng),
Math.abs(startPoint.lat - endPoint.lat),
);
if (endPoint.lng >= startPoint.lng) {
if (endPoint.lat >= startPoint.lat) {
} else {
dRotateAngle = Math.PI - dRotateAngle;
}
} else {
if (endPoint.lat >= startPoint.lat) {
dRotateAngle = 2 * Math.PI - dRotateAngle;
} else {
dRotateAngle = Math.PI + dRotateAngle;
}
}
dRotateAngle = (dRotateAngle * 180) / Math.PI;
return dRotateAngle;
};
function step() {
const currentTime = new Date().getTime();
const progress = (currentTime - startTime) / 1000;
if (progress < 1) {
const lng = startPosition.lng + (endPosition.lng - startPosition.lng) * progress;
const lat = startPosition.lat + (endPosition.lat - startPosition.lat) * progress;
const rotation = getAngle(startPosition, endPosition);
marker.setAngle(rotation);
marker.setPosition(new AMap.LngLat(lng, lat));
requestAnimationFrame(step);
} else {
marker.setPosition(endPosition);
marker.setAngle(startRotation); // 完成后还原角度
}
}
step();
}
};
export default useMarkAnimations;
搜索选点(高德)
效果图:
index.vue 先放着 稍后拆分
<template>
<div class="map-wrapper">
<div id="mapcontainer"></div>
<div class="search-box">
<a-auto-complete
v-model:value="keyword"
style="width: 200px"
placeholder="输入城市+关键字搜索"
@select="handleSelect"
@search="handleSearch"
:trigger-on-focus="false"
clearable
:options="options"
/>
<a-input
v-model:value="location.longitude"
placeholder="点击地图选择经度"
maxlength="15"
disabled
style="width: 150px; margin: 0 5px"
/>
<a-input
v-model:value="location.latitude"
placeholder="点击地图选择纬度"
maxlength="15"
disabled
style="width: 150px"
/>

<a-button
type="primary"
v-if="location.longitude && location.latitude"
style="width: 150px; margin: 0 5px"
@click="handleConfirm"
>选择该位置
</a-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { shallowRef } from 'vue';
import AMapLoader from '@amap/amap-jsapi-loader';
window._AMapSecurityConfig = {
securityJsCode: '',
};
const props = defineProps({
location: {
type: Object,
default() {
return {};
},
},
});
const emit = defineEmits(['update:modelValue']);
const map = shallowRef(null);
const options = ref(null);
const location = ref(props.location);
const handleConfirm = () => {
emit('selectLocation', location.value);
};
watch(location, (val) => {
if (val.longitude && val.latitude) {
drawMarker();
}
});
const keyword = ref('');
let placeSearch, AMapObj, marker, geocoder;
function initMap() {
AMapLoader.load({
key: '', // 申请好的Web端Key,首次调用 load 时必填
version: '2.0',
}).then((AMap) => {
AMapObj = AMap;
map.value = new AMap.Map('mapcontainer');
// 添加点击事件
map.value.on('click', onMapClick);
if (location.value.longitude) {
drawMarker();
}
AMap.plugin(
[
'AMap.ToolBar',
'AMap.Scale',
'AMap.Geolocation',
'AMap.PlaceSearch',
'AMap.Geocoder',
'AMap.AutoComplete',
],
() => {
// 缩放条
const toolbar = new AMap.ToolBar();
// 比例尺
const scale = new AMap.Scale();
// 定位
const geolocation = new AMap.Geolocation({
enableHighAccuracy: true, //是否使用高精度定位,默认:true
timeout: 10000, //超过10秒后停止定位,默认:5s
position: 'RT', //定位按钮的停靠位置
buttonOffset: new AMap.Pixel(10, 20), //定位按钮与设置的停靠位置的偏移量,默认:Pixel(10, 20)
zoomToAccuracy: true, //定位成功后是否自动调整地图视野到定位点
});
geocoder = new AMap.Geocoder({
city: '全国',
});
map.value.addControl(geolocation);
map.value.addControl(toolbar);
map.value.addControl(scale);
placeSearch = new AMap.PlaceSearch({
map: map.value,
city: '',
pageSize: 10, // 单页显示结果条数
pageIndex: 1, // 页码
citylimit: false, // 是否强制限制在设置的城市内搜索
autoFitView: true,
});
placeSearch.on('markerClick', (item) => {
console.log('markerClick', item.data);
const { pname, cityname, adname, address, name } = item?.data;
const { lng, lat } = item.data?.location;
location.value = {
longitude: lng,
latitude: lat,
address,
zone: [pname, cityname, adname],
name,
};
map.value?.setZoomAndCenter(16, [lng, lat]);
});
},
);
});
}
onMounted(() => {
initMap();
});
// 搜索地图
function handleSearch(queryString, cb) {
placeSearch.search(queryString, (status, result) => {
if (result && typeof result === 'object' && result.poiList) {
const list = result.poiList.pois;
list.forEach((item) => {
item.value = item.name;
item.label = item.name;
});
cb?.(list);
options.value = list;
} else {
cb?.([]);
}
});
}
// 点击地图
function onMapClick(e) {
const { lng, lat } = e.lnglat;
// 逆地理编码
geocoder.getAddress([lng, lat], (status, result) => {
if (status === 'complete' && result.info === 'OK') {
const { addressComponent, formattedAddress } = result.regeocode;
let { city, province, district } = addressComponent;
if (!city) {
// 直辖市
city = province;
}
console.log(
location.value,
'location',
lng,
lat,
formattedAddress,
province,
city,
district,
);
location.value = {
longitude: lng,
latitude: lat,
address: formattedAddress,
zone: [province, city, district],
};
}
});
}
// 点击搜索项
function handleSelect(label, item) {
const { pname, cityname, adname, address, name } = item;
const { lng, lat } = item.location;
location.value = {
longitude: lng,
latitude: lat,
address,
zone: [pname, cityname, adname],
name,
};
map.value?.setZoomAndCenter(16, [lng, lat]);
}
// 绘制地点marker
function drawMarker(val) {
const { longitude, latitude } = location.value || val;
if (marker) {
marker.setMap(null);
}
marker = new AMapObj.Marker({
position: new AMapObj.LngLat(longitude, latitude),
anchor: 'bottom-center',
clickable: true,
});
map.value?.add(marker);
map.value?.setZoomAndCenter(16, [longitude, latitude]);
}
</script>
<style lang="less" src="./index.less" scoped />
室内图轨迹展示(canvas)
目标:多个图标点根据实时按秒获取的坐标移动及转角,使用requestAnimationFrame来做平滑过渡。 由于canvas绘制,元素的点击事件设置只能根据点击坐标去做,案例中的点移动速度和方向不定,测试发现点击位置和点的当前位置总是差100-300距离,所以采用外部控制选中ID,绘制时判断选中的数据ID选择绘制图标。 path2D有可能可以实现,时间关系,之后再研究。
效果图:
关键点:
const distance = Math.sqrt(dx * dx + dy * dy);
平方根计算两点之间距离- 先清除画布再画所有点
Math.atan2(y2 - y1, x2 - x1) + Math.PI / 2;
计算两点之间的角度
思路:画出一个矩形和圆形的路径数据,按秒筛出每个点的当前位置和下一个位置组成一个数组。然后去清除画布,绘制所有点,使用requestAnimationFrame将距离等分,计算出当前时间节点的位置。
index.vue
<template>
<div class="inside-map-component">
<a-radio-group v-model:value="selectRobotId" class="select-robot">
<a-radio-button :value="1">机器人1</a-radio-button>
<a-radio-button :value="2">机器人2</a-radio-button>
</a-radio-group>
<RobotInfoCardComponent :robotId="selectRobotId" v-if="!!selectRobotId" />
<div class="inside-canvas">
<canvas ref="canvasRef" id="insideMap" width="1200" height="800"></canvas>
</div>
</div>
</template>
<script setup lang="ts">
import RobotInfoCardComponent from '../RobotInfoCardComponent/index.vue';
import { ref } from 'vue';
import Segmented from '@/components/Segmented/index.vue';
import { useCanvasAnimation } from './compositions/useCanvasAnimation';
import { usePathData } from '@/views/tenant/map/components/InSideMapComponent/compositions/usePathData';
const selectRobotId = ref(1);
const { pointData } = usePathData();
const { canvasRef } = useCanvasAnimation(pointData, selectRobotId);
</script>
<style src="./index.less" scoped lang="less"></style>
usePathData:
import { ref } from 'vue';
export function usePathData() {
const pointData = ref([
{
path: generateCircularPath(400, 300, 250, 36), // 360 points for a complete circle
},
{
path: generateRectanglePath(400, 300, 200, 200), // No need to specify numPoints for square
},
]);
console.log('pointData1', pointData);
function generateCircularPath(cx, cy, radius, numPoints) {
const path = [];
for (let i = 0; i < numPoints; i++) {
const angle = (i / numPoints) * Math.PI * 2;
const x = cx + radius * Math.cos(angle);
const y = cy + radius * Math.sin(angle);
path.push([
x,
y,
{
id: 2,
},
]);
}
return path;
}
function generateRectanglePath(x: number, y: number, width: number, height: number) {
const path = [[400, 300]];
const numPointsPerSide = 10; // Number of points for each side of the rectangle
// Generate points along the left side of the rectangle
for (let i = 1; i <= numPointsPerSide; i++) {
const progress = i / numPointsPerSide;
const pointX = x;
const pointY = y + progress * height;
path.push([
pointX,
pointY,
{
id: 1,
},
]);
}
// Generate points along the top side of the rectangle
for (let i = 1; i <= numPointsPerSide; i++) {
const progress = i / numPointsPerSide;
const pointX = x + progress * width;
const pointY = path[path.length - 1][1];
path.push([
pointX,
pointY,
{
id: 1,
},
]);
}
// Generate points along the right side of the rectangle
for (let i = 0; i <= numPointsPerSide; i++) {
const progress = i / numPointsPerSide;
const pointX = x + width;
const pointY = path[path.length - 1][1] + progress * height;
path.push([
pointX,
pointY,
{
id: 1,
},
]);
}
// Generate points along the bottom side of the rectangle
for (let i = 0; i <= numPointsPerSide; i++) {
const progress = i / numPointsPerSide;
const pointX = path[path.length - 1][0] + width - progress * width;
const pointY = y + height;
path.push([
pointX,
pointY,
{
id: 1,
},
]);
}
return path;
}
return {
pointData,
};
}
useCanvasAnimation:
import { ref, onMounted, onUnmounted } from 'vue';
interface Point {
path: Array<[number, number]>;
}
const ICON_SIZE = 60;
export function useCanvasAnimation(data: Point[], selectRobotId) {
let setIntervalId;
const canvasRef = ref<HTMLCanvasElement | null>(null);
const pointsToDraw = ref([]);
const iconImage = new Image();
iconImage.src = '@/assets/svg/robot/robotPoint.svg'; // Replace with the actual path
iconImage.width = ICON_SIZE;
iconImage.height = ICON_SIZE;
const activeIconImage = new Image();
activeIconImage.src = '@/assets/svg/robot/robotPointActive.svg'; // Replace with the actual path
activeIconImage.width = ICON_SIZE;
activeIconImage.height = ICON_SIZE;
const start = () => {
const animationInterval = 1000; // Adjust as needed
let currentIndex = 1;
setIntervalId = setInterval(() => {
console.log(selectRobotId, 'selectRobotId');
pointsToDraw.value = data.value
.map((point) => {
const path = point.path;
const startPoint = path[currentIndex - 1];
const endPoint = path[currentIndex];
if (startPoint && endPoint) {
return { startPoint, endPoint };
}
return null;
})
.filter((point) => point !== null);
// 找出这一帧所有的点 组成一个点数组 去绘制
animate();
currentIndex = currentIndex + 1;
}, animationInterval);
};
onMounted(() => {
canvasRef.value && start();
});
onUnmounted(() => {
clearInterval(setIntervalId);
});
// 计算移动角度
function calculateAngle(x1: number, y1: number, x2: number, y2: number) {
return Math.atan2(y2 - y1, x2 - x1) + Math.PI / 2;
}
// 缓动函数 不一定用的上
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
// 绘制动画
const animate = () => {
// console.log('startPoint', startPoint, endPoint);
const canvas = canvasRef.value;
const ctx = canvas?.getContext('2d');
if (!ctx || !canvas) return;
const startTime = new Date().getTime();
const step = () => {
const currentTime = new Date().getTime();
const progress = (currentTime - startTime) / 1000;
// 清除画布
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
if (progress <= 1) {
// 绘制所有点
pointsToDraw.value.forEach((point) => {
const { startPoint, endPoint } = point;
const [startX, startY] = startPoint; // Get the next point in the path
const [targetX, targetY] = endPoint; // Get the next point in the path
const dx = targetX - startX;
const dy = targetY - startY;
const distance = Math.sqrt(dx * dx + dy * dy);
ctx.save();
let finallyX = startX;
let finallyY = startY;
if (distance > 0) {
const step = progress * distance;
const factor = step / distance;
finallyX = startX + dx * factor;
finallyY = startY + dy * factor;
}
ctx.translate(finallyX, finallyY);
ctx.rotate(calculateAngle(startX, startY, targetX, targetY));
ctx.drawImage(
selectRobotId.value === startPoint[2]?.id ? activeIconImage : iconImage,
-ICON_SIZE / 2,
-ICON_SIZE / 2,
ICON_SIZE,
ICON_SIZE,
);
ctx.restore();
});
requestAnimationFrame(step);
}
};
step();
};
return {
canvasRef,
pointsToDraw,
};
}