数据可视化配色工程化:分类色板、感知均匀与三重校验

发布时间:2026/10/29 23:02:00
数据可视化配色工程化:分类色板、感知均匀与三重校验

数据可视化里最容易被低估、也最容易翻车的部分,大概就是色彩。我做过的图表里,被追问最多的不是坐标轴刻度怎么定的、聚合逻辑怎么写的,而是"这两个颜色分别代表什么""为什么这个蓝和那个蓝看起来一样"。有一次一份周报里放了六条折线的趋势图,配色是从一张设计稿上顺手抓的六个品牌色,结果开会时三个人对同一根线给出了三种解读,后面半小时全在核对哪根线是哪根。那次之后我就把配色当成一个正经的工程问题来处理,而不是画完图之后"顺手调一调"的收尾动作。

这篇内容想讲的就是数据可视化中的色彩运用这件事:颜色在图表里到底承担什么职责、不同类型的图表该怎么分配颜色、怎么判断一套配色是不是真的可读、以及怎么把这些判断落成能复用的代码和规范。它适合每天要出图的数据分析同学,也适合需要把图表嵌进产品里的前端和设计同学。下面这些东西基本都是我在实际项目里踩过之后留下来的结论,有些地方会跟"配色美学"的常见说法不一致,我会尽量把原因说清楚。

1. 色彩在图表里首先是编码通道,不是装饰

1.1 位置、长度、颜色:三条通道的表现力并不对等

先把一个基础认知摆正:图表是把数据映射到视觉通道上的过程,位置、长度、面积、角度、颜色、形状都是通道,但它们的"表现力"差距很大。业界有一套被反复验证过的排序结论——人对位置的判断精度最高,其次是长度和角度,面积和颜色的精度明显更差。这意味着什么?意味着当你有一组需要被精确比较的数值时,把它们交给颜色去承载,本身就是一次精度上的主动降级。

我在实际项目里见过太多反例:把销售额的高低完全交给一张热力图的颜色深浅去表达,然后让人从图上读出"哪个区比哪个区高多少"。这件事几乎做不到,因为人眼对色相和明度的判断是相对的、受周围颜色影响的,同一个色块放在浅色邻居旁边显得深,放在深色邻居旁边显得浅。所以热力图这种图,颜色只适合表达"大概的分布形态"和"找异常点",真要比较数值必须配数值标签或者换成条形图。

颜色真正的强项在于分类和强调:把一个图里几十上百个元素按类别区分开,或者把某一两条关键序列从背景里拎出来。它不擅长做精细的定量比较。你把这两件事分清之后,很多配色上的纠结会自动消失——分类色板的目标是"可区分",连续色板的目标是"有序且可感知",这两个目标的实现路径完全不同。

1.2 "好看"的配色为什么经常读不出数

从设计稿或者配色网站抓一套颜色直接用在图表上,出问题的概率非常高,原因有三个。

第一,设计配色是为"品牌识别"和"氛围"服务的,色相分布往往是不均匀的。一套品牌色里可能有三个都是蓝绿色系,彼此色相只差十几度,放在 UI 按钮上很好看,但当它们同时出现在图例里代表三个类别时,人眼根本分不清。分类配色要求的是色相在色轮上尽量均匀铺开,而且要考虑色觉差异人群能不能分辨,这跟品牌色的设计目标正好相反。

第二,绝大多数在线配色工具不考虑明度分布。而明度恰恰是颜色通道里最可靠的那个维度——它跟"深浅"直接挂钩,不受色相干扰,色觉缺陷人群对明度的感知也基本完好。一套分类色板如果所有颜色的明度都差不多,那么打印成黑白、或者投到一台偏色的会议投影仪上,整张图会糊成一片灰。

第三,颜色的可读性高度依赖上下文。同一个色值,放在白底图表里、放在深色大屏里、放在 PPT 的米色背景上,观感差别极大。我现在的习惯是:色板定义完之后一定要在真实的使用场景里过一遍——白底网页、深色大屏、黑白打印,三个场景至少看两个。

1.3 一套十秒钟能做完的自检

