Created
May 30, 2025 15:15
-
-
Save L3viathan/d23ef7953e682447deb0b50eb3057570 to your computer and use it in GitHub Desktop.
Plotting with braille characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import click | |
| from math import ceil | |
| COLORS = { | |
| 0: (0, 0, 0), | |
| 1: (133, 153, 0), | |
| 2: (42, 161, 152), | |
| 3: (38, 139, 210), | |
| 4: (108, 113, 196), | |
| 5: (211, 54, 130), | |
| 6: (220, 50, 47), | |
| } | |
| def limit_numbers(*numbers, digits): | |
| effective_digits = digits - 1 if any(number < 0 for number in numbers) else digits | |
| if any(len(str(int(abs(number)))) > effective_digits for number in numbers): | |
| # scientific notation needed | |
| return [f"{f'{number:e}'[:digits]:>{digits}}" for number in numbers] | |
| if all(abs(number) < 1 for number in numbers): | |
| if any( | |
| len(str(number)) - len(str(number).lstrip("0.")) > (digits - 3) | |
| for number in numbers | |
| ): | |
| # too small | |
| return [f"{f'{number:e}'[:digits]:>{digits}}" for number in numbers] | |
| else: | |
| return [f"{str(number)[:digits]:>{digits}}" for number in numbers] | |
| else: | |
| return [f"{str(number)[:digits]:>{digits}}" for number in numbers] | |
| def mark_last(iterable): | |
| empty = object() | |
| last = empty | |
| for element in iterable: | |
| if last is empty: | |
| last = element | |
| continue | |
| yield False, last | |
| last = element | |
| if last is not empty: | |
| yield True, last | |
| # eight pixels as a braille char | |
| class Oxel: | |
| def __init__(self): | |
| self.data = [ | |
| [0, 0], | |
| [0, 0], | |
| [0, 0], | |
| [0, 0], | |
| ] | |
| def __setitem__(self, x_y, value): | |
| x, y = x_y | |
| self.data[y][x] = value | |
| def __getitem__(self, x_y): | |
| x, y = x_y | |
| return self.data[y][x] | |
| def __str__(self): | |
| maxval = max(max(row) for row in self.data) | |
| r, g, b = COLORS.get(maxval, (255, 255, 0)) | |
| char = chr( | |
| 0x2800 | |
| + bool(self.data[0][0]) | |
| + bool(self.data[1][0]) * 2 | |
| + bool(self.data[2][0]) * 4 | |
| + bool(self.data[0][1]) * 8 | |
| + bool(self.data[1][1]) * 16 | |
| + bool(self.data[2][1]) * 32 | |
| + bool(self.data[3][0]) * 64 | |
| + bool(self.data[3][1]) * 128 | |
| ) | |
| return f"\x1b[38;2;{r};{g};{b}m{char}\x1b[0m" | |
| # a grid of oxels | |
| class Grid: | |
| def __init__(self, width, height): | |
| self.data = [ | |
| [Oxel() for _ in range(ceil(width/2))] | |
| for _ in range(ceil(height/4)) | |
| ] | |
| def lines(self): | |
| for line in self.data: | |
| yield "".join(str(oxel) for oxel in line) | |
| def __str__(self): | |
| return "\n".join(self.lines()) + "\n" | |
| def __setitem__(self, x_y, value): | |
| x, y = x_y | |
| oxel = self.data[y // 4][x // 2] | |
| oxel[x % 2, y % 4] = value | |
| def __getitem__(self, x_y): | |
| x, y = x_y | |
| oxel = self.data[y // 4][x // 2] | |
| return oxel[x % 2, y % 4] | |
| # a "virtual" grid, with scaling/floats that uses a Grid as its display | |
| class Graph: | |
| def __init__(self, width, height): | |
| self.width = width | |
| self.height = height | |
| self.dots = [] | |
| self.min_x = float("inf") | |
| self.max_x = -float("inf") | |
| self.min_y = float("inf") | |
| self.max_y = -float("inf") | |
| def add_dot(self, x, y): | |
| self.dots.append((x, y)) | |
| self.min_x = min(x, self.min_x) | |
| self.min_y = min(y, self.min_y) | |
| self.max_x = max(x, self.max_x) | |
| self.max_y = max(y, self.max_y) | |
| def scale_x(self, value): | |
| return int( | |
| (value - self.min_x) / (self.max_x - self.min_x) * (self.width - 1) | |
| ) | |
| def scale_y(self, value): | |
| return int( | |
| (value - self.min_y) / (self.max_y - self.min_y) * (self.height - 1) | |
| ) | |
| def make_grid(self): | |
| grid = Grid(self.width, self.height) | |
| for x, y in self.dots: | |
| if x < self.min_x or x > self.max_x: | |
| continue | |
| if y < self.min_y or y > self.max_y: | |
| continue | |
| scaled_x = self.scale_x(x) | |
| scaled_y = self.scale_y(y) | |
| grid[scaled_x, scaled_y] += 1 | |
| return grid | |
| def __str__(self): | |
| return str(self.make_grid()) | |
| # knows about axes, labels, ticks, ... | |
| class Plot: | |
| def __init__(self, graph, *, xlab=None, ylab=None, ticks_x=None, ticks_y=None): | |
| self.graph = graph | |
| if ticks_x is not None: | |
| ticks_x = abs(graph.scale_x(ticks_x)) - graph.scale_x(0) | |
| else: | |
| ticks_x = 20 | |
| if ticks_y is not None: | |
| ticks_y = abs(graph.scale_y(ticks_y)) - graph.scale_y(0) | |
| else: | |
| ticks_y = 20 | |
| self.tick_x = ticks_x // 2 | |
| self.tick_y = ticks_y // 5 | |
| self.xlab = xlab | |
| self.ylab = ylab | |
| def lines(self): | |
| grid = self.graph.make_grid() | |
| if self.ylab: | |
| indent = len(self.ylab) | |
| else: | |
| indent = 0 | |
| if self.xlab: | |
| yield ( | |
| " " * (indent + 2) | |
| + f"\x1b[1m{self.xlab:^{self.graph.width // 2}}\x1b[22m" | |
| ) | |
| min_x, max_x = limit_numbers(self.graph.min_x, self.graph.max_x, digits=indent) | |
| min_y, max_y = limit_numbers(self.graph.min_y, self.graph.max_y, digits=indent) | |
| yield ( | |
| " " * 2 | |
| + min_x | |
| + f" {max_x:>{self.graph.width // 2}}" | |
| ) | |
| yield ( | |
| min_y | |
| + " ┼" | |
| + ( | |
| ("─" * (self.tick_x - 1) + "┴") | |
| * ((self.graph.width // (2 * self.tick_x)) + 1) | |
| )[:self.graph.width//2 + 1] | |
| ) | |
| for is_last, (i, grid_line) in mark_last(enumerate(grid.lines())): | |
| if self.ylab and i == self.graph.height // 4 // 2: | |
| indentstr = f"\x1b[1m{self.ylab}\x1b[22m" | |
| elif is_last: | |
| indentstr = max_y | |
| else: | |
| indentstr = " " * indent | |
| if i % self.tick_y != (self.tick_y - 1): | |
| yield indentstr + f" │ {grid_line}" | |
| else: | |
| yield indentstr + f" ┤ {grid_line}" | |
| def __str__(self): | |
| return "\n".join(self.lines()) + "\n" | |
| ############################################################################### | |
| @click.group() | |
| def cli(): | |
| pass | |
| @cli.command() | |
| @click.argument("data") | |
| @click.argument("xlab") | |
| @click.argument("ylab") | |
| @click.option("--min-x", type=float, help="Override minimum X value") | |
| @click.option("--max-x", type=float, help="Override maximum X value") | |
| @click.option("--min-y", type=float, help="Override minimum Y value") | |
| @click.option("--max-y", type=float, help="Override maximum Y value") | |
| @click.option( | |
| "--width", | |
| "-w", | |
| type=int, | |
| help="Width of the graph (excluding axes etc.) in chars.", | |
| ) | |
| @click.option( | |
| "--height", | |
| "-h", | |
| type=int, | |
| help="Height of the graph (excluding axes etc.) in chars.", | |
| ) | |
| @click.option( | |
| "--ticks-x", | |
| type=float, | |
| ) | |
| @click.option( | |
| "--ticks-y", | |
| type=float, | |
| ) | |
| def dots(data, xlab, ylab, min_x, max_x, min_y, max_y, width, height, ticks_x, ticks_y): | |
| import os | |
| from csv import DictReader | |
| cols, lines = os.get_terminal_size() | |
| if width is None: | |
| width = int(cols * 1.5) | |
| if height is None: | |
| height = lines * 3 | |
| g = Graph(width, height) | |
| with open(data) as f: | |
| r = DictReader(f) | |
| for row in r: | |
| g.add_dot(float(row[xlab]), float(row[ylab])) | |
| if min_x is not None: | |
| g.min_x = min_x | |
| if max_x is not None: | |
| g.max_x = max_x | |
| if min_y is not None: | |
| g.min_y = min_y | |
| if max_y is not None: | |
| g.max_y = max_y | |
| print("scaled ty is", g.scale_y(ticks_y)) | |
| print(Plot(g, xlab=xlab, ylab=ylab, ticks_x=ticks_x, ticks_y=ticks_y)) | |
| if __name__ == "__main__": | |
| cli() |
Author
L3viathan
commented
May 30, 2025
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment