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:
1 parent
8e931efe6f
commit
169ea8af11
4 files changed
+74
-190
No files matched your search
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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 --------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user