d3 第六章-地图

1,560 阅读7分钟

目标

画一个世界地图,按照人口增长率绘制颜色,鼠标悬浮显示具体数据: image.png

数据

数据就是我们绘制地图的地理信息数据,GeoJSON 是一个比较常见的格式。也可以很容易的找到各个国家和地区的现成数据。比如阿里云就提供了有国内 GeoJSON 格式的地址数据可供下载:地址

GeoJSON

GeoJSON是一种基于json的地理空间数据交换格式,它定义了几种类型JSON对象以及它们组合在一起的方法,以表示有关地理要素、属性和它们的空间范围的数据。 具体看看百度 我们导入找到的世界地图json,打印:

image.png

他有四个键:type、crs、features、name,这些都是描述该对象的元数据。
这是一个命名为ne_50m_admin_0_countries的FeatureCollection对象,他需要一个features数组。
展开features:

image.png

每一个feature都有geometry对象和properties对象。
properties对象包含有关该特性的信息——根据本文中的信息,我们可以看到,每个特性都代表一个国家。

image.png

geometry对象为当前对象的地理信息,其下有一个经纬度的数组:

image.png

添加国家id的名称的存储器

const countryNameAccessor = d => d.properties["NAME"]
const countryIdAccessor = d => d.properties["ADM0_A3_IS"]

dataset

首先定义访问函数:
引入人口增长json数据:

image.png

const countryNameAccessor = (d: featureType) => d.properties['NAME']
const countryIdAccessor = (d: featureType) => d.properties['ADM0_A3_IS']
const metric = 'Population growth (annual %)' // 筛选series name名字

我们需要每个国家的人口增长率,将数据格式化一下变为 {国家:增长率 }格式

dataset.forEach((d: any) => {
  if (d['Series Name'] != metric) return
  metricDataByCountry[d['Country Code']] = +d['2017 [YR2017]'] || 0
})

创建边界

const dimensions = {
  width: 800,
  height: 600,
  margin: {
    top: 50,
    right: 15,
    bottom: 50,
    left: 50,
  },
  boundsWidth: 0,
  boundsHeight: 0,
}
dimensions.boundsWidth = dimensions.width - dimensions.margin.left - dimensions.margin.right
dimensions.boundsHeight = dimensions.height - dimensions.margin.top - dimensions.margin.bottom

投影介绍

地球是个球体,当我们在二维屏幕上表示它时,我们需要创建一些规则——这些规则就是投影。

想象一下,剥一个橘子,把皮肤变成一个平板——即使在不同的地方切片,也不可能完美。投影将使用扭曲(拉伸地图的某些部分)和切片的组合来接近地球的实际形状。

d3-geo内置了15个投影,更多的投影内置在d3-geo-projection

image.png 我们最常用的应该就是Mercator projection。

image.png 墨卡托已经存在了很长一段时间——它创建于1569年,甚至在南极洲被发现之前!它的平行线保留了真实的角度,这使得水手很容易用来导航,但它牺牲了垂直扭曲的水平扭曲。你可以在网格线(地图上的网格线)上看到这种扭曲,看看“正方形”靠近地图的顶部有多高。

还要注意,当它们接近两极时,国家的大小是如何变得倾斜的——格陵兰岛被描述成和非洲一样!另一种选择是温克尔-三佩尔投影,它试图平衡三种类型的失真:面积、方向和距离。

image.png

在北极和南极附近的土地仍在扩大,但乡村的形状和尺寸更准确。如果我们想显示一个地球仪,我们可以使用d3.geoOrthographic()投影。

image.png

请注意,投影的类型对覆盖更多区域的地图更重要。我们放大的越多,我们将地球形状变平的扭曲就越小。例如,一个城市的特写镜头在两个不同的投影中看起来基本相同。真的没有一个“正确的”地图投影——它们都必须扭曲一些东西,将3D形状映射到2D。作为一般的经验法则,墨卡托的一种变体,横向墨卡托(d3.geoTransverseMercator())是显示覆盖一个国家或更小国家的地图的好选择。温克尔三角公园(d3.geoWinkel3())或平等地球(d3.geoEqualEarth())是覆盖更大地区的地图的好选择,比如整个世界。但这真的是冰山一角——如果你感兴趣,请阅读更多关于投影如何工作独特的投影

完成创建我们的图表边界

如果您还记得以前,GeoJSON对象可以包含以下类型的特征:点、多点、线字符串、多线字符串、多边形、多多边形、几何集合、特征或特征集合。但这些都不能覆盖在整个地球上!别担心,d3-geo增加了对一种“球体”的支持,它将覆盖整个地球。

const sphere: GeoGeometryObjects = { type: 'Sphere' }

