目标
画一个世界地图,按照人口增长率绘制颜色,鼠标悬浮显示具体数据:
数据
数据就是我们绘制地图的地理信息数据,GeoJSON 是一个比较常见的格式。也可以很容易的找到各个国家和地区的现成数据。比如阿里云就提供了有国内 GeoJSON 格式的地址数据可供下载:地址
GeoJSON
GeoJSON是一种基于json的地理空间数据交换格式,它定义了几种类型JSON对象以及它们组合在一起的方法,以表示有关地理要素、属性和它们的空间范围的数据。 具体看看百度 我们导入找到的世界地图json,打印:
他有四个键:type、crs、features、name,这些都是描述该对象的元数据。
这是一个命名为ne_50m_admin_0_countries的FeatureCollection对象,他需要一个features数组。
展开features:
每一个feature都有geometry对象和properties对象。
properties对象包含有关该特性的信息——根据本文中的信息,我们可以看到,每个特性都代表一个国家。
geometry对象为当前对象的地理信息,其下有一个经纬度的数组:
添加国家id的名称的存储器
const countryNameAccessor = d => d.properties["NAME"]
const countryIdAccessor = d => d.properties["ADM0_A3_IS"]
dataset
首先定义访问函数:
引入人口增长json数据:
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中
我们最常用的应该就是Mercator projection。
墨卡托已经存在了很长一段时间——它创建于1569年,甚至在南极洲被发现之前!它的平行线保留了真实的角度,这使得水手很容易用来导航,但它牺牲了垂直扭曲的水平扭曲。你可以在网格线(地图上的网格线)上看到这种扭曲,看看“正方形”靠近地图的顶部有多高。
还要注意,当它们接近两极时,国家的大小是如何变得倾斜的——格陵兰岛被描述成和非洲一样!另一种选择是温克尔-三佩尔投影,它试图平衡三种类型的失真:面积、方向和距离。
在北极和南极附近的土地仍在扩大,但乡村的形状和尺寸更准确。如果我们想显示一个地球仪,我们可以使用d3.geoOrthographic()投影。
请注意,投影的类型对覆盖更多区域的地图更重要。我们放大的越多,我们将地球形状变平的扭曲就越小。例如,一个城市的特写镜头在两个不同的投影中看起来基本相同。真的没有一个“正确的”地图投影——它们都必须扭曲一些东西,将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))
打印一下
生成了path的d属性,但是我们怎么知道他有多高?
值得庆幸的是,我们的pathGenerator()有一个.bounds()方法,它将返回一个描述指定GeoJSON对象边界框的[x,y]坐标数组。让我们打印一下:
console.log(pathGenerator.bounds(sphere))
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']) // 生成颜色比例尺
绘制数据
首先,我们将画出地球的轮廓。首先绘制这个图是有意义的,因为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))
- 绘制国家
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')
浏览器定位
现代浏览器有一个全局导航器对象,它提供有关访问该网站的设备的信息。
详细了解
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()
})