每次定完配色,我会做一个非常粗暴的检查,十秒内能出结论:

  • 眯起眼睛看整张图(或者把图缩小到 30% 看),如果类别还能区分开,说明明度差异够用;
  • 把图转成灰度,如果还能区分开,说明这套配色对色觉差异人群基本友好;
  • 只看图例不看图,能不能在 3 秒内把任意两个图例项对应到图上的位置。

第三条听起来离谱,但它筛掉了大量"颜色太接近"的情况。如果我自己都要盯着图例反复比对,那读者一定也在做同样的事,而读者的耐心比我自己少得多。

一个经验判断:如果你需要放大图例才能确认某个颜色对应哪条线,这套配色就该重做了,而不是给图例加个高亮交互来补救。

2. 类别数量决定配色策略,而不是反过来

2.1 四个以内、五到八个、超过八个,是三个完全不同的问题

分类配色最容易犯的错是"先选一套好看的色板,然后往数据上套"。正确的顺序反过来:先看有几个类别,再决定用什么策略。

类别数量可行的做法常见错误
1-4 类用色相 + 明度同时区分,颜色可以饱和一些过度设计,四类用了八个颜色
5-8 类色板必须经过色觉差异校验,明度要有梯度直接从品牌色里挑八个相近的色相
9-12 类只保留前几名重点色,其余统一为低饱和灰硬凑十二种能区分的颜色
超过 12 类颜色不再是主要区分手段,改用位置、标签、分面小图用一套 20 色的渐变去区分类别

4 类以内是最舒服的区间,人眼在这个数量下的颜色分辨能力还很充足,你可以大胆用饱和度高一点的颜色,视觉上也有冲击力。5 到 8 类是大多数业务图表的实际区间,也是最容易出问题的区间——这时候颜色之间的差异开始变小,必须借助明度做二次区分,比如"深蓝/中蓝/浅蓝"配"深橙/浅橙",靠明度而不是靠色相来拉开。

9 类以上我基本不会再认真调色了。原因很实在:人眼能稳定区分的分类颜色上限大概就在七八个左右,超过这个数量,任何色板都会出现"这两块看起来一样"的情况。这时候正确的做法是转换表达方式,而不是继续在颜色上加码。

2.2 类别太多的时候,颜色不是答案

遇到类别爆炸,我常用的三个替代方案,按优先级排:

降维合并。把长尾类别合并成"其他",让主图只保留 5 到 7 个类别。如果长尾确实重要,单独出一个明细表或者下钻页面。这招最有效,因为大部分业务场景里,长尾类别加起来也没多少量,不值得占用颜色预算。

分面小图(small multiples)。每个类别一个小图,横轴纵轴完全一致,靠位置对比而不是颜色区分。十几个类别用这种方式表达,可读性比一张挤了十几条线的图高一个数量级。代价是占空间,但如果是在仪表盘的大屏上,这个代价完全值得。

直接标注。线图的末端、散点的旁边,直接把类别名写在图上,颜色只作为辅助。现在很多绘图库都支持自动避让标注,用起来不麻烦,效果比图例加颜色好得多——读者不需要做"图例 → 颜色 → 图形"的二次映射,阅读成本直接砍掉一半。

顺带说一句:如果一张图需要图例,说明颜色承担了信息传递职责;如果能在图上直接标名字,图例就可以砍掉,整张图的信噪比会明显提升。

2.3 分类色板的排序和"主色位"

分类色板的顺序不是随便排的。我习惯把最需要被注意的类别放在第一个位置,用一个饱和度高、辨识度强的颜色占住。这个位置我叫做"主色位"。

具体做法:先给数据排序(按量值、按业务重要性都行),把最重要的类别指定为第一个颜色,其余的按色板顺序分配。这样做的原因是,读者的注意力天然会先落在最醒目的那个颜色上,如果最醒目的颜色给了一个无关紧要的长尾类别,注意力就被浪费了。

另一个细节是相邻类别的颜色要尽量拉开。如果色板是按色相顺序排的(红橙黄绿青蓝紫),相邻两个类别会被分配到色相只差二三十度的两个颜色,堆叠柱状图里挨在一起就很难分。我一般会用一个"跳位"的取色策略:从色板里按固定间隔取色,比如索引 0、3、1、4、2、5 这样交错分配,让空间上相邻的类别在色相上尽量远。

