OXYGENHUB
# 26d0d091deb7 oxygenhub / crates / backdrop / src / sky.rs
17.7 KiB · Rust · last changed feat(web): Backdrops, the engine in Rust and WASM, and the dashboard's Your sky (refs #157) 23 days ago source blame history raw
17ca6cdd Andy McCall ↶ 1
//! Your sky (docs/backgrounds.md §5): the dashboard's scene, its layout
2
//! and its motion, with no browser in it so it is tested natively.
3
//!
4
//! Each repository is a cluster placed by a hash of its name, so a
5
//! viewer's sky is the same on every visit; its events of the last 30 days
6
//! orbit it as marks, brightest when newest, shaped by kind. The cursor
7
//! lights marks near it without moving them, and joins the lit marks of
8
//! one repository in the order they happened.
9
10
use std::f32::consts::TAU;
11
12
/// How far the cursor lights marks, in CSS pixels (§4).
13
pub const LIGHT_RADIUS: f32 = 140.0;
14
/// Seconds a mark takes to light, and to fade.
15
const LIT_IN: f32 = 0.15;
16
const LIT_OUT: f32 = 0.6;
17
/// The fastest a mark drifts, in CSS pixels a second.
18
pub const MAX_SPEED: f32 = 12.0;
19
/// How far each depth layer shifts against the cursor, front to back.
20
const PARALLAX: [f32; 3] = [6.0, 3.0, 1.0];
21
/// Links fade when the cursor has rested this long, in seconds.
22
const LINK_REST: f32 = 2.0;
23
/// A resting cursor this near a cluster's core names it, after this long.
24
const LABEL_NEAR: f32 = 90.0;
25
const LABEL_REST: f32 = 0.4;
26
/// Events older than this many days are not drawn.
27
pub const DAYS: f32 = 30.0;
28
29
/// What a mark stands for, which decides its shape.
30
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31
pub enum Kind {
32
    Push,
33
    Proposal,
34
    Issue,
35
    Release,
36
    /// The repository itself, its cluster's core.
37
    Repo,
38
}
39
40
impl Kind {
41
    /// The code the page's data uses: 0 push, 1 proposal, 2 issue,
42
    /// 3 release.
43
    pub fn from_code(code: u32) -> Kind {
44
        match code {
45
            1 => Kind::Proposal,
46
            2 => Kind::Issue,
47
            3 => Kind::Release,
48
            _ => Kind::Push,
49
        }
50
    }
51
52
    /// The shape the fragment shader draws: 0 dot, 1 diamond, 2 ring,
53
    /// 3 four-point star, 4 thin ring (a repository).
54
    pub fn shape(self) -> f32 {
55
        match self {
56
            Kind::Push => 0.0,
57
            Kind::Proposal => 1.0,
58
            Kind::Issue => 2.0,
59
            Kind::Release => 3.0,
60
            Kind::Repo => 4.0,
61
        }
62
    }
63
64
    /// Its radius in CSS pixels.
65
    fn size(self) -> f32 {
66
        match self {
67
            Kind::Push => 2.2,
68
            Kind::Proposal => 3.2,
69
            Kind::Issue => 3.0,
70
            Kind::Release => 4.2,
71
            Kind::Repo => 6.0,
72
        }
73
    }
74
}
75
76
/// One repository, as the page gives it.
77
#[derive(Clone, Debug)]
78
pub struct Repo {
79
    pub name: String,
80
}
81
82
/// One event, as the page gives it: its repository (an index into the
83
/// repositories), its kind and how old it is.
84
#[derive(Clone, Copy, Debug)]
85
pub struct Event {
86
    pub repo: usize,
87
    pub kind: Kind,
88
    pub age_days: f32,
89
}
90
91
/// A repository's place: a fraction of the view, and a depth layer.
92
#[derive(Clone, Debug)]
93
pub struct Cluster {
94
    pub name: String,
95
    pub fx: f32,
96
    pub fy: f32,
97
    pub layer: usize,
98
}
99
100
/// One drawn mark, orbiting its cluster.
101
#[derive(Clone, Debug)]
102
pub struct Mark {
103
    pub cluster: usize,
104
    pub kind: Kind,
105
    /// Its place in its cluster's history, 0 the newest; the core is 0
106
    /// too and has radius 0.
107
    pub order: u32,
108
    pub radius: f32,
109
    pub angle: f32,
110
    /// Radians a second, signed.
111
    pub speed: f32,
112
    /// 1 for today, fading to 0.3 for a month ago.
113
    pub bright: f32,
114
    /// How lit it is now, 0 to 1.
115
    pub lit: f32,
116
}
117
118
/// Straight RGBA, 0 to 1, as the page's tokens give them.
119
pub type Rgba = [f32; 4];
120
121
/// The three backdrop tokens (§3).
122
#[derive(Clone, Copy, Debug, PartialEq)]
123
pub struct Colours {
124
    pub mark: Rgba,
125
    pub lit: Rgba,
126
    pub link: Rgba,
127
}
128
129
/// The scene.
130
pub struct Sky {
131
    pub clusters: Vec<Cluster>,
132
    pub marks: Vec<Mark>,
133
    /// The view, in CSS pixels.
134
    pub w: f32,
135
    pub h: f32,
136
    /// Where the cursor is, if it is over the page.
137
    pub cursor: Option<(f32, f32)>,
138
    /// Seconds since the cursor last moved.
139
    pub rested: f32,
140
    /// Still (reduced motion, or the viewer's choice): no drift, no
141
    /// parallax, and marks light at once.
142
    pub still: bool,
143
    parallax: [(f32, f32); 3],
144
    link_fade: f32,
145
}
146
147
/// FNV-1a: a small, stable hash, so a repository's place never changes.
148
pub fn hash(s: &str) -> u64 {
149
    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
150
    for b in s.as_bytes() {
151
        h ^= *b as u64;
152
        h = h.wrapping_mul(0x0100_0000_01b3);
153
    }
154
    h
155
}
156
157
/// A number from 0 to 1 from bits of a hash.
158
fn unit(h: u64, shift: u32) -> f32 {
159
    ((h >> shift) & 0xffff) as f32 / 65536.0
160
}
161
162
impl Sky {
163
    /// The sky of `repos` and their `events` (newest first, as the page
164
    /// lists them), in a view of `w` by `h` CSS pixels.
165
    pub fn new(repos: &[Repo], events: &[Event], w: f32, h: f32, still: bool) -> Sky {
166
        // The most active repositories in front, the quiet ones behind.
167
        let mut activity = vec![0u32; repos.len()];
168
        for e in events {
169
            if e.repo < repos.len() && e.age_days <= DAYS {
170
                activity[e.repo] += 1;
171
            }
172
        }
173
        let mut rank: Vec<usize> = (0..repos.len()).collect();
174
        rank.sort_by_key(|&i| std::cmp::Reverse(activity[i]));
175
        let mut layer = vec![2usize; repos.len()];
176
        for (pos, &i) in rank.iter().enumerate() {
177
            layer[i] = (pos * 3 / repos.len().max(1)).min(2);
178
        }
179
        let clusters: Vec<Cluster> = repos
180
            .iter()
181
            .enumerate()
182
            .map(|(i, r)| {
183
                let h = hash(&r.name);
184
                // Around the page, mostly in its margins: a ring from a
185
                // quarter to just past half of the view out.
186
                let a = unit(h, 0) * TAU;
187
                let d = 0.25 + 0.3 * unit(h, 16).sqrt();
188
                Cluster {
189
                    name: r.name.clone(),
190
                    fx: (0.5 + d * a.cos()).clamp(0.03, 0.97),
191
                    fy: (0.5 + 0.9 * d * a.sin()).clamp(0.05, 0.95),
192
                    layer: layer[i],
193
                }
194
            })
195
            .collect();
196
        let mut marks: Vec<Mark> = Vec::new();
197
        for i in 0..clusters.len() {
198
            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 });
199
        }
