A terminal-based map viewer (TUI) written in Go. Displays OpenStreetMap tiles directly in your terminal using Unicode half-block characters with true-color ANSI rendering.
Note: Terminal must support true color (24-bit color) for proper rendering.
# 1. Clone the repository
git clone https://github.com/user/tuimaps.git && cd tuimaps
# 2. Build the binary
go build -o tuimaps ./cmd/main.go
# 3. Run it
./tuimaps- Go: 1.18 or later (to build from source)
- Terminal: Must support true color (24-bit ANSI colors) and Unicode
- Network: Internet connection to fetch OpenStreetMap tiles
- OS: Linux, macOS, or Windows
go build -o tuimaps ./cmd/main.go
sudo mv tuimaps /usr/local/bin/ # optional: install system-wideTile cache is stored at {UserCacheDir}/tuimaps/{z}/{x}/{y}.png. Logs are written to tuimaps.log in the working directory.
Nominatim Geocoding: The internal/geo/nominatim.go file implements forward and reverse geocoding using the Nominatim API. It respects the Nominatim usage policy with a 1-second rate limit between requests and identifies itself with a User-Agent containing contact information (email@example.com). The search returns up to 5 results; reverse geocoding returns the closest place to given coordinates.
No additional configuration required — tuimaps works out of the box with OpenStreetMap tile servers and Nominatim for search.
- Map Rendering: Unicode half-block rendering with 24-bit true color via ANSI escape codes
- Async Tile Loading: Parallel tile fetching with
tea.Batchfor smooth navigation - Tile Preloading: Fetches tiles beyond visible area for seamless panning
- Disk Caching: Decorator-pattern cache layer stores tiles locally at
{UserCacheDir}/tuimaps/ - Geocoding Search: Integrated Nominatim API for forward and reverse geocoding (
/key) - Clean Architecture: Inward-pointing dependencies with domain, renderer, engine, and TUI layers
- Rate-Limited API Calls: Nominatim requests are throttled to respect OSM policy (1 req/sec)
tuimaps/
├── cmd/
│ └── main.go # Entry point, sets up logging, tile source, geo search, and TUI
├── internal/
│ ├── tiles/ # Tile coordinate math and fetching
│ │ ├── coord.go # TileCoord, LatLonToTile, ViewportTiles, TileURL (Web Mercator)
│ │ ├── source.go # TileSource interface (Fetch method)
│ │ ├── fetcher.go # HTTPFetcher: 10s timeout, User-Agent, OSM tile URL format
│ │ ├── cache.go # DiskCache: decorator over TileSource, saves to {dir}/{z}/{x}/{y}.png
│ │ └── fake_fetcher.go # FakeFetcher for tests (no network/filesystem)
│ ├── renderer/ # Map rendering
│ │ ├── cell.go # Cell type (Char="▀", FG/BG colors) for half-block mode
│ │ ├── halfblock.go # DecodeHalfBlock: image → [][]Cell (1 cell = 2 pixels vertical)
│ │ └── ansi.go # RenderGrid: [][]Cell → ANSI string (38;2;R;G;B / 48;2;R;G;B)
│ ├── engine/ # Core engine
│ │ ├── viewport.go # Viewport: pan, zoom (0-19), coordinate math, preload area
│ │ └── engine.go # MapEngine: ties TileSource + Renderer, composites multi-tile output
│ ├── geo/ # Geographic search
│ │ ├── search.go # GeoSearch interface (Search, Reverse methods)
│ │ ├── nominatim.go # NominatimSearch: forward/reverse geocoding via nominatim.openstreetmap.org
│ │ └── fake_search.go # FakeSearch for testing
│ └── tui/ # Bubbletea TUI
│ ├── model.go # Bubbletea Model (Init, Update, View), immutable updates
│ ├── messages.go # Custom tea.Msg types (tileLoadedMsg, tileErrMsg, searchMsg)
│ ├── keys.go # Key bindings: arrows/WASD pan, +/- zoom, / search, q quit
│ └── search.go # Search UI model (input field with Lip Gloss styling)
├── go.mod
└── go.sum
The project follows Clean Architecture principles (Robert C. Martin). Dependencies point inward — outer layers depend on inner layers, never the reverse.
- Domain (
internal/tiles/) — Tile coordinate math, no external dependencies - Engine (
internal/engine/) — Viewport and renderer, depends on domain only - Providers (
internal/tiles/fetcher.go,cache.go,internal/geo/) — HTTP fetching, disk cache, geocoding - TUI (
internal/tui/) — Bubbletea models, depends on engine interfaces
// TileCoord identifies a single map tile in the XYZ tile scheme.
type TileCoord struct {
X, Y, Z int
}
// TileSource is the only abstraction MapEngine uses to obtain tiles.
// Implementations: HTTPFetcher, DiskCache, FakeFetcher (tests).
type TileSource interface {
Fetch(coord TileCoord) (image.Image, error)
}
// Cell represents one terminal character cell on the rendered map.
type Cell struct {
Char string // always "▀" in half-block mode
FG color.RGBA // top pixel color → ANSI foreground
BG color.RGBA // bottom pixel color → ANSI background
}Tile Coordinate Math (Web Mercator projection):
n := math.Pow(2, float64(zoom))
x := int((lon + 180.0) / 360.0 * n)
latRad := lat * math.Pi / 180.0
y := int((1.0 - math.Log(math.Tan(latRad)+1.0/math.Cos(latRad))/math.Pi) / 2.0 * n)Half-Block Decoding — one terminal cell covers 1×2 pixels:
- Top pixel → FG color (filled part of
▀) - Bottom pixel → BG color (empty part of
▀) image.RGBA().RGBA()returns uint32 in range 0–65535, shift right by 8 for uint8
ANSI True Color Format:
\033[38;2;R;G;Bm - set foreground
\033[48;2;R;G;Bm - set background
\033[0m - reset (emit at end of every row)
Viewport Tile Coverage:
- One tile is 256×256 pixels
- In half-block mode: 256 cols wide, 128 rows tall (two pixels per cell vertically)
halfW = (screenW / 256) / 2 + 1tiles needed left and right of centerhalfH = (screenH / 128) / 2 + 1tiles needed above and below center- X wraps around (world is cyclic in longitude)
- Y is clamped (Mercator projection breaks at poles)
- Model is immutable:
Updatereturns a new Model, never mutates in place tea.Cmdisfunc() tea.Msg— Bubbletea runs it in a goroutine automaticallytea.Batchruns multiple Cmds in parallel — used for concurrent tile fetchingtea.WithAltScreen()for fullscreen modeWindowSizeMsgtriggers initial tile load (width/height are 0 before it)- Tile loading: one Cmd per tile, results arrive as
tileLoadedMsgortileErrMsg
DiskCache is a decorator that wraps any TileSource as a fallback. Cache structure mirrors OSM path convention: {dir}/{z}/{x}/{y}.png.
On Fetch:
- Try to open file at cache path
- If found and decodable, return it (cache hit)
- Otherwise call
fallback.Fetch() - On success, save PNG to disk (non-fatal if save fails)
- Return the image
Cache directory: filepath.Join(os.UserCacheDir(), "tuimaps")
- Timeout: 10 seconds
- User-Agent header required by OSM policy — must be non-empty and identify the app
- Tile URL format:
https://tile.openstreetmap.org/{z}/{x}/{y}.png - On non-200 response: return descriptive error including status code and coord
- Error wrapping throughout with
fmt.Errorfand%w
NominatimSearch implements the GeoSearch interface with forward and reverse geocoding:
- Forward Search:
Search(ctx, query)returns up to 5 places matching the query - Reverse Search:
Reverse(ctx, lat, lon)returns the closest place to coordinates - Rate Limiting: Enforces 1-second minimum interval between requests (Nominatim policy)
- User-Agent:
tuimaps/0.1 (contact: email@example.com) - API Endpoints:
https://nominatim.openstreetmap.org/search?q=...&format=jsonand/reverse?...
| Type | Location | Description |
|---|---|---|
TileCoord |
internal/tiles/coord.go |
Tile coordinates (X, Y, Z) in XYZ tile scheme |
TileSource |
internal/tiles/source.go |
Interface for fetching tiles (Fetch method) |
Cell |
internal/renderer/cell.go |
Rendered cell with Char (▀), FG, BG |
Viewport |
internal/engine/viewport.go |
Pan/zoom state, coordinate math, preload area |
MapEngine |
internal/engine/engine.go |
Core rendering engine, composites multi-tile output |
GeoSearch |
internal/geo/search.go |
Interface for geocoding (Search, Reverse) |
NominatimSearch |
internal/geo/nominatim.go |
Nominatim API implementation with rate limiting |
LatLonToTile(lat, lon float64, zoom int) TileCoord— Convert lat/lon to tile using Web MercatorViewportTiles(vp Viewport) []TileCoord— Get visible tiles for a viewportDecodeHalfBlock(img image.Image) [][]Cell— Decode image to cell grid (1 cell = 2 pixels)RenderGrid(grid [][]Cell) string— Render cell grid to ANSI string with true colorPreloadTiles(vp Viewport) []TileCoord— Get tiles to fetch (visible + 1 beyond each edge)VisibleTiles(vp Viewport) []TileCoord— Get tiles to render (strictly within screen bounds)
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch (
git checkout -b feature/name) - Run tests (
go test ./internal/tiles/... ./internal/renderer/... ./internal/geo/...) - Run lint (
go vet ./...) - Submit a pull request
- Every exported type and function has a doc comment
- Errors wrapped with context using
fmt.Errorfand%w - No global state — everything passed via constructor arguments
- Interfaces defined in the package that uses them, not the package that implements them
- No
init()functions - Use table-driven tests for coordinate math
- Tests must not touch network or filesystem (use
FakeFetcher,FakeSearch)
| Key | Action |
|---|---|
| Arrow keys / WASD | Pan the map (step scales with zoom) |
+ or = |
Zoom in (0–19 range) |
- |
Zoom out |
/ |
Search for a location (Nominatim) |
q or Ctrl+C |
Quit |
Pan step formula: 0.001 * (20 - zoom) — larger steps at low zoom for consistent feel.
Zoom behavior: On zoom change, tileGrids map is cleared entirely — old zoom tiles are invalid.
Status bar: Rendered at bottom of screen showing zoom level, current lat/lon (4 decimal places), and loading/error state.
This project is licensed under the MIT License — see the LICENSE file for details.

