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