我们将在本节中讨论的最后一种可视化类型是地图。地图是对地理空间数据的符号解释。他们使用不同的投影将我们生活的地球表面绘制到平面 2D 平面上。
由于地图制作和地理信息系统与科学(分别称为GIS和GIScience)已经实践了很长时间,因此存在用于表示此类数据的成熟方法。D3 具有强大的内置函数来加载和显示地理空间数据,其中大多数都包含在 D3-geo 模块 (https://github.com/d3/d3-geo) 中。
在本章中,我们将使用两种类型的地理数据:GeoJSON 和 TopoJSON。我们将研究不同的投影,从中创建地图。我们还将添加标准功能,例如允许用户缩放和平移地图,以及不太常见的功能(例如侦听刷笔事件)以过滤地图可视化的数据。
为了创建这些可视化,我们将使用包含 1901 年至 2022 年间所有诺贝尔奖获得者的数据集,并尝试回答这个问题,“您的出生地如何影响您获得诺贝尔奖的机会?我们将构建一张世界地图,可视化诺贝尔奖获得者出生的国家和城市,以及更详细的法国地图,如图13.1所示。您可以在 https://d3js-in-action-third-edition.github.io/nobel_prize/ 看到最终项目。
地理数据可以有多种形式,从简单的纬度和经度坐标到更复杂的地理数据,如 GeoJSON 或 TopoJSON 文件中的形状或线条。我们将在本章中探讨两者。
GeoJSON 是一种以 JSON 格式对地理数据进行编码的方法。它由点,线和面等几何的集合组成,用于绘制简单的地理要素。GeoJSON 文件中的每个对象都必须包含三条信息:类型、属性和几何。该类型的两个可能值是要素和要素集合,后者将多个要素组织到一个组中。
每个特征都包含一个属性对象,其中包含有关几何的相关元信息,例如其名称。它还包括具有几何类型及其坐标的几何对象。以下代码片段提供了用于绘制图 13.2 中所示的多边形的 geoJSON 代码。每个坐标都是对应于经度和纬度的点,如笛卡尔坐标系中的 (x, y)。经度是测量格林威治子午线以东或以西距离的垂直线,而纬度是指示距赤道以北或向南距离的水平线。
{
"type":"Feature",
"geometry":{
"type":"Polygon",
"coordinates":[
[
[25,56],
[23,55],
[22,54],
[26,54],
[25,56]
]
]
},
"properties":{"name":"Country Name"},
"id":"Country Code"
}
如果形状闭合,几何图形的起点和终点将在坐标列表中出现两次,就像我们示例中的坐标 [25,56] 一样。
在 geoJSON 文件中,我们稍后将从中构建世界地图(本章代码文件中的 world.json),每个特征对应于一个国家,几何称为多多边形。但 geoJSON 几何也可以由简单的点和线组成。
我们不会详细介绍geoJSON几何形状,因为您很有可能永远不必构建自己的GeoJSON文件。但是,如果您想更深入地了解,请查看维基百科上的GeoJSON文章,其中包含每个几何图形的有用示例(https://en.wikipedia.org/wiki/GeoJSON)。
GeoJSON 的主要优点是这种格式相对容易创建、阅读和理解。但是此类文件很快就会变大,因为格式未针对文件大小进行优化,因为它保留了功能的所有行,即使它们是重复的。在非常详细地绘制大面积地图时尤其如此。在这种情况下,我们转向 TopoJSON 格式。
GeoJSON 和 TopoJSON 之间的主要区别在于,GeoJSON 为每个描述点、线或多边形的要素记录一个坐标数组,而 TopoJSON 只存储一个弧数组。弧是由数据集中的一个或多个要素共享的线的任何不同线段。
大多数数据集都有共享的区段,例如两个国家/地区之间的边界。在GeoJSON中,这些共享边界出现两次,而TopoJSON通过将它们合并到一个弧中来减少冗余,如图13.3所示。它生成的文件明显更小,允许保存更详细的地理要素的数据,并针对 Web 进行了优化。此外,如果您知道共享了哪些区段,则可以对数据执行令人兴奋的操作,例如快速计算共享边框或合并要素。
但是,虽然GeoJSON易于阅读并且可以由D3直接解释,但TopoJSON很难破译,并且需要一个名为topojson-client的附加库将TopoJSON解码回GeoJSON,然后再用D3处理它。
虽然简单的Google搜索通常足以找到创建地图所需的数据文件,但Dea Bankova创建了一个实用的可观察笔记本,其中包含指向不同资源的链接和有用的D3代码片段(https://observablehq.com/@deaxmachina/collection-of-maps)。
在本节中,我们将根据GeoJSON数据绘制世界地图。然后,我们将使用此地图来可视化诺贝尔奖获得者来自的国家和城市。
整本书都是关于创建 web 地图的,一整本书都可以写关于使用 D3.js 来制作地图。因为这只是一章,我们将掩盖许多深层次的问题。其中之一是投影。在 GIS 中,投影是指将地球仪(如地球)上的点渲染到平面(如计算机显示器)上的过程。预测不可避免地在某种程度上扭曲了地理数据。
D3 提供了四种常见投影类型的方法:方位投影、圆锥投影、圆柱投影和复合投影。方位投影(如图 13.4 所示的“等地”投影)将球体直接投影到平面上。投影的中心与地球相切,随着我们远离这个中心点,变形会增加。
圆锥投影将地球投射到圆锥体上,然后将这个圆锥体展开到平面上。它们最适合绘制长的东西距离,因为沿水平平行线的畸变是恒定的。
圆柱形投影将地球投射到圆柱体上,然后将该圆柱体展开到平面上。它使各国比现实中更接近两极。图 13.4 所示的墨卡托投影是标准圆柱投影。请注意格陵兰岛和南极洲有多大。此投影在早期版本的 Google 地图中使用,是 Tableau 和 Power BI 中的默认设置。
最后,复合投影由分组到单个显示中的多个投影组成。这些对于一起显示一个国家的遥远土地特别有用,例如阿拉斯加和夏威夷与美国大陆。请注意投影对此示例的影响。虽然阿拉斯加的面积是德克萨斯州的两倍多,但它似乎更小。
使用 D3 创建地图需要三个步骤:
以下代码片段说明了前两个步骤。首先,我们声明一个投影类型并将其保存在一个常量中 命名投影 .在这个例子中,我们使用了 d3.geoEqualEarth() ,但我们可以调用 d3-geo 模块 (https://github.com/d3/d3-geo) 中可用的任何投影。请注意我们如何将其与transition()和scale()访问器函数链接在一起,这些函数用于缩放地图并将其居中在SVG容器中。
然后我们使用 d3.geoPath() 初始化一个地理路径生成器函数,并将之前定义的投影传递给 projection() 访问器函数。这个生成器的作用类似于第 5 章中介绍的形状生成器:我们向它传递一个 GeoJSON 特征(几何),它返回相应路径元素的 d 属性。
const projection = d3.geoEqualEarth()
.translate([translationX, translationY])
.scale(factor);
const geoPathGenerator = d3.geoPath()
.projection(projection);
让我们从托管项目 (https://d3js-in-action-third-edition.github.io/nobel_prize/) 和图 13.1 中绘制世界地图!若要开始本练习,请从代码编辑器中的 chapter_13/13.2-GeoJSON 代码文件中打开起始文件夹。您可以在本书的 GitHub 存储库中找到代码文件,网址为 https://github.com/d3js-in-action-third-edition/code-files。
这个项目是用 ES6 JavaScript 模块构建的,我们已经为你添加了相关的 D3 依赖项。您可以在 package.json 中看到它们。要安装这些依赖项,请在终端中运行命令 npm install。如第 11 章所述,您将需要一个捆绑器才能在本地运行项目。如果您已安装 Parcel,则可以在终端中输入命令 parcel src/index.html,该项目将在 http://localhost:1234/ 上可用。否则,请回到第11章的开头以获取说明。
项目的数据文件夹包含三个文件:
这些文件已经加载在 main.js ,我们也在其中调用函数 drawWorldMap() ,我们将获奖者数据集和世界地图 GeoJSON 数据传递给它。要开始使用,请转到函数 drawWorldMap() 中的文件 map-world.js 。
在示例 13.1 中,我们首先声明映射的维度,并使用 index 中已经存在的 map id 属性将 SVG 容器附加到 p 中.html 。然后,我们使用 d3.geoEqualEarth() 声明一个相等地球类型的投影,并将其保存在一个常量命名的投影中。请注意,我们还没有设置它的 scale() 和 translate() 访问器函数。我们稍后将讨论这些参数。
我们还使用 d3.geoPath() 声明一个路径生成器函数,并将投影传递给其 projection() 访问器函数。最后,我们使用数据绑定模式为 GeoJSON 数据集中的每个特征附加一个路径元素,在 world.features 中找到,我们调用路径生成器函数来设置它们的 d 属性。
import { select } from "d3-selection"; #A
import { geoPath, geoEqualEarth } from "d3-geo"; #A
export const drawWorldMap = (laureates, world) => {
const width = 1230; #B
const height = 620; #B
const svg = select("#map") #C
.append("svg") #C
.attr("viewBox", `0 0 ${width} ${height}`); #C
const projection = geoEqualEarth(); #D
const geoPathGenerator = geoPath() #E
.projection(projection); #E
svg
.selectAll(".country-path") #F
.data(world.features) #F
.join("path") #F
.attr("class", "country-path") #F
.attr("d", geoPathGenerator) #G
.attr("fill", "#f8fcff")
.attr("stroke", "#09131b")
.attr("stroke-opacity", 0.4);
};
如果您保存项目并在浏览器中查看,您应该会看到世界地图!但您会注意到,它不会占用 SVG 容器中的所有可用空间,并且没有居中。这就是投影的 transition() 和 scale() 访问器函数的用途。
为了使地图居中,我们首先应用 SVG 容器宽度一半的水平过渡和其高度一半的垂直过渡,如以下代码片段所示。请注意,我们在数组中传递这些值。根据您正在使用的地理数据和 SVG 容器的大小,正确定位地图所需的过渡可能会有所不同,并且需要试错法。
const projection = geoEqualEarth()
.translate([width/2, height/2])
在缩放地图时,没有严格的规则可以应用,您需要尝试不同的比例因子,直到找到合适的比例因子。在下一段代码中,我们使用比例因子 220。之后,您的世界地图应如图 13.5 所示。
const projection = geoEqualEarth()
.translate([width/2, height/2])
.scale(220);
如您所见,使用 D3 从 GeoJSON 数据绘制地图非常简单!图13.6概括了三个主要步骤。
经纬网是表示地图上纬度和经度的网格线。它们可帮助用户更好地了解投影及其对 2D 地图制图表达的影响。他们还可以帮助他们估计位置的坐标。
不出所料,D3 有一个用于经纬网的生成器函数 d3.geoGraticule() ,它返回一个 GeoJSON 几何对象。默认情况下,它会创建一个均匀的经线网格,每 10° 平行一次,但在极点处除外,每隔 90° 绘制一次经线。图 13.7 显示了投影 d3.geoOrthographic() 上的默认经纬网,它类似于 3D 地球仪。
为了在世界地图上绘制经纬网,我们首先需要使用 d3.geoGraticule() 声明一个经纬网生成器函数。在清单 13.2 中,我们将此函数保存在名为 graticuleGenerator 的常量中。
然后,我们将一个组元素附加到 SVG 容器中,并将其保存在名为 graticules 的常量中。该组负责传播刻度的样式,例如其透明填充、描边颜色和描边宽度。
经纬网的网格是用一个路径元素绘制的。在代码中,我们将此路径元素附加到 经纬网 .您会注意到我们使用方法 datum() 将数据绑定到此路径元素而不是 data() 。datum 一词是数据的单数,此方法用于将一段数据绑定到单个元素。然后,我们将经纬网生成器传递给此方法。
最后,我们附加第二个路径元素以在地图上绘制边框。所需的基准面可通过 graticuleGenerator.outline 访问。在这两种情况下,d 属性都是由之前初始化的 geoPathGenerator 计算的。
因为我们希望经纬网出现在国家后面,所以它们的路径需要附加到国家路径之前,但在声明 geoPathGenerator 之后,因为我们需要它作为经纬网的 d 属性。
import { select } from "d3-selection";
import { geoPath, geoEqualEarth, geoGraticule } from "d3-geo";
export const drawWorldMap = (laureates, world) => {
...
const projection = geoEqualEarth()
const geoPathGenerator = geoPath()
.projection(projection);
const graticuleGenerator = geoGraticule(); #A
const graticules = svg #B
.append("g") #B
.attr("fill", "transparent") #B
.attr("stroke", "#09131b") #B
.attr("stroke-opacity", 0.2); #B
graticules #C
.append("path") #C
.datum(graticuleGenerator) #C
.attr("d", geoPathGenerator); #C
graticules #D
.append("path") #D
.datum(graticuleGenerator.outline) #D
.attr("d", geoPathGenerator); #D
svg
.selectAll(".country-path")
.data(world.features)
.join("path")
.attr("class", "country-path")
.attr("d", geoPathGenerator)
.attr("fill", "#f8fcff")
.attr("stroke", "#09131b")
.attr("stroke-opacity", 0.4);
};
完成此步骤后,我们可以欣赏如何将 3D 地球投影到 2D 表面上,如图 13.8 所示。
分区统计图是一种使用区域(例如国家/地区)的颜色对数据进行编码的地图。我们用它来比较统计数据,例如国家的GDP,人口或诺贝尔奖获得者的数量!
要创建分区统计图,我们需要一个色阶。在我们的项目中,一个国家出生的诺贝尔奖获得者的数量可以在0到289之间变化。我们希望没有诺贝尔奖获得者出生的国家保持白色,因此我们需要涵盖1到近300范围的颜色。在这种情况下,顺序色阶非常方便。它们将连续域作为输入(在任何国家/地区出生的获奖者数量),并返回连续的输出范围,即相关颜色。D3 具有 d3 比例色模块 (https://github.com/d3/d3-scale-chromatic) 中提供的预构建顺序颜色函数。对于我们的项目,我们将使用顺序多色调配色方案 d3.interpolateYlGnBu ,其中 YlGnBu 代表黄-绿-蓝。
图 13.9 显示了此色阶。诺贝尔奖获得者人数较少的国家将以黄色阴影显示,拥有大约 50 名获奖者的国家将以绿松石色显示,而获得者最多的国家将以深蓝色显示。我们将域名的上限限制为100位诺贝尔奖获得者。因为只有一个国家(美国)的获奖者明显超过100人,使用实际值289作为上限将导致几乎所有其他国家在比较中显示为黄色,使国家之间的差异几乎无法阅读。
在清单 13.3 中,我们打开文件比例.js并使用方法 d3.scaleSequential() 初始化一个顺序色阶。我们将其保存在一个名为 国家色垢 ,我们将其导出以备将来使用。我们将比例的域设置为包含两个值的数组:1,我们要应用颜色的最低值,以及 100,我们要限制色阶的值。
请注意,我们不设置比例范围。相反,我们将函数 interpollateYlGnBu 传递给 scaleSequential() ,这将负责返回所需的颜色。
import { scaleSequential } from "d3-scale";
import { interpolateYlGnBu } from "d3-scale-chromatic";
export const countryColorScale = scaleSequential(interpolateYlGnBu)
.domain([1, 100]);
为了使颜色显示在地图上,我们需要计算每个国家出生的诺贝尔奖获得者的数量。在清单 13.4 中,我们回到地图世界.js并遍历 GeoJSON 数据的特征对象中包含的国家/地区。如前所述,每个国家/地区都有一个名为属性的对象,该对象可以包含有关国家/地区的相关信息,例如其名称。这是存放与该国有关的一系列诺贝尔奖获得者的理想场所。为此,我们过滤获奖者的数组,只保留出生地与当前国家名称相对应的人。
export const drawWorldMap = (laureates, world) => {
world.features.forEach(country => {
const props = country.properties;
props.laureates = laureates.filter(laureate =>
laureate.birth_country === props.name);
});
...
};
然后,将色阶导入文件后,我们返回到附加国家/地区路径的代码。我们没有给它们一个填充 #f8fcff,而是使用三元运算符在获奖者数量大于零时调用色阶,如清单 13.5 所示。我们将获奖者的数量传递给 scale 函数,该函数返回相应的颜色。
import { countryColorScale } from "./scales";
export const drawWorldMap = (laureates, world) => {
...
svg
.selectAll(".country-path")
.data(world.features)
.join("path")
.attr("class", "country-path")
.attr("d", geoPathGenerator)
.attr("fill", d => d.properties.laureates.length > 0
? countryColorScale(d.properties.laureates.length)
: "#f8fcff")
.attr("stroke", "#09131b")
.attr("stroke-opacity", 0.4);
};
我们现在有一个分区统计图,如图 13.10 所示。您会注意到诺贝尔奖获得者最多的国家是美国,其次是英国、德国和法国。在 index.html 中,您可以使用一类图例容器取消注释 p,这将使颜色图例出现在地图左侧。我们创建了这个带有CSS背景属性和线性渐变的图例,您可以在可视化中找到.css .
请记住,分区统计图虽然有用,但受制于所谓的面积单位问题,即当您绘制边界或选择现有要素以不成比例地表示统计数据的方式时,就会发生这种情况。例如,地理区域的大小可能远大于其数据值,从而造成过度代表性。例如,与日本相比,俄罗斯的领土是巨大的。尽管两国的人口相似,但应用于俄罗斯的任何颜色都会比在较小领土上使用的颜色引起更多的关注。
我们的分区统计图很好地概述了或多或少诺贝尔奖获得者出生的国家。但我们不能仅仅从颜色上确切地知道每个国家有多少获奖者出生。因为我们使用数字可视化,所以我们可以通过交互轻松解决这个问题!当鼠标位于某个国家/地区上方时,我们将显示一个工具提示,其中包含其名称和在那里出生的获奖者人数。文件索引.html已经包含一个 id 为 map-tooltip 的 p,它绝对位于地图的右上角。我们将在此处注入工具提示的文本。
在示例 13.6 中,我们创建了两个函数:showTooltip() 和 hideTooltip(),它使工具提示与所需的文本一起显示,以及 hideTooltip(),它隐藏工具提示。我们还将事件侦听器添加到国家/地区的路径中。当鼠标进入其中一个时,我们提取所需的文本并调用 showTooltip() 。当鼠标离开时,我们调用 hideTooltip() 。
...
import { transition } from "d3-transition";
export const drawWorldMap = (laureates, world) => {
...
const showTooltip = (text) => { #A
select("#map-tooltip") #A
.text(text) #A
.transition() #A
.style("opacity", 1); #A
}; #A
const hideTooltip = () => { #B
select("#map-tooltip") #B
.transition() #B
.style("opacity", 0); #B
}; #B
svg
.selectAll(".country-path")
.data(world.features)
.join("path")
.attr("class", "country-path")
.attr("d", geoPathGenerator)
.attr("fill", d => d.properties.laureates.length > 0
? countryColorScale(d.properties.laureates.length)
: "#f8fcff")
.attr("stroke", "#09131b")
.attr("stroke-opacity", 0.4)
.on("mouseenter", (e, d) => { #C
const p = d.properties; #C
const lastWord = p.laureates.length > 1 #C
? "laureates" #C
: "laureate" #C
const text = `${p.name}, ${p.laureates.length} ${lastWord}` #C
showTooltip(text); #C
}) #C
.on("mouseleave", hideTooltip); #D
};
图 13.11 显示了鼠标位于印度上方时的工具提示。
投影不仅用于显示区域;它们还用于在地图上放置单个点。通常,您认为城市或人口不是由其空间足迹表示的(尽管您这样做是针对特别大的城市),而是使用地图上的单个点来表示,该点的大小基于变量(例如人口)。我们可以使用 D3 投影来计算城市的位置,方法是向它传递一个包含经度和纬度坐标的数组。然后,它返回 SVG 容器内该点的相应坐标。
假设我们希望我们的地图也显示诺贝尔奖获得者出生的城市。取消注释索引中 id 为“filters-container”的 p.html以显示单选按钮以选择获奖者的国家或城市地图。
在示例 13.7 中,我们监听单选按钮的选择,并调用一个显示国家或城市的函数。我们还重构了前面的代码,以允许我们在两个映射状态之间切换。当页面加载时,我们调用函数 displayCountry() ,负责将色阶应用于国家/地区。
export const drawWorldMap = (laureates, world) => {
...
let isCountryMap = true; #A
svg
.selectAll(".country-path")
.data(world.features)
.join("path")
.attr("class", "country-path")
.attr("d", geoPathGenerator)
.attr("stroke", "#09131b")
.attr("stroke-opacity", 0.4);
const updateCountryFills = () => {
selectAll(".country-path")
.on("mouseenter", (e, d) => {
const p = d.properties;
const lastWord = p.laureates.length > 1 ? "laureates" : "laureate";
const text = `${p.name}, ${p.laureates.length} ${lastWord}`;
showTooltip(text);
})
.on("mouseleave", hideTooltip)
.transition()
.attr("fill", d => d.properties.laureates.length > 0
? countryColorScale(d.properties.laureates.length)
: "#f8fcff");
};
const displayCountries = () => {
isCountryMap = true;
updateCountryFills();
};
const displayCities = () => {};
selectAll("input#countries, input#cities") #B
.on("click", e => { #B
if (e.target.id === "countries") { #B
displayCountries(); #B
} else if (e.target.id === "cities") { #B
displayCities(); #B
} #B
}); #B
displayCountries(); #C
};
每位诺贝尔奖获得者的出生城市可在获奖者的键birth_city下找到。它还包含该城市的纬度和经度在 birth_city_latitude 和 birth_city_longitude .在地图上显示这些城市之前,我们将创建一个名为 cities 的新数组,其中包含诺贝尔奖获得者出生的每个城市、其纬度、经度以及相关获奖者的数组。示例 13.8 显示了我们如何进行。
export const drawWorldMap = (laureates, world) => {
const cities = []; #A
laureates.forEach(laureate => { #B
if (laureate.birth_country !== "" && laureate.birth_city !== "") { #B
const relatedCity = cities.find(city => #C
city.city === laureate.birth_city) #C
&& cities.find(city => city.country === laureate.birth_country); #C
#C
if (relatedCity) { #C
relatedCity.laureates.push(laureate); #C
} else { #C
cities.push({ #C
city: laureate.birth_city, #C
country: laureate.birth_country, #C
latitude: laureate.birt_city_latitude, #C
longitude: laureate.birt_city_longitude, #C
laureates: [laureate] #C
}); #C
} #C
}
});
...
};
我们现在已准备好在地图上显示城市!我们将在 cities 数组中每个城市的 SVG 容器中附加一个圆圈,并根据在该城市出生的候选人数量缩放其半径。在示例 13.9 中,我们打开 scales.js并声明一个函数 getCityRadius() ,它接收两个数字:一个城市的获奖者数量和最大可能的获奖者数量。我们使用第二个数字初始化径向刻度,并返回与获奖者数量对应的半径。
import { scaleSequential, scaleRadial } from "d3-scale";
import { interpolateYlGnBu } from "d3-scale-chromatic";
export const countryColorScale = scaleSequential(interpolateYlGnBu)
.domain([1, 100]);
export const getCityRadius = (numLaureates, maxLaureates) => {
const cityRadiusScale = scaleRadial()
.domain([0, maxLaureates])
.range([0, 25]);
return cityRadiusScale(numLaureates);
};
要计算每个城市在地图上的位置,我们可以调用第 13.2.1 节中创建的投影。以下代码片段提供了一个示例。假设我们有一个投影函数保存在一个常量中 命名投影 .如果我们传递一个包含经度和纬度的数组,投影函数将返回 SVG 容器中相应坐标的数组。
const projection = d3.geoEqualEarth();
[x, y] = projection([longitude, latitude]);
示例 13.10 很长,但一旦我们分解它,它就相当简单。它包含四个主要功能:
在这一点上,前两个函数似乎没有必要,但我们稍后在添加画笔功能时将需要它们。
...
import { select, selectAll } from "d3-selection";
import { max } from "d3-array";
import { countryColorScale, getCityRadius } from "./scales";
export const drawWorldMap = (laureates, world) => {
...
const updateCountryFills = () => { #A
selectAll(".country-path")
.on("mouseenter", (e, d) => {
const p = d.properties;
const lastWord = p.laureates.length > 1 ? "laureates" : "laureate";
const text = `${p.name}, ${p.laureates.length} ${lastWord}`;
showTooltip(text);
})
.on("mouseleave", hideTooltip)
.transition()
.attr("fill", d => d.properties.laureates.length > 0
? countryColorScale(d.properties.laureates.length)
: "#f8fcff");
};
const maxLaureatesPerCity = max(cities, d => d.laureates.length);
const updateCityCircles = () => { #B
selectAll(".circle-city")
.on("mouseenter", (e, d) => {
const lastWord = d.laureates.length > 1 ? "laureates" : "laureate";
const text = `${d.city}, ${d.laureates.length} ${lastWord}`;
showTooltip(text);
})
.on("mouseleave", hideTooltip)
.transition()
.attr("r", d => getCityRadius(d.laureates.length,
maxLaureatesPerCity));
};
const displayCountries = () => { #C
isCountryMap = true; #C
#C
selectAll(".circle-city") #C
.transition() #C
.attr("fill-opacity", 0) #C
.attr("stroke-opacity", 0) #C
.remove(); #C
#C
updateCountryFills(); #C
}; #C
const displayCities = () => {
isCountryMap = false;
selectAll(".country-path") #D
.on("mouseenter", null) #D
.on("leave", null) #D
.transition() #D
.attr("fill", "#f8fcff"); #D
selectAll(".circle-city") #E
.data(cities) #E
.join("circle") #E
.attr("class", "circle-city") #E
.attr("cx", d => projection([d.longitude, d.latitude])[0]) #F
.attr("cy", d => projection([d.longitude, d.latitude])[1]) #F
.attr("fill", "#35a7c2")
.attr("fill-opacity", 0.5)
.attr("stroke", "#35a7c2");
updateCityCircles();
};
selectAll("input#countries, input#cities")
.on("click", e => {
if (e.target.id === "countries") {
displayCountries();
} else if (e.target.id === "cities") {
displayCities();
}
});
displayCountries();
};
为了完成此可视化,我们在选择获奖者国家/地区选项时显示颜色图例,并显示获奖者城市的半径图例选项。我们已经为您创建了半径图例。在清单 13.11 中,我们所要做的就是调用函数 drawLegend() 并向其传递每个城市的最大获奖者数量。
...
import { drawLegend } from "./legend";
export const drawWorldMap = (laureates, world) => {
...
const displayCountries = () => {
...
select(".legend-cities")
.style("display", "none");
select(".legend-countries")
.style("display", "flex");
};
const displayCities = () => {
...
select(".legend-countries")
.style("display", "none");
select(".legend-cities")
.style("display", "block");
};
drawLegend(maxLaureatesPerCity);
};
我们已经完成了使用经度和纬度在地图上显示城市!现在,您应该能够使用单选按钮在地图的两个状态之间切换。您可能会注意到城市圈非常密集,尤其是在欧洲。我们稍后将添加的刷牙功能将有助于使其更具可读性。
地图在网络上很普遍,用户希望它们具有一定程度的交互性,缩放和平移地图的能力是第一个想到的。缩放和平移允许用户专注于感兴趣的区域并查看更多详细信息。在本节中,我们将实现缩放功能,同时控制用户可以缩放和平移地图的程度。
D3 有一个专门用于缩放和平移功能的模块 d3-zoom (https://github.com/d3/d3-zoom),实现这样的功能非常简单。在示例 13.12 中,我们使用方法 d3.zoom() 声明一个新的缩放行为,并将其保存在名为 zoomHandler 的常量中。我们还将一个侦听器附加到 zoomHandler,用于检测“缩放”事件。检测到此事件时,我们会将其登录到控制台。
为了使缩放行为可操作,我们选择具有“map-container”类(SVG 容器的第一个父级)的 p,并使用 call() 方法将 zoomHandler 附加到此选择。
...
import { zoom } from "d3-zoom";
export const drawWorldMap = (laureates, world) => {
...
const zoomHandler = zoom() #A
.on("zoom", (e) => { #B
console.log(e); #B
}); #B
select(".map-container") #C
.call(zoomHandler); #C
};
现在保存您的项目并将光标放在地图上。通过转动鼠标滚轮或使用触控板捏合来开始缩放地图。什么都不会发生,这是意料之中的,因为我们还没有对地图应用任何变换。但是,如果您打开浏览器检查器的控制台,您应该会看到正在登录的缩放事件,如图 13.13 所示。此事件包含一个具有 k 的变换对象,缩放因子;x、水平平移;和y,垂直平移。
然后,我们可以使用转换对象根据检测到的事件缩放和移动地图。在示例 13.13 中,我们基于此事件更新 SVG 容器的 transform 属性。
export const drawWorldMap = (laureates, world) => {
...
const zoomHandler = zoom()
.on("zoom", (e) => {
console.log(e);
svg.attr("transform", e.transform); #A
});
select(".map-container")
.call(zoomHandler);
};
如果返回到浏览器,缩放和平移功能现在应按预期工作。但是我们可以看到地图溢出了由黑色边框划定的区域,看起来并不整洁。幸运的是,这是一个简单的解决方案!转到可视化.css并找到应用于 .map 容器选择的样式,该选择是 SVG 容器的第一个父项。将其溢出属性设置为隐藏,瞧,不再溢出!
.map-container {
position: relative;
border: 1px solid #09131b;
overflow: hidden;
}
我们还应该限制用户可以缩放和平移地图的程度,以避免地图完全不在视野之外的情况。缩放行为有两个方便的访问器函数, scaleExtent() 和 translateExtent() ,顾名思义,它们允许我们设置用户可以缩放和翻译选择的范围。这些函数采用最小值和最大值的数组。在清单 13.14 中,我们将缩放的最小比例设置为 1 或 100%,将最大值设置为 5 或 500%。我们还将平移范围设置为水平地图宽度的一半和垂直高度的一半。
export const drawWorldMap = (laureates, world) => {
...
const zoomHandler = zoom()
.scaleExtent([1, 5]) #A
.translateExtent([[-width/2, -height/2], [3*width/2, 3*height/2]]) #B
.on("zoom", (e) => {
console.log(e);
svg.attr("transform", e.transform);
});
select(".map-container")
.call(zoomHandler);
};
允许用户轻松地将地图重置为其初始状态也会很好。在 index.html 中,首先取消注释 ID 为 “map-reset” 的按钮。您将在地图的右上角看到一个带有文本“重置缩放”的蓝色按钮。
由于在执行任何缩放之前显示重置按钮没有意义,因此我们将在相关时向重置按钮添加和删除“隐藏”类名。此类名称将其不透明度设置为零,并阻止其检测单击(请参阅可视化.css )。我们通过在页面加载时将类名“隐藏”添加到重置按钮来做到这一点。然后,当检测到缩放事件时,我们检查重置按钮是否具有“隐藏”类名。如果是这种情况,我们会将其删除。
单击按钮时会发生实际重置。在示例 13.15 中,我们在复位按钮上附加了一个 click 事件的侦听器。当检测到单击时,我们调用附加到 .map-container 选择的 zoomHandler() 的 transform() 访问器函数。作为第二个参数,我们传递所需的转换值,为此我们可以使用函数 d3.zoomIdentity .此函数是指比例为 1 或 100% 且平移为零的转换,因此重置。
...
import { zoom, zoomIdentity } from "d3-zoom";
export const drawWorldMap = (laureates, world) => {
...
const zoomHandler = zoom()
.scaleExtent([1, 5])
.translateExtent([[-width/2, -height/2], [3*width/2, 3*height/2]])
.on("zoom", (e) => {
console.log(e);
svg.attr("transform", e.transform);
if (select("#map-reset").classed("hidden")) { #A
select("#map-reset") #A
.classed("hidden", false); #A
} #A
if (e.transform.k === 1 && #B
e.transform.x === 0 && #B
e.transform.y === 0) { #B
select("#map-reset") #B
.classed("hidden", true); #B
} #B
});
select(".map-container")
.call(zoomHandler);
select("#map-reset") #C
.attr("class", "hidden") #C
.on("click", () => { #D
select(".map-container") #D
.transition() #D
.call(zoomHandler.transform, zoomIdentity); #D
}); #D
};
如果在缩放后单击重置按钮,地图应恢复到其初始状态。
缩放视频
当你想象自己放大事物时,你自然会想到增加它们的大小。但是,通过使用映射,您可以做的不仅仅是在放大时增加大小或分辨率;您还可以更改向读者显示的数据类型。与本节中实现的图形缩放相比,此类功能称为语义式缩放。当您查看缩小的地图并仅看到国家边界和几个主要城市时,这一点最为明显,但当您放大时,您会看到道路、小城镇、公园等。移动设备上的地图通常使用这种语义式缩放。您甚至可以在允许用户放大和缩小任何数据可视化(而不仅仅是地图)时应用语义式缩放。它允许您在缩小时显示战略或全局信息,在放大时显示高分辨率数据。
另一种与地图完美结合的交互性是刷牙。我们的项目可视化了从 1901 年到 2022 年的数据。但是,如果我们想知道20世纪初诺贝尔奖获得者的起源是否与现在相似呢?通过收听画笔事件,我们可以让用户在时间轴上选择特定的年份范围。为此,我们将使用模块 d3-brush (https://github.com/d3/d3-brush) 中的方法。
在示例 13.16 中,我们简单地使用 D3 轴创建一个时间轴,作为我们画笔功能的基础。
...
import { max, min } from "d3-array";
import { scaleLinear } from "d3-scale";
import { axisBottom } from "d3-axis";
import { format } from "d3-format";
export const drawWorldMap = (laureates, world) => {
...
const tlWidth = 1000; #A
const tlHeight = 80; #A
const tlMargin= { top: 0, right: 10, bottom: 0, left: 0 }; #A
const tlInnerWidth = tlWidth - tlMargin.right - tlMargin.left; #A
const minYear = min(laureates, d => d.year); #B
const maxYear = max(laureates, d => d.year); #B
const xScale = scaleLinear() #B
.domain([minYear, maxYear]) #B
.range([0, tlInnerWidth]); #B
const yearsSelector = select("#years-selector") #C
.append("svg") #C
.attr("viewBox", `0 0 ${tlWidth} ${tlHeight}`); #C
const xAxisGenerator = axisBottom(xScale) #D
.tickFormat(format("")) #D
.tickSizeOuter(0); #D
yearsSelector #E
.append("g") #E
.attr("class", "axis-x") #E
.attr("transform", `translate(0, 30)`) #E
.call(xAxisGenerator); #E
};
创建后,您的时间轴应如图 13.14 所示。
正如您在清单 13.17 中看到的,D3 的画笔功能的设置方式类似于上一节中讨论的缩放。首先,我们使用 d3.brushX() 创建一个一维画笔,并将其保存在名为 brushHandler 的常量中。使用访问器函数 extent() ,我们让 D3 知道哪个区域是可刷的:从时间轴的左上角到右下角。然后,我们为调用函数handleBrush()的“brush”事件附加一个侦听器。目前,handleBrush() 仅在控制台中记录事件,但这是我们稍后将执行操作的地方。
我们还需要将 brushHandler 函数附加到时间轴选择,并初始化默认画笔,使其覆盖整个时间线。
...
import { brushX } from "d3-brush";
export const drawWorldMap = (laureates, world) => {
...
const handleBrush = (e) => { #A
console.log(e); #A
}; #A
const brushHandler = brushX() #B
.extent([[0, 0], [tlInnerWidth, tlHeight]]) #C
.on("brush", handleBrush); #D
yearsSelector #E
.call(brushHandler) #E
.call(brushHandler.move, [xScale(minYear), xScale(maxYear)]); #F
};
此时,您应该在时间轴上看到一个蓝色矩形,在画笔区域的控点上看到灰色矩形,如图 13.15 所示。
此蓝色矩形表示所选年份范围或画笔区域。如果打开检查器工具并查看标记,则会看到 D3 在时间轴顶部添加了四个矩形:
文件可视化.css包含以下样式,以使最后三个矩形可识别。
.selection {
fill: #35a7c2;
fill-opacity: 0.3;
stroke: none;
}
.handle {
fill: #09131b;
fill-opacity: 0.3;
}
现在,通过按 ctr 键(在 Mac 上为 cmd)并在时间轴上拖动鼠标来触发刷牙事件。您还可以使用手柄调整所选区域的大小,或左右拖动整个选区。查看登录到控制台的刷牙事件。如图 13.16 所示,您将观察到 brushing 事件包含一个选择数组,该数组包含所选内容的左边框和右边框的水平位置 如果我们使用 d3.brush() 而不是 d3.brushX() ,我们将获得选择左上角和右下角的 x 和 y 坐标。
我们可以使用选择的坐标来确定相应的年份。连续刻度(如我们用于绘制时间轴的线性刻度)有一个 invert() 方法,该方法接受范围内的值并从域返回相应的值。这种方法颠倒了我们通常使用比例的方式。我们对选择的最小值和最大值进行舍入,因为我们不希望年份是浮点数并更新名为 brushMin 和 brushMax 的两个变量。稍后我们将使用这些变量根据选择更新国家/地区颜色或城市圈。但是,当页面加载时,这些变量将设置为时间线的最小和最大年份,这是默认选择。
...
import { brushX } from "d3-brush";
export const drawWorldMap = (laureates, world) => {
...
const minYear = min(laureates, d => d.year);
const maxYear = max(laureates, d => d.year);
let brushMin = minYear;
let brushMax = maxYear;
const handleBrush = (e) => {
console.log(e);
brushMin = Math.round(xScale.invert(e.selection[0])); #A
brushMax = Math.round(xScale.invert(e.selection[1])); #A
if (isCountryMap) { #B
updateCountryFills(); #B
} else { #B
updateCityCircles(); #B
} #B
};
const brushHandler = brushX()
.extent([[0, 0], [tlInnerWidth, tlHeight]])
.on("brush", handleBrush);
yearsSelector
.call(brushHandler)
.call(brushHandler.move, [xScale(minYear), xScale(maxYear)]);
};
要根据选择更新国家/地区颜色或城市圈,我们调用函数 updateCountryFills() 或 updateCityCircles() 之前声明。更新国家/地区颜色时,我们首先制作 GeoJSON 数据集的深层副本,以避免修改原始数据。然后,我们过滤掉未包含在选择中的获奖者,并更新绑定到国家路径的数据。我们在通过制作深度复制和过滤城市数组来更新城市圈的函数中进行类似的操作。
...
import { brushX } from "d3-brush";
export const drawWorldMap = (laureates, world) => {
...
const updateCountryFills = () => {
const selectedData = JSON.parse(JSON.stringify(world.features)); #A
selectedData.forEach(d => { #B
if (d.properties.laureates) { #B
d.properties.laureates = d.properties.laureates.filter(l => #B
l.year >= brushMin && l.year <= brushMax); #B
} #B
}); #B
selectAll(".country-path")
.data(selectedData) #C
.on("mouseenter", (e, d) => {
const p = d.properties;
const lastWord = p.laureates.length > 1 ? "laureates" : "laureate";
const text = `${p.name}, ${p.laureates.length} ${lastWord}`;
showTooltip(text);
})
.on("mouseleave", hideTooltip)
.transition()
.attr("fill", d => d.properties.laureates.length > 0
? countryColorScale(d.properties.laureates.length)
: "#f8fcff");
};
const maxLaureatesPerCity = max(cities, d => d.laureates.length);
const updateCityCircles = () => {
const selectedData = JSON.parse(JSON.stringify(cities)); #D
selectedData.forEach(city => { #E
city.laureates = city.laureates.filter(l => #E
l.year >= brushMin && l.year <= brushMax); #E
}); #E
selectAll(".circle-city")
.data(selectedData) #F
.on("mouseenter", (e, d) => {
const lastWord = d.laureates.length > 1 ? "laureates" : "laureate";
const text = `${d.city}, ${d.laureates.length} ${lastWord}`;
showTooltip(text);
})
.on("mouseleave", hideTooltip)
.transition()
.attr("r", d => getCityRadius(d.laureates.length,
maxLaureatesPerCity));
};
};
我们的刷牙功能现已全面投入使用!使用获奖者的国家和获奖者的城市选项进行测试。
刷牙视频
我们已经讨论过存储地理数据的两种主要文件类型是GeoJSON和TopoJSON。到目前为止,我们只使用过GeoJSON,但在本节中,我们将使用TopoJSON数据绘制法国96个省的地图。
要从 TopoJSON 数据绘制地图,我们必须安装库 Topojson 客户端 (https://www.npmjs.com/package/topojson-client),它将数据转换为 D3 可以读取和操作的格式。要将库安装到项目中,请打开集成终端并运行命令 npm i topojson-client 。然后,打开文件地图-法国.js .我们将在函数 drawFranceMap() 中工作。
在清单 13.19 中,我们通过调用 topojson-client 库的 feature() 方法将 TopoJSON 数据转换为 GeoJSON 特征。此方法采用两个参数:topoJSON 数据集(也称为拓扑)和包含数据集中几何的对象(可在 keys 对象下找到)。FRA_adm2 .我们将这些提取的GeoJSON特征保存到一个常量命名的部门中。
我们还需要使用 topojson-client 的 mesh() 方法单独提取边界,该方法返回一个 GeoJSON MultiLineString 几何对象。我们稍后会用它来绘制边界。
import * as topojson from "topojson-client";
export const drawFranceMap = (laureates, france) => {
let departments = topojson.feature(france, #A
france.objects.FRA_adm2).features; #A
let borders = topojson.mesh(france, france.objects.FRA_adm2); #B
};
之后,绘制地图与我们之前对GeoJSON数据所做的非常相似。在清单 13.20 中,我们使用 d3.geoMercator() 声明一个新的墨卡托投影,后跟一个地理路径生成器。然后,我们使用上一步中生成的部门对象将部门路径追加到 SVG 容器。我们不会为这些路径提供笔画属性。相反,我们附加一个额外的路径元素并通过将 borders 对象传递给地理路径生成器来设置其 d 属性,从而创建边框。这样,边界将使用一条路径绘制,避免重叠。
import * as topojson from "topojson-client";
import { select } from "d3-selection";
import { geoMercator, geoPath } from "d3-geo";
export const drawFranceMap = (laureates, france) => {
let departments = topojson.feature(france,
france.objects.FRA_adm2).features;
let borders = topojson.mesh(france, france.objects.FRA_adm2);
const width = 800; #A
const height = 800; #A
const projection = geoMercator() #B
.scale(3000) #B
.translate([280, 3150]); #B
const geoPathGenerator = geoPath() #C
.projection(projection); #C
const svg = select("#map-france") #D
.append("svg") #D
.attr("viewBox", `0 0 ${width} ${height}`); #D
svg #E
.selectAll(".department") #E
.data(departments) #E
.join("path") #E
.attr("class", "department") #E
.attr("d", d => geoPathGenerator(d)) #E
.attr("fill", "#f8fcff"); #E
svg #F
.append("path") #F
.attr("class", "departments-borders") #F
.attr("d", geoPathGenerator(borders)) #F
.attr("fill", "none") #F
.attr("stroke", "#09131b") #F
.attr("stroke-opacity", 0.4); #F
};
您的法国地图现在应该类似于图 13.17 中的地图。
如本章开头所述,TopoJSON 的优点之一是数据中没有冗余。每个共享边框都是一个圆弧,而不是两个单独的笔触。这种方法不仅可以创建较小的文件,还可以使地图看起来更干净:重复的边框不会绘制两次。图 13.18 显示了我们的 GeoJSON 世界地图中的边界重复,与我们为法国地图获得的整齐边界进行了比较。
为了完成这个项目,让我们在诺贝尔奖获得者出生的法国城市上画圈。这个过程和我们在世界地图上做的时候完全一样。
1. 过滤获奖者的数据集,只保留在法国出生的人。
2.提取他们的出生城市和绘制圆圈所需的信息。
3. 为每个诺贝尔奖获得者出生的城市附加一个圆圈。通过从比例尺调用 getCityRadius() 函数来获取圆半径.js并使用城市的纬度和经度来计算圆的位置。
4. 完成后,您的地图将如下图所示。
如果您在任何时候遇到困难或想将您的解决方案与我们的解决方案进行比较,您可以在附录 D 的 D.13 节和文件夹 13.5-TopoJSON / 本章代码文件的末尾找到它。但是,像往常一样,我们鼓励您尝试自己完成它。您的解决方案可能与我们的略有不同,没关系!
如前所述,你可以用D3的映射功能做的事情将填满整本书。本节将介绍本章中未涉及的其他一些功能。
我们可以使用瓷砖构建地图:单个图像无缝连接以生成地图,就像 Google 地图使用的地图一样。我们可以将 d3-geo 与 d3 切片模块 (https://github.com/d3/d3-tile) 一起使用,在栅格切片上叠加矢量特征。如果您真的想开发基于切片的地图,您可能需要深入研究像mapboxGL(www.mapbox.com/mapbox-gl-js/api/)这样的专用库。
d3.geoPath() 的 path.context 函数允许您轻松地将矢量数据绘制到
您可以使用栅格重投影在 d3-geo 模块提供的投影之一上显示卫星投影地形图或地形图。您可以在 https://observablehq.com/@mbostock/raster-reprojection-ii 上看到Mike Bostock创建的示例。
d3-hexbin 模块 (https://github.com/d3/d3-hexbin) 允许您轻松地为地图创建六边形图框叠加层,如图 13.19 所示。当您拥有点形式的定量数据并希望按面积聚合数据时,此选项很有趣。
与十六进制数据桶一样,如果您只有点数据并希望从中创建面积数据,则可以使用 d3-delaunay 模块 (https://github.com/d3/d3-delaunay) 从图 13.20 所示的州首府等点派生多边形。Voronoi 图是将平面划分为定义最接近预定义点的区域的单元格。
页面更新:2024-02-23
本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828
© CopyRight 2020-2024 All Rights Reserved. Powered By 71396.com 闽ICP备11008920号-4
闽公网安备35020302034903号