map viewer
git clone https://git.lucas.co/cce-map.git
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))
+}