OpenLayers 6 繪制高德導航路徑的螞蟻線樣式并實現(xiàn)箭頭動畫——VectorContext的重度使用
OpenLayers架構之內提供了矢量對象樣式化的一些手段,但平時的使用總感覺有一些單一;而像高德、百度、騰訊地圖這樣的框架有著美觀豐富的UI樣式。從接觸OpenLayers開始,就一直有一起學習的朋友討論如何做一個像高德導航那樣的路徑樣式,這個需求確實在很多項目中也會用到。本文就針對這個問題進行一下詳細介紹。
原版高德導航的路徑樣式
OpenLayers實現(xiàn)的動態(tài)樣式
問題分析
稍微熟悉一點OpenLayers的開發(fā)者都知道,OpenLayers原生不支持這種帶有小箭頭類型的樣式,所以需要我們自己造輪子。路徑肯定是使用linestring類型的要素是沒差了,此外還有幾個問題需要研究一下:
仔細觀察一下高德導航的這個路徑樣式,在路徑的兩邊還有描邊,OpenLayers的linestring是只有stroke而沒有fill的,所以需要用不同顏色不同線寬繪制兩次。起點和終點的兩個點狀要素同理。
箭頭的方向會根據(jù)線段的方向變化,所以需要針對linestring每一個線段的走向,計算出相對y軸的夾角,作為箭頭偏轉的rotation屬性。
高德導航里箭頭之間的間距是可以隨著縮放級別進行自動調整的,這個也好辦,只需要繪制的時候以當view的resolution為依據(jù)就可以。
以上問題解決之后就可以實現(xiàn)基本的效果了,但是如果想要箭頭變成動態(tài)的,還需要利用OpenLayers的render機制。可以在每一次渲染圖層的時候,針對每一個箭頭符號設置一個偏移量,然后顯式調用map.render(),這樣通過不斷地渲染圖層(底圖因為revision counter沒有變化而不會重復渲染瓦片數(shù)據(jù))實現(xiàn)關鍵幀的切換,從而形成動畫。
最后還有一個性能上的問題,因為要實現(xiàn)動畫繪制箭頭,需要不斷地更新點位的信息,所以常規(guī)的VectorLayer滿足不了需求,需要使用VectorContext直接在canvas上繪制。
代碼實現(xiàn)
首先把需要使用的地圖的基本結構搭出來:
import { Map, View } from 'ol';
import OSM from 'ol/source/OSM';
import TileLayer from 'ol/layer/Tile';
import LineString from 'ol/geom/LineString';
import Point from 'ol/geom/Point';
import VectorSource from 'ol/source/Vector';
import VectorLayer from 'ol/layer/Vector';
import Feature from 'ol/Feature';
import GeoJSON from 'ol/format/GeoJSON'
import { getVectorContext } from 'ol/render';
import { Fill, Stroke, Circle, Style, Text, Icon } from 'ol/style';
import soul from './data/soul.json';
let tileLayer = new TileLayer({
source: new OSM()
})
let map = new Map({
target: 'map',
layers: [
tileLayer
],
view: new View({
center: [11936406.337013, 3786384.633134],
zoom: 5,
constrainResolution: true
})
});
var vSource = new VectorSource()
var vLayer = new VectorLayer(
{
source: vSource,
}
)
var geojsonFormat = new GeoJSON();
var features = geojsonFormat.readFeatures(soul, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857"
});
var street = features[0];
map.addLayer(vLayer);
map.getView().fit(street.getGeometry());
這里使用的路徑數(shù)據(jù)是官方實例中首爾的某些街道信息,使用了其中的一條。坐標系采用的是默認的3857。
然后緩存了一些樣式對象:
//some styles =========================================================================
var textStyle = new Style({
text: new Text({
font: 'bold 26px Mirosoft Yahei',
placement: 'line',
text: "江 南 大 街",
fill: new Fill({
color: '#000'
}),
offsetY:3,
stroke: new Stroke({
color: '#FFF',
width: 2
})
})
})
var buttomPathStyle = new Style({
stroke: new Stroke({
color: [4, 110, 74],
width: 28
}),
})
var upperPathStyle = new Style({
stroke: new Stroke({
color: [0, 186, 107],
width: 20
}),
})
var outStyle = new Style({
image: new Circle({
radius: 18,
fill: new Fill({
color: [4, 110, 74]
})
})
})
var midStyle = new Style({
image: new Circle({
radius: 15,
fill: new Fill({
color: [0, 186, 107]
})
})
})
var innerDot = new Style({
image: new Circle({
radius: 6,
fill: new Fill({
color: [255, 255, 255]
})
})
})
var foutrStyle = new Style({
image: new Circle({
radius: 18,
fill: new Fill({
color: "#000"
})
})
})
var fmidStyle = new Style({
image: new Circle({
radius: 15,
fill: new Fill({
color: '#FFF'
})
})
})
var finnerStyle = new Style({
image: new Circle({
radius: 6,
fill: new Fill({
color: '#000'
})
})
})
street.setStyle(textStyle);
vSource.addFeature(street)
//some styles end =========================================================================
之所以把這些樣式用全局變量緩存起來,是因為考慮到性能的問題, 如果不這樣做,在render過程中動態(tài)去繪制,每次刷新都要new出來這些對象,會大大拉低性能。
接下來進行一些準備工作:
var offset = 0.01;
tileLayer.on('postrender', (evt) => {
var vct = getVectorContext(evt);
vct.drawFeature(street, buttomPathStyle)
vct.drawFeature(street, upperPathStyle)
offset控制動畫中箭頭在每個關鍵幀的位置偏移,通過對offset的逐次累加,實現(xiàn)每個關鍵幀中箭頭的不同位置。
通過render繪制的絕大多數(shù)工作都是在tileLayer的postrender事件綁定回調函數(shù)中實現(xiàn)的,這里首先使用ol/render的靜態(tài)方法getVectorContext()獲取到當前圖層(即tileLayer)的VectorContext對象句柄,并立即將路徑的背景層和內襯層繪制上去。
接下來是確定箭頭的點位:
let numArr = Math.ceil((street.getGeometry().getLength() / map.getView().getResolution()) / 100)
var points = []
for (var i = 0; i <= numArr; i++) {
let fracPos = (i / numArr) + offset;
if (fracPos > 1) fracPos -= 1
let pf = new Feature(new Point(street.getGeometry().getCoordinateAt(fracPos)));
points.push(pf);
}
根據(jù)之前分析的思路,這里使用了這樣一個公式來計算路徑在當前分辨率下的像素個數(shù):
然后使用這個值去除以一個常量,這里我選的是100,也就是說箭頭之間的距離,無論在那個分辨率下面,都是100像素長度。
接下來就是按照這個點位數(shù)進行迭代取點,核心的API就是getCoordinateAt ()作用是根據(jù)百分比取曲線上的點坐標。取到點的坐標之后直接做成要素,緩存到數(shù)組里。
核心:確定點位上箭頭的方向
//確定方向并繪制
street.getGeometry().forEachSegment((start, end) => {
points.forEach((item) => {
let line = new LineString([start, end])
let coord = item.getGeometry().getFirstCoordinate();
let cPoint = line.getClosestPoint(coord);
if (Math.abs(cPoint[0] - coord[0]) < 1 && Math.abs(cPoint[1] - coord[1]) < 1) {
var myImage = new Image(117, 71);
myImage.src = '/data/arrow.png';
let dx=end[0] - start[0];
let dy=end[1] - start[1];
var rotation = Math.atan(dx/dy);
rotation=dy>0?rotation:(Math.PI+rotation);
vct.setStyle(new Style({
image: new Icon({
img: myImage,
imgSize: [117, 71],
scale: 0.15,
rotation: rotation
})
}))
vct.drawGeometry(item.getGeometry())
}
});
用作此例的一個透明背景的白色箭頭
想要確定點位上箭頭的方向,需要判定點位所在直線線段,根據(jù)直線線段的起始點和終點(注意是有序的)來計算得到需要旋轉的角度。首先我們對路徑的geometry上面的所有直線線段進行迭代(使用forEachSegment),針對每一個直線段,都判斷一下前面獲取到的點集合中,哪一個點在它上面,然后計算出線段的方向,將箭頭圖標旋轉相應的角度,通過樣式繪制在這個點位上。
基本的算法思想是這樣的,但操作起來有個很實際的問題:在上一個我們獲取點位的過程中,這些點的坐標是保留了一定精度的,也就是說,即便是原來就在某個線段上的點,如果截取了小數(shù)位,就不可能符合這個線段的直線方程!
所以在這里我用了一個技巧,利用OpenLayers提供的API getClosestPoint,計算點位到線段上最近一個點的坐標偏移量,如果這個偏移量足夠小,我就認為點位在這個線段上了(實際上就是這么回事!)
let cPoint = line.getClosestPoint(coord);
if (Math.abs(cPoint[0] - coord[0]) < 1 && Math.abs(cPoint[1] - coord[1]) < 1) {
旋轉的角度是通過xy的坐標差進行反正切之后再調整得到的:這里是反正切的象限與符號和偏轉角度計算的關系,經過總結,得到代碼中的調整公式。
let dx=end[0] - start[0];
let dy=end[1] - start[1];
var rotation = Math.atan(dx/dy);
rotation=dy>0?rotation:(Math.PI+rotation);
最后把起點和終點的樣式渲染上去:
vct.setStyle(outStyle)
vct.drawGeometry(new Point(street.getGeometry().getFirstCoordinate()))
vct.setStyle(midStyle)
vct.drawGeometry(new Point(street.getGeometry().getFirstCoordinate()))
vct.setStyle(innerDot)
vct.drawGeometry(new Point(street.getGeometry().getFirstCoordinate()));
vct.setStyle(foutrStyle)
vct.drawGeometry(new Point(street.getGeometry().getLastCoordinate()))
vct.setStyle(fmidStyle)
vct.drawGeometry(new Point(street.getGeometry().getLastCoordinate()))
vct.setStyle(finnerStyle)
vct.drawGeometry(new Point(street.getGeometry().getLastCoordinate()));
})
然后累加偏移量offset,在下一次渲染的時候微調點位的位置。這里有一個判斷,如果offset超過了1,就重置,使得動畫能夠循環(huán)進行。最后顯式調用render()函數(shù),進行下一次渲染。
offset = offset + 0.003
//復位
if (offset >= 1) offset = 0.001
map.render()
})
完整代碼:
所用道路數(shù)據(jù)為OpenLayers官方實例中jsts integrated所用素材,下載鏈接:
https://openlayers.org/en/latest/examples/data/geojson/roads-seoul.geojson
import { Map, View } from 'ol';
import OSM from 'ol/source/OSM';
import TileLayer from 'ol/layer/Tile';
import LineString from 'ol/geom/LineString';
import Point from 'ol/geom/Point';
import VectorSource from 'ol/source/Vector';
import VectorLayer from 'ol/layer/Vector';
import Feature from 'ol/Feature';
import GeoJSON from 'ol/format/GeoJSON'
import { getVectorContext } from 'ol/render';
import { Fill, Stroke, Circle, Style, Text, Icon } from 'ol/style';
import soul from './data/soul.json';
let tileLayer = new TileLayer({
source: new OSM()
})
let map = new Map({
target: 'map',
layers: [
tileLayer
],
view: new View({
center: [11936406.337013, 3786384.633134],
zoom: 5,
constrainResolution: true
})
});
var vSource = new VectorSource()
var vLayer = new VectorLayer(
{
source: vSource,
}
)
var geojsonFormat = new GeoJSON();
var features = geojsonFormat.readFeatures(soul, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857"
});
var street = features[16];
map.addLayer(vLayer);
map.getView().fit(street.getGeometry());
//some styles =========================================================================
var textStyle = new Style({
text: new Text({
font: 'bold 26px Mirosoft Yahei',
placement: 'line',
text: "江 南 大 街",
fill: new Fill({
color: '#000'
}),
offsetY:3,
stroke: new Stroke({
color: '#FFF',
width: 2
})
})
})
var buttomPathStyle = new Style({
stroke: new Stroke({
color: [4, 110, 74],
width: 28
}),
})
var upperPathStyle = new Style({
stroke: new Stroke({
color: [0, 186, 107],
width: 20
}),
})
var outStyle = new Style({
image: new Circle({
radius: 18,
fill: new Fill({
color: [4, 110, 74]
})
})
})
var midStyle = new Style({
image: new Circle({
radius: 15,
fill: new Fill({
color: [0, 186, 107]
})
})
})
var innerDot = new Style({
image: new Circle({
radius: 6,
fill: new Fill({
color: [255, 255, 255]
})
})
})
var foutrStyle = new Style({
image: new Circle({
radius: 18,
fill: new Fill({
color: "#000"
})
})
})
var fmidStyle = new Style({
image: new Circle({
radius: 15,
fill: new Fill({
color: '#FFF'
})
})
})
var finnerStyle = new Style({
image: new Circle({
radius: 6,
fill: new Fill({
color: '#000'
})
})
})
street.setStyle(textStyle);
vSource.addFeature(street)
//some styles end =========================================================================
var offset = 0.01;
tileLayer.on('postrender', (evt) => {
var vct = getVectorContext(evt);
vct.drawFeature(street, buttomPathStyle)
vct.drawFeature(street, upperPathStyle)
let numArr = Math.ceil((street.getGeometry().getLength() / map.getView().getResolution()) / 100)
var points = []
for (var i = 0; i <= numArr; i++) {
let fracPos = (i / numArr) + offset;
if (fracPos > 1) fracPos -= 1
let pf = new Feature(new Point(street.getGeometry().getCoordinateAt(fracPos)));
points.push(pf);
}
//確定方向并繪制
street.getGeometry().forEachSegment((start, end) => {
points.forEach((item) => {
let line = new LineString([start, end])
let coord = item.getGeometry().getFirstCoordinate();
let cPoint = line.getClosestPoint(coord);
if (Math.abs(cPoint[0] - coord[0]) < 1 && Math.abs(cPoint[1] - coord[1]) < 1) {
var myImage = new Image(117, 71);
myImage.src = '/data/arrow.png';
let dx=end[0] - start[0];
let dy=end[1] - start[1];
var rotation = Math.atan(dx/dy);
rotation=dy>0?rotation:(Math.PI+rotation);
vct.setStyle(new Style({
image: new Icon({
img: myImage,
imgSize: [117, 71],
scale: 0.15,
rotation: rotation
})
}))
vct.drawGeometry(item.getGeometry())
}
});
vct.setStyle(outStyle)
vct.drawGeometry(new Point(street.getGeometry().getFirstCoordinate()))
vct.setStyle(midStyle)
vct.drawGeometry(new Point(street.getGeometry().getFirstCoordinate()))
vct.setStyle(innerDot)
vct.drawGeometry(new Point(street.getGeometry().getFirstCoordinate()));
vct.setStyle(foutrStyle)
vct.drawGeometry(new Point(street.getGeometry().getLastCoordinate()))
vct.setStyle(fmidStyle)
vct.drawGeometry(new Point(street.getGeometry().getLastCoordinate()))
vct.setStyle(finnerStyle)
vct.drawGeometry(new Point(street.getGeometry().getLastCoordinate()));
})
offset = offset + 0.003
//復位
if (offset >= 1) offset = 0.001
map.render()
})
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Using OpenLayers with Webpack</title>
<link rel="stylesheet" type="text/css">
<style>
html, body {
margin: 0;
height: 100%;
}
#map {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
}
</style>
</head>
<body>
<div id="map" class="map"></div>
<script src="./amap_path.bundle.js"></script>
</body>
</html>
時間倉促,代碼中肯定還有很多可以改進的地方,比如將渲染過程函數(shù)化,關鍵數(shù)值作為參數(shù),便于調整動畫效果。另外,因為使用的是百分比截取,動畫的速度在分辨率變化之后也會變快或者變慢,這也是一個需要改進的地方。
這個實例應用了大量的VectorContext繪制,利用VectorContext還可以繪制更多更炫的效果,感興趣的可以一起研究一下。