最近从北京搬到了上海,开始了一段新的生活,算是人生中一个比较大的事件,于是特地用 Three.js 做了下可视化。
在这个地理信息相关的可视化的案例中,我们能学到地图怎么画、经纬度如何转成坐标值,这些是地理可视化的通用技术。
那我们就开始吧。
Three.js 画立方体、画圆柱、画不规则图形我们都画过,但是如何画一个地图呢?
其实地图也是由线、由多边形构成的,有了数据我们就能画出来,缺少的只是数据。
地图信息的描述是一个通用需求,所以有相应的国际标准,就是 GeoJson
,它是通过点、线、多边形来描述地理信息的。
通过指定点、线、多边形的类型、然后指定几个坐标位置,就可以描述出相应的形状。
geojson 的数据可以通过 geojson.io 这个网站做下预览。
比如中国地图的 geojson:
有了这个 json,只要用 Three.js 画出来就行,通过线和多边形两种方式。
但是还有一个问题,geojson 中记录的是经纬度信息,应该如何转成二维坐标来画呢?
这就涉及到了墨卡托转换
,它就是做经纬度转二维坐标的事情。
这个转换也不用我们自己实现,可以用 d3 内置的墨卡托坐标转换函数来做。
这样,我们就用 Three.js 根据 geojson 来画出地图。
我们还要画一条北京到上海的曲线,这个用贝塞尔曲线画就行,知道两个端点的坐标,控制点放在中间的位置。
那怎么知道两个端点,也就是上海和北京的坐标呢?
这个可以用“百度坐标拾取系统”这个工具,点击地图的某个位置,就可以直接拿到那个位置的经纬度。然后我们做一次墨卡托转换,就拿到坐标了。
地图画出来了,旅行的曲线也画出来了,接下来调整下相机位置,从北京慢慢移动到上海就可以了。
思路理清了,我们来写下代码。
我们要引入 d3,然后使用 d3 的墨卡托转换功能,
const projection = d3.geoMercator()
.center([116.412318,39.909843])
.translate([0, 0]);
中间点的坐标就是北京的经纬度,就是我们通过“百度坐标拾取工具”那里拿到的。
北京和上海的坐标位置也可以把经纬度做墨卡托转换得到:
let beijingPosition= projection([116.412318,39.909843]);
let shanghaiPosition = projection([121.495721,31.236797]);
先不着急画旅行的曲线,先来画地图吧。
先加载 geojson:
const loader = new THREE.FileLoader();
loader.load('./data/china.json', (data) => {
const jsondata = JSON.parse(data);
generateGeometry(jsondata);
})
然后根据 json 的信息画地图。
遍历 geojson 的数据,把每个经纬度通过墨卡托转换变成坐标,然后分别用线和多边形画出来。
画多边形的时候遇到北京和上海用黄色,其他城市用蓝色。
function generateGeometry(jsondata) {
const map = new THREE.Group();
jsondata.features.forEach((elem) => {
const province = new THREE.Group();
// 经纬度信息
const coordinates = elem.geometry.coordinates;
coordinates.forEach((multiPolygon) => {
multiPolygon.forEach((polygon) => {
// 画轮廓线
const line = drawBoundary(polygon);
// 画多边形
const provinceColor = ['北京市', '上海市'].includes(elem.properties.name) ? 'yellow' : 'blue';
const mesh = drawExtrudeMesh(polygon, provinceColor);
province.add(line);
province.add(mesh);
});
});
map.add(province);
})
scene.add(map);
}
然后分别实现画轮廓线和画多边形:
轮廓线(Line)就是指定一系列顶点来构成几何体(Geometry),然后指定材质(Material)颜色为黄色:
function drawBoundary(polygon) {
const lineGeometry = new THREE.Geometry();
for (let i = 0; i < polygon.length; i++) {
const [x, y] = projection(polygon[i]);
lineGeometry.vertices.push(new THREE.Vector3(x, -y, 0));
}
const lineMaterial = new THREE.LineBasicMaterial({
color: 'yellow'
});
return new THREE.Line(lineGeometry, lineMaterial);
}
现在的效果是这样的:
多边形是 ExtrudeGeometry,也就是可以先画出形状(shape),然后通过拉伸变成三维的。
function drawExtrudeMesh(polygon, color) {
const shape = new THREE.Shape();
for (let i = 0; i < polygon.length; i++) {
const [x, y] = projection(polygon[i]);
if (i === 0) {
shape.moveTo(x, -y);
}
shape.lineTo(x, -y);
}
const geometry = new THREE.ExtrudeGeometry(shape, {
depth: 0,
bevelEnabled: false
});
const material = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.2,
})
return new THREE.Mesh(geometry, material);
}
第一个点用 moveTo,后面的点用 lineTo,这样连成一个多边形,然后指定厚度为 0,指定侧面不需要多出一块斜面(bevel)。
这样,我们就给每个省都填充上了颜色,北京和上海是黄色,其余省是蓝色。
接下来,在北京和上海之间画一条贝塞尔曲线:
const line = drawLine(beijingPosition, shanghaiPosition);
scene.add(line);
贝塞尔曲线用 QuadraticBezierCurve3 来画,控制点指定中间位置的点。
function drawLine(pos1, pos2) {
const [x0, y0, z0] = [...pos1, 0];
const [x1, y1, z1] = [...pos2, 0];
const geomentry = new THREE.Geometry();
geomentry.vertices = new THREE.QuadraticBezierCurve3(
new THREE.Vector3(-x0, -y0, z0),
new THREE.Vector3(-(x0 + x1) / 2, -(y0 + y1) / 2, -10),
new THREE.Vector3(-x1, -y1, z1),
).getPoints();
const material = new THREE.LineBasicMaterial({color: 'white'});
const line = new THREE.Line(geomentry, material);
line.rotation.y = Math.PI;
return line;
}
这样,地图和旅行轨迹就都画完了:
当然,还有渲染器、相机、灯光的初始化代码:
渲染器:
const renderer = new THREE.WebGLRenderer();
renderer.setClearColor(0x000000);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
渲染器设置背景颜色为黑色,画布大小为窗口大小。
灯光:
let ambientLight = new THREE.AmbientLight(0xffffff);
scene.add(ambientLight);
灯光用环境光,也就是每个方向的明暗都一样。
相机:
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 0, 10);
camera.lookAt(scene.position);
相机用透视相机,特点是近大远小,需要指定看的角度,宽高比,和远近的范围这样四个参数。
位置设置在 0 0 10 的位置,在这个位置去观察 0 0 0,就是北京上方的俯视图(我们做墨卡托转换的时候指定了北京为中心)。
修改了相机位置之后,看到的地图大了许多:
接下来就是一帧帧的渲染,在每帧渲染的时候移动下相机位置,这样就是从北京到上海的一个移动的效果:
function render() {
if(camera.position.x < shanghaiPosition[0]) {
camera.position.x += 0.1;
}
if(camera.position.y > -shanghaiPosition[1]) {
camera.position.y -= 0.2;
}
renderer.render(scene, camera);
requestAnimationFrame(render);
}
大功告成!我们来看下最终的效果吧:
代码上传到了 github: https://github.com/QuarkGluonPlasma/threejs-exercize
也在这里贴一份:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>map-travel</title>
<style>
html body {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
</style>
</head>
<body>
<script src="./js/three.js"></script>
<script src="./js/d3.js"></script>
<script>
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer();
renderer.setClearColor(0x000000);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 0, 10);
camera.lookAt(scene.position);
let ambientLight = new THREE.AmbientLight(0xffffff);
scene.add(ambientLight);
function create() {
const loader = new THREE.FileLoader();
loader.load('./data/china.json', (data) => {
const jsondata = JSON.parse(data);
generateGeometry(jsondata);
})
}
const projection = d3.geoMercator()
.center([116.412318,39.909843])
.translate([0, 0]);
let beijingPosition= projection([116.412318,39.909843]);
let shanghaiPosition = projection([121.495721,31.236797]);
function drawBoundary(polygon) {
const lineGeometry = new THREE.Geometry();
for (let i = 0; i < polygon.length; i++) {
const [x, y] = projection(polygon[i]);
lineGeometry.vertices.push(new THREE.Vector3(x, -y, 0));
}
const lineMaterial = new THREE.LineBasicMaterial({
color: 'yellow'
});
return new THREE.Line(lineGeometry, lineMaterial);
}
function drawExtrudeMesh(polygon, color) {
const shape = new THREE.Shape();
for (let i = 0; i < polygon.length; i++) {
const [x, y] = projection(polygon[i]);
if (i === 0) {
shape.moveTo(x, -y);
}
shape.lineTo(x, -y);
}
const geometry = new THREE.ExtrudeGeometry(shape, {
depth: 0,
bevelEnabled: false
});
const material = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.2,
})
return new THREE.Mesh(geometry, material);
}
function generateGeometry(jsondata) {
const map = new THREE.Group();
jsondata.features.forEach((elem) => {
const province = new THREE.Group();
const coordinates = elem.geometry.coordinates;
coordinates.forEach((multiPolygon) => {
multiPolygon.forEach((polygon) => {
const line = drawBoundary(polygon);
const provinceColor = ['北京市', '上海市'].includes(elem.properties.name) ? 'yellow' : 'blue';
const mesh = drawExtrudeMesh(polygon, provinceColor);
province.add(line);
province.add(mesh);
});
});
map.add(province);
})
scene.add(map);
const line = drawLine(beijingPosition, shanghaiPosition);
scene.add(line);
}
function render() {
if(camera.position.x < shanghaiPosition[0]) {
camera.position.x += 0.1;
}
if(camera.position.y > -shanghaiPosition[1]) {
camera.position.y -= 0.2;
}
renderer.render(scene, camera);
requestAnimationFrame(render);
}
function drawLine(pos1, pos2) {
const [x0, y0, z0] = [...pos1, 0];
const [x1, y1, z1] = [...pos2, 0];
const geomentry = new THREE.Geometry();
geomentry.vertices = new THREE.QuadraticBezierCurve3(
new THREE.Vector3(-x0, -y0, z0),
new THREE.Vector3(-(x0 + x1) / 2, -(y0 + y1) / 2, -10),
new THREE.Vector3(-x1, -y1, z1),
).getPoints();
const material = new THREE.LineBasicMaterial({color: 'white'});
const line = new THREE.Line(geomentry, material);
line.rotation.y = Math.PI;
return line;
}
create();
render();
</script>
</body>
</html>
地图形状的表示是基于 geojson 的规范,它是由点、线、多边形等信息构成的。
用 Three.js 或者其他绘制方式来画地图只需要加载 geojson 的数据,然后通过线和多边型把每一部分画出来。
画之前还要把经纬度转成坐标,这需要用到墨卡托转换。
我们用 Three.js 画线是通过指定一系列顶点构成 Geometry,而画多边形是通过绘制一个形状,然后用 ExtrudeGeometry(挤压几何体) 拉伸成三维。墨卡托转换直接使用了 d3 的内置函数。旅行的效果是通过一帧帧的移动相机位置来实现的。
熟悉了 geojson 和墨卡托转换,就算是入门地理相关的可视化了。
你是否也想做一些和地理相关的可视化或者交互呢?不妨来尝试下吧。