use crate::anim::{Easing, HaloEmphasis, lerp, lerp2, lerp4};
use crate::interner::{LabelId, NodeIndex};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SceneNode {
pub pos: [f32; 2],
pub radius: f32,
pub color: [f32; 4],
pub opacity: f32,
pub shape: u32,
pub label: LabelId,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SceneEdge {
pub a: NodeIndex,
pub b: NodeIndex,
pub color: [f32; 4],
pub width: f32,
pub opacity: f32,
pub label: LabelId,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SceneHalo {
pub node: NodeIndex,
pub emphasis: HaloEmphasis,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Scene {
pub nodes: Vec<Option<SceneNode>>,
pub edges: Vec<SceneEdge>,
pub halos: Vec<SceneHalo>,
}
impl Scene {
pub fn set(&mut self, ix: NodeIndex, node: SceneNode) {
let i = ix as usize;
if i >= self.nodes.len() { self.nodes.resize(i + 1, None); }
self.nodes[i] = Some(node);
}
pub fn get(&self, ix: NodeIndex) -> Option<&SceneNode> {
self.nodes.get(ix as usize).and_then(|s| s.as_ref())
}
pub fn clear(&mut self) {
self.nodes.clear();
self.edges.clear();
self.halos.clear();
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FrameNode {
pub index: NodeIndex,
pub pos: [f32; 2],
pub radius: f32,
pub color: [f32; 4],
pub opacity: f32,
pub shape: u32,
pub label: LabelId,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FrameEdge { pub a: [f32; 2], pub b: [f32; 2], pub color: [f32; 4], pub width: f32, pub opacity: f32, pub label: LabelId }
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FrameHalo { pub node: NodeIndex, pub pos: [f32; 2], pub radius: f32, pub emphasis: HaloEmphasis }
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Frame {
pub nodes: Vec<FrameNode>,
pub edges: Vec<FrameEdge>,
pub halos: Vec<FrameHalo>,
}
pub struct Animator {
displayed: Scene,
from_buf: Scene,
to: Scene,
start_ms: f64,
dur_ms: f32,
easing: Easing,
animating: bool,
scratch: Vec<Option<SceneNode>>,
}
impl Default for Animator { fn default() -> Self { Self::new() } }
impl Animator {
pub fn new() -> Self {
Self {
displayed: Scene::default(),
from_buf: Scene::default(),
to: Scene::default(),
start_ms: 0.0,
dur_ms: 0.0,
easing: Easing::Linear,
animating: false,
scratch: Vec::new(),
}
}
pub fn set_target(&mut self, target: Scene, now_ms: f64, dur_ms: f32, easing: Easing, tween: bool) {
let has_displayed = self.displayed.nodes.iter().any(|n| n.is_some());
if tween && dur_ms > 0.0 && has_displayed {
self.from_buf.nodes.clone_from(&self.displayed.nodes);
self.from_buf.edges.clear();
self.from_buf.halos.clear();
self.displayed.edges.clone_from(&target.edges);
self.displayed.halos.clone_from(&target.halos);
self.to = target;
self.start_ms = now_ms;
self.dur_ms = dur_ms;
self.easing = easing;
self.animating = true;
} else {
self.displayed.nodes.clone_from(&target.nodes);
self.displayed.edges.clone_from(&target.edges);
self.displayed.halos.clone_from(&target.halos);
self.to = target;
self.animating = false;
}
}
pub fn tick_into(&mut self, now_ms: f64, out: &mut Frame) {
self.advance(now_ms);
frame_into(&self.displayed, out);
}
pub fn tick_positioned_into(&mut self, now_ms: f64, pos: &[Option<[f32; 2]>], out: &mut Frame) {
self.advance(now_ms);
for (i, slot) in self.displayed.nodes.iter_mut().enumerate() {
if let (Some(n), Some(Some(p))) = (slot.as_mut(), pos.get(i)) {
n.pos = *p;
}
}
frame_into(&self.displayed, out);
}
fn advance(&mut self, now_ms: f64) {
if self.animating {
let raw = ((now_ms - self.start_ms) as f32 / self.dur_ms).clamp(0.0, 1.0);
let t = self.easing.apply(raw);
interpolate_into(&self.from_buf, &self.to, t, &mut self.scratch);
self.displayed.nodes.clone_from(&self.scratch);
if raw >= 1.0 {
self.displayed.nodes.clone_from(&self.to.nodes);
self.animating = false;
}
}
}
pub fn target_positions(&self) -> impl Iterator<Item = [f32; 2]> + '_ {
self.to.nodes.iter().flatten().map(|n| n.pos)
}
pub fn target_position(&self, ix: NodeIndex) -> Option<[f32; 2]> {
self.to.get(ix).map(|n| n.pos)
}
}
fn interpolate_into(from: &Scene, to: &Scene, t: f32, out: &mut Vec<Option<SceneNode>>) {
let len = from.nodes.len().max(to.nodes.len());
out.clear();
out.resize(len, None);
for (i, slot) in out.iter_mut().enumerate() {
let f = from.nodes.get(i).and_then(|s| s.as_ref());
let g = to.nodes.get(i).and_then(|s| s.as_ref());
*slot = match (f, g) {
(Some(fnode), Some(tn)) => Some(SceneNode {
pos: lerp2(fnode.pos, tn.pos, t),
radius: lerp(fnode.radius, tn.radius, t),
color: lerp4(fnode.color, tn.color, t),
opacity: lerp(fnode.opacity, tn.opacity, t),
shape: tn.shape,
label: tn.label,
}),
(None, Some(tn)) => Some(SceneNode { opacity: tn.opacity * t, ..*tn }),
(Some(fnode), None) if t < 1.0 => {
Some(SceneNode { opacity: fnode.opacity * (1.0 - t), ..*fnode })
}
_ => None,
};
}
}
fn frame_into(s: &Scene, out: &mut Frame) {
out.nodes.clear();
out.edges.clear();
out.halos.clear();
for (i, n) in s.nodes.iter().enumerate() {
if let Some(n) = n {
out.nodes.push(FrameNode {
index: i as NodeIndex,
pos: n.pos, radius: n.radius, color: n.color,
opacity: n.opacity, shape: n.shape, label: n.label,
});
}
}
for e in &s.edges {
let (Some(a), Some(b)) = (s.get(e.a), s.get(e.b)) else { continue };
out.edges.push(FrameEdge { a: a.pos, b: b.pos, color: e.color, width: e.width, opacity: e.opacity, label: e.label });
}
for h in &s.halos {
let Some(n) = s.get(h.node) else { continue };
out.halos.push(FrameHalo { node: h.node, pos: n.pos, radius: n.radius, emphasis: h.emphasis });
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interner::EMPTY_LABEL;
fn node(pos: [f32; 2], radius: f32, opacity: f32) -> SceneNode {
SceneNode { pos, radius, color: [1.0, 0.0, 0.0, 1.0], opacity, shape: 0, label: 0 }
}
fn scene_with(nodes: &[(NodeIndex, SceneNode)]) -> Scene {
let mut s = Scene::default();
for (ix, n) in nodes { s.set(*ix, *n); }
s
}
#[test]
fn no_transition_emits_target_immediately() {
let mut a = Animator::new();
let s = scene_with(&[(0, node([1.0, 2.0], 10.0, 1.0))]);
a.set_target(s, 0.0, 200.0, Easing::Linear, true); let mut f = Frame::default();
a.tick_into(0.0, &mut f);
assert_eq!(f.nodes.len(), 1);
assert_eq!(f.nodes[0].pos, [1.0, 2.0]);
}
#[test]
fn moved_node_interpolates_position() {
let mut b = Animator::new();
let mut f = Frame::default();
b.set_target(scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
b.tick_into(0.0, &mut f); b.set_target(scene_with(&[(0, node([10.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
b.tick_into(0.0, &mut f);
assert!((f.nodes[0].pos[0] - 0.0).abs() < 1e-4);
b.tick_into(100.0, &mut f);
assert!((f.nodes[0].pos[0] - 5.0).abs() < 1e-4);
b.tick_into(200.0, &mut f);
assert!((f.nodes[0].pos[0] - 10.0).abs() < 1e-4);
}
#[test]
fn entering_node_fades_in_exiting_fades_out() {
let mut a = Animator::new();
let mut f = Frame::default();
a.set_target(scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
a.tick_into(0.0, &mut f);
a.set_target(scene_with(&[(1, node([5.0, 5.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
a.tick_into(100.0, &mut f); let get = |f: &Frame, ix: NodeIndex| f.nodes.iter().find(|n| n.index == ix).map(|n| n.opacity);
assert!((get(&f, 0).unwrap() - 0.5).abs() < 1e-4, "exiting alpha");
assert!((get(&f, 1).unwrap() - 0.5).abs() < 1e-4, "entering alpha");
a.tick_into(200.0, &mut f);
assert_eq!(f.nodes.len(), 1);
assert_eq!(f.nodes[0].index, 1);
}
#[test]
fn edges_resolve_endpoints_from_interpolated_positions() {
let mut a = Animator::new();
let mut f = Frame::default();
let mut s0 = scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0)), (1, node([0.0, 0.0], 10.0, 1.0))]);
s0.edges.push(SceneEdge { a: 0, b: 1, color: [0.0; 4], width: 1.0, opacity: 1.0, label: EMPTY_LABEL });
a.set_target(s0, 0.0, 200.0, Easing::Linear, true);
a.tick_into(0.0, &mut f);
let mut s1 = scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0)), (1, node([10.0, 0.0], 10.0, 1.0))]);
s1.edges.push(SceneEdge { a: 0, b: 1, color: [0.0; 4], width: 1.0, opacity: 1.0, label: EMPTY_LABEL });
a.set_target(s1, 0.0, 200.0, Easing::Linear, true);
a.tick_into(100.0, &mut f);
assert_eq!(f.edges.len(), 1);
assert!((f.edges[0].b[0] - 5.0).abs() < 1e-4); }
#[test]
fn halos_resolve_position_and_radius_from_their_node() {
let mut a = Animator::new();
let mut f = Frame::default();
let mut s = scene_with(&[(0, node([3.0, 4.0], 12.0, 1.0))]);
s.halos.push(SceneHalo { node: 0, emphasis: HaloEmphasis::Primary });
a.set_target(s, 0.0, 0.0, Easing::Linear, false); a.tick_into(0.0, &mut f);
assert_eq!(f.halos.len(), 1);
assert_eq!(f.halos[0].pos, [3.0, 4.0]);
assert_eq!(f.halos[0].radius, 12.0);
assert_eq!(f.halos[0].node, 0);
}
#[test]
fn edges_and_halos_referencing_absent_nodes_are_skipped() {
let mut a = Animator::new();
let mut f = Frame::default();
let mut s = scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]);
s.edges.push(SceneEdge { a: 0, b: 7, color: [0.0; 4], width: 1.0, opacity: 1.0, label: EMPTY_LABEL });
s.halos.push(SceneHalo { node: 7, emphasis: HaloEmphasis::Primary });
a.set_target(s, 0.0, 0.0, Easing::Linear, false);
a.tick_into(0.0, &mut f);
assert_eq!(f.nodes.len(), 1);
assert!(f.edges.is_empty());
assert!(f.halos.is_empty());
}
#[test]
fn target_position_looks_up_one_node_by_index() {
let mut a = Animator::new();
a.set_target(scene_with(&[(0, node([3.0, 4.0], 10.0, 1.0))]), 0.0, 0.0, Easing::Linear, false);
assert_eq!(a.target_position(0), Some([3.0, 4.0]));
assert_eq!(a.target_position(9), None); }
#[test]
fn target_positions_reflect_destination_even_mid_transition() {
let mut a = Animator::new();
let mut f = Frame::default();
a.set_target(scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
a.tick_into(0.0, &mut f);
a.set_target(scene_with(&[(0, node([100.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
a.tick_into(0.0, &mut f);
assert!((f.nodes[0].pos[0] - 0.0).abs() < 1e-4);
let tgt: Vec<[f32; 2]> = a.target_positions().collect();
assert_eq!(tgt, vec![[100.0, 0.0]]);
}
#[test]
fn tween_disabled_snaps_positions() {
let mut a = Animator::new();
let mut f = Frame::default();
a.set_target(scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, false);
a.tick_into(0.0, &mut f);
a.set_target(scene_with(&[(0, node([10.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, false);
a.tick_into(0.0, &mut f);
assert_eq!(f.nodes[0].pos[0], 10.0); }
#[test]
fn tick_positioned_overrides_node_positions() {
let mut a = Animator::new();
let mut f = Frame::default();
let s = scene_with(&[(0, node([0.0, 0.0], 5.0, 1.0))]);
a.set_target(s, 0.0, 0.0, Easing::Linear, false);
let pos = vec![Some([77.0, -3.0])];
a.tick_positioned_into(0.0, &pos, &mut f);
assert_eq!(f.nodes[0].pos, [77.0, -3.0], "the simulation owns position");
assert_eq!(f.nodes[0].radius, 5.0, "the animator still owns appearance");
}
#[test]
fn edges_and_halos_follow_the_overridden_positions() {
let mut sc = scene_with(&[(0, node([0.0, 0.0], 5.0, 1.0)), (1, node([1.0, 1.0], 5.0, 1.0))]);
sc.edges.push(SceneEdge { a: 0, b: 1, color: [1.0; 4], width: 1.0, opacity: 1.0, label: EMPTY_LABEL });
sc.halos.push(SceneHalo { node: 0, emphasis: HaloEmphasis::Primary });
let mut an = Animator::new();
let mut f = Frame::default();
an.set_target(sc, 0.0, 0.0, Easing::Linear, false);
let pos = vec![Some([100.0, 0.0]), Some([200.0, 50.0])];
an.tick_positioned_into(0.0, &pos, &mut f);
assert_eq!(f.edges[0].a, [100.0, 0.0]);
assert_eq!(f.edges[0].b, [200.0, 50.0]);
assert_eq!(f.halos[0].pos, [100.0, 0.0]);
}
#[test]
fn a_node_absent_from_the_overrides_keeps_its_interpolated_position() {
let mut a = Animator::new();
let mut f = Frame::default();
a.set_target(scene_with(&[(0, node([9.0, 9.0], 5.0, 1.0))]), 0.0, 0.0, Easing::Linear, false);
let empty: Vec<Option<[f32; 2]>> = Vec::new();
a.tick_positioned_into(0.0, &empty, &mut f);
assert_eq!(f.nodes[0].pos, [9.0, 9.0]);
a.tick_positioned_into(0.0, &[None], &mut f);
assert_eq!(f.nodes[0].pos, [9.0, 9.0]);
}
#[test]
fn tick_positioned_still_advances_the_transition() {
let mut a = Animator::new();
let mut f = Frame::default();
a.set_target(scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]), 0.0, 0.0, Easing::Linear, false);
a.set_target(scene_with(&[(0, node([0.0, 0.0], 20.0, 1.0))]), 0.0, 100.0, Easing::Linear, true);
let pos: Vec<Option<[f32; 2]>> = Vec::new();
a.tick_positioned_into(50.0, &pos, &mut f);
assert!((f.nodes[0].radius - 15.0).abs() < 0.01, "radius should be halfway");
a.tick_positioned_into(100.0, &pos, &mut f);
assert_eq!(f.nodes[0].radius, 20.0);
}
#[test]
fn exiting_node_keeps_its_simulated_position_while_it_fades() {
let mut a = Animator::new();
let mut f = Frame::default();
let s0 = scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0)), (1, node([0.0, 0.0], 10.0, 1.0))]);
a.set_target(s0, 0.0, 0.0, Easing::Linear, false);
let pos = vec![Some([500.0, 0.0]), Some([10.0, 10.0])];
a.tick_positioned_into(0.0, &pos, &mut f);
let s1 = scene_with(&[(0, node([1.0, 1.0], 10.0, 1.0))]);
a.set_target(s1, 0.0, 200.0, Easing::Linear, true);
let pos2 = vec![Some([501.0, 0.0]), None]; a.tick_positioned_into(0.0, &pos2, &mut f);
let b = f.nodes.iter().find(|n| n.index == 1).expect("node 1 still fading");
assert_eq!(b.pos, [10.0, 10.0], "exiting node should stay at its last simulated position while fading, not teleport to a stale scene-authored one");
}
#[test]
fn mid_flight_retarget_starts_from_the_displayed_position_not_the_old_target() {
let mut a = Animator::new();
let mut f = Frame::default();
a.set_target(scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true); a.set_target(scene_with(&[(0, node([100.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
a.tick_into(100.0, &mut f);
a.set_target(scene_with(&[(0, node([300.0, 0.0], 10.0, 1.0))]), 100.0, 200.0, Easing::Linear, true);
a.tick_into(100.0, &mut f); assert!((f.nodes[0].pos[0] - 50.0).abs() < 1e-4, "new transition should start from the displayed position (50), not the old target (100)");
}
#[test]
fn frame_buffers_do_not_reallocate_when_warm() {
let mut a = Animator::new();
let mut s = Scene::default();
for i in 0..100u32 { s.set(i, node([i as f32, 0.0], 5.0, 1.0)); }
for i in 0..99u32 {
s.edges.push(SceneEdge { a: i, b: i + 1, color: [1.0; 4], width: 1.0, opacity: 1.0, label: EMPTY_LABEL });
}
a.set_target(s.clone(), 0.0, 100.0, Easing::Linear, true);
let mut f = Frame::default();
a.tick_into(0.0, &mut f);
let mut s2 = s.clone();
for n in s2.nodes.iter_mut().flatten() { n.pos[0] += 50.0; }
a.set_target(s2, 0.0, 1e9, Easing::Linear, true);
a.tick_into(1.0, &mut f); let caps = (f.nodes.capacity(), f.edges.capacity(), f.halos.capacity());
for t in 2..150 { a.tick_into(t as f64, &mut f); }
a.set_target(s, 150.0, 1e9, Easing::Linear, true);
for t in 151..300 { a.tick_into(t as f64, &mut f); }
assert_eq!(caps, (f.nodes.capacity(), f.edges.capacity(), f.halos.capacity()));
}
#[test]
fn dense_gaps_are_skipped_not_emitted() {
let mut s = Scene::default();
s.set(5, node([1.0, 2.0], 5.0, 1.0)); let mut a = Animator::new();
a.set_target(s, 0.0, 0.0, Easing::Linear, false);
let mut f = Frame::default();
a.tick_into(0.0, &mut f);
assert_eq!(f.nodes.len(), 1);
assert_eq!(f.nodes[0].index, 5);
}
}