"""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. `--chart-1…5` step one brand hue by lightness, which is right against a designed surface and unreadable in a terminal — five steps of one hue collapse to one colour on a 16-colour tty. Here 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 ` (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)