说明
【跟月影学可视化】学习笔记。
如何实现一个 3D 地球
学习笔记源码实现:https://github.com/kaimo313/visual-learning-demo
整体实现效果如下:
1、绘制一个 3D 球体
<!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>绘制一个 3D 球体</title>
<style>
#container {
width: 600px;
height: 600px;
border: 1px dashed salmon;
}
</style>
</head>
<body>
<div id="container"></div>
<script src="http://unpkg.com/spritejs/dist/spritejs.js"></script>
<script src="http://unpkg.com/sprite-extend-3d/dist/sprite-extend-3d.js"></script>
<script>
const { Scene } = spritejs;
const { Sphere, shaders } = spritejs.ext3d;
const container = document.getElementById("container");
// 创建场景对象
const scene = new Scene({
container,
});
// 添加 Layer,设置透视相机,视角为 35 度,位置为 0, 0, 5
const layer = scene.layer3d("fglayer", {
alpha: false,
camera: {
fov: 35,
pos: [0, 0, 5],
},
});
// shaders.GEOMETRY 是一个符合 Phong 反射模型的几何体 Shader
const program = layer.createProgram({
...shaders.GEOMETRY,
cullFace: null,
});
// 创建一个球体
const globe = new Sphere(program, {
colors: "skyblue",
widthSegments: 64,
heightSegments: 32,
radius: 1,
});
layer.append(globe);
</script>
</body>
</html>
2、绘制地图
先绘制一张平面地图,然后把它以纹理的方式添加到我们创建的 3D 球体上。用 d3-geo
模块来创建等角方位投影(Equirectangular Projection
)。
<!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>绘制地图</title>
<style>
#container {
width: 960px;
height: 480px;
border: 1px dashed salmon;
}
</style>
</head>
<body>
<canvas id="container"></canvas>
<script src="https://lib.baomitu.com/topojson/3.0.2/topojson.min.js"></script>
<script src="https://d3js.org/d3-array.v2.min.js"></script>
<script src="https://d3js.org/d3-geo.v2.min.js"></script>
<script>
const ctx = document.getElementById("container").getContext("2d");
// d3 的地图投影默认宽高
const mapWidth = 960;
const mapHeight = 480;
// 将投影缩放为 4 倍,也就是将地图绘制为 3480 * 1920 大小。
const mapScale = 4 / 13;
// 创建等角方位投影
const projection = d3.geoEquirectangular();
// 通过 tanslate 将中心点调整到画布中心
projection
.scale(projection.scale() * mapScale)
.translate([
mapWidth * mapScale * 0.5,
(mapHeight + 2) * mapScale * 0.5,
]);
// 使用 topoJSON 数据加载地图
async function loadMap(
src = topojsonData,
{ strokeColor, fillColor } = {}
) {
const data = await (await fetch(src)).json();
const countries = topojson.feature(
data,
data.objects.countries
);
// 创建一个离屏 Canvas,用加载的数据来绘制地图到离屏 Canvas 上
const canvas = new OffscreenCanvas(
mapScale * mapWidth,
mapScale * mapHeight
);
const context = canvas.getContext("2d");
context.imageSmoothingEnabled = false;
return drawMap({ context, countries, strokeColor, fillColor });
}
// 绘制地图
function drawMap({
context,
countries,
strokeColor = "#666",
fillColor = "salmon",
strokeWidth = 1.5,
} = {}) {
const path = d3.geoPath(projection).context(context);
context.save();
context.strokeStyle = strokeColor;
context.lineWidth = strokeWidth;
context.fillStyle = fillColor;
context.beginPath();
path(countries);
context.fill();
context.stroke();
context.restore();
return context.canvas;
}
loadMap("./data/world-topojson.json").then((res) => {
console.log(res)
ctx.drawImage(res, 0, 0);
});
</script>
</body>
</html>
3、将地图作为纹理
<!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>将地图作为纹理</title>
<style>
#container {
width: 600px;
height: 600px;
border: 1px dashed salmon;
}
</style>
</head>
<body>
<div id="container"></div>
<script src="http://unpkg.com/spritejs/dist/spritejs.js"></script>
<script src="http://unpkg.com/sprite-extend-3d/dist/sprite-extend-3d.js"></script>
<script src="https://lib.baomitu.com/topojson/3.0.2/topojson.min.js"></script>
<script src="https://d3js.org/d3-array.v2.min.js"></script>
<script src="https://d3js.org/d3-geo.v2.min.js"></script>
<script type="module">
import { vertex, fragment } from './assets/js/40/shader.js';
const { Scene } = spritejs;
const { Sphere, shaders } = spritejs.ext3d;
// d3 的地图投影默认宽高
const mapWidth = 960;
const mapHeight = 480;
// 将投影缩放为 4 倍,也就是将地图绘制为 3480 * 1920 大小。
const mapScale = 4;
// 创建等角方位投影
const projection = d3.geoEquirectangular();
// 通过 tanslate 将中心点调整到画布中心
projection
.scale(projection.scale() * mapScale)
.translate([
mapWidth * mapScale * 0.5,
(mapHeight + 2) * mapScale * 0.5,
]);
// 使用 topoJSON 数据加载地图
async function loadMap(
src = topojsonData,
{ strokeColor, fillColor } = {}
) {
const data = await (await fetch(src)).json();
const countries = topojson.feature(
data,
data.objects.countries
);
// 创建一个离屏 Canvas,用加载的数据来绘制地图到离屏 Canvas 上
const canvas = new OffscreenCanvas(
mapScale * mapWidth,
mapScale * mapHeight
);
const context = canvas.getContext("2d");
context.imageSmoothingEnabled = false;
return drawMap({ context, countries, strokeColor, fillColor });
}
// 绘制地图
function drawMap({
context,
countries,
strokeColor = "#666",
fillColor = "salmon",
strokeWidth = 1.5,
} = {}) {
const path = d3.geoPath(projection).context(context);
context.save();
context.strokeStyle = strokeColor;
context.lineWidth = strokeWidth;
context.fillStyle = fillColor;
context.beginPath();
path(countries);
context.fill();
context.stroke();
context.restore();
return context.canvas;
}
const container = document.getElementById("container");
// 创建场景对象
const scene = new Scene({
container,
});
// 添加 Layer,设置透视相机,视角为 35 度,位置为 0, 0, 5
const layer = scene.layer3d("fglayer", {
alpha: false,
camera: {
fov: 35,
pos: [0, 0, 5],
},
});
// 创建一个 Texture 对象,将它赋给 Program 对象
const texture = layer.createTexture({});
// 加载数据
loadMap("./data/world-topojson.json").then((map) => {
console.log(map)
texture.image = map;
texture.needsUpdate = true;
layer.forceUpdate();
});
// 创建 Program
const program = layer.createProgram({
vertex,
fragment,
texture,
cullFace: null,
});
// 创建一个球体
const globe = new Sphere(program, {
colors: "skyblue",
widthSegments: 64,
heightSegments: 32,
radius: 1,
});
layer.append(globe);
// 开启旋转控制
layer.setOrbit({autoRotate: true});
</script>
</body>
</html>
如何实现星空背景
创建一个天空包围盒,让摄像机处于整个球体内部,使用二维噪声的技巧来实现来其 Shader,通过 step 函数和 vUv 的缩放,将它缩小之后,最终呈现出来星空效果。
注意这里我们需要关闭旋转控制。
<!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>如何实现星空背景</title>
<style>
html,
body {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
overflow: hidden;
}
#container {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="container"></div>
<script src="http://unpkg.com/spritejs/dist/spritejs.js"></script>
<script src="http://unpkg.com/sprite-extend-3d/dist/sprite-extend-3d.js"></script>
<script src="https://lib.baomitu.com/topojson/3.0.2/topojson.min.js"></script>
<!-- <script src="https://unpkg.com/topojson@3"></script> -->
<script src="https://d3js.org/d3-array.v2.min.js"></script>
<script src="https://d3js.org/d3-geo.v2.min.js"></script>
<script type="module">
import { vertex, fragment, skyVertex, skyFragment } from './assets/js/40/shader.js';
const { Scene } = spritejs;
const { Sphere, shaders } = spritejs.ext3d;
// d3 的地图投影默认宽高
const mapWidth = 960;
const mapHeight = 480;
// 将投影缩放为 4 倍,也就是将地图绘制为 3480 * 1920 大小。
const mapScale = 4;
// 创建等角方位投影
const projection = d3.geoEquirectangular();
// 通过 tanslate 将中心点调整到画布中心,默认 translate 是 480 X 250
projection
.scale(projection.scale() * mapScale)
.translate([
mapWidth * mapScale * 0.5,
(mapHeight + 2) * mapScale * 0.5,
]);
// 使用 topoJSON 数据加载地图
async function loadMap(
src = topojsonData,
{ strokeColor, fillColor } = {}
) {
const data = await (await fetch(src)).json();
const countries = topojson.feature(
data,
data.objects.countries
);
// 创建一个离屏 Canvas,用加载的数据来绘制地图到离屏 Canvas 上
const canvas = new OffscreenCanvas(
mapScale * mapWidth,
mapScale * mapHeight
);
const context = canvas.getContext("2d");
context.imageSmoothingEnabled = false;
return drawMap({ context, countries, strokeColor, fillColor });
}
// 绘制地图
function drawMap({
context,
countries,
strokeColor = "#666",
fillColor = "salmon",
strokeWidth = 1.5,
} = {}) {
const path = d3.geoPath(projection).context(context);
context.save();
context.strokeStyle = strokeColor;
context.lineWidth = strokeWidth;
context.fillStyle = fillColor;
context.beginPath();
path(countries);
context.fill();
context.stroke();
context.restore();
return context.canvas;
}
const container = document.getElementById("container");
// 创建场景对象
const scene = new Scene({
container,
});
// 添加 Layer,设置透视相机,视角为 35 度,位置为 0, 0, 5
const layer = scene.layer3d("fglayer", {
alpha: false,
camera: {
fov: 35,
pos: [0, 0, 5],
},
});
// 创建一个 Texture 对象,将它赋给 Program 对象
const texture = layer.createTexture({});
// 加载数据
loadMap("./data/world-topojson.json").then((map) => {
console.log(map);
texture.image = map;
texture.needsUpdate = true;
layer.forceUpdate();
});
// 创建 Program
const program = layer.createProgram({
vertex,
fragment,
texture,
cullFace: null,
});
// 创建一个球体
const globe = new Sphere(program, {
colors: "skyblue",
widthSegments: 64,
heightSegments: 32,
radius: 1,
});
layer.append(globe);
// 关闭旋转控制
layer.setOrbit({ autoRotate: false });
// 创建天空盒子
function createSky(layer, skyProgram) {
skyProgram = skyProgram ||
layer.createProgram({
vertex: skyVertex,
fragment: skyFragment,
transparent: true,
cullFace: null,
});
const skyBox = new Sphere(skyProgram);
skyBox.attributes.scale = 100;
layer.append(skyBox);
return skyBox;
}
createSky(layer);
</script>
</body>
</html>
如何选中地球上的地理位置?
下面实现当点击到地图上的国家区域的时候,想让改区域显示高亮。
1、实现坐标转换
需要将鼠标在地球区域移动的三维坐标转换成二维的地图经纬度坐标,才能通过地图数据来获取到当前经纬度下的国家或地区信息。
大致过程:
- 第1步:鼠标在地球上移动的时候,通过 SpriteJS,拿到三维的球面坐标
- 第2步:将三维坐标转换为二维平面坐标
- 第3步:拿到二维平面直角坐标之后,可以直接用等角方位投影函数的反函数将这个平面直角坐标转换为经纬度
- 第4步:通过经纬度拿到国家信息
<!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>实现坐标转换</title>
<style>
html,
body {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
overflow: hidden;
}
#container {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="container"></div>
<script src="http://unpkg.com/spritejs/dist/spritejs.js"></script>
<script src="http://unpkg.com/sprite-extend-3d/dist/sprite-extend-3d.js"></script>
<script src="https://lib.baomitu.com/topojson/3.0.2/topojson.min.js"></script>
<!-- <script src="https://unpkg.com/topojson@3"></script> -->
<script src="https://d3js.org/d3-array.v2.min.js"></script>
<script src="https://d3js.org/d3-geo.v2.min.js"></script>
<script type="module">
import { vertex, fragment, skyVertex, skyFragment } from './assets/js/40/shader.js';
const { Scene } = spritejs;
const { Sphere, shaders } = spritejs.ext3d;
// d3 的地图投影默认宽高
const mapWidth = 960;
const mapHeight = 480;
// 将投影缩放为 4 倍,也就是将地图绘制为 3480 * 1920 大小。
const mapScale = 4;
// 创建等角方位投影
const projection = d3.geoEquirectangular();
// 通过 tanslate 将中心点调整到画布中心,默认 translate 是 480 X 250
projection
.scale(projection.scale() * mapScale)
.translate([
mapWidth * mapScale * 0.5,
(mapHeight + 2) * mapScale * 0.5,
]);
// 使用 topoJSON 数据加载地图
async function loadMap(
src = topojsonData,
{ strokeColor, fillColor } = {}
) {
const data = await (await fetch(src)).json();
const countries = topojson.feature(
data,
data.objects.countries
);
// 创建一个离屏 Canvas,用加载的数据来绘制地图到离屏 Canvas 上
const canvas = new OffscreenCanvas(
mapScale * mapWidth,
mapScale * mapHeight
);
const context = canvas.getContext("2d");
context.imageSmoothingEnabled = false;
return drawMap({ context, countries, strokeColor, fillColor });
}
let _countries;
// 绘制地图
function drawMap({
context,
countries,
strokeColor = "#666",
fillColor = "salmon",
strokeWidth = 1.5,
} = {}) {
_countries = countries;
const path = d3.geoPath(projection).context(context);
context.save();
context.strokeStyle = strokeColor;
context.lineWidth = strokeWidth;
context.fillStyle = fillColor;
context.beginPath();
path(countries);
context.fill();
context.stroke();
context.restore();
return context.canvas;
}
const container = document.getElementById("container");
// 创建场景对象
const scene = new Scene({
container,
});
// 添加 Layer,设置透视相机,视角为 35 度,位置为 0, 0, 5
const layer = scene.layer3d("fglayer", {
alpha: false,
camera: {
fov: 35,
pos: [0, 0, 5],
},
});
// 创建一个 Texture 对象,将它赋给 Program 对象
const texture = layer.createTexture({});
// 加载数据
loadMap("./data/world-topojson.json").then((map) => {
console.log(map);
texture.image = map;
texture.needsUpdate = true;
layer.forceUpdate();
});
// 创建 Program
const program = layer.createProgram({
vertex,
fragment,
texture,
cullFace: null,
});
// 创建一个球体
const globe = new Sphere(program, {
colors: "skyblue",
widthSegments: 64,
heightSegments: 32,
radius: 1,
});
layer.append(globe);
// 关闭旋转控制
layer.setOrbit({ autoRotate: false });
layer.setRaycast();
// 创建天空盒子
function createSky(layer, skyProgram) {
skyProgram =
skyProgram ||
layer.createProgram({
vertex: skyVertex,
fragment: skyFragment,
transparent: true,
cullFace: null,
});
const skyBox = new Sphere(skyProgram);
skyBox.attributes.scale = 100;
// 地球包围在天空盒子内,raycast设置为none之后,鼠标就能穿透天空包围盒到达地球
skyBox.attributes.raycast = "none";
layer.append(skyBox);
return skyBox;
}
createSky(layer);
/**
* 将球面坐标转换为平面地图坐标
* @param {*} x
* @param {*} y
* @param {*} z
* @param {*} radius
*/
function unproject(x, y, z, radius = 1) {
const pLength = Math.PI * 2;
const tLength = Math.PI;
const v = Math.acos(y / radius) / tLength; // const y = radius * Math.cos(v * tLength);
let u = Math.atan2(-z, x) + Math.PI; // z / x = -1 * Math.tan(u * pLength);
u /= pLength;
return [u * mapScale * mapWidth, v * mapScale * mapHeight];
}
// 等角方位投影函数的反函数:将平面直角坐标转换为经纬度
function positionToLatlng(x, y, z, radius = 1) {
const [u, v] = unproject(x, y, z, radius);
return projection.invert([u, v]);
}
// 通过经纬度获取国家信息
function getCountryInfo(latitude, longitude, countries) {
if (!countries) return { index: -1 };
let idx = -1;
countries.features.some((d, i) => {
const ret = d3.geoContains(d, [longitude, latitude]);
if (ret) idx = i;
return ret;
});
const info = idx >= 0 ? { ...countries.features[idx] } : {};
info.index = idx;
return info;
}
globe.addEventListener("mousemove", (e) => {
const [lng, lat] = positionToLatlng(...e.hit.localPoint);
const country = getCountryInfo(lat, lng, _countries);
if (country.properties) {
console.log(country.properties.name, country.properties);
}
});
</script>
</body>
</html>
2、高亮显示国家地区
实现原理:先把原始的非高亮的图片另存一份,然后根据选中国家的 index 信息,从 contries 原始数据中取出对应的那个国家,用不同的填充色 fillStyle 再绘制一次,最后更新 texture 和 layer,就可以将高亮区域绘制出来。
<!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>高亮显示国家地区</title>
<style>
html,
body {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
overflow: hidden;
}
#container {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="container"></div>
<script src="http://unpkg.com/spritejs/dist/spritejs.js"></script>
<script src="http://unpkg.com/sprite-extend-3d/dist/sprite-extend-3d.js"></script>
<script src="https://lib.baomitu.com/topojson/3.0.2/topojson.min.js"></script>
<!-- <script src="https://unpkg.com/topojson@3"></script> -->
<script src="https://d3js.org/d3-array.v2.min.js"></script>
<script src="https://d3js.org/d3-geo.v2.min.js"></script>
<script type="module">
import { vertex, fragment, skyVertex, skyFragment } from './assets/js/40/shader.js';
const { Scene } = spritejs;
const { Sphere, shaders } = spritejs.ext3d;
// d3 的地图投影默认宽高
const mapWidth = 960;
const mapHeight = 480;
// 将投影缩放为 4 倍,也就是将地图绘制为 3480 * 1920 大小。
const mapScale = 4;
// 创建等角方位投影
const projection = d3.geoEquirectangular();
// 通过 tanslate 将中心点调整到画布中心,默认 translate 是 480 X 250
projection
.scale(projection.scale() * mapScale)
.translate([
mapWidth * mapScale * 0.5,
(mapHeight + 2) * mapScale * 0.5,
]);
// 使用 topoJSON 数据加载地图
async function loadMap(
src = topojsonData,
{ strokeColor, fillColor } = {}
) {
const data = await (await fetch(src)).json();
const countries = topojson.feature(
data,
data.objects.countries
);
// 创建一个离屏 Canvas,用加载的数据来绘制地图到离屏 Canvas 上
const canvas = new OffscreenCanvas(
mapScale * mapWidth,
mapScale * mapHeight
);
const context = canvas.getContext("2d");
context.imageSmoothingEnabled = false;
return drawMap({ context, countries, strokeColor, fillColor });
}
let _countries;
// 绘制地图
function drawMap({
context,
countries,
strokeColor = "#666",
fillColor = "salmon",
strokeWidth = 1.5,
} = {}) {
_countries = countries;
const path = d3.geoPath(projection).context(context);
context.save();
context.strokeStyle = strokeColor;
context.lineWidth = strokeWidth;
context.fillStyle = fillColor;
context.beginPath();
path(countries);
context.fill();
context.stroke();
context.restore();
return context.canvas;
}
const container = document.getElementById("container");
// 创建场景对象
const scene = new Scene({
container,
});
// 添加 Layer,设置透视相机,视角为 35 度,位置为 0, 0, 5
const layer = scene.layer3d("fglayer", {
alpha: false,
camera: {
fov: 35,
pos: [0, 0, 5],
},
});
// 创建一个 Texture 对象,将它赋给 Program 对象
const texture = layer.createTexture({});
// 加载数据
loadMap("./data/world-topojson.json").then((map) => {
console.log(map);
texture.image = map;
texture.needsUpdate = true;
layer.forceUpdate();
});
// 创建 Program
const program = layer.createProgram({
vertex,
fragment,
texture,
cullFace: null,
});
// 创建一个球体
const globe = new Sphere(program, {
colors: "skyblue",
widthSegments: 64,
heightSegments: 32,
radius: 1,
});
layer.append(globe);
// 关闭旋转控制
layer.setOrbit({ autoRotate: false });
layer.setRaycast();
// 创建天空盒子
function createSky(layer, skyProgram) {
skyProgram =
skyProgram ||
layer.createProgram({
vertex: skyVertex,
fragment: skyFragment,
transparent: true,
cullFace: null,
});
const skyBox = new Sphere(skyProgram);
skyBox.attributes.scale = 100;
// 地球包围在天空盒子内,raycast设置为none之后,鼠标就能穿透天空包围盒到达地球
skyBox.attributes.raycast = "none";
layer.append(skyBox);
return skyBox;
}
createSky(layer);
/**
* 将球面坐标转换为平面地图坐标
* @param {*} x
* @param {*} y
* @param {*} z
* @param {*} radius
*/
function unproject(x, y, z, radius = 1) {
const pLength = Math.PI * 2;
const tLength = Math.PI;
const v = Math.acos(y / radius) / tLength; // const y = radius * Math.cos(v * tLength);
let u = Math.atan2(-z, x) + Math.PI; // z / x = -1 * Math.tan(u * pLength);
u /= pLength;
return [u * mapScale * mapWidth, v * mapScale * mapHeight];
}
// 等角方位投影函数的反函数:将平面直角坐标转换为经纬度
function positionToLatlng(x, y, z, radius = 1) {
const [u, v] = unproject(x, y, z, radius);
return projection.invert([u, v]);
}
// 通过经纬度获取国家信息
function getCountryInfo(latitude, longitude, countries) {
if (!countries) return { index: -1 };
let idx = -1;
countries.features.some((d, i) => {
const ret = d3.geoContains(d, [longitude, latitude]);
if (ret) idx = i;
return ret;
});
const info = idx >= 0 ? { ...countries.features[idx] } : {};
info.index = idx;
return info;
}
// 高亮地图
let imgCache;
function highlightMap(texture, info, countries) {
if (texture.index === info.index) return;
const canvas = texture.image;
if (!canvas) return;
const idx = info.index;
console.log("canvas---->", canvas)
const highlightMapContxt = canvas.getContext("2d");
if (!imgCache) {
imgCache = new OffscreenCanvas(canvas.width, canvas.height);
imgCache.getContext("2d").drawImage(canvas, 0, 0);
}
highlightMapContxt.clearRect(
0,
0,
mapScale * mapWidth,
mapScale * mapHeight
);
highlightMapContxt.drawImage(imgCache, 0, 0);
if (idx > 0) {
const path = d3
.geoPath(projection)
.context(highlightMapContxt);
highlightMapContxt.save();
highlightMapContxt.fillStyle = "#fff";
highlightMapContxt.beginPath();
path({
type: "FeatureCollection",
features: countries.features.slice(idx, idx + 1),
});
highlightMapContxt.fill();
highlightMapContxt.restore();
}
texture.index = idx;
texture.needsUpdate = true;
layer.forceUpdate();
}
globe.addEventListener("mousemove", (e) => {
const [lng, lat] = positionToLatlng(...e.hit.localPoint);
const country = getCountryInfo(lat, lng, _countries);
if (country.properties) {
console.log(country.properties.name, country.properties);
highlightMap(texture, country, _countries);
}
});
</script>
</body>
</html>
如何在地球上放置标记?
下面实现在地球的指定经纬度处放置一些标记。
1、如何计算几何体摆放位置?
先将经纬度转成球面坐标 pos,再延展到物体高度的一半,球心的坐标是 0,0
,pos 位置就是对应的三维向量,最后使用 scale 就可以直接将它移动到需要的高度。示意图如下:
2、摆放光柱
<!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>摆放光柱</title>
<style>
html,
body {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
overflow: hidden;
}
#container {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="container"></div>
<script src="http://unpkg.com/spritejs/dist/spritejs.js"></script>
<script src="http://unpkg.com/sprite-extend-3d/dist/sprite-extend-3d.js"></script>
<script src="https://lib.baomitu.com/topojson/3.0.2/topojson.min.js"></script>
<!-- <script src="https://unpkg.com/topojson@3"></script> -->
<script src="https://d3js.org/d3-array.v2.min.js"></script>
<script src="https://d3js.org/d3-geo.v2.min.js"></script>
<script type="module">
import {
vertex,
fragment,
skyVertex,
skyFragment,
beamVertx,
beamFrag
} from "./assets/js/40/shader.js";
const { Scene } = spritejs;
const { Sphere, Cylinder, shaders } = spritejs.ext3d;
import { Vec3 } from "./common/lib/math/vec3.js";
// d3 的地图投影默认宽高
const mapWidth = 960;
const mapHeight = 480;
// 将投影缩放为 4 倍,也就是将地图绘制为 3480 * 1920 大小。
const mapScale = 4;
// 创建等角方位投影
const projection = d3.geoEquirectangular();
// 通过 tanslate 将中心点调整到画布中心,默认 translate 是 480 X 250
projection
.scale(projection.scale() * mapScale)
.translate([
mapWidth * mapScale * 0.5,
(mapHeight + 2) * mapScale * 0.5,
]);
// 使用 topoJSON 数据加载地图
async function loadMap(
src = topojsonData,
{ strokeColor, fillColor } = {}
) {
const data = await (await fetch(src)).json();
const countries = topojson.feature(
data,
data.objects.countries
);
// 创建一个离屏 Canvas,用加载的数据来绘制地图到离屏 Canvas 上
const canvas = new OffscreenCanvas(
mapScale * mapWidth,
mapScale * mapHeight
);
const context = canvas.getContext("2d");
context.imageSmoothingEnabled = false;
return drawMap({ context, countries, strokeColor, fillColor });
}
let _countries;
// 绘制地图
function drawMap({
context,
countries,
strokeColor = "#666",
fillColor = "salmon",
strokeWidth = 1.5,
} = {}) {
_countries = countries;
const path = d3.geoPath(projection).context(context);
context.save();
context.strokeStyle = strokeColor;
context.lineWidth = strokeWidth;
context.fillStyle = fillColor;
context.beginPath();
path(countries);
context.fill();
context.stroke();
context.restore();
return context.canvas;
}
const container = document.getElementById("container");
// 创建场景对象
const scene = new Scene({
container,
});
// 添加 Layer,设置透视相机,视角为 35 度,位置为 0, 0, 5
const layer = scene.layer3d("fglayer", {
alpha: false,
camera: {
fov: 35,
pos: [0, 0, 5],
},
});
// 创建一个 Texture 对象,将它赋给 Program 对象
const texture = layer.createTexture({});
// 加载数据
loadMap("./data/world-topojson.json").then((map) => {
console.log(map);
texture.image = map;
texture.needsUpdate = true;
layer.forceUpdate();
});
// 创建 Program
const program = layer.createProgram({
vertex,
fragment,
texture,
cullFace: null,
});
// 创建一个球体
const globe = new Sphere(program, {
colors: "skyblue",
widthSegments: 64,
heightSegments: 32,
radius: 1,
});
layer.append(globe);
// 关闭旋转控制
layer.setOrbit({ autoRotate: false });
layer.setRaycast();
// 创建天空盒子
function createSky(layer, skyProgram) {
skyProgram =
skyProgram ||
layer.createProgram({
vertex: skyVertex,
fragment: skyFragment,
transparent: true,
cullFace: null,
});
const skyBox = new Sphere(skyProgram);
skyBox.attributes.scale = 100;
// 地球包围在天空盒子内,raycast设置为none之后,鼠标就能穿透天空包围盒到达地球
skyBox.attributes.raycast = "none";
layer.append(skyBox);
return skyBox;
}
createSky(layer);
/**
* 将球面坐标转换为平面地图坐标
* @param {*} x
* @param {*} y
* @param {*} z
* @param {*} radius
*/
function unproject(x, y, z, radius = 1) {
const pLength = Math.PI * 2;
const tLength = Math.PI;
const v = Math.acos(y / radius) / tLength; // const y = radius * Math.cos(v * tLength);
let u = Math.atan2(-z, x) + Math.PI; // z / x = -1 * Math.tan(u * pLength);
u /= pLength;
return [u * mapScale * mapWidth, v * mapScale * mapHeight];
}
// 等角方位投影函数的反函数:将平面直角坐标转换为经纬度
function positionToLatlng(x, y, z, radius = 1) {
const [u, v] = unproject(x, y, z, radius);
return projection.invert([u, v]);
}
// 通过经纬度获取国家信息
function getCountryInfo(latitude, longitude, countries) {
if (!countries) return { index: -1 };
let idx = -1;
countries.features.some((d, i) => {
const ret = d3.geoContains(d, [longitude, latitude]);
if (ret) idx = i;
return ret;
});
const info = idx >= 0 ? { ...countries.features[idx] } : {};
info.index = idx;
return info;
}
// 高亮地图
let imgCache;
function highlightMap(texture, info, countries) {
if (texture.index === info.index) return;
const canvas = texture.image;
if (!canvas) return;
const idx = info.index;
console.log("canvas---->", canvas);
const highlightMapContxt = canvas.getContext("2d");
if (!imgCache) {
imgCache = new OffscreenCanvas(canvas.width, canvas.height);
imgCache.getContext("2d").drawImage(canvas, 0, 0);
}
highlightMapContxt.clearRect(
0,
0,
mapScale * mapWidth,
mapScale * mapHeight
);
highlightMapContxt.drawImage(imgCache, 0, 0);
if (idx > 0) {
const path = d3
.geoPath(projection)
.context(highlightMapContxt);
highlightMapContxt.save();
highlightMapContxt.fillStyle = "#fff";
highlightMapContxt.beginPath();
path({
type: "FeatureCollection",
features: countries.features.slice(idx, idx + 1),
});
highlightMapContxt.fill();
highlightMapContxt.restore();
}
texture.index = idx;
texture.needsUpdate = true;
layer.forceUpdate();
}
globe.addEventListener("mousemove", (e) => {
const [lng, lat] = positionToLatlng(...e.hit.localPoint);
const country = getCountryInfo(lat, lng, _countries);
if (country.properties) {
console.log(country.properties.name, country.properties);
highlightMap(texture, country, _countries);
}
});
/**
* 将经纬度转换为球面坐标:positionToLatlng 的反向操作
* @param {*} latitude
* @param {*} longitude
* @param {*} radius
*/
function latlngToPosition(latitude, longitude, radius = 1) {
// 用 projection 函数将经纬度映射为地图上的直角坐标,然后用直角坐标转球面坐标的公式,将它转为球面坐标。
const [u, v] = projection([longitude, latitude]);
return project(u, v, radius);
}
/**
* 将平面地图坐标转换为球面坐标
* @param {*} u
* @param {*} v
* @param {*} radius
*/
function project(u, v, radius = 1) {
u /= mapScale * mapWidth;
v /= mapScale * mapHeight;
const pLength = Math.PI * 2;
const tLength = Math.PI;
const x =
-radius * Math.cos(u * pLength) * Math.sin(v * tLength);
const y = radius * Math.cos(v * tLength);
const z =
radius * Math.sin(u * pLength) * Math.sin(v * tLength);
return new Vec3(x, y, z);
}
// 放置函数
function setGlobeTarget(
globe,
target,
{ latitude, longitude, transpose = false, ...attrs }
) {
const radius = globe.attributes.radius;
if (transpose) target.transpose();
if (latitude != null && longitude != null) {
const scale =
target.attributes.scaleY * (attrs.scale || 1.0);
const height = target.attributes.height;
// 将经纬度转换为球面坐标
const pos = latlngToPosition(latitude, longitude, radius);
// 要将底部放置在地面上
pos.scale((height * 0.5 * scale) / radius + 1);
attrs.pos = pos;
}
target.attr(attrs);
const sp = new Vec3().copy(attrs.pos).scale(2);
target.lookAt(sp);
globe.append(target);
}
// 添加光柱
function addBeam(
globe,
{
latitude,
longitude,
width = 1.0,
height = 25.0,
color = "rgba(245,250,113, 0.5)",
raycast = "none",
segments = 60,
} = {}
) {
const layer = globe.layer;
const radius = globe.attributes.radius;
if (layer) {
const r = width / 2;
const scale = radius * 0.015;
const program = layer.createProgram({
transparent: true,
vertex: beamVertx,
fragment: beamFrag,
uniforms: {
uHeight: { value: height },
},
});
// 光柱本身是圆柱体,用 Cylindar 对象来绘制
const beam = new Cylinder(program, {
radiusTop: r,
radiusBottom: r,
radialSegments: segments,
height,
colors: color,
});
setGlobeTarget(globe, beam, {
transpose: true,
latitude,
longitude,
scale,
raycast,
});
return beam;
}
}
// 随机生成经纬度
function randomPos() {
return {
latitude: -90 + 180 * Math.random(),
longitude: -180 + 360 * Math.random(),
};
}
// 随机颜色
function randomColor() {
return `hsl(${Math.floor(360 * Math.random())}, 100%, 50%)`;
}
for (let i = 0; i < 100; i++) {
addBeam(globe, {
...randomPos(),
color: randomColor(),
});
}
</script>
</body>
</html>
3、摆放地标
<!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>摆放地标</title>
<style>
html,
body {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
overflow: hidden;
}
#container {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="container"></div>
<script src="http://unpkg.com/spritejs/dist/spritejs.js"></script>
<script src="http://unpkg.com/sprite-extend-3d/dist/sprite-extend-3d.js"></script>
<script src="https://lib.baomitu.com/topojson/3.0.2/topojson.min.js"></script>
<!-- <script src="https://unpkg.com/topojson@3"></script> -->
<script src="https://d3js.org/d3-array.v2.min.js"></script>
<script src="https://d3js.org/d3-geo.v2.min.js"></script>
<script type="module">
import {
vertex,
fragment,
skyVertex,
skyFragment,
beamVertx,
beamFrag,
spotVertex,
spotFragment,
markerVertex,
markerFragment
} from "./assets/js/40/shader.js";
const { Scene, Color } = spritejs;
const { Sphere, Cylinder, Geometry, shaders, Mesh3d } = spritejs.ext3d;
import { Vec3 } from "./common/lib/math/vec3.js";
// d3 的地图投影默认宽高
const mapWidth = 960;
const mapHeight = 480;
// 将投影缩放为 4 倍,也就是将地图绘制为 3480 * 1920 大小。
const mapScale = 4;
// 创建等角方位投影
const projection = d3.geoEquirectangular();
// 通过 tanslate 将中心点调整到画布中心,默认 translate 是 480 X 250
projection
.scale(projection.scale() * mapScale)
.translate([
mapWidth * mapScale * 0.5,
(mapHeight + 2) * mapScale * 0.5,
]);
// 使用 topoJSON 数据加载地图
async function loadMap(
src = topojsonData,
{ strokeColor, fillColor } = {}
) {
const data = await (await fetch(src)).json();
const countries = topojson.feature(
data,
data.objects.countries
);
// 创建一个离屏 Canvas,用加载的数据来绘制地图到离屏 Canvas 上
const canvas = new OffscreenCanvas(
mapScale * mapWidth,
mapScale * mapHeight
);
const context = canvas.getContext("2d");
context.imageSmoothingEnabled = false;
return drawMap({ context, countries, strokeColor, fillColor });
}
let _countries;
// 绘制地图
function drawMap({
context,
countries,
strokeColor = "#666",
fillColor = "salmon",
strokeWidth = 1.5,
} = {}) {
_countries = countries;
const path = d3.geoPath(projection).context(context);
context.save();
context.strokeStyle = strokeColor;
context.lineWidth = strokeWidth;
context.fillStyle = fillColor;
context.beginPath();
path(countries);
context.fill();
context.stroke();
context.restore();
return context.canvas;
}
const container = document.getElementById("container");
// 创建场景对象
const scene = new Scene({
container,
});
// 添加 Layer,设置透视相机,视角为 35 度,位置为 0, 0, 5
const layer = scene.layer3d("fglayer", {
alpha: false,
camera: {
fov: 35,
pos: [0, 0, 5],
},
});
// 创建一个 Texture 对象,将它赋给 Program 对象
const texture = layer.createTexture({});
// 加载数据
loadMap("./data/world-topojson.json").then((map) => {
console.log(map);
texture.image = map;
texture.needsUpdate = true;
layer.forceUpdate();
});
// 创建 Program
const program = layer.createProgram({
vertex,
fragment,
texture,
cullFace: null,
});
// 创建一个球体
const globe = new Sphere(program, {
colors: "skyblue",
widthSegments: 64,
heightSegments: 32,
radius: 1,
});
layer.append(globe);
// 关闭旋转控制
layer.setOrbit({ autoRotate: false });
layer.setRaycast();
// 创建天空盒子
function createSky(layer, skyProgram) {
skyProgram =
skyProgram ||
layer.createProgram({
vertex: skyVertex,
fragment: skyFragment,
transparent: true,
cullFace: null,
});
const skyBox = new Sphere(skyProgram);
skyBox.attributes.scale = 100;
// 地球包围在天空盒子内,raycast设置为none之后,鼠标就能穿透天空包围盒到达地球
skyBox.attributes.raycast = "none";
layer.append(skyBox);
return skyBox;
}
createSky(layer);
/**
* 将球面坐标转换为平面地图坐标
* @param {*} x
* @param {*} y
* @param {*} z
* @param {*} radius
*/
function unproject(x, y, z, radius = 1) {
const pLength = Math.PI * 2;
const tLength = Math.PI;
const v = Math.acos(y / radius) / tLength; // const y = radius * Math.cos(v * tLength);
let u = Math.atan2(-z, x) + Math.PI; // z / x = -1 * Math.tan(u * pLength);
u /= pLength;
return [u * mapScale * mapWidth, v * mapScale * mapHeight];
}
// 等角方位投影函数的反函数:将平面直角坐标转换为经纬度
function positionToLatlng(x, y, z, radius = 1) {
const [u, v] = unproject(x, y, z, radius);
return projection.invert([u, v]);
}
// 通过经纬度获取国家信息
function getCountryInfo(latitude, longitude, countries) {
if (!countries) return { index: -1 };
let idx = -1;
countries.features.some((d, i) => {
const ret = d3.geoContains(d, [longitude, latitude]);
if (ret) idx = i;
return ret;
});
const info = idx >= 0 ? { ...countries.features[idx] } : {};
info.index = idx;
return info;
}
// 高亮地图
let imgCache;
function highlightMap(texture, info, countries) {
if (texture.index === info.index) return;
const canvas = texture.image;
if (!canvas) return;
const idx = info.index;
console.log("canvas---->", canvas);
const highlightMapContxt = canvas.getContext("2d");
if (!imgCache) {
imgCache = new OffscreenCanvas(canvas.width, canvas.height);
imgCache.getContext("2d").drawImage(canvas, 0, 0);
}
highlightMapContxt.clearRect(
0,
0,
mapScale * mapWidth,
mapScale * mapHeight
);
highlightMapContxt.drawImage(imgCache, 0, 0);
if (idx > 0) {
const path = d3
.geoPath(projection)
.context(highlightMapContxt);
highlightMapContxt.save();
highlightMapContxt.fillStyle = "#fff";
highlightMapContxt.beginPath();
path({
type: "FeatureCollection",
features: countries.features.slice(idx, idx + 1),
});
highlightMapContxt.fill();
highlightMapContxt.restore();
}
texture.index = idx;
texture.needsUpdate = true;
layer.forceUpdate();
}
globe.addEventListener("mousemove", (e) => {
const [lng, lat] = positionToLatlng(...e.hit.localPoint);
const country = getCountryInfo(lat, lng, _countries);
if (country.properties) {
console.log(country.properties.name, country.properties);
highlightMap(texture, country, _countries);
}
});
/**
* 将经纬度转换为球面坐标:positionToLatlng 的反向操作
* @param {*} latitude
* @param {*} longitude
* @param {*} radius
*/
function latlngToPosition(latitude, longitude, radius = 1) {
// 用 projection 函数将经纬度映射为地图上的直角坐标,然后用直角坐标转球面坐标的公式,将它转为球面坐标。
const [u, v] = projection([longitude, latitude]);
return project(u, v, radius);
}
/**
* 将平面地图坐标转换为球面坐标
* @param {*} u
* @param {*} v
* @param {*} radius
*/
function project(u, v, radius = 1) {
u /= mapScale * mapWidth;
v /= mapScale * mapHeight;
const pLength = Math.PI * 2;
const tLength = Math.PI;
const x =
-radius * Math.cos(u * pLength) * Math.sin(v * tLength);
const y = radius * Math.cos(v * tLength);
const z =
radius * Math.sin(u * pLength) * Math.sin(v * tLength);
return new Vec3(x, y, z);
}
// 放置函数
function setGlobeTarget(
globe,
target,
{ latitude, longitude, transpose = false, ...attrs }
) {
const radius = globe.attributes.radius;
if (transpose) target.transpose();
if (latitude != null && longitude != null) {
const scale =
target.attributes.scaleY * (attrs.scale || 1.0);
const height = target.attributes.height;
// 将经纬度转换为球面坐标
const pos = latlngToPosition(latitude, longitude, radius);
// 要将底部放置在地面上
pos.scale((height * 0.5 * scale) / radius + 1);
attrs.pos = pos;
}
target.attr(attrs);
const sp = new Vec3().copy(attrs.pos).scale(2);
target.lookAt(sp);
globe.append(target);
}
// 添加光柱
function addBeam(
globe,
{
latitude,
longitude,
width = 1.0,
height = 25.0,
color = "rgba(245,250,113, 0.5)",
raycast = "none",
segments = 60,
} = {}
) {
const layer = globe.layer;
const radius = globe.attributes.radius;
if (layer) {
const r = width / 2;
const scale = radius * 0.015;
const program = layer.createProgram({
transparent: true,
vertex: beamVertx,
fragment: beamFrag,
uniforms: {
uHeight: { value: height },
},
});
// 光柱本身是圆柱体,用 Cylindar 对象来绘制
const beam = new Cylinder(program, {
radiusTop: r,
radiusBottom: r,
radialSegments: segments,
height,
colors: color,
});
setGlobeTarget(globe, beam, {
transpose: true,
latitude,
longitude,
scale,
raycast,
});
return beam;
}
}
// 随机生成经纬度
function randomPos() {
return {
latitude: -90 + 180 * Math.random(),
longitude: -180 + 360 * Math.random(),
};
}
// 随机颜色
function randomColor() {
return `hsl(${Math.floor(360 * Math.random())}, 100%, 50%)`;
}
// 生成 spot 的顶点
function makeSpotVerts(radis = 1.0, n_segments) {
const vertex = [];
for (let i = 0; i <= n_segments; i++) {
const theta = (Math.PI * 2 * i) / n_segments;
const x = radis * Math.cos(theta);
const y = radis * Math.sin(theta);
vertex.push(x, y, 1, 0, x, y, 1, 1.0);
}
return {
position: { data: vertex, size: 4 },
};
}
// 生成 marker 的顶点
function makeMarkerVerts(radis = 1.0, n_segments) {
const vertex = [];
for (let i = 0; i <= n_segments; i++) {
const theta = (Math.PI * 2 * i) / n_segments;
const x = radis * Math.cos(theta);
const y = radis * Math.sin(theta);
vertex.push(x, y, 1, 0, x, y, 1, 1.0);
}
const copied = [...vertex];
vertex.push(
...copied.map((v, i) => {
return i % 4 === 2 ? 0.33 : v;
})
);
vertex.push(
...copied.map((v, i) => {
return i % 4 === 2 ? 0.67 : v;
})
);
return {
position: { data: vertex, size: 4 },
};
}
// 初始化函数,用来生成 spot 和 marker 对应的 WebGLProgram
function initMarker(
layer,
globe,
{ width, height, speed, color, segments }
) {
const markerProgram = layer.createProgram({
transparent: true,
vertex: markerVertex,
fragment: markerFragment,
uniforms: {
uTime: { value: 0 },
uColor: { value: new Color(color).slice(0, 3) },
uWidth: { value: width },
uSpeed: { value: speed },
uHeight: { value: height },
},
});
const markerGeometry = new Geometry(
layer.gl,
makeMarkerVerts(globe.attributes.radius, segments)
);
const spotProgram = layer.createProgram({
transparent: true,
vertex: spotVertex,
fragment: spotFragment,
uniforms: {
uTime: { value: 0 },
uColor: { value: new Color(color).slice(0, 3) },
uWidth: { value: width },
uSpeed: { value: speed },
uHeight: { value: height },
},
});
const spotGeometry = new Geometry(
layer.gl,
makeSpotVerts(globe.attributes.radius, segments)
);
return {
program: markerProgram,
geometry: markerGeometry,
spotGeometry,
spotProgram,
mode: "TRIANGLE_STRIP",
};
}
// 添加地标
function addMarker(
globe,
{
latitude,
longitude,
width = 1.0,
height = 0.0,
speed = 1.0,
color = "rgb(245,250,113)",
segments = 60,
lifeTime = Infinity,
} = {}
) {
const layer = globe.layer;
const radius = globe.attributes.radius;
if (layer) {
let mode = "TRIANGLES";
const ret = initMarker(layer, globe, {
width,
height,
speed,
color,
segments,
});
const markerProgram = ret.program;
const markerGeometry = ret.geometry;
const spotProgram = ret.spotProgram;
const spotGeometry = ret.spotGeometry;
mode = ret.mode;
if (markerProgram) {
const pos = latlngToPosition(
latitude,
longitude,
radius
);
const marker = new Mesh3d(markerProgram, {
model: markerGeometry,
mode,
});
const spot = new Mesh3d(spotProgram, {
model: spotGeometry,
mode,
});
setGlobeTarget(globe, marker, {
pos,
scale: 0.05,
raycast: "none",
});
setGlobeTarget(globe, spot, {
pos,
scale: 0.05,
raycast: "none",
});
layer.bindTime(marker.program);
if (Number.isFinite(lifeTime)) {
setTimeout(() => {
layer.unbindTime(marker.program);
marker.dispose();
spot.dispose();
marker.program.remove();
spot.program.remove();
}, lifeTime);
}
return { marker, spot };
}
}
}
for(let i = 0; i < 100; i++) {
addBeam(globe, {
...randomPos(),
color: randomColor(),
});
addMarker(globe, {
latitude: 90 - Math.random() * 180,
longitude: 180 - Math.random() * 360,
width: 1.0,
height: 0.0,
speed: 1.0,
color: randomColor(),
segments: 60,
lifeTime: Infinity,
});
}
</script>
</body>
</html>