1use bon::bon;
2
3use polars::{
4 frame::DataFrame,
5 prelude::{col, IntoLazy},
6};
7
8use crate::{
9 components::{
10 Axis, FacetConfig, Legend, Line as LineStyle, Mode, Rgb, Shape, Text, DEFAULT_PLOTLY_COLORS,
11 },
12 ir::data::ColumnData,
13 ir::layout::LayoutIR,
14 ir::line::LineIR,
15 ir::marker::MarkerIR,
16 ir::trace::{LinePlotIR, TraceIR},
17};
18
19#[derive(Clone)]
126#[allow(dead_code)]
127pub struct LinePlot {
128 traces: Vec<TraceIR>,
129 layout: LayoutIR,
130}
131
132#[bon]
133impl LinePlot {
134 #[builder(on(String, into), on(Text, into))]
135 pub fn new(
136 data: &DataFrame,
137 x: &str,
138 y: &str,
139 additional_lines: Option<Vec<&str>>,
140 facet: Option<&str>,
141 facet_config: Option<&FacetConfig>,
142 size: Option<usize>,
143 color: Option<Rgb>,
144 colors: Option<Vec<Rgb>>,
145 shape: Option<Shape>,
146 shapes: Option<Vec<Shape>>,
147 width: Option<f64>,
148 line: Option<LineStyle>,
149 lines: Option<Vec<LineStyle>>,
150 plot_title: Option<Text>,
151 x_title: Option<Text>,
152 y_title: Option<Text>,
153 y2_title: Option<Text>,
154 legend_title: Option<Text>,
155 x_axis: Option<&Axis>,
156 y_axis: Option<&Axis>,
157 y2_axis: Option<&Axis>,
158 legend: Option<&Legend>,
159 ) -> Self {
160 let grid = facet.map(|facet_column| {
161 let config = facet_config.cloned().unwrap_or_default();
162 let facet_categories =
163 crate::data::get_unique_groups(data, facet_column, config.sorter);
164 let n_facets = facet_categories.len();
165 let (ncols, nrows) =
166 crate::faceting::calculate_grid_dimensions(n_facets, config.cols, config.rows);
167 crate::ir::facet::GridSpec {
168 kind: crate::ir::facet::FacetKind::Axis,
169 rows: nrows,
170 cols: ncols,
171 h_gap: config.h_gap,
172 v_gap: config.v_gap,
173 scales: config.scales.clone(),
174 n_facets,
175 facet_categories,
176 title_style: config.title_style.clone(),
177 x_title: x_title.clone(),
178 y_title: y_title.clone(),
179 x_axis: x_axis.cloned(),
180 y_axis: y_axis.cloned(),
181 legend_title: legend_title.clone(),
182 legend: legend.cloned(),
183 }
184 });
185
186 let layout = LayoutIR {
187 title: plot_title.clone(),
188 x_title: if grid.is_some() {
189 None
190 } else {
191 x_title.clone()
192 },
193 y_title: if grid.is_some() {
194 None
195 } else {
196 y_title.clone()
197 },
198 y2_title: if grid.is_some() {
199 None
200 } else {
201 y2_title.clone()
202 },
203 z_title: None,
204 legend_title: if grid.is_some() {
205 None
206 } else {
207 legend_title.clone()
208 },
209 legend: if grid.is_some() {
210 None
211 } else {
212 legend.cloned()
213 },
214 dimensions: None,
215 bar_mode: None,
216 box_mode: None,
217 box_gap: None,
218 margin_bottom: None,
219 axes_2d: if grid.is_some() {
220 None
221 } else {
222 Some(crate::ir::layout::Axes2dIR {
223 x_axis: x_axis.cloned(),
224 y_axis: y_axis.cloned(),
225 y2_axis: y2_axis.cloned(),
226 })
227 },
228 scene_3d: None,
229 polar: None,
230 mapbox: None,
231 grid,
232 annotations: vec![],
233 };
234
235 let traces = match facet {
236 Some(facet_column) => {
237 let config = facet_config.cloned().unwrap_or_default();
238 Self::create_ir_traces_faceted(
239 data,
240 x,
241 y,
242 additional_lines,
243 facet_column,
244 &config,
245 size,
246 color,
247 colors,
248 shape,
249 shapes,
250 width,
251 line,
252 lines,
253 )
254 }
255 None => Self::create_ir_traces(
256 data,
257 x,
258 y,
259 additional_lines,
260 size,
261 color,
262 colors,
263 shape,
264 shapes,
265 width,
266 line,
267 lines,
268 ),
269 };
270
271 Self { traces, layout }
272 }
273}
274
275#[bon]
276impl LinePlot {
277 #[builder(
278 start_fn = try_builder,
279 finish_fn = try_build,
280 builder_type = LinePlotTryBuilder,
281 on(String, into),
282 on(Text, into),
283 )]
284 pub fn try_new(
285 data: &DataFrame,
286 x: &str,
287 y: &str,
288 additional_lines: Option<Vec<&str>>,
289 facet: Option<&str>,
290 facet_config: Option<&FacetConfig>,
291 size: Option<usize>,
292 color: Option<Rgb>,
293 colors: Option<Vec<Rgb>>,
294 shape: Option<Shape>,
295 shapes: Option<Vec<Shape>>,
296 width: Option<f64>,
297 line: Option<LineStyle>,
298 lines: Option<Vec<LineStyle>>,
299 plot_title: Option<Text>,
300 x_title: Option<Text>,
301 y_title: Option<Text>,
302 y2_title: Option<Text>,
303 legend_title: Option<Text>,
304 x_axis: Option<&Axis>,
305 y_axis: Option<&Axis>,
306 y2_axis: Option<&Axis>,
307 legend: Option<&Legend>,
308 ) -> Result<Self, crate::io::PlotlarsError> {
309 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
310 Self::__orig_new(
311 data,
312 x,
313 y,
314 additional_lines,
315 facet,
316 facet_config,
317 size,
318 color,
319 colors,
320 shape,
321 shapes,
322 width,
323 line,
324 lines,
325 plot_title,
326 x_title,
327 y_title,
328 y2_title,
329 legend_title,
330 x_axis,
331 y_axis,
332 y2_axis,
333 legend,
334 )
335 }))
336 .map_err(|panic| {
337 let msg = panic
338 .downcast_ref::<String>()
339 .cloned()
340 .or_else(|| panic.downcast_ref::<&str>().map(|s| s.to_string()))
341 .unwrap_or_else(|| "unknown error".to_string());
342 crate::io::PlotlarsError::PlotBuild { message: msg }
343 })
344 }
345}
346
347impl LinePlot {
348 #[allow(clippy::too_many_arguments)]
349 fn create_ir_traces(
350 data: &DataFrame,
351 x_col: &str,
352 y_col: &str,
353 additional_lines: Option<Vec<&str>>,
354 size: Option<usize>,
355 color: Option<Rgb>,
356 colors: Option<Vec<Rgb>>,
357 shape: Option<Shape>,
358 shapes: Option<Vec<Shape>>,
359 width: Option<f64>,
360 style: Option<LineStyle>,
361 styles: Option<Vec<LineStyle>>,
362 ) -> Vec<TraceIR> {
363 let mut traces = Vec::new();
364
365 let mode = Self::resolve_mode(shape, shapes.as_ref());
366
367 let marker_ir = MarkerIR {
368 opacity: None,
369 size,
370 color: Self::resolve_color(0, color, colors.clone()),
371 shape: Self::resolve_shape(0, shape, shapes.clone()),
372 };
373
374 let line_ir = Self::resolve_line_ir(0, width, style, styles.clone());
375
376 traces.push(TraceIR::LinePlot(LinePlotIR {
377 x: ColumnData::Numeric(crate::data::get_numeric_column(data, x_col)),
378 y: ColumnData::Numeric(crate::data::get_numeric_column(data, y_col)),
379 name: Some(y_col.to_string()),
380 marker: Some(marker_ir),
381 line: Some(line_ir),
382 mode,
383 show_legend: None,
384 legend_group: None,
385 subplot_ref: None,
386 }));
387
388 if let Some(additional_lines) = additional_lines {
389 for (i, series) in additional_lines.into_iter().enumerate() {
390 let subset = data
391 .clone()
392 .lazy()
393 .select([col(x_col), col(series)])
394 .collect()
395 .unwrap();
396
397 let marker_ir = MarkerIR {
398 opacity: None,
399 size,
400 color: Self::resolve_color(i + 1, color, colors.clone()),
401 shape: Self::resolve_shape(i + 1, shape, shapes.clone()),
402 };
403
404 let line_ir = Self::resolve_line_ir(i + 1, width, style, styles.clone());
405
406 traces.push(TraceIR::LinePlot(LinePlotIR {
407 x: ColumnData::Numeric(crate::data::get_numeric_column(&subset, x_col)),
408 y: ColumnData::Numeric(crate::data::get_numeric_column(&subset, series)),
409 name: Some(series.to_string()),
410 marker: Some(marker_ir),
411 line: Some(line_ir),
412 mode,
413 show_legend: None,
414 legend_group: None,
415 subplot_ref: None,
416 }));
417 }
418 }
419
420 traces
421 }
422
423 #[allow(clippy::too_many_arguments)]
424 fn create_ir_traces_faceted(
425 data: &DataFrame,
426 x: &str,
427 y: &str,
428 additional_lines: Option<Vec<&str>>,
429 facet_column: &str,
430 config: &FacetConfig,
431 size: Option<usize>,
432 color: Option<Rgb>,
433 colors: Option<Vec<Rgb>>,
434 shape: Option<Shape>,
435 shapes: Option<Vec<Shape>>,
436 width: Option<f64>,
437 style: Option<LineStyle>,
438 styles: Option<Vec<LineStyle>>,
439 ) -> Vec<TraceIR> {
440 const MAX_FACETS: usize = 8;
441
442 let facet_categories = crate::data::get_unique_groups(data, facet_column, config.sorter);
443
444 if facet_categories.len() > MAX_FACETS {
445 panic!(
446 "Facet column '{}' has {} unique values, but plotly.rs supports maximum {} subplots",
447 facet_column,
448 facet_categories.len(),
449 MAX_FACETS
450 );
451 }
452
453 let all_y_cols = if let Some(ref add_lines) = additional_lines {
454 let mut cols = vec![y];
455 cols.extend(add_lines.iter().copied());
456 cols
457 } else {
458 vec![y]
459 };
460
461 if let Some(ref color_vec) = colors {
462 if additional_lines.is_none() {
463 let color_count = color_vec.len();
464 let facet_count = facet_categories.len();
465 if color_count != facet_count {
466 panic!(
467 "When using colors with facet (without additional_lines), colors.len() must equal number of facets. \
468 Expected {} colors for {} facets, but got {} colors. \
469 Each facet must be assigned exactly one color.",
470 facet_count, facet_count, color_count
471 );
472 }
473 } else {
474 let color_count = color_vec.len();
475 let line_count = all_y_cols.len();
476 if color_count < line_count {
477 panic!(
478 "When using colors with additional_lines, colors.len() must be >= number of lines. \
479 Need at least {} colors for {} lines, but got {} colors",
480 line_count, line_count, color_count
481 );
482 }
483 }
484 }
485
486 let colors = if additional_lines.is_some() && colors.is_none() {
487 Some(DEFAULT_PLOTLY_COLORS.to_vec())
488 } else {
489 colors
490 };
491
492 let mut traces = Vec::new();
493
494 let mode_for_facet = Self::resolve_mode(shape, shapes.as_ref());
495
496 if config.highlight_facet {
497 for (facet_idx, facet_value) in facet_categories.iter().enumerate() {
498 let subplot_ref = format!(
499 "{}{}",
500 crate::faceting::get_axis_reference(facet_idx, "x"),
501 crate::faceting::get_axis_reference(facet_idx, "y")
502 );
503
504 for other_facet_value in facet_categories.iter() {
505 if other_facet_value != facet_value {
506 let other_data = crate::data::filter_data_by_group(
507 data,
508 facet_column,
509 other_facet_value,
510 );
511
512 let grey_color = config.unhighlighted_color.unwrap_or(Rgb(200, 200, 200));
513
514 for (line_idx, y_col) in all_y_cols.iter().enumerate() {
515 let marker_ir = MarkerIR {
516 opacity: None,
517 size,
518 color: Some(grey_color),
519 shape: Self::resolve_shape(line_idx, shape, None),
520 };
521
522 let line_ir =
523 Self::resolve_line_ir(line_idx, width, style, styles.clone());
524
525 traces.push(TraceIR::LinePlot(LinePlotIR {
526 x: ColumnData::Numeric(crate::data::get_numeric_column(
527 &other_data,
528 x,
529 )),
530 y: ColumnData::Numeric(crate::data::get_numeric_column(
531 &other_data,
532 y_col,
533 )),
534 name: None,
535 marker: Some(marker_ir),
536 line: Some(line_ir),
537 mode: mode_for_facet,
538 show_legend: Some(false),
539 legend_group: None,
540 subplot_ref: Some(subplot_ref.clone()),
541 }));
542 }
543 }
544 }
545
546 let facet_data = crate::data::filter_data_by_group(data, facet_column, facet_value);
547
548 for (line_idx, y_col) in all_y_cols.iter().enumerate() {
549 let color_index = if additional_lines.is_none() {
550 facet_idx
551 } else {
552 line_idx
553 };
554
555 let marker_ir = MarkerIR {
556 opacity: None,
557 size,
558 color: Self::resolve_color(color_index, color, colors.clone()),
559 shape: Self::resolve_shape(color_index, shape, shapes.clone()),
560 };
561
562 let line_ir = Self::resolve_line_ir(line_idx, width, style, styles.clone());
563
564 let show_legend = facet_idx == 0;
565 let name = if show_legend {
566 Some(y_col.to_string())
567 } else {
568 None
569 };
570
571 traces.push(TraceIR::LinePlot(LinePlotIR {
572 x: ColumnData::Numeric(crate::data::get_numeric_column(&facet_data, x)),
573 y: ColumnData::Numeric(crate::data::get_numeric_column(&facet_data, y_col)),
574 name,
575 marker: Some(marker_ir),
576 line: Some(line_ir),
577 mode: mode_for_facet,
578 show_legend: Some(show_legend),
579 legend_group: Some(y_col.to_string()),
580 subplot_ref: Some(subplot_ref.clone()),
581 }));
582 }
583 }
584 } else {
585 for (facet_idx, facet_value) in facet_categories.iter().enumerate() {
586 let facet_data = crate::data::filter_data_by_group(data, facet_column, facet_value);
587
588 let subplot_ref = format!(
589 "{}{}",
590 crate::faceting::get_axis_reference(facet_idx, "x"),
591 crate::faceting::get_axis_reference(facet_idx, "y")
592 );
593
594 for (line_idx, y_col) in all_y_cols.iter().enumerate() {
595 let color_index = if additional_lines.is_none() {
596 facet_idx
597 } else {
598 line_idx
599 };
600
601 let marker_ir = MarkerIR {
602 opacity: None,
603 size,
604 color: Self::resolve_color(color_index, color, colors.clone()),
605 shape: Self::resolve_shape(color_index, shape, shapes.clone()),
606 };
607
608 let line_ir = Self::resolve_line_ir(line_idx, width, style, styles.clone());
609
610 let show_legend = facet_idx == 0;
611 let name = if show_legend {
612 Some(y_col.to_string())
613 } else {
614 None
615 };
616
617 traces.push(TraceIR::LinePlot(LinePlotIR {
618 x: ColumnData::Numeric(crate::data::get_numeric_column(&facet_data, x)),
619 y: ColumnData::Numeric(crate::data::get_numeric_column(&facet_data, y_col)),
620 name,
621 marker: Some(marker_ir),
622 line: Some(line_ir),
623 mode: mode_for_facet,
624 show_legend: Some(show_legend),
625 legend_group: Some(y_col.to_string()),
626 subplot_ref: Some(subplot_ref.clone()),
627 }));
628 }
629 }
630 }
631
632 traces
633 }
634
635 fn resolve_color(index: usize, color: Option<Rgb>, colors: Option<Vec<Rgb>>) -> Option<Rgb> {
636 if let Some(c) = color {
637 return Some(c);
638 }
639 if let Some(ref cs) = colors {
640 return cs.get(index).copied();
641 }
642 None
643 }
644
645 fn resolve_shape(
646 index: usize,
647 shape: Option<Shape>,
648 shapes: Option<Vec<Shape>>,
649 ) -> Option<Shape> {
650 if let Some(s) = shape {
651 return Some(s);
652 }
653 if let Some(ref ss) = shapes {
654 return ss.get(index).cloned();
655 }
656 None
657 }
658
659 fn resolve_line_ir(
660 index: usize,
661 width: Option<f64>,
662 style: Option<LineStyle>,
663 styles: Option<Vec<LineStyle>>,
664 ) -> LineIR {
665 let resolved_style = if style.is_some() {
666 style
667 } else {
668 styles.and_then(|ss| ss.get(index).cloned())
669 };
670
671 LineIR {
672 width,
673 style: resolved_style,
674 color: None,
675 }
676 }
677
678 fn resolve_mode(shape: Option<Shape>, shapes: Option<&Vec<Shape>>) -> Option<Mode> {
679 if shape.is_some() || shapes.is_some() {
680 Some(Mode::LinesMarkers)
681 } else {
682 Some(Mode::Lines)
683 }
684 }
685}
686
687impl crate::Plot for LinePlot {
688 fn ir_traces(&self) -> &[TraceIR] {
689 &self.traces
690 }
691
692 fn ir_layout(&self) -> &LayoutIR {
693 &self.layout
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700 use crate::Plot;
701 use polars::prelude::*;
702
703 fn assert_rgb(actual: Option<Rgb>, r: u8, g: u8, b: u8) {
704 let c = actual.expect("expected Some(Rgb)");
705 assert_eq!((c.0, c.1, c.2), (r, g, b));
706 }
707
708 #[test]
709 fn test_basic_one_trace() {
710 let df = df!["x" => [1.0, 2.0, 3.0], "y" => [4.0, 5.0, 6.0]].unwrap();
711 let plot = LinePlot::builder().data(&df).x("x").y("y").build();
712 assert_eq!(plot.ir_traces().len(), 1);
713 assert!(matches!(plot.ir_traces()[0], TraceIR::LinePlot(_)));
714 }
715
716 #[test]
717 fn test_with_additional_lines() {
718 let df = df![
719 "x" => [1.0, 2.0, 3.0],
720 "y" => [4.0, 5.0, 6.0],
721 "y2" => [7.0, 8.0, 9.0]
722 ]
723 .unwrap();
724 let plot = LinePlot::builder()
725 .data(&df)
726 .x("x")
727 .y("y")
728 .additional_lines(vec!["y2"])
729 .build();
730 assert_eq!(plot.ir_traces().len(), 2);
731 assert!(matches!(plot.ir_traces()[1], TraceIR::LinePlot(_)));
732 }
733
734 #[test]
735 fn test_layout_titles() {
736 let df = df!["x" => [1.0, 2.0], "y" => [3.0, 4.0]].unwrap();
737 let plot = LinePlot::builder()
738 .data(&df)
739 .x("x")
740 .y("y")
741 .plot_title("My Title")
742 .x_title("X Axis")
743 .y_title("Y Axis")
744 .build();
745 let layout = plot.ir_layout();
746 assert!(layout.title.is_some());
747 assert!(layout.x_title.is_some());
748 assert!(layout.y_title.is_some());
749 assert!(layout.axes_2d.is_some());
750 }
751
752 #[test]
753 fn test_resolve_color_singular_priority() {
754 let result = LinePlot::resolve_color(0, Some(Rgb(255, 0, 0)), Some(vec![Rgb(0, 0, 255)]));
755 assert_rgb(result, 255, 0, 0);
756 }
757
758 #[test]
759 fn test_resolve_color_both_none() {
760 let result = LinePlot::resolve_color(0, None, None);
761 assert!(result.is_none());
762 }
763
764 #[test]
765 fn test_faceted_trace_count() {
766 let df = df![
767 "x" => [1.0, 2.0, 3.0, 4.0],
768 "y" => [4.0, 5.0, 6.0, 7.0],
769 "facet_col" => ["a", "a", "b", "b"]
770 ]
771 .unwrap();
772 let plot = LinePlot::builder()
773 .data(&df)
774 .x("x")
775 .y("y")
776 .facet("facet_col")
777 .build();
778 assert_eq!(plot.ir_traces().len(), 2);
780 }
781}