1use 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
31const 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
42pub(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
54fn 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
72pub fn preflight(spec: &Spec, rows: &[Row]) -> Result<(), Error> {
76 validate(spec)?;
77 if spec.mark == Mark::Bar {
78 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 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 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
150fn 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
161fn 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 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 if let BinValue::Config(cfg) = bin {
197 validate_bin_config(&cfg)?;
198 }
199 Ok(())
200}
201
202fn 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
215enum BarRoute {
218 Vertical,
219 Horizontal,
220 Histogram,
223}
224
225fn 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 if active_bin(&spec.encoding.x).is_some() {
266 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 if spec.encoding.y.aggregate.is_none() {
282 return Err(bin_y_no_aggregate_error());
283 }
284 return Ok(BarRoute::Histogram);
285 }
286
287 if x_count && y_count {
289 return Err(Error::Spec(
290 "aggregate belongs on exactly one channel".into(),
291 ));
292 }
293 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 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 if spec.encoding.x.time_unit.is_some() {
318 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 return Ok(BarRoute::Vertical);
331 }
332 if xt == FieldType::Temporal {
336 return Err(bar_temporal_error());
338 }
339
340 if y_count {
342 return Ok(BarRoute::Vertical);
343 }
344
345 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 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
370fn 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
394fn 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
403fn 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
440fn 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
450fn 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
460fn 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
472fn 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
480fn negative_bar_error() -> Error {
485 Error::Data("negative values are not yet supported for mark \"bar\"; use mark \"line\"".into())
486}
487
488fn 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
497fn 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
505fn 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
512fn 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
522fn 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
532fn 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
542fn 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
553fn 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
563fn 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
576fn 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
588fn 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
601fn 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
627fn 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
639fn 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
647fn 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
656fn 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
665fn 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
678fn bin_on_color_error() -> Error {
680 Error::Spec("`bin` is only supported on encoding.x; remove it from encoding.color".into())
681}
682
683fn 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
692fn 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
700fn 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
709fn 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
717fn bin_maxbins_error(maxbins: f64) -> Error {
719 Error::Spec(format!(
720 "`bin` `maxbins` must be a positive integer; got {maxbins}"
721 ))
722}
723
724fn bin_step_error(step: f64) -> Error {
726 Error::Spec(format!(
727 "`bin` `step` must be a finite positive number; got {step}"
728 ))
729}
730
731fn 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
744fn 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
758fn 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
769fn 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
792struct BarScan {
797 cats: Vec<String>,
798 series: Vec<String>,
799 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
854fn 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
865fn 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#[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
911pub 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 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
932fn 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
941fn 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
964fn 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(); 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
1002fn 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 #[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 #[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}