地理关系与网络图可视化

讲清地理可视化与关系网络图的技术选型,涵盖地图投影与坐标系、分级统计图与点密度、deck.gl 大规模渲染、力导向布局调参、桑基图与和弦图,以及图布局的可读性优化与交互下钻设计。文章以 d3-geo、ECharts 与 G6 为例给出可运行的配置片段。

引言

地理图与关系网络图是可视化里两个特殊的分支。它们的共同点是图形结构由数据本身决定,而非由设计者摆放——地图上的位置来自经纬度,网络图的节点位置来自布局算法。这意味着设计者能控制的只有「用什么投影、用什么布局、怎么编码」三件事,而不能像柱状图那样直接决定每个元素的位置。

这个特性带来两个工程难点。地理图的难点在投影与数据量:把球面展开到平面必然产生变形,选错投影会让面积或距离的对比失真;国界级别的 GeoJSON 动辄几十 MB,直接渲染会卡死浏览器。关系图的难点在布局的可读性:力导向布局的结果不确定、节点多了会糊成一团、边的交叉数随节点数平方增长,稍不注意就变成「毛线球」。

本文按「地理 → 关系 → 流向 → 大规模 → 交互」的顺序展开。地理部分覆盖投影选择、分级统计图、点密度与 deck.gl;关系部分覆盖数据结构、力导向调参、层级与聚类、桑基图与和弦图;最后两节处理可读性优化与交互设计。文中涉及的库包括 d3-geo、deck.gl、AntV G6 与 ECharts,API 以各自当前稳定版为准。

目录

  1. 地理与关系图的适用场景
  2. 地图投影与坐标系
  3. 分级统计图与点密度图
  4. 流向数据与弧线图
  5. deck.gl 与大规模地理渲染
  6. 网络图的数据结构
  7. 力导向布局的调参与稳定性
  8. 层级图、聚类图与社区发现
  9. 桑基图与和弦图
  10. 图布局的可读性优化
  11. 大规模图的聚合与降采样
  12. 交互设计与下钻

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
统计地图投影MercatorEqual Earth需要与 Web 底图对齐全球统计比较
地理表达分级统计图点密度图区域数量少、数值集中面积差异大、需反映密度
地理渲染SVG/Canvasdeck.gl数据 <5 万数据 >10 万或需三维
关系布局力导向层级/圆形无明确层级有父子或环形结构
布局计算前端实时服务端预计算节点 <500节点 >2000
大图处理直接渲染社区聚合节点 <2000节点 >5000

常见坑清单

  1. 用 Mercator 做全球统计地图——高纬度面积被放大,视觉权重失真;用等面积投影。
  2. 分级统计图用等距分档——偏态数据下多数区域挤在最低档;改用分位数分档。
  3. 地理坐标不转换到 EPSG:3857——自定义图层与 Web 底图偏移;转换后再叠加。
  4. GeoJSON 不简化直接渲染——几十 MB 的数据卡死浏览器;用 TopoJSON + 简化几何。
  5. 力导向布局当确定性——每次运行结果不同,无法复现;固定种子或预计算缓存。
  6. 节点大小用线性映射——视觉面积按平方增长,差异被夸大;用 scaleSqrt。
  7. 边数量不控制——边超过节点数 3 倍就糊成毛线球;只显示高权重边或做边绑定。
  8. 布局计算放主线程——5000 节点的力导向阻塞数秒;放 Worker 或服务端预计算。
  9. 点密度图不固定种子——每次生成的点位不同;固定随机种子保证可复现。
  10. 悬停高亮触发大量重绘——千级节点上图卡顿;关闭动画改用静态样式切换。
  11. 桑基图节点顺序随意——同层节点顺序决定连线交叉数;按流量排序或用 justify 对齐。

小结

地理图与关系图的共同特点是图形结构由数据决定,设计者能控制的只有投影、布局与编码。地理图的关键决策是投影选择(统计地图必须用等面积投影)与渲染方案(数据量决定用 SVG、Canvas 还是 deck.gl);关系图的关键决策是布局算法(力导向适合无层级的网络,层级布局适合有明确父子关系的数据)与可读性优化(控制边数、用透明度编码权重、按社区着色)。

工程上最容易踩的坑是规模失控:GeoJSON 不简化、力导向在主线程跑、边数量不控制。三条防线分别是数据简化、计算下沉(Worker 或服务端)、以及在大图上做聚合(社区聚合、度数过滤)。这些与 大屏渲染性能与优化 中的思路一脉相承。

下一步可以按场景选择深入方向:地理数据与数仓的集成见 可视化与 OLAP 数仓集成 ,把地理与关系图放进仪表盘的设计原则见 仪表盘与数据大屏设计 ;如果用声明式库实现,AntV G2 声明式图表体系 中的坐标系与标记概念对理解地理图的实现有帮助。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「数据可视化」更多文章

  1. WebGL 与三维数据可视化
  2. 数据叙事与图表沟通
  3. 嵌入式分析与白标集成