# MVT Map Tiles Background Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Render OpenStreetMap vector tiles as a braille background layer in kmlcli's map panel, so KML data is overlaid on a real map.

**Architecture:** Fetch MVT tiles from OpenFreeMap (free, no API key) based on viewport, decode protobuf, extract geometries per layer, render as colored braille lines behind KML data. Async fetching via tokio + reqwest, in-memory LRU cache. The existing `DrawCmd` pipeline in `map.rs` is extended — tile segments are collected first (background), then KML segments (foreground).

**Tech Stack additions:** prost, reqwest (blocking), flate2, lru

---

## File Structure

```
src/
├── tiles/
│   ├── mod.rs         # re-exports
│   ├── proto.rs       # prost hand-written structs: Tile, Layer, Feature, Value
│   ├── decode.rs      # MVT geometry decoding: zigzag, commands → Vec<Vec<(f64, f64)>>
│   ├── math.rs        # lat/lon ↔ tile z/x/y, viewport → visible tile set
│   ├── fetch.rs       # HTTP tile fetcher + LRU cache
│   └── render.rs      # decoded tile → Vec<DrawCmd> with layer-based coloring
├── tui/
│   ├── map.rs         # MODIFY: add tile background layer before KML layer
│   └── app.rs         # MODIFY: add TileCache to App state, compute zoom level
├── projection.rs      # MODIFY: add zoom_level() method to Viewport
├── lib.rs             # MODIFY: add pub mod tiles
└── Cargo.toml         # MODIFY: add prost, reqwest, flate2, lru
```

---

### Task 1: Dependencies + Tile Math

**Files:**
- Modify: `Cargo.toml`
- Create: `src/tiles/mod.rs`
- Create: `src/tiles/math.rs`
- Modify: `src/lib.rs`
- Modify: `src/projection.rs`
- Create: `tests/tile_math_test.rs`

- [ ] **Step 1: Add dependencies to `Cargo.toml`**

Add to `[dependencies]`:

```toml
prost = "0.13"
reqwest = { version = "0.12", features = ["blocking"] }
flate2 = "1"
lru = "0.12"
```

We use `reqwest` with the `blocking` feature — no need for full tokio async. The TUI event loop runs synchronously, and tile fetches happen in a background thread with results sent via channel.

- [ ] **Step 2: Add `zoom_level()` to `Viewport` in `src/projection.rs`**

Add this method to the `Viewport` impl block, after `lon_span()`:

```rust
    /// Compute the OSM zoom level (0-18) that best matches the current viewport span.
    pub fn zoom_level(&self) -> u32 {
        // At zoom 0, the entire world (360°) fits in one tile
        // At zoom z, each tile covers 360 / 2^z degrees of longitude
        let z = (360.0 / (self.half_lon * 2.0)).log2();
        z.round().clamp(0.0, 18.0) as u32
    }
```

- [ ] **Step 3: Create `src/tiles/mod.rs`**

```rust
pub mod decode;
pub mod fetch;
pub mod math;
pub mod proto;
pub mod render;
```

- [ ] **Step 4: Create `src/tiles/math.rs`**