# 分类色板的交错取色:避免相邻类别颜色过近 palette = ["#3B6FD4", "#E8833A", "#2E9E6B", "#C9484F", "#7A5BB5", "#D6A419", "#4FA3C7", "#8C6B4F"] def interleaved(n, pal=palette): """按交错顺序取 n 个颜色,保证相邻类别视觉距离尽量大""" order, lo, hi = [], 0, len(pal) - 1 while lo <= hi: order.append(lo) if lo != hi: order.append(hi) lo, hi = lo + 1, hi - 1 return [pal[order[i % len(order)]] for i in range(n)]

这段逻辑简单但很管用,尤其是堆叠柱状图和饼图这类"颜色块紧挨着"的图形。

3. 连续型与发散型:明度梯度比色相轮转靠谱得多

3.1 感知均匀性:为什么 RGB 空间里调的渐变会骗人

连续型配色要表达"数值从小到大的连续变化",最直观的做法是在两个颜色之间做线性插值。但如果你在 RGB 空间里直接线性插值,结果会出问题——RGB 是个设备空间,不是感知空间,它到人眼感知的映射是非线性的。

举个具体的例子:从纯蓝#0000FF到纯白#FFFFFF做线性插值,中间会经过一串明显偏紫、偏灰的颜色,而且明度的变化在两端快、中间慢,看起来像是有个"断层"。更典型的坑是从红到绿插值,中间会经过一段浑浊的褐色,因为红色和绿色在 RGB 立方体里是互补位置,中点是灰褐色。

解决办法是换到感知均匀的色彩空间里做插值,CIELAB 或者 Oklab 都可以。这两个空间的坐标距离大致对应人眼感知到的颜色差异,在它们中间做线性插值,出来的渐变在感知上是均匀的。下面这段代码用 Lab 空间做插值,不依赖第三方色彩库,直接用标准公式转换:

import numpy as np def srgb_to_linear(c): c = np.asarray(c, dtype=float) return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) def linear_to_srgb(c): c = np.clip(np.asarray(c, dtype=float), 0, 1) return np.where(c <= 0.0031308, c * 12.92, 1.055 * c ** (1 / 2.4) - 0.055) def rgb_to_lab(rgb): """rgb: 0-1 的 sRGB 三元组 -> CIELAB""" r, g, b = srgb_to_linear(rgb) x = r * 0.4124564 + g * 0.3575761 + b * 0.1804375 y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750 z = r * 0.0193339 + g * 0.1191920 + b * 0.9503041 # D65 白点 x, y, z = x / 0.95047, y / 1.00000, z / 1.08883 f = lambda t: t ** (1 / 3) if t > 0.008856 else (7.787 * t + 16 / 116) fx, fy, fz = f(x), f(y), f(z) return np.array([116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]) def lab_to_rgb(lab): L, a, b_ = lab fy = (L + 16) / 116 fx, fz = fy + a / 500, fy - b_ / 200 f_inv = lambda t: t ** 3 if t ** 3 > 0.008856 else (t - 16 / 116) / 7.787 x, y, z = f_inv(fx) * 0.95047, f_inv(fy) * 1.0, f_inv(fz) * 1.08883 r = x * 3.2404542 + y * -1.5371385 + z * -0.4985314 g = x * -0.9692660 + y * 1.8760108 + z * 0.0415560 bl = x * 0.0556434 + y * -0.2040259 + z * 1.0572252 return linear_to_srgb([r, g, bl]) def lab_gradient(c1, c2, steps=9): """在两个 sRGB 颜色之间按 Lab 空间插值,返回 steps 个色值""" l1, l2 = rgb_to_lab(c1), rgb_to_lab(c2) out = [] for t in np.linspace(0, 1, steps): rgb = lab_to_rgb(l1 + (l2 - l1) * t) out.append("#%02X%02X%02X" % tuple(int(round(v * 255)) for v in rgb)) return out

实测下来,同一个色值对,Lab 插值出来的渐变比 RGB 插值干净很多,中间不会出现浑浊的过渡色。这点在生成品牌色渐变时特别明显,因为品牌色通常是高饱和的,RGB 插值的瑕疵会被放大。

3.2 单色渐变、多色渐变,用哪个

