Skip to main content

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}