Charming Terminal
Charming Terminal renders shapes as ASCII art, in a terminal-like grid drawn on a canvas. Shapes are positioned in cells instead of pixels, and colored with cells — a character with a foreground and a background color — instead of plain colors. The grid is computed by a software rasterizer written in Rust and compiled to WebAssembly.
(() => {
const renderer = cm.terminal({cols: 30, rows: 24, mode: "double"});
const root = cm.flow().renderer(renderer);
root
.data(Array.from({length: 240}, (_, i) => (i / 240) * Math.PI * 2))
.append(cm.group, {x: 15, y: 12})
.append(cm.point, {
x: (t) => 10 * Math.cos(t) * Math.cos(t * 3),
y: (t) => 10 * Math.sin(t) * Math.cos(t * 3),
stroke: {ch: "🌟", wide: true},
});
root.render();
return renderer.node();
})();const renderer = cm.terminal({cols: 30, rows: 24, mode: "double"});
const root = cm.flow().renderer(renderer);
root
.data(Array.from({length: 240}, (_, i) => (i / 240) * Math.PI * 2))
.append(cm.group, {x: 15, y: 12})
.append(cm.point, {
x: (t) => 10 * Math.cos(t) * Math.cos(t * 3),
y: (t) => 10 * Math.sin(t) * Math.cos(t * 3),
stroke: {ch: "🌟", wide: true},
});
root.render();
document.body.append(renderer.node());cm.terminal([options])
Returns a renderer that draws a terminal. Options:
- cols, rows — the size of the grid in cells. If not specified, they're computed from width and height.
- width, height — the size in pixels used to compute the grid (default
640and480). - mode —
"single"(default) or"double". In double mode, each cell is two characters wide, so wide characters such as emoji and CJK line up. A normal character is drawn twice to fill the cell. - fontFamily — the font of the cells (default
"courier-new, courier, monospace"). - fontSize — the font size in pixels (default
15). - fontWeight — the font weight (default
"normal").
const renderer = cm.terminal({cols: 80, rows: 24});
document.body.append(renderer.node());Positions and sizes are in cells: x: 1 is the second column. cm.mapPosition maps to the grid.
The terminal renderer draws point, link, rect, text, figlet, clear and group.
Cells
A cell is an object with these fields:
- ch — the character (default
" "). - fg — the color of the character (default
"#fff"). - bg — the background color of the cell (default none).
- wide — whether the character is double-width, such as an emoji or a CJK character (default
false). Use it withmode: "double".
Cells are used where colors are used with other renderers: stroke for points, links and rect outlines, and fill for rects. A point's stroke0, stroke1 and stroke2 set its character, foreground and background separately.
(() => {
const renderer = cm.terminal({cols: 40, rows: 10});
const root = cm.flow().renderer(renderer);
root.append(cm.rect, {x: 1, y: 1, width: 12, height: 6, fill: {ch: "#", fg: "orange"}});
root.append(cm.rect, {x: 15, y: 1, width: 12, height: 6, stroke: {ch: "+", fg: "steelblue"}});
root.append(cm.link, {x: 29, y: 1, x1: 38, y1: 6, stroke: {ch: "*", fg: "seagreen"}});
root.append(cm.point, {x: 1, y: 8, stroke0: "@", stroke1: "black", stroke2: "yellow"});
root.render();
return renderer.node();
})();root.append(cm.rect, {x: 1, y: 1, width: 12, height: 6, fill: {ch: "#", fg: "orange"}});
root.append(cm.rect, {x: 15, y: 1, width: 12, height: 6, stroke: {ch: "+", fg: "steelblue"}});
root.append(cm.link, {x: 29, y: 1, x1: 38, y1: 6, stroke: {ch: "*", fg: "seagreen"}});
root.append(cm.point, {x: 1, y: 8, stroke0: "@", stroke1: "black", stroke2: "yellow"});On the terminal, text draws one character per cell. Its fill is the color of the characters.
cm.figlet
Appends text drawn as an ASCII-art banner with figlet. It's a shape for the terminal renderer only. It supports:
- text — the text.
- x, y — the position, in cells.
- fill — the color of the characters.
- textAlign —
"start"(default),"center"or"end". - textBaseline —
"top"(default),"middle"or"bottom". - fontFamily — the figlet font:
"standard"(default) or"ghost".
(() => {
const renderer = cm.terminal({cols: 60, rows: 11});
const root = cm.flow().renderer(renderer);
root.append(cm.figlet, {
text: "hello",
x: 30,
y: 5,
textAlign: "center",
textBaseline: "middle",
fontFamily: "ghost",
fill: () => (x, y, width) => `hsl(${(x / width) * 360}, 90%, 65%)`,
});
root.render();
return renderer.node();
})();const renderer = cm.terminal({cols: 60, rows: 11});
const root = cm.flow().renderer(renderer);
root.append(cm.figlet, {
text: "hello",
x: 30,
y: 5,
textAlign: "center",
textBaseline: "middle",
fontFamily: "ghost",
fill: () => (x, y, width) => `hsl(${(x / width) * 360}, 90%, 65%)`,
});
root.render();The fill of text and figlet can be a function, called for each character with its column (x), row (y), and the width and height of the text. It colors the characters individually, for example as a gradient. Because options that are functions are evaluated per datum, the function is returned from an accessor: fill: () => (x, y, width) => color. A d3-scale-chromatic interpolator works well: fill: () => (x, y, width) => d3.interpolateSinebow(x / width).
Animating
Draw frames as with any renderer. Here is a fire of characters:
(() => {
const cols = 70;
const rows = 28;
const fire = Array.from({length: rows}, () => new Array(cols).fill(0));
const renderer = cm.terminal({cols, rows});
const root = cm.flow().renderer(renderer);
const node = renderer.node();
function randomChar() {
return String.fromCharCode(32 + Math.floor(Math.random() * 95));
}
// Each cell takes the heat of the cell below it, or of its neighbor, minus a random decay.
function update() {
const noise = cm.noise(0, rows);
for (let y = 0; y < rows - 1; y++) {
for (let x = 0; x < cols; x++) {
const decay = Math.floor(Math.random() * 3);
const spread = Math.floor(Math.random() * 2) - 1;
const index = Math.min(Math.max(0, x - spread), cols - 1);
fire[y][x] = Math.max(0, fire[y + 1][index] - decay);
}
}
for (let x = 0; x < cols; x++) fire[rows - 1][x] = noise(x / 10) | 0;
}
function draw() {
root.append(cm.clear, {fill: "black"});
root
.data(fire)
.append(cm.group, {x: 0, y: (_, i) => i})
.data((row) => row)
.append(cm.point, {
x: (_, i) => i,
y: 0,
stroke: (heat) => (heat === 0 ? null : {ch: randomChar(), bg: d3.interpolateCool(heat / rows)}),
});
root.render();
}
let last = 0;
let frame = requestAnimationFrame(function animate(time) {
if (!node.isConnected && last > 0) return;
if (time - last >= 1000 / 15) {
last = time;
update();
draw();
}
frame = requestAnimationFrame(animate);
});
unsubscribe(() => cancelAnimationFrame(frame));
return node;
})();const cols = 70;
const rows = 28;
const fire = Array.from({length: rows}, () => new Array(cols).fill(0));
const renderer = cm.terminal({cols, rows});
const root = cm.flow().renderer(renderer);
function randomChar() {
return String.fromCharCode(32 + Math.floor(Math.random() * 95));
}
// Each cell takes the heat of the cell below it, or of its neighbor, minus a random decay.
function update() {
const noise = cm.noise(0, rows);
for (let y = 0; y < rows - 1; y++) {
for (let x = 0; x < cols; x++) {
const decay = Math.floor(Math.random() * 3);
const spread = Math.floor(Math.random() * 2) - 1;
const index = Math.min(Math.max(0, x - spread), cols - 1);
fire[y][x] = Math.max(0, fire[y + 1][index] - decay);
}
}
for (let x = 0; x < cols; x++) fire[rows - 1][x] = noise(x / 10) | 0;
}
function draw() {
root.append(cm.clear, {fill: "black"});
root
.data(fire)
.append(cm.group, {x: 0, y: (_, i) => i})
.data((row) => row)
.append(cm.point, {
x: (_, i) => i,
y: 0,
stroke: (heat) => (heat === 0 ? null : {ch: randomChar(), bg: d3.interpolateCool(heat / rows)}),
});
root.render();
}
setInterval(() => {
update();
draw();
}, 1000 / 15);
document.body.append(renderer.node());