nigig-org/REVIEWS/docs/map-rewrite-plan.md
2026-07-26 19:38:26 +03:00

867 lines
28 KiB
Markdown

# Map Renderer Rewrite Plan
**Date:** 2026-07-26
**Status:** Draft
**Scope:** `makepad/widgets/src/map/` (8 files, ~9,000 lines)
**Constraint:** Preserve Makepad Widget/Script API, all 184+ existing tests, downstream book.rs DSL
---
## Problem Statement
The renderer is a monolithic `MapView` struct (1,882 lines) that owns viewport, scheduling,
caching, rendering, labels, interaction, and style in one object. The review identified:
1. **Modular architecture** — 8.5/10: Good ideas, but everything lives in one struct
2. **Performance** — 8/10: CPU-bound draw, unnecessary HashMap lookups, per-frame recomputation
3. **Responsibility boundaries** — Scheduler knows about rendering, rendering knows about workers
4. **Hidden state machine**`TileLoadState` scattered as `if loading/if ready/if cached/if dirty`
5. **Cache ownership** — Who evicts? Who marks stale? Who retries? Not obvious
6. **Mutable state** — Dozens of fields mutated from multiple code paths
7. **No render graph** — Everything funnels into one draw path
8. **Long functions** — 200-500 line methods with deep nesting
---
## Target Architecture
### Current Module Structure
```
map/
mod.rs (13 lines) — pub exports
view.rs (1882 lines) — MapView widget: EVERYTHING
tile.rs (2163 lines) — Tile types + loading + tessellation + disk cache
geometry.rs (1968 lines) — Projection, DP, tessellation (well-factored)
label.rs (1098 lines) — Label extraction, scoring, collision grid
style.rs (496 lines) — Theme system
style_json.rs(3227 lines) — JSON parser + tests (behind map_style feature)
asset_loader.rs (124 lines) — Sprite/glyph caches
sprite.rs (433 lines) — POI icon classification
```
### Proposed Module Structure
```
map/
mod.rs — pub exports (MapView + subsystems for testing)
view.rs — THIN Widget shell (~400 lines): DSL, #[live] props, delegates to subsystems
viewport.rs — NEW: ViewportState (center, zoom, screen<->world, wrap/clamp)
scheduler.rs — NEW: TileScheduler (request queue, retry, prioritization, cancellation)
cache.rs — NEW: TileCache (HashMap, eviction, dedup, generation, GC)
renderer.rs — NEW: RenderPass (fill, stroke, POI, label passes with explicit ordering)
label_state.rs — NEW: LabelState (all scratch buffers, collision grid, placement)
tile.rs — SLIMMED: Tile types only (TileKey, TileEntry, TileLoadState enum, message types)
tile_decode.rs — NEW: MVT parsing, tessellation, Overpass body processing (extracted from tile.rs)
tile_disk.rs — NEW: Disk cache read/write, mbtiles batch loading (extracted from tile.rs)
geometry.rs — UNCHANGED
label.rs — UNCHANGED (scoring/extraction only)
style.rs — UNCHANGED
style_json.rs — UNCHANGED
asset_loader.rs — UNCHANGED
sprite.rs — UNCHANGED
```
**Total new files:** 6
**Total modified files:** 3 (view.rs, tile.rs, mod.rs)
**Total unchanged files:** 6 (geometry, label, style, style_json, asset_loader, sprite)
---
## New Types — Detailed Design
### 1. `ViewportState` (viewport.rs, ~150 lines)
**Addresses:** Review points #1, #4 (mutable state, coupling)
Extracts all viewport/coordinate logic from `view.rs`. No dependency on tiles, workers, or rendering.
```rust
pub struct ViewportState {
center_norm: Vec2d, // [0,1] Web Mercator
zoom: f64,
min_zoom: f64,
max_zoom: f64,
view_rect: Rect, // pixel rect of widget area
}
// Pure functions, no Cx dependency
impl ViewportState {
pub fn view_zoom(&self) -> f64;
pub fn request_zoom_level(&self, use_local: bool) -> u32;
pub fn world_size(&self) -> f64; // 256 * 2^zoom
pub fn center_world(&self) -> Vec2d; // center_norm * world_size
pub fn map_offset(&self) -> Vec2f; // screen offset for GPU uniforms
pub fn wrap_and_clamp(&mut self);
pub fn set_rect(&mut self, rect: Rect);
// Interaction
pub fn apply_drag(&mut self, delta_pixels: Vec2d);
pub fn apply_pinch(&mut self, initial_zoom: f64, initial_center: Vec2d,
initial_distance: f64, new_distance: f64, center: Vec2d);
pub fn apply_scroll(&mut self, scroll: f64, anchor_abs: Vec2d);
// Coordinate transforms
pub fn screen_to_world(&self, screen_pos: Vec2d) -> Vec2d;
pub fn world_to_screen(&self, world_pos: Vec2d) -> Vec2d;
pub fn tile_world_size_zoom(zoom: f64) -> f64; // static
// Visible tile computation
pub fn visible_tile_keys(&self) -> Vec<TileKey>;
// Property sync
pub fn sync_from_properties(&mut self, center_lon: f64, center_lat: f64, zoom: f64);
}
```
**Current code moving here:**
- `view_zoom()`, `request_zoom_level()` (view.rs:1851-1863)
- `wrap_and_clamp_center()` (view.rs:1064-1067)
- `zoom_with_anchor()` (view.rs:1069-1100)
- `visible_tile_keys()` (view.rs:1185-1226)
- Part of `on_after_apply` that sets center_norm, zoom
- Part of `draw_walk` that computes map_offset
- Part of `handle_event` FingerMove/FingerDown/FingerUp interaction
---
### 2. `TileCache` (cache.rs, ~200 lines)
**Addresses:** Review points #3, #4, #19 (cache ownership, mutable state, memory growth)
Single owner of tile lifetime. Every mutation goes through `TileCache` methods.
```rust
pub struct TileCache {
tiles: HashMap<TileKey, TileEntry>,
frame_counter: u64,
style_epoch: u64,
max_tiles: usize, // default 640
stale_frame_threshold: u64, // 240 frames
}
```
**Explicit `TileLoadState` enum** (review point #5 — replace hidden state machine):
```rust
pub enum TileLoadState {
Missing,
LoadingLocal,
LoadingNetwork,
Ready {
fill_geometry: Option<Geometry>,
stroke_geometry: Option<Geometry>,
feature_count: usize,
labels: Vec<Label>,
pois: Vec<PoiFeature>,
},
Failed {
retry_after: u64,
attempts: u8,
},
}
```
**Methods:**
```rust
impl TileCache {
// Query
pub fn get(&self, key: TileKey) -> Option<&TileEntry>;
pub fn get_mut(&mut self, key: TileKey) -> Option<&mut TileEntry>;
pub fn is_ready(&self, key: TileKey) -> bool;
pub fn is_loading(&self, key: TileKey) -> bool;
pub fn is_failed(&self, key: TileKey) -> bool;
pub fn find_ready_ancestor(&self, key: TileKey) -> Option<TileKey>;
pub fn find_ready_descendants(&self, key: TileKey) -> Vec<TileKey>;
pub fn ready_count(&self, visible: &[TileKey]) -> usize;
pub fn loading_count(&self) -> usize;
// Mutation
pub fn insert_loading(&mut self, key: TileKey, state: TileLoadState);
pub fn insert_ready(&mut self, cx: &mut Cx, key: TileKey, buffers: TileBuffers);
pub fn mark_failed(&mut self, key: TileKey, reason: &str);
pub fn tick(&mut self); // advance frame_counter
// Eviction
pub fn evict(&mut self, visible: &HashSet<TileKey>, target_zoom: u32);
// Theme change
pub fn clear_all(&mut self); // bumps style_epoch, clears everything
pub fn style_epoch(&self) -> u64;
// Status
pub fn status_counts(&self, visible: &[TileKey]) -> TileStatusCounts;
}
```
**Current code moving here:**
- All `self.tiles.*` operations from view.rs
- `insert_ready_tile()` (view.rs:813-846)
- `mark_tile_failed()` (view.rs:1039-1062)
- `tile_is_ready()`, `find_ready_ancestor()`, `fill_ready_descendants()` (view.rs:1256-1296)
- Eviction block (view.rs:1163-1181)
- `apply_theme_change()` tile clearing (view.rs:788-797)
---
### 3. `TileScheduler` (scheduler.rs, ~250 lines)
**Addresses:** Review points #1, #2, #16, #17, #18 (scheduler/renderer coupling, race conditions, worker starvation)
Owns the request queue, retry logic, priority, and cancellation. Does NOT know about rendering.
```rust
pub struct TileScheduler {
visible_tiles: Vec<TileKey>,
local_requested: HashSet<TileKey>,
local_missing: HashSet<TileKey>,
request_to_tile: HashMap<LiveId, PendingTileRequest>,
next_request_id: u64,
pending_count: usize,
local_source_missing_logged: bool,
}
```
**Generation tracking** (review point #16 — prevent old tiles overwriting new):
Every request carries a `generation: u64` that is checked when the result arrives.
The generation is incremented on zoom change and theme change.
```rust
pub struct PendingTileRequest {
pub tile_key: TileKey,
pub endpoint: &'static str,
pub generation: u64,
}
```
```rust
pub enum TileAction {
// Caller should dispatch these
LoadLocalBatch {
mbtiles_path: PathBuf,
cache_dir: String,
requested: Vec<TileKey>,
theme: CompiledMapTheme,
style_epoch: u64,
generation: u64,
},
LoadFromDiskCache {
tile_key: TileKey,
cache_path: PathBuf,
theme: CompiledMapTheme,
style_epoch: u64,
generation: u64,
},
LoadFromNetwork {
request_id: LiveId,
http_request: HttpRequest,
tile_key: TileKey,
generation: u64,
},
Nothing,
}
```
**Methods:**
```rust
impl TileScheduler {
// Core scheduling
pub fn update(&mut self, cache: &TileCache, viewport: &ViewportState,
config: &SchedulerConfig) -> Vec<TileAction>;
pub fn on_batch_loaded(&mut self, requested: Vec<TileKey>, loaded: Vec<TileKey>);
pub fn on_batch_failed(&mut self, requested: Vec<TileKey>);
pub fn on_tile_loaded(&mut self, tile_key: TileKey);
pub fn on_tile_failed(&mut self, tile_key: TileKey);
// HTTP integration
pub fn register_http_request(&mut self, request_id: LiveId, tile_key: TileKey, generation: u64);
pub fn on_http_response(&mut self, request_id: LiveId) -> Option<(TileKey, u64)>;
pub fn on_http_error(&mut self, request_id: LiveId) -> Option<(TileKey, u64)>;
// Theme change
pub fn reset_generation(&mut self); // on theme change
// Visible tile management
pub fn visible_tiles(&self) -> &[TileKey];
}
pub struct SchedulerConfig {
pub use_network: bool,
pub use_local_mbtiles: bool,
pub max_pending_requests: usize, // default 2
pub max_local_tile_batch: usize, // default 10
pub max_tile_retries: u8, // default 6
pub local_mbtiles_path: String,
pub local_tile_cache_dir: String,
}
```
**Current code moving here:**
- `request_visible_tiles_from_local_source()` (view.rs:961-1037)
- `request_tile()` (view.rs:1298-1375)
- Part of `ensure_visible_tiles()` that does retry/request logic (view.rs:1109-1181)
- `handle_tile_worker_messages()` dispatch logic (view.rs:848-936)
- `handle_http_response()` dispatch logic (view.rs:658-727)
- `handle_http_request_error()` dispatch logic (view.rs:729-748)
---
### 4. `RenderPass` (renderer.rs, ~200 lines)
**Addresses:** Review points #11 (render graph), #8 (CPU bound)
Explicit ordered passes with clear separation.
```rust
pub struct RenderPass;
impl RenderPass {
pub fn draw_fill(
cx: &mut Cx2d,
draw_map: &mut DrawMapVector,
cache: &TileCache,
draw_keys: &[TileKey],
view_zoom: f64,
map_offset: Vec2f,
);
pub fn draw_stroke(
cx: &mut Cx2d,
draw_map: &mut DrawMapVector,
cache: &TileCache,
draw_keys: &[TileKey],
view_zoom: f64,
map_offset: Vec2f,
);
pub fn draw_pois(
cx: &mut Cx2d,
draw_poi: &mut DrawColor,
cache: &TileCache,
draw_keys: &[TileKey],
view_zoom: f64,
map_offset: Vec2f,
min_zoom: f64,
);
// Labels handled by LabelState::draw()
/// Build the list of tile keys to actually draw, with ancestor/descendant fallback
pub fn build_draw_keys(
cache: &TileCache,
visible_tiles: &[TileKey],
draw_seen: &mut HashSet<TileKey>,
draw_tiles: &mut Vec<TileKey>,
descendant_scratch: &mut Vec<TileKey>,
);
}
```
**Current code moving here:**
- Fill pass loop (view.rs:569-586)
- Stroke pass loop (view.rs:588-608)
- POI pass loop (view.rs:610-640)
- `fill_draw_tile_keys()` (view.rs:1228-1254)
---
### 5. `LabelState` (label_state.rs, ~300 lines)
**Addresses:** Review points #4, #7 (mutable state, too many responsibilities)
All scratch buffers for label placement in one struct. Keeps `label.rs` unchanged as
the pure extraction/scoring module.
```rust
pub struct LabelState {
// Scratch buffers (reuse across frames)
scratch_candidates: Vec<LabelCandidate>,
scratch_accepted_centers: HashMap<String, Vec<Vec2d>>,
scratch_accepted_bounds: Vec<Rect>,
scratch_accepted_plans: Vec<(f64, usize, usize)>,
scratch_collision_grid: HashMap<(i32, i32), Vec<usize>>,
scratch_screen_path: Vec<Vec2d>,
scratch_cumulative: Vec<f64>,
scratch_smooth_a: Vec<Vec2d>,
scratch_smooth_b: Vec<Vec2d>,
path_glyphs: Vec<PathGlyphInstance>,
// Performance tracking
perf: LabelPerfStats,
prev_perf: LabelPerfStats,
}
```
**Methods:**
```rust
impl LabelState {
pub fn place_and_draw(
&mut self,
cx: &mut Cx2d,
cache: &TileCache,
draw_label: &mut DrawRotatedText,
draw_keys: &[TileKey],
view_zoom: f64,
map_offset: Vec2f,
rect: Rect,
);
// Internally:
fn collect_candidates(/* ... */);
fn build_placement(/* ... */);
}
```
**Current code moving here:**
- `place_and_draw_labels()` (view.rs:1377-1533)
- `collect_label_candidates()` (view.rs:1535-1659)
- `build_label_placement()` (view.rs:1661-1759)
---
### 6. Tile module split
**Addresses:** Review point #7 (too many responsibilities per file)
`tile.rs` currently handles types, MVT parsing, tessellation, Overpass queries, disk caching,
mbtiles batch loading — all in 2,163 lines. Split into:
**`tile.rs`** (~300 lines) — Types and tile lifecycle only:
- `TileKey`, `TileEntry`, `TileLoadState` (the explicit enum)
- `TileBuffers`, `TileWorkerMessage`
- `PendingTileRequest`
- `TileStatusCounts`
- `is_descendant_tile()`
- `overpass_query()`, `overpass_endpoint()`
**`tile_decode.rs`** (~800 lines) — Parsing and tessellation:
- `build_tile_buffers_from_body()`
- `build_tile_buffers_from_response()`
- `build_tile_buffers_from_response_owned()`
- `tessellate_tile_buffers()`
- MVT protobuf decode functions
- `mbtiles_tile_to_overpass_response()`
- Node projection, way grouping, polygon tessellation
**`tile_disk.rs`** (~300 lines) — Disk I/O:
- `tile_data_cache_path_for()`
- `store_tile_data_cache_on_disk()`
- `load_local_tile_batch()`
- Path resolution helpers
---
## Revised `view.rs` (~400 lines)
The widget becomes a thin coordinator:
```rust
pub struct MapView {
// Framework
uid: WidgetUid,
source: ScriptObjectRef,
walk: Walk,
layout: Layout,
// Draw objects (#[redraw] #[live])
draw_bg: DrawColor,
draw_map: DrawMapVector,
draw_label: DrawRotatedText,
draw_poi: DrawColor,
draw_text: DrawText,
// DSL-configurable properties (#[live])
center_lon: f64,
center_lat: f64,
zoom: f64,
min_zoom: f64,
max_zoom: f64,
dark_theme: bool,
style_light: MapThemeStyle,
style_dark: MapThemeStyle,
use_network: bool,
use_local_mbtiles: bool,
local_mbtiles_path: String,
local_tile_cache_dir: String,
// Subsystems (#[rust])
viewport: ViewportState,
cache: TileCache,
scheduler: TileScheduler,
label_state: LabelState,
renderer: RenderPass,
// Style (#[rust])
compiled_style_light: CompiledMapTheme,
compiled_style_dark: CompiledMapTheme,
applied_dark_theme: Option<bool>,
#[cfg(feature = "map_style")]
style_json_light: Option<StyleJson>,
// Worker thread (#[rust])
tile_worker_rx: ToUIReceiver<TileWorkerMessage>,
tile_thread_pool: Option<TagThreadPool<TileKey>>,
// Interaction (#[rust]) — kept minimal, delegates to viewport
drag_start_abs: Option<Vec2d>,
drag_start_center_norm: Vec2d,
active_fingers: HashMap<DigitId, Vec2d>,
pinch_initial_distance: Option<f64>,
pinch_initial_zoom: f64,
pinch_initial_center_norm: Vec2d,
}
```
**Method count reduction:**
| Before | After | Change |
|--------|-------|--------|
| ~30 methods on MapView | ~15 methods on MapView | -50% |
| 1,882 lines | ~400 lines | -79% |
**`draw_walk` becomes:**
```rust
fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep {
let rect = cx.walk_turtle(walk);
self.viewport.set_rect(rect);
self.draw_bg.draw_abs(cx, rect);
// Schedule tiles (this was previously mixed into the draw path)
let actions = self.scheduler.update(&self.cache, &self.viewport, &self.config());
self.execute_actions(cx, actions);
// Render
let map_offset = self.viewport.map_offset();
let view_zoom = self.viewport.view_zoom();
RenderPass::build_draw_keys(&self.cache, self.scheduler.visible_tiles(), ...);
RenderPass::draw_fill(cx, &mut self.draw_map, &self.cache, &draw_tiles, view_zoom, map_offset);
RenderPass::draw_stroke(cx, &mut self.draw_map, &self.cache, &draw_tiles, view_zoom, map_offset);
RenderPass::draw_pois(cx, &mut self.draw_poi, &self.cache, &draw_tiles, view_zoom, map_offset, 13.0);
if view_zoom >= 13.0 {
self.label_state.place_and_draw(
cx, &self.cache, &mut self.draw_label,
&draw_tiles, view_zoom, map_offset, rect
);
}
DrawStep::done()
}
```
**`handle_event` becomes:**
```rust
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
// 1. Worker messages → cache
self.handle_tile_worker_messages(cx);
// 2. HTTP → scheduler
self.widget_match_event(cx, event, scope);
// 3. Keyboard
if let Event::KeyDown(KeyDownEvent { key_code: KeyCode::KeyT, .. }) = event {
self.set_dark_theme(cx, !self.dark_theme);
}
// 4. Touch interaction → viewport
for actions in cx.read_hit_actions(&self.draw_bg.area, hit::Event::Hit(e)) {
match actions {
Hit::FingerDown(pe) => self.handle_finger_down(pe),
Hit::FingerMove(pe) => self.handle_finger_move(pe),
Hit::FingerUp(pe) => self.handle_finger_up(pe),
Hit::FingerScroll(fe) => self.viewport.apply_scroll(fe.scroll.y, fe.abs),
_ => {}
}
}
}
```
---
## Phase Plan
### Phase 1: Extract types (no behavior change)
**Goal:** Move types into new files without changing any behavior. Tests must pass.
**Steps:**
1. Create `tile.rs` slim version — move `TileKey`, `TileEntry`, `TileLoadState`, `TileBuffers`, `TileWorkerMessage`, `PendingTileRequest` into type-only definitions. Keep existing `tile.rs` as `tile_decode.rs` + `tile_disk.rs` temporarily.
2. Create `viewport.rs` — extract `ViewportState` with all viewport methods. Wire it into `MapView` as a field. MapView methods delegate to viewport.
3. Create `cache.rs` — extract `TileCache` wrapping the `HashMap<TileKey, TileEntry>` and all methods that operate on it. Wire into MapView.
4. Create `label_state.rs` — extract `LabelState` with all scratch buffers and label methods. Wire into MapView.
5. Create `renderer.rs` — extract `RenderPass` with fill/stroke/POI draw loops. Wire into MapView.
**Verification:** `cargo test -p makepad-widgets --features maps --lib` — all 184+ tests pass.
**Risk:** Makepad `#[derive(Widget)]` macro may have constraints on field types.
Mitigation: subsystems are plain Rust structs, no derive macros needed.
---
### Phase 2: Extract scheduler
**Goal:** Separate tile scheduling from rendering.
**Steps:**
1. Create `scheduler.rs` — extract `TileScheduler` with request/retry/priority logic.
2. Add generation tracking to `PendingTileRequest` and `TileWorkerMessage`.
3. Make `scheduler.update()` return `Vec<TileAction>` instead of directly issuing requests.
4. MapView `execute_actions()` dispatches actions to thread pool / HTTP.
5. Add `SchedulerConfig` to decouple scheduler from live properties.
**Verification:** All tests pass. Manual test: pan around Nairobi, verify tiles load correctly.
---
### Phase 3: Split tile.rs
**Goal:** Break the 2,163-line tile.rs into focused modules.
**Steps:**
1. Move MVT parsing + tessellation to `tile_decode.rs`.
2. Move disk cache + mbtiles batch to `tile_disk.rs`.
3. Keep `tile.rs` as type definitions + Overpass query generation.
**Verification:** All tests pass (tile.rs has 49 tests — they move with the functions).
---
### Phase 4: Optimize
**Address:** Review points #6, #7, #8, #9, #10, #19.
**Steps:**
1. **Cache visible tile set** — Don't recompute `visible_tile_keys()` every frame.
Store result in `ViewportState`, only recompute when viewport actually changes.
Add a `dirty` flag.
2. **Reduce HashMap traffic in draw loop**`RenderPass` methods take `&TileCache`
and do single lookups per tile. The current code does `self.tiles.get(key)`
multiple times per tile in the draw loop. Cache the entry reference.
3. **Frame allocation audit** — Add debug assertions that no allocation happens
during normal frame rendering (all scratch buffers pre-allocated).
4. **Generation-based stale detection** — Worker messages carry generation.
Cache checks generation before accepting results. Prevents zoom-10 results
overwriting zoom-11 requests.
5. **Worker priority** — Visible tiles get higher priority in the thread pool.
Use `TagThreadPool` with priority tags or a priority queue wrapper.
6. **Memory stabilization** — TileCache eviction runs every frame if count > threshold.
Verify memory stabilizes during continuous panning. Add a `memory_bytes()`
estimation method for monitoring.
---
### Phase 5: Render pass ordering (optional, future)
**Address:** Review point #11 (render graph).
Currently fill/stroke/POI/label are hardcoded loops. Future improvement:
```rust
enum Pass {
Background,
WaterFill,
LanduseFill,
BuildingFill,
RoadCasing,
RoadCenter,
Railway,
Waterway,
Label,
Poi,
Selection,
Debug,
}
```
Each pass has a `draw()` method and a `z_order` for sorting. This enables:
- Easy insertion of new passes (3D buildings, terrain, traffic)
- Per-pass culling
- Debug visualization (show only one pass)
**Not implementing now** — the current fill/stroke/POI/label ordering works.
This is documented for future reference.
---
## File-by-File Changes
### `view.rs` (1,882 → ~400 lines)
**Removed:**
- `ViewportState` fields and methods → `viewport.rs`
- `TileCache` fields and methods → `cache.rs`
- `TileScheduler` fields and methods → `scheduler.rs`
- `LabelState` fields and methods → `label_state.rs`
- `RenderPass` methods → `renderer.rs`
- Tile type definitions → `tile.rs`
**Kept:**
- `#[derive(Script, Widget)]` struct with subsystem fields
- `ScriptHook::on_after_apply` (thin: sync properties → viewport, rebuild styles)
- `Widget::handle_event` (thin: delegates to subsystems)
- `Widget::draw_walk` (thin: delegates to RenderPass + LabelState)
- `WidgetMatchEvent` (thin: delegates HTTP to scheduler)
- `set_dark_theme`, `apply_theme_change`, `apply_theme_palette`
- `load_style_json`, `recompile_style_for_zoom`
- `resolve_mbtiles_path` (could move to SchedulerConfig)
### `tile.rs` (2,163 → ~300 lines + 2 new files)
**Keeps:** Type definitions, `overpass_query`, `overpass_endpoint`, `is_descendant_tile`, constants
**Moves to `tile_decode.rs`:** MVT protobuf, tessellation, body processing
**Moves to `tile_disk.rs`:** Disk cache, mbtiles batch loading
### New files (6 files, ~1,450 lines total)
| File | Lines | Responsibility |
|------|-------|----------------|
| `viewport.rs` | ~150 | Coordinate math, interaction, visible tiles |
| `cache.rs` | ~200 | Tile storage, lifecycle, eviction |
| `scheduler.rs` | ~250 | Request queue, retry, priority, generation |
| `renderer.rs` | ~200 | Fill/stroke/POI draw passes |
| `label_state.rs` | ~300 | Label scratch buffers, placement, drawing |
| `tile_decode.rs` | ~800 | MVT parsing, tessellation |
| `tile_disk.rs` | ~300 | Disk I/O, mbtiles batch |
### Unchanged files (6)
- `geometry.rs` (1,968 lines)
- `label.rs` (1,098 lines)
- `style.rs` (496 lines)
- `style_json.rs` (3,227 lines)
- `asset_loader.rs` (124 lines)
- `sprite.rs` (433 lines)
---
## Downstream Compatibility
### Public API (must not change)
```rust
// Widget trait — framework calls these
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope);
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep;
// ScriptHook — DSL properties flow in here
fn on_after_apply(&mut self, vm: &mut ScriptVm, apply: &Apply, scope: &mut Scope, value: ScriptValue);
// WidgetMatchEvent — HTTP responses
fn handle_http_response(&mut self, cx: &mut Cx, request_id: LiveId, response: &HttpResponse, scope: &mut Scope);
fn handle_http_request_error(&mut self, cx: &mut Cx, request_id: LiveId, err: &HttpError, scope: &mut Scope);
// Public methods
fn load_style_json(&mut self, json_str: &str) -> Result<(), String>;
fn recompile_style_for_zoom(&mut self, zoom: f64);
```
### DSL (book.rs must not change)
```rust
map_layer := MapView {
width: Fill
height: Fill
center_lon: 36.8219
center_lat: -1.2921
zoom: 14.0
min_zoom: 10.0
max_zoom: 18.0
use_local_mbtiles: true
use_network: false
local_mbtiles_path: "kenya-shortbread-1.0.mbtiles"
}
```
### mbtile_reader crate (must not change)
Separate crate at `makepad/libs/mbtile_reader/`. API:
- `MbtileReader::open()`
- `get_tiles_at_zoom()`
- `get_tiles_filtered()`
- `Value`, `Error` types
---
## Test Strategy
### Existing tests (184+)
| Module | Tests | Status |
|--------|-------|--------|
| geometry.rs | 54 | UNCHANGED — no code movement |
| tile.rs | 49 | MOVE with functions to tile_decode.rs / tile_disk.rs |
| label.rs | 69 | UNCHANGED — no code movement |
| style_json.rs | 101 | UNCHANGED — no code movement |
| sprite.rs | 25 | UNCHANGED — no code movement |
| mbtile_reader | 61 | SEPARATE CRATE — no change |
**Zero test breakage** — all tests move with their functions, module paths update in `#[cfg(test)]` blocks.
### New tests per phase
**Phase 1 (types):**
- `viewport.rs`: screen<->world transforms, wrap/clamp, visible tile keys
- `cache.rs`: insert/evict/mark-failed, generation tracking
- `renderer.rs`: build_draw_keys with ancestor/descendant fallback
- `label_state.rs`: (already covered by label.rs tests)
**Phase 2 (scheduler):**
- `scheduler.rs`: request prioritization, retry timing, generation staleness
- Action generation for various cache states
**Phase 3 (tile split):**
- Verify all existing tile.rs tests pass with new module paths
**Phase 4 (optimize):**
- Visible tile cache: verify no recomputation when viewport unchanged
- Frame allocation: debug assertion that scratch buffers don't reallocate
---
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Makepad derive macro incompatibility | Low | High | Subsystems are plain structs, not widgets |
| Thread pool API changes | Low | Medium | Pin to current `TagThreadPool<TileKey>` API |
| Performance regression from indirection | Medium | Low | Profile after each phase; subsystems are `#[inline]` candidates |
| Test coverage gaps in view.rs | High | Medium | No tests exist for view.rs today — add integration tests in Phase 1 |
| merge conflicts with upstream makepad | Medium | Medium | Minimize changes to files outside `map/` directory |
---
## Decision Log
| Decision | Rationale |
|----------|-----------|
| Keep `geometry.rs`, `label.rs`, `style.rs` unchanged | Already well-factored; 297 tests pass |
| ViewportState has no Cx dependency | Enables unit testing without Makepad context |
| TileScheduler returns actions, doesn't execute | Decouples scheduling from I/O execution |
| TileCache owns the HashMap | Single clear owner for tile lifecycle |
| RenderPass takes &TileCache, doesn't own tiles | Read-only during draw, no mutation in render path |
| LabelState keeps scratch buffers | Avoids per-frame allocation; keeps label.rs pure |
| Generation tracking via u64 counter | Simple, no ABA problem at realistic rates |
| SchedulerConfig struct for constants | Makes scheduling testable without live properties |
| Split tile.rs into 3 files | Each file has one responsibility |
| Phase 1 is purely mechanical extraction | Zero risk of behavior change; builds confidence |