josie / alder-tools

Plot: stride sampling (whole window visible, staircase preserved), y/x axis labels nvtop-style; fix 2/3 height math collisions

2ce9e553166493cf55b45c7c3402057bc2f0b73b
josie <administrator@josie-c.com> · 2026-08-30T13:38 · browse files at this commit

parents: a0b993f

diff --git a/src/plot.rs b/src/plot.rs
index b750cb4..01b7f67 100644
--- a/src/plot.rs
+++ b/src/plot.rs
@@ -1,8 +1,9 @@
 //! nvtop-style line plot, re-implemented for ratatui (pattern studied in
 //! references/nvtop/src/plot.c — GPL, code never copied). A `Ring` is a
 //! fixed-capacity sample buffer; the rightmost drawn column is the newest.
-//! Cell glyphs: level `─`, rises/falls get corner glyphs, multi-row jumps
-//! get `│` between them.
+//! Style: continuous staircase (level `─`, vertical `│` joins — no corner
+//! glyphs; they masked peaks), y-axis labels on the left, time axis on the
+//! bottom (oldest left → 0s right), like nvtop's plot panes.
 
 use ratatui::{
     Frame,
@@ -58,25 +59,35 @@ fn levels(samples: &[f64], max: f64, rows: usize) -> Vec<usize> {
         .collect()
 }
 
-pub fn render(f: &mut Frame, area: Rect, ring: &Ring, window_ticks: usize, max: f64, color: Color, marker_row: Option<u16>) {
-    if area.width == 0 || area.height == 0 || max <= 0.0 {
+/// nvtop draw: y-axis labels overlay the left edge (e.g. 100/75/50/25/0),
+/// the staircase occupies the full area, time labels sit on the bottom row
+/// (rightmost = "0s", leftward = elapsed seconds of the window).
+pub fn render(
+    f: &mut Frame,
+    area: Rect,
+    ring: &Ring,
+    window_ticks: usize,
+    poll_ms: u64,
+    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;
+
+    // One sample per column, newest at the right; when the window holds
+    // more samples than columns, stride-skip (take every Nth) so the whole
+    // window is visible. Striding preserves steps better than averaging.
     let samples: Vec<f64> = ring.samples_window(window_ticks).collect();
     if samples.is_empty() {
         return;
     }
-    // Bucket the window across the plot width (mean per column): the whole
-    // graph_secs window maps to `cols` columns, and per-tick jitter within
-    // a bucket averages out instead of drawing corner squiggles.
-    let per_col = samples.len() / cols;
-    let shown: Vec<f64> = if per_col >= 2 {
-        samples
-            .chunks(samples.len().div_ceil(cols))
-            .map(|b| b.iter().sum::<f64>() / b.len() as f64)
-            .collect()
+    let stride = samples.len().div_ceil(cols);
+    let shown: Vec<f64> = if stride > 1 {
+        samples.iter().rev().step_by(stride).rev().cloned().collect()
     } else {
         samples
     };
@@ -92,16 +103,13 @@ pub fn render(f: &mut Frame, area: Rect, ring: &Ring, window_ticks: usize, max:
 
     let st = Style::default().fg(color);
     for (c, &l) in lvls.iter().enumerate() {
-        let prev_l = if c == 0 { l } else { lvls[c - 1] };
-        if prev_l == l {
-            set(l, c, '─', st);
-        } else {
-            // Flat-step style (no rounded corners — they masked peaks):
-            // both stepped rows get level dashes, verticals between.
-            let (top, bot) = if prev_l < l { (prev_l, l) } else { (l, prev_l) };
-            set(top, c, '─', st);
-            set(bot, c, '─', st);
-            for r in top + 1..bot {
+        set(l, c, '─', st);
+        if c > 0 {
+            // Vertical join between the previous level and this one so a
+            // step is continuous (staircase, nvtop-style).
+            let prev = lvls[c - 1];
+            let (top, bot) = if prev < l { (prev, l) } else { (l, prev) };
+            for r in top..=bot.min(top + rows) {
                 set(r, c, '│', st);
             }
         }
@@ -116,6 +124,38 @@ pub fn render(f: &mut Frame, area: Rect, ring: &Ring, window_ticks: usize, max:
         }
     }
 
+    // ---- axis labels (overlay, drawn last so they win blanks) ----
+    let axis_st = Style::default().fg(Color::DarkGray);
+    // y-axis: max and half-max at top/left and mid-left.
+    let y_labels = [
+        (0usize, format!("{:.0}", max)),
+        (rows / 2, format!("{:.0}", max / 2.0)),
+    ];
+    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);
+                }
+            }
+        }
+    }
+    // x-axis on the bottom row: window length in seconds at the left,
+    // "0s" at the right (nvtop: seconds count down to now).
+    let total_s = (window_ticks as u64 * poll_ms / 1000).max(1);
+    let t_label = format!("{}s", total_s);
+    for (i, ch) in t_label.chars().enumerate() {
+        if buf[rows - 1][i].0 == ' ' {
+            buf[rows - 1][i] = (ch, axis_st);
+        }
+    }
+    for (i, ch) in "0s".chars().enumerate() {
+        let c = cols - 2 + i;
+        if c < cols && buf[rows - 1][c].0 == ' ' {
+            buf[rows - 1][c] = (ch, axis_st);
+        }
+    }
+
     let lines: Vec<Line> = buf
         .into_iter()
         .map(|row| {
@@ -151,4 +191,4 @@ mod tests {
         assert_eq!(got2, vec![5.0, 6.0]);
         assert_eq!(r.max_window(2), 6.0);
     }
-}
+}
\ No newline at end of file
diff --git a/src/ui.rs b/src/ui.rs
index 88e8d52..71e68df 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -500,7 +500,7 @@ pub fn draw(f: &mut Frame, app: &App) {
                 Span::styled(text, Style::default().add_modifier(Modifier::BOLD)),
             ]);
             f.render_widget(ratatui::text::Text::from(caption), Rect { height: 1, ..rect });
-            plot::render(f, Rect { y: rect.y + 1, height: graph_rows, ..rect }, ring, ticks, max, color, marker);
+            plot::render(f, Rect { y: rect.y + 1, height: graph_rows, ..rect }, ring, ticks, app.cfg.poll_ms, max, color, marker);
             x += thirds;
         }
     } else {