1use crate::error::{Error, Result};
6use crate::types::{BoundingBox, TimeRange};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum SummaryScheme {
14 H3,
16 Quadbin,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SummaryAggregation {
24 Count,
25 Sum,
26 Mean,
27 Min,
28 Max,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct SummaryColumn {
34 pub name: String,
35 pub agg: SummaryAggregation,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct SummaryTier {
41 pub scheme: SummaryScheme,
42 pub min_zoom: u8,
43 pub max_zoom: u8,
44 pub cell_resolution_per_zoom: Vec<u8>,
45 pub columns: Vec<SummaryColumn>,
46 #[serde(default = "default_summary_layer_name")]
47 pub layer_name: String,
48 #[serde(default = "default_sub_buckets")]
55 pub sub_buckets: u32,
56}
57
58fn default_sub_buckets() -> u32 {
59 1
60}
61
62fn default_summary_layer_name() -> String {
63 "summary".to_string()
64}
65
66impl SummaryTier {
67 pub fn resolution_for_zoom(&self, zoom: u8) -> u8 {
70 if self.cell_resolution_per_zoom.is_empty() {
71 return zoom;
72 }
73 if zoom <= self.min_zoom {
74 return self.cell_resolution_per_zoom[0];
75 }
76 if zoom >= self.max_zoom {
77 return *self
78 .cell_resolution_per_zoom
79 .last()
80 .expect("non-empty per check");
81 }
82 let idx = (zoom - self.min_zoom) as usize;
83 self.cell_resolution_per_zoom
84 .get(idx)
85 .copied()
86 .unwrap_or_else(|| {
87 *self.cell_resolution_per_zoom.last().unwrap()
88 })
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101pub struct HeatmapClassDomain {
102 pub id: String,
105 pub min: f64,
107 pub max: f64,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub property: Option<String>,
117}
118
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct HeatmapDomain {
122 pub classes: Vec<HeatmapClassDomain>,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct TemporalLodLevel {
134 pub bucket_ms: u64,
136 pub max_zoom_level: u8,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct Metadata {
146 pub name: String,
148 pub description: String,
150 pub attribution: String,
152 pub bounds: BoundingBox,
154 pub time_range: TimeRange,
156 pub min_zoom: u8,
158 pub max_zoom: u8,
160 pub tile_count: u64,
162 pub feature_count: u64,
164 pub layers: Vec<String>,
166 pub properties: HashMap<String, String>,
168 pub temporal_bucket_ms: u64,
171 #[serde(default)]
174 pub summary_tier: Option<SummaryTier>,
175
176 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub temporal_lod: Option<Vec<TemporalLodLevel>>,
182
183 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub heatmap_domain: Option<HeatmapDomain>,
190}
191
192impl Default for Metadata {
193 fn default() -> Self {
194 Self {
195 name: String::new(),
196 description: String::new(),
197 attribution: String::new(),
198 bounds: BoundingBox {
199 min_lon: -180.0,
200 min_lat: -85.0511,
201 max_lon: 180.0,
202 max_lat: 85.0511,
203 },
204 time_range: TimeRange::new(0, 0),
205 min_zoom: 0,
206 max_zoom: 14,
207 tile_count: 0,
208 feature_count: 0,
209 layers: vec!["default".to_string()],
210 properties: HashMap::new(),
211 temporal_bucket_ms: 3600 * 1000, summary_tier: None,
213 temporal_lod: None,
214 heatmap_domain: None,
215 }
216 }
217}
218
219impl Metadata {
220 pub fn new(name: impl Into<String>) -> Self {
222 Self {
223 name: name.into(),
224 ..Default::default()
225 }
226 }
227
228 pub fn with_description(mut self, description: impl Into<String>) -> Self {
230 self.description = description.into();
231 self
232 }
233
234 pub fn with_attribution(mut self, attribution: impl Into<String>) -> Self {
236 self.attribution = attribution.into();
237 self
238 }
239
240 pub fn with_bounds(mut self, bounds: BoundingBox) -> Self {
242 self.bounds = bounds;
243 self
244 }
245
246 pub fn with_time_range(mut self, time_range: TimeRange) -> Self {
248 self.time_range = time_range;
249 self
250 }
251
252 pub fn with_zoom_levels(mut self, min_zoom: u8, max_zoom: u8) -> Self {
254 self.min_zoom = min_zoom;
255 self.max_zoom = max_zoom;
256 self
257 }
258
259 pub fn with_temporal_bucket_ms(mut self, temporal_bucket_ms: u64) -> Self {
261 self.temporal_bucket_ms = temporal_bucket_ms;
262 self
263 }
264
265 pub fn with_temporal_lod(mut self, levels: Vec<TemporalLodLevel>) -> Result<Self> {
273 validate_temporal_lod(self.temporal_bucket_ms, &levels)?;
274 self.temporal_lod = if levels.is_empty() { None } else { Some(levels) };
275 Ok(self)
276 }
277
278 pub fn temporal_lod_for_zoom(&self, zoom: u8) -> Option<&TemporalLodLevel> {
282 let levels = self.temporal_lod.as_ref()?;
283 levels
284 .iter()
285 .filter(|l| zoom <= l.max_zoom_level)
286 .max_by_key(|l| l.bucket_ms)
287 }
288
289 pub fn with_property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
291 self.properties.insert(key.into(), value.into());
292 self
293 }
294
295 pub fn with_summary_tier(mut self, tier: SummaryTier) -> Self {
297 self.summary_tier = Some(tier);
298 self
299 }
300
301 pub fn with_heatmap_domain(mut self, domain: HeatmapDomain) -> Self {
303 self.heatmap_domain = Some(domain);
304 self
305 }
306
307 pub fn to_json_bytes(&self) -> Result<Vec<u8>> {
309 serde_json::to_vec(self)
310 .map_err(|e| Error::Other(format!("metadata JSON encode failed: {e}")))
311 }
312
313 pub fn from_json_bytes(bytes: &[u8]) -> Result<Self> {
315 serde_json::from_slice(bytes)
316 .map_err(|e| Error::InvalidArchive(format!("metadata JSON decode failed: {e}")))
317 }
318
319 pub fn to_tilejson(&self, tile_url_template: Option<&str>) -> serde_json::Value {
330 use serde_json::json;
331 let tiles = tile_url_template.unwrap_or("{z}/{x}/{y}/{t}");
332 let center_lon = (self.bounds.min_lon + self.bounds.max_lon) / 2.0;
333 let center_lat = (self.bounds.min_lat + self.bounds.max_lat) / 2.0;
334
335 let to_iso = |ms: u64| -> serde_json::Value {
336 if ms > i64::MAX as u64 {
339 return serde_json::Value::Null;
340 }
341 chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms as i64)
342 .map(|dt| json!(dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)))
343 .unwrap_or(serde_json::Value::Null)
344 };
345
346 let vector_layers: Vec<serde_json::Value> = self
347 .layers
348 .iter()
349 .map(|name| {
350 json!({
351 "id": name,
352 "fields": {},
353 "minzoom": self.min_zoom,
354 "maxzoom": self.max_zoom,
355 })
356 })
357 .collect();
358
359 let mut temporal = json!({
363 "interval": [[to_iso(self.time_range.start), to_iso(self.time_range.end)]],
364 "bucket_ms": self.temporal_bucket_ms,
365 });
366 if let Some(step) = iso8601_duration(self.temporal_bucket_ms) {
367 temporal["step"] = json!(step);
368 }
369 if let Some(levels) = &self.temporal_lod {
370 temporal["lod"] = json!(levels
371 .iter()
372 .map(|l| json!({ "bucket_ms": l.bucket_ms, "max_zoom": l.max_zoom_level }))
373 .collect::<Vec<_>>());
374 }
375
376 json!({
377 "tilejson": "3.0.0",
378 "tiles": [tiles],
379 "name": self.name,
380 "description": self.description,
381 "attribution": self.attribution,
382 "scheme": "xyz",
383 "version": "1.0.0",
384 "minzoom": self.min_zoom,
385 "maxzoom": self.max_zoom,
386 "bounds": [
387 self.bounds.min_lon,
388 self.bounds.min_lat,
389 self.bounds.max_lon,
390 self.bounds.max_lat
391 ],
392 "center": [center_lon, center_lat, self.min_zoom],
393 "vector_layers": vector_layers,
394 "temporal": temporal,
395 })
396 }
397}
398
399fn validate_temporal_lod(base_bucket_ms: u64, levels: &[TemporalLodLevel]) -> Result<()> {
402 if base_bucket_ms == 0 {
403 return Err(Error::Other(
404 "temporal_bucket_ms must be non-zero when declaring a LOD pyramid".into(),
405 ));
406 }
407 let mut prev: Option<u64> = None;
408 for (i, level) in levels.iter().enumerate() {
409 if level.bucket_ms == 0 {
410 return Err(Error::Other(format!(
411 "temporal_lod[{i}].bucket_ms must be non-zero"
412 )));
413 }
414 if level.bucket_ms <= base_bucket_ms {
415 return Err(Error::Other(format!(
416 "temporal_lod[{i}].bucket_ms ({}) must be > base bucket ({})",
417 level.bucket_ms, base_bucket_ms
418 )));
419 }
420 if level.bucket_ms % base_bucket_ms != 0 {
421 return Err(Error::Other(format!(
422 "temporal_lod[{i}].bucket_ms ({}) must be a multiple of base bucket ({})",
423 level.bucket_ms, base_bucket_ms
424 )));
425 }
426 if let Some(p) = prev {
427 if level.bucket_ms <= p {
428 return Err(Error::Other(format!(
429 "temporal_lod must be sorted by ascending bucket_ms; got {} after {}",
430 level.bucket_ms, p
431 )));
432 }
433 }
434 prev = Some(level.bucket_ms);
435 }
436 Ok(())
437}
438
439fn iso8601_duration(ms: u64) -> Option<String> {
443 if ms == 0 {
444 return None;
445 }
446 Some(if ms % 86_400_000 == 0 {
447 format!("P{}D", ms / 86_400_000)
448 } else if ms % 3_600_000 == 0 {
449 format!("PT{}H", ms / 3_600_000)
450 } else if ms % 60_000 == 0 {
451 format!("PT{}M", ms / 60_000)
452 } else if ms % 1000 == 0 {
453 format!("PT{}S", ms / 1000)
454 } else {
455 format!("PT{:.3}S", ms as f64 / 1000.0)
456 })
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 #[test]
464 fn test_metadata_json_roundtrip() {
465 let metadata = Metadata::new("json-test")
466 .with_description("desc")
467 .with_zoom_levels(2, 12)
468 .with_temporal_bucket_ms(3_600_000)
469 .with_property("source", "unit-test");
470 let bytes = metadata.to_json_bytes().unwrap();
471 let decoded = Metadata::from_json_bytes(&bytes).unwrap();
472 assert_eq!(decoded.name, "json-test");
473 assert_eq!(decoded.min_zoom, 2);
474 assert_eq!(decoded.max_zoom, 12);
475 assert_eq!(decoded.temporal_bucket_ms, 3_600_000);
476 assert_eq!(decoded.properties.get("source").map(String::as_str), Some("unit-test"));
477 }
478
479 #[test]
480 fn test_metadata_summary_tier_roundtrip() {
481 let tier = SummaryTier {
482 scheme: SummaryScheme::H3,
483 min_zoom: 0,
484 max_zoom: 4,
485 cell_resolution_per_zoom: vec![0, 1, 2, 3, 4],
486 columns: vec![
487 SummaryColumn {
488 name: "magnitude".to_string(),
489 agg: SummaryAggregation::Mean,
490 },
491 SummaryColumn {
492 name: "magnitude".to_string(),
493 agg: SummaryAggregation::Max,
494 },
495 ],
496 layer_name: "summary".to_string(),
497 sub_buckets: 1,
498 };
499 let metadata = Metadata::new("summary-test").with_summary_tier(tier.clone());
500 let bytes = metadata.to_json_bytes().unwrap();
501 let decoded = Metadata::from_json_bytes(&bytes).unwrap();
502 let dt = decoded.summary_tier.unwrap();
503 assert_eq!(dt.scheme, SummaryScheme::H3);
504 assert_eq!(dt.min_zoom, 0);
505 assert_eq!(dt.max_zoom, 4);
506 assert_eq!(dt.cell_resolution_per_zoom.len(), 5);
507 assert_eq!(dt.columns.len(), 2);
508 assert_eq!(dt.resolution_for_zoom(2), 2);
509 assert_eq!(dt.resolution_for_zoom(10), 4);
511 }
512
513 #[test]
514 fn test_metadata_ignores_unknown_fields() {
515 let metadata = Metadata::new("fwd");
519 let mut v: serde_json::Value =
520 serde_json::from_slice(&metadata.to_json_bytes().unwrap()).unwrap();
521 v["raster_tier"] = serde_json::json!({ "min_zoom": 0, "max_zoom": 5 });
522 let decoded = Metadata::from_json_bytes(&serde_json::to_vec(&v).unwrap()).unwrap();
523 assert_eq!(decoded.name, "fwd");
524 }
525
526 #[test]
527 fn test_heatmap_domain_roundtrip() {
528 let domain = HeatmapDomain {
529 classes: vec![
530 HeatmapClassDomain {
531 id: "pickup".to_string(),
532 min: 0.0,
533 max: 7.5,
534 property: Some("intensity".to_string()),
535 },
536 HeatmapClassDomain {
537 id: "dropoff".to_string(),
538 min: 0.0,
539 max: 9.0,
540 property: None,
541 },
542 ],
543 };
544 let metadata = Metadata::new("heat-test").with_heatmap_domain(domain.clone());
545 let bytes = metadata.to_json_bytes().unwrap();
546 let decoded = Metadata::from_json_bytes(&bytes).unwrap();
547 let d = decoded.heatmap_domain.unwrap();
548 assert_eq!(d.classes.len(), 2);
549 assert_eq!(d.classes[0].id, "pickup");
550 assert_eq!(d.classes[0].max, 7.5);
551 assert_eq!(d.classes[0].property.as_deref(), Some("intensity"));
552 assert_eq!(d.classes[1].id, "dropoff");
553 assert_eq!(d.classes[1].property, None);
554 }
555
556 #[test]
557 fn test_heatmap_domain_field_omitted_when_unset() {
558 let metadata = Metadata::new("no-heat");
559 let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
560 assert!(!s.contains("heatmap_domain"), "got: {s}");
561 }
562
563 #[test]
564 fn test_metadata_without_summary_tier_decodes() {
565 let json = br#"{
568 "name": "old",
569 "description": "",
570 "attribution": "",
571 "bounds": {"min_lon":-180.0,"min_lat":-85.0,"max_lon":180.0,"max_lat":85.0},
572 "time_range": {"start":0,"end":1000},
573 "min_zoom": 0,
574 "max_zoom": 8,
575 "tile_count": 0,
576 "feature_count": 0,
577 "layers": ["default"],
578 "properties": {},
579 "temporal_bucket_ms": 3600000
580 }"#;
581 let m = Metadata::from_json_bytes(json).unwrap();
582 assert!(m.summary_tier.is_none());
583 }
584
585 #[test]
586 fn test_metadata_builder() {
587 let metadata = Metadata::new("test")
588 .with_description("Test archive")
589 .with_attribution("Test data")
590 .with_zoom_levels(0, 14)
591 .with_property("key", "value");
592
593 assert_eq!(metadata.name, "test");
594 assert_eq!(metadata.description, "Test archive");
595 assert_eq!(metadata.min_zoom, 0);
596 assert_eq!(metadata.max_zoom, 14);
597 assert_eq!(metadata.properties.get("key"), Some(&"value".to_string()));
598 }
599
600 fn hour() -> u64 {
605 3_600_000
606 }
607 fn day() -> u64 {
608 24 * hour()
609 }
610 fn thirty_days() -> u64 {
611 30 * day()
612 }
613
614 #[test]
615 fn temporal_lod_roundtrips_through_json() {
616 let levels = vec![
617 TemporalLodLevel {
618 bucket_ms: day(),
619 max_zoom_level: 8,
620 },
621 TemporalLodLevel {
622 bucket_ms: thirty_days(),
623 max_zoom_level: 4,
624 },
625 ];
626 let metadata = Metadata::new("lod")
627 .with_temporal_bucket_ms(hour())
628 .with_temporal_lod(levels.clone())
629 .unwrap();
630 let bytes = metadata.to_json_bytes().unwrap();
631 let decoded = Metadata::from_json_bytes(&bytes).unwrap();
632 assert_eq!(decoded.temporal_lod.as_deref(), Some(levels.as_slice()));
633 }
634
635 #[test]
636 fn temporal_lod_field_omitted_when_unset() {
637 let metadata = Metadata::new("no-lod").with_temporal_bucket_ms(hour());
640 let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
641 assert!(!s.contains("temporal_lod"), "got: {s}");
642 }
643
644 #[test]
645 fn temporal_lod_missing_field_decodes_back_compat() {
646 let legacy = r#"{
649 "name": "legacy",
650 "description": "",
651 "attribution": "",
652 "bounds": {"min_lon": -180, "min_lat": -85, "max_lon": 180, "max_lat": 85},
653 "time_range": {"start": 0, "end": 1},
654 "min_zoom": 0,
655 "max_zoom": 14,
656 "tile_count": 0,
657 "feature_count": 0,
658 "layers": ["default"],
659 "properties": {},
660 "temporal_bucket_ms": 3600000
661 }"#;
662 let m = Metadata::from_json_bytes(legacy.as_bytes()).unwrap();
663 assert!(m.temporal_lod.is_none());
664 }
665
666 #[test]
667 fn temporal_lod_rejects_non_multiple_bucket() {
668 let res = Metadata::new("bad").with_temporal_bucket_ms(hour()).with_temporal_lod(vec![
669 TemporalLodLevel { bucket_ms: hour() + 7, max_zoom_level: 5 },
670 ]);
671 assert!(res.is_err());
672 }
673
674 #[test]
675 fn temporal_lod_rejects_bucket_smaller_than_or_equal_to_base() {
676 let res = Metadata::new("bad")
677 .with_temporal_bucket_ms(day())
678 .with_temporal_lod(vec![TemporalLodLevel { bucket_ms: hour(), max_zoom_level: 5 }]);
679 assert!(res.is_err());
680
681 let res = Metadata::new("bad")
682 .with_temporal_bucket_ms(hour())
683 .with_temporal_lod(vec![TemporalLodLevel { bucket_ms: hour(), max_zoom_level: 5 }]);
684 assert!(res.is_err());
685 }
686
687 #[test]
688 fn temporal_lod_rejects_unsorted_levels() {
689 let res = Metadata::new("bad").with_temporal_bucket_ms(hour()).with_temporal_lod(vec![
690 TemporalLodLevel { bucket_ms: thirty_days(), max_zoom_level: 4 },
691 TemporalLodLevel { bucket_ms: day(), max_zoom_level: 8 },
692 ]);
693 assert!(res.is_err());
694 }
695
696 #[test]
697 fn temporal_lod_for_zoom_picks_coarsest_applicable() {
698 let m = Metadata::new("lod")
699 .with_temporal_bucket_ms(hour())
700 .with_temporal_lod(vec![
701 TemporalLodLevel { bucket_ms: day(), max_zoom_level: 8 },
702 TemporalLodLevel { bucket_ms: thirty_days(), max_zoom_level: 4 },
703 ])
704 .unwrap();
705 assert_eq!(
707 m.temporal_lod_for_zoom(0).map(|l| l.bucket_ms),
708 Some(thirty_days())
709 );
710 assert_eq!(m.temporal_lod_for_zoom(6).map(|l| l.bucket_ms), Some(day()));
712 assert!(m.temporal_lod_for_zoom(12).is_none());
714 }
715
716 #[test]
717 fn temporal_lod_for_zoom_is_none_when_unset() {
718 let m = Metadata::new("plain").with_temporal_bucket_ms(hour());
719 assert!(m.temporal_lod_for_zoom(0).is_none());
720 }
721
722 #[test]
723 fn temporal_lod_empty_vec_clears_to_none() {
724 let m = Metadata::new("empty")
727 .with_temporal_bucket_ms(hour())
728 .with_temporal_lod(vec![])
729 .unwrap();
730 assert!(m.temporal_lod.is_none());
731 }
732
733 #[test]
734 fn tilejson_descriptor_has_core_fields_and_temporal_extension() {
735 let m = Metadata::new("quakes")
736 .with_description("USGS earthquakes")
737 .with_attribution("USGS")
738 .with_zoom_levels(0, 10)
739 .with_temporal_bucket_ms(hour())
740 .with_time_range(TimeRange::new(1_700_000_000_000, 1_700_086_400_000))
741 .with_temporal_lod(vec![TemporalLodLevel {
742 bucket_ms: day(),
743 max_zoom_level: 6,
744 }])
745 .unwrap();
746 let tj = m.to_tilejson(Some("https://cdn/{z}/{x}/{y}/{t}.stt"));
747
748 assert_eq!(tj["tilejson"], "3.0.0");
750 assert_eq!(tj["tiles"][0], "https://cdn/{z}/{x}/{y}/{t}.stt");
751 assert_eq!(tj["minzoom"], 0);
752 assert_eq!(tj["maxzoom"], 10);
753 assert_eq!(tj["scheme"], "xyz");
754 assert_eq!(tj["vector_layers"][0]["id"], "default");
755 assert_eq!(tj["bounds"].as_array().unwrap().len(), 4);
756
757 assert_eq!(tj["temporal"]["step"], "PT1H");
759 assert_eq!(tj["temporal"]["bucket_ms"], 3_600_000u64);
760 let interval = &tj["temporal"]["interval"][0];
761 assert!(interval[0].as_str().unwrap().starts_with("2023-11-"));
762 assert!(interval[1].is_string());
763 assert_eq!(tj["temporal"]["lod"][0]["bucket_ms"], day());
764 }
765
766 #[test]
767 fn iso8601_duration_formats_common_buckets() {
768 assert_eq!(iso8601_duration(hour()).as_deref(), Some("PT1H"));
769 assert_eq!(iso8601_duration(day()).as_deref(), Some("P1D"));
770 assert_eq!(iso8601_duration(60_000).as_deref(), Some("PT1M"));
771 assert_eq!(iso8601_duration(0), None);
772 }
773
774 #[test]
775 fn tilejson_time_beyond_i64_is_null_not_garbage() {
776 let m = Metadata::new("x").with_time_range(TimeRange::new(u64::MAX, u64::MAX));
779 let tj = m.to_tilejson(None);
780 assert!(tj["temporal"]["interval"][0][0].is_null());
781 assert!(tj["temporal"]["interval"][0][1].is_null());
782 }
783}