概述
实时位置与实时轨迹的展示是webgis中非常常见的一个功能,本文结合SSE
来实现实现此功能。
SSE简介
SSE
是Sever-Sent Event的首字母缩写,它是基于HTTP
协议的,在服务器和客户端之间打开一个单向通道,服务端响应的不再是一次性的数据包,而是text/event-stream
类型的数据流信息,在有数据变更时从服务器流式传输到客户端。
websock
和SSE
都可以实现在有数据变更时从服务器主动推送到到客户端,他们的比较如下:
- SSE 是基于HTTP协议的,它们不需要特殊的协议或服务器实现即可工作;WebSocket需单独服务器来处理协议。
- SSE 单向通信,只能由服务端向客户端单向通信;webSocket全双工通信,即通信的双方可以同时发送和接受信息。
- SSE 实现简单开发成本低,无需引入其他组件;WebSocket传输数据需做二次解析,开发门槛高一些。
- SSE 默认支持断线重连;WebSocket则需要自己实现。
- SSE 只能传送文本消息,二进制数据需要经过编码后传送;WebSocket默认支持传送二进制数据。
由于SSE 单向通信的特性,所以很适合“实时位置与实时轨迹的展示”这样的场景。
实现效果
实现代码
服务端代码
服务端使用Node通过express
和stream
实现SSE,实现代码如下:
const express = require('express')
const stream = require("stream");
const app = express()
let sse = null, contentId = 0
function toDataString(data) {
if (typeof data === 'object') return toDataString(JSON.stringify(data));
return data.split(/\r\n|\r|\n/).map(line => `data: ${line}\n`).join('');
}
function random(n, m, is = false) {
let res = Math.random() * (m - n) + n
const arr = [-res, res]
if(is) res = arr[Math.round(Math.random())]
return res
}
let carDict = {}
for (let i = 0; i < 20; i++) {
const [xmin, ymin, xmax, ymax] = [114.0422270397205295, 22.5261218968098547, 114.0854819347529912, 22.5488829187520494]
carDict['car'+i] = {
id: 'car'+i,
lon: random(xmin, xmax),
lat: random(ymin, ymax),
dx: random(0.0001, 0.0005, true),
dy: random(0.0001, 0.0005, true)
}
}
app.use(express.static(__dirname + '/web'))
app.get("/sse", (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8"
});
sse = new stream.Transform({ objectMode: true });
sse._transform = (message, encoding, callback) => {
if (message.comment) sse.push(`: ${message.comment}\n`);
if (message.event) sse.push(`event: ${message.event}\n`);
if (message.id) sse.push(`id: ${message.id}\n`);
if (message.retry) sse.push(`retry: ${message.retry}\n`);
if (message.data) sse.push(toDataString(message.data));
sse.push("\n");
callback();
};
sse.write(':ok\n\n');
sse.pipe(res);
});
// 触动定时触发
setInterval(() => {
for (const carid in carDict) {
const {dx, dy} = carDict[carid]
carDict[carid].lon += dx
carDict[carid].lat += dy
}
const message = {
data: carDict,
event: "dynamicUpdate", // 事件类型,需要客户端添加对应的事件监听
id: ++contentId,
retry: 2000,
};
sse?.write(message);
}, 1000)
app.listen(18888, () => {
console.log('express server running at http://127.0.0.1:18888')
})
客户端代码
<!DOCTYPE html>
<html>
<head>
<title>XYZ</title>
<meta charset="utf-8">
<link rel="stylesheet" href="https://openlayers.org/en/v4.6.5/css/ol.css" type="text/css">
<style>
#map,
body,
html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
font-size: 16px;
}
.tools {
position: absolute;
top: 20px;
right: 20px;
z-index: 99;
}
</style>
<script src="https://openlayers.org/en/v4.6.5/build/ol.js"></script>
</head>
<body>
<div id="map" class="map">
<div class="tools">
<button id="start">start</button>
<button id="stop">stop</button>
</div>
</div>
<script>
let es, map
const navLayer = new ol.layer.Tile({
source: new ol.source.XYZ({
url: 'http://webrd01.is.autonavi.com/appmaptile?x={x}&y={y}&z={z}&lang=zh_cn&size=1&scale=1&style=8'
})
});
const styleFunction = (feat) => {
const rotation = feat.get('rotation')
const stroke = new ol.style.Stroke({
color: '#04991f',
width: 5
});
const imageData = 'data:image/png;base64,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'
let style = {}
if(rotation) {
const carId = feat.get('carId')
style = {
image: new ol.style.Icon({
src: imageData,
rotation: rotation
}),
}
} else {
style = {
stroke
}
}
return new ol.style.Style(style)
}
let vectorSource = new ol.source.Vector({
features: []
})
let vectorLayer = new ol.layer.Vector({
source: vectorSource,
style: styleFunction
});
map = new ol.Map({
target: 'map',
layers: [ navLayer, vectorLayer ],
view: new ol.View({
minZoom: 0,
maxZoom: 18,
center: [132921716.5468307, 2575606.3396163424],
zoom: 14
})
});
let carFeature = {},
carCoords = {},
carRoute = {};
function getRotation([x1, y1], [x2, y2]) {
let res = Math.atan2(y1 - y2, x1 - x2)
res = y2 > y1 ? res + Math.PI / 2 : res - Math.PI / 2
return res
}
const openServerPush = () => {
es = new EventSource("/sse");
es.addEventListener("dynamicUpdate", (e) => {
let carsDara = JSON.parse(e.data)
for (const carId in carsDara) {
const {lon, lat} = carsDara[carId]
const coord = ol.proj.fromLonLat([lon, lat])
if(!carCoords[carId]) carCoords[carId] = []
carCoords[carId].push(coord)
if(carCoords[carId].length > 1) {
const geomLine = new ol.geom.LineString(carCoords[carId])
if(carRoute[carId]) {
carRoute[carId].setGeometry(geomLine)
} else {
const feature = new ol.Feature({
geometry: geomLine,
carId
});
vectorSource.addFeature(feature)
carRoute[carId] = feature
}
}
const geom = new ol.geom.Point(coord)
if(carFeature[carId]) {
carFeature[carId].setGeometry(geom)
const prevCoord = carCoords[carId][carCoords[carId].length - 2]
carFeature[carId].set('rotation', getRotation(prevCoord, coord))
} else {
const feature = new ol.Feature({
geometry: geom,
carId,
rotation: 0
});
vectorSource.addFeature(feature)
carFeature[carId] = feature
}
}
});
es.onopen = () => {
console.log("已开启。。。");
};
es.onmessage = (e, me) => {
console.log("默认推送:" + e.data);
};
es.onerror = (err) => {
console.log(err);
};
};
const closeServerPush = () => {
if (es) {
es.close();
}
};
document.getElementById('start').onclick = openServerPush
document.getElementById('stop').onclick = closeServerPush
</script>
</body>
</html>