```rust
use std::f64::consts::PI;

/// Convert lat/lon to tile x/y at given zoom level.
pub fn ll2tile(lat: f64, lon: f64, zoom: u32) -> (u32, u32) {
    let n = 2.0_f64.powi(zoom as i32);
    let x = ((lon + 180.0) / 360.0 * n).floor() as u32;
    let lat_rad = lat.to_radians();
    let y = ((1.0 - (lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / PI) / 2.0 * n).floor() as u32;
    let max_tile = n as u32 - 1;
    (x.min(max_tile), y.min(max_tile))
}

/// Convert tile x/y at given zoom to the top-left corner lat/lon.
pub fn tile2ll(x: u32, y: u32, zoom: u32) -> (f64, f64) {
    let n = 2.0_f64.powi(zoom as i32);
    let lon = x as f64 / n * 360.0 - 180.0;
    let lat_rad = (PI * (1.0 - 2.0 * y as f64 / n)).sinh().atan();
    let lat = lat_rad.to_degrees();
    (lat, lon)
}

/// Tile key for caching.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TileCoord {
    pub z: u32,
    pub x: u32,
    pub y: u32,
}

/// Compute the set of tiles visible in the current viewport at the given zoom.
pub fn visible_tiles(
    min_lat: f64,
    max_lat: f64,
    min_lon: f64,
    max_lon: f64,
    zoom: u32,
) -> Vec<TileCoord> {
    let (x_min, y_max) = ll2tile(min_lat.max(-85.05), min_lon, zoom);
    let (x_max, y_min) = ll2tile(max_lat.min(85.05), max_lon, zoom);

    let mut tiles = Vec::new();
    for x in x_min..=x_max {
        for y in y_min..=y_max {
            tiles.push(TileCoord { z: zoom, x, y });
        }
    }
    tiles
}

/// Convert a point from tile-local coordinates (0..extent) to lat/lon.
pub fn tile_point_to_ll(tile: &TileCoord, px: f64, py: f64, extent: f64) -> (f64, f64) {
    let (top_lat, left_lon) = tile2ll(tile.x, tile.y, tile.z);
    let (bottom_lat, right_lon) = tile2ll(tile.x + 1, tile.y + 1, tile.z);

    let lon = left_lon + (px / extent) * (right_lon - left_lon);
    let lat = top_lat + (py / extent) * (bottom_lat - top_lat);
    (lat, lon)
}
```

- [ ] **Step 5: Register tiles module in `src/lib.rs`**

Add `pub mod tiles;` to `src/lib.rs`.

- [ ] **Step 6: Write tile math tests in `tests/tile_math_test.rs`**

```rust
use kmlcli::tiles::math::{ll2tile, tile2ll, visible_tiles, TileCoord};

#[test]
fn test_ll2tile_zoom_0() {
    let (x, y) = ll2tile(0.0, 0.0, 0);
    assert_eq!(x, 0);
    assert_eq!(y, 0);
}

#[test]
fn test_ll2tile_zoom_2_paris() {
    // Paris: ~48.86°N, 2.35°E
    let (x, y) = ll2tile(48.86, 2.35, 2);
    assert_eq!(x, 2);
    assert_eq!(y, 1);
}

#[test]
fn test_tile2ll_roundtrip() {
    let zoom = 10;
    let (x, y) = ll2tile(48.86, 2.35, zoom);
    let (lat, lon) = tile2ll(x, y, zoom);
    // Should be within one tile's span of original
    assert!((lat - 48.86).abs() < 1.0, "lat={lat}");
    assert!((lon - 2.35).abs() < 1.0, "lon={lon}");
}

#[test]
fn test_visible_tiles_returns_nonempty() {
    let tiles = visible_tiles(48.0, 49.0, 1.0, 3.0, 10);
    assert!(!tiles.is_empty());
    // At zoom 10, a 1°x2° area should have a few tiles
    assert!(tiles.len() > 1);
    assert!(tiles.len() < 100);
}

#[test]
fn test_visible_tiles_zoom_0_is_one_tile() {
    let tiles = visible_tiles(-85.0, 85.0, -180.0, 180.0, 0);
    assert_eq!(tiles.len(), 1);
    assert_eq!(tiles[0], TileCoord { z: 0, x: 0, y: 0 });
}
```

- [ ] **Step 7: Run tests**

```bash
cargo test --test tile_math_test
```

Expected: all 5 tests pass.

- [ ] **Step 8: Commit**

```bash
git add -A
git commit -m "add tile coordinate math and zoom level computation"
```

---

### Task 2: MVT Protobuf Structs + Geometry Decoding

**Files:**
- Create: `src/tiles/proto.rs`
- Create: `src/tiles/decode.rs`
- Create: `tests/decode_test.rs`

- [ ] **Step 1: Create `src/tiles/proto.rs`**

Hand-written prost structs matching the MVT spec (no codegen needed).

