概要
使用openlayers实现轨迹运动
整体架构流程
使用postgres(postgis)数据库以及nodejs作为后台,vue和openlayers做前端,openlayers使用http请求通过nodejs从postgres数据库获取数据。
技术名词解释
postgis:postgis是postgres的一个扩展,提供空间对象的相关操作。
技术细节
nodejs直连数据库,openlayers使用http服务通过nodejs转为数据库的查询语句。
实现思路如下:每条数据表示一条船,每个船的轨迹关键点在数据库存为MultiPointM的Geometry数据,其中M分量为时间戳,然后前端传入一个空间范围和时间戳,空间范围主要为了过滤范围外要素加速渲染,时间戳则用来查询所有船的轨迹点小于该时间戳的所有关键点,将其连成线,然后在时间戳所在的区间,使用线性插值插值出小船当前位置,线和插值出的点有相同的fid,在前端通过fid将线和插值点连接并显示,就是船的实时轨迹。
效果如下:
前端代码如下:
<template>
<div id="map" class="map"></div>
</template>
<script>
import * as ol from 'ol';
import 'ol/ol.css';
import proj from 'ol/proj'
import { fromLonLat } from 'ol/proj';
import Map from 'ol/Map';
import View from 'ol/View';
import TileLayer from 'ol/layer/Tile';
import XYZ from 'ol/source/XYZ';
import Feature from 'ol/Feature';
import Point from 'ol/geom/Point';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import { Circle as CircleStyle, Fill, Stroke, Style } from 'ol/style';
import WKB from 'ol/format/WKB';
import Icon from 'ol/style/Icon';
import { transformExtent } from 'ol/proj';
export default {
name: 'OpenLayersMap',
data() {
return {
map: null,
pointLayer: null,
lineLayer: null,
linesData: [],
pointsData: [],
iconImagePath: '../../board.png',
lastPoint: {}
};
},
mounted() {
this.initializeMap();
this.timeRange();
},
methods: {
initializeMap() {
this.map = new Map({
target: 'map',
layers: [
new TileLayer({
source: new XYZ({
url: 'https://{a-c}.tile.openstreetmap.org/{z}/{x}/{y}.png',
}),
}),
],
view: new View({
center: [0, 0],
zoom: 2,
}),
});
this.lineLayer = new VectorLayer({
source: new VectorSource(),
});
this.map.addLayer(this.lineLayer);
this.pointLayer = new VectorLayer({
source: new VectorSource(),
style: new Style({
image: new CircleStyle({
radius: 6,
fill: new Fill({ color: 'red' }),
stroke: new Stroke({ color: 'white', width: 2 }),
}),
}),
});
this.map.addLayer(this.pointLayer);
},
timeRange() {
fetch('http://localhost:4325/time-range')
.then(response => response.json())
.then(data => {
const { minTime, maxTime } = data;
console.log('Time Range:', minTime, maxTime);
this.fetchDataInRange(minTime, maxTime);
})
.catch(error => console.error('Error fetching time range:', error));
},
fetchDataInRange(startTime, endTime) {
let currentTime = startTime;
const timerId = setInterval(() => {
if (currentTime >= endTime) {
this.fetchData(endTime);
clearInterval(timerId); // Stop the timer when currentTime >= endTime
return;
}
this.fetchData(currentTime);
currentTime += 5; // Increment currentTime
//console.log('Current Time:', currentTime);
}, 200);
},
fetchData(currentTime) {
// 获取地图视图
const mapView = this.map.getView();
// 获取地图视图的范围
const extent = mapView.calculateExtent(this.map.getSize());
// 将范围转换为EPSG:4326坐标系下的值
const bbox = transformExtent(extent, mapView.getProjection(), 'EPSG:4326');
Promise.all([
fetch(`http://localhost:4325/line-geometries?timestamp=${currentTime}&bbox=${bbox.join(',')}`).then(response => response.json()),
fetch(`http://localhost:4325/points?timestamp=${currentTime}&bbox=${bbox.join(',')}`).then(response => response.json())
]).then(([linesData, pointsData]) => {
this.linesData = linesData;
this.pointsData = pointsData;
this.processData();
}).catch(error => console.error('Error fetching data:', error));
},
processData() {
const lineSource = this.lineLayer.getSource();
const pointSource = this.pointLayer.getSource();
const existingLineFeatureIds = {};
const existingPointFeatureIds = {};
// 处理线要素数据
//console.log('this.linesData', this.linesData)
this.linesData.forEach(line => {
const fid = line.fid;
let feature = lineSource.getFeatureById(fid);
if (feature) {
// 如果已存在具有相同 fid 的要素,则更新要素信息
// 更新要素信息
existingLineFeatureIds[fid] = true;
} else {
// 否则创建新的要素并添加到图层中
feature = new Feature({
// 设置要素信息
});
lineSource.addFeature(feature);
existingLineFeatureIds[fid] = true;
}
});
// 处理点要素数据
this.pointsData.forEach(point => {
const fid = point.fid;
let feature = pointSource.getFeatureById(fid);
if (feature) {
// 如果已存在具有相同 fid 的要素,则更新要素信息
// 更新要素信息
existingPointFeatureIds[fid] = true;
} else {
// 否则创建新的要素并添加到图层中
feature = new Feature({
// 设置要素信息
});
pointSource.addFeature(feature);
existingPointFeatureIds[fid] = true;
}
});
// 移除地图上已存在但未在当前数据中出现的线要素
lineSource.getFeatures().forEach(feature => {
const fid = feature.getId();
if (!existingLineFeatureIds[fid]) {
lineSource.removeFeature(feature);
}
});
// 移除地图上已存在但未在当前数据中出现的点要素
pointSource.getFeatures().forEach(feature => {
const fid = feature.getId();
if (!existingPointFeatureIds[fid]) {
pointSource.removeFeature(feature);
}
});
// Create a mapping of fid to points
const pointsMap = {};
this.pointsData.forEach(point => {
if (!pointsMap[point.fid]) {
pointsMap[point.fid] = [];
}
pointsMap[point.fid].push(point);
});
// Process lines and append points if they exist
this.linesData.forEach(line => {
const format = new WKB();
const feature = format.readFeature(line.line_geom, {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857'
});
const geometry = feature.getGeometry();
if (geometry.getType() === 'LineString' && pointsMap[line.fid]) {
const coordinates = geometry.getCoordinates();
pointsMap[line.fid].forEach(point => {
const coord = fromLonLat([point.interpolated_longitude, point.interpolated_latitude]);
coordinates.push(coord);
});
geometry.setCoordinates(coordinates);
}
//feature.setId(line.fid);
this.lineLayer.getSource().addFeature(feature);
});
// Log for debugging
//console.log('Processed Lines:', this.lineLayer.getSource().getFeatures());
//console.log('Processed Points:', this.pointLayer.getSource().getFeatures());
this.processPointLayer();
},
processPointLayer() {
const tempLastPoint = {};
const lineFeatures = this.lineLayer.getSource().getFeatures();
lineFeatures.forEach(lineFeature => {
const lineGeometry = lineFeature.getGeometry();
const lineCoordinates = lineGeometry.getCoordinates();
const numCoordinates = lineCoordinates.length;
//const fid = lineFeature.getId();
//console.log('fid', fid);
if (numCoordinates === 1) {
const defaultAngle = 0;
const lastPointCoords = lineCoordinates[0];
tempLastPoint[fid] = lastPointCoords;
const pointFeature = new Feature({
geometry: new Point(lineCoordinates[0]),
});
//pointFeature.setId(fid);
const iconStyle = this.createPointStyle(defaultAngle);
pointFeature.setStyle(iconStyle);
this.pointLayer.getSource().addFeature(pointFeature);
} else if (numCoordinates > 1) {
const lastPointCoords = lineCoordinates[numCoordinates - 1];
//console.log('lastPointCoords', lastPointCoords);
const penultimatePointCoords = lineCoordinates[numCoordinates - 2];
const dx = lastPointCoords[0] - penultimatePointCoords[0];
const dy = lastPointCoords[1] - penultimatePointCoords[1];
const angle = Math.atan2(dy, dx);
const pointFeature = new Feature({
geometry: new Point(lastPointCoords),
});
//pointFeature.setId(fid);
const iconStyle = this.createPointStyle(angle);
pointFeature.setStyle(iconStyle);
this.pointLayer.getSource().addFeature(pointFeature);
//const tempLastPointCoords = this.lastPoint[fid];
//console.log('tempLastPointCoords', tempLastPointCoords);
//if (tempLastPointCoords) {
//console.log('animate point', lineFeature.getId(), this.lastPoint[lineFeature.getId()], lastPointCoords);
//this.animatePoint(pointFeature, tempLastPointCoords, lastPointCoords);
//}
//tempLastPoint[fid] = lastPointCoords;
}
});
//this.lastPoint = tempLastPoint;
//console.log('lastPoint', this.lastPoint);
//console.log('tempLastPoint', tempLastPoint);
},
animatePoint(feature, startCoords, endCoords) {
const duration = 800; // 动画持续时间,单位毫秒
const start = performance.now();
//console.log('startCoords', startCoords);
const animate = (timestamp) => {
const elapsed = timestamp - start;
const progress = Math.min(elapsed / duration, 1); // 进度百分比,范围从0到1
// 线性插值计算当前位置
const currentCoords = [
startCoords[0] + (endCoords[0] - startCoords[0]) * progress,
startCoords[1] + (endCoords[1] - startCoords[1]) * progress,
];
feature.setGeometry(new Point(currentCoords));
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
},
createPointStyle(angle) {
// 根据朝向创建点的样式
return new Style({
image: new Icon({
src: require('@/assets/board.png'),
scale: 0.1,
rotation: -angle + (180 * Math.PI / 180), // 设置点的朝向
anchor: [0.5, 0.7], // 设置锚点位置
}),
});
}
},
};
</script>
<style scoped>
.map {
width: 100%;
height: 800px;
}
</style>
服务器代码如下:
1、数据库相关:
// database.js
const { Client } = require('pg');
const axios = require('axios');
const fs = require('fs').promises;
const moment = require('moment-timezone');
// 配置数据库连接
const client = new Client({
user: 'postgres',
host: 'loaclhost',
database: 'postgres',
password: 'root',
port: 4321, // 默认PostgreSQL端口
});
async function createTable() {
const createTableQuery = `
CREATE TABLE IF NOT EXISTS track_board_test (
fid BIGINT PRIMARY KEY,
id VARCHAR(255),
name VARCHAR(255),
mmsi VARCHAR(255),
geom GEOMETRY(MultiPointM)
);
CREATE INDEX IF NOT EXISTS geom_index ON track_board_test USING GIST (geom);
`;
try {
await client.query(createTableQuery);
console.log('Table created successfully');
} catch (err) {
console.error('Error creating table:', err.stack);
}
}
async function insertDataFromFile(filePath, isHttp) {
try {
let data;
if (isHttp) {
const response = await axios.get(filePath);
data = response.data;
} else {
const rawData = await fs.readFile(filePath);
data = JSON.parse(rawData);
}
for (const item of data.data) {
const { id, mmsi, name, hisRecord } = item;
let fid;
if (id.startsWith("radar")) {
fid = parseInt(id.substring("radar".length));
} else {
fid = parseInt(id);
}
const points = hisRecord.map(record => {
const utcTime = moment.tz(record.updateTime, "Asia/Shanghai").utc().format('YYYY-MM-DD HH:mm:ss');
return `ST_SetSRID(ST_MakePointM(${record.longitude}, ${record.latitude}, EXTRACT(EPOCH FROM TIMESTAMP '${utcTime}')), 4326)`;
}).join(', ');
const geom = `ST_Collect(ARRAY[${points}])`;
const query = `
INSERT INTO track_board_test (id, name, mmsi, geom, fid)
VALUES ($1, $2, $3, ${geom}, $4)
ON CONFLICT (fid) DO UPDATE
SET id = EXCLUDED.id, name = EXCLUDED.name, mmsi = EXCLUDED.mmsi, geom = EXCLUDED.geom, fid = EXCLUDED.fid;
`;
await client.query(query, [id, name, mmsi, fid]);
}
console.log('数据插入成功');
} catch (err) {
console.error('插入数据时发生错误:', err);
}
}
async function insertRandomData() {
const insertRandomDataQuery = `
DO $$
DECLARE
i INT;
BEGIN
FOR i IN 10010000..10015000 LOOP
EXECUTE format(
'INSERT INTO track_board_test (id, geom, fid)
VALUES (%L,
(SELECT ST_Collect(
ARRAY(
WITH RECURSIVE points AS (
SELECT
random() * 360 - 180 AS lon,
random() * 180 - 90 AS lat,
CAST(1716186468 + random() * 1000 AS INT) AS m,
1 AS iteration,
CEIL(random() * 99 + 1) AS max_iterations -- 随机生成1到100之间的点数
UNION ALL
SELECT
lon + (0.01 + random() * 0.09) * (CASE WHEN random() < 0.5 THEN 1 ELSE -1 END) AS lon,
lat + (0.01 + random() * 0.09) * (CASE WHEN random() < 0.5 THEN 1 ELSE -1 END) AS lat,
CAST(m + random() * 400 AS INT) AS m,
iteration + 1,
max_iterations
FROM points
WHERE iteration < max_iterations
)
SELECT ST_SetSRID(ST_MakePointM(lon, lat, m), 4326)
FROM points
)
)),
%L
)
ON CONFLICT (fid) DO NOTHING',
'radar_' || i,
i
);
END LOOP;
END $$;
`;
try {
await client.query(insertRandomDataQuery);
console.log('Random data insert successfully');
} catch (err) {
console.error('Error inserting random data:', err.stack);
}
}
async function getAllData() {
try {
const query = `
SELECT fid, id, name, mmsi, ST_X(dp.geom) AS Lng, ST_Y(dp.geom) AS Lat, ST_M(dp.geom) AS time
FROM track_board_test,
LATERAL ST_DumpPoints(geom) AS dp;
`;
const result = await client.query(query);
return result.rows;
} catch (err) {
console.error('Error fetching data:', err.stack);
return [];
}
}
async function getTimeRange() {
try {
const query = `
SELECT
MAX(max_time) AS max_time,
MIN(min_time) AS min_time
FROM (
SELECT
(SELECT MAX(ST_M(dp.geom)) FROM LATERAL ST_DumpPoints(track_board_test.geom) AS dp) AS max_time,
(SELECT MIN(ST_M(dp.geom)) FROM LATERAL ST_DumpPoints(track_board_test.geom) AS dp) AS min_time
FROM
track_board_test
) AS subquery;
`;
const result = await client.query(query);
const { max_time, min_time } = result.rows[0];
return { minTime: min_time, maxTime: max_time };
} catch (err) {
console.error('Error executing query', err.stack);
throw err;
}
}
async function getPointsByTimestamp(timestamp, bbox) {
try {
const query = `
WITH extracted_points AS (
SELECT
tbt.fid,
(dp).geom AS point,
ST_M((dp).geom) AS m_value
FROM
track_board_test tbt
CROSS JOIN LATERAL ST_DumpPoints(tbt.geom) AS dp
WHERE
ST_Intersects(tbt.geom, ST_MakeEnvelope($1, $2, $3, $4, 4326)) -- Add bbox filter
ORDER BY fid
),
min_max_times AS (
SELECT
fid,
MAX(CASE WHEN m_value <= $5 THEN m_value END) AS min_time,
MIN(CASE WHEN m_value > $5 THEN m_value END) AS max_time
FROM
extracted_points
GROUP BY
fid
),
min_points AS (
SELECT
ep.fid,
ep.m_value AS min_time,
ep.point AS min_point
FROM
extracted_points ep
JOIN min_max_times mmt ON ep.fid = mmt.fid AND ep.m_value = mmt.min_time
),
max_points AS (
SELECT
ep.fid,
ep.m_value AS max_time,
ep.point AS max_point
FROM
extracted_points ep
JOIN min_max_times mmt ON ep.fid = mmt.fid AND ep.m_value = mmt.max_time
)
SELECT
mmt.fid,
ST_X(ST_LineInterpolatePoint(ST_MakeLine(mp.min_point, mx.max_point), ($5 - mmt.min_time) / (mmt.max_time - mmt.min_time))) AS interpolated_longitude,
ST_Y(ST_LineInterpolatePoint(ST_MakeLine(mp.min_point, mx.max_point), ($5 - mmt.min_time) / (mmt.max_time - mmt.min_time))) AS interpolated_latitude
FROM
min_max_times mmt
JOIN min_points mp ON mmt.fid = mp.fid
JOIN max_points mx ON mmt.fid = mx.fid;
`;
const result = await client.query(query, [...bbox, timestamp]);
return result.rows;
} catch (err) {
console.error('Error fetching interpolated points:', err.stack);
return [];
}
}
async function getLineGeometries(timestamp, bbox) {
const query = `
WITH extracted_points AS (
SELECT
fid,
(ST_DumpPoints(geom)).geom AS point
FROM track_board_test
WHERE
ST_Intersects(geom, ST_MakeEnvelope($1, $2, $3, $4, 4326)) -- Add bbox filter
),
filtered_points AS (
SELECT
fid,
point,
ST_M(point) AS m_value
FROM extracted_points
WHERE ST_M(point) <= $5
),
sorted_points AS (
SELECT
fid,
point
FROM filtered_points
ORDER BY fid, m_value
)
SELECT
fid,
ST_MakeLine(point) AS line_geom
FROM sorted_points
GROUP BY fid;
`;
const result = await client.query(query, [...bbox, timestamp]);
return result.rows;
}
module.exports = {
client,
createTable,
insertDataFromFile,
insertRandomData,
getAllData,
getTimeRange,
getPointsByTimestamp,
getLineGeometries
};
http接口相关:
const express = require('express');
const cors = require('cors');
const {
client,
createTable,
insertDataFromFile,
insertRandomData,
getAllData,
getTimeRange,
getPointsByTimestamp,
getLineGeometries } = require('./database');
const app = express();
app.use(cors());
const port = 4325;
client.connect()
.then(() => console.log('Connected to the database'))
.catch(err => console.error('Connection error', err.stack));
createTable();
const filePath = './test.json'; // 替换为你的文件路径
insertDataFromFile(filePath, false);
insertRandomData();
app.get('/all-data', async (req, res) => {
try {
const data = await getAllData();
res.json(data);
} catch (err) {
res.status(500).json({ error: 'Internal Server Error' });
}
});
// 创建一个API端点
app.get('/time-range', async (req, res) => {
try {
const { minTime, maxTime } = await getTimeRange();
res.json({ minTime, maxTime });
} catch (err) {
console.error('Error fetching time range:', err.stack);
res.status(500).json({ error: 'Internal Server Error' });
}
});
app.get('/points', async (req, res) => {
const timestamp = req.query.timestamp;
const bbox = req.query.bbox.split(',').map(parseFloat); // 解析 bbox 参数为数组
if (!timestamp) {
return res.status(400).json({ error: 'Timestamp is required' });
}
try {
const points = await getPointsByTimestamp(timestamp, bbox); // 将 bbox 参数传递给函数
res.json(points);
} catch (err) {
res.status(500).json({ error: 'Internal Server Error' });
}
});
app.get('/line-geometries', async (req, res) => {
const timestamp = req.query.timestamp;
const bbox = req.query.bbox.split(',').map(parseFloat); // 解析 bbox 参数为数组
if (!timestamp) {
return res.status(400).json({ error: 'Timestamp is required' });
}
try {
const lineGeometries = await getLineGeometries(timestamp, bbox); // 将 bbox 参数传递给函数
res.json(lineGeometries);
} catch (err) {
console.error('Error fetching line geometries:', err.stack);
res.status(500).json({ error: 'Internal Server Error' });
}
});
// 启动服务器
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
});
小结
当显示全球范围性能会有明显卡顿,可能需要改进算法。