braillecanvas
v0.4.0
Published
A braille framebuffer for the terminal, with 24-bit colour and collision-avoiding labels. 2x4 dots per cell.
Downloads
1,359
Maintainers
Readme
braillecanvas
A braille framebuffer for the terminal, with 24-bit colour and labels that refuse to overlap.
Unicode braille addresses 2×4 dots per character cell, so an 80×24 terminal is really a 160×96 canvas. That's what separates a rendering from ASCII art.
⠄ sin ⠄⣀⡤⠤⠤⣀⠄ ⠄ ⠄ ⠄ ⠄ ⠄ ⠄ ⠄ ⠄ ⠄ ⠄ ⠄ ⠄ ⠄ ⠄ ⠄ ⠄⣀⠤⠤⠤⣀⠄ ⠄ ⠄ ⠄ ⠄ ⠄ ⠄ ⠄ ⠄ ⠄ ⠄
⡠⠊ ⠉⠢⡀ ⢀⠔⠉ ⠑⢄
⠉⠑⢄⡠⠊ ⠈⠢ ⢀⠔⠉⠉⠑⢄ ⣐⠕⠉⠉⠒⢄ ⠑⢄ ⢀⠔⠉⠉⠒⢄
⠔⠡⡀ ⠑⡀⡐⠁ ⠡⡀ ⢀⡚⠁ ⠡⡀ ⠢⡀ ⡐⠁ ⠡⡀
⢀⠊ ⠐⠄ ⠜⢂ ⠐⠄ ⡠⠕ ⠐⠄ ⠐⠄ ⠔ ⠐⠄
⠉⠉⠉⠉⠉⠉⢍⠉⠉⠉⠉⠉⠉⠉⠉⢉⠍⠉⠉⠩⡉⠉⠉⠉⠉⠉⠉⠉⢍⠉⠉⠉⠉⠉⠉⠉⠉⢝⠍⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⢍⠉⠉⠉⠉⠉⠉⢋⠉⢉⠍⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉
⠢ ⠠⠂ ⠐⠄ ⢂ ⠠⠪⠂ ⢂ ⠡⡂ cosInstall
npm install braillecanvasUse
import {Canvas} from 'braillecanvas';
const c = new Canvas(72, 10); // terminal columns, rows
c.line(0, 20, 143, 20); // dot coordinates: 2x the cols, 4x the rows
for (let x = 0; x < 144; x++)
c.set(x, 20 - Math.round(18 * Math.sin(x / 12)), 0x3987e5, 2);
c.tryText(2, 0, 'sin', 0x3987e5); // cell coordinates
console.log(c.toString());A series is one call, and a frame around it is another:
const c = new Canvas(60, 8);
const pts = Array.from({length: 120}, (_, i) => [i, 16 + Math.round(13 * Math.sin(i / 9))]);
c.rect(0, 0, 119, 31); // plot frame
c.polyline(pts, 0x3987e5); // the seriespolyline skips a non-finite point rather than throwing or truncating: NaN is what a
projection returns for a sample behind the viewer, and one bad reading in a thousand should leave
a gap, not delete the rest of the series.
Example: an image, dithered
examples/image.js renders a picture as braille. A 110×65 terminal is a 220×260 bitmap —
enough for a recognisable face.

# any format -> PPM (the example has no dependencies, and Node can't decode JPEG)
python3 -c "from PIL import Image; Image.open('in.jpg').save('out.ppm')"
node examples/image.js out.ppm --cols 110 --btc # colour: best quality
node examples/image.js out.ppm --cols 60 --mono --ordered # monochromeUse --btc for colour. It derives each cell's dot pattern from the cell's own eight
pixels — cluster them on their widest colour axis, and the pattern records which pixel went
which way — instead of taking the pattern from a fixed dither grid and fitting two colours to
whatever split that produced. A threshold grid imposes structure on smooth regions that have
none. Measured on the Mona Lisa at 93 columns, against ordered dither plus two colours:
| | dither + 2 colours | --btc |
|---|---|---|
| mean per-dot colour error | 44.1 | 24.8 |
| cells with no texture | 35.1% | 1.6% |
Use --ordered for anything small in monochrome. The default is Floyd–Steinberg error
diffusion, which is right for large photographic renders but works against you at low dot
counts: it preserves local average tone by scattering error, so every region lands near its
own mean and the whole picture reads as uniform texture. Ordered (Bayer) dithering uses a fixed
threshold pattern, so equal tones always produce the same dot arrangement — the eye reads that
regularity as a distinct shade rather than as noise.
Do not pre-boost contrast or gamma for a colour render. It is tempting — a 1-bit grid needs contrast — but with two colours per cell the tone is carried by colour, not dot density, and a boosted curve just crushes cells to all-dots-or-none. Measured on the Mona Lisa at 93 columns: contrast 1.8 plus gamma 2.2 left 32% of cells with 0 or 8 dots lit (flat, textureless), against 3.4% for a plain autocontrast.
Two limits worth knowing before you reach for this:
- Below ~90 columns, photographs do not work. There are not enough dots for a face. Line art, charts and sparklines are fine much smaller, because they are already high-contrast.
- Monochrome cannot render every image, however you tune it. The Mona Lisa measures 159 luminance on her face against 192 for the sky behind her — the background is brighter than the subject, so a 1-bit density render is a silhouette by construction. Check the histogram before blaming the settings.
Colour is applied per cell, and each cell carries two of them: a foreground for the lit dots and a background for the gaps, so a cell straddling an edge — skin against sky — keeps the edge instead of averaging it into mud. Measured on this image: mean per-dot colour error falls from 45.7 to 21.3, a 53% improvement.
Example: a live server monitor
examples/monitor.js plots real /proc data — CPU, memory and load average over a rolling
window, with a per-core bar strip along the bottom. It is 78×20 cells, which is a 156×80 dot
framebuffer.

