OXYGENHUB

feat(web): every scene as dense as the sky: knots, fuller streams, 1.4x (refs #167) f993f792537e

Andy: the sky's density is right, the rest should match. Ripples' and the
swarm's repositories and runners are knots laid out like a sky's clusters
(field::knot: the sky's lanes, sizes and speeds), 90 dots for the
repository and 60 for each other, six for each image a node holds; streams
carry 28 dots (Diffusion) and 20 chunks (the swarm); the ripple 72; adrift's
cluster 160; the mark 900 dots. Every scene's dots are drawn at the sky's
1.4x.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 20:26 UTC
Signature
✔ signed by Andy McCall
Hash
f993f792537e56020d40e4b598c6c384c78b10e529f307f77ef0d1304d36f68a
Depends on
17ca6cddc534 6246d1e51f4f
Depended on by
5501b1c657b2
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
52 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified assets/backdrop/backdrop.js +3 −3
…
338 unchanged lines
339 339
            arg0.viewport(arg1, arg2, arg3, arg4);
340 340
        },
341 341
        __wbindgen_generic_0000000000000001: function(arg0, arg1) {
342 -
            // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [F64], shim_idx: 6, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
342 +
            // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [F64], shim_idx: 8, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
343 343
            const ret = makeMutClosure(arg0, arg1, wasm_bindgen_ef67fbc5f0a8b406___convert__closures_____invoke___f64______true_);
344 344
            return ret;
345 345
        },
346 346
        __wbindgen_generic_0000000000000002: function(arg0, arg1) {
347 -
            // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("MouseEvent")], shim_idx: 5, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
347 +
            // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("MouseEvent")], shim_idx: 7, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
348 348
            const ret = makeMutClosure(arg0, arg1, wasm_bindgen_ef67fbc5f0a8b406___convert__closures_____invoke___web_sys_793942649ef82f98___features__gen_MouseEvent__MouseEvent______true_);
349 349
            return ret;
350 350
        },
351 351
        __wbindgen_generic_0000000000000003: function(arg0, arg1) {
352 -
            // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 4, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
352 +
            // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 6, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
353 353
            const ret = makeMutClosure(arg0, arg1, wasm_bindgen_ef67fbc5f0a8b406___convert__closures_____invoke_______true_);
354 354
            return ret;
355 355
        },
…
292 unchanged lines
replaced assets/backdrop/backdrop_bg.wasm +0 −0

No line changes.

modified crates/backdrop/src/adrift.rs +11 −20
…
2 unchanged lines
3 3
//! the page drifted from what was asked for. It drifts for a minute, fades
4 4
//! and sets out again. No data.
5 5
6 -
use crate::field::{margins, unit, Dot, Field};
6 +
use crate::field::{knot, margins, unit, Dot, Field, Orbit, ZOOM};
7 7
use crate::scene::Scene;
8 -
use crate::sky::{hash, Colours, MAX_SPEED};
9 -
use std::f32::consts::TAU;
8 +
use crate::sky::{hash, Colours};
10 9
11 -
const CLUSTER: usize = 70;
10 +
const CLUSTER: usize = 160;
12 11
/// The lost mark's speed, and how long one drift lasts, fade included.
13 12
const DRIFT_SPEED: f32 = 7.0;
14 13
const DRIFT_S: f32 = 70.0;
15 14
const FADE_S: f32 = 5.0;
16 15
17 -
struct Orbit {
18 -
    radius: f32,
19 -
    angle: f32,
20 -
    speed: f32,
21 -
}
22 -
23 16
pub struct Adrift {
24 17
    field: Field,
25 18
    orbits: Vec<Orbit>,
…
6 unchanged lines
32 25
impl Adrift {
33 26
    pub fn new(still: bool) -> Adrift {
34 27
        let mut field = Field::new(still);
35 -
        let mut orbits = Vec::new();
28 +
        let orbits = knot("adrift", CLUSTER);
36 29
        for k in 0..CLUSTER {
37 -
            let h = hash(&format!("adrift#{k}"));
38 -
            let radius = (14.0 + (k % 8) as f32 * 6.5 + unit(h, 0) * 4.0) * 1.4;
39 -
            orbits.push(Orbit { radius, angle: unit(h, 16) * TAU, speed: (MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 32)) });
40 -
            let mut d = Dot::new(0.0, 0.0, 2.6, if k % 7 == 0 { 2.0 } else { 0.0 });
30 +
            let h = hash(&format!("adrift@{k}"));
31 +
            let mut d = Dot::new(0.0, 0.0, 2.0 * ZOOM, if k % 7 == 0 { 2.0 } else { 0.0 });
41 32
            d.bright = 0.5 + 0.5 * unit(h, 48);
42 33
            d.group = 0;
43 34
            d.order = k as u32;
…
1 unchanged lines
45 36
        }
46 37
        // The cluster's core, and the lost mark, a little larger.
47 38
        field.dots.push(Dot::new(0.0, 0.0, 8.0, 4.0));
48 -
        let mut lost = Dot::new(0.0, 0.0, 4.2, 0.0);
39 +
        let mut lost = Dot::new(0.0, 0.0, 3.4 * ZOOM, 0.0);
49 40
        lost.bright = 1.0;
50 41
        field.dots.push(lost);
51 42
        let mut a = Adrift { field, orbits, centre: (0.0, 0.0), w: 0.0, h: 0.0, t: 12.0 };
…
4 unchanged lines
56 47
    fn place(&mut self) {
57 48
        let (cx, cy) = self.centre;
58 49
        for (i, o) in self.orbits.iter().enumerate() {
59 -
            let a = o.angle + o.speed * self.t;
60 -
            self.field.dots[i].x = cx + a.cos() * o.radius;
61 -
            self.field.dots[i].y = cy + a.sin() * o.radius;
50 +
            let (x, y) = o.at((cx, cy), self.t);
51 +
            self.field.dots[i].x = x;
52 +
            self.field.dots[i].y = y;
62 53
        }
63 54
        let core = &mut self.field.dots[CLUSTER];
64 55
        core.x = cx;
…
2 unchanged lines
67 58
        // each drift and setting out again from the cluster's edge.
68 59
        let p = self.t % DRIFT_S;
69 60
        let dir: f32 = -0.35;
70 -
        let from = 70.0 * 1.4;
61 +
        let from = self.orbits.iter().map(|o| o.radius).fold(0.0, f32::max) + 14.0;
71 62
        let lost = &mut self.field.dots[CLUSTER + 1];
72 63
        lost.x = cx + dir.cos() * (from + p * DRIFT_SPEED);
73 64
        lost.y = cy + dir.sin() * (from + p * DRIFT_SPEED);
…
68 unchanged lines
modified crates/backdrop/src/field.rs +35 −0
…
195 unchanged lines
196 196
    ((h >> shift) & 0xffff) as f32 / 65536.0
197 197
}
198 198
199 +
/// One dot of a knot: its orbit about the knot's centre.
200 +
#[derive(Clone, Copy, Debug)]
201 +
pub struct Orbit {
202 +
    pub radius: f32,
203 +
    pub angle: f32,
204 +
    /// Radians a second, signed.
205 +
    pub speed: f32,
206 +
}
207 +
208 +
impl Orbit {
209 +
    pub fn at(&self, centre: (f32, f32), t: f32) -> (f32, f32) {
210 +
        let a = self.angle + self.speed * t;
211 +
        (centre.0 + a.cos() * self.radius, centre.1 + a.sin() * self.radius)
212 +
    }
213 +
}
214 +
215 +
/// `n` dots as a knot like a sky's cluster (sky::Sky, Andy's 2026-10-13
216 +
/// tuning): close lanes, more of them the busier, drawn 1.4 times larger,
217 +
/// turning one way at the sky's slow speed. `seed` makes it stable.
218 +
pub fn knot(seed: &str, n: usize) -> Vec<Orbit> {
219 +
    let lanes = (6 + n / 30).min(14) as u32;
220 +
    let dir = if crate::sky::hash(seed) & 1 == 0 { 1.0 } else { -1.0 };
221 +
    (0..n as u32)
222 +
        .map(|k| {
223 +
            let h = crate::sky::hash(&format!("{seed}#{k}"));
224 +
            let radius = (16.0 + (k % lanes) as f32 * 6.5 + unit(h, 0) * 4.0) * 1.4;
225 +
            let speed = dir * (crate::sky::MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 16));
226 +
            Orbit { radius, angle: unit(h, 32) * std::f32::consts::TAU, speed }
227 +
        })
228 +
        .collect()
229 +
}
230 +
231 +
/// A dot's size drawn as the sky draws its marks (1.4 times larger).
232 +
pub const ZOOM: f32 = 1.4;
233 +
199 234
/// The page's margins beside its content column, when both are wide
200 235
/// enough to hold something: (left width, right start).
201 236
pub fn margins(w: f32, avoid: Option<(f32, f32)>) -> Option<(f32, f32)> {
…
44 unchanged lines
modified crates/backdrop/src/mark.rs +3 −3
…
2 unchanged lines
3 3
//! stream runs through the middle like the mark's infinity, each with its
4 4
//! core. Brand only: no data.
5 5
6 -
use crate::field::{unit, Dot, Field};
6 +
use crate::field::{unit, Dot, Field, ZOOM};
7 7
use crate::scene::Scene;
8 8
use crate::sky::{hash, Colours};
9 9
use std::f32::consts::TAU;
10 10
11 11
/// Dots on the two rings.
12 -
const DOTS: usize = 560;
12 +
const DOTS: usize = 900;
13 13
/// How fast the rings turn, at their rim, in CSS pixels a second.
14 14
const RIM_SPEED: f32 = 9.0;
15 15
…
23 unchanged lines
39 39
            // Off the ring's line by a bell-ish spread, most near it.
40 40
            let off = (unit(h, 16) + unit(h, 32) - 1.0) * 18.0;
41 41
            beads.push(Bead { ring, angle: unit(h, 0) * TAU, off, phase: unit(h, 48) * TAU });
42 -
            let mut d = Dot::new(0.0, 0.0, 1.5 + unit(h, 40) * 1.6, if i % 11 == 0 { 1.0 } else { 0.0 });
42 +
            let mut d = Dot::new(0.0, 0.0, (1.5 + unit(h, 40) * 1.6) * ZOOM, if i % 11 == 0 { 1.0 } else { 0.0 });
43 43
            d.bright = 0.55 + 0.45 * unit(h, 24);
44 44
            d.group = ring as u32;
45 45
            field.dots.push(d);
…
102 unchanged lines
modified crates/backdrop/src/ripples.rs +32 −28
…
4 unchanged lines
5 5
//! the status of its Diffusion pill, and a ripple widens from the
6 6
//! repository every few seconds. The data is the page's own.
7 7
8 -
use crate::field::{margins, unit, Dot, Field, Named, Tone};
8 +
use crate::field::{knot, margins, unit, Dot, Field, Named, Orbit, Tone, ZOOM};
9 9
use crate::scene::Scene;
10 -
use crate::sky::{hash, Colours};
10 +
use crate::sky::Colours;
11 11
use std::f32::consts::{PI, TAU};
12 12
13 -
/// Dots round each repository, how far out, and how fast they turn.
14 -
const RING: usize = 18;
15 -
const RING_R: f32 = 30.0;
16 -
const RING_SPEED: f32 = 6.0;
13 +
/// Dots in the repository's knot and in each other's, as dense as a sky's
14 +
/// clusters.
15 +
const KNOT_REPO: usize = 90;
16 +
const KNOT: usize = 60;
17 17
/// Dots flowing along each stream, and their speed in CSS pixels a second:
18 18
/// a stream is a flow, quicker than the drift of marks.
19 -
const FLOW: usize = 10;
19 +
const FLOW: usize = 28;
20 20
const FLOW_SPEED: f32 = 40.0;
21 21
/// A ripple every this many seconds, widening this far over this long.
22 22
const RIPPLE_EVERY: f32 = 5.0;
23 23
const RIPPLE_R: f32 = 280.0;
24 24
const RIPPLE_S: f32 = 3.2;
25 -
const RIPPLE_DOTS: usize = 40;
25 +
const RIPPLE_DOTS: usize = 72;
26 26
27 27
/// One repository on the page: its name and the tone its dots take.
28 28
pub struct Node {
…
7 unchanged lines
36 36
    nodes: Vec<Node>,
37 37
    ups: usize,
38 38
    pos: Vec<(f32, f32)>,
39 +
    /// Each knot's dots: its node, and its orbit.
40 +
    orbits: Vec<(usize, Orbit)>,
39 41
    /// Streams: from, to, and their dots' first index.
40 42
    streams: Vec<(usize, usize, usize)>,
41 43
    ripple_at: usize,
…
9 unchanged lines
51 53
        nodes.extend(upstreams);
52 54
        nodes.extend(consumers);
53 55
        let mut field = Field::new(still);
56 +
        let mut orbits = Vec::new();
54 57
        for (i, n) in nodes.iter().enumerate() {
55 58
            let mut core = Dot::new(0.0, 0.0, if i == 0 { 9.0 } else { 7.0 }, 4.0);
56 59
            core.tone = n.tone;
57 60
            field.dots.push(core);
58 -
            for k in 0..RING {
59 -
                let mut d = Dot::new(0.0, 0.0, 2.2, 0.0);
61 +
        }
62 +
        for (i, n) in nodes.iter().enumerate() {
63 +
            for (k, o) in knot(&n.name, if i == 0 { KNOT_REPO } else { KNOT }).into_iter().enumerate() {
64 +
                let mut d = Dot::new(0.0, 0.0, 2.0 * ZOOM, if k % 9 == 0 { 1.0 } else { 0.0 });
60 65
                d.tone = n.tone;
61 66
                d.group = i as u32;
62 67
                d.order = k as u32;
63 -
                d.bright = 0.6 + 0.4 * unit(hash(&format!("{}#{k}", n.name)), 0);
68 +
                d.bright = 0.55 + 0.45 * unit(crate::sky::hash(&format!("{}@{k}", n.name)), 0);
64 69
                field.dots.push(d);
70 +
                orbits.push((i, o));
65 71
            }
66 72
        }
67 73
        // Upstreams flow into the repository; the repository flows out to
…
7 unchanged lines
75 81
            };
76 82
            streams.push((from, to, field.dots.len()));
77 83
            for _ in 0..FLOW {
78 -
                let mut d = Dot::new(0.0, 0.0, 2.0, 0.0);
84 +
                let mut d = Dot::new(0.0, 0.0, 1.8 * ZOOM, 0.0);
79 85
                d.tone = node.tone;
80 86
                field.dots.push(d);
81 87
            }
82 88
        }
83 89
        let ripple_at = field.dots.len();
84 90
        for _ in 0..RIPPLE_DOTS {
85 -
            let mut d = Dot::new(0.0, 0.0, 1.8, 0.0);
91 +
            let mut d = Dot::new(0.0, 0.0, 1.6 * ZOOM, 0.0);
86 92
            d.bright = 0.0;
87 93
            field.dots.push(d);
88 94
        }
89 -
        let mut r = Ripples { field, nodes, ups, pos: Vec::new(), streams, ripple_at, w: 0.0, h: 0.0, t: 0.0 };
95 +
        let mut r = Ripples { field, nodes, ups, pos: Vec::new(), orbits, streams, ripple_at, w: 0.0, h: 0.0, t: 0.0 };
90 96
        r.layout(None);
91 97
        r
92 98
    }
…
21 unchanged lines
114 120
            })