```rust
use prost::Message;

#[derive(Clone, Message)]
pub struct Tile {
    #[prost(message, repeated, tag = "3")]
    pub layers: Vec<Layer>,
}

#[derive(Clone, Message)]
pub struct Layer {
    #[prost(string, required, tag = "1")]
    pub name: String,
    #[prost(message, repeated, tag = "2")]
    pub features: Vec<Feature>,
    #[prost(string, repeated, tag = "3")]
    pub keys: Vec<String>,
    #[prost(message, repeated, tag = "4")]
    pub values: Vec<Value>,
    #[prost(uint32, optional, tag = "5")]
    pub extent: Option<u32>,
    #[prost(uint32, required, tag = "15")]
    pub version: u32,
}

#[derive(Clone, Message)]
pub struct Feature {
    #[prost(uint64, optional, tag = "1")]
    pub id: Option<u64>,
    #[prost(uint32, repeated, packed = "true", tag = "2")]
    pub tags: Vec<u32>,
    #[prost(enumeration = "GeomType", optional, tag = "3")]
    pub r#type: Option<i32>,
    #[prost(uint32, repeated, packed = "true", tag = "4")]
    pub geometry: Vec<u32>,
}

#[derive(Clone, Message)]
pub struct Value {
    #[prost(string, optional, tag = "1")]
    pub string_value: Option<String>,
    #[prost(float, optional, tag = "2")]
    pub float_value: Option<f32>,
    #[prost(double, optional, tag = "3")]
    pub double_value: Option<f64>,
    #[prost(int64, optional, tag = "4")]
    pub int_value: Option<i64>,
    #[prost(uint64, optional, tag = "5")]
    pub uint_value: Option<u64>,
    #[prost(sint64, optional, tag = "6")]
    pub sint_value: Option<i64>,
    #[prost(bool, optional, tag = "7")]
    pub bool_value: Option<bool>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[repr(i32)]
pub enum GeomType {
    Unknown = 0,
    Point = 1,
    LineString = 2,
    Polygon = 3,
}

impl GeomType {
    pub fn from_i32(val: i32) -> Self {
        match val {
            1 => Self::Point,
            2 => Self::LineString,
            3 => Self::Polygon,
            _ => Self::Unknown,
        }
    }
}
```

- [ ] **Step 2: Create `src/tiles/decode.rs`**

```rust
use super::proto::{Feature, GeomType, Tile};
use super::math::TileCoord;

/// A decoded tile feature with lat/lon coordinates.
pub struct DecodedFeature {
    pub layer: String,
    pub geom_type: GeomType,
    pub rings: Vec<Vec<(f64, f64)>>, // Vec of rings, each ring is Vec<(lon, lat)>
}

/// Decode all features from a protobuf Tile into lat/lon coordinates.
pub fn decode_tile(tile: &Tile, coord: &TileCoord) -> Vec<DecodedFeature> {
    let mut features = Vec::new();
    for layer in &tile.layers {
        let extent = layer.extent.unwrap_or(4096) as f64;
        let layer_name = &layer.name;

        for feature in &layer.features {
            let geom_type = feature
                .r#type
                .map(GeomType::from_i32)
                .unwrap_or(GeomType::Unknown);

            let rings = decode_geometry(&feature.geometry, coord, extent);
            if rings.is_empty() {
                continue;
            }

            features.push(DecodedFeature {
                layer: layer_name.clone(),
                geom_type,
                rings,
            });
        }
    }
    features
}

/// Decode MVT command-encoded geometry into rings of (lon, lat) points.
fn decode_geometry(
    geometry: &[u32],
    coord: &TileCoord,
    extent: f64,
) -> Vec<Vec<(f64, f64)>> {
    let mut rings: Vec<Vec<(f64, f64)>> = Vec::new();
    let mut current_ring: Vec<(f64, f64)> = Vec::new();
    let mut cx: i32 = 0;
    let mut cy: i32 = 0;
    let mut i = 0;

    while i < geometry.len() {
        let cmd_int = geometry[i];
        let cmd_id = cmd_int & 0x7;
        let count = (cmd_int >> 3) as usize;
        i += 1;

        match cmd_id {
            1 => {
                // MoveTo
                if !current_ring.is_empty() {
                    rings.push(std::mem::take(&mut current_ring));
                }
                for _ in 0..count {
                    if i + 1 >= geometry.len() {
                        break;
                    }
                    cx += zigzag_decode(geometry[i]);
                    cy += zigzag_decode(geometry[i + 1]);
                    i += 2;

                    let (lat, lon) = super::math::tile_point_to_ll(
                        coord,
                        cx as f64,
                        cy as f64,
                        extent,
                    );
                    current_ring.push((lon, lat));
                }
            }
            2 => {
                // LineTo
                for _ in 0..count {
                    if i + 1 >= geometry.len() {
                        break;
                    }
                    cx += zigzag_decode(geometry[i]);
                    cy += zigzag_decode(geometry[i + 1]);
                    i += 2;

                    let (lat, lon) = super::math::tile_point_to_ll(
                        coord,
                        cx as f64,
                        cy as f64,
                        extent,
                    );
                    current_ring.push((lon, lat));
                }
            }
            7 => {
                // ClosePath
                if let Some(first) = current_ring.first() {
                    current_ring.push(*first);
                }
            }
            _ => {}
        }
    }

    if !current_ring.is_empty() {
        rings.push(current_ring);
    }

    rings
}

fn zigzag_decode(val: u32) -> i32 {
    ((val >> 1) as i32) ^ (-((val & 1) as i32))
}
```

