graph-explorer-style 0.2.0

Scene model, animation and declarative styling for graph-explorer.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
//! Scene model + animator: a target `Scene` (dense, index-keyed) is handed to
//! an `Animator`, which eases the displayed scene toward it each frame and
//! fills a caller-owned render-ready `Frame`. Pure — no GPU, no wasm — so it's
//! host-unit-testable, benchmarkable, and allocation-auditable.
//!
//! Everything here is keyed by `NodeIndex`/`LabelId` from `crate::interner`;
//! all element types are Copy and all per-frame containers are refilled in
//! place, so the steady state (graph at rest OR mid-transition) allocates
//! nothing once warm. `tests/zero_alloc.rs` enforces that with a counting
//! allocator — if you add an allocation to `set_target` (warm path),
//! `advance`, `interpolate_into`, `frame_into`, `tick_into`, or
//! `tick_positioned_into`, that test fails.

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,
    /// Interned label text, or `EMPTY_LABEL` for an unlabelled edge. A
    /// `LabelId` and not a `String`: `SceneEdge` must stay `Copy` with no heap
    /// field, or the zero-alloc guarantee on the per-frame path (see
    /// `tests/zero_alloc.rs`) breaks.
    pub label: LabelId,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SceneHalo {
    pub node: NodeIndex,
    pub emphasis: HaloEmphasis,
}

/// A fully-built target scene. `nodes` is dense over the interner's index
/// space; `None` means "not in this scene". Determinism comes from index
/// order (intern order), which is stable per session. (The old BTreeMap gave
/// lexicographic-id order; nothing may depend on that.)
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Scene {
    pub nodes: Vec<Option<SceneNode>>,
    pub edges: Vec<SceneEdge>,
    pub halos: Vec<SceneHalo>,
}

impl Scene {
    /// Place `node` at `ix`, growing the dense vec as the interner grows.
    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();
    }
}

// Render-ready outputs. `FrameNode.index` carries the node identity so the
// single wasm upload pass can fill hit targets and screen positions without
// a resolve; Strings appear nowhere in a Frame.
#[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 }

/// Caller-owned, refilled in place every tick. Compact (only present nodes).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Frame {
    pub nodes: Vec<FrameNode>,
    pub edges: Vec<FrameEdge>,
    pub halos: Vec<FrameHalo>,
}

/// Eases a displayed `Scene` toward a target `Scene`, filling `Frame`s.
pub struct Animator {
    displayed: Scene,
    /// Snapshot of `displayed` at transition start. Kept allocated across
    /// transitions (`clone_from`) so retargeting doesn't allocate once warm.
    from_buf: Scene,
    to: Scene,
    start_ms: f64,
    dur_ms: f32,
    easing: Easing,
    animating: bool,
    /// Scratch for `interpolate_into` (nodes only; edges/halos come from
    /// `to`). Persistent for the same reason as `from_buf`.
    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(),
        }
    }

    /// Point the animator at a new target. Tweens from the currently-displayed
    /// scene when `tween` is on, `dur_ms > 0`, and there's something to move
    /// from; otherwise snaps. (Same contract as before the index rewrite.)
    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);
            // from_buf carries nodes only; edges/halos always come from `to`.
            self.from_buf.edges.clear();
            self.from_buf.halos.clear();
            // Edges/halos switch to the new target's immediately (one-time
            // copy here, not per animating tick in `advance`: `to` is
            // immutable for the transition's duration).
            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;
        }
    }

    /// Advance to `now_ms` and fill `out`. Commits the target when the
    /// transition completes.
    pub fn tick_into(&mut self, now_ms: f64, out: &mut Frame) {
        self.advance(now_ms);
        frame_into(&self.displayed, out);
    }

    /// Advance, then override node positions from `pos` (dense, indexed by
    /// `NodeIndex`; `None` = animator keeps its interpolated position) before
    /// filling `out`.
    ///
    /// This is the boundary between the two things that want to move a node:
    /// the **simulation owns position**, the **animator owns appearance**.
    /// The override is written into `self.displayed` IN PLACE — not into a
    /// throwaway copy — so `displayed` stays truthful about what's on screen;
    /// the next `set_target` snapshots `displayed` as the transition's `from`,
    /// and a stale snapshot makes any node that later leaves the simulation
    /// teleport (the Slice A bug; its regression test lives below).
    ///
    /// Caller contract (unchanged from Slice A): `pos` must cover every node
    /// the simulation owns, on every call — an owned-but-omitted index falls
    /// back to plain position-tweening for that call, which reads as the node
    /// fighting the simulation.
    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);
    }

    /// Shared transition bookkeeping for both tick entry points.
    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);
            // keep visible node state current for a mid-flight retarget
            // (edges/halos were copied from `to` once, in `set_target`)
            self.displayed.nodes.clone_from(&self.scratch);
            if raw >= 1.0 {
                self.displayed.nodes.clone_from(&self.to.nodes);
                self.animating = false;
            }
        }
    }

    /// Positions of the current *target* scene's nodes (post-transition).
    pub fn target_positions(&self) -> impl Iterator<Item = [f32; 2]> + '_ {
        self.to.nodes.iter().flatten().map(|n| n.pos)
    }

    /// Target position of one node (post-transition), if present.
    pub fn target_position(&self, ix: NodeIndex) -> Option<[f32; 2]> {
        self.to.get(ix).map(|n| n.pos)
    }
}

