Skip to main content

node_gui/
charts.rs

1//! Plotters-backed throughput and CPU chart rendering.
2
3use plotters::prelude::*;
4use slint::SharedPixelBuffer;
5use wowlab_types::constants::{HUNDRED, THOUSAND};
6
7const BG: RGBColor = RGBColor(12, 12, 16);
8const GRID: RGBColor = RGBColor(37, 37, 48);
9const GRID_LIGHT: RGBColor = RGBColor(25, 25, 35);
10const LABEL_COLOR: RGBColor = RGBColor(85, 85, 106);
11const ACCENT: RGBColor = RGBColor(34, 197, 94);
12const DATA_POINTS: f64 = 60.0;
13
14const MARGIN_TOP: u32 = 6;
15const MARGIN_RIGHT: u32 = 8;
16const MARGIN_BOTTOM: u32 = 4;
17const MARGIN_LEFT: u32 = 4;
18const Y_LABEL_AREA_SIZE: u32 = 42;
19const Y_LABEL_COUNT: usize = 5;
20const GRID_BOLD_OPACITY: f64 = 0.6;
21const GRID_LIGHT_OPACITY: f64 = 0.3;
22const GRID_AXIS_OPACITY: f64 = 0.4;
23const LABEL_FONT_SIZE: i32 = 11;
24const LINE_STROKE_WIDTH: u32 = 2;
25const THROUGHPUT_Y_HEADROOM: f64 = 1.15;
26const THROUGHPUT_FILL_OPACITY: f64 = 0.15;
27const RESOURCE_FILL_OPACITY: f64 = 0.12;
28
29fn series_points<T>(data: &[T]) -> Vec<(f64, f64)>
30where
31    T: Copy + Into<f64>,
32{
33    data.iter()
34        .enumerate()
35        .map(|(index, &value)| {
36            (
37                f64::from(u32::try_from(index).unwrap_or(u32::MAX)),
38                value.into(),
39            )
40        })
41        .collect()
42}
43
44fn throughput_y_max(data: &[f64]) -> f64 {
45    data.iter().copied().fold(HUNDRED, f64::max).max(HUNDRED) * THROUGHPUT_Y_HEADROOM
46}
47
48fn format_throughput_label(value: f64) -> String {
49    if value >= THOUSAND {
50        format!("{:.1}k", value / THOUSAND)
51    } else {
52        format!("{value:.0}")
53    }
54}
55
56struct ChartConfig<'a> {
57    data: Vec<(f64, f64)>,
58    y_max: f64,
59    y_formatter: &'a dyn Fn(&f64) -> String,
60    fill_opacity: f64,
61}
62
63fn render_chart(config: &ChartConfig<'_>, width: u32, height: u32) -> slint::Image {
64    let mut pixel_buffer = SharedPixelBuffer::<slint::Rgb8Pixel>::new(width, height);
65    let size = (pixel_buffer.width(), pixel_buffer.height());
66
67    {
68        let backend = BitMapBackend::with_buffer(pixel_buffer.make_mut_bytes(), size);
69        let root = backend.into_drawing_area();
70
71        root.fill(&BG).expect("fill background");
72
73        let mut chart = ChartBuilder::on(&root)
74            .margin_top(MARGIN_TOP)
75            .margin_right(MARGIN_RIGHT)
76            .margin_bottom(MARGIN_BOTTOM)
77            .margin_left(MARGIN_LEFT)
78            .x_label_area_size(0)
79            .y_label_area_size(Y_LABEL_AREA_SIZE)
80            .build_cartesian_2d(0f64..DATA_POINTS, 0f64..config.y_max)
81            .expect("build chart");
82
83        chart
84            .configure_mesh()
85            .disable_x_mesh()
86            .disable_x_axis()
87            .y_labels(Y_LABEL_COUNT)
88            .bold_line_style(GRID.mix(GRID_BOLD_OPACITY))
89            .light_line_style(GRID_LIGHT.mix(GRID_LIGHT_OPACITY))
90            .axis_style(GRID.mix(GRID_AXIS_OPACITY))
91            .label_style(
92                ("sans-serif", LABEL_FONT_SIZE)
93                    .into_font()
94                    .color(&LABEL_COLOR),
95            )
96            .y_label_formatter(config.y_formatter)
97            .draw()
98            .expect("draw mesh");
99
100        chart
101            .draw_series(AreaSeries::new(
102                config.data.iter().copied(),
103                0.0,
104                ACCENT.mix(config.fill_opacity),
105            ))
106            .expect("draw area series");
107
108        chart
109            .draw_series(LineSeries::new(
110                config.data.iter().copied(),
111                ShapeStyle::from(ACCENT).stroke_width(LINE_STROKE_WIDTH),
112            ))
113            .expect("draw line series");
114
115        root.present().expect("present chart");
116    };
117
118    slint::Image::from_rgb8(pixel_buffer)
119}
120
121/// Renders a throughput-over-time line chart as a Slint image.
122pub(crate) fn render_throughput_chart(data: &[f64], width: u32, height: u32) -> slint::Image {
123    let y_max = throughput_y_max(data);
124    let points = series_points(data);
125
126    render_chart(
127        &ChartConfig {
128            data: points,
129            y_max,
130            y_formatter: &|value: &f64| format_throughput_label(*value),
131            fill_opacity: THROUGHPUT_FILL_OPACITY,
132        },
133        width,
134        height,
135    )
136}
137
138/// Renders a CPU usage line chart as a Slint image.
139pub(crate) fn render_resource_chart(cpu_data: &[f32], width: u32, height: u32) -> slint::Image {
140    let points = series_points(cpu_data);
141
142    render_chart(
143        &ChartConfig {
144            data: points,
145            y_max: HUNDRED,
146            y_formatter: &|v: &f64| format!("{v:.0}%"),
147            fill_opacity: RESOURCE_FILL_OPACITY,
148        },
149        width,
150        height,
151    )
152}
153
154#[cfg(test)]
155mod tests {
156    use googletest::prelude::*;
157
158    use super::*;
159
160    #[gtest]
161    fn series_points_preserve_order_and_numeric_values() -> Result<()> {
162        verify_that!(
163            series_points(&[1.5_f32, 2.25, 3.75]),
164            container_eq([(0.0, 1.5), (1.0, 2.25), (2.0, 3.75)])
165        )
166    }
167
168    #[gtest]
169    fn throughput_axis_has_floor_and_peak_headroom() -> Result<()> {
170        let floor = HUNDRED * THROUGHPUT_Y_HEADROOM;
171
172        verify_that!(throughput_y_max(&[]), near(floor, f64::EPSILON))?;
173
174        let peak = 2_000.0 * THROUGHPUT_Y_HEADROOM;
175
176        verify_that!(
177            throughput_y_max(&[10.0, 2_000.0, 50.0]),
178            near(peak, f64::EPSILON)
179        )
180    }
181
182    #[gtest]
183    fn throughput_labels_switch_to_thousands_at_boundary() -> Result<()> {
184        verify_eq!(
185            [
186                format_throughput_label(999.0),
187                format_throughput_label(1_000.0),
188                format_throughput_label(12_345.0),
189            ],
190            ["999", "1.0k", "12.3k"]
191        )
192    }
193}