Dashboard + Time Machine charts through the shared widget, now at the right

The dashboard's activity chart flips to the app-wide direction (oldest
left, now against the right gutter; caption relabelled) and both screens
drop their private renderers for ui.braillechart. The Time Machine's SNR
band is now zero-anchored instead of min-scaled: readings hang below the
grey zero line by their actual depth (or rise above it), with a third
braille row buying back the swing resolution, and the heading quotes the
zero-folded scale so the label and drawing agree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-07-11 15:01:47 -04:00
1 parent 8e931efe6f
commit 169ea8af11
4 files changed
+74 -190

No files matched your search

+13 -64
View File
@@ -6,10 +6,11 @@ stacks four reads of the mesh, coarsest first:
* **Activity** — a tall braille bar chart of *every* packet the hub hears (adverts,
telemetry, RX-logged packets, messages, acks), one dot column per minute — braille's
full horizontal resolution, two minutes per character — stretched across whatever
width the terminal offers, newest at the left with the count scale mirrored on both
edges. The header indicator's big sibling, drawn from the same
:meth:`~meshterm.services.monitor_service.MonitorService` buckets. A pulse line
beneath it reads the rate, who's been heard, and the busiest node of the window.
width the terminal offers, newest at the right (the app-wide timeline direction)
with the count scale mirrored on both edges. The header indicator's big sibling,
drawn from the same :meth:`~meshterm.services.monitor_service.MonitorService`
buckets. A pulse line beneath it reads the rate, who's been heard, and the busiest
node of the window.
* **Traffic** — the session's tallies by packet class, each with a proportional bar,
from the monitor's kind counters.
* **RF health** — the trailing window's reception quality: median SNR (on the trace
@@ -37,6 +38,7 @@ from rich.text import Text
from ..core.events import EventKind, MeshEvent
from ..core.models import NODE_TYPE_REPEATER, Observation, utcnow
from ..persistence.repository import ACTIVITY_WINDOW
from .braillechart import timeline_rows
from .theme import snr_style
from .trace_screen import snr_bar
from .tui.render import render_lines
@@ -60,11 +62,6 @@ _FEED_NAME_WIDTH = 18
#: How many braille rows tall the activity chart draws (each row is four dot rows).
_CHART_ROWS = 3
#: Braille dot masks for one cell-column filled bottom-up to height 0–4 (left column:
#: dots 7, 3, 2, 1 top-down; right column: dots 8, 6, 5, 4).
_COL_LEFT = (0x00, 0x40, 0x44, 0x46, 0x47)
_COL_RIGHT = (0x00, 0x80, 0xA0, 0xB0, 0xB8)
#: Display style per packet class, shared by the traffic panel and the feed.
_KIND_STYLES = {
"advert": "accent",
@@ -113,55 +110,6 @@ def _axis_labels(peak: int, rows: int) -> list[str]:
return labels
def braille_bars(values: list[int], *, rows: int = _CHART_ROWS) -> list[Text]:
"""Render ``values`` as a braille bar chart, one *dot column* per value.
Braille offers two dot columns per character cell — the font's full horizontal
resolution — so consecutive values pair up into one cell, each rising to its own
height. Bars are scaled so the window's peak fills all ``rows × 4`` dot rows, and
any non-zero value lights at least one dot, so a lone packet never vanishes. A
silent dot column keeps a faint one-dot floor on the bottom row — the flatline
convention of the app's sparklines — so the chart floor is always visible.
Args:
values: The bucket counts, drawn left to right (two per character cell; an
odd count is padded with one silent column).
rows: How many braille rows tall the chart is.
Returns:
``rows`` :class:`Text` lines, top row first, ``ceil(len(values) / 2)``
characters wide.
"""
peak = max(values, default=0)
total_dots = rows * 4
heights = [
(0 if peak == 0 or v <= 0 else max(1, round(v / peak * total_dots)))
for v in values
]
if len(heights) % 2:
heights.append(0)
lines: list[Text] = []
for row in range(rows):
floor = (rows - 1 - row) * 4 # dot rows below this braille row
line = Text()
for left, right in zip(heights[0::2], heights[1::2]):
lf = min(4, max(0, left - floor))
rf = min(4, max(0, right - floor))
if lf or rf:
mask = _COL_LEFT[lf] | _COL_RIGHT[rf]
if row == rows - 1:
# Keep the floor continuous under a half-silent cell: the silent
# dot column still shows its bottom baseline dot.
mask |= (0x40 if lf == 0 else 0) | (0x80 if rf == 0 else 0)
line.append(chr(0x2800 | mask), style="ok")
elif row == rows - 1:
line.append(chr(0x2800 | 0x40 | 0x80), style="faint") # the flatline
else:
line.append(chr(0x2800)) # blank braille keeps the grid monospace
lines.append(line)
return lines
class DashboardScreen(Screen):
"""The live mesh overview. Renders state and scrolls; the opener feeds it."""
@@ -274,12 +222,13 @@ class DashboardScreen(Screen):
# -- activity --
def _activity_section(self, width: int) -> list[RenderableType]:
"""The all-packet chart — newest minute at the left — plus the pulse line.
"""The all-packet chart — newest minute at the right — plus the pulse line.
One dot column per minute, two per character cell, stretched across every
cell the terminal offers between the two scale gutters; a wider terminal
simply shows more history. The scale is mirrored on both edges so the counts
are readable from either end of a wide chart.
simply shows more history. Time runs oldest→now left to right (every
MeshTerm timeline's direction), and the scale is mirrored on both edges so
the counts are readable from either end of a wide chart.
"""
histogram = list(self._activity()) # newest first, one count per minute
# Size the label lane from the whole histogram's peak (not just the visible
@@ -293,7 +242,7 @@ class DashboardScreen(Screen):
heading = Text("Activity", style="accent")
heading.append(" · every packet heard · one minute per dot column",
style="muted")
chart_rows = braille_bars(shown)
chart_rows = timeline_rows(list(reversed(shown)), rows=_CHART_ROWS)
marks = _axis_labels(peak, len(chart_rows))
out: list[RenderableType] = [heading]
for mark, row in zip(marks, chart_rows):
@@ -307,9 +256,9 @@ class DashboardScreen(Screen):
out.append(axis)
span = "−" + _span_label(minutes)
caption = Text(" " * (label_w + 2))
caption.append("now", style="faint")
caption.append(" " * max(1, chars - len("now") - len(span)))
caption.append(span, style="faint")
caption.append(" " * max(1, chars - len(span) - len("now")))
caption.append("now", style="faint")
out.append(caption)
out.append(Text())
out.append(self._pulse_line(shown, width))
+32 -65
View File
@@ -13,9 +13,11 @@ charts and stats over a switchable window (``w`` cycles 24 h → 7 d → 30 d
arrivals of the window (nodes heard for the first time ever), and the all-time
totals.
Unlike the dashboard's live chart (newest at the left, sliding), these are *histories*
and read chronologically: oldest at the left, now at the right. Everything is stored
data — no device is needed and nothing transmits.
Charts read chronologically — oldest at the left, now at the right, the app-wide
timeline direction — and draw through :mod:`~meshterm.ui.braillechart`, so the grey
baseline always marks zero: the SNR band's readings hang below it or rise above it
by their actual sign. Everything is stored data — no device is needed and nothing
transmits.
"""
from __future__ import annotations
@@ -28,7 +30,7 @@ from rich.console import Group, RenderableType
from rich.text import Text
from ..core.models import utcnow
from .dashboard_screen import _COL_LEFT, _COL_RIGHT, braille_bars
from .braillechart import chart_span, timeline_rows
from .theme import snr_style
from .tui.render import render_lines
from .tui.screen import Screen
@@ -51,9 +53,14 @@ MESH = ("mesh",)
#: Character cells the charts keep clear for their side gutters.
_GUTTER = 4
#: How many braille rows tall the charts draw.
#: How many braille rows tall the volume/rhythm charts draw.
_CHART_ROWS = 2
#: How many braille rows tall the SNR band draws — one more than the volume charts,
#: because zero-anchoring (readings hang below the grey zero line by their actual
#: depth) spends some dots on honesty and the extra row buys the swing back.
_SNR_ROWS = 3
def bucketize(stamps: list[datetime], start: datetime, end: datetime, buckets: int) -> list[int]:
"""Fold timestamps into ``buckets`` equal slices of ``[start, end]``, oldest first."""
@@ -77,58 +84,9 @@ def bucket_medians(
return [median(values) if values else None for values in grouped]
def band_rows(
values: list[Optional[float]], *, rows: int = _CHART_ROWS
) -> tuple[list[Text], float, float]:
"""Render sparse readings as a braille band scaled between their own extremes.
The volume charts scale zero-to-peak (:func:`braille_bars`); readings like SNR need
a *band* — the chart floor is the window's minimum, not zero, so a swing from
−12 dB to −4 dB still fills the height. Two values share each cell (braille's full
horizontal resolution), every present value lights at least one dot, empty columns
keep the faint one-dot floor, and each cell is coloured by its readings' quality
(the shared SNR palette).
Args:
values: Per-bucket readings, oldest first; ``None`` marks an empty bucket.
rows: How many braille rows tall the band is.
Returns:
``(lines, lo, hi)``: the rendered rows plus the scale's extremes (both 0.0
when no reading is present at all).
"""
present = [v for v in values if v is not None]
lo, hi = (min(present), max(present)) if present else (0.0, 0.0)
spread = max(1e-9, hi - lo)
total = rows * 4
heights = [
0 if v is None else max(1, 1 + round((v - lo) / spread * (total - 1)))
for v in values
]
padded = heights + [0] * (len(heights) % 2)
value_pairs = list(values) + [None] * (len(values) % 2)
lines: list[Text] = []
for row in range(rows):
floor = (rows - 1 - row) * 4
line = Text()
for i in range(0, len(padded), 2):
lf = min(4, max(0, padded[i] - floor))
rf = min(4, max(0, padded[i + 1] - floor))
cell = [v for v in value_pairs[i : i + 2] if v is not None]
if lf or rf:
mask = _COL_LEFT[lf] | _COL_RIGHT[rf]
if row == rows - 1:
mask |= (0x40 if lf == 0 else 0) | (0x80 if rf == 0 else 0)
line.append(
chr(0x2800 | mask),
style=snr_style(sum(cell) / len(cell)) if cell else "brand",
)
elif row == rows - 1:
line.append(chr(0x2800 | 0x40 | 0x80), style="faint")
else:
line.append(chr(0x2800))
lines.append(line)
return lines, lo, hi
def _snr_cell_style(values: list[float]) -> str:
"""Colour one SNR-band cell by its readings' quality (the shared SNR palette)."""
return snr_style(sum(values) / len(values))
def _chart_block(
@@ -252,11 +210,11 @@ def _node_sections(
out: list[RenderableType] = []
stamps = [o.observed_at for o in observations]
out.append(
_heading("Volume", f"{len(observations)} receptions · oldest left")
_heading("Volume", f"{len(observations)} receptions · now at the right")
)
out.extend(
_chart_block(
braille_bars(bucketize(stamps, start, now, buckets)),
timeline_rows(bucketize(stamps, start, now, buckets), rows=_CHART_ROWS),
_when_label(start), "now", chars,
)
)
@@ -265,10 +223,15 @@ def _node_sections(
(o.observed_at, float(o.snr)) for o in observations if o.snr is not None
]
if snr_pairs:
rows, lo, hi = band_rows(bucket_medians(snr_pairs, start, now, buckets))
medians = bucket_medians(snr_pairs, start, now, buckets)
lo, hi = chart_span(medians)
rows = timeline_rows(medians, rows=_SNR_ROWS, style=_snr_cell_style)
out.append(Text())
out.append(
_heading("SNR", f"median per slice · scale {lo:+.1f} → {hi:+.1f} dB")
_heading(
"SNR",
f"median per slice · grey line = 0 · scale {lo:+.1f} → {hi:+.1f} dB",
)
)
out.extend(_chart_block(rows, _when_label(start), "now", chars))
@@ -277,7 +240,7 @@ def _node_sections(
hours[stamp.astimezone().hour] += 1
out.append(Text())
out.append(_heading("Rhythm", "receptions by local hour of day"))
out.extend(_chart_block(braille_bars(hours), "0 h", "23 h", 12))
out.extend(_chart_block(timeline_rows(hours, rows=_CHART_ROWS), "0 h", "23 h", 12))
out.append(Text())
out.append(_heading("Record", "this window"))
@@ -335,17 +298,21 @@ def _mesh_sections(
out: list[RenderableType] = []
packets = [d[1] for d in shown]
out.append(
_heading("Packets per day", f"UTC days · peak {max(packets)} · oldest left")
_heading("Packets per day", f"UTC days · peak {max(packets)} · today at the right")
)
out.extend(
_chart_block(braille_bars(packets), shown[0][0], shown[-1][0], chars)
_chart_block(
timeline_rows(packets, rows=_CHART_ROWS), shown[0][0], shown[-1][0], chars
)
)
nodes = [d[2] for d in shown]
out.append(Text())
out.append(_heading("Nodes per day", f"distinct nodes heard · peak {max(nodes)}"))
out.extend(
_chart_block(braille_bars(nodes), shown[0][0], shown[-1][0], chars)
_chart_block(
timeline_rows(nodes, rows=_CHART_ROWS), shown[0][0], shown[-1][0], chars
)
)
arrivals = ctx.repo.first_seen(since=since)
+14 -51
View File
@@ -1,7 +1,8 @@
"""Dashboard tests: the braille bar chart, the live screen's state, and its data feeds.
"""Dashboard tests: the live screen's state, its chart plumbing, and its data feeds.
The screen is driven headless against fake sessions and canned observations, the same
approach as the live trace/TX screen tests.
approach as the live trace/TX screen tests. (The braille chart renderer itself is
covered in ``test_braillechart``.)
"""
from __future__ import annotations
@@ -14,7 +15,7 @@ from meshterm.core.events import MeshEvent
from meshterm.core.models import Ack, Message, Observation, utcnow
from meshterm.persistence.repository import Repository
from meshterm.services.monitor_service import ACTIVITY_BUCKETS
from meshterm.ui.dashboard_screen import DashboardScreen, braille_bars
from meshterm.ui.dashboard_screen import DashboardScreen
class _FakeSession:
@@ -52,51 +53,6 @@ def _stripped(lines: list[str]) -> list[str]:
return [re.sub(r"\x1b\[[0-9;]*m", "", line) for line in lines]
# --- braille_bars --------------------------------------------------------------------
def test_braille_bars_scales_the_peak_to_full_height() -> None:
"""The window's peak fills the chart; values pair up two dot columns per cell."""
rows = braille_bars([12, 12, 0, 0], rows=3)
assert len(rows) == 3
assert all(len(r.plain) == 2 for r in rows)
# The peak pair fills both dot columns of its cell on the top row; the silent
# pair is blank there and keeps the faint two-dot floor on the bottom row.
assert rows[0].plain[0] == chr(0x2800 | 0x47 | 0xB8)
assert rows[0].plain[1] == chr(0x2800)
assert rows[2].plain[1] == chr(0x2800 | 0x40 | 0x80)
def test_braille_bars_gives_each_dot_column_its_own_height() -> None:
"""Two values sharing a cell rise independently — braille's full resolution."""
rows = braille_bars([12, 6], rows=3)
assert rows[0].plain[0] == chr(0x2800 | 0x47) # left full, right below
assert rows[1].plain[0] == chr(0x2800 | 0x47 | 0xA0) # right's head: 2 dots
assert rows[2].plain[0] == chr(0x2800 | 0x47 | 0xB8) # both full at the base
def test_braille_bars_never_hides_a_lone_packet() -> None:
"""A tiny non-zero value still lights (in the lit style, not the faint floor's)."""
rows = braille_bars([1, 0, 1000, 1000], rows=3)
first_span = rows[2].spans[0]
assert first_span.start == 0 and first_span.style == "ok"
def test_braille_bars_all_silent_is_a_flatline() -> None:
"""With nothing heard the chart is a floor line, not a divide-by-zero."""
rows = braille_bars([0, 0, 0, 0, 0, 0], rows=2)
assert rows[1].plain == chr(0x2800 | 0x40 | 0x80) * 3
def test_braille_bars_pads_an_odd_tail_column() -> None:
"""An odd value count still renders whole cells (the tail column stays silent)."""
rows = braille_bars([4], rows=1)
assert len(rows[0].plain) == 1
# The lone value fills its left dot column; the padded right column keeps the
# floor dot so the baseline stays continuous.
assert rows[0].plain[0] == chr(0x2800 | 0x47 | 0x80)
# --- the screen ----------------------------------------------------------------------
@@ -115,15 +71,22 @@ def test_dashboard_renders_all_four_sections() -> None:
assert "advert" in body and "Alice" in body and "YUL" in body
def test_dashboard_activity_chart_reads_newest_left_with_mirrored_scale() -> None:
"""'now' anchors the left edge and the peak count marks both gutters."""
def test_dashboard_activity_chart_reads_newest_right_with_mirrored_scale() -> None:
"""'now' anchors the right edge and the peak count marks both gutters."""
screen = _screen(histogram=[9] + [0] * (ACTIVITY_BUCKETS - 1))
lines = _stripped(screen.render_body(80))
top = next(line for line in lines if "┤" in line)
assert top.strip().startswith("9 ┤")
assert top.rstrip().endswith("├ 9")
caption = next(line for line in lines if "now" in line)
assert caption.index("now") < caption.index("−") # newest left, oldest right
assert caption.index("−") < caption.index("now") # oldest left, newest right
# The lone newest-minute burst draws against the chart's right gutter, and the
# left half of the chart is bare flatline.
chart = [line for line in lines if "┤" in line or "│" in line]
bottom = chart[-1]
left_half = bottom[3 : 3 + (len(bottom) - 6) // 2]
assert all(ch in (chr(0x2800), chr(0x2800 | 0x40 | 0x80), " ") for ch in left_half)
assert any(0x2800 <= ord(ch) <= 0x28FF and ord(ch) & 0x3F for ch in chart[0])
def test_dashboard_activity_chart_fills_the_width() -> None:
+15 -10
View File
@@ -17,7 +17,7 @@ from meshterm.ui.timemachine_screen import (
TimeMachineScreen,
_mesh_sections,
_node_sections,
band_rows,
_snr_cell_style,
bucket_medians,
bucketize,
)
@@ -119,16 +119,21 @@ def test_bucketize_and_medians_split_the_span() -> None:
assert medians == [5.0, -4.0]
def test_band_rows_scales_between_extremes() -> None:
"""The band floor is the minimum reading, not zero, and gaps stay faint."""
rows, lo, hi = band_rows([-12.0, None, -4.0, -8.0], rows=2)
assert (lo, hi) == (-12.0, -4.0)
def test_snr_band_hangs_negative_readings_below_zero() -> None:
"""All-negative SNR medians hang from a grey zero ceiling, coloured by quality."""
from meshterm.ui.braillechart import chart_span, timeline_rows
medians = [-12.0, None, -4.0, -8.0]
assert chart_span(medians) == (-12.0, 0.0) # zero is always on the scale
rows = timeline_rows(medians, rows=2, style=_snr_cell_style)
assert len(rows) == 2 and len(rows[0].plain) == 2
bottom = rows[1].plain
# The minimum lights exactly one dot; the maximum fills its column.
assert bottom[0] != chr(0x2800)
top = rows[0].plain
assert top[1] != chr(0x2800) # -4.0 (the max) reaches the top row
# The worst reading (−12) hangs the full height; −8 dips its tail into the
# bottom row (right column only) while the shallower −4 stays out of it.
assert rows[1].plain[0] != chr(0x2800) # the −12 column reaches the bottom row
assert rows[1].plain[1] == chr(0x2808) # −8's tail, no −4 (left-column) dots
# Cells take the shared SNR palette, driven by their own readings.
assert rows[0].spans[0].style == _snr_cell_style([-12.0])
assert rows[0].spans[1].style == _snr_cell_style([-4.0, -8.0])
# --- the pages --------------------------------------------------------------------------