1extern crate alloc;
14use alloc::string::String;
15use alloc::vec::Vec;
16
17pub const TILE: i32 = 256;
19pub const MAX_ZOOM: u32 = 19;
21
22#[derive(Debug, Clone, PartialEq)]
24pub struct Embed {
25 pub min_lon: f64,
27 pub min_lat: f64,
28 pub max_lon: f64,
29 pub max_lat: f64,
30 pub marker: Option<(f64, f64)>,
32}
33
34#[derive(Debug, Clone, PartialEq)]
36pub struct Plan {
37 pub zoom: u32,
38 pub tiles: Vec<(String, i32, i32)>,
40 pub marker: Option<(i32, i32)>,
42}
43
44pub fn parse_embed_url(url: &str) -> Option<Embed> {
46 let u = url.trim();
47 let rest = u
48 .strip_prefix("https://")
49 .or_else(|| u.strip_prefix("http://"))
50 .or_else(|| u.strip_prefix("//"))?;
51 let (host_path, query) = rest.split_once('?')?;
52 let host_ok = host_path.starts_with("www.openstreetmap.org/") || host_path.starts_with("openstreetmap.org/");
53 if !host_ok || !host_path.ends_with("/export/embed.html") {
54 return None;
55 }
56 let mut bbox: Option<[f64; 4]> = None;
57 let mut marker = None;
58 for kv in query.split('&') {
59 let kv = kv.strip_prefix("amp;").unwrap_or(kv);
61 let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
62 let v = v.replace("%2C", ",").replace("%2c", ",");
63 match k {
64 "bbox" => {
65 let n: Vec<f64> = v.split(',').filter_map(|x| x.trim().parse::<f64>().ok()).collect();
66 if n.len() == 4 && n.iter().all(|x| x.is_finite()) {
67 bbox = Some([n[0], n[1], n[2], n[3]]);
68 }
69 }
70 "marker" => {
71 let n: Vec<f64> = v.split(',').filter_map(|x| x.trim().parse::<f64>().ok()).collect();
72 if n.len() == 2 && n.iter().all(|x| x.is_finite()) {
73 marker = Some((n[0], n[1]));
74 }
75 }
76 _ => {}
77 }
78 }
79 let [a, b, c, d] = bbox?;
80 Some(Embed {
81 min_lon: a.min(c),
82 min_lat: b.min(d),
83 max_lon: a.max(c),
84 max_lat: b.max(d),
85 marker,
86 })
87}
88
89pub fn project(lon: f64, lat: f64, z: u32) -> (f64, f64) {
91 let world = TILE as f64 * (1u64 << z) as f64;
92 let lat = lat.clamp(-85.051_128_78, 85.051_128_78);
93 let x = (lon + 180.0) / 360.0 * world;
94 let rad = lat * core::f64::consts::PI / 180.0;
95 let y = (1.0 - libm::log(libm::tan(rad) + 1.0 / libm::cos(rad)) / core::f64::consts::PI) / 2.0 * world;
96 (x, y)
97}
98
99pub fn fit_zoom(e: &Embed, w: i32, h: i32) -> u32 {
101 let mut best = 0;
102 for z in 0..=MAX_ZOOM {
103 let (x0, y0) = project(e.min_lon, e.max_lat, z);
104 let (x1, y1) = project(e.max_lon, e.min_lat, z);
105 if (x1 - x0) <= w as f64 && (y1 - y0) <= h as f64 {
106 best = z;
107 } else {
108 break;
109 }
110 }
111 best
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
116pub struct MapState {
117 pub pan_x: i32,
119 pub pan_y: i32,
121 pub zoom_level: Option<u32>,
123}
124
125impl MapState {
126 pub const fn new() -> Self {
127 Self {
128 pan_x: 0,
129 pan_y: 0,
130 zoom_level: None,
131 }
132 }
133
134 pub fn pan(&mut self, dx: i32, dy: i32) {
135 self.pan_x = self.pan_x.saturating_add(dx);
136 self.pan_y = self.pan_y.saturating_add(dy);
137 }
138
139 pub fn zoom_in(&mut self, current_zoom: u32) {
140 let z = self.zoom_level.unwrap_or(current_zoom);
141 self.zoom_level = Some((z + 1).min(MAX_ZOOM));
142 self.pan_x = self.pan_x.saturating_mul(2);
143 self.pan_y = self.pan_y.saturating_mul(2);
144 }
145
146 pub fn zoom_out(&mut self, current_zoom: u32) {
147 let z = self.zoom_level.unwrap_or(current_zoom);
148 self.zoom_level = Some(z.saturating_sub(1));
149 self.pan_x /= 2;
150 self.pan_y /= 2;
151 }
152
153 pub fn reset(&mut self) {
154 *self = Self::default();
155 }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum MapControlAction {
161 ZoomIn,
162 ZoomOut,
163}
164
165pub const CTRL_BTN_SIZE: i32 = 28;
166pub const CTRL_BTN_MARGIN: i32 = 10;
167pub const CTRL_BTN_GAP: i32 = 4;
168
169pub fn hit_test_controls(rel_x: i32, rel_y: i32) -> Option<MapControlAction> {
171 let left = CTRL_BTN_MARGIN;
172 let right = left + CTRL_BTN_SIZE;
173 if rel_x < left || rel_x >= right {
174 return None;
175 }
176 let top_in = CTRL_BTN_MARGIN;
177 let bottom_in = top_in + CTRL_BTN_SIZE;
178 if rel_y >= top_in && rel_y < bottom_in {
179 return Some(MapControlAction::ZoomIn);
180 }
181 let top_out = bottom_in + CTRL_BTN_GAP;
182 let bottom_out = top_out + CTRL_BTN_SIZE;
183 if rel_y >= top_out && rel_y < bottom_out {
184 return Some(MapControlAction::ZoomOut);
185 }
186 None
187}
188
189pub fn plan_with_state(e: &Embed, w: i32, h: i32, state: &MapState) -> Option<Plan> {
191 if w <= 0 || h <= 0 {
192 return None;
193 }
194 let z = state
195 .zoom_level
196 .unwrap_or_else(|| fit_zoom(e, w, h))
197 .min(MAX_ZOOM);
198 let (x0, y0) = project(e.min_lon, e.max_lat, z);
199 let (x1, y1) = project(e.max_lon, e.min_lat, z);
200 let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
201 let left = libm::floor(cx - w as f64 / 2.0) as i64 - state.pan_x as i64;
203 let top = libm::floor(cy - h as f64 / 2.0) as i64 - state.pan_y as i64;
204 let n = 1i64 << z;
205 let t = TILE as i64;
206 let tx0 = left.div_euclid(t);
207 let tx1 = (left + w as i64 - 1).div_euclid(t);
208 let ty0 = top.div_euclid(t).clamp(0, n - 1);
209 let ty1 = (top + h as i64 - 1).div_euclid(t).clamp(0, n - 1);
210 let mut tiles = Vec::new();
211 if ty0 <= ty1 {
212 for ty in ty0..=ty1 {
213 for tx in tx0..=tx1 {
214 let wx = tx.rem_euclid(n);
216 let url = alloc::format!("https://tile.openstreetmap.org/{}/{}/{}.png", z, wx, ty);
217 tiles.push((url, (tx * t - left) as i32, (ty * t - top) as i32));
218 }
219 }
220 }
221 let marker = e.marker.map(|(lat, lon)| {
222 let (mx, my) = project(lon, lat, z);
223 ((libm::floor(mx) as i64 - left) as i32, (libm::floor(my) as i64 - top) as i32)
224 });
225 Some(Plan { zoom: z, tiles, marker })
226}
227
228pub fn plan(e: &Embed, w: i32, h: i32) -> Option<Plan> {
230 plan_with_state(e, w, h, &MapState::default())
231}
232