ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用D3.js复刻《The Great Bear》地铁图式知识图谱实战

用D3.js复刻《The Great Bear》地铁图式知识图谱实战 之前在一次内部学习计划的 Day1 任务中我看到一个挺特别的代号UNK20 Day1VII Simon Patterson。最开始完全不知道要从哪里入手后来顺着 Simon Patterson 的名字查下去才发现这其实可以变成一节非常有意思的数据可视化实战课。Simon Patterson 是英国艺术家他有一幅经典作品《The Great Bear》把伦敦地铁图改造成了一张“知识地图”每个站名被替换成历史人物、哲学家、运动员、影星原本代表空间位置的站点一下子变成了代表知识分类的坐标。这个思路对程序员来说特别有价值因为它把“地图”和“图谱”的模型打通了。所以本文就把 UNK20 Day1 当作一个可视化训练任务带着大家用 HTML、CSS、JavaScript 和 D3.js 复刻一张“地铁图式知识图谱”从数据建模到 SVG 渲染完整走一遍。如果你刚开始接触 D3.js或者你想知道“地铁图式的知识可视化到底怎么做”这篇文章都很适合你。我会避免只贴代码不解释而是把数据设计、布局思路、常见报错、工程化建议都拆开讲清楚。你不需要先学完整个 D3.js 文档只需要有一个浏览器、一个编辑器再有一点 JavaScript 基础就能跟着做出来。1. 背景与核心概念1.1 为什么一张“艺术地铁图”能变成技术教程先说说 Simon Patterson 的《The Great Bear》。它表面上看是一张伦敦地铁图线路、换乘站、走向都很完整但站名全被替换成了人物姓名。艺术家利用地铁图本身的“线路即分类”特征让观众在三四条线路上快速理解“这是一组同一类的人物”。比如某条线全是哲学家另一条线全是喜剧演员换乘站则代表“跨领域的人”。这个设计其实正好对应数据可视化里的两个核心概念分类和关联。线路 一个类别或一组关系。站点 一个实体对象。换乘站 同时属于多个分类的实体。线路之间的交叉点 实体之间的连接关系。对开发者来说这个模型可以用在很多真实场景里比如公司组织架构与跨部门协作图。技术栈与项目依赖关系图。知识库的文章主题分类与跳转关系。商品类目与用户兴趣标签的关联地图。当我们把“地铁图”当成一种可视化工具时它就不再是艺术而是一种很高效的信息表达方式。相比传统树形图地铁图在表达“多分类实体”时更直观因为它允许一个站点同时出现在多条线路上。1.2 从“看地图”到“写地图”技术转化思路如果只是看艺术家的图很容易觉得“这没什么技术含量不就是把文字换一下”。但真正要把它做成可交互、可维护、可扩展的 Web 可视化页面就需要考虑几个技术问题数据如何组织站点坐标如何确定线路如何绘制换乘站如何表达当站点数量增加时性能如何保证这也是本文要把 UNK20 Day1 当成一个训练营任务的原因。任务名称里的 Day1 代表第一步VII 可以理解成第七个版本或第七号迭代。我们不需要纠结代号本身关键是把它转化为一个可执行的学习计划先学会用 D3.js 画出一张静态的地铁式知识地图再逐步增加交互和动画。1.3 地铁图式可视化的常见应用在实际项目中地铁图式可视化最常出现的地方是知识管理系统的知识地图。学习路径规划比如“前端学习路线地铁图”。组织人才盘点一个人可能同时属于多个项目组。交通领域自身的路线图比如地铁线路图编辑器。这种可视化最大的优点是用户不需要额外学习就能理解。我们从小到大都坐过地铁看到“线路站点”的第一反应就是“沿着某条线走”这个认知成本非常低。作为开发者如果能用这种形式呈现复杂关系数据比堆表格和树形图要友好得多。2. 环境准备与版本说明2.1 本地开发环境为了让示例尽量简单这里不需要安装复杂的脚手架也不需要配置 Node 后端。你只需要准备一个现代浏览器推荐 Chrome 或 Edge。一个代码编辑器推荐 VSCode。一个本地静态服务器工具比如 Python、Node 或者 VSCode 的 Live Server 插件。为什么需要本地服务器因为后面示例会用 D3.js 的d3.json()方法读取本地 JSON 文件。如果直接用浏览器打开 HTML 文件file://协议浏览器出于安全策略会拦截本地文件读取。启动一个本地静态服务器就能解决这个问题。2.2 使用 D3.js 的版本说明本文示例基于 D3.js v7 编写。D3.js 是一个老牌的 JavaScript 数据可视化库提供了非常灵活的选择器和 SVG 绘图能力。版本方面v7 与 v6 的核心 API 变化不大但如果你使用 v5 或更早的版本部分写法例如d3.json()的返回值会有差异。为了减少环境问题本文使用 CDN 方式引入。如果项目对版本要求严格建议把 D3.js 文件下载到本地避免 CDN 不稳定带来的调试问题。生产环境也推荐锁定确切版本号不要使用latest。2.3 示例项目结构本文的完整示例会包含三个文件unk20-day1/ ├── index.html ├── data.json └── app.jsindex.html页面结构和 SVG 容器。data.json站点、线路、连接关系的数据。app.jsD3.js 绘制逻辑。这个结构非常简单适合第一天学习。后续扩展时可以再把数据层和视图层继续拆分。3. 核心原理拆解3.1 地铁图的数据模型想用代码画地铁图第一步不是写 SVG而是先设计数据结构。可以这样理解地图上的每一个元素都应该能在数据里找到对应关系。我选择用三种对象来描述地图lines线路包含 id、名称、颜色。stations站点包含 id、名称、坐标、所属线路。segments区间表示某条线路上的两个相邻站点。对应的 JSON 结构如下{ lines: [ { id: line1, name: 古希腊哲学线, color: #2E86AB }, { id: line2, name: 理性主义线, color: #A23B72 }, { id: line3, name: 经验主义线, color: #F18F01 }, { id: line4, name: 德国观念论线, color: #3B1F2B } ], stations: [ { id: s1, name: 苏格拉底, lineIds: [line1], x: 80, y: 260 }, { id: s2, name: 柏拉图, lineIds: [line1], x: 200, y: 260 } ], segments: [ { lineId: line1, from: s1, to: s2 } ] }这里有几个设计要点站点的lineIds是数组而不是单个字符串因为一个站点可以属于多条线路。后面绘制换乘站时就能通过判断lineIds.length 1来给站点增加特殊样式。segments单独声明而不是直接写在stations里因为一条线路通常由多个站点按顺序连接而成。用数组保存“相邻关系”后绘制线路时只需要遍历数组即可。这种设计的好处是数据与视图完全解耦。以后想换颜色、改名字、调整坐标都不需要动绘图逻辑。3.2 坐标布局手工坐标还是力导向布局地铁图可视化有一个绕不开的问题站点坐标怎么来常见方案有三种手工坐标适合站点数量少、布局要求高的场景。比如 20 个以内站点时可以直接在 JSON 里写死x和y。优势是布局精准线路看起来像真正的地铁图劣势是数据量大以后维护成本很高。力导向布局D3.js 提供了d3.forceSimulation可以模拟物理粒子之间的斥力和引力自动把站点“弹”开。适合关系型网络图但缺点是结果不可控站点容易重叠而且很难整理成横平竖直的地铁风格。算法自动布局例如基于层级关系的 Sugiyama 算法、基于网格的布线算法等。这类方案需要更多代码但能处理大型数据。对于 UNK20 Day1 这个任务来说我推荐手工坐标。因为我们的目标不是做一个大规模图谱引擎而是先理解“地铁图是怎么画出来的”。当站点坐标由自己控制时你能更清楚地看到每条线的走向也不容易出现布局混乱。3.3 SVG 绘图与 D3.js 的作用浏览器里画地铁图实际上就是在操作 SVG 元素。SVG 中有三个基础元素很关键line画直线。circle画站点圆点。text画站点名称。path画更复杂的曲线。D3.js 在这里的作用不是“自动生成地铁图”而是帮助我们高效地完成“数据到元素的映射”。它提供了一套声明式的数据绑定方式可以把数组里的每一项变成一个 SVG 元素并同步设置属性。这是 D3.js 的核心能力也是它和普通 DOM 操作的区别。用一句话概括D3.js 负责把数组数据映射到 SVG 元素上让开发者能专注于数据和业务规则。4. 完整实战案例用 D3.js 复刻一张知识地铁图接下来进入实战环节。我们以“哲学家知识地铁图”为例把几位不同流派的哲学家放到不同的线路上并让“康德”作为一个跨流派换乘站出现。4.1 创建项目结构首先建立项目目录mkdir unk20-day1 cd unk20-day1然后创建index.html、data.json、app.js三个文件。下面我会逐个文件给出完整代码。4.2 编写页面入口 index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titleUNK20 Day1VII Simon Patterson 知识地铁图/title style body { margin: 0; padding: 40px; font-family: Microsoft YaHei, PingFang SC, sans-serif; background: #f7f7f9; color: #333; } h1 { font-size: 22px; margin-bottom: 8px; } p { color: #666; } .chart-container { background: #fff; border-radius: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); padding: 20px; margin-top: 20px; overflow-x: auto; } svg { display: block; margin: 0 auto; } .station-text { font-size: 13px; fill: #333; font-weight: 500; } .station-circle { stroke: #fff; stroke-width: 2; } .station-circle-transfer { fill: #fff; stroke-width: 4; } /style /head body h1哲学家知识地铁图/h1 p灵感来源Simon Patterson《The Great Bear》第七版学习实践/p div classchart-container svg idknowledge-map width900 height500 viewBox0 0 900 500/svg /div script srchttps://cdn.jsdelivr.net/npm/d37/script script srcapp.js/script /body /html这段代码里svg设置了固定的宽高和viewBox。使用viewBox的好处是图表可以等比例缩放后续做响应式适配时更方便。样式部分预先给站点文字和圆形定义了基础类后面在 JS 里直接使用。4.3 编写数据文件 data.json为了让布局清楚我提前定好了坐标。三条横向线路分别代表古希腊哲学、理性主义、经验主义第四条线路代表德国观念论从“康德”这个换乘站开始向右延伸。{ lines: [ { id: line1, name: 古希腊哲学线, color: #2E86AB }, { id: line2, name: 理性主义线, color: #A23B72 }, { id: line3, name: 经验主义线, color: #F18F01 }, { id: line4, name: 德国观念论线, color: #3B1F2B } ], stations: [ { id: s1, name: 苏格拉底, lineIds: [line1], x: 80, y: 260 }, { id: s2, name: 柏拉图, lineIds: [line1], x: 200, y: 260 }, { id: s3, name: 亚里士多德, lineIds: [line1], x: 320, y: 260 }, { id: s4, name: 笛卡尔, lineIds: [line2], x: 80, y: 150 }, { id: s5, name: 斯宾诺莎, lineIds: [line2], x: 200, y: 150 }, { id: s6, name: 莱布尼茨, lineIds: [line2], x: 320, y: 150 }, { id: s7, name: 洛克, lineIds: [line3], x: 80, y: 370 }, { id: s8, name: 贝克莱, lineIds: [line3], x: 200, y: 370 }, { id: s9, name: 休谟, lineIds: [line3], x: 320, y: 370 }, { id: s10, name: 康德, lineIds: [line2, line4], x: 480, y: 190 }, { id: s11, name: 费希特, lineIds: [line4], x: 620, y: 190 }, { id: s12, name: 黑格尔, lineIds: [line4], x: 760, y: 190 } ], segments: [ { lineId: line1, from: s1, to: s2 }, { lineId: line1, from: s2, to: s3 }, { lineId: line2, from: s4, to: s5 }, { lineId: line2, from: s5, to: s6 }, { lineId: line2, from: s6, to: s10 }, { lineId: line3, from: s7, to: s8 }, { lineId: line3, from: s8, to: s9 }, { lineId: line4, from: s10, to: s11 }, { lineId: line4, from: s11, to: s12 } ] }注意这里s10康德的lineIds是[line2, line4]并且线段line2从莱布尼茨s6连到康德s10线段line4从康德s10继续向右延伸。这样康德就成了一个衔接理性主义与德国观念论的换乘站。4.4 编写绘图逻辑 app.js接下来是核心代码。我先把完整代码写出来再逐段解释。// 文件路径unk20-day1/app.js d3.json(data.json).then(data { const svg d3.select(#knowledge-map); const stationById new Map(data.stations.map(d [d.id, d])); const lineColorById new Map(data.lines.map(d [d.id, d.color])); // 1. 绘制线路区间 data.segments.forEach(seg { const from stationById.get(seg.from); const to stationById.get(seg.to); const color lineColorById.get(seg.lineId); svg.append(line) .attr(x1, from.x) .attr(y1, from.y) .attr(x2, to.x) .attr(y2, to.y) .attr(stroke, color) .attr(stroke-width, 6) .attr(stroke-linecap, round) .attr(opacity, 0.85); }); // 2. 绘制站点圆点 const stationGroup svg.selectAll(g.station) .data(data.stations) .enter() .append(g) .attr(class, station) .attr(transform, d translate(${d.x}, ${d.y})); stationGroup.append(circle) .attr(class, station-circle) .attr(r, d d.lineIds.length 1 ? 12 : 8) .attr(fill, d { if (d.lineIds.length 1) { return #fff; } return lineColorById.get(d.lineIds[0]); }) .attr(cx, 0) .attr(cy, 0); // 3. 为换乘站增加一个小内圈强调线路数量 stationGroup.filter(d d.lineIds.length 1) .append(circle) .attr(class, station-circle-transfer) .attr(r, 4) .attr(fill, lineColorById.get(d d.lineIds[0])) .attr(cx, 0) .attr(cy, 0); // 4. 绘制站点名称 stationGroup.append(text) .attr(class, station-text) .attr(x, 0) .attr(y, d d.lineIds.length 1 ? -20 : -16) .attr(text-anchor, middle) .text(d d.name); // 5. 绘制线路图例 const legendGroup svg.append(g) .attr(transform, translate(680, 400)); data.lines.forEach((line, i) { const row legendGroup.append(g) .attr(transform, translate(0, ${i * 22})); row.append(line) .attr(x1, 0) .attr(y1, 8) .attr(x2, 30) .attr(y2, 8) .attr(stroke, line.color) .attr(stroke-width, 4); row.append(text) .attr(x, 38) .attr(y, 13) .attr(font-size, 12px) .attr(fill, #555) .text(line.name); }); }).catch(error { console.error(数据文件加载失败, error); });4.5 代码逐段解释第一步加载数据d3.json(data.json)返回一个 Promise。数据加载成功后我们获得完整的 lines、stations、segments。为了在绘制线段时快速通过 id 找到站点和颜色我使用Map建立了两个查询表。这样代码看起来更干净也减少了每画一条线就遍历一次数组的浪费。第二步绘制线路区间遍历segments数组对每一段区间都生成一个line元素。这条线的起终点来自起始站点和终点站点的x、y坐标颜色来自该线段所属线路的颜色。这里我把stroke-width设置为 6目的是让线路看起来粗一点更接近真实地铁图的视觉效果。需要注意segments数组中有多个区间可能共享同一个站点。由于我们是分别画线段而不是一次画出整条线路所以每条线路在换乘站附近会自动形成一个折线效果代码逻辑非常简单。第三步绘制站点圆点使用 D3 的selectAll().data().enter().append()模式把stations数组映射到多个g分组元素。每个分组通过transform: translate(x, y)移动到自己的坐标位置。普通站点的圆点是彩色实心圆颜色使用所属线路的颜色。换乘站则绘制成白色大圆因为后面还会叠加一个内圈用来表示“这里可以换乘”。这里有一个设计上的细节换乘站半径是 12普通站点半径是 8。如果所有站点都使用同一个半径换乘站在视觉上不够突出用户很难第一时间发现“康德的特殊性”。第四步绘制换乘站内圈为了突出换乘站我给lineIds.length 1的站点额外增加了一个小圆。代码里这行可能稍微绕attr(fill, lineColorById.get(d d.lineIds[0]))这行是有问题的因为Map.get()应该接收字符串而不是函数。正确的写法应该是.attr(fill, d lineColorById.get(d.lineIds[0]))这个问题我在实际运行时就遇到过因为把一个函数传给了get结果内圈颜色一直是undefined。这里修正后内圈颜色会使用当前站点第一条所属线路的颜色。对于康德来说就是理性主义线的紫红色。第五步绘制站点名称名称文字放在圆点上方换乘站由于圆更大文字偏移量也更大避免文字和圆点边缘重叠。text-anchor设置为middle可以让文字水平居中显示在中轴线两侧。第六步绘制图例图例不是必须的但加上以后用户能快速知道每条线代表什么。图例放在画布右下角每一行由一段短线和一个文本组成颜色与对应线路一致。4.6 运行与验证由于d3.json()不能直接读取本地file://路径下的文件建议启动一个本地静态服务器。如果你电脑上有 Python 3可以在项目目录里执行python -m http.server 8080然后在浏览器中访问http://localhost:8080如果你使用的是 VSCode也可以安装 Live Server 插件在index.html上右键选择“Open with Live Server”效果是一样的。4.7 预期效果页面打开后你应该会看到上方一条紫红色线路包含笛卡尔、斯宾诺莎、莱布尼茨并延伸至康德。中间一条蓝色线路包含苏格拉底、柏拉图、亚里士多德。下方一条橙色线路包含洛克、贝克莱、休谟。右侧一条深色线路从康德开始经过费希特到黑格尔。康德站点是一个白色大圆同时被两条线路穿过视觉上很突出。如果看到这些说明你已经成功完成了一版“知识地铁图”。5. 常见问题与排查思路第一次运行这类 D3.js 项目很容易踩到几个常见问题。下面按我的排查经验整理成一张表你可以直接对照处理。问题现象常见原因解决思路浏览器打开页面后没有图表控制台报错Failed to fetch直接双击 HTML 文件使用了file://协议浏览器禁止跨文件读取启动本地静态服务器例如python -m http.server 8080再通过http://localhost:8080访问控制台报错d3 is not definedD3.js CDN 没有成功加载或者脚本加载顺序错误检查网络环境确认 D3 的script标签在app.js之前生产环境建议把 D3 下载到本地站点文字和圆点重叠文字偏移量不足或者中文字体宽度较大增大dy的绝对值或者根据lineIds.length动态调整偏移量也可以给文字增加白色背景遮罩换乘站内圈颜色显示成黑色或没有颜色Map.get()传入了函数而不是字符串改成d lineColorById.get(d.lineIds[0])先取当前站点第一条线路的 id再去查颜色线路被站点圆点遮挡看起来不美观绘制顺序不正确站点画在了线路下层SVG 默认按创建顺序渲染后创建的元素在上层。先画所有线段再画站点圆点顺序不要颠倒页面在手机上显示错位SVG 宽度固定没有做缩放适配给svg设置viewBox并且 CSS 中让容器支持横向滚动或使用宽度 100%这里我想特别强调一下“绘制顺序”。SVG 本身没有 CSS 的z-index概念它是严格按照 DOM 顺序绘制的。先出现的元素在下层后出现的元素在上层。所以正确顺序是先画所有线路线段。再画站点圆点。最后画站点名称文字。如果顺序反了线路会把站点盖住整张图会非常难看。6. 最佳实践与工程建议6.1 把数据与视图彻底分离在示例中数据放在data.json绘图逻辑放在app.js页面结构放在index.html。这是一个很基础但非常重要的分层思路。实际项目中数据可能来自后端接口而不是静态 JSON 文件。这时你只需要把d3.json(data.json)替换成fetch(/api/map)后续绘图代码不用大变。这种解耦能显著提高代码可维护性。6.2 为换乘站设计统一规则换乘站是地铁图可视化的核心难点。建议在数据模型里就明确规则而不是在绘图函数里到处判断。比如可以约定lineIds.length 1普通站实心圆半径 8。lineIds.length 1换乘站白底圆半径 12叠加内圈。统一规则后后续增加新的换乘站只需要修改数据不需要修改代码。这也是我在工程中比较推荐的做法。6.3 颜色语义要克制地铁图最容易犯的错是颜色过于丰富。如果每一条线都用高饱和颜色整张图会显得非常杂乱用户很难快速识别主次关系。建议主线颜色控制在 4 到 6 种以内。相邻线路的颜色明度要拉开差距。换乘站尽量使用白色或浅色底保持统一。不要用纯红色和纯绿色作为仅有的区分方式考虑到色盲用户。6.4 性能优化从 SVG 到 CanvasD3.js 默认操作 SVGO适合节点数量在几百以内的场景。如果你的知识地图有上千个站点SVG 会产生大量 DOM 节点页面会明显卡顿。这时可以考虑使用 Canvas 渲染线路和圆点。使用 D3 的 quadtree 来做空间索引。只渲染当前可视区域内的站点即视口裁剪。不过这些属于进阶优化Day1 阶段不需要马上实现。先掌握 SVG 版本后续再迁移到 Canvas思路是相通的。6.5 无障碍与可访问性地铁图这种可视化对鼠标用户很友好但对键盘用户和屏幕阅读器用户来说纯 SVG 并不够友好。建议在关键元素上增加aria-label和title让用户即使看不见图形也能通过文字了解站点名称和线路信息。例如svg.append(circle) .attr(aria-label, d ${d.name}换乘站可换乘理性主义线和德国观念论线) .append(title) .text(d ${d.name});这看起来是一小步但能显著提升可视化产品的专业度。6.6 组件化封装如果只是写一个页面这段代码没问题。但进入真实项目后我建议把“地铁图”封装成一个独立组件。输入是lines、stations、segments输出是一张可交互地图。类似思路function createMetroChart(container, data, options) { // 在这里实现绘制逻辑 // 返回 update、destroy 等方法 }这样做的好处是同一个页面可以渲染多张知识地图。数据更新时不需要重新加载页面。组件可以被 Vue、React 复用。7. 总结与学习路线从 Simon Patterson 的《The Great Bear》到一张能运行的 D3.js 知识地铁图这篇 Day1 实战做完后你已经掌握了一个完整的可视化小项目闭环设计数据结构、确认布局方案、编写 SVG 渲染、处理换乘站、排查常见问题。下一步我建议你按这个顺序继续深入给地图增加 hover 交互鼠标悬停到站点上时高亮显示该站点所属的所有线路。给地图增加点击事件点击换乘站时展示“可换乘线路”的文字说明。把静态数据处理成后端接口数据模拟真实项目的数据流。引入 D3 的zoom缩放和drag拖拽功能让地图可以平移缩放。有条件的话学习力导向图和 Canvas 渲染扩充知识边界。做可视化最容易犯的错是“一开始就想做得很完美”。其实从一个很小的地图开始先跑通流程再逐步加功能反而是最快的学习路径。希望这篇 Day1 笔记能给你带来启发动手改一版属于你自己的知识地铁图吧。
返回列表