1use 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
121pub(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
138pub(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}