git.lucas.co / cce-map
map viewer
git clone https://git.lucas.co/cce-map.git

commitd8898b986792f24dc15310ea96d1ebf6ebdf258f
authorLucas Galante <[email protected]>
date2026-08-10 12:18
cce-map: OSM raster tile viewer MVP

Slippy-map client on cce-ui: continuous zoom (wheel/pinch/keys, anchored
at the cursor), drag panning, tiles fetched from OSM (or CCE_MAP_TILE_URL)
by a 4-thread worker pool with a disk cache under ~/.cache/cce/map/tiles,
GPU-image LRU under the 256-image registry cap, and ancestor-tile fallback
clipped in while a tile loads.

Co-Authored-By: Claude Fable 5 <[email protected]>

 .gitignore   |   2 +
 Cargo.toml   |  13 +++
 src/main.rs  | 285 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/tiles.rs | 192 ++++++++++++++++++++++++++++++++++++++++
 4 files changed, 492 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..96ef6c0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+Cargo.lock
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..7fcbd0c
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "cce-map"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+cce-ui = { path = "../cce-ui" }
+calloop = "0.13.0"
+wayland-client = { version = "0.31", features = ["system"] }
+reqwest = { version = "0.12", features = ["blocking"] }
+image = { version = "0.25", default-features = false, features = ["png"] }
+log = "0.4"
+env_logger = "0.11"
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..5a2d0ae
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,285 @@
+//! cce-map — slippy-map raster tile viewer (OpenStreetMap by default).
+//!
+//! View state is a Web-Mercator world coordinate (u, v) ∈ [0,1]² at the
+//! window center plus a continuous zoom. Tiles render at the nearest
+//! integer zoom, scaled to the continuous zoom; while a tile loads, the
+//! nearest resident ancestor is drawn clipped to the tile's rect.
+
+mod tiles;
+
+use wayland_client::QueueHandle;
+
+use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{DisplayList, PaintCtx};
+use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey};
+
+use tiles::{TileKey, TileManager, MAX_ZOOM, TILE_SIZE};
+
+const WHEEL_ZOOM_STEP: f64 = 0.25;
+const KEY_PAN_PX: f64 = 120.0;
+
+#[derive(Debug, Clone)]
+enum Message {
+    Tile { key: TileKey, image: Option<u32> },
+}
+
+struct MapApp {
+    tiles: TileManager,
+    /// World coords of the window center, u east [0,1), v south [0,1].
+    center: (f64, f64),
+    zoom: f64,
+    win: (f32, f32),
+    pointer: (f64, f64),
+    drag: Option<(f64, f64)>,
+}
+
+/// Width of the whole world in logical pixels at a given zoom.
+fn world_px(zoom: f64) -> f64 {
+    TILE_SIZE * 2f64.powf(zoom)
+}
+
+impl MapApp {
+    fn zoom_by(&mut self, dz: f64, px: f64, py: f64) {
+        let old = world_px(self.zoom);
+        let new_zoom = (self.zoom + dz).clamp(0.0, MAX_ZOOM as f64);
+        let new = world_px(new_zoom);
+        let (w, h) = (self.win.0 as f64, self.win.1 as f64);
+        let u = self.center.0 + (px - w / 2.0) / old;
+        let v = self.center.1 + (py - h / 2.0) / old;
+        self.center.0 = (u - (px - w / 2.0) / new).rem_euclid(1.0);
+        self.center.1 = (v - (py - h / 2.0) / new).clamp(0.0, 1.0);
+        self.zoom = new_zoom;
+    }
+
+    fn pan_px(&mut self, dx: f64, dy: f64) {
+        let scale = world_px(self.zoom);
+        self.center.0 = (self.center.0 + dx / scale).rem_euclid(1.0);
+        self.center.1 = (self.center.1 + dy / scale).clamp(0.0, 1.0);
+    }
+
+    fn center_lat_lon(&self) -> (f64, f64) {
+        let lon = self.center.0 * 360.0 - 180.0;
+        let lat = (std::f64::consts::PI * (1.0 - 2.0 * self.center.1)).sinh().atan().to_degrees();
+        (lat, lon)
+    }
+}
+
+impl Application for MapApp {
+    type Message = Message;
+
+    fn new(_qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
+        Self {
+            tiles: TileManager::new(sender),
+            center: (0.5, 0.5),
+            zoom: 2.0,
+            win: (1000.0, 700.0),
+            pointer: (0.0, 0.0),
+            drag: None,
+        }
+    }
+
+    fn settings(&self) -> WindowSettings {
+        WindowSettings {
+            title: "Map".to_string(),
+            app_id: "cce-map".to_string(),
+            width: 1000,
+            height: 700,
+            fullscreen: false,
+            min_size: Some((320, 240)),
+        }
+    }
+
+    fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
+        match msg {
+            Message::Tile { key, image } => {
+                self.tiles.complete(key, image);
+                *needs_rebuild = true;
+            }
+        }
+    }
+
+    fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
+
+    fn handle_resize(&mut self, width: f32, height: f32, _scale: f64) {
+        self.win = (width, height);
+    }
+
+    fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
+        let (px, py) = (pos.x as f64, pos.y as f64);
+        if let Some((lx, ly)) = self.drag {
+            self.pan_px(lx - px, ly - py);
+            self.drag = Some((px, py));
+            *needs_rebuild = true;
+        }
+        self.pointer = (px, py);
+    }
+
+    fn handle_mouse_input(
+        &mut self,
+        button: MouseButton,
+        state: ElementState,
+        pos: LogicalPosition,
+        _needs_rebuild: &mut bool,
+    ) -> Option<Self::Message> {
+        if button == MouseButton::Left {
+            self.drag = match state {
+                ElementState::Pressed => Some((pos.x as f64, pos.y as f64)),
+                ElementState::Released => None,
+            };
+        }
+        None
+    }
+
+    fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
+        let notches = delta.notches_y() as f64;
+        if notches != 0.0 {
+            self.zoom_by(notches * WHEEL_ZOOM_STEP, pos.x as f64, pos.y as f64);
+            *needs_rebuild = true;
+        }
+    }
+
+    fn handle_pinch(&mut self, factor: f32, pos: LogicalPosition, needs_rebuild: &mut bool) -> bool {
+        if factor > 0.0 && factor != 1.0 {
+            self.zoom_by((factor as f64).log2(), pos.x as f64, pos.y as f64);
+            *needs_rebuild = true;
+        }
+        true
+    }
+
+    fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
+        if event.state != ElementState::Pressed {
+            return None;
+        }
+        let (cx, cy) = (self.win.0 as f64 / 2.0, self.win.1 as f64 / 2.0);
+        let mut handled = true;
+        match &event.logical_key {
+            Key::Character(c) if c == "+" || c == "=" => self.zoom_by(0.5, cx, cy),
+            Key::Character(c) if c == "-" => self.zoom_by(-0.5, cx, cy),
+            Key::Named(NamedKey::ArrowLeft) => self.pan_px(-KEY_PAN_PX, 0.0),
+            Key::Named(NamedKey::ArrowRight) => self.pan_px(KEY_PAN_PX, 0.0),
+            Key::Named(NamedKey::ArrowUp) => self.pan_px(0.0, -KEY_PAN_PX),
+            Key::Named(NamedKey::ArrowDown) => self.pan_px(0.0, KEY_PAN_PX),
+            Key::Named(NamedKey::Home) => {
+                self.center = (0.5, 0.5);
+                self.zoom = 2.0;
+            }
+            _ => handled = false,
+        }
+        if handled {
+            *needs_rebuild = true;
+        }
+        None
+    }
+
+    fn display_list(&mut self, size: LogicalSize, _scale: f64) -> Option<DisplayList> {
+        self.win = (size.width, size.height);
+        self.tiles.begin_frame();
+        let mut pc = PaintCtx::new();
+        let (w, h) = (size.width as f64, size.height as f64);
+        pc.quad(
+            Rect { x: 0.0, y: 0.0, width: size.width, height: size.height },
+            [0.07, 0.08, 0.09, 1.0],
+        );
+
+        let scale_px = world_px(self.zoom);
+        let tz = (self.zoom.round() as i32).clamp(0, MAX_ZOOM as i32) as u8;
+        let n = 1u64 << tz;
+        let tile_px = scale_px / n as f64;
+        // World coord of the window's top-left corner.
+        let u0 = self.center.0 - w / 2.0 / scale_px;
+        let v0 = self.center.1 - h / 2.0 / scale_px;
+        // Unwrapped tile-index range covering the window (x wraps around
+        // the antimeridian via rem_euclid; y is clamped to the world).
+        let tx0 = (u0 * n as f64).floor() as i64;
+        let tx1 = ((u0 + w / scale_px) * n as f64).floor() as i64;
+        let ty0 = ((v0 * n as f64).floor() as i64).max(0);
+        let ty1 = (((v0 + h / scale_px) * n as f64).floor() as i64).min(n as i64 - 1);
+
+        for ty in ty0..=ty1 {
+            for tx in tx0..=tx1 {
+                let key = TileKey {
+                    z: tz,
+                    x: tx.rem_euclid(n as i64) as u32,
+                    y: ty as u32,
+                };
+                let rect = Rect {
+                    x: ((tx as f64 / n as f64 - u0) * scale_px) as f32,
+                    y: ((ty as f64 / n as f64 - v0) * scale_px) as f32,
+                    width: tile_px as f32,
+                    height: tile_px as f32,
+                };
+                if let Some(img) = self.tiles.ensure(key) {
+                    pc.image(img, rect, 1.0);
+                    continue;
+                }
+                // Loading: checkerboard placeholder, overdrawn by the
+                // nearest resident ancestor scaled up and clipped.
+                let shade = if (tx + ty) % 2 == 0 { 0.10 } else { 0.12 };
+                pc.quad(rect, [shade, shade, shade + 0.01, 1.0]);
+                for d in 1..=5u8 {
+                    if d > tz {
+                        break;
+                    }
+                    let az = tz - d;
+                    let f = 1i64 << d;
+                    let atx = tx.div_euclid(f);
+                    let aty = ty.div_euclid(f);
+                    let akey = TileKey {
+                        z: az,
+                        x: atx.rem_euclid((n / (f as u64)) as i64) as u32,
+                        y: aty as u32,
+                    };
+                    if let Some(img) = self.tiles.ready(akey) {
+                        let arect = Rect {
+                            x: ((atx as f64 * f as f64 / n as f64 - u0) * scale_px) as f32,
+                            y: ((aty as f64 * f as f64 / n as f64 - v0) * scale_px) as f32,
+                            width: (tile_px * f as f64) as f32,
+                            height: (tile_px * f as f64) as f32,
+                        };
+                        pc.clip(rect, |pc| pc.image(img, arect, 1.0));
+                        break;
+                    }
+                }
+            }
+        }
+
+        // HUD: zoom + center coordinates (top-left), attribution (bottom-right).
+        let (lat, lon) = self.center_lat_lon();
+        pc.quad(Rect { x: 8.0, y: 8.0, width: 232.0, height: 24.0 }, [0.0, 0.0, 0.0, 0.45]);
+        pc.text(
+            format!("z {:.2}   {:.4}°, {:.4}°", self.zoom, lat, lon),
+            16.0,
+            13.0,
+            12.0,
+            [230, 230, 230],
+        );
+        let attr_w = 200.0f32;
+        pc.quad(
+            Rect { x: size.width - attr_w, y: size.height - 24.0, width: attr_w, height: 24.0 },
+            [0.0, 0.0, 0.0, 0.45],
+        );
+        pc.text(
+            "© OpenStreetMap contributors",
+            size.width - attr_w + 8.0,
+            size.height - 19.0,
+            11.0,
+            [200, 200, 200],
+        );
+
+        Some(pc.finish())
+    }
+
+    fn display_list_text(&self) -> bool {
+        true
+    }
+
+    fn clear_color(&self) -> [f32; 4] {
+        [0.07, 0.08, 0.09, 1.0]
+    }
+}
+
+fn main() {
+    env_logger::init();
+    cce_ui::engine::run::<MapApp>();
+}
diff --git a/src/tiles.rs b/src/tiles.rs
new file mode 100644
index 0000000..dfb08ed
--- /dev/null
+++ b/src/tiles.rs
@@ -0,0 +1,192 @@
+//! Slippy-map tile store: disk cache + HTTP fetch workers + GPU-image LRU.
+//!
+//! Workers decode PNGs and call `cce_ui::vk::upload_rgba` directly (the
+//! upload queue is thread-safe; the actual GPU work happens on the next
+//! frame), then notify the app through the calloop channel so the engine
+//! wakes and repaints.
+
+use std::collections::HashMap;
+use std::path::PathBuf;
+use std::sync::mpsc;
+use std::sync::{Arc, Mutex};
+
+use crate::Message;
+
+pub const TILE_SIZE: f64 = 256.0;
+pub const MAX_ZOOM: u8 = 19;
+
+/// GPU tiles kept resident. The cce-ui image registry hard-caps at 256
+/// images total, so leave headroom for other consumers and churn.
+const MAX_GPU_TILES: usize = 180;
+const FETCH_THREADS: usize = 4;
+
+/// OSM tile-usage policy requires an identifying User-Agent.
+const USER_AGENT: &str = concat!("cce-map/", env!("CARGO_PKG_VERSION"), " (cce desktop environment)");
+const DEFAULT_TILE_URL: &str = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct TileKey {
+    pub z: u8,
+    pub x: u32,
+    pub y: u32,
+}
+
+enum TileState {
+    Pending,
+    Ready { image: u32, last_used: u64 },
+    Failed,
+}
+
+pub struct TileManager {
+    states: HashMap<TileKey, TileState>,
+    queue: mpsc::Sender<TileKey>,
+    /// Frame counter used as the LRU clock; bumped by the app each rebuild.
+    frame: u64,
+}
+
+impl TileManager {
+    pub fn new(notify: calloop::channel::Sender<Message>) -> Self {
+        let (queue, rx) = mpsc::channel::<TileKey>();
+        let rx = Arc::new(Mutex::new(rx));
+        let url_template = std::env::var("CCE_MAP_TILE_URL").unwrap_or_else(|_| DEFAULT_TILE_URL.to_string());
+        let cache_root = cache_root();
+        for _ in 0..FETCH_THREADS {
+            let rx = Arc::clone(&rx);
+            let notify = notify.clone();
+            let url_template = url_template.clone();
+            let cache_root = cache_root.clone();
+            std::thread::spawn(move || worker(rx, notify, url_template, cache_root));
+        }
+        Self { states: HashMap::new(), queue, frame: 0 }
+    }
+
+    pub fn begin_frame(&mut self) {
+        self.frame += 1;
+    }
+
+    /// The tile's GPU image if resident (marks it used); otherwise queues a
+    /// fetch (once) and returns None.
+    pub fn ensure(&mut self, key: TileKey) -> Option<u32> {
+        match self.states.get_mut(&key) {
+            Some(TileState::Ready { image, last_used }) => {
+                *last_used = self.frame;
+                Some(*image)
+            }
+            Some(_) => None,
+            None => {
+                self.states.insert(key, TileState::Pending);
+                let _ = self.queue.send(key);
+                None
+            }
+        }
+    }
+
+    /// Like `ensure` but never queues a fetch — used for ancestor fallback.
+    pub fn ready(&mut self, key: TileKey) -> Option<u32> {
+        match self.states.get_mut(&key) {
+            Some(TileState::Ready { image, last_used }) => {
+                *last_used = self.frame;
+                Some(*image)
+            }
+            _ => None,
+        }
+    }
+
+    pub fn complete(&mut self, key: TileKey, image: Option<u32>) {
+        let state = match image {
+            Some(image) => TileState::Ready { image, last_used: self.frame },
+            None => TileState::Failed,
+        };
+        self.states.insert(key, state);
+        self.evict();
+    }
+
+    /// Free the least-recently-used GPU tiles once over budget. Tiles
+    /// touched this frame are never evicted.
+    fn evict(&mut self) {
+        let resident = self.states.values().filter(|s| matches!(s, TileState::Ready { .. })).count();
+        if resident <= MAX_GPU_TILES {
+            return;
+        }
+        let mut ready: Vec<(TileKey, u64)> = self
+            .states
+            .iter()
+            .filter_map(|(k, s)| match s {
+                TileState::Ready { last_used, .. } if *last_used < self.frame => Some((*k, *last_used)),
+                _ => None,
+            })
+            .collect();
+        ready.sort_by_key(|&(_, used)| used);
+        let excess = resident - MAX_GPU_TILES;
+        for (key, _) in ready.into_iter().take(excess) {
+            if let Some(TileState::Ready { image, .. }) = self.states.remove(&key) {
+                cce_ui::vk::free_image(image);
+            }
+        }
+    }
+}
+
+fn cache_root() -> PathBuf {
+    let base = match std::env::var("XDG_CACHE_HOME") {
+        Ok(x) if !x.is_empty() => PathBuf::from(x),
+        _ => PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".cache"),
+    };
+    base.join("cce").join("map").join("tiles")
+}
+
+fn worker(
+    rx: Arc<Mutex<mpsc::Receiver<TileKey>>>,
+    notify: calloop::channel::Sender<Message>,
+    url_template: String,
+    cache_root: PathBuf,
+) {
+    let client = reqwest::blocking::Client::builder()
+        .user_agent(USER_AGENT)
+        .timeout(std::time::Duration::from_secs(15))
+        .build()
+        .expect("http client");
+    loop {
+        let key = match rx.lock().unwrap().recv() {
+            Ok(k) => k,
+            Err(_) => return,
+        };
+        let image = fetch_tile(&client, &url_template, &cache_root, key)
+            .map_err(|e| log::warn!("tile {}/{}/{}: {e}", key.z, key.x, key.y))
+            .ok();
+        if notify.send(Message::Tile { key, image }).is_err() {
+            return;
+        }
+    }
+}
+
+fn fetch_tile(
+    client: &reqwest::blocking::Client,
+    url_template: &str,
+    cache_root: &PathBuf,
+    key: TileKey,
+) -> Result<u32, String> {
+    let path = cache_root.join(key.z.to_string()).join(key.x.to_string()).join(format!("{}.png", key.y));
+    let bytes = match std::fs::read(&path) {
+        Ok(b) => b,
+        Err(_) => {
+            let url = url_template
+                .replace("{z}", &key.z.to_string())
+                .replace("{x}", &key.x.to_string())
+                .replace("{y}", &key.y.to_string());
+            let resp = client.get(&url).send().map_err(|e| e.to_string())?;
+            if !resp.status().is_success() {
+                return Err(format!("HTTP {}", resp.status()));
+            }
+            let bytes = resp.bytes().map_err(|e| e.to_string())?.to_vec();
+            if let Some(dir) = path.parent() {
+                let _ = std::fs::create_dir_all(dir);
+            }
+            let _ = std::fs::write(&path, &bytes);
+            bytes
+        }
+    };
+    let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
+    let rgba = img.to_rgba8();
+    let (w, h) = rgba.dimensions();
+    Ok(cce_ui::vk::upload_rgba(rgba.into_raw(), w, h))
+}