说明

【跟月影学可视化】学习笔记。

如何实现一个 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, 5const layer = scene.layer3d("fglayer", {alpha: false,camera: {fov: 35,pos: [0, 0, 5],},});// shaders.GEOMETRY 是一个符合 Phong 反射模型的几何体 Shaderconst 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, 5const 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();});// 创建 Programconst 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 250projection.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, 5const 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();});// 创建 Programconst 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 250projection.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, 5const 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();});// 创建 Programconst 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 250projection.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, 5const 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();});// 创建 Programconst 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 250projection.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, 5const 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();});// 创建 Programconst 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 250projection.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, 5const 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();});// 创建 Programconst 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 对应的 WebGLProgramfunction 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>

【实战篇】40 # 如何实现3D地球可视化?相关推荐

  1. [知识图谱实战篇] 三.Python提取JSON数据、HTML+D3构建基本可视化布局

    前面作者讲解了很多知识图谱原理知识,包括知识图谱相关技术.Neo4j绘制关系图谱等,但仍缺少一个系统全面的实例.为了加深自己对知识图谱构建的认识,为后续创建贵州旅游知识图谱打下基础,作者深入学习了张宏 ...

  2. [知识图谱实战篇] 二.Json+Seaborn可视化展示电影实体

    前面作者讲解了很多知识图谱原理知识,包括知识图谱相关技术.Neo4j绘制关系图谱等,但仍缺少一个系统全面的实例.为了加深自己对知识图谱构建的认识,为后续创建贵州旅游知识图谱打下基础,作者深入学习了张宏 ...

  3. python数据分析及可视化(十五)数据分析可视化实战篇(抖音用户数据分析、二手房数据分析)

    python数据分析的实战篇,围绕实例的数据展开分析,通过数据操作案例来了解数据分析中的频繁用到的知识内容. 抖音用户数据分析 1.理解数据 数据字段含义 了解数据内容,确保数据来源是正常的,安全合法 ...

  4. [知识图谱实战篇] 六.HTML+D3实现点击节点显示相关属性及属性值

    前面作者讲解了很多知识图谱原理知识,包括知识图谱相关技术.Neo4j绘制关系图谱等,但仍缺少一个系统全面的实例.为了加深自己对知识图谱构建的认识,为后续创建贵州旅游知识图谱打下基础,作者深入学习了张宏 ...

  5. selenium之 chromedriver与chrome版本映射表_NLP实战篇之tf2训练与评估

    本文是基于tensorflow2.2.0版本,介绍了模型的训练与评估.主要介绍了tf.keras的内置训练过程,包括compile.fit,其中compile中包含优化器.loss与metrics的使 ...

  6. 从零开始学架构5 - 实战篇

    从零开始学架构5 - 实战篇 38 | 架构师应该如何判断技术演进的方向? 潮流派? 保守派? 跟风派? 技术演进的动力 1)对于产品类业务,答案看起来很明显:技术创新推动业务发展! 苹果开发智能手机 ...

  7. PaddleHub实战篇{ERNIE实现文新闻本分类、ERNIE3.0 实现序列标注}【四】

     相关文章: 基础知识介绍: [一]ERNIE:飞桨开源开发套件,入门学习,看看行业顶尖持续学习语义理解框架,如何取得世界多个实战的SOTA效果?_汀.的博客-CSDN博客_ernie模型 百度飞桨: ...

  8. threejs 绘制球体_实战:用 threejs 创建一个地球

    原标题:实战:用 threejs 创建一个地球 提示: 讲座 前端大型免费公开课讲座 教程 从零基础学前端教程,都在这~ 上个月底,在朋友圈看到一个号称"这可能是地球上最美的h5" ...

  9. 使用matplotlib python数据可视化系列创建3d视频可视化

    用MATPLOTLIB设计 (DESIGNING WITH MATPLOTLIB) Hi, we will give you an excellent technique to create 3D v ...

最新文章

  1. ado.net 修改,查询
  2. ptp driver Linux doc
  3. java float 四舍五入_JAVA如何把一个float四舍五入到小数点后指定位数.
  4. DOM(二)——XML DOM
  5. 2021级C语言大作业 - 霓虹深渊
  6. 最佳实践之Android代码规范
  7. linux fdisk 4k,linux查看硬盘4K对齐方法
  8. Java To CSharp源代码转换
  9. 人机大战简史(第二版)
  10. java wsimport https,wsimport使用小结二
  11. Nginx网络压缩 CSS压缩 图片压缩 JSON压缩
  12. 同时删除多个 Word 文档空白行
  13. 人员疏散模型(pso元胞自动机)网挑思路
  14. 医学图像的 有损压缩 以及可接受的 压缩比
  15. 领导最不赏识这5类下属
  16. ui界面颜色设计_界面设计ui的颜色基础
  17. SpringBoot、EasyPoi、Echarts 实现文档导入、出、图表显示 (饼状图、柱状图) 保姆级教程
  18. 【数据分析】滴滴数据分析岗实习经验
  19. 经典神经网络模型整理
  20. 【历史上的今天】10 月 29 日:互联网的正式诞生;MariaDB 发布首个版本;“天河一号”研制成功

热门文章

  1. Java实时报表统计查询慢_如何解决报表关联计算中的性能问题
  2. uboot drm框架
  3. 京东大客户开放平台VOP接口对接记录
  4. 利器 | Java 接口自动化测试首选方案:REST Assured 实践 (一)
  5. nand读寿命_Nand Flash的擦写次数与使用寿命
  6. # 2021-01-13 #「FVWM」- 配置命令章节列表
  7. C语言实现单链表头插法
  8. 如何利用Simulink来设计一个AEB的算法,并通过SCANeR仿真来测试?
  9. 网站中的新老访客怎么定义,有何区别?
  10. 计算机教 学计划,计算机教学计划