Skip to main content

chartml_chart_cartesian/
bar.rs

1use chartml_core::data::DataTable;
2use chartml_core::element::{ChartElement, ElementData, TextAnchor, Transform, ViewBox};
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};
11
12use chartml_core::layout::legend::{calculate_legend_layout, LegendConfig};
13
14use crate::helpers::{GridConfig, 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
16struct SingleSeriesBarParams<'a> {
17    category_field: &'a str,
18    value_field: &'a str,
19    categories: &'a [String],
20    inner_width: f64,
21    inner_height: f64,
22    is_horizontal: bool,
23    y_fmt_ref: Option<&'a str>,
24    domain_min: f64,
25    domain_max: f64,
26}
27
28struct MultiSeriesBarParams<'a> {
29    category_field: &'a str,
30    value_field: &'a str,
31    color_field: &'a str,
32    categories: &'a [String],
33    inner_width: f64,
34    inner_height: f64,
35    is_stacked: bool,
36    is_normalized: bool,
37    is_horizontal: bool,
38    y_fmt_ref: Option<&'a str>,
39    domain_min: f64,
40    domain_max: f64,
41}
42
43pub fn render_bar(data: &DataTable, config: &ChartConfig) -> Result<ChartElement, ChartError> {
44    use chartml_core::spec::{FieldRef, FieldRefItem, FieldSpec};
45    
46
47    // Detect multi-field rows (combo chart pattern)
48    let multi_fields: Vec<FieldSpec> = match &config.visualize.rows {
49        Some(FieldRef::Multiple(items)) => items.iter().map(|item| match item {
50            FieldRefItem::Detailed(spec) => spec.as_ref().clone(),
51            FieldRefItem::Simple(name) => FieldSpec {
52                field: name.clone(), mark: None, axis: None, label: None,
53                color: None, format: None, data_labels: None,
54                line_style: None, upper: None, lower: None, opacity: None,
55            },
56        }).collect(),
57        _ => vec![],
58    };
59
60    if !multi_fields.is_empty() {
61        return render_combo(data, config, &multi_fields);
62    }
63
64    let category_field = get_field_name(&config.visualize.columns)?;
65    let value_field = get_field_name(&config.visualize.rows)?;
66
67    let color_field = get_color_field(config);
68
69    // For single-series bars (no color field), use per-row categories to support
70    // duplicate category names. Each row gets a unique band key for positioning,
71    // while display_labels preserves the original (possibly duplicate) text.
72    // For multi-series (with color field), use unique categories as before since
73    // stacking/grouping logic depends on deduplication.
74    let (categories, display_labels): (Vec<String>, Option<Vec<String>>) = if color_field.is_none() {
75        let all_vals = data.all_values(&category_field);
76        if all_vals.is_empty() {
77            return Err(ChartError::DataError("No category values found".into()));
78        }
79        // Check if there are any duplicates; if so, create indexed band keys
80        let has_duplicates = {
81            let mut seen = std::collections::HashSet::new();
82            all_vals.iter().any(|v| !seen.insert(v.as_str()))
83        };
84        if has_duplicates {
85            let band_keys: Vec<String> = all_vals.iter().enumerate()
86                .map(|(i, v)| format!("{}\x00{}", v, i))
87                .collect();
88            (band_keys, Some(all_vals))
89        } else {
90            (all_vals, None)
91        }
92    } else {
93        let unique = data.unique_values(&category_field);
94        if unique.is_empty() {
95            return Err(ChartError::DataError("No category values found".into()));
96        }
97        (unique, None)
98    };
99
100    let is_horizontal = matches!(config.visualize.orientation, Some(Orientation::Horizontal));
101    let is_normalized = matches!(config.visualize.mode, Some(ChartMode::Normalized));
102    let is_stacked = matches!(config.visualize.mode, Some(ChartMode::Stacked)) || is_normalized;
103    let _is_grouped = matches!(config.visualize.mode, Some(ChartMode::Grouped));
104
105    // Step 1: Compute label strategy for margin estimation (only for vertical bars)
106    let x_format = get_x_format(config);
107    let y_fmt = get_y_format(config);
108    let y_fmt_ref = y_fmt.as_deref();
109    let (axis_min, axis_max) = get_y_axis_bounds(config);
110
111    // Use display labels for width estimation (original text, not indexed keys)
112    let labels_for_strategy = display_labels.as_deref().unwrap_or(&categories);
113    let x_extra_margin = if !is_horizontal {
114        let estimated_width = config.width - 80.0;
115        let x_strategy = LabelStrategy::determine(labels_for_strategy, estimated_width, &LabelStrategyConfig::default());
116        match &x_strategy {
117            LabelStrategy::Rotated { margin, .. } => *margin,
118            _ => 0.0,
119        }
120    } else {
121        0.0
122    };
123
124    // Step 1b: Pre-compute domain for left margin estimation (matches JS two-pass approach).
125    // JS computes finalMarginLeft from actual y-axis tick label widths; we approximate here.
126    let (prelim_data_min, prelim_data_max): (f64, f64) = if let Some(ref color_f) = color_field {
127        if is_stacked {
128            let groups = data.group_by(color_f);
129            let series_names = data.unique_values(color_f);
130            let stacked_vals: Vec<f64> = categories.iter().map(|cat| {
131                series_names.iter().map(|s| {
132                    groups.get(s).and_then(|series_data| {
133                        (0..series_data.num_rows()).find_map(|i| {
134                            if series_data.get_string(i, &category_field).as_deref() == Some(cat.as_str()) {
135                                series_data.get_f64(i, &value_field)
136                            } else {
137                                None
138                            }
139                        })
140                    }).unwrap_or(0.0)
141                }).sum::<f64>()
142            }).collect();
143            let mn = stacked_vals.iter().cloned().fold(f64::INFINITY, f64::min);
144            let mx = stacked_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
145            (mn, mx)
146        } else {
147            let vals: Vec<f64> = (0..data.num_rows()).filter_map(|i| data.get_f64(i, &value_field)).collect();
148            let mn = vals.iter().cloned().fold(f64::INFINITY, f64::min);
149            let mx = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
150            (mn, mx)
151        }
152    } else {
153        let vals: Vec<f64> = (0..data.num_rows()).filter_map(|i| data.get_f64(i, &value_field)).collect();
154        let mn = vals.iter().cloned().fold(f64::INFINITY, f64::min);
155        let mx = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
156        (mn, mx)
157    };
158    let prelim_data_max = if prelim_data_max <= 0.0 { 1.0 } else { prelim_data_max };
159    // Keep data_min at 0 when all values are non-negative (standard bar chart behavior)
160    let prelim_data_min = if prelim_data_min >= 0.0 { 0.0 } else { prelim_data_min };
161
162    let prelim_domain_max = if is_normalized {
163        1.0
164    } else {
165        let raw_max = axis_max.unwrap_or(prelim_data_max);
166        if axis_max.is_none() { nice_domain(axis_min.unwrap_or(prelim_data_min), raw_max, 5).1 } else { raw_max }
167    };
168    let prelim_domain_min = if is_normalized { 0.0 } else { axis_min.unwrap_or(prelim_data_min) };
169
170    // Generate representative tick label (domain max is typically widest label).
171    // For horizontal charts the y-axis shows categories, so use those for left margin.
172    let y_tick_labels_for_margin: Vec<String> = if !is_horizontal {
173        let prelim_fmt = if is_normalized { Some(".0%") } else { y_fmt_ref };
174        vec![
175            format_value(prelim_domain_max, prelim_fmt),
176            format_value(prelim_domain_min, prelim_fmt),
177        ]
178    } else {
179        let display = display_labels.as_deref().unwrap_or(&categories);
180        display.to_vec()
181    };
182
183    // Step 2: Calculate margins including rotation
184    let has_x_axis_label = config.visualize.axes.as_ref()
185        .and_then(|a| a.x.as_ref())
186        .and_then(|a| a.label.as_ref())
187        .is_some();
188    let margin_config = MarginConfig {
189        has_title: config.title.is_some(),
190        has_legend: color_field.is_some(),
191        has_x_axis_label,
192        x_label_strategy_margin: x_extra_margin,
193        y_tick_labels: y_tick_labels_for_margin,
194        ..Default::default()
195    };
196    let margins = calculate_margins(&margin_config);
197
198    let inner_width = margins.inner_width(config.width);
199    let inner_height = margins.inner_height(config.height);
200
201    let mut children = Vec::new();
202
203    // Title is rendered as an HTML div outside the SVG (matches JS chartml behaviour)
204    // — do NOT add it here as a SVG text element.
205
206    let grid = GridConfig::from_config(config);
207
208    let _tick_count = adaptive_tick_count(inner_height);
209
210    // Compute final domain (same as prelim for vertical bars).
211    let raw_data_max = prelim_data_max;
212
213    // For normalized mode, domain is always 0-1 (the NumberFormatter handles % display).
214    let (domain_min, domain_max) = if is_normalized {
215        (0.0, 1.0)
216    } else {
217        let raw_domain_min = axis_min.unwrap_or(prelim_data_min);
218        let raw_domain_max = axis_max.unwrap_or(raw_data_max);
219        // Apply nice rounding to domain so ticks are round numbers with headroom (Regressions 2 & 3).
220        // Only apply when no explicit axis bounds are set by the user.
221        if axis_min.is_none() && axis_max.is_none() {
222            // Match JS: yLeft.nice() uses default count=10 for domain rounding.
223            nice_domain(raw_domain_min, raw_domain_max, 5)
224        } else {
225            (raw_domain_min, raw_domain_max)
226        }
227    };
228    // For normalized mode, override Y-axis format to show percentages.
229    // Otherwise, use the format from config (axes.left.format).
230    let effective_y_fmt: Option<String> = if is_normalized {
231        Some(".0%".to_string())
232    } else {
233        y_fmt.clone()
234    };
235    let effective_y_fmt_ref = effective_y_fmt.as_deref();
236
237    let (_, bar_elements) = if let Some(ref color_f) = color_field {
238        render_multi_series_bars(
239            data,
240            config,
241            &MultiSeriesBarParams {
242                category_field: &category_field,
243                value_field: &value_field,
244                color_field: color_f,
245                categories: &categories,
246                inner_width,
247                inner_height,
248                is_stacked,
249                is_normalized,
250                is_horizontal,
251                y_fmt_ref,
252                domain_min,
253                domain_max,
254            },
255        )?
256    } else {
257        render_single_series_bars(
258            data,
259            config,
260            &SingleSeriesBarParams {
261                category_field: &category_field,
262                value_field: &value_field,
263                categories: &categories,
264                inner_width,
265                inner_height,
266                is_horizontal,
267                y_fmt_ref,
268                domain_min,
269                domain_max,
270            },
271        )?
272    };
273
274    // Axes (use domain_min/domain_max instead of 0.0/value_max)
275    let axis_elements = if is_horizontal {
276        // Category y-axis: generate at x=0 relative, then offset by margins.left
277        let x_axis = generate_y_axis_with_display(&categories, display_labels.as_deref(), (0.0, inner_height), 0.0, None);
278        let y_axis = generate_x_axis_numeric((domain_min, domain_max), (0.0, inner_width), margins.top + inner_height, effective_y_fmt_ref, 5, Some(inner_height), &grid);
279        let mut axes = Vec::new();
280        axes.extend(x_axis.into_iter().map(|e| offset_element(e, margins.left, margins.top)));
281        axes.extend(y_axis.into_iter().map(|e| offset_element(e, margins.left, 0.0)));
282        axes
283    } else {
284        let bottom_axis_label = config.visualize.axes.as_ref()
285            .and_then(|a| a.x.as_ref())
286            .and_then(|a| a.label.as_deref());
287        let x_axis_result = generate_x_axis_with_display(&crate::helpers::XAxisParams {
288            labels: &categories,
289            display_label_overrides: display_labels.as_deref(),
290            range: (0.0, inner_width),
291            y_position: margins.top + inner_height,
292            available_width: inner_width,
293            x_format: x_format.as_deref(),
294            chart_height: Some(inner_height),
295            grid: &grid,
296            axis_label: bottom_axis_label,
297        });
298        let left_axis_label = config.visualize.axes.as_ref()
299            .and_then(|a| a.left.as_ref())
300            .and_then(|a| a.label.as_deref());
301        let y_axis = generate_y_axis_numeric(&crate::helpers::YAxisNumericParams {
302            domain: (domain_min, domain_max),
303            range: (inner_height, 0.0),
304            x_position: margins.left,
305            fmt: effective_y_fmt_ref,
306            tick_count: adaptive_tick_count(inner_height),
307            chart_width: Some(inner_width),
308            grid: &grid,
309            axis_label: left_axis_label,
310        });
311        let mut axes = Vec::new();
312        axes.extend(x_axis_result.elements.into_iter().map(|e| offset_element(e, margins.left, 0.0)));
313        axes.extend(y_axis.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
314        axes
315    };
316
317    children.push(ChartElement::Group {
318        class: "axes".to_string(),
319        transform: None,
320        children: axis_elements,
321    });
322
323    children.push(ChartElement::Group {
324        class: "bars".to_string(),
325        transform: Some(Transform::Translate(margins.left, margins.top)),
326        children: bar_elements,
327    });
328
329    // Annotations — rendered on top of bars, in inner coordinate space
330    if !is_horizontal {
331        if let Some(annotations) = config.visualize.annotations.as_deref() {
332            if !annotations.is_empty() {
333                use chartml_core::scales::ScaleLinear;
334                let ann_scale = ScaleLinear::new((domain_min, domain_max), (inner_height, 0.0));
335                let ann_cats = display_labels.as_deref().unwrap_or(&categories);
336                let ann_elements = generate_annotations(
337                    annotations,
338                    &ann_scale,
339                    0.0,
340                    inner_width,
341                    inner_height,
342                    Some(ann_cats),
343                );
344                if !ann_elements.is_empty() {
345                    children.push(ChartElement::Group {
346                        class: "annotations".to_string(),
347                        transform: Some(Transform::Translate(margins.left, margins.top)),
348                        children: ann_elements,
349                    });
350                }
351            }
352        }
353    }
354
355    // Legend
356    if let Some(ref color_f) = color_field {
357        let series_names = data.unique_values(color_f);
358        let legend_config = LegendConfig::default();
359        let legend_layout = calculate_legend_layout(&series_names, &config.colors, config.width, &legend_config);
360        let legend_y = config.height - legend_layout.total_height - 8.0;
361        let legend_elements = generate_legend(&series_names, &config.colors, config.width, legend_y);
362        children.push(ChartElement::Group {
363            class: "legend".to_string(),
364            transform: None,
365            children: legend_elements,
366        });
367    }
368
369    Ok(ChartElement::Svg {
370        viewbox: ViewBox::new(0.0, 0.0, config.width, config.height),
371        width: Some(config.width),
372        height: Some(config.height),
373        class: "chartml-bar".to_string(),
374        children,
375    })
376}
377
378fn render_single_series_bars(
379    data: &DataTable,
380    config: &ChartConfig,
381    params: &SingleSeriesBarParams,
382) -> Result<(f64, Vec<ChartElement>), ChartError> {
383    let category_field = params.category_field;
384    let value_field = params.value_field;
385    let categories = params.categories;
386    let inner_width = params.inner_width;
387    let inner_height = params.inner_height;
388    let is_horizontal = params.is_horizontal;
389    let y_fmt_ref = params.y_fmt_ref;
390    let domain_min = params.domain_min;
391    let domain_max = params.domain_max;
392    // Find the max value (for return value only — domain_max is already caller-computed)
393    let values: Vec<f64> = (0..data.num_rows())
394        .filter_map(|i| data.get_f64(i, value_field))
395        .collect();
396    let value_max = values.iter().cloned().fold(0.0_f64, f64::max);
397    let value_max = if value_max <= 0.0 { 1.0 } else { value_max };
398    // Use the passed domain_max directly (caller already applied nice rounding if needed)
399    let effective_max = domain_max;
400
401    let mut elements = Vec::new();
402    // Single-series bars always use one color (the first palette color).
403    // Color is per-series, not per-category — matches JS d3ChartMapper.js behavior.
404    let fill_color = config.colors.first()
405        .cloned()
406        .unwrap_or_else(|| "#2E7D9A".to_string());
407
408    if is_horizontal {
409        let band = ScaleBand::new(categories.to_vec(), (0.0, inner_height))
410            .padding(crate::helpers::adaptive_bar_padding(categories.len()));
411        let linear = ScaleLinear::new((domain_min, effective_max), (0.0, inner_width));
412        // Match JS: barHeight = min(bandwidth, 40), centered in band
413        let bar_render_height = band.bandwidth().min(40.0);
414        let y_inset = (band.bandwidth() - bar_render_height) / 2.0;
415
416        for i in 0..data.num_rows() {
417            let cat = match data.get_string(i, category_field) {
418                Some(c) => c,
419                None => continue,
420            };
421            let val = data.get_f64(i, value_field).unwrap_or(0.0);
422            // Use indexed band key for positioning (handles duplicate categories)
423            let band_key = categories.get(i).map(|k| k.as_str()).unwrap_or(&cat);
424            let y = match band.map(band_key) {
425                Some(y) => y,
426                None => continue,
427            };
428            let bar_width = linear.map(val);
429
430            elements.push(ChartElement::Rect {
431                x: 0.0,
432                y: y + y_inset,
433                width: bar_width,
434                height: bar_render_height,
435                fill: fill_color.clone(),
436                stroke: None,
437                class: "bar".to_string(),
438                data: Some(ElementData::new(&cat, format_value(val, y_fmt_ref))),
439            });
440        }
441    } else {
442        let band = ScaleBand::new(categories.to_vec(), (0.0, inner_width))
443            .padding(crate::helpers::adaptive_bar_padding(categories.len()));
444        let linear = ScaleLinear::new((domain_min, effective_max), (inner_height, 0.0));
445        // Match JS: barWidth = min(bandwidth, chartWidth * 0.2), centered in band
446        let max_bar_width = inner_width * 0.2;
447        let bar_render_width = band.bandwidth().min(max_bar_width);
448        let x_inset = (band.bandwidth() - bar_render_width) / 2.0;
449
450        for i in 0..data.num_rows() {
451            let cat = match data.get_string(i, category_field) {
452                Some(c) => c,
453                None => continue,
454            };
455            let val = data.get_f64(i, value_field).unwrap_or(0.0);
456            // Use indexed band key for positioning (handles duplicate categories)
457            let band_key = categories.get(i).map(|k| k.as_str()).unwrap_or(&cat);
458            let x = match band.map(band_key) {
459                Some(x) => x,
460                None => continue,
461            };
462            let bar_val_y = linear.map(val);
463            let bar_zero_y = linear.map(0.0);
464            let bar_height = (bar_zero_y - bar_val_y).abs();
465            // For positive bars, rect y is at the value (above zero line).
466            // For negative bars, rect y is at zero line (bar extends downward).
467            let rect_y = bar_val_y.min(bar_zero_y);
468
469            elements.push(ChartElement::Rect {
470                x: x + x_inset,
471                y: rect_y,
472                width: bar_render_width,
473                height: bar_height,
474                fill: fill_color.clone(),
475                stroke: None,
476                class: "bar".to_string(),
477                data: Some(ElementData::new(&cat, format_value(val, y_fmt_ref))),
478            });
479
480            // Data label above bar (if configured)
481            if let Some(dl) = get_data_labels_config(config) {
482                if dl.show == Some(true) {
483                    let label_fmt = dl.format.as_deref().or(y_fmt_ref);
484                    let label_y = match dl.position.as_deref() {
485                        Some("center") => rect_y + bar_height / 2.0,
486                        Some("bottom") => rect_y + bar_height - 5.0,
487                        _ => if val >= 0.0 { rect_y - 5.0 } else { rect_y + bar_height + 12.0 }, // "top" or default
488                    };
489                    elements.push(ChartElement::Text {
490                        x: x + band.bandwidth() / 2.0,
491                        y: label_y,
492                        content: format_value(val, label_fmt),
493                        anchor: TextAnchor::Middle,
494                        dominant_baseline: None,
495                        transform: None,
496                        font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "11px".to_string())),
497                        font_weight: None,
498                        fill: Some(dl.color.clone().unwrap_or_else(|| "#333".to_string())),
499                        class: "data-label".to_string(),
500                        data: None,
501                    });
502                }
503            }
504        }
505    }
506
507    Ok((value_max, elements))
508}
509
510fn render_multi_series_bars(
511    data: &DataTable,
512    config: &ChartConfig,
513    params: &MultiSeriesBarParams,
514) -> Result<(f64, Vec<ChartElement>), ChartError> {
515    let category_field = params.category_field;
516    let value_field = params.value_field;
517    let color_field = params.color_field;
518    let categories = params.categories;
519    let inner_width = params.inner_width;
520    let inner_height = params.inner_height;
521    let is_stacked = params.is_stacked;
522    let is_normalized = params.is_normalized;
523    let is_horizontal = params.is_horizontal;
524    let y_fmt_ref = params.y_fmt_ref;
525    let domain_min = params.domain_min;
526    let domain_max = params.domain_max;
527    use chartml_core::layout::stack::{StackLayout, StackOffset};
528
529    let series_names = data.unique_values(color_field);
530    let groups = data.group_by(color_field);
531
532    let mut elements = Vec::new();
533
534    if is_stacked {
535        // Build values matrix: values[series_idx][category_idx]
536        let mut values_matrix: Vec<Vec<f64>> = Vec::new();
537        for series in &series_names {
538            let mut series_vals = Vec::new();
539            let series_data = groups.get(series);
540            for cat in categories {
541                let val = series_data
542                    .map(|sd| {
543                        (0..sd.num_rows())
544                            .find_map(|i| {
545                                if sd.get_string(i, category_field).as_deref() == Some(cat.as_str()) {
546                                    sd.get_f64(i, value_field)
547                                } else {
548                                    None
549                                }
550                            })
551                            .unwrap_or(0.0)
552                    })
553                    .unwrap_or(0.0);
554                series_vals.push(val);
555            }
556            values_matrix.push(series_vals);
557        }
558
559        let stack = if is_normalized {
560            StackLayout::new().offset(StackOffset::Normalize)
561        } else {
562            StackLayout::new()
563        };
564        let stacked_points = stack.layout(categories, &series_names, &values_matrix);
565
566        // For normalized mode, domain is 0-1; for regular stacked, use the raw max.
567        let (effective_min, effective_max) = if is_normalized {
568            (0.0, 1.0)
569        } else {
570            let value_max = stacked_points
571                .iter()
572                .map(|p| p.y1)
573                .fold(0.0_f64, f64::max);
574            let value_max = if value_max <= 0.0 { 1.0 } else { value_max };
575            (domain_min, if domain_max < f64::MAX { domain_max } else { value_max })
576        };
577
578        if is_horizontal {
579            // Horizontal stacked: band on y-axis (height), linear on x-axis (width)
580            let band = ScaleBand::new(categories.to_vec(), (0.0, inner_height))
581                .padding(crate::helpers::adaptive_bar_padding(categories.len()));
582            let linear = ScaleLinear::new((effective_min, effective_max), (0.0, inner_width));
583            let bar_render_height = band.bandwidth().min(40.0);
584            let y_inset = (band.bandwidth() - bar_render_height) / 2.0;
585
586            for point in &stacked_points {
587                let y = match band.map(&point.key) {
588                    Some(y) => y,
589                    None => continue,
590                };
591                let x_left = linear.map(point.y0);
592                let x_right = linear.map(point.y1);
593                let bar_width = (x_right - x_left).abs();
594
595                let series_idx = series_names.iter().position(|s| s == &point.series).unwrap_or(0);
596                let fill = config
597                    .colors
598                    .get(series_idx)
599                    .cloned()
600                    .unwrap_or_else(|| "#2E7D9A".to_string());
601
602                elements.push(ChartElement::Rect {
603                    x: x_left.min(x_right),
604                    y: y + y_inset,
605                    width: bar_width,
606                    height: bar_render_height,
607                    fill,
608                    stroke: None,
609                    class: "bar".to_string(),
610                    data: Some(
611                        ElementData::new(&point.key, format_value(point.value, y_fmt_ref))
612                            .with_series(&point.series),
613                    ),
614                });
615            }
616        } else {
617            // Vertical stacked: band on x-axis (width), linear on y-axis (height)
618            let band = ScaleBand::new(categories.to_vec(), (0.0, inner_width))
619                .padding(crate::helpers::adaptive_bar_padding(categories.len()));
620            let linear = ScaleLinear::new((effective_min, effective_max), (inner_height, 0.0));
621            // Match JS: barWidth = min(bandwidth, chartWidth * 0.2), centered in band
622            let max_bar_width = inner_width * 0.2;
623            let bar_render_width = band.bandwidth().min(max_bar_width);
624            let x_inset = (band.bandwidth() - bar_render_width) / 2.0;
625
626            for point in &stacked_points {
627                let x = match band.map(&point.key) {
628                    Some(x) => x,
629                    None => continue,
630                };
631                let y_top = linear.map(point.y1);
632                let y_bottom = linear.map(point.y0);
633                let bar_height = (y_bottom - y_top).abs();
634
635                let series_idx = series_names.iter().position(|s| s == &point.series).unwrap_or(0);
636                let fill = config
637                    .colors
638                    .get(series_idx)
639                    .cloned()
640                    .unwrap_or_else(|| "#2E7D9A".to_string());
641
642                elements.push(ChartElement::Rect {
643                    x: x + x_inset,
644                    y: y_top,
645                    width: bar_render_width,
646                    height: bar_height,
647                    fill,
648                    stroke: None,
649                    class: "bar".to_string(),
650                    data: Some(
651                        ElementData::new(&point.key, format_value(point.value, y_fmt_ref))
652                            .with_series(&point.series),
653                    ),
654                });
655            }
656        }
657
658        Ok((effective_max, elements))
659    } else {
660        // Grouped (or default multi-series)
661        // Find overall max value
662        let value_max = (0..data.num_rows())
663            .filter_map(|i| data.get_f64(i, value_field))
664            .fold(0.0_f64, f64::max);
665        let value_max = if value_max <= 0.0 { 1.0 } else { value_max };
666        let effective_max = if domain_max < f64::MAX { domain_max } else { value_max };
667
668        let num_series = series_names.len().max(1);
669
670        if is_horizontal {
671            // Horizontal grouped: band on y-axis (height), linear on x-axis (width)
672            let band = ScaleBand::new(categories.to_vec(), (0.0, inner_height))
673                .padding(0.05);
674            let linear = ScaleLinear::new((domain_min, effective_max), (0.0, inner_width));
675            let sub_band_height = band.bandwidth() / num_series as f64;
676
677            for i in 0..data.num_rows() {
678                let cat = match data.get_string(i, category_field) {
679                    Some(c) => c,
680                    None => continue,
681                };
682                let series = match data.get_string(i, color_field) {
683                    Some(s) => s,
684                    None => continue,
685                };
686                let val = data.get_f64(i, value_field).unwrap_or(0.0);
687
688                let y_base = match band.map(&cat) {
689                    Some(y) => y,
690                    None => continue,
691                };
692                let series_idx = series_names.iter().position(|s| s == &series).unwrap_or(0);
693                let y = y_base + series_idx as f64 * sub_band_height;
694
695                let bar_left = linear.map(0.0);
696                let bar_right = linear.map(val);
697                let bar_width = (bar_right - bar_left).abs();
698
699                let fill = config
700                    .colors
701                    .get(series_idx)
702                    .cloned()
703                    .unwrap_or_else(|| "#2E7D9A".to_string());
704
705                elements.push(ChartElement::Rect {
706                    x: bar_left.min(bar_right),
707                    y,
708                    width: bar_width,
709                    height: sub_band_height,
710                    fill,
711                    stroke: None,
712                    class: "bar".to_string(),
713                    data: Some(
714                        ElementData::new(&cat, format_value(val, y_fmt_ref)).with_series(&series),
715                    ),
716                });
717            }
718        } else {
719            // Vertical grouped: band on x-axis (width), linear on y-axis (height)
720            let band = ScaleBand::new(categories.to_vec(), (0.0, inner_width))
721                .padding(0.05);
722            let linear = ScaleLinear::new((domain_min, effective_max), (inner_height, 0.0));
723            let sub_band_width = band.bandwidth() / num_series as f64;
724
725            for i in 0..data.num_rows() {
726                let cat = match data.get_string(i, category_field) {
727                    Some(c) => c,
728                    None => continue,
729                };
730                let series = match data.get_string(i, color_field) {
731                    Some(s) => s,
732                    None => continue,
733                };
734                let val = data.get_f64(i, value_field).unwrap_or(0.0);
735
736                let x_base = match band.map(&cat) {
737                    Some(x) => x,
738                    None => continue,
739                };
740                let series_idx = series_names.iter().position(|s| s == &series).unwrap_or(0);
741                let x = x_base + series_idx as f64 * sub_band_width;
742
743                let bar_top = linear.map(val);
744                let bar_bottom = linear.map(0.0);
745                let bar_height = (bar_bottom - bar_top).abs();
746
747                let fill = config
748                    .colors
749                    .get(series_idx)
750                    .cloned()
751                    .unwrap_or_else(|| "#2E7D9A".to_string());
752
753                elements.push(ChartElement::Rect {
754                    x,
755                    y: bar_top,
756                    width: sub_band_width,
757                    height: bar_height,
758                    fill,
759                    stroke: None,
760                    class: "bar".to_string(),
761                    data: Some(
762                        ElementData::new(&cat, format_value(val, y_fmt_ref)).with_series(&series),
763                    ),
764                });
765            }
766        }
767
768        Ok((value_max, elements))
769    }
770}
771
772/// Render a combo chart: multiple fields with different marks (bar/line) and optional dual axis.
773fn render_combo(
774    data: &DataTable,
775    config: &ChartConfig,
776    fields: &[chartml_core::spec::FieldSpec],
777) -> Result<ChartElement, ChartError> {
778    use chartml_core::shapes::LineGenerator;
779    use chartml_core::layout::stack::StackLayout;
780
781    let category_field = get_field_name(&config.visualize.columns)?;
782    let categories = data.unique_values(&category_field);
783    if categories.is_empty() {
784        return Err(ChartError::DataError("No category values found".into()));
785    }
786
787    let y_fmt = get_y_format(config);
788    let y_fmt_ref = y_fmt.as_deref();
789    let grid = GridConfig::from_config(config);
790    let x_format = get_x_format(config);
791
792    // Detect stacking mode and color field for bar sub-series
793    let color_field = get_color_field(config);
794    let is_stacked = matches!(config.visualize.mode, Some(ChartMode::Stacked));
795
796    // Margins — account for right axis if present
797    let has_right = fields.iter().any(|f| f.axis.as_deref() == Some("right"));
798    let right_fmt = config.visualize.axes.as_ref()
799        .and_then(|a| a.right.as_ref())
800        .and_then(|a| a.format.as_deref());
801
802    // Pre-compute right tick labels to measure their width
803    let right_tick_labels: Vec<String> = if has_right {
804        // Estimate right-axis values for label width measurement
805        let right_max = fields.iter()
806            .filter(|f| f.axis.as_deref() == Some("right"))
807            .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
808            .fold(0.0_f64, f64::max);
809        let right_domain_max = config.visualize.axes.as_ref()
810            .and_then(|a| a.right.as_ref())
811            .and_then(|a| a.max)
812            .unwrap_or(if right_max <= 0.0 { 1.0 } else { right_max });
813        let tmp_scale = ScaleLinear::new((0.0, right_domain_max), (0.0, 100.0));
814        tmp_scale.ticks(5).iter().map(|v| format_value(*v, right_fmt)).collect()
815    } else {
816        vec![]
817    };
818
819    let has_x_axis_label = config.visualize.axes.as_ref()
820        .and_then(|a| a.x.as_ref())
821        .and_then(|a| a.label.as_ref())
822        .is_some();
823    let margin_config = MarginConfig {
824        has_title: config.title.is_some(),
825        has_legend: fields.len() > 1 || color_field.is_some(),
826        // Left Y-axis label is not rendered for combo charts (see comment below),
827        // so do not reserve extra left-margin space for it.
828        has_y_axis_label: false,
829        has_x_axis_label,
830        has_right_axis: has_right,
831        right_tick_labels,
832        ..Default::default()
833    };
834    let margins = calculate_margins(&margin_config);
835    let inner_width = margins.inner_width(config.width);
836    let inner_height = margins.inner_height(config.height);
837
838    let band = ScaleBand::new(categories.clone(), (0.0, inner_width))
839        .padding(crate::helpers::adaptive_bar_padding(categories.len()));
840    let bandwidth = band.bandwidth();
841
842    // Separate fields by axis
843    let left_fields: Vec<&chartml_core::spec::FieldSpec> = fields.iter()
844        .filter(|f| f.axis.as_deref() != Some("right"))
845        .collect();
846    let right_fields: Vec<&chartml_core::spec::FieldSpec> = fields.iter()
847        .filter(|f| f.axis.as_deref() == Some("right"))
848        .collect();
849
850    // Compute left-axis domain with D3-style nice rounding (Regressions 2 & 3).
851    // When stacked with a color field, the domain max is the per-category sum of all series.
852    let left_max = if let (true, Some(color_f)) = (is_stacked, color_field.as_ref()) {
853        let color_series = data.unique_values(color_f);
854        let mut max_stack = 0.0_f64;
855        for f in &left_fields {
856            for cat in &categories {
857                let mut stack_total = 0.0_f64;
858                for series in &color_series {
859                    let val = (0..data.num_rows())
860                        .find(|&i| {
861                            data.get_string(i, &category_field).as_deref() == Some(cat.as_str())
862                                && data.get_string(i, color_f).as_deref() == Some(series.as_str())
863                        })
864                        .and_then(|i| data.get_f64(i, &f.field))
865                        .unwrap_or(0.0);
866                    stack_total += val;
867                }
868                max_stack = max_stack.max(stack_total);
869            }
870        }
871        max_stack
872    } else {
873        left_fields.iter()
874            .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
875            .fold(0.0_f64, f64::max)
876    };
877    // Compute left-axis data minimum to support negative bar values.
878    let left_data_min = left_fields.iter()
879        .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
880        .fold(0.0_f64, f64::min);
881    // Keep data_min at 0 when all values are non-negative (standard bar chart behavior)
882    let left_data_min = if left_data_min >= 0.0 { 0.0 } else { left_data_min };
883    let axes_left = config.visualize.axes.as_ref().and_then(|a| a.left.as_ref());
884    let left_explicit_min = axes_left.and_then(|a| a.min);
885    let left_explicit_max = axes_left.and_then(|a| a.max);
886    let raw_left_domain_min = left_explicit_min.unwrap_or(left_data_min);
887    let raw_left_domain_max = left_explicit_max.unwrap_or(if left_max <= 0.0 { 1.0 } else { left_max });
888    let (left_domain_min, left_domain_max) = if left_explicit_min.is_none() && left_explicit_max.is_none() {
889        // Use count=5 to align with generate_y_axis_numeric's hardcoded tick count of 5.
890        nice_domain(raw_left_domain_min, raw_left_domain_max, 5)
891    } else {
892        (raw_left_domain_min, raw_left_domain_max)
893    };
894    let left_scale = ScaleLinear::new((left_domain_min, left_domain_max), (inner_height, 0.0));
895
896    // Compute right-axis domain with D3-style nice rounding (Regressions 2 & 3).
897    let right_scale = if !right_fields.is_empty() {
898        let right_max = right_fields.iter()
899            .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
900            .fold(0.0_f64, f64::max);
901        let right_data_min = right_fields.iter()
902            .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
903            .fold(0.0_f64, f64::min);
904        let axes_right = config.visualize.axes.as_ref().and_then(|a| a.right.as_ref());
905        let right_explicit_min = axes_right.and_then(|a| a.min);
906        let right_explicit_max = axes_right.and_then(|a| a.max);
907        let raw_right_domain_min = right_explicit_min.unwrap_or(if right_data_min < 0.0 { right_data_min } else { 0.0 });
908        let raw_right_domain_max = right_explicit_max.unwrap_or(if right_max <= 0.0 { 1.0 } else { right_max });
909        let (right_domain_min, right_domain_max) = if right_explicit_min.is_none() && right_explicit_max.is_none() {
910            // Use count=5 to align with generate_y_axis_numeric's hardcoded tick count of 5.
911            nice_domain(raw_right_domain_min, raw_right_domain_max, 5)
912        } else {
913            (raw_right_domain_min, raw_right_domain_max)
914        };
915        Some(ScaleLinear::new((right_domain_min, right_domain_max), (inner_height, 0.0)))
916    } else {
917        None
918    };
919
920    let mut children = Vec::new();
921
922    // Title is rendered as HTML outside the SVG — not added here.
923
924    // Axes
925    let bottom_axis_label = config.visualize.axes.as_ref()
926        .and_then(|a| a.x.as_ref())
927        .and_then(|a| a.label.as_deref());
928    let x_axis_result = generate_x_axis(&crate::helpers::XAxisParams {
929        labels: &categories,
930        display_label_overrides: None,
931        range: (0.0, inner_width),
932        y_position: margins.top + inner_height,
933        available_width: inner_width,
934        x_format: x_format.as_deref(),
935        chart_height: Some(inner_height),
936        grid: &grid,
937        axis_label: bottom_axis_label,
938    });
939    let left_axis_label = axes_left.and_then(|a| a.label.as_deref());
940    let y_axis_left = generate_y_axis_numeric(&crate::helpers::YAxisNumericParams {
941        domain: (left_domain_min, left_domain_max),
942        range: (inner_height, 0.0),
943        x_position: margins.left,
944        fmt: y_fmt_ref,
945        tick_count: adaptive_tick_count(inner_height),
946        chart_width: Some(inner_width),
947        grid: &grid,
948        axis_label: left_axis_label,
949    });
950
951    let mut axis_elements = Vec::new();
952    axis_elements.extend(x_axis_result.elements.into_iter().map(|e| offset_element(e, margins.left, 0.0)));
953    axis_elements.extend(y_axis_left.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
954
955    // Right axis — ticks and labels on the right side
956    if let Some(ref rs) = right_scale {
957        let right_fmt = config.visualize.axes.as_ref()
958            .and_then(|a| a.right.as_ref())
959            .and_then(|a| a.format.as_deref());
960        // Right axis label is rendered manually below (outside this block),
961        // so pass None here to avoid duplication.
962        let right_axis = generate_y_axis_numeric_right(
963            rs.domain(), (inner_height, 0.0), margins.left + inner_width,
964            right_fmt, adaptive_tick_count(inner_height),
965            None,
966        );
967        axis_elements.extend(right_axis.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
968    }
969
970    // Right axis title label — rendered manually here with absolute positioning
971    // (the left axis label is already handled by generate_y_axis_numeric above).
972    if let Some(label) = config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.label.clone()) {
973        let rx = config.width - 12.0;
974        axis_elements.push(ChartElement::Text {
975            x: rx,
976            y: margins.top + inner_height / 2.0,
977            content: label,
978            anchor: TextAnchor::Middle,
979            dominant_baseline: None,
980            transform: Some(Transform::Rotate(90.0, rx, margins.top + inner_height / 2.0)),
981            font_size: Some("12px".to_string()),
982            font_weight: None,
983            fill: Some("#666".to_string()),
984            class: "axis-label".to_string(),
985            data: None,
986        });
987    }
988
989    children.push(ChartElement::Group {
990        class: "axes".to_string(), transform: None, children: axis_elements,
991    });
992
993    // Render each field
994    let mut mark_elements = Vec::new();
995    let line_gen = LineGenerator::new().curve(chartml_core::shapes::CurveType::MonotoneX);
996
997    // Count bar fields for grouped subdivision
998    let num_bar_fields = fields.iter()
999        .filter(|f| f.mark.as_deref().unwrap_or("bar") == "bar")
1000        .count()
1001        .max(1);
1002    // Match JS: barWidth = min(bandwidth, chartWidth * 0.2), centered within band
1003    let max_bar_width = inner_width * 0.2;
1004    let effective_bandwidth = bandwidth.min(max_bar_width);
1005    let combo_x_inset = (bandwidth - effective_bandwidth) / 2.0;
1006    let sub_bar_padding = effective_bandwidth * 0.05;
1007    let sub_bar_width = (effective_bandwidth - sub_bar_padding * (num_bar_fields as f64 - 1.0).max(0.0)) / num_bar_fields as f64;
1008    let mut bar_field_idx = 0_usize;
1009    let mut series_names = Vec::new();
1010    let mut series_colors = Vec::new();
1011    let mut series_marks = Vec::new();
1012
1013    // Pre-compute stacked bar layout if stacking with color field
1014    let stacked_bar_rendered = if let (true, Some(color_f)) = (is_stacked, color_field.as_ref()) {
1015        let color_series = data.unique_values(color_f);
1016
1017        // For each bar field, render stacked bars by color series
1018        for field_spec in fields.iter() {
1019            let mark = field_spec.mark.as_deref().unwrap_or("bar");
1020            if mark != "bar" { continue; }
1021
1022            let field_name = &field_spec.field;
1023            let is_right = field_spec.axis.as_deref() == Some("right");
1024            let scale = if is_right { right_scale.as_ref().unwrap_or(&left_scale) } else { &left_scale };
1025            let fmt_ref = if is_right {
1026                config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.format.as_deref())
1027            } else {
1028                y_fmt_ref
1029            };
1030
1031            // Build values matrix: values[series_idx][category_idx]
1032            let mut values_matrix: Vec<Vec<f64>> = Vec::new();
1033            for series in &color_series {
1034                let mut series_vals = Vec::new();
1035                for cat in &categories {
1036                    let val = (0..data.num_rows())
1037                        .find(|&i| {
1038                            data.get_string(i, &category_field).as_deref() == Some(cat.as_str())
1039                                && data.get_string(i, color_f).as_deref() == Some(series.as_str())
1040                        })
1041                        .and_then(|i| data.get_f64(i, field_name))
1042                        .unwrap_or(0.0);
1043                    series_vals.push(val);
1044                }
1045                values_matrix.push(series_vals);
1046            }
1047
1048            let stack = StackLayout::new();
1049            let stacked_points = stack.layout(&categories, &color_series, &values_matrix);
1050
1051            let bar_render_width = bandwidth.min(max_bar_width);
1052            let x_inset = (bandwidth - bar_render_width) / 2.0;
1053
1054            for point in &stacked_points {
1055                let x = match band.map(&point.key) { Some(x) => x, None => continue };
1056                let y_top = scale.map(point.y1);
1057                let y_bottom = scale.map(point.y0);
1058                let bar_height = (y_bottom - y_top).abs();
1059
1060                let series_idx = color_series.iter().position(|s| s == &point.series).unwrap_or(0);
1061                let fill = config.colors.get(series_idx).cloned().unwrap_or_else(|| "#2E7D9A".to_string());
1062
1063                mark_elements.push(ChartElement::Rect {
1064                    x: x + x_inset + margins.left, y: y_top + margins.top,
1065                    width: bar_render_width, height: bar_height,
1066                    fill, stroke: None,
1067                    class: "bar".to_string(),
1068                    data: Some(ElementData::new(&point.key, format_value(point.value, fmt_ref)).with_series(&point.series)),
1069                });
1070            }
1071        }
1072
1073        // Add color series to legend tracking
1074        for (si, series_name) in color_series.iter().enumerate() {
1075            let color = config.colors.get(si).cloned().unwrap_or_else(|| "#2E7D9A".to_string());
1076            series_names.push(series_name.clone());
1077            series_colors.push(color);
1078            series_marks.push("bar".to_string());
1079        }
1080
1081        true
1082    } else {
1083        false
1084    };
1085
1086    for (field_idx, field_spec) in fields.iter().enumerate() {
1087        let field_name = &field_spec.field;
1088        let is_right = field_spec.axis.as_deref() == Some("right");
1089        let scale = if is_right { right_scale.as_ref().unwrap_or(&left_scale) } else { &left_scale };
1090        let mark = field_spec.mark.as_deref().unwrap_or("bar");
1091        let color = field_spec.color.clone()
1092            .unwrap_or_else(|| config.colors.get(field_idx).cloned().unwrap_or_else(|| "#2E7D9A".to_string()));
1093        let label = field_spec.label.clone().unwrap_or_else(|| field_name.clone());
1094        let fmt_ref = if is_right {
1095            config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.format.as_deref())
1096        } else {
1097            y_fmt_ref
1098        };
1099
1100        match mark {
1101            "bar" if stacked_bar_rendered => {
1102                // Already rendered above via stacked layout — skip
1103            }
1104            "bar" => {
1105                let this_bar_idx = bar_field_idx;
1106                bar_field_idx += 1;
1107
1108                for row_i in 0..data.num_rows() {
1109                    let cat = match data.get_string(row_i, &category_field) { Some(c) => c, None => continue };
1110                    let val = data.get_f64(row_i, field_name).unwrap_or(0.0);
1111                    let x = match band.map(&cat) { Some(x) => x, None => continue };
1112                    let bar_x = x + combo_x_inset + this_bar_idx as f64 * (sub_bar_width + sub_bar_padding);
1113                    let bar_val_y = scale.map(val);
1114                    let bar_zero_y = scale.map(0.0);
1115                    let bar_height = (bar_zero_y - bar_val_y).abs();
1116                    let rect_y = bar_val_y.min(bar_zero_y);
1117
1118                    mark_elements.push(ChartElement::Rect {
1119                        x: bar_x + margins.left, y: rect_y + margins.top,
1120                        width: sub_bar_width, height: bar_height,
1121                        fill: color.clone(), stroke: None,
1122                        class: "bar".to_string(),
1123                        data: Some(ElementData::new(&cat, format_value(val, fmt_ref)).with_series(&label)),
1124                    });
1125
1126                    // Data labels
1127                    if let Some(ref dl) = field_spec.data_labels {
1128                        if dl.show == Some(true) {
1129                            let dl_fmt = dl.format.as_deref().or(fmt_ref);
1130                            mark_elements.push(ChartElement::Text {
1131                                x: bar_x + sub_bar_width / 2.0 + margins.left,
1132                                y: rect_y + margins.top - 5.0,
1133                                content: format_value(val, dl_fmt),
1134                                anchor: TextAnchor::Middle, dominant_baseline: None,
1135                                transform: None,
1136                                font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "11px".to_string())),
1137                                font_weight: None,
1138                                fill: Some(dl.color.clone().unwrap_or_else(|| "#333".to_string())),
1139                                class: "data-label".to_string(), data: None,
1140                            });
1141                        }
1142                    }
1143                }
1144            }
1145            _ => {
1146                let mut points = Vec::new();
1147                let mut point_data = Vec::new();
1148                for cat in &categories {
1149                    let row_i = match (0..data.num_rows()).find(|&i| data.get_string(i, &category_field).as_deref() == Some(cat.as_str())) {
1150                        Some(i) => i, None => continue,
1151                    };
1152                    let val = match data.get_f64(row_i, field_name) { Some(v) => v, None => continue };
1153                    let x = match band.map(cat) { Some(x) => x + bandwidth / 2.0, None => continue };
1154                    let y = scale.map(val);
1155                    points.push((x + margins.left, y + margins.top));
1156                    point_data.push((cat.clone(), val));
1157                }
1158
1159                if !points.is_empty() {
1160                    let path_d = line_gen.generate(&points);
1161                    mark_elements.push(ChartElement::Path {
1162                        d: path_d, fill: None, stroke: Some(color.clone()),
1163                        stroke_width: Some(2.0), stroke_dasharray: None,
1164                        opacity: None,
1165                        class: "line".to_string(),
1166                        data: Some(ElementData::new(&label, "").with_series(&label)),
1167                    });
1168
1169                    // Dots
1170                    for (i, &(px, py)) in points.iter().enumerate() {
1171                        let (ref cat, val) = point_data[i];
1172                        mark_elements.push(ChartElement::Circle {
1173                            cx: px, cy: py, r: 5.0,
1174                            fill: color.clone(), stroke: Some("#fff".to_string()),
1175                            class: "chartml-line-dot".to_string(),
1176                            data: Some(ElementData::new(cat, format_value(val, fmt_ref)).with_series(&label)),
1177                        });
1178                    }
1179
1180                    // Data labels
1181                    if let Some(ref dl) = field_spec.data_labels {
1182                        if dl.show == Some(true) {
1183                            let dl_fmt = dl.format.as_deref().or(fmt_ref);
1184                            for (i, &(px, py)) in points.iter().enumerate() {
1185                                let (_, val) = &point_data[i];
1186                                mark_elements.push(ChartElement::Text {
1187                                    x: px, y: py - 10.0,
1188                                    content: format_value(*val, dl_fmt),
1189                                    anchor: TextAnchor::Middle, dominant_baseline: None,
1190                                    transform: None,
1191                                    font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "11px".to_string())),
1192                                    font_weight: None,
1193                                    fill: Some(dl.color.clone().unwrap_or_else(|| color.clone())),
1194                                    class: "data-label".to_string(), data: None,
1195                                });
1196                            }
1197                        }
1198                    }
1199                }
1200            }
1201        }
1202
1203        // When stacked bars were rendered via color field, the color series are already
1204        // tracked for legend — skip adding the bar field itself.
1205        if !(stacked_bar_rendered && mark == "bar") {
1206            series_names.push(label);
1207            series_colors.push(color);
1208            series_marks.push(mark.to_string());
1209        }
1210    }
1211
1212    children.push(ChartElement::Group {
1213        class: "marks".to_string(), transform: None, children: mark_elements,
1214    });
1215
1216    // Annotations — rendered on top of marks, in inner coordinate space
1217    if let Some(annotations) = config.visualize.annotations.as_deref() {
1218        if !annotations.is_empty() {
1219            let ann_elements = generate_annotations(
1220                annotations,
1221                &left_scale,
1222                0.0,
1223                inner_width,
1224                inner_height,
1225                Some(&categories),
1226            );
1227            if !ann_elements.is_empty() {
1228                children.push(ChartElement::Group {
1229                    class: "annotations".to_string(),
1230                    transform: Some(Transform::Translate(margins.left, margins.top)),
1231                    children: ann_elements,
1232                });
1233            }
1234        }
1235    }
1236
1237    // Legend with mixed marks
1238    if series_names.len() > 1 {
1239        let mut legend_elements = Vec::new();
1240        let total_w: f64 = series_names.iter().map(|name| {
1241            let tw = chartml_core::layout::labels::approximate_text_width(name);
1242            12.0 + 6.0 + tw + 16.0
1243        }).sum();
1244        let mut x_offset = (config.width - total_w).max(0.0) / 2.0;
1245
1246        for (i, name) in series_names.iter().enumerate() {
1247            let color = &series_colors[i];
1248            let mark = series_marks[i].as_str();
1249            let y = config.height - 10.0;
1250
1251            match mark {
1252                "line" => {
1253                    legend_elements.push(ChartElement::Line {
1254                        x1: x_offset, y1: y + 6.0, x2: x_offset + 12.0, y2: y + 6.0,
1255                        stroke: color.clone(), stroke_width: Some(2.5),
1256                        stroke_dasharray: None, class: "legend-symbol legend-line".to_string(),
1257                    });
1258                }
1259                _ => {
1260                    legend_elements.push(ChartElement::Rect {
1261                        x: x_offset, y, width: 12.0, height: 12.0,
1262                        fill: color.clone(), stroke: None,
1263                        class: "legend-symbol".to_string(), data: None,
1264                    });
1265                }
1266            }
1267
1268            legend_elements.push(ChartElement::Text {
1269                x: x_offset + 18.0, y: y + 10.0, content: name.clone(),
1270                anchor: TextAnchor::Start, dominant_baseline: None,
1271                transform: None, font_size: Some("11px".to_string()),
1272                font_weight: None,
1273                fill: Some("#333".to_string()), class: "legend-label".to_string(), data: None,
1274            });
1275
1276            let tw = chartml_core::layout::labels::approximate_text_width(name);
1277            x_offset += 12.0 + 6.0 + tw + 16.0;
1278        }
1279
1280        children.push(ChartElement::Group {
1281            class: "legend".to_string(), transform: None, children: legend_elements,
1282        });
1283    }
1284
1285    Ok(ChartElement::Svg {
1286        viewbox: ViewBox::new(0.0, 0.0, config.width, config.height),
1287        width: Some(config.width),
1288        height: Some(config.height),
1289        class: "chartml-bar chartml-combo".to_string(),
1290        children,
1291    })
1292}
1293