A record's ground is drawn under the stats, each pin labelled with its hash byte

The area drawing rode beside the stats in a third of the width, which squeezed the
shape and left no cell for a label on any pin. It is a block of its own now, between
the stat lanes and the route graph — page-wide up to a cap, a fixed eight rows, north
up, the shape against the left edge under the left-aligned stats — and every hop's pin
carries its first hash byte, placed the way the map places a name (beside the pin,
clear of the drawn lines where it can, dropped rather than overprinting), so a pin and
the graph node under it match by eye. The star stays bare: it already says who that is.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5.1 committed 2026-09-12 02:50:50 -04:00
1 parent 07a566d49d
commit 121f75c2a0
2 files changed
+110 -75

No files matched your search

+78 -61
View File
@@ -16,10 +16,10 @@ opened from the main menu. Every successful trace — a *Trace target* boomerang
* opening a record pushes :class:`RecordScreen`, a full-screen page — every stat the walk
was measured by
(the far point named with the node it reached, the longest leg with the link it spanned),
the walk drawn two ways: on THE route graph (the Message paths dialog's shape, us at both
ends, drawn no taller than one lane
needs) and, beside the stats, as the enclosed area it swept on a braille mini-map (us and
every positioned hop, coloured node pins, no labels); then the full route — the path
the walk drawn two ways: under the stats, as the enclosed area it swept on a braille
mini-map (north up, us and every positioned hop as coloured node pins, each hop labelled
with its hash byte) and, under that, on THE route graph (the Message paths dialog's
shape, us at both ends, drawn no taller than one lane needs); then the full route — the path
widget again, unlabelled across the card's whole width, wrapping at hop boundaries — and
the spec, and when/by which app version it was
set. The card scrolls (PgUp/PgDn/Home/End) when it outgrows the terminal. From there
@@ -88,14 +88,18 @@ _GRAPH_ROWS = 3
#: colour, so the shape reads as one flat silhouette rather than two brightnesses.
_AREA_TONE: RGB = (148, 163, 184)
#: The area drawing's cell box, to the right of the stats: a width that scales with the
#: dialog but stays inside these bounds, extra rows spent below the stats so the shape has
#: room, and a floor of columns below which it is dropped (the stats reclaim the full
#: width) rather than squeezed into illegibility.
_POLY_MIN_W = 16
_POLY_MAX_W = 28
_POLY_EXTRA_ROWS = 2
_SIDE_BY_SIDE_MIN = 44
#: The area drawing's cell box, a block of its own under the stats: as wide as the page up
#: to a cap (proportions are kept, so past it a shape only gains blank canvas), a fixed
#: number of rows, and a floor of columns below which it is dropped rather than squeezed
#: into illegibility. It used to ride beside the stats in a third of the width, which left
#: no room for a label on any pin.
_AREA_MAX_W = 40
_AREA_MIN_W = 16
_AREA_ROWS = 8
#: Dots kept clear inside the box's edges — wider on the sides than above and below, so a
#: pin at the eastern or western extreme still has the cells its hash-byte label needs.
_AREA_PAD_X = 8.0
_AREA_PAD_Y = 2.0
#: Cells the record card's label lane spans. Every labelled row on the card — the stat
#: lanes, the spec row's hanging indent, the recorded line — shares this one column, so
@@ -142,13 +146,13 @@ def discipline_lane() -> int:
def _drawn_rows(lines: list[str]) -> list[str]:
"""``lines`` with the rows nothing actually landed on stripped from either end.
Both blocks the card stacks are drawn on a canvas sized for the worst case and then
filled: the stats' area drawing keeps :data:`_POLY_EXTRA_ROWS` in hand for a tall shape,
and the route graph pads a row either side of its markers for the labels. A shape that
comes out flat, or labels that all seat on one side, leave those rows empty — and an
empty row still costs a line of card, stacking up as a gap the layout never intended
(three blank lines between the stats and the graph where one was meant). Trimming happens
*after* the drawing, so nothing placed is ever lost: only rows that stayed blank go.
Both drawn blocks the card stacks are rendered on a canvas sized for the worst case and
then filled: the area drawing has :data:`_AREA_ROWS` for a tall shape, and the route
graph pads a row either side of its markers for the labels. A shape that comes out
flat, or labels that all seat on one side, leave those rows empty — and an empty row
still costs a line of card, stacking up as a gap the layout never intended (three blank
lines between the stats and the graph where one was meant). Trimming happens *after*
the drawing, so nothing placed is ever lost: only rows that stayed blank go.
"""
kept = list(lines)
while kept and not Text.from_ansi(kept[-1]).plain.strip():
@@ -172,6 +176,9 @@ class WalkVertex:
glyph: The node-type marker to pin at this point.
color: The marker's truecolour.
is_self: Whether this vertex is our own node (the yellow star at the origin).
label: The hop's hash byte, written beside its pin — the same label the route
graph gives the node, so the two drawings are read against each other. Empty
for our own node: the star already says who that is.
"""
x: float
@@ -179,6 +186,7 @@ class WalkVertex:
glyph: str
color: RGB
is_self: bool
label: str = ""
class RecordScreen(Screen):
@@ -191,9 +199,9 @@ class RecordScreen(Screen):
Every stat the walk was measured by — a reliability read from the far node's trace
history, the far point named with the node it reached, the longest leg drawn as the
link it spanned (our own end on the app-wide ★) — with the walk's enclosed area
drawn beside them on a braille mini-map (us at the origin, every positioned hop pinned
in its own name hue, no labels) when the terminal has the room; below, the walk on THE
link it spanned (our own end on the app-wide ★); under them the walk's enclosed area
on a braille mini-map (north up, us at the origin, every positioned hop pinned in its
own name hue and labelled with its hash byte); below that, the walk on THE
route graph (us at both ends, relays wearing their map marker over a node-type key),
the route in full on THE path widget — no label lane, the card's whole width, wrapped
at hop boundaries and never truncated — and the
@@ -426,41 +434,35 @@ class RecordScreen(Screen):
lanes.append(self._lane("round trip", Text(f"{rtt:.0f} ms")))
return lanes
def _compose_stats(self, lanes: list[Text], width: int) -> list[str]:
"""Lay the stat lanes out, the area drawing pinned to their right when it fits.
def _area_lines(self, width: int) -> list[str]:
"""The walk's ground, as a block of its own between the stats and the route graph.
With a drawable walk and room to spare, the polygon takes a fixed cell box on the
right and the lanes are cropped to the column beside it; too narrow, or no shape,
and the lanes reclaim the whole width and the drawing is dropped. The block is
trimmed to the rows something landed on (:func:`_drawn_rows`), so a shape that comes
out flat hands its unused rows back instead of leaving the card gaping under the
last stat.
Empty when there is no drawable shape, or the page is too narrow for one to be
legible. Otherwise a blank line, the drawing trimmed to the rows something landed
on (:func:`_drawn_rows`), and a faint caption reading the drawing back the way the
graph's does. It used to ride beside the stats in a third of the width, which
squeezed the shape and left no cell for a label on any pin.
"""
if not self._shape or width < _SIDE_BY_SIDE_MIN:
return [render_to_ansi(lane, width, no_wrap=True) for lane in lanes]
poly_w = min(_POLY_MAX_W, max(_POLY_MIN_W, width // 3))
left_w = width - poly_w - 2
# Spend a couple of rows beyond the stat lanes so the shape has room; the extra
# rows hang just below the last stat, in the air above the route graph.
poly = self._shape_lines(poly_w, len(lanes) + _POLY_EXTRA_ROWS)
out: list[str] = []
for i in range(len(poly)):
row = lanes[i].copy() if i < len(lanes) else Text()
row.no_wrap = True
row.truncate(left_w, overflow="ellipsis", pad=True)
row.append(" ")
row.append_text(Text.from_ansi(poly[i]))
out.append(render_to_ansi(row, width, no_wrap=True))
return _drawn_rows(out)
if not self._shape or width < _AREA_MIN_W:
return []
lines = [""]
lines.extend(_drawn_rows(self._shape_lines(min(width, _AREA_MAX_W), _AREA_ROWS)))
caption = Text("area walked · north up · labels = hash byte", style="faint")
lines.append(render_to_ansi(caption, width, no_wrap=True))
return lines
def _shape_lines(self, cell_w: int, cell_h: int) -> list[str]:
"""Draw the walk's enclosed area on a braille canvas: fill, loop, coloured pins.
"""Draw the walk's enclosed area on a braille canvas: fill, loop, labelled pins.
The projected circuit (us at the origin, every positioned hop around it) is scaled
to the cell box preserving true proportions — braille dots are square, so equal x/y
scaling keeps the geography honest — then filled as a shaded region, outlined as the
walked loop, and pinned with each node's map marker. No labels: the pins carry the
node types and the stats beside them carry the numbers.
walked loop, and pinned with each node's map marker. The shape sits against the
box's left edge (the stats above it are left-aligned, and a shape centred in a
page-wide box would float off on its own) and is centred vertically. Each hop's pin
carries its hash byte, placed the way the map places a node's name: beside the pin,
clear of the drawn lines where a spot allows and over them where it doesn't, and
dropped rather than overprinting another pin or label.
"""
verts = self._shape or []
canvas = MapCanvas(cell_w, cell_h)
@@ -468,12 +470,11 @@ class RecordScreen(Screen):
ys = [v.y for v in verts]
span_x = (max(xs) - min(xs)) or 1e-6
span_y = (max(ys) - min(ys)) or 1e-6
pad = 2.0
avail_w = max(1.0, canvas.dot_w - 1 - 2 * pad)
avail_h = max(1.0, canvas.dot_h - 1 - 2 * pad)
avail_w = max(1.0, canvas.dot_w - 1 - 2 * _AREA_PAD_X)
avail_h = max(1.0, canvas.dot_h - 1 - 2 * _AREA_PAD_Y)
scale = min(avail_w / span_x, avail_h / span_y)
origin_x = pad + (avail_w - span_x * scale) / 2
origin_y = pad + (avail_h - span_y * scale) / 2
origin_x = _AREA_PAD_X
origin_y = _AREA_PAD_Y + (avail_h - span_y * scale) / 2
min_x, max_y = min(xs), max(ys)
# Flip y so north points up: the northernmost point lands at the top dot row.
ring = [(origin_x + (v.x - min_x) * scale, origin_y + (max_y - v.y) * scale) for v in verts]
@@ -485,16 +486,28 @@ class RecordScreen(Screen):
canvas.draw_line(closed, _AREA_TONE, priority=0)
# Hops first, then our own node last, so the star always wins its cell — a hop that
# projects onto the same cell can never hide us.
for v, (dx, dy) in zip(verts, ring, strict=True):
pins = [
(v, int(round(dx)), int(round(dy))) for v, (dx, dy) in zip(verts, ring, strict=True)
]
for v, px, py in pins:
if not v.is_self:
canvas.marker(int(round(dx)), int(round(dy)), v.glyph, v.color)
for v, (dx, dy) in zip(verts, ring, strict=True):
canvas.marker(px, py, v.glyph, v.color)
for v, px, py in pins:
if v.is_self:
canvas.marker(int(round(dx)), int(round(dy)), v.glyph, v.color)
canvas.marker(px, py, v.glyph, v.color)
# Labels once every pin is down, so none is written over a pin placed later. Two
# sweeps, the map's way: a spot clear of the dots first, then any free spot.
unlabelled = [(v, px, py) for v, px, py in pins if v.label and not v.is_self]
for avoid_dots in (True, False):
unlabelled = [
(v, px, py)
for v, px, py in unlabelled
if not canvas.marker_label(px, py, v.label, v.color, avoid_dots=avoid_dots)
]
return canvas.to_ansi_lines()
def render_body(self, width: int) -> list[str]:
"""Stats lanes, the route graph, the full route, provenance, then the actions."""
"""Stat lanes, the area drawing, the route graph, the route, provenance, actions."""
# Everything above the action rows — the stat lanes, the area drawing, the
# route graph and the provenance — is a pure function of the frozen record
# and the width, so it is composed once per width; only the cursor-bearing
@@ -537,7 +550,8 @@ class RecordScreen(Screen):
record = self._record
lines: list[str] = []
lines.extend(self._compose_stats(self._stat_lanes(), width))
lines.extend(render_to_ansi(lane, width, no_wrap=True) for lane in self._stat_lanes())
lines.extend(self._area_lines(width))
lines.append("")
lines.extend(self._graph_lines(width))
@@ -700,7 +714,10 @@ async def open_records(ctx: AppContext) -> dict:
name = resolve(node_id)
hop_glyph = node_marker(ntype)[0]
hop_color = name_rgb(name, node_id) if name and name != node_id else _AREA_TONE
verts.append(WalkVertex(east, north, hop_glyph, hop_color, False))
# The pin's label is the hop's first hash byte — what the route graph under the
# drawing labels the same node with, so a pin and a graph node match by eye.
byte = node_id.lower().removeprefix("0x")[:2]
verts.append(WalkVertex(east, north, hop_glyph, hop_color, False, label=byte))
dist = haversine_km(self_pos[0], self_pos[1], pos[0], pos[1])
if dist > far_dist:
far_dist = dist
+32 -14
View File
@@ -49,8 +49,8 @@ def _loop_shape() -> list[WalkVertex]:
nglyph, ncolor = node_marker(1) # a plain node
return [
WalkVertex(0.0, 0.0, sglyph, scolor, True),
WalkVertex(2.0, 1.0, hglyph, hcolor, False),
WalkVertex(1.0, 3.0, nglyph, ncolor, False),
WalkVertex(2.0, 1.0, hglyph, hcolor, False, label=HUB_ID[:2]),
WalkVertex(1.0, 3.0, nglyph, ncolor, False, label=FAR_ID[:2]),
]
@@ -294,26 +294,44 @@ def test_record_dialog_shows_reliability_when_the_far_node_has_history() -> None
assert "reliability" not in without
def test_record_dialog_draws_the_enclosed_area_beside_the_stats() -> None:
"""A drawable walk pins its area, with node markers and no labels, right of the stats."""
def _is_braille(ch: str) -> bool:
return chr(0x2800) <= ch <= chr(0x28FF)
def test_record_draws_the_enclosed_area_under_the_stats_with_hash_byte_pins() -> None:
"""A drawable walk pins its ground under the stats, each hop labelled with its hash byte.
The drawing used to ride beside the stats in a third of the width, unlabelled — there
was no cell for a label. It is a block of its own now, between the stats and the route
graph, so a pin and the graph node under it can be matched by their shared byte.
"""
lines = _plain(
_dialog(_record(), far_label="Far", shape=_loop_shape()).render_body(58)
).splitlines()
stats_block = lines[: lines.index("")] # the lanes, up to the blank before the graph
block = "\n".join(stats_block)
assert stats_block[0].startswith("score") # the drawing rides beside the stats
assert any(chr(0x2800) <= ch <= chr(0x28FF) for ch in block) # the area, in braille
first_blank = lines.index("")
stats = lines[:first_blank]
assert stats[0].startswith("score")
assert not any(_is_braille(ch) for ch in "\n".join(stats)), "no drawing beside the lanes"
assert "Far" in "\n".join(stats) # the far-point name is a stat, not a drawing label
area = lines[first_blank + 1 : lines.index("", first_blank + 1)]
block = "\n".join(area)
assert any(_is_braille(ch) for ch in block) # the area, in braille
assert "★" in block # us pinned at the origin
assert "▲" in block # the repeater vertex, in its map glyph
assert "Far" in block # the far-point name (in the stats, not a drawing label)
assert HUB_ID[:2] in block and FAR_ID[:2] in block # each hop's hash byte beside its pin
assert area[-1].startswith("area walked") # the caption closes the block
graph_caption = next(i for i, line in enumerate(lines) if line.startswith("you → … → you"))
assert graph_caption > lines.index(area[-1]), "the route graph follows the drawing"
def test_record_dialog_drops_the_area_drawing_when_too_narrow() -> None:
"""Below the side-by-side floor the stats reclaim the width; the far name stays."""
body = _plain(_dialog(_record(), far_label="Far", shape=_loop_shape()).render_body(40))
def test_record_drops_the_area_drawing_when_too_narrow() -> None:
"""Below the drawing's floor the block is left out; the far name stays in the stats."""
body = _plain(_dialog(_record(), far_label="Far", shape=_loop_shape()).render_body(14))
lines = body.splitlines()
score_row = next(line for line in lines if line.startswith("score"))
assert not any(chr(0x2800) <= ch <= chr(0x28FF) for ch in score_row) # no drawing
assert "area walked" not in body # no drawing, no caption for one
# The stats run straight into the blank line before the route graph; nothing pinned.
assert "★" not in "\n".join(lines[: lines.index("")])
assert "Far" in body # the far-point name survives the drop