可视化练习

# 可视化练习

# 使用 D3.js 绘图

# 实现疫情地图

假设,我们要使用世界地图的可视化,来呈现不同国家和地区,从 2020 年 1 月 22 日到 3 月 19 日这些天的新冠肺炎疫情进展。主要有四个步骤,分别是准备数据、绘制地图、整合数据和更新绘制方法。

# 步骤一:准备数据

新冠肺炎的官方数据在 WHO (opens new window) 网站上每天都会更新,我们可以直接找到 2020 年 1 月 22 日到 3 月 19 日的数据,将这些数据收集和整理成一份 JSON 文件。这份 JSON 文件也可以在这里 (opens new window)找到。

有了 JSON 数据之后,我们就可以将这个数据和世界地图上的国家一一对应。那接下来的任务就是准备世界地图,想要绘制一份世界地图,我们也需要有世界地图的地理数据,这也是一份 JSON 文件。

地理数据通常可以从开源社区中获取公开数据,或者从相应国家的测绘部门获取当地的公开数据。这次用到的世界地图的数据是通过开源社区获得的。

# GeoJSON 数据和 TopoJSON 数据

一般来说,地图的 JSON 文件有两种数据格式,一种是 GeoJSON,另一种是 TopoJSON。其中 GeoJSON 是基础格式,它包含了描述地图地理信息的坐标数据。

比如下面的代码就是一个合法的 GeoJSON 数据,它定义了一个地图上的多边形区域,坐标是由四个包含了经纬度的点组成的(代码中一共是五个点,但是首尾两个点是重合的)。

