The docstring still justified the ANSI colours by the old one-hue web ramp. The web now draws from the categorical --chart-1…8; the terminal keeps named ANSI colours so it follows the user's terminal theme. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
214 lines
7.8 KiB
Python
214 lines
7.8 KiB
Python
"""Curves at a terminal, drawn with braille.
|
||
|
||
A terminal cell holds 2x4 braille dots, so an 80x20 pane is a 160x80 plot —
|
||
enough resolution that a training curve reads as a curve rather than as a bar
|
||
chart. Nothing is imported for this: a plotting library would be two
|
||
dependencies on a package whose every dependency is argued for in
|
||
`pyproject.toml`, for something a screenful of arithmetic does.
|
||
|
||
The colours are deliberately not the web's. The web draws from the categorical
|
||
`--chart-1…8` palette (DESIGN-GUIDELINES.md, "Chart palette"), fixed values
|
||
chosen against its own surfaces. A terminal has a theme of its own, so these
|
||
are named ANSI colours that the user's terminal maps to its scheme, light or
|
||
dark. Identity is carried by hue, and the legend names every series anyway.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Iterable, Iterator, Sequence
|
||
from typing import Any
|
||
|
||
from rich.text import Text
|
||
from textual.widget import Widget
|
||
|
||
#: One run's readings: a label, and `[[x, y], ...]` as the engine answers.
|
||
Series = tuple[str, Sequence[Sequence[float]]]
|
||
|
||
#: The cap the web draws at, for the same reason: past five, a legend is a
|
||
#: puzzle. A sweep of twenty wants faceting, not a sixth colour.
|
||
MAX_SERIES = 5
|
||
|
||
#: Distinct hues, and the bright half of the ANSI sixteen so they hold up on a
|
||
#: light terminal as well as a dark one.
|
||
SERIES_STYLES = (
|
||
"bright_cyan",
|
||
"bright_magenta",
|
||
"bright_yellow",
|
||
"bright_green",
|
||
"bright_red",
|
||
)
|
||
|
||
#: `⠀`, and the dot bits of the cell above it, by (column, row) within it.
|
||
BRAILLE = 0x2800
|
||
DOTS = ((0x01, 0x02, 0x04, 0x40), (0x08, 0x10, 0x20, 0x80))
|
||
|
||
#: What the y tick labels take, and what the x axis and its labels take below.
|
||
GUTTER = 9
|
||
AXIS_ROWS = 2
|
||
|
||
|
||
def _num(value: float) -> str:
|
||
"""A tick label short enough to sit in the gutter."""
|
||
if value and (abs(value) >= 1e5 or abs(value) < 1e-3):
|
||
return f"{value:.1e}"
|
||
return f"{value:.4g}"
|
||
|
||
|
||
def shorten(label: str) -> str:
|
||
"""A run's label, with the id cut to the tail that identifies it.
|
||
|
||
The engine answers `<run id> (seed 3)`; a terminal wants the eight
|
||
characters people actually read it by, and the seed kept.
|
||
"""
|
||
head, space, rest = label.partition(" ")
|
||
return head[-8:] + space + rest
|
||
|
||
|
||
def _segment(x0: int, y0: int, x1: int, y1: int) -> Iterator[tuple[int, int]]:
|
||
"""Every dot along a straight line between two readings.
|
||
|
||
Points alone would draw a dotted cloud: forty readings across a
|
||
two-hundred dot axis touch one dot in five.
|
||
"""
|
||
dx, dy = abs(x1 - x0), -abs(y1 - y0)
|
||
step_x = 1 if x0 < x1 else -1
|
||
step_y = 1 if y0 < y1 else -1
|
||
error = dx + dy
|
||
while True:
|
||
yield x0, y0
|
||
if x0 == x1 and y0 == y1:
|
||
return
|
||
doubled = 2 * error
|
||
if doubled >= dy:
|
||
error += dy
|
||
x0 += step_x
|
||
if doubled <= dx:
|
||
error += dx
|
||
y0 += step_y
|
||
|
||
|
||
def _bounds(lines: Sequence[Series]) -> tuple[float, float, float, float]:
|
||
xs = [point[0] for _, points in lines for point in points]
|
||
ys = [point[1] for _, points in lines for point in points]
|
||
return min(xs), max(xs), min(ys), max(ys)
|
||
|
||
|
||
def _place(value: float, low: float, high: float, span: int) -> int:
|
||
"""A reading's dot along an axis of `span` dots.
|
||
|
||
A flat curve has no range to divide by and is drawn down the middle, which
|
||
is the honest picture of a metric that never moved.
|
||
"""
|
||
if high <= low:
|
||
return (span - 1) // 2
|
||
return round((value - low) / (high - low) * (span - 1))
|
||
|
||
|
||
def _cells(
|
||
lines: Sequence[Series], cols: int, rows: int
|
||
) -> tuple[dict[tuple[int, int], list[int]], tuple[float, float, float, float]]:
|
||
"""The braille cells the curves fill, and the bounds they were scaled to."""
|
||
low_x, high_x, low_y, high_y = _bounds(lines)
|
||
width, height = cols * 2, rows * 4
|
||
cells: dict[tuple[int, int], list[int]] = {}
|
||
for index, (_, points) in enumerate(lines):
|
||
previous: tuple[int, int] | None = None
|
||
for x_value, y_value in points:
|
||
spot = (
|
||
_place(x_value, low_x, high_x, width),
|
||
height - 1 - _place(y_value, low_y, high_y, height),
|
||
)
|
||
dots = _segment(*previous, *spot) if previous is not None else [spot]
|
||
for dot_x, dot_y in dots:
|
||
cell = cells.get((dot_y // 4, dot_x // 2))
|
||
bit = DOTS[dot_x % 2][dot_y % 4]
|
||
if cell is None:
|
||
cells[dot_y // 4, dot_x // 2] = [bit, index]
|
||
else:
|
||
# ponytail: the first series to reach a cell keeps its
|
||
# colour — a terminal cell has one foreground. Half-blocks
|
||
# would let two share it, at a quarter of the resolution.
|
||
cell[0] |= bit
|
||
previous = spot
|
||
return cells, (low_x, high_x, low_y, high_y)
|
||
|
||
|
||
def _row(cells: dict[tuple[int, int], list[int]], row: int, cols: int) -> Text:
|
||
"""One line of the plot, as few spans as the colours allow."""
|
||
out = Text()
|
||
run, style = "", ""
|
||
for col in range(cols):
|
||
cell = cells.get((row, col))
|
||
glyph = chr(BRAILLE + cell[0]) if cell else " "
|
||
wanted = SERIES_STYLES[cell[1] % len(SERIES_STYLES)] if cell else ""
|
||
if wanted != style:
|
||
out.append(run, style=style or None)
|
||
run, style = "", wanted
|
||
run += glyph
|
||
out.append(run, style=style or None)
|
||
return out
|
||
|
||
|
||
def plot(lines: Sequence[Series], width: int, height: int, x_label: str = "") -> Text:
|
||
"""The curves, scaled to a pane `width` by `height` cells.
|
||
|
||
Every series shares one y scale, which is the whole point of drawing them
|
||
together: two runs whose losses differ by a factor of ten should look like
|
||
it.
|
||
"""
|
||
drawn = [line for line in lines[:MAX_SERIES] if len(line[1]) > 0]
|
||
if not drawn:
|
||
return Text("No readings.", style="dim")
|
||
cols, rows = width - GUTTER, height - AXIS_ROWS
|
||
if cols < 8 or rows < 2:
|
||
return Text("Too small to draw.", style="dim")
|
||
|
||
cells, (low_x, high_x, low_y, high_y) = _cells(drawn, cols, rows)
|
||
#: Top, middle and bottom carry a value; labelling every row would be
|
||
#: noise on a chart that is mostly one curve.
|
||
labels = {0: high_y, rows // 2: (low_y + high_y) / 2, rows - 1: low_y}
|
||
|
||
out = Text(no_wrap=True, overflow="crop")
|
||
for row in range(rows):
|
||
mark = _num(labels[row]) if row in labels else ""
|
||
out.append(f"{mark:>{GUTTER - 2}} ", style="dim")
|
||
out.append("┤" if row in labels else "│", style="dim")
|
||
out.append_text(_row(cells, row, cols))
|
||
out.append("\n")
|
||
out.append(" " * (GUTTER - 1) + "└" + "─" * cols + "\n", style="dim")
|
||
left, right = _num(low_x), _num(high_x)
|
||
tail = f" {x_label}" if x_label else ""
|
||
pad = max(cols - len(left) - len(right) - len(tail), 1)
|
||
out.append(" " * GUTTER + left + " " * pad + right + tail, style="dim")
|
||
return out
|
||
|
||
|
||
def legend(labels: Iterable[str]) -> Text:
|
||
"""Which colour is which run — never colour alone."""
|
||
out = Text()
|
||
for index, label in enumerate(list(labels)[:MAX_SERIES]):
|
||
if index:
|
||
out.append(" ")
|
||
out.append("■ ", style=SERIES_STYLES[index % len(SERIES_STYLES)])
|
||
out.append(shorten(label))
|
||
return out
|
||
|
||
|
||
class Curves(Widget):
|
||
"""The plot, redrawn for whatever size the terminal gives it."""
|
||
|
||
DEFAULT_CSS = "Curves { height: 1fr; }"
|
||
|
||
def __init__(self, **kwargs: Any) -> None:
|
||
super().__init__(**kwargs)
|
||
self.lines: list[Series] = []
|
||
self.x_label = ""
|
||
|
||
def show(self, lines: Sequence[Series], x_label: str = "") -> None:
|
||
self.lines = list(lines)[:MAX_SERIES]
|
||
self.x_label = x_label
|
||
self.refresh()
|
||
|
||
def render(self) -> Text:
|
||
return plot(self.lines, self.size.width, self.size.height, self.x_label)
|