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:
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.
The rendering pipeline consists of several key components:
{
"@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
}
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!
```javascript name=braille-buffer-example. js 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
```javascript name=line-drawing.js
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);
}
}
}
For filled areas (buildings, water bodies), MapSCII uses polygon triangulation:
```javascript name=polygon-drawing.js 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);
}
} } } ```
```javascript name=tile-processing.js 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; } } ```
```javascript name=renderer.js 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;
} } } ```
```javascript name=coordinate-utils.js 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
```javascript name=simple-map-example.js
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());
MapSCII uses several clever optimizations:
simplify-js to reduce point countsTo rasterize vector shapes on the terminal:
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.