{
  "type":"FeatureCollection", 
  "features": [
    {
      "type":"Feature",
      "geometry":{
        "type":"Polygon",
        "coordinates":
          [
            [[117.42218831167838,31.68971206252246],
            [118.8025942451759,31.685801564127132],
            [118.79961418869482,30.633841626314336],
            [117.41920825519742,30.637752124709664],
            [117.42218831167838,31.68971206252246]]
          ]
      },
      "properties":{"Id":0}
    }
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

而 TopoJSON 格式就是 GeoJSON 格式经过压缩之后得到的,它通过对坐标建立索引来减少冗余,数据压缩能够大大减少 JSON 文件的体积。

这里有两份世界地图的 JSON 数据,一份 GeoJSON 数据 (opens new window),一份是 TopoJSON 数据 (opens new window)

# 步骤二:绘制地图

将数据绘制成地图的方法有很多种,我们既可以用 Canvas2D、也可以用 SVG,还可以用 WebGL。除了用 WebGL 相对复杂,用 Canvas2D 和 SVG 都比较简单。这里选择用比较简单的 Canvas2D 来绘制地图。

# 墨卡托投影

首先,我们将 GeoJSON 数据中,coordinates 属性里的经纬度信息转换成画布坐标,这个转换被称为地图投影。实际上,地图有很多种投影方式,但最简单的方式是墨卡托投影 (opens new window),也叫做等圆柱投影

它的实现思路就是把地球从南北两极往外扩,先变成一个圆柱体,再将世界地图看作是贴在圆柱侧面的曲面,经纬度作为 x、y 坐标。最后,我们再把圆柱侧面展开,经纬度自然就被投影到平面上了

墨卡托投影是最常用的投影方式,因为它的坐标转换非常简单,而且经过墨卡托投影之后的地图中,国家和地区的形状与真实的形状仍然保持一致。但它也有缺点,由于是从两极往外扩,因此高纬度国家的面积看起来比实际的面积要大,并且维度越高面积偏离的幅度越大M

在地图投影之前,需要先明确下经纬度的基本概念。

经度的范围是 -180 度到 180 度,负数代表西经,正数代表东经。纬度的范围是 -90 度到 90 度,负数代表南纬,正数代表北纬。

接下来,我们就可以将经纬度按照墨卡托投影的方式转换为画布上的 x、y 坐标。对应的经纬度投影如下图所示。

注意,精度范围是 360 度,而纬度范围是 180 度,而且因为 y 轴向下,所以计算 y 需要用 1.0 减一下

有了图中的换算公式,我们就可以将它封装成投影函数,代码如下:

// 将 geojson 数据用墨卡托投影方式投影到 1024*512 宽高的 canvas 上
const width = 1024;
const height = 512;

// longitude 是经度,latitude 是纬度,width 是 Canvas 的宽度,height 是 Canvas 的高度
function projection([longitude, latitude]) {
  const x = width * (180 + longitude) / 360;
  const y = height * (1.0 - (90 + latitude) / 180); // Canvas 坐标系 y 轴朝下
  return [x, y];
}
1
2
3
4
5
6
7
8
9
10

有了投影函数之后,我们就可以读取和遍历 GeoJSON 数据,然后绘制地图了。

完整的代码在这里 (opens new window),到这一步的绘制效果可以看这里 (opens new window)

# 转换 TopoJSON 数据

不过,GeoJSON 数据通常比较大,如果我们直接在 Web 应用中使用,有些浪费带宽,也可能会导致网络加载延迟,所以,使用 TopoJSON 数据是一个更好的选择。

比如,同样的世界地图数据,GeoJSON 格式数据有 251KB,而经过了压缩的 TopoJSON 数据只有 84KB,体积约为原来的 1/3。

尽管体积比 GeoJSON 数据小了不少,但是 TopoJSON 数据经过了复杂的压缩之后,我们在使用的时候还需要对它解压,把它变成 GeoJSON 数据。可是,如果我们自己写代码去解压,实现起来比较复杂。好在,我们可以采用现成的工具对它进行解压。这里,我们可以使用 GitHub 上的TopoJSON 官方仓库 (opens new window) 的 JavaScript 模块来处理 TopoJSON 数据。

这个转换简单到只用一行代码就可以完成,转换完成之后,我们就可以用同样的方法将世界地图绘制出来了。使用 TopoJSON 数据绘制地图的完整代码在这里 (opens new window),效果可以看这里 (opens new window)。转换的代码如下:

const countries = topojson.feature(worldData, worldData.objects.countries);
1

# 步骤三:整合数据

有了疫情数据和世界地图之后,下一步就是将疫情的 JSON 数据整合进地图数据里面。

在 GeoJSON 或者 TopoJSON 解压后的 countries 数据中,除了用 geometries 字段保存地图的地区信息外,还用 properties 字段来保存了其他的属性。在我们这一份地图数据中,properties 只有一个 name 属性,对应着不同国家的名字。

打开 TopoJSON 文件 (opens new window) 就可以看到在 contries.geometries 下的 properties 属性中有一个 name 属性,对应国家的名字。

然后,再打开 疫情的 JSON 数据 (opens new window),就会发现疫情数据中的 contry 属性和 GeoJSON 数据里面的国家名称是一一对应的。

利用这点我们就可以建立一个数据映射关系,将疫情数据中的每个国家的疫情数据直接写入到 GeoJSON 数据的 properties 字段里面。

下面是这个数据映射函数,在这个函数里,我们先将疫情数据的数组转换成以国家名为 key 的 Map,然后将它写入到 TopoJSON 读取出的 Geo 数据对象里。

function mapDataToCountries(geoData, convidData) {
  const convidDataMap = {};
  convidData.dailyReports.forEach((d) => {
    const date = d.updatedDate;
    const countries = d.countries;
    countries.forEach((country) => {
      const name = country.country;
      convidDataMap[name] = convidDataMap[name] || {};
      convidDataMap[name][date] = country;
    });
  });
  geoData.features.forEach((d) => {
    const name = d.properties.name;
    d.properties.convid = convidDataMap[name];
  });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

最后,我们直接读取两个 JSON 数据,调用这个数据映射函数就完成了数据整合。

const worldData = await (await fetch('./assets/data/world-topojson.json')).json();
const countries = topojson.feature(worldData, worldData.objects.countries);

const convidData = await (await fetch('./assets/data/convid-data.json')).json();
mapDataToCountries(countries, convidData);
1
2
3
4
5

整合后的数据格式如下:


{
  "objects": {
  "countries": {
    "type": "GeometryCollection",
    "geometries": [{
      "arcs": [
        [0, 1, 2, 3, 4, 5]
      ],
      "type": "Polygon",
      "properties": {
        "name": "Afghanistan",
        "convid": {
          "2020-01-22": {
            "confirmed": 1,
            "recovered": 0,
            "death": 0,
          },
          "2020-01-23": {
            // ...
          },
          // ...
        }
      }
    },
  // ...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

# 步骤四:将数据与地图结合

最后一步就是将数据与地图结合。

我们可以用不同的颜色来表示疫情的严重程度,填充地图,确诊人数越多的区域颜色越红。要实现这个效果,我们先要创建一个确诊人数和颜色的映射函数。

首先把无人感染到感染人数超过 10000 人划分了 7 个等级,每个等级用不同的颜色表示:

function mapColor(confirmed) {
  if(!confirmed) {
    return '#3ac';
  }
  if(confirmed < 10) {
    return 'rgb(250, 247, 171)';
  }
  if(confirmed < 100) {
    return 'rgb(255, 186, 66)';
  }
  if(confirmed < 500) {
    return 'rgb(234, 110, 41)';
  }
  if(confirmed < 1000) {
    return 'rgb(224, 81, 57)';
  }
  if(confirmed < 10000) {
    return 'rgb(192, 50, 39)';
  }
  return 'rgb(151, 32, 19)';
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

然后,我们在绘制地图的代码里根据确诊人数设置 Canvas 的填充信息:


function drawMap(ctx, countries, date) {
  date = formatDate(date); // 转换日期格式
  countries.features.forEach(({geometry, properties}) => {
    // ... 读取当前日期下的确诊人数
    ctx.fillStyle = mapColor(confirmed); // 映射成地图颜色并设置到 Canvas 上下文
    //... 执行绘制
  });

1
2
3
4
5
6
7
8
9

此时,就可以绘制出在某一天静态的世界疫情地图了。完整代码看这里 (opens new window),效果可以看这里 (opens new window)

此外,由于疫情的数据每天都会更新,如果想让疫情地图随着日期自动更新,我们还可以给地图绘制过程加上一个定时器,这样我们就能得到一个动态的疫情地图了,它会自动显示从 1 月 22 日到当前日期疫情变化。这样,我们就能看到疫情随时间的变化了。最终的完整代码在这里 (opens new window),效果可以看这里 (opens new window)

const startDate = new Date('2020/01/22');
let i = 0;
const timer = setInterval(() => {
  const date = new Date(startDate.getTime() + 86400000 * (++i));
  drawMap(ctx, countries, date);
  if(date.getTime() + 86400000 > Date.now()) {
    clearInterval(timer);
  }
}, 100);
drawMap(ctx, countries, startDate);
1
2
3
4
5
6
7
8
9
10

# 实现 3D 地球可视化

3D 地球可视化主要是以 3D 的方式呈现整个地球的模型,视觉上看起来更炫酷。它是可视化应用里常见的一种形式,通常用来实现全球地理信息相关的可视化应用,例如全球黑客攻防示意图、全球航班信息示意图以及全球贸易活动示意图等等。

# 步骤一:实现一个 3D 地球

第一步,我们自然是要实现一个旋转的地球。

# 1. 绘制一个 3D 球体

首先,创建场景对象,添加 Layer,与 2D 的 Layer 不同,SpriteJS 的 3D 扩展创建的 Layer 需要设置相机。这里,我们设置了一个透视相机,视角为 35 度,位置为 0, 0, 5。

const {Scene} = spritejs;
const container = document.getElementById('container');
const scene = new Scene({
  container,
});
const layer = scene.layer3d('fglayer', {
  alpha: false,
  camera: {
    fov: 35,
    pos: [0, 0, 5],
  },
});
1
2
3
4
5
6
7
8
9
10
11
12

接着是创建 WebGL 的 Program,我们通过 Layer 对象的 createProgram 来创建。SpriteJS 的 3D 扩展内置了一些常用的 Shader,比如 shaders.GEOMETRY 就是一个符合 Phong 反射模型的几何体 Shader。

const {Sphere, shaders} = spritejs.ext3d;


const program = layer.createProgram({
  ...shaders.GEOMETRY,
  cullFace: null,
});
1
2
3
4
5
6
7

接着,我们创建一个球体,它在 SpriteJS 的 3D 扩展中对应 Sphere 对象,并且给球体设置颜色、宽度、高度和半径这些默认的属性,然后将它添加到 layer 上,这样我们就能在画布上将这个球体显示出来了。

const globe = new Sphere(program, {
  colors: '#333',
  widthSegments: 64,
  heightSegments: 32,
  radius: 1,
});


layer.append(globe);
1
2
3
4
5
6
7
8
9

不过,现在我们只在画布上显示了一个灰色的球体,它和我们要实现的地球还相差甚远。

# 2. 绘制地图

接下来,我们要先绘制一张平面地图,然后把它以纹理的方式添加到我们创建的 3D 球体上。

不过,与平面地图采用墨卡托投影不同,作为纹理的球面地图需要采用等角方位投影(Equirectangular Projection)。d3-geo (opens new window) 模块中同样支持这种投影方式,我们可以直接加载 d3-geo 模块,然后使用对应的代码来创建投影。

下面的代码首先通过 d3.geoEquirectangular 方法来创建等角方位投影,再将它进行缩放。d3 的地图投影默认宽高为 960 * 480,我们将投影缩放为 4 倍,也就是将地图绘制为 3480 * 1920 大小。这样一来,它就能在大屏上显示得更清晰。

然后,我们通过 tanslate 将中心点调整到画布中心,因为 JSON 的地图数据的 0,0 点在画布正中心。我们在 Y 方向上多调整一个像素,这是因为原始数据坐标有一点偏差。

const mapWidth = 960;
const mapHeight = 480;
const mapScale = 4;
    
const projection = d3.geoEquirectangular();
projection.scale(projection.scale() * mapScale).translate([mapWidth * mapScale * 0.5, (mapHeight + 2) * mapScale * 0.5]);
1
2
3
4
5
6

创建好投影之后,就可以开始绘制地图了。我们从 topoJSON 数据加载地图,并且创建了一个离屏 Canvas,用加载的数据来绘制地图到离屏 Canvas 上。

async function loadMap(src = topojsonData, {strokeColor, fillColor} = {}) {
  const data = await (await fetch(src)).json();
  const countries = topojson.feature(data, data.objects.countries);
  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 = '#000',
  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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

这样,我们就完成了地图加载和绘制的逻辑。不过,我们现在还看不到地图,因为我们只是将它绘制到了一个离屏的 Canvas 对象上,并没有将这个对象显示出来。

# 3. 将地图作为纹理

要显示地图为 3D 地球,我们需要将刚刚绘制的地图作为纹理添加到之前绘制的球体上。

之前我们绘制球体时,使用的是 SpriteJS 中默认的 shader,它是符合 Phong 光照模型的几何材质的。因为考虑到地球有特殊光照,我们现在自己实现一组自定义的 shader。

const vertex = `
  precision highp float;
  precision highp int;

  attribute vec3 position;
  attribute vec3 normal;
  attribute vec4 color;
  attribute vec2 uv;

  uniform mat4 modelViewMatrix;
  uniform mat4 projectionMatrix;
  uniform mat3 normalMatrix;

  varying vec3 vNormal;
  varying vec2 vUv;
  varying vec4 vColor;

  uniform vec3 pointLightPosition; //点光源位置

  void main() {
    vNormal = normalize(normalMatrix * normal);

    vUv = uv;
    vColor = color;

    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }    
`;

const fragment = `
  precision highp float;
  precision highp int;

  varying vec3 vNormal;
  varying vec4 vColor;

  uniform sampler2D tMap;
  varying vec2 vUv;

  uniform vec2 uResolution;

  void main() {
    vec4 color = vColor;
    vec4 texColor = texture2D(tMap, vUv);
    vec2 st = gl_FragCoord.xy / uResolution;

    float alpha = texColor.a;
    color.rgb = mix(color.rgb, texColor.rgb, alpha);
    color.rgb = mix(texColor.rgb, color.rgb, clamp(color.a / max(0.0001, texColor.a), 0.0, 1.0));
    color.a = texColor.a + (1.0 - texColor.a) * color.a;

    float d = distance(st, vec2(0.5));

    gl_FragColor.rgb = color.rgb + 0.3 * pow((1.0 - d), 3.0);
    gl_FragColor.a = color.a;
  } 
`;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57

接着,我们创建一个 Texture 对象,将它赋给 Program 对象。

const texture = layer.createTexture({});

const program = layer.createProgram({
  vertex,
  fragment,
  texture,
  cullFace: null,
});
1
2
3
4
5
6
7
8

现在,画布上就显示出了一个中心有些亮光的球体。

这里我们还是看不出地球的样子。这是因为我们给的 texture 对象是一个空的纹理对象。接下来,我们只要执行 loadMap 方法,将地图加载出来,再添加给这个空的纹理对象,然后刷新画布就可以了。

loadMap().then((map) => {
  texture.image = map;
  texture.needsUpdate = true;
  layer.forceUpdate();
});
1
2
3
4
5

现在,就显示出地球的样子了。

接着给地球添加轨迹球控制,并让它自动旋转。

layer.setOrbit({autoRotate: true}); // 开启旋转控制
1

这样我们就得到一个自动旋转的地球效果了。

# 步骤二:实现星空背景

要实现星空的效果,第一步是要创建一个天空包围盒。天空包围盒也是一个球体(Sphere)对象,只不过它要比地球大很多,以此让摄像机处于整个球体内部。为了显示群星,天空包围盒有自己特殊的 Shader。

const skyVertex = `
  precision highp float;
  precision highp int;


  attribute vec3 position;
  attribute vec3 normal;
  attribute vec2 uv;


  uniform mat3 normalMatrix;
  uniform mat4 modelViewMatrix;
  uniform mat4 projectionMatrix;


  varying vec2 vUv;


  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;


const skyFragment = `
  precision highp float;
  precision highp int;
  varying vec2 vUv;


  highp float random(vec2 co)
  {
    highp float a = 12.9898;
    highp float b = 78.233;
    highp float c = 43758.5453;
    highp float dt= dot(co.xy ,vec2(a,b));
    highp float sn= mod(dt,3.14);
    return fract(sin(sn) * c);
  }


  // Value Noise by Inigo Quilez - iq/2013
  // https://www.shadertoy.com/view/lsf3WH
  highp float noise(vec2 st) {
    vec2 i = floor(st);
    vec2 f = fract(st);
    vec2 u = f * f * (3.0 - 2.0 * f);
    return mix( mix( random( i + vec2(0.0,0.0) ),
                    random( i + vec2(1.0,0.0) ), u.x),
                mix( random( i + vec2(0.0,1.0) ),
                    random( i + vec2(1.0,1.0) ), u.x), u.y);
  }


  void main() {
    gl_FragColor.rgb = vec3(1.0);
    gl_FragColor.a = step(0.93, noise(vUv * 6000.0));
  }
`
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
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);
1
2
3
4
5
6
7
8
9
10
11
12
13
14

实际上它是使用二维噪声的技巧来实现的,前面我们使用它来模拟水滴滚过的效果。但在这里,我们通过 step 函数和 vUv 的缩放,将它缩小之后,最终呈现出来星空效果。

# 步骤三:添加交互效果

# 如何选中地球上的地理位置

为什么我们在绘制 3D 地球的时候,要大费周章地使用 topoJSON 数据,而不是直接用一个现成的等角方位投影的世界地图图片作为球体的纹理。

这是因为,我们想让地球能够和我们的鼠标进行交互,比如当点击到地图上的中国区域的时候,我们想让中国显示高亮,这是纹理图片无法实现的。

# 实现坐标转换

实现交互效果的难点在于坐标转换。因为鼠标指向地球上的某个区域的时候,我们通过 SpriteJS 拿到的是当前鼠标在点击的地球区域的一个三维坐标,而这个坐标是不能直接判断点中的区域属于哪个国家的,我们需要将它转换成二维的地图经纬度坐标,才能通过地图数据来获取到当前经纬度下的国家或地区信息。

首先,我们的鼠标在地球上移动的时候,通过 SpriteJS,我们拿到三维的球面坐标。

layer.setRaycast();

globe.addEventListener('mousemove', (e) => {
  console.log(e.hit.localPoint);
});
// ...
skyBox.attributes.raycast = 'none';
1
2
3
4
5
6
7

注意,这里将天空包围盒的 raycast 设置成了 none,是因为地球包围在天空盒子内,这样设置之后,鼠标就能穿透天空包围盒到达地球,如果不这么做,天空盒子就会遮挡住鼠标事件,地球也就捕获不到事件了。这样一来,当鼠标移动到地球上时,我们就可以得到相应的三维坐标信息了。

接下来,我们要将三维坐标信息转换为经纬度信息,那第一步就是将三维坐标转换为二维平面坐标。

/**
 * 将球面坐标转换为平面地图坐标
 * @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];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

这个球面和平面坐标转换,实际上就是将空间坐标系从球坐标系转换为平面直接坐标系。具体的转换方法是,我们先将球坐标系转为圆柱坐标系,再将圆柱坐标系转为平面直角坐标系。具体的公式推导过程比较复杂,我们没必要深入理解,如果对推导过程感兴趣,也可以阅读这篇文章 (opens new window)

拿到了二维平面直角坐标之后,我们可以直接用等角方位投影函数的反函数将这个平面直角坐标转换为经纬度。

function positionToLatlng(x, y, z, radius = 1) {
  const [u, v] = unproject(x, y, z, radius);
  return projection.invert([u, v]);
}
1
2
3
4

接着,我们实现一个通过经纬度拿到国家信息的函数。这里,我们直接通过 d3.geoContains 方法,从 countries 数据中拿到对应的国家信息。

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;
}
1
2
3
4
5
6
7
8
9
10
11
12

这样一来,我们只要修改 mousemove 方法,就可以知道我们的鼠标移动在哪个国家之上了。

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);
  }
});
1
2
3
4
5
6
7

# 高亮国家地区

要高亮对应的国家或地区,我们先把原始的非高亮的图片另存一份,然后根据选中国家的 index 信息,从 contries 原始数据中取出对应的那个国家,用不同的填充色 fillStyle 再绘制一次,最后更新 texture 和 layer,就可以将高亮区域绘制出来了

function highlightMap(texture, info, countries) {
  if(texture.index === info.index) return;
  const canvas = texture.image;
  if(!canvas) return;


  const idx = info.index;
  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();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

这里做了两点优化:

  • 一是我们在 texture 对象上记录了上一次选中区域的 index。如果移动鼠标时,index 没发生变化,说明鼠标仍然在当前高亮的国家内,没有必要重绘。

  • 二是我们保存了原始非高亮图片。之所以这样做,是因为我们只要将保存的非高亮图片通过 drawImage 一次绘制,然后再绘制高亮区域,就可以完成地图高亮效果,而不需要每次都重新用 Path 来绘制整个地图了,因而大大减少了 Canvas2D 绘图指令的数量,显著提升了性能。

实现了这个函数之后,我们改写 mousemove 事件处理函数,就能将这个交互效果完整地显示出来了。

globe.addEventListener('mousemove', (e) => {
  const [lng, lat] = positionToLatlng(...e.hit.localPoint);
  const country = getCountryInfo(lat, lng, countries);
  highlightMap(texture, country, countries);
});
1
2
3
4
5

# 如何在地球上放置标记

既然要把物体放置在地球指定的经纬坐标处,我们依然需要进行坐标转换。首先,我们知道几何体通常默认是以中心点为(0,0)点,但我们放置的时候,却需要将物体的底部放置在球面上,所以我们需要对球面坐标位置进行一个坐标变换。

因此,我们在实现放置函数的时候,会通过 latlngToPosition 先将经纬度转成球面坐标 pos,再延展到物体高度的一半,因为球心的坐标是 0,0,所以 pos 位置就是对应的三维向量,我们使用 scale 就可以直接将它移动到我们要的高度了。

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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

这里的 latlngToPosition 是前面 positionToLatlng 的反向操作,也就是先用 projection 函数将经纬度映射为地图上的直角坐标,然后用直角坐标转球面坐标的公式,将它转为球面坐标。

/**
 * 将经纬度转换为球面坐标
 * @param {*} latitude
 * @param {*} longitude
 * @param {*} radius
 */
function latlngToPosition(latitude, longitude, radius = 1) {
  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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

有了这个位置之后,我们将物体放上去,并且让物体朝向球面的法线方向。这一步我们可以用 lookAt 函数来实现。不过,lookAt 函数是让物体的 z 轴朝向向量方向,而我们绘制的一些几何体,比如圆柱体,其实是要让 y 轴朝向向量方向,所以这种情况下,我们需要对几何体的顶点做一个转置操作,也就是将它的顶点向量的 x、y、z 的值轮换一下,让 x = y、y = z、 z = x。这么做之后,我们就可以在地球表面摆放几何体了。

# 摆放光柱

光柱通常可以用来标记当前位置是一个重要的地点。光柱的效果如下图:

因为光柱本身是圆柱体,所以我们可以用 Cylindar 对象来绘制。而且光柱的光线还会随着高度衰减,对应的 shader 代码如下:

const beamVertx = `
  precision highp float;
  precision highp int;

  attribute vec3 position;
  attribute vec3 normal;
  attribute vec4 color;

  uniform mat4 modelViewMatrix;
  uniform mat4 projectionMatrix;
  uniform mat3 normalMatrix;

  varying vec3 vNormal;
  varying vec4 vColor;

  uniform vec4 ambientColor; // 环境光
  uniform float uHeight;

  void main() {
    vNormal = normalize(normalMatrix * normal);
    vec3 ambient = ambientColor.rgb * color.rgb; // 计算环境光反射颜色
    float height = 0.5 - position.z / uHeight;
    vColor = vec4(ambient + 0.3 * sin(height), color.a * height);
    vec3 P = position;
    P.xy *= 2.0 - pow(height, 3.0);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(P, 1.0);
  }
`;

const beamFrag = `
  precision highp float;
  precision highp int;

  varying vec3 vNormal;
  varying vec4 vColor;

  void main() {
    gl_FragColor = vColor;
  } 
`
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

在顶点着色器里,我们可以根据高度减少颜色的不透明度。另外,我们还可以根据高度对 xy 也就是圆柱的截面做一个扩展:P.xy *= 2.0 - pow(height, 3.0),这样就能产生一种光线发散(顶部比底部略大)的效果了。

对应的 addBeam 函数就是根据参数创建对应的圆柱体对象,并把它们添加到地球对应的经纬度位置上。

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},
      },
    });
    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;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

# 摆放地标

除了摆放光柱,我们还可以摆放地标。地标通常表示当前位置产生了一个重大事件。

地标实现起来会比光柱更复杂一些,它由一个定位点(Spot)和一个动态的标记(Marker)共同组成。摆放了地标的地图效果如下图:

我们还是需要实现对应的 Shader,并且这次需要实现两组 Shader。

首先是 spot 的顶点着色器和片元着色器,实现起来也非常简单。在顶点着色器中,我们根据 uWidth 扩展 x、y 坐标,根据顶点绘制出一个特定大小的平面图形。在片元着色器中,我们让图形的中心稍亮一些,让边缘亮度随着距离衰减,这么做是为了增强视觉效果。

const spotVertex = `
  precision highp float;
  precision highp int;

  attribute vec4 position;

  uniform mat4 modelViewMatrix;
  uniform mat4 projectionMatrix;
  uniform mat3 normalMatrix;

  uniform float uWidth;
  uniform float uSpeed;
  uniform float uHeight;

  varying vec2 st;

  void main() {
    float s = 0.0 + (0.2 * uWidth * position.w);
    vec3 P = vec3(s * position.xy, 0.0);
    st = P.xy;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(P, 1.0);
  }
`;

const spotFragment = `
  precision highp float;
  precision highp int;

  uniform vec2 uResolution;
  uniform vec3 uColor;
  uniform float uWidth;

  varying vec2 st;

  void main() {
    float d = distance(st, vec2(0));
    gl_FragColor.rgb = uColor + 1.5 * (0.2 * uWidth - 2.0 * d);
    gl_FragColor.a = 1.0;
  }
`;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

接着是 marker 的顶点着色器和片元着色器,它们稍微复杂一些。

const markerVertex = `
  precision highp float;
  precision highp int;

  attribute vec4 position;

  uniform mat4 modelViewMatrix;
  uniform mat4 projectionMatrix;
  uniform mat3 normalMatrix;

  uniform float uTime;
  uniform float uWidth;
  uniform float uSpeed;
  uniform float uHeight;

  varying float time;

  void main() {
    time = mod(uTime, 1.5 / uSpeed) * uSpeed + position.z - 1.0;
    float d = clamp(0.0, uWidth * mix(1.0, 0.5, min(1.0, uHeight)), time);
    float s = d + (0.1 * position.w);
    vec3 P = vec3(s * position.xy, uHeight * time);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(P, 1.0);
  }
`;

const markerFragment = `
  precision highp float;
  precision highp int;

  uniform vec2 uResolution;
  uniform vec3 uColor;

  varying float time;

  void main() {
    float t = clamp(0.0, 1.0, time);
    gl_FragColor.rgb = uColor;
    gl_FragColor.a = 1.0 - t;
  }
`;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

在顶点着色器里,我们根据时间参数 uTime 来调整物体定点的高度。这样,当我们设置 uHeight 参数时,marker 就能呈现出立体的效果。

有了这两组着色器之后,我们再实现两个函数,用来分别生成 spot 和 marker 的顶点。这两个函数都是用生成正多边形顶点的算法来生成对应的顶点,它们的区别是,spot 只生成一组顶点,因为是平面图形,所以 z 坐标为 0,而 marker 则生成三组不同高度的顶点组成立体的形状。

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},
  };
}

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},
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

接着,我们再实现一个初始化函数,用来生成 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',
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

最后,我们实现 addMarker 方法,将地标添加到地球上。这样,我们就实现了绘制地标的功能。

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};
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

# 完整代码

# 实现手势密码解锁组件

组件设计一般来说包括 7 个步骤,分别是理解需求技术选型结构(UI)设计数据和 API 设计流程设计兼容性和细节优化,以及工具和工程化

当然,并不是每个组件设计的时候都需要进行这些过程,但一个项目总会在其中一些过程里遇到问题需要解决。

# 理解需求

如果只是照着需求把整个组件的状态切换和流程封装起来,或者只是提供了一定的 UI 样式配置能力的话,还远远不够。实际上这个组件如果要给用户使用,我们需要将过程节点开放出来。也就是说,需要由使用者决定设置密码的过程里执行什么操作、验证密码的过程和密码验证成功后执行什么操作,这些是组件开发者无法代替使用者来决定的。

var password = '11121323';

var locker = new HandLock.Locker({
  container: document.querySelector('#handlock'),
  check: {
    checked: function(res){
      if(res.err){
        console.error(res.err); //密码错误或长度太短
        [执行操作...]
      }else{
        console.log(`正确,密码是:${res.records}`);
        [执行操作...]
      }
    },
  },
  update:{
    beforeRepeat: function(res){
      if(res.err){
        console.error(res.err); //密码长度太短
        [执行操作...]
      }else{
        console.log(`密码初次输入完成,等待重复输入`);
        [执行操作...]
      }
    },
    afterRepeat: function(res){
      if(res.err){
        console.error(res.err); //密码长度太短或者两次密码输入不一致
        [执行操作...]
      }else{
        console.log(`密码更新完成,新密码是:${res.records}`);
        [执行操作...]
      }
    },
  }
});

locker.check(password)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

# 技术选型

手势密码的 UI 展现的核心是九宫格和选中的小圆点,从技术上来讲,我们有三种可选方案: DOM/Canvas/SVG,三者都是可以实现主体 UI 的。

如果使用 DOM,最简单的方式是使用 flex 布局,这样能够做成响应式的。使用 DOM 的优点是容易实现响应式,事件处理简单,布局也不复杂(但是和 Canvas 比起来略微复杂),但是斜线(demo 里没有画)的长度和斜率需要计算

除了使用 DOM 外,使用 Canvas 绘制也很方便。

用 Canvas 实现有两个小细节:

一是要实现响应式,我们可以用 DOM 构造一个正方形的容器。这里使用 padding-top:100% 撑开容器高度使它等于容器宽度

#container {
  position: relative;
  overflow: hidden;
  width: 100%;
  padding-top: 100%;
  height: 0px;
  background-color: white;
}
1
2
3
4
5
6
7
8

二是为了在 retina 屏上获得清晰的显示效果,我们将 Canvas 的宽高增加一倍,然后通过 transform: scale(0.5) 来缩小到匹配容器宽高。

#container canvas {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%) scale(0.5);
}
1
2
3
4
5
6

通过这样的方法,就可以通过在 Canvas 上绘制实心圆和连线来实现 UI 了。

如果是使用 SVG 来实现的话,由于 SVG 原生操作的 API 不是很方便,我们可以使用 Snap.svg (opens new window) 这个库,实现起来和使用 Canvas 大同小异。不过 SVG 有个问题就是移动端兼容性不如 DOM 和 Canvas 好。

因此,综合上面的情况,最终选择使用 Canvas 来实现。

# 结构设计

使用 Canvas 实现的话, DOM 结构就比较简单了。为了实现响应式,我们实现了一个自适应宽度的正方形容器,然后我们在容器中创建 Canvas。

需要注意的是,我们应当把 Canvas 分层。这是因为 Canvas 的渲染机制里,要更新画布的内容,需要刷新要更新的区域重新绘制。因此我们有必要把频繁变化的内容和基本不变的内容分层管理,这样能显著提升性能。

在这里可以把 UI 分别绘制在 3 个图层里,对应 3 个 Canvas。

最上层只有随着手指头移动的那个线段,中间是九个点,最下层是已经绘制好的线。之所以这样分,是因为随手指头移动的那条线需要不断刷新,底下两层都不用频繁更新,但是把连好的线放在最底层是因为我要做出圆点把线的一部分遮挡住的效果。

接着确定圆点的位置。

圆点的位置有两种定位法:

  • 第一种是九个九宫格,圆点在小九宫格的中心位置。在 DOM 方案里,我们就是采用这样的方式。这个时候,圆点的直径为 11.1%。

  • 第二种方式是用横竖三条线把宽高四等分,圆点在这些线的交点处。

在 Canvas 里我们采用第二种方法来确定圆点(代码里的 n = 3)。

let range = Math.round(width / (n + 1));

let circles = [];

// drawCircleCenters
for (let i = 1; i <= n; i++) {
  for (let j = 1; j <= n; j++) {
    let y = range * i, x = range * j;
    drawSolidCircle(circleCtx, fgColor, x, y, innerRadius);
    let circlePoint = {x, y};
    circlePoint.pos = [i, j];
    circles.push(circlePoint);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

最后还有一点需要注意的就是,因为我们的 UI 是通过触屏操作,所以我们需要考虑 Touch 事件处理和坐标的转换。不过这一点严格来说不属于结构设计。

这里将 Touch 相对于屏幕的坐标转换为 Canvas 相对于画布的坐标。代码里的 2 倍是因为要让 retina 屏下清晰,我们将 Canvas 放大为原来的 2 倍。

function getCanvasPoint(canvas, x, y){
  let rect = canvas.getBoundingClientRect();
  return {
    x: 2 * (x - rect.left), 
    y: 2 * (y - rect.top),
  };
}
1
2
3
4
5
6
7

# API 设计

将组件功能分解为更加底层的组件,是一种简化组件设计的常用模式。

我们可以将组件功能分解一下,独立出一个单纯记录手势的 Recorder。我们抽取出底层的 Recorder,让 Locker 继承 Recorder,Recorder 负责记录,Locker 管理实际的设置和验证密码的过程

Recorder 只负责记录用户行为,由于用户操作是异步操作,我们将它设计为 Promise 规范的 API,它可以以如下方式使用:

var recorder = new HandLock.Recorder({
  container: document.querySelector('#main')
});

function recorded(res){
  if(res.err){
    console.error(res.err);
    recorder.clearPath();
    if(res.err.message !== HandLock.Recorder.ERR_USER_CANCELED){
      recorder.record().then(recorded);
    }
  }else{
    console.log(res.records);
    recorder.record().then(recorded);
  }      
}

recorder.record().then(recorded)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

对于输出结果,我们简单用选中圆点的行列坐标拼接起来得到一个唯一的序列。例如 “11121323” 就是如下选择图形:

为了让 UI 显示具有灵活性,我们还可以将外观配置抽取出来。

const defaultOptions = {
  container: null, // 创建 canvas 的容器,如果不填,自动在 body 上创建覆盖全屏的层
  focusColor: '#e06555', // 当前选中的圆的颜色
  fgColor: '#d6dae5', // 未选中的圆的颜色
  bgColor: '#fff', // canvas 背景颜色
  n: 3, // 圆点的数量:n x n
  innerRadius: 20, // 圆点的内半径
  outerRadius: 50, // 圆点的外半径,focus 的时候显示
  touchRadius: 70, // 判定 touch 事件的圆半径
  render: true, // 自动渲染
  customStyle: false, // 自定义样式
  minPoints: 4, //最小允许的点数
};
1
2
3
4
5
6
7
8
9
10
11
12
13

这样,我们就实现了完整的 Recoder 对象 (opens new window)

# 流程设计

接下来,我们基于 Recorder 来设计设置和验证密码的流程。

首先是验证密码的流程:

其次是设置密码的流程:

有了异步 Promise API 的 Recorder 之后,不难实现这两个流程。

// 验证密码的内部流程
async check(password){
  if(this.mode !== Locker.MODE_CHECK){
    await this.cancel();
    this.mode = Locker.MODE_CHECK;
  }  


  let checked = this.options.check.checked;


  let res = await this.record();


  if(res.err && res.err.message === Locker.ERR_USER_CANCELED){
    return Promise.resolve(res);
  }


  if(!res.err && password !== res.records){
    res.err = new Error(Locker.ERR_PASSWORD_MISMATCH)
  }


  checked.call(this, res);
  this.check(password);
  return Promise.resolve(res);
}

// 设置密码的内部流程
async update(){
  if(this.mode !== Locker.MODE_UPDATE){
    await this.cancel();
    this.mode = Locker.MODE_UPDATE;
  }

  let beforeRepeat = this.options.update.beforeRepeat, 
      afterRepeat = this.options.update.afterRepeat;

  let first = await this.record();

  if(first.err && first.err.message === Locker.ERR_USER_CANCELED){
    return Promise.resolve(first);
  }

  if(first.err){
    this.update();
    beforeRepeat.call(this, first);
    return Promise.resolve(first);   
  }

  beforeRepeat.call(this, first);

  let second = await this.record();      

  if(second.err && second.err.message === Locker.ERR_USER_CANCELED){
    return Promise.resolve(second);
  }

  if(!second.err && first.records !== second.records){
    second.err = new Error(Locker.ERR_PASSWORD_MISMATCH);
  }

  this.update();
  afterRepeat.call(this, second);
  return Promise.resolve(second);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67

此外,还有一些细节问题。由于实际在手机上触屏时,如果上下拖动,浏览器的默认行为会导致页面上下移动,因此我们需要阻止 touchmove 的默认事件。touchmove 事件在 Chrome 下默认是一个 Passive Event (opens new window),因此,我们 addEventListener 的时候需要传参 {passive: false},否则就不能 preventDefault。

this.container.addEventListener('touchmove', evt => evt.preventDefault(), {passive: false});
1

而且,因为我们的代码使用了 ES6+,所以需要引入 babel 编译,我们的组件也使用 webpack 进行打包,以便于使用者在浏览器中直接引入。

# 参考代码

手势密码设置和解锁的实现 (opens new window)

# 总结思考

通过完成这个手势密码组件设计,我们应该从中记住以下三点:

  1. 在设计 API 的时候思考真正的需求,判断什么该开放、什么该封装

  2. 做好技术调研和核心方案研究,选择合适的方案

  3. 着手优化和解决细节问题,要站在 API 使用者的角度思考

# Vue + Canvas 实现五子棋

五子棋 (opens new window)

上次更新时间: 2022年03月04日 21:09:41