OXYGENHUB
# 797eca1a3e64 oxygenhub-android / rust-core / src / backdrop / sky.rs
30.2 KiB · Rust · last changed The hub's living backgrounds in the app: the mark at sign-in, Your sky on the dashboard, live runs pulse (refs #23) 8 days ago source blame history raw
1
// Copied from OxygenHub crates/backdrop/src/sky.rs (6dc080f6) by
2
// scripts/sync-backdrop.sh: change it there, never here.
3
//! Your sky (docs/backgrounds.md §5): the dashboard's scene, its layout
4
//! and its motion, with no browser in it so it is tested natively.
5
//!
6
//! Each repository is a cluster placed by a hash of its name, so a
7
//! viewer's sky is the same on every visit; its events of the last 30 days
8
//! orbit it as marks, brightest when newest, shaped by kind. The cursor
9
//! lights marks near it without moving them, and joins the lit marks of
10
//! one repository in the order they happened.
11
12
use std::f32::consts::TAU;
13
14
/// The scale of marks and orbits: the sky drawn 40% closer than first
15
/// built (Andy, 2026-10-13).
16
const ZOOM: f32 = 1.4;
17
/// How far the cursor lights marks, in CSS pixels (§4).
18
pub const LIGHT_RADIUS: f32 = 170.0;
19
/// Seconds a mark takes to light, and to fade.
20
const LIT_IN: f32 = 0.15;
21
const LIT_OUT: f32 = 0.6;
22
/// The fastest a mark drifts, in CSS pixels a second.
23
pub const MAX_SPEED: f32 = 12.0;
24
/// How far each depth layer shifts against the cursor, front to back.
25
const PARALLAX: [f32; 3] = [6.0, 3.0, 1.0];
26
/// The longest link: only marks that happened one after another and sit
27
/// near each other are joined, so a busy repository reads as a
28
/// constellation, not a scribble.
29
const LINK_MAX: f32 = 85.0;
30
/// Links fade when the cursor has rested this long, in seconds.
31
const LINK_REST: f32 = 2.0;
32
/// A resting cursor this near a cluster's core names it, after this long.
33
const LABEL_NEAR: f32 = 110.0;
34
const LABEL_REST: f32 = 0.4;
35
/// Events older than this many days are not drawn.
36
pub const DAYS: f32 = 30.0;
37
/// The most marks one push makes (one a change), and in all.
38
const PER_PUSH: u32 = 40;
39
const MAX_MARKS: usize = 3000;
40
/// A side margin narrower than this holds no clusters: the sky falls back
41
/// to a ring around the page.
42
const MIN_MARGIN: f32 = 180.0;
43
/// The width a cluster gets in a margin's grid.
44
const CELL: f32 = 260.0;
45
/// An arrival's ring (§4): seconds it lasts, and its radius from first to
46
/// last, in CSS pixels before the zoom.
47
const RING_S: f32 = 1.2;
48
const RING_FROM: f32 = 6.0;
49
const RING_TO: f32 = 46.0;
50
51
/// What a mark stands for, which decides its shape.
52
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53
pub enum Kind {
54
    Push,
55
    Proposal,
56
    Issue,
57
    Release,
58
    /// A Flow run.
59
    Run,
60
    /// The repository itself, its cluster's core.
61
    Repo,
62
}
63
64
impl Kind {
65
    /// The code the page's data uses: 0 a push's change, 1 a proposal
66
    /// event, 2 an issue event, 3 a release, 4 a Flow run.
67
    pub fn from_code(code: u32) -> Kind {
68
        match code {
69
            1 => Kind::Proposal,
70
            2 => Kind::Issue,
71
            3 => Kind::Release,
72
            4 => Kind::Run,
73
            _ => Kind::Push,
74
        }
75
    }
76
77
    /// The shape the fragment shader draws: 0 dot, 1 diamond, 2 ring,
78
    /// 3 four-point star, 4 thin ring (a repository), 5 square.
79
    pub fn shape(self) -> f32 {
80
        match self {
81
            Kind::Push => 0.0,
82
            Kind::Proposal => 1.0,
83
            Kind::Issue => 2.0,
84
            Kind::Release => 3.0,
85
            Kind::Repo => 4.0,
86
            Kind::Run => 5.0,
87
        }
88
    }
89
90
    /// Its radius in CSS pixels.
91
    fn size(self) -> f32 {
92
        match self {
93
            Kind::Push => 2.0,
94
            Kind::Run => 2.2,
95
            Kind::Proposal => 3.2,
96
            Kind::Issue => 3.0,
97
            Kind::Release => 4.2,
98
            Kind::Repo => 6.0,
99
        }
100
    }
101
}
102
103
/// One repository, as the page gives it.
104
#[derive(Clone, Debug)]
105
pub struct Repo {
106
    pub name: String,
107
}
108
109
/// One event, as the page gives it: its repository (an index into the
110
/// repositories), its kind, how old it is, and how many marks it makes (a
111
/// push's changes; one otherwise).
112
#[derive(Clone, Copy, Debug)]
113
pub struct Event {
114
    pub repo: usize,
115
    pub kind: Kind,
116
    pub age_days: f32,
117
    pub n: u32,
118
}
119
120
/// A repository's place: a fraction of the view, and a depth layer.
121
#[derive(Clone, Debug)]
122
pub struct Cluster {
123
    pub name: String,
124
    pub fx: f32,
125
    pub fy: f32,
126
    pub layer: usize,
127
}
128
129
/// One drawn mark, orbiting its cluster.
130
#[derive(Clone, Debug)]
131
pub struct Mark {
132
    pub cluster: usize,
133
    pub kind: Kind,
134
    /// Its place in its cluster's history, 0 the newest; the core is 0
135
    /// too and has radius 0.
136
    pub order: u32,
137
    pub radius: f32,
138
    pub angle: f32,
139
    /// Radians a second, signed.
140
    pub speed: f32,
141
    /// 1 for today, fading to 0.3 for a month ago.
142
    pub bright: f32,
143
    /// How lit it is now, 0 to 1.
144
    pub lit: f32,
145
}
146
147
/// Straight RGBA, 0 to 1, as the page's tokens give them.
148
pub type Rgba = [f32; 4];
149
150
/// The three backdrop tokens (§3), and the page's status colours for the
151
/// scenes whose data has a status (Diffusion's pills).
152
#[derive(Clone, Copy, Debug, Default, PartialEq)]
153
pub struct Colours {
154
    pub mark: Rgba,
155
    pub lit: Rgba,
156
    pub link: Rgba,
157
    pub ok: Rgba,
158
    pub warn: Rgba,
159
    pub bad: Rgba,
160
}
161
162
/// A widening ring at a cluster's core: something just arrived there.
163
#[derive(Clone, Copy, Debug)]
164
pub struct Ring {
165
    pub cluster: usize,
166
    /// Seconds since it began.
167
    pub age: f32,
168
}
169
170
/// The scene.
171
pub struct Sky {
172
    pub clusters: Vec<Cluster>,
173
    pub marks: Vec<Mark>,
174
    /// The view, in CSS pixels.
175
    pub w: f32,
176
    pub h: f32,
177
    /// Where the cursor is, if it is over the page.
178
    pub cursor: Option<(f32, f32)>,
179
    /// Seconds since the cursor last moved.
180
    pub rested: f32,
181
    /// Still (reduced motion, or the viewer's choice): no drift, no
182
    /// parallax, and marks light at once.
183
    pub still: bool,
184
    /// The page's content column, left and right, in CSS pixels: clusters
185
    /// go in the margins beside it, where they can be seen.
186
    avoid: Option<(f32, f32)>,
187
    parallax: [(f32, f32); 3],
188
    link_fade: f32,
189
    /// Arrivals' rings, while they widen.
190
    pub rings: Vec<Ring>,
191
    /// Marks added since the page loaded, for their places in the lanes.
192
    arrived: u32,
193
}
194
195
/// FNV-1a, then splitmix64's finaliser: a small, stable hash, so a
196
/// repository's place never changes. The finaliser spreads every bit:
197
/// FNV alone gives names that differ only at the end (`repo#1`, `repo#2`)
198
/// alike high bits, which drew a cluster's marks as fans at a few angles.
199
pub fn hash(s: &str) -> u64 {
200
    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
201
    for b in s.as_bytes() {
202
        h ^= *b as u64;
203
        h = h.wrapping_mul(0x0100_0000_01b3);
204
    }
205
    h = (h ^ (h >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
206
    h = (h ^ (h >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
207
    h ^ (h >> 31)
208
}
209
210
/// A number from 0 to 1 from bits of a hash.
211
fn unit(h: u64, shift: u32) -> f32 {
212
    ((h >> shift) & 0xffff) as f32 / 65536.0
213
}
214
215
impl Sky {
216
    /// The sky of `repos` and their `events` (newest first, as the page
217
    /// lists them), in a view of `w` by `h` CSS pixels.
218
    pub fn new(repos: &[Repo], events: &[Event], w: f32, h: f32, still: bool) -> Sky {
219
        // The most active repositories in front, the quiet ones behind.
220
        let mut activity = vec![0u32; repos.len()];
221
        for e in events {
222
            if e.repo < repos.len() && e.age_days <= DAYS {
223
                activity[e.repo] += 1;
224
            }
225
        }
226
        let mut rank: Vec<usize> = (0..repos.len()).collect();
227
        rank.sort_by_key(|&i| std::cmp::Reverse(activity[i]));
228
        let mut layer = vec![2usize; repos.len()];
229
        for (pos, &i) in rank.iter().enumerate() {
230
            layer[i] = (pos * 3 / repos.len().max(1)).min(2);
231
        }
232
        let clusters: Vec<Cluster> =
233
            repos.iter().enumerate().map(|(i, r)| Cluster { name: r.name.clone(), fx: 0.5, fy: 0.5, layer: layer[i] }).collect();
234
        let mut marks: Vec<Mark> = Vec::new();
235
        for i in 0..clusters.len() {
236
            marks.push(Mark { cluster: i, kind: Kind::Repo, order: 0, radius: 0.0, angle: 0.0, speed: 0.0, bright: 0.9, lit: 0.0 });
237
        }
238
        // Each event's marks: a push is a dot for each change it brought.
239
        let expanded: Vec<Event> = events
240
            .iter()
241
            .filter(|e| e.repo < repos.len() && e.age_days <= DAYS)
242
            .flat_map(|e| std::iter::repeat_n(*e, if e.kind == Kind::Push { e.n.clamp(1, PER_PUSH) as usize } else { 1 }))
243
            .take(MAX_MARKS)
244
            .collect();
245
        // A busy repository gets more lanes, so its marks spread out.
246
        let mut count = vec![0u32; repos.len()];
247
        for e in &expanded {
248
            count[e.repo] += 1;
249
        }
250
        let mut seen = vec![0u32; repos.len()];
251
        for e in &expanded {
252
            let k = seen[e.repo];
253
            seen[e.repo] += 1;
254
            let lanes = (6 + count[e.repo] / 30).min(14);
255
            let h = hash(&format!("{}#{k}", repos[e.repo].name));
256
            // Each repository turns one way; its marks keep their lanes, so
257
            // two clusters' streams cross without touching.
258
            let dir = if hash(&repos[e.repo].name) & 1 == 0 { 1.0 } else { -1.0 };
259
            // Lanes close together: each repository a tight knot.
260
            let radius = (16.0 + (k % lanes) as f32 * 6.5 + unit(h, 0) * 4.0) * ZOOM;
261
            let speed = dir * (MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 16));
262
            marks.push(Mark {
263
                cluster: e.repo,
264
                kind: e.kind,
265
                order: k,
266
                radius,
267
                angle: unit(h, 32) * TAU,
268
                speed,
269
                bright: 1.0 - 0.7 * (e.age_days / DAYS).clamp(0.0, 1.0),
270
                lit: 0.0,
271
            });
272
        }
273
        let mut sky = Sky {
274
            clusters,
275
            marks,
276
            w,
277
            h,
278
            cursor: None,
279
            rested: 0.0,
280
            still,
281
            avoid: None,
282
            parallax: [(0.0, 0.0); 3],
283
            link_fade: 0.0,
284
            rings: Vec::new(),
285
            arrived: 0,
286
        };
287
        sky.layout();
288
        sky
289
    }
290
291
    /// The view is now `w` by `h`, its content column between `avoid`'s
292
    /// left and right.
293
    pub fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>) {
294
        self.w = w;
295
        self.h = h;
296
        self.avoid = avoid;
297
        self.layout();
298
    }
299
300
    /// Place the clusters. In the margins beside the content when both are
301
    /// wide enough: repositories in the order of their names' hashes, split
302
    /// between the sides by their widths, each side a staggered grid from
303
    /// top to bottom, so the sky fills the space the page leaves. Otherwise
304
    /// a ring around the middle. The same repositories in the same view
305
    /// always land in the same places.
306
    fn layout(&mut self) {
307
        let n = self.clusters.len();
308
        let mut order: Vec<usize> = (0..n).collect();
309
        order.sort_by_key(|&i| hash(&self.clusters[i].name));
310
        let margins = self.avoid.and_then(|(x0, x1)| {
311
            let (l, r) = (x0.max(0.0), (self.w - x1).max(0.0));
312
            (l >= MIN_MARGIN && r >= MIN_MARGIN).then_some((l, r, x1))
313
        });
314
        // One repository's own sky (oxygenhub#196): its cluster sits in the
315
        // wider margin, a little below the middle, even a narrow one, so
316
        // it never lands on the page's heading or tabs.
317
        if n == 1 {
318
            if let Some((x0, x1)) = self.avoid {
319
                let (l, r) = (x0.max(0.0), (self.w - x1).max(0.0));
320
                if l.max(r) >= 40.0 && self.w > 0.0 {
321
                    let cx = if r >= l { x1 + r / 2.0 } else { l / 2.0 };
322
                    self.clusters[0].fx = cx / self.w;
323
                    self.clusters[0].fy = 0.6;
324
                    return;
325
                }
326
            }
327
        }
328
        match margins {
329
            Some((l, r, x1)) => {
330
                let left = (((n as f32) * l / (l + r)).round() as usize).min(n);
331
                let (a, b) = order.split_at(left);
332
                self.grid(a, 0.0, l);
333
                self.grid(b, x1, self.w);
334
            }
335
            None => {
336
                for c in &mut self.clusters {
337
                    let h = hash(&c.name);
338
                    let a = unit(h, 0) * TAU;
339
                    let d = 0.25 + 0.3 * unit(h, 16).sqrt();
340
                    c.fx = (0.5 + d * a.cos()).clamp(0.03, 0.97);
341
                    c.fy = (0.5 + 0.9 * d * a.sin()).clamp(0.05, 0.95);
342
                }
343
            }
344
        }
345
    }
346
347
    /// Lay `ids` out in a staggered grid between `x0` and `x1`.
348
    fn grid(&mut self, ids: &[usize], x0: f32, x1: f32) {
349
        if ids.is_empty() || self.w <= 0.0 || self.h <= 0.0 {
350
            return;
351
        }
352
        let (top, bottom) = (self.h * 0.1, self.h * 0.97);
353
        let width = x1 - x0;
354
        let cols = ((width / CELL) as usize).clamp(1, 3);
355
        let rows = ids.len().div_ceil(cols);
356
        let (cw, ch) = (width / cols as f32, (bottom - top) / rows as f32);
357
        for (j, &i) in ids.iter().enumerate() {
358
            let (col, row) = (j % cols, j / cols);
359
            let h = hash(&self.clusters[i].name);
360
            let stagger = if cols > 1 && row % 2 == 1 { cw * 0.25 } else { 0.0 };
361
            let cx = x0 + (col as f32 + 0.5) * cw + stagger + (unit(h, 0) - 0.5) * 0.3 * cw;
362
            let cy = top + (row as f32 + 0.5) * ch + (unit(h, 16) - 0.5) * 0.3 * ch;
363
            self.clusters[i].fx = cx.clamp(x0 + 24.0, x1 - 24.0) / self.w;
364
            self.clusters[i].fy = cy / self.h;
365
        }
366
    }
367
368
    /// The cursor moved to `(x, y)`, or left the page.
369
    pub fn point(&mut self, at: Option<(f32, f32)>) {
370
        self.cursor = at;
371
        self.rested = 0.0;
372
    }
373
374
    /// New activity in the repository `name` (§5, "Live"): its marks join
375
    /// its cluster as the newest, and one ring widens from its core. A
376
    /// repository the sky does not draw is ignored; a still sky adds the
377
    /// marks without the ring.
378
    pub fn arrive(&mut self, name: &str, kind: Kind, n: u32) {
379
        let Some(c) = self.clusters.iter().position(|cl| cl.name == name) else {
380
            return;
381
        };
382
        let add = if kind == Kind::Push { n.clamp(1, PER_PUSH) } else { 1 };
383
        for m in self.marks.iter_mut().filter(|m| m.cluster == c && m.kind != Kind::Repo) {
384
            m.order += add;
385
        }
386
        let have = self.marks.iter().filter(|m| m.cluster == c && m.kind != Kind::Repo).count() as u32;
387
        let lanes = (6 + (have + add) / 30).min(14);
388
        let dir = if hash(name) & 1 == 0 { 1.0 } else { -1.0 };
389
        for i in 0..add {
390
            let h = hash(&format!("{name}+{}", self.arrived));
391
            self.arrived += 1;
392
            let radius = (16.0 + ((have + i) % lanes) as f32 * 6.5 + unit(h, 0) * 4.0) * ZOOM;
393
            let speed = dir * (MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 16));
394
            self.marks.push(Mark { cluster: c, kind, order: add - 1 - i, radius, angle: unit(h, 32) * TAU, speed, bright: 1.0, lit: 0.0 });
395
        }
396
        // Over the limit, the cluster's oldest marks make room.
397
        while self.marks.len() > MAX_MARKS {
398
            let Some(oldest) = self
399
                .marks
400
                .iter()
401
                .enumerate()
402
                .filter(|(_, m)| m.cluster == c && m.kind != Kind::Repo)
403
                .max_by_key(|(_, m)| m.order)
404
                .map(|(i, _)| i)
405
            else {
406
                break;
407
            };
408
            self.marks.swap_remove(oldest);
409
        }
410
        if !self.still {
411
            self.rings.retain(|r| r.cluster != c);
412
            self.rings.push(Ring { cluster: c, age: 0.0 });
413
        }
414
    }
415
416
    /// Where cluster `c`'s core is now.
417
    pub fn core(&self, c: usize) -> (f32, f32) {
418
        let cl = &self.clusters[c];
419
        let (px, py) = self.parallax[cl.layer];
420
        (cl.fx * self.w + px, cl.fy * self.h + py)
421
    }
422
423
    /// Where mark `m` is now.
424
    pub fn position(&self, m: &Mark) -> (f32, f32) {
425
        let (cx, cy) = self.core(m.cluster);
426
        (cx + m.angle.cos() * m.radius, cy + m.angle.sin() * m.radius)
427
    }
428
429
    /// Move the scene on by `dt` seconds.
430
    pub fn step(&mut self, dt: f32) {
431
        let dt = dt.clamp(0.0, 0.1);
432
        self.rested += dt;
433
        for r in &mut self.rings {
434
            r.age += dt;
435
        }
436
        self.rings.retain(|r| r.age < RING_S);
437
        if !self.still {
438
            for m in &mut self.marks {
439
                m.angle = (m.angle + m.speed * dt) % TAU;
440
            }
441
            let ease = 1.0 - (-dt * 4.0).exp();
442
            for (l, p) in self.parallax.iter_mut().enumerate() {
443
                let (tx, ty) = match self.cursor {
444
                    Some((x, y)) if self.w > 0.0 && self.h > 0.0 => {
445
                        (-(x - self.w / 2.0) / (self.w / 2.0) * PARALLAX[l], -(y - self.h / 2.0) / (self.h / 2.0) * PARALLAX[l])
446
                    }
447
                    _ => (0.0, 0.0),
448
                };
449
                p.0 += (tx - p.0) * ease;
450
                p.1 += (ty - p.1) * ease;
451
            }
452
        }
453
        let lit_targets: Vec<f32> = self
454
            .marks
455
            .iter()
456
            .map(|m| match self.cursor {
457
                Some((x, y)) => {
458
                    let (mx, my) = self.position(m);
459
                    if (mx - x).hypot(my - y) <= LIGHT_RADIUS {
460
                        1.0
461
                    } else {
462
                        0.0
463
                    }
464
                }
465
                None => 0.0,
466
            })
467
            .collect();
468
        for (m, target) in self.marks.iter_mut().zip(lit_targets) {
469
            m.lit = if self.still {
470
                target
471
            } else if target > m.lit {
472
                (m.lit + dt / LIT_IN).min(target)
473
            } else {
474
                (m.lit - dt / LIT_OUT).max(target)
475
            };
476
        }
477
        let fade_to = if self.cursor.is_some() && self.rested < LINK_REST { 1.0 } else { 0.0 };
478
        self.link_fade = if self.still {
479
            fade_to
480
        } else if fade_to > self.link_fade {
481
            (self.link_fade + dt / LIT_IN).min(1.0)
482
        } else {
483
            (self.link_fade - dt / LIT_OUT).max(0.0)
484
        };
485
    }
486
487
    /// Whether anything besides the drift is still changing: lighting,
488
    /// links or a ring. The engine keeps drawing a moving sky for a while
489
    /// after the last input anyway (§6), then stops until something is.
490
    pub fn busy(&self) -> bool {
491
        !self.rings.is_empty() || self.marks.iter().any(|m| m.lit > 0.0 && m.lit < 1.0) || (self.link_fade > 0.0 && self.link_fade < 1.0)
492
    }
493
494
    /// One instance per mark: centre x, y and radius in CSS pixels, its
495
    /// shape, and its colour premultiplied, 8 floats each.
496
    pub fn instances(&self, c: &Colours, out: &mut Vec<f32>) {
497
        out.clear();
498
        for m in &self.marks {
499
            let (x, y) = self.position(m);
500
            let rest = [c.mark[0], c.mark[1], c.mark[2], c.mark[3] * m.bright];
501
            let col = mix(rest, c.lit, m.lit);
502
            let size = m.kind.size() * ZOOM * (1.0 + 0.35 * m.lit);
503
            out.extend_from_slice(&[x, y, size, m.kind.shape(), col[0] * col[3], col[1] * col[3], col[2] * col[3], col[3]]);
504
        }
505
        // Arrivals: a thin ring in the lit colour, widening as it fades.
506
        for r in &self.rings {
507
            let t = (r.age / RING_S).clamp(0.0, 1.0);
508
            let (x, y) = self.core(r.cluster);
509
            let size = (RING_FROM + (RING_TO - RING_FROM) * (1.0 - (1.0 - t) * (1.0 - t))) * ZOOM;
510
            let a = c.lit[3] * (1.0 - t) * 0.8;
511
            out.extend_from_slice(&[x, y, size, Kind::Repo.shape(), c.lit[0] * a, c.lit[1] * a, c.lit[2] * a, a]);
512
        }
513
    }
514
515
    /// The links among lit marks: each repository's lit marks in the order
516
    /// they happened, its core to its newest lit mark. Two vertices a
517
    /// link, x, y and a premultiplied colour each, 6 floats a vertex.
518
    pub fn links(&self, c: &Colours, out: &mut Vec<f32>) {
519
        out.clear();
520
        if self.link_fade <= 0.0 {
521
            return;
522
        }
523
        for ci in 0..self.clusters.len() {
524
            let mut lit: Vec<&Mark> = self.marks.iter().filter(|m| m.cluster == ci && m.lit > 0.05).collect();
525
            lit.sort_by_key(|m| (m.kind != Kind::Repo, m.order));
526
            for pair in lit.windows(2) {
527
                let (a, b) = (self.position(pair[0]), self.position(pair[1]));
528
                if (a.0 - b.0).hypot(a.1 - b.1) > LINK_MAX {
529
                    continue;
530
                }
531
                let alpha = c.link[3] * pair[0].lit.min(pair[1].lit) * self.link_fade;
532
                let col = [c.link[0] * alpha, c.link[1] * alpha, c.link[2] * alpha, alpha];
533
                out.extend_from_slice(&[a.0, a.1, col[0], col[1], col[2], col[3], b.0, b.1, col[0], col[1], col[2], col[3]]);
534
            }
535
        }
536
    }
537
538
    /// How far a cluster's marks reach from its core.
539
    fn reach(&self, c: usize) -> f32 {
540
        self.marks.iter().filter(|m| m.cluster == c).map(|m| m.radius).fold(0.0, f32::max)
541
    }
542
543
    /// The name to show for a resting cursor, and where: centred just
544
    /// above the nearest cluster within reach, clear of its marks.
545
    pub fn label(&self) -> Option<(&str, f32, f32)> {
546
        let (x, y) = self.cursor?;
547
        if self.rested < LABEL_REST {
548
            return None;
549
        }
550
        (0..self.clusters.len())
551
            .map(|i| (i, self.core(i)))
552
            .map(|(i, (cx, cy))| (i, cx, cy, (cx - x).hypot(cy - y)))
553
            .filter(|t| t.3 <= LABEL_NEAR)
554
            .min_by(|a, b| a.3.total_cmp(&b.3))
555
            .map(|(i, cx, cy, _)| (self.clusters[i].name.as_str(), cx, cy - self.reach(i) - 10.0))
556
    }
557
}
558
559
fn mix(a: Rgba, b: Rgba, t: f32) -> Rgba {
560
    let t = t.clamp(0.0, 1.0);
561
    [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t]
562
}
563
564
/// A computed CSS colour: `rgb(r, g, b)`, `rgba(r, g, b, a)` or
565
/// `color(srgb r g b / a)`, as browsers report them.
566
pub fn parse_colour(s: &str) -> Option<Rgba> {
567
    let s = s.trim();
568
    if let Some(inner) = s.strip_prefix("color(srgb").and_then(|r| r.strip_suffix(')')) {
569
        let (rgb, a) = match inner.split_once('/') {
570
            Some((rgb, a)) => (rgb, a.trim().parse().ok()?),
571
            None => (inner, 1.0),
572
        };
573
        let v: Vec<f32> = rgb.split_whitespace().map(|x| x.parse().ok()).collect::<Option<_>>()?;
574
        return (v.len() == 3).then(|| [v[0], v[1], v[2], a]);
575
    }
576
    let inner = s.strip_prefix("rgba(").or_else(|| s.strip_prefix("rgb("))?.strip_suffix(')')?;
577
    let v: Vec<f32> = inner.split([',', ' ', '/']).filter(|x| !x.is_empty()).map(|x| x.trim().parse().ok()).collect::<Option<_>>()?;
578
    match v.len() {
579
        3 => Some([v[0] / 255.0, v[1] / 255.0, v[2] / 255.0, 1.0]),
580
        4 => Some([v[0] / 255.0, v[1] / 255.0, v[2] / 255.0, v[3]]),
581
        _ => None,
582
    }
583
}
584
585
#[cfg(test)]
586
mod tests {
587
    use super::*;
588
589
    fn sky(still: bool) -> Sky {
590
        let repos = vec![Repo { name: "andy/hub".into() }, Repo { name: "andy/oxygen".into() }];
591
        let events = vec![
592
            Event { repo: 0, kind: Kind::Push, age_days: 0.0, n: 3 },
593
            Event { repo: 0, kind: Kind::Proposal, age_days: 2.0, n: 1 },
594
            Event { repo: 1, kind: Kind::Release, age_days: 29.0, n: 1 },
595
            Event { repo: 1, kind: Kind::Issue, age_days: 45.0, n: 1 },
596
        ];
597
        Sky::new(&repos, &events, 1600.0, 900.0, still)
598
    }
599
600
    #[test]
601
    fn a_sky_is_the_same_on_every_visit() {
602
        let (a, b) = (sky(false), sky(false));
603
        assert_eq!(a.clusters[0].fx, b.clusters[0].fx);
604
        assert_eq!(a.clusters[1].fy, b.clusters[1].fy);
605
        // A core per repository, a dot per change pushed, and one mark per
606
        // other event of the last 30 days.
607
        assert_eq!(a.marks.len(), 2 + 3 + 1 + 1);
608
        assert!(a.marks.iter().all(|m| m.kind != Kind::Issue), "older than 30 days: not drawn");
609
        let newest = a.marks.iter().find(|m| m.kind == Kind::Push).unwrap();
610
        let old = a.marks.iter().find(|m| m.kind == Kind::Release).unwrap();
611
        assert!(newest.bright > old.bright, "newer is brighter");
612
        assert_eq!(a.clusters[0].layer, 0, "the busiest in front");
613
    }
614
615
    #[test]
616
    fn marks_drift_slowly_and_the_cursor_lights_without_pulling() {
617
        let mut s = sky(false);
618
        for m in &s.marks {
619
            assert!(m.speed.abs() * m.radius <= MAX_SPEED + 0.01, "at most 12 px a second");
620
        }
621
        let target = s.position(&s.marks[2]);
622
        let radii: Vec<f32> = s.marks.iter().map(|m| m.radius).collect();
623
        s.point(Some(target));
624
        for _ in 0..30 {
625
            s.step(1.0 / 60.0);
626
        }
627
        assert!(s.marks[2].lit > 0.99, "lit near the cursor");
628
        assert_eq!(radii, s.marks.iter().map(|m| m.radius).collect::<Vec<_>>(), "nothing is pulled");
629
        let mut links = Vec::new();
630
        let colours = Colours { mark: [0.5, 0.5, 0.5, 0.2], lit: [0.0, 0.4, 0.9, 0.7], link: [0.0, 0.4, 0.9, 0.3], ..Default::default() };
631
        s.links(&colours, &mut links);
632
        assert!(!links.is_empty() && links.len() % 12 == 0, "lit marks of one repository are joined");
633
        // Resting fades the links; leaving fades the light.
634
        for _ in 0..240 {
635
            s.step(1.0 / 60.0);
636
        }
637
        s.links(&colours, &mut links);
638
        assert!(links.is_empty(), "a resting cursor's links fade");
639
        s.point(None);
640
        for _ in 0..60 {
641
            s.step(1.0 / 60.0);
642
        }
643
        assert!(s.marks.iter().all(|m| m.lit == 0.0));
644
    }
645
646
    #[test]
647
    fn still_means_no_drift_and_instant_light() {
648
        let mut s = sky(true);
649
        let before: Vec<f32> = s.marks.iter().map(|m| m.angle).collect();
650
        let at = s.position(&s.marks[2]);
651
        s.point(Some(at));
652
        s.step(1.0 / 60.0);
653
        assert_eq!(before, s.marks.iter().map(|m| m.angle).collect::<Vec<_>>());
654
        assert_eq!(s.marks[2].lit, 1.0);
655
        assert!(!s.busy(), "nothing left to animate");
656
    }
657
658
    #[test]
659
    fn instances_are_premultiplied_and_a_resting_cursor_names_a_cluster() {
660
        let mut s = sky(false);
661
        let colours = Colours { mark: [1.0, 1.0, 1.0, 0.5], lit: [0.0, 0.0, 1.0, 1.0], link: [0.0, 0.0, 1.0, 0.3], ..Default::default() };
662
        let mut inst = Vec::new();
663
        s.instances(&colours, &mut inst);
664
        assert_eq!(inst.len(), s.marks.len() * 8);
665
        let core = &inst[0..8];
666
        assert!((core[4] - core[7]).abs() < 1e-6 && core[7] < 0.5, "premultiplied, dimmed by brightness");
667
        let (cx, cy) = s.core(1);
668
        s.point(Some((cx + 10.0, cy)));
669
        assert!(s.label().is_none(), "not while moving");
670
        for _ in 0..10 {
671
            s.step(0.05);
672
        }
673
        assert_eq!(s.label().map(|l| l.0), Some("andy/oxygen"));
674
    }
675
676
    #[test]
677
    fn clusters_fill_the_margins_beside_the_content() {
678
        let repos: Vec<Repo> = (0..17).map(|i| Repo { name: format!("owner/repo{i}") }).collect();
679
        let mut s = Sky::new(&repos, &[], 2560.0, 1270.0, false);
680
        s.resize(2560.0, 1270.0, Some((707.0, 1838.0)));
681
        let xs: Vec<f32> = (0..17).map(|i| s.core(i).0).collect();
682
        assert!(xs.iter().all(|&x| x < 707.0 || x > 1838.0), "none behind the content: {xs:?}");
683
        let left = xs.iter().filter(|&&x| x < 707.0).count();
684
        assert!((7..=10).contains(&left), "both sides used: {left} on the left");
685
        let ys: Vec<f32> = (0..17).map(|i| s.core(i).1).collect();
686
        assert!(ys.iter().any(|&y| y < 400.0) && ys.iter().any(|&y| y > 900.0), "top to bottom: {ys:?}");
687
        // The same view lays the same sky out.
688
        let mut again = Sky::new(&repos, &[], 2560.0, 1270.0, false);
689
        again.resize(2560.0, 1270.0, Some((707.0, 1838.0)));
690
        assert_eq!(xs, (0..17).map(|i| again.core(i).0).collect::<Vec<_>>());
691
        // Narrow margins: back to the ring.
692
        s.resize(1200.0, 800.0, Some((100.0, 1100.0)));
693
        assert!((0..17).any(|i| (s.core(i).0 - 600.0).abs() < 400.0));
694
    }
695
696
    #[test]
697
    fn a_clusters_marks_spread_all_the_way_round() {
698
        let repos = vec![Repo { name: "doomsdayone/rstar-api".into() }];
699
        let events: Vec<Event> = (0..200).map(|_| Event { repo: 0, kind: Kind::Issue, age_days: 1.0, n: 1 }).collect();
700
        let s = Sky::new(&repos, &events, 1600.0, 900.0, true);
701
        // Every eighth of the circle holds a fair share of the marks.
702
        let mut octants = [0u32; 8];
703
        for m in s.marks.iter().filter(|m| m.kind == Kind::Issue) {
704
            octants[((m.angle / TAU * 8.0) as usize).min(7)] += 1;
705
        }
706
        assert!(octants.iter().all(|&n| (12..=40).contains(&n)), "{octants:?}");
707
    }
708
709
    #[test]
710
    fn one_repository_sits_in_the_wider_margin() {
711
        let repos = vec![Repo { name: "andy/demo".into() }];
712
        let mut s = Sky::new(&repos, &[], 1400.0, 900.0, false);
713
        s.resize(1400.0, 900.0, Some((127.0, 1259.0)));
714
        let (x, y) = s.core(0);
715
        assert!((x - (1259.0 + 141.0 / 2.0)).abs() < 8.0 && (y - 540.0).abs() < 8.0, "{x} {y}");
716
    }
717
718
    #[test]
719
    fn an_arrival_joins_its_cluster_with_one_ring() {
720
        let mut s = sky(false);
721
        let before = s.marks.iter().filter(|m| m.cluster == 1).count();
722
        s.arrive("andy/oxygen", Kind::Push, 2);
723
        s.arrive("someone/else", Kind::Issue, 1);
724
        let marks: Vec<&Mark> = s.marks.iter().filter(|m| m.cluster == 1).collect();
725
        assert_eq!(marks.len(), before + 2, "a dot for each change pushed; an unknown repository adds nothing");
726
        let newest: Vec<&&Mark> = marks.iter().filter(|m| m.kind == Kind::Push).collect();
727
        assert!(newest.iter().all(|m| m.order < 2 && m.bright == 1.0), "the newest, at full brightness");
728
        assert!(marks.iter().any(|m| m.kind == Kind::Release && m.order == 2), "older marks move back");
729
        assert_eq!(s.rings.len(), 1);
730
        assert!(s.busy(), "a ring is drawn while it widens");
731
        let colours = Colours { lit: [0.0, 0.4, 0.9, 0.7], ..Default::default() };
732
        let mut inst = Vec::new();
733
        s.instances(&colours, &mut inst);
734
        assert_eq!(inst.len(), (s.marks.len() + 1) * 8, "the ring is one more instance");
735
        for _ in 0..80 {
736
            s.step(1.0 / 60.0);
737
        }
738
        assert!(s.rings.is_empty() && !s.busy(), "gone after 1.2 s, and nothing left to draw but the drift");
739
        // A still sky takes the marks without the ring.
740
        let mut still = sky(true);
741
        still.arrive("andy/hub", Kind::Release, 1);
742
        assert!(still.rings.is_empty());
743
        assert_eq!(still.marks.iter().filter(|m| m.kind == Kind::Release).count(), 2);
744
    }
745
746
    #[test]
747
    fn computed_colours_parse() {
748
        assert_eq!(parse_colour("rgb(9, 105, 218)"), Some([9.0 / 255.0, 105.0 / 255.0, 218.0 / 255.0, 1.0]));
749
        assert_eq!(parse_colour("rgba(0, 240, 255, 0.75)").map(|c| c[3]), Some(0.75));
750
        assert_eq!(parse_colour("color(srgb 0 0.5 1 / 0.2)"), Some([0.0, 0.5, 1.0, 0.2]));
751
        assert_eq!(parse_colour("rgb(1 2 3 / 0.5)").map(|c| c[3]), Some(0.5));
752
        assert!(parse_colour("transparent").is_none());
753
    }
754
}