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 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}