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: 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 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 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 let has_right = is_multi_field && multi_fields.iter().any(|f| f.axis.as_deref() == Some("right"));
67
68 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 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 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 let legend_height = if has_series {
128 let legend_series_names: Vec<String> = if is_multi_field {
129 multi_fields.iter()
130 .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 let (domain_min, domain_max, right_domain): (f64, f64, Option<(f64, f64)>) = if has_right {
177 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 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 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 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 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 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 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 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 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 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 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(); 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 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 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; }
439
440 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 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 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 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 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 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 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 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 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 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 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 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