200
        let mut seen = vec![0u32; repos.len()];
201
        for e in events {
202
            if e.repo >= repos.len() || e.age_days > DAYS {
203
                continue;
204
            }
205
            let k = seen[e.repo];
206
            seen[e.repo] += 1;
207
            let h = hash(&format!("{}#{k}", repos[e.repo].name));
208
            // Each repository turns one way; its marks keep their lanes, so
209
            // two clusters' streams cross without touching.
210
            let dir = if hash(&repos[e.repo].name) & 1 == 0 { 1.0 } else { -1.0 };
211
            let radius = 22.0 + (k % 6) as f32 * 11.0 + unit(h, 0) * 6.0;
212
            let speed = dir * (MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 16));
213
            marks.push(Mark {
214
                cluster: e.repo,
215
                kind: e.kind,
216
                order: k,
217
                radius,
218
                angle: unit(h, 32) * TAU,
219
                speed,
220
                bright: 1.0 - 0.7 * (e.age_days / DAYS).clamp(0.0, 1.0),
221
                lit: 0.0,
222
            });
223
        }
224
        Sky { clusters, marks, w, h, cursor: None, rested: 0.0, still, parallax: [(0.0, 0.0); 3], link_fade: 0.0 }
225
    }
226
227
    pub fn resize(&mut self, w: f32, h: f32) {
228
        self.w = w;
229
        self.h = h;
230
    }
