widgetry/tools/
url.rs

1use anyhow::Result;
2
3use crate::EventCtx;
4use geom::{GPSBounds, LonLat, Pt2D};
5
6/// Utilities for reflecting the current map and viewport in the URL on the web. No effect on
7/// native.
8pub struct URLManager;
9
10impl URLManager {
11    /// Modify the current URL to change the first free parameter in the HTTP GET params to the
12    /// specified value, adding it if needed.
13    pub fn update_url_free_param(free_param: String) {
14        must_update_url(Box::new(move |url| change_url_free_param(url, &free_param)))
15    }
16
17    /// Modify the current URL to change the first named parameter in the HTTP GET params to the
18    /// specified value, adding it if needed.
19    pub fn update_url_param(key: String, value: String) {
20        must_update_url(Box::new(move |url| change_url_param(url, &key, &value)))
21    }
22
23    /// Get an OSM-style `zoom/lat/lon` string
24    /// (https://wiki.openstreetmap.org/wiki/Browsing#Other_URL_tricks) based on the current
25    /// viewport.
26    pub fn get_cam_param(ctx: &EventCtx, gps_bounds: &GPSBounds) -> String {
27        let center = ctx.canvas.center_to_map_pt().to_gps(gps_bounds);
28
29        // To calculate zoom, just solve for the inverse of the code in parse_center_camera.
30        let earth_circumference_equator = 40_075_016.686;
31        let log_arg =
32            earth_circumference_equator * center.y().to_radians().cos() * ctx.canvas.cam_zoom;
33        let zoom_lvl = log_arg.log2() - 8.0;
34
35        // Trim precision
36        format!("{:.2}/{:.5}/{:.5}", zoom_lvl, center.y(), center.x())
37    }
38
39    /// Modify the current URL to set --cam to an OSM-style `zoom/lat/lon` string
40    /// (https://wiki.openstreetmap.org/wiki/Browsing#Other_URL_tricks) based on the current
41    /// viewport.
42    pub fn update_url_cam(ctx: &EventCtx, gps_bounds: &GPSBounds) {
43        let cam = URLManager::get_cam_param(ctx, gps_bounds);
44        must_update_url(Box::new(move |url| change_url_param(url, "--cam", &cam)))
45    }
46
47    /// Parse an OSM-style `zoom/lat/lon` string
48    /// (https://wiki.openstreetmap.org/wiki/Browsing#Other_URL_tricks), changing the canvas
49    /// appropriately. Returns true upon success.
50    pub fn change_camera(ctx: &mut EventCtx, raw: Option<&String>, gps_bounds: &GPSBounds) -> bool {
51        if let Some((pt, zoom)) =
52            raw.and_then(|raw| URLManager::parse_center_camera(raw, gps_bounds))
53        {
54            ctx.canvas.cam_zoom = zoom;
55            ctx.canvas.center_on_map_pt(pt);
56            true
57        } else {
58            false
59        }
60    }
61
62    /// Parse an OSM-style `zoom/lat/lon` string
63    /// (https://wiki.openstreetmap.org/wiki/Browsing#Other_URL_tricks), returning the map point to
64    /// center on and the camera zoom.
65    fn parse_center_camera(raw: &str, gps_bounds: &GPSBounds) -> Option<(Pt2D, f64)> {
66        let parts: Vec<&str> = raw.split('/').collect();
67        if parts.len() != 3 {
68            return None;
69        }
70        let zoom_lvl = parts[0].parse::<f64>().ok()?;
71        let lat = parts[1].parse::<f64>().ok()?;
72        let lon = parts[2].parse::<f64>().ok()?;
73        let gps = LonLat::new(lon, lat);
74        if !gps_bounds.contains(gps) {
75            return None;
76        }
77        let pt = gps.to_pt(gps_bounds);
78
79        // To figure out zoom, first calculate horizontal meters per pixel, using the formula from
80        // https://wiki.openstreetmap.org/wiki/Zoom_levels.
81        let earth_circumference_equator = 40_075_016.686;
82        let horiz_meters_per_pixel =
83            earth_circumference_equator * gps.y().to_radians().cos() / 2.0_f64.powf(zoom_lvl + 8.0);
84
85        // So this is the width in meters that should cover our screen
86        // let horiz_meters_per_screen = ctx.canvas.window_width * horiz_meters_per_pixel;
87        // Now we want to make screen_to_map(the top-right corner of the screen) =
88        // horiz_meters_per_screen. Easy algebra:
89        // let cam_zoom = ctx.canvas.window_width / horiz_meters_per_screen;
90
91        // But actually, the algebra shows we don't even need window_width. Easy!
92        let cam_zoom = 1.0 / horiz_meters_per_pixel;
93
94        Some((pt, cam_zoom))
95    }
96}
97
98fn must_update_url(transform: Box<dyn Fn(String) -> String>) {
99    if let Err(err) = update_url(transform) {
100        warn!("Couldn't update URL: {}", err);
101    }
102}
103
104#[allow(unused_variables)]
105fn update_url(transform: Box<dyn Fn(String) -> String>) -> Result<()> {
106    #[cfg(target_arch = "wasm32")]
107    {
108        let window = web_sys::window().ok_or(anyhow!("no window?"))?;
109        let url = window.location().href().map_err(|err| {
110            anyhow!(err
111                .as_string()
112                .unwrap_or("window.location.href failed".to_string()))
113        })?;
114        let new_url = (transform)(url);
115
116        // Setting window.location.href may seem like the obvious thing to do, but that actually
117        // refreshes the page. This method just changes the URL and doesn't mess up history. See
118        // https://developer.mozilla.org/en-US/docs/Web/API/History_API/Working_with_the_History_API.
119        let history = window.history().map_err(|err| {
120            anyhow!(err
121                .as_string()
122                .unwrap_or("window.history failed".to_string()))
123        })?;
124        history
125            .replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(&new_url))
126            .map_err(|err| {
127                anyhow!(err
128                    .as_string()
129                    .unwrap_or("window.history.replace_state failed".to_string()))
130            })?;
131    }
132    Ok(())
133}
134
135fn change_url_free_param(url: String, free_param: &str) -> String {
136    // The URL parsing crates I checked had lots of dependencies and didn't even expose such a nice
137    // API for doing this anyway.
138    let url_parts = url.split('?').collect::<Vec<_>>();
139    if url_parts.len() == 1 {
140        return format!("{}?{}", url, free_param);
141    }
142    let mut query_params = String::new();
143    let mut found_free = false;
144    let mut first = true;
145    for x in url_parts[1].split('&') {
146        if !first {
147            query_params.push('&');
148        }
149        first = false;
150
151        if x.starts_with("--") {
152            query_params.push_str(x);
153        } else if !found_free {
154            // Replace the first free parameter
155            query_params.push_str(free_param);
156            found_free = true;
157        } else {
158            query_params.push_str(x);
159        }
160    }
161    if !found_free {
162        if !first {
163            query_params.push('&');
164        }
165        query_params.push_str(free_param);
166    }
167
168    format!("{}?{}", url_parts[0], query_params)
169}
170
171fn change_url_param(url: String, key: &str, value: &str) -> String {
172    // The URL parsing crates I checked had lots of dependencies and didn't even expose such a nice
173    // API for doing this anyway.
174    let url_parts = url.split('?').collect::<Vec<_>>();
175    if url_parts.len() == 1 {
176        return format!("{}?{}={}", url, key, value);
177    }
178    let mut query_params = String::new();
179    let mut found_key = false;
180    let mut first = true;
181    for x in url_parts[1].split('&') {
182        if !first {
183            query_params.push('&');
184        }
185        first = false;
186
187        if x.starts_with(key) {
188            query_params.push_str(&format!("{}={}", key, value));
189            found_key = true;
190        } else {
191            query_params.push_str(x);
192        }
193    }
194    if !found_key {
195        if !first {
196            query_params.push('&');
197        }
198        query_params.push_str(&format!("{}={}", key, value));
199    }
200
201    format!("{}?{}", url_parts[0], query_params)
202}
203
204#[cfg(test)]
205mod tests {
206    #[test]
207    fn test_change_url_free_param() {
208        use super::change_url_free_param;
209
210        assert_eq!(
211            "http://0.0.0.0:8000/?--dev&seattle/maps/montlake.bin",
212            change_url_free_param(
213                "http://0.0.0.0:8000/?--dev".to_string(),
214                "seattle/maps/montlake.bin"
215            )
216        );
217        assert_eq!(
218            "http://0.0.0.0:8000/?--dev&seattle/maps/qa.bin",
219            change_url_free_param(
220                "http://0.0.0.0:8000/?--dev&seattle/maps/montlake.bin".to_string(),
221                "seattle/maps/qa.bin"
222            )
223        );
224        assert_eq!(
225            "http://0.0.0.0:8000?seattle/maps/montlake.bin",
226            change_url_free_param(
227                "http://0.0.0.0:8000".to_string(),
228                "seattle/maps/montlake.bin"
229            )
230        );
231    }
232
233    #[test]
234    fn test_change_url_param() {
235        use super::change_url_param;
236
237        assert_eq!(
238            "http://0.0.0.0:8000/?--dev&seattle/maps/montlake.bin&--cam=16.6/53.78449/-1.70701",
239            change_url_param(
240                "http://0.0.0.0:8000/?--dev&seattle/maps/montlake.bin".to_string(),
241                "--cam",
242                "16.6/53.78449/-1.70701"
243            )
244        );
245        assert_eq!(
246            "http://0.0.0.0:8000?--cam=16.6/53.78449/-1.70701",
247            change_url_param(
248                "http://0.0.0.0:8000".to_string(),
249                "--cam",
250                "16.6/53.78449/-1.70701"
251            )
252        );
253    }
254}