ferrix_app/widgets/
line_charts.rs

1/* line_charts.rs
2 *
3 * Copyright 2025 Michail Krasnov <mskrasnov07@ya.ru>
4 *
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: GPL-3.0-or-later
19 */
20
21//! Linear charts
22
23/********************************************************
24 *               WARNING WARNING WARNING                *
25 ********************************************************
26 * To implement the charts of the system monitor, I     *
27 * used the iced_aksel crate.                           *
28 *                                                      *
29 * The main part of the source code from here is taken  *
30 * from the `dashboard` example:                        *
31 * https://github.com/QuistHQ/iced_aksel/tree/main/examples/dashboard *
32 ********************************************************
33 * NOTE: refactoring!                                   *
34 ********************************************************
35 *            END WARNING WARNING WARNING               *
36 ********************************************************/
37
38use iced::{
39    Color, Pixels, Theme,
40    alignment::{Horizontal, Vertical},
41    time::Instant,
42};
43use iced_aksel::{
44    Axis, Chart, Measure, PlotPoint, State, Stroke,
45    axis::{self, TickResult},
46    plot::{Plot, PlotData},
47    scale::Linear,
48    shape::{Area, Label, Polygon, Polyline, Rectangle},
49    style::DashStyle,
50};
51use std::collections::{HashMap, VecDeque};
52
53type AxisID = String;
54
55#[derive(Debug, Clone)]
56pub struct LineSeries {
57    pub name: String,
58    pub current_values: VecDeque<f64>,
59    pub target_values: VecDeque<f64>,
60    pub y_key: String,
61    pub color: Color,
62    pub width: f32,
63    pub show_markers: bool,
64    pub fill_color: Option<Color>,
65    pub max_displayed_values: usize,
66}
67
68impl LineSeries {
69    pub fn new(name: impl Into<String>, color: Color, mx: usize) -> Self {
70        Self {
71            name: name.into(),
72            current_values: VecDeque::new(),
73            target_values: VecDeque::new(),
74            y_key: "Y".to_string(),
75            color,
76            width: 1.5,
77            show_markers: false,
78            fill_color: None,
79            max_displayed_values: mx,
80        }
81    }
82
83    pub fn set_max_values(&mut self, mx: usize) {
84        self.max_displayed_values = mx;
85    }
86
87    pub fn axis(mut self, y_id: impl Into<String>) -> Self {
88        self.y_key = y_id.into();
89        self
90    }
91
92    pub const fn width(mut self, width: f32) -> Self {
93        self.width = width;
94        self
95    }
96
97    pub const fn markers(mut self, show: bool) -> Self {
98        self.show_markers = show;
99        self
100    }
101
102    pub const fn fill(mut self, color: Color) -> Self {
103        self.fill_color = Some(color);
104        self
105    }
106
107    pub fn push(&mut self, val: f64) {
108        let start_val = self.current_values.iter().last().copied().unwrap_or(0.);
109        self.current_values.push_back(start_val);
110        self.target_values.push_back(val);
111
112        if self.current_values.len() >= self.max_displayed_values + 1 {
113            self.current_values.pop_front();
114        }
115        if self.target_values.len() >= self.max_displayed_values + 1 {
116            self.target_values.pop_front();
117        }
118    }
119
120    pub fn extend(&mut self, vals: impl IntoIterator<Item = f64>) {
121        for v in vals {
122            self.push(v);
123        }
124    }
125
126    fn tick(&mut self, alpha: f64) {
127        if self.current_values.len() < self.target_values.len() {
128            self.current_values.resize(self.target_values.len(), 0.);
129        }
130        for (cur, tgt) in self
131            .current_values
132            .iter_mut()
133            .zip(self.target_values.iter())
134        {
135            let diff = *tgt - *cur;
136            if diff.abs() > 1e-5 {
137                *cur += diff * alpha;
138            } else {
139                *cur = *tgt;
140            }
141        }
142    }
143
144    fn snap(&mut self) {
145        self.current_values = self.target_values.clone();
146    }
147}
148
149/// Line chart
150#[derive(Debug)]
151pub struct LineChart {
152    state: State<AxisID, f64>,
153    series: Vec<LineSeries>,
154    labels: Vec<String>,
155    defined_axes: Vec<String>,
156    show_legend: bool,
157    show_y_axis: bool,
158
159    /********************
160     *     Animation    *
161     ********************/
162    animation_speed: Option<f64>,
163    last_tick: Option<Instant>,
164
165    /********************
166     *  Animated state  *
167     ********************/
168    current_stack_factor: f64,
169    target_stack_factor: f64,
170
171    fill_enabled: bool,
172    current_fill_alpha: f32,
173    target_fill_alpha: f32,
174
175    current_x_domain: (f64, f64),
176    current_y_domains: HashMap<String, (f64, f64)>,
177
178    max_values: usize,
179    max_y_val: Option<f64>,
180}
181
182impl LineChart {
183    pub const X: &'static str = "X";
184    pub const Y: &'static str = "Y";
185
186    pub fn new() -> Self {
187        Self {
188            state: State::new(),
189            series: vec![],
190            labels: vec![],
191            defined_axes: vec![],
192            show_legend: true,
193            show_y_axis: true,
194            animation_speed: None,
195            last_tick: None,
196            current_stack_factor: 0.,
197            target_stack_factor: 0.,
198            fill_enabled: false,
199            current_fill_alpha: 0.,
200            target_fill_alpha: 0.2,
201            current_x_domain: (0.0, 1.0),
202            current_y_domains: HashMap::new(),
203            max_values: 50,
204            max_y_val: None,
205        }
206    }
207
208    pub fn max_values(mut self, mx: usize) -> Self {
209        self.max_values = mx;
210        for i in &mut self.series {
211            i.set_max_values(self.max_values);
212        }
213        self
214    }
215
216    pub fn set_max_values(&mut self, mx: usize) {
217        self.max_values = mx;
218        for i in &mut self.series {
219            i.set_max_values(self.max_values);
220        }
221    }
222
223    pub fn set_max_y(&mut self, y: f64) {
224        self.max_y_val = Some(y);
225    }
226
227    pub fn with_default_axes(mut self) -> Self {
228        self.with_axis(
229            Self::X,
230            Axis::new(Linear::new(0., 1.), axis::Position::Bottom),
231        );
232        self.with_axis(Self::Y, y_axis(0., 1.));
233        self
234    }
235
236    pub const fn animated(mut self, speed: f64) -> Self {
237        self.animation_speed = Some(speed.max(0.0).min(1.0));
238        self
239    }
240
241    pub const fn legend(mut self, show: bool) -> Self {
242        self.show_legend = show;
243        self
244    }
245
246    pub const fn y_axis(mut self, show: bool) -> Self {
247        self.show_y_axis = show;
248        self
249    }
250
251    pub fn fill_alpha(mut self, alpha: f32) -> Self {
252        self.target_fill_alpha = alpha.clamp(0., 1.);
253        // If already enabled, we might need to update current immediately
254        // if not animating
255        if self.fill_enabled && self.animation_speed.is_none() {
256            self.current_fill_alpha = self.target_fill_alpha;
257            self.update_series_fill();
258        }
259        self
260    }
261
262    pub fn toggle_fill(&mut self) {
263        self.fill_enabled = !self.fill_enabled;
264        let target = if self.fill_enabled {
265            self.target_fill_alpha
266        } else {
267            0.
268        };
269        if self.animation_speed.is_none() {
270            self.current_fill_alpha = target;
271            self.update_series_fill();
272        }
273    }
274
275    fn update_series_fill(&mut self) {
276        for s in &mut self.series {
277            let mut color = s.color;
278            color.a = self.current_fill_alpha;
279            s.fill_color = if self.current_fill_alpha > 0. {
280                Some(color)
281            } else {
282                None
283            };
284        }
285    }
286
287    pub fn stacked(mut self, stacked: bool) -> Self {
288        self.set_stacked(stacked);
289        self
290    }
291
292    pub fn set_stacked(&mut self, stacked: bool) {
293        self.target_stack_factor = if stacked { 1. } else { 0. };
294        // Link Fill Enabled to Stacked State
295        self.fill_enabled = stacked;
296
297        if self.animation_speed.is_none() {
298            self.current_stack_factor = self.target_stack_factor;
299            self.current_fill_alpha = if self.fill_enabled {
300                self.target_fill_alpha
301            } else {
302                0.
303            };
304            self.update_series_fill();
305            self.snap_axes();
306        }
307    }
308
309    pub fn toggle_stacked(&mut self) {
310        let new_stacked_state = self.target_stack_factor <= 0.5;
311        self.set_stacked(new_stacked_state);
312    }
313
314    pub fn with_axis(&mut self, id: impl Into<String>, axis: Axis<f64>) {
315        let key = id.into();
316        self.state.set_axis(key.clone(), axis);
317        if !self.defined_axes.contains(&key) {
318            self.defined_axes.push(key);
319        }
320    }
321
322    pub fn push_series(&mut self, mut series: LineSeries) {
323        if self.current_fill_alpha > 0.0 {
324            let mut color = series.color;
325            color.a = self.current_fill_alpha;
326            series.fill_color = Some(color);
327        }
328        self.ensure_axes_exist(&series);
329        self.series.push(series);
330        if self.animation_speed.is_none() {
331            self.snap_axes();
332        }
333    }
334
335    pub fn clear(&mut self) {
336        self.series.clear();
337        self.labels.clear();
338        self.snap_axes();
339    }
340
341    pub fn get_last(&self) -> Option<&LineSeries> {
342        self.series.last()
343    }
344
345    pub fn tick(&mut self, now: Instant) {
346        let Some(speed_normalized) = self.animation_speed else {
347            return;
348        };
349
350        let dt = self
351            .last_tick
352            .map_or(0.0, |last| (now - last).as_secs_f32() as f64);
353        self.last_tick = Some(now);
354
355        let physics_speed = speed_normalized * 10.0;
356        let alpha = 1.0 - (-physics_speed * dt).exp();
357
358        // 1. Calculate Targets
359        let (target_x, target_ys) = self.calculate_targets();
360
361        // 2. Animate Axes
362        let next_x0 =
363            (target_x.0 - self.current_x_domain.0).mul_add(alpha, self.current_x_domain.0);
364        let next_x1 =
365            (target_x.1 - self.current_x_domain.1).mul_add(alpha, self.current_x_domain.1);
366
367        self.current_x_domain = (next_x0, next_x1);
368
369        if let Some(axis) = self.state.axis_mut_opt(&Self::X.to_string()) {
370            axis.set_domain(self.current_x_domain.0, self.current_x_domain.1);
371        }
372
373        for (id, target) in target_ys {
374            let current = self.current_y_domains.entry(id.clone()).or_insert(target);
375            current.0 += (target.0 - current.0) * alpha;
376            current.1 += (target.1 - current.1) * alpha;
377
378            if let Some(axis) = self.state.axis_mut_opt(&id) {
379                axis.set_domain(current.0, current.1);
380            }
381        }
382
383        // 3. Animate Content
384        for s in &mut self.series {
385            s.tick(alpha);
386        }
387
388        // Animate Stacking Factor
389        let diff_stack = self.target_stack_factor - self.current_stack_factor;
390        if diff_stack.abs() > 1e-5 {
391            self.current_stack_factor += diff_stack * alpha;
392        } else {
393            self.current_stack_factor = self.target_stack_factor;
394        }
395
396        // 4. Animate Fill Alpha
397        let target_alpha = if self.fill_enabled {
398            self.target_fill_alpha
399        } else {
400            0.0
401        };
402        let diff_alpha = target_alpha - self.current_fill_alpha;
403        if diff_alpha.abs() > 1e-5 {
404            self.current_fill_alpha += diff_alpha * (alpha as f32);
405            self.update_series_fill();
406        } else if self.current_fill_alpha != target_alpha {
407            self.current_fill_alpha = target_alpha;
408            self.update_series_fill();
409        }
410    }
411
412    fn snap_axes(&mut self) {
413        let (tx, tys) = self.calculate_targets();
414        self.current_x_domain = tx;
415        self.current_y_domains = tys;
416
417        if let Some(axis) = self.state.axis_mut_opt(&Self::X.to_string()) {
418            axis.set_domain(tx.0, tx.1);
419        }
420        for (id, d) in &self.current_y_domains {
421            if let Some(axis) = self.state.axis_mut_opt(id) {
422                axis.set_domain(d.0, d.1);
423            }
424        }
425    }
426
427    fn calculate_targets(&self) -> ((f64, f64), HashMap<String, (f64, f64)>) {
428        if self.series.is_empty() {
429            return ((0.0, 1.0), HashMap::new());
430        }
431
432        let max_len = self
433            .series
434            .iter()
435            .map(|s| s.target_values.len())
436            .max()
437            .unwrap_or(0);
438        let x_max = (max_len as f64 - 1.0).max(0.0);
439        let target_x = (0.0, x_max);
440
441        let mut target_ys = HashMap::new();
442        let mut stacked_sums: HashMap<String, Vec<f64>> = HashMap::new();
443        let factor = self.target_stack_factor;
444
445        for s in &self.series {
446            let sums = stacked_sums.entry(s.y_key.clone()).or_default();
447            if s.target_values.len() > sums.len() {
448                sums.resize(s.target_values.len(), 0.0);
449            }
450
451            let entry = target_ys
452                .entry(s.y_key.clone())
453                .or_insert((f64::MAX, f64::MIN));
454
455            for (i, &val) in s.target_values.iter().enumerate() {
456                let baseline = sums[i];
457                let effective_val = baseline.mul_add(factor, val);
458                entry.0 = entry.0.min(effective_val);
459                entry.1 = entry.1.max(effective_val);
460                sums[i] += val;
461            }
462        }
463
464        for (_, bounds) in target_ys.iter_mut() {
465            let (min, max) = match self.max_y_val {
466                Some(max_y_val) => (bounds.0, max_y_val),
467                None => *bounds,
468            };
469            let padding = if max > min { (max - min) * 0.05 } else { 1.0 };
470            let final_min = if factor > 0.1 {
471                min.min(0.0)
472            } else {
473                if min < 0. { min } else { 0.0 }
474            };
475            *bounds = (final_min, max + padding);
476        }
477
478        (target_x, target_ys)
479    }
480
481    pub fn push(&mut self, label: impl Into<String>, value: f64) {
482        let label = label.into();
483        if self.series.is_empty() {
484            let default_series =
485                LineSeries::new("Data", Color::from_rgb(0.2, 0.4, 0.8), self.max_values);
486            self.push_series(default_series);
487        }
488
489        let needs_label_update = if let Some(last) = self.series.last() {
490            last.target_values.len() >= self.labels.len()
491        } else {
492            false
493        };
494
495        if needs_label_update {
496            self.labels.push(label);
497            self.update_x_axis_labels();
498        }
499
500        if let Some(last) = self.series.last_mut() {
501            last.push(value);
502        }
503
504        if self.animation_speed.is_none() {
505            for s in &mut self.series {
506                s.current_values = s.target_values.clone();
507            }
508            self.snap_axes();
509        }
510    }
511
512    pub fn push_value(&mut self, value: f64) {
513        self.push("", value);
514    }
515
516    pub fn push_to(&mut self, index: usize, label: impl Into<String>, value: f64) {
517        let needs_label_update = if let Some(series) = self.series.get(index) {
518            series.target_values.len() >= self.labels.len()
519        } else {
520            false
521        };
522
523        if needs_label_update {
524            self.labels.push(label.into());
525            self.update_x_axis_labels();
526        }
527
528        if let Some(series) = self.series.get_mut(index) {
529            series.push(value);
530        }
531
532        if self.animation_speed.is_none() {
533            if let Some(s) = self.series.get_mut(index) {
534                s.snap();
535            }
536            self.snap_axes();
537        }
538    }
539
540    pub fn push_value_to(&mut self, index: usize, value: f64) {
541        self.push_to(index, "", value);
542    }
543
544    pub fn push_value_last_series(&mut self, value: f64) {
545        self.push_value(value);
546    }
547
548    pub const fn series_count(&self) -> usize {
549        self.series.len()
550    }
551
552    fn update_x_axis_labels(&mut self) {
553        let labels = self.labels.clone();
554        let x_axis = self.state.axis_mut(&Self::X.to_string());
555
556        x_axis.set_tick_renderer(move |ctx| {
557            let idx = ctx.tick.value.round();
558            if (ctx.tick.value - idx).abs() > 0.001 {
559                return TickResult::default();
560            }
561            let idx = idx as usize;
562            let valid_idx = idx < labels.len();
563
564            let label = valid_idx.then(|| ctx.label(labels[idx].clone()));
565            let tick_line = valid_idx.then(|| ctx.tickline());
566
567            TickResult {
568                label,
569                tick_line,
570                ..Default::default()
571            }
572        });
573    }
574
575    fn ensure_axes_exist(&mut self, series: &LineSeries) {
576        let x_key = Self::X.to_string();
577        if !self.defined_axes.contains(&x_key) {
578            self.state.set_axis(
579                x_key.clone(),
580                Axis::new(Linear::new(0.0, 1.0), axis::Position::Bottom),
581            );
582            self.defined_axes.push(x_key);
583            self.update_x_axis_labels();
584        }
585
586        if !self.defined_axes.contains(&series.y_key) {
587            self.state.set_axis(
588                series.y_key.clone(),
589                if self.show_y_axis {
590                    y_axis(0., 1.)
591                } else {
592                    y_axis_without_label(0., 1.)
593                },
594            );
595            self.defined_axes.push(series.y_key.clone());
596        }
597    }
598
599    pub fn chart<Message>(&self) -> Chart<'_, AxisID, f64, Message> {
600        let mut chart = Chart::new(&self.state);
601        let first_y = self
602            .series
603            .first()
604            .map(|s| s.y_key.clone())
605            .unwrap_or_else(|| Self::Y.to_string());
606        chart = chart.plot_data(self, Self::X.to_string(), first_y);
607        chart
608    }
609}
610
611impl PlotData<f64> for LineChart {
612    fn draw(&self, plot: &mut Plot<f64>, theme: &Theme) {
613        let chart_floor = self
614            .state
615            .axis_opt(&Self::Y.to_string())
616            .map_or(0.0, |axis| *axis.domain().0);
617
618        let mut baseline: Vec<f64> = Vec::new();
619
620        for s in &self.series {
621            if s.current_values.len() < 2 {
622                continue;
623            }
624
625            if baseline.len() < s.current_values.len() {
626                baseline.resize(s.current_values.len(), 0.0);
627            }
628
629            let points: Vec<PlotPoint<f64>> = s
630                .current_values
631                .iter()
632                .enumerate()
633                .map(|(i, &v)| {
634                    let effective_base = baseline[i] * self.current_stack_factor;
635                    let total = effective_base + v;
636                    PlotPoint::new(i as f64, total)
637                })
638                .collect();
639
640            if self.current_fill_alpha > 0.0 {
641                let mut fill_poly = points.clone();
642                for (i, _) in s
643                    .current_values
644                    .iter()
645                    .enumerate()
646                    .take(s.current_values.len())
647                    .rev()
648                {
649                    let base_val = baseline[i] * self.current_stack_factor;
650                    let floor = chart_floor * (1.0 - self.current_stack_factor)
651                        + base_val * self.current_stack_factor;
652                    fill_poly.push(PlotPoint::new(i as f64, floor));
653                }
654                let mut color = s.color;
655                color.a = self.current_fill_alpha;
656                plot.add_shape(Area::new(fill_poly).fill(color));
657            }
658
659            plot.add_shape(Polyline {
660                points: points.clone(),
661                stroke: Some(Stroke::new(s.color, Measure::Screen(s.width))),
662                extend_start: false,
663                extend_end: false,
664                arrow_start: false,
665                arrow_end: false,
666                arrow_size: 10.0,
667            });
668
669            if s.show_markers {
670                for point in &points {
671                    let marker_size = Measure::Screen(s.width.mul_add(2.0, 2.0));
672                    plot.add_shape(Polygon::new(*point, marker_size, 4).fill(s.color));
673                }
674            }
675
676            for (i, &v) in s.current_values.iter().enumerate() {
677                baseline[i] += v;
678            }
679        }
680
681        if self.show_legend {
682            let palette = theme.palette();
683            if let (Some(x_axis), Some(y_axis)) = (
684                self.state.axis_opt(&Self::X.to_string()),
685                self.state.axis_opt(&Self::Y.to_string()),
686            ) {
687                let (x_min, x_max) = x_axis.domain();
688                let (y_min, y_max) = y_axis.domain();
689
690                let max_cols = 6;
691                let item_cnt = self.series.len();
692                let cols_per_row = if item_cnt <= 2 { item_cnt } else { max_cols };
693
694                let start_x = (x_max - x_min).mul_add(0.02, *x_min);
695                let start_y = (y_max - y_min).mul_add(-0.05, *y_max);
696                let step_y = (y_max - y_min) * 0.1;
697
698                let avail_width = (x_max - x_min) * 0.8;
699                let col_width = if cols_per_row > 0 {
700                    avail_width / cols_per_row as f64
701                } else {
702                    avail_width
703                };
704
705                for (i, series) in self.series.iter().enumerate() {
706                    let row = i / cols_per_row;
707                    let col = i % cols_per_row;
708
709                    let x_pos = start_x + (col as f64) * col_width;
710                    let y_pos = start_y - (row as f64) * step_y;
711
712                    // let y_pos = (i as f64).mul_add(-step_y, start_y);
713                    plot.add_shape(
714                        Rectangle::centered(
715                            PlotPoint::new(x_pos, y_pos),
716                            Measure::Screen(10.0),
717                            Measure::Screen(10.0),
718                        )
719                        .fill(series.color),
720                    );
721                    let text_offset = (x_max - x_min) * 0.01;
722                    plot.add_shape(
723                        Label::new(&series.name, PlotPoint::new(x_pos + text_offset, y_pos))
724                            .fill(palette.text)
725                            .size(Measure::Screen(12.))
726                            .align(Horizontal::Left, Vertical::Center),
727                    );
728                }
729            }
730        }
731    }
732}
733
734fn y_axis(min_y: f64, max_y: f64) -> Axis<f64> {
735    Axis::new(Linear::new(min_y, max_y), axis::Position::Right)
736        .with_tick_renderer(|ctx| {
737            let line_color = ctx.gridline().color;
738
739            match ctx.tick.level {
740                0 => TickResult {
741                    label: Some(ctx.label(format!("{}%", ctx.tick.value))),
742                    grid_line: Some(ctx.gridline()),
743                    tick_line: Some({
744                        let mut line = ctx.tickline();
745                        line.color = line_color;
746                        line.width = Pixels::ZERO;
747                        line.length = Pixels::ZERO;
748                        line
749                    }),
750                    ..Default::default()
751                },
752                _ => TickResult::default(),
753            }
754        })
755        .style(|s| {
756            s.spine.width = Pixels::ZERO;
757            s.tick.width = Pixels::ZERO;
758            s.label.size = Pixels::from(10.);
759            s.grid.dashed = Some(DashStyle::new(1., 2.));
760        })
761}
762
763fn y_axis_without_label(min_y: f64, max_y: f64) -> Axis<f64> {
764    Axis::new(Linear::new(min_y, max_y), axis::Position::Right)
765        .with_tick_renderer(|_| TickResult::default())
766}