之前在一次内部学习计划的 Day1 任务中,我看到一个挺特别的代号:UNK20 Day1:VII 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.js:D3.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 lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>UNK20 Day1:VII 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 class="chart-container"> <svg id="knowledge-map" width="900" height="500" viewBox="0 0 900 500"></svg> </div> <script src="https://cdn.jsdelivr.net/npm/d3@7"></script> <script src="app.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 defined | D3.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 到 Canvas
D3.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 笔记能给你带来启发,动手改一版属于你自己的知识地铁图吧。