Skip to main content

benday_core/compile/
mod.rs

1//! The compiler: resolve a spec against its data into a `Scene` — every
2//! data- and layout-dependent decision made, geometry normalized to the plot
3//! rect. All colors are resolved here; the rasterizer never sees a `Theme`.
4//!
5//! Every mark compiles here: bars to `SceneMark::Bars`, and line/point/area to
6//! per-series `Path`/`Points`/`Fill` marks whose points are normalized to the
7//! plot rect. `preflight()` is the shared spec/data validation run before
8//! dispatch. Geometry convention: a mark point is `[frac_x, frac_y]` where
9//! `frac_x = xscale.norm(x)` (0 at the left edge) and `frac_y` is already
10//! FLIPPED to `1 - yscale.norm(y)` (0 at the top edge) so the rasterizer only
11//! multiplies by its pixel grid — it never re-flips.
12
13use crate::data;
14use crate::error::Error;
15use crate::ingest::{Row, Table};
16use crate::scale::{fmt_tick, Linear};
17use crate::scene::{
18    Bar, BarDirection, Chrome, LegendEntry, Placed, Rect, Scene, SceneMark, SeriesRef, Size,
19    Source, XAxis, YAxis, YTick,
20};
21use crate::spec::{Aggregate, Channel, FieldType, Mark, Spec};
22use crate::theme::Theme;
23use std::collections::HashMap;
24
25mod bars;
26mod xy;
27
28use bars::{compile_bar, compile_bar_h};
29use xy::compile_xy;
30
31// 12 row-intervals divide evenly by 2/3/4/6 for the row-aligned tick search;
32// width 72 plus the axis gutter stays under 80 columns.
33const DEFAULT_WIDTH: usize = 72;
34const DEFAULT_HEIGHT: usize = 13;
35
36pub struct CompileOptions {
37    pub width: Option<usize>,
38    pub height: Option<usize>,
39    pub theme: Theme,
40}
41
42/// Plot area dimensions: caller override, else spec, else defaults; floored so
43/// there is always room for a chart.
44pub(crate) fn plot_dims(
45    width: Option<usize>,
46    height: Option<usize>,
47    spec: &Spec,
48) -> (usize, usize) {
49    let plot_w = width.or(spec.width).unwrap_or(DEFAULT_WIDTH).max(8);
50    let plot_h = height.or(spec.height).unwrap_or(DEFAULT_HEIGHT).max(3);
51    (plot_w, plot_h)
52}
53
54/// Y ticks for a row-aligned scale: k intervals over plot_h-1 rows with
55/// exact integer spacing, one YTick per scale tick, rows descending from
56/// the bottom. `top` is the plot's buffer-absolute first row.
57fn y_ticks(y: &Linear, plot_h: usize, top: usize) -> Vec<YTick> {
58    let k = ((y.max - y.min) / y.step).round() as usize;
59    let spacing = (plot_h - 1) / k;
60    y.ticks()
61        .iter()
62        .enumerate()
63        .map(|(i, &t)| YTick {
64            value: t,
65            frac: y.norm(t),
66            label: fmt_tick(t, y.step),
67            row: top + (plot_h - 1) - i * spacing,
68        })
69        .collect()
70}
71
72/// Spec- and data-level rules the type system can't express, run before either
73/// render path. Loud by design: a silently ignored channel produces a chart
74/// the caller didn't ask for, which an agent reading dot art cannot detect.
75pub fn preflight(spec: &Spec, rows: &[Row]) -> Result<(), Error> {
76    validate(spec)?;
77    if spec.mark == Mark::Bar {
78        // Bar field checks are orientation-neutral, and must run BEFORE
79        // orientation is resolved: an absent field infers Nominal, so resolving
80        // orientation first would misroute a missing value field into the
81        // both-categorical error instead of reporting the missing field. Each
82        // channel's field must exist unless that channel is an intrinsic
83        // `count` (count needs no field). The category channel never carries
84        // count — count forces its channel to be the quantitative value axis —
85        // so this checks the category unconditionally and the value axis unless
86        // it's a count, in EITHER orientation.
87        if !matches!(spec.encoding.x.aggregate, Some(Aggregate::Count)) {
88            data::check_field(rows, &spec.encoding.x.field)?;
89        }
90        if !matches!(spec.encoding.y.aggregate, Some(Aggregate::Count)) {
91            data::check_field(rows, &spec.encoding.y.field)?;
92        }
93    } else {
94        data::check_field(rows, &spec.encoding.x.field)?;
95        if !matches!(spec.encoding.y.aggregate, Some(Aggregate::Count)) {
96            data::check_field(rows, &spec.encoding.y.field)?;
97        }
98    }
99    if let Some(c) = &spec.encoding.color {
100        data::check_field(rows, &c.field)?;
101    }
102    Ok(())
103}
104
105fn validate(spec: &Spec) -> Result<(), Error> {
106    if spec.encoding.x_offset.is_some() {
107        return Err(Error::Spec(
108            "`xOffset` is not supported; grouping is expressed with color alone \
109             — set encoding.color to the grouping field"
110                .into(),
111        ));
112    }
113    // Aggregate-on-x is a blanket error for NON-bar marks only. For bars,
114    // quantitative x is now a legal (horizontal) route and `aggregate` placement
115    // is checked post-orientation, per compiler, against the CATEGORICAL channel.
116    if spec.mark != Mark::Bar && spec.encoding.x.aggregate.is_some() {
117        return Err(Error::Spec(
118            "`aggregate` on encoding.x is not supported; aggregation runs over y, grouped by x"
119                .into(),
120        ));
121    }
122    if let Some(c) = &spec.encoding.color {
123        if c.aggregate.is_some() {
124            return Err(Error::Spec(
125                "`aggregate` on encoding.color is not supported; put it on encoding.y".into(),
126            ));
127        }
128    }
129    Ok(())
130}
131
132/// Which way a bar chart runs. Resolved once, up front, from the count rule and
133/// the channel-type precedence chain.
134enum BarRoute {
135    Vertical,
136    Horizontal,
137}
138
139/// Resolve bar orientation. RESOLUTION ORDER IS A HARDENED CONTRACT:
140///   1. `count` on a channel makes it THE quantitative value channel (count is
141///      intrinsically numeric and its field may be absent from rows entirely,
142///      inferring Nominal — which must not misroute). y-count → vertical,
143///      x-count → horizontal, both → error.
144///   2. Otherwise resolve both channel types through precedence (spec `type` >
145///      declared column type > inference) and route by the type pair.
146///   3. Both-categorical: a coercion rescue (stdin-cycle contract) reconsiders
147///      channels WITHOUT an explicit spec `type`, biasing to vertical.
148fn bar_route(spec: &Spec, table: &Table) -> Result<BarRoute, Error> {
149    let rows = &table.rows;
150    let xf = &spec.encoding.x.field;
151    let yf = &spec.encoding.y.field;
152    let x_count = matches!(spec.encoding.x.aggregate, Some(Aggregate::Count));
153    let y_count = matches!(spec.encoding.y.aggregate, Some(Aggregate::Count));
154
155    // 1. Count rule FIRST.
156    match (x_count, y_count) {
157        (true, true) => {
158            return Err(Error::Spec(
159                "aggregate belongs on exactly one channel".into(),
160            ));
161        }
162        (false, true) => return Ok(BarRoute::Vertical),
163        (true, false) => return Ok(BarRoute::Horizontal),
164        (false, false) => {}
165    }
166
167    // 2. Resolve both channel types through the precedence chain. The inference
168    // rung is NATIVE-typed: a JSON string is categorical-SHAPED even when its
169    // contents are numeric (e.g. dice faces "1".."6"), so a string x stays a
170    // category and the bar stays vertical. Numeric strings that genuinely belong
171    // on the value axis are recovered by the coercion rescue below.
172    let resolve = |ch: &Channel, f: &str| -> FieldType {
173        ch.ty
174            .or_else(|| table.declared.get(f).copied())
175            .unwrap_or_else(|| native_type(rows, f))
176    };
177    let x_quant = resolve(&spec.encoding.x, xf) == FieldType::Quantitative;
178    let y_quant = resolve(&spec.encoding.y, yf) == FieldType::Quantitative;
179    match (x_quant, y_quant) {
180        (false, true) => Ok(BarRoute::Vertical),
181        (true, false) => Ok(BarRoute::Horizontal),
182        (true, true) => Err(bar_channel_error(xf, yf, "quantitative")),
183        (false, false) => {
184            // 3. Coercion rescue — ONLY channels without an explicit spec type
185            // (an explicit `"type"` is stated intent, never overridden). Bias
186            // to vertical (compat) when y coerces numeric, else horizontal.
187            if spec.encoding.y.ty.is_none() && data::infer_type(rows, yf) == FieldType::Quantitative
188            {
189                Ok(BarRoute::Vertical)
190            } else if spec.encoding.x.ty.is_none()
191                && data::infer_type(rows, xf) == FieldType::Quantitative
192            {
193                Ok(BarRoute::Horizontal)
194            } else {
195                Err(bar_channel_error(xf, yf, "categorical"))
196            }
197        }
198    }
199}
200
201/// Quantitative iff the field has a value and every present, non-null value is
202/// a NATIVE JSON number. Unlike `data::infer_type`, numeric STRINGS do not
203/// count: for orientation, a string-shaped field is a category (its values may
204/// still be coerced onto the value axis by the rescue or by `data::num`).
205fn native_type(rows: &[Row], field: &str) -> FieldType {
206    let mut saw_value = false;
207    for row in rows {
208        if let Some(v) = row.get(field) {
209            if v.is_null() {
210                continue;
211            }
212            saw_value = true;
213            if !v.is_number() {
214                return FieldType::Nominal;
215            }
216        }
217    }
218    if saw_value {
219        FieldType::Quantitative
220    } else {
221        FieldType::Nominal
222    }
223}
224
225/// The bar orientation-resolution error, for both-quantitative (`both` =
226/// "quantitative") and failed rescue (`both` = "categorical").
227fn bar_channel_error(xf: &str, yf: &str, both: &str) -> Error {
228    Error::Spec(format!(
229        "bar needs one categorical and one quantitative channel; both x (\"{xf}\") and y \
230         (\"{yf}\") resolved {both}; put categories on one axis or set an explicit \"type\""
231    ))
232}
233
234/// Aggregate placed on the CATEGORICAL channel. `value_axis` is the channel that
235/// SHOULD carry the aggregate ("y" for vertical, "x" for horizontal).
236fn bar_aggregate_error(value_axis: &str) -> Error {
237    Error::Spec(format!(
238        "aggregation runs over the quantitative channel, grouped by the categorical one; \
239         put `aggregate` on encoding.{value_axis}"
240    ))
241}
242
243pub(crate) fn aggregate(values: &[f64], agg: Aggregate) -> f64 {
244    match agg {
245        Aggregate::Sum => values.iter().sum(),
246        Aggregate::Mean => values.iter().sum::<f64>() / values.len() as f64,
247        Aggregate::Median => {
248            let mut v = values.to_vec();
249            v.sort_by(f64::total_cmp);
250            let mid = v.len() / 2;
251            if v.len().is_multiple_of(2) {
252                (v[mid - 1] + v[mid]) / 2.0
253            } else {
254                v[mid]
255            }
256        }
257        Aggregate::Min => values.iter().cloned().fold(f64::INFINITY, f64::min),
258        Aggregate::Max => values.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
259        Aggregate::Count => values.len() as f64,
260    }
261}
262
263pub(crate) fn truncate(s: &str, max: usize) -> String {
264    if s.chars().count() <= max {
265        s.to_string()
266    } else {
267        s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
268    }
269}
270
271/// The type-precedence contract, in ONE place: an explicit spec `type` beats a
272/// declared column type beats inference from the data. (Bar ORIENTATION
273/// resolution deliberately uses a native-typed inference rung instead — see
274/// `bar_route`.)
275fn resolved_type(ch: &Channel, table: &Table) -> FieldType {
276    ch.ty
277        .or_else(|| table.declared.get(&ch.field).copied())
278        .unwrap_or_else(|| data::infer_type(&table.rows, &ch.field))
279}
280
281/// Width of the y-axis label gutter: the widest formatted tick.
282fn tick_gutter(scale: &Linear) -> usize {
283    scale
284        .ticks()
285        .iter()
286        .map(|t| fmt_tick(*t, scale.step).chars().count())
287        .max()
288        .unwrap_or(1)
289}
290
291/// Centered title over the plot, plus the rows it occupies — the title and a
292/// blank row of breathing room beneath it, or zero without one.
293fn place_title(spec: &Spec, gutter: usize, plot_w: usize) -> (Option<Placed>, usize) {
294    let title = spec.title.as_deref().map(|t| Placed {
295        text: t.to_string(),
296        col: gutter + 1 + plot_w.saturating_sub(t.chars().count()) / 2,
297        row: 0,
298    });
299    let rows = if title.is_some() { 2 } else { 0 };
300    (title, rows)
301}
302
303/// Right-aligned category names for the horizontal-bar gutter, each truncated
304/// to 24 cells (with a visible '…'); the gutter is the widest surviving name.
305fn name_gutter(cats: &[String]) -> (Vec<String>, usize) {
306    let names: Vec<String> = cats.iter().map(|c| truncate(c, 24)).collect();
307    let gutter = names.iter().map(|s| s.chars().count()).max().unwrap_or(1);
308    (names, gutter)
309}
310
311// --- Error constructors. These strings are CONTRACT: agents pattern-match
312// them to self-correct, and corpus snapshots pin them — each exists once.
313
314/// Negative bar values are rejected, in every orientation.
315fn negative_bar_error() -> Error {
316    Error::Data("negative values are not yet supported for mark \"bar\"; use mark \"line\"".into())
317}
318
319/// More series than palette colors: color is the sole channel identifying a
320/// series, so cycling the palette would make two series indistinguishable.
321fn palette_cap_error(n_series: usize, palette_len: usize, color_field: &str) -> Error {
322    Error::Data(format!(
323        "{n_series} series exceed the {palette_len} distinguishable series colors; \
324         aggregate or filter \"{color_field}\""
325    ))
326}
327
328/// A bar scan that yielded no usable rows.
329fn no_rows_error(value_field: &str, cat_field: &str) -> Error {
330    Error::Data(format!(
331        "no usable rows: field \"{value_field}\" has no numeric values \
332         (or \"{cat_field}\" is always missing)"
333    ))
334}
335
336/// Horizontal-bar content taller than the height ceiling.
337fn height_ceiling_error(n_bars: usize, content: usize) -> Error {
338    Error::Data(format!(
339        "{n_bars} bars need height {content}; filter or aggregate, or raise --height"
340    ))
341}
342
343/// One pass over the rows for ANY bar variant: categories (first-seen) down
344/// one dimension, series (first-seen; one unnamed series when `series_field`
345/// is None) down the other, each cell the raw values awaiting aggregation.
346/// A `count` aggregate yields 1.0 per row without reading the value field.
347struct BarScan {
348    cats: Vec<String>,
349    series: Vec<String>,
350    /// `[category][series]` → raw values; an empty Vec is a missing cell.
351    cells: Vec<Vec<Vec<f64>>>,
352    dropped: usize,
353}
354
355fn scan_bars(
356    rows: &[Row],
357    cat_field: &str,
358    value_field: &str,
359    series_field: Option<&str>,
360    agg: Aggregate,
361) -> BarScan {
362    let mut cats: Vec<String> = Vec::new();
363    let mut series: Vec<String> = match series_field {
364        Some(_) => Vec::new(),
365        None => vec![String::new()],
366    };
367    let mut raw: HashMap<(usize, usize), Vec<f64>> = HashMap::new();
368    let mut dropped = 0usize;
369    for row in rows {
370        let Some(cv) = row.get(cat_field) else {
371            dropped += 1;
372            continue;
373        };
374        let vn = if agg == Aggregate::Count {
375            Some(1.0)
376        } else {
377            row.get(value_field).and_then(data::num)
378        };
379        let Some(vn) = vn else {
380            dropped += 1;
381            continue;
382        };
383        let ci = index_of_or_push(&mut cats, data::text(cv));
384        let si = match series_field {
385            Some(sf) => {
386                let name = row.get(sf).map(data::text).unwrap_or_else(|| "null".into());
387                index_of_or_push(&mut series, name)
388            }
389            None => 0,
390        };
391        raw.entry((ci, si)).or_default().push(vn);
392    }
393    let mut cells = vec![vec![Vec::new(); series.len()]; cats.len()];
394    for ((ci, si), v) in raw {
395        cells[ci][si] = v;
396    }
397    BarScan {
398        cats,
399        series,
400        cells,
401        dropped,
402    }
403}
404
405/// First-seen interning: the index of `item`, pushing it if new.
406fn index_of_or_push(list: &mut Vec<String>, item: String) -> usize {
407    match list.iter().position(|s| *s == item) {
408        Some(i) => i,
409        None => {
410            list.push(item);
411            list.len() - 1
412        }
413    }
414}
415
416/// Lexical category sort for ordinal axes (ISO dates sort chronologically),
417/// carrying each category's cell row along. Series order stays first-seen.
418fn sort_cats(cats: Vec<String>, cells: Vec<Vec<Vec<f64>>>) -> (Vec<String>, Vec<Vec<Vec<f64>>>) {
419    let mut pairs: Vec<(String, Vec<Vec<f64>>)> = cats.into_iter().zip(cells).collect();
420    pairs.sort_by(|a, b| a.0.cmp(&b.0));
421    pairs.into_iter().unzip()
422}
423
424/// Aggregate every cell (an empty cell stays `None` — a visible gap at a
425/// stable position), rejecting negative results, tracking the maximum for the
426/// value scale.
427#[allow(clippy::type_complexity)]
428fn aggregate_cells(
429    cells: &[Vec<Vec<f64>>],
430    agg: Aggregate,
431) -> Result<(Vec<Vec<Option<f64>>>, f64), Error> {
432    let mut vmax = f64::NEG_INFINITY;
433    let mut out = Vec::with_capacity(cells.len());
434    for row in cells {
435        let mut out_row = Vec::with_capacity(row.len());
436        for values in row {
437            if values.is_empty() {
438                out_row.push(None);
439                continue;
440            }
441            let v = aggregate(values, agg);
442            if v < 0.0 {
443                return Err(negative_bar_error());
444            }
445            vmax = vmax.max(v);
446            out_row.push(Some(v));
447        }
448        out.push(out_row);
449    }
450    Ok((out, vmax))
451}
452
453/// Resolve a spec into a Scene: every data- and layout-dependent decision made,
454/// geometry normalized, colors baked in. Bars and xy marks share `preflight`.
455pub fn compile(spec: &Spec, table: &Table, opts: &CompileOptions) -> Result<Scene, Error> {
456    preflight(spec, &table.rows)?;
457    let (plot_w, plot_h) = plot_dims(opts.width, opts.height, spec);
458    match spec.mark {
459        Mark::Bar => match bar_route(spec, table)? {
460            BarRoute::Vertical => compile_bar(spec, table, opts, plot_w, plot_h),
461            // Horizontal bars are content-sized: their height is derived from the
462            // category count, not `plot_dims` (which collapses "no height" into
463            // the default 13 and so can't tell an explicit 13 from the default).
464            // Pass the RAW height Option straight through.
465            BarRoute::Horizontal => {
466                compile_bar_h(spec, table, opts, plot_w, opts.height.or(spec.height))
467            }
468        },
469        Mark::Line | Mark::Point | Mark::Area => compile_xy(spec, table, opts, plot_w, plot_h),
470    }
471}
472
473/// A quantitative x value axis: plot-relative tick columns plus greedily-placed
474/// tick labels. Extracted from `compile_xy`'s quantitative-x branch so the
475/// horizontal-bar value axis and the line/point/area value axis share ONE
476/// implementation. Behavior-neutral for xy: identical columns, anchors, and
477/// `place_x_labels` call as before.
478fn value_axis_x(
479    xscale: &Linear,
480    plot_w: usize,
481    gutter: usize,
482    columns: usize,
483    label_row: usize,
484) -> (Vec<usize>, Vec<Placed>) {
485    let tks = xscale.ticks();
486    let tick_cols: Vec<usize> = tks
487        .iter()
488        .map(|t| ((xscale.norm(*t) * (plot_w - 1) as f64).round() as usize).min(plot_w - 1))
489        .collect();
490    let anchors: Vec<(usize, String)> = tick_cols
491        .iter()
492        .zip(&tks)
493        .map(|(c, t)| (*c, fmt_tick(*t, xscale.step)))
494        .collect();
495    let labels = place_x_labels(&anchors, gutter, columns, label_row);
496    (tick_cols, labels)
497}
498
499/// The shared multi-series legend: "── name" entries flow below the x labels
500/// starting at `top + plot_h + 2`, wrapping before the right edge. Entries are
501/// never clipped; a name wider than the whole row is visibly truncated with
502/// '…'. Colors cycle the palette by entry index (`theme.series`). Returns the
503/// placed entries plus the number of rows they occupy. Used by both the xy
504/// (line/point/area) path and the grouped-bar path — ONE implementation.
505fn legend_below(
506    names: &[String],
507    theme: &Theme,
508    gutter: usize,
509    columns: usize,
510    top: usize,
511    plot_h: usize,
512) -> (Vec<LegendEntry>, usize) {
513    let legend_row0 = top + plot_h + 2;
514    let left = gutter + 1;
515    let max_name = columns.saturating_sub(left + 3);
516    let (mut col, mut row) = (left, legend_row0);
517    let mut legend: Vec<LegendEntry> = Vec::new();
518    for (i, name) in names.iter().enumerate() {
519        let name = truncate(name, max_name);
520        let w = 3 + name.chars().count(); // "── " + name
521        if col > left && col + w > columns {
522            col = left;
523            row += 1;
524        }
525        legend.push(LegendEntry {
526            name,
527            color: theme.series(i),
528            col,
529            row,
530        });
531        col += w + 3;
532    }
533    let legend_rows = legend.last().map_or(0, |e| e.row + 1 - legend_row0);
534    (legend, legend_rows)
535}
536
537/// Greedy left-to-right x-label placement: each label centered on its anchor
538/// column, clamped inside the buffer, skipped if it would collide with the one
539/// before it. Mirrors the old `draw_x_axis`; survivors carry buffer-absolute
540/// start columns.
541fn place_x_labels(
542    anchors: &[(usize, String)],
543    gutter: usize,
544    width: usize,
545    row: usize,
546) -> Vec<Placed> {
547    let mut out = Vec::new();
548    let mut next_free = 0usize;
549    for (col, label) in anchors {
550        let len = label.chars().count();
551        if len == 0 || len > width {
552            continue;
553        }
554        let start = (gutter + 1 + col).saturating_sub(len / 2).min(width - len);
555        if start < next_free {
556            continue;
557        }
558        out.push(Placed {
559            text: label.clone(),
560            col: start,
561            row,
562        });
563        next_free = start + len + 1;
564    }
565    out
566}