mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-04-30 01:20:00 -07:00
252 lines
7.7 KiB
Rust
252 lines
7.7 KiB
Rust
use crate::data::{DataPoint, DualRun, Result, Run};
|
|
use crate::format;
|
|
use plotters::prelude::*;
|
|
use std::path::Path;
|
|
|
|
const FONT: &str = "monospace";
|
|
const FONT_SIZE: i32 = 20;
|
|
const FONT_SIZE_BIG: i32 = 30;
|
|
const SIZE: (u32, u32) = (2000, 1000);
|
|
const TIME_BUFFER_MS: u64 = 10_000;
|
|
|
|
const BG_COLOR: RGBColor = RGBColor(18, 18, 24);
|
|
const TEXT_COLOR: RGBColor = RGBColor(230, 230, 240);
|
|
const COLORS: [RGBColor; 6] = [
|
|
RGBColor(255, 99, 132), // Pink/Red
|
|
RGBColor(54, 162, 235), // Blue
|
|
RGBColor(75, 192, 192), // Teal
|
|
RGBColor(255, 206, 86), // Yellow
|
|
RGBColor(153, 102, 255), // Purple
|
|
RGBColor(255, 159, 64), // Orange
|
|
];
|
|
|
|
pub enum YAxisFormat {
|
|
Bytes,
|
|
Number,
|
|
}
|
|
|
|
pub struct ChartConfig<'a> {
|
|
pub output_path: &'a Path,
|
|
pub title: String,
|
|
pub y_label: String,
|
|
pub y_format: YAxisFormat,
|
|
}
|
|
|
|
/// Generate a simple line chart from runs
|
|
pub fn generate(config: ChartConfig, runs: &[Run]) -> Result<()> {
|
|
if runs.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let max_time_ms = runs.iter().map(|r| r.max_timestamp()).max().unwrap_or(1000) + TIME_BUFFER_MS;
|
|
let max_time_s = max_time_ms as f64 / 1000.0;
|
|
let max_value = runs.iter().map(|r| r.max_value()).fold(0.0, f64::max);
|
|
|
|
let (time_scaled, time_divisor, time_label) = format::time(max_time_s);
|
|
let (value_scaled, scale_factor, y_label) = scale_y_axis(max_value, &config.y_label, &config.y_format);
|
|
let x_labels = label_count(time_scaled);
|
|
|
|
let root = SVGBackend::new(config.output_path, SIZE).into_drawing_area();
|
|
root.fill(&BG_COLOR)?;
|
|
|
|
let mut chart = ChartBuilder::on(&root)
|
|
.caption(&config.title, (FONT, FONT_SIZE_BIG).into_font().color(&TEXT_COLOR))
|
|
.margin(20)
|
|
.margin_right(40)
|
|
.x_label_area_size(50)
|
|
.margin_left(50)
|
|
.right_y_label_area_size(75)
|
|
.build_cartesian_2d(0.0..time_scaled * 1.025, 0.0..value_scaled * 1.1)?;
|
|
|
|
configure_mesh(&mut chart, time_label, &y_label, &config.y_format, x_labels)?;
|
|
|
|
for (idx, run) in runs.iter().enumerate() {
|
|
let color = COLORS[idx % COLORS.len()];
|
|
draw_series(&mut chart, &run.data, &run.id, color, time_divisor, scale_factor)?;
|
|
}
|
|
|
|
configure_legend(&mut chart)?;
|
|
root.present()?;
|
|
println!("Generated: {}", config.output_path.display());
|
|
Ok(())
|
|
}
|
|
|
|
/// Generate a chart with dual series per run (e.g., current + peak memory)
|
|
pub fn generate_dual(
|
|
config: ChartConfig,
|
|
runs: &[DualRun],
|
|
primary_suffix: &str,
|
|
secondary_suffix: &str,
|
|
) -> Result<()> {
|
|
if runs.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let max_time_ms = runs
|
|
.iter()
|
|
.flat_map(|r| r.primary.iter().chain(r.secondary.iter()))
|
|
.map(|d| d.timestamp_ms)
|
|
.max()
|
|
.unwrap_or(1000)
|
|
+ TIME_BUFFER_MS;
|
|
let max_time_s = max_time_ms as f64 / 1000.0;
|
|
let max_value = runs.iter().map(|r| r.max_value()).fold(0.0, f64::max);
|
|
|
|
let (time_scaled, time_divisor, time_label) = format::time(max_time_s);
|
|
let (value_scaled, scale_factor, y_label) = scale_y_axis(max_value, &config.y_label, &config.y_format);
|
|
let x_labels = label_count(time_scaled);
|
|
|
|
let root = SVGBackend::new(config.output_path, SIZE).into_drawing_area();
|
|
root.fill(&BG_COLOR)?;
|
|
|
|
let mut chart = ChartBuilder::on(&root)
|
|
.caption(&config.title, (FONT, FONT_SIZE_BIG).into_font().color(&TEXT_COLOR))
|
|
.margin(20)
|
|
.margin_right(40)
|
|
.x_label_area_size(50)
|
|
.margin_left(50)
|
|
.right_y_label_area_size(75)
|
|
.build_cartesian_2d(0.0..time_scaled * 1.025, 0.0..value_scaled * 1.1)?;
|
|
|
|
configure_mesh(&mut chart, time_label, &y_label, &config.y_format, x_labels)?;
|
|
|
|
for (idx, run) in runs.iter().enumerate() {
|
|
let color = COLORS[idx % COLORS.len()];
|
|
|
|
// Primary series (solid)
|
|
draw_series(
|
|
&mut chart,
|
|
&run.primary,
|
|
&format!("{} {}", run.id, primary_suffix),
|
|
color,
|
|
time_divisor,
|
|
scale_factor,
|
|
)?;
|
|
|
|
// Secondary series (dashed)
|
|
draw_dashed_series(
|
|
&mut chart,
|
|
&run.secondary,
|
|
&format!("{} {}", run.id, secondary_suffix),
|
|
color.mix(0.5),
|
|
time_divisor,
|
|
scale_factor,
|
|
)?;
|
|
}
|
|
|
|
configure_legend(&mut chart)?;
|
|
root.present()?;
|
|
println!("Generated: {}", config.output_path.display());
|
|
Ok(())
|
|
}
|
|
|
|
fn scale_y_axis(max_value: f64, base_label: &str, y_format: &YAxisFormat) -> (f64, f64, String) {
|
|
match y_format {
|
|
YAxisFormat::Bytes => {
|
|
let (scaled, unit) = format::bytes(max_value);
|
|
let factor = max_value / scaled;
|
|
(scaled, factor, format!("{} ({})", base_label, unit))
|
|
}
|
|
YAxisFormat::Number => (max_value, 1.0, base_label.to_string()),
|
|
}
|
|
}
|
|
|
|
/// Calculate appropriate label count to avoid duplicates when rounding to integers
|
|
fn label_count(max_value: f64) -> usize {
|
|
let max_int = max_value.ceil() as usize;
|
|
// Don't exceed the range, cap at 12 for readability
|
|
max_int.clamp(2, 12)
|
|
}
|
|
|
|
type Chart<'a, 'b> = ChartContext<
|
|
'a,
|
|
SVGBackend<'b>,
|
|
Cartesian2d<plotters::coord::types::RangedCoordf64, plotters::coord::types::RangedCoordf64>,
|
|
>;
|
|
|
|
fn configure_mesh(chart: &mut Chart, x_label: &str, y_label: &str, y_format: &YAxisFormat, x_labels: usize) -> Result<()> {
|
|
let y_formatter: Box<dyn Fn(&f64) -> String> = match y_format {
|
|
YAxisFormat::Bytes => Box::new(|y: &f64| {
|
|
if y.fract() == 0.0 {
|
|
format!("{:.0}", y)
|
|
} else {
|
|
format!("{:.1}", y)
|
|
}
|
|
}),
|
|
YAxisFormat::Number => Box::new(|y: &f64| format::axis_number(*y)),
|
|
};
|
|
|
|
chart
|
|
.configure_mesh()
|
|
.disable_mesh()
|
|
.x_desc(x_label)
|
|
.y_desc(y_label)
|
|
.x_label_formatter(&|x| format!("{:.0}", x))
|
|
.y_label_formatter(&y_formatter)
|
|
.x_labels(x_labels)
|
|
.y_labels(10)
|
|
.x_label_style((FONT, FONT_SIZE).into_font().color(&TEXT_COLOR.mix(0.7)))
|
|
.y_label_style((FONT, FONT_SIZE).into_font().color(&TEXT_COLOR.mix(0.7)))
|
|
.axis_style(TEXT_COLOR.mix(0.3))
|
|
.draw()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn draw_series(
|
|
chart: &mut Chart,
|
|
data: &[DataPoint],
|
|
label: &str,
|
|
color: RGBColor,
|
|
time_divisor: f64,
|
|
scale_factor: f64,
|
|
) -> Result<()> {
|
|
let points = data
|
|
.iter()
|
|
.map(|d| (d.timestamp_ms as f64 / 1000.0 / time_divisor, d.value / scale_factor));
|
|
|
|
chart
|
|
.draw_series(LineSeries::new(points, color.stroke_width(1)))?
|
|
.label(label)
|
|
.legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], color.stroke_width(1)));
|
|
Ok(())
|
|
}
|
|
|
|
fn draw_dashed_series(
|
|
chart: &mut Chart,
|
|
data: &[DataPoint],
|
|
label: &str,
|
|
color: RGBAColor,
|
|
time_divisor: f64,
|
|
scale_factor: f64,
|
|
) -> Result<()> {
|
|
let points: Vec<_> = data
|
|
.iter()
|
|
.map(|d| (d.timestamp_ms as f64 / 1000.0 / time_divisor, d.value / scale_factor))
|
|
.collect();
|
|
|
|
// Draw dashed line by skipping every other segment
|
|
chart
|
|
.draw_series(
|
|
points
|
|
.windows(2)
|
|
.enumerate()
|
|
.filter(|(i, _)| i % 2 == 0)
|
|
.map(|(_, w)| PathElement::new(vec![w[0], w[1]], color.stroke_width(2))),
|
|
)?
|
|
.label(label)
|
|
.legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 10, y), (x + 20, y)], color.stroke_width(2)));
|
|
Ok(())
|
|
}
|
|
|
|
fn configure_legend<'a>(chart: &mut Chart<'a, 'a>) -> Result<()> {
|
|
chart
|
|
.configure_series_labels()
|
|
.position(SeriesLabelPosition::UpperLeft)
|
|
.label_font((FONT, FONT_SIZE).into_font().color(&TEXT_COLOR.mix(0.9)))
|
|
.background_style(BG_COLOR.mix(0.98))
|
|
.border_style(BG_COLOR)
|
|
.margin(10)
|
|
.draw()?;
|
|
Ok(())
|
|
}
|