上一节(以及更多)中提到的每个投影都在 d3-geo 或 d3-geo-projection 中实现。 我们可以使用这些投影 从 [经度, 纬度] 坐标转换为 [x, y] 像素的函数坐标。
本质上,投影函数是我们在地理世界中的尺度。 让我们创建我们的投影函数。 我们将使用 d3.geoEqualEarth() — 一旦我们完成了地图的绘制,就可以随意尝试其他选项。

const projection = d3.geoEqualEarth()

每个投影都有自己的默认大小,我们希望我们的投影和边界宽度相同。
为了更新投影的宽度,我们可以使用它的。fitWidth()方法,它需要两个参数:

  • 宽度(px)
  • 一个GeoJson对象 当我们调用此方法时,我们的投影将更新其大小,以便我们传递的GeoJSON对象(2)将是指定的宽度(1)。
const projection = d3.geoEqualEarth().fitWidth(dimensions.boundsWidth, sphere)

nice,下一步如何设置球体高度? d3.geoPath()类似于我们用于时间轴的线生成器 (d3.line())
在第 1 章中。当我们将我们的投影传递给它时,它会创建一个生成器函数
将帮助我们创造我们的地理形状。

const pathGenerator = d3.geoPath(projection)
console.log(pathGenerator(sphere))

打印一下

image.png 生成了path的d属性,但是我们怎么知道他有多高?
值得庆幸的是,我们的pathGenerator()有一个.bounds()方法,它将返回一个描述指定GeoJSON对象边界框的[x,y]坐标数组。让我们打印一下:

console.log(pathGenerator.bounds(sphere))

image.png

const pathGenerator = d3.geoPath(projection)
const [[x0, y0], [x1, y1]] = pathGenerator.bounds(sphere)

我们希望整个地球都在我们的范围内,所以我们想要设置我们的边界高度来覆盖我们的球体。

dimensions.boundsHeight = y1
  dimensions.height = dimensions.boundsHeight + dimensions.margin.top + dimensions.margin.bottom

绘制

绘制边界

const wrapper = d3
    .select('#wordMap')
    .append('svg')
    .attr('viewBox', `0,0,${dimensions.width},${dimensions.height}`)
    .attr('width', '100%')
    .attr('height', '100%')
    .attr('preserveAspectRatio', 'xMinYMin')
const bounds = wrapper
    .append('g')
    .attr('class', 'bounds')
    .style('transform', `translate(${dimensions.margin.left}px,${dimensions.margin.top}px)`)

绘制比例尺

将数据中的人口增长率提出来将其转化为颜色值:

  const metricValues = Object.values(metricDataByCountry) // value数组
  const metricValueExtent = d3.extent(metricValues) as number[] // 获取value区域数组
  const maxChange = d3.max([-metricValueExtent[0], metricValueExtent[1]]) // 获取绝对值最大值
  const colorScale = d3.scaleLinear().domain([-maxChange, 0, maxChange]).range(['indigo', 'white', 'darkgreen']) // 生成颜色比例尺

image.png

绘制数据

首先,我们将画出地球的轮廓。首先绘制这个图是有意义的,因为SVG元素根据它们在DOM中的顺序进行分层,并且我们希望所有其他元素覆盖这个轮廓。
请记住,我们的路径生成器知道我们正在使用的投影,他将起到一个比例尺的作用,将GeoJson对象转换为path标签的d属性。

const earth = bounds.append('path').attr('class', 'earth').attr('d', pathGenerator(sphere))
  • 给地图添加网格 网格是纬度和纵向线的网格,本质上是地图的标记。这些都有助于对齐相距遥远的地图元素,也有助于传达投影是如何扭曲地球的。
    我们可以很容易地创建一个GeoJSON网格——d3.geoGraticule10()将每10度生成一个经典的网格。 详细可查看文档
    我们的pathGenerator()知道如何处理任何GeoJSON类型:
// 地图分割线
  const graticuleJson = d3.geoGraticule10() // 每10°分割
  const graticule = bounds.append('path').attr('class', 'graticule').attr('d', pathGenerator(graticuleJson))

image.png

  • 绘制国家
  const countries = bounds
    .selectAll('.country')
    .data(countryShapes.features) // 绑定所有国家数据
    .enter()
    .append('path') // 添加path
    .attr('class', 'country') // 添加类名
    .attr('d', (d: any) => pathGenerator(d)) // 通过pathGenerator解析GeoJson数据生成d字符串
    .attr('fill', (d) => {
      // 通过颜色刻度尺修改填充颜色
      const metricValue = metricDataByCountry[countryIdAccessor(d)]
      if (typeof metricValue == 'undefined') return '#e2e6e9'
      return colorScale(metricValue)
    })
  • 图例 新增一个图例来解释我们的颜色代表的含义。
    我们将将它放在一个新的g标签里,为了让图例在不同大小屏幕下位置合适,在小屏上,让我们把它放在地图的底部,在更大的屏幕上,我们可以把它放在地图的左下角。
