//! 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). //! Scrolling view: the last `cols` ticks fill the width 1:1 — no //! decimation, no smoothing, so spikes read raw; older history stays in the //! ring for the peak counter and scale. 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, start: usize, len: usize, /// All-time (session) maximum pushed. peak: f64, } impl Ring { pub fn new(capacity: usize) -> Ring { Ring { data: vec![0.0; capacity], start: 0, len: 0, peak: f64::MIN, } } pub fn push(&mut self, v: f64) { let cap = self.data.len(); let end = (self.start + self.len) % cap; self.data[end] = v; if v > self.peak { self.peak = v; } if self.len < cap { self.len += 1; } else { self.start = (self.start + 1) % cap; } } /// Session all-time max (f64::MIN before the first push). pub fn peak(&self) -> f64 { self.peak } pub fn samples_window(&self, window_ticks: usize) -> impl Iterator + '_ { 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) } pub fn min_window(&self, window_ticks: usize) -> f64 { self.samples_window(window_ticks).fold(f64::MAX, f64::min) } } /// Plain rounding map (used by tests; hysteresis variant is used in render). #[allow(dead_code)] fn levels(samples: &[f64], min: f64, max: f64, rows: usize) -> Vec { // Row 0 = top of the plot = max value. let span = max - min; samples .iter() .map(|&v| (((1.0 - (v - min) / span).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], min: f64, max: f64, rows: usize) -> Vec { let span = max - min; let cont = |v: f64| (1.0 - (v - min) / span).clamp(0.0, 1.0) * (rows - 1) as f64; let mut out: Vec = Vec::with_capacity(samples.len()); let mut last: Option = 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); } out } /// Thin-line draw: y-axis labels overlay the left edge (max top-left, /// min bottom-left), the staircase occupies the full area. No time axis — /// stacked plots share one window, so x is time on every plot. Scrolling /// view: the last `cols` ticks fill the width 1:1 (newest at the right /// edge), older history scrolls off the left — no decimation or smoothing. /// Returns the number of columns actually used by the trace (0 = nothing /// drawn). The caller renders y-axis labels OUTSIDE this area to the left. #[allow(clippy::too_many_arguments)] pub fn render( f: &mut Frame, area: Rect, ring: &Ring, window_ticks: usize, min: f64, max: f64, color: Color, marker_row: Option, ) -> u16 { if area.width < 4 || area.height < 2 || max <= min { return 0; } let rows = area.height as usize; let cols = area.width as usize; // Scrolling view: one sample per column, newest at the right edge. // The window is the visible width, not the whole graph_secs history — // the ring keeps the full window for the peak counter and scale. let samples: Vec = ring.samples_window(window_ticks).collect(); if samples.is_empty() { return 0; } let skip = samples.len().saturating_sub(cols); let lvls = levels_hysteresis(&samples[skip..], min, max, rows); let used = (cols - skip.min(cols)) as u16; // columns the trace occupies 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)); } } } let lines: Vec = buf .into_iter() .map(|row| { Line::from( row.into_iter() .map(|(ch, sty)| Span::styled(ch.to_string(), sty)) .collect::>(), ) }) .collect(); f.render_widget(ratatui::text::Text::from(lines), area); used } /// Compact axis label: integers stay short ("200", "0"), fractional values /// keep up to 3 decimals, trimmed ("0.5", "1.25"). pub fn fmt_axis(v: f64) -> String { if v.fract() == 0.0 { return format!("{v:.0}"); } let s = format!("{v:.3}"); s.trim_end_matches('0').trim_end_matches('.').to_string() } #[cfg(test)] mod tests { use super::*; #[test] fn levels_map_value_to_rows() { assert_eq!(levels(&[10.0, 0.0], 0.0, 10.0, 5), [0, 4]); assert_eq!(levels(&[5.0], 0.0, 10.0, 5), [2]); // Non-zero floor: min sits at the bottom row, span maps over min..max. assert_eq!(levels(&[10.0, 5.0, 20.0], 5.0, 20.0, 5), [3, 4, 0]); } #[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 min = 0.0; 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, min, 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, min, 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 = r.samples_window(4).collect(); assert_eq!(got, vec![3.0, 4.0, 5.0, 6.0]); let got2: Vec = r.samples_window(2).collect(); assert_eq!(got2, vec![5.0, 6.0]); assert_eq!(r.max_window(2), 6.0); assert_eq!(r.min_window(2), 5.0); assert_eq!(r.min_window(4), 3.0); } }