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 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 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 let has_right = is_multi_field && multi_fields.iter().any(|f| f.axis.as_deref() == Some("right"));
63
64 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 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 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 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 let (domain_min, domain_max, right_domain): (f64, f64, Option<(f64, f64)>) = if has_right {
167 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 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 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 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 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 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 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 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 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 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 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(); 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 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 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; }
424
425 let field_name = &field_spec.field;
426 let label = field_spec.label.clone().unwrap_or_else(|| field_name.clone());
427
428 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 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 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 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 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 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 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 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 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 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 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