ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

坐标轴刻度别靠硬凑:用一个小算法画出 0、20、40、60、80、100

坐标轴刻度别靠硬凑:用一个小算法画出 0、20、40、60、80、100

数据范围是 12.8 到 88.4,简单等分会得到一串不好读的小数。把所有刻度随手改成整数也不行:间隔不等、重复数字或基线错位,会让图看起来规整,读数却失真。

我会先问图表任务:是比较量级、观察局部波动,还是要让人精确读值?柱图通常应保留零基线;局部趋势图可以采用更紧的范围,但要明确显示坐标,不偷偷夸大变化。

“漂亮”首先得有规则

D3 的 ticks 文档说明,刻度值常采用 1、2、5 乘 10 的幂,数量是近似目标。下面给出一个更小的向上取整步长算法,不把它称为完整的扩展 Wilkinson 实现。D3 刻度文档

刻度不能统一保留十位小数:0~1e-12这样的范围会被全部舍成零。下面用Fraction精确选择步长,再构造Decimal刻度,计算过程不转回二进制浮点数。

这个教学函数接受int、float、十进制字符串和Decimal,返回Decimal列表。非零输入的十进制数量级指数限定为 −100 到 100,输入最多含 100 位小数;目标间隔数是 1~1000 的整数,超出范围明确报错。float按它的str()表示解释;差值若在传入前已经丢失,函数无法恢复,很窄的范围应优先传字符串或Decimal。

from decimal import Decimal, InvalidOperation from fractions import Fraction def decimal_bound(value): if isinstance(value, bool) or not isinstance(value, (int, float, str, Decimal)): raise ValueError('a decimal number is required') try: value = Decimal(str(value)) except InvalidOperation as error: raise ValueError('invalid decimal number') from error if not value.is_finite(): raise ValueError('finite bounds required') if value.as_tuple().exponent < -100 or ( value != 0 and not -100 <= value.adjusted() <= 100): raise ValueError('bounds outside the teaching domain') return value def nice_ticks(low, high, intervals=5): if isinstance(intervals, bool) or not isinstance(intervals, int) or not 1 <= intervals <= 1000: raise ValueError('intervals must be an integer in [1, 1000]') low, high = Fraction(decimal_bound(low)), Fraction(decimal_bound(high)) if low > high: raise ValueError('low must not exceed high') if low == high: # Explicit constant-axis padding policy, not a universal default. padding = max(Fraction(1), abs(low) / 100) low, high = low-padding, high+padding raw = (high-low) / intervals exponent = len(str(raw.numerator))-len(str(raw.denominator)) magnitude = Fraction(10) ** exponent if raw < magnitude: exponent -= 1 magnitude /= 10 unit = next(v for v in (1, 2, 5, 10) if v*magnitude >= raw) step = unit * magnitude start = low // step stop = -((-high) // step) # String construction is exact; no fixed decimal rounding or context division. return [Decimal(f'{i*unit}e{exponent}') for i in range(start, stop+1)] ticks = nice_ticks(12.8, 88.4) assert ticks == [0, 20, 40, 60, 80, 100] tiny = nice_ticks(0, 1e-12) assert tiny == [Decimal(v) for v in ('0', '2e-13', '4e-13', '6e-13', '8e-13', '1e-12')] cases = [ (12.8, 88.4, 5), (0, 1e-12, 5), ('0.1', '0.3', 5), ('1.0000000000001', '1.0000000000009', 5), ('-0.37', '-0.11', 5), ('-1e-12', '1e-12', 5), (7, 7, 5), (0, 0, 5), ('0.001', '0.001', 5), (1e16, 1e16+2, 5), ('0', '1e-100', 1000), ('1e100', '1.1e100', 5)] for low, high, count in cases: result = nice_ticks(low, high, count) assert 2 <= len(result) <= count+3 assert result[0] <= decimal_bound(low) and result[-1] >= decimal_bound(high) gaps = [Fraction(b)-Fraction(a) for a, b in zip(result, result[1:])] assert gaps[0] > 0 and len(set(gaps)) == 1 # Strict order and exact equal spacing. invalid = [(1, 0, 5), (0, 1, 0), (0, 1, -1), (0, 1, True), (0, 1, 2.5), (0, 1, 1001), (0, float('inf'), 5), (float('nan'), 1, 5), (True, 1, 5), (None, 1, 5), ('bad', 1, 5), (0, '1e-101', 5), (0, '1e101', 5), (0, 5e-324, 5)] for args in invalid: try: nice_ticks(*args) except ValueError: pass else: raise AssertionError(('unsupported input accepted', args)) print('main:', [format(v, 'f') for v in ticks]) print('tiny:', [str(v) for v in tiny]) print('boundary cases:', len(cases), 'accepted;', len(invalid), 'rejected as specified')

主例步长仍是 20,得到六条刻度;微小范围则得到0、2e-13、4e-13、6e-13、8e-13、1e-12,不会再合并成零。断言还检查了普通小数、窄范围、负数、跨零、常量、大基数小跨度,以及约定输入域的两端。每组都核对覆盖边界、严格递增和精确等距,而不是只检查主例。

目标间隔数依然是近似值,不保证所有范围都正好六条刻度。零跨度时,这里采用max(1, |值|×1%)留白;很小的常量也可能被扩到跨零范围。这只是写明的一种策略,业务图表应按单位和任务另定留白,不能把它当成唯一正确的默认值。

生成刻度与摆放标签是两项工作

数值间距正确,不代表文字就不会重叠。百万与千的缩写、负数符号、小数位、纵轴旋转和窄屏宽度都需要处理。可以减少显示标签,却不能改变刻度对应的真实位置;省略的单位也应放在轴标题或图注里。

Decimal可用format(value, 'f')生成标签。若前端只接受 JavaScriptNumber,直接转回浮点数仍可能把大基数下的微小差值合并;可以考虑显示相对原点的偏移,或核对图表引擎的数值能力。这里没有验证这层适配,精确生成刻度不等于任意前端都能精确摆放它们。

跨图比较时,自动缩放还可能造成另一个误读:两张图的柱高一样,但一个上限 100、另一个上限 1000。若任务是比总量,使用共同范围;若任务是看各自趋势,则提示范围不同。

本文只运行数值算法,没有验证 D3/ECharts 渲染、文本测量或无障碍体验。也没有“阅读效率提升 300%”的用户实验。好的刻度不需要这种装饰数字;读者能沿着等距标记复算,已经是它的主要价值。

延伸阅读

  • D3 刻度文档
  • W3C 非文本对比度解释
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进