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, Mark};
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    /// Points-per-series counts etc. needed to reproduce --meta exactly.
162    pub series_points: Vec<usize>,
163    /// Data provenance (from `Table::provenance`). Drives the conditional
164    /// `--meta` data block; always serialized (null when absent) so
165    /// `--dump-scene` shows it.
166    pub data_source: DataSource,
167    pub truncated: Option<bool>,
168    pub total_rows: Option<u64>,
169}
170
171impl Scene {
172    pub fn to_json(&self) -> String {
173        serde_json::to_string_pretty(self).expect("scene serialization is infallible")
174    }
175
176    /// The --meta payload. Must reproduce the pre-refactor format exactly.
177    /// Keys serialize alphabetically (serde_json's default map ordering), so
178    /// the order they appear in each `json!` block is irrelevant.
179    pub fn meta(&self) -> serde_json::Value {
180        let size = json!({ "columns": self.size.columns, "rows": self.size.rows });
181        let mut meta = match self.source.mark {
182            Mark::Bar => {
183                // Orientation is append-only and conditional: a VERTICAL bar
184                // reports the pre-existing shape byte-identically (no "direction"
185                // key). A HORIZONTAL bar has no x categories (its x is the
186                // quantitative value axis) — that's the detector — so it reports
187                // x as quantitative-with-domain, y as nominal-with-categories,
188                // plus a "direction" key.
189                let mut base = if self.x_axis.categories.is_none() {
190                    // RAW names, not the truncated tick labels: meta is the
191                    // machine-readable surface a caller matches back to rows.
192                    let cats = self
193                        .y_axis
194                        .categories
195                        .as_ref()
196                        .expect("horizontal bar scenes carry raw y categories");
197                    json!({
198                        "mark": "bar",
199                        "direction": "horizontal",
200                        "x": {
201                            "field": self.source.x_field,
202                            "type": "quantitative",
203                            "aggregate": self.source.aggregate,
204                            "domain": self.x_axis.domain,
205                        },
206                        "y": {
207                            "field": self.source.y_field,
208                            "type": "nominal",
209                            "categories": cats,
210                        },
211                        "dropped_rows": self.dropped_rows,
212                        "size": size,
213                    })
214                } else {
215                    json!({
216                    "mark": "bar",
217                    "x": {
218                        "field": self.source.x_field,
219                        "type": "nominal",
220                        "categories": self.x_axis.categories,
221                    },
222                    "y": {
223                        "field": self.source.y_field,
224                        "aggregate": self.source.aggregate,
225                        "domain": self.y_axis.domain,
226                    },
227                    "dropped_rows": self.dropped_rows,
228                    "size": size,
229                    })
230                };
231                // Grouped bars carry a legend; append the xy-shaped series array
232                // (name/color/cell-count) from the legend entries zipped with the
233                // per-series counts. Plain and tinted bars have no legend and emit
234                // byte-identical meta to before.
235                if !self.legend.is_empty() {
236                    let series: Vec<serde_json::Value> = self
237                        .legend
238                        .iter()
239                        .zip(&self.source.series_points)
240                        .map(|(e, count)| {
241                            json!({
242                                "name": e.name,
243                                "color": e.color.hex(),
244                                "points": count,
245                            })
246                        })
247                        .collect();
248                    base.as_object_mut()
249                        .expect("bar meta is an object")
250                        .insert("series".to_string(), json!(series));
251                }
252                base
253            }
254            Mark::Line | Mark::Point | Mark::Area => {
255                // x type/domain: nominal reports its category list, quantitative
256                // its numeric [min, max]. Series (name/color/count) come from the
257                // marks, in first-seen order.
258                let (x_type, x_domain) = match &self.x_axis.categories {
259                    Some(cats) => ("nominal", json!(cats)),
260                    None => ("quantitative", json!(self.x_axis.domain)),
261                };
262                let series: Vec<serde_json::Value> = self
263                    .marks
264                    .iter()
265                    .filter_map(|m| {
266                        let (sref, count) = match m {
267                            SceneMark::Path { series, points }
268                            | SceneMark::Points { series, points }
269                            | SceneMark::Fill { series, points } => (series, points.len()),
270                            SceneMark::Bars { .. } => return None,
271                        };
272                        Some(json!({
273                            "name": sref.name.clone().unwrap_or_default(),
274                            "color": sref.color.hex(),
275                            "points": count,
276                        }))
277                    })
278                    .collect();
279                json!({
280                    "mark": self.source.mark,
281                    "x": {
282                        "field": self.source.x_field,
283                        "type": x_type,
284                        "domain": x_domain,
285                    },
286                    "y": {
287                        "field": self.source.y_field,
288                        "domain": self.y_axis.domain,
289                    },
290                    "series": series,
291                    "dropped_rows": self.dropped_rows,
292                    "size": size,
293                })
294            }
295        };
296        // The `data` block reports what the caller can't already know from
297        // their own bytes: it fires only when the data came from stdin, or the
298        // envelope declared truncation info. Inline data is the caller's own
299        // bytes, so inline-values/columns charts emit no data block — which
300        // keeps the glyph-gallery meta bundles byte-identical.
301        let informative = matches!(
302            self.source.data_source,
303            DataSource::StdinValues | DataSource::StdinColumns
304        ) || self.source.truncated.is_some()
305            || self.source.total_rows.is_some();
306        if informative {
307            if let Some(obj) = meta.as_object_mut() {
308                obj.insert(
309                    "data".to_string(),
310                    json!({
311                        "source": self.source.data_source,
312                        "truncated": self.source.truncated,
313                        "total_rows": self.source.total_rows,
314                    }),
315                );
316            }
317        }
318        meta
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn serializes_stable_json() {
328        let scene = Scene {
329            size: Size {
330                columns: 30,
331                rows: 8,
332            },
333            plot: Rect {
334                x: 4,
335                y: 1,
336                w: 24,
337                h: 5,
338            },
339            chrome: Chrome {
340                axis: Rgb(106, 112, 122),
341                title: Rgb(222, 226, 232),
342            },
343            title: Some(Placed {
344                text: "Sales".to_string(),
345                col: 4,
346                row: 0,
347            }),
348            legend: vec![LegendEntry {
349                name: "north".to_string(),
350                color: Rgb(0, 128, 255),
351                col: 12,
352                row: 0,
353            }],
354            y_axis: YAxis {
355                domain: [0.0, 10.0],
356                step: 5.0,
357                categories: None,
358                ticks: vec![
359                    YTick {
360                        value: 0.0,
361                        frac: 0.0,
362                        label: "0".to_string(),
363                        row: 5,
364                    },
365                    YTick {
366                        value: 10.0,
367                        frac: 1.0,
368                        label: "10".to_string(),
369                        row: 1,
370                    },
371                ],
372            },
373            x_axis: XAxis {
374                categories: Some(vec!["a".to_string(), "b".to_string()]),
375                domain: None,
376                tick_cols: vec![],
377                labels: vec![Placed {
378                    text: "a".to_string(),
379                    col: 4,
380                    row: 6,
381                }],
382            },
383            marks: vec![SceneMark::Bars {
384                bars: vec![
385                    Bar {
386                        x0: 0.0,
387                        y0: 0.4,
388                        w: 0.5,
389                        h: 0.6,
390                        color: Rgb(0, 128, 255),
391                    },
392                    Bar {
393                        x0: 0.5,
394                        y0: 0.0,
395                        w: 0.5,
396                        h: 1.0,
397                        color: Rgb(255, 128, 0),
398                    },
399                ],
400                direction: BarDirection::Vertical,
401            }],
402            dropped_rows: 0,
403            source: Source {
404                mark: crate::spec::Mark::Bar,
405                x_field: "cat".to_string(),
406                y_field: "val".to_string(),
407                aggregate: None,
408                series_points: vec![2],
409                data_source: DataSource::InlineValues,
410                truncated: None,
411                total_rows: None,
412            },
413        };
414
415        insta::assert_snapshot!(scene.to_json(), @r##"
416        {
417          "size": {
418            "columns": 30,
419            "rows": 8
420          },
421          "plot": {
422            "x": 4,
423            "y": 1,
424            "w": 24,
425            "h": 5
426          },
427          "chrome": {
428            "axis": "#6a707a",
429            "title": "#dee2e8"
430          },
431          "title": {
432            "text": "Sales",
433            "col": 4,
434            "row": 0
435          },
436          "legend": [
437            {
438              "name": "north",
439              "color": "#0080ff",
440              "col": 12,
441              "row": 0
442            }
443          ],
444          "y_axis": {
445            "domain": [
446              0.0,
447              10.0
448            ],
449            "step": 5.0,
450            "ticks": [
451              {
452                "value": 0.0,
453                "frac": 0.0,
454                "label": "0",
455                "row": 5
456              },
457              {
458                "value": 10.0,
459                "frac": 1.0,
460                "label": "10",
461                "row": 1
462              }
463            ]
464          },
465          "x_axis": {
466            "categories": [
467              "a",
468              "b"
469            ],
470            "domain": null,
471            "tick_cols": [],
472            "labels": [
473              {
474                "text": "a",
475                "col": 4,
476                "row": 6
477              }
478            ]
479          },
480          "marks": [
481            {
482              "Bars": {
483                "bars": [
484                  {
485                    "x0": 0.0,
486                    "y0": 0.4,
487                    "w": 0.5,
488                    "h": 0.6,
489                    "color": "#0080ff"
490                  },
491                  {
492                    "x0": 0.5,
493                    "y0": 0.0,
494                    "w": 0.5,
495                    "h": 1.0,
496                    "color": "#ff8000"
497                  }
498                ],
499                "direction": "vertical"
500              }
501            }
502          ],
503          "dropped_rows": 0,
504          "source": {
505            "mark": "bar",
506            "x_field": "cat",
507            "y_field": "val",
508            "aggregate": null,
509            "series_points": [
510              2
511            ],
512            "data_source": "inline_values",
513            "truncated": null,
514            "total_rows": null
515          }
516        }
517        "##);
518    }
519}