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