node examples/monitor.js # ~20s, then exits
node examples/monitor.js --frames 500 --interval 250
node examples/monitor.js --no-colour # pipe-friendlyIt exercises the three things below in one picture: the gridlines are drawn at weight 0 so a
trace crossing them keeps its colour, the labels are placed with tryText so a crowded chart
drops a label rather than clipping one, and the traces resolve to a quarter of a character
cell.
What it does that a plain dot canvas doesn't
Most braille canvases set and clear dots. The two things that turn that into something you can actually put a chart in:
Colour, arbitrated. A cell holds eight dots but takes one foreground colour, so when
several marks share a cell one has to win. set(x, y, rgb, weight) lets the heavier mark keep
the colour — pass a magnitude, a z-depth, an importance, whatever ranks your marks. Blending
would turn a dense cluster grey, and the eye does the same thing anyway.
Labels that decline rather than clip. tryText places a label only if the space is free and
returns whether it went in, so callers place in priority order and whatever doesn't fit is
simply not drawn — which is what a cartographer would do. It also flips to the other side of its
anchor at the frame edge, so you never get Saturn rendered as Sat.
API
| | |
|---|---|
| new Canvas(cols, rows, {aspect}) | aspect is dot height/width; 1.0 is square |
| set(x, y, rgb?, weight?) | light one dot (dot coords) |
| setCellBackground(col, row, rgb) | paint a cell's background (cell coords) |
| line(x0, y0, x1, y1, rgb?, weight?) | Bresenham |
| dottedLine(..., step = 2) | every step-th dot |
| polyline(points, rgb?, weight?) | connected run; [x,y] pairs or {x,y} objects |
| rect(x0, y0, x1, y1, rgb?, weight?) | outline; corners in any order |
| fillRect(x0, y0, x1, y1, rgb?, weight?) | solid |
| circle(cx, cy, r, rgb?, weight?) | ring |
| fillCircle(cx, cy, r, rgb?, weight?) | disc |
| text(col, row, str, rgb?) | unconditional, cell coords |
| tryText(col, row, str, rgb?, {pad}) | places only if free; returns boolean |
| clear() | dots, colours and labels |
| toString({colour, background}) | ANSI; background defaults to null (inherit) |
Coordinates: set/line take dot coordinates (canvas.width = cols*2,
canvas.height = rows*4). text/tryText take cell coordinates, because glyphs can't live
on the dot grid.
Two things worth knowing
The dots are very nearly square, which is easy to assume otherwise. A character cell is
about 1:2; two columns and four rows gives spacing of w/2 and h/4 = w/2. Measured in Ubuntu Mono
11: cell 8.00 × 17px, so dots are 4.00 × 4.25px — an aspect of 1.062. aspect corrects that
residual 6% by scaling y about the origin, so a circle drawn with aspect: 1.062 comes out
round rather than 6% flat. Ignore it and shapes are slightly squat, not unrecognisable.
Out-of-range coordinates light nothing. NaN, undefined and negative fractions are all
rejected rather than clamped — a projection returns NaN for a point behind the viewer, and
silently plotting that in the corner is worse than dropping it.
Dotted guide lines are not cosmetic. Scaffolding competes with the data it points at, and on a dense canvas it wins, which is backwards. A worked example: on a 100×28 terminal, solid figures for 89 constellations put 545 lit dots of line against 186 of star — 2.9:1. A step of 3 brings it to about 1.1:1, which finally puts more ink into the subject than the guides.
Used by
- starwheel — a live planisphere in your terminal
- terrafirma — a real sky as your GNOME wallpaper
Extracted from those, so the awkward parts — colour arbitration, label collision, NaN coordinates from a projection — are ones it has already hit.
Changelog
- 0.4.0 — shape primitives:
polyline,rect,fillRect,circle,fillCircle. Every one clips to the canvas rather than bounding its iteration count, so a shape far larger than the view still draws the part you can see —circle(40, 500, r=480)on an 80×32 canvas has an arc straight through the middle, and an earlier draft ofcirclerefused it outright. - 0.3.3 —
dottedLinesharesline's Liang–Barsky clip; a 1e8-length dotted line went from 629 ms to 0.2 ms.
Licence
MIT.
