Files
app/backend/fluksio/tui/chart.py
T
stroblme a057b61859 Explain the terminal chart colours by the terminal theme
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.
2026-10-06 07:24:54 +02:00

214 lines
7.8 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)