benday_core/spec.rs
1//! The chart specification: a strict subset of Vega-Lite's JSON grammar.
2//!
3//! Unknown fields are rejected at parse time (`deny_unknown_fields`) so that
4//! callers emitting full Vega-Lite get a correctable error instead of a
5//! silently wrong chart.
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Deserialize)]
10#[serde(deny_unknown_fields)]
11pub struct Spec {
12 /// Optional: rows may instead arrive on stdin. `ingest::resolve` enforces
13 /// that data is present in exactly one place.
14 #[serde(default)]
15 pub data: Option<Data>,
16 pub mark: Mark,
17 pub encoding: Encoding,
18 #[serde(default)]
19 pub title: Option<String>,
20 /// Plot area width in terminal cells (not total output width).
21 #[serde(default)]
22 pub width: Option<usize>,
23 /// Plot area height in terminal cells.
24 #[serde(default)]
25 pub height: Option<usize>,
26}
27
28/// Inline data: tidy row objects, OR columnar `columns` + `rows`. Exactly one
29/// form — `ingest::resolve` enforces it (serde can't express either/or here
30/// without wrecking error paths).
31#[derive(Debug, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct Data {
34 /// Inline tidy data: one JSON object per row.
35 #[serde(default)]
36 pub values: Option<Vec<serde_json::Map<String, serde_json::Value>>>,
37 #[serde(default)]
38 pub columns: Option<Vec<Column>>,
39 #[serde(default)]
40 pub rows: Option<Vec<Vec<serde_json::Value>>>,
41}
42
43/// Strict twin of `ingest::EnvColumn`: the spec is agent-authored, so unknown
44/// keys are rejected here.
45#[derive(Debug, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct Column {
48 pub name: String,
49 #[serde(default, rename = "type")]
50 pub ty: Option<String>,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
54#[serde(rename_all = "lowercase")]
55pub enum Mark {
56 Bar,
57 Line,
58 Point,
59 Area,
60}
61
62#[derive(Debug, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct Encoding {
65 pub x: Channel,
66 pub y: Channel,
67 #[serde(default)]
68 pub color: Option<Channel>,
69 /// Accepted by the grammar only so validation can reject it with a helpful
70 /// redirect (grouping is expressed with `color`). Typed as a raw `Value`,
71 /// not a `Channel`: Vega-Lite emits several xOffset shapes (`{"field": …}`,
72 /// `{"value": …}`, band configs) and a strict `Channel` would bounce them
73 /// into serde's generic unknown-field error before validation could help.
74 #[serde(default, rename = "xOffset")]
75 pub x_offset: Option<serde_json::Value>,
76}
77
78#[derive(Debug, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct Channel {
81 pub field: String,
82 /// Inferred from the data when omitted.
83 #[serde(default, rename = "type")]
84 pub ty: Option<FieldType>,
85 #[serde(default)]
86 pub aggregate: Option<Aggregate>,
87 /// Calendar-truncation bucketing for a temporal x on a `bar` mark: each
88 /// value is floored to the unit's boundary, keeping the calendar prefix
89 /// (`"month"` maps `2026-06-14` to `2026-06`, NOT Vega-Lite's cyclic "all
90 /// Junes"). Bar-only, x-only this cycle — every other placement is a
91 /// teaching error (see `compile`). Inferred nothing when omitted.
92 #[serde(default, rename = "timeUnit")]
93 pub time_unit: Option<TimeUnit>,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
97#[serde(rename_all = "lowercase")]
98pub enum FieldType {
99 Quantitative,
100 Nominal,
101 Ordinal,
102 /// Continuous time: positioned at true instants on a calendar scale (see
103 /// docs/plans/2026-07-05-temporal-family-design.md). An explicit
104 /// `"ordinal"` restores evenly-spaced categorical behavior.
105 Temporal,
106}
107
108/// A calendar-truncation bucket unit for `timeUnit` (temporal bars). Ordered
109/// coarse-relevant but semantics are per-variant: truncate a timestamp to this
110/// unit's boundary, KEEPING the year (design §spec semantics). Week anchors to
111/// Monday.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
113#[serde(rename_all = "lowercase")]
114pub enum TimeUnit {
115 Year,
116 Quarter,
117 Month,
118 Week,
119 Day,
120 Hour,
121 Minute,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
125#[serde(rename_all = "lowercase")]
126pub enum Aggregate {
127 Sum,
128 Mean,
129 Median,
130 Min,
131 Max,
132 Count,
133}