mapbox 中使用动态图标

906 阅读4分钟

一、需求

我们在开发地图相关应用时为了增强用户体检和交互性有时需要使用一些动效来增强我们的表达,但是实际上我们去使用一些开源或者商业的地图类库时其实并没有那么方便。以 mapbox-gl 为例,我们在绘制点类型时常见的有三种方式:

  1. Marker
  2. symbol 图层
  3. circle 图层

大部分的场景其实静态点已经足够,如果我们想在 mapbox-gl 实现动态点叠加能否实现呢?答案其实是有的,包括 mapbox-gl 官方也给到了一些示例,下面我们就来看一看这些方案都有什么特殊。

二、方案

解决方案

  1. 使用 Marker
  2. 使用 symbol 图层的 style-dirven,通过动态的设置数据的属性值或者使用map.setFeatureState来实时改变状态。
  3. 使用 canvas 图标:手写 canvas 动画、 gif 动画、lottie动画。

优缺点

方案优点缺点
Marker可以使用 dom 和 css3,实现简单1. 如果有大量点可能会造成性能问题;2. Marker 是 dom 实现它永远在地图最上层,无法被其他图层覆盖;3. 无法维持深度关系
使用样式的数据驱动1. 使用 mapbox 内置 gl 渲染,性能好;2. 图标可以维持深度关系;3. 能和其他图层维持层级关系1. 单帧中需要实时设置数据,会对性能有一定的影响;2. 仅能实现一些简单动画
canvas 图标1. 使用 mapbox 内置 gl 渲染,性能好;2. 图标可以维持深度关系;3. 能和其他图层维持层级关系;4. 适用于各类矢量数据源1. 需要手写绘制逻辑,或者引入其他库来实现 canvas 动画

三、简单示例

  1. 使用 Marker 实现点的动效

    核心代码:

    const el = document.createElement('div');
    const width = 50;
    const height = 50;
    el.className = 'waves';
    el.style.backgroundImage = `url(https://placekitten.com/g/${width}/${height}/)`;
    el.style.width = `${width}px`;
    el.style.height = `${height}px`;
    el.style.backgroundSize = '100%';
    
    new mapboxgl.Marker(el, { anchor: 'center' }).setLngLat([-63.292236, -18.281518]).addTo(map);
    

    样式:

.waves {
       position: relative;
       background: rgba(255, 255, 255, 0.3);
       width: 25px;
       height: 25px;
       margin-left: -12px;
       margin-top: -12px;
       border-radius: 50%;
       -webkit-backface-visibility: hidden;
     }
   
     .waves:before {
       position: absolute;
       background: white;
       margin-left: 0;
       margin-top: 0;
       width: 50px;
       height: 50px;
       content: '';
       display: block;
       border-radius: 50%;
       -webkit-backface-visibility: hidden;
       animation: wave-animate 3s infinite ease-out;
     }
   
     .waves:after {
       position: absolute;
       background: white;
       margin-left: 0;
       margin-top: 0;
       width: 50px;
       height: 50px;
       content: '';
       display: block;
       border-radius: 50%;
       -webkit-backface-visibility: hidden;
       opacity: 0;
       animation: wave-animate 3s 1.5s infinite ease-out;
     }
   
     @keyframes wave-animate {
       0% {
         transform: scale(0);
         opacity: 1;
         transform-origin: center;
       }
       100% {
         transform: scale(3);
         opacity: 0;
         transform-origin: center;
       }
     }

最终实现的效果如下图:

iShot_2023-11-29_19.56.36

  1. 使用样式驱动来实现一些简单动画

核心代码:

const size = 50;

function animate() {
  const duration = 1000;
  const t = (performance.now() % duration) / duration;

  const radius = (size / 2) * 0.3;
  const outerRadius = (size / 2) * 0.7 * t + radius;

  map.setPaintProperty('circle-layer', 'circle-radius', outerRadius);

  raf.value = requestAnimationFrame(animate);
}

map.on('load', () => {
  map.addSource('circle', {
    type: 'geojson',
    data: {
      type: 'FeatureCollection',
      features: [
        {
          type: 'Feature',
          properties: {
            color: '#F7455D', // red
          },
          geometry: {
            type: 'Point',
            coordinates: [-63.292236, -18.281518],
          },
        },
      ],
    },
  });

  map.addLayer({
    id: 'circle-layer',
    type: 'circle',
    source: 'circle',
    paint: {
      'circle-radius': 20,
      'circle-stroke-width': 2,
      'circle-color': 'red',
      'circle-stroke-color': 'white',
    },
  });
  animate();
});

最终实现的效果如下图:

