Skip to main content

orbital_charts/shared/plots/
line_plot.rs

1//! Line plot layer.
2
3use std::collections::HashMap;
4
5use leptos::prelude::*;
6
7use crate::context::{
8    is_series_visible, use_chart_context, use_hidden_series, use_x_scale, use_y_scale,
9    ChartInteractionContext,
10};
11use crate::engine::{
12    build_line_path, data_fingerprint, projected_for_plot_type, resolve_series_color,
13    resolve_stack_config, stack_series, PlotPoint,
14};
15use crate::shared::marks::LineStroke;
16use crate::{ChartScale, ChartType, CurveType, SeriesDef, StackOffset};
17
18/// Skip per-point markers above this count (path still renders).
19const LAZY_MARKER_THRESHOLD: usize = 500;
20
21fn scale_y(scale: &ChartScale, value: f64) -> f64 {
22    match scale {
23        ChartScale::Linear(l) => l.scale(value),
24        _ => value,
25    }
26}
27
28fn collect_line_markers(
29    projected: &crate::engine::ProjectedChartData,
30    series_defs: &[SeriesDef],
31    x_scale: &ChartScale,
32    y_scale: &ChartScale,
33) -> Vec<(f64, f64, String, usize)> {
34    let plot_data = projected_for_plot_type(projected, series_defs, ChartType::Line);
35    if plot_data.series.is_empty() {
36        return Vec::new();
37    }
38
39    let stack_groups: HashMap<String, String> = series_defs
40        .iter()
41        .filter_map(|s| s.stack_group.as_ref().map(|g| (s.id.clone(), g.clone())))
42        .collect();
43    let has_stack = plot_data
44        .series
45        .iter()
46        .any(|s| stack_groups.contains_key(&s.id));
47
48    let (offset, order) = if has_stack {
49        let group = stack_groups
50            .values()
51            .next()
52            .cloned()
53            .unwrap_or_else(|| "stack".into());
54        resolve_stack_config(&group, series_defs, ChartType::Line)
55    } else {
56        (StackOffset::None, crate::StackOrder::None)
57    };
58
59    let render_series = if has_stack {
60        stack_series(&plot_data.series, &stack_groups, offset, order)
61    } else {
62        plot_data.series.clone()
63    };
64
65    let total_points = render_series.iter().map(|s| s.data.len()).sum::<usize>();
66    if total_points > LAZY_MARKER_THRESHOLD {
67        return Vec::new();
68    }
69
70    let mut line_markers = Vec::new();
71    for s in render_series.iter().filter(|s| is_series_visible(&s.id)) {
72        for (i, cat) in plot_data.categories.iter().enumerate() {
73            let x = category_x(x_scale, cat, i, plot_data.categories.len());
74            let y_val = s.data.get(i).copied();
75            if let Some(y) = y_val.filter(|v| !v.is_nan()).map(|v| scale_y(y_scale, v)) {
76                line_markers.push((x, y, s.id.clone(), i));
77            }
78        }
79    }
80    line_markers
81}
82
83/// Renders line strokes and optional markers for each series.
84#[component]
85pub fn LinePlot() -> impl IntoView {
86    let ctx = use_chart_context();
87    let interaction = expect_context::<ChartInteractionContext>();
88    let hidden = use_hidden_series();
89    let skip = ctx.skip_animation;
90    let highlight_scope = ctx.highlight_scope;
91    let projected = ctx.projected.clone();
92    let series_defs = ctx.series.clone();
93    let palette = ctx.palette.clone();
94    let x_scale = use_x_scale("x");
95    let y_scale = use_y_scale("y");
96
97    let projected_for_markers = projected.clone();
98    let series_defs_for_markers = series_defs.clone();
99    let x_scale_for_markers = x_scale.clone();
100    let y_scale_for_markers = y_scale.clone();
101    let line_markers = Memo::new(move |_| {
102        let _ = hidden.get();
103        let Some(data) = projected_for_markers.as_ref() else {
104            return Vec::new();
105        };
106        collect_line_markers(
107            data,
108            &series_defs_for_markers,
109            &x_scale_for_markers,
110            &y_scale_for_markers,
111        )
112    });
113
114    Effect::new(move |_| {
115        interaction.plot_line_markers.set(line_markers.get());
116    });
117
118    view! {
119        {move || {
120            let Some(data) = projected.as_ref() else {
121                return ().into_any();
122            };
123
124            let plot_data = projected_for_plot_type(data, &series_defs, ChartType::Line);
125            if plot_data.series.is_empty() {
126                return ().into_any();
127            }
128
129            let stack_groups: HashMap<String, String> = series_defs
130                .iter()
131                .filter_map(|s| s.stack_group.as_ref().map(|g| (s.id.clone(), g.clone())))
132                .collect();
133            let has_stack = plot_data
134                .series
135                .iter()
136                .any(|s| stack_groups.contains_key(&s.id));
137
138            let (offset, order) = if has_stack {
139                let group = stack_groups
140                    .values()
141                    .next()
142                    .cloned()
143                    .unwrap_or_else(|| "stack".into());
144                resolve_stack_config(&group, &series_defs, ChartType::Line)
145            } else {
146                (StackOffset::None, crate::StackOrder::None)
147            };
148
149            let render_series = if has_stack {
150                stack_series(&plot_data.series, &stack_groups, offset, order)
151            } else {
152                plot_data.series.clone()
153            };
154
155            let total_points = render_series.iter().map(|s| s.data.len()).sum::<usize>();
156            let lazy_markers = total_points > LAZY_MARKER_THRESHOLD;
157
158            render_series
159                .iter()
160                .enumerate()
161                .filter(|(_, s)| is_series_visible(&s.id))
162                .map(|(idx, s)| {
163                    let def = series_defs
164                        .iter()
165                        .find(|d| d.id == s.id)
166                        .cloned()
167                        .unwrap_or_default();
168                    let color = resolve_series_color(idx, &def, &palette);
169                    let curve = def.curve.unwrap_or(CurveType::Linear);
170                    let connect_nulls = def.connect_nulls.unwrap_or(false);
171                    let show_markers = def.show_markers.unwrap_or(false) && !lazy_markers;
172                    let scope = def.highlight_scope.or(highlight_scope);
173
174                    let points: Vec<PlotPoint> = plot_data
175                        .categories
176                        .iter()
177                        .enumerate()
178                        .map(|(i, cat)| {
179                            let x = category_x(&x_scale, cat, i, plot_data.categories.len());
180                            let y_val = s.data.get(i).copied();
181                            PlotPoint {
182                                x,
183                                y: y_val.and_then(|v| {
184                                    if v.is_nan() {
185                                        None
186                                    } else {
187                                        Some(scale_y(&y_scale, v))
188                                    }
189                                }),
190                            }
191                        })
192                        .collect();
193
194                    let line = build_line_path(&points, curve, connect_nulls);
195                    let marker_triples: Vec<(f64, f64, usize)> = line
196                        .markers
197                        .iter()
198                        .enumerate()
199                        .map(|(i, (x, y))| (*x, *y, i))
200                        .collect();
201                    let d: Signal<String> = Signal::from(line.d.clone());
202                    let stroke: Signal<String> = Signal::from(color.clone());
203                    let key: Signal<String> =
204                        Signal::from(format!("{}-{}", s.id, data_fingerprint(&s.data)));
205
206                    view! {
207                        <LineStroke
208                            d=d
209                            stroke=stroke
210                            markers=marker_triples
211                            series_id=s.id.clone()
212                            show_markers=show_markers
213                            skip_animation=skip
214                            draw_key=key
215                            highlight_scope=scope
216                        />
217                    }
218                })
219                .collect_view()
220                .into_any()
221        }}
222    }
223}
224
225fn category_x(scale: &ChartScale, category: &str, index: usize, count: usize) -> f64 {
226    match scale {
227        ChartScale::Band(b) => b.scale(category).unwrap_or_else(|| {
228            if count > 1 {
229                (b.step() * index as f64) + b.bandwidth() / 2.0
230            } else {
231                b.bandwidth() / 2.0
232            }
233        }),
234        ChartScale::Linear(l) => l.scale(index as f64),
235    }
236}