231
232
    /// The cursor moved to `(x, y)`, or left the page.
233
    pub fn point(&mut self, at: Option<(f32, f32)>) {
234
        self.cursor = at;
235
        self.rested = 0.0;
236
    }
237
238
    /// Where cluster `c`'s core is now.
239
    pub fn core(&self, c: usize) -> (f32, f32) {
240
        let cl = &self.clusters[c];
241
        let (px, py) = self.parallax[cl.layer];
242
        (cl.fx * self.w + px, cl.fy * self.h + py)
243
    }
244
245
    /// Where mark `m` is now.
246
    pub fn position(&self, m: &Mark) -> (f32, f32) {
247
        let (cx, cy) = self.core(m.cluster);
248
        (cx + m.angle.cos() * m.radius, cy + m.angle.sin() * m.radius)
249
    }
250
251
    /// Move the scene on by `dt` seconds.
252
    pub fn step(&mut self, dt: f32) {
253
        let dt = dt.clamp(0.0, 0.1);
254
        self.rested += dt;
255
        if !self.still {
256
            for m in &mut self.marks {
257
                m.angle = (m.angle + m.speed * dt) % TAU;
258
            }
259
            let ease = 1.0 - (-dt * 4.0).exp();
260
            for (l, p) in self.parallax.iter_mut().enumerate() {
261
                let (tx, ty) = match self.cursor {
262
                    Some((x, y)) if self.w > 0.0 && self.h > 0.0 => {
263
                        (-(x - self.w / 2.0) / (self.w / 2.0) * PARALLAX[l], -(y - self.h / 2.0) / (self.h / 2.0) * PARALLAX[l])
264
                    }
265
                    _ => (0.0, 0.0),
266
                };
267
                p.0 += (tx - p.0) * ease;
268
                p.1 += (ty - p.1) * ease;
269
            }
270
        }
271
        let lit_targets: Vec<f32> = self
272
            .marks
273
            .iter()
274
            .map(|m| match self.cursor {
275
                Some((x, y)) => {
276
                    let (mx, my) = self.position(m);
277
                    if (mx - x).hypot(my - y) <= LIGHT_RADIUS {
278
                        1.0
279
                    } else {
280
                        0.0
281
                    }
282
                }
283
                None => 0.0,
284
            })
285
            .collect();
286
        for (m, target) in self.marks.iter_mut().zip(lit_targets) {
287
            m.lit = if self.still {
288
                target
289
            } else if target > m.lit {
290
                (m.lit + dt / LIT_IN).min(target)
291
            } else {
292
                (m.lit - dt / LIT_OUT).max(target)
293
            };
294
        }
295
        let fade_to = if self.cursor.is_some() && self.rested < LINK_REST { 1.0 } else { 0.0 };
296
        self.link_fade = if self.still {
297
            fade_to
298
        } else if fade_to > self.link_fade {
299
            (self.link_fade + dt / LIT_IN).min(1.0)
300
        } else {
301
            (self.link_fade - dt / LIT_OUT).max(0.0)
302
        };
303
    }
304
305
    /// Whether anything is still changing: a frame is worth drawing.
306
    pub fn busy(&self) -> bool {
307
        !self.still || self.marks.iter().any(|m| m.lit > 0.0 && m.lit < 1.0) || (self.link_fade > 0.0 && self.link_fade < 1.0)
308
    }
309
310
    /// One instance per mark: centre x, y and radius in CSS pixels, its
311
    /// shape, and its colour premultiplied, 8 floats each.
312
    pub fn instances(&self, c: &Colours, out: &mut Vec<f32>) {
313
        out.clear();
314
        for m in &self.marks {
315
            let (x, y) = self.position(m);
316
            let rest = [c.mark[0], c.mark[1], c.mark[2], c.mark[3] * m.bright];
317
            let col = mix(rest, c.lit, m.lit);
318
            let size = m.kind.size() * (1.0 + 0.35 * m.lit);
319
            out.extend_from_slice(&[x, y, size, m.kind.shape(), col[0] * col[3], col[1] * col[3], col[2] * col[3], col[3]]);
320
        }
321
    }
