benday_core/scene.rs
1//! The Scene: benday's intermediate representation. `compile()` resolves a
2//! spec against its data into a Scene — every data- and layout-dependent
3//! decision made, geometry normalized to the plot rect — and `rasterize()`
4//! turns a Scene into glyphs. The serialized form is the golden-corpus
5//! snapshot and the `--dump-scene` output; it is explicitly unstable.
6
7use serde::Serialize;
8use serde_json::json;
9
10use crate::ingest::DataSource;
11use crate::raster::Rgb;
12use crate::spec::{Aggregate, FieldType, Mark, TimeUnit};
13
14#[derive(Serialize)]
15pub struct Scene {
16 pub size: Size,
17 pub plot: Rect,
18 /// Resolved theme colors for non-mark elements. Colors are compile-time
19 /// facts everywhere — the rasterizer never sees a Theme.
20 pub chrome: Chrome,
21 pub title: Option<Placed>,
22 pub legend: Vec<LegendEntry>,
23 pub y_axis: YAxis,
24 pub x_axis: XAxis,
25 pub marks: Vec<SceneMark>,
26 pub dropped_rows: usize,
27 /// Provenance for --meta output.
28 pub source: Source,
29}
30
31/// Colors for axes/labels (`axis`) and the title (`title`). Legend swatches
32/// carry their own color per entry; legend NAME text uses `axis`.
33#[derive(Serialize)]
34pub struct Chrome {
35 pub axis: Rgb,
36 pub title: Rgb,
37}
38
39#[derive(Serialize)]
40pub struct Size {
41 pub columns: usize,
42 pub rows: usize,
43}
44
45#[derive(Serialize)]
46pub struct Rect {
47 pub x: usize,
48 pub y: usize,
49 pub w: usize,
50 pub h: usize,
51}
52
53/// Text plus its resolved starting column (buffer-absolute) and row.
54#[derive(Serialize)]
55pub struct Placed {
56 pub text: String,
57 pub col: usize,
58 pub row: usize,
59}
60
61#[derive(Serialize)]
62pub struct LegendEntry {
63 pub name: String,
64 pub color: Rgb,
65 pub col: usize,
66 pub row: usize,
67}
68
69#[derive(Serialize)]
70pub struct YAxis {
71 pub domain: [f64; 2],
72 pub step: f64,
73 /// Categorical y (horizontal bars): the RAW, untruncated category names in
74 /// axis order — the machine-readable surface for `--meta`, where the
75 /// truncated tick labels would silently corrupt names a caller matches
76 /// back to its rows. None on every quantitative-y path; skipped when None
77 /// so pre-existing scene snapshots stay byte-identical.
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub categories: Option<Vec<String>>,
80 /// In draw order; rows are distinct by construction. `row` is buffer-absolute.
81 pub ticks: Vec<YTick>,
82}
83
84#[derive(Serialize)]
85pub struct YTick {
86 pub value: f64,
87 pub frac: f64,
88 pub label: String,
89 pub row: usize,
90}
91
92#[derive(Serialize)]
93pub struct XAxis {
94 /// Nominal x: resolved category order. Quantitative: None.
95 pub categories: Option<Vec<String>>,
96 pub domain: Option<[f64; 2]>,
97 /// Columns (plot-relative) that get a '┴' glyph. Empty for bars.
98 pub tick_cols: Vec<usize>,
99 /// Labels that survived greedy placement; `col` is the buffer-absolute
100 /// start column. Dropped labels simply don't appear — visible in diffs.
101 pub labels: Vec<Placed>,
102}
103
104#[derive(Serialize)]
105pub struct SeriesRef {
106 pub name: Option<String>,
107 pub color: Rgb,
108}
109
110#[derive(Serialize)]
111pub enum SceneMark {
112 Bars {
113 /// One entry per category, in category order.
114 bars: Vec<Bar>,
115 /// Bar orientation. Rect anchors can't encode it: a bottom-row
116 /// horizontal bar has `x0 == 0` AND `y0 + h == 1`, exactly a vertical
117 /// bar's signature — so the direction is carried once per mark.
118 direction: BarDirection,
119 },
120 Path {
121 series: SeriesRef,
122 points: Vec<[f64; 2]>,
123 },
124 Points {
125 series: SeriesRef,
126 points: Vec<[f64; 2]>,
127 },
128 /// Area: fill under the path plus the path itself.
129 Fill {
130 series: SeriesRef,
131 points: Vec<[f64; 2]>,
132 },
133}
134
135/// One bar as a normalized rect over the plot area: `x0/w` as fractions of
136/// plot width, `y0/h` as fractions of plot height, y0 = 0 at the TOP (same
137/// orientation as point geometry). Vertical bars: y0 = 1 - h, full h to the
138/// baseline. Horizontal bars: x0 = 0, w = value fraction.
139#[derive(Serialize)]
140pub struct Bar {
141 pub x0: f64,
142 pub y0: f64,
143 pub w: f64,
144 pub h: f64,
145 pub color: Rgb,
146}
147
148#[derive(Serialize, Clone, Copy, PartialEq)]
149#[serde(rename_all = "snake_case")]
150pub enum BarDirection {
151 Vertical,
152 Horizontal,
153}
154
155#[derive(Serialize)]
156pub struct Source {
157 pub mark: crate::spec::Mark,
158 pub x_field: String,
159 pub y_field: String,
160 pub aggregate: Option<Aggregate>,
161 /// The resolved x type, set to `Some(Temporal)` ONLY by the temporal xy
162 /// path and `None` everywhere else — so `meta()` can tell a temporal x
163 /// (ISO domain) from a quantitative one, and every non-temporal snapshot
164 /// stays byte-identical (skipped when None).
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub x_type: Option<FieldType>,
167 /// The `timeUnit` bucketing a temporal bar's x, `Some` ONLY on that path.
168 /// `meta()` reports it in the x block (with the canonical bucket keys as the
169 /// categories); skipped when None so every other snapshot stays identical.
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub time_unit: Option<TimeUnit>,
172 /// Points-per-series counts etc. needed to reproduce --meta exactly.
173 pub series_points: Vec<usize>,
174 /// Data provenance (from `Table::provenance`). Drives the conditional
175 /// `--meta` data block; always serialized (null when absent) so
176 /// `--dump-scene` shows it.
177 pub data_source: DataSource,
178 pub truncated: Option<bool>,
179 pub total_rows: Option<u64>,
180}
181
182impl Scene {
183 pub fn to_json(&self) -> String {
184 serde_json::to_string_pretty(self).expect("scene serialization is infallible")
185 }
186
187 /// The --meta payload. Must reproduce the pre-refactor format exactly.
188 /// Keys serialize alphabetically (serde_json's default map ordering), so
189 /// the order they appear in each `json!` block is irrelevant.
190 pub fn meta(&self) -> serde_json::Value {
191 let size = json!({ "columns": self.size.columns, "rows": self.size.rows });
192 let mut meta = match self.source.mark {
193 Mark::Bar => {
194 // Orientation is append-only and conditional: a VERTICAL bar
195 // reports the pre-existing shape byte-identically (no "direction"
196 // key). A HORIZONTAL bar has no x categories (its x is the
197 // quantitative value axis) — that's the detector — so it reports
198 // x as quantitative-with-domain, y as nominal-with-categories,
199 // plus a "direction" key.
200 let mut base = if self.x_axis.categories.is_none() {
201 // RAW names, not the truncated tick labels: meta is the
202 // machine-readable surface a caller matches back to rows.
203 let cats = self
204 .y_axis
205 .categories
206 .as_ref()
207 .expect("horizontal bar scenes carry raw y categories");
208 json!({
209 "mark": "bar",
210 "direction": "horizontal",
211 "x": {
212 "field": self.source.x_field,
213 "type": "quantitative",
214 "aggregate": self.source.aggregate,
215 "domain": self.x_axis.domain,
216 },
217 "y": {
218 "field": self.source.y_field,
219 "type": "nominal",
220 "categories": cats,
221 },
222 "dropped_rows": self.dropped_rows,
223 "size": size,
224 })
225 } else {
226 // A `timeUnit` bar reports its buckets as the (canonical-key)
227 // categories plus a "timeUnit" tag and a "temporal" type — a
228 // plain bar keeps the byte-identical nominal shape.
229 let mut x = json!({
230 "field": self.source.x_field,
231 "type": "nominal",
232 "categories": self.x_axis.categories,
233 });
234 if let Some(tu) = self.source.time_unit {
235 let x = x.as_object_mut().expect("x meta is an object");
236 x.insert("type".to_string(), json!("temporal"));
237 x.insert("timeUnit".to_string(), json!(tu));
238 }
239 json!({
240 "mark": "bar",
241 "x": x,
242 "y": {
243 "field": self.source.y_field,
244 "aggregate": self.source.aggregate,
245 "domain": self.y_axis.domain,
246 },
247 "dropped_rows": self.dropped_rows,
248 "size": size,
249 })
250 };
251 // Grouped bars carry a legend; append the xy-shaped series array
252 // (name/color/cell-count) from the legend entries zipped with the
253 // per-series counts. Plain and tinted bars have no legend and emit
254 // byte-identical meta to before.
255 if !self.legend.is_empty() {
256 let series: Vec<serde_json::Value> = self
257 .legend
258 .iter()
259 .zip(&self.source.series_points)
260 .map(|(e, count)| {
261 json!({
262 "name": e.name,
263 "color": e.color.hex(),
264 "points": count,
265 })
266 })
267 .collect();
268 base.as_object_mut()
269 .expect("bar meta is an object")
270 .insert("series".to_string(), json!(series));
271 }
272 base
273 }
274 Mark::Line | Mark::Point | Mark::Area => {
275 // x type/domain: nominal reports its category list; temporal its
276 // domain as ISO strings (never raw millis — meta must not lie);
277 // quantitative its numeric [min, max]. Series (name/color/count)
278 // come from the marks, in first-seen order.
279 let (x_type, x_domain) = match &self.x_axis.categories {
280 Some(cats) => ("nominal", json!(cats)),
281 None if self.source.x_type == Some(FieldType::Temporal) => {
282 let d = self
283 .x_axis
284 .domain
285 .expect("temporal x carries a numeric domain");
286 (
287 "temporal",
288 json!([crate::time::format_iso(d[0]), crate::time::format_iso(d[1]),]),
289 )
290 }
291 None => ("quantitative", json!(self.x_axis.domain)),
292 };
293 let series: Vec<serde_json::Value> = self
294 .marks
295 .iter()
296 .filter_map(|m| {
297 let (sref, count) = match m {
298 SceneMark::Path { series, points }
299 | SceneMark::Points { series, points }
300 | SceneMark::Fill { series, points } => (series, points.len()),
301 SceneMark::Bars { .. } => return None,
302 };
303 Some(json!({
304 "name": sref.name.clone().unwrap_or_default(),
305 "color": sref.color.hex(),
306 "points": count,
307 }))
308 })
309 .collect();
310 json!({
311 "mark": self.source.mark,
312 "x": {
313 "field": self.source.x_field,
314 "type": x_type,
315 "domain": x_domain,
316 },
317 "y": {
318 "field": self.source.y_field,
319 "domain": self.y_axis.domain,
320 },
321 "series": series,
322 "dropped_rows": self.dropped_rows,
323 "size": size,
324 })
325 }
326 };
327 // The `data` block reports what the caller can't already know from
328 // their own bytes: it fires only when the data came from stdin, or the
329 // envelope declared truncation info. Inline data is the caller's own
330 // bytes, so inline-values/columns charts emit no data block — which
331 // keeps the glyph-gallery meta bundles byte-identical.
332 let informative = matches!(
333 self.source.data_source,
334 DataSource::StdinValues | DataSource::StdinColumns
335 ) || self.source.truncated.is_some()
336 || self.source.total_rows.is_some();
337 if informative {
338 if let Some(obj) = meta.as_object_mut() {
339 obj.insert(
340 "data".to_string(),
341 json!({
342 "source": self.source.data_source,
343 "truncated": self.source.truncated,
344 "total_rows": self.source.total_rows,
345 }),
346 );
347 }
348 }
349 meta
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn serializes_stable_json() {
359 let scene = Scene {
360 size: Size {
361 columns: 30,
362 rows: 8,
363 },
364 plot: Rect {
365 x: 4,
366 y: 1,
367 w: 24,
368 h: 5,
369 },
370 chrome: Chrome {
371 axis: Rgb(106, 112, 122),
372 title: Rgb(222, 226, 232),
373 },
374 title: Some(Placed {
375 text: "Sales".to_string(),
376 col: 4,
377 row: 0,
378 }),
379 legend: vec![LegendEntry {
380 name: "north".to_string(),
381 color: Rgb(0, 128, 255),
382 col: 12,
383 row: 0,
384 }],
385 y_axis: YAxis {
386 domain: [0.0, 10.0],
387 step: 5.0,
388 categories: None,
389 ticks: vec![
390 YTick {
391 value: 0.0,
392 frac: 0.0,
393 label: "0".to_string(),
394 row: 5,
395 },
396 YTick {
397 value: 10.0,
398 frac: 1.0,
399 label: "10".to_string(),
400 row: 1,
401 },
402 ],
403 },
404 x_axis: XAxis {
405 categories: Some(vec!["a".to_string(), "b".to_string()]),
406 domain: None,
407 tick_cols: vec![],
408 labels: vec![Placed {
409 text: "a".to_string(),
410 col: 4,
411 row: 6,
412 }],
413 },
414 marks: vec![SceneMark::Bars {
415 bars: vec![
416 Bar {
417 x0: 0.0,
418 y0: 0.4,
419 w: 0.5,
420 h: 0.6,
421 color: Rgb(0, 128, 255),
422 },
423 Bar {
424 x0: 0.5,
425 y0: 0.0,
426 w: 0.5,
427 h: 1.0,
428 color: Rgb(255, 128, 0),
429 },
430 ],
431 direction: BarDirection::Vertical,
432 }],
433 dropped_rows: 0,
434 source: Source {
435 mark: crate::spec::Mark::Bar,
436 x_field: "cat".to_string(),
437 y_field: "val".to_string(),
438 aggregate: None,
439 x_type: None,
440 time_unit: None,
441 series_points: vec![2],
442 data_source: DataSource::InlineValues,
443 truncated: None,
444 total_rows: None,
445 },
446 };
447
448 insta::assert_snapshot!(scene.to_json(), @r##"
449 {
450 "size": {
451 "columns": 30,
452 "rows": 8
453 },
454 "plot": {
455 "x": 4,
456 "y": 1,
457 "w": 24,
458 "h": 5
459 },
460 "chrome": {
461 "axis": "#6a707a",
462 "title": "#dee2e8"
463 },
464 "title": {
465 "text": "Sales",
466 "col": 4,
467 "row": 0
468 },
469 "legend": [
470 {
471 "name": "north",
472 "color": "#0080ff",
473 "col": 12,
474 "row": 0
475 }
476 ],
477 "y_axis": {
478 "domain": [
479 0.0,
480 10.0
481 ],
482 "step": 5.0,
483 "ticks": [
484 {
485 "value": 0.0,
486 "frac": 0.0,
487 "label": "0",
488 "row": 5
489 },
490 {
491 "value": 10.0,
492 "frac": 1.0,
493 "label": "10",
494 "row": 1
495 }
496 ]
497 },
498 "x_axis": {
499 "categories": [
500 "a",
501 "b"
502 ],
503 "domain": null,
504 "tick_cols": [],
505 "labels": [
506 {
507 "text": "a",
508 "col": 4,
509 "row": 6
510 }
511 ]
512 },
513 "marks": [
514 {
515 "Bars": {
516 "bars": [
517 {
518 "x0": 0.0,
519 "y0": 0.4,
520 "w": 0.5,
521 "h": 0.6,
522 "color": "#0080ff"
523 },
524 {
525 "x0": 0.5,
526 "y0": 0.0,
527 "w": 0.5,
528 "h": 1.0,
529 "color": "#ff8000"
530 }
531 ],
532 "direction": "vertical"
533 }
534 }
535 ],
536 "dropped_rows": 0,
537 "source": {
538 "mark": "bar",
539 "x_field": "cat",
540 "y_field": "val",
541 "aggregate": null,
542 "series_points": [
543 2
544 ],
545 "data_source": "inline_values",
546 "truncated": null,
547 "total_rows": null
548 }
549 }
550 "##);
551 }
552}