引言
地理图与关系网络图是可视化里两个特殊的分支。它们的共同点是图形结构由数据本身决定,而非由设计者摆放——地图上的位置来自经纬度,网络图的节点位置来自布局算法。这意味着设计者能控制的只有「用什么投影、用什么布局、怎么编码」三件事,而不能像柱状图那样直接决定每个元素的位置。
这个特性带来两个工程难点。地理图的难点在投影与数据量:把球面展开到平面必然产生变形,选错投影会让面积或距离的对比失真;国界级别的 GeoJSON 动辄几十 MB,直接渲染会卡死浏览器。关系图的难点在布局的可读性:力导向布局的结果不确定、节点多了会糊成一团、边的交叉数随节点数平方增长,稍不注意就变成「毛线球」。
本文按「地理 → 关系 → 流向 → 大规模 → 交互」的顺序展开。地理部分覆盖投影选择、分级统计图、点密度与 deck.gl;关系部分覆盖数据结构、力导向调参、层级与聚类、桑基图与和弦图;最后两节处理可读性优化与交互设计。文中涉及的库包括 d3-geo、deck.gl、AntV G6 与 ECharts,API 以各自当前稳定版为准。
目录
- 地理与关系图的适用场景
- 地图投影与坐标系
- 分级统计图与点密度图
- 流向数据与弧线图
- deck.gl 与大规模地理渲染
- 网络图的数据结构
- 力导向布局的调参与稳定性
- 层级图、聚类图与社区发现
- 桑基图与和弦图
- 图布局的可读性优化
- 大规模图的聚合与降采样
- 交互设计与下钻
1. 地理与关系图的适用场景
地理图只在「位置本身是分析维度」时才有价值。如果数据里有省份字段,但分析的是「各省销售额排名」,用排序条形图比地图更准确——地图的图形面积与数值无关,读者无法从地图上比较大小。地理图真正不可替代的场景有三类:空间分布模式(哪些区域密集、哪些稀疏)、空间邻接关系(相邻区域是否相似)、路径与流向(从哪到哪)。
判断标准是:把地图换成按行政区划排序的条形图,信息会丢失吗。如果不会,就不该用地图。这个判据能过滤掉大量「为了好看而画地图」的需求。
关系图只在「关系本身是分析对象」时才有价值。如果只是展示「A 比 B 大」,用条形图。关系图的价值在于揭示结构:谁是中心节点、哪些节点聚成社区、有没有桥接节点、有没有孤立子图。这些是列表和表格无法表达的。
两类图共同的适用前提是数据量适中。地理图的行政区不超过几百个,关系图的节点不超过几千个——超过这个规模,可读性急剧下降,应该先做聚合或社区发现。
2. 地图投影与坐标系
把球面投影到平面必然产生变形,不同投影保留不同属性:
| 投影 | 保留属性 | 典型用途 | d3-geo |
|---|---|---|---|
| Mercator | 角度(形状) | Web 地图底图 | geoMercator |
| Equal Earth | 面积 | 全球统计地图 | geoEqualEarth |
| Albers USA | 面积(美国) | 美国州级地图 | geoAlbersUsa |
| Orthographic | 无(视觉) | 地球仪效果 | geoOrthographic |
| Conic Equal Area | 面积 | 中纬度国家 | geoConicEqualArea |
默认应该用等面积投影(Equal Earth 或 Albers)做统计地图。Mercator 在极地附近面积严重放大——格陵兰在 Mercator 上看起来和非洲差不多大,实际面积只有非洲的十四分之一。用 Mercator 做分级统计图会系统性地夸大高纬度区域的视觉权重。
import { geoEqualEarth, geoPath } from 'd3-geo';
import { feature } from 'topojson-client';
// 加载 TopoJSON(比 GeoJSON 小得多)
const topo = await fetch('/data/world-110m.json').then((r) => r.json());
const countries = feature(topo, topo.objects.countries);
// 等面积投影,适合统计地图
const projection = geoEqualEarth()
.fitSize([width, height], { type: 'FeatureCollection', features: countries });
const path = geoPath(projection);
svg.selectAll('path').data(countries).join('path').attr('d', path);
TopoJSON 比 GeoJSON 小 80%。它用拓扑编码共享边界,相邻区域的公共边只存一次。国界级别的数据从 20MB 降到 4MB 是常见的。渲染前务必简化几何:用 topojson-simplify 把精度降到屏幕需要的级别,1:1000 万的地图不需要 1:100 万的顶点密度。
Web 地图底图的坐标系是 Web Mercator(EPSG:3857)。用 Leaflet、Mapbox、高德、百度等底图时,自定义图层必须转换到 EPSG:3857 坐标,否则会偏移。这是国内地图集成最常见的错误。
// WGS84(EPSG:4326,经纬度)转 Web Mercator(EPSG:3857,米)
function lngLatToMercator([lng, lat]) {
const R = 20037508.34 / 180;
return [lng * R, (Math.log(Math.tan(((90 + lat) * Math.PI) / 360)) / (Math.PI / 180)) * R];
}
3. 分级统计图与点密度图
分级统计图(choropleth)用颜色深浅编码区域的统计值。它的核心问题是分档:同一个数据集,分档方式不同会让结论完全不同。
import { scaleQuantize, scaleQuantile, scaleThreshold } from 'd3-scale';
// 等距分档:按数值范围均分,适合分布均匀的数据
const equalInterval = scaleQuantize().domain([0, 1000]).range(colors5);
// 分位数分档:每档数量相同,适合偏态分布(推荐默认)
const quantile = scaleQuantile().domain(values).range(colors5);
// 自定义阈值:按业务含义分档(如达标线、警戒线)
const threshold = scaleThreshold().domain([100, 500, 1000, 5000]).range(colors5);
分位数分档(quantile)应该是默认选择,因为统计数据几乎总是偏态分布(少数区域值极高)。等距分档会让大部分区域挤在最低档,看不出差异。但如果业务上有明确的阈值(如「完成率 80%」),应该用 scaleThreshold 显式指定。
分级统计图有个固有缺陷:它把区域面积当成了视觉权重。面积大的区域(如新疆、西藏)即使数值很低,也会因为占据大量视觉空间而显得重要。解决办法是用点密度图替代——在区域内部按人口或面积撒点,让点的密度反映数值。
// 点密度图:每个点代表固定数量的事件,点的密度反映强度
import { geoContains } from 'd3-geo';
const dots = [];
for (const region of regions) {
const count = Math.round(region.value / 100); // 每点代表 100
for (let placed = 0, attempts = 0; placed < count && attempts < count * 50; attempts++) {
const [x, y] = [Math.random() * width, Math.random() * height];
if (geoContains(region, projection.invert([x, y]))) { dots.push([x, y]); placed++; }
}
}
点密度图的代价是随机性——每次生成的点位不同,需要固定随机种子保证可复现。另外,点的数量与数值成正比,但视觉上面积是平方关系,读者会低估密度差异。
4. 流向数据与弧线图
流向数据(OD 数据,Origin-Destination)的表达有三种方式。
直线/弧线图用贝塞尔曲线连接起点与终点,弧线的粗细编码流量。它适合展示「点对点」的流向,但边多了会互相遮挡。
import { geoInterpolate } from 'd3-geo';
// 起点到终点的弧线:取大圆中点,向一侧偏移形成弧度
const mid = geoInterpolate(start, end)(0.5);
const [p0, p1, p2] = [start, mid, end].map((p) => projection(p));
const d = `M${p0} Q${p1} ${p2}`;
弧线的弯曲方向要一致(如统一向右弯),否则会形成视觉噪声。弯曲程度通常与距离成正比,短距离的线接近直线。
**流线图(flow map)**用连续的曲线表示移动轨迹,适合展示「从 A 到 B 的路径」,如航班航线、迁徙路径。它的实现基于 d3-geo 的大圆插值(geoInterpolate 默认走大圆)。
流向热力图把 OD 数据聚合成网格,用颜色表示流量。它放弃了「起点终点」的精确信息,换取对整体流向模式的呈现。适合数据量大的场景。
-- OD 数据聚合:按网格 ID 聚合,用于流向热力图
SELECT origin_grid_id, dest_grid_id, count(*) AS trips, avg(duration) AS avg_duration
FROM trips WHERE trip_date >= today() - 7 GROUP BY origin_grid_id, dest_grid_id
HAVING trips >= 10 -- 过滤低频流向,避免噪声
ORDER BY trips DESC;
5. deck.gl 与大规模地理渲染
数据量超过几千个点或几百条边后,SVG 与 Canvas 2D 都会吃力。deck.gl 是 Uber 开源的地理可视化库,基于 WebGL,能流畅渲染百万级点。
import { Deck } from '@deck.gl/core';
import { ScatterplotLayer, ArcLayer } from '@deck.gl/layers';
const deck = new Deck({
initialViewState: { longitude: 121.47, latitude: 31.23, zoom: 10, pitch: 45 },
controller: true,
layers: [
new ScatterplotLayer({
id: 'points', data: millionPoints, getPosition: (d) => [d.lng, d.lat],
getRadius: 20, getFillColor: [47, 111, 237, 160], radiusUnits: 'meters',
}),
new ArcLayer({
id: 'flows', data: odPairs,
getSourcePosition: (d) => [d.fromLng, d.fromLat],
getTargetPosition: (d) => [d.toLng, d.toLat],
getWidth: (d) => Math.sqrt(d.volume) / 10, // 宽度用 sqrt 映射
}),
],
});
deck.gl 的核心优势是 GPU 实例化渲染——十万个点只发一次绘制调用,而不是十万次。它的 HexagonLayer 还能在 GPU 上做六边形分箱聚合,把百万点压成几千个格子,同时解决了性能与可读性问题。
deck.gl 与底图库配合使用。通常用 Mapbox GL 或 MapLibre 作为底图,deck.gl 作为覆盖层,两者共享相机状态。国内地图(高德、百度)需要自定义底图适配层,或直接用静态瓦片。
| 方案 | 数据量上限 | 三维支持 | 底图集成 | 学习成本 |
|---|---|---|---|---|
| SVG (d3) | ~2 千 | 无 | 手动 | 低 |
| Canvas 2D | ~5 万 | 无 | 手动 | 中 |
| deck.gl | ~100 万 | 强 | Mapbox/MapLibre | 中高 |
| ECharts GL | ~10 万 | 中 | 内置地图 | 低 |
ECharts GL 是国内的折中选择:内置中国地图数据、支持 3D 柱图与飞线,学习成本低,但数据量上限与三维能力不如 deck.gl。选型取决于数据规模与是否需要真三维。
6. 网络图的数据结构
网络图的数据模型是「节点 + 边」。节点有唯一 ID 与属性,边有源、目标、权重与方向。
const graph = {
nodes: [
{ id: 'u1', name: '张三', group: '研发', size: 12 },
{ id: 'u2', name: '李四', group: '产品', size: 8 },
{ id: 'u3', name: '王五', group: '研发', size: 15 },
],
edges: [
{ source: 'u1', target: 'u2', weight: 5, type: '协作' },
{ source: 'u1', target: 'u3', weight: 9, type: '协作' },
],
};
边的方向性决定了图的类型:有向图(如关注关系、调用链)需要箭头,无向图(如好友关系、共现关系)不需要。混用会导致读者误判关系语义。
权重映射到视觉属性要谨慎。边宽是长度编码(精确),颜色明度是弱编码。边的粗细应该用 sqrt 或 log 映射,因为视觉上粗度的感知接近平方关系——直接用线性映射会让粗边显得过粗。
// 边宽:用 sqrt 映射,避免粗细差异被过度放大
const widthScale = scaleSqrt().domain([0, maxWeight]).range([1, 12]);
// 节点大小:同理,面积编码要用平方根
const sizeScale = scaleSqrt().domain([0, maxDegree]).range([4, 40]);
节点大小通常编码「度」(连接数)或「重要性」(如 PageRank 值)。度是最直观的选择,但要注意枢纽节点会挤压其他节点的空间——一个连接了 100 条边的节点会让周围区域过密。
7. 力导向布局的调参与稳定性
力导向是最常用的网络图布局,它模拟物理系统:节点之间斥力、有边相连的节点之间引力、整体向心力。
import { forceSimulation, forceLink, forceManyBody, forceCenter,
forceCollide, forceX, forceY } from 'd3-force';
const simulation = forceSimulation(graph.nodes)
.force('link', forceLink(graph.edges).id((d) => d.id)
.distance((d) => 30 + 100 / (d.weight + 1)) // 权重越大越近
.strength((d) => Math.min(d.weight / 10, 1)))
.force('charge', forceManyBody().strength(-300).distanceMax(400))
.force('center', forceCenter(width / 2, height / 2))
.force('collide', forceCollide().radius((d) => sizeScale(d.degree) + 2))
.force('x', forceX(width / 2).strength(0.02)) // 弱向心力,防止漂移
.force('y', forceY(height / 2).strength(0.02)); // 防止孤立子图漂出视野
四个调参要点。charge.strength 的负值越大图越松散,节点多时需要更强的斥力(-300 到 -1000)。distanceMax 限制斥力的作用范围,避免远距离节点间的无谓计算,这在大图上能显著提速。collide 防止节点重叠,半径要加上节点自身的绘制半径。forceX/forceY 提供弱向心力,防止孤立子图漂出视野。
力导向的结果不确定,每次运行布局都不同。解决办法有三:其一,用 d3-force 前固定 Math.random(替换成种子随机数);其二,预计算一次后缓存坐标,后续渲染直接使用;其三,用 simulation.tick(n) 同步跑完再渲染,避免逐帧抖动。
// 预计算布局:跑 300 次迭代后停止,此时 x/y 已确定,可一次性绘制
simulation.stop();
for (let i = 0; i < 300; i++) simulation.tick();
固定节点位置是另一个常用需求:用 fx/fy 把核心节点钉在中心,其余节点围绕它布局,结构更稳定也更符合业务预期(如把「自己公司」固定在中心)。
8. 层级图、聚类图与社区发现
**层级图(树、矩形树、旭日图)**适合有明确父子关系的数据,如组织架构、产品分类、调用链。它们的布局是确定性的,不存在力导向的不稳定问题。d3-hierarchy 的 tree、treemap、pack、partition 覆盖了主要形态,选型依据是「层级深度」与「是否强调占比」。
聚类图把节点按属性或拓扑结构分组。两种实现路径:
按属性分组用 forceX/forceY 把同组节点拉向同一位置(如按类别分列),再用力导向在组内布局。这是最实用的做法,因为分组结果符合业务预期。
// 按 group 分列:不同组拉向不同的 X 中心
const groupCenters = { 研发: width * 0.2, 产品: width * 0.5, 运营: width * 0.8 };
simulation.force('x', forceX((d) => groupCenters[d.group]).strength(0.08));
按拓扑分组用社区发现算法(Louvain、标签传播)自动识别紧密连接的子群,再按社区着色。这能揭示「数据里自然形成的圈子」,适合社交网络、协作网络分析。
import networkx as nx
from networkx.algorithms.community import louvain_communities
G = nx.Graph()
G.add_weighted_edges_from([(u, v, w) for u, v, w in edges])
# Louvain 社区发现:识别紧密连接的子群,seed 固定保证可复现
communities = louvain_communities(G, weight='weight', seed=42)
for i, comm in enumerate(communities):
for node in comm:
G.nodes[node]['community'] = i
print(f'{len(communities)} 个社区,模块度 {nx.community.modularity(G, communities):.3f}')
**模块度(modularity)**衡量社区划分的质量,0.3 以上通常认为结构显著。用固定 seed 保证结果可复现。
9. 桑基图与和弦图
桑基图展示流量在多个阶段之间的分配与流转,如「用户从各渠道进入,经过各环节,最终到各结局」。它的结构是分层的:每一层是一组节点,节点之间的连线宽度表示流量。
// ECharts 桑基图
const option = {
series: [{
type: 'sankey', layout: 'none', // 用节点层级而非力导向
nodeAlign: 'justify', // 对齐以减少连线交叉
data: [
{ name: '搜索', depth: 0 }, { name: '社交', depth: 0 },
{ name: '浏览商品', depth: 1 }, { name: '加购', depth: 1 },
{ name: '下单', depth: 2 }, { name: '流失', depth: 2 },
],
links: [
{ source: '搜索', target: '浏览商品', value: 3200 },
{ source: '社交', target: '浏览商品', value: 2100 },
{ source: '浏览商品', target: '下单', value: 2600 },
],
}],
};
桑基图的可读性依赖节点排序。同一层内节点的顺序决定了连线是否交叉——交叉多则难以追踪。优化方法是按流量大小排序,或用 nodeAlign: 'justify' 让节点对齐以最小化交叉。
和弦图展示一组实体之间的双向关系强度,如「不同部门之间的协作次数」。它把实体排成圆周,实体之间的弧带宽度表示关系强度。和弦图的信息密度高但解读门槛也高——读者需要理解「圆周上的位置代表实体、内部的弧带代表关系」这一约定。它适合展示「关系的整体格局」,不适合精确读数。
10. 图布局的可读性优化
网络图最容易变成「毛线球」。五条优化手段。
**边绑定(Edge Bundling)**把走向相近的边聚成束,显著降低视觉杂乱。代价是引入失真——读者无法追踪单条边的精确路径。它适合展示「整体流向」而非「单条关系」。
减少边数。只显示权重最高的 N 条边,或对边做聚合(如把同一对社区的边合并)。边的数量应控制在节点数的 2 到 3 倍以内,超过就会糊。
用透明度表达权重。低权重的边降低不透明度,让高权重的边凸显。这是「改变前注意特征引导注意」原则的应用。
// 边的不透明度与权重成正比,弱边自动退到背景
const linkOpacity = (d) => 0.15 + 0.6 * (d.weight / maxWeight);
节点标签只显示重要的。全部标注会互相遮挡。策略是:只标注度数前 10% 的节点,其余节点在悬停时显示标签。
用社区着色而非按 ID 着色。按社区着色能让读者看到结构;按任意 ID 着色只会产生视觉噪声。
11. 大规模图的聚合与降采样
节点超过 2000 个后,力导向布局的计算(O(n log n) 到 O(n²))与渲染都会成为瓶颈。四种策略。
社区聚合:先做社区发现,把每个社区压成一个「超级节点」,只显示社区之间的边。展开某个社区时再显示内部节点。这是最常用的策略,能处理十万级节点。
度数过滤:只保留度数大于阈值的节点,把低度数节点聚合到「其他」类别。适合展示「核心结构」。
采样:随机采样一部分节点与边。代价是可能丢失关键结构,需要谨慎使用。
WebGL 渲染:用 AntV G6 的 WebGL 模式或 sigma.js(基于 WebGL 的图渲染库),能流畅渲染十万级节点。布局计算则应该放到 Web Worker 或服务端预计算。
// G6 5.x 的 WebGL 渲染 + 服务端预计算布局
import { Graph } from '@antv/g6';
const graph = new Graph({
container: 'container',
renderer: 'webgl', // WebGL 渲染,支持十万级节点
layout: { type: 'preset' }, // 使用预计算坐标,不在前端跑力导向
node: { style: { size: 6, fill: (d) => communityColor[d.community] } },
edge: { style: { strokeOpacity: 0.2 } },
});
graph.setData(precomputedGraphData).render(); // 坐标由服务端或 Worker 算好下发
布局计算的成本要单独考虑。力导向的 300 次迭代在 5000 节点上可能耗时数秒到数十秒,这个计算应放在服务端定时预计算或前端 Worker 里,而不是阻塞主线程。
12. 交互设计与下钻
地理图与关系图的交互设计有共同的三条原则。
下钻要有层级路径。地图从「全国」下钻到「省」再到「市」,关系图从「社区」下钻到「节点」,都需要面包屑显示当前位置并提供返回。这与格式塔的「共同区域」原则一致。
悬停要高亮关联元素。地图上悬停某个区域时高亮相邻区域;关系图上悬停某个节点时高亮其所有邻居(用 adjacency 模式),其余元素压暗。
// ECharts 关系图:悬停高亮相邻节点与边
series: [{ type: 'graph', layout: 'force',
emphasis: { focus: 'adjacency', scale: 1.2 }, // 只高亮相邻元素
// focus 可选: 'none' | 'self' | 'adjacency' | 'ancestor' | 'descendant'
}]
筛选要即时反馈。按社区、按权重、按时间筛选时,应该有过渡动画让读者看清「哪些元素消失了」。瞬间重绘会让读者失去上下文。
点击要有明确的行为。点击地图区域是「筛选到该区域」还是「打开详情页」,必须在视觉上区分(前者用高亮,后者用光标变化 + 箭头)。同一元素上绑定多种点击行为是常见的混乱来源。
性能保护:悬停高亮在关系图上可能触发大量重绘。节点超过 1000 个时应关闭逐节点的高亮动画,改用静态的样式切换(transitionDuration: 0)。
权衡取舍
| 决策点 | 选项 A | 选项 B | 何时选 A | 何时选 B |
|---|---|---|---|---|
| 统计地图投影 | Mercator | Equal Earth | 需要与 Web 底图对齐 | 全球统计比较 |
| 地理表达 | 分级统计图 | 点密度图 | 区域数量少、数值集中 | 面积差异大、需反映密度 |
| 地理渲染 | SVG/Canvas | deck.gl | 数据 <5 万 | 数据 >10 万或需三维 |
| 关系布局 | 力导向 | 层级/圆形 | 无明确层级 | 有父子或环形结构 |
| 布局计算 | 前端实时 | 服务端预计算 | 节点 <500 | 节点 >2000 |
| 大图处理 | 直接渲染 | 社区聚合 | 节点 <2000 | 节点 >5000 |
常见坑清单
- 用 Mercator 做全球统计地图——高纬度面积被放大,视觉权重失真;用等面积投影。
- 分级统计图用等距分档——偏态数据下多数区域挤在最低档;改用分位数分档。
- 地理坐标不转换到 EPSG:3857——自定义图层与 Web 底图偏移;转换后再叠加。
- GeoJSON 不简化直接渲染——几十 MB 的数据卡死浏览器;用 TopoJSON + 简化几何。
- 力导向布局当确定性——每次运行结果不同,无法复现;固定种子或预计算缓存。
- 节点大小用线性映射——视觉面积按平方增长,差异被夸大;用
scaleSqrt。 - 边数量不控制——边超过节点数 3 倍就糊成毛线球;只显示高权重边或做边绑定。
- 布局计算放主线程——5000 节点的力导向阻塞数秒;放 Worker 或服务端预计算。
- 点密度图不固定种子——每次生成的点位不同;固定随机种子保证可复现。
- 悬停高亮触发大量重绘——千级节点上图卡顿;关闭动画改用静态样式切换。
- 桑基图节点顺序随意——同层节点顺序决定连线交叉数;按流量排序或用 justify 对齐。
小结
地理图与关系图的共同特点是图形结构由数据决定,设计者能控制的只有投影、布局与编码。地理图的关键决策是投影选择(统计地图必须用等面积投影)与渲染方案(数据量决定用 SVG、Canvas 还是 deck.gl);关系图的关键决策是布局算法(力导向适合无层级的网络,层级布局适合有明确父子关系的数据)与可读性优化(控制边数、用透明度编码权重、按社区着色)。
工程上最容易踩的坑是规模失控:GeoJSON 不简化、力导向在主线程跑、边数量不控制。三条防线分别是数据简化、计算下沉(Worker 或服务端)、以及在大图上做聚合(社区聚合、度数过滤)。这些与 大屏渲染性能与优化 中的思路一脉相承。
下一步可以按场景选择深入方向:地理数据与数仓的集成见 可视化与 OLAP 数仓集成 ,把地理与关系图放进仪表盘的设计原则见 仪表盘与数据大屏设计 ;如果用声明式库实现,AntV G2 声明式图表体系 中的坐标系与标记概念对理解地理图的实现有帮助。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。