Skip to content

Latest commit

 

History

History
207 lines (161 loc) · 13.5 KB

File metadata and controls

207 lines (161 loc) · 13.5 KB

TerseSpec 设计草案(v0)

状态:设计评审已完成(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。

1. 设计目标与非目标

目标

  1. 一个懂数据的人在文档陪伴下 5 分钟内能手写出第一张图;
  2. 全字段可省略:最小合法 spec 是 {}(得到空图表)或 { x: "Region", y: "sum(Sales)" }(得到一张柱状图);
  3. 渐进式:TerseSpec 与 canonical 不是两门语言,而是同一 schema 的省略程度谱系——任何 canonical 片段都可以混写在 terse spec 里做精细控制,normalize 都接得住;
  4. 自包含:被引用的 computed 字段定义内联在 spec 内,离开原 workspace 依然有效;
  5. LLM 友好:字段按名字引用、结构浅、与 Vega-Lite 的心智模型对齐,便于模型生成。

非目标

  • 不替代 canonical IChart(持久化、undo/redo、store 内部一律不变);
  • 不描述 dashboard/多图布局(单图表 spec);
  • 不引入新的计算能力(表达式仍是 IExpression 的能力边界);
  • v0 不支持 paint(画笔字段无法合理手写,投影时遇到 paint 字段报 warning 并跳过)。

2. Schema 草案

/** 字段引用:名字或 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 } }
}

3. 字段引用解析(name → fid)

解析在 normalize() 内进行,依赖传入的 meta: IMutField[],规则按序:

  1. fid: 前缀直引:'fid:sales' 绕过名字解析直接引用 fid。用于重名兜底与程序化生成;
  2. 精确名字匹配:与 meta[].name 精确相等(区分大小写);
  3. 内联 computed 匹配:与 computed[].name 精确相等;
  4. fid 直接匹配:与 meta[].fid 精确相等(容忍用户把 fid 当名字写);
  5. 大小写不敏感兜底:唯一命中时接受并产生 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)>

4. 聚合 shorthand 文法

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,按名字解析,不会静默变成聚合。

5. Normalize 展开规则(terse → canonical)

按序执行,每步都是纯函数:

  1. 解析引用:全部 TerseFieldRef / TerseFilter.field / TerseComputedField 内的字段引用按 §3 解析成 fid;内联 computed 字段生成确定性 fid(gw_t_ + name 的短哈希,同名同 fid,保证幂等);
  2. 构建池子:dimensions/measures 池 = meta 全体字段(newChart 语义,含 count/mea_key/mea_val 合成字段) + 内联 computed 字段。这正是 dsl-design-review §3.3 的重建规则——terse 不含池子,池子是推导物;
  3. 通道映射:x → columnsy → rows,其余通道同名映射;TerseFieldRef 展开为完整 IViewField(从池子取字段对象,叠加 aggregate/sort 覆盖);timeUnit 展开为真实的 dateTimeDrill 计算字段——镜像 UI 的 createDateDrillField 语义,drill 字段同时进入 dimensions/measures 池(供 toWorkflow 收集 transform)与目标通道,影响查询 group-by 而非仅显示;
  4. 旋钮映射:mark → config.geoms[0]aggregate → config.defaultAggregatedstack → layout.stacklimit → config.limitsort → 最后一个 y 度量的 sort;
  5. canonical 片段合并:config/layout 字段浅合并,优先级最高(显式的 canonical 写法压过扁平旋钮;两处都写时产生 warning);
  6. 进入既有管线:结果作为 PartialChartfillChartalgebraLint + lintExtraFields$schema 戳——与 normalize 现有出口完全一致。

6. 与 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/dateTimeFeature computed 字段与 regexp 过滤器无法表达,跳过并 warning;字段名与 shorthand 文法冲突或重名时回退 fid: 前缀形式;非默认的 config/layout 残差经逃生舱携带以满足往返标准;
  • gen-schema 管线新增 tersespec_v1.json 产物,$schema URL 指向它;
  • 废弃 Specification 接口与 renderSpec()(标 @deprecated,下个 major 删除)。

7. 开放问题决议(2026-07-07 评审收敛)

决议原则(压倒一切):本迭代不改变任何既有运行时行为;terse 的新语法只允许映射到既有语义,凡需要新语义的特性一律砍出 v0。理由:本次迭代体量已经很大;且外部系统(如 gw-dsl-parser 及其下游)依赖当前行为,尤其时区语义历史上有过坑,任何"顺手改进"都可能破坏跨层兼容。

  1. sort 的作用目标 → 维持 v0 定义,不扩展。扁平旋钮 sort 是"最后一个 y 度量"的纯语法糖,对象形式的 per-field sort 直接映射到既有 IViewField.sort——两者都不引入新语义。多度量 + facet 的 per-field 扁平语法不做。
  2. timeRange 字符串日期 → 砍出 v0,只接受毫秒时间戳。计算层规范与 conformance 套件规定 temporal range 只收毫秒;ISO 字符串解析必须选定新的时区语义,属于新行为,且直接踩在 gw-dsl-parser 历史坑位上。v0 与 computation.md §3 逐字对齐;字符串日期支持若未来要做,须与 parser 层时区语义一起评审(横跨两层 DSL 的联合决策),并先在 conformance 套件加用例。
  3. 内联 computed 的 fid 生成 → name 短哈希(gw_t_ 前缀)+ 单图冲突检测。此机制只存在于 terse 展开这一新领地,不触碰既有行为(canonical 图表的 fid 永远显式携带,UI 内建字段仍走 gw_ + nanoid)。同名同 fid 保证幂等;单图内同名不同表达式报错;跨图冲突理论存在但与现状(nanoid 随机)相比不劣化。表达式哈希方案(fid 随表达式变化)因破坏重命名稳定性被否。
  4. 投影往返标准 → 采纳 canonical 层等价:normalize(project(normalize(t))) ≡ normalize(t)。这是测试标准而非运行时行为,不涉及兼容风险;shorthand 的多种等价写法不可能也不需要逐字往返。
  5. 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 套件加用例。

8. 与现有机制的关系速查

现有物 TerseSpec 落地后
Specification + renderSpec() @deprecated,功能被 TerseSpec 全量覆盖
spec prop(root.tsx) 下个 major 换为接受 TerseSpec
vlSpec prop 保留(vega-lite 路径已在 normalize 内)
chart prop / importCode 完全不变(canonical 层)
exportCode() 完全不变;新增可选的 exportTerse() 投影