1use std::fmt;
24
25#[derive(Debug, Clone, PartialEq)]
33pub struct VectorLayerMeta {
34 pub id: String,
36 pub description: Option<String>,
38 pub min_zoom: Option<u8>,
40 pub max_zoom: Option<u8>,
42}
43
44impl VectorLayerMeta {
45 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#[derive(Debug, Clone, PartialEq)]
66pub struct TileJson {
67 pub tilejson: String,
69 pub name: Option<String>,
71 pub description: Option<String>,
73 pub version: Option<String>,
75 pub attribution: Option<String>,
77 pub tiles: Vec<String>,
79 pub min_zoom: u8,
81 pub max_zoom: u8,
83 pub bounds: Option<[f64; 4]>,
87 pub center: Option<[f64; 3]>,
89 pub scheme: TileScheme,
91 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 pub fn with_tiles(tiles: Vec<String>) -> Self {
117 Self {
118 tiles,
119 ..Self::default()
120 }
121 }
122
123 #[inline]
125 pub fn first_tile_url(&self) -> Option<&str> {
126 self.tiles.first().map(String::as_str)
127 }
128
129 #[inline]
132 pub fn is_vector(&self) -> bool {
133 !self.vector_layers.is_empty()
134 }
135
136 pub fn source_layer_names(&self) -> Vec<&str> {
138 self.vector_layers.iter().map(|vl| vl.id.as_str()).collect()
139 }
140
141 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
156pub enum TileScheme {
157 #[default]
159 Xyz,
160 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#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum TileJsonError {
180 InvalidJson(String),
182 MissingField(&'static str),
184 InvalidField {
186 field: &'static str,
188 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#[cfg(feature = "style-json")]
214mod parsing {
215 use super::*;
216 use serde_json::Value;
217
218 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 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#[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": "© 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}