- [ ] **Step 3: Write decode tests in `tests/decode_test.rs`**

```rust
use kmlcli::tiles::decode::zigzag_decode_pub;

// We need to expose zigzag_decode for testing.
// Alternative: test via full tile decode with known data.

#[test]
fn test_zigzag_decode() {
    // 0 → 0, 1 → -1, 2 → 1, 3 → -2, 4 → 2
    assert_eq!(kmlcli::tiles::decode::zigzag_decode_pub(0), 0);
    assert_eq!(kmlcli::tiles::decode::zigzag_decode_pub(1), -1);
    assert_eq!(kmlcli::tiles::decode::zigzag_decode_pub(2), 1);
    assert_eq!(kmlcli::tiles::decode::zigzag_decode_pub(3), -2);
    assert_eq!(kmlcli::tiles::decode::zigzag_decode_pub(4), 2);
}
```

Add a public wrapper in `src/tiles/decode.rs`:

```rust
/// Public for testing.
pub fn zigzag_decode_pub(val: u32) -> i32 {
    zigzag_decode(val)
}
```

- [ ] **Step 4: Run tests**

```bash
cargo test --test decode_test
```

Expected: passes.

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "add MVT protobuf structs and geometry decoder"
```

---

### Task 3: Tile Fetcher + Cache

**Files:**
- Create: `src/tiles/fetch.rs`

- [ ] **Step 1: Create `src/tiles/fetch.rs`**

```rust
use std::io::Read;
use std::num::NonZeroUsize;
use std::sync::{Arc, Mutex};

use lru::LruCache;
use prost::Message;

use super::decode::{decode_tile, DecodedFeature};
use super::math::TileCoord;
use super::proto::Tile;

const TILE_URL_TEMPLATE: &str =
    "https://tiles.openfreemap.org/planet/{z}/{x}/{y}.pbf";
const CACHE_SIZE: usize = 64;

pub struct TileCache {
    cache: Arc<Mutex<LruCache<TileCoord, Vec<DecodedFeature>>>>,
    client: reqwest::blocking::Client,
}

impl TileCache {
    pub fn new() -> Self {
        Self {
            cache: Arc::new(Mutex::new(LruCache::new(
                NonZeroUsize::new(CACHE_SIZE).unwrap(),
            ))),
            client: reqwest::blocking::Client::builder()
                .user_agent("kmlcli/0.1")
                .build()
                .expect("Failed to build HTTP client"),
        }
    }

    /// Get decoded features for a tile. Returns from cache if available,
    /// otherwise fetches, decodes, caches, and returns.
    pub fn get(&self, coord: &TileCoord) -> Option<Vec<DecodedFeature>> {
        // Check cache first
        {
            let mut cache = self.cache.lock().unwrap();
            if let Some(features) = cache.get(coord) {
                return Some(features.clone());
            }
        }

        // Fetch from network
        let features = self.fetch_and_decode(coord)?;

        // Store in cache
        {
            let mut cache = self.cache.lock().unwrap();
            cache.put(*coord, features.clone());
        }

        Some(features)
    }

