Skip to main content

esoc_chart/express/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Express API: one-liner chart creation functions.
3
4use crate::error::Result;
5use crate::grammar::chart::Chart;
6use crate::grammar::facet::Facet;
7use crate::grammar::layer::{Layer, MarkType};
8use crate::grammar::position::Position;
9use crate::grammar::stat::Stat;
10use crate::new_theme::NewTheme;
11
12// ── Shared builder macros ────────────────────────────────────────────
13
14/// Generate common XY chart builder methods (title, x_label, y_label, theme, size, to_svg).
15macro_rules! xy_builder_methods {
16    () => {
17        /// Set title.
18        pub fn title(mut self, title: impl Into<String>) -> Self {
19            self.title = Some(title.into());
20            self
21        }
22
23        /// Set X-axis label.
24        pub fn x_label(mut self, label: impl Into<String>) -> Self {
25            self.x_label = Some(label.into());
26            self
27        }
28
29        /// Set Y-axis label.
30        pub fn y_label(mut self, label: impl Into<String>) -> Self {
31            self.y_label = Some(label.into());
32            self
33        }
34
35        /// Set theme.
36        pub fn theme(mut self, theme: NewTheme) -> Self {
37            self.theme = theme;
38            self
39        }
40
41        /// Set dimensions.
42        pub fn size(mut self, width: f32, height: f32) -> Self {
43            self.width = width;
44            self.height = height;
45            self
46        }
47
48        /// Build and render to SVG.
49        #[allow(deprecated)]
50        pub fn to_svg(self) -> Result<String> {
51            self.build().to_svg()
52        }
53
54        /// Build and save as SVG file.
55        #[allow(deprecated)]
56        pub fn save_svg(self, path: impl AsRef<std::path::Path>) -> Result<()> {
57            let svg = self.build().to_svg()?;
58            std::fs::write(path, svg)?;
59            Ok(())
60        }
61
62        /// Set explicit X-axis domain (overrides auto-computed bounds).
63        pub fn x_domain(mut self, min: f64, max: f64) -> Self {
64            self.x_domain = Some((min, max));
65            self
66        }
67
68        /// Set explicit Y-axis domain (overrides auto-computed bounds).
69        pub fn y_domain(mut self, min: f64, max: f64) -> Self {
70            self.y_domain = Some((min, max));
71            self
72        }
73
74        /// Set point/fill opacity (0.0–1.0).
75        pub fn opacity(mut self, alpha: f32) -> Self {
76            self.opacity = Some(alpha.clamp(0.0, 1.0));
77            self
78        }
79
80        /// Add a horizontal reference line at the given y value.
81        pub fn hline(mut self, y: f64) -> Self {
82            self.annotations
83                .push(crate::grammar::annotation::Annotation::hline(y));
84            self
85        }
86
87        /// Add a vertical reference line at the given x value.
88        pub fn vline(mut self, x: f64) -> Self {
89            self.annotations
90                .push(crate::grammar::annotation::Annotation::vline(x));
91            self
92        }
93    };
94}
95
96/// Generate common pie chart builder methods (title, theme, size, to_svg).
97macro_rules! pie_builder_methods {
98    () => {
99        /// Set title.
100        pub fn title(mut self, title: impl Into<String>) -> Self {
101            self.title = Some(title.into());
102            self
103        }
104
105        /// Set theme.
106        pub fn theme(mut self, theme: NewTheme) -> Self {
107            self.theme = theme;
108            self
109        }
110
111        /// Set dimensions.
112        pub fn size(mut self, width: f32, height: f32) -> Self {
113            self.width = width;
114            self.height = height;
115            self
116        }
117
118        /// Build and render to SVG.
119        pub fn to_svg(self) -> Result<String> {
120            self.build().to_svg()
121        }
122
123        /// Build and save as SVG file.
124        pub fn save_svg(self, path: impl AsRef<std::path::Path>) -> Result<()> {
125            let svg = self.build().to_svg()?;
126            std::fs::write(path, svg)?;
127            Ok(())
128        }
129    };
130}
131
132/// Apply optional title/x_label/y_label to a Chart.
133macro_rules! apply_chart_labels {
134    (xy: $chart:expr, $self:expr) => {{
135        let mut chart = $chart;
136        if let Some(t) = $self.title {
137            chart = chart.title(t);
138        }
139        if let Some(l) = $self.x_label {
140            chart = chart.x_label(l);
141        }
142        if let Some(l) = $self.y_label {
143            chart = chart.y_label(l);
144        }
145        chart
146    }};
147    (pie: $chart:expr, $self:expr) => {{
148        let mut chart = $chart;
149        if let Some(t) = $self.title {
150            chart = chart.title(t);
151        }
152        chart
153    }};
154}
155
156// ── Scatter ──────────────────────────────────────────────────────────
157
158/// Create a scatter plot.
159#[must_use]
160pub fn scatter(x: &[f64], y: &[f64]) -> ScatterBuilder {
161    ScatterBuilder {
162        x: x.to_vec(),
163        y: y.to_vec(),
164        categories: None,
165        facet_values: None,
166        facet_ncol: 2,
167        title: None,
168        x_label: None,
169        y_label: None,
170        theme: NewTheme::default(),
171        width: 800.0,
172        height: 600.0,
173        x_domain: None,
174        y_domain: None,
175        opacity: None,
176        error_bars: None,
177        add_trend: false,
178        annotations: Vec::new(),
179    }
180}
181
182/// Builder for scatter plots.
183pub struct ScatterBuilder {
184    x: Vec<f64>,
185    y: Vec<f64>,
186    categories: Option<Vec<String>>,
187    facet_values: Option<Vec<String>>,
188    facet_ncol: usize,
189    title: Option<String>,
190    x_label: Option<String>,
191    y_label: Option<String>,
192    theme: NewTheme,
193    width: f32,
194    height: f32,
195    x_domain: Option<(f64, f64)>,
196    y_domain: Option<(f64, f64)>,
197    opacity: Option<f32>,
198    error_bars: Option<Vec<f64>>,
199    add_trend: bool,
200    annotations: Vec<crate::grammar::annotation::Annotation>,
201}
202
203impl ScatterBuilder {
204    xy_builder_methods!();
205
206    /// Color points by category.
207    pub fn color_by(mut self, categories: &[impl ToString]) -> Self {
208        self.categories = Some(categories.iter().map(|c| c.to_string()).collect());
209        self
210    }
211
212    /// Enable facet wrapping (small multiples) with per-row facet assignments.
213    pub fn facet_wrap(mut self, facet_values: &[impl ToString], ncol: usize) -> Self {
214        self.facet_values = Some(facet_values.iter().map(|v| v.to_string()).collect());
215        self.facet_ncol = ncol;
216        self
217    }
218
219    /// Set symmetric error bar values (±err per point).
220    pub fn error_bars(mut self, errors: &[f64]) -> Self {
221        self.error_bars = Some(errors.to_vec());
222        self
223    }
224
225    /// Add a LOESS trend line overlay.
226    pub fn trend_line(mut self) -> Self {
227        self.add_trend = true;
228        self
229    }
230
231    /// Build the chart.
232    pub fn build(self) -> Chart {
233        let mut layer = Layer::new(MarkType::Point)
234            .with_x(self.x.clone())
235            .with_y(self.y.clone());
236        if let Some(cats) = self.categories {
237            layer = layer.with_categories(cats);
238        }
239        if let Some(fv) = self.facet_values {
240            layer = layer.with_facet_values(fv);
241        }
242        if let Some(eb) = self.error_bars {
243            layer = layer.with_error_bars(eb);
244        }
245        let mut chart = Chart::new()
246            .layer(layer)
247            .size(self.width, self.height)
248            .theme(self.theme.clone());
249        // Add LOESS trend line as a second layer
250        if self.add_trend && self.x.len() >= 3 {
251            let trend_layer = Layer::new(MarkType::Line)
252                .with_x(self.x)
253                .with_y(self.y)
254                .stat(Stat::Smooth { bandwidth: 0.3 });
255            chart = chart.layer(trend_layer);
256        }
257        if (!matches!(chart.facet, Facet::None) || self.facet_ncol > 0)
258            && chart.layers.iter().any(|l| l.facet_values.is_some())
259        {
260            chart = chart.facet(Facet::Wrap {
261                ncol: self.facet_ncol,
262            });
263        }
264        if let Some((lo, hi)) = self.x_domain {
265            chart = chart.x_domain(lo, hi);
266        }
267        if let Some((lo, hi)) = self.y_domain {
268            chart = chart.y_domain(lo, hi);
269        }
270        for ann in self.annotations {
271            chart = chart.annotate(ann);
272        }
273        apply_chart_labels!(xy: chart, self)
274    }
275}
276
277// ── Line ─────────────────────────────────────────────────────────────
278
279/// Create a line chart.
280#[must_use]
281pub fn line(x: &[f64], y: &[f64]) -> LineBuilder {
282    LineBuilder {
283        x: x.to_vec(),
284        y: y.to_vec(),
285        categories: None,
286        title: None,
287        x_label: None,
288        y_label: None,
289        theme: NewTheme::default(),
290        width: 800.0,
291        height: 600.0,
292        x_domain: None,
293        y_domain: None,
294        opacity: None,
295        annotations: Vec::new(),
296    }
297}
298
299/// Builder for line charts.
300pub struct LineBuilder {
301    x: Vec<f64>,
302    y: Vec<f64>,
303    categories: Option<Vec<String>>,
304    title: Option<String>,
305    x_label: Option<String>,
306    y_label: Option<String>,
307    theme: NewTheme,
308    width: f32,
309    height: f32,
310    x_domain: Option<(f64, f64)>,
311    y_domain: Option<(f64, f64)>,
312    opacity: Option<f32>,
313    annotations: Vec<crate::grammar::annotation::Annotation>,
314}
315
316impl LineBuilder {
317    xy_builder_methods!();
318
319    /// Color lines by category.
320    pub fn color_by(mut self, categories: &[impl ToString]) -> Self {
321        self.categories = Some(categories.iter().map(|c| c.to_string()).collect());
322        self
323    }
324
325    /// Build the chart.
326    pub fn build(self) -> Chart {
327        let mut layer = Layer::new(MarkType::Line).with_x(self.x).with_y(self.y);
328        if let Some(cats) = self.categories {
329            layer = layer.with_categories(cats);
330        }
331        let mut chart = Chart::new()
332            .layer(layer)
333            .size(self.width, self.height)
334            .theme(self.theme);
335        if let Some((lo, hi)) = self.x_domain {
336            chart = chart.x_domain(lo, hi);
337        }
338        if let Some((lo, hi)) = self.y_domain {
339            chart = chart.y_domain(lo, hi);
340        }
341        for ann in self.annotations {
342            chart = chart.annotate(ann);
343        }
344        apply_chart_labels!(xy: chart, self)
345    }
346}
347
348// ── Bar ──────────────────────────────────────────────────────────────
349
350/// Create a bar chart.
351#[must_use]
352pub fn bar(categories: &[impl ToString], values: &[f64]) -> BarBuilder {
353    let x: Vec<f64> = (0..categories.len()).map(|i| i as f64).collect();
354    BarBuilder {
355        x,
356        y: values.to_vec(),
357        labels: categories.iter().map(|c| c.to_string()).collect(),
358        error_bars: None,
359        title: None,
360        x_label: None,
361        y_label: None,
362        theme: NewTheme::default(),
363        width: 800.0,
364        height: 600.0,
365        x_domain: None,
366        y_domain: None,
367        opacity: None,
368        annotations: Vec::new(),
369    }
370}
371
372/// Builder for bar charts.
373pub struct BarBuilder {
374    x: Vec<f64>,
375    y: Vec<f64>,
376    labels: Vec<String>,
377    error_bars: Option<Vec<f64>>,
378    title: Option<String>,
379    x_label: Option<String>,
380    y_label: Option<String>,
381    theme: NewTheme,
382    width: f32,
383    height: f32,
384    x_domain: Option<(f64, f64)>,
385    y_domain: Option<(f64, f64)>,
386    opacity: Option<f32>,
387    annotations: Vec<crate::grammar::annotation::Annotation>,
388}
389
390impl BarBuilder {
391    xy_builder_methods!();
392
393    /// Set symmetric error bar values (±err per bar).
394    pub fn error_bars(mut self, errors: &[f64]) -> Self {
395        self.error_bars = Some(errors.to_vec());
396        self
397    }
398
399    /// Build the chart.
400    pub fn build(self) -> Chart {
401        let mut layer = Layer::new(MarkType::Bar)
402            .with_x(self.x)
403            .with_y(self.y)
404            .with_categories(self.labels);
405        if let Some(eb) = self.error_bars {
406            layer = layer.with_error_bars(eb);
407        }
408        let mut chart = Chart::new()
409            .layer(layer)
410            .size(self.width, self.height)
411            .theme(self.theme);
412        if let Some((lo, hi)) = self.x_domain {
413            chart = chart.x_domain(lo, hi);
414        }
415        if let Some((lo, hi)) = self.y_domain {
416            chart = chart.y_domain(lo, hi);
417        }
418        for ann in self.annotations {
419            chart = chart.annotate(ann);
420        }
421        apply_chart_labels!(xy: chart, self)
422    }
423}
424
425// ── Histogram ────────────────────────────────────────────────────────
426
427/// Create a histogram from raw values.
428#[must_use]
429pub fn histogram(values: &[f64]) -> HistogramBuilder {
430    HistogramBuilder {
431        values: values.to_vec(),
432        bins: 0,
433        title: None,
434        x_label: None,
435        y_label: None,
436        theme: NewTheme::default(),
437        width: 800.0,
438        height: 600.0,
439        x_domain: None,
440        y_domain: None,
441        opacity: None,
442        annotations: Vec::new(),
443    }
444}
445
446/// Builder for histograms.
447pub struct HistogramBuilder {
448    values: Vec<f64>,
449    bins: usize,
450    title: Option<String>,
451    x_label: Option<String>,
452    y_label: Option<String>,
453    theme: NewTheme,
454    width: f32,
455    height: f32,
456    x_domain: Option<(f64, f64)>,
457    y_domain: Option<(f64, f64)>,
458    opacity: Option<f32>,
459    annotations: Vec<crate::grammar::annotation::Annotation>,
460}
461
462impl HistogramBuilder {
463    xy_builder_methods!();
464
465    /// Set the number of bins.
466    pub fn bins(mut self, bins: usize) -> Self {
467        self.bins = bins;
468        self
469    }
470
471    /// Build the chart.
472    pub fn build(self) -> Chart {
473        let layer = Layer::new(MarkType::Bar)
474            .with_x(self.values)
475            .stat(Stat::Bin { bins: self.bins });
476        let mut chart = Chart::new()
477            .layer(layer)
478            .size(self.width, self.height)
479            .theme(self.theme);
480        if let Some((lo, hi)) = self.x_domain {
481            chart = chart.x_domain(lo, hi);
482        }
483        if let Some((lo, hi)) = self.y_domain {
484            chart = chart.y_domain(lo, hi);
485        }
486        for ann in self.annotations {
487            chart = chart.annotate(ann);
488        }
489        apply_chart_labels!(xy: chart, self)
490    }
491}
492
493// ── BoxPlot ──────────────────────────────────────────────────────────
494
495/// Create a box plot.
496#[must_use]
497pub fn boxplot(categories: &[impl ToString], values: &[f64]) -> BoxPlotBuilder {
498    BoxPlotBuilder {
499        categories: categories.iter().map(|c| c.to_string()).collect(),
500        values: values.to_vec(),
501        title: None,
502        x_label: None,
503        y_label: None,
504        theme: NewTheme::default(),
505        width: 800.0,
506        height: 600.0,
507        x_domain: None,
508        y_domain: None,
509        opacity: None,
510        annotations: Vec::new(),
511    }
512}
513
514/// Builder for box plots.
515pub struct BoxPlotBuilder {
516    categories: Vec<String>,
517    values: Vec<f64>,
518    title: Option<String>,
519    x_label: Option<String>,
520    y_label: Option<String>,
521    theme: NewTheme,
522    width: f32,
523    height: f32,
524    x_domain: Option<(f64, f64)>,
525    y_domain: Option<(f64, f64)>,
526    opacity: Option<f32>,
527    annotations: Vec<crate::grammar::annotation::Annotation>,
528}
529
530impl BoxPlotBuilder {
531    xy_builder_methods!();
532
533    /// Build the chart.
534    pub fn build(self) -> Chart {
535        let layer = Layer::new(MarkType::Bar)
536            .with_y(self.values)
537            .with_categories(self.categories)
538            .stat(Stat::BoxPlot);
539        let mut chart = Chart::new()
540            .layer(layer)
541            .size(self.width, self.height)
542            .theme(self.theme);
543        if let Some((lo, hi)) = self.x_domain {
544            chart = chart.x_domain(lo, hi);
545        }
546        if let Some((lo, hi)) = self.y_domain {
547            chart = chart.y_domain(lo, hi);
548        }
549        for ann in self.annotations {
550            chart = chart.annotate(ann);
551        }
552        apply_chart_labels!(xy: chart, self)
553    }
554}
555
556// ── Area ─────────────────────────────────────────────────────────────
557
558/// Create an area chart.
559#[must_use]
560pub fn area(x: &[f64], y: &[f64]) -> AreaBuilder {
561    AreaBuilder {
562        x: x.to_vec(),
563        y: y.to_vec(),
564        categories: None,
565        title: None,
566        x_label: None,
567        y_label: None,
568        theme: NewTheme::default(),
569        width: 800.0,
570        height: 600.0,
571        x_domain: None,
572        y_domain: None,
573        opacity: None,
574        annotations: Vec::new(),
575    }
576}
577
578/// Builder for area charts.
579pub struct AreaBuilder {
580    x: Vec<f64>,
581    y: Vec<f64>,
582    categories: Option<Vec<String>>,
583    title: Option<String>,
584    x_label: Option<String>,
585    y_label: Option<String>,
586    theme: NewTheme,
587    width: f32,
588    height: f32,
589    x_domain: Option<(f64, f64)>,
590    y_domain: Option<(f64, f64)>,
591    opacity: Option<f32>,
592    annotations: Vec<crate::grammar::annotation::Annotation>,
593}
594
595impl AreaBuilder {
596    xy_builder_methods!();
597
598    /// Color areas by category.
599    pub fn color_by(mut self, categories: &[impl ToString]) -> Self {
600        self.categories = Some(categories.iter().map(|c| c.to_string()).collect());
601        self
602    }
603
604    /// Build the chart.
605    pub fn build(self) -> Chart {
606        let mut layer = Layer::new(MarkType::Area).with_x(self.x).with_y(self.y);
607        if let Some(cats) = self.categories {
608            layer = layer.with_categories(cats);
609        }
610        let mut chart = Chart::new()
611            .layer(layer)
612            .size(self.width, self.height)
613            .theme(self.theme);
614        if let Some((lo, hi)) = self.x_domain {
615            chart = chart.x_domain(lo, hi);
616        }
617        if let Some((lo, hi)) = self.y_domain {
618            chart = chart.y_domain(lo, hi);
619        }
620        for ann in self.annotations {
621            chart = chart.annotate(ann);
622        }
623        apply_chart_labels!(xy: chart, self)
624    }
625}
626
627// ── Pie ──────────────────────────────────────────────────────────────
628
629/// Create a pie chart with consistent (labels, values) parameter order.
630#[must_use]
631pub fn pie_labeled(labels: &[impl ToString], values: &[f64]) -> PieBuilder {
632    PieBuilder {
633        values: values.to_vec(),
634        labels: labels.iter().map(|l| l.to_string()).collect(),
635        inner_fraction: 0.0,
636        title: None,
637        theme: NewTheme::default(),
638        width: 600.0,
639        height: 600.0,
640    }
641}
642
643/// Create a pie chart.
644///
645/// Note: parameter order is `(values, labels)`. Prefer [`pie_labeled`] which uses
646/// the consistent `(labels, values)` order matching `bar()` and `treemap()`.
647#[deprecated(note = "Use pie_labeled(labels, values) for consistent parameter order")]
648pub fn pie(values: &[f64], labels: &[impl ToString]) -> PieBuilder {
649    PieBuilder {
650        values: values.to_vec(),
651        labels: labels.iter().map(|l| l.to_string()).collect(),
652        inner_fraction: 0.0,
653        title: None,
654        theme: NewTheme::default(),
655        width: 600.0,
656        height: 600.0,
657    }
658}
659
660/// Builder for pie/donut charts.
661pub struct PieBuilder {
662    values: Vec<f64>,
663    labels: Vec<String>,
664    inner_fraction: f32,
665    title: Option<String>,
666    theme: NewTheme,
667    width: f32,
668    height: f32,
669}
670
671impl PieBuilder {
672    pie_builder_methods!();
673
674    /// Make it a donut chart with given inner radius fraction (0.0–0.9).
675    pub fn donut(mut self, inner_fraction: f32) -> Self {
676        self.inner_fraction = inner_fraction.clamp(0.0, 0.9);
677        self
678    }
679
680    /// Build the chart.
681    pub fn build(self) -> Chart {
682        let layer = Layer::new(MarkType::Arc)
683            .with_y(self.values)
684            .with_categories(self.labels)
685            .with_inner_radius_fraction(self.inner_fraction);
686        let chart = Chart::new()
687            .layer(layer)
688            .size(self.width, self.height)
689            .theme(self.theme);
690        apply_chart_labels!(pie: chart, self)
691    }
692}
693
694// ── Treemap ──────────────────────────────────────────────────────────
695
696/// Create a treemap chart.
697#[must_use]
698pub fn treemap(labels: &[impl ToString], values: &[f64]) -> TreemapBuilder {
699    TreemapBuilder {
700        labels: labels.iter().map(|l| l.to_string()).collect(),
701        values: values.to_vec(),
702        title: None,
703        theme: NewTheme::default(),
704        width: 600.0,
705        height: 600.0,
706    }
707}
708
709/// Builder for treemap charts.
710pub struct TreemapBuilder {
711    labels: Vec<String>,
712    values: Vec<f64>,
713    title: Option<String>,
714    theme: NewTheme,
715    width: f32,
716    height: f32,
717}
718
719impl TreemapBuilder {
720    pie_builder_methods!();
721
722    /// Build the chart.
723    pub fn build(self) -> Chart {
724        let layer = Layer::new(MarkType::Treemap)
725            .with_y(self.values)
726            .with_categories(self.labels);
727        let chart = Chart::new()
728            .layer(layer)
729            .size(self.width, self.height)
730            .theme(self.theme);
731        apply_chart_labels!(pie: chart, self)
732    }
733}
734
735// ── Multi-series Bar (Stacked / Grouped) ─────────────────────────────
736
737/// Builder for multi-series bar charts (stacked or grouped).
738pub struct MultiBarBuilder {
739    categories: Vec<String>,
740    groups: Vec<String>,
741    values: Vec<f64>,
742    position: Position,
743    title: Option<String>,
744    x_label: Option<String>,
745    y_label: Option<String>,
746    theme: NewTheme,
747    width: f32,
748    height: f32,
749    x_domain: Option<(f64, f64)>,
750    y_domain: Option<(f64, f64)>,
751    opacity: Option<f32>,
752    annotations: Vec<crate::grammar::annotation::Annotation>,
753}
754
755/// Backward-compatible alias for stacked bar builder.
756pub type StackedBarBuilder = MultiBarBuilder;
757/// Backward-compatible alias for grouped bar builder.
758pub type GroupedBarBuilder = MultiBarBuilder;
759
760/// Create a stacked bar chart.
761///
762/// `categories` defines the x-axis groups, `groups` assigns each value to a series,
763/// and `values` is a flat array of values. The data is split into per-group layers internally.
764#[must_use]
765pub fn stacked_bar(
766    categories: &[impl ToString],
767    groups: &[impl ToString],
768    values: &[f64],
769) -> MultiBarBuilder {
770    MultiBarBuilder {
771        categories: categories.iter().map(|c| c.to_string()).collect(),
772        groups: groups.iter().map(|g| g.to_string()).collect(),
773        values: values.to_vec(),
774        position: Position::Stack,
775        title: None,
776        x_label: None,
777        y_label: None,
778        theme: NewTheme::default(),
779        width: 800.0,
780        height: 600.0,
781        x_domain: None,
782        y_domain: None,
783        opacity: None,
784        annotations: Vec::new(),
785    }
786}
787
788/// Create a grouped (dodged) bar chart.
789#[must_use]
790pub fn grouped_bar(
791    categories: &[impl ToString],
792    groups: &[impl ToString],
793    values: &[f64],
794) -> MultiBarBuilder {
795    MultiBarBuilder {
796        categories: categories.iter().map(|c| c.to_string()).collect(),
797        groups: groups.iter().map(|g| g.to_string()).collect(),
798        values: values.to_vec(),
799        position: Position::Dodge,
800        title: None,
801        x_label: None,
802        y_label: None,
803        theme: NewTheme::default(),
804        width: 800.0,
805        height: 600.0,
806        x_domain: None,
807        y_domain: None,
808        opacity: None,
809        annotations: Vec::new(),
810    }
811}
812
813impl MultiBarBuilder {
814    xy_builder_methods!();
815
816    /// Build the chart, returning an error if inputs are invalid.
817    pub fn try_build(self) -> std::result::Result<Chart, String> {
818        let config = ChartConfig {
819            title: self.title,
820            x_label: self.x_label,
821            y_label: self.y_label,
822            theme: self.theme,
823            width: self.width,
824            height: self.height,
825        };
826        let mut chart = try_build_grouped_chart(
827            self.categories,
828            self.groups,
829            self.values,
830            self.position,
831            config,
832        )?;
833        if let Some((lo, hi)) = self.x_domain {
834            chart = chart.x_domain(lo, hi);
835        }
836        if let Some((lo, hi)) = self.y_domain {
837            chart = chart.y_domain(lo, hi);
838        }
839        for ann in self.annotations {
840            chart = chart.annotate(ann);
841        }
842        Ok(chart)
843    }
844
845    /// Build the chart. Panics if categories, groups, and values have different lengths.
846    ///
847    /// Prefer [`try_build()`](Self::try_build) which returns a `Result` instead of panicking.
848    #[allow(deprecated)]
849    #[deprecated(note = "Use try_build() instead — build() panics on invalid input")]
850    pub fn build(self) -> Chart {
851        self.try_build().expect("MultiBarBuilder::build() failed")
852    }
853}
854
855// ── Shared grouped chart logic ───────────────────────────────────────
856
857/// Configuration shared across chart builders.
858struct ChartConfig {
859    title: Option<String>,
860    x_label: Option<String>,
861    y_label: Option<String>,
862    theme: NewTheme,
863    width: f32,
864    height: f32,
865}
866
867/// Shared logic for stacked/grouped bar chart construction.
868/// Returns `Err` if categories, groups, and values have different lengths.
869fn try_build_grouped_chart(
870    categories: Vec<String>,
871    groups: Vec<String>,
872    values: Vec<f64>,
873    position: Position,
874    config: ChartConfig,
875) -> std::result::Result<Chart, String> {
876    use std::collections::HashSet;
877
878    // Validate parallel vector lengths
879    if categories.len() != groups.len() || categories.len() != values.len() {
880        return Err(format!(
881            "categories ({}), groups ({}), and values ({}) must have the same length",
882            categories.len(),
883            groups.len(),
884            values.len(),
885        ));
886    }
887
888    let mut unique_cats: Vec<String> = Vec::new();
889    let mut seen_cats = HashSet::new();
890    for c in &categories {
891        if seen_cats.insert(c.as_str()) {
892            unique_cats.push(c.clone());
893        }
894    }
895
896    let mut unique_groups: Vec<String> = Vec::new();
897    let mut seen_groups = HashSet::new();
898    for g in &groups {
899        if seen_groups.insert(g.as_str()) {
900            unique_groups.push(g.clone());
901        }
902    }
903
904    let cat_idx: std::collections::HashMap<&str, f64> = unique_cats
905        .iter()
906        .enumerate()
907        .map(|(i, c)| (c.as_str(), i as f64))
908        .collect();
909
910    let mut chart = Chart::new()
911        .size(config.width, config.height)
912        .theme(config.theme);
913
914    for group in &unique_groups {
915        let mut x_data = Vec::new();
916        let mut y_data = Vec::new();
917        for (i, g) in groups.iter().enumerate() {
918            if g == group {
919                if let Some(&x) = cat_idx.get(categories[i].as_str()) {
920                    x_data.push(x);
921                    y_data.push(values[i]);
922                }
923            }
924        }
925        let mut layer = Layer::new(MarkType::Bar)
926            .with_x(x_data)
927            .with_y(y_data)
928            .with_label(group.clone())
929            .position(position);
930        // All layers carry category labels (needed for axis label lookup via find_map)
931        layer = layer.with_categories(unique_cats.clone());
932        chart = chart.layer(layer);
933    }
934
935    if let Some(t) = config.title {
936        chart = chart.title(t);
937    }
938    if let Some(l) = config.x_label {
939        chart = chart.x_label(l);
940    }
941    if let Some(l) = config.y_label {
942        chart = chart.y_label(l);
943    }
944    Ok(chart)
945}
946
947// ── Heatmap ─────────────────────────────────────────────────────────
948
949/// Create a heatmap from a 2D matrix (takes ownership).
950///
951/// Prefer [`heatmap_ref`] to avoid cloning when you already have the data.
952#[must_use]
953pub fn heatmap(data: Vec<Vec<f64>>) -> HeatmapBuilder {
954    HeatmapBuilder {
955        data,
956        row_labels: None,
957        col_labels: None,
958        annotate: false,
959        title: None,
960        x_label: None,
961        y_label: None,
962        theme: NewTheme::default(),
963        width: 600.0,
964        height: 600.0,
965        x_domain: None,
966        y_domain: None,
967        opacity: None,
968        annotations: Vec::new(),
969    }
970}
971
972/// Create a heatmap from a borrowed 2D slice.
973#[must_use]
974pub fn heatmap_ref(data: &[Vec<f64>]) -> HeatmapBuilder {
975    HeatmapBuilder {
976        data: data.to_vec(),
977        row_labels: None,
978        col_labels: None,
979        annotate: false,
980        title: None,
981        x_label: None,
982        y_label: None,
983        theme: NewTheme::default(),
984        width: 600.0,
985        height: 600.0,
986        x_domain: None,
987        y_domain: None,
988        opacity: None,
989        annotations: Vec::new(),
990    }
991}
992
993/// Builder for heatmap charts.
994pub struct HeatmapBuilder {
995    data: Vec<Vec<f64>>,
996    row_labels: Option<Vec<String>>,
997    col_labels: Option<Vec<String>>,
998    annotate: bool,
999    title: Option<String>,
1000    x_label: Option<String>,
1001    y_label: Option<String>,
1002    theme: NewTheme,
1003    width: f32,
1004    height: f32,
1005    x_domain: Option<(f64, f64)>,
1006    y_domain: Option<(f64, f64)>,
1007    opacity: Option<f32>,
1008    annotations: Vec<crate::grammar::annotation::Annotation>,
1009}
1010
1011impl HeatmapBuilder {
1012    xy_builder_methods!();
1013
1014    /// Enable cell value annotations.
1015    pub fn annotate(mut self) -> Self {
1016        self.annotate = true;
1017        self
1018    }
1019
1020    /// Set row labels (owned).
1021    #[deprecated(note = "Use with_row_labels(&[impl ToString]) instead")]
1022    pub fn row_labels(mut self, labels: Vec<String>) -> Self {
1023        self.row_labels = Some(labels);
1024        self
1025    }
1026
1027    /// Set column labels (owned).
1028    #[deprecated(note = "Use with_col_labels(&[impl ToString]) instead")]
1029    pub fn col_labels(mut self, labels: Vec<String>) -> Self {
1030        self.col_labels = Some(labels);
1031        self
1032    }
1033
1034    /// Set row labels from any string-like slice.
1035    pub fn with_row_labels(mut self, labels: &[impl ToString]) -> Self {
1036        self.row_labels = Some(labels.iter().map(|l| l.to_string()).collect());
1037        self
1038    }
1039
1040    /// Set column labels from any string-like slice.
1041    pub fn with_col_labels(mut self, labels: &[impl ToString]) -> Self {
1042        self.col_labels = Some(labels.iter().map(|l| l.to_string()).collect());
1043        self
1044    }
1045
1046    /// Build the chart.
1047    pub fn build(self) -> Chart {
1048        let mut layer = Layer::new(MarkType::Heatmap).with_heatmap_data(self.data);
1049        if let Some(rl) = self.row_labels {
1050            layer = layer.with_row_labels(rl);
1051        }
1052        if let Some(cl) = self.col_labels {
1053            layer = layer.with_col_labels(cl);
1054        }
1055        if self.annotate {
1056            layer = layer.annotate_cells();
1057        }
1058        let mut chart = Chart::new()
1059            .layer(layer)
1060            .size(self.width, self.height)
1061            .theme(self.theme);
1062        if let Some((lo, hi)) = self.x_domain {
1063            chart = chart.x_domain(lo, hi);
1064        }
1065        if let Some((lo, hi)) = self.y_domain {
1066            chart = chart.y_domain(lo, hi);
1067        }
1068        for ann in self.annotations {
1069            chart = chart.annotate(ann);
1070        }
1071        apply_chart_labels!(xy: chart, self)
1072    }
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077    use super::*;
1078
1079    #[test]
1080    fn scatter_builds_svg() {
1081        let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1082        let y = vec![2.0, 4.0, 1.0, 5.0, 3.0];
1083        let svg = scatter(&x, &y).title("Test Scatter").to_svg().unwrap();
1084        assert!(svg.contains("<svg"));
1085        assert!(svg.contains("</svg>"));
1086        assert!(svg.contains("<circle"));
1087    }
1088
1089    #[test]
1090    fn scatter_with_categories() {
1091        let x = vec![1.0, 2.0, 3.0, 4.0];
1092        let y = vec![2.0, 4.0, 1.0, 5.0];
1093        let cats = vec!["A", "B", "A", "B"];
1094        let svg = scatter(&x, &y)
1095            .color_by(&cats)
1096            .title("Colored Scatter")
1097            .to_svg()
1098            .unwrap();
1099        assert!(svg.contains("<circle"));
1100    }
1101
1102    #[test]
1103    fn line_builds_svg() {
1104        let x = vec![0.0, 1.0, 2.0, 3.0];
1105        let y = vec![0.0, 1.0, 4.0, 9.0];
1106        let svg = line(&x, &y)
1107            .title("Quadratic")
1108            .x_label("x")
1109            .y_label("y")
1110            .to_svg()
1111            .unwrap();
1112        assert!(svg.contains("<polyline"));
1113    }
1114
1115    #[test]
1116    fn bar_builds_svg() {
1117        let cats = vec!["A", "B", "C"];
1118        let vals = vec![10.0, 25.0, 15.0];
1119        let svg = bar(&cats, &vals).title("Bar Chart").to_svg().unwrap();
1120        assert!(svg.contains("<rect"));
1121    }
1122
1123    #[test]
1124    fn histogram_builds_svg() {
1125        let data: Vec<f64> = (0..100).map(|i| f64::from(i) * 0.1).collect();
1126        let svg = histogram(&data).title("Histogram").to_svg().unwrap();
1127        assert!(svg.contains("<svg"));
1128        assert!(svg.contains("<rect"));
1129    }
1130
1131    #[test]
1132    fn histogram_with_bins() {
1133        let data: Vec<f64> = (0..50).map(f64::from).collect();
1134        let svg = histogram(&data).bins(5).title("5 Bins").to_svg().unwrap();
1135        assert!(svg.contains("<rect"));
1136    }
1137
1138    #[test]
1139    fn area_builds_svg() {
1140        let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
1141        let y = vec![1.0, 3.0, 2.0, 5.0, 4.0];
1142        let svg = area(&x, &y).title("Area Chart").to_svg().unwrap();
1143        assert!(svg.contains("<svg"));
1144        assert!(svg.contains("<path"));
1145    }
1146
1147    #[test]
1148    fn pie_builds_svg() {
1149        let values = vec![30.0, 20.0, 50.0];
1150        let labels = vec!["A", "B", "C"];
1151        let svg = pie_labeled(&labels, &values)
1152            .title("Pie Chart")
1153            .to_svg()
1154            .unwrap();
1155        assert!(svg.contains("<svg"));
1156        assert!(!svg.contains("<line") || svg.contains("<path"));
1157    }
1158
1159    #[test]
1160    fn pie_equal_values() {
1161        let values = vec![1.0, 1.0, 1.0];
1162        let labels = vec!["X", "Y", "Z"];
1163        let svg = pie_labeled(&labels, &values).to_svg().unwrap();
1164        assert!(svg.contains("<svg"));
1165    }
1166
1167    #[test]
1168    fn donut_builds_svg() {
1169        let values = vec![40.0, 60.0];
1170        let labels = vec!["Yes", "No"];
1171        let svg = pie_labeled(&labels, &values).donut(0.5).to_svg().unwrap();
1172        assert!(svg.contains("<svg"));
1173    }
1174
1175    #[test]
1176    fn boxplot_builds_svg() {
1177        let cats = vec!["A", "A", "A", "A", "A", "B", "B", "B", "B", "B"];
1178        let vals = vec![1.0, 2.0, 3.0, 4.0, 5.0, 2.0, 4.0, 6.0, 8.0, 10.0];
1179        let svg = boxplot(&cats, &vals).title("Box Plot").to_svg().unwrap();
1180        assert!(svg.contains("<svg"));
1181        assert!(svg.contains("<rect"));
1182        assert!(svg.contains("<line"));
1183    }
1184
1185    #[test]
1186    fn annotation_hline() {
1187        use crate::grammar::annotation::Annotation;
1188        let x = vec![1.0, 2.0, 3.0];
1189        let y = vec![10.0, 20.0, 30.0];
1190        let chart = scatter(&x, &y).build().annotate(Annotation::hline(15.0));
1191        let svg = chart.to_svg().unwrap();
1192        assert!(svg.contains("<line"));
1193    }
1194
1195    #[test]
1196    fn subtitle_and_caption() {
1197        use crate::grammar::chart::Chart;
1198        use crate::grammar::layer::{Layer, MarkType};
1199        let chart = Chart::new()
1200            .layer(
1201                Layer::new(MarkType::Point)
1202                    .with_x(vec![1.0])
1203                    .with_y(vec![1.0]),
1204            )
1205            .title("Title")
1206            .subtitle("Subtitle here")
1207            .caption("Source: data");
1208        let svg = chart.to_svg().unwrap();
1209        assert!(svg.contains("Subtitle here"));
1210        assert!(svg.contains("Source: data"));
1211    }
1212
1213    #[test]
1214    fn legend_with_categories() {
1215        let x = vec![1.0, 2.0, 3.0, 4.0];
1216        let y = vec![10.0, 20.0, 15.0, 25.0];
1217        let cats = vec!["Group A", "Group B", "Group A", "Group B"];
1218        let svg = scatter(&x, &y).color_by(&cats).to_svg().unwrap();
1219        assert!(svg.contains("Group A"));
1220        assert!(svg.contains("Group B"));
1221    }
1222
1223    #[test]
1224    fn flipped_coord_bar() {
1225        use crate::grammar::chart::Chart;
1226        use crate::grammar::coord::CoordSystem;
1227        use crate::grammar::layer::{Layer, MarkType};
1228        let chart = Chart::new()
1229            .layer(
1230                Layer::new(MarkType::Bar)
1231                    .with_x(vec![0.0, 1.0, 2.0])
1232                    .with_y(vec![10.0, 20.0, 30.0]),
1233            )
1234            .coord(CoordSystem::Flipped)
1235            .title("Horizontal Bars");
1236        let svg = chart.to_svg().unwrap();
1237        assert!(svg.contains("<rect"));
1238    }
1239
1240    #[test]
1241    fn stacked_bar_builds_svg() {
1242        let cats = vec!["Q1", "Q2", "Q3", "Q1", "Q2", "Q3"];
1243        let groups = vec!["A", "A", "A", "B", "B", "B"];
1244        let vals = vec![10.0, 20.0, 30.0, 5.0, 15.0, 25.0];
1245        let svg = stacked_bar(&cats, &groups, &vals)
1246            .title("Stacked")
1247            .to_svg()
1248            .unwrap();
1249        assert!(svg.contains("<rect"));
1250    }
1251
1252    #[test]
1253    fn grouped_bar_builds_svg() {
1254        let cats = vec!["Q1", "Q2", "Q1", "Q2"];
1255        let groups = vec!["A", "A", "B", "B"];
1256        let vals = vec![10.0, 20.0, 15.0, 25.0];
1257        let svg = grouped_bar(&cats, &groups, &vals)
1258            .title("Grouped")
1259            .to_svg()
1260            .unwrap();
1261        assert!(svg.contains("<rect"));
1262    }
1263
1264    #[test]
1265    fn facet_wrap_scatter_builds_svg() {
1266        let x = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
1267        let y = vec![2.0, 4.0, 1.0, 5.0, 3.0, 6.0];
1268        let facets = vec!["A", "A", "A", "B", "B", "B"];
1269        let svg = scatter(&x, &y)
1270            .facet_wrap(&facets, 2)
1271            .title("Faceted Scatter")
1272            .to_svg()
1273            .unwrap();
1274        assert!(svg.contains("<svg"));
1275        assert!(svg.contains("<circle"));
1276        assert!(svg.contains('A'));
1277        assert!(svg.contains('B'));
1278    }
1279
1280    #[test]
1281    fn heatmap_builds_svg() {
1282        let data = vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]];
1283        let svg = heatmap(data).title("Heatmap").to_svg().unwrap();
1284        assert!(svg.contains("<svg"));
1285        assert!(svg.contains("<rect"));
1286    }
1287
1288    #[test]
1289    fn heatmap_annotated_builds_svg() {
1290        let data = vec![vec![10.0, 20.0], vec![30.0, 40.0]];
1291        let svg = heatmap(data)
1292            .annotate()
1293            .title("Annotated")
1294            .to_svg()
1295            .unwrap();
1296        assert!(svg.contains("<svg"));
1297        assert!(svg.contains("<rect"));
1298        assert!(svg.contains("<text"));
1299    }
1300
1301    #[test]
1302    fn facet_single_value_no_split() {
1303        let x = vec![1.0, 2.0, 3.0];
1304        let y = vec![10.0, 20.0, 30.0];
1305        let facets = vec!["All", "All", "All"];
1306        let svg = scatter(&x, &y).facet_wrap(&facets, 2).to_svg().unwrap();
1307        assert!(svg.contains("<svg"));
1308        assert!(svg.contains("<circle"));
1309    }
1310
1311    #[test]
1312    fn grouped_bar_legend_has_group_names() {
1313        let cats = vec!["Q1", "Q2", "Q1", "Q2"];
1314        let groups = vec!["Revenue", "Revenue", "Costs", "Costs"];
1315        let vals = vec![10.0, 20.0, 15.0, 25.0];
1316        let svg = grouped_bar(&cats, &groups, &vals).to_svg().unwrap();
1317        assert!(
1318            svg.contains("Revenue"),
1319            "legend should contain group name 'Revenue'"
1320        );
1321        assert!(
1322            svg.contains("Costs"),
1323            "legend should contain group name 'Costs'"
1324        );
1325    }
1326
1327    #[test]
1328    fn grouped_bar_mismatched_lengths_panics() {
1329        #[allow(deprecated)]
1330        let result = std::panic::catch_unwind(|| {
1331            grouped_bar(&["Q1", "Q2"], &["A", "A", "B"], &[10.0, 20.0]).build();
1332        });
1333        assert!(result.is_err(), "mismatched lengths should panic");
1334    }
1335
1336    #[test]
1337    fn grouped_bar_try_build_returns_err() {
1338        let result = grouped_bar(&["Q1", "Q2"], &["A", "A", "B"], &[10.0, 20.0]).try_build();
1339        assert!(result.is_err());
1340    }
1341
1342    #[test]
1343    fn line_color_by() {
1344        let x = vec![1.0, 2.0, 3.0, 4.0];
1345        let y = vec![10.0, 20.0, 15.0, 25.0];
1346        let cats = vec!["A", "B", "A", "B"];
1347        let svg = line(&x, &y).color_by(&cats).to_svg().unwrap();
1348        assert!(svg.contains("<svg"));
1349    }
1350
1351    #[test]
1352    fn area_color_by() {
1353        let x = vec![1.0, 2.0, 3.0, 4.0];
1354        let y = vec![10.0, 20.0, 15.0, 25.0];
1355        let cats = vec!["A", "B", "A", "B"];
1356        let svg = area(&x, &y).color_by(&cats).to_svg().unwrap();
1357        assert!(svg.contains("<svg"));
1358    }
1359
1360    #[test]
1361    fn svg_contains_title_and_role() {
1362        use crate::grammar::chart::Chart;
1363        use crate::grammar::layer::{Layer, MarkType};
1364        let chart = Chart::new()
1365            .layer(
1366                Layer::new(MarkType::Point)
1367                    .with_x(vec![1.0])
1368                    .with_y(vec![1.0]),
1369            )
1370            .title("My Chart")
1371            .description("A scatter plot of test data");
1372        let svg = chart.to_svg().unwrap();
1373        assert!(svg.contains(r#"role="img""#), "SVG should have role=img");
1374        assert!(
1375            svg.contains("<title>My Chart</title>"),
1376            "SVG should contain <title>"
1377        );
1378        assert!(
1379            svg.contains("<desc>A scatter plot of test data</desc>"),
1380            "SVG should contain <desc>"
1381        );
1382    }
1383
1384    #[test]
1385    fn heatmap_legend_rendered() {
1386        let data = vec![vec![1.0, 5.0], vec![3.0, 9.0]];
1387        let svg = heatmap(data).title("Heatmap Legend").to_svg().unwrap();
1388        // Should have the gradient legend with min/max labels
1389        assert!(svg.contains("<svg"));
1390    }
1391
1392    #[test]
1393    fn treemap_builds_svg() {
1394        let labels = vec!["A", "B", "C", "D"];
1395        let values = vec![30.0, 20.0, 15.0, 10.0];
1396        let svg = treemap(&labels, &values).title("Treemap").to_svg().unwrap();
1397        assert!(svg.contains("<svg"));
1398        assert!(svg.contains("<rect"));
1399    }
1400
1401    #[test]
1402    fn treemap_single_item() {
1403        let svg = treemap(&["Only"], &[100.0]).to_svg().unwrap();
1404        assert!(svg.contains("<svg"));
1405        assert!(svg.contains("<rect"));
1406    }
1407
1408    #[test]
1409    fn treemap_with_zeros() {
1410        let labels = vec!["A", "B", "C"];
1411        let values = vec![30.0, 0.0, 20.0];
1412        let svg = treemap(&labels, &values).to_svg().unwrap();
1413        assert!(svg.contains("<svg"));
1414    }
1415
1416    #[test]
1417    fn bar_with_error_bars_builds_svg() {
1418        let cats = vec!["A", "B", "C"];
1419        let vals = vec![10.0, 25.0, 15.0];
1420        let errs = vec![1.5, 2.0, 1.0];
1421        let svg = bar(&cats, &vals)
1422            .error_bars(&errs)
1423            .title("Bar with Error Bars")
1424            .to_svg()
1425            .unwrap();
1426        assert!(svg.contains("<rect"));
1427        assert!(svg.contains("<line"), "should have whisker lines");
1428    }
1429
1430    #[test]
1431    fn stacked_bar_legend_has_group_names() {
1432        let cats = vec!["Q1", "Q2", "Q1", "Q2"];
1433        let groups = vec!["Alpha", "Alpha", "Beta", "Beta"];
1434        let vals = vec![10.0, 20.0, 5.0, 15.0];
1435        let svg = stacked_bar(&cats, &groups, &vals).to_svg().unwrap();
1436        assert!(
1437            svg.contains("Alpha"),
1438            "legend should contain group name 'Alpha'"
1439        );
1440        assert!(
1441            svg.contains("Beta"),
1442            "legend should contain group name 'Beta'"
1443        );
1444    }
1445
1446    #[test]
1447    fn boxplot_shows_category_labels() {
1448        let cats = vec![
1449            "GroupA", "GroupA", "GroupA", "GroupA", "GroupA", "GroupB", "GroupB", "GroupB",
1450            "GroupB", "GroupB",
1451        ];
1452        let vals = vec![1.0, 2.0, 3.0, 4.0, 5.0, 2.0, 4.0, 6.0, 8.0, 10.0];
1453        let svg = boxplot(&cats, &vals).title("Box Plot").to_svg().unwrap();
1454        assert!(
1455            svg.contains("GroupA"),
1456            "boxplot SVG should contain category name GroupA"
1457        );
1458        assert!(
1459            svg.contains("GroupB"),
1460            "boxplot SVG should contain category name GroupB"
1461        );
1462    }
1463}