1pub(crate) mod annotation;
5mod axis_gen;
6pub(crate) mod facet;
7mod layout;
8pub(crate) mod layout_treemap;
9pub(crate) mod legend_gen;
10mod mark_gen;
11pub(crate) mod position;
12pub(crate) mod stat_aggregate;
13pub(crate) mod stat_bin;
14pub(crate) mod stat_boxplot;
15pub(crate) mod stat_smooth;
16pub(crate) mod stat_transform;
17
18use crate::error::{ChartError, Result};
19use crate::grammar::chart::Chart;
20use crate::grammar::facet::Facet;
21use esoc_scene::bounds::DataBounds;
22use esoc_scene::node::Node;
23use esoc_scene::transform::Affine2D;
24use esoc_scene::SceneGraph;
25use stat_transform::ResolvedLayer;
26
27pub(crate) struct Margins {
29 pub top: f32,
30 pub right: f32,
31 pub bottom: f32,
32 pub left: f32,
33 pub legend_placement: layout::LegendPlacement,
34}
35
36pub fn compile_chart(chart: &Chart) -> Result<SceneGraph> {
38 if chart.layers.is_empty() {
39 return Err(ChartError::EmptyData);
40 }
41
42 if matches!(chart.coord, crate::grammar::coord::CoordSystem::Polar) {
44 return Err(ChartError::InvalidParameter(
45 "Polar coordinates are not yet implemented".into(),
46 ));
47 }
48
49 for (i, layer) in chart.layers.iter().enumerate() {
51 if matches!(layer.mark, crate::grammar::layer::MarkType::Treemap) {
53 if let Some(y) = &layer.y_data {
54 for (j, &v) in y.iter().enumerate() {
55 if v < 0.0 {
56 return Err(ChartError::InvalidData {
57 layer: i,
58 detail: format!(
59 "treemap values must be non-negative, got {v} at index {j}"
60 ),
61 });
62 }
63 if v.is_nan() || v.is_infinite() {
64 return Err(ChartError::InvalidData {
65 layer: i,
66 detail: format!(
67 "treemap y_data contains {} at index {j}",
68 if v.is_nan() { "NaN" } else { "Inf" }
69 ),
70 });
71 }
72 }
73 }
74 continue; }
76 if let (Some(x), Some(y)) = (&layer.x_data, &layer.y_data) {
77 if x.len() != y.len() {
78 return Err(ChartError::DimensionMismatch {
79 layer: i,
80 x_len: x.len(),
81 y_len: y.len(),
82 });
83 }
84 }
85 if let (Some(x), Some(y)) = (&layer.x_data, &layer.y_data) {
90 let n = x.len().min(y.len());
91 if let Some(fv) = &layer.facet_values {
92 if fv.len() != n {
93 return Err(ChartError::InvalidData {
94 layer: i,
95 detail: format!(
96 "facet_values has {} elements but data has {}",
97 fv.len(),
98 n
99 ),
100 });
101 }
102 }
103 }
104 if let (Some(eb), Some(y)) = (&layer.error_bars, &layer.y_data) {
106 if eb.len() != y.len() {
107 return Err(ChartError::InvalidData {
108 layer: i,
109 detail: format!(
110 "error_bars has {} elements but y_data has {}",
111 eb.len(),
112 y.len()
113 ),
114 });
115 }
116 }
117 if let Some(x) = &layer.x_data {
119 for (j, &v) in x.iter().enumerate() {
120 if v.is_nan() || v.is_infinite() {
121 return Err(ChartError::InvalidData {
122 layer: i,
123 detail: format!(
124 "x_data contains {} at index {}",
125 if v.is_nan() { "NaN" } else { "Inf" },
126 j
127 ),
128 });
129 }
130 }
131 }
132 if let Some(y) = &layer.y_data {
133 for (j, &v) in y.iter().enumerate() {
134 if v.is_nan() || v.is_infinite() {
135 return Err(ChartError::InvalidData {
136 layer: i,
137 detail: format!(
138 "y_data contains {} at index {}",
139 if v.is_nan() { "NaN" } else { "Inf" },
140 j
141 ),
142 });
143 }
144 }
145 }
146 if let Some(heatmap) = &layer.heatmap_data {
148 for (r, row) in heatmap.iter().enumerate() {
149 for (c, &v) in row.iter().enumerate() {
150 if v.is_nan() || v.is_infinite() {
151 return Err(ChartError::InvalidData {
152 layer: i,
153 detail: format!(
154 "heatmap_data contains {} at [{r}][{c}]",
155 if v.is_nan() { "NaN" } else { "Inf" }
156 ),
157 });
158 }
159 }
160 }
161 }
162 }
163
164 let mut resolved: Vec<ResolvedLayer> = chart
166 .layers
167 .iter()
168 .enumerate()
169 .map(|(i, layer)| stat_transform::resolve_layer(layer, i))
170 .collect::<Result<Vec<_>>>()?;
171
172 position::apply_positions(&mut resolved)?;
174
175 let mut scene = SceneGraph::with_root();
176 let root = scene.root().unwrap();
177
178 let theme = &chart.theme;
179
180 let mut data_bounds = compute_resolved_data_bounds(&resolved)?;
182
183 {
187 let has_bar_or_area = chart.layers.iter().any(|l| {
188 matches!(
189 l.mark,
190 crate::grammar::layer::MarkType::Bar
191 | crate::grammar::layer::MarkType::Area
192 | crate::grammar::layer::MarkType::Heatmap
193 | crate::grammar::layer::MarkType::Treemap
194 )
195 });
196 if !has_bar_or_area {
197 let x_range = data_bounds.x_max - data_bounds.x_min;
198 let y_range = data_bounds.y_max - data_bounds.y_min;
199 let pad_x = if x_range.abs() < 1e-12 {
200 1.0
201 } else {
202 x_range * 0.05
203 };
204 let pad_y = if y_range.abs() < 1e-12 {
205 1.0
206 } else {
207 y_range * 0.05
208 };
209 data_bounds.x_min -= pad_x;
210 data_bounds.x_max += pad_x;
211 data_bounds.y_min -= pad_y;
212 data_bounds.y_max += pad_y;
213 }
214 }
215
216 {
219 use esoc_scene::scale::Scale;
220 let target_x = layout::target_tick_count(chart.width, 80.0);
221 let target_y = layout::target_tick_count(chart.height, 40.0);
222 let x_niced = Scale::Linear {
223 domain: (data_bounds.x_min, data_bounds.x_max),
224 range: (0.0, chart.width),
225 }
226 .nice(target_x);
227 let y_niced = Scale::Linear {
228 domain: (data_bounds.y_min, data_bounds.y_max),
229 range: (chart.height, 0.0),
230 }
231 .nice(target_y);
232 if let Scale::Linear { domain, .. } = &x_niced {
233 data_bounds.x_min = domain.0;
234 data_bounds.x_max = domain.1;
235 }
236 if let Scale::Linear { domain, .. } = &y_niced {
237 data_bounds.y_min = domain.0;
238 data_bounds.y_max = domain.1;
239 }
240 }
241
242 let has_bar_or_area = resolved.iter().any(|l| {
247 matches!(
248 l.mark,
249 crate::grammar::layer::MarkType::Bar | crate::grammar::layer::MarkType::Area
250 )
251 });
252 if has_bar_or_area {
256 if data_bounds.y_min > 0.0 {
257 data_bounds.y_min = 0.0;
258 }
259 if data_bounds.y_max < 0.0 {
260 data_bounds.y_max = 0.0;
261 }
262 }
263
264 if let Some((lo, hi)) = chart.x_domain {
266 data_bounds.x_min = lo;
267 data_bounds.x_max = hi;
268 }
269 if let Some((lo, hi)) = chart.y_domain {
270 data_bounds.y_min = lo;
271 data_bounds.y_max = hi;
272 }
273
274 let is_flipped = matches!(chart.coord, crate::grammar::coord::CoordSystem::Flipped);
276 if is_flipped {
277 data_bounds = esoc_scene::bounds::DataBounds::new(
278 data_bounds.y_min,
279 data_bounds.y_max,
280 data_bounds.x_min,
281 data_bounds.x_max,
282 );
283 for layer in &mut resolved {
284 std::mem::swap(&mut layer.x_data, &mut layer.y_data);
285 }
286 }
287
288 let margins = layout::compute_margins(chart, &data_bounds);
290
291 let plot_x = margins.left;
292 let plot_y = margins.top;
293 let plot_w = (chart.width - margins.left - margins.right).max(1.0);
294 let plot_h = (chart.height - margins.top - margins.bottom).max(1.0);
295
296 if chart.width < margins.left + margins.right || chart.height < margins.top + margins.bottom {
297 return Err(ChartError::InvalidParameter(
298 "chart dimensions are too small for the required margins".into(),
299 ));
300 }
301
302 let bg_node = Node::with_mark(esoc_scene::mark::Mark::Rect(esoc_scene::mark::RectMark {
304 bounds: esoc_scene::bounds::BoundingBox::new(0.0, 0.0, chart.width, chart.height),
305 fill: esoc_scene::style::FillStyle::Solid(theme.background),
306 stroke: esoc_scene::style::StrokeStyle {
307 width: 0.0,
308 ..Default::default()
309 },
310 corner_radius: 0.0,
311 }))
312 .z_order(-10);
313 scene.insert_child(root, bg_node);
314
315 let has_facets =
317 !matches!(chart.facet, Facet::None) && resolved.iter().any(|l| l.facet_values.is_some());
318
319 if has_facets {
320 compile_faceted(
321 chart,
322 &mut scene,
323 root,
324 &resolved,
325 &data_bounds,
326 plot_x,
327 plot_y,
328 plot_w,
329 plot_h,
330 )?;
331 } else {
332 compile_single_panel(
333 chart,
334 &mut scene,
335 root,
336 &resolved,
337 &data_bounds,
338 plot_x,
339 plot_y,
340 plot_w,
341 plot_h,
342 is_flipped,
343 margins.legend_placement,
344 )?;
345 }
346
347 if let Some(title) = &chart.title {
349 let max_chars = (chart.width / (theme.base_font_size * 0.6)).floor() as usize;
350 let lines = layout::wrap_text(title, max_chars, 2);
351 for (i, line) in lines.iter().enumerate() {
352 let y = theme.title_font_size + 4.0 + i as f32 * theme.title_font_size * 1.2;
353 let title_node =
354 Node::with_mark(esoc_scene::mark::Mark::Text(esoc_scene::mark::TextMark {
355 position: [chart.width * 0.5, y],
356 text: line.clone(),
357 font: esoc_scene::style::FontStyle {
358 family: theme.font_family.clone(),
359 size: theme.title_font_size,
360 weight: 700,
361 italic: false,
362 },
363 fill: esoc_scene::style::FillStyle::Solid(theme.foreground),
364 angle: 0.0,
365 anchor: esoc_scene::mark::TextAnchor::Middle,
366 }))
367 .z_order(10);
368 scene.insert_child(root, title_node);
369 }
370 }
371
372 if let Some(subtitle) = &chart.subtitle {
374 annotation::generate_subtitle(
375 &mut scene,
376 root,
377 subtitle,
378 chart.width,
379 theme.title_font_size,
380 theme,
381 );
382 }
383
384 if let Some(caption) = &chart.caption {
386 annotation::generate_caption(&mut scene, root, caption, chart.width, chart.height, theme);
387 }
388
389 Ok(scene)
390}
391
392#[allow(clippy::too_many_arguments)]
394fn generate_heatmap_axes(
395 chart: &Chart,
396 scene: &mut SceneGraph,
397 root: esoc_scene::node::NodeId,
398 _plot_id: esoc_scene::node::NodeId,
399 resolved: &[ResolvedLayer],
400 plot_x: f32,
401 plot_y: f32,
402 plot_w: f32,
403 plot_h: f32,
404) {
405 use esoc_scene::mark::{Mark, TextAnchor, TextMark};
406 use esoc_scene::style::{FillStyle, FontStyle};
407
408 let theme = &chart.theme;
409 let layer = resolved.first();
410
411 if let Some(col_labels) = layer.and_then(|l| l.col_labels.as_ref()) {
413 let cols = col_labels.len();
414 let cell_w = plot_w / cols as f32;
415 for (c, label) in col_labels.iter().enumerate() {
416 let x = plot_x + (c as f32 + 0.5) * cell_w;
417 let y = plot_y + plot_h + theme.tick_font_size + 5.0;
418 let text = Node::with_mark(Mark::Text(TextMark {
419 position: [x, y],
420 text: label.clone(),
421 font: FontStyle {
422 family: theme.font_family.clone(),
423 size: theme.tick_font_size,
424 weight: 400,
425 italic: false,
426 },
427 fill: FillStyle::Solid(theme.foreground),
428 angle: 0.0,
429 anchor: TextAnchor::Middle,
430 }))
431 .z_order(5);
432 scene.insert_child(root, text);
433 }
434 }
435
436 if let Some(row_labels) = layer.and_then(|l| l.row_labels.as_ref()) {
438 let rows = row_labels.len();
439 let cell_h = plot_h / rows as f32;
440 for (r, label) in row_labels.iter().enumerate() {
441 let x = plot_x - 5.0;
442 let y = plot_y + (r as f32 + 0.5) * cell_h;
443 let text = Node::with_mark(Mark::Text(TextMark {
444 position: [x, y],
445 text: label.clone(),
446 font: FontStyle {
447 family: theme.font_family.clone(),
448 size: theme.tick_font_size,
449 weight: 400,
450 italic: false,
451 },
452 fill: FillStyle::Solid(theme.foreground),
453 angle: 0.0,
454 anchor: TextAnchor::End,
455 }))
456 .z_order(5);
457 scene.insert_child(root, text);
458 }
459 }
460
461 if let Some(label) = &chart.x_label {
463 let text = Node::with_mark(Mark::Text(TextMark {
464 position: [
465 plot_x + plot_w * 0.5,
466 plot_y + plot_h + theme.tick_font_size + theme.label_font_size + 15.0,
467 ],
468 text: label.clone(),
469 font: FontStyle {
470 family: theme.font_family.clone(),
471 size: theme.label_font_size,
472 weight: 400,
473 italic: false,
474 },
475 fill: FillStyle::Solid(theme.foreground),
476 angle: 0.0,
477 anchor: TextAnchor::Middle,
478 }))
479 .z_order(5);
480 scene.insert_child(root, text);
481 }
482
483 if let Some(label) = &chart.y_label {
485 let text = Node::with_mark(Mark::Text(TextMark {
486 position: [plot_x - theme.tick_font_size * 3.5, plot_y + plot_h * 0.5],
487 text: label.clone(),
488 font: FontStyle {
489 family: theme.font_family.clone(),
490 size: theme.label_font_size,
491 weight: 400,
492 italic: false,
493 },
494 fill: FillStyle::Solid(theme.foreground),
495 angle: -90.0,
496 anchor: TextAnchor::Middle,
497 }))
498 .z_order(5);
499 scene.insert_child(root, text);
500 }
501}
502
503#[allow(clippy::too_many_arguments)]
505fn compile_single_panel(
506 chart: &Chart,
507 scene: &mut SceneGraph,
508 root: esoc_scene::node::NodeId,
509 resolved: &[ResolvedLayer],
510 data_bounds: &DataBounds,
511 plot_x: f32,
512 plot_y: f32,
513 plot_w: f32,
514 plot_h: f32,
515 is_flipped: bool,
516 legend_placement: layout::LegendPlacement,
517) -> Result<()> {
518 let theme = &chart.theme;
519
520 let plot_container = Node::container().transform(Affine2D::translate(plot_x, plot_y));
521 let plot_id = scene.insert_child(root, plot_container);
522
523 let is_pie = resolved
524 .iter()
525 .all(|l| matches!(l.mark, crate::grammar::layer::MarkType::Arc));
526 let is_heatmap = resolved
527 .iter()
528 .all(|l| matches!(l.mark, crate::grammar::layer::MarkType::Heatmap));
529 let is_treemap = resolved
530 .iter()
531 .all(|l| matches!(l.mark, crate::grammar::layer::MarkType::Treemap));
532
533 if is_heatmap {
534 generate_heatmap_axes(
535 chart, scene, root, plot_id, resolved, plot_x, plot_y, plot_w, plot_h,
536 );
537 } else if !is_pie && !is_treemap {
538 let x_label = chart.x_label.as_deref();
543 let y_label = chart.y_label.as_deref();
544
545 let all_bar = resolved
547 .iter()
548 .all(|l| matches!(l.mark, crate::grammar::layer::MarkType::Bar));
549 let grid_axes = if all_bar {
550 axis_gen::GridAxes::HorizontalOnly
551 } else {
552 axis_gen::GridAxes::Both
553 };
554
555 let bar_categories: Option<Vec<String>> = if all_bar {
557 resolved.iter().find_map(|l| l.categories.clone())
558 } else {
559 None
560 };
561
562 let x_cats = if is_flipped && all_bar {
564 None
565 } else {
566 bar_categories.as_deref()
567 };
568 let y_cats = if is_flipped && all_bar {
569 bar_categories.as_deref()
570 } else {
571 None
572 };
573
574 axis_gen::generate_axes(
575 scene,
576 plot_id,
577 root,
578 data_bounds,
579 plot_w,
580 plot_h,
581 plot_x,
582 plot_y,
583 theme,
584 x_label,
585 y_label,
586 grid_axes,
587 x_cats,
588 y_cats,
589 );
590 }
591
592 let total_layers = resolved.len();
593 for resolved_layer in resolved {
594 mark_gen::generate_layer_marks_flipped(
595 scene,
596 plot_id,
597 resolved_layer,
598 data_bounds,
599 plot_w,
600 plot_h,
601 theme,
602 is_flipped,
603 total_layers,
604 )?;
605 }
606
607 let mut legends = legend_gen::collect_legends(resolved, theme);
609 if let Some(lt) = &chart.legend_title {
611 for legend in &mut legends {
612 if legend.title.is_none() {
613 legend.title = Some(lt.clone());
614 }
615 }
616 }
617 if !legends.is_empty() {
618 match legend_placement {
619 layout::LegendPlacement::Bottom => {
620 let title_gap = theme.label_font_size * 1.2;
623 let descender = theme.label_font_size * 0.35;
624 let axis_skirt_offset = if chart.x_label.is_some() {
625 theme.tick_font_size + theme.label_font_size + title_gap + descender
626 } else {
627 theme.tick_font_size + 4.0
628 };
629 legend_gen::generate_legends_bottom(
630 scene,
631 root,
632 &legends,
633 plot_x,
634 plot_y,
635 plot_w,
636 plot_h,
637 axis_skirt_offset,
638 theme,
639 );
640 }
641 _ => {
642 legend_gen::generate_legends(
643 scene, root, &legends, plot_x, plot_y, plot_w, plot_h, theme,
644 );
645 }
646 }
647 }
648
649 if !chart.annotations.is_empty() && !is_pie {
651 annotation::generate_annotations(
652 scene,
653 plot_id,
654 root,
655 &chart.annotations,
656 data_bounds,
657 plot_w,
658 plot_h,
659 plot_x,
660 plot_y,
661 theme,
662 );
663 }
664
665 Ok(())
666}
667
668#[allow(clippy::too_many_arguments)]
670fn compile_faceted(
671 chart: &Chart,
672 scene: &mut SceneGraph,
673 root: esoc_scene::node::NodeId,
674 resolved: &[ResolvedLayer],
675 global_bounds: &DataBounds,
676 plot_x: f32,
677 plot_y: f32,
678 plot_w: f32,
679 plot_h: f32,
680) -> Result<()> {
681 let theme = &chart.theme;
682 let panels = facet::compute_panels(&chart.facet, resolved);
683 let ncol = match &chart.facet {
684 Facet::Wrap { ncol } => *ncol,
685 Facet::Grid { col_count, .. } => *col_count,
686 Facet::None => 1,
687 };
688
689 let gap = 20.0_f32;
690 let strip_h = theme.tick_font_size + 6.0;
691 let effective_h = plot_h - (strip_h * panels.len().div_ceil(ncol) as f32);
693 let layout =
694 facet::compute_facet_layout(panels.len(), ncol, plot_w, effective_h.max(100.0), gap);
695
696 let plot_container = Node::container().transform(Affine2D::translate(plot_x, plot_y));
698 let plot_area_id = scene.insert_child(root, plot_container);
699
700 let mut facet_theme = theme.clone();
702 facet_theme.tick_font_size = (theme.tick_font_size - 1.0).max(7.0);
703
704 let nrow = panels.len().div_ceil(ncol);
705
706 for (i, (panel, rect)) in panels.iter().zip(layout.iter()).enumerate() {
707 let panel_bounds = facet::compute_panel_bounds(panel, chart.facet_scales, global_bounds);
708
709 let panel_y_offset = rect.y + strip_h;
711 let panel_container =
712 Node::container().transform(Affine2D::translate(rect.x, panel_y_offset));
713 let panel_id = scene.insert_child(plot_area_id, panel_container);
714
715 let panel_row = i / ncol;
716 let panel_col = i % ncol;
717 let is_bottom_row = panel_row == nrow - 1;
718 let is_left_col = panel_col == 0;
719
720 let show_x = is_bottom_row;
722 let show_y = is_left_col;
723
724 let panel_all_bar = panel
726 .layers
727 .iter()
728 .all(|l| matches!(l.mark, crate::grammar::layer::MarkType::Bar));
729 let panel_grid_axes = if panel_all_bar {
730 axis_gen::GridAxes::HorizontalOnly
731 } else {
732 axis_gen::GridAxes::Both
733 };
734 let panel_bar_categories: Option<Vec<String>> = if panel_all_bar {
735 panel.layers.iter().find_map(|l| l.categories.clone())
736 } else {
737 None
738 };
739
740 axis_gen::generate_axes(
742 scene,
743 panel_id,
744 panel_id,
745 &panel_bounds,
746 rect.w,
747 rect.h,
748 0.0,
749 0.0,
750 &facet_theme,
751 if show_x {
752 chart.x_label.as_deref()
753 } else {
754 None
755 },
756 if show_y {
757 chart.y_label.as_deref()
758 } else {
759 None
760 },
761 panel_grid_axes,
762 panel_bar_categories.as_deref(),
763 None,
764 );
765
766 let panel_total_layers = panel.layers.len();
768 for layer in &panel.layers {
769 mark_gen::generate_layer_marks(
770 scene,
771 panel_id,
772 layer,
773 &panel_bounds,
774 rect.w,
775 rect.h,
776 theme,
777 panel_total_layers,
778 )?;
779 }
780
781 facet::generate_strip_label(scene, panel_id, &panel.label, rect.w, theme);
783 }
784
785 let legends = legend_gen::collect_legends(resolved, theme);
787 if !legends.is_empty() {
788 legend_gen::generate_legends(scene, root, &legends, plot_x, plot_y, plot_w, plot_h, theme);
789 }
790
791 Ok(())
792}
793
794fn compute_resolved_data_bounds(layers: &[ResolvedLayer]) -> Result<DataBounds> {
795 let all_arc = layers
797 .iter()
798 .all(|l| matches!(l.mark, crate::grammar::layer::MarkType::Arc));
799 if all_arc {
800 return Ok(DataBounds::new(0.0, 1.0, 0.0, 1.0));
801 }
802
803 let all_treemap = layers
805 .iter()
806 .all(|l| matches!(l.mark, crate::grammar::layer::MarkType::Treemap));
807 if all_treemap {
808 return Ok(DataBounds::new(0.0, 1.0, 0.0, 1.0));
809 }
810
811 let all_heatmap = layers
813 .iter()
814 .all(|l| matches!(l.mark, crate::grammar::layer::MarkType::Heatmap));
815 if all_heatmap {
816 if let Some(data) = layers.first().and_then(|l| l.heatmap_data.as_ref()) {
817 let rows = data.len();
818 let cols = data.first().map_or(0, |r| r.len());
819 return Ok(DataBounds::new(
820 -0.5,
821 cols as f64 - 0.5,
822 -0.5,
823 rows as f64 - 0.5,
824 ));
825 }
826 return Ok(DataBounds::new(0.0, 1.0, 0.0, 1.0));
827 }
828
829 let mut bounds = DataBounds::new(
830 f64::INFINITY,
831 f64::NEG_INFINITY,
832 f64::INFINITY,
833 f64::NEG_INFINITY,
834 );
835 let mut has_data = false;
836
837 for layer in layers {
838 for (i, (&x, &y)) in layer.x_data.iter().zip(layer.y_data.iter()).enumerate() {
839 bounds.include_point(x, y);
840 has_data = true;
841 if let Some(errors) = &layer.error_bars {
843 if let Some(&err) = errors.get(i) {
844 bounds.include_point(x, y - err);
845 bounds.include_point(x, y + err);
846 }
847 }
848 }
849 if let Some(summaries) = &layer.boxplot {
850 for s in summaries {
851 bounds.include_point(0.0, s.whisker_lo);
852 bounds.include_point(0.0, s.whisker_hi);
853 for &o in &s.outliers {
854 bounds.include_point(0.0, o);
855 }
856 }
857 }
858 if let Some(baseline) = &layer.y_baseline {
859 for &y in baseline {
860 bounds.include_point(0.0, y);
861 }
862 }
863 }
864
865 if !has_data {
866 return Err(ChartError::EmptyData);
867 }
868
869 let has_bar = layers
871 .iter()
872 .any(|l| matches!(l.mark, crate::grammar::layer::MarkType::Bar));
873 if has_bar {
874 bounds.x_min -= 0.4;
875 bounds.x_max += 0.4;
876 }
877
878 let has_bar_or_area = layers.iter().any(|l| {
880 matches!(
881 l.mark,
882 crate::grammar::layer::MarkType::Bar | crate::grammar::layer::MarkType::Area
883 )
884 });
885 if has_bar_or_area {
886 if bounds.y_min > 0.0 {
887 bounds.y_min = 0.0;
888 }
889 if bounds.y_max < 0.0 {
890 bounds.y_max = 0.0;
891 }
892 } else {
893 let y_range = bounds.y_max - bounds.y_min;
896 if bounds.y_min > 0.0 && y_range > 0.0 && bounds.y_min < 0.25 * y_range {
897 bounds.y_min = 0.0;
898 }
899 }
900
901 Ok(bounds)
902}
903
904#[cfg(test)]
905mod tests {
906 use super::*;
907 use crate::grammar::chart::Chart;
908 use crate::grammar::coord::CoordSystem;
909 use crate::grammar::layer::{Layer, MarkType};
910
911 #[test]
912 fn empty_chart_returns_error() {
913 let chart = Chart::new(); let result = compile_chart(&chart);
915 assert!(matches!(result, Err(ChartError::EmptyData)));
916 }
917
918 #[test]
919 fn dimension_mismatch_returns_error() {
920 let layer = Layer::new(MarkType::Point)
921 .with_x(vec![1.0, 2.0, 3.0])
922 .with_y(vec![1.0, 2.0]); let chart = Chart::new().layer(layer);
924 let result = compile_chart(&chart);
925 assert!(matches!(
926 result,
927 Err(ChartError::DimensionMismatch {
928 layer: 0,
929 x_len: 3,
930 y_len: 2
931 })
932 ));
933 }
934
935 #[test]
936 fn bar_chart_zero_inclusion() {
937 let layer = Layer::new(MarkType::Bar)
939 .with_x(vec![0.0, 1.0, 2.0])
940 .with_y(vec![5.0, 10.0, 15.0]);
941 let resolved = stat_transform::resolve_layer(&layer, 0).unwrap();
942 let bounds = compute_resolved_data_bounds(&[resolved]).unwrap();
943 assert!(
944 bounds.y_min <= 0.0,
945 "bar chart should include y=0, got y_min={}",
946 bounds.y_min
947 );
948 }
949
950 #[test]
951 fn flipped_coords_swaps_data() {
952 let layer = Layer::new(MarkType::Bar)
953 .with_x(vec![0.0, 1.0, 2.0])
954 .with_y(vec![10.0, 20.0, 30.0]);
955 let chart = Chart::new().layer(layer).coord(CoordSystem::Flipped);
956 let scene = compile_chart(&chart).unwrap();
957 assert!(scene.root().is_some());
959 }
960
961 #[test]
962 fn single_point_chart_compiles() {
963 let layer = Layer::new(MarkType::Point)
964 .with_x(vec![5.0])
965 .with_y(vec![10.0]);
966 let chart = Chart::new().layer(layer);
967 let scene = compile_chart(&chart).unwrap();
968 assert!(scene.root().is_some());
969 }
970
971 #[test]
974 fn nan_in_x_data_returns_error() {
975 let layer = Layer::new(MarkType::Point)
976 .with_x(vec![1.0, f64::NAN, 3.0])
977 .with_y(vec![1.0, 2.0, 3.0]);
978 let chart = Chart::new().layer(layer);
979 let result = compile_chart(&chart);
980 assert!(matches!(result, Err(ChartError::InvalidData { .. })));
981 }
982
983 #[test]
984 fn inf_in_y_data_returns_error() {
985 let layer = Layer::new(MarkType::Point)
986 .with_x(vec![1.0, 2.0])
987 .with_y(vec![1.0, f64::INFINITY]);
988 let chart = Chart::new().layer(layer);
989 let result = compile_chart(&chart);
990 assert!(matches!(result, Err(ChartError::InvalidData { .. })));
991 }
992
993 #[test]
994 fn mismatched_categories_returns_error_for_points() {
995 let layer = Layer::new(MarkType::Point)
997 .with_x(vec![1.0, 2.0, 3.0])
998 .with_y(vec![1.0, 2.0, 3.0])
999 .with_categories(vec!["A".into(), "B".into()]); let chart = Chart::new().layer(layer);
1001 let result = compile_chart(&chart);
1002 assert!(result.is_err());
1003 }
1004
1005 #[test]
1006 fn mismatched_facet_values_length_returns_error() {
1007 let layer = Layer::new(MarkType::Point)
1008 .with_x(vec![1.0, 2.0, 3.0])
1009 .with_y(vec![1.0, 2.0, 3.0])
1010 .with_facet_values(vec!["A".into()]); let chart = Chart::new().layer(layer);
1012 let result = compile_chart(&chart);
1013 assert!(matches!(result, Err(ChartError::InvalidData { .. })));
1014 }
1015
1016 #[test]
1017 fn text_mark_returns_error() {
1018 let layer = Layer::new(MarkType::Text)
1019 .with_x(vec![1.0])
1020 .with_y(vec![1.0]);
1021 let chart = Chart::new().layer(layer);
1022 let result = compile_chart(&chart);
1023 assert!(matches!(result, Err(ChartError::InvalidParameter(_))));
1024 }
1025
1026 #[test]
1027 fn zero_size_chart_returns_error() {
1028 let layer = Layer::new(MarkType::Point)
1029 .with_x(vec![1.0, 2.0])
1030 .with_y(vec![1.0, 2.0]);
1031 let chart = Chart::new().layer(layer).size(10.0, 10.0);
1032 let result = compile_chart(&chart);
1034 assert!(result.is_err());
1035 }
1036
1037 #[test]
1040 fn scatter_data_gets_padding() {
1041 let layer = Layer::new(MarkType::Point)
1042 .with_x(vec![0.0, 10.0])
1043 .with_y(vec![0.0, 100.0]);
1044 let resolved = stat_transform::resolve_layer(&layer, 0).unwrap();
1045 let bounds = compute_resolved_data_bounds(&[resolved]).unwrap();
1046 assert!(bounds.y_min <= 0.0);
1050 }
1051
1052 #[test]
1053 fn polar_coord_returns_error() {
1054 let layer = Layer::new(MarkType::Point)
1055 .with_x(vec![1.0, 2.0])
1056 .with_y(vec![1.0, 2.0]);
1057 let chart = Chart::new().layer(layer).coord(CoordSystem::Polar);
1058 let result = compile_chart(&chart);
1059 assert!(matches!(result, Err(ChartError::InvalidParameter(_))));
1060 }
1061
1062 #[test]
1063 fn heatmap_nan_returns_error() {
1064 let layer = Layer::new(MarkType::Heatmap)
1065 .with_heatmap_data(vec![vec![1.0, f64::NAN], vec![3.0, 4.0]]);
1066 let chart = Chart::new().layer(layer);
1067 let result = compile_chart(&chart);
1068 assert!(matches!(result, Err(ChartError::InvalidData { .. })));
1069 }
1070
1071 #[test]
1072 fn heatmap_inf_returns_error() {
1073 let layer = Layer::new(MarkType::Heatmap).with_heatmap_data(vec![vec![1.0, f64::INFINITY]]);
1074 let chart = Chart::new().layer(layer);
1075 let result = compile_chart(&chart);
1076 assert!(matches!(result, Err(ChartError::InvalidData { .. })));
1077 }
1078
1079 #[test]
1082 fn explicit_domain_overrides_auto_bounds() {
1083 let layer = Layer::new(MarkType::Point)
1084 .with_x(vec![1.0, 2.0, 3.0])
1085 .with_y(vec![10.0, 20.0, 30.0]);
1086 let chart = Chart::new()
1087 .layer(layer)
1088 .x_domain(0.0, 5.0)
1089 .y_domain(0.0, 50.0);
1090 let scene = compile_chart(&chart).unwrap();
1092 assert!(scene.root().is_some());
1093 }
1094
1095 #[test]
1096 fn domain_with_flipped_coords() {
1097 let layer = Layer::new(MarkType::Bar)
1098 .with_x(vec![0.0, 1.0, 2.0])
1099 .with_y(vec![10.0, 20.0, 30.0]);
1100 let chart = Chart::new()
1101 .layer(layer)
1102 .x_domain(0.0, 5.0)
1103 .y_domain(0.0, 50.0)
1104 .coord(CoordSystem::Flipped);
1105 let scene = compile_chart(&chart).unwrap();
1106 assert!(scene.root().is_some());
1107 }
1108
1109 #[test]
1110 fn error_bars_extend_bounds() {
1111 let layer = Layer::new(MarkType::Bar)
1112 .with_x(vec![0.0, 1.0])
1113 .with_y(vec![10.0, 20.0])
1114 .with_error_bars(vec![5.0, 3.0]);
1115 let resolved = stat_transform::resolve_layer(&layer, 0).unwrap();
1116 let bounds = compute_resolved_data_bounds(&[resolved]).unwrap();
1117 assert!(
1119 bounds.y_max >= 23.0,
1120 "bounds should include error bar extent, got y_max={}",
1121 bounds.y_max
1122 );
1123 assert!(
1125 bounds.y_min <= 5.0,
1126 "bounds should include error bar extent, got y_min={}",
1127 bounds.y_min
1128 );
1129 }
1130
1131 #[test]
1132 fn domain_smaller_than_data_compiles() {
1133 let layer = Layer::new(MarkType::Point)
1134 .with_x(vec![1.0, 2.0, 3.0])
1135 .with_y(vec![10.0, 20.0, 30.0]);
1136 let chart = Chart::new()
1138 .layer(layer)
1139 .x_domain(1.5, 2.5)
1140 .y_domain(15.0, 25.0);
1141 let scene = compile_chart(&chart).unwrap();
1142 assert!(scene.root().is_some());
1143 }
1144}