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::{bins_auto, bins_maxbins, bins_step, cell_edges, fmt_tick, Linear};
17use crate::scene::{
18    Bar, BarDirection, BinInfo, Chrome, LegendEntry, Placed, Rect, Scene, SceneMark, SeriesRef,
19    Size, Source, XAxis, YAxis, YTick,
20};
21use crate::spec::{Aggregate, BinConfig, BinValue, Channel, FieldType, Mark, Spec, TimeUnit};
22use crate::theme::Theme;
23use std::collections::HashMap;
24
25mod bars;
26mod xy;
27
28use bars::{compile_bar, compile_bar_h, compile_histogram};
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    // `timeUnit` is bar-only and x-only this cycle (design §spec semantics).
130    // Reject the three misplacements up front, each naming the fix; the
131    // non-temporal-x case needs the resolved type and so lives in `bar_route`.
132    if spec.encoding.y.time_unit.is_some() {
133        return Err(timeunit_channel_error("y"));
134    }
135    if spec
136        .encoding
137        .color
138        .as_ref()
139        .is_some_and(|c| c.time_unit.is_some())
140    {
141        return Err(timeunit_channel_error("color"));
142    }
143    if spec.encoding.x.time_unit.is_some() && spec.mark != Mark::Bar {
144        return Err(timeunit_mark_error(spec.mark));
145    }
146    validate_bin(spec)?;
147    Ok(())
148}
149
150/// The active `bin` on a channel, or `None` when absent OR `false`. `bin: false`
151/// is Vega-Lite noise that means "no binning", so it is treated as absent and
152/// trips no placement rule (design §spec semantics). `true`, `{}`, and
153/// `{maxbins|step}` are all active.
154fn active_bin(ch: &Channel) -> Option<BinValue> {
155    match ch.bin {
156        Some(BinValue::Flag(false)) | None => None,
157        Some(b) => Some(b),
158    }
159}
160
161/// Pure spec-shape `bin` validation: placement (which channel) then, for the
162/// only supported placement (x on a bar), the knob shape. The TYPE-dependent
163/// bin errors — non-quantitative x, a raw y with no aggregate, a resolved bin
164/// count over the plot width — need resolved types and live in the compile
165/// path, not here. Placement is checked before shape so a mis-placed bin names
166/// the channel, never an incidental bad knob.
167fn validate_bin(spec: &Spec) -> Result<(), Error> {
168    if active_bin(&spec.encoding.y).is_some() {
169        return Err(bin_on_y_error());
170    }
171    if spec
172        .encoding
173        .color
174        .as_ref()
175        .is_some_and(|c| active_bin(c).is_some())
176    {
177        return Err(bin_on_color_error());
178    }
179    let Some(bin) = active_bin(&spec.encoding.x) else {
180        return Ok(());
181    };
182    // `bin` is on x, the only supported channel. Structural conflicts first,
183    // deterministic order: non-bar mark, then a competing `timeUnit`, then a
184    // series-splitting `color` (any color is a split — tint-on-bins is undefined
185    // and benday never silently ignores a channel).
186    if spec.mark != Mark::Bar {
187        return Err(bin_mark_error(spec.mark));
188    }
189    if spec.encoding.x.time_unit.is_some() {
190        return Err(bin_timeunit_error());
191    }
192    if let Some(c) = &spec.encoding.color {
193        return Err(bin_color_series_error(&c.field));
194    }
195    // Knob shape: only the object form carries knobs. `true`/`{}` are automatic.
196    if let BinValue::Config(cfg) = bin {
197        validate_bin_config(&cfg)?;
198    }
199    Ok(())
200}
201
202/// The `bin` object's knob shape. `maxbins` and `step` fight; each alone must be
203/// well-formed so the selection primitives (which call `log10` unguarded) never
204/// see a non-positive or non-finite input. `{}` (neither knob) is automatic
205/// binning, `bin: true` parity.
206fn validate_bin_config(cfg: &BinConfig) -> Result<(), Error> {
207    match (cfg.maxbins, cfg.step) {
208        (Some(_), Some(_)) => Err(bin_maxbins_and_step_error()),
209        (Some(m), None) if m < 1.0 || m.fract() != 0.0 => Err(bin_maxbins_error(m)),
210        (None, Some(s)) if !(s.is_finite() && s > 0.0) => Err(bin_step_error(s)),
211        _ => Ok(()),
212    }
213}
214
215/// Which way a bar chart runs. Resolved once, up front, from the count rule and
216/// the channel-type precedence chain.
217enum BarRoute {
218    Vertical,
219    Horizontal,
220    /// A histogram: `bin` is active on a quantitative x. Its own compiler owns
221    /// bin selection, integer-edge tiling, and the edge-ticked value axis.
222    Histogram,
223}
224
225/// Resolve bar orientation. RESOLUTION ORDER IS A HARDENED CONTRACT:
226///   0. An ACTIVE `bin` on x is a HISTOGRAM, routed ahead of every rule below
227///      — in particular the y-count short-circuit (rule 4), which would else
228///      render a count histogram as a nonsense per-value chart. Gated on a
229///      canonically-resolved quantitative x and an aggregated y.
230///   1. Count-on-both is ill-formed regardless of anything else → error.
231///   2. x-count makes x THE quantitative value channel (count is intrinsically
232///      numeric and ignores the field's values, so even a temporal field counts
233///      fine) → horizontal, BEFORE the temporal gate. `timeUnit` here has no
234///      time axis to bucket: teaching error, never silently ignored.
235///   3. The temporal/timeUnit gate, BEFORE the y-count rule (a y-count would
236///      otherwise short-circuit a temporal x into a raw-timestamp category
237///      axis). `timeUnit` present is EXPLICIT temporal intent, so it gates on
238///      the CANONICAL chain (`resolved_type`, whose inference rung PROMOTES an
239///      all-ISO-string column) — undeclared raw-log timestamps bucket without
240///      a declaration, the design's no-SQL workload. Non-temporal under that
241///      chain → teaching error naming the type and the deciding rung. WITHOUT
242///      a `timeUnit`, the native rung governs as always: declared/explicit
243///      temporal → teaching error; undeclared date strings stay categorical
244///      (the recorded stdin contract — see the closure below).
245///   4. y-count → vertical (count's field may be absent from rows entirely,
246///      inferring Nominal — which must not misroute through the type pair).
247///   5. Otherwise route both channel types (resolved through precedence: spec
248///      `type` > declared column type > inference) by the type pair.
249///   6. Both-categorical: a coercion rescue (stdin-cycle contract) reconsiders
250///      channels WITHOUT an explicit spec `type`, biasing to vertical.
251fn bar_route(spec: &Spec, table: &Table) -> Result<BarRoute, Error> {
252    let rows = &table.rows;
253    let xf = &spec.encoding.x.field;
254    let yf = &spec.encoding.y.field;
255    let x_count = matches!(spec.encoding.x.aggregate, Some(Aggregate::Count));
256    let y_count = matches!(spec.encoding.y.aggregate, Some(Aggregate::Count));
257
258    // 0. Histogram: an ACTIVE `bin` on x (true / {} / config, not false) makes
259    // this a histogram — a quantitative×quantitative shape the type pair below
260    // rejects. It routes ahead of EVERY orientation rule, in particular the
261    // y-count short-circuit (rule 4): a count histogram (`y.aggregate = count`)
262    // would otherwise render as a nonsense per-value bar chart. bin+timeUnit and
263    // bin+color were already rejected in preflight, so neither confounds the
264    // gate here; an errant `aggregate` on x is caught in `compile_histogram`.
265    if active_bin(&spec.encoding.x).is_some() {
266        // The type gate uses the CANONICAL precedence chain (spec `type` >
267        // declared > inference), NOT the native rung orientation uses — the
268        // design resolves the binned field's type the standard way. A temporal
269        // x has `timeUnit`, not `bin`; a nominal/ordinal x names the resolved
270        // type, the deciding rung, and the dirty-numeric escape.
271        let xt = resolved_type(&spec.encoding.x, table);
272        match xt {
273            FieldType::Temporal => return Err(bin_temporal_error()),
274            FieldType::Nominal | FieldType::Ordinal => {
275                return Err(bin_not_quantitative_error(spec, table, xf, xt));
276            }
277            FieldType::Quantitative => {}
278        }
279        // A binned x groups many rows into each bar, so y MUST carry an
280        // aggregate: count is the histogram, mean/sum/… ride the same path.
281        if spec.encoding.y.aggregate.is_none() {
282            return Err(bin_y_no_aggregate_error());
283        }
284        return Ok(BarRoute::Histogram);
285    }
286
287    // 1. Count-on-both.
288    if x_count && y_count {
289        return Err(Error::Spec(
290            "aggregate belongs on exactly one channel".into(),
291        ));
292    }
293    // 2. x-count → horizontal, ahead of the temporal gate: count makes x the
294    // VALUE axis and never reads the field's values, so a temporal x is no
295    // obstacle — but a `timeUnit` on it has no time axis to bucket.
296    if x_count {
297        if spec.encoding.x.time_unit.is_some() {
298            return Err(timeunit_xcount_error());
299        }
300        return Ok(BarRoute::Horizontal);
301    }
302
303    // Resolve both channel types through the precedence chain. The inference
304    // rung is NATIVE-typed: a JSON string is categorical-SHAPED even when its
305    // contents are numeric (e.g. dice faces "1".."6"), so a string x stays a
306    // category and the bar stays vertical. Numeric strings that genuinely belong
307    // on the value axis are recovered by the coercion rescue below.
308    let resolve = |ch: &Channel, f: &str| -> FieldType {
309        ch.ty
310            .or_else(|| table.declared.get(f).copied())
311            .unwrap_or_else(|| native_type(rows, f))
312    };
313    let xt = resolve(&spec.encoding.x, xf);
314
315    // 3. The temporal/timeUnit gate, before the y-count rule (a temporal x is
316    // the vertical bucket/category axis in every remaining shape).
317    if spec.encoding.x.time_unit.is_some() {
318        // `timeUnit` is explicit temporal intent, so the gate uses CANONICAL
319        // resolution — spec `type` > declared > `data::infer_type`, whose
320        // inference rung PROMOTES an undeclared all-ISO-string column — not
321        // the native rung above. Raw JSON logs bucket without a declaration.
322        // A non-temporal x names the type it resolved to AND which precedence
323        // rung decided — a teaching error.
324        let xt = resolved_type(&spec.encoding.x, table);
325        if xt != FieldType::Temporal {
326            return Err(timeunit_not_temporal_error(spec, table, xf, xt));
327        }
328        // Temporal + timeUnit: the vertical bucket path (compile_bar buckets on
329        // the timeUnit). A misplaced non-count aggregate on x is caught there.
330        return Ok(BarRoute::Vertical);
331    }
332    // Native inference never yields Temporal, so `xt == Temporal` here means a
333    // declared or explicit temporal x — undeclared date strings stay on the
334    // categorical routes below, exactly as before timeUnit existed.
335    if xt == FieldType::Temporal {
336        // A temporal x with no buckets to draw; name the fix (`timeUnit`).
337        return Err(bar_temporal_error());
338    }
339
340    // 4. y-count → vertical.
341    if y_count {
342        return Ok(BarRoute::Vertical);
343    }
344
345    // 5. Route by the type pair (x already resolved above).
346    let x_quant = xt == FieldType::Quantitative;
347    let y_quant = resolve(&spec.encoding.y, yf) == FieldType::Quantitative;
348    match (x_quant, y_quant) {
349        (false, true) => Ok(BarRoute::Vertical),
350        (true, false) => Ok(BarRoute::Horizontal),
351        (true, true) => Err(bar_channel_error(xf, yf, "quantitative")),
352        (false, false) => {
353            // 6. Coercion rescue — ONLY channels without an explicit spec type
354            // (an explicit `"type"` is stated intent, never overridden). Bias
355            // to vertical (compat) when y coerces numeric, else horizontal.
356            if spec.encoding.y.ty.is_none() && data::infer_type(rows, yf) == FieldType::Quantitative
357            {
358                Ok(BarRoute::Vertical)
359            } else if spec.encoding.x.ty.is_none()
360                && data::infer_type(rows, xf) == FieldType::Quantitative
361            {
362                Ok(BarRoute::Horizontal)
363            } else {
364                Err(bar_channel_error(xf, yf, "categorical"))
365            }
366        }
367    }
368}
369
370/// Quantitative iff the field has a value and every present, non-null value is
371/// a NATIVE JSON number. Unlike `data::infer_type`, numeric STRINGS do not
372/// count: for orientation, a string-shaped field is a category (its values may
373/// still be coerced onto the value axis by the rescue or by `data::num`).
374fn native_type(rows: &[Row], field: &str) -> FieldType {
375    let mut saw_value = false;
376    for row in rows {
377        if let Some(v) = row.get(field) {
378            if v.is_null() {
379                continue;
380            }
381            saw_value = true;
382            if !v.is_number() {
383                return FieldType::Nominal;
384            }
385        }
386    }
387    if saw_value {
388        FieldType::Quantitative
389    } else {
390        FieldType::Nominal
391    }
392}
393
394/// The bar orientation-resolution error, for both-quantitative (`both` =
395/// "quantitative") and failed rescue (`both` = "categorical").
396fn bar_channel_error(xf: &str, yf: &str, both: &str) -> Error {
397    Error::Spec(format!(
398        "bar needs one categorical and one quantitative channel; both x (\"{xf}\") and y \
399         (\"{yf}\") resolved {both}; put categories on one axis or set an explicit \"type\""
400    ))
401}
402
403/// Aggregate placed on the CATEGORICAL channel. `value_axis` is the channel that
404/// SHOULD carry the aggregate ("y" for vertical, "x" for horizontal).
405fn bar_aggregate_error(value_axis: &str) -> Error {
406    Error::Spec(format!(
407        "aggregation runs over the quantitative channel, grouped by the categorical one; \
408         put `aggregate` on encoding.{value_axis}"
409    ))
410}
411
412pub(crate) fn aggregate(values: &[f64], agg: Aggregate) -> f64 {
413    match agg {
414        Aggregate::Sum => values.iter().sum(),
415        Aggregate::Mean => values.iter().sum::<f64>() / values.len() as f64,
416        Aggregate::Median => {
417            let mut v = values.to_vec();
418            v.sort_by(f64::total_cmp);
419            let mid = v.len() / 2;
420            if v.len().is_multiple_of(2) {
421                (v[mid - 1] + v[mid]) / 2.0
422            } else {
423                v[mid]
424            }
425        }
426        Aggregate::Min => values.iter().cloned().fold(f64::INFINITY, f64::min),
427        Aggregate::Max => values.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
428        Aggregate::Count => values.len() as f64,
429    }
430}
431
432pub(crate) fn truncate(s: &str, max: usize) -> String {
433    if s.chars().count() <= max {
434        s.to_string()
435    } else {
436        s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
437    }
438}
439
440/// The type-precedence contract, in ONE place: an explicit spec `type` beats a
441/// declared column type beats inference from the data. (Bar ORIENTATION
442/// resolution deliberately uses a native-typed inference rung instead — see
443/// `bar_route`.)
444fn resolved_type(ch: &Channel, table: &Table) -> FieldType {
445    ch.ty
446        .or_else(|| table.declared.get(&ch.field).copied())
447        .unwrap_or_else(|| data::infer_type(&table.rows, &ch.field))
448}
449
450/// Width of the y-axis label gutter: the widest formatted tick.
451fn tick_gutter(scale: &Linear) -> usize {
452    scale
453        .ticks()
454        .iter()
455        .map(|t| fmt_tick(*t, scale.step).chars().count())
456        .max()
457        .unwrap_or(1)
458}
459
460/// Centered title over the plot, plus the rows it occupies — the title and a
461/// blank row of breathing room beneath it, or zero without one.
462fn place_title(spec: &Spec, gutter: usize, plot_w: usize) -> (Option<Placed>, usize) {
463    let title = spec.title.as_deref().map(|t| Placed {
464        text: t.to_string(),
465        col: gutter + 1 + plot_w.saturating_sub(t.chars().count()) / 2,
466        row: 0,
467    });
468    let rows = if title.is_some() { 2 } else { 0 };
469    (title, rows)
470}
471
472/// Right-aligned category names for the horizontal-bar gutter, each truncated
473/// to 24 cells (with a visible '…'); the gutter is the widest surviving name.
474fn name_gutter(cats: &[String]) -> (Vec<String>, usize) {
475    let names: Vec<String> = cats.iter().map(|c| truncate(c, 24)).collect();
476    let gutter = names.iter().map(|s| s.chars().count()).max().unwrap_or(1);
477    (names, gutter)
478}
479
480// --- Error constructors. These strings are CONTRACT: agents pattern-match
481// them to self-correct, and corpus snapshots pin them — each exists once.
482
483/// Negative bar values are rejected, in every orientation.
484fn negative_bar_error() -> Error {
485    Error::Data("negative values are not yet supported for mark \"bar\"; use mark \"line\"".into())
486}
487
488/// More series than palette colors: color is the sole channel identifying a
489/// series, so cycling the palette would make two series indistinguishable.
490fn palette_cap_error(n_series: usize, palette_len: usize, color_field: &str) -> Error {
491    Error::Data(format!(
492        "{n_series} series exceed the {palette_len} distinguishable series colors; \
493         aggregate or filter \"{color_field}\""
494    ))
495}
496
497/// A bar scan that yielded no usable rows.
498fn no_rows_error(value_field: &str, cat_field: &str) -> Error {
499    Error::Data(format!(
500        "no usable rows: field \"{value_field}\" has no numeric values \
501         (or \"{cat_field}\" is always missing)"
502    ))
503}
504
505/// Horizontal-bar content taller than the height ceiling.
506fn height_ceiling_error(n_bars: usize, content: usize) -> Error {
507    Error::Data(format!(
508        "{n_bars} bars need height {content}; filter or aggregate, or raise --height"
509    ))
510}
511
512/// A `bar` with a temporal x, which has no discrete buckets to draw. The exact
513/// string is pinned by the design: `timeUnit` (task 4) is the fix it names.
514fn bar_temporal_error() -> Error {
515    Error::Spec(
516        "bars need discrete time buckets; add `\"timeUnit\": \"day\"` (or week/month/…) \
517         — or use `line`/`point` for continuous time"
518            .into(),
519    )
520}
521
522/// Temporal on the y channel: time belongs on x, so name the resolved type and
523/// the two fixes instead of falling through to the generic categorical-y error.
524fn temporal_y_error(mark: Mark, field: &str) -> Error {
525    Error::Data(format!(
526        "mark {mark:?} resolved y field \"{field}\" as temporal, but y must be quantitative; \
527         put \"{field}\" on encoding.x (time belongs on x), or aggregate it (e.g. count) for a \
528         quantitative y"
529    ))
530}
531
532/// Temporal on the color channel: rejected before it explodes into one series
533/// per timestamp. Suggests a categorical grouping field.
534fn temporal_color_error(field: &str) -> Error {
535    Error::Data(format!(
536        "encoding.color field \"{field}\" resolved as temporal; color would split into one \
537         series per timestamp — group by a categorical field instead (or bucket time with a \
538         phase-2 `timeUnit`)"
539    ))
540}
541
542/// An unparseable value in a resolved-temporal x column. Names the row, the
543/// offending string, and the four accepted shapes — a promoted column parses
544/// by construction, so this fires only for declared/explicit temporal types.
545fn temporal_parse_error(row: usize, value: &str, field: &str) -> Error {
546    Error::Data(format!(
547        "row {row}: could not parse \"{value}\" as temporal in column \"{field}\"; accepted: \
548         \"2026-07-05\", \"2026-07-05T14:30:00\" (or a space instead of T, optional .fff), either \
549         with a trailing \"Z\" or \"±hh:mm\" offset, or a bare \"14:30:00\""
550    ))
551}
552
553/// The lowercase spelling of a resolved field type, for error prose.
554fn type_name(t: FieldType) -> &'static str {
555    match t {
556        FieldType::Quantitative => "quantitative",
557        FieldType::Nominal => "nominal",
558        FieldType::Ordinal => "ordinal",
559        FieldType::Temporal => "temporal",
560    }
561}
562
563/// Which precedence rung decided the x channel's resolved type — named by the
564/// teaching errors for a wrongly-typed binned or `timeUnit` x (spec `type` >
565/// declared column type > inference, the `resolved_type` chain).
566fn deciding_rung(spec: &Spec, table: &Table, xf: &str) -> &'static str {
567    if spec.encoding.x.ty.is_some() {
568        "the explicit spec `type`"
569    } else if table.declared.contains_key(xf) {
570        "the declared column type"
571    } else {
572        "inference from the data"
573    }
574}
575
576/// `timeUnit` on an x that did not resolve temporal: names the resolved type
577/// AND which precedence rung decided it (the design's teaching requirement),
578/// then the two ways to make x temporal.
579fn timeunit_not_temporal_error(spec: &Spec, table: &Table, xf: &str, xt: FieldType) -> Error {
580    Error::Spec(format!(
581        "`timeUnit` buckets a temporal x, but \"{xf}\" resolved to {} via {}; declare the \
582         column DATE/DATETIME/TIMESTAMP/TIME, or set encoding.x.type to \"temporal\"",
583        type_name(xt),
584        deciding_rung(spec, table, xf)
585    ))
586}
587
588/// The lowercase spelling of a `timeUnit`, for error prose.
589fn unit_name(u: TimeUnit) -> &'static str {
590    match u {
591        TimeUnit::Year => "year",
592        TimeUnit::Quarter => "quarter",
593        TimeUnit::Month => "month",
594        TimeUnit::Week => "week",
595        TimeUnit::Day => "day",
596        TimeUnit::Hour => "hour",
597        TimeUnit::Minute => "minute",
598    }
599}
600
601/// A densified `timeUnit` walk wider than the plot: a fine unit over a long
602/// span (a month of minutes is 43,200 buckets) would draw sub-column garbage —
603/// the vertical twin of the horizontal height ceiling. Counted before
604/// materializing; N == width stays legal (one-column bars). The coarsening
605/// suggestion names the next-coarser unit(s) from the actual one; year has
606/// none, so only the width fix remains.
607fn timeunit_overflow_error(n: usize, plot_w: usize, unit: TimeUnit) -> Error {
608    let coarser = match unit {
609        TimeUnit::Minute => Some("hour or day"),
610        TimeUnit::Hour => Some("day or week"),
611        TimeUnit::Day => Some("week or month"),
612        TimeUnit::Week => Some("month or quarter"),
613        TimeUnit::Month => Some("quarter or year"),
614        TimeUnit::Quarter => Some("year"),
615        TimeUnit::Year => None,
616    };
617    let fix = match coarser {
618        Some(c) => format!("use a coarser timeUnit ({c}) or a wider size"),
619        None => "use a wider size".to_string(),
620    };
621    Error::Data(format!(
622        "timeUnit \"{}\" spans {n} buckets but the plot is {plot_w} columns; {fix}",
623        unit_name(unit)
624    ))
625}
626
627/// `timeUnit` on an x that carries `aggregate: "count"`: count makes x the
628/// quantitative VALUE axis (a horizontal count-per-category bar), so there is
629/// no time axis to bucket — name both ways out.
630fn timeunit_xcount_error() -> Error {
631    Error::Spec(
632        "`timeUnit` buckets the time (category) axis, but `aggregate: \"count\"` makes \
633         encoding.x the count value axis; drop `timeUnit`, or move `count` to encoding.y \
634         and put the time field on encoding.x with `timeUnit`"
635            .into(),
636    )
637}
638
639/// `timeUnit` placed on y or color: it is x-only (it buckets the category axis).
640fn timeunit_channel_error(channel: &str) -> Error {
641    Error::Spec(format!(
642        "`timeUnit` is only supported on encoding.x (it buckets time on the category axis); \
643         remove it from encoding.{channel}"
644    ))
645}
646
647/// `timeUnit` on a line/point/area mark: those already plot continuous time, so
648/// bucketing is redundant — name the two fixes.
649fn timeunit_mark_error(mark: Mark) -> Error {
650    Error::Spec(format!(
651        "`timeUnit` buckets bars into discrete periods; mark {mark:?} already plots continuous \
652         time — drop `timeUnit`, or switch to `bar`"
653    ))
654}
655
656/// `timeUnit` combined with a grouping color: densify is scoped to plain bars
657/// (design §compile), so a grouped temporal bar is rejected.
658fn timeunit_grouped_error(color_field: &str) -> Error {
659    Error::Spec(format!(
660        "`timeUnit` bars do not support a grouping `color` (\"{color_field}\") yet; \
661         drop `color`, or drop `timeUnit`"
662    ))
663}
664
665// --- `bin` errors (spec-shape). `bin` is bar-only and x-only this cycle; the
666// type-dependent bin errors (non-quantitative x, y without an aggregate, a
667// resolved bin count over the plot) live in the compile path.
668
669/// `bin` on the y channel: binning is an x transform, so name the axis swap.
670fn bin_on_y_error() -> Error {
671    Error::Spec(
672        "`bin` is only supported on encoding.x; to bin these values, move the field to \
673         encoding.x and swap the axes"
674            .into(),
675    )
676}
677
678/// `bin` on the color channel: binning is an x transform, not a grouping.
679fn bin_on_color_error() -> Error {
680    Error::Spec("`bin` is only supported on encoding.x; remove it from encoding.color".into())
681}
682
683/// A binned x plus a series-splitting `color`: overlaid histograms cannot
684/// overlap legibly in braille, so they are out this cycle — name both fixes.
685fn bin_color_series_error(color_field: &str) -> Error {
686    Error::Spec(format!(
687        "a binned x cannot be split into series by `color` (\"{color_field}\"); overlaid \
688         histograms are out this cycle — drop `color`, or pre-filter to a single series"
689    ))
690}
691
692/// `bin` on a line/point/area mark: histogram bins are bar-only this cycle.
693fn bin_mark_error(mark: Mark) -> Error {
694    Error::Spec(format!(
695        "`bin` draws histogram bars and is only supported on mark \"bar\"; mark {mark:?} cannot \
696         bin — switch to `bar`, or drop `bin`"
697    ))
698}
699
700/// `bin` and `timeUnit` on the same x channel: two transforms, one channel.
701fn bin_timeunit_error() -> Error {
702    Error::Spec(
703        "`bin` and `timeUnit` cannot both apply to encoding.x — one transform per channel; \
704         `bin` groups quantitative values and `timeUnit` buckets time, so pick one"
705            .into(),
706    )
707}
708
709/// `bin` with both `maxbins` and `step`: the two knobs fight over bin width.
710fn bin_maxbins_and_step_error() -> Error {
711    Error::Spec(
712        "`bin` cannot set both `maxbins` and `step` — they fight over the bin width; pick one"
713            .into(),
714    )
715}
716
717/// `bin` `maxbins` that is not a positive integer.
718fn bin_maxbins_error(maxbins: f64) -> Error {
719    Error::Spec(format!(
720        "`bin` `maxbins` must be a positive integer; got {maxbins}"
721    ))
722}
723
724/// `bin` `step` that is not a finite positive number.
725fn bin_step_error(step: f64) -> Error {
726    Error::Spec(format!(
727        "`bin` `step` must be a finite positive number; got {step}"
728    ))
729}
730
731// --- Type-dependent `bin` errors (need resolved types / the selected layout,
732// so they live in the compile path, not the spec-shape `validate_bin`).
733
734/// `bin` on a temporal x: binning groups quantitative values; time is bucketed
735/// with `timeUnit`, not `bin`. Points at the fix (design error #2).
736fn bin_temporal_error() -> Error {
737    Error::Spec(
738        "`bin` groups quantitative values, but encoding.x resolved temporal; bucket time with \
739         `timeUnit` (\"day\"/\"month\"/…) instead of `bin`"
740            .into(),
741    )
742}
743
744/// `bin` on an x that resolved nominal/ordinal: names the resolved type, the
745/// deciding precedence rung, and — for a numeric column dirtied into Nominal by
746/// the all-or-nothing inference rule — the `"type": "quantitative"` escape that
747/// bins it (the dirty rows then drop). Design error #3.
748fn bin_not_quantitative_error(spec: &Spec, table: &Table, xf: &str, xt: FieldType) -> Error {
749    Error::Spec(format!(
750        "`bin` needs a quantitative x, but \"{xf}\" resolved to {} via {}; if \"{xf}\" is numeric \
751         with some non-numeric values, set encoding.x.type to \"quantitative\" to bin it (the \
752         dirty rows drop)",
753        type_name(xt),
754        deciding_rung(spec, table, xf)
755    ))
756}
757
758/// A binned x with a raw (un-aggregated) y: each bar spans many rows, so y is
759/// undefined without an aggregate. Names the count shortcut and the general
760/// fix. Design error #4.
761fn bin_y_no_aggregate_error() -> Error {
762    Error::Spec(
763        "binned x groups many rows per bar; add an aggregate to encoding.y \
764         (`\"aggregate\": \"count\"` for a histogram, or mean/sum/… over the binned values)"
765            .into(),
766    )
767}
768
769/// A resolved bin count wider than the plot: whichever knob drove it (`step`
770/// too fine, `maxbins` too high) is named, with the count, the plot width, and
771/// both fixes. Checked AFTER selection, so it also backstops zero-cell-wide
772/// bins (a bin narrower than a column). Design error #10.
773fn bin_overflow_error(n: usize, plot_w: usize, bin: BinValue) -> Error {
774    let fix = match bin {
775        BinValue::Config(BinConfig { step: Some(s), .. }) => {
776            format!(
777                "`step` {s} is too fine — use a larger `step`, or widen the plot with `--width`"
778            )
779        }
780        BinValue::Config(BinConfig {
781            maxbins: Some(m), ..
782        }) => {
783            format!("`maxbins` {m} is too high — lower it, or widen the plot with `--width`")
784        }
785        _ => "use a coarser `step`/`maxbins`, or widen the plot with `--width`".to_string(),
786    };
787    Error::Spec(format!(
788        "`bin` resolved {n} bins but the plot is only {plot_w} columns; {fix}"
789    ))
790}
791
792/// One pass over the rows for ANY bar variant: categories (first-seen) down
793/// one dimension, series (first-seen; one unnamed series when `series_field`
794/// is None) down the other, each cell the raw values awaiting aggregation.
795/// A `count` aggregate yields 1.0 per row without reading the value field.
796struct BarScan {
797    cats: Vec<String>,
798    series: Vec<String>,
799    /// `[category][series]` → raw values; an empty Vec is a missing cell.
800    cells: Vec<Vec<Vec<f64>>>,
801    dropped: usize,
802}
803
804fn scan_bars(
805    rows: &[Row],
806    cat_field: &str,
807    value_field: &str,
808    series_field: Option<&str>,
809    agg: Aggregate,
810) -> BarScan {
811    let mut cats: Vec<String> = Vec::new();
812    let mut series: Vec<String> = match series_field {
813        Some(_) => Vec::new(),
814        None => vec![String::new()],
815    };
816    let mut raw: HashMap<(usize, usize), Vec<f64>> = HashMap::new();
817    let mut dropped = 0usize;
818    for row in rows {
819        let Some(cv) = row.get(cat_field) else {
820            dropped += 1;
821            continue;
822        };
823        let vn = if agg == Aggregate::Count {
824            Some(1.0)
825        } else {
826            row.get(value_field).and_then(data::num)
827        };
828        let Some(vn) = vn else {
829            dropped += 1;
830            continue;
831        };
832        let ci = index_of_or_push(&mut cats, data::text(cv));
833        let si = match series_field {
834            Some(sf) => {
835                let name = row.get(sf).map(data::text).unwrap_or_else(|| "null".into());
836                index_of_or_push(&mut series, name)
837            }
838            None => 0,
839        };
840        raw.entry((ci, si)).or_default().push(vn);
841    }
842    let mut cells = vec![vec![Vec::new(); series.len()]; cats.len()];
843    for ((ci, si), v) in raw {
844        cells[ci][si] = v;
845    }
846    BarScan {
847        cats,
848        series,
849        cells,
850        dropped,
851    }
852}
853
854/// First-seen interning: the index of `item`, pushing it if new.
855fn index_of_or_push(list: &mut Vec<String>, item: String) -> usize {
856    match list.iter().position(|s| *s == item) {
857        Some(i) => i,
858        None => {
859            list.push(item);
860            list.len() - 1
861        }
862    }
863}
864
865/// Lexical category sort for ordinal axes (ISO dates sort chronologically),
866/// carrying each category's cell row along. Series order stays first-seen.
867fn sort_cats(cats: Vec<String>, cells: Vec<Vec<Vec<f64>>>) -> (Vec<String>, Vec<Vec<Vec<f64>>>) {
868    let mut pairs: Vec<(String, Vec<Vec<f64>>)> = cats.into_iter().zip(cells).collect();
869    pairs.sort_by(|a, b| a.0.cmp(&b.0));
870    pairs.into_iter().unzip()
871}
872
873/// Aggregate every cell, rejecting negative results, tracking the maximum for
874/// the value scale. An empty cell is normally `None` — a visible gap at a
875/// stable position. Only the `densified` temporal path (which INSERTS empty
876/// buckets for calendar gaps) treats an empty cell under `count` as a
877/// well-defined zero; every other aggregate — and every non-densified caller —
878/// keeps it `None` (mean of nothing is undefined).
879#[allow(clippy::type_complexity)]
880fn aggregate_cells(
881    cells: &[Vec<Vec<f64>>],
882    agg: Aggregate,
883    densified: bool,
884) -> Result<(Vec<Vec<Option<f64>>>, f64), Error> {
885    let mut vmax = f64::NEG_INFINITY;
886    let mut out = Vec::with_capacity(cells.len());
887    for row in cells {
888        let mut out_row = Vec::with_capacity(row.len());
889        for values in row {
890            if values.is_empty() {
891                if densified && agg == Aggregate::Count {
892                    vmax = vmax.max(0.0);
893                    out_row.push(Some(0.0));
894                } else {
895                    out_row.push(None);
896                }
897                continue;
898            }
899            let v = aggregate(values, agg);
900            if v < 0.0 {
901                return Err(negative_bar_error());
902            }
903            vmax = vmax.max(v);
904            out_row.push(Some(v));
905        }
906        out.push(out_row);
907    }
908    Ok((out, vmax))
909}
910
911/// Resolve a spec into a Scene: every data- and layout-dependent decision made,
912/// geometry normalized, colors baked in. Bars and xy marks share `preflight`.
913pub fn compile(spec: &Spec, table: &Table, opts: &CompileOptions) -> Result<Scene, Error> {
914    preflight(spec, &table.rows)?;
915    let (plot_w, plot_h) = plot_dims(opts.width, opts.height, spec);
916    match spec.mark {
917        Mark::Bar => match bar_route(spec, table)? {
918            BarRoute::Vertical => compile_bar(spec, table, opts, plot_w, plot_h),
919            BarRoute::Histogram => compile_histogram(spec, table, opts, plot_w, plot_h),
920            // Horizontal bars are content-sized: their height is derived from the
921            // category count, not `plot_dims` (which collapses "no height" into
922            // the default 13 and so can't tell an explicit 13 from the default).
923            // Pass the RAW height Option straight through.
924            BarRoute::Horizontal => {
925                compile_bar_h(spec, table, opts, plot_w, opts.height.or(spec.height))
926            }
927        },
928        Mark::Line | Mark::Point | Mark::Area => compile_xy(spec, table, opts, plot_w, plot_h),
929    }
930}
931
932/// Map a scale value to its plot-relative x column: norm over `plot_w - 1`
933/// columns, half-up rounding, clamped to the last column. The ONE column
934/// arithmetic for every x tick — quantitative, temporal, and nominal axes all
935/// route through here, and `time::accept` pre-tests temporal rungs against
936/// this exact formula (see its doc); changing it in one place is the point.
937fn x_col(xscale: &Linear, v: f64, plot_w: usize) -> usize {
938    ((xscale.norm(v) * (plot_w - 1) as f64).round() as usize).min(plot_w - 1)
939}
940
941/// A quantitative x value axis: plot-relative tick columns plus greedily-placed
942/// tick labels. Extracted from `compile_xy`'s quantitative-x branch so the
943/// horizontal-bar value axis and the line/point/area value axis share ONE
944/// implementation. Behavior-neutral for xy: identical columns, anchors, and
945/// `place_x_labels` call as before.
946fn value_axis_x(
947    xscale: &Linear,
948    plot_w: usize,
949    gutter: usize,
950    columns: usize,
951    label_row: usize,
952) -> (Vec<usize>, Vec<Placed>) {
953    let tks = xscale.ticks();
954    let tick_cols: Vec<usize> = tks.iter().map(|t| x_col(xscale, *t, plot_w)).collect();
955    let anchors: Vec<(usize, String)> = tick_cols
956        .iter()
957        .zip(&tks)
958        .map(|(c, t)| (*c, fmt_tick(*t, xscale.step)))
959        .collect();
960    let labels = place_x_labels(&anchors, gutter, columns, label_row);
961    (tick_cols, labels)
962}
963
964/// The shared multi-series legend: "── name" entries flow below the x labels
965/// starting at `top + plot_h + 2`, wrapping before the right edge. Entries are
966/// never clipped; a name wider than the whole row is visibly truncated with
967/// '…'. Colors cycle the palette by entry index (`theme.series`). Returns the
968/// placed entries plus the number of rows they occupy. Used by both the xy
969/// (line/point/area) path and the grouped-bar path — ONE implementation.
970fn legend_below(
971    names: &[String],
972    theme: &Theme,
973    gutter: usize,
974    columns: usize,
975    top: usize,
976    plot_h: usize,
977) -> (Vec<LegendEntry>, usize) {
978    let legend_row0 = top + plot_h + 2;
979    let left = gutter + 1;
980    let max_name = columns.saturating_sub(left + 3);
981    let (mut col, mut row) = (left, legend_row0);
982    let mut legend: Vec<LegendEntry> = Vec::new();
983    for (i, name) in names.iter().enumerate() {
984        let name = truncate(name, max_name);
985        let w = 3 + name.chars().count(); // "── " + name
986        if col > left && col + w > columns {
987            col = left;
988            row += 1;
989        }
990        legend.push(LegendEntry {
991            name,
992            color: theme.series(i),
993            col,
994            row,
995        });
996        col += w + 3;
997    }
998    let legend_rows = legend.last().map_or(0, |e| e.row + 1 - legend_row0);
999    (legend, legend_rows)
1000}
1001
1002/// Greedy left-to-right x-label placement: each label centered on its anchor
1003/// column, clamped inside the buffer, skipped if it would collide with the one
1004/// before it. Mirrors the old `draw_x_axis`; survivors carry buffer-absolute
1005/// start columns.
1006///
1007/// `time::accept` is the temporal-axis mirror of this rule, run at gutter 0 and
1008/// width `plot_w` to pre-test a candidate tick rung: it is deliberately STRICTER
1009/// (a whole-rung reject, its right clamp one column tighter), so any rung it
1010/// accepts survives here at any y-gutter — the shift by `gutter` only loosens
1011/// spacing, never tightens it. Diverge one from the other and temporal ticks
1012/// silently drop their labels; keep the arithmetic in lockstep.
1013fn place_x_labels(
1014    anchors: &[(usize, String)],
1015    gutter: usize,
1016    width: usize,
1017    row: usize,
1018) -> Vec<Placed> {
1019    let mut out = Vec::new();
1020    let mut next_free = 0usize;
1021    for (col, label) in anchors {
1022        let len = label.chars().count();
1023        if len == 0 || len > width {
1024            continue;
1025        }
1026        let start = (gutter + 1 + col).saturating_sub(len / 2).min(width - len);
1027        if start < next_free {
1028            continue;
1029        }
1030        out.push(Placed {
1031            text: label.clone(),
1032            col: start,
1033            row,
1034        });
1035        next_free = start + len + 1;
1036    }
1037    out
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042    use super::*;
1043    use crate::ingest;
1044    use crate::theme;
1045
1046    fn compile_spec(json: &str) -> Scene {
1047        let spec: Spec = serde_json::from_str(json).expect("spec parses");
1048        let table = ingest::resolve(&spec, None).expect("resolves");
1049        let opts = CompileOptions {
1050            width: None,
1051            height: None,
1052            theme: theme::by_name("benday").unwrap(),
1053        };
1054        compile(&spec, &table, &opts).expect("compiles")
1055    }
1056
1057    /// `bin: false` is Vega-Lite noise meaning "no binning"; it must compile
1058    /// byte-for-byte identically to omitting `bin` entirely — the same plain
1059    /// bar spec with and without the flag yields the same Scene JSON. Pins the
1060    /// `active_bin` Flag(false)→None collapse end-to-end.
1061    #[test]
1062    fn bin_false_is_identical_to_absent() {
1063        let absent = compile_spec(
1064            r#"{"data":{"values":[{"cat":"a","v":3},{"cat":"b","v":5},{"cat":"c","v":2}]},
1065                "mark":"bar","encoding":{"x":{"field":"cat"},"y":{"field":"v"}}}"#,
1066        );
1067        let bin_false = compile_spec(
1068            r#"{"data":{"values":[{"cat":"a","v":3},{"cat":"b","v":5},{"cat":"c","v":2}]},
1069                "mark":"bar","encoding":{"x":{"field":"cat","bin":false},"y":{"field":"v"}}}"#,
1070        );
1071        assert_eq!(
1072            absent.to_json(),
1073            bin_false.to_json(),
1074            "`bin: false` must compile identically to an absent bin"
1075        );
1076    }
1077
1078    /// The corpus harness snapshots `to_json()` and never calls `meta()`, so
1079    /// pin the HISTOGRAM meta shape here: the x block carries a `bin` object
1080    /// with step / domain / bins, y reports its aggregate, and a histogram
1081    /// OMITS the `direction` key (vertical is the unmarked case). Bridges to
1082    /// the gallery meta snapshots landing in task 5.
1083    #[test]
1084    fn histogram_meta_reports_bin_layout() {
1085        let scene = compile_spec(
1086            r#"{"data":{"values":[{"v":1},{"v":2},{"v":3},{"v":42},{"v":55},{"v":97}]},
1087                "mark":"bar","encoding":{"x":{"field":"v","bin":true},
1088                                         "y":{"field":"v","aggregate":"count"}}}"#,
1089        );
1090        let meta = scene.meta();
1091        assert_eq!(meta["mark"], "bar");
1092        let x = &meta["x"];
1093        assert_eq!(x["field"], "v");
1094        assert_eq!(x["type"], "quantitative");
1095        let bin = &x["bin"];
1096        assert!(bin["step"].is_number(), "bin.step is a number: {bin}");
1097        assert!(bin["bins"].is_number(), "bin.bins is a number: {bin}");
1098        assert!(
1099            bin["domain"].as_array().is_some_and(|d| d.len() == 2),
1100            "bin.domain is a [lo, hi] pair: {bin}"
1101        );
1102        assert_eq!(meta["y"]["aggregate"], "count");
1103        assert!(
1104            meta.get("direction").is_none(),
1105            "a histogram omits the direction key: {meta}"
1106        );
1107    }
1108}