iShot_2023-12-14_02.09.12

  1. canvas 图标-简易 canvas

    核心代码:

    map.loadImage(withBase('/assets/images/typhoon.png'), (err, image) => {
      const size = 50;
      const icon = new CanvasIcon(size, size, {
        autoPixelRatio: true,
        onAdd(ctx: any) {
          ctx.then = 0;
          ctx.rotationSpeed = 5;
          ctx.rotation = 0;
        },
        renderCallback(ctx: any) {
          let now = performance.now();
    
          now *= 0.001;
          const deltaTime = now - ctx.then;
          ctx.then = now;
    
          ctx.rotation = ctx.rotationSpeed * deltaTime;
    
          const { context } = ctx;
          if (context) {
            context.clearRect(0, 0, ctx.width, ctx.height);
            const [halfW, halfH] = [ctx.width / 2 + 1, ctx.height / 2 + 1];
    
            context.translate(halfW, halfH);
            context.rotate(-ctx.rotation);
            context.translate(-halfW, -halfH);
    
            context.drawImage(image, 0, 0);
          }
        },
        postRender(ctx) {
          map.triggerRepaint();
        },
      });
    
      if (!map.hasImage('typhoon')) {
        map.addImage('typhoon', icon);
      }
    
      map.addLayer({
        id: 'point',
        type: 'symbol',
        source: 'point',
        layout: {
          'icon-image': 'typhoon',
          'icon-pitch-alignment': 'map',
          'icon-allow-overlap': true, // 图标允许压盖
        },
      });
    });
    

    iShot_2023-11-30_18.34.23

  2. canvas 图标 - gif

    核心代码如下:

    import { AnimatedGIF, CanvasIcon } from '@sakitam-gis/viz-mapbox-gl';
    
    fetch(withBase('/assets/textures/cat.gif'))
      .then((res) => res.arrayBuffer())
      .then((res) => {
        const canvasIcon = new CanvasIcon(476, 280, {
          autoPixelRatio: true,
          onAdd(ctx: any) {
            ctx.gif = AnimatedGIF.fromBuffer(res, ctx.context, {});
          },
          renderCallback(ctx: any) {
            ctx.gif.update(performance.now() / 1000);
            ctx.gif.updateFrame();
          },
          postRender(ctx) {
            map.triggerRepaint();
          },
        });
    
        const markerId = `animate-icon`;
    
        if (!map.hasImage(markerId)) {
          map.addImage(markerId, canvasIcon);
        }
    
        map.addSource('point', {
          type: 'geojson',
          data: {
            type: 'FeatureCollection',
            features: [
              {
                type: 'Feature',
                properties: {},
                geometry: {
                  type: 'Point',
                  coordinates: [0, 0],
                },
              },
              {
                type: 'Feature',
                properties: {},
                geometry: {
                  type: 'Point',
                  coordinates: [45, 2],
                },
              },
            ],
          },
        });
    
        map.addLayer({
          id: 'point',
          type: 'symbol',
          source: 'point',
          layout: {
            visibility: 'visible',
            'icon-image': markerId,
            'icon-size': 0.8,
            'icon-anchor': 'bottom',
            'icon-ignore-placement': true,
            'icon-allow-overlap': true, // 图标允许压盖
          },
          paint: {},
          filter: ['all', ['in', '$type', 'Point']],
        });
      });
    

    效果如下:

    iShot_2023-11-30_19.46.23

  3. canvas 图标 - lottie 动画

    核心代码:

    import { CanvasIcon } from '@sakitam-gis/viz-mapbox-gl';
    import lottie from 'lottie-web/build/player/lottie_canvas';
    const canvasIcon = new CanvasIcon(100, 100, {
      autoPixelRatio: true,
      onAdd(ctx: any) {
        lottie.loadAnimation({
          // https://github.com/airbnb/lottie-web/issues/2058
          // container: el, // 容器节点
          renderer: 'canvas',
          loop: true,
          autoplay: true,
          path: withBase('/assets/json/LottieAnimation.json'), // JSON文件路径
          rendererSettings: {
            context: ctx.context, // the canvas context, only support "2d" context
            // preserveAspectRatio: 'xMinYMin slice', // Supports the same options as the svg element's preserveAspectRatio property
            clearCanvas: true,
          },
        });
      },
      renderCallback(ctx: any) {},
      postRender(ctx) {
        map.triggerRepaint();
      },
    });
    
    const markerId = `animate-icon`;
    
    if (!map.hasImage(markerId)) {
      map.addImage(markerId, canvasIcon);
    }
    
    map.addSource('point', {
      type: 'geojson',
      data: {
        type: 'FeatureCollection',
        features: [
          {
            type: 'Feature',
            properties: {},
            geometry: {
              type: 'Point',
              coordinates: [0, 0],
            },
          },
          {
            type: 'Feature',
            properties: {},
            geometry: {
              type: 'Point',
              coordinates: [45, 2],
            },
          },
        ],
      },
    });
    
    map.addLayer({
      id: 'point',
      type: 'symbol',
      source: 'point',
      layout: {
        visibility: 'visible',
        'icon-image': markerId,
        'icon-size': 0.8,
        'icon-anchor': 'bottom',
        'icon-ignore-placement': true,
        'icon-allow-overlap': true, // 图标允许压盖
      },
      paint: {},
      filter: ['all', ['in', '$type', 'Point']],
    });
    

    iShot_2023-11-27_18.46.51

    canvas 图标除了可以实现上述的一些动画外,还能实现一些简易的伪3D 效果,比如柱状图:

image-20231127195249830

当然除了以上方式外我们还可以使用自定义图层 custom style layer 来实现动画,当然这样更复杂,不在本文讨论范围内。

四、参考

docs.mapbox.com/mapbox-gl-j…

docs.mapbox.com/mapbox-gl-j…