Skip to main content

dioxus_ui_system/organisms/charts/
line_chart.rs

1//! Line Chart component
2//!
3//! A flexible line chart for showing trends over time or categories.
4
5#![allow(unpredictable_function_pointer_comparisons)]
6
7use crate::atoms::Box;
8use crate::organisms::charts::common::*;
9use crate::theme::tokens::Color;
10use crate::theme::use_theme;
11use dioxus::prelude::*;
12
13/// Line chart variant
14#[derive(Default, Clone, PartialEq, Debug)]
15pub enum LineChartVariant {
16    /// Simple line chart (default)
17    #[default]
18    Line,
19    /// Smooth curved lines
20    Smooth,
21    /// Step chart (right angles)
22    Step,
23    /// Area chart (filled below line)
24    Area,
25    /// Stacked area chart
26    StackedArea,
27}
28
29/// Line chart properties
30#[derive(Props, Clone, PartialEq)]
31pub struct LineChartProps {
32    /// Chart title
33    #[props(default)]
34    pub title: Option<String>,
35    /// Single series data
36    #[props(default)]
37    pub data: Option<Vec<ChartDataPoint>>,
38    /// Multiple series data
39    #[props(default)]
40    pub series: Option<Vec<ChartSeries>>,
41    /// Chart width
42    #[props(default = "100%".to_string())]
43    pub width: String,
44    /// Chart height
45    #[props(default = "300px".to_string())]
46    pub height: String,
47    /// Chart variant
48    #[props(default)]
49    pub variant: LineChartVariant,
50    /// Chart margins
51    #[props(default)]
52    pub margin: ChartMargin,
53    /// X-axis configuration
54    #[props(default)]
55    pub x_axis: ChartAxis,
56    /// Y-axis configuration
57    #[props(default)]
58    pub y_axis: ChartAxis,
59    /// Line color (for single series)
60    #[props(default)]
61    pub line_color: Option<Color>,
62    /// Line width
63    #[props(default = 2)]
64    pub line_width: u8,
65    /// Show data points
66    #[props(default = true)]
67    pub show_points: bool,
68    /// Point radius
69    #[props(default = 4)]
70    pub point_radius: u8,
71    /// Show values on points
72    #[props(default = false)]
73    pub show_values: bool,
74    /// Value formatter
75    #[props(default)]
76    pub value_format: Option<fn(f64) -> String>,
77    /// Legend position
78    #[props(default)]
79    pub legend_position: LegendPosition,
80    /// Tooltip configuration
81    #[props(default)]
82    pub tooltip: ChartTooltip,
83    /// Animation configuration
84    #[props(default)]
85    pub animation: ChartAnimation,
86    /// Click handler for points
87    #[props(default)]
88    pub on_point_click: Option<EventHandler<ChartDataPoint>>,
89    /// Custom styles
90    #[props(default)]
91    pub style: Option<String>,
92}
93
94/// Line chart component
95#[component]
96pub fn LineChart(props: LineChartProps) -> Element {
97    let theme = use_theme();
98    let tokens = theme.tokens.read();
99
100    // Tooltip state
101    let mut tooltip_state = use_signal(|| None as Option<(i32, i32, String)>);
102
103    // Collect all data
104    let all_series: Vec<ChartSeries> = if let Some(series) = &props.series {
105        series.clone()
106    } else if let Some(data) = &props.data {
107        vec![ChartSeries::new(
108            "Series 1",
109            props
110                .line_color
111                .clone()
112                .unwrap_or_else(|| tokens.colors.primary.clone()),
113            data.clone(),
114        )]
115    } else {
116        vec![]
117    };
118
119    if all_series.is_empty() || all_series[0].data.is_empty() {
120        return rsx! {
121            Box {
122                width: Some(props.width.clone()),
123                height: Some(props.height.clone()),
124                display: crate::atoms::BoxDisplay::Flex,
125                align_items: crate::atoms::AlignItems::Center,
126                justify_content: crate::atoms::JustifyContent::Center,
127                "No data"
128            }
129        };
130    }
131
132    // Calculate dimensions
133    let margin = props.margin.clone();
134    let svg_width = 800;
135    let svg_height = 400;
136    let chart_width = svg_width - margin.left - margin.right;
137    let chart_height = svg_height - margin.top - margin.bottom;
138
139    // Calculate value range
140    let (min_value, max_value) = match props.variant {
141        LineChartVariant::StackedArea => {
142            let data_len = all_series[0].data.len();
143            let mut min_val = f64::INFINITY;
144            let mut max_val = f64::NEG_INFINITY;
145            for i in 0..data_len {
146                let sum: f64 = all_series.iter().map(|s| s.data[i].value).sum();
147                min_val = min_val.min(sum);
148                max_val = max_val.max(sum);
149            }
150            (0.0_f64.min(min_val), max_val)
151        }
152        _ => {
153            let all_values: Vec<f64> = all_series
154                .iter()
155                .flat_map(|s| s.data.iter().map(|p| p.value))
156                .collect();
157            (
158                all_values.iter().fold(f64::INFINITY, |a, &b| a.min(b)),
159                all_values.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b)),
160            )
161        }
162    };
163
164    let y_min = props.y_axis.min.unwrap_or(min_value);
165    let y_max = props.y_axis.max.unwrap_or(max_value * 1.1);
166
167    // Calculate scales
168    let y_scale = move |value: f64| -> f64 {
169        let range = y_max - y_min;
170        if range == 0.0 {
171            chart_height as f64 / 2.0
172        } else {
173            chart_height as f64 - ((value - y_min) / range * chart_height as f64)
174        }
175    };
176
177    let x_scale = move |index: usize, total: usize| -> f64 {
178        if total <= 1 {
179            0.0
180        } else {
181            index as f64 / (total - 1) as f64 * chart_width as f64
182        }
183    };
184
185    // Calculate Y ticks
186    let y_ticks = calculate_nice_ticks(y_min, y_max, props.y_axis.tick_count);
187
188    // Generate series paths and points
189    let data_len = all_series[0].data.len();
190
191    let container_style = format!(
192        "width: {}; height: {}; font-family: system-ui, -apple-system, sans-serif; position: relative; {}",
193        props.width,
194        props.height,
195        props.style.as_deref().unwrap_or("")
196    );
197
198    let title = props.title.clone();
199    let variant = props.variant.clone();
200    let y_axis = props.y_axis.clone();
201    let show_points = props.show_points;
202    let point_radius = props.point_radius;
203    let line_width = props.line_width;
204    let on_point_click = props.on_point_click.clone();
205    let tooltip = props.tooltip.clone();
206
207    // Pre-compute Y axis data: (y_position, label, x2_for_grid)
208    let y_axis_data: Vec<(f64, String, u16)> = y_ticks
209        .iter()
210        .map(|&tick| {
211            let y = margin.top as f64 + y_scale(tick);
212            let label = if let Some(formatter) = y_axis.label_format {
213                formatter(tick)
214            } else {
215                format_compact_number(tick)
216            };
217            let x2 = margin.left + chart_width;
218            (y, label, x2)
219        })
220        .collect();
221
222    // Pre-compute series data with tooltip content
223    let mut series_data = Vec::new();
224    for (series_idx, series) in all_series.iter().enumerate() {
225        let color = series.color.clone();
226        let color_css = color.to_rgba();
227
228        let points: Vec<(f64, f64)> = (0..data_len)
229            .map(|i| {
230                let x = margin.left as f64 + x_scale(i, data_len);
231                let y_val = match variant {
232                    LineChartVariant::StackedArea => {
233                        let stack_bottom: f64 = all_series[..series_idx]
234                            .iter()
235                            .map(|s| s.data[i].value)
236                            .sum();
237                        stack_bottom + series.data[i].value
238                    }
239                    _ => series.data[i].value,
240                };
241                let y = margin.top as f64 + y_scale(y_val);
242                (x, y)
243            })
244            .collect();
245
246        let path_d = match variant {
247            LineChartVariant::Step => {
248                let mut d = format!("M {},{} ", points[0].0, points[0].1);
249                for i in 1..points.len() {
250                    let prev = points[i - 1];
251                    let curr = points[i];
252                    d.push_str(&format!("L {},{} L {},{} ", curr.0, prev.1, curr.0, curr.1));
253                }
254                d
255            }
256            LineChartVariant::Smooth => calculate_smooth_line(&points),
257            _ => {
258                let mut d = format!("M {},{} ", points[0].0, points[0].1);
259                for point in &points[1..] {
260                    d.push_str(&format!("L {},{} ", point.0, point.1));
261                }
262                d
263            }
264        };
265
266        let area_path = if matches!(
267            variant,
268            LineChartVariant::Area | LineChartVariant::StackedArea
269        ) {
270            if variant == LineChartVariant::StackedArea && series_idx > 0 {
271                let prev_points: Vec<(f64, f64)> = (0..data_len)
272                    .map(|i| {
273                        let x = margin.left as f64 + x_scale(i, data_len);
274                        let stack_bottom: f64 = all_series[..series_idx]
275                            .iter()
276                            .map(|s| s.data[i].value)
277                            .sum();
278                        let y = margin.top as f64 + y_scale(stack_bottom);
279                        (x, y)
280                    })
281                    .collect();
282
283                let mut d = format!("M {},{} ", points[0].0, points[0].1);
284                for point in &points[1..] {
285                    d.push_str(&format!("L {},{} ", point.0, point.1));
286                }
287                for point in prev_points.iter().rev() {
288                    d.push_str(&format!("L {},{} ", point.0, point.1));
289                }
290                d.push_str("Z");
291                Some(d)
292            } else {
293                let baseline = margin.top as f64 + chart_height as f64;
294                let mut d = format!("M {},{} ", points[0].0, baseline);
295                d.push_str(&format!("L {},{} ", points[0].0, points[0].1));
296                for point in &points[1..] {
297                    d.push_str(&format!("L {},{} ", point.0, point.1));
298                }
299                d.push_str(&format!("L {},{} ", points[points.len() - 1].0, baseline));
300                d.push_str("Z");
301                Some(d)
302            }
303        } else {
304            None
305        };
306
307        let area_color = format!("rgba({}, {}, {}, 0.2)", color.r, color.g, color.b);
308        let show_line = !matches!(
309            variant,
310            LineChartVariant::Area | LineChartVariant::StackedArea
311        ) || series_idx < all_series.len() - 1;
312
313        // Pre-compute tooltip content for each point
314        let tooltip_contents: Vec<String> = series
315            .data
316            .iter()
317            .map(|point| tooltip.get_content(point, Some(&series.name)))
318            .collect();
319
320        series_data.push((
321            series_idx,
322            path_d,
323            area_path,
324            area_color,
325            color_css,
326            points,
327            show_line,
328            tooltip_contents,
329            series.name.clone(),
330        ));
331    }
332
333    // Pre-compute X axis labels: (x_position, label_text)
334    let x_labels: Vec<(f64, String)> = all_series[0]
335        .data
336        .iter()
337        .enumerate()
338        .map(|(idx, point)| {
339            let x = margin.left as f64 + x_scale(idx, data_len);
340            (x, point.label.clone())
341        })
342        .collect();
343
344    let title_x = svg_width / 2;
345    let x_labels_y = margin.top + chart_height + 20;
346    let bg_color = tokens.colors.background.to_rgba();
347
348    // Tooltip styling
349    let tooltip_bg = tokens.colors.popover.to_rgba();
350    let tooltip_fg = tokens.colors.popover_foreground.to_rgba();
351    let tooltip_border = tokens.colors.border.to_rgba();
352
353    rsx! {
354        Box {
355            width: Some(props.width.clone()),
356            height: Some(props.height.clone()),
357            style: Some(container_style),
358
359            // Tooltip
360            if tooltip.enabled {
361                if let Some((x, y, content)) = tooltip_state() {
362                    div {
363                        style: "position: fixed; left: {x}px; top: {y}px; transform: translate(-50%, -100%); margin-top: -8px; padding: 8px 12px; background: {tooltip_bg}; color: {tooltip_fg}; border: 1px solid {tooltip_border}; border-radius: 6px; font-size: 12px; font-weight: 500; white-space: nowrap; pointer-events: none; z-index: 10000; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);",
364                        "{content}"
365                    }
366                }
367            }
368
369            svg {
370                view_box: "0 0 {svg_width} {svg_height}",
371                width: "100%",
372                height: "100%",
373                preserve_aspect_ratio: "xMidYMid meet",
374
375                // Title
376                if let Some(t) = title {
377                    text {
378                        x: "{title_x}",
379                        y: "20",
380                        "text-anchor": "middle",
381                        "font-size": "16",
382                        "font-weight": "bold",
383                        fill: "{tokens.colors.foreground.to_rgba()}",
384                        "{t}"
385                    }
386                }
387
388                // Y axis elements
389                for (y, label, x2) in y_axis_data {
390                    // Grid line
391                    if y_axis.show_grid {
392                        line {
393                            x1: "{margin.left}",
394                            y1: "{y}",
395                            x2: "{x2}",
396                            y2: "{y}",
397                            stroke: "{tokens.colors.border.to_rgba()}",
398                            "stroke-width": "1",
399                            "stroke-dasharray": "2,2",
400                        }
401                    }
402
403                    // Label
404                    text {
405                        x: "{margin.left - 10}",
406                        y: "{y}",
407                        "text-anchor": "end",
408                        "dominant-baseline": "middle",
409                        "font-size": "12",
410                        fill: "{tokens.colors.muted_foreground.to_rgba()}",
411                        "{label}"
412                    }
413                }
414
415                // Series (lines, areas, points)
416                for (series_idx, path_d, area_path, area_color, color_css, points, show_line, tooltip_contents, series_name) in series_data {
417                    LineSeries {
418                        path_d: path_d,
419                        area_path: area_path,
420                        area_color: area_color,
421                        color_css: color_css,
422                        points: points,
423                        show_line: show_line,
424                        show_points: show_points && !matches!(variant, LineChartVariant::StackedArea),
425                        point_radius: point_radius,
426                        line_width: line_width,
427                        bg_color: bg_color.clone(),
428                        series_data: all_series[series_idx].data.clone(),
429                        tooltip_contents: tooltip_contents,
430                        series_name: series_name,
431                        on_point_click: on_point_click.clone(),
432                        tooltip_enabled: tooltip.enabled,
433                        on_tooltip_show: Some(EventHandler::new(move |(x, y, content): (i32, i32, String)| {
434                            tooltip_state.set(Some((x, y, content)));
435                        })),
436                        on_tooltip_hide: Some(EventHandler::new(move |_| {
437                            tooltip_state.set(None);
438                        })),
439                    }
440                }
441
442                // X axis labels
443                for (x, label) in x_labels {
444                    text {
445                        x: "{x}",
446                        y: "{x_labels_y}",
447                        "text-anchor": "middle",
448                        "dominant-baseline": "top",
449                        "font-size": "12",
450                        fill: "{tokens.colors.muted_foreground.to_rgba()}",
451                        "{label}"
452                    }
453                }
454            }
455        }
456    }
457}
458
459#[derive(Props, Clone, PartialEq)]
460struct LineSeriesProps {
461    path_d: String,
462    area_path: Option<String>,
463    area_color: String,
464    color_css: String,
465    points: Vec<(f64, f64)>,
466    show_line: bool,
467    show_points: bool,
468    point_radius: u8,
469    line_width: u8,
470    bg_color: String,
471    series_data: Vec<ChartDataPoint>,
472    tooltip_contents: Vec<String>,
473    series_name: String,
474    on_point_click: Option<EventHandler<ChartDataPoint>>,
475    tooltip_enabled: bool,
476    on_tooltip_show: Option<EventHandler<(i32, i32, String)>>,
477    on_tooltip_hide: Option<EventHandler<()>>,
478}
479
480#[component]
481fn LineSeries(props: LineSeriesProps) -> Element {
482    rsx! {
483        g {
484            // Area fill
485            if let Some(area_d) = props.area_path {
486                path {
487                    d: "{area_d}",
488                    fill: "{props.area_color}",
489                    stroke: "none",
490                }
491            }
492
493            // Line
494            if props.show_line {
495                path {
496                    d: "{props.path_d}",
497                    fill: "none",
498                    stroke: "{props.color_css}",
499                    "stroke-width": "{props.line_width}",
500                    "stroke-linecap": "round",
501                    "stroke-linejoin": "round",
502                }
503            }
504
505            // Data points
506            if props.show_points {
507                for (i, (x, y)) in props.points.iter().enumerate() {
508                    LinePoint {
509                        x: *x,
510                        y: *y,
511                        point_radius: props.point_radius,
512                        bg_color: props.bg_color.clone(),
513                        color_css: props.color_css.clone(),
514                        point: props.series_data[i].clone(),
515                        tooltip_content: props.tooltip_contents[i].clone(),
516                        series_name: props.series_name.clone(),
517                        on_point_click: props.on_point_click.clone(),
518                        tooltip_enabled: props.tooltip_enabled,
519                        on_tooltip_show: props.on_tooltip_show.clone(),
520                        on_tooltip_hide: props.on_tooltip_hide.clone(),
521                    }
522                }
523            }
524        }
525    }
526}
527
528#[derive(Props, Clone, PartialEq)]
529struct LinePointProps {
530    x: f64,
531    y: f64,
532    point_radius: u8,
533    bg_color: String,
534    color_css: String,
535    point: ChartDataPoint,
536    tooltip_content: String,
537    series_name: String,
538    on_point_click: Option<EventHandler<ChartDataPoint>>,
539    tooltip_enabled: bool,
540    on_tooltip_show: Option<EventHandler<(i32, i32, String)>>,
541    on_tooltip_hide: Option<EventHandler<()>>,
542}
543
544#[component]
545fn LinePoint(props: LinePointProps) -> Element {
546    let on_click = props.on_point_click.clone();
547    let point = props.point.clone();
548    let tooltip_content = props.tooltip_content.clone();
549
550    rsx! {
551        circle {
552            cx: "{props.x}",
553            cy: "{props.y}",
554            r: "{props.point_radius}",
555            fill: "{props.bg_color}",
556            stroke: "{props.color_css}",
557            "stroke-width": "2",
558            onclick: move |_| {
559                if let Some(handler) = &on_click {
560                    handler.call(point.clone());
561                }
562            },
563            onmouseenter: move |e: Event<MouseData>| {
564                if props.tooltip_enabled {
565                    if let Some(handler) = &props.on_tooltip_show {
566                        let coords = e.data().page_coordinates();
567                        handler.call((coords.x as i32, coords.y as i32, tooltip_content.clone()));
568                    }
569                }
570            },
571            onmouseleave: move |_| {
572                if props.tooltip_enabled {
573                    if let Some(handler) = &props.on_tooltip_hide {
574                        handler.call(());
575                    }
576                }
577            },
578        }
579    }
580}