ASCII 图表 是做什么的?
基于纯 Unicode 块字符构建的终端风格图表,可像字面文本一样嵌入 Markdown 的围栏代码块或 HTML 的 <pre> 标签中。无需图表服务、无“由……制作”页脚、无需外部帐户,并且在所有等宽字体渲染环境中效果一致。
scripts/ascii_chart.py 为每种图表类型提供一个函数。运行 python3 scripts/ascii_chart.py 可打印每种类型的一个示例,作为健全性检查。
这里有两件事是经过测量而非假设的,而最初它们都是错的:在 390px 的手机视口上,超过约 44 个字符的行会在右边缘被截断;而等宽字体中的“圆形”需要约 2.49(而非 2.0)的水平纵向修正。请参阅“尺寸”和“饼图/环形图”部分。
新闻通讯平台在粘贴代码块时的行为各不相同。在依赖某个平台之前,请先将测试图表粘贴到一个真实草稿中,并检查网页、应用和电子邮件中的渲染效果。
字符集
█▓▒░(U+2588, U+2593, U+2592, U+2591 — 纯/深/中/浅色阴影) — 表示填充密度,从“最多”到“最少”。条形图仅使用█/░(填充与轨道);饼图/环形图使用全部四种(如果切片数超过 4 个,还会加上▚/▞/▤/▥)作为每个切片的区分填充;热力图使用全部四种加上·(中间点,不是空格)作为最低级别,因此“低密度”单元格看起来仍是网格中有意的一部分,而非空隙。- 数字
1–7— 韦恩图区域编码(表示区域属于哪个集合的位掩码——参见下面的韦恩图) +— 表示范围条的上限为开放端的标记(如果数值可能高于所示上限,请勿拉伸条本身以伪造不存在的数字)┃— 子弹图的目标标记,绘制在任何填充/阴影之上,确保始终可见└─┘— 间隙括号,跨越两个条末端之间的范围★/☆— 实心/空心星号,兼作星形直方图的行标签- 框线字符 (
─│╭╮╰╯) — 折线图和趋势线连接符 ●— 散点图数据点标记
全图不使用任何颜色。这是媒介本身的特点,而非需要克服的局限——粘贴到 Substack 的代码块中只有字面字符才能保留(内联 CSS、带样式的 span 都无效)。图表需要做的任何区分都存在于字符/数字/图例中,而不是无法使用的颜色通道中。
图表类型菜单
| 用途 | 函数 | 应用场景 |
|---|---|---|
| 多类别数值比较,均从 0 开始 | bar(value, max_value, width) + render() | 加油(5 分钟)对比直流快充(45 分钟) |
| 单个类别的最小-最大范围 | range_bar(start, end, scale_max, width, open_ended=) + render() | “安装成本 $100k–$300k+” |
| 两个数值,前后对比 | 纯文本:f"{start} → {end}" | “批发 $220 → 酒馆 $650” — 条形图无额外信息;箭头本身就是图表 |
| 一个测量值与一个基准区间加一个目标值的对比 | bullet(value, scale_max, target=, benchmark=) + render() | “您的评分:4.2,正常范围 3.8–4.5,目标 4.7” |
| 显式标注两个条之间的差距 | gap_bracket(a, b, scale_max, width, label=) via render(bracket=) | “您 vs. 市场中位数”——报告的标志性手法 |
| 1-5 星(或类似)评分分布 | star_histogram(counts) | Google/Yelp 星评分解构,每行对应一个星级 |
| 双分类坐标轴,按强度着色 | heatmap(matrix, row_labels, col_ticks=) | 按天×小时的人流量——“您什么时候忙/闲” |
| 单序列时间趋势 | line_chart(values, height, x_labels=) | 月收入、一年内的评论数量变化 |
| 在句子中内联显示趋势 | sparkline(values) | “每月评论:▂▃▅▆█▇▅▃,自三月以来增长 3 倍” |
| 2 或 3 个命名集合,重叠程度本身就是发现 | venn(labels) | “您的一星评论中有 38% 也提到了等待时间”——重叠程度是重点 |
| 整体的部分构成,单次快照 | pie(slices, radius) | 收入构成:咖啡 / 热食 / 饮料 / 彩票 |
| 整体的部分构成,附带总数标题 | donut(slices, radius, center_text=) | 同上,中间显示“247 条评论” |
| 两个数值变量,是否存在关联 | scatter(points, width, height) | 价格 vs. 评分,跨竞品 |
| 同上,并显示趋势线 | scatter_with_trend(points, width, height) | 同上,附带拟合的相关线 |
如果上表没有适合的选项,请停下来思考清楚,而不是强行使用不匹配的图表类型——一个技术上可渲染但令人困惑的图表,比使用 Chrome 无头 PNG 流水线更糟糕。
图表的 2 秒测试
一个图表需要满足与一个好句子相同的标准:让疲惫的读者在手机上第一眼就能理解,无需重读,无需解码。在选择图表类型之前就应用该测试,而不是在构建之后。
2 秒测试: 目标读者(小企业主,而非数据分析师)能否在 2 秒内说出这个图表的意思,而无需先阅读图例?如果理解任何一个区域或切片都需要先解码图例,那它就是在视觉上使用了“行话”:技术上精确,但迫使读者付出努力,而不是吸引他们。
在真实案例中被发现,由编辑在冷读已发布的内容时发现:
- 三集合韦恩图的数字位掩码图需要先阅读一个 7 行的图例,才能理解任何区域的含义。编辑:他们都看不懂,那么普通企业主也看不懂。背后的信息——“你需要这三者,而不仅仅是其中之一”——用一句话就能说清楚,而图之后的有序列表已经用通俗的英语说明了。这张图增加了解码成本,却没有提供散文部分已经表达得更清晰的信息。修复方案:删除该图,让句子和列表完成工作。
- 直流快充成本范围条(单一条,无坐标轴,无对比点)没有对比对象。条形图的价值在于显示相对于某个参考点的大小——比例尺、另一个条、基准。没有参考点的孤立条形图是装饰性的,而非信息性的。它展示的数字($100k–$300k+)已经在前一句话中清楚地说明了。修复方案:同样删除。
遵循的规则:
- 一个需要自身图例才能阅读的图表是一个危险信号——不仅仅针对韦恩图。坐标轴未标注的热力图,或切片数超出读者脑容量的饼图,同样会失败。
- 如果一句平实的陈述已经和图表一样清晰地表达了事实,请跳过图表。同样,在散文中用实际数字替换“几个/许多/各种”的直觉:只有在比较或趋势是重点时才使用可视化手段,而不仅仅是因为数据具有形状。
- 每个条形图都需要一个坐标轴(
render(..., axis=))或一个在同一比例尺上的第二个条来进行对比。两者皆无的单个条无法传达任何信息——就像被动语态且无真正主语的句子一样失败。但是,一旦满足了这个条件,如果坐标轴只是重复了每行数值标签中已有的数字,则可以删除它——一个加油 vs. 充电的条就因此删除了它的“0 ... 45 min”坐标轴线:因为两行都已经携带了自己的值(“5 min”、“45 min”),所以坐标轴只是重复了第二次“45 min”。与从句子中删除冗余从句的原则相同。 - 如果一个数字已经能用一句干净的陈述表达,不要将其制成图表。“$100k–$300k+ installed”作为文字已经极其清晰;将其制成图表不会揭示比较、趋势或重叠,只会增加一个无回报的解码步骤。
- 以冷读的方式阅读完成的图表,就像实际读者会做的那样——一位在手机上的加油站老板,而不是刚刚构建它并且已经知道它应该说什么的人。
工作流
使用 scripts/ascii_chart.py,而不是手动对齐字符或栅格化几何图形——列计算和圆/角度计算正是那种容易因目测而出现细微错误,并且事后才以单个字符错位或圆形不对称的形式显现出来的事情。随时运行 python3 scripts/ascii_chart.py 可打印每种图表类型的一个示例,作为健全性检查。
条形图 / 范围图
from ascii_chart import bar, range_bar, render
rows = [
("Gas fill-up", bar(5, 45, 24), "5 min"),
("DC fast charge", bar(45, 45, 24), "45 min"),
]
print(render(rows, axis=("0", "45 min")))
row = ("DC fast charger", range_bar(100, 300, 300, 24, open_ended=True), "$100k–$300k+")
print(render([row]))
render() 接收 (label, bar_string, value_str) 元组,并处理所有列对齐——传入您自己预格式化的 value_str(货币、单位等,根据文案需要);函数仅处理几何部分。
子弹图
from ascii_chart import bullet, render
rows = [("Avg rating", bullet(4.2, 5.0, target=4.7, benchmark=(3.8, 4.5), width=24), "4.2 (target 4.7)")]
print(render(rows, axis=("0", "5.0")))
一个实心条表示测量的 value,绘制在可选的阴影 benchmark 区间(“正常”范围,▒)上方,并带有一个可选的目标标记(┃,始终最后绘制,以确保它在任何填充/阴影下方仍然可见)。一瞥之间回答“我是否在正常范围内,距离目标有多远”——这里仍然只有一个真正的条(测量值);区间只是背景信息。
间隙括号
from ascii_chart import bar, gap_bracket, render
rows = [("You", bar(42000, 60000, 24), "$42,000"), ("Market median", bar(58000, 60000, 24), "$58,000")]
print(render(rows, bracket=gap_bracket(42000, 58000, 60000, 24)))
一个 └─┘ 括号跨越两个条末端之间,间隙大小居中显示(根据两个数值自动格式化,或传入 label= 自定义文本)。对条和括号使用相同的 scale_max/width,否则跨度无法对齐。这是“您 vs. 市场”的显式表达,无需读者目测。
星形直方图
from ascii_chart import star_histogram
print(star_histogram([(5, 61), (4, 22), (3, 9), (2, 4), (1, 4)]))
counts 是 [(stars, count), ...] 格式,按任意顺序打印(通常从 5 到 1)。实心/空心星号字形(★/☆)兼作行标签——不需要再在文字中写出“5 星”。完全基于 bar() + render() 构建。
热力图
from ascii_chart import heatmap
print(heatmap(matrix, ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
col_ticks=[(0, "12am"), (6, "6am"), (12, "12pm"), (18, "6pm")]))
matrix 是行×列的数值强度(例如 7 天 × 24 小时),基于整个矩阵(而非每行)进行归一化,使得各行之间的阴影具有可比性。col_ticks 是 [(column_index, label), ...] 格式——没有自动间距,请自行选择不会冲突的位置。
折线图 / 火花线
from ascii_chart import line_chart, sparkline
print(line_chart([120, 135, 128, 150, 175, 190, 205, 198, 220],
height=10, x_labels=("Jan", "Sep")))
print(sparkline([3, 5, 4, 8, 12, 10, 15, 14, 18]))
line_chart 使用框线拐角(╭╮╰╯│─)绘制一条折线——没有字符表示任意中间斜率,因此宽度固定为 len(values) - 1 列。x_labels 如果给出,只是一个在坐标轴行上打印的 (first, last) 元组——中间的点在周围的散文中说明,不要尝试标注每个刻度。sparkline 是单行的 8 级趋势(▁▂▃▄▅▆▇█),用于句子中的一个子句,而非独立图表。
韦恩图(2 或 3 个集合)
from ascii_chart import venn
diagram, legend = venn(["Highway exit", "Big store", "Grid capacity"])
print(diagram)
print(legend)
将重叠的圆形栅格化,并用数字(位掩码,表示区域属于哪个集合:1、2、4 表示独立集合,3/5/6 表示两两重叠,7 表示三者重叠)填充每个区域,而不是试图将标签文本塞入弯曲的边界内(这在等宽渲染中无法实现)。图例(单独返回,打印两者)将每个数字映射回实际的标签组合,例如 7 = Highway exit + Big store + Grid capacity <- all of them。最初,这被认为在 ASCII 中不可能实现——数字区域技术干净地解决了这个问题;现在不再有韦恩图需要 PNG 回退的情况。
但在使用它之前,请先通过上面的 2 秒测试——数字图例本身正是导致一个真实韦恩图让普通读者感到困惑的原因。 只有当重叠程度是发现点时,韦恩图才有其价值(例如,“您的一星评论中有 38% 也提到了等待时间”)。当实际信息是“您需要这三者一起,没有部分学分”时,即使韦恩图渲染正确,它也是一个错误的工具——纯文本可以无需图例地完成这项工作,例如:
Highway exit + Big store + Grid capacity
miss one of these, skip the site
饼图 / 环形图
from ascii_chart import pie, donut
diagram, legend = pie([("Coffee", 40), ("Hot food", 25), ("Drinks", 20), ("Lottery", 15)], radius=9)
print(diagram); print(legend)
diagram, legend = donut([("Coffee", 40), ("Hot food", 25), ("Drinks", 20), ("Lottery", 15)],
radius=9, center_text="247")
print(diagram); print(legend)
切片从 12 点钟方向开始顺时针排列(标准的饼图惯例),每个切片获得一个独特的填充字符(█▓▒░▚▞▤▥,在典型的 2-6 个切片计数中,相邻切片不会重复),使得边界在没有颜色的情况下仍然可辨。donut 是带有空心中心的 pie——center_text 将标题数字/标签置于中心(例如总数),这是 ASCII 环形图能够做到而真实环形图通常浪费在空白空间上的事情之一。radius 以行为单位;函数内部自动修正等宽字符的宽高比(共享的 ASPECT 常量,venn() 也使用),因此结果是真正的圆形,而不是扁平的椭圆——不要单独调整宽度/高度,只需传入 radius。
修正: ASPECT 原本硬编码为假设的 2.0,从未……
……[截断以进行翻译]