    /// Try to get from cache only (no network). For use during rendering
    /// when we don't want to block.
    pub fn get_cached(&self, coord: &TileCoord) -> Option<Vec<DecodedFeature>> {
        let mut cache = self.cache.lock().unwrap();
        cache.get(coord).cloned()
    }

    /// Fetch a tile from the network, decompress, decode.
    fn fetch_and_decode(&self, coord: &TileCoord) -> Option<Vec<DecodedFeature>> {
        let url = TILE_URL_TEMPLATE
            .replace("{z}", &coord.z.to_string())
            .replace("{x}", &coord.x.to_string())
            .replace("{y}", &coord.y.to_string());

        let response = self.client.get(&url).send().ok()?;
        if !response.status().is_success() {
            return None;
        }

        let bytes = response.bytes().ok()?;

        // Try gzip decompression first, fall back to raw
        let decompressed = decompress_gzip(&bytes).unwrap_or_else(|| bytes.to_vec());

        let tile = Tile::decode(decompressed.as_slice()).ok()?;
        Some(decode_tile(&tile, coord))
    }

    /// Prefetch tiles in a background thread. Tiles will be available
    /// via get_cached() once fetched.
    pub fn prefetch(&self, coords: Vec<TileCoord>) {
        let cache = self.cache.clone();
        let client = self.client.clone();

        std::thread::spawn(move || {
            for coord in coords {
                // Skip if already cached
                {
                    let cache_lock = cache.lock().unwrap();
                    if cache_lock.contains(&coord) {
                        continue;
                    }
                }

                let url = TILE_URL_TEMPLATE
                    .replace("{z}", &coord.z.to_string())
                    .replace("{x}", &coord.x.to_string())
                    .replace("{y}", &coord.y.to_string());

                if let Ok(response) = client.get(&url).send() {
                    if response.status().is_success() {
                        if let Ok(bytes) = response.bytes() {
                            let decompressed =
                                decompress_gzip(&bytes).unwrap_or_else(|| bytes.to_vec());
                            if let Ok(tile) = Tile::decode(decompressed.as_slice()) {
                                let features = decode_tile(&tile, &coord);
                                let mut cache_lock = cache.lock().unwrap();
                                cache_lock.put(coord, features);
                            }
                        }
                    }
                }
            }
        });
    }
}

fn decompress_gzip(data: &[u8]) -> Option<Vec<u8>> {
    let mut decoder = flate2::read::GzDecoder::new(data);
    let mut buf = Vec::new();
    decoder.read_to_end(&mut buf).ok()?;
    Some(buf)
}
```

NOTE: `DecodedFeature` must derive `Clone` — update `src/tiles/decode.rs` to add `#[derive(Clone)]` on `DecodedFeature`, and derive `Clone, Copy, Hash, Eq, PartialEq` on `GeomType` in proto.rs (already done above).

- [ ] **Step 2: Verify it compiles**

```bash
cargo build
```

Expected: compiles. We can't unit-test network fetching easily, but the compile check validates the API surface.

- [ ] **Step 3: Commit**

```bash
git add -A
git commit -m "add tile fetcher with LRU cache and background prefetch"
```

---

### Task 4: Tile Renderer (MVT → DrawCmd)

**Files:**
- Create: `src/tiles/render.rs`

- [ ] **Step 1: Create `src/tiles/render.rs`**

This converts decoded tile features into the existing `DrawCmd` format used by `map.rs`. It colors features by layer name.

