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 }