115 121
            .collect();
116 122
        self.field.named =
117 -
            self.nodes.iter().zip(&self.pos).map(|(nd, &(x, y))| Named { name: nd.name.clone(), x, y, reach: RING_R + 6.0 }).collect();
123 +
            self.nodes.iter().zip(&self.pos).map(|(nd, &(x, y))| Named { name: nd.name.clone(), x, y, reach: 90.0 }).collect();
118 124
    }
119 125
120 126
    fn place(&mut self) {
121 -
        let mut at = 0;
122 -
        let spin = RING_SPEED / RING_R * self.t;
127 +
        let n = self.nodes.len();
123 128
        for (i, &(x, y)) in self.pos.iter().enumerate() {
124 -
            self.field.dots[at].x = x;
125 -
            self.field.dots[at].y = y;
126 -
            at += 1;
127 -
            for k in 0..RING {
128 -
                let a = k as f32 / RING as f32 * TAU + if i % 2 == 0 { spin } else { -spin };
129 -
                self.field.dots[at].x = x + a.cos() * RING_R;
130 -
                self.field.dots[at].y = y + a.sin() * RING_R;
131 -
                at += 1;
132 -
            }
129 +
            self.field.dots[i].x = x;
130 +
            self.field.dots[i].y = y;
131 +
        }
132 +
        for (k, (node, o)) in self.orbits.iter().enumerate() {
133 +
            let (x, y) = o.at(self.pos[*node], self.t);
134 +
            self.field.dots[n + k].x = x;
135 +
            self.field.dots[n + k].y = y;
133 136
        }
134 137
        // Streams: a gentle arc from each source to its destination, its
135 138
        // dots evenly spaced and moving along it, faded at the ends.
…
16 unchanged lines
152 155
        for k in 0..RIPPLE_DOTS {
153 156
            let d = &mut self.field.dots[self.ripple_at + k];
154 157
            let a = k as f32 / RIPPLE_DOTS as f32 * TAU;
155 -
            let r = RING_R + RIPPLE_R * p.min(1.0);
158 +
            let r = 40.0 + RIPPLE_R * p.min(1.0);
156 159
            d.x = x + a.cos() * r;
157 160
            d.y = y + a.sin() * r;
158 161
            d.bright = if self.field.still || p >= 1.0 { 0.0 } else { 0.8 * (1.0 - p) };
…
57 unchanged lines
216 219
        r.resize(2560.0, 1270.0, Some((707.0, 1838.0)));
217 220
        assert!(r.pos[0].0 < 707.0 && r.pos[1].0 < r.pos[0].0, "the repository and its upstream in the left margin");
218 221
        assert!(r.pos[2].0 > 1838.0 && r.pos[3].0 > 1838.0, "its consumers in the right");
219 -
        assert_eq!(r.streams, vec![(1, 0, 4 * (RING + 1)), (0, 2, 4 * (RING + 1) + FLOW), (0, 3, 4 * (RING + 1) + 2 * FLOW)]);
222 +
        let knots = 4 + KNOT_REPO + 3 * KNOT;
223 +
        assert_eq!(r.streams, vec![(1, 0, knots), (0, 2, knots + FLOW), (0, 3, knots + 2 * FLOW)]);
220 224
        let bad_stream = r.streams[2].2;
221 225
        assert!(r.field.dots[bad_stream..bad_stream + FLOW].iter().all(|d| d.tone == Tone::Bad), "a failing consumer's stream is red");
222 226
        let before = r.field.dots[bad_stream].x;
…
17 unchanged lines
modified crates/backdrop/src/swarm.rs +23 −28
…
3 unchanged lines
4 4
//! to it from every holder, the hub and its peers alike, as Jetstream's
5 5
//! swarm fetches them. The data is the page's own snapshot.
6 6
7 -
use crate::field::{margins, unit, Dot, Field, Named};
7 +
use crate::field::{knot, margins, Dot, Field, Named, Orbit, ZOOM};
8 8
use crate::scene::Scene;
9 -
use crate::sky::{hash, Colours, MAX_SPEED};
10 -
use std::f32::consts::{PI, TAU};
9 +
use crate::sky::Colours;
10 +
use std::f32::consts::PI;
11 11
12 +
/// Dots for each image a node holds (its chunks, drawn as a sky's knot).
13 +
const PER_IMAGE: usize = 6;
12 14
/// Chunks streaming along each holder-to-fetcher path, their speed, and
13 15
/// the most streams drawn.
14 -
const CHUNKS: usize = 8;
16 +
const CHUNKS: usize = 20;
15 17
const CHUNK_SPEED: f32 = 40.0;
16 18
const MAX_STREAMS: usize = 40;
17 19
…
5 unchanged lines
23 25
24 26
struct Held {
25 27
    node: usize,
26 -
    radius: f32,
27 -
    angle: f32,
28 -
    speed: f32,
28 +
    orbit: Orbit,
29 29
}
30 30
31 31
pub struct Swarm {
…
16 unchanged lines
48 48
            field.dots.push(Dot::new(0.0, 0.0, if i == 0 { 10.0 } else { 8.0 }, 4.0));
49 49
        }
50 50
        let mut held = Vec::new();
51 -
        let mut count = vec![0u32; names.len()];
52 -
        for (n, img) in images.iter().enumerate() {
51 +
        let mut count = vec![0usize; names.len()];
52 +
        for img in &images {
53 53
            for &node in img.held.iter().filter(|&&n| n < names.len()) {
54 -
                let k = count[node];
55 54
                count[node] += 1;
56 -
                let h = hash(&format!("{}#{n}", names[node]));
57 -
                let radius = (16.0 + (k % 5) as f32 * 7.0 + unit(h, 0) * 4.0) * 1.4;
58 -
                held.push(Held {
59 -
                    node,
60 -
                    radius,
61 -
                    angle: unit(h, 16) * TAU,
62 -
                    speed: (MAX_SPEED / radius).min(0.2) * (0.5 + 0.5 * unit(h, 32)),
63 -
                });
64 -
                let mut d = Dot::new(0.0, 0.0, 2.6, 0.0);
55 +
            }
56 +
        }
57 +
        for (node, name) in names.iter().enumerate() {
58 +
            for (k, orbit) in knot(name, count[node] * PER_IMAGE).into_iter().enumerate() {
59 +
                let mut d = Dot::new(0.0, 0.0, 2.0 * ZOOM, if k % PER_IMAGE == 0 { 5.0 } else { 0.0 });
65 60
                d.group = node as u32;
66 -
                d.order = k;
67 -
                d.bright = 0.7;
61 +
                d.order = k as u32;
62 +
                d.bright = 0.75;
68 63
                field.dots.push(d);
64 +
                held.push(Held { node, orbit });
69 65
            }
70 66
        }
71 67
        let mut streams = Vec::new();
…
5 unchanged lines
77 73
                    }
78 74
                    streams.push((from, to, field.dots.len()));
79 75
                    for _ in 0..CHUNKS {
80 -
                        let mut d = Dot::new(0.0, 0.0, 2.2, 5.0);
76 +
                        let mut d = Dot::new(0.0, 0.0, 1.8 * ZOOM, 5.0);
81 77
                        d.bright = 0.0;
82 78
                        field.dots.push(d);
83 79
                    }
…
25 unchanged lines
109 105
            .zip(&self.pos)
110 106
            .enumerate()
111 107
            .map(|(i, (name, &(x, y)))| {
112 -
                let reach = self.held.iter().filter(|h| h.node == i).map(|h| h.radius).fold(12.0, f32::max);
108 +
                let reach = self.held.iter().filter(|h| h.node == i).map(|h| h.orbit.radius).fold(12.0, f32::max);
113 109
                Named { name: name.clone(), x, y, reach }
114 110
            })
115 111
            .collect();
…
6 unchanged lines
122 118
            self.field.dots[i].y = y;
123 119
        }
124 120
        for (k, hd) in self.held.iter().enumerate() {
125 -
            let (x, y) = self.pos[hd.node];
126 -
            let a = hd.angle + hd.speed * self.t;
127 -
            self.field.dots[n + k].x = x + a.cos() * hd.radius;
128 -
            self.field.dots[n + k].y = y + a.sin() * hd.radius;
121 +
            let (x, y) = hd.orbit.at(self.pos[hd.node], self.t);
122 +
            self.field.dots[n + k].x = x;
123 +
            self.field.dots[n + k].y = y;
129 124
        }
130 125
        for &(from, to, first) in &self.streams {
131 126
            let (a, b) = (self.pos[from], self.pos[to]);
…
62 unchanged lines
194 189
        let mut s = Swarm::new(names, images, false);
195 190
        s.resize(2560.0, 1270.0, Some((707.0, 1838.0)));
196 191
        assert!(s.pos[0].0 < 707.0 && s.pos[1].0 > 1838.0 && s.pos[2].0 > 1838.0, "the hub on one side, runners on the other");
197 -
        assert_eq!(s.held.len(), 5, "a dot for every image each node holds");
192 +
        assert_eq!(s.held.len(), 5 * PER_IMAGE, "dots for every image each node holds");
198 193
        assert_eq!(s.streams.iter().map(|&(a, b, _)| (a, b)).collect::<Vec<_>>(), vec![(0, 2), (1, 2)], "from the hub and the peer");
199 194
        let first = s.streams[0].2;
200 195
        let before = s.field.dots[first].x;
…
9 unchanged lines