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