Skip to main content

esoc_chart/compile/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Chart → SceneGraph compiler.
3
4pub(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
27/// Plot area margins.
28pub(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
36/// Compile a Chart definition into a SceneGraph.
37pub fn compile_chart(chart: &Chart) -> Result<SceneGraph> {
38    if chart.layers.is_empty() {
39        return Err(ChartError::EmptyData);
40    }
41
42    // 1c: Guard against unimplemented Polar coordinates
43    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    // Validate dimensions and data integrity before stat resolution
50    for (i, layer) in chart.layers.iter().enumerate() {
51        // Treemap validation: non-negative values, skip x/y mismatch
52        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; // skip x/y dimension checks for treemap
75        }
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        // 1B: Validate parallel vector lengths
86        // Only validate categories/facet_values against data when both x and y are
87        // present (categories used purely for axis labeling in grouped bars may
88        // intentionally differ in length from per-group x/y data).
89        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        // Validate error bar length matches y_data
105        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        // 1A: Check for NaN/Inf in data
118        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        // 1b: Validate heatmap data for NaN/Inf
147        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    // Resolve stat transforms: Layer → ResolvedLayer
165    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    // Apply position adjustments (stack, dodge, fill, jitter)
173    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    // Compute global data bounds (before margins, since margins depend on tick labels)
181    let mut data_bounds = compute_resolved_data_bounds(&resolved)?;
182
183    // C5: Apply 5% padding for scatter/line/point charts so data points
184    // don't sit on axis frame edges. Bar/area/heatmap skip this since they
185    // have their own domain handling (zero-inclusion, bar padding, etc.).
186    {
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    // Nice the bounds so ticks align exactly with domain edges.
217    // Build preliminary scales, nice them, then extract the niced domain back.
218    {
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    // Bar/area charts: re-clamp y_min to 0 after nicing when the actual data minimum
243    // is at or near zero.  nice() can round y_min to something like -0.05 even when
244    // the smallest data value is -0.0004, creating a large gap that makes bars float
245    // above the x-axis baseline.
246    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    // Bar/area charts must always include the zero baseline to avoid truncated-bar
253    // misleading visuals.  This is applied before domain overrides so users can
254    // still override manually.
255    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    // Apply explicit domain overrides (after nicing so user intent wins)
265    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    // For Flipped coordinate system, swap x/y bounds
275    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    // Compute margins for axes/title (needs data_bounds for tick label measurement)
289    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    // Background
303    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    // Check if faceting is needed
316    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    // Title (with word-wrap for long titles)
348    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    // Subtitle
373    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    // Caption
385    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/// Generate axis labels and category tick labels for heatmap charts.
393#[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    // Column labels (below plot, at each column center)
412    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    // Row labels (left of plot, at each row center)
437    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    // X axis label
462    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    // Y axis label (rotated)
484    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/// Compile a single (non-faceted) panel.
504#[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        // x_label always labels the bottom (horizontal) axis and y_label the
539        // left (vertical) axis, regardless of coord flip — matches matplotlib
540        // / plotly convention so .x_label("Score") / .y_label("Language") on
541        // a flipped (horizontal) bar chart still places "Score" on the bottom.
542        let x_label = chart.x_label.as_deref();
543        let y_label = chart.y_label.as_deref();
544
545        // Determine grid axes based on mark types
546        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        // Extract category labels from bar layers for x-axis labeling
556        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        // When flipped, categories belong on the y-axis, not x-axis
563        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    // Legends
608    let mut legends = legend_gen::collect_legends(resolved, theme);
609    // Apply chart-level legend title
610    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                // Mirror axis_gen's x-label vertical placement so the legend
621                // starts below any rendered x-axis title rather than inside it.
622                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    // Annotations
650    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/// Compile a faceted chart (small multiples).
669#[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    // Account for strip labels in available height
692    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    // Plot area container
697    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    // Use smaller tick font for facet panels to prevent label overlap
701    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        // Panel container offset within the plot area
710        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        // Only show x-labels on bottom row, y-labels on left column
721        let show_x = is_bottom_row;
722        let show_y = is_left_col;
723
724        // Detect if panel layers are all-bar for grid axes and category labels
725        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        // Axes for this panel (use facet theme with smaller ticks)
741        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        // Marks
767        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        // Strip label
782        facet::generate_strip_label(scene, panel_id, &panel.label, rect.w, theme);
783    }
784
785    // Faceted chart legend: collect once for the whole chart, position to the right
786    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    // For Arc-only charts (pie/donut), scales are not used; return dummy bounds
796    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    // For Treemap-only charts, scales are not used; return dummy bounds
804    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    // For Heatmap-only charts, bounds come from matrix dimensions
812    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            // Extend bounds to include error bar extent
842            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    // Bar charts: pad x domain by 0.5 so edge bars don't overflow the plot area
870    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    // Zero-inclusion: bar/area charts must include y=0
879    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        // For line/point: include zero if data minimum is within 25% of the range
894        // above zero, making the chart more contextually honest.
895        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(); // no layers
914        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]); // mismatched lengths
923        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        // Bar y-data all positive — bounds should include y=0
938        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        // Should succeed without error; scene should have nodes
958        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    // ── Phase 1 tests ──
972
973    #[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        // Per-point categories that don't match data length cause batch errors
996        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()]); // 2 cats, 3 data points
1000        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()]); // only 1, not 3
1011        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        // Very small chart may fail if margins exceed dimensions
1033        let result = compile_chart(&chart);
1034        assert!(result.is_err());
1035    }
1036
1037    // ── Phase 2 tests ──
1038
1039    #[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        // With 5% padding applied before nicing in compile_chart,
1047        // the raw bounds for scatter should extend beyond data range
1048        // (checked here at the pre-padding stage to verify the base calculation)
1049        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    // ── Domain override tests ──
1080
1081    #[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        // Should compile without error
1091        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        // y_max should be at least 20+3=23
1118        assert!(
1119            bounds.y_max >= 23.0,
1120            "bounds should include error bar extent, got y_max={}",
1121            bounds.y_max
1122        );
1123        // y_min should be at most 10-5=5 (but bar chart zero-includes to 0)
1124        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        // Domain is smaller than data range — should still compile
1137        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}