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 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 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 let has_right = is_multi_field && multi_fields.iter().any(|f| f.axis.as_deref() == Some("right"));
59
60 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 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 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 let (domain_min, domain_max, right_domain): (f64, f64, Option<(f64, f64)>) = if has_right {
141 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 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 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 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 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 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 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 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 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 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(); 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 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 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; }
378
379 let field_name = &field_spec.field;
380 let label = field_spec.label.clone().unwrap_or_else(|| field_name.clone());
381
382 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 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 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 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 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 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 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 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 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 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 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