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, TimeUnit};
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    // `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    Ok(())
147}
148
149/// Which way a bar chart runs. Resolved once, up front, from the count rule and
150/// the channel-type precedence chain.
151enum BarRoute {
152    Vertical,
153    Horizontal,
154}
155
156/// Resolve bar orientation. RESOLUTION ORDER IS A HARDENED CONTRACT:
157///   1. Count-on-both is ill-formed regardless of anything else → error.
158///   2. x-count makes x THE quantitative value channel (count is intrinsically
159///      numeric and ignores the field's values, so even a temporal field counts
160///      fine) → horizontal, BEFORE the temporal gate. `timeUnit` here has no
161///      time axis to bucket: teaching error, never silently ignored.
162///   3. The temporal/timeUnit gate, BEFORE the y-count rule (a y-count would
163///      otherwise short-circuit a temporal x into a raw-timestamp category
164///      axis). `timeUnit` present is EXPLICIT temporal intent, so it gates on
165///      the CANONICAL chain (`resolved_type`, whose inference rung PROMOTES an
166///      all-ISO-string column) — undeclared raw-log timestamps bucket without
167///      a declaration, the design's no-SQL workload. Non-temporal under that
168///      chain → teaching error naming the type and the deciding rung. WITHOUT
169///      a `timeUnit`, the native rung governs as always: declared/explicit
170///      temporal → teaching error; undeclared date strings stay categorical
171///      (the recorded stdin contract — see the closure below).
172///   4. y-count → vertical (count's field may be absent from rows entirely,
173///      inferring Nominal — which must not misroute through the type pair).
174///   5. Otherwise route both channel types (resolved through precedence: spec
175///      `type` > declared column type > inference) by the type pair.
176///   6. Both-categorical: a coercion rescue (stdin-cycle contract) reconsiders
177///      channels WITHOUT an explicit spec `type`, biasing to vertical.
178fn bar_route(spec: &Spec, table: &Table) -> Result<BarRoute, Error> {
179    let rows = &table.rows;
180    let xf = &spec.encoding.x.field;
181    let yf = &spec.encoding.y.field;
182    let x_count = matches!(spec.encoding.x.aggregate, Some(Aggregate::Count));
183    let y_count = matches!(spec.encoding.y.aggregate, Some(Aggregate::Count));
184
185    // 1. Count-on-both.
186    if x_count && y_count {
187        return Err(Error::Spec(
188            "aggregate belongs on exactly one channel".into(),
189        ));
190    }
191    // 2. x-count → horizontal, ahead of the temporal gate: count makes x the
192    // VALUE axis and never reads the field's values, so a temporal x is no
193    // obstacle — but a `timeUnit` on it has no time axis to bucket.
194    if x_count {
195        if spec.encoding.x.time_unit.is_some() {
196            return Err(timeunit_xcount_error());
197        }
198        return Ok(BarRoute::Horizontal);
199    }
200
201    // Resolve both channel types through the precedence chain. The inference
202    // rung is NATIVE-typed: a JSON string is categorical-SHAPED even when its
203    // contents are numeric (e.g. dice faces "1".."6"), so a string x stays a
204    // category and the bar stays vertical. Numeric strings that genuinely belong
205    // on the value axis are recovered by the coercion rescue below.
206    let resolve = |ch: &Channel, f: &str| -> FieldType {
207        ch.ty
208            .or_else(|| table.declared.get(f).copied())
209            .unwrap_or_else(|| native_type(rows, f))
210    };
211    let xt = resolve(&spec.encoding.x, xf);
212
213    // 3. The temporal/timeUnit gate, before the y-count rule (a temporal x is
214    // the vertical bucket/category axis in every remaining shape).
215    if spec.encoding.x.time_unit.is_some() {
216        // `timeUnit` is explicit temporal intent, so the gate uses CANONICAL
217        // resolution — spec `type` > declared > `data::infer_type`, whose
218        // inference rung PROMOTES an undeclared all-ISO-string column — not
219        // the native rung above. Raw JSON logs bucket without a declaration.
220        // A non-temporal x names the type it resolved to AND which precedence
221        // rung decided — a teaching error.
222        let xt = resolved_type(&spec.encoding.x, table);
223        if xt != FieldType::Temporal {
224            return Err(timeunit_not_temporal_error(spec, table, xf, xt));
225        }
226        // Temporal + timeUnit: the vertical bucket path (compile_bar buckets on
227        // the timeUnit). A misplaced non-count aggregate on x is caught there.
228        return Ok(BarRoute::Vertical);
229    }
230    // Native inference never yields Temporal, so `xt == Temporal` here means a
231    // declared or explicit temporal x — undeclared date strings stay on the
232    // categorical routes below, exactly as before timeUnit existed.
233    if xt == FieldType::Temporal {
234        // A temporal x with no buckets to draw; name the fix (`timeUnit`).
235        return Err(bar_temporal_error());
236    }
237
238    // 4. y-count → vertical.
239    if y_count {
240        return Ok(BarRoute::Vertical);
241    }
242
243    // 5. Route by the type pair (x already resolved above).
244    let x_quant = xt == FieldType::Quantitative;
245    let y_quant = resolve(&spec.encoding.y, yf) == FieldType::Quantitative;
246    match (x_quant, y_quant) {
247        (false, true) => Ok(BarRoute::Vertical),
248        (true, false) => Ok(BarRoute::Horizontal),
249        (true, true) => Err(bar_channel_error(xf, yf, "quantitative")),
250        (false, false) => {
251            // 6. Coercion rescue — ONLY channels without an explicit spec type
252            // (an explicit `"type"` is stated intent, never overridden). Bias
253            // to vertical (compat) when y coerces numeric, else horizontal.
254            if spec.encoding.y.ty.is_none() && data::infer_type(rows, yf) == FieldType::Quantitative
255            {
256                Ok(BarRoute::Vertical)
257            } else if spec.encoding.x.ty.is_none()
258                && data::infer_type(rows, xf) == FieldType::Quantitative
259            {
260                Ok(BarRoute::Horizontal)
261            } else {
262                Err(bar_channel_error(xf, yf, "categorical"))
263            }
264        }
265    }
266}
267
268/// Quantitative iff the field has a value and every present, non-null value is
269/// a NATIVE JSON number. Unlike `data::infer_type`, numeric STRINGS do not
270/// count: for orientation, a string-shaped field is a category (its values may
271/// still be coerced onto the value axis by the rescue or by `data::num`).
272fn native_type(rows: &[Row], field: &str) -> FieldType {
273    let mut saw_value = false;
274    for row in rows {
275        if let Some(v) = row.get(field) {
276            if v.is_null() {
277                continue;
278            }
279            saw_value = true;
280            if !v.is_number() {
281                return FieldType::Nominal;
282            }
283        }
284    }
285    if saw_value {
286        FieldType::Quantitative
287    } else {
288        FieldType::Nominal
289    }
290}
291
292/// The bar orientation-resolution error, for both-quantitative (`both` =
293/// "quantitative") and failed rescue (`both` = "categorical").
294fn bar_channel_error(xf: &str, yf: &str, both: &str) -> Error {
295    Error::Spec(format!(
296        "bar needs one categorical and one quantitative channel; both x (\"{xf}\") and y \
297         (\"{yf}\") resolved {both}; put categories on one axis or set an explicit \"type\""
298    ))
299}
300
301/// Aggregate placed on the CATEGORICAL channel. `value_axis` is the channel that
302/// SHOULD carry the aggregate ("y" for vertical, "x" for horizontal).
303fn bar_aggregate_error(value_axis: &str) -> Error {
304    Error::Spec(format!(
305        "aggregation runs over the quantitative channel, grouped by the categorical one; \
306         put `aggregate` on encoding.{value_axis}"
307    ))
308}
309
310pub(crate) fn aggregate(values: &[f64], agg: Aggregate) -> f64 {
311    match agg {
312        Aggregate::Sum => values.iter().sum(),
313        Aggregate::Mean => values.iter().sum::<f64>() / values.len() as f64,
314        Aggregate::Median => {
315            let mut v = values.to_vec();
316            v.sort_by(f64::total_cmp);
317            let mid = v.len() / 2;
318            if v.len().is_multiple_of(2) {
319                (v[mid - 1] + v[mid]) / 2.0
320            } else {
321                v[mid]
322            }
323        }
324        Aggregate::Min => values.iter().cloned().fold(f64::INFINITY, f64::min),
325        Aggregate::Max => values.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
326        Aggregate::Count => values.len() as f64,
327    }
328}
329
330pub(crate) fn truncate(s: &str, max: usize) -> String {
331    if s.chars().count() <= max {
332        s.to_string()
333    } else {
334        s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
335    }
336}
337
338/// The type-precedence contract, in ONE place: an explicit spec `type` beats a
339/// declared column type beats inference from the data. (Bar ORIENTATION
340/// resolution deliberately uses a native-typed inference rung instead — see
341/// `bar_route`.)
342fn resolved_type(ch: &Channel, table: &Table) -> FieldType {
343    ch.ty
344        .or_else(|| table.declared.get(&ch.field).copied())
345        .unwrap_or_else(|| data::infer_type(&table.rows, &ch.field))
346}
347
348/// Width of the y-axis label gutter: the widest formatted tick.
349fn tick_gutter(scale: &Linear) -> usize {
350    scale
351        .ticks()
352        .iter()
353        .map(|t| fmt_tick(*t, scale.step).chars().count())
354        .max()
355        .unwrap_or(1)
356}
357
358/// Centered title over the plot, plus the rows it occupies — the title and a
359/// blank row of breathing room beneath it, or zero without one.
360fn place_title(spec: &Spec, gutter: usize, plot_w: usize) -> (Option<Placed>, usize) {
361    let title = spec.title.as_deref().map(|t| Placed {
362        text: t.to_string(),
363        col: gutter + 1 + plot_w.saturating_sub(t.chars().count()) / 2,
364        row: 0,
365    });
366    let rows = if title.is_some() { 2 } else { 0 };
367    (title, rows)
368}
369
370/// Right-aligned category names for the horizontal-bar gutter, each truncated
371/// to 24 cells (with a visible '…'); the gutter is the widest surviving name.
372fn name_gutter(cats: &[String]) -> (Vec<String>, usize) {
373    let names: Vec<String> = cats.iter().map(|c| truncate(c, 24)).collect();
374    let gutter = names.iter().map(|s| s.chars().count()).max().unwrap_or(1);
375    (names, gutter)
376}
377
378// --- Error constructors. These strings are CONTRACT: agents pattern-match
379// them to self-correct, and corpus snapshots pin them — each exists once.
380
381/// Negative bar values are rejected, in every orientation.
382fn negative_bar_error() -> Error {
383    Error::Data("negative values are not yet supported for mark \"bar\"; use mark \"line\"".into())
384}
385
386/// More series than palette colors: color is the sole channel identifying a
387/// series, so cycling the palette would make two series indistinguishable.
388fn palette_cap_error(n_series: usize, palette_len: usize, color_field: &str) -> Error {
389    Error::Data(format!(
390        "{n_series} series exceed the {palette_len} distinguishable series colors; \
391         aggregate or filter \"{color_field}\""
392    ))
393}
394
395/// A bar scan that yielded no usable rows.
396fn no_rows_error(value_field: &str, cat_field: &str) -> Error {
397    Error::Data(format!(
398        "no usable rows: field \"{value_field}\" has no numeric values \
399         (or \"{cat_field}\" is always missing)"
400    ))
401}
402
403/// Horizontal-bar content taller than the height ceiling.
404fn height_ceiling_error(n_bars: usize, content: usize) -> Error {
405    Error::Data(format!(
406        "{n_bars} bars need height {content}; filter or aggregate, or raise --height"
407    ))
408}
409
410/// A `bar` with a temporal x, which has no discrete buckets to draw. The exact
411/// string is pinned by the design: `timeUnit` (task 4) is the fix it names.
412fn bar_temporal_error() -> Error {
413    Error::Spec(
414        "bars need discrete time buckets; add `\"timeUnit\": \"day\"` (or week/month/…) \
415         — or use `line`/`point` for continuous time"
416            .into(),
417    )
418}
419
420/// Temporal on the y channel: time belongs on x, so name the resolved type and
421/// the two fixes instead of falling through to the generic categorical-y error.
422fn temporal_y_error(mark: Mark, field: &str) -> Error {
423    Error::Data(format!(
424        "mark {mark:?} resolved y field \"{field}\" as temporal, but y must be quantitative; \
425         put \"{field}\" on encoding.x (time belongs on x), or aggregate it (e.g. count) for a \
426         quantitative y"
427    ))
428}
429
430/// Temporal on the color channel: rejected before it explodes into one series
431/// per timestamp. Suggests a categorical grouping field.
432fn temporal_color_error(field: &str) -> Error {
433    Error::Data(format!(
434        "encoding.color field \"{field}\" resolved as temporal; color would split into one \
435         series per timestamp — group by a categorical field instead (or bucket time with a \
436         phase-2 `timeUnit`)"
437    ))
438}
439
440/// An unparseable value in a resolved-temporal x column. Names the row, the
441/// offending string, and the four accepted shapes — a promoted column parses
442/// by construction, so this fires only for declared/explicit temporal types.
443fn temporal_parse_error(row: usize, value: &str, field: &str) -> Error {
444    Error::Data(format!(
445        "row {row}: could not parse \"{value}\" as temporal in column \"{field}\"; accepted: \
446         \"2026-07-05\", \"2026-07-05T14:30:00\" (or a space instead of T, optional .fff), either \
447         with a trailing \"Z\" or \"±hh:mm\" offset, or a bare \"14:30:00\""
448    ))
449}
450
451/// The lowercase spelling of a resolved field type, for error prose.
452fn type_name(t: FieldType) -> &'static str {
453    match t {
454        FieldType::Quantitative => "quantitative",
455        FieldType::Nominal => "nominal",
456        FieldType::Ordinal => "ordinal",
457        FieldType::Temporal => "temporal",
458    }
459}
460
461/// `timeUnit` on an x that did not resolve temporal: names the resolved type
462/// AND which precedence rung decided it (the design's teaching requirement),
463/// then the two ways to make x temporal.
464fn timeunit_not_temporal_error(spec: &Spec, table: &Table, xf: &str, xt: FieldType) -> Error {
465    let rung = if spec.encoding.x.ty.is_some() {
466        "the explicit spec `type`"
467    } else if table.declared.contains_key(xf) {
468        "the declared column type"
469    } else {
470        "inference from the data"
471    };
472    Error::Spec(format!(
473        "`timeUnit` buckets a temporal x, but \"{xf}\" resolved to {} via {rung}; declare the \
474         column DATE/DATETIME/TIMESTAMP/TIME, or set encoding.x.type to \"temporal\"",
475        type_name(xt)
476    ))
477}
478
479/// The lowercase spelling of a `timeUnit`, for error prose.
480fn unit_name(u: TimeUnit) -> &'static str {
481    match u {
482        TimeUnit::Year => "year",
483        TimeUnit::Quarter => "quarter",
484        TimeUnit::Month => "month",
485        TimeUnit::Week => "week",
486        TimeUnit::Day => "day",
487        TimeUnit::Hour => "hour",
488        TimeUnit::Minute => "minute",
489    }
490}
491
492/// A densified `timeUnit` walk wider than the plot: a fine unit over a long
493/// span (a month of minutes is 43,200 buckets) would draw sub-column garbage —
494/// the vertical twin of the horizontal height ceiling. Counted before
495/// materializing; N == width stays legal (one-column bars). The coarsening
496/// suggestion names the next-coarser unit(s) from the actual one; year has
497/// none, so only the width fix remains.
498fn timeunit_overflow_error(n: usize, plot_w: usize, unit: TimeUnit) -> Error {
499    let coarser = match unit {
500        TimeUnit::Minute => Some("hour or day"),
501        TimeUnit::Hour => Some("day or week"),
502        TimeUnit::Day => Some("week or month"),
503        TimeUnit::Week => Some("month or quarter"),
504        TimeUnit::Month => Some("quarter or year"),
505        TimeUnit::Quarter => Some("year"),
506        TimeUnit::Year => None,
507    };
508    let fix = match coarser {
509        Some(c) => format!("use a coarser timeUnit ({c}) or a wider size"),
510        None => "use a wider size".to_string(),
511    };
512    Error::Data(format!(
513        "timeUnit \"{}\" spans {n} buckets but the plot is {plot_w} columns; {fix}",
514        unit_name(unit)
515    ))
516}
517
518/// `timeUnit` on an x that carries `aggregate: "count"`: count makes x the
519/// quantitative VALUE axis (a horizontal count-per-category bar), so there is
520/// no time axis to bucket — name both ways out.
521fn timeunit_xcount_error() -> Error {
522    Error::Spec(
523        "`timeUnit` buckets the time (category) axis, but `aggregate: \"count\"` makes \
524         encoding.x the count value axis; drop `timeUnit`, or move `count` to encoding.y \
525         and put the time field on encoding.x with `timeUnit`"
526            .into(),
527    )
528}
529
530/// `timeUnit` placed on y or color: it is x-only (it buckets the category axis).
531fn timeunit_channel_error(channel: &str) -> Error {
532    Error::Spec(format!(
533        "`timeUnit` is only supported on encoding.x (it buckets time on the category axis); \
534         remove it from encoding.{channel}"
535    ))
536}
537
538/// `timeUnit` on a line/point/area mark: those already plot continuous time, so
539/// bucketing is redundant — name the two fixes.
540fn timeunit_mark_error(mark: Mark) -> Error {
541    Error::Spec(format!(
542        "`timeUnit` buckets bars into discrete periods; mark {mark:?} already plots continuous \
543         time — drop `timeUnit`, or switch to `bar`"
544    ))
545}
546
547/// `timeUnit` combined with a grouping color: densify is scoped to plain bars
548/// (design §compile), so a grouped temporal bar is rejected.
549fn timeunit_grouped_error(color_field: &str) -> Error {
550    Error::Spec(format!(
551        "`timeUnit` bars do not support a grouping `color` (\"{color_field}\") yet; \
552         drop `color`, or drop `timeUnit`"
553    ))
554}
555
556/// One pass over the rows for ANY bar variant: categories (first-seen) down
557/// one dimension, series (first-seen; one unnamed series when `series_field`
558/// is None) down the other, each cell the raw values awaiting aggregation.
559/// A `count` aggregate yields 1.0 per row without reading the value field.
560struct BarScan {
561    cats: Vec<String>,
562    series: Vec<String>,
563    /// `[category][series]` → raw values; an empty Vec is a missing cell.
564    cells: Vec<Vec<Vec<f64>>>,
565    dropped: usize,
566}
567
568fn scan_bars(
569    rows: &[Row],
570    cat_field: &str,
571    value_field: &str,
572    series_field: Option<&str>,
573    agg: Aggregate,
574) -> BarScan {
575    let mut cats: Vec<String> = Vec::new();
576    let mut series: Vec<String> = match series_field {
577        Some(_) => Vec::new(),
578        None => vec![String::new()],
579    };
580    let mut raw: HashMap<(usize, usize), Vec<f64>> = HashMap::new();
581    let mut dropped = 0usize;
582    for row in rows {
583        let Some(cv) = row.get(cat_field) else {
584            dropped += 1;
585            continue;
586        };
587        let vn = if agg == Aggregate::Count {
588            Some(1.0)
589        } else {
590            row.get(value_field).and_then(data::num)
591        };
592        let Some(vn) = vn else {
593            dropped += 1;
594            continue;
595        };
596        let ci = index_of_or_push(&mut cats, data::text(cv));
597        let si = match series_field {
598            Some(sf) => {
599                let name = row.get(sf).map(data::text).unwrap_or_else(|| "null".into());
600                index_of_or_push(&mut series, name)
601            }
602            None => 0,
603        };
604        raw.entry((ci, si)).or_default().push(vn);
605    }
606    let mut cells = vec![vec![Vec::new(); series.len()]; cats.len()];
607    for ((ci, si), v) in raw {
608        cells[ci][si] = v;
609    }
610    BarScan {
611        cats,
612        series,
613        cells,
614        dropped,
615    }
616}
617
618/// First-seen interning: the index of `item`, pushing it if new.
619fn index_of_or_push(list: &mut Vec<String>, item: String) -> usize {
620    match list.iter().position(|s| *s == item) {
621        Some(i) => i,
622        None => {
623            list.push(item);
624            list.len() - 1
625        }
626    }
627}
628
629/// Lexical category sort for ordinal axes (ISO dates sort chronologically),
630/// carrying each category's cell row along. Series order stays first-seen.
631fn sort_cats(cats: Vec<String>, cells: Vec<Vec<Vec<f64>>>) -> (Vec<String>, Vec<Vec<Vec<f64>>>) {
632    let mut pairs: Vec<(String, Vec<Vec<f64>>)> = cats.into_iter().zip(cells).collect();
633    pairs.sort_by(|a, b| a.0.cmp(&b.0));
634    pairs.into_iter().unzip()
635}
636
637/// Aggregate every cell, rejecting negative results, tracking the maximum for
638/// the value scale. An empty cell is normally `None` — a visible gap at a
639/// stable position. Only the `densified` temporal path (which INSERTS empty
640/// buckets for calendar gaps) treats an empty cell under `count` as a
641/// well-defined zero; every other aggregate — and every non-densified caller —
642/// keeps it `None` (mean of nothing is undefined).
643#[allow(clippy::type_complexity)]
644fn aggregate_cells(
645    cells: &[Vec<Vec<f64>>],
646    agg: Aggregate,
647    densified: bool,
648) -> Result<(Vec<Vec<Option<f64>>>, f64), Error> {
649    let mut vmax = f64::NEG_INFINITY;
650    let mut out = Vec::with_capacity(cells.len());
651    for row in cells {
652        let mut out_row = Vec::with_capacity(row.len());
653        for values in row {
654            if values.is_empty() {
655                if densified && agg == Aggregate::Count {
656                    vmax = vmax.max(0.0);
657                    out_row.push(Some(0.0));
658                } else {
659                    out_row.push(None);
660                }
661                continue;
662            }
663            let v = aggregate(values, agg);
664            if v < 0.0 {
665                return Err(negative_bar_error());
666            }
667            vmax = vmax.max(v);
668            out_row.push(Some(v));
669        }
670        out.push(out_row);
671    }
672    Ok((out, vmax))
673}
674
675/// Resolve a spec into a Scene: every data- and layout-dependent decision made,
676/// geometry normalized, colors baked in. Bars and xy marks share `preflight`.
677pub fn compile(spec: &Spec, table: &Table, opts: &CompileOptions) -> Result<Scene, Error> {
678    preflight(spec, &table.rows)?;
679    let (plot_w, plot_h) = plot_dims(opts.width, opts.height, spec);
680    match spec.mark {
681        Mark::Bar => match bar_route(spec, table)? {
682            BarRoute::Vertical => compile_bar(spec, table, opts, plot_w, plot_h),
683            // Horizontal bars are content-sized: their height is derived from the
684            // category count, not `plot_dims` (which collapses "no height" into
685            // the default 13 and so can't tell an explicit 13 from the default).
686            // Pass the RAW height Option straight through.
687            BarRoute::Horizontal => {
688                compile_bar_h(spec, table, opts, plot_w, opts.height.or(spec.height))
689            }
690        },
691        Mark::Line | Mark::Point | Mark::Area => compile_xy(spec, table, opts, plot_w, plot_h),
692    }
693}
694
695/// Map a scale value to its plot-relative x column: norm over `plot_w - 1`
696/// columns, half-up rounding, clamped to the last column. The ONE column
697/// arithmetic for every x tick — quantitative, temporal, and nominal axes all
698/// route through here, and `time::accept` pre-tests temporal rungs against
699/// this exact formula (see its doc); changing it in one place is the point.
700fn x_col(xscale: &Linear, v: f64, plot_w: usize) -> usize {
701    ((xscale.norm(v) * (plot_w - 1) as f64).round() as usize).min(plot_w - 1)
702}
703
704/// A quantitative x value axis: plot-relative tick columns plus greedily-placed
705/// tick labels. Extracted from `compile_xy`'s quantitative-x branch so the
706/// horizontal-bar value axis and the line/point/area value axis share ONE
707/// implementation. Behavior-neutral for xy: identical columns, anchors, and
708/// `place_x_labels` call as before.
709fn value_axis_x(
710    xscale: &Linear,
711    plot_w: usize,
712    gutter: usize,
713    columns: usize,
714    label_row: usize,
715) -> (Vec<usize>, Vec<Placed>) {
716    let tks = xscale.ticks();
717    let tick_cols: Vec<usize> = tks.iter().map(|t| x_col(xscale, *t, plot_w)).collect();
718    let anchors: Vec<(usize, String)> = tick_cols
719        .iter()
720        .zip(&tks)
721        .map(|(c, t)| (*c, fmt_tick(*t, xscale.step)))
722        .collect();
723    let labels = place_x_labels(&anchors, gutter, columns, label_row);
724    (tick_cols, labels)
725}
726
727/// The shared multi-series legend: "── name" entries flow below the x labels
728/// starting at `top + plot_h + 2`, wrapping before the right edge. Entries are
729/// never clipped; a name wider than the whole row is visibly truncated with
730/// '…'. Colors cycle the palette by entry index (`theme.series`). Returns the
731/// placed entries plus the number of rows they occupy. Used by both the xy
732/// (line/point/area) path and the grouped-bar path — ONE implementation.
733fn legend_below(
734    names: &[String],
735    theme: &Theme,
736    gutter: usize,
737    columns: usize,
738    top: usize,
739    plot_h: usize,
740) -> (Vec<LegendEntry>, usize) {
741    let legend_row0 = top + plot_h + 2;
742    let left = gutter + 1;
743    let max_name = columns.saturating_sub(left + 3);
744    let (mut col, mut row) = (left, legend_row0);
745    let mut legend: Vec<LegendEntry> = Vec::new();
746    for (i, name) in names.iter().enumerate() {
747        let name = truncate(name, max_name);
748        let w = 3 + name.chars().count(); // "── " + name
749        if col > left && col + w > columns {
750            col = left;
751            row += 1;
752        }
753        legend.push(LegendEntry {
754            name,
755            color: theme.series(i),
756            col,
757            row,
758        });
759        col += w + 3;
760    }
761    let legend_rows = legend.last().map_or(0, |e| e.row + 1 - legend_row0);
762    (legend, legend_rows)
763}
764
765/// Greedy left-to-right x-label placement: each label centered on its anchor
766/// column, clamped inside the buffer, skipped if it would collide with the one
767/// before it. Mirrors the old `draw_x_axis`; survivors carry buffer-absolute
768/// start columns.
769///
770/// `time::accept` is the temporal-axis mirror of this rule, run at gutter 0 and
771/// width `plot_w` to pre-test a candidate tick rung: it is deliberately STRICTER
772/// (a whole-rung reject, its right clamp one column tighter), so any rung it
773/// accepts survives here at any y-gutter — the shift by `gutter` only loosens
774/// spacing, never tightens it. Diverge one from the other and temporal ticks
775/// silently drop their labels; keep the arithmetic in lockstep.
776fn place_x_labels(
777    anchors: &[(usize, String)],
778    gutter: usize,
779    width: usize,
780    row: usize,
781) -> Vec<Placed> {
782    let mut out = Vec::new();
783    let mut next_free = 0usize;
784    for (col, label) in anchors {
785        let len = label.chars().count();
786        if len == 0 || len > width {
787            continue;
788        }
789        let start = (gutter + 1 + col).saturating_sub(len / 2).min(width - len);
790        if start < next_free {
791            continue;
792        }
793        out.push(Placed {
794            text: label.clone(),
795            col: start,
796            row,
797        });
798        next_free = start + len + 1;
799    }
800    out
801}