连续色板分两类:单色渐变(同一个色相,明度从浅到深)和多色渐变(跨几个色相,比如经典的蓝-绿-黄)。

单色渐变的优点是安全、不会产生误导、黑白打印也没问题,缺点是表达范围窄——如果一个数据集里既有很小的值又有极大的值,单色渐变的动态范围可能不够,小的那些值全部挤在浅色端看不清。多色渐变能拉开更大的感知距离,代价是颜色本身开始携带"额外的语义",比如蓝-黄渐变里,黄色天然会显得"更亮眼",读者会不自觉地认为黄色区域更重要。

我自己的选择原则是:如果数值是单一的、同质的量,用单色渐变;如果需要强调两端或者跨越零点,用发散色板。多色连续渐变(比如蓝-绿-黄)我一般只在需要极高动态范围的时候用,而且要确认它不会让人误以为中间某个色相代表特殊含义。

3.3 发散色板的中性中点必须是"真的中性"

发散色板用来表达"相对某个基准值的偏离",比如同比增减、盈亏、偏差。它的结构是:两个对比色相 + 中间一个中性色。

这里最容易踩的坑是中点不是中性色。很多人做发散色板会直接从红渐变到蓝,插值出来的中点是一团紫灰色。问题在于,那个紫色的明度和两端不一样,导致基准值附近的数据在视觉上反而更"跳",你本来想突出的是偏差大的区域,结果视觉重心跑到了中间。

正确做法是让中点显式地等于背景色或者一个接近白的低饱和色,然后从中点向两侧各自做渐变。这样中点会自然"融进"背景,偏差越大颜色越浓,视觉重心和业务逻辑就对齐了。

def diverging(mid="#F2F2F2", pos="#C9484F", neg="#3B6FD4", steps=5): """对称发散色板:中点中性,两侧各自渐变""" right = lab_gradient(hex2rgb(mid), hex2rgb(pos), steps) left = lab_gradient(hex2rgb(mid), hex2rgb(neg), steps)[::-1] return left[:-1] + right # 拼成 2*steps-1 个色阶

还有一点:发散色板的两侧一定要对称。正值侧用 5 个色阶,负值侧也用 5 个色阶,而且两侧的明度变化幅度要一致。如果一侧深一侧浅,读者会误以为某一侧的量级更大,这在正负对比的场景里是致命的误导。

3.4 关于彩虹色板,我的实际结论

彩虹色板(红橙黄绿青蓝紫那种)在科研绘图里被批判了很多年,核心问题有两个:一是它的明度变化是非单调的,黄色那一带特别亮、红蓝两端特别暗,导致数据在黄色区域被人为地"凸显"出来;二是它的色相顺序在感知上不连续,读者没法凭直觉判断"哪个更大"。

但我在实际工作里并不会一刀切地禁用彩虹色板。有些场景确实需要它,比如多类别的热力图需要同时区分数值和类别,或者需要跟已有的历史图表保持视觉一致。我的处理方式是:只在明确需要"多色相识别"而不需要"精确比较数值"的时候用彩虹色板,并且一定要配数值标签或者一个数值色阶条。如果这张图的用途是让人读出具体数值,那就老老实实用单色渐变。

反过来,像 viridis 这类感知均匀的多色渐变,现在已经是我的默认选择。它在明度上是单调递增的(从深紫到亮黄),色觉缺陷人群看着也没问题,黑白打印也能看出深浅差异。代价是它的颜色本身不那么"中性",带一点点荧光感,如果图表要放进偏正式的报告里,我会换成更低饱和的版本。

4. 三重校验:色觉差异、灰度打印、暗色模式

4.1 色觉缺陷不是小众问题

人群里红绿色觉异常的比例不算低,男性里大概每十几个人就有一个。这不是"少数人的特殊需求",而是你出一张图,有可能十几分之一的读者看到的跟你看到的不一样。红绿色觉异常的人看红绿对比会感觉是"两个亮度差不多的黄褐色",如果你的分类色板里红和绿是相邻类别,对他们来说这两类就完全没法区分。

应对方式不复杂,核心是不要只靠色相区分,一定要叠加明度差异。红和绿如果一个是深红、一个是亮绿,明度差足够大,色觉异常的人也能靠深浅分开。这也是我在第 2 节里强调"5 到 8 类必须靠明度做二次区分"的原因。

