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

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:

  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.