Skip to main content

graph_explorer_style/
scene.rs

1//! Scene model + animator: a target `Scene` (dense, index-keyed) is handed to
2//! an `Animator`, which eases the displayed scene toward it each frame and
3//! fills a caller-owned render-ready `Frame`. Pure — no GPU, no wasm — so it's
4//! host-unit-testable, benchmarkable, and allocation-auditable.
5//!
6//! Everything here is keyed by `NodeIndex`/`LabelId` from `crate::interner`;
7//! all element types are Copy and all per-frame containers are refilled in
8//! place, so the steady state (graph at rest OR mid-transition) allocates
9//! nothing once warm. `tests/zero_alloc.rs` enforces that with a counting
10//! allocator — if you add an allocation to `set_target` (warm path),
11//! `advance`, `interpolate_into`, `frame_into`, `tick_into`, or
12//! `tick_positioned_into`, that test fails.
13
14use crate::anim::{Easing, HaloEmphasis, lerp, lerp2, lerp4};
15use crate::interner::{LabelId, NodeIndex};
16
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct SceneNode {
19    pub pos: [f32; 2],
20    pub radius: f32,
21    pub color: [f32; 4],
22    pub opacity: f32,
23    pub shape: u32,
24    pub label: LabelId,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct SceneEdge {
29    pub a: NodeIndex,
30    pub b: NodeIndex,
31    pub color: [f32; 4],
32    pub width: f32,
33    pub opacity: f32,
34    /// Interned label text, or `EMPTY_LABEL` for an unlabelled edge. A
35    /// `LabelId` and not a `String`: `SceneEdge` must stay `Copy` with no heap
36    /// field, or the zero-alloc guarantee on the per-frame path (see
37    /// `tests/zero_alloc.rs`) breaks.
38    pub label: LabelId,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct SceneHalo {
43    pub node: NodeIndex,
44    pub emphasis: HaloEmphasis,
45}
46
47/// A fully-built target scene. `nodes` is dense over the interner's index
48/// space; `None` means "not in this scene". Determinism comes from index
49/// order (intern order), which is stable per session. (The old BTreeMap gave
50/// lexicographic-id order; nothing may depend on that.)
51#[derive(Debug, Clone, Default, PartialEq)]
52pub struct Scene {
53    pub nodes: Vec<Option<SceneNode>>,
54    pub edges: Vec<SceneEdge>,
55    pub halos: Vec<SceneHalo>,
56}
57
58impl Scene {
59    /// Place `node` at `ix`, growing the dense vec as the interner grows.
60    pub fn set(&mut self, ix: NodeIndex, node: SceneNode) {
61        let i = ix as usize;
62        if i >= self.nodes.len() { self.nodes.resize(i + 1, None); }
63        self.nodes[i] = Some(node);
64    }
65    pub fn get(&self, ix: NodeIndex) -> Option<&SceneNode> {
66        self.nodes.get(ix as usize).and_then(|s| s.as_ref())
67    }
68    pub fn clear(&mut self) {
69        self.nodes.clear();
70        self.edges.clear();
71        self.halos.clear();
72    }
73}
74
75// Render-ready outputs. `FrameNode.index` carries the node identity so the
76// single wasm upload pass can fill hit targets and screen positions without
77// a resolve; Strings appear nowhere in a Frame.
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub struct FrameNode {
80    pub index: NodeIndex,
81    pub pos: [f32; 2],
82    pub radius: f32,
83    pub color: [f32; 4],
84    pub opacity: f32,
85    pub shape: u32,
86    pub label: LabelId,
87}
88#[derive(Debug, Clone, Copy, PartialEq)]
89pub struct FrameEdge { pub a: [f32; 2], pub b: [f32; 2], pub color: [f32; 4], pub width: f32, pub opacity: f32, pub label: LabelId }
90#[derive(Debug, Clone, Copy, PartialEq)]
91pub struct FrameHalo { pub node: NodeIndex, pub pos: [f32; 2], pub radius: f32, pub emphasis: HaloEmphasis }
92
93/// Caller-owned, refilled in place every tick. Compact (only present nodes).
94#[derive(Debug, Clone, Default, PartialEq)]
95pub struct Frame {
96    pub nodes: Vec<FrameNode>,
97    pub edges: Vec<FrameEdge>,
98    pub halos: Vec<FrameHalo>,
99}
100
101/// Eases a displayed `Scene` toward a target `Scene`, filling `Frame`s.
102pub struct Animator {
103    displayed: Scene,
104    /// Snapshot of `displayed` at transition start. Kept allocated across
105    /// transitions (`clone_from`) so retargeting doesn't allocate once warm.
106    from_buf: Scene,
107    to: Scene,
108    start_ms: f64,
109    dur_ms: f32,
110    easing: Easing,
111    animating: bool,
112    /// Scratch for `interpolate_into` (nodes only; edges/halos come from
113    /// `to`). Persistent for the same reason as `from_buf`.
114    scratch: Vec<Option<SceneNode>>,
115}
116
117impl Default for Animator { fn default() -> Self { Self::new() } }
118
119impl Animator {
120    pub fn new() -> Self {
121        Self {
122            displayed: Scene::default(),
123            from_buf: Scene::default(),
124            to: Scene::default(),
125            start_ms: 0.0,
126            dur_ms: 0.0,
127            easing: Easing::Linear,
128            animating: false,
129            scratch: Vec::new(),
130        }
131    }
132
133    /// Point the animator at a new target. Tweens from the currently-displayed
134    /// scene when `tween` is on, `dur_ms > 0`, and there's something to move
135    /// from; otherwise snaps. (Same contract as before the index rewrite.)
136    pub fn set_target(&mut self, target: Scene, now_ms: f64, dur_ms: f32, easing: Easing, tween: bool) {
137        let has_displayed = self.displayed.nodes.iter().any(|n| n.is_some());
138        if tween && dur_ms > 0.0 && has_displayed {
139            self.from_buf.nodes.clone_from(&self.displayed.nodes);
140            // from_buf carries nodes only; edges/halos always come from `to`.
141            self.from_buf.edges.clear();
142            self.from_buf.halos.clear();
143            // Edges/halos switch to the new target's immediately (one-time
144            // copy here, not per animating tick in `advance`: `to` is
145            // immutable for the transition's duration).
146            self.displayed.edges.clone_from(&target.edges);
147            self.displayed.halos.clone_from(&target.halos);
148            self.to = target;
149            self.start_ms = now_ms;
150            self.dur_ms = dur_ms;
151            self.easing = easing;
152            self.animating = true;
153        } else {
154            self.displayed.nodes.clone_from(&target.nodes);
155            self.displayed.edges.clone_from(&target.edges);
156            self.displayed.halos.clone_from(&target.halos);
157            self.to = target;
158            self.animating = false;
159        }
160    }
161
162    /// Advance to `now_ms` and fill `out`. Commits the target when the
163    /// transition completes.
164    pub fn tick_into(&mut self, now_ms: f64, out: &mut Frame) {
165        self.advance(now_ms);
166        frame_into(&self.displayed, out);
167    }
168
169    /// Advance, then override node positions from `pos` (dense, indexed by
170    /// `NodeIndex`; `None` = animator keeps its interpolated position) before
171    /// filling `out`.
172    ///
173    /// This is the boundary between the two things that want to move a node:
174    /// the **simulation owns position**, the **animator owns appearance**.
175    /// The override is written into `self.displayed` IN PLACE — not into a
176    /// throwaway copy — so `displayed` stays truthful about what's on screen;
177    /// the next `set_target` snapshots `displayed` as the transition's `from`,
178    /// and a stale snapshot makes any node that later leaves the simulation
179    /// teleport (the Slice A bug; its regression test lives below).
180    ///
181    /// Caller contract (unchanged from Slice A): `pos` must cover every node
182    /// the simulation owns, on every call — an owned-but-omitted index falls
183    /// back to plain position-tweening for that call, which reads as the node
184    /// fighting the simulation.
185    pub fn tick_positioned_into(&mut self, now_ms: f64, pos: &[Option<[f32; 2]>], out: &mut Frame) {
186        self.advance(now_ms);
187        for (i, slot) in self.displayed.nodes.iter_mut().enumerate() {
188            if let (Some(n), Some(Some(p))) = (slot.as_mut(), pos.get(i)) {
189                n.pos = *p;
190            }
191        }
192        frame_into(&self.displayed, out);
193    }
194
195    /// Shared transition bookkeeping for both tick entry points.
196    fn advance(&mut self, now_ms: f64) {
197        if self.animating {
198            let raw = ((now_ms - self.start_ms) as f32 / self.dur_ms).clamp(0.0, 1.0);
199            let t = self.easing.apply(raw);
200            interpolate_into(&self.from_buf, &self.to, t, &mut self.scratch);
201            // keep visible node state current for a mid-flight retarget
202            // (edges/halos were copied from `to` once, in `set_target`)
203            self.displayed.nodes.clone_from(&self.scratch);
204            if raw >= 1.0 {
205                self.displayed.nodes.clone_from(&self.to.nodes);
206                self.animating = false;
207            }
208        }
209    }
210
211    /// Positions of the current *target* scene's nodes (post-transition).
212    pub fn target_positions(&self) -> impl Iterator<Item = [f32; 2]> + '_ {
213        self.to.nodes.iter().flatten().map(|n| n.pos)
214    }
215
216    /// Target position of one node (post-transition), if present.
217    pub fn target_position(&self, ix: NodeIndex) -> Option<[f32; 2]> {
218        self.to.get(ix).map(|n| n.pos)
219    }
220}
221
222/// Interpolate two node sets at eased `t` into `out` (cleared and refilled).
223/// Nodes present in both lerp; entering (to-only) fade in; exiting
224/// (from-only) fade out and are kept at reduced alpha until `t == 1`.
225fn interpolate_into(from: &Scene, to: &Scene, t: f32, out: &mut Vec<Option<SceneNode>>) {
226    let len = from.nodes.len().max(to.nodes.len());
227    out.clear();
228    out.resize(len, None);
229    for (i, slot) in out.iter_mut().enumerate() {
230        let f = from.nodes.get(i).and_then(|s| s.as_ref());
231        let g = to.nodes.get(i).and_then(|s| s.as_ref());
232        *slot = match (f, g) {
233            (Some(fnode), Some(tn)) => Some(SceneNode {
234                pos: lerp2(fnode.pos, tn.pos, t),
235                radius: lerp(fnode.radius, tn.radius, t),
236                color: lerp4(fnode.color, tn.color, t),
237                opacity: lerp(fnode.opacity, tn.opacity, t),
238                shape: tn.shape,
239                label: tn.label,
240            }),
241            (None, Some(tn)) => Some(SceneNode { opacity: tn.opacity * t, ..*tn }),
242            (Some(fnode), None) if t < 1.0 => {
243                Some(SceneNode { opacity: fnode.opacity * (1.0 - t), ..*fnode })
244            }
245            _ => None,
246        };
247    }
248}
249
250/// Resolve a scene into `out` (cleared and refilled): edge endpoints and halo
251/// positions/radii looked up by index; references to an absent node are
252/// skipped. O(1) per lookup, no hashing, no Strings.
253fn frame_into(s: &Scene, out: &mut Frame) {
254    out.nodes.clear();
255    out.edges.clear();
256    out.halos.clear();
257    for (i, n) in s.nodes.iter().enumerate() {
258        if let Some(n) = n {
259            out.nodes.push(FrameNode {
260                index: i as NodeIndex,
261                pos: n.pos, radius: n.radius, color: n.color,
262                opacity: n.opacity, shape: n.shape, label: n.label,
263            });
264        }
265    }
266    for e in &s.edges {
267        let (Some(a), Some(b)) = (s.get(e.a), s.get(e.b)) else { continue };
268        out.edges.push(FrameEdge { a: a.pos, b: b.pos, color: e.color, width: e.width, opacity: e.opacity, label: e.label });
269    }
270    for h in &s.halos {
271        let Some(n) = s.get(h.node) else { continue };
272        out.halos.push(FrameHalo { node: h.node, pos: n.pos, radius: n.radius, emphasis: h.emphasis });
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::interner::EMPTY_LABEL;
280
281    fn node(pos: [f32; 2], radius: f32, opacity: f32) -> SceneNode {
282        SceneNode { pos, radius, color: [1.0, 0.0, 0.0, 1.0], opacity, shape: 0, label: 0 }
283    }
284
285    fn scene_with(nodes: &[(NodeIndex, SceneNode)]) -> Scene {
286        let mut s = Scene::default();
287        for (ix, n) in nodes { s.set(*ix, *n); }
288        s
289    }
290
291    #[test]
292    fn no_transition_emits_target_immediately() {
293        let mut a = Animator::new();
294        let s = scene_with(&[(0, node([1.0, 2.0], 10.0, 1.0))]);
295        a.set_target(s, 0.0, 200.0, Easing::Linear, true); // empty displayed => snaps
296        let mut f = Frame::default();
297        a.tick_into(0.0, &mut f);
298        assert_eq!(f.nodes.len(), 1);
299        assert_eq!(f.nodes[0].pos, [1.0, 2.0]);
300    }
301
302    #[test]
303    fn moved_node_interpolates_position() {
304        let mut b = Animator::new();
305        let mut f = Frame::default();
306        b.set_target(scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
307        b.tick_into(0.0, &mut f); // commits displayed = from (empty before => snaps to this)
308        b.set_target(scene_with(&[(0, node([10.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
309        b.tick_into(0.0, &mut f);
310        assert!((f.nodes[0].pos[0] - 0.0).abs() < 1e-4);
311        b.tick_into(100.0, &mut f);
312        assert!((f.nodes[0].pos[0] - 5.0).abs() < 1e-4);
313        b.tick_into(200.0, &mut f);
314        assert!((f.nodes[0].pos[0] - 10.0).abs() < 1e-4);
315    }
316
317    #[test]
318    fn entering_node_fades_in_exiting_fades_out() {
319        let mut a = Animator::new();
320        let mut f = Frame::default();
321        a.set_target(scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
322        a.tick_into(0.0, &mut f);
323        a.set_target(scene_with(&[(1, node([5.0, 5.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
324        a.tick_into(100.0, &mut f); // midpoint
325        let get = |f: &Frame, ix: NodeIndex| f.nodes.iter().find(|n| n.index == ix).map(|n| n.opacity);
326        assert!((get(&f, 0).unwrap() - 0.5).abs() < 1e-4, "exiting alpha");
327        assert!((get(&f, 1).unwrap() - 0.5).abs() < 1e-4, "entering alpha");
328        a.tick_into(200.0, &mut f);
329        assert_eq!(f.nodes.len(), 1);
330        assert_eq!(f.nodes[0].index, 1);
331    }
332
333    #[test]
334    fn edges_resolve_endpoints_from_interpolated_positions() {
335        let mut a = Animator::new();
336        let mut f = Frame::default();
337        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))]);
338        s0.edges.push(SceneEdge { a: 0, b: 1, color: [0.0; 4], width: 1.0, opacity: 1.0, label: EMPTY_LABEL });
339        a.set_target(s0, 0.0, 200.0, Easing::Linear, true);
340        a.tick_into(0.0, &mut f);
341        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))]);
342        s1.edges.push(SceneEdge { a: 0, b: 1, color: [0.0; 4], width: 1.0, opacity: 1.0, label: EMPTY_LABEL });
343        a.set_target(s1, 0.0, 200.0, Easing::Linear, true);
344        a.tick_into(100.0, &mut f);
345        assert_eq!(f.edges.len(), 1);
346        assert!((f.edges[0].b[0] - 5.0).abs() < 1e-4); // b endpoint mid-glide
347    }
348
349    #[test]
350    fn halos_resolve_position_and_radius_from_their_node() {
351        let mut a = Animator::new();
352        let mut f = Frame::default();
353        let mut s = scene_with(&[(0, node([3.0, 4.0], 12.0, 1.0))]);
354        s.halos.push(SceneHalo { node: 0, emphasis: HaloEmphasis::Primary });
355        a.set_target(s, 0.0, 0.0, Easing::Linear, false); // snap
356        a.tick_into(0.0, &mut f);
357        assert_eq!(f.halos.len(), 1);
358        assert_eq!(f.halos[0].pos, [3.0, 4.0]);
359        assert_eq!(f.halos[0].radius, 12.0);
360        assert_eq!(f.halos[0].node, 0);
361    }
362
363    #[test]
364    fn edges_and_halos_referencing_absent_nodes_are_skipped() {
365        let mut a = Animator::new();
366        let mut f = Frame::default();
367        let mut s = scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]);
368        // index 7 is absent from the scene: the edge and halo must be dropped,
369        // not emitted with garbage endpoints.
370        s.edges.push(SceneEdge { a: 0, b: 7, color: [0.0; 4], width: 1.0, opacity: 1.0, label: EMPTY_LABEL });
371        s.halos.push(SceneHalo { node: 7, emphasis: HaloEmphasis::Primary });
372        a.set_target(s, 0.0, 0.0, Easing::Linear, false);
373        a.tick_into(0.0, &mut f);
374        assert_eq!(f.nodes.len(), 1);
375        assert!(f.edges.is_empty());
376        assert!(f.halos.is_empty());
377    }
378
379    #[test]
380    fn target_position_looks_up_one_node_by_index() {
381        let mut a = Animator::new();
382        a.set_target(scene_with(&[(0, node([3.0, 4.0], 10.0, 1.0))]), 0.0, 0.0, Easing::Linear, false);
383        assert_eq!(a.target_position(0), Some([3.0, 4.0]));
384        assert_eq!(a.target_position(9), None); // missing index
385    }
386
387    #[test]
388    fn target_positions_reflect_destination_even_mid_transition() {
389        let mut a = Animator::new();
390        let mut f = Frame::default();
391        a.set_target(scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
392        a.tick_into(0.0, &mut f);
393        a.set_target(scene_with(&[(0, node([100.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
394        // the emitted frame at t=0 still shows the OLD position...
395        a.tick_into(0.0, &mut f);
396        assert!((f.nodes[0].pos[0] - 0.0).abs() < 1e-4);
397        // ...but target_positions already reports the destination.
398        let tgt: Vec<[f32; 2]> = a.target_positions().collect();
399        assert_eq!(tgt, vec![[100.0, 0.0]]);
400    }
401
402    #[test]
403    fn tween_disabled_snaps_positions() {
404        let mut a = Animator::new();
405        let mut f = Frame::default();
406        a.set_target(scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, false);
407        a.tick_into(0.0, &mut f);
408        a.set_target(scene_with(&[(0, node([10.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, false);
409        a.tick_into(0.0, &mut f);
410        assert_eq!(f.nodes[0].pos[0], 10.0); // no interpolation
411    }
412
413    #[test]
414    fn tick_positioned_overrides_node_positions() {
415        let mut a = Animator::new();
416        let mut f = Frame::default();
417        let s = scene_with(&[(0, node([0.0, 0.0], 5.0, 1.0))]);
418        a.set_target(s, 0.0, 0.0, Easing::Linear, false);
419
420        let pos = vec![Some([77.0, -3.0])];
421        a.tick_positioned_into(0.0, &pos, &mut f);
422        assert_eq!(f.nodes[0].pos, [77.0, -3.0], "the simulation owns position");
423        assert_eq!(f.nodes[0].radius, 5.0, "the animator still owns appearance");
424    }
425
426    #[test]
427    fn edges_and_halos_follow_the_overridden_positions() {
428        // This is the whole reason the override happens before frame assembly:
429        // edges and halos resolve endpoints out of the node set, so they track
430        // the simulation for free instead of needing their own path.
431        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))]);
432        sc.edges.push(SceneEdge { a: 0, b: 1, color: [1.0; 4], width: 1.0, opacity: 1.0, label: EMPTY_LABEL });
433        sc.halos.push(SceneHalo { node: 0, emphasis: HaloEmphasis::Primary });
434
435        let mut an = Animator::new();
436        let mut f = Frame::default();
437        an.set_target(sc, 0.0, 0.0, Easing::Linear, false);
438
439        let pos = vec![Some([100.0, 0.0]), Some([200.0, 50.0])];
440        an.tick_positioned_into(0.0, &pos, &mut f);
441
442        assert_eq!(f.edges[0].a, [100.0, 0.0]);
443        assert_eq!(f.edges[0].b, [200.0, 50.0]);
444        assert_eq!(f.halos[0].pos, [100.0, 0.0]);
445    }
446
447    #[test]
448    fn a_node_absent_from_the_overrides_keeps_its_interpolated_position() {
449        // Synthetic radial affordances (`__more`, `__agg:…`) are not in the
450        // simulation, so they must keep tweening normally. Both an empty
451        // slice and an explicit None slot mean "animator keeps its position".
452        let mut a = Animator::new();
453        let mut f = Frame::default();
454        a.set_target(scene_with(&[(0, node([9.0, 9.0], 5.0, 1.0))]), 0.0, 0.0, Easing::Linear, false);
455        let empty: Vec<Option<[f32; 2]>> = Vec::new();
456        a.tick_positioned_into(0.0, &empty, &mut f);
457        assert_eq!(f.nodes[0].pos, [9.0, 9.0]);
458        a.tick_positioned_into(0.0, &[None], &mut f);
459        assert_eq!(f.nodes[0].pos, [9.0, 9.0]);
460    }
461
462    #[test]
463    fn tick_positioned_still_advances_the_transition() {
464        // The override must not bypass the tween bookkeeping, or a mid-flight
465        // colour transition would freeze whenever the sim is running.
466        let mut a = Animator::new();
467        let mut f = Frame::default();
468        a.set_target(scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]), 0.0, 0.0, Easing::Linear, false);
469        a.set_target(scene_with(&[(0, node([0.0, 0.0], 20.0, 1.0))]), 0.0, 100.0, Easing::Linear, true);
470
471        let pos: Vec<Option<[f32; 2]>> = Vec::new();
472        a.tick_positioned_into(50.0, &pos, &mut f);
473        assert!((f.nodes[0].radius - 15.0).abs() < 0.01, "radius should be halfway");
474        a.tick_positioned_into(100.0, &pos, &mut f);
475        assert_eq!(f.nodes[0].radius, 20.0);
476    }
477
478    #[test]
479    fn exiting_node_keeps_its_simulated_position_while_it_fades() {
480        // The more common trigger for the clone-and-discard bug: a node
481        // removed from the graph is also removed from the simulation's
482        // position set. `interpolate_into` keeps a source-only ("exiting")
483        // node at `from`'s position while it fades out. If the override were
484        // written into a throwaway copy instead of into `self.displayed`,
485        // `displayed` would still hold the pre-simulation, scene-authored
486        // position when `set_target` snapshots it as the new `from` — so the
487        // exiting node would teleport back to that stale position and only
488        // then fade out.
489        let mut a = Animator::new();
490        let mut f = Frame::default();
491        let s0 = scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0)), (1, node([0.0, 0.0], 10.0, 1.0))]);
492        a.set_target(s0, 0.0, 0.0, Easing::Linear, false); // snap
493
494        let pos = vec![Some([500.0, 0.0]), Some([10.0, 10.0])];
495        a.tick_positioned_into(0.0, &pos, &mut f); // displayed now holds simulated positions
496
497        // node 1 drops out of the graph (and out of the simulation); 0 remains.
498        let s1 = scene_with(&[(0, node([1.0, 1.0], 10.0, 1.0))]);
499        a.set_target(s1, 0.0, 200.0, Easing::Linear, true); // node 1 becomes exiting
500
501        let pos2 = vec![Some([501.0, 0.0]), None]; // node 1 no longer simulated
502        a.tick_positioned_into(0.0, &pos2, &mut f); // t=0 of the new transition
503
504        let b = f.nodes.iter().find(|n| n.index == 1).expect("node 1 still fading");
505        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");
506    }
507
508    #[test]
509    fn mid_flight_retarget_starts_from_the_displayed_position_not_the_old_target() {
510        // Retargeting while a transition is in flight must start the new
511        // transition from wherever the node is currently displayed (the
512        // in-flight interpolated position), not from the previous target
513        // (`to`) it was heading toward but had not yet reached.
514        let mut a = Animator::new();
515        let mut f = Frame::default();
516        a.set_target(scene_with(&[(0, node([0.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true); // snaps (displayed was empty)
517        a.set_target(scene_with(&[(0, node([100.0, 0.0], 10.0, 1.0))]), 0.0, 200.0, Easing::Linear, true);
518        a.tick_into(100.0, &mut f); // halfway: displayed pos[0] == 50.0, still mid-flight
519
520        a.set_target(scene_with(&[(0, node([300.0, 0.0], 10.0, 1.0))]), 100.0, 200.0, Easing::Linear, true);
521        a.tick_into(100.0, &mut f); // t=0 of the new transition
522        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)");
523    }
524
525    #[test]
526    fn frame_buffers_do_not_reallocate_when_warm() {
527        let mut a = Animator::new();
528        let mut s = Scene::default();
529        for i in 0..100u32 { s.set(i, node([i as f32, 0.0], 5.0, 1.0)); }
530        for i in 0..99u32 {
531            s.edges.push(SceneEdge { a: i, b: i + 1, color: [1.0; 4], width: 1.0, opacity: 1.0, label: EMPTY_LABEL });
532        }
533        // First target snaps (displayed is empty); warm up one frame.
534        a.set_target(s.clone(), 0.0, 100.0, Easing::Linear, true);
535        let mut f = Frame::default();
536        a.tick_into(0.0, &mut f);
537        // Second target actually animates; duration long enough that every
538        // tick below stays mid-transition, so `advance`'s animating body
539        // (interpolate_into + scratch + node clone) runs each frame.
540        let mut s2 = s.clone();
541        for n in s2.nodes.iter_mut().flatten() { n.pos[0] += 50.0; }
542        a.set_target(s2, 0.0, 1e9, Easing::Linear, true);
543        a.tick_into(1.0, &mut f); // capture capacities only after an animating tick
544        let caps = (f.nodes.capacity(), f.edges.capacity(), f.halos.capacity());
545        for t in 2..150 { a.tick_into(t as f64, &mut f); }
546        // Mid-loop retarget exercises the warm `from_buf.clone_from` path.
547        a.set_target(s, 150.0, 1e9, Easing::Linear, true);
548        for t in 151..300 { a.tick_into(t as f64, &mut f); }
549        assert_eq!(caps, (f.nodes.capacity(), f.edges.capacity(), f.halos.capacity()));
550    }
551
552    #[test]
553    fn dense_gaps_are_skipped_not_emitted() {
554        let mut s = Scene::default();
555        s.set(5, node([1.0, 2.0], 5.0, 1.0)); // indices 0..5 are None
556        let mut a = Animator::new();
557        a.set_target(s, 0.0, 0.0, Easing::Linear, false);
558        let mut f = Frame::default();
559        a.tick_into(0.0, &mut f);
560        assert_eq!(f.nodes.len(), 1);
561        assert_eq!(f.nodes[0].index, 5);
562    }
563}