tutorials

MapSCII Rendering

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.

Architecture Overview

The rendering pipeline consists of several key components:

  1. TileSource - Fetches and manages vector tiles
  2. Tile - Parses vector tile data (Protobuf format)
  3. Renderer - Orchestrates the rendering process
  4. Canvas - Provides drawing primitives (lines, polygons)
  5. 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

```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);
    }
  }
}

Step 4: Drawing Filled Polygons with Earcut

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);
  }
}   } } ```

Step 5: Processing Vector Tiles

```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;   } } ```

Step 6: The Complete Rendering Pipeline

```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;
}   } } ```

Step 7: Coordinate Transformations

```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());

Performance Optimizations

MapSCII uses several clever optimizations:

  1. RBush Spatial Index: Only processes features visible in the viewport
  2. Polyline Simplification: Uses simplify-js to reduce point counts
  3. Tile Padding: Renders slightly beyond viewport to avoid edge artifacts
  4. Draw Order Optimization: Different layer orders for different zoom levels
  5. Color Caching: Avoids repeated color conversions

Key Takeaways

To rasterize vector shapes on the terminal:

  1. Use Braille characters for 4x resolution boost (2×4 pixels per cell)
  2. Implement a pixel buffer that maps coordinates to Braille dot positions
  3. Use Bresenham’s algorithm for line rasterization
  4. Use Earcut for polygon triangulation and scanline filling
  5. Leverage xterm-256 colors with ANSI escape codes
  6. Transform coordinates from geographic space to screen space
  7. 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.