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 =
221            serde_json::from_slice(bytes).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
296            .get("name")
297            .and_then(|v| v.as_str())
298            .map(ToOwned::to_owned);
299        let description = obj
300            .get("description")
301            .and_then(|v| v.as_str())
302            .map(ToOwned::to_owned);
303        let version = obj
304            .get("version")
305            .and_then(|v| v.as_str())
306            .map(ToOwned::to_owned);
307        let attribution = obj
308            .get("attribution")
309            .and_then(|v| v.as_str())
310            .map(ToOwned::to_owned);
311
312        let vector_layers = obj
313            .get("vector_layers")
314            .and_then(|v| v.as_array())
315            .map(|arr| arr.iter().filter_map(parse_vector_layer_meta).collect())
316            .unwrap_or_default();
317
318        Ok(TileJson {
319            tilejson,
320            name,
321            description,
322            version,
323            attribution,
324            tiles,
325            min_zoom,
326            max_zoom,
327            bounds,
328            center,
329            scheme,
330            vector_layers,
331        })
332    }
333
334    fn parse_vector_layer_meta(value: &Value) -> Option<VectorLayerMeta> {
335        let obj = value.as_object()?;
336        let id = obj.get("id")?.as_str()?.to_owned();
337        let description = obj
338            .get("description")
339            .and_then(|v| v.as_str())
340            .map(ToOwned::to_owned);
341        let min_zoom = obj
342            .get("minzoom")
343            .and_then(|v| v.as_u64())
344            .map(|v| v.min(30) as u8);
345        let max_zoom = obj
346            .get("maxzoom")
347            .and_then(|v| v.as_u64())
348            .map(|v| v.min(30) as u8);
349        Some(VectorLayerMeta {
350            id,
351            description,
352            min_zoom,
353            max_zoom,
354        })
355    }
356}
357
358#[cfg(feature = "style-json")]
359pub use parsing::{parse_tilejson, parse_tilejson_value};
360
361// ---------------------------------------------------------------------------
362// Tests
363// ---------------------------------------------------------------------------
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn default_tilejson_has_sensible_values() {
371        let tj = TileJson::default();
372        assert_eq!(tj.tilejson, "3.0.0");
373        assert_eq!(tj.min_zoom, 0);
374        assert_eq!(tj.max_zoom, 22);
375        assert!(tj.tiles.is_empty());
376        assert!(!tj.is_vector());
377        assert!(tj.source_layer_names().is_empty());
378    }
379
380    #[test]
381    fn with_tiles_constructor() {
382        let tj = TileJson::with_tiles(vec!["https://example.com/{z}/{x}/{y}.pbf".into()]);
383        assert_eq!(tj.tiles.len(), 1);
384        assert_eq!(
385            tj.first_tile_url(),
386            Some("https://example.com/{z}/{x}/{y}.pbf")
387        );
388    }
389
390    #[test]
391    fn is_vector_when_layers_present() {
392        let mut tj = TileJson::default();
393        assert!(!tj.is_vector());
394        tj.vector_layers.push(VectorLayerMeta::new("water"));
395        assert!(tj.is_vector());
396        assert_eq!(tj.source_layer_names(), vec!["water"]);
397    }
398
399    #[test]
400    fn contains_point_unbounded() {
401        let tj = TileJson::default();
402        assert!(tj.contains_point(0.0, 0.0));
403        assert!(tj.contains_point(180.0, 90.0));
404    }
405
406    #[test]
407    fn contains_point_bounded() {
408        let tj = TileJson {
409            bounds: Some([-10.0, -20.0, 30.0, 40.0]),
410            ..TileJson::default()
411        };
412        assert!(tj.contains_point(0.0, 0.0));
413        assert!(tj.contains_point(-10.0, -20.0));
414        assert!(tj.contains_point(30.0, 40.0));
415        assert!(!tj.contains_point(-11.0, 0.0));
416        assert!(!tj.contains_point(0.0, 41.0));
417    }
418
419    #[test]
420    fn tile_scheme_display() {
421        assert_eq!(TileScheme::Xyz.to_string(), "xyz");
422        assert_eq!(TileScheme::Tms.to_string(), "tms");
423    }
424
425    #[test]
426    fn tilejson_error_display() {
427        let err = TileJsonError::MissingField("tiles");
428        assert!(err.to_string().contains("tiles"));
429    }
430
431    #[cfg(feature = "style-json")]
432    mod json_parsing {
433        use super::*;
434
435        #[test]
436        fn parse_minimal_vector_tilejson() {
437            let json = br#"{
438                "tilejson": "3.0.0",
439                "tiles": ["https://example.com/{z}/{x}/{y}.pbf"],
440                "minzoom": 0,
441                "maxzoom": 14,
442                "vector_layers": [
443                    {"id": "water", "minzoom": 0, "maxzoom": 14},
444                    {"id": "roads", "description": "Road network"}
445                ]
446            }"#;
447
448            let tj = parse_tilejson(json).expect("valid tilejson");
449            assert_eq!(tj.tilejson, "3.0.0");
450            assert_eq!(tj.tiles.len(), 1);
451            assert_eq!(tj.min_zoom, 0);
452            assert_eq!(tj.max_zoom, 14);
453            assert!(tj.is_vector());
454            assert_eq!(tj.vector_layers.len(), 2);
455            assert_eq!(tj.vector_layers[0].id, "water");
456            assert_eq!(tj.vector_layers[0].min_zoom, Some(0));
457            assert_eq!(tj.vector_layers[0].max_zoom, Some(14));
458            assert_eq!(tj.vector_layers[1].id, "roads");
459            assert_eq!(
460                tj.vector_layers[1].description.as_deref(),
461                Some("Road network")
462            );
463        }
464
465        #[test]
466        fn parse_raster_tilejson() {
467            let json = br#"{
468                "tilejson": "2.2.0",
469                "tiles": ["https://tile.example.com/{z}/{x}/{y}.png"],
470                "minzoom": 0,
471                "maxzoom": 18,
472                "bounds": [-180, -85.05, 180, 85.05],
473                "center": [0, 0, 2],
474                "name": "OpenStreetMap",
475                "attribution": "&copy; OSM contributors"
476            }"#;
477
478            let tj = parse_tilejson(json).expect("valid tilejson");
479            assert_eq!(tj.tilejson, "2.2.0");
480            assert!(!tj.is_vector());
481            assert_eq!(tj.name.as_deref(), Some("OpenStreetMap"));
482            assert!(tj.attribution.is_some());
483            assert!(tj.bounds.is_some());
484            assert!(tj.center.is_some());
485            let bounds = tj.bounds.expect("bounds");
486            assert!((bounds[0] - (-180.0)).abs() < 1e-9);
487        }
488
489        #[test]
490        fn parse_tilejson_missing_tiles_fails() {
491            let json = br#"{"tilejson": "3.0.0"}"#;
492            let err = parse_tilejson(json).expect_err("should fail");
493            assert!(matches!(err, TileJsonError::MissingField("tiles")));
494        }
495
496        #[test]
497        fn parse_tilejson_invalid_json() {
498            let err = parse_tilejson(b"not json").expect_err("should fail");
499            assert!(matches!(err, TileJsonError::InvalidJson(_)));
500        }
501
502        #[test]
503        fn parse_tilejson_with_scheme() {
504            let json = br#"{
505                "tiles": ["https://example.com/{z}/{x}/{y}.pbf"],
506                "scheme": "tms"
507            }"#;
508            let tj = parse_tilejson(json).expect("valid tilejson");
509            assert_eq!(tj.scheme, TileScheme::Tms);
510        }
511    }
512}