const legendGroup = wrapper.append('g').attr('transform', `translate(200,${dimensions.boundsHeight * 0.7})`)

再给他添加标题和副标题

  const legendTitle = legendGroup.append('text').attr('y', -23).attr('class', 'legend-title').text('Population growth')

  const legendByline = legendGroup
    .append('text')
    .attr('y', -9)
    .attr('class', 'legend-byline')
    .text('Percent change in 2017')

添加图例颜色,在svg下创建一个def元素来存储一个渐变

const defs = wrapper.append('defs') // 添加<def>
  const legendGradientId = 'legend-gradient'
  const gradient = defs // 添加渐变
    .append('linearGradient')
    .attr('id', legendGradientId)
    .selectAll('stop')
    .data(colorScale.range())
    .enter()
    .append('stop')
    .attr('stop-color', (d) => d)
    .attr(
      'offset',
      (d, i) =>
        `${
          (i * 100) / 2 // 2 is one less than our array's length
        }%`
    )

  const legendWidth = 120
  const legendHeight = 16
  const legendGradient = legendGroup
    .append('rect')
    .attr('x', -legendWidth / 2)
    .attr('height', legendHeight)
    .attr('width', legendWidth)
    .style('fill', `url(#${legendGradientId})`) // 使用渐变

然后在图例颜色区域左右两侧加上数值区域:

  const legendValueRight = legendGroup
    .append('text')
    .attr('class', 'legend-value')
    .attr('x', legendWidth / 2 + 10)
    .attr('y', legendHeight / 2)
    .text(`${d3.format('.1f')(maxChange)}%`)

  const legendValueLeft = legendGroup
    .append('text')
    .attr('class', 'legend-value')
    .attr('x', -legendWidth / 2 - 10)
    .attr('y', legendHeight / 2)
    .text(`-${d3.format('.1f')(maxChange)}%`)
    .style('text-anchor', 'end')

浏览器定位

现代浏览器有一个全局导航器对象,它提供有关访问该网站的设备的信息。

image.png

详细了解
navigator.geolocation有一种可以抓取浏览器位置的方法:
.getCurrentPosition()大多数现代的浏览器都支持此方法,此方法能获取到具体位置信息,我们通过projection将获取到的经纬度转换为x,y坐标绘制一个圆代表所在的国家。

// navigator.geolocation.getCurrentPosition 获取当前设备位置
navigator.geolocation.getCurrentPosition((myPosition) => {
    const [x, y] = projection([myPosition.coords.longitude, myPosition.coords.latitude]) as number[]
    const myLocation = bounds
      .append('circle')
      .attr('class', 'my-location')
      .attr('cx', x)
      .attr('cy', y)
      .attr('r', 0)
      .transition()
      .duration(500)
      .attr('r', 5)
  })

设置交互

添加tooltip,显示当前国家的名字和人头增长率。

    <div class="tooltip">
      <div>{{ tipData.name }}</div>
      <div>{{ tipData.growRate }}</div>
    </div>

当我们悬浮在某一个path上触发,显示tooltip,鼠标移出时隐藏tooltip。 主要问题是确定tooltip的位置,我们希望他的位置在当前国家的中心。
pathGenerator.centroid(d)-这个方法返回一个传递的GeoJSON对象的中心,有了中心位置我们在此处画一个圆然后获取原的DOMRect,这样我们就有了当前国家相对于窗口的坐标,如此tooltip得到了准确的位置。

const tipData = reactive({
  growRate: '',
  name: '',
})
countries
    .on('mouseenter', function (e, d) {
      // 获取人口增长值
      const metricValue = metricDataByCountry[countryIdAccessor(d)]
      // 添加描述字符串
      tipData.growRate = metricValue.toFixed(2) + '% population change'
      // 国家名字
      tipData.name = countryNameAccessor(d)
      // 获取悬浮国家path的中心坐标
      const [centerX, centerY] = pathGenerator.centroid(d)
      // 加个圆心
      const hoveredCircle = bounds.append('circle').attr('cx', centerX).attr('cy', centerY).attr('r', 0)
      // 找到圆心相对于窗口的domRect
      const domRect = hoveredCircle.node()?.getClientRects()[0] as DOMRect
      // 计算tooltip的x,y坐标
      const x = domRect?.left
      const y = domRect?.top - 7.5
      tooltip.style('transform', `translate(` + `calc( -50% + ${x}px),` + `calc(-100% + ${y}px)` + `)`)
      // 显示tooltip
      tooltip.style('opacity', 1)
    })
    .on('mouseleave', function () {
      // 隐藏tooltip
      tooltip.style('opacity', 0)
      // 移出圆点
      d3.selectAll('.hovered-circle').remove()
    })

image.png