๐บ๏ธ TerminalMap
The entire world, rendered in your terminal. A high precision, interactive map viewer and embeddable Rust SDK that turns OpenStreetMap vector tiles into beautiful braille/ASCII art, right in any terminal.
Works offline at low zoom. No API keys. No external dependencies. Just a terminal.

๐ฆ Made with love using Rust | ๐ป For terminal lovers | ๐ Powered by OpenStreetMap
๐ค What is this
TerminalMap is two things:
- A standalone terminal app ๐ฅ๏ธ for browsing OpenStreetMap interactively with keyboard and mouse
- A Rust library (SDK) ๐ฆ you can embed in any TUI application to add live, zoomable maps
It renders Mapbox Vector Tiles (MVT/protobuf) using Unicode braille characters at 2x8 subpixel resolution per terminal cell, giving you smooth, detailed maps at every zoom level from continents down to individual streets.
๐ Who is this for?
- ๐ฆ Rustaceans who want a real world use case for terminal rendering
- ๐ป Terminal enthusiasts who live in the command line
- ๐บ๏ธ GIS and mapping nerds exploring vector tiles and OpenStreetMap data
- ๐งโ๐ป TUI developers building dashboards, DevOps tools, or monitoring UIs
- ๐จ Creative coders experimenting with braille art and Unicode rendering
- ๐ Open source advocates looking for API free, self contained mapping
- ๐ก DevOps and SRE teams who want geographic context in terminal dashboards
- ๐ Offline first builders needing maps without network dependencies
โจ Features
- ๐ค Renders OpenStreetMap vector tiles using Unicode braille characters for high resolution
- ๐ Smooth zoom from world view to street level (zoom 0 to 18)
- โจ๏ธ Keyboard and mouse navigation (pan, zoom, scroll)
- ๐ Full Mapbox Vector Tile (MVT/protobuf) parsing
- ๐จ Mapbox GL style support with layer filtering and color mapping
- ๐ท๏ธ Label collision detection to avoid overlapping text
- ๐บ Polygon triangulation and filled rendering
- ๐พ Tile caching (in memory LRU + persistent disk cache)
- ๐ฆ Embedded offline tiles for low zoom (world view works without network)
- ๐ Toggle between braille and ASCII block character rendering
- ๐ Marker system with shapes, colors, and animations (blink, pulse, flash)
- ๐ฌ Scriptable camera with smooth fly to animation between locations
- ๐ผ๏ธ Multiple independent map instances for dashboards and split views
- ๐งฉ Designed as a reusable library component with
MapStateAPI
๐ Quick Start
๐ฅ Install
Choose your preferred method:
Cargo (Rust)
Chocolatey (Windows)
choco install terminalmap
Winget (Windows)
winget install psmux.TerminalMap
Scoop (Windows)
scoop bucket add terminalmap https://github.com/psmux/scoop-terminalmap
scoop install terminalmap
APT (Debian/Ubuntu)
|
|
&&
From source
๐ฎ Controls
| Key | Action |
|---|---|
| Arrow keys / h,j,k,l | Pan the map |
| a / + | Zoom in |
| z / y / - | Zoom out |
| c | Toggle braille/ASCII mode |
| n | Toggle labels on/off |
| m | Toggle demo markers |
| g | Start/stop globe tour (auto camera) |
| t | Start/stop marker tour |
| w | Fit world view |
| Mouse scroll | Zoom in/out |
| q / Esc | Quit |
๐ฆ As a library (Map SDK)
TerminalMap is designed as a reusable map component you can embed in any Rust TUI application. Each MapState is an independent map instance with its own center, zoom, markers, and renderer. You can create as many as you need and place them wherever you want in your layout.
Add to your Cargo.toml:
[]
= { = "path/to/TerminalMap" }
= { = "1", = ["full"] }
= "1"
๐งช Minimal example
use MapConfig;
use MapState;
async
๐บ๏ธ Startup view recipes
Control exactly what the user sees when the map first loads:
use MapConfig;
use MapState;
// 1. Zoomed out world view (default behavior)
let mut map = new.await?;
map.set_size_from_terminal;
// zoom auto fits the terminal
// 2. Centered on a specific city
let config = MapConfig ;
let mut map = new.await?;
// 3. Fit the whole world (auto calculated zoom)
let mut map = new.await?;
map.set_size_from_terminal;
map.fit_world;
// 4. Country level view of Japan
let config = MapConfig ;
let mut map = new.await?;
// 5. Street level detail
let config = MapConfig ;
let mut map = new.await?;
โจ๏ธ Handling keyboard and mouse input
TerminalMap does not capture input on its own. You wire up whatever keys you want to the MapState methods. Here is a complete example using crossterm:
use ;
use MapConfig;
use MapState;
// In your event loop:
loop
The zoom/pan math is simple:
zoom_by(0.2)zooms in one step,zoom_by(-0.2)zooms outmove_by(dlat, dlon)pans by a delta. Divide by2^zoomso panning feels the same speed at any zoom levelset_center(lat, lon)jumps instantly to a coordinatefit_world()auto calculates zoom to show all landmass
๐ผ๏ธ Multiple maps side by side
Each MapState is fully independent. Create as many as you need for split views, dashboards, or comparison layouts:
let mut world = new.await?;
world.set_size;
world.fit_world; // auto zoom to show all landmass
let mut detail = new.await?;
detail.set_size;
let world_frame = world.render.await?;
let detail_frame = detail.render.await?;
// Position each frame wherever you want in your TUI layout
๐ Markers
TerminalMap includes a full marker system for plotting points of interest on the map. Markers support custom colors, shapes, animations, and labels.
Adding markers
use ;
// Simple colored dot (lat, lon, r, g, b)
map.add_marker;
// Or use xterm-256 color index directly
map.add_marker;
Marker shapes
| Shape | Code | Description |
|---|---|---|
| Dot | MarkerShape::Dot |
3x3 filled dot (default) |
| Cross | MarkerShape::Cross |
+ shaped cross |
| Diamond | MarkerShape::Diamond |
Diamond outline |
| Ring | MarkerShape::Ring(radius) |
Circle outline |
| Filled circle | MarkerShape::FilledCircle(radius) |
Solid circle |
| Character | MarkerShape::Char('X') |
Any Unicode character |
map.add_marker;
map.add_marker;
Marker animations
| Animation | Code | Effect |
|---|---|---|
| None | MarkerAnimation::None |
Static, always visible |
| Blink | MarkerAnimation::Blink |
On/off cycle (~400ms) |
| Flash | MarkerAnimation::Flash |
Rapid on/off (~150ms) |
| Pulse | MarkerAnimation::Pulse |
Ring radius grows and shrinks (use with Ring shape) |
// Blinking alert marker
map.add_marker;
// Pulsing radar effect
map.add_marker;
For animations to work, call map.advance_tick() on each frame/poll cycle in your event loop.
Managing markers
// Remove a marker by ID
map.remove_marker;
// Clear all markers
map.clear_markers;
// Read all markers
for marker in map.markers
// Check if animations need redraws
if map.has_animated_markers
๐งฉ MapState API reference
| Method | Description |
|---|---|
MapState::new(config) |
Create a new map instance |
set_size(w, h) |
Set canvas size in pixels (cols2, rows4) |
set_size_from_terminal(cols, rows) |
Set size from terminal dimensions |
render() |
Render the current view to an ANSI string |
zoom_by(step) |
Zoom in (positive) or out (negative) |
move_by(dlat, dlon) |
Pan the map by a lat/lon delta |
set_center(lat, lon) |
Jump to specific coordinates |
fit_world() |
Auto zoom to show all landmass |
toggle_braille() |
Switch between braille and ASCII block rendering |
toggle_labels() |
Toggle country/city/POI labels on/off |
footer() |
Get status text with current position and zoom |
add_marker(marker) |
Add a marker to the map |
remove_marker(id) |
Remove a marker by its ID |
clear_markers() |
Remove all markers |
markers() |
Get a reference to all markers |
advance_tick() |
Advance animation frame counter |
has_animated_markers() |
Check if any markers have animations |
start_globe_tour() |
Start a country level globe tour |
start_globe_tour_at(zoom) |
Start a globe tour at a specific zoom level |
start_marker_tour(zoom) |
Tour all markers at the given zoom |
toggle_camera() |
Toggle the camera on/off |
update_camera() |
Advance camera one frame (call each tick) |
camera() / camera_mut() |
Access the camera controller |
needs_animation_redraw() |
Check if markers or camera need redraws |
๐ฌ Camera / auto animation
TerminalMap includes a scriptable camera that smoothly flies the map between locations. It works out of the box with one line, or you can fully script your own tour.
Quick start (one liner)
// Country level globe tour (the default)
map.start_globe_tour;
// Or pick a zoom level: 2.0 = countries, 4.0 = regions, 6.0 = streets
map.start_globe_tour_at;
// Tour your own markers instead
map.start_marker_tour;
// Stop anytime
map.camera_mut.stop;
Zoom level guide
| Zoom | What you see | Good for |
|---|---|---|
| 1.0 | Continents | World overview |
| 2.0 | Countries | Globe tour default |
| 4.0 | Metro regions | City context |
| 6.0 | City streets | Street level detail |
| 8.0+ | Neighborhoods | Close inspection |
Custom scripted tour (5 lines per stop)
use ;
let mut cam = new;
cam.looping = true;
// Each waypoint: where to go, how zoomed in, how fast, how long to stay
cam.add_waypoint;
cam.add_waypoint;
// Load and go
*map.camera_mut = cam;
map.camera_mut.start;
Change zoom on the fly
Already have a tour but want to switch between country and city level?
// Switch an existing tour to street level
map.camera_mut.set_zoom;
// Or back to country level
map.camera_mut.set_zoom;
Activity driven camera
Point the camera at markers or events on your map. Combine markers with the camera to build a live dashboard that automatically pans to activity:
// Add markers for active locations
map.add_marker;
// Then tour all markers automatically
map.start_marker_tour;
New markers added later? Just rebuild the tour:
map.start_marker_tour; // picks up all current markers
Driving the camera in your event loop
Call update_camera() every tick. It returns true when the view changed:
loop
How the camera moves
The camera automatically handles all the smooth motion:
- Cubic ease in/out so movement feels natural, not jerky
- Zooms out slightly mid flight between cities, then back in at the destination
- Takes the shortest path around the globe (wraps across the antimeridian)
- Loops forever or plays once (set
cam.looping = falsefor one shot)
Camera API reference
| Method | Description |
|---|---|
Camera::new() |
Empty camera, add your own waypoints |
Camera::globe_tour(zoom) |
Pre-built world tour at the given zoom level |
Camera::from_markers(markers, zoom) |
Tour that visits each marker |
cam.add_waypoint(wp) |
Append a stop to the tour |
cam.set_zoom(zoom) |
Change zoom for all stops at once |
cam.start(lat, lon, zoom) |
Start from a position |
cam.stop() |
Stop the camera |
cam.toggle(lat, lon, zoom) |
Toggle on/off |
cam.is_active() |
Check if running |
cam.current_label() |
Label of current destination |
cam.tick() |
Advance one frame, returns (lat, lon, zoom) |
cam.looping |
true = loop forever, false = play once |
โ๏ธ MapConfig options
| Field | Default | Description |
|---|---|---|
initial_lat |
52.51298 |
Starting latitude |
initial_lon |
13.42012 |
Starting longitude |
initial_zoom |
None (auto) |
Starting zoom level, None fits the terminal |
max_zoom |
18.0 |
Maximum zoom level |
zoom_step |
0.2 |
Zoom increment per step |
source |
https://tiles.openfreemap.org/planet |
Tile server URL (TileJSON endpoint or direct prefix ending with /) |
style_data |
None (dark theme) |
Custom Mapbox GL style JSON string |
use_braille |
true |
Use braille characters for higher resolution |
tile_range |
14 |
Maximum tile zoom to request from server |
project_size |
256.0 |
Tile projection size |
label_margin |
5.0 |
Minimum spacing between labels |
poi_marker |
โ |
Character used for point of interest symbols |
show_labels |
true |
Show country/city names and POI labels |
๐๏ธ Architecture
TerminalMap/
src/
lib.rs -- Library root, re-exports all modules
main.rs -- Standalone TUI application
config.rs -- Configuration struct with defaults
widget.rs -- MapState: the reusable component API
marker.rs -- Marker system (shapes, animations, colors)
renderer.rs -- Vector tile to terminal frame renderer
canvas.rs -- Drawing primitives (lines, polygons, text)
braille.rs -- Braille/ASCII character buffer with colors
label.rs -- Label collision detection buffer
styler.rs -- Mapbox GL style parser and feature matcher
tile.rs -- Vector tile (MVT protobuf) decoder
tile_source.rs -- HTTP tile fetcher with caching
proto.rs -- Prost protobuf message definitions
utils.rs -- Map math (projections, color, simplification)
styles/
dark.json -- Dark theme (default)
bright.json -- Bright theme
๐ Tile Source
By default, TerminalMap fetches vector tiles from OpenFreeMap, a free, open source vector tile service with no API keys or usage limits. The tile data comes from OpenStreetMap. Low zoom levels (zoom 0 and 1) are embedded directly in the binary, so world view rendering works completely offline.
You can configure a custom vector tile server in two ways:
let mut config = default;
// TileJSON endpoint (recommended): TerminalMap fetches the TileJSON once
// and discovers the tile URL template automatically
config.source = "https://tiles.openfreemap.org/planet".to_string;
// Direct URL prefix: append {z}/{x}/{y}.pbf to the source
// (must end with `/`)
config.source = "https://your-tile-server.com/tiles/".to_string;
Tiles are expected in Mapbox Vector Tile (MVT) protobuf format. Both OpenMapTiles and Mapbox Streets v6 schemas are supported transparently.
๐ Credits
Inspired by mapscii by Michael Strassburger. Tile data from OpenStreetMap via OpenFreeMap.
โญ If you find this useful, give it a star! It helps others discover the project.
๐ฆ Built with Rust. ๐บ๏ธ Powered by OpenStreetMap. ๐ป Made for the terminal.
TerminalMap is free and open source. Contributions, issues, and feature requests are welcome!