facett-graph3d/src/travels.rs
//! **TRAVELS — the guided constellation tour** (the owner's "list of interesting
//! travels"). A **glowing, ghostlike spline** drawn in 3-D space that takes a camera +
//! highlight on a TOUR through an existing graph: a [`TravelRoute`] names an ordered
//! set of node ids ("latest changes", "reported bugs — start at the top, travel down
//! into the bug", "spotlight this one node"), and a [`Travel`] flies a **glowing ghost
//! marker + the camera** along a smooth Catmull–Rom spline through those node centres,
//! easing between stops and pausing/pulsing at each visited node.
//!
//! ## A reusable PRIMITIVE (host-drives-data contract)
//! This crate renders the tour; it does **not** fetch data. The HOST (dwarves'
//! navigator page, korp) supplies the ordered node sets from nornir's warehouse
//! (recency / bug anchors / a single node) via the [`TravelRoute`] constructors
//! ([`TravelRoute::latest_changes`], [`TravelRoute::bugs_top_down`],
//! [`TravelRoute::spotlight`]); [`Travel`] turns the route + the graph's laid-out node
//! centres into the animated ghost. It is a plain animator (no egui, no I/O) plus a
//! pure [`Travel::paint`] overlay, so it drops onto a [`Graph3D`](crate::Graph3D)
//! (default OFF — no travel until a route is set, so existing renders/goldens are
//! byte-stable) and is unit-testable headless.
//!
//! ## Determinism (the crate-wide LAW)
//! The tour timeline is an explicit `elapsed` in seconds advanced ONLY through
//! [`Travel::advance`] / [`Travel::seek`] (the [`Graph3D`](crate::Graph3D) `Tick` /
//! `SetClock` seam) — never `Instant::now()`. So a fixed clock ⇒ a byte-stable frame,
//! and the geometry/animation goldens are reproducible.
use egui::{Color32, Painter, Pos2, Stroke};
use serde::{Deserialize, Serialize};
use crate::neon;
use facett_core::render::decor;
/// A node's stable id in the toured [`Graph3D`](crate::Graph3D) — its node **index**
/// (invariant across camera spin and relayout, exactly like the selection key).
pub type NodeId = usize;
/// Which built-in KIND of tour a [`TravelRoute`] is — a small enum so the host / a
/// robot can assert "this is the recency tour" vs "the bug tour" from `state_json`.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RouteKind {
/// A single-node spotlight ([`TravelRoute::spotlight`]).
Spotlight,
/// The most-recently-changed units, newest → oldest ([`TravelRoute::latest_changes`]).
LatestChanges,
/// Reported bugs, top of the graph down into the bug ([`TravelRoute::bugs_top_down`]).
BugsTopDown,
/// A host-authored custom route ([`TravelRoute::new`]).
Custom,
}
impl RouteKind {
pub fn label(self) -> &'static str {
match self {
RouteKind::Spotlight => "spotlight",
RouteKind::LatestChanges => "latest_changes",
RouteKind::BugsTopDown => "bugs_top_down",
RouteKind::Custom => "custom",
}
}
}
/// **A tour route** — a `name`, its [`RouteKind`], and the ordered `stops` (node ids)
/// the camera visits in order. Pure data the host builds from warehouse metadata; the
/// built-in constructors ([`latest_changes`](Self::latest_changes) /
/// [`bugs_top_down`](Self::bugs_top_down) / [`spotlight`](Self::spotlight)) are the
/// "list of interesting travels".
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TravelRoute {
pub name: String,
pub kind: RouteKind,
pub stops: Vec<NodeId>,
}
impl TravelRoute {
/// A host-authored custom route over an explicit ordered stop list.
pub fn new(name: impl Into<String>, stops: Vec<NodeId>) -> Self {
Self { name: name.into(), kind: RouteKind::Custom, stops }
}
/// **Spotlight** — a single-node tour: the camera flies to `node` and pulses there.
/// (the owner's single-node spotlight.)
pub fn spotlight(node: NodeId) -> Self {
Self { name: "spotlight".into(), kind: RouteKind::Spotlight, stops: vec![node] }
}
/// **Latest changes** — visit the recently-changed units in the order the host
/// supplies them (newest first). The HOST passes the node set (from nornir's
/// warehouse recency); facett just renders the tour.
pub fn latest_changes(nodes_by_recency: &[NodeId]) -> Self {
Self { name: "latest changes".into(), kind: RouteKind::LatestChanges, stops: nodes_by_recency.to_vec() }
}
/// **Bugs, top-down** — "start at the top, travel down into the bug": visit the
/// reported-bug anchors in the host-supplied top→down order. The HOST passes the
/// bug anchors (from nornir's warehouse); facett renders the descent.
pub fn bugs_top_down(bug_anchors: &[NodeId]) -> Self {
Self { name: "bugs top-down".into(), kind: RouteKind::BugsTopDown, stops: bug_anchors.to_vec() }
}
/// Whether this route has any stops to tour.
pub fn is_empty(&self) -> bool {
self.stops.is_empty()
}
}
/// **A uniform Catmull–Rom spline** through control points — the smooth path the ghost
/// follows. Catmull–Rom **passes through every control point**, so the spline visits
/// each stop's node centre exactly (`point(seg, 0.0) == pts[seg]`,
/// `point(seg, 1.0) == pts[seg+1]`) — the geometry proof the tests assert.
#[derive(Clone, Debug)]
pub struct Spline {
pts: Vec<[f32; 3]>,
}
impl Spline {
pub fn new(pts: Vec<[f32; 3]>) -> Self {
Self { pts }
}
/// The number of segments (`pts.len() - 1`, saturating at 0).
pub fn segments(&self) -> usize {
self.pts.len().saturating_sub(1)
}
/// Evaluate segment `seg` (from `pts[seg]` to `pts[seg+1]`) at local `u ∈ [0,1]`.
/// Endpoints are clamped (the ends repeat their neighbour), so the curve stays put
/// at the first/last control point.
pub fn point(&self, seg: usize, u: f32) -> [f32; 3] {
let n = self.pts.len();
if n == 0 {
return [0.0; 3];
}
if n == 1 {
return self.pts[0];
}
let seg = seg.min(n - 2);
let i1 = seg;
let i2 = seg + 1;
let i0 = i1.saturating_sub(1);
let i3 = (i2 + 1).min(n - 1);
let (p0, p1, p2, p3) = (self.pts[i0], self.pts[i1], self.pts[i2], self.pts[i3]);
let u = u.clamp(0.0, 1.0);
let u2 = u * u;
let u3 = u2 * u;
let mut out = [0.0f32; 3];
for c in 0..3 {
// Uniform Catmull–Rom basis (passes through p1 at u=0, p2 at u=1).
out[c] = 0.5
* ((2.0 * p1[c])
+ (-p0[c] + p2[c]) * u
+ (2.0 * p0[c] - 5.0 * p1[c] + 4.0 * p2[c] - p3[c]) * u2
+ (-p0[c] + 3.0 * p1[c] - 3.0 * p2[c] + p3[c]) * u3);
}
out
}
/// Sample the whole spline at `t ∈ [0,1]` (across all segments).
pub fn sample(&self, t: f32) -> [f32; 3] {
let segs = self.segments();
if segs == 0 {
return self.pts.first().copied().unwrap_or([0.0; 3]);
}
let t = t.clamp(0.0, 1.0) * segs as f32;
let seg = (t.floor() as usize).min(segs - 1);
self.point(seg, t - seg as f32)
}
}
/// Smooth ease-in-out (Hermite `smoothstep`) so the ghost accelerates out of a stop
/// and decelerates into the next — a rest-to-rest glide, not a constant crawl.
#[inline]
fn ease(u: f32) -> f32 {
let u = u.clamp(0.0, 1.0);
u * u * (3.0 - 2.0 * u)
}
/// The ghost's pale, luminous body colour (a cyan-white spectre).
const GHOST: Color32 = Color32::from_rgb(185, 240, 255);
/// Where the ghost is on the timeline right now — the source `stop`, whether it is
/// **dwelling** at that stop (else travelling toward `stop + 1`), and its 3-D `pos`.
#[derive(Clone, Copy, Debug)]
struct Located {
stop: usize,
in_dwell: bool,
pos: [f32; 3],
}
/// **The tour animator** — flies a glowing ghost marker + the camera along a
/// [`TravelRoute`]'s spline. Holds the route, the stop node centres (the spline
/// control points, supplied by the host from the graph layout), and the deterministic
/// `elapsed` timeline. Advanced through [`advance`](Self::advance) / [`seek`](Self::seek)
/// (the `Tick`/`SetClock` seam), controlled through play/pause/next/prev/speed.
#[derive(Clone, Debug)]
pub struct Travel {
route: TravelRoute,
/// One 3-D centre per stop (the spline control points).
centers: Vec<[f32; 3]>,
spline: Spline,
/// Seconds along the tour timeline (deterministic; advanced only via the seam).
elapsed: f64,
playing: bool,
/// Timeline speed multiplier (`advance(dt)` adds `dt * speed`).
speed: f32,
/// Travel time per segment (tour seconds).
seg_secs: f32,
/// Dwell (pause + pulse) at each stop (tour seconds).
dwell_secs: f32,
/// Whether the camera flies to face the ghost (the "camera fly" seam).
camera_follow: bool,
/// Highest stop index whose arrival event has fired (for edge-triggered events).
last_fired: i64,
}
impl Travel {
/// Build a tour for `route` over `graph_centers` — the toured graph's laid-out node
/// positions (`graph.nodes[i].pos`). The host slices the stops' centres out for the
/// spline; the tour starts **paused at the first stop** (the arrival of stop 0 is
/// pending until the first advance, so a robot sees the visit sequence).
pub fn new(route: TravelRoute, graph_centers: &[[f32; 3]]) -> Self {
let centers: Vec<[f32; 3]> = route.stops.iter().map(|&i| graph_centers.get(i).copied().unwrap_or([0.0; 3])).collect();
let spline = Spline::new(centers.clone());
Self {
route,
centers,
spline,
elapsed: 0.0,
playing: true,
speed: 1.0,
seg_secs: 1.4,
dwell_secs: 0.6,
camera_follow: true,
last_fired: -1,
}
}
/// Rebuild the spline control points from a fresh graph layout (after a relayout /
/// absorb repositions nodes) without disturbing the timeline.
pub fn refresh_centers(&mut self, graph_centers: &[[f32; 3]]) {
self.centers = self.route.stops.iter().map(|&i| graph_centers.get(i).copied().unwrap_or([0.0; 3])).collect();
self.spline = Spline::new(self.centers.clone());
}
/// Builder: start paused.
pub fn paused(mut self) -> Self {
self.playing = false;
self
}
/// Builder: set the timeline speed multiplier.
pub fn with_speed(mut self, speed: f32) -> Self {
self.speed = speed.max(0.0);
self
}
/// Builder: enable/disable the camera fly-to-ghost.
pub fn with_camera_follow(mut self, follow: bool) -> Self {
self.camera_follow = follow;
self
}
// ── read model ─────────────────────────────────────────────────────────────
pub fn route(&self) -> &TravelRoute {
&self.route
}
/// A tour is **active** iff its route has stops (no travel until a route is set).
pub fn is_active(&self) -> bool {
!self.route.stops.is_empty()
}
pub fn is_playing(&self) -> bool {
self.playing
}
pub fn speed(&self) -> f32 {
self.speed
}
pub fn camera_follow(&self) -> bool {
self.camera_follow
}
pub fn elapsed(&self) -> f64 {
self.elapsed
}
/// The full tour duration in tour seconds (`m·dwell + (m-1)·seg`; a lone stop is
/// just its dwell).
pub fn total(&self) -> f64 {
let m = self.route.stops.len();
if m == 0 {
0.0
} else if m == 1 {
self.dwell_secs as f64
} else {
m as f64 * self.dwell_secs as f64 + (m - 1) as f64 * self.seg_secs as f64
}
}
/// Overall progress `∈ [0,1]` along the tour.
pub fn progress(&self) -> f32 {
let total = self.total();
if total <= 0.0 {
0.0
} else {
(self.elapsed / total).clamp(0.0, 1.0) as f32
}
}
/// The index into `route.stops` of the current (source) stop.
pub fn stop_index(&self) -> usize {
self.locate(self.elapsed).stop
}
/// The current node id (`route.stops[stop_index]`), if the route has stops.
pub fn current_node(&self) -> Option<NodeId> {
self.route.stops.get(self.stop_index()).copied()
}
/// The ghost marker's current 3-D position (on the spline, eased).
pub fn marker_pos(&self) -> [f32; 3] {
self.locate(self.elapsed).pos
}
/// Whether the ghost is currently **dwelling** (paused + pulsing) at a stop, versus
/// travelling between two stops along the spline.
pub fn is_dwelling(&self) -> bool {
self.locate(self.elapsed).in_dwell
}
/// The camera yaw (radians) that brings the ghost to the FRONT of the orbit — the
/// "camera fly" target the host applies while `camera_follow` is on.
pub fn camera_yaw(&self) -> f32 {
let p = self.marker_pos();
// Depth after the Y-rotation is maximised (nearest camera) at this yaw.
p[0].atan2(p[2])
}
// ── timeline seam (deterministic) ────────────────────────────────────────────
/// The wall-clock time (tour seconds) at which stop `i`'s dwell begins.
fn arrival_time(&self, i: usize) -> f64 {
i as f64 * (self.dwell_secs as f64 + self.seg_secs as f64)
}
/// Fire the arrival events for every stop reached at the current `elapsed` that has
/// not fired yet (monotonic-forward, in stop order) — returns the newly-visited
/// node ids. This is the stop-visited event.
fn fire_arrivals(&mut self) -> Vec<NodeId> {
let m = self.route.stops.len();
let mut out = Vec::new();
while (self.last_fired + 1) < m as i64 {
let i = (self.last_fired + 1) as usize;
if self.arrival_time(i) <= self.elapsed + 1e-9 {
out.push(self.route.stops[i]);
self.last_fired = i as i64;
} else {
break;
}
}
out
}
/// Sync `last_fired` to the arrivals already passed at the current `elapsed` (used
/// after a backward seek / prev-stop so events re-fire correctly going forward).
fn resync_fired(&mut self) {
let m = self.route.stops.len();
self.last_fired = (0..m).rev().find(|&i| self.arrival_time(i) <= self.elapsed + 1e-9).map(|i| i as i64).unwrap_or(-1);
}
/// **Advance the tour** by `dt` real seconds (scaled by `speed`) when playing —
/// returns the node ids of any stops **newly visited** this step (the stop-visited
/// event). Deterministic: a fixed `dt` sequence yields a fixed timeline.
pub fn advance(&mut self, dt: f64) -> Vec<NodeId> {
if !(self.playing && self.is_active()) {
return Vec::new();
}
self.elapsed = (self.elapsed + dt * self.speed as f64).clamp(0.0, self.total());
self.fire_arrivals()
}
/// **Seek** to an absolute timeline position `seconds` (the `SetClock` seam) —
/// returns the stops newly visited by moving forward to it.
pub fn seek(&mut self, seconds: f64) -> Vec<NodeId> {
let back = seconds < self.elapsed;
self.elapsed = seconds.clamp(0.0, self.total());
if back {
self.resync_fired();
Vec::new()
} else {
self.fire_arrivals()
}
}
// ── controls ────────────────────────────────────────────────────────────────
pub fn play(&mut self) {
self.playing = true;
}
pub fn pause(&mut self) {
self.playing = false;
}
pub fn set_speed(&mut self, speed: f32) {
self.speed = speed.max(0.0);
}
/// Jump to (and dwell at) the next stop.
pub fn next_stop(&mut self) {
let m = self.route.stops.len();
if m == 0 {
return;
}
let target = (self.stop_index() + 1).min(m - 1);
self.elapsed = self.arrival_time(target).min(self.total());
self.resync_fired();
}
/// Jump back to the previous stop.
pub fn prev_stop(&mut self) {
if self.route.stops.is_empty() {
return;
}
let target = self.stop_index().saturating_sub(1);
self.elapsed = self.arrival_time(target);
self.resync_fired();
}
/// Locate the ghost on the timeline at `e` seconds.
fn locate(&self, e: f64) -> Located {
let m = self.route.stops.len();
if m == 0 {
return Located { stop: 0, in_dwell: true, pos: [0.0; 3] };
}
if m == 1 {
return Located { stop: 0, in_dwell: true, pos: self.centers[0] };
}
let dwell = self.dwell_secs as f64;
let seg = self.seg_secs as f64;
let mut acc = 0.0;
for i in 0..m {
if e < acc + dwell {
return Located { stop: i, in_dwell: true, pos: self.centers[i] };
}
acc += dwell;
if i < m - 1 {
if e < acc + seg {
let u = ((e - acc) / seg) as f32;
return Located { stop: i, in_dwell: false, pos: self.spline.point(i, ease(u)) };
}
acc += seg;
}
}
Located { stop: m - 1, in_dwell: true, pos: *self.centers.last().unwrap() }
}
/// **Pure ghost overlay paint.** Draws the glowing ghostlike spline trail, a soft
/// highlight at each stop node, and the pulsing emissive ghost marker at the head —
/// projecting every 3-D point through the host's `project` closure (the SAME camera
/// the graph paints with, L5). `phase` (cycles) drives the pulse (a pinned clock ⇒
/// a byte-stable ghost). Returns the number of shapes drawn (for the render emit).
pub fn paint(&self, painter: &Painter, project: impl Fn([f32; 3]) -> (Pos2, f32), phase: f32) -> usize {
if !self.is_active() {
return 0;
}
let mut drawn = 0usize;
let pulse = decor::pulse(phase); // 0..1 breathing
// ── the ghostly spline trail (multi-sample translucent poly-line) ──────────
let segs = self.spline.segments();
if segs > 0 {
let samples = (segs * 10).max(12);
let mut prev: Option<Pos2> = None;
for s in 0..=samples {
let t = s as f32 / samples as f32;
let (p, z) = project(self.spline.sample(t));
let bright = (0.5 + (z + 1.0) * 0.25).clamp(0.35, 1.0);
if let Some(pp) = prev {
// A faint, breathing spectre line.
let a = (70.0 + 60.0 * pulse) * bright;
painter.line_segment([pp, p], Stroke::new(2.0_f32, GHOST.gamma_multiply((a / 255.0).clamp(0.0, 1.0))));
drawn += 1;
}
prev = Some(p);
}
}
// ── a soft glow at each visited stop node ──────────────────────────────────
for &c in &self.centers {
let (p, z) = project(c);
let r = 4.0 + (z + 1.0) * 2.0;
neon::paint_node_orb(painter, p, r, GHOST, 0.4, 0.7);
drawn += 1;
}
// ── the ghost MARKER at the head of the tour (pulsing emissive orb) ────────
let (mp, mz) = project(self.marker_pos());
let base_r = 6.0 + (mz + 1.0) * 3.0;
let r = base_r * (0.85 + 0.35 * pulse);
// Wide ghost halo, then a bright translucent core.
neon::paint_node_orb(painter, mp, r * 1.4, GHOST, 0.7, 0.9);
painter.circle_filled(mp, r, GHOST.gamma_multiply(0.55 + 0.35 * pulse));
painter.circle_filled(mp, r * 0.5, Color32::WHITE.gamma_multiply(0.6 + 0.3 * pulse));
drawn += 3;
drawn
}
/// The observable tour surface for `state_json` — merged into the host's object.
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"travel_active": self.is_active(),
"travel_playing": self.playing,
"travel_name": self.route.name,
"travel_kind": self.route.kind.label(),
"travel_stops": self.route.stops,
"travel_stop_index": self.stop_index(),
"travel_progress": self.progress(),
"travel_dwelling": self.is_dwelling(),
"travel_node": self.current_node(),
"travel_speed": self.speed,
"travel_elapsed": self.elapsed,
"travel_camera_follow": self.camera_follow,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn centers(n: usize) -> Vec<[f32; 3]> {
// A deterministic spread so each stop centre is distinct.
(0..n)
.map(|i| {
let a = i as f32 * 0.9;
[a.cos(), (i as f32 / n as f32) * 2.0 - 1.0, a.sin()]
})
.collect()
}
#[test]
fn spline_passes_through_every_control_point_in_order() {
// GEOMETRY PROOF (RED-when-broken): Catmull–Rom must hit each control point at
// the segment boundaries, in order. If the spline stopped interpolating the
// stops (e.g. a bezier that misses the knots), the boundary points drift → RED.
let pts = centers(5);
let sp = Spline::new(pts.clone());
assert_eq!(sp.segments(), 4);
for seg in 0..sp.segments() {
let a = sp.point(seg, 0.0);
let b = sp.point(seg, 1.0);
for c in 0..3 {
assert!((a[c] - pts[seg][c]).abs() < 1e-5, "seg {seg} u=0 hits stop {seg}");
assert!((b[c] - pts[seg + 1][c]).abs() < 1e-5, "seg {seg} u=1 hits stop {}", seg + 1);
}
}
}
#[test]
fn travel_marker_sits_exactly_on_each_stop_at_its_dwell() {
// The eased path plus the dwell means the marker is exactly on stop i during its
// dwell window — the "pauses at each visited node" contract.
let route = TravelRoute::new("t", vec![0, 2, 4]);
let cs = centers(6);
let mut tv = Travel::new(route, &cs);
for stop in 0..3 {
tv.seek(tv.arrival_time(stop) + 0.01);
let m = tv.marker_pos();
let want = cs[[0, 2, 4][stop]];
for c in 0..3 {
assert!((m[c] - want[c]).abs() < 1e-5, "dwell {stop} marker on the stop node");
}
assert_eq!(tv.current_node(), Some([0usize, 2, 4][stop]));
}
}
#[test]
fn advancing_the_tour_visits_every_stop_in_order_and_progress_grows() {
// TICK PROOF (RED-when-broken): stepping the clock must fire the stop-visited
// event for EVERY stop, in route order, and progress must climb 0→1. A frozen
// tour (tick a no-op) fires nothing and progress stays 0 → RED.
let stops = vec![3usize, 1, 4, 2];
let route = TravelRoute::latest_changes(&stops);
let cs = centers(6);
let mut tv = Travel::new(route, &cs);
let mut visited = Vec::new();
let mut last_progress = tv.progress();
let mut progressed = false;
// Small deterministic steps across the whole timeline.
for _ in 0..400 {
let got = tv.advance(0.05);
visited.extend(got);
let p = tv.progress();
if p > last_progress {
progressed = true;
}
last_progress = p;
}
assert_eq!(visited, stops, "every stop visited exactly once, in order");
assert!(progressed, "progress advanced as the clock ticked");
assert!((tv.progress() - 1.0).abs() < 1e-4, "the tour reached the end");
}
#[test]
fn paused_tour_does_not_move() {
// RED-when-broken guard on the pause control: a paused tour ignores ticks.
let route = TravelRoute::spotlight(2);
let cs = centers(4);
let mut tv = Travel::new(route, &cs).paused();
let before = tv.progress();
let visited = tv.advance(5.0);
assert!(visited.is_empty(), "a paused tour fires no arrivals");
assert_eq!(tv.progress(), before, "a paused tour does not advance");
tv.play();
let _ = tv.advance(0.001);
// The spotlight is a single-stop tour: stop 0 arrives once play resumes.
assert_eq!(tv.current_node(), Some(2));
}
#[test]
fn next_and_prev_stop_jump_between_stops() {
let route = TravelRoute::new("t", vec![0, 1, 2, 3]);
let cs = centers(4);
let mut tv = Travel::new(route, &cs).paused();
assert_eq!(tv.stop_index(), 0);
tv.next_stop();
assert_eq!(tv.stop_index(), 1);
tv.next_stop();
tv.next_stop();
assert_eq!(tv.stop_index(), 3);
tv.next_stop();
assert_eq!(tv.stop_index(), 3, "next clamps at the last stop");
tv.prev_stop();
assert_eq!(tv.stop_index(), 2);
}
#[test]
fn camera_yaw_faces_the_current_marker() {
// The camera-fly target: the yaw brings the marker to the front (max depth).
let route = TravelRoute::spotlight(1);
let cs = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]];
let tv = Travel::new(route, &cs);
// Marker at [1,0,0]: yaw = atan2(1,0) = π/2.
assert!((tv.camera_yaw() - std::f32::consts::FRAC_PI_2).abs() < 1e-5);
}
#[test]
fn route_constructors_carry_kind_and_stops() {
assert_eq!(TravelRoute::spotlight(7).kind, RouteKind::Spotlight);
assert_eq!(TravelRoute::spotlight(7).stops, vec![7]);
let r = TravelRoute::latest_changes(&[2, 5, 1]);
assert_eq!(r.kind, RouteKind::LatestChanges);
assert_eq!(r.stops, vec![2, 5, 1]);
let b = TravelRoute::bugs_top_down(&[9, 4]);
assert_eq!(b.kind, RouteKind::BugsTopDown);
assert_eq!(b.kind.label(), "bugs_top_down");
}
#[test]
fn state_json_exposes_the_tour_surface() {
let route = TravelRoute::bugs_top_down(&[1, 3]);
let cs = centers(4);
let mut tv = Travel::new(route, &cs);
let _ = tv.advance(0.1);
let j = tv.state_json();
assert_eq!(j["travel_active"], true);
assert_eq!(j["travel_playing"], true);
assert_eq!(j["travel_kind"], "bugs_top_down");
assert_eq!(j["travel_stops"], serde_json::json!([1, 3]));
assert_eq!(j["travel_stop_index"], 0);
assert_eq!(j["travel_node"], 1);
assert!(j["travel_progress"].as_f64().unwrap() >= 0.0);
}
}