全网首发,基于D3.js绘制的鹰眼地图(迷你地图)
迷你地图(Mini Map
)或鹰眼地图(Overview Map
)的主要作用是提供全局视角,帮助用户在更大或更复杂的地图上进行导航和定位。它通常在 GIS
(地理信息系统)、大规模数据可视化或复杂图形界面中使用。起到快速导航、全局视图和上下文定位、简化缩放和平移操作的作用。
本文介绍一种使用D3.js
(版本6)绘制鹰眼地图的方法,只做基础绘制,不做具体的主地图交互,有兴趣朋友可自行拓展。本文提供两种绘制思路,以及遇到的问题供爱好可视化的朋友一起探讨。
先看成品:
一、绘制基础地图
1.1 样式设置
先给出样式,主要是对主地图和迷你地图布局进行设置,确保迷你地图位于右下角。主地图全屏显示(读者可根据自己需求设置宽高)。
<style>
html,
body {
margin: 10px;
padding: 0;
width: 100%;
height: 100%;
}
/* 包含主地图和迷你地图的容器 */
.map-container {
position: relative;
width: 100%;
height: 100%;
background-color: #e0e0e0;
}
/* 主地图样式 */
#mainMap {
border: 1px solid black;
background-color: #aaffff;
width: 100%;
height: 100%;
}
/* 迷你地图样式 */
#eagleEyeMap {
border: 1px solid red;
background-color: #55ff7f;
position: absolute;
bottom: 20px;
right: 20px;
z-index: 10;/* 确保迷你地图显示在主地图之上 */
box-shadow: 0 0 5px rgba(0, 0, 0, 0.3);/* 迷你地图的阴影效果 */
}
/* 笔刷样式 */
.brush .selection{
fill: #888;
fill-opacity: 0.5;
stroke: #000;
}
/* path样式 */
.mapPath {
stroke: black
}
</style>
1.2 容器‘
这里采用svg
的viewBox
属性来调整视图。读者可自定义主地图的宽高,为了简便建议使用width="800" height="600"
,这样viewBox
全填充svg。
<div class="map-container">
<svg id="mainMap" viewBox="0 0 800 600"></svg>
<svg id="eagleEyeMap" width="200" height="200" viewBox="0 0 200 200"
preserveAspectRatio="xMidYMid meet"></svg>
</div>
1.3 基础script代码
设置地图大小和缩略图大小
const width = 800, height = 600;
const eagleEyeWidth = 200, eagleEyeHeight = 200;
let mappath;
对两个地图均使用墨卡托投影(Mercator projection
),这种投影的特点是所有经线均垂直相交于赤道,并且都是等角的。正是由于整个特性导致在高纬度地区,形状失真也很明显,特别是接近极点的地方。面积失真逐渐增大。靠近两极的地区(如格陵兰和南极洲)在地图上显得异常巨大,而实际上它们的面积远小于地图上显示的。这种投影会影响两地图之间的交互。
const projection = d3.geoMercator()
const eagleEyeProjection = d3.geoMercator()
给每个国家随机分配一个颜色,用于后续观察。
// 生成随机颜色函数
function getRandomColor() {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
}
接着,读取地理json
文件,从TopoJSON
对象中提取出 countries
这个集合,并将这些国家的地理特征转换成一个 GeoJSON
格式的特征集合赋值给 features
变量。” 这个 features
变量随后可以被用于 D3.js 的地理路径生成器(d3.geoPath
)来绘制地图上的各个国家。
d3.json("https://d3js.org/world-110m.v1.json").then(function(world) {
const features = topojson.feature(world, world.objects.countries)
const countries = features.features;
// 为每个国家生成一个固定的颜色并存储
countries.forEach(function(country) {
country.color = getRandomColor();
});
......
})
随后,投影对象的fitSize
自动调整投影的缩放比例和中心点,以便完全适应给定尺寸的容器,并展示指定的地理特征。这时候可以直接绘制鹰眼地图,对于主地图的绘制有两种方法,后续会讲到。
projection.fitSize([width, height], features)
eagleEyeProjection.fitSize([eagleEyeWidth, eagleEyeHeight], features)
const path = d3.geoPath()
.projection(projection);
const eagleEyePath = d3.geoPath()
.projection(eagleEyeProjection);
// 直接绘制鹰眼地图
svgEagleEye.append("g")
.selectAll("path")
.data(countries)
.enter()
.append("path")
.attr("d", eagleEyePath)
.attr("fill", function(d) {
return d.color;
}) // 使用存储的颜色
.attr("class", "mapPath");
如果你看到这里,你已经完成了迷你地图的绘制了。你已经成功了一小部分啦。
定义笔刷行为,并用g元素添加到鹰眼画布上。
const brush = d3.brush()
.extent([
[0, 0],
[eagleEyeWidth, eagleEyeHeight]
])
.on("start", brushStart)
.on("brush", brushed)
.on("end", brushEnded);
svgEagleEye.append("g")
.attr("class", "brush")
.call(brush);
function brushStart(event) {
}
function brushed(event) {
}
function brushEnded(event) {
}
定义主地图缩放行为。这里并不限制缩放范围,主要是因为后面会根据笔刷范围来调整缩放范围。这里使用缩放倍数的倒数是为了视觉一致性。在地图上,我们通常希望线条的粗细看起来保持一致,不管地图是放大还是缩小。例如,如果地图放大了两倍,那么同样宽度的线条应该看起来是原来的一半粗细,以便保持视觉上的一致性。
假设原始的 stroke-width
为 s
,当缩放比例为 k
时,SVG
会自动将 stroke-width
调整为 s * k
。为了保持视觉上的粗细不变,我们需要手动将 stroke-width
设置回 s
,这可以通过设置 stroke-width
为 s / k
来实现。因为 s * k / k = s
,所以这会抵消缩放的影响。
const zoom = d3.zoom()
.on("zoom", zoomed)
.translateExtent([
[0, 0],
[width, height]
]) // 限制平移范围// 缩放范围;
svgMain.call(zoom);//应用到主地图
function zoomed(event) {
//event 对象,包含缩放的详细信息
const {
transform
} = event;
mainMapGroup.selectAll("path")
.attr("transform", transform)
.attr("stroke-width", 1 / transform.k);
}
二、主地图与鹰眼地图交互原理
如果两个地图使用相同的投影方式(d3.geoMercator()
),并且使用同一份地理json
数据,那么无论这两个地图放在何种尺寸的svg
画布中,经纬度与svg
坐标具有相互转化关系。如下图:
就是说在鹰眼地图的svg
坐标中的(x0,y0)
转化为对应的地理坐标(lng0,lat0)
。然后主地图通过获取这个坐标,来确定这个地理坐标在主地图svg
中,对应的坐标(x0Main,y0Main)
。这样在鹰眼地图中brush
中的区域,在主地图中就可以显示了。
代码实现:
function brushed(event) {
if (event.selection) {
const [
[x0, y0],
[x1, y1]
] = event.selection;
const [
[lng0, lat0],
[lng1, lat1]
] = [eagleEyeProjection.invert([x0, y0]), eagleEyeProjection.invert([x1, y1])];
const [
[x0Main, y0Main],
[x1Main, y1Main]
] = [projection([lng0, lat0]), projection([lng1, lat1])];
// 计算选中区域的宽高
const dx = x1Main - x0Main;
const dy = y1Main - y0Main;
// 计算缩放比例,考虑地图的宽高比
const scale = Math.max(width / dx, height / dy); // 确保宽高比例保持一致
// 被选中区域在主地图的中心点,便于把区域居中显示
const translateX = width / 2 - scale * (x0Main + dx / 2);
const translateY = height / 2 - scale * (y0Main + dy / 2);
}
}
接下来进入主地图的绘制部分。
三、方法一:主地图与鹰眼地图一样绘制
这种方法很好理解,就是主地图和鹰眼地图绘制同一份世界地图。然后鹰眼地图选中部分,在主地图把此部分裁剪出来。其余部分依然存在,但是不在裁剪区域内,所以看不见。
此方法优缺点对比:
优点 | 缺点 |
---|---|
一致性:用户在鹰眼地图上看到的内容与主地图完全一致,只是视图范围不同,这提供了很好的用户体验一致性。 | 资源浪费:即使用户只看到一小部分地图,整个地图数据仍然被加载和渲染,这可能会导致不必要的内存和计算资源消耗。 |
简化逻辑:后端只需要发送一份数据集,前端通过裁剪来实现不同的视图,减少了数据处理和传输的复杂性。 | 响应延迟:如果地图数据非常大,加载和渲染整个地图可能会导致初始加载时间较长,影响用户体验。 |
性能优化:对于大型数据集,裁剪操作通常比重新绘制整个地图的性能开销小。 | 限制灵活性:这种方法可能不适合那些需要根据用户选择动态加载或过滤数据的应用场景。 |
3.1 添加路径裁剪工具
const rect = svgMain.append("defs")
.append("clipPath") // 创建裁剪路径元素
.attr("id", "clip") // 设置id,稍后通过url(#clip)引用
.append("rect") // 定义裁剪的形状,这里是矩形
在SVG(Scalable Vector Graphics)
中,裁剪路径可以用来定义一个区域,只有在这个区域内的图形才会被显示,区域外的部分则会被隐藏。在SVG
中,<defs>
元素用于定义可重用的图形或者样式。它本身不会在页面上渲染任何内容,而是作为一个容器,存储了可以在整个SVG
文档中引用的元素。在<clipPath>
元素内部添加一个<rect>
元素,定义了一个矩形作为裁剪区域。这样,任何使用这个裁剪路径的图形都会被限制在这个矩形区域内显示。
<clipPath>
元素则是用来定义一个裁剪区域。当你在<clipPath>
内部定义形状(如矩形<rect>
、圆形<circle>
、路径<path>
等),SVG
会使用这个形状作为裁剪的模板。任何引用了这个裁剪路径的图形都会被裁剪,只显示在定义的形状内的部分。
至于裁剪工具的位置和大小,需要在brushed()
动态设置。
3.2 绘制主地图
主地图与鹰眼地图使用同一个数据集。
mappath = mainMapGroup.selectAll("path")
.data(countries)
.enter()
.append("path")
.attr("d", path)
.attr("fill", function(d) {
return d.color;
}) // 使用存储的颜色
.attr("class", "mapPath")
绘制效果如下:
此时如果不对区域进行裁剪和主视图移动到中心位置,主地图将不会有任何显示。
动态设置裁剪区域范围,让主地图始终显示的是被选中的区域。
rect.attr("x", x0Main) // 矩形的左上角x坐标
.attr("y", y0Main) // 矩形的左上角y坐标
.attr("width", Math.max(0, dx)) // 矩形的宽度
.attr("height", Math.max(0, dy)); // 矩形的高度
mappath.attr("clip-path", "url(#clip)"); // 应用裁剪路径
将svgMain
元素(一个SVG
容器)应用一个缩放和移动变换。首先,使用d3.zoomIdentity
设置一个没有缩放和移动的初始状态。然后,通过.translate(translateX, translateY)
方法设置平移变换,将SVG
元素在x轴上移动translateX
单位,在y轴上移动translateY
单位。最后,通过.scale(scale)
方法设置缩放变换,将SVG
元素按scale
的比例进行缩放。这样就使得被选中区域的中心点,与主地图svg
的中心点重合,达到了居中显示的目的。
zoom.scaleExtent([scale, scale * 5])//动态设置主地图缩放范围
svgMain.call(
zoom.transform,
d3.zoomIdentity
.translate(translateX, translateY)
.scale(scale)
);
3.3 结果展示
3.4 评价
第三章开头部分提到过,裁剪工具区域外的地图只是被隐藏了,实际上他们依然存在于页面中,并未真正去掉。下图可以很明显看到,在对主地图放大或者移动时,裁剪部分的边框显而易见,这是我想解决的问题。
四、方法二:主地图与鹰眼地图绘制不一样
这种方法可以实现懒加载,只有当用户通过鹰眼地图选择某个区域时,才加载和渲染该区域的详细数据。
此方法优缺点对比:
优点 | 缺点 |
---|---|
资源优化:只加载和渲染用户实际需要看到的数据,减少了资源消耗,提高了性能。 | 增加复杂性:需要更复杂的逻辑来处理和同步两个视图中的数据。 |
灵活性:可以根据用户的视图选择动态地加载和显示数据,适合数据量大或需要根据用户交互动态更新的场景。 | 数据同步挑战:确保两个视图之间的数据同步和一致性可能是一个挑战,特别是在用户交互频繁的情况下。 |
更好的用户体验:通过只显示相关数据,可以减少视觉上的杂乱,使用户更容易专注于感兴趣的区域。 | 可能的性能问题:如果实现不当,动态加载和处理数据可能会引入延迟,影响用户体验。 |
4.1 绘制主地图
通过遍历json
文件,来获取选中的区域信息,然后再主地图中绘制,裁剪掉多余的部分。
//过滤掉不在指定经纬度范围内的国家
const filteredCountries = countries.filter(d => {
const bounds = d3.geoBounds(d);
return !(
bounds[0][0] < lng0 ||
bounds[0][1] > lat0 ||
bounds[1][0] > lng1 ||
bounds[1][1] < lat1);
});
mainMapGroup.selectAll("path")
.data(filteredCountries)
.join("path")
.attr("d", path)
.attr("fill", function(d) {
return d.color;
}) // 使用存储的颜色
.attr("class", "mapPath")
.attr("clip-path", "url(#clip)"); // 应用裁剪路径
4.2 结果展示
4.3 评价
这种方法四周也有多余的空白,这是因为获取的数据只能显示一整个国家,裁剪国家中的一部分显示。比起第一种方法,空白区域小很多。但是这种方法有一个致命缺陷:越靠近南北极,此法会失效。如果正对某个国家进行绘制,不包括南北极时,可以采用此方法。
极点问题:南北极点(90°S
和 90°N
)在墨卡托投影等地图投影中会被无限放大。d3.geoBounds
函数返回的边界框可能在极点附近变得非常大,甚至可能包含整个半球,这会导致比较逻辑出现问题。
投影失真:在极地区域,由于投影失真,边界框的表示可能不准确。墨卡托投影在极地附近会极度拉伸,导致计算出的边界框与实际的地理区域不符。
五、总结
如何实现裁剪区域上下边界与主地图svg
对齐,包括放大后平移到边界依然是对其的,这点暂时还没有得到解决。有想法的朋友,可以评论或者私聊我。
近南北极,此法会失效。