feat: improve plot rendering, use supersampling and do it in a background thread

* Threaded plot render

* Better supersampler implementation

* Better to display nothing than do long freeze

* Fix plot throttling jumping around

* Further improve rendering by using filled legend

* Spawn render thread with minimum priority

* Optimize Cairo bindings

* Simplify code as we no longer need to track initial state

Signed-off-by: Alik Aslanyan <inline0@pm.me>

* Add plotters package override for opt-level 3 in release

* Immediately react to size changes of the widget, don't wait for new data

* Scale plots in GTK, instead of Cairo for Trillinear filtering, rewrite supersampling

---------

Signed-off-by: Alik Aslanyan <inline0@pm.me>
This commit is contained in:
Alik Aslanyan 2024-10-25 22:48:15 +04:00 committed by GitHub
parent d43ec9cb82
commit dbb24c5bd2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 522 additions and 393 deletions

180
Cargo.lock generated
View File

@ -76,12 +76,6 @@ dependencies = [
"libc",
]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstream"
version = "0.6.15"
@ -388,12 +382,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.1.12"
@ -439,33 +427,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "clap"
version = "4.5.16"
@ -600,61 +561,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "criterion"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
dependencies = [
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"is-terminal",
"itertools 0.10.5",
"num-traits",
"once_cell",
"oorandom",
"plotters",
"rayon",
"regex",
"serde",
"serde_derive",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
"itertools 0.10.5",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.11"
@ -1497,32 +1403,12 @@ dependencies = [
"libc",
]
[[package]]
name = "is-terminal"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b"
dependencies = [
"hermit-abi 0.4.0",
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.13.0"
@ -1637,13 +1523,12 @@ version = "0.5.7"
dependencies = [
"amdgpu-sysfs",
"anyhow",
"cairo-rs",
"chrono",
"criterion",
"gtk4",
"itertools 0.13.0",
"itertools",
"lact-client",
"lact-daemon",
"lact-gui",
"lact-schema",
"libadwaita",
"plotters",
@ -1651,6 +1536,7 @@ dependencies = [
"pretty_assertions",
"relm4",
"relm4-components",
"thread-priority",
"tracing",
"tracing-subscriber",
]
@ -1962,12 +1848,6 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "oorandom"
version = "11.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b410bbe7e14ab526a0e86877eb47c6996a2bd7746f027ba551028c925390e4e9"
[[package]]
name = "ordered-stream"
version = "0.2.0"
@ -2115,7 +1995,6 @@ dependencies = [
"num-traits",
"pathfinder_geometry",
"plotters-backend",
"plotters-svg",
"ttf-parser",
"wasm-bindgen",
"web-sys",
@ -2137,15 +2016,6 @@ dependencies = [
"plotters-backend",
]
[[package]]
name = "plotters-svg"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81b30686a7d9c3e010b84284bdd26a29f2138574f52f5eb6f794fc0ad924e705"
dependencies = [
"plotters-backend",
]
[[package]]
name = "polling"
version = "3.7.3"
@ -2249,26 +2119,6 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9"
[[package]]
name = "rayon"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.3"
@ -2698,6 +2548,20 @@ dependencies = [
"syn",
]
[[package]]
name = "thread-priority"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d3b04d33c9633b8662b167b847c7ab521f83d1ae20f2321b65b5b925e532e36"
dependencies = [
"bitflags 2.6.0",
"cfg-if",
"libc",
"log",
"rustversion",
"winapi",
]
[[package]]
name = "thread_local"
version = "1.1.8"
@ -2741,16 +2605,6 @@ dependencies = [
"time-core",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "tokio"
version = "1.39.2"

View File

@ -31,6 +31,15 @@ codegen-units = 1
opt-level = "s"
lto = true
[profile.release.package.cairo-rs]
opt-level = 3
[profile.release.package.plotters-cairo]
opt-level = 3
[profile.release.package.plotters]
opt-level = 3
[profile.bench]
strip = false
debug = true
debug = 1

View File

@ -36,13 +36,10 @@ plotters = { version = "0.3.5", default-features = false, features = [
"full_palette",
] }
plotters-cairo = "0.7.0"
cairo-rs = { version = "0.20", default-features = false }
itertools = "0.13.0"
[dev-dependencies]
criterion = "0.5.1"
pretty_assertions = "1.4.0"
lact-gui = { path = ".", features = ["bench"] }
thread-priority = "1.1.0"
[[bench]]
name = "gui"
harness = false
[dev-dependencies]
pretty_assertions = "1.4.0"

View File

@ -1,44 +0,0 @@
use criterion::{criterion_group, criterion_main, BatchSize, Criterion};
use gtk::glib::{subclass::types::ObjectSubclassIsExt, Object};
use lact_gui::app::{Plot, PlotData};
use plotters::backend::SVGBackend;
pub fn criterion_benchmark(c: &mut Criterion) {
gtk::init().unwrap();
let mut plot_data = PlotData::default();
let mut time = chrono::NaiveDateTime::new(
chrono::NaiveDate::from_yo_opt(2024, 1).unwrap(),
chrono::NaiveTime::default(),
);
for value in (0..100).step_by(5) {
plot_data.push_line_series_with_time("value", value as f64, time);
time += chrono::TimeDelta::seconds(2);
}
let plot: Plot = Object::builder().build();
*plot.data_mut() = plot_data.clone();
let imp = plot.imp();
c.bench_function("plot_pdf", |b| {
b.iter(|| {
let mut buf = String::new();
let plotters_backend = SVGBackend::with_string(&mut buf, (1000, 1000));
imp.plot_pdf(plotters_backend).unwrap();
})
});
c.bench_function("trim_plot_data", |b| {
b.iter_batched(
|| plot_data.clone(),
|mut data| {
data.trim_data(60);
},
BatchSize::SmallInput,
)
});
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

View File

@ -1,17 +1,13 @@
use super::cubic_spline::cubic_spline_interpolation;
use anyhow::Context;
use chrono::NaiveDateTime;
use glib::Properties;
use gtk::{glib, prelude::*, subclass::prelude::*};
use itertools::Itertools;
use plotters::prelude::*;
use plotters::style::colors::full_palette::DEEPORANGE_100;
use plotters_cairo::CairoBackend;
use std::cell::Cell;
use std::cell::RefCell;
use std::cmp::max;
use std::collections::BTreeMap;
use tracing::error;
use super::render_thread::{RenderRequest, RenderThread};
#[derive(Properties, Default)]
#[properties(wrapper_type = super::Plot)]
@ -23,10 +19,12 @@ pub struct Plot {
#[property(get, set)]
secondary_value_suffix: RefCell<String>,
#[property(get, set)]
y_label_area_size: Cell<u32>,
y_label_area_relative_size: Cell<f64>,
#[property(get, set)]
secondary_y_label_area_size: Cell<u32>,
secondary_y_label_area_relative_size: Cell<f64>,
pub(super) data: RefCell<PlotData>,
pub(super) dirty: Cell<bool>,
render_thread: RenderThread,
}
#[glib::object_subclass]
@ -57,17 +55,38 @@ impl WidgetImpl for Plot {
return;
}
let bounds = gtk::graphene::Rect::new(0.0, 0.0, width as f32, height as f32);
let cr = snapshot.append_cairo(&bounds);
// Supersample the plot area
let backend = CairoBackend::new(&cr, (width * 2, height * 2)).unwrap();
if let Err(err) = self.plot_pdf(backend) {
error!("Failed to plot PDF chart: {err:?}")
let last_texture = self.render_thread.get_last_texture();
let size_changed = last_texture
.as_ref()
.map(|texture| (texture.width() as u32, texture.height() as u32) != (width, height))
.unwrap_or(true);
if self.dirty.replace(false) || size_changed {
self.render_thread.replace_render_request(RenderRequest {
data: self.data.borrow().clone(),
width,
height,
title: self.title.borrow().clone(),
value_suffix: self.value_suffix.borrow().clone(),
secondary_value_suffix: self.secondary_value_suffix.borrow().clone(),
y_label_area_relative_size: self.y_label_area_relative_size.get(),
secondary_y_label_relative_area_size: self
.secondary_y_label_area_relative_size
.get(),
supersample_factor: 4,
});
}
// Rendering is always behind by at least one frame, but it's not an issue
if let Some(texture) = last_texture {
let bounds = gtk::graphene::Rect::new(0.0, 0.0, width as f32, height as f32);
// Uses by default Trillinear texture filtering, which is quite good at 4x supersampling
snapshot.append_texture(&texture, &bounds);
}
}
}
#[derive(Default)]
#[derive(Default, Clone)]
#[cfg_attr(feature = "bench", derive(Clone))]
pub struct PlotData {
line_series: BTreeMap<String, Vec<(i64, f64)>>,
@ -163,159 +182,3 @@ impl PlotData {
.retain(|(time_point, _)| ((maximum_point - *time_point) / 1000) < last_seconds);
}
}
impl Plot {
pub fn plot_pdf<'a, DB>(&self, backend: DB) -> anyhow::Result<()>
where
DB: DrawingBackend + 'a,
<DB as plotters::prelude::DrawingBackend>::ErrorType: 'static,
{
let root = backend.into_drawing_area();
let data = self.data.borrow();
let start_date = data
.line_series_iter()
.filter_map(|(_, data)| Some(data.first()?.0))
.min()
.unwrap_or_default();
let end_date = data
.line_series_iter()
.map(|(_, value)| value)
.filter_map(|data| Some(data.first()?.0))
.max()
.unwrap_or_default();
let mut maximum_value = data
.line_series_iter()
.flat_map(|(_, data)| data.iter().map(|(_, value)| value))
.max_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal))
.cloned()
.unwrap_or_default();
if maximum_value < 100.0f64 {
maximum_value = 100.0f64;
}
root.fill(&WHITE)?;
let mut chart = ChartBuilder::on(&root)
.x_label_area_size(40)
.y_label_area_size(self.y_label_area_size.get())
.right_y_label_area_size(self.secondary_y_label_area_size.get())
.margin(20)
.caption(self.title.borrow().as_str(), ("sans-serif", 30))
.build_cartesian_2d(
start_date..max(end_date, start_date + 60 * 1000),
0f64..maximum_value,
)?
.set_secondary_coord(
start_date..max(end_date, start_date + 60 * 1000),
0.0..100.0,
);
chart
.configure_mesh()
.x_label_formatter(&|date_time| {
let date_time = chrono::DateTime::from_timestamp_millis(*date_time).unwrap();
date_time.format("%H:%M:%S").to_string()
})
.y_label_formatter(&|x| format!("{x}{}", self.value_suffix.borrow()))
.x_labels(5)
.y_labels(10)
.label_style(("sans-serif", 30))
.draw()
.context("Failed to draw mesh")?;
chart
.configure_secondary_axes()
.y_label_formatter(&|x| format!("{x}{}", self.secondary_value_suffix.borrow()))
.y_labels(10)
.label_style(("sans-serif", 30))
.draw()
.context("Failed to draw mesh")?;
// Draw the throttling histogram
chart
.draw_series(
data.throttling_iter()
// Group segments of consecutive enabled/disabled throttlings
.chunk_by(|(_, _, point)| *point)
.into_iter()
// Filter only when throttling is enabled
.filter_map(|(point, group_iter)| point.then_some(group_iter))
// Get last and first times
.filter_map(|mut group_iter| {
let first = group_iter.next()?;
Some((first, group_iter.last().unwrap_or(first)))
})
// Filter out redundant data
.map(|((start, name, _), (end, _, _))| ((start, end), name))
.map(|((start_time, end_time), _)| {
let mut bar = Rectangle::new(
[(start_time, 0f64), (end_time, maximum_value)],
DEEPORANGE_100.filled(),
);
bar.set_margin(0, 0, 5, 5);
bar
}),
)
.context("Failed to draw throttling histogram")?;
for (idx, (caption, data)) in (0..).zip(data.line_series_iter()) {
chart
.draw_series(LineSeries::new(
cubic_spline_interpolation(data.iter())
.into_iter()
.flat_map(|((first_time, second_time), segment)| {
// Interpolate in intervals of one millisecond
(first_time..second_time).map(move |current_date| {
(current_date, segment.evaluate(current_date))
})
}),
Palette99::pick(idx).stroke_width(1),
))
.context("Failed to draw series")?
.label(caption)
.legend(move |(x, y)| {
Rectangle::new([(x - 10, y - 10), (x + 10, y + 10)], Palette99::pick(idx))
});
}
for (idx, (caption, data)) in (0..).zip(data.secondary_line_series_iter()) {
chart
.draw_secondary_series(LineSeries::new(
cubic_spline_interpolation(data.iter())
.into_iter()
.flat_map(|((first_time, second_time), segment)| {
// Interpolate in intervals of one millisecond
(first_time..second_time).map(move |current_date| {
(current_date, segment.evaluate(current_date))
})
}),
Palette99::pick(idx + 10).stroke_width(1), // Offset the pallete pick compared to the main graph
))
.context("Failed to draw series")?
.label(caption)
.legend(move |(x, y)| {
Rectangle::new(
[(x - 10, y - 10), (x + 10, y + 10)],
Palette99::pick(idx + 10),
)
});
}
chart
.configure_series_labels()
.margin(40)
.label_font(("sans-serif", 30))
.position(SeriesLabelPosition::LowerRight)
.background_style(WHITE.mix(0.8))
.border_style(BLACK)
.draw()
.context("Failed to draw series labels")?;
root.present()?;
Ok(())
}
}

View File

@ -1,5 +1,7 @@
mod cubic_spline;
mod imp;
mod render_thread;
mod to_texture_ext;
use std::cell::RefMut;
@ -14,6 +16,7 @@ glib::wrapper! {
impl Plot {
pub fn data_mut(&self) -> RefMut<'_, PlotData> {
self.imp().dirty.set(true);
self.imp().data.borrow_mut()
}
}

View File

@ -0,0 +1,366 @@
use super::cubic_spline::cubic_spline_interpolation;
use super::to_texture_ext::ToTextureExt;
use super::PlotData;
use anyhow::Context;
use cairo::{Context as CairoContext, ImageSurface};
use gtk::gdk::MemoryTexture;
use itertools::Itertools;
use plotters::prelude::*;
use plotters::style::colors::full_palette::DEEPORANGE_100;
use plotters::style::RelativeSize;
use plotters_cairo::CairoBackend;
use std::cmp::{max, min};
use std::ops::{Deref, DerefMut};
use std::sync::{Arc, Mutex};
use thread_priority::{ThreadBuilderExt, ThreadPriority};
use tracing::error;
enum Request {
Terminate,
Render(RenderRequest),
}
#[derive(Default)]
pub struct RenderRequest {
pub title: String,
pub value_suffix: String,
pub secondary_value_suffix: String,
pub y_label_area_relative_size: f64,
pub secondary_y_label_relative_area_size: f64,
pub data: PlotData,
pub width: u32,
pub height: u32,
pub supersample_factor: u32,
}
#[derive(Default)]
struct RenderThreadState {
request_condition_variable: std::sync::Condvar,
last_texture: Mutex<Option<MemoryTexture>>,
current_request: Mutex<Option<Request>>,
}
/// A rendering thread that will listen for rendering requests and process them asynchronously.
/// Requests that weren't processed in time or resulted in error are dropped.
pub struct RenderThread {
/// Shared state is between the main thread and the rendering thread.
state: Arc<RenderThreadState>,
thread_handle: Option<std::thread::JoinHandle<()>>,
}
/// Ensure the rendering thread is terminated properly when the RenderThread object is dropped.
/// We send Request::Terminate to swiftly terminate rendering thread and then join to let it finish last render.
impl Drop for RenderThread {
fn drop(&mut self) {
self.state
.current_request
.lock()
.unwrap()
.replace(Request::Terminate);
self.state.request_condition_variable.notify_all();
self.thread_handle.take().map(|handle| handle.join().ok());
}
}
impl RenderThread {
pub fn new() -> Self {
let state = Arc::new(RenderThreadState::default());
let thread_handle = std::thread::Builder::new()
.name("Plot-Renderer".to_owned())
// Render thread is very unimportant, skipping frames and rendering slowly is ok
.spawn_with_priority(ThreadPriority::Min, {
let state = state.clone();
move |_| loop {
let RenderThreadState {
request_condition_variable,
last_texture,
current_request,
} = &*state;
// Wait until there is a new request (blocking if there is none).
let mut current_request = request_condition_variable
.wait_while(current_request.lock().unwrap(), |pending_request| {
pending_request.is_none()
})
.unwrap();
match current_request.take() {
Some(Request::Render(render_request)) => {
// Create a new ImageSurface for Cairo rendering.
let mut surface = ImageSurface::create(
cairo::Format::ARgb32,
(render_request.width * render_request.supersample_factor) as i32,
(render_request.height * render_request.supersample_factor) as i32,
)
.unwrap();
let cairo_context = CairoContext::new(&surface).unwrap();
// Don't use Cairo's default antialiasing, it makes the lines look too blurry
// Supersampling is our 2D anti-aliasing solution.
if render_request.supersample_factor > 1 {
cairo_context.set_antialias(cairo::Antialias::None);
}
let cairo_backend = CairoBackend::new(
&cairo_context,
// Supersample the rendering
(
render_request.width * render_request.supersample_factor,
render_request.height * render_request.supersample_factor,
),
)
.unwrap();
if let Err(err) = render_request.draw(cairo_backend) {
error!("Failed to plot chart: {err:?}")
}
match (
surface.to_texture(),
last_texture.lock().unwrap().deref_mut(),
) {
// Successfully generated a new texture, but the old texture is also there
(Some(texture), Some(last_texture)) => {
*last_texture = texture;
}
// If texture conversion failed, keep the old texture if it's present.
(None, None) => {
error!("Failed to convert cairo surface to gdk texture, not overwriting old one");
}
// Update the last texture, if The old texture wasn't ever generated (None),
// No matter the result of conversion
(result, last_texture) => {
*last_texture = result;
}
};
}
// Terminate the thread if a Terminate request is received.
Some(Request::Terminate) => break,
None => {}
}
}
})
.unwrap();
Self {
state,
thread_handle: Some(thread_handle),
}
}
/// Replace the current render request with a new one (effectively dropping possible pending frame)
/// Returns dropped request if any
pub fn replace_render_request(&self, request: RenderRequest) -> Option<RenderRequest> {
let mut current_request = self.state.current_request.lock().unwrap();
let result = current_request.replace(Request::Render(request));
self.state.request_condition_variable.notify_one(); // Notify the thread to start rendering.
match result? {
Request::Render(render) => Some(render),
Request::Terminate => None,
}
}
/// Return the last texture.
/// Requests that weren't processed in time or resulted in error are dropped.
pub fn get_last_texture(&self) -> Option<MemoryTexture> {
self.state.last_texture.lock().unwrap().deref().clone()
}
}
// Implement the default constructor for RenderThread using the `new` method.
impl Default for RenderThread {
fn default() -> Self {
Self::new()
}
}
impl RenderRequest {
pub fn relative_size(&self, ratio: f64) -> f64 {
min(self.height, self.width) as f64 * ratio
}
// Method to handle the actual drawing of the chart.
pub fn draw<'a, DB>(&self, backend: DB) -> anyhow::Result<()>
where
DB: DrawingBackend + 'a,
<DB as plotters::prelude::DrawingBackend>::ErrorType: 'static,
{
let root = backend.into_drawing_area(); // Create the drawing area.
let data = &self.data;
// Determine the start and end dates of the data series.
let start_date = data
.line_series_iter()
.filter_map(|(_, data)| Some(data.first()?.0))
.min()
.unwrap_or_default();
let end_date = data
.line_series_iter()
.map(|(_, value)| value)
.filter_map(|data| Some(data.first()?.0))
.max()
.unwrap_or_default();
// Calculate the maximum value for the y-axis.
let mut maximum_value = data
.line_series_iter()
.flat_map(|(_, data)| data.iter().map(|(_, value)| value))
.max_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal))
.cloned()
.unwrap_or_default();
// Ensure that the maximum value is at least 100 for better visualization.
if maximum_value < 100.0f64 {
maximum_value = 100.0f64;
}
root.fill(&WHITE)?; // Fill the background with white color.
// Set up the main chart with axes and labels.
let mut chart = ChartBuilder::on(&root)
.x_label_area_size(RelativeSize::Smaller(0.05))
.y_label_area_size(RelativeSize::Smaller(self.y_label_area_relative_size))
.right_y_label_area_size(RelativeSize::Smaller(
self.secondary_y_label_relative_area_size,
))
.margin(RelativeSize::Smaller(0.045))
.caption(
self.title.as_str(),
("sans-serif", RelativeSize::Smaller(0.08)),
)
.build_cartesian_2d(
start_date..max(end_date, start_date + 60 * 1000),
0f64..maximum_value,
)?
.set_secondary_coord(
start_date..max(end_date, start_date + 60 * 1000),
0.0..100.0,
);
// Configure the x-axis and y-axis mesh.
chart
.configure_mesh()
.x_label_formatter(&|date_time| {
let date_time = chrono::DateTime::from_timestamp_millis(*date_time).unwrap();
date_time.format("%H:%M:%S").to_string()
})
.y_label_formatter(&|x| format!("{x}{}", &self.value_suffix))
.x_labels(5)
.y_labels(10)
.label_style(("sans-serif", RelativeSize::Smaller(0.08)))
.draw()
.context("Failed to draw mesh")?;
// Configure the secondary axes (for the secondary y-axis).
chart
.configure_secondary_axes()
.y_label_formatter(&|x| format!("{x}{}", self.secondary_value_suffix.as_str()))
.y_labels(10)
.label_style(("sans-serif", RelativeSize::Smaller(0.08)))
.draw()
.context("Failed to draw mesh")?;
// Draw the throttling histogram as a series of bars.
chart
.draw_series(
data.throttling_iter()
.chunk_by(|(_, _, point)| *point)
.into_iter()
.filter_map(|(point, group_iter)| point.then_some(group_iter))
.filter_map(|mut group_iter| {
let first = group_iter.next()?;
Some((first, group_iter.last().unwrap_or(first)))
})
.map(|((start, name, _), (end, _, _))| ((start, end), name))
.map(|((start_time, end_time), _)| (start_time, end_time))
.sorted_by_key(|&(start_time, _)| start_time)
.coalesce(|(start1, end1), (start2, end2)| {
if end1 >= start2 {
Ok((start1, std::cmp::max(end1, end2)))
} else {
Err(((start1, end1), (start2, end2)))
}
})
.map(|(start_time, end_time)| {
Rectangle::new(
[(start_time, 0f64), (end_time, maximum_value)],
DEEPORANGE_100.filled(),
)
}),
)
.context("Failed to draw throttling histogram")?;
// Draw the main line series using cubic spline interpolation.
for (idx, (caption, data)) in (0..).zip(data.line_series_iter()) {
chart
.draw_series(LineSeries::new(
cubic_spline_interpolation(data.iter())
.into_iter()
.flat_map(|((first_time, second_time), segment)| {
// Interpolate in intervals of one millisecond.
(first_time..second_time).map(move |current_date| {
(current_date, segment.evaluate(current_date))
})
}),
Palette99::pick(idx).stroke_width(8),
))
.context("Failed to draw series")?
.label(caption)
.legend(move |(x, y)| {
let offset = self.relative_size(0.04) as i32;
Rectangle::new(
[(x - offset, y - offset), (x + offset, y + offset)],
Palette99::pick(idx).filled(),
)
});
}
// Draw the secondary line series on the secondary y-axis.
for (idx, (caption, data)) in (0..).zip(data.secondary_line_series_iter()) {
chart
.draw_secondary_series(LineSeries::new(
cubic_spline_interpolation(data.iter())
.into_iter()
.flat_map(|((first_time, second_time), segment)| {
(first_time..second_time).map(move |current_date| {
(current_date, segment.evaluate(current_date))
})
}),
Palette99::pick(idx + 10).stroke_width(8),
))
.context("Failed to draw series")?
.label(caption)
.legend(move |(x, y)| {
let offset = self.relative_size(0.04) as i32;
Rectangle::new(
[(x - offset, y - offset), (x + offset, y + offset)],
Palette99::pick(idx + 10).filled(),
)
});
}
// Configure and draw series labels (the legend).
chart
.configure_series_labels()
.margin(RelativeSize::Smaller(0.10))
.label_font(("sans-serif", RelativeSize::Smaller(0.08)))
.position(SeriesLabelPosition::LowerRight)
.legend_area_size(RelativeSize::Smaller(0.045))
.background_style(WHITE.mix(0.8))
.border_style(BLACK)
.draw()
.context("Failed to draw series labels")?;
root.present()?; // Present the final image.
Ok(())
}
}

View File

@ -0,0 +1,81 @@
use std::ffi::c_void;
use cairo::ImageSurface;
use gtk::gdk;
use gtk::gdk::MemoryTexture;
use gtk::glib;
use gtk::glib::ffi::g_bytes_new_with_free_func;
use gtk::glib::translate::FromGlibPtrFull;
pub(super) trait ToTextureExt {
fn to_texture(&mut self) -> Option<MemoryTexture>;
}
impl ToTextureExt for ImageSurface {
fn to_texture(&mut self) -> Option<MemoryTexture> {
// Ensure the surface is of type image
if self.type_() != cairo::SurfaceType::Image {
return None;
}
let width = self.width();
let height = self.height();
// Check if the surface has valid dimensions
if width <= 0 || height <= 0 {
return None;
}
let stride = self.stride();
let format = self.format();
// Use with_data to get mutable access to surface data
let mut bytes = None;
self.with_data(|data| {
// Reference the surface to be passed to the free function
let surface_ref = self.clone();
// Use g_bytes_new_with_free_func to manage memory
unsafe {
let ptr = g_bytes_new_with_free_func(
data.as_ptr() as *const c_void,
(height * stride) as usize,
Some(c_surface_destroy_notify),
Box::into_raw(Box::new(surface_ref)) as *mut c_void,
);
bytes = Some(glib::Bytes::from_glib_full(ptr));
};
})
.expect("Failed to get surface data");
// Create the GdkTexture
let texture = MemoryTexture::new(
width,
height,
cairo_format_to_memory_format(format),
&bytes.unwrap(),
stride as usize,
);
Some(texture)
}
}
// Function that will act as GDestroyNotify to free the cairo surface
extern "C" fn c_surface_destroy_notify(surface_ptr: *mut c_void) {
if !surface_ptr.is_null() {
// SAFETY: We know this is a valid ImageSurface as we passed it in Box::into_raw
let surface: Box<ImageSurface> = unsafe { Box::from_raw(surface_ptr as *mut ImageSurface) };
drop(surface); // Automatically handles the cleanup
}
}
// Convert cairo format to gdk::MemoryFormat
fn cairo_format_to_memory_format(format: cairo::Format) -> gdk::MemoryFormat {
match format {
cairo::Format::Rgb24 => gdk::MemoryFormat::R8g8b8,
cairo::Format::ARgb32 => gdk::MemoryFormat::R8g8b8a8,
_ => panic!("Unsupported cairo format"),
}
}

View File

@ -19,7 +19,7 @@ template $GraphsWindow: Window {
title: "Temperature";
hexpand: true;
value-suffix: "°C";
y-label-area-size: 80;
y-label-area-relative-size: 0.15;
layout {
column: 0;
@ -32,8 +32,8 @@ template $GraphsWindow: Window {
hexpand: true;
value-suffix: "RPM";
secondary-value-suffix: "%";
y-label-area-size: 140;
secondary-y-label-area-size: 80;
y-label-area-relative-size: 0.25;
secondary-y-label-area-relative-size: 0.15;
layout {
column: 0;
@ -45,7 +45,7 @@ template $GraphsWindow: Window {
title: "Clockspeed";
hexpand: true;
value-suffix: "MHz";
y-label-area-size: 140;
y-label-area-relative-size: 0.25;
layout {
column: 1;
@ -57,7 +57,7 @@ template $GraphsWindow: Window {
title: "Power usage";
hexpand: true;
value-suffix: "W";
y-label-area-size: 80;
y-label-area-relative-size: 0.2;
layout {
column: 1;