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,
164 pub feature_count: u64,
169 pub layers: Vec<String>,
171 pub properties: HashMap<String, String>,
173 pub temporal_bucket_ms: u64,
176 #[serde(default)]
179 pub summary_tier: Option<SummaryTier>,
180
181 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub temporal_lod: Option<Vec<TemporalLodLevel>>,
187
188 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub heatmap_domain: Option<HeatmapDomain>,
195}
196
197impl Default for Metadata {
198 fn default() -> Self {
199 Self {
200 name: String::new(),
201 description: String::new(),
202 attribution: String::new(),
203 bounds: BoundingBox {
204 min_lon: -180.0,
205 min_lat: -85.0511,
206 max_lon: 180.0,
207 max_lat: 85.0511,
208 },
209 time_range: TimeRange::new(0, 0),
210 min_zoom: 0,
211 max_zoom: 14,
212 tile_count: 0,
213 feature_count: 0,
214 layers: vec!["default".to_string()],
215 properties: HashMap::new(),
216 temporal_bucket_ms: 3600 * 1000, summary_tier: None,
218 temporal_lod: None,
219 heatmap_domain: None,
220 }
221 }
222}
223
224impl Metadata {
225 pub fn new(name: impl Into<String>) -> Self {
227 Self {
228 name: name.into(),
229 ..Default::default()
230 }
231 }
232
233 pub fn with_description(mut self, description: impl Into<String>) -> Self {
235 self.description = description.into();
236 self
237 }
238
239 pub fn with_attribution(mut self, attribution: impl Into<String>) -> Self {
241 self.attribution = attribution.into();
242 self
243 }
244
245 pub fn with_bounds(mut self, bounds: BoundingBox) -> Self {
247 self.bounds = bounds;
248 self
249 }
250
251 pub fn with_time_range(mut self, time_range: TimeRange) -> Self {
253 self.time_range = time_range;
254 self
255 }
256
257 pub fn with_zoom_levels(mut self, min_zoom: u8, max_zoom: u8) -> Self {
259 self.min_zoom = min_zoom;
260 self.max_zoom = max_zoom;
261 self
262 }
263
264 pub fn with_temporal_bucket_ms(mut self, temporal_bucket_ms: u64) -> Self {
266 self.temporal_bucket_ms = temporal_bucket_ms;
267 self
268 }
269
270 pub fn with_temporal_lod(mut self, levels: Vec<TemporalLodLevel>) -> Result<Self> {
278 validate_temporal_lod(self.temporal_bucket_ms, &levels)?;
279 self.temporal_lod = if levels.is_empty() { None } else { Some(levels) };
280 Ok(self)
281 }
282
283 pub fn temporal_lod_for_zoom(&self, zoom: u8) -> Option<&TemporalLodLevel> {
287 let levels = self.temporal_lod.as_ref()?;
288 levels
289 .iter()
290 .filter(|l| zoom <= l.max_zoom_level)
291 .max_by_key(|l| l.bucket_ms)
292 }
293
294 pub fn with_property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
296 self.properties.insert(key.into(), value.into());
297 self
298 }
299
300 pub fn with_summary_tier(mut self, tier: SummaryTier) -> Self {
302 self.summary_tier = Some(tier);
303 self
304 }
305
306 pub fn with_heatmap_domain(mut self, domain: HeatmapDomain) -> Self {
308 self.heatmap_domain = Some(domain);
309 self
310 }
311
312 pub fn to_json_bytes(&self) -> Result<Vec<u8>> {
314 serde_json::to_vec(self)
315 .map_err(|e| Error::Other(format!("metadata JSON encode failed: {e}")))
316 }
317
318 pub fn from_json_bytes(bytes: &[u8]) -> Result<Self> {
320 serde_json::from_slice(bytes)
321 .map_err(|e| Error::InvalidArchive(format!("metadata JSON decode failed: {e}")))
322 }
323
324 pub fn to_tilejson(&self, tile_url_template: Option<&str>) -> serde_json::Value {
335 use serde_json::json;
336 let tiles = tile_url_template.unwrap_or("{z}/{x}/{y}/{t}");
337 let center_lon = (self.bounds.min_lon + self.bounds.max_lon) / 2.0;
338 let center_lat = (self.bounds.min_lat + self.bounds.max_lat) / 2.0;
339
340 let to_iso = |ms: u64| -> serde_json::Value {
341 if ms > i64::MAX as u64 {
344 return serde_json::Value::Null;
345 }
346 chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms as i64)
347 .map(|dt| json!(dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)))
348 .unwrap_or(serde_json::Value::Null)
349 };
350
351 let vector_layers: Vec<serde_json::Value> = self
352 .layers
353 .iter()
354 .map(|name| {
355 json!({
356 "id": name,
357 "fields": {},
358 "minzoom": self.min_zoom,
359 "maxzoom": self.max_zoom,
360 })
361 })
362 .collect();
363
364 let mut temporal = json!({
368 "interval": [[to_iso(self.time_range.start), to_iso(self.time_range.end)]],
369 "bucket_ms": self.temporal_bucket_ms,
370 });
371 if let Some(step) = iso8601_duration(self.temporal_bucket_ms) {
372 temporal["step"] = json!(step);
373 }
374 if let Some(levels) = &self.temporal_lod {
375 temporal["lod"] = json!(levels
376 .iter()
377 .map(|l| json!({ "bucket_ms": l.bucket_ms, "max_zoom": l.max_zoom_level }))
378 .collect::<Vec<_>>());
379 }
380
381 json!({
382 "tilejson": "3.0.0",
383 "tiles": [tiles],
384 "name": self.name,
385 "description": self.description,
386 "attribution": self.attribution,
387 "scheme": "xyz",
388 "version": "1.0.0",
389 "minzoom": self.min_zoom,
390 "maxzoom": self.max_zoom,
391 "bounds": [
392 self.bounds.min_lon,
393 self.bounds.min_lat,
394 self.bounds.max_lon,
395 self.bounds.max_lat
396 ],
397 "center": [center_lon, center_lat, self.min_zoom],
398 "vector_layers": vector_layers,
399 "temporal": temporal,
400 })
401 }
402}
403
404fn validate_temporal_lod(base_bucket_ms: u64, levels: &[TemporalLodLevel]) -> Result<()> {
407 if base_bucket_ms == 0 {
408 return Err(Error::Other(
409 "temporal_bucket_ms must be non-zero when declaring a LOD pyramid".into(),
410 ));
411 }
412 let mut prev: Option<u64> = None;
413 for (i, level) in levels.iter().enumerate() {
414 if level.bucket_ms == 0 {
415 return Err(Error::Other(format!(
416 "temporal_lod[{i}].bucket_ms must be non-zero"
417 )));
418 }
419 if level.bucket_ms <= base_bucket_ms {
420 return Err(Error::Other(format!(
421 "temporal_lod[{i}].bucket_ms ({}) must be > base bucket ({})",
422 level.bucket_ms, base_bucket_ms
423 )));
424 }
425 if level.bucket_ms % base_bucket_ms != 0 {
426 return Err(Error::Other(format!(
427 "temporal_lod[{i}].bucket_ms ({}) must be a multiple of base bucket ({})",
428 level.bucket_ms, base_bucket_ms
429 )));
430 }
431 if let Some(p) = prev {
432 if level.bucket_ms <= p {
433 return Err(Error::Other(format!(
434 "temporal_lod must be sorted by ascending bucket_ms; got {} after {}",
435 level.bucket_ms, p
436 )));
437 }
438 }
439 prev = Some(level.bucket_ms);
440 }
441 Ok(())
442}
443
444fn iso8601_duration(ms: u64) -> Option<String> {
448 if ms == 0 {
449 return None;
450 }
451 Some(if ms % 86_400_000 == 0 {
452 format!("P{}D", ms / 86_400_000)
453 } else if ms % 3_600_000 == 0 {
454 format!("PT{}H", ms / 3_600_000)
455 } else if ms % 60_000 == 0 {
456 format!("PT{}M", ms / 60_000)
457 } else if ms % 1000 == 0 {
458 format!("PT{}S", ms / 1000)
459 } else {
460 format!("PT{:.3}S", ms as f64 / 1000.0)
461 })
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467
468 #[test]
469 fn test_metadata_json_roundtrip() {
470 let metadata = Metadata::new("json-test")
471 .with_description("desc")
472 .with_zoom_levels(2, 12)
473 .with_temporal_bucket_ms(3_600_000)
474 .with_property("source", "unit-test");
475 let bytes = metadata.to_json_bytes().unwrap();
476 let decoded = Metadata::from_json_bytes(&bytes).unwrap();
477 assert_eq!(decoded.name, "json-test");
478 assert_eq!(decoded.min_zoom, 2);
479 assert_eq!(decoded.max_zoom, 12);
480 assert_eq!(decoded.temporal_bucket_ms, 3_600_000);
481 assert_eq!(decoded.properties.get("source").map(String::as_str), Some("unit-test"));
482 }
483
484 #[test]
485 fn test_metadata_summary_tier_roundtrip() {
486 let tier = SummaryTier {
487 scheme: SummaryScheme::H3,
488 min_zoom: 0,
489 max_zoom: 4,
490 cell_resolution_per_zoom: vec![0, 1, 2, 3, 4],
491 columns: vec![
492 SummaryColumn {
493 name: "magnitude".to_string(),
494 agg: SummaryAggregation::Mean,
495 },
496 SummaryColumn {
497 name: "magnitude".to_string(),
498 agg: SummaryAggregation::Max,
499 },
500 ],
501 layer_name: "summary".to_string(),
502 sub_buckets: 1,
503 };
504 let metadata = Metadata::new("summary-test").with_summary_tier(tier.clone());
505 let bytes = metadata.to_json_bytes().unwrap();
506 let decoded = Metadata::from_json_bytes(&bytes).unwrap();
507 let dt = decoded.summary_tier.unwrap();
508 assert_eq!(dt.scheme, SummaryScheme::H3);
509 assert_eq!(dt.min_zoom, 0);
510 assert_eq!(dt.max_zoom, 4);
511 assert_eq!(dt.cell_resolution_per_zoom.len(), 5);
512 assert_eq!(dt.columns.len(), 2);
513 assert_eq!(dt.resolution_for_zoom(2), 2);
514 assert_eq!(dt.resolution_for_zoom(10), 4);
516 }
517
518 #[test]
519 fn test_metadata_ignores_unknown_fields() {
520 let metadata = Metadata::new("fwd");
524 let mut v: serde_json::Value =
525 serde_json::from_slice(&metadata.to_json_bytes().unwrap()).unwrap();
526 v["raster_tier"] = serde_json::json!({ "min_zoom": 0, "max_zoom": 5 });
527 let decoded = Metadata::from_json_bytes(&serde_json::to_vec(&v).unwrap()).unwrap();
528 assert_eq!(decoded.name, "fwd");
529 }
530
531 #[test]
532 fn test_heatmap_domain_roundtrip() {
533 let domain = HeatmapDomain {
534 classes: vec![
535 HeatmapClassDomain {
536 id: "pickup".to_string(),
537 min: 0.0,
538 max: 7.5,
539 property: Some("intensity".to_string()),
540 },
541 HeatmapClassDomain {
542 id: "dropoff".to_string(),
543 min: 0.0,
544 max: 9.0,
545 property: None,
546 },
547 ],
548 };
549 let metadata = Metadata::new("heat-test").with_heatmap_domain(domain.clone());
550 let bytes = metadata.to_json_bytes().unwrap();
551 let decoded = Metadata::from_json_bytes(&bytes).unwrap();
552 let d = decoded.heatmap_domain.unwrap();
553 assert_eq!(d.classes.len(), 2);
554 assert_eq!(d.classes[0].id, "pickup");
555 assert_eq!(d.classes[0].max, 7.5);
556 assert_eq!(d.classes[0].property.as_deref(), Some("intensity"));
557 assert_eq!(d.classes[1].id, "dropoff");
558 assert_eq!(d.classes[1].property, None);
559 }
560
561 #[test]
562 fn test_heatmap_domain_field_omitted_when_unset() {
563 let metadata = Metadata::new("no-heat");
564 let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
565 assert!(!s.contains("heatmap_domain"), "got: {s}");
566 }
567
568 #[test]
569 fn test_metadata_without_summary_tier_decodes() {
570 let json = br#"{
573 "name": "old",
574 "description": "",
575 "attribution": "",
576 "bounds": {"min_lon":-180.0,"min_lat":-85.0,"max_lon":180.0,"max_lat":85.0},
577 "time_range": {"start":0,"end":1000},
578 "min_zoom": 0,
579 "max_zoom": 8,
580 "tile_count": 0,
581 "feature_count": 0,
582 "layers": ["default"],
583 "properties": {},
584 "temporal_bucket_ms": 3600000
585 }"#;
586 let m = Metadata::from_json_bytes(json).unwrap();
587 assert!(m.summary_tier.is_none());
588 }
589
590 #[test]
591 fn test_metadata_builder() {
592 let metadata = Metadata::new("test")
593 .with_description("Test archive")
594 .with_attribution("Test data")
595 .with_zoom_levels(0, 14)
596 .with_property("key", "value");
597
598 assert_eq!(metadata.name, "test");
599 assert_eq!(metadata.description, "Test archive");
600 assert_eq!(metadata.min_zoom, 0);
601 assert_eq!(metadata.max_zoom, 14);
602 assert_eq!(metadata.properties.get("key"), Some(&"value".to_string()));
603 }
604
605 fn hour() -> u64 {
610 3_600_000
611 }
612 fn day() -> u64 {
613 24 * hour()
614 }
615 fn thirty_days() -> u64 {
616 30 * day()
617 }
618
619 #[test]
620 fn temporal_lod_roundtrips_through_json() {
621 let levels = vec![
622 TemporalLodLevel {
623 bucket_ms: day(),
624 max_zoom_level: 8,
625 },
626 TemporalLodLevel {
627 bucket_ms: thirty_days(),
628 max_zoom_level: 4,
629 },
630 ];
631 let metadata = Metadata::new("lod")
632 .with_temporal_bucket_ms(hour())
633 .with_temporal_lod(levels.clone())
634 .unwrap();
635 let bytes = metadata.to_json_bytes().unwrap();
636 let decoded = Metadata::from_json_bytes(&bytes).unwrap();
637 assert_eq!(decoded.temporal_lod.as_deref(), Some(levels.as_slice()));
638 }
639
640 #[test]
641 fn temporal_lod_field_omitted_when_unset() {
642 let metadata = Metadata::new("no-lod").with_temporal_bucket_ms(hour());
645 let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
646 assert!(!s.contains("temporal_lod"), "got: {s}");
647 }
648
649 #[test]
650 fn temporal_lod_missing_field_decodes_back_compat() {
651 let legacy = r#"{
654 "name": "legacy",
655 "description": "",
656 "attribution": "",
657 "bounds": {"min_lon": -180, "min_lat": -85, "max_lon": 180, "max_lat": 85},
658 "time_range": {"start": 0, "end": 1},
659 "min_zoom": 0,
660 "max_zoom": 14,
661 "tile_count": 0,
662 "feature_count": 0,
663 "layers": ["default"],
664 "properties": {},
665 "temporal_bucket_ms": 3600000
666 }"#;
667 let m = Metadata::from_json_bytes(legacy.as_bytes()).unwrap();
668 assert!(m.temporal_lod.is_none());
669 }
670
671 #[test]
672 fn temporal_lod_rejects_non_multiple_bucket() {
673 let res = Metadata::new("bad").with_temporal_bucket_ms(hour()).with_temporal_lod(vec![
674 TemporalLodLevel { bucket_ms: hour() + 7, max_zoom_level: 5 },
675 ]);
676 assert!(res.is_err());
677 }
678
679 #[test]
680 fn temporal_lod_rejects_bucket_smaller_than_or_equal_to_base() {
681 let res = Metadata::new("bad")
682 .with_temporal_bucket_ms(day())
683 .with_temporal_lod(vec![TemporalLodLevel { bucket_ms: hour(), max_zoom_level: 5 }]);
684 assert!(res.is_err());
685
686 let res = Metadata::new("bad")
687 .with_temporal_bucket_ms(hour())
688 .with_temporal_lod(vec![TemporalLodLevel { bucket_ms: hour(), max_zoom_level: 5 }]);
689 assert!(res.is_err());
690 }
691
692 #[test]
693 fn temporal_lod_rejects_unsorted_levels() {
694 let res = Metadata::new("bad").with_temporal_bucket_ms(hour()).with_temporal_lod(vec![
695 TemporalLodLevel { bucket_ms: thirty_days(), max_zoom_level: 4 },
696 TemporalLodLevel { bucket_ms: day(), max_zoom_level: 8 },
697 ]);
698 assert!(res.is_err());
699 }
700
701 #[test]
702 fn temporal_lod_for_zoom_picks_coarsest_applicable() {
703 let m = Metadata::new("lod")
704 .with_temporal_bucket_ms(hour())
705 .with_temporal_lod(vec![
706 TemporalLodLevel { bucket_ms: day(), max_zoom_level: 8 },
707 TemporalLodLevel { bucket_ms: thirty_days(), max_zoom_level: 4 },
708 ])
709 .unwrap();
710 assert_eq!(
712 m.temporal_lod_for_zoom(0).map(|l| l.bucket_ms),
713 Some(thirty_days())
714 );
715 assert_eq!(m.temporal_lod_for_zoom(6).map(|l| l.bucket_ms), Some(day()));
717 assert!(m.temporal_lod_for_zoom(12).is_none());
719 }
720
721 #[test]
722 fn temporal_lod_for_zoom_is_none_when_unset() {
723 let m = Metadata::new("plain").with_temporal_bucket_ms(hour());
724 assert!(m.temporal_lod_for_zoom(0).is_none());
725 }
726
727 #[test]
728 fn temporal_lod_empty_vec_clears_to_none() {
729 let m = Metadata::new("empty")
732 .with_temporal_bucket_ms(hour())
733 .with_temporal_lod(vec![])
734 .unwrap();
735 assert!(m.temporal_lod.is_none());
736 }
737
738 #[test]
739 fn tilejson_descriptor_has_core_fields_and_temporal_extension() {
740 let m = Metadata::new("quakes")
741 .with_description("USGS earthquakes")
742 .with_attribution("USGS")
743 .with_zoom_levels(0, 10)
744 .with_temporal_bucket_ms(hour())
745 .with_time_range(TimeRange::new(1_700_000_000_000, 1_700_086_400_000))
746 .with_temporal_lod(vec![TemporalLodLevel {
747 bucket_ms: day(),
748 max_zoom_level: 6,
749 }])
750 .unwrap();
751 let tj = m.to_tilejson(Some("https://cdn/{z}/{x}/{y}/{t}.stt"));
752
753 assert_eq!(tj["tilejson"], "3.0.0");
755 assert_eq!(tj["tiles"][0], "https://cdn/{z}/{x}/{y}/{t}.stt");
756 assert_eq!(tj["minzoom"], 0);
757 assert_eq!(tj["maxzoom"], 10);
758 assert_eq!(tj["scheme"], "xyz");
759 assert_eq!(tj["vector_layers"][0]["id"], "default");
760 assert_eq!(tj["bounds"].as_array().unwrap().len(), 4);
761
762 assert_eq!(tj["temporal"]["step"], "PT1H");
764 assert_eq!(tj["temporal"]["bucket_ms"], 3_600_000u64);
765 let interval = &tj["temporal"]["interval"][0];
766 assert!(interval[0].as_str().unwrap().starts_with("2023-11-"));
767 assert!(interval[1].is_string());
768 assert_eq!(tj["temporal"]["lod"][0]["bucket_ms"], day());
769 }
770
771 #[test]
772 fn iso8601_duration_formats_common_buckets() {
773 assert_eq!(iso8601_duration(hour()).as_deref(), Some("PT1H"));
774 assert_eq!(iso8601_duration(day()).as_deref(), Some("P1D"));
775 assert_eq!(iso8601_duration(60_000).as_deref(), Some("PT1M"));
776 assert_eq!(iso8601_duration(0), None);
777 }
778
779 #[test]
780 fn tilejson_time_beyond_i64_is_null_not_garbage() {
781 let m = Metadata::new("x").with_time_range(TimeRange::new(u64::MAX, u64::MAX));
784 let tj = m.to_tilejson(None);
785 assert!(tj["temporal"]["interval"][0][0].is_null());
786 assert!(tj["temporal"]["interval"][0][1].is_null());
787 }
788}