use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
use crate::tree::NodeId;
pub const COUNT_UP: Duration = Duration::from_millis(200);
pub const ARRIVAL: Duration = Duration::from_millis(900);
pub const FLASH: Duration = Duration::from_millis(160);
pub const RUNG: Duration = Duration::from_millis(45);
pub const DIM: Duration = Duration::from_millis(200);
pub const SHIMMER: Duration = Duration::from_millis(700);
#[derive(Clone, Copy, Debug)]
pub struct Chase {
shown: f64,
at: Instant,
settled: bool,
}
impl Chase {
#[must_use]
pub fn new(value: u64, now: Instant) -> Self {
Self {
#[expect(
clippy::cast_precision_loss,
reason = "a byte count large enough to lose precision here is 4 petabytes, and \
the value is on its way to a display rounded to one decimal place"
)]
shown: value as f64,
at: now,
settled: true,
}
}
pub fn advance(&mut self, target: u64, now: Instant) -> u64 {
#[expect(
clippy::cast_precision_loss,
reason = "as in `new`: the display this is bound for has one decimal place"
)]
let target = target as f64;
let elapsed = now.saturating_duration_since(self.at).as_secs_f64();
self.at = now;
let closed = 1.0 - (-elapsed * 3.0 / COUNT_UP.as_secs_f64()).exp();
self.shown += (target - self.shown) * closed;
if (target - self.shown).abs() <= (target.abs() * 0.0005).max(1.0) {
self.shown = target;
self.settled = true;
} else {
self.settled = false;
}
self.value()
}
pub fn jam(&mut self, value: u64, now: Instant) {
*self = Self::new(value, now);
}
#[must_use]
pub fn value(&self) -> u64 {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the chase only ever runs between two byte counts, so it is bounded by \
them; a negative is arithmetically unreachable and saturates to zero \
rather than wrapping"
)]
let value = self.shown.max(0.0).round() as u64;
value
}
#[must_use]
pub fn settled(&self) -> bool {
self.settled
}
}
#[derive(Debug)]
pub struct Moving {
rows: HashMap<NodeId, Chase>,
arrived: HashMap<NodeId, Instant>,
cascade: HashMap<NodeId, Instant>,
freeing: HashMap<NodeId, u64>,
spent: HashMap<NodeId, Instant>,
settled: u64,
hot: HashSet<NodeId>,
freed: u64,
now: Instant,
}
impl Moving {
#[must_use]
pub fn new(now: Instant) -> Self {
Self {
rows: HashMap::new(),
arrived: HashMap::new(),
cascade: HashMap::new(),
freeing: HashMap::new(),
spent: HashMap::new(),
settled: 0,
hot: HashSet::new(),
freed: 0,
now,
}
}
pub fn tick(&mut self, now: Instant) {
self.now = now;
}
pub fn advance(&mut self, now: Instant, rows: &[(NodeId, u64, bool)], freed: u64) {
self.now = now;
for &(id, target, exact) in rows {
let chase = self
.rows
.entry(id)
.or_insert_with(|| Chase::new(target, now));
if exact {
chase.jam(target, now);
} else {
chase.advance(target, now);
}
}
self.rows.retain(|_, chase| chase.at == now);
self.freed = freed;
self.arrived
.retain(|_, at| now.saturating_duration_since(*at) < ARRIVAL);
self.cascade
.retain(|_, at| now.saturating_duration_since(*at) < FLASH);
}
#[must_use]
pub fn shown(&self, id: NodeId, truth: u64) -> u64 {
self.rows.get(&id).map_or(truth, Chase::value)
}
#[must_use]
pub fn freed(&self) -> u64 {
self.freed
}
pub fn arrived(&mut self, id: NodeId, now: Instant) {
self.arrived.insert(id, now);
}
#[must_use]
pub fn freshness(&self, id: NodeId) -> f64 {
let Some(at) = self.arrived.get(&id) else {
return 0.0;
};
let elapsed = self.now.saturating_duration_since(*at).as_secs_f64();
(1.0 - elapsed / ARRIVAL.as_secs_f64()).clamp(0.0, 1.0)
}
pub fn cascade(&mut self, ancestors: &[NodeId], now: Instant) {
for (rung, &id) in ancestors.iter().enumerate() {
self.cascade
.insert(id, now + RUNG * u32::try_from(rung).unwrap_or(u32::MAX));
}
}
#[must_use]
pub fn is_cascading(&self, id: NodeId) -> bool {
self.cascade
.get(&id)
.is_some_and(|at| *at <= self.now && self.now.saturating_duration_since(*at) < FLASH)
}
pub fn heats(&mut self, id: NodeId) {
self.hot.insert(id);
}
pub fn cools(&mut self, id: NodeId) {
self.hot.remove(&id);
}
pub fn cooled(&mut self) {
self.hot.clear();
}
#[must_use]
pub fn is_hot(&self, id: NodeId) -> bool {
self.hot.contains(&id)
}
pub fn hot(&self) -> impl Iterator<Item = NodeId> + '_ {
self.hot.iter().copied()
}
#[must_use]
pub fn shimmer(&self, width: usize, epoch: Instant) -> usize {
if width == 0 {
return 0;
}
let step = SHIMMER.as_millis().max(1) / width as u128;
let elapsed = self.now.saturating_duration_since(epoch).as_millis();
usize::try_from(elapsed / step.max(1) % width as u128).unwrap_or(0)
}
pub fn frees(&mut self, id: NodeId, bytes: u64) {
let freed = self.freeing.entry(id).or_insert(0);
*freed = (*freed).max(bytes);
}
pub fn spends(&mut self, id: NodeId, bytes: u64, now: Instant) {
self.frees(id, bytes);
self.spent.entry(id).or_insert(now);
}
#[must_use]
pub fn is_freeing(&self, id: NodeId) -> bool {
self.freeing.contains_key(&id) && !self.spent.contains_key(&id)
}
#[must_use]
pub fn is_spent(&self, id: NodeId) -> bool {
self.spent.contains_key(&id)
}
#[must_use]
pub fn is_leaving(&self, id: NodeId) -> bool {
self.is_freeing(id) || self.is_spent(id)
}
pub fn leaving(&self) -> impl Iterator<Item = (NodeId, u64)> + '_ {
self.freeing.iter().map(|(&id, &bytes)| (id, bytes))
}
#[must_use]
pub fn freed_from(&self, id: NodeId) -> u64 {
self.freeing.get(&id).copied().unwrap_or(0)
}
#[must_use]
pub fn freed_so_far(&self) -> u64 {
self.settled + self.freeing.values().sum::<u64>()
}
pub fn collapsed(&mut self, now: Instant) -> Vec<NodeId> {
let due: Vec<NodeId> = self
.spent
.iter()
.filter(|(_, at)| now.saturating_duration_since(**at) >= DIM)
.map(|(&id, _)| id)
.collect();
for id in &due {
self.spent.remove(id);
self.settled += self.freeing.remove(id).unwrap_or(0);
}
due
}
pub fn banked(&mut self) {
self.freeing.clear();
self.settled = 0;
}
#[must_use]
pub fn is_moving(&self) -> bool {
!self.hot.is_empty()
|| !self.freeing.is_empty()
|| !self.spent.is_empty()
|| !self.cascade.is_empty()
|| !self.arrived.is_empty()
|| self.rows.values().any(|chase| !chase.settled())
}
}
#[cfg(test)]
mod tests {
use super::{ARRIVAL, COUNT_UP, Chase, DIM, FLASH, Moving, RUNG};
use std::time::{Duration, Instant};
#[test]
fn a_chase_climbs_toward_its_target_and_arrives_at_it_exactly() {
let start = Instant::now();
let mut chase = Chase::new(0, start);
let half = chase.advance(1_000_000, start + COUNT_UP / 2);
assert!(half > 0 && half < 1_000_000, "{half}");
assert!(!chase.settled());
let landed = chase.advance(1_000_000, start + COUNT_UP * 4);
assert_eq!(landed, 1_000_000);
assert!(chase.settled());
}
#[test]
fn a_chase_runs_downwards_as_readily_as_up() {
let start = Instant::now();
let mut chase = Chase::new(1_000_000, start);
let draining = chase.advance(0, start + COUNT_UP / 2);
assert!(draining > 0 && draining < 1_000_000, "{draining}");
assert_eq!(chase.advance(0, start + COUNT_UP * 8), 0);
}
#[test]
fn a_target_that_moves_mid_flight_is_chased_rather_than_restarted() {
let start = Instant::now();
let mut chase = Chase::new(0, start);
let first = chase.advance(100, start + COUNT_UP / 4);
let second = chase.advance(200, start + COUNT_UP / 2);
assert!(second > first, "{first} -> {second}");
assert!(second < 200);
}
#[test]
fn a_row_that_scrolled_off_the_screen_is_forgotten_rather_than_animated() {
let start = Instant::now();
let mut moving = Moving::new(start);
moving.advance(start, &[(1, 100, false), (2, 200, false)], 0);
moving.advance(start + COUNT_UP, &[(1, 100, false)], 0);
assert_eq!(
moving.shown(2, 999),
999,
"a row nobody drew kept its state"
);
assert_eq!(moving.shown(1, 100), 100);
}
#[test]
fn a_newly_arrived_row_is_lit_and_the_light_decays() {
let start = Instant::now();
let mut moving = Moving::new(start);
moving.arrived(7, start);
moving.advance(start, &[], 0);
assert!((moving.freshness(7) - 1.0).abs() < f64::EPSILON);
moving.advance(start + ARRIVAL / 2, &[], 0);
assert!(
(0.4..0.6).contains(&moving.freshness(7)),
"{}",
moving.freshness(7)
);
moving.advance(start + ARRIVAL * 2, &[], 0);
assert!(moving.freshness(7).abs() < f64::EPSILON);
assert!(
!moving.is_moving(),
"a light nobody can see is still animating"
);
}
#[test]
fn a_cascade_lights_each_rung_later_than_the_one_below_it() {
let start = Instant::now();
let mut moving = Moving::new(start);
moving.cascade(&[10, 11, 12], start);
moving.advance(start, &[], 0);
assert!(moving.is_cascading(10));
assert!(!moving.is_cascading(12), "the whole chain flashed at once");
moving.advance(start + RUNG * 2, &[], 0);
assert!(moving.is_cascading(12), "the mark never reached the root");
moving.advance(start + RUNG * 2 + FLASH, &[], 0);
assert!(!moving.is_cascading(12));
assert!(!moving.is_moving());
}
#[test]
fn a_row_stays_until_its_dimmed_beat_is_over_and_is_handed_back_once() {
let start = Instant::now();
let mut moving = Moving::new(start);
moving.frees(3, 40);
assert!(moving.is_freeing(3));
assert!(!moving.is_spent(3), "dimmed while it is still emptying");
assert_eq!(moving.leaving().collect::<Vec<_>>(), [(3, 40)]);
moving.spends(3, 100, start);
assert!(moving.is_spent(3));
assert!(!moving.is_freeing(3));
assert_eq!(moving.leaving().collect::<Vec<_>>(), [(3, 100)]);
assert!(moving.collapsed(start + DIM / 2).is_empty());
assert_eq!(moving.collapsed(start + DIM), vec![3]);
assert!(moving.collapsed(start + DIM * 2).is_empty());
assert!(!moving.is_spent(3));
}
#[test]
fn a_progress_report_that_arrives_behind_a_later_one_does_not_wind_the_row_backwards() {
let start = Instant::now();
let mut moving = Moving::new(start);
moving.frees(3, 900);
moving.frees(3, 400);
assert_eq!(moving.leaving().collect::<Vec<_>>(), [(3, 900)]);
}
#[test]
fn banking_a_batch_leaves_nothing_for_the_counter_to_count_twice() {
let start = Instant::now();
let mut moving = Moving::new(start);
moving.spends(3, 100, start);
moving.banked();
assert_eq!(moving.leaving().count(), 0);
assert!(moving.is_spent(3));
assert_eq!(moving.collapsed(start + DIM), vec![3]);
}
#[test]
fn the_shimmer_travels_and_comes_round() {
let start = Instant::now();
let mut moving = Moving::new(start);
moving.advance(start, &[], 0);
let first = moving.shimmer(5, start);
moving.advance(start + super::SHIMMER / 5, &[], 0);
let second = moving.shimmer(5, start);
assert_ne!(first, second, "the shimmer stood still");
moving.advance(start + super::SHIMMER, &[], 0);
assert_eq!(moving.shimmer(5, start), first, "it never came round");
}
#[test]
fn a_view_with_nothing_happening_in_it_reports_itself_still() {
let start = Instant::now();
let mut moving = Moving::new(start);
moving.advance(start, &[(1, 100, false)], 0);
assert!(!moving.is_moving());
moving.heats(1);
assert!(moving.is_moving());
moving.cools(1);
assert!(!moving.is_moving());
moving.advance(start + Duration::from_millis(1), &[(1, 100_000, false)], 0);
assert!(moving.is_moving(), "a number in flight is motion");
}
}