1use 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 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#[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 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#[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#[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
101pub struct Animator {
103 displayed: Scene,
104 from_buf: Scene,
107 to: Scene,
108 start_ms: f64,
109 dur_ms: f32,
110 easing: Easing,
111 animating: bool,
112 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 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 self.from_buf.edges.clear();
142 self.from_buf.halos.clear();
143 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 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 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 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 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 pub fn target_positions(&self) -> impl Iterator<Item = [f32; 2]> + '_ {
213 self.to.nodes.iter().flatten().map(|n| n.pos)
214 }
215
216 pub fn target_position(&self, ix: NodeIndex) -> Option<[f32; 2]> {
218 self.to.get(ix).map(|n| n.pos)
219 }
220}
221
222fn 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
250fn 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); 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); 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); 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); }
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); 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 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); }
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 a.tick_into(0.0, &mut f);
396 assert!((f.nodes[0].pos[0] - 0.0).abs() < 1e-4);
397 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); }
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 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 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 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 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); let pos = vec![Some([500.0, 0.0]), Some([10.0, 10.0])];
495 a.tick_positioned_into(0.0, &pos, &mut f); 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); 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");
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 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); 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); 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); 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 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 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); 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 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)); 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}