Skip to main content

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