322
323
    /// The links among lit marks: each repository's lit marks in the order
324
    /// they happened, its core to its newest lit mark. Two vertices a
325
    /// link, x, y and a premultiplied colour each, 6 floats a vertex.
326
    pub fn links(&self, c: &Colours, out: &mut Vec<f32>) {
327
        out.clear();
328
        if self.link_fade <= 0.0 {
329
            return;
330
        }
331
        for ci in 0..self.clusters.len() {
332
            let mut lit: Vec<&Mark> = self.marks.iter().filter(|m| m.cluster == ci && m.lit > 0.05).collect();
333
            lit.sort_by_key(|m| (m.kind != Kind::Repo, m.order));
334
            for pair in lit.windows(2) {
335
                let (a, b) = (self.position(pair[0]), self.position(pair[1]));
336
                if (a.0 - b.0).hypot(a.1 - b.1) > LIGHT_RADIUS * 1.2 {
337
                    continue;
338
                }
339
                let alpha = c.link[3] * pair[0].lit.min(pair[1].lit) * self.link_fade;
340
                let col = [c.link[0] * alpha, c.link[1] * alpha, c.link[2] * alpha, alpha];
341
                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]]);
342
            }
343
        }
344
    }
345
346
    /// The name to show beside a resting cursor, and where: the nearest
347
    /// cluster core within reach.
348
    pub fn label(&self) -> Option<(&str, f32, f32)> {
349
        let (x, y) = self.cursor?;
350
        if self.rested < LABEL_REST {
351
            return None;
352
        }
353
        (0..self.clusters.len())
354
            .map(|i| (i, self.core(i)))
355
            .map(|(i, (cx, cy))| (i, cx, cy, (cx - x).hypot(cy - y)))
356
            .filter(|t| t.3 <= LABEL_NEAR)
357
            .min_by(|a, b| a.3.total_cmp(&b.3))
358
            .map(|(i, cx, cy, _)| (self.clusters[i].name.as_str(), cx, cy))
359
    }
360
}
361
362
fn mix(a: Rgba, b: Rgba, t: f32) -> Rgba {
363
    let t = t.clamp(0.0, 1.0);
364
    [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]
365
}
366
367
/// A computed CSS colour: `rgb(r, g, b)`, `rgba(r, g, b, a)` or
368
/// `color(srgb r g b / a)`, as browsers report them.
369
pub fn parse_colour(s: &str) -> Option<Rgba> {
370
    let s = s.trim();
371
    if let Some(inner) = s.strip_prefix("color(srgb").and_then(|r| r.strip_suffix(')')) {
372
        let (rgb, a) = match inner.split_once('/') {
373
            Some((rgb, a)) => (rgb, a.trim().parse().ok()?),
374
            None => (inner, 1.0),
375
        };
376
        let v: Vec<f32> = rgb.split_whitespace().map(|x| x.parse().ok()).collect::<Option<_>>()?;
377
        return (v.len() == 3).then(|| [v[0], v[1], v[2], a]);
378
    }
379
    let inner = s.strip_prefix("rgba(").or_else(|| s.strip_prefix("rgb("))?.strip_suffix(')')?;
380
    let v: Vec<f32> = inner.split([',', ' ', '/']).filter(|x| !x.is_empty()).map(|x| x.trim().parse().ok()).collect::<Option<_>>()?;
381
    match v.len() {
382
        3 => Some([v[0] / 255.0, v[1] / 255.0, v[2] / 255.0, 1.0]),
383
        4 => Some([v[0] / 255.0, v[1] / 255.0, v[2] / 255.0, v[3]]),
384
        _ => None,
385
    }
386
}
387
388
#[cfg(test)]
389
mod tests {
390
    use super::*;
391
392
    fn sky(still: bool) -> Sky {
393
        let repos = vec![Repo { name: "andy/hub".into() }, Repo { name: "andy/oxygen".into() }];
394
        let events = vec![
395
            Event { repo: 0, kind: Kind::Push, age_days: 0.0 },
396
            Event { repo: 0, kind: Kind::Proposal, age_days: 2.0 },
397
            Event { repo: 1, kind: Kind::Release, age_days: 29.0 },
398
            Event { repo: 1, kind: Kind::Issue, age_days: 45.0 },
399
        ];
400
        Sky::new(&repos, &events, 1600.0, 900.0, still)
401
    }
402
403
    #[test]
404
    fn a_sky_is_the_same_on_every_visit() {
405
        let (a, b) = (sky(false), sky(false));
406
        assert_eq!(a.clusters[0].fx, b.clusters[0].fx);
407
        assert_eq!(a.clusters[1].fy, b.clusters[1].fy);
408
        // A core per repository, and one mark per event of the last 30 days.
409
        assert_eq!(a.marks.len(), 2 + 3);
410
        assert!(a.marks.iter().all(|m| m.kind != Kind::Issue), "older than 30 days: not drawn");
411
        let newest = a.marks.iter().find(|m| m.kind == Kind::Push).unwrap();
412
        let old = a.marks.iter().find(|m| m.kind == Kind::Release).unwrap();
413
        assert!(newest.bright > old.bright, "newer is brighter");
414
        assert_eq!(a.clusters[0].layer, 0, "the busiest in front");
415
    }
416
417
    #[test]
418
    fn marks_drift_slowly_and_the_cursor_lights_without_pulling() {
419
        let mut s = sky(false);
420
        for m in &s.marks {
421
            assert!(m.speed.abs() * m.radius <= MAX_SPEED + 0.01, "at most 12 px a second");
422
        }
423
        let target = s.position(&s.marks[2]);
424
        let radii: Vec<f32> = s.marks.iter().map(|m| m.radius).collect();
425
        s.point(Some(target));
426
        for _ in 0..30 {
427
            s.step(1.0 / 60.0);
428
        }
429
        assert!(s.marks[2].lit > 0.99, "lit near the cursor");
430
        assert_eq!(radii, s.marks.iter().map(|m| m.radius).collect::<Vec<_>>(), "nothing is pulled");
431
        let mut links = Vec::new();
432
        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] };
