一开始,希望在mapbox-gl上实现一些链路的可视化时,并没有一些比较直接的方法,例如:飞机航线图、人口迁徙图等。
后来发现通过deck.gl这个插件可以比较轻松的实现一些3D 链路的效果。就像下面这样:
最后又希望让链路样式更丰富一些,比如让链路有动的效果,所以就想到了three.js抛物线的流动效果。最后实现效果就是下面这样:
实现原理就是通过three.js实现抛物线流动的效果——飞线,并且将这个飞线效果集成到mapbox-gl地图上。整体步骤就分为:
1、three.js 实现抛物线流动
2、将three.js 集成到maobox-gl
three.js 实现抛物线流动
分成两步走,1、画出抛物线,2、实现抛物线流动效果。
初始化three.js场景
代码:
function init() {
let containerWidth = containerDom.value.clientWidth
let containerHeight = containerDom.value.clientHeight
scene = new THREE.Scene()
camera = new THREE.PerspectiveCamera(75, containerWidth / containerHeight, 0.1, 1000)
camera.position.z = 50
renderer = new THREE.WebGLRenderer()
renderer.setSize(containerWidth, containerHeight)
renderer.setClearColor(0x666666, 1)
containerDom.value.appendChild(renderer.domElement)
renderer.render(scene, camera)
// 辅助线
const axesHelper = new THREE.AxesHelper(100)
scene.add(axesHelper)
// 控制器
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true // 阻尼(惯性)
controls.dampingFactor = 0.05 // 阻尼大小
animate()
makeArcline() // 创建抛物线
}
创建抛物线
我们可以通过二次贝塞尔曲线来画抛物线,three.js中提供了创建二次贝塞尔曲线的方法:QuadraticBezierCurve3。
我们只需要提供3个点的坐标就可以画抛物线,3个点分别是起始点、目的点和控制点坐标。当然控制点坐标可以通过起始点和目的点来算。所以只需要提供起始点和目的点就好了。
控制点其实就是在起始点与目的点中间并高于这两个点所在的平面。比较简单,具体可以看下面代码。
const yHeight = 50
const point1 = new THREE.Vector3(50, 0, 0)
const point2 = new THREE.Vector3(-50, 0, 0)
const controlPoint = new THREE.Vector3(
(point1.x + point2.x) / 2,
(point1.y + point2.y) / 2 + yHeight,
(point1.z + point2.z) / 2
)
// 创建三维二次贝塞尔曲线
const curve = new THREE.QuadraticBezierCurve3(
new THREE.Vector3(point1.x, point1.y, point1.z),
new THREE.Vector3(controlPoint.x, controlPoint.y, controlPoint.z),
new THREE.Vector3(point2.x, point2.y, point2.z),
)
添加抛物线到场景
现在抛物线有了,只需要再通过几何体(BufferGeometry) + 材质(LineBasicMaterial)就可以创建一条线了(Line)。
几何体的顶点位置我们可以通过刚刚创建的贝塞尔曲线获得,并同时赋予顶点颜色,方便实现后面的流动效果。所以使用材质的时候就需要设置开启顶点颜色。具体代码如下:
const divisions = 20 // 曲线的分段数量
const points = curve.getPoints(divisions) // 返回分段的点 31个
// 创建几何体
const geometry = new THREE.BufferGeometry().setFromPoints(points)
line.geometry = geometry // 全局保存一下
let colors = new Float32Array(points.length * 4)
points.forEach((d, i) => {
colors[i * 4] = colorHigh.r
colors[i * 4 + 1] = colorHigh.g
colors[i * 4 + 2] = colorHigh.b
colors[i * 4 + 3] = 1
})
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 4))
// 材质
const material = new THREE.LineBasicMaterial({
vertexColors: true, // 顶点着色
linewidth: 5,
transparent: true,
side: THREE.DoubleSide
})
const mesh = new THREE.Line(geometry, material)
scene.add(mesh)
注意 LineBasicMaterial 中设置linewidth 是没有效果的,官网是这样解释的:
颜色我这里设置了一个蓝色
let colorHigh = new THREE.Color("rgb(33, 150, 243)")
所以最后效果是这样:
makeArcline 的完整代码:
// 绘制抛物线
function makeArcline() {
const yHeight = 50
const point1 = new THREE.Vector3(50, 0, 0)
const point2 = new THREE.Vector3(-50, 0, 0)
const controlPoint = new THREE.Vector3(
(point1.x + point2.x) / 2,
(point1.y + point2.y) / 2 + yHeight,
(point1.z + point2.z) / 2
)
// 创建三维二次贝塞尔曲线
const curve = new THREE.QuadraticBezierCurve3(
new THREE.Vector3(point1.x, point1.y, point1.z),
new THREE.Vector3(controlPoint.x, controlPoint.y, controlPoint.z),
new THREE.Vector3(point2.x, point2.y, point2.z),
)
// 流动线效果
const divisions = 20 // 曲线的分段数量
const points = curve.getPoints(divisions) // 返回分段的点 31个
console.log(points)
// 创建几何体
const geometry = new THREE.BufferGeometry().setFromPoints(points)
line.geometry = geometry // 全局保存一下
let colors = new Float32Array(points.length * 4)
points.forEach((d, i) => {
colors[i * 4] = colorHigh.r
colors[i * 4 + 1] = colorHigh.g
colors[i * 4 + 2] = colorHigh.b
colors[i * 4 + 3] = 1
})
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 4))
// 材质
const material = new THREE.LineBasicMaterial({
vertexColors: true, // 顶点着色
linewidth: 5,
transparent: true,
side: THREE.DoubleSide
})
const mesh = new THREE.Line(geometry, material)
scene.add(mesh)
}
抛物线的动态效果
其实这里实现"动态"的主要思路就是去顺序不断的改变几何体中顶点的颜色,当然也可以修改透明度。具体代码图下:
let timestamp = 0
let colorIndex = 0
// 线的动画
function arclineAnimate() {
if (!line.geometry) {
return
}
let color = line.geometry.getAttribute('color')
let now = Date.now()
if (now - timestamp > 30) {
timestamp = now
colorIndex++
if (colorIndex >= color.count) {
colorIndex = 0
}
}
for(let i = 0; i < color.array.length; i += 4) {
if ((i / 4) === colorIndex) {
color.array[i + 3] = 1
} else {
color.array[i + 3] = 0
}
}
line.geometry.attributes.color.needsUpdate = true // 更新颜色
}
效果如下:
这里只是改了一下透明度,当然也可以修改为不同的颜色。
mapbox-gl 集成 three.js
mapbox-gl 光网有一个添加3D model到地图上的例子,详细内容可以点击查看这里。
另外mapbox-gl上集成也有一个插件——threebox,这里是它的github。它原本这个库很久没有维护了,并不支持最新版本的three.js。有大神在这个库基础上继续开发,并还在持续维护,详情可以点击访问这个github。
我这里没有选择用threebox插件,因为功能相对简单,自己写代码也不多。
有了上面实现three.js抛物的基础上,我们只需要在mapbox-gl添加一个three.js场景的图层即可。
添加自定义图层
自定义图层也没啥好说的,可以直接看官网API。这里直接贴代码:
// 创建three.js图层
function initThreeLayer() {
let threeLayer = {
id: 'three-layer',
type: 'custom',
renderingMode: '3d',
onAdd: async function (map, mbxContext) {
this.camera = new THREE.Camera()
this.scene = new THREE.Scene()
this.map = map
this.renderer = new THREE.WebGLRenderer({
canvas: map.getCanvas(),
context: mbxContext,
antialias: true
})
this.renderer.autoClear = false
// 辅助线
let axes = new THREE.AxesHelper(100000)
this.scene.add(axes)
let lineGroup = makeArcGroup() // 抛物线集合
this.scene.add(lineGroup)
},
render: function (gl, matrix) {
this.camera.projectionMatrix = new THREE.Matrix4().fromArray(matrix)
this.renderer.state.reset()
this.renderer.render(this.scene, this.camera)
this.map.triggerRepaint()
arclineAnimate() // 抛物流动逻辑
}
}
return threeLayer
}
画抛物线
画抛物线这里涉及到一个经纬度坐标转three.js场景坐标的问题。mapbox-gl提供mapboxgl.MercatorCoordinate.fromLngLat方法,方法使用如下:
我这里一般画线的数据都是包含经纬度坐标,大概就是下面这样:
let linklist = [
{
source: {
latitude: 37.28669,
longitude: -98.02589,
},
target: {
latitude: 36.54614,
longitude: -98.27007,
},
},
{
source: {
latitude: 37.28527,
longitude: -98.577433,
},
target: {
latitude: 36.45336,
longitude: -100.53737,
},
},
{
source: {
latitude: 37.28113,
longitude: -98.577433,
},
target: {
latitude: 36.43222,
longitude: -100.14111,
},
},
{
source: {
latitude: 37.28113,
longitude: -98.577433,
},
target: {
latitude: 36.45169,
longitude: -103.1841,
},
},
{
source: {
latitude: 36.910248,
longitude: -103.807756,
},
target: {
latitude: 36.638648,
longitude: -103.882987,
},
},
{
source: {
latitude: 37.28113,
longitude: -98.577433,
},
target: {
latitude: 36.43222,
longitude: -100.14111,
},
}
]
画线的代码基本和上面three.js画线一样,只是这里多了一个转坐标的步骤。
// 全局变量
let colorHigh = new THREE.Color("rgb(244, 67, 54)") // line color high
let colorNormal = new THREE.Color("rgb(255, 235, 59)") // line color
let lineGeometryList = []
// 绘制抛物线
function makeArcline(link) {
const source = [link.source.longitude, link.source.latitude]
const target = [link.target.longitude, link.target.latitude]
let sourcePoint = mapboxMercator(source)
let targetPoint = mapboxMercator(target)
let controlPoint = getControlPoint(sourcePoint, targetPoint)
// 创建三维二次贝塞尔曲线
const curve = new THREE.QuadraticBezierCurve3(
new THREE.Vector3(sourcePoint.x, sourcePoint.y, sourcePoint.z),
new THREE.Vector3(controlPoint.x, controlPoint.y, controlPoint.z),
new THREE.Vector3(targetPoint.x, targetPoint.y, targetPoint.z),
)
// 流动线效果
const divisions = 30 // 曲线的分段数量
const points = curve.getPoints(divisions) // 返回分段的点 divisions + 1 个
// 创建几何体
const geometry = new THREE.BufferGeometry().setFromPoints(points)
let colors = new Float32Array(points.length * 4)
points.forEach((d, i) => {
colors[i * 4] = colorNormal.r
colors[i * 4 + 1] = colorNormal.g
colors[i * 4 + 2] = colorNormal.b
colors[i * 4 + 3] = 1
})
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 4))
// 材质
const material = new THREE.LineBasicMaterial({
vertexColors: true, // 顶点着色
linewidth: 5, // 没用,不支持设置lineWidth
transparent: true,
side: THREE.DoubleSide
})
const mesh = new THREE.Line(geometry, material)
lineGeometryList.push(geometry)
return mesh
}
// 经纬度转坐标点
function mapboxMercator(lngLat) {
return mapboxgl.MercatorCoordinate.fromLngLat(lngLat, 0);
}
画出来结果就是这样:
让抛物动起来
最后这里的动画逻辑和上面的一样,直接放代码了:
// 线的动画
let timestamp = 0
let colorIndex = 0
function arclineAnimate() {
lineGeometryList.forEach(geometry => {
let color = geometry.getAttribute('color')
let now = Date.now()
if (now - timestamp > 30) {
timestamp = now
colorIndex++
if (colorIndex >= color.count) {
colorIndex = 0
}
}
for (let i = 0; i < color.array.length; i += 4) {
if ((i / 4) === colorIndex) {
color.array[i + 0] = colorHigh.r
color.array[i + 1] = colorHigh.g
color.array[i + 2] = colorHigh.b
color.array[i + 3] = 1
} else {
color.array[i + 0] = colorNormal.r
color.array[i + 1] = colorNormal.g
color.array[i + 2] = colorNormal.b
color.array[i + 3] = 0.1
}
}
geometry.attributes.color.needsUpdate = true
})
}
最后效果就是这样: