Astryx ChartV2 设计研究:从「能画」到「画得对」的 API 与正确性工程
【免费下载链接】astryxAn open source design system that's fully customizable and agent ready项目地址: https://gitcode.com/GitHub_Trending/as/astryx
本文以 packages/charts/docs/CHARTV2_STAGE1_DESIGN.md 为核心骨架,结合 Astryx 仓库中
@astryxdesign/charts包的实际源码与测试,深入拆解 ChartV2 在正式发布前完成的「API 正确性 + 高质量 API」设计工作:为什么 marks + 单一共享 scale 的架构是对的、channel/encoding 模型如何统一约 25 处 API 不一致、颜色系统与 scale 正确性缺口如何补齐,以及一张完整的失败模式矩阵如何驱动 Stage 2 的测试。读完你将理解 Astryx 图表库从 v1 演进到 v2 的设计决策依据,以及这些决策在 layout.ts、types.ts、getChartColors.ts 等源码中的落地形态。
1. 这份设计文档在说什么
CHARTV2_STAGE1_DESIGN.md是 ChartV2 三个阶段规划中Stage 1("make it correct + a genuinely good API")的设计研究文档,与CHARTV2_PHASE1_PLAN.md(实现顺序与证据)互为姊妹篇:前者回答"什么样的图表 API 才是好的、让人不后悔的",后者回答"按什么顺序把它做出来"。文档状态明确标注为"research + proposals",其中第 8 节的 owner 决策项已经给出推荐默认值。
根据 packages/charts/docs/README.md 的记录,这些文件名中的 "ChartV2" 是历史命名——文档写于图表功能从@astryxdesign/lab提取为独立的@astryxdesign/charts包之前。今天的 packages/charts/README.md 描述的就是这份设计落地后的形态:
import {Chart, bar, line} from '@astryxdesign/charts'; <Chart data={data} xKey="month" series={[bar('revenue'), line('trend')]} />;核心架构一句话概括:mark 是返回配置对象的工厂函数,图表根节点拥有唯一一套 x/y scale,mark 先resolve()再render()。文档的结论是:这个架构"现代且正确",应当保留;真正需要花大力气的是 API 表面的一致性和缺失的 scale 能力。
2. ChartV2 在图表面向库格局中的位置
文档将主流图表库划分为四个 API 家族,并明确 ChartV2 的定位是其中"最好的两个"的混合体:
| API 家族 | 代表库 | 形态 | 优缺点 |
|---|---|---|---|
| 组合式 / JSX children | Recharts、Victory、visx | <LineChart data><Line dataKey/><XAxis/></LineChart> | 可发现性强、符合 React 习惯,但 scale 难以协调、节点多时性能差、跨 mark 一致性靠手工 |
| 按图表类型的配置单体 | Nivo、Chart.js、Tremor | <ResponsiveLine data={...} 大属性包/> | 常见场景快,但难以混合 mark 类型(bar + line + band 同图)、组合性差 |
| 图形语法 / marks | Observable Plot、Vega-Lite、AntV G2 | Plot.plot({marks:[Plot.barY(data,{x,y,fill})]}) | mark 是函数/对象,plot 拥有 scale,每个视觉属性都是 channel,正确性与组合性最佳,概念数量略多 |
| 序列数组配置 | MUI X Charts、Plotly、Highcharts、ECharts | series={[{type:'bar',...}]},轴单独配置 | 务实,仪表盘场景极常见 |
ChartV2 的定位:Observable Plot 风格的 marks(bar(...)、line(...)返回配置)+ MUI 风格的series={[...]}数组 + 单一共享 scale。这是一个"强大且站得住脚"的基础,所以设计结论是保留架构,修复 API 表面,补齐缺失的 scale 能力。
2.1 顶级库做到而 ChartV2 尚未做到的六件事
- 统一 channel:Plot/Vega-Lite 中 color/size/opacity 处处接受
常量 | 字段 | accessor;ChartV2 当时只在bar/dot的 color 上做到。 - 一等公民、可配置的 scale:x/y(以及 color/size)的 scale 类型和选项应显式可覆盖;ChartV2 当时只从数据推断 linear-or-band,且不暴露任何配置。
- 时间 scale:所有人都有的能力,ChartV2 当时没有——金融/流式场景只能拿 band 字符串或裸数字凑合。文档特别指出
ScaleTime已导入轴类型,但根节点从未真正构建过它。 - 多轴 / 次级轴:为价格 vs 成交量这类双单位图提供独立轴域;单一共享 y 是金融组合图看起来"坏掉"的根源。
- CVD(色盲)安全的默认分类色板:Vega/Plot 内置 tableau10 风格色板,ChartV2 需要验证。
- 分面 / small multiples(明确标注为超出范围)。
第 3 点在当前源码中仍能从类型层面看到痕迹:ChartAxis.tsx与ChartGrid.tsx的 scale 联合类型中保留了ScaleTime<number, number>(见 ChartAxis.tsx),印证了文档"时间 scale 能力已预留但未接通"的判断。
3. 编码 / channel 模型:最大杠杆的 API 决策
3.1 问题具象化:一个视觉属性有五种拼法
文档的 API 审计列举了同一概念被"拼写"成多种形态的实例:
- color:
ColorAccessor(bar/dot)|string(line/area/band/errorBar/referenceLine)| 必填string(dotGL/dotGLInteractive/streamGL)|string[]色带(heatmapGL)|upColor/downColor对(candlestick)——五种形态。 - 点的"大小":
radius(dot,半径)vssize(dotGL,直径)。 - opacity:
opacity(大多数)vsbandOpacity(referenceLine)vs 没有(line/candlestick)。
这些不一致在今天源码中的ColorAccessor类型里依然可见其雏形——types.ts 中:
export type ColorAccessor = string | ((datum: Record<string, unknown>, index: number) => string);即"常量字符串"或"逐数据点 accessor"两种形态,而文档提议的{field: string}第三种形态(字段映射)尚未进入类型定义——这正是 Stage 1 要做的扩展。
3.2 提议:一个统一的Channel<T>概念
借用 Observable Plot 的 channel 思想:一个视觉属性接受三种东西之一。
type Channel<T> = | T // 常量:color: '#0171E3' 或一个 token | {field: string} // 读取数据列(经 scale 映射) | ((datum: Row, index: number) => T); // accessor统一作用于随数据点/随序列变化的属性:color(fill/stroke)、size/r、opacity。效果:
bar('rev', {color: '#...'})依旧可用(常量)。bar('rev', {color: d => d.up ? green : red})可用(accessor),并且能进入图例。dot('y', {color: {field: 'category'}})把一列数据经过 color scale(分类)映射——即按类别着色的散点,这是当时"不可能做到"的常见需求。
文档强调该概念是增量式的:今天的常量用法全部继续工作。同时明确范围:不需要完整的 Vega-Lite 类型系统(quantitative/ordinal/temporal),"channels + 一小撮显式 scale" 对这个库来说是恰到好处的力量。
这个单一概念同时消解了审计中的 C3/C8/C9/C14 四项不一致,并解锁 color-by-field 能力。
4. 颜色系统深潜
4.1 已有的好底子:token 集与 v1 调色板 API
Token 定义在 packages/core/src/theme/domainTokens/dataTokens.ts,结构与文档描述完全一致:
- 10 个分类色:blue、orange、purple、green、pink、cyan、red、teal、brown、indigo,tableau10 风格,token 形如
--color-data-categorical-blue。 - 9 个顺序色带 × 5 档(5=最深 → 1=最浅):blue、shamrock、orange、pink、purple、red、teal、yellow、gray,用于有序/定量(热力图、分级统计图),token 形如
--color-data-blue-5。 - 1 个中性色
--color-data-neutral:标签、参考线、空状态。
值得注意的实现细节:所有 data token 都以light-dark(#X, #X)形式定义,且分类色与顺序色在明暗两套值上相同——这正是文档第 4.4 节讨论的"刻意为之的设计点"。
调色板 API 目前在 getChartColors.ts 中落地为纯函数(另有 useChartColors.ts React hook 包装),完整暴露文档所述能力:
export interface ChartColorsAPI { categorical(n: number): string[]; sequential: Record<SequentialHue, (n: number) => string[]>; diverging: { positiveNegative(n: number): string[]; coldHot(n: number): string[]; custom(neg, pos, n, midpoint?): string[]; }; semantic: {positive: string; negative: string; warning: string; neutral: string}; structural: {axis: string; grid: string; tick: string; label: string}; alpha(hex: string, opacity: number): string; }SequentialHue联合类型包含全部 9 个色相名,与 token 文件一一对应。文档的结论是"这是一个真正好的 API——整体采纳进 charts 包",源码证实这一建议已被执行。
4.2 需要修复的六个问题
失效的默认值:mark 默认引用
--color-chart-1/--color-positive/--color-negative,这些 token 在 core 中并不存在 → 渲染为黑色/不可见。修复方案:由图表根节点按序列索引自动分配分类色板(mark 默认 color = "auto",根节点用categorical(n)填充未设色的序列)。当前源码中 types.ts 的SeriesDef已出现_resolvedColor?: string("由图表根节点为未提供静态颜色的序列分配的调色板颜色"),legend.ts 中的legendColor()也以series.color ?? series._resolvedColor ?? DEFAULT_SERIES_COLOR的优先级解析代表色——正是该修复方案的落地证据。GL mark 无法使用 token:文档描述
hexToGL用parseInt(hex,16)解析,CSS 变量或rgb()会变成NaN静默错渲染。当前 webgl.ts 的hexToGL已改为经parseHex解析(接受#rgb/#rgba/#rrggbb/#rrggbbaa,非 hex 输入返回中性回退值而非 NaN,注释明确说明"GPU 永远不会收到坏的 uniform")——这是比文档描述时更进一步的实现,但"GL 与 SVG 接受同一套颜色、可自动分配色板"的目标仍需在 Stage 1 中完成验证。accessor 颜色从图例消失:
deriveLegendItems当时会丢弃任何 color 不是静态字符串的序列。修复方向:序列拥有一个resolved代表色(分配的色板槽位、accessor 在稳定点采样的结果、或 color-by-field 的小 scale)。当前legendColor()的三级回退链(静态色 → 根节点分配色 → 兜底var(--color-accent))已经让 accessor 着色的序列能进入图例。暗色模式:分类 token 明暗相同(刻意设计的中间调);真正的风险在顺序色带的深档(如
--color-data-blue-5: #02165E)在暗色背景上失去对比度,使用低档位(深色档)的热力图/面积图需要专项检查。CVD 安全:10 个分类色是 tableau10 风格但未对 deuteranopia/protanopia/tritanopia 验证。需要新增验证任务,并文档化推荐的最大可区分序列数(约 8 个)。
序列过多:超过 10 个分类色后的行为——文档推荐"循环复用 + dev 警告"(Plot 是警告,ECharts 是循环复用)。
4.3 新增的小型 color scale
为支持color: {field}用法,在根节点引入显式 color scale:
- 分类字段 → 按不同值
categorical(n)(序数色标)。 - 定量字段 → 顺序色带(选色相)。
- 发散 → 围绕中点
diverging.*。
这是 color-by-field 一致且可入图例的前提。
5. Scale:正确性的地基
5.1 现状(设计文档记录的问题)
computeLayout当时对 x 轴要么构建scaleLinear(数值 x)要么scaleBand(其他情况),y 轴恒为scaleLinear且 domain 为[min,max].nice()(仅当存在 bar/area 时才包含零)。后果:端点贴边、单调曲线溢出到绘图区外(无裁剪)、空数据落入 band 分支产生 NaN、无时间 scale、无对数 scale、无按轴控制。
这些问题的详细证据记录在 CHARTV2_PHASE1_PLAN.md 的 Confirmed findings 中(含具体行号:layout.ts:128-136无 headroom、Chart.tsx:295-346无 clipPath、streamGL.tsx:146-152空数据导致 blank)。
5.2 提议的修复
domain/baseline 对齐 v1:
yBaseline: 'auto'|'zero'|'data'、yDomain、xDomain,显式给出时权威优先(不做.nice());给连续图加clipPath和小幅 headroom。退化 domain 处理:单点 / 零跨度 / 全等值 → 合成合理跨度(如
v±1或[0, v*2]);空数据 + 显式 domain → 尊重 domain(修复流式渲染)。显式 scale 配置(新增、可选):
xScale={{type: 'time'}} // 或 'linear' | 'band' | 'log' yScale={{type: 'log', domain: [1, 1e6], nice: true}}推断仍为默认,这是 MUI/ECharts/Plot 的通用做法,消除一类"猜错了"的 bug。
时间 scale:为 Date 型 x 值接入
scaleTime,配合移植的shortDate/monthYear格式化。次级 y 轴:允许 mark 指向独立的 y2 scale(如
bar('volume', {axis: 'y2'})),类似 MUI 的yAxisId/ Plotly 的y2。设计张力需要显式说明:单一共享 scale 是核心价值主张(mark 之间不可能不一致),次级轴是"被认可的例外",必须 opt-in 且显式,绝不自动。
5.3 源码中的落地现状
layout.ts 已经实现了提议中的大部分内容:
LayoutInput接受yBaseline?: YBaseline、yDomain?: [number, number]、xDomain?: [number, number],注释明确"显式 domain 权威优先——不做 baseline/headroom/nice()",且"xDomain 在data为空时依然生效(稳定的流式窗口)"。finiteExtent()防御Math.min/max的两个失败模式(NaN/Infinity 污染、超大数组展开导致栈溢出),空/退化范围自动扩展([0,1]或v±0.5|v|)。CONTINUOUS_HEADROOM = 0.08:连续图(无 bar/area 零基线)在数据范围外补 8% 的头部空间,正是文档"headroom"建议的具体数值。_uid = \${seriesIndex}:${s.key}`` 按数组位置分配免碰撞标识,解决重复 dataKey 冲突(Phase 1 的 W1 工作项)。- 分类 y band scale(
yBandScale,由layout.yBandKey触发)让热力图的 y 轴也共享图表 scale——对应 Phase 1 的 W6。
types.ts 中YBaseline类型定义了'auto' | 'zero' | 'data'三种模式及语义注释,与文档提议逐字对应。
6. 一切可能出错的地方:失败模式矩阵
这是文档中信息密度最高、对 Stage 2 测试驱动价值最大的部分。每一行都遵循"今天发生什么 → 应该发生什么"的格式。整理如下:
数据病理
| 情形 | 今天的行为 | 应该的行为 |
|---|---|---|
data=[]空数据 | band scale、NaN、空白(破坏流式) | 尊重显式 domain;否则渲染空状态(坐标轴 + "no data") |
| 单条数据 | 零宽度/近退化 domain | 合成跨度,居中绘制点 |
| 全等值 / 零跨度 | 平 domain,nice()可能折叠刻度 | 给 domain 加 padding |
| 全零 / 全负 | baseline 处理(bar 可能反向生长) | 用新边界 case stories 验证;yBaseline正确性 |
| 一个大离群值 | 其他数据被压扁 | 文档化 log scale 方案;story 展示 log 补救 |
| 数值列中的 NaN/null/undefined/非数值 | 静默强转为 0(误导) | line/area 断开路径(gap)、dot 跳过该点;决策并文档化。倾向 gap(line/area)、skip(dot) |
| 拼错的 dataKey | 到处静默 0/undefined | dev 警告 |
| band 的重复 x 值 / line 的未排序 x | band 静默去重;line 画成锯齿 | line 未排序 x 发 dev 警告 |
| 超大 N(1 万+ SVG 节点) | 卡顿 | GL mark 已存在;文档化阈值;(m4 降采样为跟进项) |
Scale / 渲染
| 情形 | 今天的行为 | 应该的行为 |
|---|---|---|
| 曲线溢出绘图区(monotone/natural) | 逃逸到 margin | clipPath |
| 轴标签溢出/重叠(多 band 类别、长标签、大数字) | 今日蜡烛图糊成一片 | 宽度感知的自动跳过 + 旋转/截断选项 |
| 视口边缘的 tooltip | 可能被裁剪 | 翻转/钳制(部分已处理,需验证) |
| 图例溢出(多序列) | legend="end"裁掉绘图区 | 换行/滚动 |
| 参考线标签出画布 | 溢出 | 将 badge 钳制在绘图区内 |
运行时 / 平台
| 情形 | 应该的行为 |
|---|---|
| SSR / 无 DOM(Next.js,docsite 正是 Next.js) | 守卫 ResizeObserver/canvas/window访问;挂载前渲染空或占位符 |
| WebGL 不可用 / context 丢失 | 回退消息或 SVG 路径;处理webglcontextlost |
| canvas / 监听器清理(流式 rAF、环形缓冲、ResizeObserver) | 审计 teardown,防泄漏 |
| 高 DPI | 模糊 → webgl utils 已有 DPR 处理,需跨 mark 验证 |
| 运行时切换主题 | 颜色必须重新解析(尤其缓存 hex 的 GL)→ 主题变化时重新上传 |
| 减少动态效果 | 轴有animated,需遵循prefers-reduced-motion |
无障碍
- 屏幕阅读器:SVG 目前仅根节点有 title/desc,序列/数据点无描述 → 至少加 role/aria-label,考虑>npm install @astryxdesign/charts@canary @astryxdesign/core@canary
Canary 构建跟踪
main的最新提交(0.x.y-canary.<sha>),任意两个版本之间可能破坏兼容,需要稳定性请锁定精确版本。包内
package.json的"private": true加"astryx": {"canaryOnly": true}标记是 npm 层面"绝不发稳定版"的硬保证。对于想深入理解这套设计决策的读者,packages/charts/docs/ 目录下四份记录(plan / design / readiness / verification checklist)构成了完整的演进档案:Stage 1 设计研究回答"什么是对的",Phase 1 计划给出"按什么顺序做",readiness 与 checklist 负责"做到什么程度才算能发"。附录:文档中的源码引用与当前仓库位置的对照
设计文档成文时引用的部分路径指向
packages/lab/src/Chart/,图表功能现已提取到packages/charts/,对应关系如下:设计文档引用 当前仓库位置 packages/core/src/theme/domainTokens/dataTokens.tsdataTokens.ts(未变) Chart/getChartColors.ts、useChartColors.tspackages/charts/src/getChartColors.ts、useChartColors.ts Chart/webgl.tshexToGLpackages/charts/src/webgl.ts Chart/utils.tsxPixelpackages/charts/src/utils.ts Chart/Chart.tsx(domain/baseline 参照)packages/charts/src/Chart.tsx 完整 API 审计 镜像于 CHARTV2_PHASE1_PLAN.md 的 findings 对照之后可以清楚看到:这份设计文档不是"纸上谈兵"——它提出的 channel 模型、domain/baseline parity、退化处理、
_uid身份方案、调色板 API 复用与图例修复,绝大多数已在当前packages/charts源码中落地,本文各节引用的行号与类型定义即为佐证。【免费下载链接】astryxAn open source design system that's fully customizable and agent ready
项目地址: https://gitcode.com/GitHub_Trending/as/astryx
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考