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};
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 Ok(())
130}
131
132enum BarRoute {
135 Vertical,
136 Horizontal,
137}
138
139fn bar_route(spec: &Spec, table: &Table) -> Result<BarRoute, Error> {
149 let rows = &table.rows;
150 let xf = &spec.encoding.x.field;
151 let yf = &spec.encoding.y.field;
152 let x_count = matches!(spec.encoding.x.aggregate, Some(Aggregate::Count));
153 let y_count = matches!(spec.encoding.y.aggregate, Some(Aggregate::Count));
154
155 match (x_count, y_count) {
157 (true, true) => {
158 return Err(Error::Spec(
159 "aggregate belongs on exactly one channel".into(),
160 ));
161 }
162 (false, true) => return Ok(BarRoute::Vertical),
163 (true, false) => return Ok(BarRoute::Horizontal),
164 (false, false) => {}
165 }
166
167 let resolve = |ch: &Channel, f: &str| -> FieldType {
173 ch.ty
174 .or_else(|| table.declared.get(f).copied())
175 .unwrap_or_else(|| native_type(rows, f))
176 };
177 let x_quant = resolve(&spec.encoding.x, xf) == FieldType::Quantitative;
178 let y_quant = resolve(&spec.encoding.y, yf) == FieldType::Quantitative;
179 match (x_quant, y_quant) {
180 (false, true) => Ok(BarRoute::Vertical),
181 (true, false) => Ok(BarRoute::Horizontal),
182 (true, true) => Err(bar_channel_error(xf, yf, "quantitative")),
183 (false, false) => {
184 if spec.encoding.y.ty.is_none() && data::infer_type(rows, yf) == FieldType::Quantitative
188 {
189 Ok(BarRoute::Vertical)
190 } else if spec.encoding.x.ty.is_none()
191 && data::infer_type(rows, xf) == FieldType::Quantitative
192 {
193 Ok(BarRoute::Horizontal)
194 } else {
195 Err(bar_channel_error(xf, yf, "categorical"))
196 }
197 }
198 }
199}
200
201fn native_type(rows: &[Row], field: &str) -> FieldType {
206 let mut saw_value = false;
207 for row in rows {
208 if let Some(v) = row.get(field) {
209 if v.is_null() {
210 continue;
211 }
212 saw_value = true;
213 if !v.is_number() {
214 return FieldType::Nominal;
215 }
216 }
217 }
218 if saw_value {
219 FieldType::Quantitative
220 } else {
221 FieldType::Nominal
222 }
223}
224
225fn bar_channel_error(xf: &str, yf: &str, both: &str) -> Error {
228 Error::Spec(format!(
229 "bar needs one categorical and one quantitative channel; both x (\"{xf}\") and y \
230 (\"{yf}\") resolved {both}; put categories on one axis or set an explicit \"type\""
231 ))
232}
233
234fn bar_aggregate_error(value_axis: &str) -> Error {
237 Error::Spec(format!(
238 "aggregation runs over the quantitative channel, grouped by the categorical one; \
239 put `aggregate` on encoding.{value_axis}"
240 ))
241}
242
243pub(crate) fn aggregate(values: &[f64], agg: Aggregate) -> f64 {
244 match agg {
245 Aggregate::Sum => values.iter().sum(),
246 Aggregate::Mean => values.iter().sum::<f64>() / values.len() as f64,
247 Aggregate::Median => {
248 let mut v = values.to_vec();
249 v.sort_by(f64::total_cmp);
250 let mid = v.len() / 2;
251 if v.len().is_multiple_of(2) {
252 (v[mid - 1] + v[mid]) / 2.0
253 } else {
254 v[mid]
255 }
256 }
257 Aggregate::Min => values.iter().cloned().fold(f64::INFINITY, f64::min),
258 Aggregate::Max => values.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
259 Aggregate::Count => values.len() as f64,
260 }
261}
262
263pub(crate) fn truncate(s: &str, max: usize) -> String {
264 if s.chars().count() <= max {
265 s.to_string()
266 } else {
267 s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
268 }
269}
270
271fn resolved_type(ch: &Channel, table: &Table) -> FieldType {
276 ch.ty
277 .or_else(|| table.declared.get(&ch.field).copied())
278 .unwrap_or_else(|| data::infer_type(&table.rows, &ch.field))
279}
280
281fn tick_gutter(scale: &Linear) -> usize {
283 scale
284 .ticks()
285 .iter()
286 .map(|t| fmt_tick(*t, scale.step).chars().count())
287 .max()
288 .unwrap_or(1)
289}
290
291fn place_title(spec: &Spec, gutter: usize, plot_w: usize) -> (Option<Placed>, usize) {
294 let title = spec.title.as_deref().map(|t| Placed {
295 text: t.to_string(),
296 col: gutter + 1 + plot_w.saturating_sub(t.chars().count()) / 2,
297 row: 0,
298 });
299 let rows = if title.is_some() { 2 } else { 0 };
300 (title, rows)
301}
302
303fn name_gutter(cats: &[String]) -> (Vec<String>, usize) {
306 let names: Vec<String> = cats.iter().map(|c| truncate(c, 24)).collect();
307 let gutter = names.iter().map(|s| s.chars().count()).max().unwrap_or(1);
308 (names, gutter)
309}
310
311fn negative_bar_error() -> Error {
316 Error::Data("negative values are not yet supported for mark \"bar\"; use mark \"line\"".into())
317}
318
319fn palette_cap_error(n_series: usize, palette_len: usize, color_field: &str) -> Error {
322 Error::Data(format!(
323 "{n_series} series exceed the {palette_len} distinguishable series colors; \
324 aggregate or filter \"{color_field}\""
325 ))
326}
327
328fn no_rows_error(value_field: &str, cat_field: &str) -> Error {
330 Error::Data(format!(
331 "no usable rows: field \"{value_field}\" has no numeric values \
332 (or \"{cat_field}\" is always missing)"
333 ))
334}
335
336fn height_ceiling_error(n_bars: usize, content: usize) -> Error {
338 Error::Data(format!(
339 "{n_bars} bars need height {content}; filter or aggregate, or raise --height"
340 ))
341}
342
343struct BarScan {
348 cats: Vec<String>,
349 series: Vec<String>,
350 cells: Vec<Vec<Vec<f64>>>,
352 dropped: usize,
353}
354
355fn scan_bars(
356 rows: &[Row],
357 cat_field: &str,
358 value_field: &str,
359 series_field: Option<&str>,
360 agg: Aggregate,
361) -> BarScan {
362 let mut cats: Vec<String> = Vec::new();
363 let mut series: Vec<String> = match series_field {
364 Some(_) => Vec::new(),
365 None => vec![String::new()],
366 };
367 let mut raw: HashMap<(usize, usize), Vec<f64>> = HashMap::new();
368 let mut dropped = 0usize;
369 for row in rows {
370 let Some(cv) = row.get(cat_field) else {
371 dropped += 1;
372 continue;
373 };
374 let vn = if agg == Aggregate::Count {
375 Some(1.0)
376 } else {
377 row.get(value_field).and_then(data::num)
378 };
379 let Some(vn) = vn else {
380 dropped += 1;
381 continue;
382 };
383 let ci = index_of_or_push(&mut cats, data::text(cv));
384 let si = match series_field {
385 Some(sf) => {
386 let name = row.get(sf).map(data::text).unwrap_or_else(|| "null".into());
387 index_of_or_push(&mut series, name)
388 }
389 None => 0,
390 };
391 raw.entry((ci, si)).or_default().push(vn);
392 }
393 let mut cells = vec![vec![Vec::new(); series.len()]; cats.len()];
394 for ((ci, si), v) in raw {
395 cells[ci][si] = v;
396 }
397 BarScan {
398 cats,
399 series,
400 cells,
401 dropped,
402 }
403}
404
405fn index_of_or_push(list: &mut Vec<String>, item: String) -> usize {
407 match list.iter().position(|s| *s == item) {
408 Some(i) => i,
409 None => {
410 list.push(item);
411 list.len() - 1
412 }
413 }
414}
415
416fn sort_cats(cats: Vec<String>, cells: Vec<Vec<Vec<f64>>>) -> (Vec<String>, Vec<Vec<Vec<f64>>>) {
419 let mut pairs: Vec<(String, Vec<Vec<f64>>)> = cats.into_iter().zip(cells).collect();
420 pairs.sort_by(|a, b| a.0.cmp(&b.0));
421 pairs.into_iter().unzip()
422}
423
424#[allow(clippy::type_complexity)]
428fn aggregate_cells(
429 cells: &[Vec<Vec<f64>>],
430 agg: Aggregate,
431) -> Result<(Vec<Vec<Option<f64>>>, f64), Error> {
432 let mut vmax = f64::NEG_INFINITY;
433 let mut out = Vec::with_capacity(cells.len());
434 for row in cells {
435 let mut out_row = Vec::with_capacity(row.len());
436 for values in row {
437 if values.is_empty() {
438 out_row.push(None);
439 continue;
440 }
441 let v = aggregate(values, agg);
442 if v < 0.0 {
443 return Err(negative_bar_error());
444 }
445 vmax = vmax.max(v);
446 out_row.push(Some(v));
447 }
448 out.push(out_row);
449 }
450 Ok((out, vmax))
451}
452
453pub fn compile(spec: &Spec, table: &Table, opts: &CompileOptions) -> Result<Scene, Error> {
456 preflight(spec, &table.rows)?;
457 let (plot_w, plot_h) = plot_dims(opts.width, opts.height, spec);
458 match spec.mark {
459 Mark::Bar => match bar_route(spec, table)? {
460 BarRoute::Vertical => compile_bar(spec, table, opts, plot_w, plot_h),
461 BarRoute::Horizontal => {
466 compile_bar_h(spec, table, opts, plot_w, opts.height.or(spec.height))
467 }
468 },
469 Mark::Line | Mark::Point | Mark::Area => compile_xy(spec, table, opts, plot_w, plot_h),
470 }
471}
472
473fn value_axis_x(
479 xscale: &Linear,
480 plot_w: usize,
481 gutter: usize,
482 columns: usize,
483 label_row: usize,
484) -> (Vec<usize>, Vec<Placed>) {
485 let tks = xscale.ticks();
486 let tick_cols: Vec<usize> = tks
487 .iter()
488 .map(|t| ((xscale.norm(*t) * (plot_w - 1) as f64).round() as usize).min(plot_w - 1))
489 .collect();
490 let anchors: Vec<(usize, String)> = tick_cols
491 .iter()
492 .zip(&tks)
493 .map(|(c, t)| (*c, fmt_tick(*t, xscale.step)))
494 .collect();
495 let labels = place_x_labels(&anchors, gutter, columns, label_row);
496 (tick_cols, labels)
497}
498
499fn legend_below(
506 names: &[String],
507 theme: &Theme,
508 gutter: usize,
509 columns: usize,
510 top: usize,
511 plot_h: usize,
512) -> (Vec<LegendEntry>, usize) {
513 let legend_row0 = top + plot_h + 2;
514 let left = gutter + 1;
515 let max_name = columns.saturating_sub(left + 3);
516 let (mut col, mut row) = (left, legend_row0);
517 let mut legend: Vec<LegendEntry> = Vec::new();
518 for (i, name) in names.iter().enumerate() {
519 let name = truncate(name, max_name);
520 let w = 3 + name.chars().count(); if col > left && col + w > columns {
522 col = left;
523 row += 1;
524 }
525 legend.push(LegendEntry {
526 name,
527 color: theme.series(i),
528 col,
529 row,
530 });
531 col += w + 3;
532 }
533 let legend_rows = legend.last().map_or(0, |e| e.row + 1 - legend_row0);
534 (legend, legend_rows)
535}
536
537fn place_x_labels(
542 anchors: &[(usize, String)],
543 gutter: usize,
544 width: usize,
545 row: usize,
546) -> Vec<Placed> {
547 let mut out = Vec::new();
548 let mut next_free = 0usize;
549 for (col, label) in anchors {
550 let len = label.chars().count();
551 if len == 0 || len > width {
552 continue;
553 }
554 let start = (gutter + 1 + col).saturating_sub(len / 2).min(width - len);
555 if start < next_free {
556 continue;
557 }
558 out.push(Placed {
559 text: label.clone(),
560 col: start,
561 row,
562 });
563 next_free = start + len + 1;
564 }
565 out
566}