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 on a categorical x
98 /// (nominal / timeUnit vertical bars); populated on a quantitative value
99 /// axis (horizontal bars, xy marks) and on a histogram's binned x — a
100 /// tick per left cell edge, the right domain edge left tickless (design
101 /// §Axis: column `plot_w` is unrepresentable).
102 pub tick_cols: Vec<usize>,
103 /// Labels that survived greedy placement; `col` is the buffer-absolute
104 /// start column. Dropped labels simply don't appear — visible in diffs.
105 pub labels: Vec<Placed>,
106}
107
108#[derive(Serialize)]
109pub struct SeriesRef {
110 pub name: Option<String>,
111 pub color: Rgb,
112}
113
114#[derive(Serialize)]
115pub enum SceneMark {
116 Bars {
117 /// One entry per category, in category order.
118 bars: Vec<Bar>,
119 /// Bar orientation. Rect anchors can't encode it: a bottom-row
120 /// horizontal bar has `x0 == 0` AND `y0 + h == 1`, exactly a vertical
121 /// bar's signature — so the direction is carried once per mark.
122 direction: BarDirection,
123 },
124 Path {
125 series: SeriesRef,
126 points: Vec<[f64; 2]>,
127 },
128 Points {
129 series: SeriesRef,
130 points: Vec<[f64; 2]>,
131 },
132 /// Area: fill under the path plus the path itself.
133 Fill {
134 series: SeriesRef,
135 points: Vec<[f64; 2]>,
136 },
137}
138
139/// One bar as a normalized rect over the plot area: `x0/w` as fractions of
140/// plot width, `y0/h` as fractions of plot height, y0 = 0 at the TOP (same
141/// orientation as point geometry). Vertical bars: y0 = 1 - h, full h to the
142/// baseline. Horizontal bars: x0 = 0, w = value fraction.
143#[derive(Serialize)]
144pub struct Bar {
145 pub x0: f64,
146 pub y0: f64,
147 pub w: f64,
148 pub h: f64,
149 pub color: Rgb,
150}
151
152#[derive(Serialize, Clone, Copy, PartialEq)]
153#[serde(rename_all = "snake_case")]
154pub enum BarDirection {
155 Vertical,
156 Horizontal,
157}
158
159#[derive(Serialize)]
160pub struct Source {
161 pub mark: crate::spec::Mark,
162 pub x_field: String,
163 pub y_field: String,
164 pub aggregate: Option<Aggregate>,
165 /// The resolved x type, set to `Some(Temporal)` ONLY by the temporal xy
166 /// path and `None` everywhere else — so `meta()` can tell a temporal x
167 /// (ISO domain) from a quantitative one, and every non-temporal snapshot
168 /// stays byte-identical (skipped when None).
169 #[serde(skip_serializing_if = "Option::is_none")]
170 pub x_type: Option<FieldType>,
171 /// The `timeUnit` bucketing a temporal bar's x, `Some` ONLY on that path.
172 /// `meta()` reports it in the x block (with the canonical bucket keys as the
173 /// categories); skipped when None so every other snapshot stays identical.
174 #[serde(skip_serializing_if = "Option::is_none")]
175 pub time_unit: Option<TimeUnit>,
176 /// The resolved bin layout of a histogram's x, `Some` ONLY on that path.
177 /// `meta()` reports it in the x block (step / domain / bin count) so an
178 /// agent can verify how the values were binned; skipped when None so every
179 /// non-histogram snapshot stays byte-identical.
180 #[serde(skip_serializing_if = "Option::is_none")]
181 pub bin: Option<BinInfo>,
182 /// Points-per-series counts etc. needed to reproduce --meta exactly.
183 pub series_points: Vec<usize>,
184 /// Data provenance (from `Table::provenance`). Drives the conditional
185 /// `--meta` data block; always serialized (null when absent) so
186 /// `--dump-scene` shows it.
187 pub data_source: DataSource,
188 pub truncated: Option<bool>,
189 pub total_rows: Option<u64>,
190}
191
192/// A histogram's resolved bin layout, carried on `Source` for `--meta`: the bin
193/// width (`step`), the snapped `[lo, hi]` `domain`, and the bin count (`bins`).
194/// Enough for an agent to confirm the chart binned as it intended.
195#[derive(Serialize)]
196pub struct BinInfo {
197 pub step: f64,
198 pub domain: [f64; 2],
199 pub bins: usize,
200}
201
202impl Scene {
203 pub fn to_json(&self) -> String {
204 serde_json::to_string_pretty(self).expect("scene serialization is infallible")
205 }
206
207 /// The --meta payload. Must reproduce the pre-refactor format exactly.
208 /// Keys serialize alphabetically (serde_json's default map ordering), so
209 /// the order they appear in each `json!` block is irrelevant.
210 pub fn meta(&self) -> serde_json::Value {
211 let size = json!({ "columns": self.size.columns, "rows": self.size.rows });
212 let mut meta = match self.source.mark {
213 Mark::Bar => {
214 // Orientation keys on the mark's explicit `direction`, NOT on
215 // x-category presence: a histogram's vertical bar carries a
216 // domain-valued x (categories None) exactly like a horizontal
217 // bar's, so category presence can no longer tell them apart.
218 // A HORIZONTAL bar reports x as quantitative-with-domain, y as
219 // nominal-with-categories, plus a "direction" key; a VERTICAL
220 // nominal/timeUnit bar keeps the pre-existing shape
221 // byte-identically (no "direction" key).
222 let direction = self
223 .marks
224 .iter()
225 .find_map(|m| match m {
226 SceneMark::Bars { direction, .. } => Some(*direction),
227 _ => None,
228 })
229 .expect("bar scenes carry exactly one Bars mark");
230 let mut base = match direction {
231 BarDirection::Horizontal => {
232 // RAW names, not the truncated tick labels: meta is the
233 // machine-readable surface a caller matches back to rows.
234 let cats = self
235 .y_axis
236 .categories
237 .as_ref()
238 .expect("horizontal bar scenes carry raw y categories");
239 json!({
240 "mark": "bar",
241 "direction": "horizontal",
242 "x": {
243 "field": self.source.x_field,
244 "type": "quantitative",
245 "aggregate": self.source.aggregate,
246 "domain": self.x_axis.domain,
247 },
248 "y": {
249 "field": self.source.y_field,
250 "type": "nominal",
251 "categories": cats,
252 },
253 "dropped_rows": self.dropped_rows,
254 "size": size,
255 })
256 }
257 BarDirection::Vertical if self.x_axis.categories.is_some() => {
258 // A `timeUnit` bar reports its buckets as the (canonical-key)
259 // categories plus a "timeUnit" tag and a "temporal" type — a
260 // plain bar keeps the byte-identical nominal shape.
261 let mut x = json!({
262 "field": self.source.x_field,
263 "type": "nominal",
264 "categories": self.x_axis.categories,
265 });
266 if let Some(tu) = self.source.time_unit {
267 let x = x.as_object_mut().expect("x meta is an object");
268 x.insert("type".to_string(), json!("temporal"));
269 x.insert("timeUnit".to_string(), json!(tu));
270 }
271 json!({
272 "mark": "bar",
273 "x": x,
274 "y": {
275 "field": self.source.y_field,
276 "aggregate": self.source.aggregate,
277 "domain": self.y_axis.domain,
278 },
279 "dropped_rows": self.dropped_rows,
280 "size": size,
281 })
282 }
283 BarDirection::Vertical => {
284 // Histogram: a vertical bar over a binned quantitative x
285 // (categories None). Reports the bin layout so an agent
286 // can verify the binning; no "direction" key — vertical
287 // stays the unmarked case.
288 let bin = self
289 .source
290 .bin
291 .as_ref()
292 .expect("histogram scenes carry a bin layout");
293 json!({
294 "mark": "bar",
295 "x": {
296 "field": self.source.x_field,
297 "type": "quantitative",
298 "bin": {
299 "step": bin.step,
300 "domain": bin.domain,
301 "bins": bin.bins,
302 },
303 },
304 "y": {
305 "field": self.source.y_field,
306 "aggregate": self.source.aggregate,
307 "domain": self.y_axis.domain,
308 },
309 "dropped_rows": self.dropped_rows,
310 "size": size,
311 })
312 }
313 };
314 // Grouped bars carry a legend; append the xy-shaped series array
315 // (name/color/cell-count) from the legend entries zipped with the
316 // per-series counts. Plain and tinted bars have no legend and emit
317 // byte-identical meta to before.
318 if !self.legend.is_empty() {
319 let series: Vec<serde_json::Value> = self
320 .legend
321 .iter()
322 .zip(&self.source.series_points)
323 .map(|(e, count)| {
324 json!({
325 "name": e.name,
326 "color": e.color.hex(),
327 "points": count,
328 })
329 })
330 .collect();
331 base.as_object_mut()
332 .expect("bar meta is an object")
333 .insert("series".to_string(), json!(series));
334 }
335 base
336 }
337 Mark::Line | Mark::Point | Mark::Area => {
338 // x type/domain: nominal reports its category list; temporal its
339 // domain as ISO strings (never raw millis — meta must not lie);
340 // quantitative its numeric [min, max]. Series (name/color/count)
341 // come from the marks, in first-seen order.
342 let (x_type, x_domain) = match &self.x_axis.categories {
343 Some(cats) => ("nominal", json!(cats)),
344 None if self.source.x_type == Some(FieldType::Temporal) => {
345 let d = self
346 .x_axis
347 .domain
348 .expect("temporal x carries a numeric domain");
349 (
350 "temporal",
351 json!([crate::time::format_iso(d[0]), crate::time::format_iso(d[1]),]),
352 )
353 }
354 None => ("quantitative", json!(self.x_axis.domain)),
355 };
356 let series: Vec<serde_json::Value> = self
357 .marks
358 .iter()
359 .filter_map(|m| {
360 let (sref, count) = match m {
361 SceneMark::Path { series, points }
362 | SceneMark::Points { series, points }
363 | SceneMark::Fill { series, points } => (series, points.len()),
364 SceneMark::Bars { .. } => return None,
365 };
366 Some(json!({
367 "name": sref.name.clone().unwrap_or_default(),
368 "color": sref.color.hex(),
369 "points": count,
370 }))
371 })
372 .collect();
373 json!({
374 "mark": self.source.mark,
375 "x": {
376 "field": self.source.x_field,
377 "type": x_type,
378 "domain": x_domain,
379 },
380 "y": {
381 "field": self.source.y_field,
382 "domain": self.y_axis.domain,
383 },
384 "series": series,
385 "dropped_rows": self.dropped_rows,
386 "size": size,
387 })
388 }
389 };
390 // The `data` block reports what the caller can't already know from
391 // their own bytes: it fires only when the data came from stdin, or the
392 // envelope declared truncation info. Inline data is the caller's own
393 // bytes, so inline-values/columns charts emit no data block — which
394 // keeps the glyph-gallery meta bundles byte-identical.
395 let informative = matches!(
396 self.source.data_source,
397 DataSource::StdinValues | DataSource::StdinColumns
398 ) || self.source.truncated.is_some()
399 || self.source.total_rows.is_some();
400 if informative {
401 if let Some(obj) = meta.as_object_mut() {
402 obj.insert(
403 "data".to_string(),
404 json!({
405 "source": self.source.data_source,
406 "truncated": self.source.truncated,
407 "total_rows": self.source.total_rows,
408 }),
409 );
410 }
411 }
412 meta
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn serializes_stable_json() {
422 let scene = Scene {
423 size: Size {
424 columns: 30,
425 rows: 8,
426 },
427 plot: Rect {
428 x: 4,
429 y: 1,
430 w: 24,
431 h: 5,
432 },
433 chrome: Chrome {
434 axis: Rgb(106, 112, 122),
435 title: Rgb(222, 226, 232),
436 },
437 title: Some(Placed {
438 text: "Sales".to_string(),
439 col: 4,
440 row: 0,
441 }),
442 legend: vec![LegendEntry {
443 name: "north".to_string(),
444 color: Rgb(0, 128, 255),
445 col: 12,
446 row: 0,
447 }],
448 y_axis: YAxis {
449 domain: [0.0, 10.0],
450 step: 5.0,
451 categories: None,
452 ticks: vec![
453 YTick {
454 value: 0.0,
455 frac: 0.0,
456 label: "0".to_string(),
457 row: 5,
458 },
459 YTick {
460 value: 10.0,
461 frac: 1.0,
462 label: "10".to_string(),
463 row: 1,
464 },
465 ],
466 },
467 x_axis: XAxis {
468 categories: Some(vec!["a".to_string(), "b".to_string()]),
469 domain: None,
470 tick_cols: vec![],
471 labels: vec![Placed {
472 text: "a".to_string(),
473 col: 4,
474 row: 6,
475 }],
476 },
477 marks: vec![SceneMark::Bars {
478 bars: vec![
479 Bar {
480 x0: 0.0,
481 y0: 0.4,
482 w: 0.5,
483 h: 0.6,
484 color: Rgb(0, 128, 255),
485 },
486 Bar {
487 x0: 0.5,
488 y0: 0.0,
489 w: 0.5,
490 h: 1.0,
491 color: Rgb(255, 128, 0),
492 },
493 ],
494 direction: BarDirection::Vertical,
495 }],
496 dropped_rows: 0,
497 source: Source {
498 mark: crate::spec::Mark::Bar,
499 x_field: "cat".to_string(),
500 y_field: "val".to_string(),
501 aggregate: None,
502 x_type: None,
503 time_unit: None,
504 bin: None,
505 series_points: vec![2],
506 data_source: DataSource::InlineValues,
507 truncated: None,
508 total_rows: None,
509 },
510 };
511
512 insta::assert_snapshot!(scene.to_json(), @r##"
513 {
514 "size": {
515 "columns": 30,
516 "rows": 8
517 },
518 "plot": {
519 "x": 4,
520 "y": 1,
521 "w": 24,
522 "h": 5
523 },
524 "chrome": {
525 "axis": "#6a707a",
526 "title": "#dee2e8"
527 },
528 "title": {
529 "text": "Sales",
530 "col": 4,
531 "row": 0
532 },
533 "legend": [
534 {
535 "name": "north",
536 "color": "#0080ff",
537 "col": 12,
538 "row": 0
539 }
540 ],
541 "y_axis": {
542 "domain": [
543 0.0,
544 10.0
545 ],
546 "step": 5.0,
547 "ticks": [
548 {
549 "value": 0.0,
550 "frac": 0.0,
551 "label": "0",
552 "row": 5
553 },
554 {
555 "value": 10.0,
556 "frac": 1.0,
557 "label": "10",
558 "row": 1
559 }
560 ]
561 },
562 "x_axis": {
563 "categories": [
564 "a",
565 "b"
566 ],
567 "domain": null,
568 "tick_cols": [],
569 "labels": [
570 {
571 "text": "a",
572 "col": 4,
573 "row": 6
574 }
575 ]
576 },
577 "marks": [
578 {
579 "Bars": {
580 "bars": [
581 {
582 "x0": 0.0,
583 "y0": 0.4,
584 "w": 0.5,
585 "h": 0.6,
586 "color": "#0080ff"
587 },
588 {
589 "x0": 0.5,
590 "y0": 0.0,
591 "w": 0.5,
592 "h": 1.0,
593 "color": "#ff8000"
594 }
595 ],
596 "direction": "vertical"
597 }
598 }
599 ],
600 "dropped_rows": 0,
601 "source": {
602 "mark": "bar",
603 "x_field": "cat",
604 "y_field": "val",
605 "aggregate": null,
606 "series_points": [
607 2
608 ],
609 "data_source": "inline_values",
610 "truncated": null,
611 "total_rows": null
612 }
613 }
614 "##);
615 }
616}