josie / alder-tools

Plot: row-mapping hysteresis (decisive-move threshold 0.75 row) — boundary flapping was the dash comb; test included

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

parents: 8c2345d

diff --git a/src/plot.rs b/src/plot.rs
index 1ce0595..cd85f36 100644
--- a/src/plot.rs
+++ b/src/plot.rs
@@ -59,6 +59,33 @@ fn levels(samples: &[f64], max: f64, rows: usize) -> Vec<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
+}
+
 /// 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).
@@ -103,7 +130,7 @@ pub fn render(
         samples
     };
     let skip = shown.len().saturating_sub(cols);
-    let lvls = levels(&shown[skip..], max, rows);
+    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| {
@@ -189,6 +216,23 @@ mod tests {
         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);