Skip to main content

rustial_engine/
tilejson.rs

1//! TileJSON metadata model for vector and raster tile sources.
2//!
3//! [TileJSON](https://github.com/mapbox/tilejson-spec) is the standard
4//! metadata format used by MapLibre GL JS and Mapbox GL JS to describe
5//! tile sources.  A typical vector tile endpoint exposes a
6//! `tiles.json` (or inline `TileJSON`) that provides:
7//!
8//! - tile URL templates
9//! - zoom range
10//! - geographic bounds
11//! - source-layer names (for vector tiles)
12//! - attribution
13//!
14//! This module provides a parse-once, query-many [`TileJson`] struct
15//! that the engine can use to configure tile managers and sources.
16//!
17//! ## Feature gate
18//!
19//! Parsing from JSON bytes requires the `style-json` feature (which
20//! enables `serde` + `serde_json`).  The [`TileJson`] struct itself
21//! is always available for programmatic construction.
22
23use std::fmt;
24
25// ---------------------------------------------------------------------------
26// VectorLayer metadata
27// ---------------------------------------------------------------------------
28
29/// Metadata for a single source layer inside a vector tile source.
30///
31/// Mirrors the `vector_layers` entry in TileJSON 3.0.
32#[derive(Debug, Clone, PartialEq)]
33pub struct VectorLayerMeta {
34    /// Machine-readable source-layer name (e.g. `"water"`, `"roads"`).
35    pub id: String,
36    /// Optional human-readable description.
37    pub description: Option<String>,
38    /// Minimum zoom at which this layer appears.
39    pub min_zoom: Option<u8>,
40    /// Maximum zoom at which this layer appears.
41    pub max_zoom: Option<u8>,
42}
43
44impl VectorLayerMeta {
45    /// Create metadata with only an id.
46    pub fn new(id: impl Into<String>) -> Self {
47        Self {
48            id: id.into(),
49            description: None,
50            min_zoom: None,
51            max_zoom: None,
52        }
53    }
54}
55
56// ---------------------------------------------------------------------------
57// TileJson
58// ---------------------------------------------------------------------------
59
60/// Parsed TileJSON metadata.
61///
62/// This is a framework-agnostic representation of the subset of
63/// TileJSON fields that the engine needs to configure tile managers
64/// and sources.
65#[derive(Debug, Clone, PartialEq)]
66pub struct TileJson {
67    /// TileJSON spec version (e.g. `"3.0.0"`).
68    pub tilejson: String,
69    /// Optional human-readable name.
70    pub name: Option<String>,
71    /// Optional description.
72    pub description: Option<String>,
73    /// TileJSON version string (semver).
74    pub version: Option<String>,
75    /// Optional attribution HTML string.
76    pub attribution: Option<String>,
77    /// Tile URL templates with `{z}`, `{x}`, `{y}` placeholders.
78    pub tiles: Vec<String>,
79    /// Minimum zoom level supported by the source.
80    pub min_zoom: u8,
81    /// Maximum zoom level supported by the source.
82    pub max_zoom: u8,
83    /// Geographic bounds as `[west, south, east, north]` in WGS-84 degrees.
84    ///
85    /// `None` means the source covers the whole world.
86    pub bounds: Option<[f64; 4]>,
87    /// Default center + zoom as `[lon, lat, zoom]`.
88    pub center: Option<[f64; 3]>,
89    /// Tile encoding scheme.
90    pub scheme: TileScheme,
91    /// Vector layer metadata (only present for vector tile sources).
92    pub vector_layers: Vec<VectorLayerMeta>,
93}
94
95impl Default for TileJson {
96    fn default() -> Self {
97        Self {
98            tilejson: "3.0.0".into(),
99            name: None,
100            description: None,
101            version: None,
102            attribution: None,
103            tiles: Vec::new(),
104            min_zoom: 0,
105            max_zoom: 22,
106            bounds: None,
107            center: None,
108            scheme: TileScheme::Xyz,
109            vector_layers: Vec::new(),
110        }
111    }
112}
113
114impl TileJson {
115    /// Create a minimal TileJSON with a single tile URL template.
116    pub fn with_tiles(tiles: Vec<String>) -> Self {
117        Self {
118            tiles,
119            ..Self::default()
120        }
121    }
122
123    /// Return the first tile URL template, if any.
124    #[inline]
125    pub fn first_tile_url(&self) -> Option<&str> {
126        self.tiles.first().map(String::as_str)
127    }
128
129    /// Return `true` if this TileJSON describes a vector tile source
130    /// (has `vector_layers`).
131    #[inline]
132    pub fn is_vector(&self) -> bool {
133        !self.vector_layers.is_empty()
134    }
135
136    /// Return the names of all declared vector source layers.
137    pub fn source_layer_names(&self) -> Vec<&str> {
138        self.vector_layers.iter().map(|vl| vl.id.as_str()).collect()
139    }
140
141    /// Check whether a geographic coordinate is within the source bounds.
142    ///
143    /// Returns `true` when no bounds are set (unbounded source).
144    pub fn contains_point(&self, lon: f64, lat: f64) -> bool {
145        match self.bounds {
146            Some([west, south, east, north]) => {
147                lon >= west && lon <= east && lat >= south && lat <= north
148            }
149            None => true,
150        }
151    }
152}
153
154/// Tile coordinate scheme.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
156pub enum TileScheme {
157    /// Standard `z/x/y` (OSM / slippy map convention).
158    #[default]
159    Xyz,
160    /// TMS convention where `y` is flipped.
161    Tms,
162}
163
164impl fmt::Display for TileScheme {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        match self {
167            TileScheme::Xyz => write!(f, "xyz"),
168            TileScheme::Tms => write!(f, "tms"),
169        }
170    }
171}
172
173// ---------------------------------------------------------------------------
174// TileJSON error
175// ---------------------------------------------------------------------------
176
177/// Errors that can occur when parsing TileJSON.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum TileJsonError {
180    /// The JSON was malformed.
181    InvalidJson(String),
182    /// A required field was missing.
183    MissingField(&'static str),
184    /// A field had an unexpected type or value.
185    InvalidField {
186        /// Field name.
187        field: &'static str,
188        /// Description of the problem.
189        reason: String,
190    },
191}
192
193impl fmt::Display for TileJsonError {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        match self {
196            TileJsonError::InvalidJson(msg) => write!(f, "invalid TileJSON: {msg}"),
197            TileJsonError::MissingField(field) => {
198                write!(f, "missing required TileJSON field: `{field}`")
199            }
200            TileJsonError::InvalidField { field, reason } => {
201                write!(f, "invalid TileJSON field `{field}`: {reason}")
202            }
203        }
204    }
205}
206
207impl std::error::Error for TileJsonError {}
208
209// ---------------------------------------------------------------------------
210// Parsing (requires serde_json via the style-json feature)
211// ---------------------------------------------------------------------------
212
213#[cfg(feature = "style-json")]
214mod parsing {
215    use super::*;
216    use serde_json::Value;
217
218    /// Parse TileJSON from raw JSON bytes.
219    pub fn parse_tilejson(bytes: &[u8]) -> Result<TileJson, TileJsonError> {
220        let value: Value = serde_json::from_slice(bytes)
221            .map_err(|e| TileJsonError::InvalidJson(e.to_string()))?;
222        parse_tilejson_value(&value)
223    }
224
225    /// Parse TileJSON from a `serde_json::Value`.
226    pub fn parse_tilejson_value(value: &Value) -> Result<TileJson, TileJsonError> {
227        let obj = value
228            .as_object()
229            .ok_or(TileJsonError::InvalidJson("root is not an object".into()))?;
230
231        let tilejson = obj
232            .get("tilejson")
233            .and_then(|v| v.as_str())
234            .unwrap_or("3.0.0")
235            .to_owned();
236
237        let tiles = obj
238            .get("tiles")
239            .and_then(|v| v.as_array())
240            .map(|arr| {
241                arr.iter()
242                    .filter_map(|v| v.as_str().map(ToOwned::to_owned))
243                    .collect::<Vec<_>>()
244            })
245            .unwrap_or_default();
246
247        if tiles.is_empty() {
248            return Err(TileJsonError::MissingField("tiles"));
249        }
250
251        let min_zoom = obj
252            .get("minzoom")
253            .and_then(|v| v.as_u64())
254            .map(|v| v.min(30) as u8)
255            .unwrap_or(0);
256
257        let max_zoom = obj
258            .get("maxzoom")
259            .and_then(|v| v.as_u64())
260            .map(|v| v.min(30) as u8)
261            .unwrap_or(22);
262
263        let bounds = obj.get("bounds").and_then(|v| {
264            let arr = v.as_array()?;
265            if arr.len() >= 4 {
266                Some([
267                    arr[0].as_f64()?,
268                    arr[1].as_f64()?,
269                    arr[2].as_f64()?,
270                    arr[3].as_f64()?,
271                ])
272            } else {
273                None
274            }
275        });
276
277        let center = obj.get("center").and_then(|v| {
278            let arr = v.as_array()?;
279            if arr.len() >= 3 {
280                Some([arr[0].as_f64()?, arr[1].as_f64()?, arr[2].as_f64()?])
281            } else {
282                None
283            }
284        });
285
286        let scheme = obj
287            .get("scheme")
288            .and_then(|v| v.as_str())
289            .map(|s| match s {
290                "tms" => TileScheme::Tms,
291                _ => TileScheme::Xyz,
292            })
293            .unwrap_or(TileScheme::Xyz);
294
295        let name = obj.get("name").and_then(|v| v.as_str()).map(ToOwned::to_owned);
296        let description = obj
297            .get("description")
298            .and_then(|v| v.as_str())
299            .map(ToOwned::to_owned);
300        let version = obj
301            .get("version")
302            .and_then(|v| v.as_str())
303            .map(ToOwned::to_owned);
304        let attribution = obj
305            .get("attribution")
306            .and_then(|v| v.as_str())
307            .map(ToOwned::to_owned);
308
309        let vector_layers = obj
310            .get("vector_layers")
311            .and_then(|v| v.as_array())
312            .map(|arr| arr.iter().filter_map(parse_vector_layer_meta).collect())
313            .unwrap_or_default();
314
315        Ok(TileJson {
316            tilejson,
317            name,
318            description,
319            version,
320            attribution,
321            tiles,
322            min_zoom,
323            max_zoom,
324            bounds,
325            center,
326            scheme,
327            vector_layers,
328        })
329    }
330
331    fn parse_vector_layer_meta(value: &Value) -> Option<VectorLayerMeta> {
332        let obj = value.as_object()?;
333        let id = obj.get("id")?.as_str()?.to_owned();
334        let description = obj
335            .get("description")
336            .and_then(|v| v.as_str())
337            .map(ToOwned::to_owned);
338        let min_zoom = obj
339            .get("minzoom")
340            .and_then(|v| v.as_u64())
341            .map(|v| v.min(30) as u8);
342        let max_zoom = obj
343            .get("maxzoom")
344            .and_then(|v| v.as_u64())
345            .map(|v| v.min(30) as u8);
346        Some(VectorLayerMeta {
347            id,
348            description,
349            min_zoom,
350            max_zoom,
351        })
352    }
353}
354
355#[cfg(feature = "style-json")]
356pub use parsing::{parse_tilejson, parse_tilejson_value};
357
358// ---------------------------------------------------------------------------
359// Tests
360// ---------------------------------------------------------------------------
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn default_tilejson_has_sensible_values() {
368        let tj = TileJson::default();
369        assert_eq!(tj.tilejson, "3.0.0");
370        assert_eq!(tj.min_zoom, 0);
371        assert_eq!(tj.max_zoom, 22);
372        assert!(tj.tiles.is_empty());
373        assert!(!tj.is_vector());
374        assert!(tj.source_layer_names().is_empty());
375    }
376
377    #[test]
378    fn with_tiles_constructor() {
379        let tj = TileJson::with_tiles(vec!["https://example.com/{z}/{x}/{y}.pbf".into()]);
380        assert_eq!(tj.tiles.len(), 1);
381        assert_eq!(
382            tj.first_tile_url(),
383            Some("https://example.com/{z}/{x}/{y}.pbf")
384        );
385    }
386
387    #[test]
388    fn is_vector_when_layers_present() {
389        let mut tj = TileJson::default();
390        assert!(!tj.is_vector());
391        tj.vector_layers.push(VectorLayerMeta::new("water"));
392        assert!(tj.is_vector());
393        assert_eq!(tj.source_layer_names(), vec!["water"]);
394    }
395
396    #[test]
397    fn contains_point_unbounded() {
398        let tj = TileJson::default();
399        assert!(tj.contains_point(0.0, 0.0));
400        assert!(tj.contains_point(180.0, 90.0));
401    }
402
403    #[test]
404    fn contains_point_bounded() {
405        let mut tj = TileJson::default();
406        tj.bounds = Some([-10.0, -20.0, 30.0, 40.0]);
407        assert!(tj.contains_point(0.0, 0.0));
408        assert!(tj.contains_point(-10.0, -20.0));
409        assert!(tj.contains_point(30.0, 40.0));
410        assert!(!tj.contains_point(-11.0, 0.0));
411        assert!(!tj.contains_point(0.0, 41.0));
412    }
413
414    #[test]
415    fn tile_scheme_display() {
416        assert_eq!(TileScheme::Xyz.to_string(), "xyz");
417        assert_eq!(TileScheme::Tms.to_string(), "tms");
418    }
419
420    #[test]
421    fn tilejson_error_display() {
422        let err = TileJsonError::MissingField("tiles");
423        assert!(err.to_string().contains("tiles"));
424    }
425
426    #[cfg(feature = "style-json")]
427    mod json_parsing {
428        use super::*;
429
430        #[test]
431        fn parse_minimal_vector_tilejson() {
432            let json = br#"{
433                "tilejson": "3.0.0",
434                "tiles": ["https://example.com/{z}/{x}/{y}.pbf"],
435                "minzoom": 0,
436                "maxzoom": 14,
437                "vector_layers": [
438                    {"id": "water", "minzoom": 0, "maxzoom": 14},
439                    {"id": "roads", "description": "Road network"}
440                ]
441            }"#;
442
443            let tj = parse_tilejson(json).expect("valid tilejson");
444            assert_eq!(tj.tilejson, "3.0.0");
445            assert_eq!(tj.tiles.len(), 1);
446            assert_eq!(tj.min_zoom, 0);
447            assert_eq!(tj.max_zoom, 14);
448            assert!(tj.is_vector());
449            assert_eq!(tj.vector_layers.len(), 2);
450            assert_eq!(tj.vector_layers[0].id, "water");
451            assert_eq!(tj.vector_layers[0].min_zoom, Some(0));
452            assert_eq!(tj.vector_layers[0].max_zoom, Some(14));
453            assert_eq!(tj.vector_layers[1].id, "roads");
454            assert_eq!(
455                tj.vector_layers[1].description.as_deref(),
456                Some("Road network")
457            );
458        }
459
460        #[test]
461        fn parse_raster_tilejson() {
462            let json = br#"{
463                "tilejson": "2.2.0",
464                "tiles": ["https://tile.example.com/{z}/{x}/{y}.png"],
465                "minzoom": 0,
466                "maxzoom": 18,
467                "bounds": [-180, -85.05, 180, 85.05],
468                "center": [0, 0, 2],
469                "name": "OpenStreetMap",
470                "attribution": "&copy; OSM contributors"
471            }"#;
472
473            let tj = parse_tilejson(json).expect("valid tilejson");
474            assert_eq!(tj.tilejson, "2.2.0");
475            assert!(!tj.is_vector());
476            assert_eq!(tj.name.as_deref(), Some("OpenStreetMap"));
477            assert!(tj.attribution.is_some());
478            assert!(tj.bounds.is_some());
479            assert!(tj.center.is_some());
480            let bounds = tj.bounds.expect("bounds");
481            assert!((bounds[0] - (-180.0)).abs() < 1e-9);
482        }
483
484        #[test]
485        fn parse_tilejson_missing_tiles_fails() {
486            let json = br#"{"tilejson": "3.0.0"}"#;
487            let err = parse_tilejson(json).expect_err("should fail");
488            assert!(matches!(err, TileJsonError::MissingField("tiles")));
489        }
490
491        #[test]
492        fn parse_tilejson_invalid_json() {
493            let err = parse_tilejson(b"not json").expect_err("should fail");
494            assert!(matches!(err, TileJsonError::InvalidJson(_)));
495        }
496
497        #[test]
498        fn parse_tilejson_with_scheme() {
499            let json = br#"{
500                "tiles": ["https://example.com/{z}/{x}/{y}.pbf"],
501                "scheme": "tms"
502            }"#;
503            let tj = parse_tilejson(json).expect("valid tilejson");
504            assert_eq!(tj.scheme, TileScheme::Tms);
505        }
506    }
507}