OpenLayers 6 繪制高德導(dǎo)航路徑的螞蟻線樣式并實(shí)現(xiàn)箭頭動(dòng)畫——VectorContext的重度使用
OpenLayers架構(gòu)之內(nèi)提供了矢量對(duì)象樣式化的一些手段,但平時(shí)的使用總感覺有一些單一;而像高德、百度、騰訊地圖這樣的框架有著美觀豐富的UI樣式。從接觸OpenLayers開始,就一直有一起學(xué)習(xí)的朋友討論如何做一個(gè)像高德導(dǎo)航那樣的路徑樣式,這個(gè)需求確實(shí)在很多項(xiàng)目中也會(huì)用到。本文就針對(duì)這個(gè)問(wèn)題進(jìn)行一下詳細(xì)介紹。
原版高德導(dǎo)航的路徑樣式
OpenLayers實(shí)現(xiàn)的動(dòng)態(tài)樣式
問(wèn)題分析
稍微熟悉一點(diǎn)OpenLayers的開發(fā)者都知道,OpenLayers原生不支持這種帶有小箭頭類型的樣式,所以需要我們自己造輪子。路徑肯定是使用linestring類型的要素是沒差了,此外還有幾個(gè)問(wèn)題需要研究一下:
仔細(xì)觀察一下高德導(dǎo)航的這個(gè)路徑樣式,在路徑的兩邊還有描邊,OpenLayers的linestring是只有stroke而沒有fill的,所以需要用不同顏色不同線寬繪制兩次。起點(diǎn)和終點(diǎn)的兩個(gè)點(diǎn)狀要素同理。
箭頭的方向會(huì)根據(jù)線段的方向變化,所以需要針對(duì)linestring每一個(gè)線段的走向,計(jì)算出相對(duì)y軸的夾角,作為箭頭偏轉(zhuǎn)的rotation屬性。
高德導(dǎo)航里箭頭之間的間距是可以隨著縮放級(jí)別進(jìn)行自動(dòng)調(diào)整的,這個(gè)也好辦,只需要繪制的時(shí)候以當(dāng)view的resolution為依據(jù)就可以。
以上問(wèn)題解決之后就可以實(shí)現(xiàn)基本的效果了,但是如果想要箭頭變成動(dòng)態(tài)的,還需要利用OpenLayers的render機(jī)制。可以在每一次渲染圖層的時(shí)候,針對(duì)每一個(gè)箭頭符號(hào)設(shè)置一個(gè)偏移量,然后顯式調(diào)用map.render(),這樣通過(guò)不斷地渲染圖層(底圖因?yàn)閞evision counter沒有變化而不會(huì)重復(fù)渲染瓦片數(shù)據(jù))實(shí)現(xiàn)關(guān)鍵幀的切換,從而形成動(dòng)畫。
最后還有一個(gè)性能上的問(wèn)題,因?yàn)橐獙?shí)現(xiàn)動(dòng)畫繪制箭頭,需要不斷地更新點(diǎn)位的信息,所以常規(guī)的VectorLayer滿足不了需求,需要使用VectorContext直接在canvas上繪制。
代碼實(shí)現(xiàn)
首先把需要使用的地圖的基本結(jié)構(gòu)搭出來(lái):
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ù)是官方實(shí)例中首爾的某些街道信息,使用了其中的一條。坐標(biāo)系采用的是默認(rèn)的3857。
然后緩存了一些樣式對(duì)象:
//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 =========================================================================
之所以把這些樣式用全局變量緩存起來(lái),是因?yàn)榭紤]到性能的問(wèn)題, 如果不這樣做,在render過(guò)程中動(dòng)態(tài)去繪制,每次刷新都要new出來(lái)這些對(duì)象,會(huì)大大拉低性能。
接下來(lái)進(jìn)行一些準(zhǔn)備工作:
var offset = 0.01;
tileLayer.on('postrender', (evt) => {
var vct = getVectorContext(evt);
vct.drawFeature(street, buttomPathStyle)
vct.drawFeature(street, upperPathStyle)
offset控制動(dòng)畫中箭頭在每個(gè)關(guān)鍵幀的位置偏移,通過(guò)對(duì)offset的逐次累加,實(shí)現(xiàn)每個(gè)關(guān)鍵幀中箭頭的不同位置。
通過(guò)render繪制的絕大多數(shù)工作都是在tileLayer的postrender事件綁定回調(diào)函數(shù)中實(shí)現(xiàn)的,這里首先使用ol/render的靜態(tài)方法getVectorContext()獲取到當(dāng)前圖層(即tileLayer)的VectorContext對(duì)象句柄,并立即將路徑的背景層和內(nèi)襯層繪制上去。
接下來(lái)是確定箭頭的點(diǎn)位:
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ù)之前分析的思路,這里使用了這樣一個(gè)公式來(lái)計(jì)算路徑在當(dāng)前分辨率下的像素個(gè)數(shù):
然后使用這個(gè)值去除以一個(gè)常量,這里我選的是100,也就是說(shuō)箭頭之間的距離,無(wú)論在那個(gè)分辨率下面,都是100像素長(zhǎng)度。
接下來(lái)就是按照這個(gè)點(diǎn)位數(shù)進(jìn)行迭代取點(diǎn),核心的API就是getCoordinateAt ()作用是根據(jù)百分比取曲線上的點(diǎn)坐標(biāo)。取到點(diǎn)的坐標(biāo)之后直接做成要素,緩存到數(shù)組里。
核心:確定點(diǎn)位上箭頭的方向
//確定方向并繪制
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())
}
});
用作此例的一個(gè)透明背景的白色箭頭
想要確定點(diǎn)位上箭頭的方向,需要判定點(diǎn)位所在直線線段,根據(jù)直線線段的起始點(diǎn)和終點(diǎn)(注意是有序的)來(lái)計(jì)算得到需要旋轉(zhuǎn)的角度。首先我們對(duì)路徑的geometry上面的所有直線線段進(jìn)行迭代(使用forEachSegment),針對(duì)每一個(gè)直線段,都判斷一下前面獲取到的點(diǎn)集合中,哪一個(gè)點(diǎn)在它上面,然后計(jì)算出線段的方向,將箭頭圖標(biāo)旋轉(zhuǎn)相應(yīng)的角度,通過(guò)樣式繪制在這個(gè)點(diǎn)位上。
基本的算法思想是這樣的,但操作起來(lái)有個(gè)很實(shí)際的問(wèn)題:在上一個(gè)我們獲取點(diǎn)位的過(guò)程中,這些點(diǎn)的坐標(biāo)是保留了一定精度的,也就是說(shuō),即便是原來(lái)就在某個(gè)線段上的點(diǎn),如果截取了小數(shù)位,就不可能符合這個(gè)線段的直線方程!
所以在這里我用了一個(gè)技巧,利用OpenLayers提供的API getClosestPoint,計(jì)算點(diǎn)位到線段上最近一個(gè)點(diǎn)的坐標(biāo)偏移量,如果這個(gè)偏移量足夠小,我就認(rèn)為點(diǎn)位在這個(gè)線段上了(實(shí)際上就是這么回事!)
let cPoint = line.getClosestPoint(coord);
if (Math.abs(cPoint[0] - coord[0]) < 1 && Math.abs(cPoint[1] - coord[1]) < 1) {
旋轉(zhuǎn)的角度是通過(guò)xy的坐標(biāo)差進(jìn)行反正切之后再調(diào)整得到的:這里是反正切的象限與符號(hào)和偏轉(zhuǎn)角度計(jì)算的關(guān)系,經(jīng)過(guò)總結(jié),得到代碼中的調(diào)整公式。
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);
最后把起點(diǎn)和終點(diǎn)的樣式渲染上去:
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,在下一次渲染的時(shí)候微調(diào)點(diǎn)位的位置。這里有一個(gè)判斷,如果offset超過(guò)了1,就重置,使得動(dòng)畫能夠循環(huán)進(jìn)行。最后顯式調(diào)用render()函數(shù),進(jìn)行下一次渲染。
offset = offset + 0.003
//復(fù)位
if (offset >= 1) offset = 0.001
map.render()
})
完整代碼:
所用道路數(shù)據(jù)為OpenLayers官方實(shí)例中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
//復(fù)位
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í)間倉(cāng)促,代碼中肯定還有很多可以改進(jìn)的地方,比如將渲染過(guò)程函數(shù)化,關(guān)鍵數(shù)值作為參數(shù),便于調(diào)整動(dòng)畫效果。另外,因?yàn)槭褂玫氖前俜直冉厝。瑒?dòng)畫的速度在分辨率變化之后也會(huì)變快或者變慢,這也是一個(gè)需要改進(jìn)的地方。
這個(gè)實(shí)例應(yīng)用了大量的VectorContext繪制,利用VectorContext還可以繪制更多更炫的效果,感興趣的可以一起研究一下。