1use 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
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 Ok(())
147}
148
149enum BarRoute {
152 Vertical,
153 Horizontal,
154}
155
156fn 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 if x_count && y_count {
187 return Err(Error::Spec(
188 "aggregate belongs on exactly one channel".into(),
189 ));
190 }
191 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 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 if spec.encoding.x.time_unit.is_some() {
216 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 return Ok(BarRoute::Vertical);
229 }
230 if xt == FieldType::Temporal {
234 return Err(bar_temporal_error());
236 }
237
238 if y_count {
240 return Ok(BarRoute::Vertical);
241 }
242
243 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 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
268fn 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
292fn 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
301fn 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
338fn 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
348fn 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
358fn 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
370fn 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
378fn negative_bar_error() -> Error {
383 Error::Data("negative values are not yet supported for mark \"bar\"; use mark \"line\"".into())
384}
385
386fn 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
395fn 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
403fn 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
410fn 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
420fn 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
430fn 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
440fn 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
451fn 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
461fn 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
479fn 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
492fn 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
518fn 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
530fn 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
538fn 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
547fn 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
556struct BarScan {
561 cats: Vec<String>,
562 series: Vec<String>,
563 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
618fn 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
629fn 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#[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
675pub 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 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
695fn 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
704fn 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
727fn 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(); 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
765fn 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}