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 }