Skip to main content

chartml_chart_cartesian/
line.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::layout::adaptive_tick_count;
7use chartml_core::scales::{ScaleBand, ScaleLinear};
8use chartml_core::shapes::LineGenerator;
9
10use chartml_core::layout::labels::{LabelStrategy, LabelStrategyConfig, TextMetrics};
11
12use chartml_core::layout::legend::{calculate_legend_layout, LegendConfig};
13
14use crate::helpers::{GridConfig, LegendMark, emit_zero_line_if_crosses, format_value, generate_annotations, generate_x_axis, generate_y_axis_numeric, generate_y_axis_numeric_right, generate_legend_with_mark, get_color_field, get_field_name, get_x_format, get_y_format, nice_domain, offset_element};
15
16pub fn render_line(data: &DataTable, config: &ChartConfig) -> Result<ChartElement, ChartError> {
17    use chartml_core::spec::{FieldRef, FieldRefItem, FieldSpec};
18
19    let category_field = get_field_name(&config.visualize.columns)?;
20
21    let categories = data.unique_values(&category_field);
22    if categories.is_empty() {
23        return Err(ChartError::DataError("No category values found".into()));
24    }
25
26    // Detect multi-field rows (e.g., [{field: revenue, color: ...}, {field: target, ...}])
27    let multi_fields: Vec<FieldSpec> = match &config.visualize.rows {
28        Some(FieldRef::Multiple(items)) => items.iter().map(|item| match item {
29            FieldRefItem::Detailed(spec) => spec.as_ref().clone(),
30            FieldRefItem::Simple(name) => FieldSpec {
31                field: name.clone(), mark: None, axis: None, label: None,
32                color: None, format: None, data_labels: None,
33                line_style: None, upper: None, lower: None, opacity: None,
34            },
35        }).collect(),
36        _ => vec![],
37    };
38    let is_multi_field = !multi_fields.is_empty();
39    let value_field = if is_multi_field {
40        multi_fields[0].field.clone()
41    } else {
42        get_field_name(&config.visualize.rows)?
43    };
44
45    let color_field = get_color_field(config);
46    let has_series = color_field.is_some() || is_multi_field;
47
48    // Step 1: Compute label strategy for margin estimation
49    let estimated_width = config.width - 80.0;
50    let x_format = get_x_format(config);
51    let formatted_for_strategy = crate::helpers::format_display_labels(&categories, x_format.as_deref());
52    let x_strategy = LabelStrategy::determine(&formatted_for_strategy, estimated_width, &LabelStrategyConfig {
53        text_metrics: TextMetrics::from_theme_axis_label(&config.theme),
54        ..LabelStrategyConfig::default()
55    });
56    let x_extra_margin = match &x_strategy {
57        LabelStrategy::Rotated { margin, .. } => *margin,
58        _ => 0.0,
59    };
60
61    // Detect dual-axis: check if any multi-field spec has axis: "right"
62    let has_right = is_multi_field && multi_fields.iter().any(|f| f.axis.as_deref() == Some("right"));
63
64    // Step 1b: Pre-compute domain for left margin estimation (matches JS two-pass approach).
65    // When dual-axis, only left-axis fields contribute to the left prelim domain.
66    let all_value_fields_prelim: Vec<String> = if is_multi_field {
67        let mut fields: Vec<String> = multi_fields.iter()
68            .filter(|f| f.mark.as_deref() != Some("range"))
69            .filter(|f| !has_right || f.axis.as_deref() != Some("right"))
70            .map(|f| f.field.clone())
71            .collect();
72        // Include upper/lower bound fields from range marks for domain calculation
73        for f in &multi_fields {
74            if f.mark.as_deref() == Some("range") {
75                if let Some(ref upper) = f.upper { fields.push(upper.clone()); }
76                if let Some(ref lower) = f.lower { fields.push(lower.clone()); }
77            }
78        }
79        fields
80    } else {
81        vec![value_field.clone()]
82    };
83    let mut all_values_prelim: Vec<f64> = Vec::new();
84    for field in &all_value_fields_prelim {
85        for i in 0..data.num_rows() {
86            if let Some(v) = data.get_f64(i, field) {
87                all_values_prelim.push(v);
88            }
89        }
90    }
91    let prelim_value_max = all_values_prelim.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
92    let prelim_value_min = all_values_prelim.iter().cloned().fold(f64::INFINITY, f64::min);
93    let prelim_domain_min = if prelim_value_min >= 0.0 { 0.0 } else { prelim_value_min };
94    let prelim_domain_max = if prelim_value_max <= 0.0 { 1.0 } else { prelim_value_max };
95    let (prelim_domain_min, prelim_domain_max) = nice_domain(prelim_domain_min, prelim_domain_max, 5);
96    let y_fmt = get_y_format(config);
97    let y_fmt_ref = y_fmt.as_deref();
98    let prelim_labels = vec![
99        format_value(prelim_domain_max, y_fmt_ref),
100        format_value(prelim_domain_min, y_fmt_ref),
101    ];
102
103    // Pre-compute right tick labels for margin estimation (mirrors bar.rs render_combo)
104    let right_fmt = config.visualize.axes.as_ref()
105        .and_then(|a| a.right.as_ref())
106        .and_then(|a| a.format.as_deref());
107    let right_tick_labels: Vec<String> = if has_right {
108        let right_max = multi_fields.iter()
109            .filter(|f| f.axis.as_deref() == Some("right"))
110            .flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
111            .fold(0.0_f64, f64::max);
112        let right_domain_max = if right_max <= 0.0 { 1.0 } else { right_max };
113        let tmp_scale = ScaleLinear::new((0.0, right_domain_max), (0.0, 100.0));
114        tmp_scale.ticks(5).iter().map(|v| format_value(*v, right_fmt)).collect()
115    } else {
116        vec![]
117    };
118
119    // Pre-compute legend height so the bottom margin accounts for multi-row legends.
120    let legend_height = if has_series {
121        let legend_series_names: Vec<String> = if is_multi_field {
122            multi_fields.iter().map(|f| {
123                f.label.clone().unwrap_or_else(|| f.field.clone())
124            }).collect()
125        } else if let Some(ref color_f) = color_field {
126            data.unique_values(color_f)
127        } else {
128            vec![]
129        };
130        let legend_config = LegendConfig {
131            text_metrics: TextMetrics::from_theme_legend(&config.theme),
132            ..LegendConfig::default()
133        };
134        calculate_legend_layout(&legend_series_names, &config.colors, config.width, &legend_config).total_height
135    } else {
136        0.0
137    };
138
139    let has_y_axis_label = config.visualize.axes.as_ref()
140        .and_then(|a| a.left.as_ref())
141        .and_then(|a| a.label.as_ref())
142        .is_some();
143    let has_x_axis_label = config.visualize.axes.as_ref()
144        .and_then(|a| a.x.as_ref())
145        .and_then(|a| a.label.as_ref())
146        .is_some();
147    let margin_config = MarginConfig {
148        has_title: config.title.is_some(),
149        legend_height,
150        has_y_axis_label,
151        has_x_axis_label,
152        x_label_strategy_margin: x_extra_margin,
153        y_tick_labels: prelim_labels,
154        has_right_axis: has_right,
155        right_tick_labels,
156        tick_value_metrics: TextMetrics::from_theme_tick_value(&config.theme),
157        axis_label_metrics: TextMetrics::from_theme_axis_label(&config.theme),
158        ..Default::default()
159    };
160    let margins = calculate_margins(&margin_config);
161
162    let inner_width = margins.inner_width(config.width);
163    let inner_height = margins.inner_height(config.height);
164
165    // Find value extent — when dual-axis, compute separate domains for left and right
166    let (domain_min, domain_max, right_domain): (f64, f64, Option<(f64, f64)>) = if has_right {
167        // Left-axis fields only
168        let left_fields: Vec<&str> = multi_fields.iter()
169            .filter(|f| f.axis.as_deref() != Some("right") && f.mark.as_deref() != Some("range"))
170            .map(|f| f.field.as_str())
171            .collect();
172        let mut left_vals: Vec<f64> = Vec::new();
173        for field in &left_fields {
174            for i in 0..data.num_rows() {
175                if let Some(v) = data.get_f64(i, field) { left_vals.push(v); }
176            }
177        }
178        let left_min = left_vals.iter().cloned().fold(f64::INFINITY, f64::min);
179        let left_max = left_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
180        let left_domain_min = if left_min >= 0.0 { 0.0 } else { left_min };
181        let left_domain_max = if left_max <= 0.0 { 1.0 } else { left_max };
182        let (left_domain_min, left_domain_max) = nice_domain(left_domain_min, left_domain_max, 5);
183
184        // Right-axis fields only
185        let right_fields: Vec<&str> = multi_fields.iter()
186            .filter(|f| f.axis.as_deref() == Some("right"))
187            .map(|f| f.field.as_str())
188            .collect();
189        let mut right_vals: Vec<f64> = Vec::new();
190        for field in &right_fields {
191            for i in 0..data.num_rows() {
192                if let Some(v) = data.get_f64(i, field) { right_vals.push(v); }
193            }
194        }
195        let right_min = right_vals.iter().cloned().fold(f64::INFINITY, f64::min);
196        let right_max = right_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
197        let right_domain_min = if right_min >= 0.0 { 0.0 } else { right_min };
198        let right_domain_max = if right_max <= 0.0 { 1.0 } else { right_max };
199        let (right_domain_min, right_domain_max) = nice_domain(right_domain_min, right_domain_max, 5);
200
201        (left_domain_min, left_domain_max, Some((right_domain_min, right_domain_max)))
202    } else {
203        // Single-axis: all fields share one domain
204        let all_value_fields: Vec<String> = if is_multi_field {
205            multi_fields.iter().map(|f| f.field.clone()).collect()
206        } else {
207            vec![value_field.clone()]
208        };
209        let mut all_values: Vec<f64> = Vec::new();
210        for field in &all_value_fields {
211            for i in 0..data.num_rows() {
212                if let Some(v) = data.get_f64(i, field) { all_values.push(v); }
213            }
214        }
215        let value_min = all_values.iter().cloned().fold(f64::INFINITY, f64::min);
216        let value_max = all_values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
217        let dm = if value_min >= 0.0 { 0.0 } else { value_min };
218        let dx = if value_max <= 0.0 { 1.0 } else { value_max };
219        let (dm, dx) = nice_domain(dm, dx, 5);
220        (dm, dx, None)
221    };
222
223    let band = ScaleBand::new(categories.clone(), (0.0, inner_width));
224    let linear = ScaleLinear::new((domain_min, domain_max), (inner_height, 0.0));
225    let right_scale = right_domain.map(|(rmin, rmax)| ScaleLinear::new((rmin, rmax), (inner_height, 0.0)));
226
227    let mut children = Vec::new();
228
229    // Title is rendered as HTML outside the SVG — not added here.
230
231    // Axes — read format string from spec
232    let grid = GridConfig::from_config(config);
233
234    let bottom_axis_label = config.visualize.axes.as_ref()
235        .and_then(|a| a.x.as_ref())
236        .and_then(|a| a.label.as_deref());
237    let x_axis_result = generate_x_axis(&crate::helpers::XAxisParams {
238        labels: &categories,
239        display_label_overrides: None,
240        range: (0.0, inner_width),
241        y_position: margins.top + inner_height,
242        available_width: inner_width,
243        x_format: x_format.as_deref(),
244        chart_height: Some(inner_height),
245        grid: &grid,
246        axis_label: bottom_axis_label,
247        theme: &config.theme,
248    });
249    let left_axis_label = config.visualize.axes.as_ref()
250        .and_then(|a| a.left.as_ref())
251        .and_then(|a| a.label.as_deref());
252    let y_axis_elements = generate_y_axis_numeric(&crate::helpers::YAxisNumericParams {
253        domain: (domain_min, domain_max),
254        range: (inner_height, 0.0),
255        x_position: margins.left,
256        fmt: y_fmt_ref,
257        tick_count: adaptive_tick_count(inner_height),
258        chart_width: Some(inner_width),
259        grid: &grid,
260        axis_label: left_axis_label,
261        theme: &config.theme,
262    });
263
264    let mut axis_elements = Vec::new();
265    axis_elements.extend(
266        x_axis_result.elements
267            .into_iter()
268            .map(|e| offset_element(e, margins.left, 0.0)),
269    );
270    axis_elements.extend(
271        y_axis_elements
272            .into_iter()
273            .map(|e| offset_element(e, 0.0, margins.top)),
274    );
275    // Zero-line (Phase 7): emitted after the y-axis grid lines so the line
276    // series paints over it. No-op when theme.zero_line is None (default) or
277    // when the domain doesn't strictly cross zero.
278    if let Some(zl) = emit_zero_line_if_crosses(
279        &config.theme,
280        (domain_min, domain_max),
281        inner_width,
282        inner_height,
283        false,
284    ) {
285        axis_elements.push(offset_element(zl, margins.left, margins.top));
286    }
287
288    // Right axis — ticks and labels on the right side
289    if let Some(ref rs) = right_scale {
290        let right_axis = generate_y_axis_numeric_right(
291            rs.domain(), (inner_height, 0.0), margins.left + inner_width,
292            right_fmt, adaptive_tick_count(inner_height),
293            None, &config.theme,
294        );
295        axis_elements.extend(right_axis.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
296    }
297
298    // Right axis title label — rendered manually with absolute positioning
299    if has_right {
300        if let Some(label) = config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.label.clone()) {
301            let rx = config.width - 12.0;
302            let ts = TextStyle::for_role(&config.theme, TextRole::AxisLabel);
303            axis_elements.push(ChartElement::Text {
304                x: rx,
305                y: margins.top + inner_height / 2.0,
306                content: label,
307                anchor: TextAnchor::Middle,
308                dominant_baseline: None,
309                transform: Some(Transform::Rotate(90.0, rx, margins.top + inner_height / 2.0)),
310                font_family: ts.font_family,
311                font_size: ts.font_size,
312                font_weight: ts.font_weight,
313                letter_spacing: ts.letter_spacing,
314                text_transform: ts.text_transform,
315                fill: Some(config.theme.text_secondary.clone()),
316                class: "axis-label".to_string(),
317                data: None,
318            });
319        }
320    }
321
322    children.push(ChartElement::Group {
323        class: "axes".to_string(),
324        transform: None,
325        children: axis_elements,
326    });
327
328    // Annotations — rendered below the line (added before line elements)
329    if let Some(annotations) = config.visualize.annotations.as_deref() {
330        if !annotations.is_empty() {
331            let ann_scale = ScaleLinear::new((domain_min, domain_max), (inner_height, 0.0));
332            let ann_elements = generate_annotations(
333                annotations,
334                &ann_scale,
335                0.0,
336                inner_width,
337                inner_height,
338                Some(&categories),
339                &config.theme,
340            );
341            if !ann_elements.is_empty() {
342                children.push(ChartElement::Group {
343                    class: "annotations".to_string(),
344                    transform: Some(Transform::Translate(margins.left, margins.top)),
345                    children: ann_elements,
346                });
347            }
348        }
349    }
350
351    // Line paths — select curve type from style.curveType
352    let curve_type = match config.visualize.style.as_ref().and_then(|s| s.curve_type.as_deref()) {
353        Some("step") => chartml_core::shapes::CurveType::Step,
354        Some("linear") => chartml_core::shapes::CurveType::Linear,
355        _ => chartml_core::shapes::CurveType::MonotoneX,
356    };
357    let line_gen = LineGenerator::new().curve(curve_type);
358    let bandwidth = band.bandwidth();
359    let mut line_elements = Vec::new();
360
361    if is_multi_field {
362        // Multi-field rows: each field spec is a separate line series
363        let mut series_names = Vec::new();
364        let mut series_colors = Vec::new();
365
366        for (field_idx, field_spec) in multi_fields.iter().enumerate() {
367            let color = field_spec.color.clone()
368                .unwrap_or_else(|| config.colors.get(field_idx).cloned().unwrap_or_else(|| "#2E7D9A".to_string()));
369
370            // Range mark — render shaded area between upper and lower bounds
371            if field_spec.mark.as_deref() == Some("range") {
372                if let (Some(ref upper_field), Some(ref lower_field)) = (&field_spec.upper, &field_spec.lower) {
373                    let fill_opacity = field_spec.opacity.unwrap_or(0.15);
374                    let mut area_points: Vec<(f64, f64, f64)> = Vec::new(); // (x, y_upper, y_lower)
375
376                    for cat in &categories {
377                        let row_i = match (0..data.num_rows()).find(|&i| data.get_string(i, &category_field).as_deref() == Some(cat.as_str())) {
378                            Some(i) => i,
379                            None => continue,
380                        };
381                        // Skip rows where bounds are null (historical data)
382                        let upper_val = match data.get_f64(row_i, upper_field) {
383                            Some(v) => v,
384                            None => continue,
385                        };
386                        let lower_val = match data.get_f64(row_i, lower_field) {
387                            Some(v) => v,
388                            None => continue,
389                        };
390                        let x = match band.map(cat) {
391                            Some(x) => x + bandwidth / 2.0,
392                            None => continue,
393                        };
394                        area_points.push((x, linear.map(upper_val), linear.map(lower_val)));
395                    }
396
397                    if !area_points.is_empty() {
398                        // Build area path: forward along upper, backward along lower
399                        let mut d = String::new();
400                        for (i, &(x, y_upper, _)) in area_points.iter().enumerate() {
401                            if i == 0 { d.push_str(&format!("M{:.2},{:.2}", x, y_upper)); }
402                            else { d.push_str(&format!("L{:.2},{:.2}", x, y_upper)); }
403                        }
404                        for &(x, _, y_lower) in area_points.iter().rev() {
405                            d.push_str(&format!("L{:.2},{:.2}", x, y_lower));
406                        }
407                        d.push('Z');
408
409                        line_elements.push(ChartElement::Path {
410                            d,
411                            fill: Some(color.clone()),
412                            stroke: None,
413                            stroke_width: None,
414                            stroke_dasharray: None,
415                            opacity: Some(fill_opacity),
416                            class: "range-area".to_string(),
417                            data: None,
418                            animation_origin: None,
419                        });
420                    }
421                }
422                continue; // range marks don't render as lines
423            }
424
425            let field_name = &field_spec.field;
426            let label = field_spec.label.clone().unwrap_or_else(|| field_name.clone());
427
428            // Determine dash pattern from lineStyle
429            let dasharray = match field_spec.line_style.as_deref() {
430                Some("dashed") => Some("8 4".to_string()),
431                Some("dotted") => Some("2 4".to_string()),
432                _ => None,
433            };
434
435            // Select scale based on axis assignment
436            let is_right_axis = field_spec.axis.as_deref() == Some("right");
437            let scale_for_field = if is_right_axis {
438                right_scale.as_ref().unwrap_or(&linear)
439            } else {
440                &linear
441            };
442            let fmt_for_field: Option<&str> = if is_right_axis { right_fmt } else { y_fmt_ref };
443
444            let mut points: Vec<(f64, f64)> = Vec::new();
445            let mut point_data: Vec<(String, f64)> = Vec::new();
446
447            for cat in &categories {
448                // Find the row for this category
449                let row_i = match (0..data.num_rows()).find(|&i| data.get_string(i, &category_field).as_deref() == Some(cat.as_str())) {
450                    Some(i) => i,
451                    None => continue,
452                };
453                let val = match data.get_f64(row_i, field_name) {
454                    Some(v) => v,
455                    None => continue,
456                };
457                let x = match band.map(cat) {
458                    Some(x) => x + bandwidth / 2.0,
459                    None => continue,
460                };
461                let y = scale_for_field.map(val);
462                points.push((x, y));
463                point_data.push((cat.clone(), val));
464            }
465
466            if points.is_empty() {
467                continue;
468            }
469
470            // Only emit a line path when there are 2+ points (a single point
471            // produces a degenerate M-only path with no visible line).
472            if points.len() > 1 {
473                let path_d = line_gen.generate(&points);
474
475                line_elements.push(ChartElement::Path {
476                    d: path_d,
477                    fill: None,
478                    stroke: Some(color.clone()),
479                    stroke_width: Some(config.theme.series_line_weight as f64),
480                    stroke_dasharray: dasharray,
481                    opacity: None,
482                    class: "chartml-line-path series-line".to_string(),
483                    data: Some(ElementData::new(&label, "").with_series(&label)),
484                    animation_origin: None,
485                });
486            }
487
488            // Hover dots
489            let dot_r = config.theme.dot_radius as f64;
490            for (i, &(px, py)) in points.iter().enumerate() {
491                let (ref cat, val) = point_data[i];
492                if let Some(halo) = emit_dot_halo_if_enabled(&config.theme, px, py, dot_r) {
493                    line_elements.push(halo);
494                }
495                line_elements.push(ChartElement::Circle {
496                    cx: px, cy: py, r: dot_r,
497                    fill: color.clone(),
498                    stroke: Some(config.theme.bg.clone()),
499                    class: "chartml-line-dot dot-marker".to_string(),
500                    data: Some(ElementData::new(cat, format_value(val, fmt_for_field)).with_series(&label)),
501                });
502            }
503
504            // Data labels (if configured on this field spec)
505            if let Some(ref dl) = field_spec.data_labels {
506                if dl.show == Some(true) {
507                    let dl_fmt = dl.format.as_deref().or(y_fmt_ref);
508                    for (i, &(px, py)) in points.iter().enumerate() {
509                        let (_, val) = &point_data[i];
510                        let label_y = match dl.position.as_deref() {
511                            Some("bottom") => py + 15.0,
512                            _ => py - 10.0,
513                        };
514                        line_elements.push(ChartElement::Text {
515                            x: px, y: label_y,
516                            content: format_value(*val, dl_fmt),
517                            anchor: TextAnchor::Middle,
518                            dominant_baseline: None,
519                            transform: None,
520                            font_family: None,
521                            font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "12px".to_string())),
522                            font_weight: None,
523                            letter_spacing: None,
524                            text_transform: None,
525                            fill: Some(dl.color.clone().unwrap_or_else(|| color.clone())),
526                            class: "data-label".to_string(),
527                            data: None,
528                        });
529                    }
530                }
531            }
532
533            series_names.push(label);
534            series_colors.push(color);
535        }
536
537        // Legend
538        let legend_config = LegendConfig {
539            text_metrics: TextMetrics::from_theme_legend(&config.theme),
540            ..LegendConfig::default()
541        };
542        let legend_layout = calculate_legend_layout(&series_names, &series_colors, config.width, &legend_config);
543        let legend_y = config.height - legend_layout.total_height - 8.0;
544        let legend_elements = generate_legend_with_mark(&series_names, &series_colors, config.width, legend_y, LegendMark::Line, &config.theme);
545        children.push(ChartElement::Group {
546            class: "legend".to_string(),
547            transform: None,
548            children: legend_elements,
549        });
550    } else if let Some(ref color_f) = color_field {
551        let series_names = data.unique_values(color_f);
552        let groups = data.group_by(color_f);
553
554        for (series_idx, series_name) in series_names.iter().enumerate() {
555            let series_data = match groups.get(series_name) {
556                Some(d) => d,
557                None => continue,
558            };
559
560            let mut points: Vec<(f64, f64)> = Vec::new();
561            let mut point_data: Vec<(String, f64)> = Vec::new();
562
563            for cat in &categories {
564                let row_i = match (0..series_data.num_rows()).find(|&i| {
565                    series_data.get_string(i, &category_field).as_deref() == Some(cat.as_str())
566                }) {
567                    Some(i) => i,
568                    None => continue,
569                };
570                let val = match series_data.get_f64(row_i, &value_field) {
571                    Some(v) => v,
572                    None => continue,
573                };
574                let x = match band.map(cat) {
575                    Some(x) => x + bandwidth / 2.0,
576                    None => continue,
577                };
578                let y = linear.map(val);
579                points.push((x, y));
580                point_data.push((cat.clone(), val));
581            }
582
583            if points.is_empty() {
584                continue;
585            }
586
587            let color = config
588                .colors
589                .get(series_idx)
590                .cloned()
591                .unwrap_or_else(|| "#2E7D9A".to_string());
592
593            if points.len() > 1 {
594                let path_d = line_gen.generate(&points);
595                line_elements.push(ChartElement::Path {
596                    d: path_d,
597                    fill: None,
598                    stroke: Some(color.clone()),
599                    stroke_width: Some(config.theme.series_line_weight as f64),
600                    stroke_dasharray: None,
601                    opacity: None,
602                    class: "chartml-line-path series-line".to_string(),
603                    data: Some(ElementData::new(series_name, "").with_series(series_name)),
604                    animation_origin: None,
605                });
606            }
607
608            // Hover dots at each data point
609            let dot_r = config.theme.dot_radius as f64;
610            for (i, &(px, py)) in points.iter().enumerate() {
611                let (ref cat, val) = point_data[i];
612                if let Some(halo) = emit_dot_halo_if_enabled(&config.theme, px, py, dot_r) {
613                    line_elements.push(halo);
614                }
615                line_elements.push(ChartElement::Circle {
616                    cx: px,
617                    cy: py,
618                    r: dot_r,
619                    fill: color.clone(),
620                    stroke: Some(config.theme.bg.clone()),
621                    class: "chartml-line-dot dot-marker".to_string(),
622                    data: Some(ElementData::new(cat, format_value(val, y_fmt_ref)).with_series(series_name)),
623                });
624            }
625        }
626
627        // Legend
628        let legend_config = LegendConfig {
629            text_metrics: TextMetrics::from_theme_legend(&config.theme),
630            ..LegendConfig::default()
631        };
632        let legend_layout = calculate_legend_layout(&series_names, &config.colors, config.width, &legend_config);
633        let legend_y = config.height - legend_layout.total_height - 8.0;
634        let legend_elements =
635            generate_legend_with_mark(&series_names, &config.colors, config.width, legend_y, LegendMark::Line, &config.theme);
636        children.push(ChartElement::Group {
637            class: "legend".to_string(),
638            transform: None,
639            children: legend_elements,
640        });
641    } else {
642        // Single series
643        let mut points: Vec<(f64, f64)> = Vec::new();
644        let mut point_data: Vec<(String, f64)> = Vec::new();
645
646        for cat in &categories {
647            let row_i = match (0..data.num_rows()).find(|&i| {
648                data.get_string(i, &category_field).as_deref() == Some(cat.as_str())
649            }) {
650                Some(i) => i,
651                None => continue,
652            };
653            let val = match data.get_f64(row_i, &value_field) {
654                Some(v) => v,
655                None => continue,
656            };
657            let x = match band.map(cat) {
658                Some(x) => x + bandwidth / 2.0,
659                None => continue,
660            };
661            let y = linear.map(val);
662            points.push((x, y));
663            point_data.push((cat.clone(), val));
664        }
665
666        if !points.is_empty() {
667            let color = config
668                .colors
669                .first()
670                .cloned()
671                .unwrap_or_else(|| "#2E7D9A".to_string());
672
673            if points.len() > 1 {
674                let path_d = line_gen.generate(&points);
675                line_elements.push(ChartElement::Path {
676                    d: path_d,
677                    fill: None,
678                    stroke: Some(color.clone()),
679                    stroke_width: Some(config.theme.series_line_weight as f64),
680                    stroke_dasharray: None,
681                    opacity: None,
682                    class: "chartml-line-path series-line".to_string(),
683                    data: None,
684                    animation_origin: None,
685                });
686            }
687
688            // Hover dots at each data point
689            let dot_r = config.theme.dot_radius as f64;
690            for (i, &(px, py)) in points.iter().enumerate() {
691                let (ref cat, val) = point_data[i];
692                if let Some(halo) = emit_dot_halo_if_enabled(&config.theme, px, py, dot_r) {
693                    line_elements.push(halo);
694                }
695                line_elements.push(ChartElement::Circle {
696                    cx: px,
697                    cy: py,
698                    r: dot_r,
699                    fill: color.clone(),
700                    stroke: Some(config.theme.bg.clone()),
701                    class: "chartml-line-dot dot-marker".to_string(),
702                    data: Some(ElementData::new(cat, format_value(val, y_fmt_ref))),
703                });
704            }
705        }
706    }
707
708    children.push(ChartElement::Group {
709        class: "lines".to_string(),
710        transform: Some(Transform::Translate(margins.left, margins.top)),
711        children: line_elements,
712    });
713
714    Ok(ChartElement::Svg {
715        viewbox: ViewBox::new(0.0, 0.0, config.width, config.height),
716        width: Some(config.width),
717        height: Some(config.height),
718        class: "chartml-line".to_string(),
719        children,
720    })
721}
722