Skip to main content

chartml_chart_cartesian/
bar.rs

1use chartml_core::data::DataTable;
2use chartml_core::element::{ChartElement, ElementData, TextAnchor, TextRole, TextStyle, Transform, ViewBox, emit_dot_halo_if_enabled};
3use chartml_core::error::ChartError;
4use chartml_core::layout::margins::{calculate_margins, MarginConfig};
5use chartml_core::plugin::ChartConfig;
6use chartml_core::scales::{ScaleBand, ScaleLinear};
7use chartml_core::layout::adaptive_tick_count;
8use chartml_core::spec::{ChartMode, Orientation};
9
10use chartml_core::layout::labels::{LabelStrategy, LabelStrategyConfig, TextMetrics, measure_text};
11
12use chartml_core::layout::legend::{calculate_legend_layout, LegendConfig};
13
14use crate::helpers::{GridConfig, emit_zero_line_if_crosses, format_value, generate_annotations, generate_x_axis, generate_x_axis_numeric, generate_x_axis_with_display, generate_y_axis_with_display, generate_y_axis_numeric, generate_y_axis_numeric_right, generate_legend, get_color_field, get_data_labels_config, get_field_name, get_x_format, get_y_axis_bounds, get_y_format, nice_domain, offset_element};
15
16/// Build a single bar element, honoring `theme.bar_corner_radius`.
17///
18/// Decision tree:
19/// - `BarCornerRadius::Uniform(0.0)` or `Top(0.0)` → emit a plain
20///   `ChartElement::Rect` with `rx`/`ry` == `None` (byte-identical to the
21///   pre-3.1 un-themed output).
22/// - `BarCornerRadius::Uniform(r)` with `r > 0.0` → emit `Rect` with
23///   `rx = ry = Some(r)`.
24/// - `BarCornerRadius::Top(r)` with `r > 0.0` → emit a `ChartElement::Path`
25///   with a `d` string that rounds only the two corners at the max-value
26///   end of the bar (the top of a vertical positive bar, the bottom of a
27///   vertical negative bar, the right end of a horizontal positive bar,
28///   the left end of a horizontal negative bar). The radius is clamped to
29///   `min(width, height) / 2.0` to prevent degenerate paths.
30pub(crate) struct BarRectSpec {
31    pub x: f64,
32    pub y: f64,
33    pub width: f64,
34    pub height: f64,
35    pub is_horizontal: bool,
36    pub is_negative: bool,
37    pub fill: String,
38    pub class: String,
39    pub data: Option<ElementData>,
40}
41
42/// Compute the CSS `transform-origin` anchor for a bar's entrance animation.
43///
44/// The anchor is the bar's value-baseline edge midpoint, in absolute SVG
45/// coordinates, so a `scaleX`/`scaleY` keyframe grows the bar from the axis
46/// outward toward its value end:
47///
48/// - vertical, positive value  → bottom-center  (grows up)
49/// - vertical, negative value  → top-center     (grows down)
50/// - horizontal, positive value → left-center   (grows right)
51/// - horizontal, negative value → right-center  (grows left)
52///
53/// Computing the anchor at emission time is essential: the renderer cannot
54/// recover orientation/sign from `<rect>`/`<path>` geometry alone (the
55/// historical `width > height` heuristic guessed wrong for square bars and
56/// for any negative bar). See `chartml-leptos/src/element.rs`, where the
57/// heuristic was deleted in favor of consuming this value.
58pub fn bar_animation_origin(
59    x: f64,
60    y: f64,
61    width: f64,
62    height: f64,
63    is_horizontal: bool,
64    is_negative: bool,
65) -> (f64, f64) {
66    match (is_horizontal, is_negative) {
67        // Vertical positive: rect spans [y, y+h]; baseline is y+h (bottom).
68        (false, false) => (x + width / 2.0, y + height),
69        // Vertical negative: rect spans [y, y+h] BELOW zero line; baseline is y (top).
70        (false, true) => (x + width / 2.0, y),
71        // Horizontal positive: rect spans [x, x+w]; baseline is x (left).
72        (true, false) => (x, y + height / 2.0),
73        // Horizontal negative: rect spans [x, x+w] LEFT of zero line; baseline is x+w (right).
74        (true, true) => (x + width, y + height / 2.0),
75    }
76}
77
78pub(crate) fn build_bar_element(
79    spec: BarRectSpec,
80    theme: &chartml_core::theme::Theme,
81) -> ChartElement {
82    use chartml_core::theme::BarCornerRadius;
83    let BarRectSpec {
84        x, y, width, height, is_horizontal, is_negative, fill, class, data,
85    } = spec;
86    let anim_origin = Some(bar_animation_origin(x, y, width, height, is_horizontal, is_negative));
87
88    // Extract requested radius; short-circuit the zero case to emit a plain
89    // Rect (byte-identical contract).
90    let (radius, top_only) = match theme.bar_corner_radius {
91        BarCornerRadius::Uniform(r) => (r as f64, false),
92        BarCornerRadius::Top(r) => (r as f64, true),
93    };
94
95    if radius <= 0.0 {
96        return ChartElement::Rect {
97            x,
98            y,
99            width,
100            height,
101            fill,
102            stroke: None,
103            rx: None,
104            ry: None,
105            class,
106            data,
107            animation_origin: anim_origin,
108        };
109    }
110
111    if !top_only {
112        return ChartElement::Rect {
113            x,
114            y,
115            width,
116            height,
117            fill,
118            stroke: None,
119            rx: Some(radius),
120            ry: Some(radius),
121            class,
122            data,
123            animation_origin: anim_origin,
124        };
125    }
126
127    // Top-only rounding: emit a Path with custom d.
128    // Clamp radius to min(w,h)/2 to prevent degenerate geometry on very
129    // thin bars. debug_assert flags regressions in tests.
130    let max_r = (width.min(height) / 2.0).max(0.0);
131    debug_assert!(
132        radius <= max_r + 1e-9 || width <= 0.0 || height <= 0.0,
133        "bar_corner_radius {} exceeds min(w,h)/2 = {} (w={}, h={})",
134        radius, max_r, width, height
135    );
136    let r = radius.min(max_r);
137
138    // Degenerate zero-dimension bars (e.g. a value-at-zero bar that has
139    // height 0 on vertical orientation) collapse to a plain Rect. Emitting
140    // an arc of radius 0 would pollute the path string and confuse
141    // consumers.
142    if r <= 0.0 {
143        return ChartElement::Rect {
144            x,
145            y,
146            width,
147            height,
148            fill,
149            stroke: None,
150            rx: None,
151            ry: None,
152            class,
153            data,
154            animation_origin: anim_origin,
155        };
156    }
157
158    // Absolute coordinates of the rect corners.
159    let x0 = x;
160    let y0 = y;
161    let x1 = x + width;
162    let y1 = y + height;
163
164    // Which two corners get rounded:
165    //   vertical + !negative → top two   (y0 edge)
166    //   vertical +  negative → bottom two (y1 edge)
167    //   horizontal + !negative → right two (x1 edge)
168    //   horizontal +  negative → left two  (x0 edge)
169    //
170    // Path is always traced clockwise starting from the corner immediately
171    // counter-clockwise of the first rounded corner, so the arc sweep flag
172    // is always 1 (clockwise).
173    let d = match (is_horizontal, is_negative) {
174        // Vertical, top rounding (two corners at y0)
175        (false, false) => format!(
176            "M {x0},{y0r} A {r},{r} 0 0 1 {x0r},{y0} L {x1mr},{y0} A {r},{r} 0 0 1 {x1},{y0r} L {x1},{y1} L {x0},{y1} Z",
177            x0 = x0, y0 = y0, x1 = x1, y1 = y1, r = r,
178            x0r = x0 + r, x1mr = x1 - r, y0r = y0 + r,
179        ),
180        // Vertical, negative value → bottom rounding (two corners at y1)
181        (false, true) => format!(
182            "M {x0},{y0} L {x1},{y0} L {x1},{y1mr} A {r},{r} 0 0 1 {x1mr},{y1} L {x0r},{y1} A {r},{r} 0 0 1 {x0},{y1mr} Z",
183            x0 = x0, y0 = y0, x1 = x1, y1 = y1, r = r,
184            x0r = x0 + r, x1mr = x1 - r, y1mr = y1 - r,
185        ),
186        // Horizontal, positive value → right-end rounding (two corners at x1)
187        (true, false) => format!(
188            "M {x0},{y0} L {x1mr},{y0} A {r},{r} 0 0 1 {x1},{y0r} L {x1},{y1mr} A {r},{r} 0 0 1 {x1mr},{y1} L {x0},{y1} Z",
189            x0 = x0, y0 = y0, x1 = x1, y1 = y1, r = r,
190            x1mr = x1 - r, y0r = y0 + r, y1mr = y1 - r,
191        ),
192        // Horizontal, negative value → left-end rounding (two corners at x0)
193        (true, true) => format!(
194            "M {x0r},{y0} L {x1},{y0} L {x1},{y1} L {x0r},{y1} A {r},{r} 0 0 1 {x0},{y1mr} L {x0},{y0r} A {r},{r} 0 0 1 {x0r},{y0} Z",
195            x0 = x0, y0 = y0, x1 = x1, y1 = y1, r = r,
196            x0r = x0 + r, y0r = y0 + r, y1mr = y1 - r,
197        ),
198    };
199
200    ChartElement::Path {
201        d,
202        fill: Some(fill),
203        stroke: None,
204        stroke_width: None,
205        stroke_dasharray: None,
206        opacity: None,
207        class,
208        data,
209        animation_origin: anim_origin,
210    }
211}
212
213struct SingleSeriesBarParams<'a> {
214    category_field: &'a str,
215    value_field: &'a str,
216    categories: &'a [String],
217    inner_width: f64,
218    inner_height: f64,
219    is_horizontal: bool,
220    y_fmt_ref: Option<&'a str>,
221    domain_min: f64,
222    domain_max: f64,
223}
224
225struct MultiSeriesBarParams<'a> {
226    category_field: &'a str,
227    value_field: &'a str,
228    color_field: &'a str,
229    categories: &'a [String],
230    inner_width: f64,
231    inner_height: f64,
232    is_stacked: bool,
233    is_normalized: bool,
234    is_horizontal: bool,
235    y_fmt_ref: Option<&'a str>,
236    domain_min: f64,
237    domain_max: f64,
238}
239
240pub fn render_bar(data: &DataTable, config: &ChartConfig) -> Result<ChartElement, ChartError> {
241    use chartml_core::spec::{FieldRef, FieldRefItem, FieldSpec};
242    
243
244    // Detect multi-field rows (combo chart pattern)
245    let multi_fields: Vec<FieldSpec> = match &config.visualize.rows {
246        Some(FieldRef::Multiple(items)) => items.iter().map(|item| match item {
247            FieldRefItem::Detailed(spec) => spec.as_ref().clone(),
248            FieldRefItem::Simple(name) => FieldSpec {
249                field: name.clone(), mark: None, axis: None, label: None,
250                color: None, format: None, data_labels: None,
251                line_style: None, upper: None, lower: None, opacity: None,
252            },
253        }).collect(),
254        _ => vec![],
255    };
256
257    if !multi_fields.is_empty() {
258        let is_horizontal = matches!(config.visualize.orientation, Some(Orientation::Horizontal));
259        let has_line_fields = multi_fields.iter().any(|f| f.mark.as_deref() == Some("line"));
260        let has_right_axis = multi_fields.iter().any(|f| f.axis.as_deref() == Some("right"));
261
262        // When horizontal with only bar fields (no lines/right axis), delegate to the
263        // standard bar renderer which already supports horizontal layout. The combo path
264        // only handles vertical because swapping axes for lines doesn't make sense.
265        if is_horizontal && !has_line_fields && !has_right_axis {
266            // Build a grouped bar config: use color field to separate the bar fields.
267            // Reshape wide-format data (revenue+target columns) into long-format
268            // (field_name column + value column) so the standard grouped bar path handles it.
269            let category_field = get_field_name(&config.visualize.columns)?;
270            let mut long_rows: Vec<chartml_core::data::Row> = Vec::new();
271            for i in 0..data.num_rows() {
272                for field_spec in &multi_fields {
273                    let cat = data.get_string(i, &category_field).unwrap_or_default();
274                    let val = data.get_f64(i, &field_spec.field).unwrap_or(0.0);
275                    let label = field_spec.label.clone().unwrap_or_else(|| field_spec.field.clone());
276                    let mut row = std::collections::HashMap::new();
277                    row.insert(category_field.clone(), serde_json::json!(cat));
278                    row.insert("_value".to_string(), serde_json::json!(val));
279                    row.insert("_series".to_string(), serde_json::json!(label));
280                    long_rows.push(row);
281                }
282            }
283            let long_data = DataTable::from_rows(&long_rows)
284                .map_err(|e| ChartError::DataError(format!("Failed to reshape data: {}", e)))?;
285
286            // Build a config that uses the long-format columns
287            let mut viz = config.visualize.clone();
288            viz.rows = Some(FieldRef::Simple("_value".to_string()));
289            viz.marks = Some(chartml_core::spec::MarksSpec {
290                color: Some(chartml_core::spec::MarkEncoding::Simple("_series".to_string())),
291                size: None, shape: None, text: None,
292            });
293            viz.mode = Some(ChartMode::Grouped);
294            // Assign colors from field specs or config palette
295            let mut colors = Vec::new();
296            for (i, f) in multi_fields.iter().enumerate() {
297                colors.push(f.color.clone().unwrap_or_else(|| {
298                    config.colors.get(i).cloned().unwrap_or_else(|| "#2E7D9A".to_string())
299                }));
300            }
301            let long_config = ChartConfig {
302                visualize: viz,
303                title: config.title.clone(),
304                width: config.width,
305                height: config.height,
306                colors,
307                theme: config.theme.clone(),
308            };
309            return render_bar(&long_data, &long_config);
310        }
311
312        return render_combo(data, config, &multi_fields);
313    }
314
315    let category_field = get_field_name(&config.visualize.columns)?;
316    let value_field = get_field_name(&config.visualize.rows)?;
317
318    let color_field = get_color_field(config);
319
320    // For single-series bars (no color field), use per-row categories to support
321    // duplicate category names. Each row gets a unique band key for positioning,
322    // while display_labels preserves the original (possibly duplicate) text.
323    // For multi-series (with color field), use unique categories as before since
324    // stacking/grouping logic depends on deduplication.
325    let (categories, display_labels): (Vec<String>, Option<Vec<String>>) = if color_field.is_none() {
326        let all_vals = data.all_values(&category_field);
327        if all_vals.is_empty() {
328            return Err(ChartError::DataError("No category values found".into()));
329        }
330        // Check if there are any duplicates; if so, create indexed band keys
331        let has_duplicates = {
332            let mut seen = std::collections::HashSet::new();
333            all_vals.iter().any(|v| !seen.insert(v.as_str()))
334        };
335        if has_duplicates {
336            let band_keys: Vec<String> = all_vals.iter().enumerate()
337                .map(|(i, v)| format!("{}\x00{}", v, i))
338                .collect();
339            (band_keys, Some(all_vals))
340        } else {
341            (all_vals, None)
342        }
343    } else {
344        let unique = data.unique_values(&category_field);
345        if unique.is_empty() {
346            return Err(ChartError::DataError("No category values found".into()));
347        }
348        (unique, None)
349    };
350
351    let is_horizontal = matches!(config.visualize.orientation, Some(Orientation::Horizontal));
352    let is_normalized = matches!(config.visualize.mode, Some(ChartMode::Normalized));
353    let is_stacked = matches!(config.visualize.mode, Some(ChartMode::Stacked)) || is_normalized;
354    let _is_grouped = matches!(config.visualize.mode, Some(ChartMode::Grouped));
355
356    // Step 1: Compute label strategy for margin estimation (only for vertical bars)
357    let x_format = get_x_format(config);
358    let y_fmt = get_y_format(config);
359    let y_fmt_ref = y_fmt.as_deref();
360    let (axis_min, axis_max) = get_y_axis_bounds(config);
361
362    // Format labels the same way generate_x_axis will — margin estimation must
363    // use the actual display strings, not raw data values.
364    let raw_for_strategy = display_labels.as_deref().unwrap_or(&categories);
365    let formatted_for_strategy = crate::helpers::format_display_labels(raw_for_strategy, x_format.as_deref());
366    let x_extra_margin = if !is_horizontal {
367        let estimated_width = config.width - 80.0;
368        let label_strategy_config = LabelStrategyConfig {
369            text_metrics: TextMetrics::from_theme_axis_label(&config.theme),
370            ..LabelStrategyConfig::default()
371        };
372        let x_strategy = LabelStrategy::determine(&formatted_for_strategy, estimated_width, &label_strategy_config);
373        match &x_strategy {
374            LabelStrategy::Rotated { margin, .. } => *margin,
375            _ => 0.0,
376        }
377    } else {
378        0.0
379    };
380
381    // Step 1b: Pre-compute domain for left margin estimation (matches JS two-pass approach).
382    // JS computes finalMarginLeft from actual y-axis tick label widths; we approximate here.
383    let (prelim_data_min, prelim_data_max): (f64, f64) = if let Some(ref color_f) = color_field {
384        if is_stacked {
385            let groups = data.group_by(color_f);
386            let series_names = data.unique_values(color_f);
387            let stacked_vals: Vec<f64> = categories.iter().map(|cat| {
388                series_names.iter().map(|s| {
389                    groups.get(s).and_then(|series_data| {
390                        (0..series_data.num_rows()).find_map(|i| {
391                            if series_data.get_string(i, &category_field).as_deref() == Some(cat.as_str()) {
392                                series_data.get_f64(i, &value_field)
393                            } else {
394                                None
395                            }
396                        })
397                    }).unwrap_or(0.0)
398                }).sum::<f64>()
399            }).collect();
400            let mn = stacked_vals.iter().cloned().fold(f64::INFINITY, f64::min);
401            let mx = stacked_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
402            (mn, mx)
403        } else {
404            let vals: Vec<f64> = (0..data.num_rows()).filter_map(|i| data.get_f64(i, &value_field)).collect();
405            let mn = vals.iter().cloned().fold(f64::INFINITY, f64::min);
406            let mx = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
407            (mn, mx)
408        }
409    } else {
410        let vals: Vec<f64> = (0..data.num_rows()).filter_map(|i| data.get_f64(i, &value_field)).collect();
411        let mn = vals.iter().cloned().fold(f64::INFINITY, f64::min);
412        let mx = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
413        (mn, mx)
414    };
415    let prelim_data_max = if prelim_data_max <= 0.0 { 1.0 } else { prelim_data_max };
416    // Keep data_min at 0 when all values are non-negative (standard bar chart behavior)
417    let prelim_data_min = if prelim_data_min >= 0.0 { 0.0 } else { prelim_data_min };
418
419    let prelim_domain_max = if is_normalized {
420        1.0
421    } else {
422        let raw_max = axis_max.unwrap_or(prelim_data_max);
423        if axis_max.is_none() { nice_domain(axis_min.unwrap_or(prelim_data_min), raw_max, 5).1 } else { raw_max }
424    };
425    let prelim_domain_min = if is_normalized { 0.0 } else { axis_min.unwrap_or(prelim_data_min) };
426
427    // Generate representative tick label (domain max is typically widest label).
428    // For horizontal charts the y-axis shows categories, so use those for left margin.
429    let y_tick_labels_for_margin: Vec<String> = if !is_horizontal {
430        let prelim_fmt = if is_normalized { Some(".0%") } else { y_fmt_ref };
431        vec![
432            format_value(prelim_domain_max, prelim_fmt),
433            format_value(prelim_domain_min, prelim_fmt),
434        ]
435    } else {
436        let display = display_labels.as_deref().unwrap_or(&categories);
437        display.to_vec()
438    };
439
440    // Pre-compute legend height so the bottom margin accounts for multi-row legends.
441    let legend_height = if let Some(ref color_f) = color_field {
442        let series_names = data.unique_values(color_f);
443        let legend_config = LegendConfig {
444            text_metrics: TextMetrics::from_theme_legend(&config.theme),
445            ..LegendConfig::default()
446        };
447        calculate_legend_layout(&series_names, &config.colors, config.width, &legend_config).total_height
448    } else {
449        0.0
450    };
451
452    // Step 2: Calculate margins including rotation
453    let has_x_axis_label = config.visualize.axes.as_ref()
454        .and_then(|a| a.x.as_ref())
455        .and_then(|a| a.label.as_ref())
456        .is_some();
457    let has_y_axis_label = config.visualize.axes.as_ref()
458        .and_then(|a| a.left.as_ref())
459        .and_then(|a| a.label.as_ref())
460        .is_some();
461    let margin_config = MarginConfig {
462        has_title: config.title.is_some(),
463        legend_height,
464        has_x_axis_label,
465        has_y_axis_label,
466        x_label_strategy_margin: x_extra_margin,
467        y_tick_labels: y_tick_labels_for_margin,
468        // For horizontal charts the Y-axis displays category labels (axis
469        // label metrics); for vertical charts it shows numeric tick values.
470        tick_value_metrics: if is_horizontal {
471            TextMetrics::from_theme_axis_label(&config.theme)
472        } else {
473            TextMetrics::from_theme_tick_value(&config.theme)
474        },
475        axis_label_metrics: TextMetrics::from_theme_axis_label(&config.theme),
476        ..Default::default()
477    };
478    let margins = calculate_margins(&margin_config);
479
480    let inner_width = margins.inner_width(config.width);
481    let inner_height = margins.inner_height(config.height);
482
483    let mut children = Vec::new();
484
485    // Title is rendered as an HTML div outside the SVG (matches JS chartml behaviour)
486    // — do NOT add it here as a SVG text element.
487
488    let grid = GridConfig::from_config(config);
489
490    let _tick_count = adaptive_tick_count(inner_height);
491
492    // Compute final domain (same as prelim for vertical bars).
493    let raw_data_max = prelim_data_max;
494
495    // For normalized mode, domain is always 0-1 (the NumberFormatter handles % display).
496    let (domain_min, domain_max) = if is_normalized {
497        (0.0, 1.0)
498    } else {
499        let raw_domain_min = axis_min.unwrap_or(prelim_data_min);
500        let raw_domain_max = axis_max.unwrap_or(raw_data_max);
501        // Apply nice rounding to domain so ticks are round numbers with headroom (Regressions 2 & 3).
502        // Only apply when no explicit axis bounds are set by the user.
503        if axis_min.is_none() && axis_max.is_none() {
504            // Match JS: yLeft.nice() uses default count=10 for domain rounding.
505            nice_domain(raw_domain_min, raw_domain_max, 5)
506        } else {
507            (raw_domain_min, raw_domain_max)
508        }
509    };
510    // For normalized mode, override Y-axis format to show percentages.
511    // Otherwise, use the format from config (axes.left.format).
512    let effective_y_fmt: Option<String> = if is_normalized {
513        Some(".0%".to_string())
514    } else {
515        y_fmt.clone()
516    };
517    let effective_y_fmt_ref = effective_y_fmt.as_deref();
518
519    let (_, bar_elements) = if let Some(ref color_f) = color_field {
520        render_multi_series_bars(
521            data,
522            config,
523            &MultiSeriesBarParams {
524                category_field: &category_field,
525                value_field: &value_field,
526                color_field: color_f,
527                categories: &categories,
528                inner_width,
529                inner_height,
530                is_stacked,
531                is_normalized,
532                is_horizontal,
533                y_fmt_ref,
534                domain_min,
535                domain_max,
536            },
537        )?
538    } else {
539        render_single_series_bars(
540            data,
541            config,
542            &SingleSeriesBarParams {
543                category_field: &category_field,
544                value_field: &value_field,
545                categories: &categories,
546                inner_width,
547                inner_height,
548                is_horizontal,
549                y_fmt_ref,
550                domain_min,
551                domain_max,
552            },
553        )?
554    };
555
556    // Axes (use domain_min/domain_max instead of 0.0/value_max)
557    let axis_elements = if is_horizontal {
558        // Category y-axis: generate at x=0 relative, then offset by margins.left
559        let x_axis = generate_y_axis_with_display(&categories, display_labels.as_deref(), (0.0, inner_height), 0.0, None, &config.theme);
560        let y_axis = generate_x_axis_numeric(&crate::helpers::XAxisNumericParams {
561            domain: (domain_min, domain_max),
562            range: (0.0, inner_width),
563            y_position: margins.top + inner_height,
564            fmt: effective_y_fmt_ref,
565            tick_count: 5,
566            chart_height: Some(inner_height),
567            grid: &grid,
568            theme: &config.theme,
569        });
570        let mut axes = Vec::new();
571        axes.extend(x_axis.into_iter().map(|e| offset_element(e, margins.left, margins.top)));
572        axes.extend(y_axis.into_iter().map(|e| offset_element(e, margins.left, 0.0)));
573        // Zero-line (Phase 7): for horizontal bars the numeric axis is x,
574        // so the zero line is vertical — emitted here after axes and before
575        // the series group below. No-op when theme.zero_line is None or the
576        // x-domain doesn't strictly cross zero.
577        if let Some(zl) = emit_zero_line_if_crosses(
578            &config.theme,
579            (domain_min, domain_max),
580            inner_width,
581            inner_height,
582            true,
583        ) {
584            axes.push(offset_element(zl, margins.left, margins.top));
585        }
586        axes
587    } else {
588        let bottom_axis_label = config.visualize.axes.as_ref()
589            .and_then(|a| a.x.as_ref())
590            .and_then(|a| a.label.as_deref());
591        let x_axis_result = generate_x_axis_with_display(&crate::helpers::XAxisParams {
592            labels: &categories,
593            display_label_overrides: display_labels.as_deref(),
594            range: (0.0, inner_width),
595            y_position: margins.top + inner_height,
596            available_width: inner_width,
597            x_format: x_format.as_deref(),
598            chart_height: Some(inner_height),
599            grid: &grid,
600            axis_label: bottom_axis_label,
601            theme: &config.theme,
602        });
603        let left_axis_label = config.visualize.axes.as_ref()
604            .and_then(|a| a.left.as_ref())
605            .and_then(|a| a.label.as_deref());
606        let y_axis = generate_y_axis_numeric(&crate::helpers::YAxisNumericParams {
607            domain: (domain_min, domain_max),
608            range: (inner_height, 0.0),
609            x_position: margins.left,
610            fmt: effective_y_fmt_ref,
611            tick_count: adaptive_tick_count(inner_height),
612            chart_width: Some(inner_width),
613            grid: &grid,
614            axis_label: left_axis_label,
615            theme: &config.theme,
616        });
617        let mut axes = Vec::new();
618        axes.extend(x_axis_result.elements.into_iter().map(|e| offset_element(e, margins.left, 0.0)));
619        axes.extend(y_axis.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
620        // Zero-line (Phase 7): emitted after grid lines, before the series group
621        // is pushed below — so the series paints over it. No-op when theme.zero_line
622        // is None (default) or when the domain doesn't strictly cross zero.
623        if let Some(zl) = emit_zero_line_if_crosses(
624            &config.theme,
625            (domain_min, domain_max),
626            inner_width,
627            inner_height,
628            is_horizontal,
629        ) {
630            axes.push(offset_element(zl, margins.left, margins.top));
631        }
632        axes
633    };
634
635    children.push(ChartElement::Group {
636        class: "axes".to_string(),
637        transform: None,
638        children: axis_elements,
639    });
640
641    children.push(ChartElement::Group {
642        class: "bars".to_string(),
643        transform: Some(Transform::Translate(margins.left, margins.top)),
644        children: bar_elements,
645    });
646
647    // Annotations — rendered on top of bars, in inner coordinate space
648    if !is_horizontal {
649        if let Some(annotations) = config.visualize.annotations.as_deref() {
650            if !annotations.is_empty() {
651                use chartml_core::scales::ScaleLinear;
652                let ann_scale = ScaleLinear::new((domain_min, domain_max), (inner_height, 0.0));
653                let ann_cats = display_labels.as_deref().unwrap_or(&categories);
654                let ann_elements = generate_annotations(
655                    annotations,
656                    &ann_scale,
657                    0.0,
658                    inner_width,
659                    inner_height,
660                    Some(ann_cats),
661                    &config.theme,
662                );
663                if !ann_elements.is_empty() {
664                    children.push(ChartElement::Group {
665                        class: "annotations".to_string(),
666                        transform: Some(Transform::Translate(margins.left, margins.top)),
667                        children: ann_elements,
668                    });
669                }
670            }
671        }
672    }
673
674    // Legend
675    if let Some(ref color_f) = color_field {
676        let series_names = data.unique_values(color_f);
677        let legend_config = LegendConfig {
678            text_metrics: TextMetrics::from_theme_legend(&config.theme),
679            ..LegendConfig::default()
680        };
681        let legend_layout = calculate_legend_layout(&series_names, &config.colors, config.width, &legend_config);
682        let legend_y = config.height - legend_layout.total_height - 8.0;
683        let legend_elements = generate_legend(&series_names, &config.colors, config.width, legend_y, &config.theme);
684        children.push(ChartElement::Group {
685            class: "legend".to_string(),
686            transform: None,
687            children: legend_elements,
688        });
689    }
690
691    let svg_class = if is_horizontal { "chartml-bar chartml-horizontal" } else { "chartml-bar" };
692    Ok(ChartElement::Svg {
693        viewbox: ViewBox::new(0.0, 0.0, config.width, config.height),
694        width: Some(config.width),
695        height: Some(config.height),
696        class: svg_class.to_string(),
697        children,
698    })
699}
700
701fn render_single_series_bars(
702    data: &DataTable,
703    config: &ChartConfig,
704    params: &SingleSeriesBarParams,
705) -> Result<(f64, Vec<ChartElement>), ChartError> {
706    let category_field = params.category_field;
707    let value_field = params.value_field;
708    let categories = params.categories;
709    let inner_width = params.inner_width;
710    let inner_height = params.inner_height;
711    let is_horizontal = params.is_horizontal;
712    let y_fmt_ref = params.y_fmt_ref;
713    let domain_min = params.domain_min;
714    let domain_max = params.domain_max;
715    // Find the max value (for return value only — domain_max is already caller-computed)
716    let values: Vec<f64> = (0..data.num_rows())
717        .filter_map(|i| data.get_f64(i, value_field))
718        .collect();
719    let value_max = values.iter().cloned().fold(0.0_f64, f64::max);
720    let value_max = if value_max <= 0.0 { 1.0 } else { value_max };
721    // Use the passed domain_max directly (caller already applied nice rounding if needed)
722    let effective_max = domain_max;
723
724    let mut elements = Vec::new();
725    // Single-series bars always use one color (the first palette color).
726    // Color is per-series, not per-category — matches JS d3ChartMapper.js behavior.
727    let fill_color = config.colors.first()
728        .cloned()
729        .unwrap_or_else(|| "#2E7D9A".to_string());
730
731    if is_horizontal {
732        let band = ScaleBand::new(categories.to_vec(), (0.0, inner_height))
733            .padding(crate::helpers::adaptive_bar_padding(categories.len()));
734        let linear = ScaleLinear::new((domain_min, effective_max), (0.0, inner_width));
735        // Match JS: barHeight = min(bandwidth, 40), centered in band
736        let bar_render_height = band.bandwidth().min(40.0);
737        let y_inset = (band.bandwidth() - bar_render_height) / 2.0;
738
739        for i in 0..data.num_rows() {
740            let cat = match data.get_string(i, category_field) {
741                Some(c) => c,
742                None => continue,
743            };
744            let val = data.get_f64(i, value_field).unwrap_or(0.0);
745            // Use indexed band key for positioning (handles duplicate categories)
746            let band_key = categories.get(i).map(|k| k.as_str()).unwrap_or(&cat);
747            let y = match band.map(band_key) {
748                Some(y) => y,
749                None => continue,
750            };
751            let bar_width = linear.map(val);
752
753            elements.push(build_bar_element(
754                BarRectSpec {
755                    x: 0.0,
756                    y: y + y_inset,
757                    width: bar_width,
758                    height: bar_render_height,
759                    is_horizontal: true,
760                    is_negative: val < 0.0,
761                    fill: fill_color.clone(),
762                    class: "bar bar-rect".to_string(),
763                    data: Some(ElementData::new(&cat, format_value(val, y_fmt_ref))),
764                },
765                &config.theme,
766            ));
767        }
768    } else {
769        let band = ScaleBand::new(categories.to_vec(), (0.0, inner_width))
770            .padding(crate::helpers::adaptive_bar_padding(categories.len()));
771        let linear = ScaleLinear::new((domain_min, effective_max), (inner_height, 0.0));
772        // Match JS: barWidth = min(bandwidth, chartWidth * 0.2), centered in band
773        let max_bar_width = inner_width * 0.2;
774        let bar_render_width = band.bandwidth().min(max_bar_width);
775        let x_inset = (band.bandwidth() - bar_render_width) / 2.0;
776
777        for i in 0..data.num_rows() {
778            let cat = match data.get_string(i, category_field) {
779                Some(c) => c,
780                None => continue,
781            };
782            let val = data.get_f64(i, value_field).unwrap_or(0.0);
783            // Use indexed band key for positioning (handles duplicate categories)
784            let band_key = categories.get(i).map(|k| k.as_str()).unwrap_or(&cat);
785            let x = match band.map(band_key) {
786                Some(x) => x,
787                None => continue,
788            };
789            let bar_val_y = linear.map(val);
790            let bar_zero_y = linear.map(0.0);
791            let bar_height = (bar_zero_y - bar_val_y).abs();
792            // For positive bars, rect y is at the value (above zero line).
793            // For negative bars, rect y is at zero line (bar extends downward).
794            let rect_y = bar_val_y.min(bar_zero_y);
795
796            elements.push(build_bar_element(
797                BarRectSpec {
798                    x: x + x_inset,
799                    y: rect_y,
800                    width: bar_render_width,
801                    height: bar_height,
802                    is_horizontal: false,
803                    is_negative: val < 0.0,
804                    fill: fill_color.clone(),
805                    class: "bar bar-rect".to_string(),
806                    data: Some(ElementData::new(&cat, format_value(val, y_fmt_ref))),
807                },
808                &config.theme,
809            ));
810
811            // Data label above bar (if configured)
812            if let Some(dl) = get_data_labels_config(config) {
813                if dl.show == Some(true) {
814                    let label_fmt = dl.format.as_deref().or(y_fmt_ref);
815                    let label_y = match dl.position.as_deref() {
816                        Some("center") => rect_y + bar_height / 2.0,
817                        Some("bottom") => rect_y + bar_height - 5.0,
818                        _ => if val >= 0.0 { rect_y - 5.0 } else { rect_y + bar_height + 12.0 }, // "top" or default
819                    };
820                    elements.push(ChartElement::Text {
821                        x: x + band.bandwidth() / 2.0,
822                        y: label_y,
823                        content: format_value(val, label_fmt),
824                        anchor: TextAnchor::Middle,
825                        dominant_baseline: None,
826                        transform: None,
827                        font_family: None,
828                        font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "12px".to_string())),
829                        font_weight: None,
830                        letter_spacing: None,
831                        text_transform: None,
832                        fill: Some(dl.color.clone().unwrap_or_else(|| config.theme.text_secondary.clone())),
833                        class: "data-label".to_string(),
834                        data: None,
835                    });
836                }
837            }
838        }
839    }
840
841    Ok((value_max, elements))
842}
843
844fn render_multi_series_bars(
845    data: &DataTable,
846    config: &ChartConfig,
847    params: &MultiSeriesBarParams,
848) -> Result<(f64, Vec<ChartElement>), ChartError> {
849    let category_field = params.category_field;
850    let value_field = params.value_field;
851    let color_field = params.color_field;
852    let categories = params.categories;
853    let inner_width = params.inner_width;
854    let inner_height = params.inner_height;
855    let is_stacked = params.is_stacked;
856    let is_normalized = params.is_normalized;
857    let is_horizontal = params.is_horizontal;
858    let y_fmt_ref = params.y_fmt_ref;
859    let domain_min = params.domain_min;
860    let domain_max = params.domain_max;
861    use chartml_core::layout::stack::{StackLayout, StackOffset};
862
863    let series_names = data.unique_values(color_field);
864    let groups = data.group_by(color_field);
865
866    let mut elements = Vec::new();
867
868    if is_stacked {
869        // Build values matrix: values[series_idx][category_idx]
870        let mut values_matrix: Vec<Vec<f64>> = Vec::new();
871        for series in &series_names {
872            let mut series_vals = Vec::new();
873            let series_data = groups.get(series);
874            for cat in categories {
875                let val = series_data
876                    .map(|sd| {
877                        (0..sd.num_rows())
878                            .find_map(|i| {
879                                if sd.get_string(i, category_field).as_deref() == Some(cat.as_str()) {
880                                    sd.get_f64(i, value_field)
881                                } else {
882                                    None
883                                }
884                            })
885                            .unwrap_or(0.0)
886                    })
887                    .unwrap_or(0.0);
888                series_vals.push(val);
889            }
890            values_matrix.push(series_vals);
891        }
892
893        let stack = if is_normalized {
894            StackLayout::new().offset(StackOffset::Normalize)
895        } else {
896            StackLayout::new()
897        };
898        let stacked_points = stack.layout(categories, &series_names, &values_matrix);
899
900        // For normalized mode, domain is 0-1; for regular stacked, use the raw max.
901        let (effective_min, effective_max) = if is_normalized {
902            (0.0, 1.0)
903        } else {
904            let value_max = stacked_points
905                .iter()
906                .map(|p| p.y1)
907                .fold(0.0_f64, f64::max);
908            let value_max = if value_max <= 0.0 { 1.0 } else { value_max };
909            (domain_min, if domain_max < f64::MAX { domain_max } else { value_max })
910        };
911
912        if is_horizontal {
913            // Horizontal stacked: band on y-axis (height), linear on x-axis (width)
914            let band = ScaleBand::new(categories.to_vec(), (0.0, inner_height))
915                .padding(crate::helpers::adaptive_bar_padding(categories.len()));
916            let linear = ScaleLinear::new((effective_min, effective_max), (0.0, inner_width));
917            let bar_render_height = band.bandwidth().min(40.0);
918            let y_inset = (band.bandwidth() - bar_render_height) / 2.0;
919
920            for point in &stacked_points {
921                let y = match band.map(&point.key) {
922                    Some(y) => y,
923                    None => continue,
924                };
925                let x_left = linear.map(point.y0);
926                let x_right = linear.map(point.y1);
927                let bar_width = (x_right - x_left).abs();
928
929                let series_idx = series_names.iter().position(|s| s == &point.series).unwrap_or(0);
930                let fill = config
931                    .colors
932                    .get(series_idx)
933                    .cloned()
934                    .unwrap_or_else(|| "#2E7D9A".to_string());
935
936                elements.push(build_bar_element(
937                    BarRectSpec {
938                        x: x_left.min(x_right),
939                        y: y + y_inset,
940                        width: bar_width,
941                        height: bar_render_height,
942                        is_horizontal: true,
943                        is_negative: point.value < 0.0,
944                        fill,
945                        class: "bar bar-rect".to_string(),
946                        data: Some(
947                            ElementData::new(&point.key, format_value(point.value, y_fmt_ref))
948                                .with_series(&point.series),
949                        ),
950                    },
951                    &config.theme,
952                ));
953            }
954        } else {
955            // Vertical stacked: band on x-axis (width), linear on y-axis (height)
956            let band = ScaleBand::new(categories.to_vec(), (0.0, inner_width))
957                .padding(crate::helpers::adaptive_bar_padding(categories.len()));
958            let linear = ScaleLinear::new((effective_min, effective_max), (inner_height, 0.0));
959            // Match JS: barWidth = min(bandwidth, chartWidth * 0.2), centered in band
960            let max_bar_width = inner_width * 0.2;
961            let bar_render_width = band.bandwidth().min(max_bar_width);
962            let x_inset = (band.bandwidth() - bar_render_width) / 2.0;
963
964            for point in &stacked_points {
965                let x = match band.map(&point.key) {
966                    Some(x) => x,
967                    None => continue,
968                };
969                let y_top = linear.map(point.y1);
970                let y_bottom = linear.map(point.y0);
971                let bar_height = (y_bottom - y_top).abs();
972
973                let series_idx = series_names.iter().position(|s| s == &point.series).unwrap_or(0);
974                let fill = config
975                    .colors
976                    .get(series_idx)
977                    .cloned()
978                    .unwrap_or_else(|| "#2E7D9A".to_string());
979
980                elements.push(build_bar_element(
981                    BarRectSpec {
982                        x: x + x_inset,
983                        y: y_top,
984                        width: bar_render_width,
985                        height: bar_height,
986                        is_horizontal: false,
987                        is_negative: point.value < 0.0,
988                        fill,
989                        class: "bar bar-rect".to_string(),
990                        data: Some(
991                            ElementData::new(&point.key, format_value(point.value, y_fmt_ref))
992                                .with_series(&point.series),
993                        ),
994                    },
995                    &config.theme,
996                ));
997            }
998        }
999
1000        Ok((effective_max, elements))
1001    } else {
1002        // Grouped (or default multi-series)
1003        // Find overall max value
1004        let value_max = (0..data.num_rows())
1005            .filter_map(|i| data.get_f64(i, value_field))
1006            .fold(0.0_f64, f64::max);
1007        let value_max = if value_max <= 0.0 { 1.0 } else { value_max };
1008        let effective_max = if domain_max < f64::MAX { domain_max } else { value_max };
1009
1010        let num_series = series_names.len().max(1);
1011
1012        if is_horizontal {
1013            // Horizontal grouped: band on y-axis (height), linear on x-axis (width)
1014            let band = ScaleBand::new(categories.to_vec(), (0.0, inner_height))
1015                .padding(0.05);
1016            let linear = ScaleLinear::new((domain_min, effective_max), (0.0, inner_width));
1017            let sub_band_height = band.bandwidth() / num_series as f64;
1018
1019            for i in 0..data.num_rows() {
1020                let cat = match data.get_string(i, category_field) {
1021                    Some(c) => c,
1022                    None => continue,
1023                };
1024                let series = match data.get_string(i, color_field) {
1025                    Some(s) => s,
1026                    None => continue,
1027                };
1028                let val = data.get_f64(i, value_field).unwrap_or(0.0);
1029
1030                let y_base = match band.map(&cat) {
1031                    Some(y) => y,
1032                    None => continue,
1033                };
1034                let series_idx = series_names.iter().position(|s| s == &series).unwrap_or(0);
1035                let y = y_base + series_idx as f64 * sub_band_height;
1036
1037                let bar_left = linear.map(0.0);
1038                let bar_right = linear.map(val);
1039                let bar_width = (bar_right - bar_left).abs();
1040
1041                let fill = config
1042                    .colors
1043                    .get(series_idx)
1044                    .cloned()
1045                    .unwrap_or_else(|| "#2E7D9A".to_string());
1046
1047                elements.push(build_bar_element(
1048                    BarRectSpec {
1049                        x: bar_left.min(bar_right),
1050                        y,
1051                        width: bar_width,
1052                        height: sub_band_height,
1053                        is_horizontal: true,
1054                        is_negative: val < 0.0,
1055                        fill,
1056                        class: "bar bar-rect".to_string(),
1057                        data: Some(
1058                            ElementData::new(&cat, format_value(val, y_fmt_ref))
1059                                .with_series(&series),
1060                        ),
1061                    },
1062                    &config.theme,
1063                ));
1064            }
1065        } else {
1066            // Vertical grouped: band on x-axis (width), linear on y-axis (height)
1067            let band = ScaleBand::new(categories.to_vec(), (0.0, inner_width))
1068                .padding(0.05);
1069            let linear = ScaleLinear::new((domain_min, effective_max), (inner_height, 0.0));
1070            let sub_band_width = band.bandwidth() / num_series as f64;
1071
1072            for i in 0..data.num_rows() {
1073                let cat = match data.get_string(i, category_field) {
1074                    Some(c) => c,
1075                    None => continue,
1076                };
1077                let series = match data.get_string(i, color_field) {
1078                    Some(s) => s,
1079                    None => continue,
1080                };
1081                let val = data.get_f64(i, value_field).unwrap_or(0.0);
1082
1083                let x_base = match band.map(&cat) {
1084                    Some(x) => x,
1085                    None => continue,
1086                };
1087                let series_idx = series_names.iter().position(|s| s == &series).unwrap_or(0);
1088                let x = x_base + series_idx as f64 * sub_band_width;
1089
1090                let bar_top = linear.map(val);
1091                let bar_bottom = linear.map(0.0);
1092                let bar_height = (bar_bottom - bar_top).abs();
1093
1094                let fill = config
1095                    .colors
1096                    .get(series_idx)
1097                    .cloned()
1098                    .unwrap_or_else(|| "#2E7D9A".to_string());
1099
1100                elements.push(build_bar_element(
1101                    BarRectSpec {
1102                        x,
1103                        y: bar_top,
1104                        width: sub_band_width,
1105                        height: bar_height,
1106                        is_horizontal: false,
1107                        is_negative: val < 0.0,
1108                        fill,
1109                        class: "bar bar-rect".to_string(),
1110                        data: Some(
1111                            ElementData::new(&cat, format_value(val, y_fmt_ref))
1112                                .with_series(&series),
1113                        ),
1114                    },
1115                    &config.theme,
1116                ));
1117            }
1118        }
1119
1120        Ok((value_max, elements))
1121    }
1122}
1123
1124/// Render a combo chart: multiple fields with different marks (bar/line) and optional dual axis.
1125fn render_combo(
1126    data: &DataTable,
1127    config: &ChartConfig,
1128    fields: &[chartml_core::spec::FieldSpec],
1129) -> Result<ChartElement, ChartError> {
1130    use chartml_core::shapes::LineGenerator;
1131    use chartml_core::layout::stack::StackLayout;
1132
1133    let category_field = get_field_name(&config.visualize.columns)?;
1134    let categories = data.unique_values(&category_field);
1135    if categories.is_empty() {
1136        return Err(ChartError::DataError("No category values found".into()));
1137    }
1138
1139    let y_fmt = get_y_format(config);
1140    let y_fmt_ref = y_fmt.as_deref();
1141    let grid = GridConfig::from_config(config);
1142    let x_format = get_x_format(config);
1143
1144    // Detect stacking mode and color field for bar sub-series
1145    let color_field = get_color_field(config);
1146    let is_stacked = matches!(config.visualize.mode, Some(ChartMode::Stacked));
1147
1148    // Margins — account for right axis if present
1149    let has_right = fields.iter().any(|f| f.axis.as_deref() == Some("right"));
1150    let right_fmt = config.visualize.axes.as_ref()
1151        .and_then(|a| a.right.as_ref())
1152        .and_then(|a| a.format.as_deref());
1153
1154    // Pre-compute right tick labels to measure their width
1155    let right_tick_labels: Vec<String> = if has_right {
1156        // Estimate right-axis values for label width measurement
1157        let right_max = fields.iter()
1158            .filter(|f| f.axis.as_deref() == Some("right"))
1159            .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
1160            .fold(0.0_f64, f64::max);
1161        let right_domain_max = config.visualize.axes.as_ref()
1162            .and_then(|a| a.right.as_ref())
1163            .and_then(|a| a.max)
1164            .unwrap_or(if right_max <= 0.0 { 1.0 } else { right_max });
1165        let tmp_scale = ScaleLinear::new((0.0, right_domain_max), (0.0, 100.0));
1166        tmp_scale.ticks(5).iter().map(|v| format_value(*v, right_fmt)).collect()
1167    } else {
1168        vec![]
1169    };
1170
1171    let has_x_axis_label = config.visualize.axes.as_ref()
1172        .and_then(|a| a.x.as_ref())
1173        .and_then(|a| a.label.as_ref())
1174        .is_some();
1175    // Pre-compute combo legend height from field labels
1176    let combo_legend_labels: Vec<String> = fields.iter()
1177        .map(|f| f.label.clone().unwrap_or_else(|| f.field.clone()))
1178        .collect();
1179    let combo_legend_height = if combo_legend_labels.len() > 1 || color_field.is_some() {
1180        let legend_config = LegendConfig {
1181            text_metrics: TextMetrics::from_theme_legend(&config.theme),
1182            ..LegendConfig::default()
1183        };
1184        calculate_legend_layout(&combo_legend_labels, &config.colors, config.width, &legend_config).total_height
1185    } else {
1186        0.0
1187    };
1188    let margin_config = MarginConfig {
1189        has_title: config.title.is_some(),
1190        legend_height: combo_legend_height,
1191        // Left Y-axis label is not rendered for combo charts (see comment below),
1192        // so do not reserve extra left-margin space for it.
1193        has_y_axis_label: false,
1194        has_x_axis_label,
1195        has_right_axis: has_right,
1196        right_tick_labels,
1197        tick_value_metrics: TextMetrics::from_theme_tick_value(&config.theme),
1198        axis_label_metrics: TextMetrics::from_theme_axis_label(&config.theme),
1199        ..Default::default()
1200    };
1201    let margins = calculate_margins(&margin_config);
1202    let inner_width = margins.inner_width(config.width);
1203    let inner_height = margins.inner_height(config.height);
1204
1205    let band = ScaleBand::new(categories.clone(), (0.0, inner_width))
1206        .padding(crate::helpers::adaptive_bar_padding(categories.len()));
1207    let bandwidth = band.bandwidth();
1208
1209    // Separate fields by axis
1210    let left_fields: Vec<&chartml_core::spec::FieldSpec> = fields.iter()
1211        .filter(|f| f.axis.as_deref() != Some("right"))
1212        .collect();
1213    let right_fields: Vec<&chartml_core::spec::FieldSpec> = fields.iter()
1214        .filter(|f| f.axis.as_deref() == Some("right"))
1215        .collect();
1216
1217    // Compute left-axis domain with D3-style nice rounding (Regressions 2 & 3).
1218    // When stacked with a color field, the domain max is the per-category sum of all series.
1219    let left_max = if let (true, Some(color_f)) = (is_stacked, color_field.as_ref()) {
1220        let color_series = data.unique_values(color_f);
1221        let mut max_stack = 0.0_f64;
1222        for f in &left_fields {
1223            for cat in &categories {
1224                let mut stack_total = 0.0_f64;
1225                for series in &color_series {
1226                    let val = (0..data.num_rows())
1227                        .find(|&i| {
1228                            data.get_string(i, &category_field).as_deref() == Some(cat.as_str())
1229                                && data.get_string(i, color_f).as_deref() == Some(series.as_str())
1230                        })
1231                        .and_then(|i| data.get_f64(i, &f.field))
1232                        .unwrap_or(0.0);
1233                    stack_total += val;
1234                }
1235                max_stack = max_stack.max(stack_total);
1236            }
1237        }
1238        max_stack
1239    } else {
1240        left_fields.iter()
1241            .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
1242            .fold(0.0_f64, f64::max)
1243    };
1244    // Compute left-axis data minimum to support negative bar values.
1245    let left_data_min = left_fields.iter()
1246        .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
1247        .fold(0.0_f64, f64::min);
1248    // Keep data_min at 0 when all values are non-negative (standard bar chart behavior)
1249    let left_data_min = if left_data_min >= 0.0 { 0.0 } else { left_data_min };
1250    let axes_left = config.visualize.axes.as_ref().and_then(|a| a.left.as_ref());
1251    let left_explicit_min = axes_left.and_then(|a| a.min);
1252    let left_explicit_max = axes_left.and_then(|a| a.max);
1253    let raw_left_domain_min = left_explicit_min.unwrap_or(left_data_min);
1254    let raw_left_domain_max = left_explicit_max.unwrap_or(if left_max <= 0.0 { 1.0 } else { left_max });
1255    let (left_domain_min, left_domain_max) = if left_explicit_min.is_none() && left_explicit_max.is_none() {
1256        // Use count=5 to align with generate_y_axis_numeric's hardcoded tick count of 5.
1257        nice_domain(raw_left_domain_min, raw_left_domain_max, 5)
1258    } else {
1259        (raw_left_domain_min, raw_left_domain_max)
1260    };
1261    let left_scale = ScaleLinear::new((left_domain_min, left_domain_max), (inner_height, 0.0));
1262
1263    // Compute right-axis domain with D3-style nice rounding (Regressions 2 & 3).
1264    let right_scale = if !right_fields.is_empty() {
1265        let right_max = right_fields.iter()
1266            .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
1267            .fold(0.0_f64, f64::max);
1268        let right_data_min = right_fields.iter()
1269            .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
1270            .fold(0.0_f64, f64::min);
1271        let axes_right = config.visualize.axes.as_ref().and_then(|a| a.right.as_ref());
1272        let right_explicit_min = axes_right.and_then(|a| a.min);
1273        let right_explicit_max = axes_right.and_then(|a| a.max);
1274        let raw_right_domain_min = right_explicit_min.unwrap_or(if right_data_min < 0.0 { right_data_min } else { 0.0 });
1275        let raw_right_domain_max = right_explicit_max.unwrap_or(if right_max <= 0.0 { 1.0 } else { right_max });
1276        let (right_domain_min, right_domain_max) = if right_explicit_min.is_none() && right_explicit_max.is_none() {
1277            // Use count=5 to align with generate_y_axis_numeric's hardcoded tick count of 5.
1278            nice_domain(raw_right_domain_min, raw_right_domain_max, 5)
1279        } else {
1280            (raw_right_domain_min, raw_right_domain_max)
1281        };
1282        Some(ScaleLinear::new((right_domain_min, right_domain_max), (inner_height, 0.0)))
1283    } else {
1284        None
1285    };
1286
1287    let mut children = Vec::new();
1288
1289    // Title is rendered as HTML outside the SVG — not added here.
1290
1291    // Axes
1292    let bottom_axis_label = config.visualize.axes.as_ref()
1293        .and_then(|a| a.x.as_ref())
1294        .and_then(|a| a.label.as_deref());
1295    let x_axis_result = generate_x_axis(&crate::helpers::XAxisParams {
1296        labels: &categories,
1297        display_label_overrides: None,
1298        range: (0.0, inner_width),
1299        y_position: margins.top + inner_height,
1300        available_width: inner_width,
1301        x_format: x_format.as_deref(),
1302        chart_height: Some(inner_height),
1303        grid: &grid,
1304        axis_label: bottom_axis_label,
1305        theme: &config.theme,
1306    });
1307    let left_axis_label = axes_left.and_then(|a| a.label.as_deref());
1308    let y_axis_left = generate_y_axis_numeric(&crate::helpers::YAxisNumericParams {
1309        domain: (left_domain_min, left_domain_max),
1310        range: (inner_height, 0.0),
1311        x_position: margins.left,
1312        fmt: y_fmt_ref,
1313        tick_count: adaptive_tick_count(inner_height),
1314        chart_width: Some(inner_width),
1315        grid: &grid,
1316        axis_label: left_axis_label,
1317        theme: &config.theme,
1318    });
1319
1320    let mut axis_elements = Vec::new();
1321    axis_elements.extend(x_axis_result.elements.into_iter().map(|e| offset_element(e, margins.left, 0.0)));
1322    axis_elements.extend(y_axis_left.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
1323    // Zero-line (Phase 7): applied to the LEFT numeric axis domain only. The
1324    // combo/grouped path is always vertically-oriented (grouped horizontal bars
1325    // are not supported here). No-op when theme.zero_line is None (default) or
1326    // when the left domain doesn't strictly cross zero.
1327    if let Some(zl) = emit_zero_line_if_crosses(
1328        &config.theme,
1329        (left_domain_min, left_domain_max),
1330        inner_width,
1331        inner_height,
1332        false,
1333    ) {
1334        axis_elements.push(offset_element(zl, margins.left, margins.top));
1335    }
1336
1337    // Right axis — ticks and labels on the right side
1338    if let Some(ref rs) = right_scale {
1339        let right_fmt = config.visualize.axes.as_ref()
1340            .and_then(|a| a.right.as_ref())
1341            .and_then(|a| a.format.as_deref());
1342        // Right axis label is rendered manually below (outside this block),
1343        // so pass None here to avoid duplication.
1344        let right_axis = generate_y_axis_numeric_right(
1345            rs.domain(), (inner_height, 0.0), margins.left + inner_width,
1346            right_fmt, adaptive_tick_count(inner_height),
1347            None, &config.theme,
1348        );
1349        axis_elements.extend(right_axis.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
1350    }
1351
1352    // Right axis title label — rendered manually here with absolute positioning
1353    // (the left axis label is already handled by generate_y_axis_numeric above).
1354    if let Some(label) = config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.label.clone()) {
1355        let rx = config.width - 12.0;
1356        let ts = TextStyle::for_role(&config.theme, TextRole::AxisLabel);
1357        axis_elements.push(ChartElement::Text {
1358            x: rx,
1359            y: margins.top + inner_height / 2.0,
1360            content: label,
1361            anchor: TextAnchor::Middle,
1362            dominant_baseline: None,
1363            transform: Some(Transform::Rotate(90.0, rx, margins.top + inner_height / 2.0)),
1364            font_family: ts.font_family,
1365            font_size: ts.font_size,
1366            font_weight: ts.font_weight,
1367            letter_spacing: ts.letter_spacing,
1368            text_transform: ts.text_transform,
1369            fill: Some(config.theme.text_secondary.clone()),
1370            class: "axis-label".to_string(),
1371            data: None,
1372        });
1373    }
1374
1375    children.push(ChartElement::Group {
1376        class: "axes".to_string(), transform: None, children: axis_elements,
1377    });
1378
1379    // Render each field
1380    let mut mark_elements = Vec::new();
1381    let line_gen = LineGenerator::new().curve(chartml_core::shapes::CurveType::MonotoneX);
1382
1383    // Count bar fields for grouped subdivision
1384    let num_bar_fields = fields.iter()
1385        .filter(|f| f.mark.as_deref().unwrap_or("bar") == "bar")
1386        .count()
1387        .max(1);
1388    // Match JS: barWidth = min(bandwidth, chartWidth * 0.2), centered within band
1389    let max_bar_width = inner_width * 0.2;
1390    let effective_bandwidth = bandwidth.min(max_bar_width);
1391    let combo_x_inset = (bandwidth - effective_bandwidth) / 2.0;
1392    let sub_bar_padding = effective_bandwidth * 0.05;
1393    let sub_bar_width = (effective_bandwidth - sub_bar_padding * (num_bar_fields as f64 - 1.0).max(0.0)) / num_bar_fields as f64;
1394    let mut bar_field_idx = 0_usize;
1395    let mut series_names = Vec::new();
1396    let mut series_colors = Vec::new();
1397    let mut series_marks = Vec::new();
1398
1399    // Pre-compute stacked bar layout if stacking with color field
1400    let stacked_bar_rendered = if let (true, Some(color_f)) = (is_stacked, color_field.as_ref()) {
1401        let color_series = data.unique_values(color_f);
1402
1403        // For each bar field, render stacked bars by color series
1404        for field_spec in fields.iter() {
1405            let mark = field_spec.mark.as_deref().unwrap_or("bar");
1406            if mark != "bar" { continue; }
1407
1408            let field_name = &field_spec.field;
1409            let is_right = field_spec.axis.as_deref() == Some("right");
1410            let scale = if is_right { right_scale.as_ref().unwrap_or(&left_scale) } else { &left_scale };
1411            let fmt_ref = if is_right {
1412                config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.format.as_deref())
1413            } else {
1414                y_fmt_ref
1415            };
1416
1417            // Build values matrix: values[series_idx][category_idx]
1418            let mut values_matrix: Vec<Vec<f64>> = Vec::new();
1419            for series in &color_series {
1420                let mut series_vals = Vec::new();
1421                for cat in &categories {
1422                    let val = (0..data.num_rows())
1423                        .find(|&i| {
1424                            data.get_string(i, &category_field).as_deref() == Some(cat.as_str())
1425                                && data.get_string(i, color_f).as_deref() == Some(series.as_str())
1426                        })
1427                        .and_then(|i| data.get_f64(i, field_name))
1428                        .unwrap_or(0.0);
1429                    series_vals.push(val);
1430                }
1431                values_matrix.push(series_vals);
1432            }
1433
1434            let stack = StackLayout::new();
1435            let stacked_points = stack.layout(&categories, &color_series, &values_matrix);
1436
1437            let bar_render_width = bandwidth.min(max_bar_width);
1438            let x_inset = (bandwidth - bar_render_width) / 2.0;
1439
1440            for point in &stacked_points {
1441                let x = match band.map(&point.key) { Some(x) => x, None => continue };
1442                let y_top = scale.map(point.y1);
1443                let y_bottom = scale.map(point.y0);
1444                let bar_height = (y_bottom - y_top).abs();
1445
1446                let series_idx = color_series.iter().position(|s| s == &point.series).unwrap_or(0);
1447                let fill = config.colors.get(series_idx).cloned().unwrap_or_else(|| "#2E7D9A".to_string());
1448
1449                mark_elements.push(build_bar_element(
1450                    BarRectSpec {
1451                        x: x + x_inset + margins.left,
1452                        y: y_top + margins.top,
1453                        width: bar_render_width,
1454                        height: bar_height,
1455                        is_horizontal: false,
1456                        is_negative: point.value < 0.0,
1457                        fill,
1458                        class: "bar bar-rect".to_string(),
1459                        data: Some(
1460                            ElementData::new(&point.key, format_value(point.value, fmt_ref))
1461                                .with_series(&point.series),
1462                        ),
1463                    },
1464                    &config.theme,
1465                ));
1466            }
1467        }
1468
1469        // Add color series to legend tracking
1470        for (si, series_name) in color_series.iter().enumerate() {
1471            let color = config.colors.get(si).cloned().unwrap_or_else(|| "#2E7D9A".to_string());
1472            series_names.push(series_name.clone());
1473            series_colors.push(color);
1474            series_marks.push("bar".to_string());
1475        }
1476
1477        true
1478    } else {
1479        false
1480    };
1481
1482    for (field_idx, field_spec) in fields.iter().enumerate() {
1483        let field_name = &field_spec.field;
1484        let is_right = field_spec.axis.as_deref() == Some("right");
1485        let scale = if is_right { right_scale.as_ref().unwrap_or(&left_scale) } else { &left_scale };
1486        let mark = field_spec.mark.as_deref().unwrap_or("bar");
1487        let color = field_spec.color.clone()
1488            .unwrap_or_else(|| config.colors.get(field_idx).cloned().unwrap_or_else(|| "#2E7D9A".to_string()));
1489        let label = field_spec.label.clone().unwrap_or_else(|| field_name.clone());
1490        let fmt_ref = if is_right {
1491            config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.format.as_deref())
1492        } else {
1493            y_fmt_ref
1494        };
1495
1496        match mark {
1497            "bar" if stacked_bar_rendered => {
1498                // Already rendered above via stacked layout — skip
1499            }
1500            "bar" => {
1501                let this_bar_idx = bar_field_idx;
1502                bar_field_idx += 1;
1503
1504                for row_i in 0..data.num_rows() {
1505                    let cat = match data.get_string(row_i, &category_field) { Some(c) => c, None => continue };
1506                    let val = data.get_f64(row_i, field_name).unwrap_or(0.0);
1507                    let x = match band.map(&cat) { Some(x) => x, None => continue };
1508                    let bar_x = x + combo_x_inset + this_bar_idx as f64 * (sub_bar_width + sub_bar_padding);
1509                    let bar_val_y = scale.map(val);
1510                    let bar_zero_y = scale.map(0.0);
1511                    let bar_height = (bar_zero_y - bar_val_y).abs();
1512                    let rect_y = bar_val_y.min(bar_zero_y);
1513
1514                    mark_elements.push(build_bar_element(
1515                        BarRectSpec {
1516                            x: bar_x + margins.left,
1517                            y: rect_y + margins.top,
1518                            width: sub_bar_width,
1519                            height: bar_height,
1520                            is_horizontal: false,
1521                            is_negative: val < 0.0,
1522                            fill: color.clone(),
1523                            class: "bar bar-rect".to_string(),
1524                            data: Some(
1525                                ElementData::new(&cat, format_value(val, fmt_ref))
1526                                    .with_series(&label),
1527                            ),
1528                        },
1529                        &config.theme,
1530                    ));
1531
1532                    // Data labels
1533                    if let Some(ref dl) = field_spec.data_labels {
1534                        if dl.show == Some(true) {
1535                            let dl_fmt = dl.format.as_deref().or(fmt_ref);
1536                            mark_elements.push(ChartElement::Text {
1537                                x: bar_x + sub_bar_width / 2.0 + margins.left,
1538                                y: rect_y + margins.top - 5.0,
1539                                content: format_value(val, dl_fmt),
1540                                anchor: TextAnchor::Middle, dominant_baseline: None,
1541                                transform: None,
1542                                font_family: None,
1543                                font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "12px".to_string())),
1544                                font_weight: None,
1545                                letter_spacing: None,
1546                                text_transform: None,
1547                                fill: Some(dl.color.clone().unwrap_or_else(|| config.theme.text_secondary.clone())),
1548                                class: "data-label".to_string(), data: None,
1549                            });
1550                        }
1551                    }
1552                }
1553            }
1554            _ => {
1555                let mut points = Vec::new();
1556                let mut point_data = Vec::new();
1557                for cat in &categories {
1558                    let row_i = match (0..data.num_rows()).find(|&i| data.get_string(i, &category_field).as_deref() == Some(cat.as_str())) {
1559                        Some(i) => i, None => continue,
1560                    };
1561                    let val = match data.get_f64(row_i, field_name) { Some(v) => v, None => continue };
1562                    let x = match band.map(cat) { Some(x) => x + bandwidth / 2.0, None => continue };
1563                    let y = scale.map(val);
1564                    points.push((x + margins.left, y + margins.top));
1565                    point_data.push((cat.clone(), val));
1566                }
1567
1568                if !points.is_empty() {
1569                    let path_d = line_gen.generate(&points);
1570                    mark_elements.push(ChartElement::Path {
1571                        d: path_d, fill: None, stroke: Some(color.clone()),
1572                        stroke_width: Some(config.theme.series_line_weight as f64), stroke_dasharray: None,
1573                        opacity: None,
1574                        class: "chartml-line-path series-line".to_string(),
1575                        data: Some(ElementData::new(&label, "").with_series(&label)),
1576                        animation_origin: None,
1577                    });
1578
1579                    // Dots
1580                    let dot_r = config.theme.dot_radius as f64;
1581                    for (i, &(px, py)) in points.iter().enumerate() {
1582                        let (ref cat, val) = point_data[i];
1583                        if let Some(halo) = emit_dot_halo_if_enabled(&config.theme, px, py, dot_r) {
1584                            mark_elements.push(halo);
1585                        }
1586                        mark_elements.push(ChartElement::Circle {
1587                            cx: px, cy: py, r: dot_r,
1588                            fill: color.clone(), stroke: Some(config.theme.bg.clone()),
1589                            class: "chartml-line-dot dot-marker".to_string(),
1590                            data: Some(ElementData::new(cat, format_value(val, fmt_ref)).with_series(&label)),
1591                        });
1592                    }
1593
1594                    // Data labels
1595                    if let Some(ref dl) = field_spec.data_labels {
1596                        if dl.show == Some(true) {
1597                            let dl_fmt = dl.format.as_deref().or(fmt_ref);
1598                            for (i, &(px, py)) in points.iter().enumerate() {
1599                                let (_, val) = &point_data[i];
1600                                mark_elements.push(ChartElement::Text {
1601                                    x: px, y: py - 10.0,
1602                                    content: format_value(*val, dl_fmt),
1603                                    anchor: TextAnchor::Middle, dominant_baseline: None,
1604                                    transform: None,
1605                                    font_family: None,
1606                                    font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "12px".to_string())),
1607                                    font_weight: None,
1608                                    letter_spacing: None,
1609                                    text_transform: None,
1610                                    fill: Some(dl.color.clone().unwrap_or_else(|| color.clone())),
1611                                    class: "data-label".to_string(), data: None,
1612                                });
1613                            }
1614                        }
1615                    }
1616                }
1617            }
1618        }
1619
1620        // When stacked bars were rendered via color field, the color series are already
1621        // tracked for legend — skip adding the bar field itself.
1622        if !(stacked_bar_rendered && mark == "bar") {
1623            series_names.push(label);
1624            series_colors.push(color);
1625            series_marks.push(mark.to_string());
1626        }
1627    }
1628
1629    children.push(ChartElement::Group {
1630        class: "marks".to_string(), transform: None, children: mark_elements,
1631    });
1632
1633    // Annotations — rendered on top of marks, in inner coordinate space
1634    if let Some(annotations) = config.visualize.annotations.as_deref() {
1635        if !annotations.is_empty() {
1636            let ann_elements = generate_annotations(
1637                annotations,
1638                &left_scale,
1639                0.0,
1640                inner_width,
1641                inner_height,
1642                Some(&categories),
1643                &config.theme,
1644            );
1645            if !ann_elements.is_empty() {
1646                children.push(ChartElement::Group {
1647                    class: "annotations".to_string(),
1648                    transform: Some(Transform::Translate(margins.left, margins.top)),
1649                    children: ann_elements,
1650                });
1651            }
1652        }
1653    }
1654
1655    // Legend with mixed marks — anchor using pre-computed legend height
1656    if series_names.len() > 1 {
1657        let combo_legend_metrics = TextMetrics::from_theme_legend(&config.theme);
1658        let mut legend_elements = Vec::new();
1659        let total_w: f64 = series_names.iter().map(|name| {
1660            let tw = measure_text(name, &combo_legend_metrics);
1661            12.0 + 6.0 + tw + 16.0
1662        }).sum();
1663        let mut x_offset = (config.width - total_w).max(0.0) / 2.0;
1664        let legend_y = config.height - combo_legend_height - 8.0;
1665
1666        for (i, name) in series_names.iter().enumerate() {
1667            let color = &series_colors[i];
1668            let mark = series_marks[i].as_str();
1669            let y = legend_y;
1670
1671            match mark {
1672                "line" => {
1673                    legend_elements.push(ChartElement::Line {
1674                        x1: x_offset, y1: y + 6.0, x2: x_offset + 12.0, y2: y + 6.0,
1675                        stroke: color.clone(), stroke_width: Some(2.5),
1676                        stroke_dasharray: None, class: "legend-symbol legend-line".to_string(),
1677                    });
1678                }
1679                _ => {
1680                    legend_elements.push(ChartElement::Rect {
1681                        x: x_offset, y, width: 12.0, height: 12.0,
1682                        fill: color.clone(), stroke: None,
1683                        rx: None, ry: None,
1684                        class: "legend-symbol".to_string(), data: None,
1685                        animation_origin: None,
1686                    });
1687                }
1688            }
1689
1690            let ts = TextStyle::for_role(&config.theme, TextRole::LegendLabel);
1691            legend_elements.push(ChartElement::Text {
1692                x: x_offset + 18.0, y: y + 10.0, content: name.clone(),
1693                anchor: TextAnchor::Start, dominant_baseline: None,
1694                transform: None,
1695                font_family: ts.font_family,
1696                font_size: ts.font_size,
1697                font_weight: ts.font_weight,
1698                letter_spacing: ts.letter_spacing,
1699                text_transform: ts.text_transform,
1700                fill: Some(config.theme.text_secondary.clone()), class: "legend-label".to_string(), data: None,
1701            });
1702
1703            let tw = measure_text(name, &combo_legend_metrics);
1704            x_offset += 12.0 + 6.0 + tw + 16.0;
1705        }
1706
1707        children.push(ChartElement::Group {
1708            class: "legend".to_string(), transform: None, children: legend_elements,
1709        });
1710    }
1711
1712    Ok(ChartElement::Svg {
1713        viewbox: ViewBox::new(0.0, 0.0, config.width, config.height),
1714        width: Some(config.width),
1715        height: Some(config.height),
1716        class: "chartml-bar chartml-combo".to_string(),
1717        children,
1718    })
1719}
1720