1use stt_core::metadata::{PropertyStyleHint, StyleHints};
12
13pub const STYLE_HINTS_VERSION: u32 = 1;
15
16const LAYER_HINTS: [&str; 4] = ["points", "paths", "trips", "polygons"];
19
20#[derive(Debug, Clone)]
28pub enum PropertyValues {
29 Numeric(Vec<f64>),
31 Categorical { distinct: usize },
33}
34
35pub fn profile_properties(
49 props: &[(String, PropertyValues)],
50 time_range_ms: u64,
51 temporal_bucket_ms: u64,
52 layer_kind_hint: Option<&str>,
53) -> StyleHints {
54 let properties = props
55 .iter()
56 .filter_map(|(name, values)| match values {
57 PropertyValues::Numeric(raw) => numeric_hint(name, raw),
58 PropertyValues::Categorical { distinct } => Some(PropertyStyleHint {
59 name: name.clone(),
60 cardinality: Some((*distinct).min(u32::MAX as usize) as u32),
61 ..Default::default()
62 }),
63 })
64 .collect();
65
66 StyleHints {
67 version: STYLE_HINTS_VERSION,
68 properties,
69 suggested_playback_seconds: suggested_playback_seconds(
70 time_range_ms,
71 temporal_bucket_ms,
72 ),
73 layer_hint: layer_kind_hint
74 .filter(|h| LAYER_HINTS.contains(h))
75 .map(str::to_string),
76 }
77}
78
79fn numeric_hint(name: &str, raw: &[f64]) -> Option<PropertyStyleHint> {
82 let mut vals: Vec<f64> = raw.iter().copied().filter(|v| v.is_finite()).collect();
83 if vals.is_empty() {
84 return None;
85 }
86 vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
87 let min = vals[0];
88 let max = *vals.last().expect("non-empty per check");
89 let p97 = percentile(&vals, 0.97);
90 Some(PropertyStyleHint {
91 name: name.to_string(),
92 min: Some(min),
93 p50: Some(percentile(&vals, 0.50)),
94 p90: Some(percentile(&vals, 0.90)),
95 p95: Some(percentile(&vals, 0.95)),
96 p97: Some(p97),
97 p99: Some(percentile(&vals, 0.99)),
98 max: Some(max),
99 suggested_domain: Some([
100 round_2sf_outward(min, false),
101 round_2sf_outward(p97, true),
102 ]),
103 cardinality: None,
104 })
105}
106
107fn percentile(sorted: &[f64], q: f64) -> f64 {
110 let idx = ((sorted.len() as f64 * q).floor() as usize).min(sorted.len() - 1);
111 sorted[idx]
112}
113
114fn round_2sf_outward(v: f64, up: bool) -> f64 {
120 if v == 0.0 || !v.is_finite() {
121 return v;
122 }
123 let exp = v.abs().log10().floor() as i32 - 1;
125 let pow = 10f64.powi(exp.abs());
126 let scaled = if exp >= 0 { v / pow } else { v * pow };
127 let eps = 1e-9;
128 let r = if up {
129 (scaled - eps).ceil()
130 } else {
131 (scaled + eps).floor()
132 };
133 if exp >= 0 {
134 r * pow
135 } else {
136 r / pow
139 }
140}
141
142fn suggested_playback_seconds(time_range_ms: u64, temporal_bucket_ms: u64) -> Option<u32> {
146 if temporal_bucket_ms == 0 {
147 return None;
148 }
149 let buckets = time_range_ms as f64 / temporal_bucket_ms as f64;
150 Some((buckets.sqrt().round() as u64).clamp(20, 90) as u32)
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 fn numeric(name: &str, vals: Vec<f64>) -> (String, PropertyValues) {
158 (name.to_string(), PropertyValues::Numeric(vals))
159 }
160
161 #[test]
162 fn percentiles_on_known_1000_value_vec() {
163 let mut vals: Vec<f64> = (1..=1000).map(|i| i as f64).collect();
166 vals.reverse(); let hints = profile_properties(&[numeric("v", vals)], 0, 0, None);
168 assert_eq!(hints.version, STYLE_HINTS_VERSION);
169 let p = &hints.properties[0];
170 assert_eq!(p.name, "v");
171 assert_eq!(p.min, Some(1.0));
172 assert_eq!(p.p50, Some(501.0)); assert_eq!(p.p90, Some(901.0));
174 assert_eq!(p.p95, Some(951.0));
175 assert_eq!(p.p97, Some(971.0));
176 assert_eq!(p.p99, Some(991.0));
177 assert_eq!(p.max, Some(1000.0));
178 assert_eq!(p.suggested_domain, Some([1.0, 980.0]));
180 assert_eq!(p.cardinality, None);
181 }
182
183 #[test]
184 fn domain_rounds_outward_to_two_sig_figs() {
185 let mut vals = vec![0.123];
187 vals.extend((1..=96).map(|i| 0.2 + i as f64 * 0.05)); vals.extend([5.27, 6.0, 9.1]);
189 assert_eq!(vals.len(), 100);
190 let hints = profile_properties(&[numeric("mag", vals)], 0, 0, None);
191 let p = &hints.properties[0];
192 assert_eq!(p.p97, Some(5.27));
193 assert_eq!(p.suggested_domain, Some([0.12, 5.3]));
194 }
195
196 #[test]
197 fn outward_rounding_handles_signs_and_exact_endpoints() {
198 assert_eq!(round_2sf_outward(0.123, false), 0.12);
200 assert_eq!(round_2sf_outward(5.27, true), 5.3);
201 assert_eq!(round_2sf_outward(-0.123, false), -0.13);
202 assert_eq!(round_2sf_outward(-5.27, true), -5.2);
203 assert_eq!(round_2sf_outward(971.0, true), 980.0);
204 assert_eq!(round_2sf_outward(5.3, true), 5.3);
206 assert_eq!(round_2sf_outward(5.3, false), 5.3);
207 assert_eq!(round_2sf_outward(0.0, true), 0.0);
208 }
209
210 #[test]
211 fn nan_and_infinite_values_are_filtered() {
212 let vals = vec![f64::NAN, 1.0, f64::INFINITY, 2.0, f64::NEG_INFINITY, 3.0];
213 let hints = profile_properties(&[numeric("v", vals)], 0, 0, None);
214 let p = &hints.properties[0];
215 assert_eq!(p.min, Some(1.0));
216 assert_eq!(p.max, Some(3.0));
217 }
218
219 #[test]
220 fn empty_or_all_nan_numeric_property_is_omitted() {
221 let hints = profile_properties(
222 &[
223 numeric("empty", vec![]),
224 numeric("nans", vec![f64::NAN, f64::NAN]),
225 numeric("ok", vec![1.0]),
226 ],
227 0,
228 0,
229 None,
230 );
231 assert_eq!(hints.properties.len(), 1);
232 assert_eq!(hints.properties[0].name, "ok");
233 }
234
235 #[test]
236 fn categorical_carries_cardinality_only() {
237 let hints = profile_properties(
238 &[("category".to_string(), PropertyValues::Categorical { distinct: 7 })],
239 0,
240 0,
241 None,
242 );
243 let p = &hints.properties[0];
244 assert_eq!(p.name, "category");
245 assert_eq!(p.cardinality, Some(7));
246 assert_eq!(p.min, None);
247 assert_eq!(p.p50, None);
248 assert_eq!(p.p97, None);
249 assert_eq!(p.max, None);
250 assert_eq!(p.suggested_domain, None);
251 }
252
253 #[test]
254 fn playback_seconds_clamps_and_guards_zero_bucket() {
255 let hour = 3_600_000u64;
256 assert_eq!(
258 profile_properties(&[], 4 * hour, hour, None).suggested_playback_seconds,
259 Some(20)
260 );
261 assert_eq!(
263 profile_properties(&[], 2025 * hour, hour, None).suggested_playback_seconds,
264 Some(45)
265 );
266 assert_eq!(
268 profile_properties(&[], 100_000 * hour, hour, None).suggested_playback_seconds,
269 Some(90)
270 );
271 assert_eq!(
273 profile_properties(&[], hour, 0, None).suggested_playback_seconds,
274 None
275 );
276 }
277
278 #[test]
279 fn layer_hint_passes_known_vocabulary_only() {
280 assert_eq!(
281 profile_properties(&[], 0, 0, Some("trips")).layer_hint.as_deref(),
282 Some("trips")
283 );
284 assert_eq!(profile_properties(&[], 0, 0, Some("hexagons")).layer_hint, None);
285 assert_eq!(profile_properties(&[], 0, 0, None).layer_hint, None);
286 }
287}