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

src/main.rs (14.4K)

  1 //! cce-map — slippy-map raster tile viewer (OpenStreetMap by default).
  2 //!
  3 //! View state is a Web-Mercator world coordinate (u, v) ∈ [0,1]² at the
  4 //! window center plus a continuous zoom. Tiles render at the nearest
  5 //! integer zoom, scaled to the continuous zoom; while a tile loads, the
  6 //! nearest resident ancestor is drawn clipped to the tile's rect.
  7 
  8 mod tiles;
  9 
 10 use wayland_client::QueueHandle;
 11 
 12 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
 13 use cce_ui::scene::layout::Rect;
 14 use cce_ui::scene::paint::{DisplayList, PaintCtx};
 15 use cce_ui::widget::scroll_motion::{current_scroll_phase, scroll_settings, ScrollPhase};
 16 use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey};
 17 
 18 use tiles::{TileKey, TileManager, MAX_ZOOM, TILE_SIZE};
 19 
 20 const WHEEL_ZOOM_STEP: f64 = 0.25;
 21 const KEY_PAN_PX: f64 = 120.0;
 22 
 23 #[derive(Debug, Clone)]
 24 enum Message {
 25     Tile { generation: u64, key: TileKey, image: Option<u32> },
 26 }
 27 
 28 struct MapApp {
 29     tiles: TileManager,
 30     /// Whether a renderer has been handed over yet — the first one is the
 31     /// process's own, any later one is a replacement after a reconnect. See
 32     /// `renderer_init`.
 33     seen_renderer: bool,
 34     /// World coords of the window center, u east [0,1), v south [0,1].
 35     center: (f64, f64),
 36     zoom: f64,
 37     /// Where the wheel is taking the zoom: each notch moves this and `tick`
 38     /// eases `zoom` toward it around `zoom_anchor` at cce-ui's wheel-glide
 39     /// rate (`scroll_ease`; instant with `smooth_scroll false`), so a burst
 40     /// of notches is one glide rather than a staircase. A trackpad, pinch,
 41     /// keys and Home set the zoom directly and pull the target along.
 42     zoom_target: f64,
 43     zoom_anchor: (f64, f64),
 44     win: (f32, f32),
 45     pointer: (f64, f64),
 46     drag: Option<(f64, f64)>,
 47 }
 48 
 49 /// Width of the whole world in logical pixels at a given zoom.
 50 fn world_px(zoom: f64) -> f64 {
 51     TILE_SIZE * 2f64.powf(zoom)
 52 }
 53 
 54 impl MapApp {
 55     /// Change the zoom by `dz` keeping the world point under (px, py) fixed.
 56     fn zoom_step(&mut self, dz: f64, px: f64, py: f64) {
 57         let old = world_px(self.zoom);
 58         let new_zoom = (self.zoom + dz).clamp(0.0, MAX_ZOOM as f64);
 59         let new = world_px(new_zoom);
 60         let (w, h) = (self.win.0 as f64, self.win.1 as f64);
 61         let u = self.center.0 + (px - w / 2.0) / old;
 62         let v = self.center.1 + (py - h / 2.0) / old;
 63         self.center.0 = (u - (px - w / 2.0) / new).rem_euclid(1.0);
 64         self.center.1 = (v - (py - h / 2.0) / new).clamp(0.0, 1.0);
 65         self.zoom = new_zoom;
 66     }
 67 
 68     /// A direct zoom change (pinch, keys, trackpad): lands at once and
 69     /// cancels any wheel glide in flight.
 70     fn zoom_by(&mut self, dz: f64, px: f64, py: f64) {
 71         self.zoom_step(dz, px, py);
 72         self.zoom_target = self.zoom;
 73     }
 74 
 75     /// A wheel notch: retarget the glide around the pointer.
 76     fn zoom_wheel(&mut self, dz: f64, px: f64, py: f64) {
 77         self.zoom_anchor = (px, py);
 78         self.zoom_target = (self.zoom_target + dz).clamp(0.0, MAX_ZOOM as f64);
 79         if !scroll_settings().smooth {
 80             let remaining = self.zoom_target - self.zoom;
 81             self.zoom_step(remaining, px, py);
 82         }
 83     }
 84 
 85     /// Ease the zoom toward its wheel target; true while it moved.
 86     fn tick_zoom(&mut self, dt: f32) -> bool {
 87         let remaining = self.zoom_target - self.zoom;
 88         if remaining == 0.0 {
 89             return false;
 90         }
 91         let (ax, ay) = self.zoom_anchor;
 92         // Frame-rate independent exponential approach (cce-ui's glide),
 93         // snapping the last sliver so it settles instead of trailing off.
 94         let step = if remaining.abs() < 1e-3 {
 95             remaining
 96         } else {
 97             remaining * (1.0 - (-(scroll_settings().ease_rate as f64) * dt as f64).exp())
 98         };
 99         self.zoom_step(step, ax, ay);
100         true
101     }
102 
103     fn pan_px(&mut self, dx: f64, dy: f64) {
104         let scale = world_px(self.zoom);
105         self.center.0 = (self.center.0 + dx / scale).rem_euclid(1.0);
106         self.center.1 = (self.center.1 + dy / scale).clamp(0.0, 1.0);
107     }
108 
109     fn center_lat_lon(&self) -> (f64, f64) {
110         let lon = self.center.0 * 360.0 - 180.0;
111         let lat = (std::f64::consts::PI * (1.0 - 2.0 * self.center.1)).sinh().atan().to_degrees();
112         (lat, lon)
113     }
114 }
115 
116 impl Application for MapApp {
117     type Message = Message;
118 
119     fn new(_qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
120         Self {
121             tiles: TileManager::new(sender),
122             seen_renderer: false,
123             center: (0.5, 0.5),
124             zoom: 2.0,
125             zoom_target: 2.0,
126             zoom_anchor: (500.0, 350.0),
127             win: (1000.0, 700.0),
128             pointer: (0.0, 0.0),
129             drag: None,
130         }
131     }
132 
133     fn settings(&self) -> WindowSettings {
134         WindowSettings {
135             title: "Map".to_string(),
136             app_id: "cce-map".to_string(),
137             width: 1000,
138             height: 700,
139             fullscreen: false,
140             min_size: Some((320, 240)),
141         }
142     }
143 
144     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
145         match msg {
146             Message::Tile { generation, key, image } => {
147                 self.tiles.complete(generation, key, image);
148                 *needs_rebuild = true;
149             }
150         }
151     }
152 
153     /// Re-fetch the visible tiles when the renderer is replaced.
154     ///
155     /// The tile store caches **renderer** image ids, which do not survive the
156     /// reconnect `window_runner` performs around a live `Application` — see
157     /// [`TileManager::reset`] for the whole story. Every resident tile is
158     /// dropped here and the next paint asks for what it needs again, off the
159     /// disk cache.
160     ///
161     /// Not on the first renderer: the tiles queued from `new()` are waiting
162     /// for exactly that one.
163     fn renderer_init(&mut self, _renderer: &mut cce_ui::vk::VkRenderer) {
164         if std::mem::replace(&mut self.seen_renderer, true) {
165             log::info!("[map] renderer replaced; re-fetching the resident tiles");
166             self.tiles.reset();
167         }
168     }
169 
170     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
171         if self.tick_zoom(dt) {
172             *needs_rebuild = true;
173         }
174     }
175 
176     fn handle_resize(&mut self, width: f32, height: f32, _scale: f64) {
177         self.win = (width, height);
178     }
179 
180     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
181         let (px, py) = (pos.x as f64, pos.y as f64);
182         if let Some((lx, ly)) = self.drag {
183             self.pan_px(lx - px, ly - py);
184             self.drag = Some((px, py));
185             *needs_rebuild = true;
186         }
187         self.pointer = (px, py);
188     }
189 
190     fn handle_mouse_input(
191         &mut self,
192         button: MouseButton,
193         state: ElementState,
194         pos: LogicalPosition,
195         _needs_rebuild: &mut bool,
196     ) -> Option<Self::Message> {
197         if button == MouseButton::Left {
198             self.drag = match state {
199                 ElementState::Pressed => Some((pos.x as f64, pos.y as f64)),
200                 ElementState::Released => None,
201             };
202         }
203         None
204     }
205 
206     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
207         let notches = delta.notches_y() as f64;
208         if notches == 0.0 {
209             return;
210         }
211         let dz = notches * WHEEL_ZOOM_STEP;
212         let (px, py) = (pos.x as f64, pos.y as f64);
213         // A finger on a trackpad is followed 1:1 (nothing is smoother than
214         // the hand); discrete notches glide.
215         let finger = matches!(delta, MouseScrollDelta::PixelDelta(_))
216             && matches!(current_scroll_phase(), ScrollPhase::Finger | ScrollPhase::FingerEnd);
217         if finger {
218             self.zoom_by(dz, px, py);
219         } else {
220             self.zoom_wheel(dz, px, py);
221         }
222         *needs_rebuild = true;
223     }
224 
225     fn handle_pinch(&mut self, factor: f32, pos: LogicalPosition, needs_rebuild: &mut bool) -> bool {
226         if factor > 0.0 && factor != 1.0 {
227             self.zoom_by((factor as f64).log2(), pos.x as f64, pos.y as f64);
228             *needs_rebuild = true;
229         }
230         true
231     }
232 
233     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
234         if event.state != ElementState::Pressed {
235             return None;
236         }
237         let (cx, cy) = (self.win.0 as f64 / 2.0, self.win.1 as f64 / 2.0);
238         let mut handled = true;
239         match &event.logical_key {
240             Key::Character(c) if c == "+" || c == "=" => self.zoom_by(0.5, cx, cy),
241             Key::Character(c) if c == "-" => self.zoom_by(-0.5, cx, cy),
242             Key::Named(NamedKey::ArrowLeft) => self.pan_px(-KEY_PAN_PX, 0.0),
243             Key::Named(NamedKey::ArrowRight) => self.pan_px(KEY_PAN_PX, 0.0),
244             Key::Named(NamedKey::ArrowUp) => self.pan_px(0.0, -KEY_PAN_PX),
245             Key::Named(NamedKey::ArrowDown) => self.pan_px(0.0, KEY_PAN_PX),
246             Key::Named(NamedKey::Home) => {
247                 self.center = (0.5, 0.5);
248                 self.zoom = 2.0;
249                 self.zoom_target = 2.0;
250             }
251             _ => handled = false,
252         }
253         if handled {
254             *needs_rebuild = true;
255         }
256         None
257     }
258 
259     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
260         self.win = (size.width, size.height);
261         self.tiles.begin_frame();
262         let mut pc = PaintCtx::new();
263         let (w, h) = (size.width as f64, size.height as f64);
264         // The standard root plate (cce-ui PlateSpec::window); the tiles are
265         // full-bleed content drawn on it, so it shows only where they do not.
266         pc.root_plate(size.width, size.height);
267 
268         let scale_px = world_px(self.zoom);
269         // Pick the tile zoom for physical resolution: on a scale-2 output a
270         // z+1 tile drawn at 128 logical px is 1:1 physical. Backed off when
271         // the viewport would need more resident tiles than the GPU image
272         // registry (256, shared) comfortably holds.
273         let mut tz = ((self.zoom + scale.max(1.0).log2()).round() as i32).clamp(0, MAX_ZOOM as i32) as u8;
274         while tz > 0 {
275             let tile_px = scale_px / (1u64 << tz) as f64;
276             if (w / tile_px + 2.0) * (h / tile_px + 2.0) <= 160.0 {
277                 break;
278             }
279             tz -= 1;
280         }
281         let n = 1u64 << tz;
282         let tile_px = scale_px / n as f64;
283         // World coord of the window's top-left corner.
284         let u0 = self.center.0 - w / 2.0 / scale_px;
285         let v0 = self.center.1 - h / 2.0 / scale_px;
286         // Unwrapped tile-index range covering the window (x wraps around
287         // the antimeridian via rem_euclid; y is clamped to the world).
288         let tx0 = (u0 * n as f64).floor() as i64;
289         let tx1 = ((u0 + w / scale_px) * n as f64).floor() as i64;
290         let ty0 = ((v0 * n as f64).floor() as i64).max(0);
291         let ty1 = (((v0 + h / scale_px) * n as f64).floor() as i64).min(n as i64 - 1);
292 
293         for ty in ty0..=ty1 {
294             for tx in tx0..=tx1 {
295                 let key = TileKey {
296                     z: tz,
297                     x: tx.rem_euclid(n as i64) as u32,
298                     y: ty as u32,
299                 };
300                 let rect = Rect {
301                     x: ((tx as f64 / n as f64 - u0) * scale_px) as f32,
302                     y: ((ty as f64 / n as f64 - v0) * scale_px) as f32,
303                     width: tile_px as f32,
304                     height: tile_px as f32,
305                 };
306                 if let Some(img) = self.tiles.ensure(key) {
307                     pc.image(img, rect, 1.0);
308                     continue;
309                 }
310                 // Loading: checkerboard placeholder, overdrawn by the
311                 // nearest resident ancestor scaled up and clipped.
312                 let shade = if (tx + ty) % 2 == 0 { 0.10 } else { 0.12 };
313                 pc.quad(rect, [shade, shade, shade + 0.01, 1.0]);
314                 for d in 1..=5u8 {
315                     if d > tz {
316                         break;
317                     }
318                     let az = tz - d;
319                     let f = 1i64 << d;
320                     let atx = tx.div_euclid(f);
321                     let aty = ty.div_euclid(f);
322                     let akey = TileKey {
323                         z: az,
324                         x: atx.rem_euclid((n / (f as u64)) as i64) as u32,
325                         y: aty as u32,
326                     };
327                     if let Some(img) = self.tiles.ready(akey) {
328                         let arect = Rect {
329                             x: ((atx as f64 * f as f64 / n as f64 - u0) * scale_px) as f32,
330                             y: ((aty as f64 * f as f64 / n as f64 - v0) * scale_px) as f32,
331                             width: (tile_px * f as f64) as f32,
332                             height: (tile_px * f as f64) as f32,
333                         };
334                         pc.clip(rect, |pc| pc.image(img, arect, 1.0));
335                         break;
336                     }
337                 }
338             }
339         }
340 
341         // HUD: zoom + center coordinates (top-left), attribution (bottom-right).
342         // Both stand the root plate's inset off the window edge — the one
343         // number the ladder gives for that — with the control text inset
344         // inside their boxes.
345         let inset = cce_ui::layout::root_plate_inset();
346         let text_in = cce_ui::layout::CONTROL_TEXT_INSET;
347         let hud_h = 24.0f32;
348         let (lat, lon) = self.center_lat_lon();
349         pc.quad(Rect { x: inset, y: inset, width: 232.0, height: hud_h }, [0.0, 0.0, 0.0, 0.45]);
350         pc.text(
351             format!("z {:.2}   {:.4}°, {:.4}°", self.zoom, lat, lon),
352             inset + text_in,
353             inset + 5.0,
354             12.0,
355             [230, 230, 230],
356         );
357         let attr_w = 200.0f32;
358         let (ax, ay) = (size.width - inset - attr_w, size.height - inset - hud_h);
359         pc.quad(Rect { x: ax, y: ay, width: attr_w, height: hud_h }, [0.0, 0.0, 0.0, 0.45]);
360         pc.text(
361             "© OpenStreetMap contributors",
362             ax + text_in,
363             ay + 5.0,
364             11.0,
365             [200, 200, 200],
366         );
367 
368         Some(pc.finish())
369     }
370 
371     fn display_list_text(&self) -> bool {
372         true
373     }
374 
375     fn clear_color(&self) -> [f32; 4] {
376         [0.07, 0.08, 0.09, 1.0]
377     }
378 }
379 
380 fn main() {
381     env_logger::init();
382     cce_ui::engine::run::<MapApp>();
383 }