use stt_core::metadata::{PropertyStyleHint, StyleHints};
pub const STYLE_HINTS_VERSION: u32 = 1;
const LAYER_HINTS: [&str; 4] = ["points", "paths", "trips", "polygons"];
#[derive(Debug, Clone)]
pub enum PropertyValues {
Numeric(Vec<f64>),
Categorical { distinct: usize },
}
pub fn profile_properties(
props: &[(String, PropertyValues)],
time_range_ms: u64,
temporal_bucket_ms: u64,
layer_kind_hint: Option<&str>,
) -> StyleHints {
let properties = props
.iter()
.filter_map(|(name, values)| match values {
PropertyValues::Numeric(raw) => numeric_hint(name, raw),
PropertyValues::Categorical { distinct } => Some(PropertyStyleHint {
name: name.clone(),
cardinality: Some((*distinct).min(u32::MAX as usize) as u32),
..Default::default()
}),
})
.collect();
StyleHints {
version: STYLE_HINTS_VERSION,
properties,
suggested_playback_seconds: suggested_playback_seconds(
time_range_ms,
temporal_bucket_ms,
),
layer_hint: layer_kind_hint
.filter(|h| LAYER_HINTS.contains(h))
.map(str::to_string),
}
}
fn numeric_hint(name: &str, raw: &[f64]) -> Option<PropertyStyleHint> {
let mut vals: Vec<f64> = raw.iter().copied().filter(|v| v.is_finite()).collect();
if vals.is_empty() {
return None;
}
vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let min = vals[0];
let max = *vals.last().expect("non-empty per check");
let p97 = percentile(&vals, 0.97);
Some(PropertyStyleHint {
name: name.to_string(),
min: Some(min),
p50: Some(percentile(&vals, 0.50)),
p90: Some(percentile(&vals, 0.90)),
p95: Some(percentile(&vals, 0.95)),
p97: Some(p97),
p99: Some(percentile(&vals, 0.99)),
max: Some(max),
suggested_domain: Some([
round_2sf_outward(min, false),
round_2sf_outward(p97, true),
]),
cardinality: None,
})
}
fn percentile(sorted: &[f64], q: f64) -> f64 {
let idx = ((sorted.len() as f64 * q).floor() as usize).min(sorted.len() - 1);
sorted[idx]
}
fn round_2sf_outward(v: f64, up: bool) -> f64 {
if v == 0.0 || !v.is_finite() {
return v;
}
let exp = v.abs().log10().floor() as i32 - 1;
let pow = 10f64.powi(exp.abs());
let scaled = if exp >= 0 { v / pow } else { v * pow };
let eps = 1e-9;
let r = if up {
(scaled - eps).ceil()
} else {
(scaled + eps).floor()
};
if exp >= 0 {
r * pow
} else {
r / pow
}
}
fn suggested_playback_seconds(time_range_ms: u64, temporal_bucket_ms: u64) -> Option<u32> {
if temporal_bucket_ms == 0 {
return None;
}
let buckets = time_range_ms as f64 / temporal_bucket_ms as f64;
Some((buckets.sqrt().round() as u64).clamp(20, 90) as u32)
}
#[cfg(test)]
mod tests {
use super::*;
fn numeric(name: &str, vals: Vec<f64>) -> (String, PropertyValues) {
(name.to_string(), PropertyValues::Numeric(vals))
}
#[test]
fn percentiles_on_known_1000_value_vec() {
let mut vals: Vec<f64> = (1..=1000).map(|i| i as f64).collect();
vals.reverse(); let hints = profile_properties(&[numeric("v", vals)], 0, 0, None);
assert_eq!(hints.version, STYLE_HINTS_VERSION);
let p = &hints.properties[0];
assert_eq!(p.name, "v");
assert_eq!(p.min, Some(1.0));
assert_eq!(p.p50, Some(501.0)); assert_eq!(p.p90, Some(901.0));
assert_eq!(p.p95, Some(951.0));
assert_eq!(p.p97, Some(971.0));
assert_eq!(p.p99, Some(991.0));
assert_eq!(p.max, Some(1000.0));
assert_eq!(p.suggested_domain, Some([1.0, 980.0]));
assert_eq!(p.cardinality, None);
}
#[test]
fn domain_rounds_outward_to_two_sig_figs() {
let mut vals = vec![0.123];
vals.extend((1..=96).map(|i| 0.2 + i as f64 * 0.05)); vals.extend([5.27, 6.0, 9.1]);
assert_eq!(vals.len(), 100);
let hints = profile_properties(&[numeric("mag", vals)], 0, 0, None);
let p = &hints.properties[0];
assert_eq!(p.p97, Some(5.27));
assert_eq!(p.suggested_domain, Some([0.12, 5.3]));
}
#[test]
fn outward_rounding_handles_signs_and_exact_endpoints() {
assert_eq!(round_2sf_outward(0.123, false), 0.12);
assert_eq!(round_2sf_outward(5.27, true), 5.3);
assert_eq!(round_2sf_outward(-0.123, false), -0.13);
assert_eq!(round_2sf_outward(-5.27, true), -5.2);
assert_eq!(round_2sf_outward(971.0, true), 980.0);
assert_eq!(round_2sf_outward(5.3, true), 5.3);
assert_eq!(round_2sf_outward(5.3, false), 5.3);
assert_eq!(round_2sf_outward(0.0, true), 0.0);
}
#[test]
fn nan_and_infinite_values_are_filtered() {
let vals = vec![f64::NAN, 1.0, f64::INFINITY, 2.0, f64::NEG_INFINITY, 3.0];
let hints = profile_properties(&[numeric("v", vals)], 0, 0, None);
let p = &hints.properties[0];
assert_eq!(p.min, Some(1.0));
assert_eq!(p.max, Some(3.0));
}
#[test]
fn empty_or_all_nan_numeric_property_is_omitted() {
let hints = profile_properties(
&[
numeric("empty", vec![]),
numeric("nans", vec![f64::NAN, f64::NAN]),
numeric("ok", vec![1.0]),
],
0,
0,
None,
);
assert_eq!(hints.properties.len(), 1);
assert_eq!(hints.properties[0].name, "ok");
}
#[test]
fn categorical_carries_cardinality_only() {
let hints = profile_properties(
&[("category".to_string(), PropertyValues::Categorical { distinct: 7 })],
0,
0,
None,
);
let p = &hints.properties[0];
assert_eq!(p.name, "category");
assert_eq!(p.cardinality, Some(7));
assert_eq!(p.min, None);
assert_eq!(p.p50, None);
assert_eq!(p.p97, None);
assert_eq!(p.max, None);
assert_eq!(p.suggested_domain, None);
}
#[test]
fn playback_seconds_clamps_and_guards_zero_bucket() {
let hour = 3_600_000u64;
assert_eq!(
profile_properties(&[], 4 * hour, hour, None).suggested_playback_seconds,
Some(20)
);
assert_eq!(
profile_properties(&[], 2025 * hour, hour, None).suggested_playback_seconds,
Some(45)
);
assert_eq!(
profile_properties(&[], 100_000 * hour, hour, None).suggested_playback_seconds,
Some(90)
);
assert_eq!(
profile_properties(&[], hour, 0, None).suggested_playback_seconds,
None
);
}
#[test]
fn layer_hint_passes_known_vocabulary_only() {
assert_eq!(
profile_properties(&[], 0, 0, Some("trips")).layer_hint.as_deref(),
Some("trips")
);
assert_eq!(profile_properties(&[], 0, 0, Some("hexagons")).layer_hint, None);
assert_eq!(profile_properties(&[], 0, 0, None).layer_hint, None);
}
}