Skip to main content

atmos/os_lib/web_engine/
osm_embed.rs

1//! OpenStreetMap 埋め込み(`<iframe src="https://www.openstreetmap.org/export/embed.html?bbox=..&marker=..">`)
2//! を、タイル画像を並べて描くための計算(純粋モジュール)。
3//!
4//! 【2026-09-26】現在の OSM 埋め込みページは MapLibre GL(WebGL で描画)で、AtmOS には WebGL が無いため
5//! iframe の中身をそのまま実行しても地図は出ない。一方、地図タイル自体は普通の PNG
6//! (`https://tile.openstreetmap.org/{z}/{x}/{y}.png`)なので、埋め込み URL の範囲(bbox)と
7//! マーカーから、表示に必要なタイルと配置を計算して**ネイティブに描く**(PDF を内蔵ビューアで
8//! 表示するのと同じ考え方)。ズームは MapLibre/Leaflet の `fitBounds` と同じく「範囲が枠に収まる
9//! 最大の整数ズーム」、中心は範囲の中心。
10//!
11//! 座標は Web メルカトル(EPSG:3857)。ズーム z の世界全体の大きさは 256·2^z ピクセル。
12
13extern crate alloc;
14use alloc::string::String;
15use alloc::vec::Vec;
16
17/// タイル 1 枚の大きさ(px)。
18pub const TILE: i32 = 256;
19/// OSM 標準タイルの最大ズーム。
20pub const MAX_ZOOM: u32 = 19;
21
22/// 埋め込み URL から読み取った情報。
23#[derive(Debug, Clone, PartialEq)]
24pub struct Embed {
25    /// 範囲(経度・緯度の最小・最大)。
26    pub min_lon: f64,
27    pub min_lat: f64,
28    pub max_lon: f64,
29    pub max_lat: f64,
30    /// マーカー(緯度, 経度)。
31    pub marker: Option<(f64, f64)>,
32}
33
34/// 描画計画。座標は iframe の左上を原点とする px。
35#[derive(Debug, Clone, PartialEq)]
36pub struct Plan {
37    pub zoom: u32,
38    /// (タイル URL, 左上 x, 左上 y)。
39    pub tiles: Vec<(String, i32, i32)>,
40    /// マーカーの位置(px)。
41    pub marker: Option<(i32, i32)>,
42}
43
44/// URL が OSM の埋め込みページなら、その範囲とマーカーを返す。
45pub 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        // HTML 属性由来の `&amp;` は属性解析で `&` に戻っている前提だが、念のため許容する。
60        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
89/// 経度・緯度 → ズーム z の世界ピクセル座標。
90pub 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
99/// 範囲が w×h の枠に収まる最大の整数ズーム(0〜MAX_ZOOM)。
100pub 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/// w×h の枠に地図を描く計画を立てる。枠が空なら None。
115pub fn plan(e: &Embed, w: i32, h: i32) -> Option<Plan> {
116    if w <= 0 || h <= 0 {
117        return None;
118    }
119    let z = fit_zoom(e, w, h);
120    let (x0, y0) = project(e.min_lon, e.max_lat, z);
121    let (x1, y1) = project(e.max_lon, e.min_lat, z);
122    let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
123    let left = libm::floor(cx - w as f64 / 2.0) as i64;
124    let top = libm::floor(cy - h as f64 / 2.0) as i64;
125    let n = 1i64 << z;
126    let t = TILE as i64;
127    let tx0 = left.div_euclid(t);
128    let tx1 = (left + w as i64 - 1).div_euclid(t);
129    let ty0 = top.div_euclid(t).max(0);
130    let ty1 = (top + h as i64 - 1).div_euclid(t).min(n - 1);
131    let mut tiles = Vec::new();
132    for ty in ty0..=ty1 {
133        for tx in tx0..=tx1 {
134            // 経度方向は世界を一周するので折り返す。
135            let wx = tx.rem_euclid(n);
136            let url = alloc::format!("https://tile.openstreetmap.org/{}/{}/{}.png", z, wx, ty);
137            tiles.push((url, (tx * t - left) as i32, (ty * t - top) as i32));
138        }
139    }
140    let marker = e.marker.map(|(lat, lon)| {
141        let (mx, my) = project(lon, lat, z);
142        ((libm::floor(mx) as i64 - left) as i32, (libm::floor(my) as i64 - top) as i32)
143    });
144    Some(Plan { zoom: z, tiles, marker })
145}