Skip to content

Muid-lab/tui-maps

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

tuimaps

License: MIT

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.

Demo

tuimaps screenshot

tuimaps demo

Note: Terminal must support true color (24-bit color) for proper rendering.

Quick Start

# 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

System Requirements

  • 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

Installation

From Source

go build -o tuimaps ./cmd/main.go
sudo mv tuimaps /usr/local/bin/  # optional: install system-wide

Configuration

Tile 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.

Key Developments

  • Map Rendering: Unicode half-block rendering with 24-bit true color via ANSI escape codes
  • Async Tile Loading: Parallel tile fetching with tea.Batch for 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)

Project Structure

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

Architecture

The project follows Clean Architecture principles (Robert C. Martin). Dependencies point inward — outer layers depend on inner layers, never the reverse.

Layer Order (inner to outer):

  1. Domain (internal/tiles/) — Tile coordinate math, no external dependencies
  2. Engine (internal/engine/) — Viewport and renderer, depends on domain only
  3. Providers (internal/tiles/fetcher.go, cache.go, internal/geo/) — HTTP fetching, disk cache, geocoding
  4. TUI (internal/tui/) — Bubbletea models, depends on engine interfaces

Core Types

// 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
}

Key Algorithms

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 + 1 tiles needed left and right of center
  • halfH = (screenH / 128) / 2 + 1 tiles needed above and below center
  • X wraps around (world is cyclic in longitude)
  • Y is clamped (Mercator projection breaks at poles)

Bubbletea Conventions

  • Model is immutable: Update returns a new Model, never mutates in place
  • tea.Cmd is func() tea.Msg — Bubbletea runs it in a goroutine automatically
  • tea.Batch runs multiple Cmds in parallel — used for concurrent tile fetching
  • tea.WithAltScreen() for fullscreen mode
  • WindowSizeMsg triggers initial tile load (width/height are 0 before it)
  • Tile loading: one Cmd per tile, results arrive as tileLoadedMsg or tileErrMsg

DiskCache Behavior

DiskCache is a decorator that wraps any TileSource as a fallback. Cache structure mirrors OSM path convention: {dir}/{z}/{x}/{y}.png.

On Fetch:

  1. Try to open file at cache path
  2. If found and decodable, return it (cache hit)
  3. Otherwise call fallback.Fetch()
  4. On success, save PNG to disk (non-fatal if save fails)
  5. Return the image

Cache directory: filepath.Join(os.UserCacheDir(), "tuimaps")

HTTP Fetcher Requirements

  • 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.Errorf and %w

Nominatim Geocoding

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=json and /reverse?...

API Documentation

Core Types

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

Key Functions

  • LatLonToTile(lat, lon float64, zoom int) TileCoord — Convert lat/lon to tile using Web Mercator
  • ViewportTiles(vp Viewport) []TileCoord — Get visible tiles for a viewport
  • DecodeHalfBlock(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 color
  • PreloadTiles(vp Viewport) []TileCoord — Get tiles to fetch (visible + 1 beyond each edge)
  • VisibleTiles(vp Viewport) []TileCoord — Get tiles to render (strictly within screen bounds)

Contributing

Contributions are welcome! Please:

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/name)
  3. Run tests (go test ./internal/tiles/... ./internal/renderer/... ./internal/geo/...)
  4. Run lint (go vet ./...)
  5. Submit a pull request

Development Guidelines

  • Every exported type and function has a doc comment
  • Errors wrapped with context using fmt.Errorf and %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 Bindings

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.

License

This project is licensed under the MIT License — see the LICENSE file for details.

About

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.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages