mirror of
https://github.com/ilya-zlobintsev/LACT.git
synced 2025-02-25 18:55:26 -06:00
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:
parent
d43ec9cb82
commit
dbb24c5bd2
180
Cargo.lock
generated
180
Cargo.lock
generated
@ -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"
|
||||
|
11
Cargo.toml
11
Cargo.toml
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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);
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
366
lact-gui/src/app/graphs_window/plot/render_thread.rs
Normal file
366
lact-gui/src/app/graphs_window/plot/render_thread.rs
Normal 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(())
|
||||
}
|
||||
}
|
81
lact-gui/src/app/graphs_window/plot/to_texture_ext.rs
Normal file
81
lact-gui/src/app/graphs_window/plot/to_texture_ext.rs
Normal 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"),
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user