```rust
use ratatui::style::Color;

use super::decode::DecodedFeature;
use super::proto::GeomType;
use crate::projection::Viewport;

/// Segment matching the DrawCmd format from map.rs.
pub struct TileSegment {
    pub x1: f64,
    pub y1: f64,
    pub x2: f64,
    pub y2: f64,
    pub color: Color,
}

/// Convert decoded tile features into line segments for Canvas rendering.
pub fn render_tile_features(
    features: &[DecodedFeature],
    viewport: &Viewport,
) -> Vec<TileSegment> {
    let mut segments = Vec::new();

    for feature in features {
        let color = layer_color(&feature.layer);
        if color.is_none() {
            continue; // Skip layers we don't render
        }
        let color = color.unwrap();

        match feature.geom_type {
            GeomType::LineString | GeomType::Polygon => {
                for ring in &feature.rings {
                    for window in ring.windows(2) {
                        let (lon1, lat1) = (window[0].0, window[0].1);
                        let (lon2, lat2) = (window[1].0, window[1].1);

                        let coord1 = crate::model::Coord {
                            lon: lon1,
                            lat: lat1,
                            alt: None,
                        };
                        let coord2 = crate::model::Coord {
                            lon: lon2,
                            lat: lat2,
                            alt: None,
                        };

                        let (x1, y1) = viewport.project_for_canvas(&coord1);
                        let (x2, y2) = viewport.project_for_canvas(&coord2);

                        segments.push(TileSegment {
                            x1,
                            y1,
                            x2,
                            y2,
                            color,
                        });
                    }
                }
            }
            GeomType::Point => {
                // Skip points for now — labels and POIs add clutter at this level
            }
            GeomType::Unknown => {}
        }
    }

    segments
}

/// Map OSM layer names to terminal colors.
fn layer_color(layer: &str) -> Option<Color> {
    match layer {
        "water" => Some(Color::Blue),
        "waterway" => Some(Color::Blue),
        "landuse" => Some(Color::DarkGray),
        "landcover" => Some(Color::DarkGray),
        "park" => Some(Color::Green),
        "transportation" => Some(Color::Rgb(80, 80, 80)),
        "building" => Some(Color::Rgb(60, 60, 60)),
        "boundary" => Some(Color::Rgb(100, 100, 100)),
        _ => None, // Skip unlisted layers (labels, POIs, etc.)
    }
}
```

- [ ] **Step 2: Verify it compiles**

```bash
cargo build
```

- [ ] **Step 3: Commit**

```bash
git add -A
git commit -m "add tile renderer mapping MVT layers to colored segments"
```

---

### Task 5: Integrate Tiles into Map Widget

**Files:**
- Modify: `src/tui/app.rs`
- Modify: `src/tui/map.rs`

This is the integration task — wire the tile system into the existing TUI.

- [ ] **Step 1: Add TileCache to App state in `src/tui/app.rs`**

Add import at top of app.rs:

```rust
use crate::tiles::fetch::TileCache;
```

Add field to `App` struct:

```rust
pub struct App {
    doc: KmlDocument,
    viewport: Viewport,
    focus: Focus,
    selected: usize,
    tree_items: Vec<TreeItem>,
    tree_scroll: usize,
    should_quit: bool,
    tile_cache: TileCache,
}
```

Initialize in `App::new()`:

```rust
        Self {
            doc,
            viewport,
            focus: Focus::Tree,
            selected: 0,
            tree_items,
            tree_scroll: 0,
            should_quit: false,
            tile_cache: TileCache::new(),
        }
```

- [ ] **Step 2: Trigger tile prefetch on viewport changes**

Add a method to App:

```rust
    fn prefetch_visible_tiles(&self) {
        let zoom = self.viewport.zoom_level();
        let x_bounds = self.viewport.x_bounds();
        let y_bounds_ll = self.viewport.lat_bounds();

        let tiles = crate::tiles::math::visible_tiles(
            y_bounds_ll[0],
            y_bounds_ll[1],
            x_bounds[0],
            x_bounds[1],
            zoom,
        );
        self.tile_cache.prefetch(tiles);
    }
```

Add `lat_bounds()` to `Viewport` in `src/projection.rs`:

```rust
    /// Returns lat bounds (for tile math — not mercator-projected).
    pub fn lat_bounds(&self) -> [f64; 2] {
        [self.center_lat - self.half_lat, self.center_lat + self.half_lat]
    }
```

