基于D3.js的地图下钻基本实现

534 阅读3分钟

地图下钻基本策略:

  • ObservableHQ上找demo,最后锁定两个demo,一个支持滚轮缩放和画布拖动,一个是指定位置缩放,不支持滚轮,也不能拖画布,为了后续可能的bug少一些,实现选了后者。
  • 查怎么绘制地图,写python脚本把DataV上的省市JSON数据爬下来,或者直接拼接url动态获取
  • 然后用d3的geoPath画地图,画不出来,stack overflow查了一圈,说坐标必须保证顺时针还是逆时针顺序来着,忘了,然后又引了个turf依赖,把坐标翻转了一下,能画出来了
  • 然后是悬停高亮,这里还好,用d3获取悬停的区域,改下fill就行了,后续还需要画个圆角矩形显示区域的数据,也不是很复杂
  • 比例尺需要根据当前选的配色,从白到当前色做渐变,目前用的是100个矩形,interpolateRGB算颜色,给矩形依次上色形成渐变,后来发现其实用一个矩形像css那样做个线性渐变就行了
  • 南沙群岛的显示也给我折磨够呛,需要单独拿出来放到右下一个矩形里,最后实现的策略得益于学过PPT的障眼法,通过有限的画布把地图主体的南沙群岛遮住,然后再画一次南沙群岛和海岸线,移动到相应位置,再借助clipPath修剪一下周围。自此,最基本的绘制就完成了。接下来是下钻。
  • 首先地图整体是通过所有省的点集合拼出来的,所以大区-小区的交互都是基于省的,所以首要的高亮问题就是:
  1. 获取当前的下钻级别是大区还是小区
  2. 悬停到某个省的时候,获取省所在的大区/小区信息,并将包含的所有省高亮
  • 大区/小区下钻,这个地方也折腾了很久,因为单个省的数据是包含中心点坐标的,但是大小区是手动拼出来的集合,中心点需要算。一大段时间的stack overflow之后,可行的解决方案是把区域下所有省的geoPath加到一起形成一个svg里的path节点,然后用getBBox()的方法取中心点
  • 后续大部分是通用交互了,下钻时取相应的地图JSON,缩放过程中隐藏父级的path,重绘比例尺,到市级的时候往数据的features里添加点坐标数据实现经销商打点
  • 最后就是支持级联组件的联动,痛苦的过程就不描述了,方案就是取选中那一级的父级进行绘制,然后模拟点击选中区域
    至此这个花里胡哨的地图下钻功能就做完了。

640.gif 不过这是好久以前当牛马时做的了,源码不在手里了,放一个基础代码,剩下的勇士们自己去探索吧:) 祝好运

  1. 初始化和依赖
pnpm create vite@latest
pnpm add d3
pnpm add @turf/turf
  1. main.js
import * as d3 from 'd3';
import * as turf from '@turf/turf';


const ch = await d3.json('https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json');
const width = window.innerWidth - 20;
const height = window.innerHeight - 20;
let currentTransform = [width / 2, height / 2, height];

const remove = (interpolation, className, gNode) => gNode.transition()
  .duration(interpolation.duration)
  .attrTween("transform", () => t => transform(currentTransform = interpolation(t)))
  .selection()
  .selectAll(className)
  .selectAll('path')
  .transition()
  .duration(750)
  .style('opacity', 0)
  .remove();

const svg = d3.create("svg")
  .attr("viewBox", [0, 0, width, height])
  .on("click", () => {
    const i = d3.interpolateZoom(currentTransform, [width / 2, height / 2, height]);

    remove(i, '.province', g);
    remove(i, '.city', g);
    drawMap();
  });

const g = svg.append("g").attr('transform', `translate(0, 0)`);

const mercator = d3.geoMercator()
  .center([107, 31])
  .scale(500)
  .translate([width / 2, height / 2]);
const geoPath = d3.geoPath()
  .projection(mercator);

const drawMap = () =>
  g.append('g').attr('id', 'map').selectAll('path')
    .data(ch.features.map(feature => ({ ...turf.rewind(feature, { reverse: true }), level: 1 })))
    .join('path')
    .attr('stroke', '#b5b5b5')
    .attr('stroke-width', 1)
    .attr('d', geoPath)
    .attr('fill', 'white')
    .attr('class', 'overview')
    .on('mouseover', (e, d) => {
      d3.select(e.target).transition().attr('fill', 'gold');
    })
    .on('click', transition)
    .on('mouseout', e => {
      d3.select(e.target).transition().attr('fill', 'white');
    });

drawMap();

async function transition(e, d) {
  const [[x0, y0], [x1, y1]] = geoPath.bounds(d);
  e.stopPropagation();
  const i = d3.interpolateZoom(currentTransform, [...geoPath.centroid(d), (y1 - y0) * 2]);
  if (d.level === 1) {
    remove(i, '#map', g);
  }
  try {
    const data = await d3.json(`https://geo.datav.aliyun.com/areas_v3/bound/${d.properties.adcode}_full.json`);
    const specificMap = (level) => {
      let result = [];
      switch (level) {
        case 1:
          result = [data, 2];
          break;
        case 2:
          result = [data, 3];
          break;
      }
      return result[0].features.map(feature => ({ ...turf.rewind(feature, { reverse: true }), level: result[1] }));
    };
    g
      .transition()
      .duration(i.duration)
      .attrTween("transform", () => t => transform(currentTransform = i(t)))
      .selection()
      .append('g')
      .attr('class', d.level === 1 ? 'province' : 'city')
      .selectAll('path')
      .data(specificMap(d.level))
      .join('path')
      .on('mouseover', (e, d) => {
        d3.select(e.target).transition().attr('fill', 'gold');
      })
      .on('click', transition)
      .on('mouseout', e => {
        d3.select(e.target).transition().attr('fill', 'white');
      })
      .transition()
      .delay(250)
      .duration(750)
      .attr('fill', 'white')
      .attr('stroke', '#666')
      .attr('stroke-width', 1 / d.level / 3)
      .attr('d', geoPath);
  } catch (e) {
    console.log(e);
  }
}

function transform([x, y, r]) {
  return `
      translate(${width / 2}, ${height / 2})
      scale(${height / r})
      translate(${-x}, ${-y})
    `;
}
document.querySelector('#app').appendChild(svg.node());