状态:设计评审已完成(2026-07-07),§7 开放问题已全部按"不改变既有行为"原则收敛,可以开始第三阶段实现。接入方式见 §6。 前置阅读:
docs/dsl-design-review.md(§1.4、§3.3 修订版)。 核心原则:TerseSpec 是 authoring format(为人写),canonical IChart 是持久化与机器格式。TerseSpec →normalize()→ canonical 单向转换;canonical → TerseSpec 是可选的有损投影(丢弃未被图表引用的字段),持久化永远走 canonical。
目标
- 一个懂数据的人在文档陪伴下 5 分钟内能手写出第一张图;
- 全字段可省略:最小合法 spec 是
{}(得到空图表)或{ x: "Region", y: "sum(Sales)" }(得到一张柱状图); - 渐进式:TerseSpec 与 canonical 不是两门语言,而是同一 schema 的省略程度谱系——任何 canonical 片段都可以混写在 terse spec 里做精细控制,normalize 都接得住;
- 自包含:被引用的 computed 字段定义内联在 spec 内,离开原 workspace 依然有效;
- LLM 友好:字段按名字引用、结构浅、与 Vega-Lite 的心智模型对齐,便于模型生成。
非目标
- 不替代 canonical IChart(持久化、undo/redo、store 内部一律不变);
- 不描述 dashboard/多图布局(单图表 spec);
- 不引入新的计算能力(表达式仍是
IExpression的能力边界); - v0 不支持 paint(画笔字段无法合理手写,投影时遇到 paint 字段报 warning 并跳过)。
/** 字段引用:名字或 fid;或带 per-channel 覆盖的对象形式 */
type TerseFieldRef =
| string // 'Sales' | 'sum(Sales)' | 'fid:sales'(见 §3、§4)
| {
field: string; // 名字、shorthand 或 'fid:' 前缀引用
aggregate?: Exclude<IAggregator, 'expr'>; // 与 shorthand 二选一,重复时报错;'expr' 不允许(走内联 computed)
sort?: 'ascending' | 'descending';
/** 展开为真实的 dateTimeDrill 计算字段(query 级钻取,与 UI 钻取语义一致),而非仅轴显示格式 */
timeUnit?: (typeof DATE_TIME_DRILL_LEVELS)[number];
};
/** 过滤器:四种规则的字面语法 */
type TerseFilter =
| { field: string; oneOf: any[] }
| { field: string; notIn: any[] }
| { field: string; range: [number | null, number | null] }
/** 毫秒时间戳,与 computation.md §3 temporal range 逐字对齐;v0 不做字符串日期解析(见 §7 决议 2) */
| { field: string; timeRange: [number | null, number | null] };
/** 内联 computed 字段定义(自包含的关键) */
interface TerseComputedField {
name: string; // terse 内的引用名,亦作展示名
/** 三选一的定义方式 */
expr?: string; // SQL-like 表达式,如 'sales * 0.85'
bin?: { field: string; count?: number }; // 等宽分箱
log?: { field: string; base?: number }; // 对数变换
analyticType?: 'dimension' | 'measure'; // 缺省按定义方式推断
}
interface TerseSpec {
/** 版本戳,可选;缺省视为最新 terse 版本 */
$schema?: string; // 'https://graphic-walker.kanaries.net/tersespec_v1.json'
/** 图表类型,对应 config.geoms[0];缺省 'auto' */
mark?: string;
name?: string;
/** 主轴:x → columns,y → rows;数组表示多字段(facet/多度量) */
x?: TerseFieldRef | TerseFieldRef[];
y?: TerseFieldRef | TerseFieldRef[];
/** 视觉通道,单字段 */
color?: TerseFieldRef;
opacity?: TerseFieldRef;
size?: TerseFieldRef;
shape?: TerseFieldRef;
text?: TerseFieldRef;
/** 多字段通道 */
details?: TerseFieldRef | TerseFieldRef[];
/** 极坐标 / 地理通道 */
theta?: TerseFieldRef;
radius?: TerseFieldRef;
longitude?: TerseFieldRef;
latitude?: TerseFieldRef;
geoId?: TerseFieldRef;
filters?: TerseFilter[];
/** 内联 computed 字段;只需列出本图用到的 */
computed?: TerseComputedField[];
/** 常用旋钮的扁平化提升(高频配置不该藏在两层深处) */
aggregate?: boolean; // → config.defaultAggregated,缺省 true
stack?: 'none' | 'stack' | 'normalize' | 'center'; // → layout.stack
limit?: number; // → config.limit
sort?: 'ascending' | 'descending'; // 应用于最后一个度量,与 Tableau 心智一致
/** 渐进式逃生舱:任何 canonical 片段,最后浅合并(见 §5 优先级) */
config?: Partial<IVisualConfigNew>;
layout?: Partial<IVisualLayout>;
}JSON 示例
最小:
{ "x": "Region", "y": "sum(Sales)" }典型:
{
"mark": "bar",
"x": "Region",
"y": ["sum(Sales)", "mean(Profit)"],
"color": "Segment",
"filters": [{ "field": "Year", "oneOf": [2024, 2025] }],
"stack": "normalize",
"limit": 20
}含内联 computed 字段与精细控制:
{
"mark": "point",
"computed": [
{ "name": "Net Sales", "expr": "sales * 0.85", "analyticType": "measure" },
{ "name": "Sales Bucket", "bin": { "field": "Sales", "count": 10 } }
],
"x": "Sales Bucket",
"y": "sum(Net Sales)",
"size": { "field": "Profit", "aggregate": "mean" },
"layout": { "size": { "mode": "fixed", "width": 800, "height": 600 } }
}解析在 normalize() 内进行,依赖传入的 meta: IMutField[],规则按序:
fid:前缀直引:'fid:sales'绕过名字解析直接引用 fid。用于重名兜底与程序化生成;- 精确名字匹配:与
meta[].name精确相等(区分大小写); - 内联 computed 匹配:与
computed[].name精确相等; - fid 直接匹配:与
meta[].fid精确相等(容忍用户把 fid 当名字写); - 大小写不敏感兜底:唯一命中时接受并产生 warning;多命中报错。
错误报告格式(实现约束,不是建议):找不到字段时,错误信息必须包含:输入的引用串、最相近的 3 个候选名(编辑距离)、可用字段总数。重名(两个字段同名)时:报错并要求 fid: 前缀,错误信息列出冲突各方的 fid。
为什么不做模糊匹配:LLM 与人都会写错字段名,但静默容错会把错误图表当正确结果交付;显式报错 + 候选提示的纠错回路更短。
已知边界(实现定稿):字面名恰为整个引用串的字段优先于 shorthand 解读(exact 匹配限定,大小写不敏感回退不参与此规则),count() 同样适用;数据列名字面为 fid:xxx 形式时无法按名引用,须用其自身 fid 引用(文档明示的限制);内联 computed 的 fid 哈希与既有 fid 冲突时报错(单图冲突检测,同样覆盖合成 drill 字段);残留边界(LOW,终轮复核归档):投影一个含 UI 创建的 computed 字段(nanoid fid)且其显示名恰与 shorthand 文法冲突(如字面名为 sum(Sales))的 canonical 图表时,refName 回退发射原始 fid,而内联定义在重新展开时以 gw_t_ 哈希 fid 重建,导致该投影不可重新展开——仅影响对抗性命名的 UI computed 字段,terse 出身的图表免疫(不在往返标准量化范围内);若要闭合,对 computed 字段的 fid 回退改为发射 fid:gw_t_<hash(name)>。
shorthand := aggName '(' fieldRef ')' | fieldRef
aggName := 'sum' | 'count' | 'max' | 'min' | 'mean' | 'median'
| 'variance' | 'stdev' | 'distinctCount'
'sum(Sales)'≡{ field: 'Sales', aggregate: 'sum' };aggName全集与IAggregator对齐,唯expr除外(聚合表达式必须走对象形式 + 内联 computed,shorthand 里没有合理的语法位置);'count()'无参形式映射到内置 count 字段(gw_count_fid);- 字段名本身含
(的用fid:前缀或对象形式绕开;shorthand 解析只在最外层一对括号且前缀恰为合法 aggName 时触发,否则整串按字段名处理——'log(x)'不是合法 aggName,按名字解析,不会静默变成聚合。
按序执行,每步都是纯函数:
- 解析引用:全部
TerseFieldRef/TerseFilter.field/TerseComputedField内的字段引用按 §3 解析成 fid;内联 computed 字段生成确定性 fid(gw_t_+ name 的短哈希,同名同 fid,保证幂等); - 构建池子:
dimensions/measures 池 = meta 全体字段(newChart 语义,含 count/mea_key/mea_val 合成字段) + 内联 computed 字段。这正是 dsl-design-review §3.3 的重建规则——terse 不含池子,池子是推导物; - 通道映射:
x → columns、y → rows,其余通道同名映射;TerseFieldRef展开为完整IViewField(从池子取字段对象,叠加 aggregate/sort 覆盖);timeUnit展开为真实的 dateTimeDrill 计算字段——镜像 UI 的createDateDrillField语义,drill 字段同时进入 dimensions/measures 池(供toWorkflow收集 transform)与目标通道,影响查询 group-by 而非仅显示; - 旋钮映射:
mark → config.geoms[0]、aggregate → config.defaultAggregated、stack → layout.stack、limit → config.limit、sort → 最后一个 y 度量的 sort; - canonical 片段合并:
config/layout字段浅合并,优先级最高(显式的 canonical 写法压过扁平旋钮;两处都写时产生 warning); - 进入既有管线:结果作为
PartialChart走fillChart→algebraLint+lintExtraFields→$schema戳——与 normalize 现有出口完全一致。
detectSpecKind探测顺序(实现定稿,2026-07-07):① vega$schema→ vega-lite;② tersespec$schema→ terse;③ VL 独占键(encoding/spec/layer/concat系)→ vega-lite;④ terse 特征键(x/y/computed/filters)存在且无encodings→ terse(排在 layout 规则之前,使 terse 的layout逃生舱不会把 spec 误路由到 chart);⑤ 裸mark(无 terse 通道)→ vega-lite(维持现状);⑥layout→ chart;⑦ legacy config 键 → vis-spec;⑧ 其余 → partial-chart;normalize()switch 增加case 'terse': chart = expandTerse(input, meta),之后共用现有出口管线;- 新增
projectTerse(chart: IChart): TerseSpec反向投影(实现名,原设计名project因过于泛化改名):遍历 channels/filters 收集实际引用的字段(folds 经 config 逃生舱携带),computed 字段内联为TerseComputedField,未引用字段全部丢弃(有损,文档明示);dateTimeDrill投影为对象形式的timeUnit;paint/binCount/dateTimeFeaturecomputed 字段与regexp过滤器无法表达,跳过并 warning;字段名与 shorthand 文法冲突或重名时回退fid:前缀形式;非默认的 config/layout 残差经逃生舱携带以满足往返标准; gen-schema管线新增tersespec_v1.json产物,$schemaURL 指向它;- 废弃
Specification接口与renderSpec()(标@deprecated,下个 major 删除)。
决议原则(压倒一切):本迭代不改变任何既有运行时行为;terse 的新语法只允许映射到既有语义,凡需要新语义的特性一律砍出 v0。理由:本次迭代体量已经很大;且外部系统(如 gw-dsl-parser 及其下游)依赖当前行为,尤其时区语义历史上有过坑,任何"顺手改进"都可能破坏跨层兼容。
sort的作用目标 → 维持 v0 定义,不扩展。扁平旋钮sort是"最后一个 y 度量"的纯语法糖,对象形式的 per-fieldsort直接映射到既有IViewField.sort——两者都不引入新语义。多度量 + facet 的 per-field 扁平语法不做。timeRange字符串日期 → 砍出 v0,只接受毫秒时间戳。计算层规范与 conformance 套件规定 temporal range 只收毫秒;ISO 字符串解析必须选定新的时区语义,属于新行为,且直接踩在 gw-dsl-parser 历史坑位上。v0 与 computation.md §3 逐字对齐;字符串日期支持若未来要做,须与 parser 层时区语义一起评审(横跨两层 DSL 的联合决策),并先在 conformance 套件加用例。- 内联 computed 的 fid 生成 → name 短哈希(
gw_t_前缀)+ 单图冲突检测。此机制只存在于 terse 展开这一新领地,不触碰既有行为(canonical 图表的 fid 永远显式携带,UI 内建字段仍走gw_+ nanoid)。同名同 fid 保证幂等;单图内同名不同表达式报错;跨图冲突理论存在但与现状(nanoid 随机)相比不劣化。表达式哈希方案(fid 随表达式变化)因破坏重命名稳定性被否。 - 投影往返标准 → 采纳 canonical 层等价:
normalize(project(normalize(t))) ≡ normalize(t)。这是测试标准而非运行时行为,不涉及兼容风险;shorthand 的多种等价写法不可能也不需要逐字往返。 - mark 数组 / layer → 明确不做。GW 的
geoms是单值语义,layer 是全新的渲染能力而非语法问题,不在 DSL 层解决。terse 的mark保持单值;layered Vega-Lite spec 由detectSpecKind显式路由到 VL 路径并在能力边界处报错(不静默吞掉)。
Conformance 待办(实现阶段发现):log: { base: N } 在 N ≠ 2/10 时映射到 op 'log'(客户端已实现,但 UI 从不生成),SQL 后端与 conformance 套件从未覆盖此 op——上线前应在 conformance 套件加用例。
| 现有物 | TerseSpec 落地后 |
|---|---|
Specification + renderSpec() |
标 @deprecated,功能被 TerseSpec 全量覆盖 |
spec prop(root.tsx) |
下个 major 换为接受 TerseSpec |
vlSpec prop |
保留(vega-lite 路径已在 normalize 内) |
chart prop / importCode |
完全不变(canonical 层) |
exportCode() |
完全不变;新增可选的 exportTerse() 投影 |