Call `prefetch_visible_tiles()` at the end of `App::new()` and after each viewport-changing action in `handle_action()`. Add after each zoom/pan action:

```rust
            Action::ZoomIn => { self.viewport.zoom_in(); self.prefetch_visible_tiles(); }
            Action::ZoomOut => { self.viewport.zoom_out(); self.prefetch_visible_tiles(); }
            Action::PanLeft => { self.viewport.pan_left(); self.prefetch_visible_tiles(); }
            Action::PanRight => { self.viewport.pan_right(); self.prefetch_visible_tiles(); }
            Action::PanUp => { self.viewport.pan_up(); self.prefetch_visible_tiles(); }
            Action::PanDown => { self.viewport.pan_down(); self.prefetch_visible_tiles(); }
```

And in ToggleExpand when centering on a coord:

```rust
                        self.viewport.center_on(&coord);
                        self.prefetch_visible_tiles();
```

- [ ] **Step 3: Pass tile_cache to MapView in `src/tui/app.rs`**

Update the MapView construction in `draw()`:

```rust
        let map_view = MapView::new(
            &self.doc,
            &self.viewport,
            selected_path,
            self.focus == Focus::Map,
            &self.tile_cache,
        );
```

Same for the degraded layout (map-only) branch.

- [ ] **Step 4: Update MapView to accept and render tiles in `src/tui/map.rs`**

Update `MapView` struct:

```rust
use crate::tiles::fetch::TileCache;
use crate::tiles::math;
use crate::tiles::render::render_tile_features;
```

```rust
pub struct MapView<'a> {
    pub doc: &'a KmlDocument,
    pub viewport: &'a Viewport,
    pub selected_path: Option<&'a [usize]>,
    pub focused: bool,
    pub tile_cache: &'a TileCache,
}
```

Update `MapView::new()` to accept `tile_cache`:

```rust
    pub fn new(
        doc: &'a KmlDocument,
        viewport: &'a Viewport,
        selected_path: Option<&'a [usize]>,
        focused: bool,
        tile_cache: &'a TileCache,
    ) -> Self {
        Self {
            doc,
            viewport,
            selected_path,
            focused,
            tile_cache,
        }
    }
```

Update `widget()` to collect tile segments before KML segments:

```rust
    pub fn widget(self) -> impl ratatui::widgets::Widget + 'a {
        let x_bounds = self.viewport.x_bounds();
        let y_bounds = self.viewport.y_bounds();

        let border_style = if self.focused {
            ratatui::style::Style::default().fg(Color::Yellow)
        } else {
            ratatui::style::Style::default()
        };

        let block = Block::default()
            .borders(Borders::ALL)
            .title("Map")
            .border_style(border_style);

        // Collect tile background segments
        let zoom = self.viewport.zoom_level();
        let lat_bounds = self.viewport.lat_bounds();
        let visible = math::visible_tiles(
            lat_bounds[0],
            lat_bounds[1],
            x_bounds[0],
            x_bounds[1],
            zoom,
        );

        let mut tile_segments = Vec::new();
        for tc in &visible {
            if let Some(features) = self.tile_cache.get_cached(tc) {
                let segs = render_tile_features(&features, self.viewport);
                tile_segments.extend(segs);
            }
        }

        // Collect KML foreground segments
        let selected_path = self.selected_path.map(|p| p.to_vec());
        let kml_segments = collect_segments(self.doc, self.viewport, &selected_path);

        Canvas::default()
            .block(block)
            .x_bounds(x_bounds)
            .y_bounds(y_bounds)
            .marker(Marker::Braille)
            .paint(move |ctx| {
                // Background: tile map
                for seg in &tile_segments {
                    ctx.draw(&CanvasLine {
                        x1: seg.x1,
                        y1: seg.y1,
                        x2: seg.x2,
                        y2: seg.y2,
                        color: seg.color,
                    });
                }

                // Foreground: KML data
                for seg in &kml_segments {
                    match seg {
                        DrawCmd::Line { x1, y1, x2, y2, color } => {
                            ctx.draw(&CanvasLine {
                                x1: *x1,
                                y1: *y1,
                                x2: *x2,
                                y2: *y2,
                                color: *color,
                            });
                        }
                        DrawCmd::Point { x, y, color } => {
                            let coords = [(*x, *y)];
                            ctx.draw(&Points {
                                coords: &coords,
                                color: *color,
                            });
                        }
                    }
                }
            })
    }
```