具体校验有一个很实用的方法:把配色转成模拟红绿色觉异常后的样子,直接看。模拟算法需要把 sRGB 转到 LMS 空间做矩阵变换,实现起来不算复杂,但如果只是做校验,用现成的在线模拟工具截图对比更快,我一般是配色定稿前跑一次模拟,肉眼确认主要类别还能分开就行。

4.2 灰度打印这一关,能筛掉一半的问题配色

灰度转换是最便宜也最有效的校验,因为它的门槛极低——大多数图像处理软件一键就能转,甚至不用工具,直接用相对亮度公式算一下每个色值的亮度,看排序就行。

def luminance(hex_color): """WCAG 相对亮度,用于灰度模拟与对比度计算""" r, g, b = hex2rgb(hex_color) r, g, b = srgb_to_linear([r, g, b]) return 0.2126 * r + 0.7152 * g + 0.0722 * b def contrast_ratio(c1, c2): l1, l2 = sorted([luminance(c1), luminance(c2)], reverse=True) return (l1 + 0.05) / (l2 + 0.05)

判定标准很简单:把分类色板里所有颜色的亮度算出来排个序,如果任意两个亮度值相差不到 0.05,这一对颜色在灰度下就是糊的。我做过一个统计,从网上随手抓的分类色板里,大概有一半以上存在亮度过于接近的问题——因为很多色板是为了在彩色屏幕上"好看"而设计的,压根没考虑灰度场景。

对于连续色板,灰度校验更简单:转成灰度之后,色阶必须是单调的。如果转灰度后出现"中间深两头浅"的情况,说明这个色板的明度变化是非单调的,读者在灰度场景下会读出错误的数值顺序。

4.3 暗色模式不是"把背景调黑"就完事

这两年做深色大屏的需求很多,我发现一个普遍现象:把浅色主题的配色直接放到深色背景上,颜色会整体显得更亮、更饱和,原本区分度刚好的两个颜色,在深色底上会互相抢,视觉上很吵。

原因是对比度机制变了。浅色背景上,深色元素对比度高、浅色元素对比度低;换成深色背景后正好反过来,原本"低调"的浅色现在变成了高对比的显眼元素。所以暗色主题的配色需要单独调一套,而不是简单反色。

我的做法是给每套配色维护两个版本:浅色版和深色版,色相基本保持不变,但明度和饱和度分别调整。深色版整体把亮度提一档、饱和度降一档,让颜色在深底上不至于"发光"。同时要保证文字和背景的对比度达到可读标准——正文文字和背景的对比度至少 4.5:1,图表里的辅助线、网格线这类非文本元素至少 3:1。这两个数值是通行的可访问性标准里给出的,直接拿来当验收阈值就行。

4.4 定稿前的校验清单

我把上面这些整理成了一张表,每次配色定稿前过一遍,花不了几分钟,但能避免大部分返工:

校验项判定标准不通过的典型表现
灰度可分任意两色亮度差 > 0.05转灰度后类别糊成一片
明度单调连续色板灰度值单调变化中间某段特别亮或特别暗
色觉模拟模拟后主要类别仍可区分红绿相邻类别无法分辨
文字对比度正文 ≥ 4.5:1,辅助元素 ≥ 3:1深色底上灰字看不清
小尺寸可读缩到 30% 仍能区分必须放大才能看清图例
跨场景一致白底、深底、打印均可用只在白底屏幕上正常

5. 把品牌色改造成可用的数据配色

5.1 品牌色通常不能直接当连续色板用

品牌色是给标识、按钮、标题这些场景设计的,它追求的是识别度和情绪表达,通常会选一个饱和度高、有个性的主色。这种颜色直接拿来做连续色板的端点,问题很明显:单色相的品牌色做不出足够的明度跨度,从品牌蓝渐变到白色,中间一大段都是浅色,数据密集区域全挤在浅色端,区分度不够。

更麻烦的是发散色板。品牌色往往只有一个主色,而发散色板需要两个对比色相。硬凑一个"品牌色的互补色"经常出来很怪的颜色,跟整体视觉不搭。