/// Interpolate two node sets at eased `t` into `out` (cleared and refilled).
/// Nodes present in both lerp; entering (to-only) fade in; exiting
/// (from-only) fade out and are kept at reduced alpha until `t == 1`.
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,
        };
    }
}

/// Resolve a scene into `out` (cleared and refilled): edge endpoints and halo
/// positions/radii looked up by index; references to an absent node are
/// skipped. O(1) per lookup, no hashing, no Strings.
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); // empty displayed => snaps
        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); // commits displayed = from (empty before => snaps to this)
        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); // midpoint
        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); // b endpoint mid-glide
    }

    #[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); // snap
        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))]);
        // index 7 is absent from the scene: the edge and halo must be dropped,
        // not emitted with garbage endpoints.
        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); // missing index
    }

    #[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);
        // the emitted frame at t=0 still shows the OLD position...
        a.tick_into(0.0, &mut f);
        assert!((f.nodes[0].pos[0] - 0.0).abs() < 1e-4);
        // ...but target_positions already reports the destination.
        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); // no interpolation
    }

    #[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() {
        // This is the whole reason the override happens before frame assembly:
        // edges and halos resolve endpoints out of the node set, so they track
        // the simulation for free instead of needing their own path.
        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() {
        // Synthetic radial affordances (`__more`, `__agg:…`) are not in the
        // simulation, so they must keep tweening normally. Both an empty
        // slice and an explicit None slot mean "animator keeps its 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() {
        // The override must not bypass the tween bookkeeping, or a mid-flight
        // colour transition would freeze whenever the sim is running.
        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() {
        // The more common trigger for the clone-and-discard bug: a node
        // removed from the graph is also removed from the simulation's
        // position set. `interpolate_into` keeps a source-only ("exiting")
        // node at `from`'s position while it fades out. If the override were
        // written into a throwaway copy instead of into `self.displayed`,
        // `displayed` would still hold the pre-simulation, scene-authored
        // position when `set_target` snapshots it as the new `from` — so the
        // exiting node would teleport back to that stale position and only
        // then fade out.
        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); // snap

        let pos = vec![Some([500.0, 0.0]), Some([10.0, 10.0])];
        a.tick_positioned_into(0.0, &pos, &mut f); // displayed now holds simulated positions

        // node 1 drops out of the graph (and out of the simulation); 0 remains.
        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); // node 1 becomes exiting

        let pos2 = vec![Some([501.0, 0.0]), None]; // node 1 no longer simulated
        a.tick_positioned_into(0.0, &pos2, &mut f); // t=0 of the new transition

        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() {
        // Retargeting while a transition is in flight must start the new
        // transition from wherever the node is currently displayed (the
        // in-flight interpolated position), not from the previous target
        // (`to`) it was heading toward but had not yet reached.
        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); // snaps (displayed was empty)
        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); // halfway: displayed pos[0] == 50.0, still mid-flight

        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); // t=0 of the new transition
        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 });
        }
        // First target snaps (displayed is empty); warm up one frame.
        a.set_target(s.clone(), 0.0, 100.0, Easing::Linear, true);
        let mut f = Frame::default();
        a.tick_into(0.0, &mut f);
        // Second target actually animates; duration long enough that every
        // tick below stays mid-transition, so `advance`'s animating body
        // (interpolate_into + scratch + node clone) runs each frame.
        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); // capture capacities only after an animating tick
        let caps = (f.nodes.capacity(), f.edges.capacity(), f.halos.capacity());
        for t in 2..150 { a.tick_into(t as f64, &mut f); }
        // Mid-loop retarget exercises the warm `from_buf.clone_from` path.
        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)); // indices 0..5 are None
        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);
    }
}