1#![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#[derive(Default, Clone, PartialEq, Debug)]
15pub enum LineChartVariant {
16 #[default]
18 Line,
19 Smooth,
21 Step,
23 Area,
25 StackedArea,
27}
28
29#[derive(Props, Clone, PartialEq)]
31pub struct LineChartProps {
32 #[props(default)]
34 pub title: Option<String>,
35 #[props(default)]
37 pub data: Option<Vec<ChartDataPoint>>,
38 #[props(default)]
40 pub series: Option<Vec<ChartSeries>>,
41 #[props(default = "100%".to_string())]
43 pub width: String,
44 #[props(default = "300px".to_string())]
46 pub height: String,
47 #[props(default)]
49 pub variant: LineChartVariant,
50 #[props(default)]
52 pub margin: ChartMargin,
53 #[props(default)]
55 pub x_axis: ChartAxis,
56 #[props(default)]
58 pub y_axis: ChartAxis,
59 #[props(default)]
61 pub line_color: Option<Color>,
62 #[props(default = 2)]
64 pub line_width: u8,
65 #[props(default = true)]
67 pub show_points: bool,
68 #[props(default = 4)]
70 pub point_radius: u8,
71 #[props(default = false)]
73 pub show_values: bool,
74 #[props(default)]
76 pub value_format: Option<fn(f64) -> String>,
77 #[props(default)]
79 pub legend_position: LegendPosition,
80 #[props(default)]
82 pub tooltip: ChartTooltip,
83 #[props(default)]
85 pub animation: ChartAnimation,
86 #[props(default)]
88 pub on_point_click: Option<EventHandler<ChartDataPoint>>,
89 #[props(default)]
91 pub style: Option<String>,
92}
93
94#[component]
96pub fn LineChart(props: LineChartProps) -> Element {
97 let theme = use_theme();
98 let tokens = theme.tokens.read();
99
100 let mut tooltip_state = use_signal(|| None as Option<(i32, i32, String)>);
102
103 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 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 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 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 let y_ticks = calculate_nice_ticks(y_min, y_max, props.y_axis.tick_count);
187
188 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 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 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 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 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 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 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 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 for (y, label, x2) in y_axis_data {
390 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 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 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 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 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 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 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}