I asked Claude Sonnet 4.5 on GitHub Copilot:
How does mapscii render vector tiles as text blocks on the terminal? What are the libraries it uses and how does it use them? Explain as a detailed tutorial with examples so that I can learn how to rasterize vector shapes on the terminal.
Below’s the explanation. What’s noteworthy to me is:
- How it uses Braille characters to get 8 pixels per character cell (2x4 grid).
- How it fills polygons using (a) triangulation and (b) getting all edge points using Bresenham’s algorithm, then (c) filling horizontal spans between edge pairs.
I’ll help you understand how mapscii renders vector tiles as text blocks on the terminal. Let me search through the repository to find the relevant code and understand the architecture. Now let me get more specific files to understand the rendering pipeline better:# How MapSCII Renders Vector Tiles as Text Blocks on the Terminal
MapSCII is a fascinating project that transforms geographic vector tiles into ASCII/Braille art on your terminal. Let me break down the complete rendering pipeline with detailed examples so you can learn how to rasterize vector shapes on the terminal.
Architecture Overview
The rendering pipeline consists of several key components:
- TileSource - Fetches and manages vector tiles
- Tile - Parses vector tile data (Protobuf format)
- Renderer - Orchestrates the rendering process
- Canvas - Provides drawing primitives (lines, polygons)
- BrailleBuffer - Converts pixels to Braille/ASCII characters
Key Libraries Used
{
"@mapbox/vector-tile": "^1.3.1", // Parse vector tile format
"pbf": "^3.2.1", // Protobuf decoding
"earcut": "^2.2.2", // Polygon triangulation
"rbush": "^3.0.1", // 2D spatial indexing
"bresenham": "0.0.4", // Line rasterization
"simplify-js": "^1.2.4", // Polyline simplification
"x256": "0.0.2" // Color conversion to xterm-256
}
Step-by-Step Tutorial
Step 1: Understanding Braille Characters for Pixels
Braille characters are perfect for terminal graphics because each character represents a 2×4 pixel grid:
// BrailleBuffer uses Unicode Braille patterns (U+2800 - U+28FF)
// Each Braille character has 8 dots in a 2×4 grid:
//
// ⠁ ⠂ (left column: 0x1, 0x2, 0x4, 0x40)
// ⠄ ⠈ (right column: 0x8, 0x10, 0x20, 0x80)
// ⠐ ⠠
// ⠈ ⡀
const brailleMap = [
[0x1, 0x8],
[0x2, 0x10],
[0x4, 0x20],
[0x40, 0x80],
];
Each Braille character gives you 8 pixels in just one terminal cell, effectively quadrupling your resolution!
Step 2: Creating a Braille Buffer
class BrailleBuffer {
constructor(width, height) {
// Map of which bits correspond to which dot positions
this.brailleMap = [
[0x1, 0x8],
[0x2, 0x10],
[0x4, 0x20],
[0x40, 0x80],
];
// Store pixel state, colors
const size = (width * height) / 8; // Each cell stores 8 pixels
this.pixelBuffer = Buffer.alloc(size);
this.foregroundBuffer = Buffer.alloc(size);
this.backgroundBuffer = Buffer.alloc(size);
}
// Set a single pixel at (x, y)
setPixel(x, y, color) {
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
// Project x,y to buffer index
const idx = (x >> 1) + (this.width >> 1) * (y >> 2);
// Get the specific dot mask for this pixel
const mask = this.brailleMap[y & 3][x & 1];
// Set the bit
this.pixelBuffer[idx] |= mask;
this.foregroundBuffer[idx] = color;
}
}
// Generate terminal output
frame() {
const output = [];
let currentColor = null;
for (let y = 0; y < this.height / 4; y++) {
for (let x = 0; x < this.width / 2; x++) {
const idx = (y * this.width) / 2 + x;
// Add ANSI color codes when color changes
const colorCode = this._termColor(
this.foregroundBuffer[idx],
this.backgroundBuffer[idx],
);
if (currentColor !== colorCode) {
output.push(currentColor = colorCode);
}
// Convert pixel bits to Braille character
const brailleChar = String.fromCharCode(0x2800 + this.pixelBuffer[idx]);
output.push(brailleChar);
}
output.push("\n");
}
return output.join("");
}
_termColor(foreground, background) {
if (foreground && background) {
return `\x1B[38;5;${foreground};48;5;${background}m`;
} else if (foreground) {
return `\x1B[49;38;5;${foreground}m`;
}
return "\x1B[39;49m"; // Reset
}
}
Step 3: Drawing Lines with Bresenham’s Algorithm
const bresenham = require("bresenham");
class Canvas {
constructor(width, height) {
this.buffer = new BrailleBuffer(width, height);
}
// Draw a line from point A to point B
line(from, to, color, width = 1) {
if (width === 1) {
// Simple bresenham for single-pixel width
bresenham(from.x, from.y, to.x, to.y, (x, y) => {
this.buffer.setPixel(x, y, color);
});
} else {
// Thick line using Bresenham with width
this._thickLine(from.x, from.y, to.x, to.y, width, color);
}
}
// Draw a polyline (e.g., roads, rivers)
polyline(points, color, width = 1) {
for (let i = 1; i < points.length; i++) {
this.line(points[i - 1], points[i], color, width);
}
}
}
Step 4: Drawing Filled Polygons with Earcut
For filled areas (buildings, water bodies), MapSCII uses polygon triangulation:
const earcut = require("earcut");
class Canvas {
polygon(rings, color) {
// Convert rings to flat array of vertices
const vertices = [];
const holes = [];
for (const ring of rings) {
if (vertices.length) {
holes.push(vertices.length / 2); // Mark hole start
}
for (const point of ring) {
vertices.push(point.x);
vertices.push(point.y);
}
}
// Triangulate the polygon
const triangles = earcut(vertices, holes);
// Draw each triangle
for (let i = 0; i < triangles.length; i += 3) {
const pa = [vertices[triangles[i] * 2], vertices[triangles[i] * 2 + 1]];
const pb = [
vertices[triangles[i + 1] * 2],
vertices[triangles[i + 1] * 2 + 1],
];
const pc = [
vertices[triangles[i + 2] * 2],
vertices[triangles[i + 2] * 2 + 1],
];
this._filledTriangle(pa, pb, pc, color);
}
}
_filledTriangle(pointA, pointB, pointC, color) {
// Get edges using Bresenham
const a = bresenham(pointB[0], pointB[1], pointC[0], pointC[1]);
const b = bresenham(pointA[0], pointA[1], pointC[0], pointC[1]);
const c = bresenham(pointA[0], pointA[1], pointB[0], pointB[1]);
// Combine and sort all edge points by Y, then X
const points = a
.concat(b)
.concat(c)
.filter((p) => p.y >= 0 && p.y < this.height)
.sort((a, b) => (a.y === b.y ? a.x - b.x : a.y - b.y));
// Fill horizontal spans between edge pairs
for (let i = 0; i < points.length - 1; i++) {
const point = points[i];
const next = points[i + 1];
if (point.y === next.y) {
// Draw horizontal line
for (let x = point.x; x <= next.x; x++) {
this.buffer.setPixel(x, point.y, color);
}
} else {
this.buffer.setPixel(point.x, point.y, color);
}
}
}
}
Step 5: Processing Vector Tiles
const VectorTile = require("@mapbox/vector-tile").VectorTile;
const Protobuf = require("pbf");
const RBush = require("rbush");
const zlib = require("zlib");
class Tile {
async load(buffer) {
// Decompress if gzipped
if (this._isGzipped(buffer)) {
buffer = await new Promise((resolve, reject) => {
zlib.gunzip(buffer, (err, data) => {
err ? reject(err) : resolve(data);
});
});
}
// Parse Protobuf
this.tile = new VectorTile(new Protobuf(buffer));
// Process each layer
const layers = {};
for (const name in this.tile.layers) {
const layer = this.tile.layers[name];
const nodes = [];
for (let i = 0; i < layer.length; i++) {
const feature = layer.feature(i);
const geometries = feature.loadGeometry();
// Get style for this feature
const style = this.styler.getStyleFor(name, feature);
if (!style) continue;
// Convert geometries to nodes with boundaries
for (const points of geometries) {
nodes.push(
this._addBoundaries({
layer: name,
style,
points,
color: this._getColor(style),
}),
);
}
}
// Create spatial index for efficient querying
const tree = new RBush(18);
tree.load(nodes);
layers[name] = {
extent: layer.extent,
tree,
};
}
return layers;
}
}
Step 6: The Complete Rendering Pipeline
class Renderer {
async draw(center, zoom) {
this.canvas.clear();
// 1. Calculate visible tiles
const tiles = this._visibleTiles(center, zoom);
// 2. Fetch all tiles in parallel
await Promise.all(
tiles.map(async (tile) => {
tile.data = await this.tileSource.getTile(
tile.xyz.z,
tile.xyz.x,
tile.xyz.y,
);
// 3. Query spatial index for visible features
const scale = tile.data.layers[layerId].extent / tileSize;
tile.layers[layerId].features = tile.data.layers[layerId].tree.search({
minX: -tile.position.x * scale,
minY: -tile.position.y * scale,
maxX: (this.width - tile.position.x) * scale,
maxY: (this.height - tile.position.y) * scale,
});
}),
);
// 4. Draw features in correct order (background to foreground)
const drawOrder = ["water", "landuse", "building", "road", "poi_label"];
for (const layerId of drawOrder) {
for (const tile of tiles) {
for (const feature of tile.layers[layerId].features) {
this._drawFeature(tile, feature, scale);
}
}
}
// 5. Generate terminal output
return this.canvas.frame();
}
_drawFeature(tile, feature, scale) {
// Transform coordinates from tile space to screen space
const points = feature.points.map((p) => ({
x: Math.floor(tile.position.x + p.x / scale),
y: Math.floor(tile.position.y + p.y / scale),
}));
// Draw based on geometry type
switch (feature.style.type) {
case "line":
this.canvas.polyline(
points,
feature.color,
feature.style.paint["line-width"],
);
break;
case "fill":
this.canvas.polygon([points], feature.color);
break;
case "symbol":
this.canvas.text(
feature.label,
points[0].x,
points[0].y,
feature.color,
);
break;
}
}
}
Step 7: Coordinate Transformations
const utils = {
// Convert lat/lon to tile coordinates at zoom level
ll2tile(lon, lat, zoom) {
const n = Math.pow(2, zoom);
return {
x: ((lon + 180) / 360) * n,
y: ((1
- Math.log(
Math.tan((lat * Math.PI) / 180)
+ 1 / Math.cos((lat * Math.PI) / 180),
) / Math.PI) / 2) * n,
};
},
// Convert tile coordinates back to lat/lon
tile2ll(x, y, zoom) {
const n = Math.pow(2, zoom);
return {
lon: (x / n) * 360 - 180,
lat: (Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / n))) * 180) / Math.PI,
};
},
// Get tile size in pixels at fractional zoom
tilesizeAtZoom(zoom) {
return Math.pow(2, zoom - Math.floor(zoom)) * 256;
},
};
Complete Example: Drawing a Simple Map
const Canvas = require("./Canvas");
const x256 = require("x256");
// Create a 160x64 pixel canvas (80x16 terminal cells in Braille mode)
const canvas = new Canvas(160, 64);
// Define some colors (xterm-256 color codes)
const BLUE = x256([0, 0, 255]);
const GREEN = x256([0, 200, 0]);
const GRAY = x256([128, 128, 128]);
// Draw a river (polyline)
const riverPoints = [
{ x: 20, y: 50 },
{ x: 40, y: 45 },
{ x: 60, y: 40 },
{ x: 80, y: 35 },
{ x: 100, y: 30 },
];
canvas.polyline(riverPoints, BLUE, 3);
// Draw a building (polygon)
const buildingRing = [
{ x: 70, y: 20 },
{ x: 90, y: 20 },
{ x: 90, y: 40 },
{ x: 70, y: 40 },
];
canvas.polygon([buildingRing], GRAY);
// Draw a road (polyline)
const roadPoints = [
{ x: 0, y: 25 },
{ x: 160, y: 25 },
];
canvas.polyline(roadPoints, GRAY, 2);
// Draw a label
canvas.text("Town Hall", 80, 30, GRAY);
// Output to terminal
process.stdout.write(canvas.frame());
Performance Optimizations
MapSCII uses several clever optimizations:
- RBush Spatial Index: Only processes features visible in the viewport
- Polyline Simplification: Uses
simplify-jsto reduce point counts - Tile Padding: Renders slightly beyond viewport to avoid edge artifacts
- Draw Order Optimization: Different layer orders for different zoom levels
- Color Caching: Avoids repeated color conversions
Key Takeaways
To rasterize vector shapes on the terminal:
- Use Braille characters for 4x resolution boost (2×4 pixels per cell)
- Implement a pixel buffer that maps coordinates to Braille dot positions
- Use Bresenham’s algorithm for line rasterization
- Use Earcut for polygon triangulation and scanline filling
- Leverage xterm-256 colors with ANSI escape codes
- Transform coordinates from geographic space to screen space
- Use spatial indexing (RBush) for efficient viewport queries
The complete rendering pipeline is: Vector Tiles → Parse Protobuf → Query Spatial Index → Transform Coordinates → Rasterize to Pixels → Convert to Braille → Output ANSI Codes
You can explore more implementation details in the rastapasta/mapscii repository.