1use bon::bon;
2
3use plotly::{
4 common::{Line as LinePlotly, Marker as MarkerPlotly, Mode},
5 layout::{GridPattern, LayoutGrid},
6 Layout as LayoutPlotly, Scatter, Trace,
7};
8
9use polars::{
10 frame::DataFrame,
11 prelude::{col, IntoLazy},
12};
13use serde::Serialize;
14
15use crate::{
16 common::{Layout, Line, Marker, PlotHelper, Polar},
17 components::{
18 Axis, FacetConfig, FacetScales, Legend, Line as LineStyle, Rgb, Shape, Text,
19 DEFAULT_PLOTLY_COLORS,
20 },
21};
22
23#[derive(Clone, Serialize)]
125pub struct LinePlot {
126 traces: Vec<Box<dyn Trace + 'static>>,
127 layout: LayoutPlotly,
128}
129
130#[bon]
131impl LinePlot {
132 #[builder(on(String, into), on(Text, into))]
133 pub fn new(
134 data: &DataFrame,
135 x: &str,
136 y: &str,
137 additional_lines: Option<Vec<&str>>,
138 facet: Option<&str>,
139 facet_config: Option<&FacetConfig>,
140 size: Option<usize>,
141 color: Option<Rgb>,
142 colors: Option<Vec<Rgb>>,
143 with_shape: Option<bool>,
144 shape: Option<Shape>,
145 shapes: Option<Vec<Shape>>,
146 width: Option<f64>,
147 line: Option<LineStyle>,
148 lines: Option<Vec<LineStyle>>,
149 plot_title: Option<Text>,
150 x_title: Option<Text>,
151 y_title: Option<Text>,
152 y2_title: Option<Text>,
153 legend_title: Option<Text>,
154 x_axis: Option<&Axis>,
155 y_axis: Option<&Axis>,
156 y2_axis: Option<&Axis>,
157 legend: Option<&Legend>,
158 ) -> Self {
159 let z_title = None;
160 let z_axis = None;
161
162 let (layout, traces) = match facet {
163 Some(facet_column) => {
164 let config = facet_config.cloned().unwrap_or_default();
165
166 let layout = Self::create_faceted_layout(
167 data,
168 facet_column,
169 &config,
170 plot_title,
171 x_title,
172 y_title,
173 legend_title,
174 x_axis,
175 y_axis,
176 legend,
177 );
178
179 let traces = Self::create_faceted_traces(
180 data,
181 x,
182 y,
183 additional_lines,
184 facet_column,
185 &config,
186 size,
187 color,
188 colors,
189 with_shape,
190 shape,
191 shapes,
192 width,
193 line,
194 lines,
195 );
196
197 (layout, traces)
198 }
199 None => {
200 let layout = Self::create_layout(
201 plot_title,
202 x_title,
203 y_title,
204 y2_title,
205 z_title,
206 legend_title,
207 x_axis,
208 y_axis,
209 y2_axis,
210 z_axis,
211 legend,
212 None,
213 );
214
215 let traces = Self::create_traces(
216 data,
217 x,
218 y,
219 additional_lines,
220 size,
221 color,
222 colors,
223 with_shape,
224 shape,
225 shapes,
226 width,
227 line,
228 lines,
229 );
230
231 (layout, traces)
232 }
233 };
234
235 Self { traces, layout }
236 }
237
238 #[allow(clippy::too_many_arguments)]
239 fn create_traces(
240 data: &DataFrame,
241 x_col: &str,
242 y_col: &str,
243 additional_lines: Option<Vec<&str>>,
244 size: Option<usize>,
245 color: Option<Rgb>,
246 colors: Option<Vec<Rgb>>,
247 with_shape: Option<bool>,
248 shape: Option<Shape>,
249 shapes: Option<Vec<Shape>>,
250 width: Option<f64>,
251 style: Option<LineStyle>,
252 styles: Option<Vec<LineStyle>>,
253 ) -> Vec<Box<dyn Trace + 'static>> {
254 let mut traces: Vec<Box<dyn Trace + 'static>> = Vec::new();
255
256 let opacity = None;
257
258 let marker = Self::create_marker(
259 0,
260 opacity,
261 size,
262 color,
263 colors.clone(),
264 shape,
265 shapes.clone(),
266 );
267
268 let line = Self::create_line(0, width, style, styles.clone());
269
270 let name = Some(y_col);
271
272 let trace = Self::create_trace(data, x_col, y_col, name, with_shape, marker, line);
273
274 traces.push(trace);
275
276 if let Some(additional_lines) = additional_lines {
277 let additional_lines = additional_lines.into_iter();
278
279 for (i, series) in additional_lines.enumerate() {
280 let marker = Self::create_marker(
281 i + 1,
282 opacity,
283 size,
284 color,
285 colors.clone(),
286 shape,
287 shapes.clone(),
288 );
289
290 let line = Self::create_line(i + 1, width, style, styles.clone());
291
292 let subset = data
293 .clone()
294 .lazy()
295 .select([col(x_col), col(series)])
296 .collect()
297 .unwrap();
298
299 let name = Some(series);
300
301 let trace =
302 Self::create_trace(&subset, x_col, series, name, with_shape, marker, line);
303
304 traces.push(trace);
305 }
306 }
307
308 traces
309 }
310
311 fn create_trace(
312 data: &DataFrame,
313 x_col: &str,
314 y_col: &str,
315 name: Option<&str>,
316 with_shape: Option<bool>,
317 marker: MarkerPlotly,
318 line: LinePlotly,
319 ) -> Box<dyn Trace + 'static> {
320 let x_data = Self::get_numeric_column(data, x_col);
321 let y_data = Self::get_numeric_column(data, y_col);
322
323 let mut trace = Scatter::default().x(x_data).y(y_data);
324
325 if let Some(with_shape) = with_shape {
326 if with_shape {
327 trace = trace.mode(Mode::LinesMarkers);
328 } else {
329 trace = trace.mode(Mode::Lines);
330 }
331 }
332
333 trace = trace.marker(marker);
334 trace = trace.line(line);
335
336 if let Some(name) = name {
337 trace = trace.name(name);
338 }
339
340 trace
341 }
342
343 #[allow(clippy::too_many_arguments)]
344 fn build_line_trace_with_axes(
345 data: &DataFrame,
346 x_col: &str,
347 y_col: &str,
348 name: Option<&str>,
349 with_shape: Option<bool>,
350 marker: MarkerPlotly,
351 line: LinePlotly,
352 x_axis: Option<&str>,
353 y_axis: Option<&str>,
354 show_legend: bool,
355 legend_group: Option<&str>,
356 ) -> Box<dyn Trace + 'static> {
357 let x_data = Self::get_numeric_column(data, x_col);
358 let y_data = Self::get_numeric_column(data, y_col);
359
360 let mut trace = Scatter::default().x(x_data).y(y_data);
361
362 if let Some(with_shape) = with_shape {
363 if with_shape {
364 trace = trace.mode(Mode::LinesMarkers);
365 } else {
366 trace = trace.mode(Mode::Lines);
367 }
368 } else {
369 trace = trace.mode(Mode::Lines);
370 }
371
372 trace = trace.marker(marker);
373 trace = trace.line(line);
374
375 if let Some(name) = name {
376 trace = trace.name(name);
377 }
378
379 if let Some(axis) = x_axis {
380 trace = trace.x_axis(axis);
381 }
382
383 if let Some(axis) = y_axis {
384 trace = trace.y_axis(axis);
385 }
386
387 if let Some(group) = legend_group {
388 trace = trace.legend_group(group);
389 }
390
391 if !show_legend {
392 trace.show_legend(false)
393 } else {
394 trace
395 }
396 }
397
398 #[allow(clippy::too_many_arguments)]
399 fn create_faceted_traces(
400 data: &DataFrame,
401 x: &str,
402 y: &str,
403 additional_lines: Option<Vec<&str>>,
404 facet_column: &str,
405 config: &FacetConfig,
406 size: Option<usize>,
407 color: Option<Rgb>,
408 colors: Option<Vec<Rgb>>,
409 with_shape: Option<bool>,
410 shape: Option<Shape>,
411 shapes: Option<Vec<Shape>>,
412 width: Option<f64>,
413 style: Option<LineStyle>,
414 styles: Option<Vec<LineStyle>>,
415 ) -> Vec<Box<dyn Trace + 'static>> {
416 const MAX_FACETS: usize = 8;
417
418 let facet_categories = Self::get_unique_groups(data, facet_column, config.sorter);
419
420 if facet_categories.len() > MAX_FACETS {
421 panic!(
422 "Facet column '{}' has {} unique values, but plotly.rs supports maximum {} subplots",
423 facet_column,
424 facet_categories.len(),
425 MAX_FACETS
426 );
427 }
428
429 let all_y_cols = if let Some(ref add_lines) = additional_lines {
430 let mut cols = vec![y];
431 cols.extend(add_lines.iter().copied());
432 cols
433 } else {
434 vec![y]
435 };
436
437 if let Some(ref color_vec) = colors {
438 if additional_lines.is_none() {
439 let color_count = color_vec.len();
440 let facet_count = facet_categories.len();
441
442 if color_count != facet_count {
443 panic!(
444 "When using colors with facet (without additional_lines), colors.len() must equal number of facets. \
445 Expected {} colors for {} facets, but got {} colors. \
446 Each facet must be assigned exactly one color.",
447 facet_count, facet_count, color_count
448 );
449 }
450 } else {
451 let color_count = color_vec.len();
452 let line_count = all_y_cols.len();
453
454 if color_count < line_count {
455 panic!(
456 "When using colors with additional_lines, colors.len() must be >= number of lines. \
457 Need at least {} colors for {} lines, but got {} colors",
458 line_count, line_count, color_count
459 );
460 }
461 }
462 }
463
464 let colors = if additional_lines.is_some() && colors.is_none() {
465 Some(DEFAULT_PLOTLY_COLORS.to_vec())
466 } else {
467 colors
468 };
469
470 let mut all_traces = Vec::new();
471 let opacity = None;
472
473 if config.highlight_facet {
474 for (facet_idx, facet_value) in facet_categories.iter().enumerate() {
475 let x_axis = Self::get_axis_reference(facet_idx, "x");
476 let y_axis = Self::get_axis_reference(facet_idx, "y");
477
478 for other_facet_value in facet_categories.iter() {
479 if other_facet_value != facet_value {
480 let other_data =
481 Self::filter_data_by_group(data, facet_column, other_facet_value);
482
483 let grey_color = config.unhighlighted_color.unwrap_or(Rgb(200, 200, 200));
484
485 for (line_idx, y_col) in all_y_cols.iter().enumerate() {
486 let grey_marker = Self::create_marker(
487 line_idx,
488 opacity,
489 size,
490 Some(grey_color),
491 None,
492 shape,
493 None,
494 );
495
496 let grey_line =
497 Self::create_line(line_idx, width, style, styles.clone());
498
499 let trace = Self::build_line_trace_with_axes(
500 &other_data,
501 x,
502 y_col,
503 None,
504 with_shape,
505 grey_marker,
506 grey_line,
507 Some(&x_axis),
508 Some(&y_axis),
509 false,
510 Some(y_col),
511 );
512
513 all_traces.push(trace);
514 }
515 }
516 }
517
518 let facet_data = Self::filter_data_by_group(data, facet_column, facet_value);
519
520 for (line_idx, y_col) in all_y_cols.iter().enumerate() {
521 let color_index = if additional_lines.is_none() {
522 facet_idx
523 } else {
524 line_idx
525 };
526
527 let marker = Self::create_marker(
528 color_index,
529 opacity,
530 size,
531 color,
532 colors.clone(),
533 shape,
534 shapes.clone(),
535 );
536
537 let line = Self::create_line(line_idx, width, style, styles.clone());
538
539 let show_legend = facet_idx == 0;
540 let name = if show_legend { Some(*y_col) } else { None };
541
542 let trace = Self::build_line_trace_with_axes(
543 &facet_data,
544 x,
545 y_col,
546 name,
547 with_shape,
548 marker,
549 line,
550 Some(&x_axis),
551 Some(&y_axis),
552 show_legend,
553 Some(y_col),
554 );
555
556 all_traces.push(trace);
557 }
558 }
559 } else {
560 for (facet_idx, facet_value) in facet_categories.iter().enumerate() {
561 let facet_data = Self::filter_data_by_group(data, facet_column, facet_value);
562
563 let x_axis = Self::get_axis_reference(facet_idx, "x");
564 let y_axis = Self::get_axis_reference(facet_idx, "y");
565
566 for (line_idx, y_col) in all_y_cols.iter().enumerate() {
567 let color_index = if additional_lines.is_none() {
568 facet_idx
569 } else {
570 line_idx
571 };
572
573 let marker = Self::create_marker(
574 color_index,
575 opacity,
576 size,
577 color,
578 colors.clone(),
579 shape,
580 shapes.clone(),
581 );
582
583 let line = Self::create_line(line_idx, width, style, styles.clone());
584
585 let show_legend = facet_idx == 0;
586 let name = if show_legend { Some(*y_col) } else { None };
587
588 let trace = Self::build_line_trace_with_axes(
589 &facet_data,
590 x,
591 y_col,
592 name,
593 with_shape,
594 marker,
595 line,
596 Some(&x_axis),
597 Some(&y_axis),
598 show_legend,
599 Some(y_col),
600 );
601
602 all_traces.push(trace);
603 }
604 }
605 }
606
607 all_traces
608 }
609
610 #[allow(clippy::too_many_arguments)]
611 fn create_faceted_layout(
612 data: &DataFrame,
613 facet_column: &str,
614 config: &FacetConfig,
615 plot_title: Option<Text>,
616 x_title: Option<Text>,
617 y_title: Option<Text>,
618 legend_title: Option<Text>,
619 x_axis: Option<&Axis>,
620 y_axis: Option<&Axis>,
621 legend: Option<&Legend>,
622 ) -> LayoutPlotly {
623 let facet_categories = Self::get_unique_groups(data, facet_column, config.sorter);
624 let n_facets = facet_categories.len();
625
626 let (ncols, nrows) = Self::calculate_grid_dimensions(n_facets, config.cols, config.rows);
627
628 let mut grid = LayoutGrid::new()
629 .rows(nrows)
630 .columns(ncols)
631 .pattern(GridPattern::Independent);
632
633 if let Some(x_gap) = config.h_gap {
634 grid = grid.x_gap(x_gap);
635 }
636 if let Some(y_gap) = config.v_gap {
637 grid = grid.y_gap(y_gap);
638 }
639
640 let mut layout = LayoutPlotly::new().grid(grid);
641
642 if let Some(title) = plot_title {
643 layout = layout.title(title.to_plotly());
644 }
645
646 layout = Self::apply_axis_matching(layout, n_facets, &config.scales);
647
648 layout = Self::apply_facet_axis_titles(
649 layout, n_facets, ncols, nrows, x_title, y_title, x_axis, y_axis,
650 );
651
652 let annotations =
653 Self::create_facet_annotations(&facet_categories, config.title_style.as_ref());
654 layout = layout.annotations(annotations);
655
656 layout = layout.legend(Legend::set_legend(legend_title, legend));
657
658 layout
659 }
660
661 fn apply_axis_matching(
662 mut layout: LayoutPlotly,
663 n_facets: usize,
664 scales: &FacetScales,
665 ) -> LayoutPlotly {
666 use plotly::layout::Axis as AxisPlotly;
667
668 match scales {
669 FacetScales::Fixed => {
670 for i in 1..n_facets {
671 let x_axis = AxisPlotly::new().matches("x");
672 let y_axis = AxisPlotly::new().matches("y");
673 layout = match i {
674 1 => layout.x_axis2(x_axis).y_axis2(y_axis),
675 2 => layout.x_axis3(x_axis).y_axis3(y_axis),
676 3 => layout.x_axis4(x_axis).y_axis4(y_axis),
677 4 => layout.x_axis5(x_axis).y_axis5(y_axis),
678 5 => layout.x_axis6(x_axis).y_axis6(y_axis),
679 6 => layout.x_axis7(x_axis).y_axis7(y_axis),
680 7 => layout.x_axis8(x_axis).y_axis8(y_axis),
681 _ => layout,
682 };
683 }
684 }
685 FacetScales::FreeX => {
686 for i in 1..n_facets {
687 let axis = AxisPlotly::new().matches("y");
688 layout = match i {
689 1 => layout.y_axis2(axis),
690 2 => layout.y_axis3(axis),
691 3 => layout.y_axis4(axis),
692 4 => layout.y_axis5(axis),
693 5 => layout.y_axis6(axis),
694 6 => layout.y_axis7(axis),
695 7 => layout.y_axis8(axis),
696 _ => layout,
697 };
698 }
699 }
700 FacetScales::FreeY => {
701 for i in 1..n_facets {
702 let axis = AxisPlotly::new().matches("x");
703 layout = match i {
704 1 => layout.x_axis2(axis),
705 2 => layout.x_axis3(axis),
706 3 => layout.x_axis4(axis),
707 4 => layout.x_axis5(axis),
708 5 => layout.x_axis6(axis),
709 6 => layout.x_axis7(axis),
710 7 => layout.x_axis8(axis),
711 _ => layout,
712 };
713 }
714 }
715 FacetScales::Free => {}
716 }
717
718 layout
719 }
720
721 #[allow(clippy::too_many_arguments)]
722 fn apply_facet_axis_titles(
723 mut layout: LayoutPlotly,
724 n_facets: usize,
725 ncols: usize,
726 nrows: usize,
727 x_title: Option<Text>,
728 y_title: Option<Text>,
729 x_axis_config: Option<&Axis>,
730 y_axis_config: Option<&Axis>,
731 ) -> LayoutPlotly {
732 for i in 0..n_facets {
733 let is_bottom = Self::is_bottom_row(i, ncols, nrows, n_facets);
734 let is_left = Self::is_left_column(i, ncols);
735
736 let x_title_for_subplot = if is_bottom { x_title.clone() } else { None };
737 let y_title_for_subplot = if is_left { y_title.clone() } else { None };
738
739 if x_title_for_subplot.is_some() || x_axis_config.is_some() {
740 let axis = match x_axis_config {
741 Some(config) => Axis::set_axis(x_title_for_subplot, config, None),
742 None => {
743 if let Some(title) = x_title_for_subplot {
744 Axis::set_axis(Some(title), &Axis::default(), None)
745 } else {
746 continue;
747 }
748 }
749 };
750
751 layout = match i {
752 0 => layout.x_axis(axis),
753 1 => layout.x_axis2(axis),
754 2 => layout.x_axis3(axis),
755 3 => layout.x_axis4(axis),
756 4 => layout.x_axis5(axis),
757 5 => layout.x_axis6(axis),
758 6 => layout.x_axis7(axis),
759 7 => layout.x_axis8(axis),
760 _ => layout,
761 };
762 }
763
764 if y_title_for_subplot.is_some() || y_axis_config.is_some() {
765 let axis = match y_axis_config {
766 Some(config) => Axis::set_axis(y_title_for_subplot, config, None),
767 None => {
768 if let Some(title) = y_title_for_subplot {
769 Axis::set_axis(Some(title), &Axis::default(), None)
770 } else {
771 continue;
772 }
773 }
774 };
775
776 layout = match i {
777 0 => layout.y_axis(axis),
778 1 => layout.y_axis2(axis),
779 2 => layout.y_axis3(axis),
780 3 => layout.y_axis4(axis),
781 4 => layout.y_axis5(axis),
782 5 => layout.y_axis6(axis),
783 6 => layout.y_axis7(axis),
784 7 => layout.y_axis8(axis),
785 _ => layout,
786 };
787 }
788 }
789
790 layout
791 }
792}
793
794impl Layout for LinePlot {}
795impl Line for LinePlot {}
796impl Marker for LinePlot {}
797impl Polar for LinePlot {}
798
799impl PlotHelper for LinePlot {
800 fn get_layout(&self) -> &LayoutPlotly {
801 &self.layout
802 }
803
804 fn get_traces(&self) -> &Vec<Box<dyn Trace + 'static>> {
805 &self.traces
806 }
807}