diff --git a/crates/rendering/src/zoom_spring.rs b/crates/rendering/src/zoom_spring.rs index f35c9567c1..a8ddaab2ca 100644 --- a/crates/rendering/src/zoom_spring.rs +++ b/crates/rendering/src/zoom_spring.rs @@ -183,9 +183,41 @@ impl CursorCropMaps { } } +struct ClusterCenters { + clusters: Vec, + time_ordered: bool, +} + +impl ClusterCenters { + fn new(clusters: Vec) -> Self { + let time_ordered = + clusters.is_sorted_by(|left, right| left.start_time_ms <= right.start_time_ms); + Self { + clusters, + time_ordered, + } + } + + fn center_at(&self, time_ms: f64) -> Option<(f64, f64)> { + if self.time_ordered { + let through_time = self + .clusters + .partition_point(|cluster| cluster.start_time_ms <= time_ms); + self.clusters.get(through_time.saturating_sub(1)) + } else { + self.clusters + .iter() + .rev() + .find(|cluster| cluster.start_time_ms <= time_ms) + .or_else(|| self.clusters.first()) + } + .map(ClickCluster::center) + } +} + struct CroppedClusters { crop: Option, - clusters: Vec, + clusters: ClusterCenters, } struct TimedClusterVariant { @@ -195,7 +227,7 @@ struct TimedClusterVariant { } enum ZoomClusters { - Static(Vec), + Static(ClusterCenters), Styled { variants: Vec, styles: Vec, @@ -212,14 +244,14 @@ impl ZoomClusters { crops: &CursorCropMaps, ) -> Self { let build = |crop| { - build_clusters( + ClusterCenters::new(build_clusters( cursor_events, moves_time_ordered, recording_start, recording_end, segment.amount, crop, - ) + )) }; let base = build(crops.base); let overlaps = @@ -275,7 +307,7 @@ impl ZoomClusters { &variants[variant].clusters } }; - cluster_center_at_time(clusters, recording_ms) + clusters.center_at(recording_ms) } } @@ -422,20 +454,6 @@ pub(crate) fn build_clusters( clusters } -/// The active cluster at `time_ms` (RECORDING time) is the last cluster whose -/// first event time is <= t; the spring smooths the discrete re-aims. -pub(crate) fn cluster_center_at_time( - clusters: &[ClickCluster], - time_ms: f64, -) -> Option<(f64, f64)> { - clusters - .iter() - .rev() - .find(|c| c.start_time_ms <= time_ms) - .or_else(|| clusters.first()) - .map(|c| c.center()) -} - /// One entry of the timeline-time -> recording-time mapping derived from /// [`TimelineConfiguration::get_segment_time`]'s accumulation logic. #[derive(Clone, Copy)] @@ -1855,7 +1873,10 @@ mod tests { }; for ordered in [false, true] { let clusters = build_clusters(&cursor, ordered, 2.0, 10.0, 2.0, None); - assert_eq!(cluster_center_at_time(&clusters, 2000.0), Some((0.1, 0.1))); + assert_eq!( + ClusterCenters::new(clusters).center_at(2000.0), + Some((0.1, 0.1)) + ); } } @@ -2679,4 +2700,62 @@ mod tests { } } } + + fn linear_cluster_center_at_time( + clusters: &[ClickCluster], + time_ms: f64, + ) -> Option<(f64, f64)> { + clusters + .iter() + .rev() + .find(|cluster| cluster.start_time_ms <= time_ms) + .or_else(|| clusters.first()) + .map(ClickCluster::center) + } + + #[test] + fn cluster_centers_match_linear_lookup_for_all_short_sequences() { + let starts = [ + f64::NEG_INFINITY, + -0.0, + 0.0, + 1.0, + 2.0, + f64::INFINITY, + f64::NAN, + ]; + let queries = [ + f64::NEG_INFINITY, + -1.0, + -0.0, + 0.0, + 1.0_f64.next_down(), + 1.0, + 1.0_f64.next_up(), + 2.0, + 3.0, + f64::INFINITY, + f64::NAN, + ]; + for length in 0..=4 { + for mut sequence in 0..starts.len().pow(length) { + let clusters = (0..length) + .map(|index| { + let start = starts[sequence % starts.len()]; + sequence /= starts.len(); + ClickCluster::new(f64::from(index), f64::from(index) * 0.5, start) + }) + .collect(); + let centers = ClusterCenters::new(clusters); + for time in queries { + assert_eq!( + centers.center_at(time), + linear_cluster_center_at_time(¢ers.clusters, time), + "clusters {:?}, time {time}", + centers.clusters + ); + } + } + } + } }