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}