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