- [ ] **Step 5: Update KML colors for contrast**

In `collect_feature_segments()` in `map.rs`, change the non-selected KML color to stand out from the tile background:

```rust
                let color = if is_selected {
                    Color::Yellow
                } else {
                    Color::White
                };
```

- [ ] **Step 6: Verify it compiles**

```bash
cargo build
```

- [ ] **Step 7: Test manually**

```bash
cargo run -- tests/fixtures/multi.kml
```

Expected: Map panel shows OSM tile data as background (blue water, gray roads) with KML data overlaid in white/yellow. First render may take a moment while tiles fetch. Pan/zoom should trigger new tile fetches.

- [ ] **Step 8: Commit**

```bash
git add -A
git commit -m "integrate MVT tile background into map widget"
```

---

### Task 6: Polish — Loading Indicator + Error Resilience

**Files:**
- Modify: `src/tui/app.rs`
- Modify: `src/tui/map.rs`

- [ ] **Step 1: Add loading state tracking**

Add to `App` struct:

```rust
    tiles_loading: bool,
```

Initialize to `true` in `App::new()`. Set to `false` when tiles are available (check in `draw()`).

In the status bar in `draw()`, show a loading indicator when tiles are pending:

```rust
        let tile_status = {
            let zoom = self.viewport.zoom_level();
            let lat_bounds = self.viewport.lat_bounds();
            let x_bounds = self.viewport.x_bounds();
            let visible = crate::tiles::math::visible_tiles(
                lat_bounds[0], lat_bounds[1], x_bounds[0], x_bounds[1], zoom,
            );
            let cached = visible.iter().filter(|tc| self.tile_cache.get_cached(tc).is_some()).count();
            if cached < visible.len() {
                format!(" | tiles: {}/{}", cached, visible.len())
            } else {
                String::new()
            }
        };

        let status_text = format!(
            " {doc_name} | [{focus_label}] | z{zoom}{tile_status} | [q]uit [tab]focus [j/k]nav [+/-]zoom [hjkl]pan",
            zoom = self.viewport.zoom_level(),
        );
```

- [ ] **Step 2: Cap zoom level for tile fetching**

In `prefetch_visible_tiles()`, cap at zoom 16 to avoid fetching too many tiles:

```rust
    fn prefetch_visible_tiles(&self) {
        let zoom = self.viewport.zoom_level().min(16);
        // ...
    }
```

Same cap in `map.rs` when computing visible tiles.

- [ ] **Step 3: Limit tile count per render**

In `map.rs`, cap `visible` to avoid rendering too many tiles at once:

```rust
        let visible = math::visible_tiles(
            lat_bounds[0], lat_bounds[1],
            x_bounds[0], x_bounds[1],
            zoom,
        );
        // Don't render more than 16 tiles at once
        let visible: Vec<_> = visible.into_iter().take(16).collect();
```

- [ ] **Step 4: Test manually**

```bash
cargo run -- tests/fixtures/multi.kml
```

Expected: Status bar shows zoom level and tile loading progress. Tiles load progressively. No crash on rapid zoom/pan.

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "add tile loading indicator, zoom cap, and render limits"
```

---

## Summary

| Task | What | Files |
|------|------|-------|
| 1 | Tile math + zoom level | tiles/math.rs, projection.rs |
| 2 | MVT protobuf + geometry decoder | tiles/proto.rs, tiles/decode.rs |
| 3 | Tile fetcher + LRU cache | tiles/fetch.rs |
| 4 | Tile renderer (layers → DrawCmd) | tiles/render.rs |
| 5 | Integration into map widget | tui/app.rs, tui/map.rs |
| 6 | Polish: loading, limits, resilience | tui/app.rs, tui/map.rs |

Tasks 1-4 build the tile pipeline. Task 5 wires it in. Task 6 hardens it.
