//! Thin-line plot, re-implemented for ratatui (nvtop plot.c pattern studied
//! in references/nvtop — GPL, code never copied). A `Ring` is a fixed-
//! capacity sample buffer; the rightmost drawn column is the newest.
//! Style: MSI Afterburner frametime-monitor look — one glyph per column,
//! RIGHT-ANGLE corners (level `─`, corners `┐└` on falls / `┘┌` on rises,
//! `│` between) so peaks/valleys read as sharp steps. Y-axis: max top-left,
//! 0 bottom-left. No time axis (all stacked plots share one window).
use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
text::{Line, Span},
};
/// Ring of samples, oldest first. Capacity is sized for the configured
/// graph window at the current poll period (min 512); plots clip to the
/// visible width anyway.
pub struct Ring {
data: Vec<f64>,
start: usize,
len: usize,
}
impl Ring {
pub fn new(capacity: usize) -> Ring {
Ring {
data: vec![0.0; capacity],
start: 0,
len: 0,
}
}
pub fn push(&mut self, v: f64) {
let cap = self.data.len();
let end = (self.start + self.len) % cap;
self.data[end] = v;
if self.len < cap {
self.len += 1;
} else {
self.start = (self.start + 1) % cap;
}
}
pub fn samples_window(&self, window_ticks: usize) -> impl Iterator<Item = f64> + '_ {
let skip = self.len.saturating_sub(window_ticks);
(skip..self.len).map(move |i| self.data[(self.start + i) % self.data.len()])
}
pub fn max_window(&self, window_ticks: usize) -> f64 {
self.samples_window(window_ticks).fold(f64::MIN, f64::max)
}
}
/// Plain rounding map (used by tests; hysteresis variant is used in render).
#[allow(dead_code)]
fn levels(samples: &[f64], max: f64, rows: usize) -> Vec<usize> {
// Row 0 = top of the plot = max value.
samples
.iter()
.map(|&v| (((1.0 - v / max).clamp(0.0, 1.0)) * (rows - 1) as f64).round() as usize)
.collect()
}
/// Hysteresis: a value hovering on a row boundary makes a plain rounding
/// map flap between two rows (`─│─│─│` comb). Only move off the previous
/// row when the continuous level is nearer to the new row by a decisive
/// margin, so noise rides out and real steps still track.
fn levels_hysteresis(samples: &[f64], max: f64, rows: usize) -> Vec<usize> {
let cont = |v: f64| (1.0 - v / max).clamp(0.0, 1.0) * (rows - 1) as f64;
let mut out: Vec<usize> = Vec::with_capacity(samples.len());
let mut last: Option<f64> = None; // continuous position of drawn row
for &v in samples {
let target = cont(v);
let row = match last {
None => target.round(),
Some(prev) => {
// Distance from target to the drawn row's continuous pos.
if (target - prev).abs() > 0.75 {
target.round()
} else {
prev.round()
}
}
};
out.push(row as usize);
last = Some(row as f64);
}
out
}
/// Thin-line draw: y-axis labels overlay the left edge (max top-left,
/// 0 bottom-left), the staircase occupies the full area. No time axis —
/// stacked plots share one window, so x is time on every plot.
pub fn render(
f: &mut Frame,
area: Rect,
ring: &Ring,
window_ticks: usize,
max: f64,
color: Color,
marker_row: Option<u16>,
) {
if area.width < 8 || area.height < 3 || max <= 0.0 {
return;
}
let rows = area.height as usize;
let cols = area.width as usize;
// nvtop's ring holds exactly one sample per plot column — no draw-time
// resampling, so nothing aliases. When our window holds more ticks than
// columns, decimate by MAX per column (peak-preserving; mean/stride
// made adjacent columns alternate rows → dash artifacts). Buckets run
// oldest→newest so the newest sample lands in the rightmost column.
let samples: Vec<f64> = ring.samples_window(window_ticks).collect();
if samples.is_empty() {
return;
}
let shown: Vec<f64> = if samples.len() > cols {
// EMA-smooth first (alpha 0.35): idle wattage jitters ±0.3 W per
// tick and max-decimation amplifies a single jumpy sample into a
// 1-row flip that defeats row hysteresis. Subagent-diagnosed.
let a = 0.35;
let mut ema = samples[0];
let smoothed: Vec<f64> = samples
.iter()
.map(|&v| {
ema = a * v + (1.0 - a) * ema;
ema
})
.collect();
let bucket = smoothed.len() / cols;
let rem = smoothed.len() % cols;
let mut out = Vec::with_capacity(cols);
let mut idx = 0;
for c in 0..cols {
let extra = if c < rem { 1 } else { 0 };
let end = idx + bucket + extra;
out.push(smoothed[idx..end].iter().cloned().fold(f64::MIN, f64::max));
idx = end;
}
out
} else {
samples
};
let skip = shown.len().saturating_sub(cols);
let lvls = levels_hysteresis(&shown[skip..], max, rows);
let mut buf = vec![vec![(' ', Style::default()); cols]; rows];
let mut set = |r: usize, c: usize, ch: char, st: Style| {
if r < rows && c < cols && buf[r][c].0 == ' ' {
buf[r][c] = (ch, st);
}
};
let st = Style::default().fg(color);
// Right-angle staircase (Afterburner frametime style): on a step the
// corner glyphs *join* the horizontals — old level gets a corner open
// toward the incoming line, new level a corner open toward the outgoing
// line, '│' strictly between. Falls: '┐' old / '└' land. Rises: '┘'
// old / '┌' land. Rounded ╮╰╯╭ are banned — spikes must read sharp.
for (c, &l) in lvls.iter().enumerate() {
if c == 0 {
set(l, c, '─', st);
continue;
}
let prev = lvls[c - 1];
if prev == l {
set(l, c, '─', st);
} else if prev < l {
// value fell: line steps DOWN screen. Old level: '┐' (opens
// left toward incoming). New level: '└' (opens right toward
// outgoing). '│' between.
set(prev, c, '┐', st);
set(l, c, '└', st);
for r in prev + 1..l {
set(r, c, '│', st);
}
} else {
// value rose: line steps UP screen. Old level: '┘', new: '┌'.
set(prev, c, '┘', st);
set(l, c, '┌', st);
for r in l + 1..prev {
set(r, c, '│', st);
}
}
}
if let Some(mr) = marker_row {
let mr = (mr as usize).min(rows - 1);
for cell in buf[mr].iter_mut().take(cols) {
if cell.0 == ' ' {
*cell = ('┄', Style::default().fg(Color::Red));
}
}
}
// ---- axis labels (overlay, drawn last so they win blanks) ----
let axis_st = Style::default().fg(Color::DarkGray);
// y-axis: max top-left, 0 bottom-left (Afterburner pane style).
let y_labels = [
(0usize, format!("{:.0}", max)),
(rows - 1, "0".to_string()),
];
for (r, label) in y_labels {
if r < rows {
for (i, ch) in label.chars().take(4).enumerate() {
if buf[r][i].0 == ' ' {
buf[r][i] = (ch, axis_st);
}
}
}
}
let lines: Vec<Line> = buf
.into_iter()
.map(|row| {
Line::from(
row.into_iter()
.map(|(ch, sty)| Span::styled(ch.to_string(), sty))
.collect::<Vec<_>>(),
)
})
.collect();
f.render_widget(ratatui::text::Text::from(lines), area);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn levels_map_value_to_rows() {
assert_eq!(levels(&[10.0, 0.0], 10.0, 5), [0, 4]);
assert_eq!(levels(&[5.0], 10.0, 5), [2]);
}
#[test]
fn hysteresis_kills_boundary_flapping() {
// Values hovering right at a row boundary: plain rounding would
// alternate; hysteresis must hold the row until a decisive move.
let max = 100.0;
let rows = 10;
// 49.9..50.1 straddles rows-1/2 boundary (cont ~4.5).
let vals = [50.4, 49.6, 50.4, 49.6, 50.4, 49.6];
let lv = levels_hysteresis(&vals, max, rows);
let all_same = lv.iter().all(|&r| r == lv[0]);
assert!(all_same, "flapped: {:?}", lv);
// A real step still tracks: push decisively past the margin.
let vals2 = [50.0, 50.0, 70.0, 70.0];
let lv2 = levels_hysteresis(&vals2, max, rows);
assert!(lv2[3] < lv2[1], "did not step up on real change: {:?}", lv2);
}
#[test]
fn ring_window_and_wrap() {
let mut r = Ring::new(4);
for v in 1..=6 {
r.push(v as f64);
}
// wrapped: holds 3..6 (capacity 4)
let got: Vec<f64> = r.samples_window(4).collect();
assert_eq!(got, vec![3.0, 4.0, 5.0, 6.0]);
let got2: Vec<f64> = r.samples_window(2).collect();
assert_eq!(got2, vec![5.0, 6.0]);
assert_eq!(r.max_window(2), 6.0);
}
}