433
        s.links(&colours, &mut links);
434
        assert!(!links.is_empty() && links.len() % 12 == 0, "lit marks of one repository are joined");
435
        // Resting fades the links; leaving fades the light.
436
        for _ in 0..240 {
437
            s.step(1.0 / 60.0);
438
        }
439
        s.links(&colours, &mut links);
440
        assert!(links.is_empty(), "a resting cursor's links fade");
441
        s.point(None);
442
        for _ in 0..60 {
443
            s.step(1.0 / 60.0);
444
        }
445
        assert!(s.marks.iter().all(|m| m.lit == 0.0));
446
    }
447
448
    #[test]
449
    fn still_means_no_drift_and_instant_light() {
450
        let mut s = sky(true);
451
        let before: Vec<f32> = s.marks.iter().map(|m| m.angle).collect();
452
        let at = s.position(&s.marks[2]);
453
        s.point(Some(at));
454
        s.step(1.0 / 60.0);
455
        assert_eq!(before, s.marks.iter().map(|m| m.angle).collect::<Vec<_>>());
456
        assert_eq!(s.marks[2].lit, 1.0);
457
        assert!(!s.busy(), "nothing left to animate");
458
    }
459
460
    #[test]
461
    fn instances_are_premultiplied_and_a_resting_cursor_names_a_cluster() {
462
        let mut s = sky(false);
463
        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] };
464
        let mut inst = Vec::new();
465
        s.instances(&colours, &mut inst);
466
        assert_eq!(inst.len(), s.marks.len() * 8);
467
        let core = &inst[0..8];
468
        assert!((core[4] - core[7]).abs() < 1e-6 && core[7] < 0.5, "premultiplied, dimmed by brightness");
469
        let (cx, cy) = s.core(1);
470
        s.point(Some((cx + 10.0, cy)));
471
        assert!(s.label().is_none(), "not while moving");
472
        for _ in 0..10 {
473
            s.step(0.05);
474
        }
475
        assert_eq!(s.label().map(|l| l.0), Some("andy/oxygen"));
476
    }
477
478
    #[test]
479
    fn computed_colours_parse() {
480
        assert_eq!(parse_colour("rgb(9, 105, 218)"), Some([9.0 / 255.0, 105.0 / 255.0, 218.0 / 255.0, 1.0]));
481
        assert_eq!(parse_colour("rgba(0, 240, 255, 0.75)").map(|c| c[3]), Some(0.75));
482
        assert_eq!(parse_colour("color(srgb 0 0.5 1 / 0.2)"), Some([0.0, 0.5, 1.0, 0.2]));
483
        assert_eq!(parse_colour("rgb(1 2 3 / 0.5)").map(|c| c[3]), Some(0.5));
484
        assert!(parse_colour("transparent").is_none());
485
    }
486
}