Day charts: even dot-split columns, packed right-first ticks, honest y-marks

Day bars now share the chart's dot columns via one floor-edge split
(_day_spans), so widths never differ by more than one dot and the wider
days interleave with the narrower ones instead of pooling on the left.
Every day at least three dots wide opens with its notch — the oldest
day's doubling as its clearance off the axis border.

Axis ticks pack as dense as two blank cells between labels allow,
claimed newest day first, oldest second, then right to left, so a
crowded axis thins from the middle-left instead of down to an evenly
spaced handful. Months are written on the calendar anchors (the oldest
day and the first of a month) rather than tracked across placed ticks.

The y-gutter's ┤ tick crosses a braille row at its third dot, so each
mark now quotes the value of a bar peaking at that dot — computed with
timeline_rows' own baseline math — instead of the row's top edge, which
sat a dot above where the glyph points.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-07-12 21:09:12 -04:00
1 parent ff76f41736
commit 7f1d8e7d10
5 files changed
+186 -100

No files matched your search

+23 -9
View File
@@ -326,20 +326,31 @@ def axis_caption(
def y_axis_labels(peak: float, rows: int, *, lo: float = 0.0) -> list[str]:
"""Each chart row's top-edge value, top row first, dupes and zeros blanked.
"""Each chart row's ticked-dot value, top row first, dupes and zeros blanked.
The top row always reads the peak; lower rows read the proportional values at
their upper edges, but a mark that would repeat the one above (a low peak makes
neighbouring rows round to the same value) or read zero is left blank, so the
The gutter's ``┤`` tick crosses a braille row at the third dot up from the
row's bottom, so each mark quotes the value of a bar peaking *at that dot* —
the reading the tick visibly points at, not the row's top edge — using the
same zero-folded scaling :func:`timeline_rows` draws bars with (baseline row
and all). A mark that would repeat the one above (a low peak makes
neighbouring dots round to the same value) or read zero is left blank, so the
scale never shows the same number twice. A signed chart passes its floor as
``lo`` (``< 0``): the marks then run linearly from ``peak`` at the top down
through the grey zero line to ``lo`` at the bottom, so the gutter quotes both
extremes (an SNR band's ``+5 … −10 dB``) instead of only the positive peak.
``lo`` (``< 0``): marks above the grey zero line quote rise heights, marks
below it hang depths, so the gutter shows both signs of a zero-crossing
series (an SNR band's ``+5 … −10 dB``) instead of only the positive peak.
"""
total = rows * 4
base = _baseline_row(lo, peak, total)
labels: list[str] = []
seen: set[int] = set()
for i in range(rows):
value = round(peak - (peak - lo) * i / rows)
dot = (rows - 1 - i) * 4 + 2 # the dot row this row's ┤ tick crosses
if dot > base:
value = round(peak * (dot - base + 1) / (total - base))
elif dot < base:
value = round(lo * (base - dot + 1) / (base + 1))
else:
value = 0 # the tick sits on the zero baseline itself
if peak != lo and value and value not in seen:
labels.append(str(value))
seen.add(value)
@@ -425,7 +436,10 @@ def axis_chart(
Args:
chart_rows: The chart's rows, as returned by :func:`timeline_rows`.
peak: The value the top row's mark reads (the chart's tallest bar).
peak: The chart's full-scale ceiling (its tallest bar's value); each
row's gutter mark quotes the value at the dot its tick points at
(see :func:`y_axis_labels`), so the peak itself sizes the gutter
but is not necessarily printed.
chars: The chart's width in character cells.
label_at: Maps a position fraction to the x-axis caption at that point
(a continuous chart); ignored when ``ticks`` is given.
+86 -69
View File
@@ -30,7 +30,7 @@ from rich.console import Group, RenderableType
from rich.text import Text
from ..core.models import utcnow
from .braillechart import GAP, axis_chart, chart_span, timeline_rows
from .braillechart import GAP, _TICK_GAP, axis_chart, chart_span, timeline_rows
from .menus import fit_cells, section_heading
from .theme import snr_style
from .tui.render import render_lines
@@ -314,12 +314,35 @@ def _node_sections(
# --- the mesh page -----------------------------------------------------------------------
def _day_spans(days: int, chars: int) -> list[tuple[int, int]]:
"""Each day's ``(start, width)`` share of the chart's ``2 × chars`` dot columns.
The one even split behind both the bars (:func:`_day_columns`) and their axis
ticks (:func:`_day_centers`): day ``i`` runs from dot ``⌊i·D/n⌋`` to
``⌊(i+1)·D/n⌋`` (``D = 2 × chars``), so no two days differ by more than one
dot column and the wider days interleave evenly among the narrower ones
instead of pooling at either end. The widths always sum to exactly ``D``,
keeping the bars flush with the axis border and caption sized for them.
Args:
days: How many day bars the chart draws.
chars: The chart's width in character cells.
Returns:
One ``(start dot, width in dots)`` per day, oldest first.
"""
dots = chars * 2
n = max(1, days)
edges = [i * dots // n for i in range(n + 1)]
return [(edges[i], edges[i + 1] - edges[i]) for i in range(n)]
def _day_centers(days: int, chars: int) -> list[int]:
"""The chart cell each day's bar is centred on, mirroring :func:`_day_columns`.
Replays the same even split ``_day_columns`` uses to spread ``days`` bars across
``2 × chars`` dot columns, so a tick placed at ``centers[i]`` lands under day
``i``'s bar rather than at an arbitrary fraction of the axis.
Reads the same :func:`_day_spans` split ``_day_columns`` draws with, so a tick
placed at ``centers[i]`` lands under day ``i``'s bar rather than at an
arbitrary fraction of the axis.
Args:
days: How many day bars the chart draws.
@@ -328,25 +351,24 @@ def _day_centers(days: int, chars: int) -> list[int]:
Returns:
One centre cell (``0 .. chars - 1``) per day, oldest first.
"""
n = max(1, days)
centers: list[int] = []
dot = 0 # running dot-column offset, two per character cell
base, extra = divmod(chars, n) if n <= chars else divmod(chars * 2, n)
for i in range(n):
span = (base + (1 if i < extra else 0)) * (2 if n <= chars else 1)
centers.append(min(chars - 1, (dot + span // 2) // 2))
dot += span
return centers
return [
min(chars - 1, (start + width // 2) // 2)
for start, width in _day_spans(days, chars)
]
def _day_ticks(shown: list, chars: int) -> list[tuple[int, str]]:
"""``(cell, label)`` axis ticks under the day bars: short dates, thinned to fit.
"""``(cell, label)`` axis ticks under the day bars: as many short dates as fit.
One tick per day where they all fit, else an evenly spaced subset keeping both
ends; each label sits under its own bar (see :func:`_day_centers`). Dates are
kept compact — the month is shown only on the first tick and whenever it rolls
over, so most ticks read as a bare day number — and the newest bar reads
``today`` when it is, mirroring the node page's closing ``now``.
Ticks are claimed in priority order — the newest day first, the oldest day
second, then the rest right to left — each label centred under its own bar
(see :func:`_day_centers`) and keeping at least two blank cells from every
label already placed, so the axis packs as dense as it can and thins from the
middle-left outward when room runs out, never losing either end. Dates stay
compact: the month is written only on the oldest day and on the first of a
month (the calendar anchors), the rest read as bare day numbers, and the
newest bar reads ``today`` when it is, mirroring the node page's closing
``now``.
Args:
shown: The charted days, oldest first, each ``(iso_date, ...)``.
@@ -358,31 +380,36 @@ def _day_ticks(shown: list, chars: int) -> list[tuple[int, str]]:
n = len(shown)
centers = _day_centers(n, chars)
today = utcnow().strftime("%Y-%m-%d")
# A dated label is at most "Jul 12" (6 cells); keep a couple of cells between them.
fit = max(2, chars // 8)
if n <= fit:
picks = list(range(n))
else:
picks = sorted({round(i * (n - 1) / (fit - 1)) for i in range(fit)})
ticks: list[tuple[int, str]] = []
prev_month: Optional[str] = None
for i in picks:
def label_for(i: int) -> str:
iso = shown[i][0]
try:
day = datetime.strptime(iso, "%Y-%m-%d")
except ValueError:
ticks.append((centers[i], iso))
continue
month = day.strftime("%b")
return iso
if i == n - 1 and iso == today:
label = "today"
elif month != prev_month:
label = f"{month} {day.day}"
else:
label = str(day.day)
prev_month = month
ticks.append((centers[i], label))
return ticks
return "today"
if i == 0 or day.day == 1:
return f"{day:%b} {day.day}"
return str(day.day)
# Claimed [start, end) label spans — the same centring and spacing arithmetic
# _tick_axis renders with, so everything placed here survives its backstop.
taken: list[tuple[int, int]] = []
def claim(i: int) -> Optional[tuple[int, str]]:
label = label_for(i)
start = max(0, min(chars - len(label), centers[i] - len(label) // 2))
end = start + len(label)
if end > chars or any(
start < e + _TICK_GAP and s < end + _TICK_GAP for s, e in taken
):
return None
taken.append((start, end))
return centers[i], label
order = [n - 1, *([0] if n > 1 else []), *range(n - 2, 0, -1)]
return sorted(tick for i in order if (tick := claim(i)) is not None)
def _fill_days(
@@ -427,18 +454,19 @@ def _day_columns(values: list[int], chars: int) -> list:
One dot column per day leaves a short history as a sliver in a wide terminal —
beneath how every other MeshTerm chart spends its width — so each day repeats
over a share of the chart's columns instead. The share is an even split with
the one-off remainder spread across the oldest days, so the total always lands
on exactly ``2 × chars`` dot columns: a plain floor division drops the
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).
over its :func:`_day_spans` share of the chart's dot columns instead: an even
split whose widths never differ by more than one dot and whose total always
lands on exactly ``2 × chars`` (a plain floor division drops the remainder,
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 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``.
When every day is at least three dots wide, each opens with a
:data:`~meshterm.ui.braillechart.GAP` dot column — its first dot, blank clean
down to the axis — so same-height neighbours read as separate bars instead of
fusing into one solid block. The oldest day keeps its notch too: it doubles as
the bar's one-dot clearance off the axis border, paid for inside the day's own
span. A deeper history (days of one or two dots) drops the notch chart-wide —
such a bar has no dot to spare — and the days simply abut.
Args:
values: Per-day counts, oldest first.
@@ -448,26 +476,15 @@ def _day_columns(values: list[int], chars: int) -> list:
Exactly ``2 × chars`` dot-column readings, oldest first (a
:data:`~meshterm.ui.braillechart.GAP` marks a day-boundary notch).
"""
n = max(1, len(values))
spans = _day_spans(len(values), chars)
notch = min(width for _start, width in spans) >= 3
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),
# which is what makes "blank the leading cell's left half" well-defined.
base, extra = divmod(chars, n)
for i, value in enumerate(values):
width_chars = base + (1 if i < extra else 0)
if width_chars > 1:
out.append(GAP)
out.extend([value] * (width_chars * 2 - 1))
else:
out.extend([value] * (width_chars * 2))
else:
# More days than characters: no day gets a whole one, so no notch is drawn
# (nothing to space apart) — just split the dot columns themselves.
base, extra = divmod(chars * 2, n)
for i, value in enumerate(values):
out.extend([value] * (base + (1 if i < extra else 0)))
for value, (_start, width) in zip(values, spans):
if notch:
out.append(GAP)
out.extend([value] * (width - 1))
else:
out.extend([value] * width)
return out
+19 -13
View File
@@ -195,9 +195,14 @@ def test_y_axis_labels_blanks_a_silent_chart() -> None:
assert y_axis_labels(0, 3) == ["", "", ""]
def test_y_axis_labels_top_row_reads_the_peak() -> None:
"""The top row always reads the peak; lower rows are proportional."""
assert y_axis_labels(10, 2) == ["10", "5"]
def test_y_axis_labels_marks_quote_the_ticked_dot() -> None:
"""Each mark reads the value at its row's third dot — where the ┤ tick points.
peak 10 over 2 rows (8 dots): the top row's tick crosses dot 6 (a 7-dot bar,
10·7/8 → 9), the bottom row's dot 2 (a 3-dot bar, 10·3/8 → 4) — not the rows'
top-edge values (10 and 5), which sit a dot above where the glyph points.
"""
assert y_axis_labels(10, 2) == ["9", "4"]
def test_y_axis_labels_blanks_a_repeated_mark() -> None:
@@ -210,25 +215,26 @@ def test_axis_chart_mirrors_marks_on_both_gutters() -> None:
rows = timeline_rows([9] * 8, rows=2)
out = axis_chart(rows, 9, 4, lambda f: "now" if f >= 1.0 else "old")
assert len(out) == 4 # 2 chart rows + bottom border + caption
assert out[0].plain == "9 ┤⣿⣿⣿⣿├ 9"
assert out[1].plain == "4 ┤⣿⣿⣿⣿├ 4"
assert out[0].plain == "8 ┤⣿⣿⣿⣿├ 8" # ticked-dot values (peak 9 sizes the gutter)
assert out[1].plain == "3 ┤⣿⣿⣿⣿├ 3"
assert out[2].plain.startswith(" └") and out[2].plain.endswith("┘")
def test_y_axis_labels_signed_span_quotes_both_extremes() -> None:
"""A signed chart's marks run from the peak down through zero toward the floor."""
# rows=3 over +8..-4: top edge 8, middle 4, then 0 (blanked — it is the grey line).
assert y_axis_labels(8, 3, lo=-4) == ["8", "4", ""]
# A wider signed span surfaces a negative mark on the bottom row.
assert y_axis_labels(6, 3, lo=-6) == ["6", "2", "-2"]
"""A signed chart's marks quote rise heights above zero and hang depths below."""
# rows=3 over +8..-4 (baseline on dot 4): the top tick reads a 7-dot rise (7),
# the middle a 3-dot rise (3), the bottom a 3-deep hang below zero (−2).
assert y_axis_labels(8, 3, lo=-4) == ["7", "3", "-2"]
# A symmetric span pins the middle tick onto the baseline itself: blanked zero.
assert y_axis_labels(6, 3, lo=-6) == ["5", "", "-4"]
def test_axis_chart_floor_frames_a_signed_chart_and_widens_the_gutter() -> None:
"""A signed floor draws negative marks and sizes the gutter for the widest one."""
rows = timeline_rows([5, -5], rows=3, span=(-5, 5))
out = axis_chart(rows, 5, 1, lambda f: "x", floor=-5)
assert out[0].plain.startswith(" 5 ┤") # peak on top, gutter widened to two cells
assert out[2].plain.startswith("-2 ┤") # a negative mark near the floor
assert out[0].plain.startswith(" 4 ┤") # gutter widened to two cells by the floor
assert out[2].plain.startswith("-4 ┤") # a negative mark near the floor
def test_axis_chart_honours_a_shared_label_width() -> None:
@@ -239,7 +245,7 @@ def test_axis_chart_honours_a_shared_label_width() -> None:
"""
rows = timeline_rows([9] * 8, rows=1)
out = axis_chart(rows, 9, 4, lambda f: "x", label_w=3)
assert out[0].plain.startswith(" 9 ┤")
assert out[0].plain.startswith(" 7 ┤")
# --- axis_chart column ticks ----------------------------------------------------------
+7 -3
View File
@@ -75,12 +75,16 @@ def test_dashboard_renders_all_four_sections() -> None:
def test_dashboard_activity_chart_reads_newest_right_with_mirrored_scale() -> None:
"""'now' anchors the right edge and the peak count marks both gutters."""
"""'now' anchors the right edge and the scale marks mirror on both gutters.
Peak 9 over 3 rows (12 dots): the top gutter's ┤ tick crosses its row's third
dot (an 11-dot bar → 9·11/12 ≈ 8), so the mark reads 8, not the peak itself.
"""
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")
assert top.strip().startswith("8 ┤")
assert top.rstrip().endswith("├ 8")
caption = next(line for line in lines if "now" in line)
assert caption.index("−") < caption.index("now") # oldest left, newest right
# The lone newest-minute burst draws against the chart's right gutter, and the
+51 -6
View File
@@ -224,21 +224,66 @@ def test_day_ticks_shorten_dates_and_align_to_bars() -> None:
def test_day_ticks_reprint_the_month_on_a_rollover() -> None:
"""The month reappears the first time a new one starts, so a boundary reads clearly."""
"""The first of a month always carries its month name, so a boundary reads clearly."""
days = [(iso, 1, 1) for iso in ("2026-06-29", "2026-06-30", "2026-07-01", "2026-07-02")]
labels = [label for _, label in _day_ticks(days, 60)]
assert labels == ["Jun 29", "30", "Jul 1", "2"]
def test_day_ticks_thin_to_fit_keeping_both_ends() -> None:
"""More days than labels fit: an evenly spaced subset is kept, first and last included."""
def test_day_ticks_prioritise_the_newest_then_the_oldest_day() -> None:
"""With room for almost nothing, the newest day wins, then the oldest.
A chart squeezed to the minimum width still labels its "now" edge first and
its far edge second; whatever interior days fit are claimed right to left, so
the freshest history stays the best annotated.
"""
days = [(f"2026-06-{d:02d}", 1, 1) for d in range(1, 31)] # 30 days
chars = 40 # ~5 dated labels fit (chars // 8)
chars = 20
ticks = _day_ticks(days, chars)
centers = _day_centers(len(days), chars)
assert 2 <= len(ticks) <= 6
cols = [cell for cell, _ in ticks]
assert cols[0] == centers[0] and cols[-1] == centers[-1] # both ends survive the thinning
assert centers[-1] in cols # the newest day always keeps its tick
assert centers[0] in cols # …and the oldest is claimed right after it
def test_day_columns_widths_differ_by_at_most_one_dot_and_interleave() -> None:
"""An uneven split spreads the wider days through the chart, not to one side.
13 days over 30 chars (60 dots) can't divide evenly: days get 4 or 5 dots.
The widths must never differ by more than one dot column, and the wide days
must mix with the narrow ones instead of pooling at either end (the old
char-unit split put every wide day on the left, every narrow one on the right).
"""
cols = _day_columns([1] * 13, 30)
assert len(cols) == 60
starts = [i for i, c in enumerate(cols) if c is GAP] # one notch opens each day
widths = [b - a for a, b in zip(starts, [*starts[1:], len(cols)])]
assert len(widths) == 13 and set(widths) == {4, 5}
first_wide = widths.index(5)
last_wide = len(widths) - 1 - widths[::-1].index(5)
assert any(w == 4 for w in widths[first_wide:last_wide]) # narrow days sit between wide ones
def test_day_ticks_pack_the_axis_keeping_both_ends() -> None:
"""More days than labels fit: the axis packs what it can, ends always included.
Ticks are claimed newest-first, then oldest, then right to left, each keeping
two blank cells from its neighbours — so a crowded axis stays dense (well past
the old evenly-thinned handful) and any dropped days come from the interior.
"""
days = [(f"2026-06-{d:02d}", 1, 1) for d in range(1, 31)] # 30 days
chars = 40
ticks = _day_ticks(days, chars)
centers = _day_centers(len(days), chars)
cols = [cell for cell, _ in ticks]
assert cols[0] == centers[0] and cols[-1] == centers[-1] # both ends survive
assert len(ticks) >= 8 # bare day numbers pack far denser than dated labels
# Every placed label keeps at least two blank cells from the one before it.
spans = []
for cell, label in ticks:
start = max(0, min(chars - len(label), cell - len(label) // 2))
spans.append((start, start + len(label)))
assert all(b_start >= a_end + 2 for (_, a_end), (b_start, _) in zip(spans, spans[1:]))
def test_day_columns_notches_the_first_dot_of_a_multi_character_day() -> None: