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

28 KiB

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

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.

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):

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:

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.

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.

pub struct PendingTileRequest {
    pub tile_key: TileKey,
    pub endpoint: &'static str,
    pub generation: u64,
}
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:

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.

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.

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:

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:

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:

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:

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 loopRenderPass 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:

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)

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

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