ASCII Canvas

ascii-canvas.svelte

A per-cell ASCII canvas that runs a rAF painter and diffs cells.

Demo

 
SCORE: 0 click the grid, then use arrow keys
 
Usage
001 <script lang="ts">
002 import ASCIICanvas from '$lib/components/ascii-canvas.svelte';
003 import SnakeDemo from '$lib/config/(demos)/snake.svelte';
004 </script>
005
006 <ASCIICanvas rows={8} />
007
008 <SnakeDemo />
009
ascii-canvas.svelte
001 <script lang="ts" module>
002 import type { HTMLAttributes } from 'svelte/elements';
003
004 /** A per-cell-painted ASCII canvas. A render function maps each (x,y,t) cell
005 * to a glyph + color; the component builds a span grid sized to the element,
006 * runs a requestAnimationFrame loop (only while visible), and writes only
007 * the cells that changed each frame. */
008 export type ASCIICanvasCell = { char: string; color: string };
009
010 export type ASCIICanvasRender = (
011 x: number,
012 y: number,
013 t: number,
014 cols: number,
015 rows: number
016 ) => ASCIICanvasCell;
017
018 export type ASCIICanvasProps = Omit<HTMLAttributes<HTMLPreElement>, 'children' | 'style'> & {
019 ref?: HTMLPreElement | null;
020 rows?: number;
021 render?: ASCIICanvasRender;
022 /** Fired whenever the measured column count changes (after layout). */
023 onlayout?: (cols: number) => void;
024 /** Fired at the top of each animation frame, before painting. */
025 onframe?: (t: number, cols: number) => void;
026 };
027 </script>
028
029 <script lang="ts">
030 let {
031 class: className,
032 ref = $bindable(null),
033 rows = 10,
034 render,
035 onframe,
036 onlayout,
037 ...restProps
038 }: ASCIICanvasProps = $props();
039
040 let preEl = $state<HTMLPreElement>(null!);
041
042 // Default render: a simple shimmer of '0'/'1' digits in shifting greyscale.
043 const defaultRender = (x: number, y: number, t: number): ASCIICanvasCell => {
044 const speed = t * 8;
045 const wave1 = Math.sin(x * 0.15 + speed) * Math.cos(y * 0.1 + speed * 0.7);
046 const wave2 = Math.sin((x + y) * 0.08 + speed * 1.3);
047 const v = wave1 + wave2;
048 const density = '10';
049 const digit = density[Math.floor(x * 0.5 + y * 0.3 + speed * 2) % density.length];
050 const brightness = Math.floor(((Math.sin(v * 2) + 1) / 2) * 180 + 50);
051 const hex = brightness.toString(16).padStart(2, '0');
052 return { char: digit, color: `#${hex}${hex}${hex}` };
053 };
054
055 const draw = $derived(render ?? defaultRender);
056
057 $effect(() => {
058 const el = preEl;
059 if (!el || !draw) return;
060 let cancelled = false;
061
062 const measure = document.createElement('span');
063 measure.style.visibility = 'hidden';
064 measure.style.position = 'absolute';
065 measure.style.whiteSpace = 'pre';
066 measure.textContent = 'X';
067 el.appendChild(measure);
068
069 let cols = 40;
070 let prevCols = -1;
071 let grid: HTMLSpanElement[] = [];
072 let prevChars: string[] = [];
073 let prevColors: string[] = [];
074
075 const buildGrid = (n: number) => {
076 if (n === prevCols) return;
077 prevCols = n;
078 while (el.firstChild && el.firstChild !== measure) el.removeChild(el.firstChild);
079 const frag = document.createDocumentFragment();
080 const spans: HTMLSpanElement[] = [];
081 for (let y = 0; y < rows; y++) {
082 for (let x = 0; x < n; x++) {
083 const s = document.createElement('span');
084 s.textContent = ' ';
085 spans.push(s);
086 frag.appendChild(s);
087 }
088 if (y < rows - 1) frag.appendChild(document.createTextNode('\n'));
089 }
090 el.insertBefore(frag, measure);
091 grid = spans;
092 prevChars = new Array(n * rows).fill('');
093 prevColors = new Array(n * rows).fill('');
094 };
095
096 const updateCols = () => {
097 const chW = measure.getBoundingClientRect().width;
098 if (chW > 0) {
099 cols = Math.floor(el.clientWidth / chW);
100 buildGrid(cols);
101 onlayout?.(cols);
102 }
103 };
104 updateCols();
105
106 const resizeObs = new ResizeObserver(updateCols);
107 resizeObs.observe(el);
108
109 // Font class changes on <body> alter --font-family-mono / char width, so
110 // rebuild the grid when body's class/style mutates.
111 const fontObs = new MutationObserver(() => {
112 prevCols = 0;
113 updateCols();
114 });
115 fontObs.observe(document.body, { attributes: true, attributeFilter: ['class', 'style'] });
116
117 let visible = false;
118 let frame = 0;
119 const interObs = new IntersectionObserver(
120 ([entry]) => {
121 const was = visible;
122 visible = entry.isIntersecting;
123 if (entry.isIntersecting && !was) frame = requestAnimationFrame(loop);
124 },
125 { threshold: 0 }
126 );
127 interObs.observe(el);
128
129 const loop = () => {
130 if (!visible || cancelled) return;
131 const total = cols * rows;
132 const time = performance.now() * 0.0001;
133 onframe?.(time, cols);
134 for (let idx = 0; idx < total && idx < grid.length; idx++) {
135 const column = idx % cols;
136 const row = (idx - column) / cols;
137 const cell = draw(column, row, time, cols, rows);
138 const span = grid[idx];
139 if (cell.char !== prevChars[idx]) {
140 span.textContent = cell.char;
141 prevChars[idx] = cell.char;
142 }
143 if (cell.color !== prevColors[idx]) {
144 span.style.color = cell.color;
145 prevColors[idx] = cell.color;
146 }
147 }
148 frame = requestAnimationFrame(loop);
149 };
150 frame = requestAnimationFrame(loop);
151
152 return () => {
153 cancelled = true;
154 cancelAnimationFrame(frame);
155 resizeObs.disconnect();
156 fontObs.disconnect();
157 interObs.disconnect();
158 measure.remove();
159 };
160 });
161
162 $effect(() => {
163 ref = preEl;
164 });
165 </script>
166
167 <pre
168 bind:this={preEl}
169 class={`ascii-canvas ${className ?? ''}`}
170 style={`height: calc(var(--line) * ${rows})`}
171 {...restProps}></pre>
172
173 <style>
174 .ascii-canvas {
175 display: block;
176 width: 100%;
177 font-family: var(--font-family-mono);
178 font-size: var(--font-size);
179 line-height: var(--line);
180 overflow: hidden;
181 margin: 0;
182 padding: 0;
183 }
184 </style>
185