我的处理思路是:品牌色决定色相,但明度和饱和度由数据需求决定;品牌色占主色位,其余颜色从它派生。也就是说,品牌色是这套配色的"锚点",不是全部。

5.2 从品牌色派生配色的具体做法

派生分三步走。

第一步是把品牌色转成 Lab 或者 HSL,看清它的三个属性。色相值决定了整套配色的基调,饱和度决定了这套配色能有多"跳",明度决定它在深色还是浅色背景上更合适。我一般会把品牌色的明度调到一个中间偏深的位置作为"主色",因为太亮的品牌色在图表里做大面积填充会很刺眼。

第二步是围绕主色相生成分类色板。做法是在 Lab 空间里调整 a、b 通道,保持 L(明度)大致在同一区间,色相角均匀铺开。这里要注意,Lab 的 a-b 平面不是圆的,某些方向的颜色会更饱和一些,所以生成完之后一定要肉眼过一遍,把明显过艳或者过灰的颜色手动调一下。

import colorsys def brand_to_palette(brand_hex, n=6, sat_scale=1.0): """围绕品牌色相生成 n 个分类色,保持明度与饱和度接近""" r, g, b = hex2rgb(brand_hex) h, l, s = colorsys.rgb_to_hls(r, g, b) out = [] for i in range(n): nh = (h + i / n) % 1.0 # 明度做轻微梯度,避免所有颜色亮度相同导致灰度下不可分 nl = min(0.72, max(0.34, l + (i - n / 2) * 0.045)) ns = min(0.95, max(0.35, s * sat_scale)) nr, ng, nb = colorsys.hls_to_rgb(nh, nl, ns) out.append("#%02X%02X%02X" % (int(nr * 255), int(ng * 255), int(nb * 255))) return out

第三步是做减法。这一步最容易被跳过,但它是让配色从"能用"变成"好用"的关键。生成出来的色板一定有冗余——可能有两个颜色的色相太近,可能有一个颜色在灰度下跟另一个撞了。我的做法是把生成的色板跑一遍前面的校验清单,把不合格的颜色逐个替换,通常 8 个颜色最后会砍到 5 到 6 个。

5.3 一套可以直接照搬的落地流程

我把整个流程固化成了五个步骤,从拿到品牌色到产出可用配色,大概半小时能完成:

  1. 拆解品牌色:转 HSL,记录色相、明度、饱和度,判断它在深浅背景上的适配性;
  2. 生成分类色板候选:以品牌色为起点,色相均匀铺开生成 8 到 10 个候选;
  3. 生成连续色板:选品牌色的深色版本作为端点,向背景色做 Lab 插值,生成 7 到 9 个色阶;
  4. 生成发散色板:品牌色做正极端,补一个色相距离 150 到 180 度的对比色做负极端,中点用背景色;
  5. 三重校验 + 减法:过灰度、色觉模拟、小尺寸三道校验,砍掉不合格的颜色。

这套流程的好处是可复现。下一个项目换一个品牌色,同样的步骤跑一遍就能出结果,不需要重新凭感觉调。

提醒一句:派生的分类色板不要直接替换掉品牌主色。主色必须留在分类色板的第一个位置,否则整套图表会失去品牌一致性,视觉上会像"另一个产品"。

6. 从色值到代码:让配色变成可维护的配置

6.1 用一套色板令牌统管所有图表

配色的最大维护成本不在设计阶段,而在后面——图表散落在不同的报表、仪表盘、产品页面里,改一个色值要改十几个地方,改着改着就出现同一个类别在不同图里是不同颜色的情况。这在多人协作的项目里特别常见,读者会因此混淆。

我的做法是定义一个统一的色板令牌(color token),所有图表都从这里取色,不允许在业务代码里写死十六进制值。

# palette.py —— 全项目唯一的色彩来源 PALETTE = { "categorical": ["#3B6FD4", "#E8833A", "#2E9E6B", "#C9484F", "#7A5BB5", "#D6A419", "#4FA3C7", "#8C6B4F"], "sequential": ["#EAF0FA", "#C5D6F0", "#9BB9E4", "#6E99D4", "#4878C0", "#2E5CA6", "#1B3F7E"], "diverging": ["#3B6FD4", "#7FA2E0", "#C3D2EE", "#F2F2F2", "#EFC3C5", "#DA888C", "#C9484F"], } SEMANTIC = { "positive": "#2E9E6B", "negative": "#C9484F", "neutral": "#8C8C8C", "highlight": "#E8833A", "muted": "#C9CDD4", } def cat(i, palette="categorical"): """取第 i 个分类色,天然支持循环""" colors = PALETTE[palette] return colors[i % len(colors)]

