"""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 ` (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)