1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
//! Tile types and operations
use crate::error::{Error, Result};
use crate::types::{GeometryType, TimeRange};
use std::cmp::Ordering;
/// Unique identifier for a spatiotemporal tile
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TileId {
/// Zoom level (0-22)
pub z: u8,
/// X coordinate
pub x: u32,
/// Y coordinate
pub y: u32,
/// Timestamp (Unix milliseconds)
pub t: u64,
}
impl TileId {
/// Create a new tile ID
pub fn new(z: u8, x: u32, y: u32, t: u64) -> Self {
Self { z, x, y, t }
}
/// Validate tile coordinates
pub fn validate(&self) -> Result<()> {
let max_coord = 1u32 << self.z;
if self.x >= max_coord || self.y >= max_coord {
return Err(Error::InvalidCoordinates(self.z, self.x, self.y));
}
Ok(())
}
/// Calculate Hilbert curve index for spatial ordering.
///
/// Uses the integer (`discrete`) Hilbert curve at the tile's own zoom
/// order, so neighbouring tiles never collide on the directory sort key.
///
/// The previous implementation normalised `(x, y)` to f64 `[0,1)` and
/// multiplied by `u64::MAX`. f64 has only a 52-bit mantissa, so at
/// zoom ≥ 14 the per-axis precision was already at the mantissa limit and
/// the post-multiply discarded trailing bits — neighbouring tiles could
/// collide on the same `hilbert_index`, silently degrading the archive's
/// range-coalescing locality. The integer variant is also ~10× faster.
pub fn hilbert_index(&self) -> u64 {
// `xy2h_discrete` requires `order >= 1`. At zoom 0 the only valid
// coordinates are (0, 0) and the index is trivially 0.
if self.z == 0 {
return 0;
}
let order = self.z as usize;
let h = hilbert_2d::xy2h_discrete(
self.x as usize,
self.y as usize,
order,
hilbert_2d::Variant::Hilbert,
);
h as u64
}
/// Get parent tile at zoom level z-1
pub fn parent(&self) -> Option<TileId> {
if self.z == 0 {
return None;
}
Some(TileId {
z: self.z - 1,
x: self.x / 2,
y: self.y / 2,
t: self.t,
})
}
/// Get child tiles at zoom level z+1
pub fn children(&self) -> Vec<TileId> {
if self.z >= 22 {
return vec![];
}
vec![
TileId::new(self.z + 1, self.x * 2, self.y * 2, self.t),
TileId::new(self.z + 1, self.x * 2 + 1, self.y * 2, self.t),
TileId::new(self.z + 1, self.x * 2, self.y * 2 + 1, self.t),
TileId::new(self.z + 1, self.x * 2 + 1, self.y * 2 + 1, self.t),
]
}
/// Convert to string format: z/x/y/t
pub fn to_string(&self) -> String {
format!("{}/{}/{}/{}", self.z, self.x, self.y, self.t)
}
}
impl PartialOrd for TileId {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for TileId {
fn cmp(&self, other: &Self) -> Ordering {
// Sort by: zoom, hilbert index, time
self.z
.cmp(&other.z)
.then(self.hilbert_index().cmp(&other.hilbert_index()))
.then(self.t.cmp(&other.t))
}
}
/// A decoded tile with all its features
#[derive(Debug, Clone)]
pub struct Tile {
pub id: TileId,
pub time_range: TimeRange,
pub layers: Vec<Layer>,
}
/// A layer within a tile
#[derive(Debug, Clone)]
pub struct Layer {
pub name: String,
pub extent: u32,
pub features: Vec<Feature>,
}
/// A feature within a layer
#[derive(Debug, Clone)]
pub struct Feature {
pub id: u64,
pub geometry_type: GeometryType,
pub positions: Vec<Position>,
pub properties: std::collections::HashMap<String, Value>,
pub time_range: Option<TimeRange>,
}
/// Absolute geographic position
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Position {
pub lon: f64,
pub lat: f64,
}
/// Property value
#[derive(Debug, Clone)]
pub enum Value {
String(String),
Double(f64),
Float(f32),
Int(i64),
UInt(u64),
Bool(bool),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tile_id_validation() {
let valid = TileId::new(10, 512, 384, 1609459200000);
assert!(valid.validate().is_ok());
let invalid = TileId::new(10, 2048, 384, 1609459200000);
assert!(invalid.validate().is_err());
}
#[test]
fn test_tile_id_parent() {
let tile = TileId::new(10, 512, 384, 1609459200000);
let parent = tile.parent().unwrap();
assert_eq!(parent.z, 9);
assert_eq!(parent.x, 256);
assert_eq!(parent.y, 192);
}
#[test]
fn test_tile_id_children() {
let tile = TileId::new(10, 512, 384, 1609459200000);
let children = tile.children();
assert_eq!(children.len(), 4);
assert_eq!(children[0].z, 11);
}
#[test]
fn test_tile_id_ordering() {
let t1 = TileId::new(10, 512, 384, 1609459200000);
let t2 = TileId::new(10, 512, 384, 1609545600000);
let t3 = TileId::new(11, 1024, 768, 1609459200000);
assert!(t1 < t2); // Same spatial, different time
assert!(t1 < t3); // Different zoom
}
/// Regression: at zoom ≥ 14 the previous f64-normalised Hilbert collapsed
/// neighbouring tiles to the same index, breaking range coalescing. The
/// integer variant must produce distinct indices for every distinct tile
/// at every supported zoom.
#[test]
fn hilbert_index_is_distinct_for_distinct_tiles_at_high_zoom() {
for &z in &[14u8, 16, 18, 20, 22] {
let n = 1u32 << z;
// Sample 4 adjacent tiles near the centre.
let cx = n / 2;
let cy = n / 2;
let coords = [(cx, cy), (cx + 1, cy), (cx, cy + 1), (cx + 1, cy + 1)];
let indices: std::collections::HashSet<u64> = coords
.iter()
.map(|&(x, y)| TileId::new(z, x, y, 0).hilbert_index())
.collect();
assert_eq!(
indices.len(),
4,
"hilbert_index collided at zoom {} for adjacent tiles",
z
);
}
}
/// Zoom 0 is a single tile — Hilbert index is trivially 0 and the function
/// must not call into the discrete impl (which rejects order = 0).
#[test]
fn hilbert_index_zoom_zero_is_zero() {
assert_eq!(TileId::new(0, 0, 0, 0).hilbert_index(), 0);
}
}