这套写法的价值在于,换品牌色的时候只改这一个文件,所有图表自动跟着变。语义色单独抽出来也很有必要,因为"正面/负面"这类颜色在业务系统里是跨图表一致的,不应该跟着分类色板乱走。

前端项目同理,用 CSS 变量做同样的抽象:

:root { --cat-1: #3B6FD4; --cat-2: #E8833A; --cat-3: #2E9E6B; --cat-4: #C9484F; --seq-1: #EAF0FA; --seq-7: #1B3F7E; --semantic-positive: #2E9E6B; --semantic-negative: #C9484F; --chart-grid: #E5E7EB; --chart-bg: #FFFFFF; } [data-theme="dark"] { --cat-1: #6E9AE8; --cat-2: #F0A46A; --cat-3: #5CBE90; --cat-4: #E07A80; --chart-grid: #2A2E35; --chart-bg: #14171C; }

深色主题单独覆盖一遍,而不是用 filter 反转,这点很关键。反转会把所有颜色的色相也一起翻掉,蓝色会变成橙色,语义全乱。

6.2 一个能自动卡住问题的校验脚本

人工过校验清单容易漏,尤其是配色改了好几轮之后。我写了个小脚本放在 CI 里,配色文件一改就跑,不通过直接报警。

def validate_palette(colors, min_lum_gap=0.05, min_contrast=3.0, bg="#FFFFFF"): """校验分类色板:灰度可分 + 与背景对比度达标""" issues = [] lums = [(c, luminance(c)) for c in colors] # 1. 两两亮度差 for i in range(len(lums)): for j in range(i + 1, len(lums)): gap = abs(lums[i][1] - lums[j][1]) if gap < min_lum_gap: issues.append( f"亮度过于接近: {lums[i][0]} vs {lums[j][0]} (Δ={gap:.3f})" ) # 2. 与背景的对比度 for c in colors: cr = contrast_ratio(c, bg) if cr < min_contrast: issues.append(f"与背景对比度不足: {c} (CR={cr:.2f})") return issues if __name__ == "__main__": problems = validate_palette(PALETTE["categorical"]) for p in problems: print("[WARN]", p) print(f"共发现 {len(problems)} 处问题")

跑出来的是告警不是报错,因为有些场景下亮度接近是刻意的(比如同一类别的深浅变体),需要人来判断。但如果一个色板跑出来十几条告警,那基本可以确定需要重做了。

6.3 上线前的最后一遍检查

代码层面的东西都搞定之后,还有几件事必须人工确认,脚本替代不了。

第一是在真实数据上跑一遍。用测试数据调的配色,换成真实数据经常翻车——真实数据往往有长尾、有极端值、有空值,颜色分布跟你预想的完全不同。我曾经用一份均匀分布的模拟数据调好了一套连续色板,结果真实数据是极度右偏的,90% 的值挤在最小的一段色阶里,整张图看上去几乎全是同一个浅色。

第二是在真实的显示设备上看。办公室的显示器和大屏、投影仪、笔记本屏幕的色域差异很大,一套在广色域屏上看着很舒服的低饱和配色,投到会议室的投影仪上可能灰成一片。如果这张图要上大屏,一定要在现场看一眼。

第三是让一个没参与制作的人看一遍。这个人的反馈最有价值,因为他不知道你的设计意图,只能靠图本身来理解。他如果问出"这两个颜色是什么意思",那就说明配色或图例有问题,不用辩解,直接改。

最后分享一个我在实际项目里养成的小习惯:每套配色定稿后,在代码仓库里存一张截图,标注日期和用途。半年后再回头改的时候,能立刻看出当时为什么这么选,省掉大量"考古"时间。配色这件事,判断依据往往比色值本身更重要,而判断依据是会被忘掉的。