Chart notches break the baseline cleanly to the axis
The day-boundary notch left its bottom dot lit: the zero baseline is drawn per braille cell (both dot columns), so it filled in under the notch's blank column, and the notch connected to the axis instead of extending down to it. Worse, when the neighbouring bar was tall the notch's bottom dot took the bar's colour, not even the faint grey. Introduce a GAP sentinel in braillechart: a timeline column that renders fully blank — no bar and no baseline — the one deliberate break in the "grey is zero" flatline. _assemble now decides the baseline per dot column rather than per cell, so a GAP column goes blank clean to the axis while its cell-mate keeps its own zero line. The Time Machine's day charts mark their notches with GAP instead of None. A genuine zero-value day still draws its faint grey baseline as before — only the notch column breaks it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
4e8325af08
commit
fa340bec03
4 files changed
+115
-27
No files matched your search
+45
-11
@@ -12,7 +12,10 @@ histories — renders through this module, so they all share the same three rule
|
||||
bars grow *from that line*, so a silent stretch reads as a flatline, never a
|
||||
hole. When a series carries negative values (an SNR history, say) the baseline
|
||||
sits at zero's height inside the chart — positive readings rise above it,
|
||||
negative ones hang below — rather than being nailed to the chart floor.
|
||||
negative ones hang below — rather than being nailed to the chart floor. The one
|
||||
deliberate break in the line is :data:`GAP`, a column that renders *fully blank*
|
||||
down to the axis, used to notch adjacent bars apart (the Time Machine's per-day
|
||||
charts space one day from the next this way).
|
||||
* **Two readings per cell.** Braille offers two dot columns per character cell;
|
||||
every chart uses both, so each cell shows two consecutive readings and the
|
||||
chart draws at twice the horizontal resolution of the cells it occupies.
|
||||
@@ -56,6 +59,23 @@ _RIGHT_BITS = (0x80, 0x20, 0x10, 0x08)
|
||||
CellStyle = Union[str, Callable[[list[float]], str]]
|
||||
|
||||
|
||||
class _Gap:
|
||||
"""The type of :data:`GAP`; a private singleton, so ``value is GAP`` identifies it."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - a debugging aid only
|
||||
return "GAP"
|
||||
|
||||
|
||||
#: A timeline value marking a hard gap: the column renders *fully blank* — no bar and
|
||||
#: no zero baseline — so it breaks the "grey is zero" flatline on purpose. ``None`` and
|
||||
#: ``0`` still draw the faint zero line (a silent reading is still a reading); only
|
||||
#: ``GAP`` punches a hole in it, used to notch adjacent day bars apart in the Time
|
||||
#: Machine's per-day charts so same-height neighbours never fuse into one solid block.
|
||||
GAP = _Gap()
|
||||
|
||||
|
||||
def chart_span(
|
||||
values: Sequence[Optional[float]], span: Optional[tuple[float, float]] = None
|
||||
) -> tuple[float, float]:
|
||||
@@ -68,13 +88,14 @@ def chart_span(
|
||||
label and the drawing can never disagree.
|
||||
|
||||
Args:
|
||||
values: The chart's readings (``None`` marks an empty slot).
|
||||
values: The chart's readings (``None`` marks an empty slot; :data:`GAP` a
|
||||
hard gap — neither carries a magnitude, so both sit out the span).
|
||||
span: An optional wider range to honour (it too is folded around zero).
|
||||
|
||||
Returns:
|
||||
``(lo, hi)`` with ``lo <= 0 <= hi``; ``(0.0, 0.0)`` for an empty series.
|
||||
"""
|
||||
present = [v for v in values if v is not None]
|
||||
present = [v for v in values if v is not None and v is not GAP]
|
||||
if span is not None:
|
||||
present = [*present, *span]
|
||||
lo = min([0.0, *present])
|
||||
@@ -97,11 +118,13 @@ def timeline_rows(
|
||||
cell), scaled onto :func:`chart_span`'s zero-folded range: the series' peak
|
||||
fills the space above the baseline, its floor the space below, and any
|
||||
non-zero reading lights at least one dot so a lone packet never vanishes.
|
||||
``None`` (no reading) and ``0`` alike draw only the faint zero baseline.
|
||||
``None`` (no reading) and ``0`` alike draw only the faint zero baseline;
|
||||
:data:`GAP` draws nothing at all, breaking the baseline into a clean notch.
|
||||
|
||||
Args:
|
||||
values: Per-slot readings, oldest first (the rightmost is "now"). An odd
|
||||
count is padded with one silent column so whole cells always render.
|
||||
A :data:`GAP` slot renders fully blank (bar and baseline both).
|
||||
rows: How many braille rows tall the chart is (four dot rows each).
|
||||
span: A wider range to scale against (see :func:`chart_span`), so several
|
||||
charts — or a chart and its caption — can share one scale.
|
||||
@@ -125,7 +148,9 @@ def timeline_rows(
|
||||
|
||||
bars: list[Optional[tuple[int, int]]] = []
|
||||
for value in values:
|
||||
if value is None or value == 0:
|
||||
# GAP must short-circuit ahead of the numeric tests — it has no magnitude,
|
||||
# so ``value == 0`` / ``value > 0`` would misfire (or raise) on the sentinel.
|
||||
if value is GAP or value is None or value == 0:
|
||||
bars.append(None)
|
||||
elif value > 0:
|
||||
height = max(1, round(value / hi * up))
|
||||
@@ -415,12 +440,15 @@ def _assemble(
|
||||
Every bar span includes the baseline row by construction, so a half-silent
|
||||
cell keeps the zero line continuous inside the lit character; a fully silent
|
||||
cell shows just the faint baseline dots on whichever row holds them; anything
|
||||
else stays blank braille to keep the grid monospace.
|
||||
else stays blank braille to keep the grid monospace. The baseline is decided
|
||||
per *dot column*, not per cell, so a :data:`GAP` column can go fully blank —
|
||||
breaking the zero line into a notch — while its cell-mate keeps its own.
|
||||
|
||||
Args:
|
||||
bars: Per-column inclusive dot-row spans (``None`` = no bar).
|
||||
values: The readings behind the columns, aligned with ``bars`` (fed to a
|
||||
callable ``style`` two at a time, per cell).
|
||||
callable ``style`` two at a time, per cell); a :data:`GAP` reading
|
||||
suppresses that column's baseline so the notch reaches the axis.
|
||||
rows: Chart height in braille rows.
|
||||
base: The zero baseline's dot row.
|
||||
style: Fixed style, or per-cell callable (see :data:`CellStyle`).
|
||||
@@ -442,20 +470,26 @@ def _assemble(
|
||||
base_bit_right = _RIGHT_BITS[base - floor] if floor <= base <= floor + 3 else 0
|
||||
line = Text()
|
||||
for i in range(0, len(bars), 2):
|
||||
# A GAP column carries no baseline, so the zero line breaks there and the
|
||||
# notch runs clean from the top edge down to the axis border.
|
||||
bl = 0 if values[i] is GAP else base_bit_left
|
||||
br = 0 if values[i + 1] is GAP else base_bit_right
|
||||
left = _column_bits(bars[i], floor, _LEFT_BITS)
|
||||
right = _column_bits(bars[i + 1], floor, _RIGHT_BITS)
|
||||
if left or right:
|
||||
mask = left | right | base_bit_left | base_bit_right
|
||||
mask = left | right | bl | br
|
||||
if column_styles is not None and i + 1 < len(column_styles):
|
||||
cell_style = column_styles[i + 1] if right else column_styles[i]
|
||||
elif callable(style):
|
||||
present = [v for v in values[i : i + 2] if v is not None]
|
||||
present = [
|
||||
v for v in values[i : i + 2] if v is not None and v is not GAP
|
||||
]
|
||||
cell_style = style(present) if present else baseline_style
|
||||
else:
|
||||
cell_style = style
|
||||
line.append(chr(0x2800 | mask), style=cell_style)
|
||||
elif base_bit_left:
|
||||
line.append(chr(0x2800 | base_bit_left | base_bit_right), style=baseline_style)
|
||||
elif bl or br:
|
||||
line.append(chr(0x2800 | bl | br), style=baseline_style)
|
||||
else:
|
||||
line.append(chr(0x2800))
|
||||
lines.append(line)
|
||||
|
||||
@@ -30,7 +30,7 @@ from rich.console import Group, RenderableType
|
||||
from rich.text import Text
|
||||
|
||||
from ..core.models import utcnow
|
||||
from .braillechart import axis_caption, axis_chart, chart_span, timeline_rows
|
||||
from .braillechart import GAP, axis_caption, axis_chart, chart_span, timeline_rows
|
||||
from .theme import snr_style
|
||||
from .tui.render import render_lines
|
||||
from .tui.screen import Screen
|
||||
@@ -319,7 +319,7 @@ def _day_axis(shown: list) -> Callable[[float], str]:
|
||||
return label_at
|
||||
|
||||
|
||||
def _day_columns(values: list[int], chars: int) -> list[Optional[int]]:
|
||||
def _day_columns(values: list[int], chars: int) -> list:
|
||||
"""Stretch per-day counts into day-wide bars that fill the chart's width exactly.
|
||||
|
||||
One dot column per day leaves a short history as a sliver in a wide terminal —
|
||||
@@ -330,23 +330,23 @@ def _day_columns(values: list[int], chars: int) -> list[Optional[int]]:
|
||||
remainder instead, leaving the bars short of the axis border and caption
|
||||
sized for the full width (misreading as the whole chart sitting shifted left).
|
||||
|
||||
A day wide enough to span more than one character has its very first dot
|
||||
column left blank — the left half of its leading braille cell — so same-height
|
||||
neighbours still read as separate bars instead of fusing into one solid block.
|
||||
A history deeper than the chart is wide falls back to one dot column per day
|
||||
(no room for a full character each, so no notch), the dot-column total still
|
||||
landing exactly on ``2 × chars``.
|
||||
A day wide enough to span more than one character opens with a
|
||||
:data:`~meshterm.ui.braillechart.GAP` dot column — the left half of its leading
|
||||
braille cell, blank clean down to the axis — so same-height neighbours read as
|
||||
separate bars instead of fusing into one solid block. A history deeper than the
|
||||
chart is wide falls back to one dot column per day (no room for a full character
|
||||
each, so no notch), the dot-column total still landing exactly on ``2 × chars``.
|
||||
|
||||
Args:
|
||||
values: Per-day counts, oldest first.
|
||||
chars: The chart's width in character cells.
|
||||
|
||||
Returns:
|
||||
Exactly ``2 × chars`` dot-column readings, oldest first (``None`` marks a
|
||||
day-boundary notch).
|
||||
Exactly ``2 × chars`` dot-column readings, oldest first (a
|
||||
:data:`~meshterm.ui.braillechart.GAP` marks a day-boundary notch).
|
||||
"""
|
||||
n = max(1, len(values))
|
||||
out: list[Optional[int]] = []
|
||||
out: list = []
|
||||
if n <= chars:
|
||||
# Each day spans at least one whole character: split in character units so
|
||||
# every day's block starts on an even dot-column index (a fresh cell),
|
||||
@@ -355,7 +355,7 @@ def _day_columns(values: list[int], chars: int) -> list[Optional[int]]:
|
||||
for i, value in enumerate(values):
|
||||
width_chars = base + (1 if i < extra else 0)
|
||||
if width_chars > 1:
|
||||
out.append(None)
|
||||
out.append(GAP)
|
||||
out.extend([value] * (width_chars * 2 - 1))
|
||||
else:
|
||||
out.extend([value] * (width_chars * 2))
|
||||
|
||||
@@ -7,6 +7,7 @@ the rendered braille bitmasks.
|
||||
from __future__ import annotations
|
||||
|
||||
from meshterm.ui.braillechart import (
|
||||
GAP,
|
||||
activity_sparkline,
|
||||
axis_chart,
|
||||
chart_span,
|
||||
@@ -84,6 +85,37 @@ def test_timeline_pads_an_odd_tail_column() -> None:
|
||||
assert rows[0].plain[0] == _ch(_L[4], _R[1]) # full bar + the padded column's floor
|
||||
|
||||
|
||||
# --- timeline_rows: GAP (a hard break in the baseline) --------------------------------
|
||||
|
||||
|
||||
def test_timeline_gap_breaks_the_baseline_but_zero_keeps_it() -> None:
|
||||
"""A GAP column draws nothing — not even the zero line — while a 0 keeps its dot.
|
||||
|
||||
The cell pairs a GAP (left) with a plain 0 (right): the left column is fully
|
||||
blank down to the floor, the right column still shows its faint baseline dot —
|
||||
the distinction that lets a day-boundary notch reach the axis while an empty
|
||||
day stays a grey flatline.
|
||||
"""
|
||||
rows = timeline_rows([GAP, 0], rows=1)
|
||||
assert rows[0].plain[0] == _ch(_R[1]) # only the right (0) column's baseline dot
|
||||
assert rows[0].spans and rows[0].spans[0].style == "faint"
|
||||
|
||||
|
||||
def test_timeline_gap_column_stays_blank_beside_a_full_bar() -> None:
|
||||
"""A GAP paired with a tall bar keeps its own column blank to the axis."""
|
||||
rows = timeline_rows([GAP, 4], rows=1) # left GAP, right full-height bar
|
||||
# Right column carries the whole bar; the left (GAP) column lights no dot at all,
|
||||
# not even the bottom baseline the bar's cell would otherwise share.
|
||||
assert rows[0].plain[0] == _ch(_R[4])
|
||||
assert not (ord(rows[0].plain[0]) & 0x40) # no bottom-left dot
|
||||
|
||||
|
||||
def test_chart_span_ignores_gap_columns() -> None:
|
||||
"""GAP carries no magnitude, so it never widens the span."""
|
||||
assert chart_span([GAP, 3, 7]) == (0.0, 7.0)
|
||||
assert chart_span([GAP, GAP]) == (0.0, 0.0)
|
||||
|
||||
|
||||
# --- timeline_rows: negative and mixed series ------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from types import SimpleNamespace
|
||||
|
||||
from meshterm.core.models import Observation, utcnow
|
||||
from meshterm.persistence.repository import Repository
|
||||
from meshterm.ui.braillechart import GAP
|
||||
from meshterm.ui.timemachine_screen import (
|
||||
TimeMachineScreen,
|
||||
_day_columns,
|
||||
@@ -168,9 +169,9 @@ def test_day_columns_always_hits_the_exact_width() -> None:
|
||||
|
||||
|
||||
def test_day_columns_notches_the_first_dot_of_a_multi_character_day() -> None:
|
||||
"""A day wide enough to span several characters opens with a blank dot column."""
|
||||
"""A day wide enough to span several characters opens with a GAP dot column."""
|
||||
cols = _day_columns([2, 8, 4], 12) # 3 days, 12 chars -> 4 chars (8 dots) each
|
||||
assert cols[0] is None and cols[8] is None and cols[16] is None
|
||||
assert cols[0] is GAP and cols[8] is GAP and cols[16] is GAP
|
||||
assert cols[1:8] == [2] * 7
|
||||
assert cols[9:16] == [8] * 7
|
||||
assert cols[17:24] == [4] * 7
|
||||
@@ -179,7 +180,7 @@ def test_day_columns_notches_the_first_dot_of_a_multi_character_day() -> None:
|
||||
def test_day_columns_skips_the_notch_for_a_single_character_day() -> None:
|
||||
"""A day exactly one character wide stays fully lit — nothing to space apart."""
|
||||
cols = _day_columns([5, 9], 2) # 2 days, 2 chars -> exactly 1 char (2 dots) each
|
||||
assert None not in cols
|
||||
assert GAP not in cols
|
||||
assert cols == [5, 5, 9, 9]
|
||||
|
||||
|
||||
@@ -187,7 +188,28 @@ def test_day_columns_skips_notches_when_days_outnumber_characters() -> None:
|
||||
"""More days than character columns: every day is sub-character, no notches."""
|
||||
cols = _day_columns(list(range(50)), 30) # 50 days into 30 chars (<=60 dots)
|
||||
assert len(cols) == 60
|
||||
assert None not in cols
|
||||
assert GAP not in cols
|
||||
|
||||
|
||||
def test_day_chart_notch_reaches_the_axis_but_a_zero_day_keeps_its_baseline() -> None:
|
||||
"""The notch column is blank down to the axis; a zero-value day stays a grey line.
|
||||
|
||||
Tall day, empty day, tall day at 4 chars each: the leading cell of each tall day
|
||||
shows its right dot column only (the notch's left column blank all the way down),
|
||||
and the empty day draws the faint zero baseline across its own columns.
|
||||
"""
|
||||
from meshterm.ui.braillechart import timeline_rows
|
||||
|
||||
cols = _day_columns([8, 0, 8], 12) # -> 4 chars (8 dot cols) per day
|
||||
bottom = timeline_rows(cols, rows=2)[-1]
|
||||
# Leading cell of the first tall day: right column filled, left (notch) blank —
|
||||
# no bottom-left dot, so the gap runs clean to the axis border below.
|
||||
assert not (ord(bottom.plain[0]) & 0x40) # the notch's bottom-left dot is unlit
|
||||
# The empty middle day (cells 4..7) draws the faint zero baseline, in grey.
|
||||
zero_cells = bottom.plain[4:8]
|
||||
assert all(ord(c) & 0x80 for c in zero_cells) # each keeps its right baseline dot
|
||||
zero_spans = [s for s in bottom.spans if 4 <= s.start < 8]
|
||||
assert zero_spans and all(s.style == "faint" for s in zero_spans)
|
||||
|
||||
|
||||
# --- the pages --------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user