feat(web): the mark, ripples, the swarm and adrift (closes #167) 6246d1e51f4f
The engine gains a Scene trait and a shared field (dots placed by their scene each frame, lit, linked in groups, named on rest, in the backdrop tokens or the page's status colours), and four scenes beside the sky: the Oxygen mark's two rings turning opposite ways behind sign in, register and device sign-in; ripples on a repository's Diffusion tab, its upstreams flowing in and changes out to its consumers in their pills' tones, a ripple widening every five seconds; the swarm on Admin › Runners › Images (now standard width, for margins), chunks streaming to a fetching runner from every holder; and one mark drifting from its cluster on a page not found or a server error. Each page names its scene in #backdrop-data.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-13 20:18 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
6246d1e51f4fdb99485662612d4148901d1456d34c65f0ecf80643da769d634e- Depends on
- 0a3b244b748f 17ca6cddc534 18e381ea88c2 19bb31ff1f5c 25ab8c73514a 2f14c5f39e81 33bd0bd45f8a 42fa95540468 61ecbd299f22 6907a989e82a 6ca0b971ff07 757195ad7a5d b5668108487f b6b7e4cf6b24 d23464c0ac60 df5c69cbd047 df71c7892d27 eb56c8bc4d0e
- Depended on by
- 737de0fac722 da8f7826a645 5501b1c657b2 f993f792537e
- 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
- 64 atoms, 6 tree operations, 25 files
Browse the tree at this change
added crates/backdrop/src/adrift.rs +141 −0
| 1 | + | //! Adrift (docs/backgrounds.md §2): the not-found and server-error pages. |
|
| 2 | + | //! A quiet cluster in the margin, and one mark drifting away from it, as |
|
| 3 | + | //! the page drifted from what was asked for. It drifts for a minute, fades |
|
| 4 | + | //! and sets out again. No data. |
|
| 5 | + | ||
| 6 | + | use crate::field::{margins, unit, Dot, Field}; |
|
| 7 | + | use crate::scene::Scene; |
|
| 8 | + | use crate::sky::{hash, Colours, MAX_SPEED}; |
|
| 9 | + | use std::f32::consts::TAU; |
|
| 10 | + | ||
| 11 | + | const CLUSTER: usize = 70; |
|
| 12 | + | /// The lost mark's speed, and how long one drift lasts, fade included. |
|
| 13 | + | const DRIFT_SPEED: f32 = 7.0; |
|
| 14 | + | const DRIFT_S: f32 = 70.0; |
|
| 15 | + | const FADE_S: f32 = 5.0; |
|
| 16 | + | ||
| 17 | + | struct Orbit { |
|
| 18 | + | radius: f32, |
|
| 19 | + | angle: f32, |
|
| 20 | + | speed: f32, |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | pub struct Adrift { |
|
| 24 | + | field: Field, |
|
| 25 | + | orbits: Vec<Orbit>, |
|
| 26 | + | centre: (f32, f32), |
|
| 27 | + | w: f32, |
|
| 28 | + | h: f32, |
|
| 29 | + | t: f32, |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | impl Adrift { |
|
| 33 | + | pub fn new(still: bool) -> Adrift { |
|
| 34 | + | let mut field = Field::new(still); |
|
| 35 | + | let mut orbits = Vec::new(); |
|
| 36 | + | 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 }); |
|
| 41 | + | d.bright = 0.5 + 0.5 * unit(h, 48); |
|
| 42 | + | d.group = 0; |
|
| 43 | + | d.order = k as u32; |
|
| 44 | + | field.dots.push(d); |
|
| 45 | + | } |
|
| 46 | + | // The cluster's core, and the lost mark, a little larger. |
|
| 47 | + | 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); |
|
| 49 | + | lost.bright = 1.0; |
|
| 50 | + | field.dots.push(lost); |
|
| 51 | + | let mut a = Adrift { field, orbits, centre: (0.0, 0.0), w: 0.0, h: 0.0, t: 12.0 }; |
|
| 52 | + | a.place(); |
|
| 53 | + | a |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | fn place(&mut self) { |
|
| 57 | + | let (cx, cy) = self.centre; |
|
| 58 | + | 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; |
|
| 62 | + | } |
|
| 63 | + | let core = &mut self.field.dots[CLUSTER]; |
|
| 64 | + | core.x = cx; |
|
| 65 | + | core.y = cy; |
|
| 66 | + | // The lost mark leaves up and to the right, fading at the end of |
|
| 67 | + | // each drift and setting out again from the cluster's edge. |
|
| 68 | + | let p = self.t % DRIFT_S; |
|
| 69 | + | let dir: f32 = -0.35; |
|
| 70 | + | let from = 70.0 * 1.4; |
|
| 71 | + | let lost = &mut self.field.dots[CLUSTER + 1]; |
|
| 72 | + | lost.x = cx + dir.cos() * (from + p * DRIFT_SPEED); |
|
| 73 | + | lost.y = cy + dir.sin() * (from + p * DRIFT_SPEED); |
|
| 74 | + | lost.bright = ((DRIFT_S - p) / FADE_S).min(p / FADE_S).clamp(0.0, 1.0); |
|
| 75 | + | } |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | impl Scene for Adrift { |
|
| 79 | + | fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>) { |
|
| 80 | + | self.w = w; |
|
| 81 | + | self.h = h; |
|
| 82 | + | self.centre = match margins(w, avoid) { |
|
| 83 | + | Some((l, _)) => (l * 0.45, h * 0.62), |
|
| 84 | + | None => (w * 0.15, h * 0.7), |
|
| 85 | + | }; |
|
| 86 | + | self.place(); |
|
| 87 | + | } |
|
| 88 | + | fn point(&mut self, at: Option<(f32, f32)>) { |
|
| 89 | + | self.field.point(at); |
|
| 90 | + | } |
|
| 91 | + | fn step(&mut self, dt: f32) { |
|
| 92 | + | if !self.field.still { |
|
| 93 | + | self.t += dt.clamp(0.0, 0.1); |
|
| 94 | + | } |
|
| 95 | + | self.place(); |
|
| 96 | + | self.field.light(dt); |
|
| 97 | + | } |
|
| 98 | + | fn instances(&self, c: &Colours, out: &mut Vec<f32>) { |
|
| 99 | + | self.field.instances(c, out) |
|
| 100 | + | } |
|
| 101 | + | fn links(&self, c: &Colours, out: &mut Vec<f32>) { |
|
| 102 | + | self.field.links(c, out) |
|
| 103 | + | } |
|
| 104 | + | fn label(&self) -> Option<(&str, f32, f32)> { |
|
| 105 | + | None |
|
| 106 | + | } |
|
| 107 | + | fn busy(&self) -> bool { |
|
| 108 | + | !self.field.still || self.field.easing() |
|
| 109 | + | } |
|
| 110 | + | fn still(&self) -> bool { |
|
| 111 | + | self.field.still |
|
| 112 | + | } |
|
| 113 | + | fn size(&self) -> (f32, f32) { |
|
| 114 | + | (self.w, self.h) |
|
| 115 | + | } |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | #[cfg(test)] |
|
| 119 | + | mod tests { |
|
| 120 | + | use super::*; |
|
| 121 | + | ||
| 122 | + | #[test] |
|
| 123 | + | fn one_mark_drifts_away_from_the_cluster_and_sets_out_again() { |
|
| 124 | + | let mut a = Adrift::new(false); |
|
| 125 | + | a.resize(2560.0, 1270.0, Some((707.0, 1838.0))); |
|
| 126 | + | assert!(a.centre.0 < 707.0, "the cluster sits in the margin"); |
|
| 127 | + | let dist = |a: &Adrift| { |
|
| 128 | + | let l = &a.field.dots[CLUSTER + 1]; |
|
| 129 | + | (l.x - a.centre.0).hypot(l.y - a.centre.1) |
|
| 130 | + | }; |
|
| 131 | + | let early = dist(&a); |
|
| 132 | + | for _ in 0..200 { |
|
| 133 | + | a.step(0.1); |
|
| 134 | + | } |
|
| 135 | + | assert!(dist(&a) > early + 100.0, "it drifts away"); |
|
| 136 | + | for _ in 0..500 { |
|
| 137 | + | a.step(0.1); |
|
| 138 | + | } |
|
| 139 | + | assert!(dist(&a) < early + 100.0, "and sets out again"); |
|
| 140 | + | } |
|
| 141 | + | } |
added crates/backdrop/src/field.rs +245 −0
| 1 | + | //! What every scene but the sky shares (docs/backgrounds.md §4): a field |
|
| 2 | + | //! of dots that its scene places each frame, lit by the cursor without |
|
| 3 | + | //! being moved, joined within a group in order, and named things that a |
|
| 4 | + | //! resting cursor names. Dots of a scene with a status (Diffusion's pills) |
|
| 5 | + | //! take the page's status colours; every other dot the backdrop tokens. |
|
| 6 | + | ||
| 7 | + | use crate::sky::{Colours, Rgba}; |
|
| 8 | + | ||
| 9 | + | /// How far the cursor lights dots, in CSS pixels, as in the sky. |
|
| 10 | + | pub const LIGHT_RADIUS: f32 = 170.0; |
|
| 11 | + | const LIT_IN: f32 = 0.15; |
|
| 12 | + | const LIT_OUT: f32 = 0.6; |
|
| 13 | + | /// The longest link drawn between two dots of a group. |
|
| 14 | + | const LINK_MAX: f32 = 85.0; |
|
| 15 | + | const LINK_REST: f32 = 2.0; |
|
| 16 | + | const LABEL_NEAR: f32 = 110.0; |
|
| 17 | + | const LABEL_REST: f32 = 0.4; |
|
| 18 | + | ||
| 19 | + | /// A dot's colour: the backdrop's own, or a status where the data has one. |
|
| 20 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 21 | + | pub enum Tone { |
|
| 22 | + | Mark, |
|
| 23 | + | Ok, |
|
| 24 | + | Warn, |
|
| 25 | + | Bad, |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | impl Tone { |
|
| 29 | + | /// A Diffusion pill half's tone as the page names it. |
|
| 30 | + | pub fn from_name(s: &str) -> Tone { |
|
| 31 | + | match s { |
|
| 32 | + | "green" => Tone::Ok, |
|
| 33 | + | "amber" => Tone::Warn, |
|
| 34 | + | "red" => Tone::Bad, |
|
| 35 | + | _ => Tone::Mark, |
|
| 36 | + | } |
|
| 37 | + | } |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | /// One drawn dot. |
|
| 41 | + | #[derive(Clone, Copy, Debug)] |
|
| 42 | + | pub struct Dot { |
|
| 43 | + | pub x: f32, |
|
| 44 | + | pub y: f32, |
|
| 45 | + | /// Radius in CSS pixels. |
|
| 46 | + | pub size: f32, |
|
| 47 | + | /// The shader's shape (sky::Kind::shape). |
|
| 48 | + | pub shape: f32, |
|
| 49 | + | pub tone: Tone, |
|
| 50 | + | /// 0 to 1: how strongly it shows at rest. |
|
| 51 | + | pub bright: f32, |
|
| 52 | + | pub lit: f32, |
|
| 53 | + | /// Dots of one group are joined in `order` when lit; `NONE` never. |
|
| 54 | + | pub group: u32, |
|
| 55 | + | pub order: u32, |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | impl Dot { |
|
| 59 | + | pub const NONE: u32 = u32::MAX; |
|
| 60 | + | ||
| 61 | + | pub fn new(x: f32, y: f32, size: f32, shape: f32) -> Dot { |
|
| 62 | + | Dot { x, y, size, shape, tone: Tone::Mark, bright: 1.0, lit: 0.0, group: Dot::NONE, order: 0 } |
|
| 63 | + | } |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | /// Something with a name the cursor can rest by. |
|
| 67 | + | #[derive(Clone, Debug)] |
|
| 68 | + | pub struct Named { |
|
| 69 | + | pub name: String, |
|
| 70 | + | pub x: f32, |
|
| 71 | + | pub y: f32, |
|
| 72 | + | /// How far its dots reach: the label sits just above. |
|
| 73 | + | pub reach: f32, |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | pub struct Field { |
|
| 77 | + | pub dots: Vec<Dot>, |
|
| 78 | + | pub named: Vec<Named>, |
|
| 79 | + | pub cursor: Option<(f32, f32)>, |
|
| 80 | + | pub rested: f32, |
|
| 81 | + | pub still: bool, |
|
| 82 | + | link_fade: f32, |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | impl Field { |
|
| 86 | + | pub fn new(still: bool) -> Field { |
|
| 87 | + | Field { dots: Vec::new(), named: Vec::new(), cursor: None, rested: 0.0, still, link_fade: 0.0 } |
|
| 88 | + | } |
|
| 89 | + | ||
| 90 | + | pub fn point(&mut self, at: Option<(f32, f32)>) { |
|
| 91 | + | self.cursor = at; |
|
| 92 | + | self.rested = 0.0; |
|
| 93 | + | } |
|
| 94 | + | ||
| 95 | + | /// Light the dots near the cursor, after the scene has placed them. |
|
| 96 | + | pub fn light(&mut self, dt: f32) { |
|
| 97 | + | let dt = dt.clamp(0.0, 0.1); |
|
| 98 | + | self.rested += dt; |
|
| 99 | + | let cursor = self.cursor; |
|
| 100 | + | let still = self.still; |
|
| 101 | + | for d in &mut self.dots { |
|
| 102 | + | let target = match cursor { |
|
| 103 | + | Some((x, y)) if (d.x - x).hypot(d.y - y) <= LIGHT_RADIUS => 1.0, |
|
| 104 | + | _ => 0.0, |
|
| 105 | + | }; |
|
| 106 | + | d.lit = if still { |
|
| 107 | + | target |
|
| 108 | + | } else if target > d.lit { |
|
| 109 | + | (d.lit + dt / LIT_IN).min(target) |
|
| 110 | + | } else { |
|
| 111 | + | (d.lit - dt / LIT_OUT).max(target) |
|
| 112 | + | }; |
|
| 113 | + | } |
|
| 114 | + | let fade_to = if cursor.is_some() && self.rested < LINK_REST { 1.0 } else { 0.0 }; |
|
| 115 | + | self.link_fade = if still { |
|
| 116 | + | fade_to |
|
| 117 | + | } else if fade_to > self.link_fade { |
|
| 118 | + | (self.link_fade + dt / LIT_IN).min(1.0) |
|
| 119 | + | } else { |
|
| 120 | + | (self.link_fade - dt / LIT_OUT).max(0.0) |
|
| 121 | + | }; |
|
| 122 | + | } |
|
| 123 | + | ||
| 124 | + | /// Whether lighting or links are still easing. |
|
| 125 | + | pub fn easing(&self) -> bool { |
|
| 126 | + | self.dots.iter().any(|d| d.lit > 0.0 && d.lit < 1.0) || (self.link_fade > 0.0 && self.link_fade < 1.0) |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | fn colour(c: &Colours, tone: Tone, bright: f32, lit: f32) -> Rgba { |
|
| 130 | + | let (rest, hot) = match tone { |
|
| 131 | + | Tone::Mark => (c.mark, c.lit), |
|
| 132 | + | Tone::Ok => (with_alpha(c.ok, 0.45), with_alpha(c.ok, 0.95)), |
|
| 133 | + | Tone::Warn => (with_alpha(c.warn, 0.45), with_alpha(c.warn, 0.95)), |
|
| 134 | + | Tone::Bad => (with_alpha(c.bad, 0.45), with_alpha(c.bad, 0.95)), |
|
| 135 | + | }; |
|
| 136 | + | let rest = [rest[0], rest[1], rest[2], rest[3] * bright]; |
|
| 137 | + | let t = lit.clamp(0.0, 1.0); |
|
| 138 | + | [ |
|
| 139 | + | rest[0] + (hot[0] - rest[0]) * t, |
|
| 140 | + | rest[1] + (hot[1] - rest[1]) * t, |
|
| 141 | + | rest[2] + (hot[2] - rest[2]) * t, |
|
| 142 | + | rest[3] + (hot[3] - rest[3]) * t, |
|
| 143 | + | ] |
|
| 144 | + | } |
|
| 145 | + | ||
| 146 | + | /// 8 floats a dot, as sky::Sky::instances lays them out. |
|
| 147 | + | pub fn instances(&self, c: &Colours, out: &mut Vec<f32>) { |
|
| 148 | + | out.clear(); |
|
| 149 | + | for d in &self.dots { |
|
| 150 | + | let col = Field::colour(c, d.tone, d.bright, d.lit); |
|
| 151 | + | let size = d.size * (1.0 + 0.35 * d.lit); |
|
| 152 | + | out.extend_from_slice(&[d.x, d.y, size, d.shape, col[0] * col[3], col[1] * col[3], col[2] * col[3], col[3]]); |
|
| 153 | + | } |
|
| 154 | + | } |
|
| 155 | + | ||
| 156 | + | /// Lit dots of a group joined in order, where near each other. |
|
| 157 | + | pub fn links(&self, c: &Colours, out: &mut Vec<f32>) { |
|
| 158 | + | out.clear(); |
|
| 159 | + | if self.link_fade <= 0.0 { |
|
| 160 | + | return; |
|
| 161 | + | } |
|
| 162 | + | let mut lit: Vec<&Dot> = self.dots.iter().filter(|d| d.group != Dot::NONE && d.lit > 0.05).collect(); |
|
| 163 | + | lit.sort_by_key(|d| (d.group, d.order)); |
|
| 164 | + | for pair in lit.windows(2) { |
|
| 165 | + | let (a, b) = (pair[0], pair[1]); |
|
| 166 | + | if a.group != b.group || (a.x - b.x).hypot(a.y - b.y) > LINK_MAX { |
|
| 167 | + | continue; |
|
| 168 | + | } |
|
| 169 | + | let alpha = c.link[3] * a.lit.min(b.lit) * self.link_fade; |
|
| 170 | + | let col = [c.link[0] * alpha, c.link[1] * alpha, c.link[2] * alpha, alpha]; |
|
| 171 | + | out.extend_from_slice(&[a.x, a.y, col[0], col[1], col[2], col[3], b.x, b.y, col[0], col[1], col[2], col[3]]); |
|
| 172 | + | } |
|
| 173 | + | } |
|
| 174 | + | ||
| 175 | + | /// The name above the named thing a resting cursor is by. |
|
| 176 | + | pub fn label(&self) -> Option<(&str, f32, f32)> { |
|
| 177 | + | let (x, y) = self.cursor?; |
|
| 178 | + | if self.rested < LABEL_REST { |
|
| 179 | + | return None; |
|
| 180 | + | } |
|
| 181 | + | self.named |
|
| 182 | + | .iter() |
|
| 183 | + | .map(|n| (n, (n.x - x).hypot(n.y - y))) |
|
| 184 | + | .filter(|(n, d)| *d <= LABEL_NEAR.max(n.reach)) |
|
| 185 | + | .min_by(|a, b| a.1.total_cmp(&b.1)) |
|
| 186 | + | .map(|(n, _)| (n.name.as_str(), n.x, n.y - n.reach - 10.0)) |
|
| 187 | + | } |
|
| 188 | + | } |
|
| 189 | + | ||
| 190 | + | fn with_alpha(c: Rgba, a: f32) -> Rgba { |
|
| 191 | + | [c[0], c[1], c[2], a] |
|
| 192 | + | } |
|
| 193 | + | ||
| 194 | + | /// A number from 0 to 1 from bits of a hash (sky::hash). |
|
| 195 | + | pub fn unit(h: u64, shift: u32) -> f32 { |
|
| 196 | + | ((h >> shift) & 0xffff) as f32 / 65536.0 |
|
| 197 | + | } |
|
| 198 | + | ||
| 199 | + | /// The page's margins beside its content column, when both are wide |
|
| 200 | + | /// enough to hold something: (left width, right start). |
|
| 201 | + | pub fn margins(w: f32, avoid: Option<(f32, f32)>) -> Option<(f32, f32)> { |
|
| 202 | + | let (x0, x1) = avoid?; |
|
| 203 | + | (x0 >= 180.0 && w - x1 >= 180.0).then_some((x0, x1)) |
|
| 204 | + | } |
|
| 205 | + | ||
| 206 | + | #[cfg(test)] |
|
| 207 | + | mod tests { |
|
| 208 | + | use super::*; |
|
| 209 | + | ||
| 210 | + | #[test] |
|
| 211 | + | fn the_cursor_lights_and_joins_a_group_and_names_what_it_rests_by() { |
|
| 212 | + | let mut f = Field::new(false); |
|
| 213 | + | for i in 0..4 { |
|
| 214 | + | let mut d = Dot::new(100.0 + i as f32 * 20.0, 100.0, 3.0, 0.0); |
|
| 215 | + | d.group = 0; |
|
| 216 | + | d.order = i; |
|
| 217 | + | f.dots.push(d); |
|
| 218 | + | } |
|
| 219 | + | f.dots.push(Dot::new(900.0, 900.0, 3.0, 0.0)); |
|
| 220 | + | f.named.push(Named { name: "earth-1".into(), x: 130.0, y: 100.0, reach: 40.0 }); |
|
| 221 | + | f.point(Some((130.0, 100.0))); |
|
| 222 | + | for _ in 0..30 { |
|
| 223 | + | f.light(1.0 / 60.0); |
|
| 224 | + | } |
|
| 225 | + | assert!(f.dots[..4].iter().all(|d| d.lit == 1.0) && f.dots[4].lit == 0.0); |
|
| 226 | + | let c = Colours::default(); |
|
| 227 | + | let mut links = Vec::new(); |
|
| 228 | + | f.links(&c, &mut links); |
|
| 229 | + | assert_eq!(links.len(), 3 * 12, "three links in order, none to the dot far away"); |
|
| 230 | + | assert_eq!(f.label().map(|l| l.0), Some("earth-1")); |
|
| 231 | + | let mut inst = Vec::new(); |
|
| 232 | + | f.instances(&c, &mut inst); |
|
| 233 | + | assert_eq!(inst.len(), 5 * 8); |
|
| 234 | + | } |
|
| 235 | + | ||
| 236 | + | #[test] |
|
| 237 | + | fn status_tones_come_from_the_pill_names() { |
|
| 238 | + | assert_eq!(Tone::from_name("green"), Tone::Ok); |
|
| 239 | + | assert_eq!(Tone::from_name("amber"), Tone::Warn); |
|
| 240 | + | assert_eq!(Tone::from_name("red"), Tone::Bad); |
|
| 241 | + | assert_eq!(Tone::from_name("grey"), Tone::Mark); |
|
| 242 | + | assert_eq!(margins(2560.0, Some((707.0, 1838.0))), Some((707.0, 1838.0))); |
|
| 243 | + | assert_eq!(margins(1200.0, Some((100.0, 1100.0))), None); |
|
| 244 | + | } |
|
| 245 | + | } |
added crates/backdrop/src/mark.rs +147 −0
| 1 | + | //! The mark (docs/backgrounds.md §2): sign in, register and device sign-in. |
|
| 2 | + | //! The Oxygen mark's two rings drawn in dots, turning opposite ways so the |
|
| 3 | + | //! stream runs through the middle like the mark's infinity, each with its |
|
| 4 | + | //! core. Brand only: no data. |
|
| 5 | + | ||
| 6 | + | use crate::field::{unit, Dot, Field}; |
|
| 7 | + | use crate::scene::Scene; |
|
| 8 | + | use crate::sky::{hash, Colours}; |
|
| 9 | + | use std::f32::consts::TAU; |
|
| 10 | + | ||
| 11 | + | /// Dots on the two rings. |
|
| 12 | + | const DOTS: usize = 560; |
|
| 13 | + | /// How fast the rings turn, at their rim, in CSS pixels a second. |
|
| 14 | + | const RIM_SPEED: f32 = 9.0; |
|
| 15 | + | ||
| 16 | + | struct Bead { |
|
| 17 | + | ring: usize, |
|
| 18 | + | angle: f32, |
|
| 19 | + | /// Its distance off the ring's line, and its breathing's phase. |
|
| 20 | + | off: f32, |
|
| 21 | + | phase: f32, |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | pub struct Mark { |
|
| 25 | + | field: Field, |
|
| 26 | + | beads: Vec<Bead>, |
|
| 27 | + | w: f32, |
|
| 28 | + | h: f32, |
|
| 29 | + | t: f32, |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | impl Mark { |
|
| 33 | + | pub fn new(still: bool) -> Mark { |
|
| 34 | + | let mut field = Field::new(still); |
|
| 35 | + | let mut beads = Vec::new(); |
|
| 36 | + | for i in 0..DOTS { |
|
| 37 | + | let h = hash(&format!("mark#{i}")); |
|
| 38 | + | let ring = i % 2; |
|
| 39 | + | // Off the ring's line by a bell-ish spread, most near it. |
|
| 40 | + | let off = (unit(h, 16) + unit(h, 32) - 1.0) * 18.0; |
|
| 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 }); |
|
| 43 | + | d.bright = 0.55 + 0.45 * unit(h, 24); |
|
| 44 | + | d.group = ring as u32; |
|
| 45 | + | field.dots.push(d); |
|
| 46 | + | } |
|
| 47 | + | // Join each ring's beads in the order round it. |
|
| 48 | + | for ring in 0..2 { |
|
| 49 | + | let mut idx: Vec<usize> = (0..DOTS).filter(|&i| beads[i].ring == ring).collect(); |
|
| 50 | + | idx.sort_by(|&a, &b| beads[a].angle.total_cmp(&beads[b].angle)); |
|
| 51 | + | for (k, i) in idx.into_iter().enumerate() { |
|
| 52 | + | field.dots[i].order = k as u32; |
|
| 53 | + | } |
|
| 54 | + | } |
|
| 55 | + | // The two cores. |
|
| 56 | + | for _ in 0..2 { |
|
| 57 | + | field.dots.push(Dot::new(0.0, 0.0, 7.0, 0.0)); |
|
| 58 | + | } |
|
| 59 | + | let mut m = Mark { field, beads, w: 0.0, h: 0.0, t: 0.0 }; |
|
| 60 | + | m.place(); |
|
| 61 | + | m |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | /// The rings' radius and centres for this view. |
|
| 65 | + | fn rings(&self) -> (f32, [(f32, f32); 2]) { |
|
| 66 | + | let r = (self.w.min(self.h * 1.6) * 0.17).clamp(110.0, 330.0); |
|
| 67 | + | let (cx, cy) = (self.w / 2.0, self.h / 2.0); |
|
| 68 | + | (r, [(cx - r, cy), (cx + r, cy)]) |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | fn place(&mut self) { |
|
| 72 | + | let (r, centres) = self.rings(); |
|
| 73 | + | let spin = RIM_SPEED / r * self.t; |
|
| 74 | + | for (i, b) in self.beads.iter().enumerate() { |
|
| 75 | + | // The left ring turns one way and the right the other. |
|
| 76 | + | let a = b.angle + if b.ring == 0 { spin } else { -spin }; |
|
| 77 | + | let rr = r + b.off + 3.0 * (self.t * 0.4 + b.phase).sin(); |
|
| 78 | + | let (cx, cy) = centres[b.ring]; |
|
| 79 | + | self.field.dots[i].x = cx + a.cos() * rr; |
|
| 80 | + | self.field.dots[i].y = cy + a.sin() * rr; |
|
| 81 | + | } |
|
| 82 | + | for (k, (cx, cy)) in centres.iter().enumerate() { |
|
| 83 | + | let d = &mut self.field.dots[DOTS + k]; |
|
| 84 | + | d.x = *cx; |
|
| 85 | + | d.y = *cy; |
|
| 86 | + | } |
|
| 87 | + | } |
|
| 88 | + | } |
|
| 89 | + | ||
| 90 | + | impl Scene for Mark { |
|
| 91 | + | fn resize(&mut self, w: f32, h: f32, _avoid: Option<(f32, f32)>) { |
|
| 92 | + | self.w = w; |
|
| 93 | + | self.h = h; |
|
| 94 | + | self.place(); |
|
| 95 | + | } |
|
| 96 | + | fn point(&mut self, at: Option<(f32, f32)>) { |
|
| 97 | + | self.field.point(at); |
|
| 98 | + | } |
|
| 99 | + | fn step(&mut self, dt: f32) { |
|
| 100 | + | if !self.field.still { |
|
| 101 | + | self.t += dt.clamp(0.0, 0.1); |
|
| 102 | + | } |
|
| 103 | + | self.place(); |
|
| 104 | + | self.field.light(dt); |
|
| 105 | + | } |
|
| 106 | + | fn instances(&self, c: &Colours, out: &mut Vec<f32>) { |
|
| 107 | + | self.field.instances(c, out) |
|
| 108 | + | } |
|
| 109 | + | fn links(&self, c: &Colours, out: &mut Vec<f32>) { |
|
| 110 | + | self.field.links(c, out) |
|
| 111 | + | } |
|
| 112 | + | fn label(&self) -> Option<(&str, f32, f32)> { |
|
| 113 | + | None |
|
| 114 | + | } |
|
| 115 | + | fn busy(&self) -> bool { |
|
| 116 | + | !self.field.still || self.field.easing() |
|
| 117 | + | } |
|
| 118 | + | fn still(&self) -> bool { |
|
| 119 | + | self.field.still |
|
| 120 | + | } |
|
| 121 | + | fn size(&self) -> (f32, f32) { |
|
| 122 | + | (self.w, self.h) |
|
| 123 | + | } |
|
| 124 | + | } |
|
| 125 | + | ||
| 126 | + | #[cfg(test)] |
|
| 127 | + | mod tests { |
|
| 128 | + | use super::*; |
|
| 129 | + | ||
| 130 | + | #[test] |
|
| 131 | + | fn two_rings_turn_opposite_ways_about_their_cores() { |
|
| 132 | + | let mut m = Mark::new(false); |
|
| 133 | + | m.resize(1600.0, 900.0, None); |
|
| 134 | + | let (r, centres) = m.rings(); |
|
| 135 | + | assert!(centres[0].0 < 800.0 && centres[1].0 > 800.0); |
|
| 136 | + | for (i, b) in m.beads.iter().enumerate() { |
|
| 137 | + | let (cx, cy) = centres[b.ring]; |
|
| 138 | + | let d = (m.field.dots[i].x - cx).hypot(m.field.dots[i].y - cy); |
|
| 139 | + | assert!((d - r).abs() <= 22.0, "on its ring: {d} against {r}"); |
|
| 140 | + | } |
|
| 141 | + | let before = (m.field.dots[0].x, m.field.dots[0].y); |
|
| 142 | + | m.step(0.1); |
|
| 143 | + | let moved = (m.field.dots[0].x - before.0).hypot(m.field.dots[0].y - before.1); |
|
| 144 | + | assert!(moved > 0.0 && moved < 2.0, "slow: {moved} px in a tenth of a second"); |
|
| 145 | + | assert!(m.label().is_none(), "brand only: nothing to name"); |
|
| 146 | + | } |
|
| 147 | + | } |
added crates/backdrop/src/ripples.rs +239 −0
| 1 | + | //! Ripples (docs/backgrounds.md §2): a repository's Diffusion tab. The |
|
| 2 | + | //! repository in one margin, the upstreams it takes from beside it and the |
|
| 3 | + | //! consumers it diffuses to in the other; its changes flow out along arcs |
|
| 4 | + | //! to its consumers and its upstreams' flow in, each stream and node in |
|
| 5 | + | //! the status of its Diffusion pill, and a ripple widens from the |
|
| 6 | + | //! repository every few seconds. The data is the page's own. |
|
| 7 | + | ||
| 8 | + | use crate::field::{margins, unit, Dot, Field, Named, Tone}; |
|
| 9 | + | use crate::scene::Scene; |
|
| 10 | + | use crate::sky::{hash, Colours}; |
|
| 11 | + | use std::f32::consts::{PI, TAU}; |
|
| 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; |
|
| 17 | + | /// Dots flowing along each stream, and their speed in CSS pixels a second: |
|
| 18 | + | /// a stream is a flow, quicker than the drift of marks. |
|
| 19 | + | const FLOW: usize = 10; |
|
| 20 | + | const FLOW_SPEED: f32 = 40.0; |
|
| 21 | + | /// A ripple every this many seconds, widening this far over this long. |
|
| 22 | + | const RIPPLE_EVERY: f32 = 5.0; |
|
| 23 | + | const RIPPLE_R: f32 = 280.0; |
|
| 24 | + | const RIPPLE_S: f32 = 3.2; |
|
| 25 | + | const RIPPLE_DOTS: usize = 40; |
|
| 26 | + | ||
| 27 | + | /// One repository on the page: its name and the tone its dots take. |
|
| 28 | + | pub struct Node { |
|
| 29 | + | pub name: String, |
|
| 30 | + | pub tone: Tone, |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | pub struct Ripples { |
|
| 34 | + | field: Field, |
|
| 35 | + | /// The repository first, then its upstreams, then its consumers. |
|
| 36 | + | nodes: Vec<Node>, |
|
| 37 | + | ups: usize, |
|
| 38 | + | pos: Vec<(f32, f32)>, |
|
| 39 | + | /// Streams: from, to, and their dots' first index. |
|
| 40 | + | streams: Vec<(usize, usize, usize)>, |
|
| 41 | + | ripple_at: usize, |
|
| 42 | + | w: f32, |
|
| 43 | + | h: f32, |
|
| 44 | + | t: f32, |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | impl Ripples { |
|
| 48 | + | pub fn new(repo: String, upstreams: Vec<Node>, consumers: Vec<Node>, still: bool) -> Ripples { |
|
| 49 | + | let ups = upstreams.len(); |
|
| 50 | + | let mut nodes = vec![Node { name: repo, tone: Tone::Mark }]; |
|
| 51 | + | nodes.extend(upstreams); |
|
| 52 | + | nodes.extend(consumers); |
|
| 53 | + | let mut field = Field::new(still); |
|
| 54 | + | for (i, n) in nodes.iter().enumerate() { |
|
| 55 | + | let mut core = Dot::new(0.0, 0.0, if i == 0 { 9.0 } else { 7.0 }, 4.0); |
|
| 56 | + | core.tone = n.tone; |
|
| 57 | + | field.dots.push(core); |
|
| 58 | + | for k in 0..RING { |
|
| 59 | + | let mut d = Dot::new(0.0, 0.0, 2.2, 0.0); |
|
| 60 | + | d.tone = n.tone; |
|
| 61 | + | d.group = i as u32; |
|
| 62 | + | d.order = k as u32; |
|
| 63 | + | d.bright = 0.6 + 0.4 * unit(hash(&format!("{}#{k}", n.name)), 0); |
|
| 64 | + | field.dots.push(d); |
|
| 65 | + | } |
|
| 66 | + | } |
|
| 67 | + | // Upstreams flow into the repository; the repository flows out to |
|
| 68 | + | // each consumer, the stream in the consumer's tone. |
|
| 69 | + | let mut streams = Vec::new(); |
|
| 70 | + | for (i, node) in nodes.iter().enumerate() { |
|
| 71 | + | let (from, to) = match i { |
|
| 72 | + | 0 => continue, |
|
| 73 | + | i if i <= ups => (i, 0), |
|
| 74 | + | i => (0, i), |
|
| 75 | + | }; |
|
| 76 | + | streams.push((from, to, field.dots.len())); |
|
| 77 | + | for _ in 0..FLOW { |
|
| 78 | + | let mut d = Dot::new(0.0, 0.0, 2.0, 0.0); |
|
| 79 | + | d.tone = node.tone; |
|
| 80 | + | field.dots.push(d); |
|
| 81 | + | } |
|
| 82 | + | } |
|
| 83 | + | let ripple_at = field.dots.len(); |
|
| 84 | + | for _ in 0..RIPPLE_DOTS { |
|
| 85 | + | let mut d = Dot::new(0.0, 0.0, 1.8, 0.0); |
|
| 86 | + | d.bright = 0.0; |
|
| 87 | + | field.dots.push(d); |
|
| 88 | + | } |
|
| 89 | + | let mut r = Ripples { field, nodes, ups, pos: Vec::new(), streams, ripple_at, w: 0.0, h: 0.0, t: 0.0 }; |
|
| 90 | + | r.layout(None); |
|
| 91 | + | r |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | fn layout(&mut self, avoid: Option<(f32, f32)>) { |
|
| 95 | + | let (w, h) = (self.w, self.h); |
|
| 96 | + | let n = self.nodes.len(); |
|
| 97 | + | let cons = n - 1 - self.ups; |
|
| 98 | + | let spread = |i: usize, of: usize, top: f32, bottom: f32| { |
|
| 99 | + | if of <= 1 { |
|
| 100 | + | (top + bottom) / 2.0 |
|
| 101 | + | } else { |
|
| 102 | + | top + (bottom - top) * i as f32 / (of - 1) as f32 |
|
| 103 | + | } |
|
| 104 | + | }; |
|
| 105 | + | let (repo_x, up_x, con_x) = match margins(w, avoid) { |
|
| 106 | + | Some((l, x1)) => (l * 0.62, l * 0.22, x1 + (w - x1) * 0.45), |
|
| 107 | + | None => (w * 0.12, w * 0.04, w * 0.88), |
|
| 108 | + | }; |
|
| 109 | + | self.pos = (0..n) |
|
| 110 | + | .map(|i| match i { |
|
| 111 | + | 0 => (repo_x, h * 0.5), |
|
| 112 | + | i if i <= self.ups => (up_x, spread(i - 1, self.ups, h * 0.25, h * 0.75)), |
|
| 113 | + | i => (con_x, spread(i - 1 - self.ups, cons, h * 0.15, h * 0.85)), |
|
| 114 | + | }) |
|
| 115 | + | .collect(); |
|
| 116 | + | 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(); |
|
| 118 | + | } |
|
| 119 | + | ||
| 120 | + | fn place(&mut self) { |
|
| 121 | + | let mut at = 0; |
|
| 122 | + | let spin = RING_SPEED / RING_R * self.t; |
|
| 123 | + | 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 | + | } |
|
| 133 | + | } |
|
| 134 | + | // Streams: a gentle arc from each source to its destination, its |
|
| 135 | + | // dots evenly spaced and moving along it, faded at the ends. |
|
| 136 | + | for &(from, to, first) in &self.streams { |
|
| 137 | + | let (a, b) = (self.pos[from], self.pos[to]); |
|
| 138 | + | let len = (b.0 - a.0).hypot(b.1 - a.1).max(1.0); |
|
| 139 | + | let ctrl = ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0 - len * 0.18); |
|
| 140 | + | for k in 0..FLOW { |
|
| 141 | + | let s = (k as f32 / FLOW as f32 + self.t * FLOW_SPEED / len).fract(); |
|
| 142 | + | let u = 1.0 - s; |
|
| 143 | + | let d = &mut self.field.dots[first + k]; |
|
| 144 | + | d.x = u * u * a.0 + 2.0 * u * s * ctrl.0 + s * s * b.0; |
|
| 145 | + | d.y = u * u * a.1 + 2.0 * u * s * ctrl.1 + s * s * b.1; |
|
| 146 | + | d.bright = (s * PI).sin(); |
|
| 147 | + | } |
|
| 148 | + | } |
|
| 149 | + | // The ripple: a ring of dots widening from the repository. |
|
| 150 | + | let p = (self.t % RIPPLE_EVERY) / RIPPLE_S; |
|
| 151 | + | let (x, y) = self.pos[0]; |
|
| 152 | + | for k in 0..RIPPLE_DOTS { |
|
| 153 | + | let d = &mut self.field.dots[self.ripple_at + k]; |
|
| 154 | + | let a = k as f32 / RIPPLE_DOTS as f32 * TAU; |
|
| 155 | + | let r = RING_R + RIPPLE_R * p.min(1.0); |
|
| 156 | + | d.x = x + a.cos() * r; |
|
| 157 | + | d.y = y + a.sin() * r; |
|
| 158 | + | d.bright = if self.field.still || p >= 1.0 { 0.0 } else { 0.8 * (1.0 - p) }; |
|
| 159 | + | } |
|
| 160 | + | } |
|
| 161 | + | } |
|
| 162 | + | ||
| 163 | + | impl Scene for Ripples { |
|
| 164 | + | fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>) { |
|
| 165 | + | self.w = w; |
|
| 166 | + | self.h = h; |
|
| 167 | + | self.layout(avoid); |
|
| 168 | + | self.place(); |
|
| 169 | + | } |
|
| 170 | + | fn point(&mut self, at: Option<(f32, f32)>) { |
|
| 171 | + | self.field.point(at); |
|
| 172 | + | } |
|
| 173 | + | fn step(&mut self, dt: f32) { |
|
| 174 | + | if !self.field.still { |
|
| 175 | + | self.t += dt.clamp(0.0, 0.1); |
|
| 176 | + | } |
|
| 177 | + | self.place(); |
|
| 178 | + | self.field.light(dt); |
|
| 179 | + | } |
|
| 180 | + | fn instances(&self, c: &Colours, out: &mut Vec<f32>) { |
|
| 181 | + | self.field.instances(c, out) |
|
| 182 | + | } |
|
| 183 | + | fn links(&self, c: &Colours, out: &mut Vec<f32>) { |
|
| 184 | + | self.field.links(c, out) |
|
| 185 | + | } |
|
| 186 | + | fn label(&self) -> Option<(&str, f32, f32)> { |
|
| 187 | + | self.field.label() |
|
| 188 | + | } |
|
| 189 | + | fn busy(&self) -> bool { |
|
| 190 | + | !self.field.still || self.field.easing() |
|
| 191 | + | } |
|
| 192 | + | fn still(&self) -> bool { |
|
| 193 | + | self.field.still |
|
| 194 | + | } |
|
| 195 | + | fn size(&self) -> (f32, f32) { |
|
| 196 | + | (self.w, self.h) |
|
| 197 | + | } |
|
| 198 | + | } |
|
| 199 | + | ||
| 200 | + | #[cfg(test)] |
|
| 201 | + | mod tests { |
|
| 202 | + | use super::*; |
|
| 203 | + | ||
| 204 | + | fn node(name: &str, tone: Tone) -> Node { |
|
| 205 | + | Node { name: name.into(), tone } |
|
| 206 | + | } |
|
| 207 | + | ||
| 208 | + | #[test] |
|
| 209 | + | fn upstreams_flow_in_and_consumers_out_in_their_pills_tones() { |
|
| 210 | + | let mut r = Ripples::new( |
|
| 211 | + | "andy/api".into(), |
|
| 212 | + | vec![node("andy/base", Tone::Ok)], |
|
| 213 | + | vec![node("andy/game", Tone::Ok), node("andy/other", Tone::Bad)], |
|
| 214 | + | false, |
|
| 215 | + | ); |
|
| 216 | + | r.resize(2560.0, 1270.0, Some((707.0, 1838.0))); |
|
| 217 | + | 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 | + | 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)]); |
|
| 220 | + | let bad_stream = r.streams[2].2; |
|
| 221 | + | assert!(r.field.dots[bad_stream..bad_stream + FLOW].iter().all(|d| d.tone == Tone::Bad), "a failing consumer's stream is red"); |
|
| 222 | + | let before = r.field.dots[bad_stream].x; |
|
| 223 | + | r.step(0.1); |
|
| 224 | + | assert_ne!(before, r.field.dots[bad_stream].x, "streams flow"); |
|
| 225 | + | r.point(Some(r.pos[3])); |
|
| 226 | + | for _ in 0..30 { |
|
| 227 | + | r.step(0.02); |
|
| 228 | + | } |
|
| 229 | + | assert_eq!(r.label().map(|l| l.0), Some("andy/other")); |
|
| 230 | + | } |
|
| 231 | + | ||
| 232 | + | #[test] |
|
| 233 | + | fn still_keeps_the_ripple_away() { |
|
| 234 | + | let mut r = Ripples::new("andy/api".into(), vec![], vec![node("andy/game", Tone::Mark)], true); |
|
| 235 | + | r.resize(1600.0, 900.0, None); |
|
| 236 | + | r.step(0.1); |
|
| 237 | + | assert!(r.field.dots[r.ripple_at..].iter().all(|d| d.bright == 0.0)); |
|
| 238 | + | } |
|
| 239 | + | } |
added crates/backdrop/src/scene.rs +54 −0
| 1 | + | //! A backdrop's scene (docs/backgrounds.md §2): what it draws and how it |
|
| 2 | + | //! moves. The engine (lib.rs) drives any of them the same way. |
|
| 3 | + | ||
| 4 | + | use crate::sky::Colours; |
|
| 5 | + | ||
| 6 | + | pub trait Scene { |
|
| 7 | + | /// The view is now `w` by `h` CSS pixels, the page's content column |
|
| 8 | + | /// between `avoid`'s left and right. |
|
| 9 | + | fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>); |
|
| 10 | + | /// The cursor moved to `(x, y)`, or left the page. |
|
| 11 | + | fn point(&mut self, at: Option<(f32, f32)>); |
|
| 12 | + | /// Move on by `dt` seconds. |
|
| 13 | + | fn step(&mut self, dt: f32); |
|
| 14 | + | /// The marks, 8 floats each (sky::Sky::instances). |
|
| 15 | + | fn instances(&self, c: &Colours, out: &mut Vec<f32>); |
|
| 16 | + | /// The links, 12 floats each (sky::Sky::links). |
|
| 17 | + | fn links(&self, c: &Colours, out: &mut Vec<f32>); |
|
| 18 | + | /// The name a resting cursor shows, and where. |
|
| 19 | + | fn label(&self) -> Option<(&str, f32, f32)>; |
|
| 20 | + | /// Whether a frame would show anything new. |
|
| 21 | + | fn busy(&self) -> bool; |
|
| 22 | + | fn still(&self) -> bool; |
|
| 23 | + | fn size(&self) -> (f32, f32); |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | impl Scene for crate::sky::Sky { |
|
| 27 | + | fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>) { |
|
| 28 | + | crate::sky::Sky::resize(self, w, h, avoid) |
|
| 29 | + | } |
|
| 30 | + | fn point(&mut self, at: Option<(f32, f32)>) { |
|
| 31 | + | crate::sky::Sky::point(self, at) |
|
| 32 | + | } |
|
| 33 | + | fn step(&mut self, dt: f32) { |
|
| 34 | + | crate::sky::Sky::step(self, dt) |
|
| 35 | + | } |
|
| 36 | + | fn instances(&self, c: &Colours, out: &mut Vec<f32>) { |
|
| 37 | + | crate::sky::Sky::instances(self, c, out) |
|
| 38 | + | } |
|
| 39 | + | fn links(&self, c: &Colours, out: &mut Vec<f32>) { |
|
| 40 | + | crate::sky::Sky::links(self, c, out) |
|
| 41 | + | } |
|
| 42 | + | fn label(&self) -> Option<(&str, f32, f32)> { |
|
| 43 | + | crate::sky::Sky::label(self) |
|
| 44 | + | } |
|
| 45 | + | fn busy(&self) -> bool { |
|
| 46 | + | crate::sky::Sky::busy(self) |
|
| 47 | + | } |
|
| 48 | + | fn still(&self) -> bool { |
|
| 49 | + | self.still |
|
| 50 | + | } |
|
| 51 | + | fn size(&self) -> (f32, f32) { |
|
| 52 | + | (self.w, self.h) |
|
| 53 | + | } |
|
| 54 | + | } |
added crates/backdrop/src/swarm.rs +209 −0
| 1 | + | //! The swarm (docs/backgrounds.md §2): Admin › Runners › Images. The hub |
|
| 2 | + | //! and the runners as nodes, each ringed by a dot for every image it |
|
| 3 | + | //! holds; while a runner fetches an image, chunks (small squares) stream |
|
| 4 | + | //! to it from every holder, the hub and its peers alike, as Jetstream's |
|
| 5 | + | //! swarm fetches them. The data is the page's own snapshot. |
|
| 6 | + | ||
| 7 | + | use crate::field::{margins, unit, Dot, Field, Named}; |
|
| 8 | + | use crate::scene::Scene; |
|
| 9 | + | use crate::sky::{hash, Colours, MAX_SPEED}; |
|
| 10 | + | use std::f32::consts::{PI, TAU}; |
|
| 11 | + | ||
| 12 | + | /// Chunks streaming along each holder-to-fetcher path, their speed, and |
|
| 13 | + | /// the most streams drawn. |
|
| 14 | + | const CHUNKS: usize = 8; |
|
| 15 | + | const CHUNK_SPEED: f32 = 40.0; |
|
| 16 | + | const MAX_STREAMS: usize = 40; |
|
| 17 | + | ||
| 18 | + | /// One image: the nodes that hold it and those fetching it now. |
|
| 19 | + | pub struct Image { |
|
| 20 | + | pub held: Vec<usize>, |
|
| 21 | + | pub fetching: Vec<usize>, |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | struct Held { |
|
| 25 | + | node: usize, |
|
| 26 | + | radius: f32, |
|
| 27 | + | angle: f32, |
|
| 28 | + | speed: f32, |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | pub struct Swarm { |
|
| 32 | + | field: Field, |
|
| 33 | + | /// The hub first, then the runners. |
|
| 34 | + | names: Vec<String>, |
|
| 35 | + | pos: Vec<(f32, f32)>, |
|
| 36 | + | held: Vec<Held>, |
|
| 37 | + | /// Streams: from, to, and their chunks' first index. |
|
| 38 | + | streams: Vec<(usize, usize, usize)>, |
|
| 39 | + | w: f32, |
|
| 40 | + | h: f32, |
|
| 41 | + | t: f32, |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | impl Swarm { |
|
| 45 | + | pub fn new(names: Vec<String>, images: Vec<Image>, still: bool) -> Swarm { |
|
| 46 | + | let mut field = Field::new(still); |
|
| 47 | + | for i in 0..names.len() { |
|
| 48 | + | field.dots.push(Dot::new(0.0, 0.0, if i == 0 { 10.0 } else { 8.0 }, 4.0)); |
|
| 49 | + | } |
|
| 50 | + | let mut held = Vec::new(); |
|
| 51 | + | let mut count = vec![0u32; names.len()]; |
|
| 52 | + | for (n, img) in images.iter().enumerate() { |
|
| 53 | + | for &node in img.held.iter().filter(|&&n| n < names.len()) { |
|
| 54 | + | let k = count[node]; |
|
| 55 | + | 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); |
|
| 65 | + | d.group = node as u32; |
|
| 66 | + | d.order = k; |
|
| 67 | + | d.bright = 0.7; |
|
| 68 | + | field.dots.push(d); |
|
| 69 | + | } |
|
| 70 | + | } |
|
| 71 | + | let mut streams = Vec::new(); |
|
| 72 | + | 'images: for img in &images { |
|
| 73 | + | for &to in img.fetching.iter().filter(|&&n| n < names.len()) { |
|
| 74 | + | for &from in img.held.iter().filter(|&&n| n < names.len() && n != to) { |
|
| 75 | + | if streams.len() == MAX_STREAMS { |
|
| 76 | + | break 'images; |
|
| 77 | + | } |
|
| 78 | + | streams.push((from, to, field.dots.len())); |
|
| 79 | + | for _ in 0..CHUNKS { |
|
| 80 | + | let mut d = Dot::new(0.0, 0.0, 2.2, 5.0); |
|
| 81 | + | d.bright = 0.0; |
|
| 82 | + | field.dots.push(d); |
|
| 83 | + | } |
|
| 84 | + | } |
|
| 85 | + | } |
|
| 86 | + | } |
|
| 87 | + | let mut s = Swarm { field, names, pos: Vec::new(), held, streams, w: 0.0, h: 0.0, t: 0.0 }; |
|
| 88 | + | s.layout(None); |
|
| 89 | + | s |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | fn layout(&mut self, avoid: Option<(f32, f32)>) { |
|
| 93 | + | let (w, h) = (self.w, self.h); |
|
| 94 | + | let runners = self.names.len().saturating_sub(1); |
|
| 95 | + | let (hub_x, run_x) = match margins(w, avoid) { |
|
| 96 | + | Some((l, x1)) => (l * 0.5, x1 + (w - x1) * 0.5), |
|
| 97 | + | None => (w * 0.1, w * 0.9), |
|
| 98 | + | }; |
|
| 99 | + | self.pos = (0..self.names.len()) |
|
| 100 | + | .map(|i| match i { |
|
| 101 | + | 0 => (hub_x, h * 0.5), |
|
| 102 | + | _ if runners <= 1 => (run_x, h * 0.5), |
|
| 103 | + | i => (run_x, h * 0.2 + h * 0.6 * (i - 1) as f32 / (runners - 1) as f32), |
|
| 104 | + | }) |
|
| 105 | + | .collect(); |
|
| 106 | + | self.field.named = self |
|
| 107 | + | .names |
|
| 108 | + | .iter() |
|
| 109 | + | .zip(&self.pos) |
|
| 110 | + | .enumerate() |
|
| 111 | + | .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); |
|
| 113 | + | Named { name: name.clone(), x, y, reach } |
|
| 114 | + | }) |
|
| 115 | + | .collect(); |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | fn place(&mut self) { |
|
| 119 | + | let n = self.names.len(); |
|
| 120 | + | for (i, &(x, y)) in self.pos.iter().enumerate() { |
|
| 121 | + | self.field.dots[i].x = x; |
|
| 122 | + | self.field.dots[i].y = y; |
|
| 123 | + | } |
|
| 124 | + | 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; |
|
| 129 | + | } |
|
| 130 | + | for &(from, to, first) in &self.streams { |
|
| 131 | + | let (a, b) = (self.pos[from], self.pos[to]); |
|
| 132 | + | let len = (b.0 - a.0).hypot(b.1 - a.1).max(1.0); |
|
| 133 | + | // Peers' streams bow one way, the hub's the other, so they |
|
| 134 | + | // read as separate sources. |
|
| 135 | + | let bow = if from == 0 { -0.12 } else { 0.12 }; |
|
| 136 | + | let ctrl = ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0 + len * bow); |
|
| 137 | + | for k in 0..CHUNKS { |
|
| 138 | + | let s = (k as f32 / CHUNKS as f32 + self.t * CHUNK_SPEED / len).fract(); |
|
| 139 | + | let u = 1.0 - s; |
|
| 140 | + | let d = &mut self.field.dots[first + k]; |
|
| 141 | + | d.x = u * u * a.0 + 2.0 * u * s * ctrl.0 + s * s * b.0; |
|
| 142 | + | d.y = u * u * a.1 + 2.0 * u * s * ctrl.1 + s * s * b.1; |
|
| 143 | + | d.bright = (s * PI).sin(); |
|
| 144 | + | } |
|
| 145 | + | } |
|
| 146 | + | } |
|
| 147 | + | } |
|
| 148 | + | ||
| 149 | + | impl Scene for Swarm { |
|
| 150 | + | fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>) { |
|
| 151 | + | self.w = w; |
|
| 152 | + | self.h = h; |
|
| 153 | + | self.layout(avoid); |
|
| 154 | + | self.place(); |
|
| 155 | + | } |
|
| 156 | + | fn point(&mut self, at: Option<(f32, f32)>) { |
|
| 157 | + | self.field.point(at); |
|
| 158 | + | } |
|
| 159 | + | fn step(&mut self, dt: f32) { |
|
| 160 | + | if !self.field.still { |
|
| 161 | + | self.t += dt.clamp(0.0, 0.1); |
|
| 162 | + | } |
|
| 163 | + | self.place(); |
|
| 164 | + | self.field.light(dt); |
|
| 165 | + | } |
|
| 166 | + | fn instances(&self, c: &Colours, out: &mut Vec<f32>) { |
|
| 167 | + | self.field.instances(c, out) |
|
| 168 | + | } |
|
| 169 | + | fn links(&self, c: &Colours, out: &mut Vec<f32>) { |
|
| 170 | + | self.field.links(c, out) |
|
| 171 | + | } |
|
| 172 | + | fn label(&self) -> Option<(&str, f32, f32)> { |
|
| 173 | + | self.field.label() |
|
| 174 | + | } |
|
| 175 | + | fn busy(&self) -> bool { |
|
| 176 | + | !self.field.still || self.field.easing() |
|
| 177 | + | } |
|
| 178 | + | fn still(&self) -> bool { |
|
| 179 | + | self.field.still |
|
| 180 | + | } |
|
| 181 | + | fn size(&self) -> (f32, f32) { |
|
| 182 | + | (self.w, self.h) |
|
| 183 | + | } |
|
| 184 | + | } |
|
| 185 | + | ||
| 186 | + | #[cfg(test)] |
|
| 187 | + | mod tests { |
|
| 188 | + | use super::*; |
|
| 189 | + | ||
| 190 | + | #[test] |
|
| 191 | + | fn chunks_stream_to_a_fetching_runner_from_every_holder() { |
|
| 192 | + | let names = vec!["hub".to_string(), "earth-1".to_string(), "plex-1".to_string()]; |
|
| 193 | + | let images = vec![Image { held: vec![0, 1], fetching: vec![2] }, Image { held: vec![0, 1, 2], fetching: vec![] }]; |
|
| 194 | + | let mut s = Swarm::new(names, images, false); |
|
| 195 | + | s.resize(2560.0, 1270.0, Some((707.0, 1838.0))); |
|
| 196 | + | 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"); |
|
| 198 | + | assert_eq!(s.streams.iter().map(|&(a, b, _)| (a, b)).collect::<Vec<_>>(), vec![(0, 2), (1, 2)], "from the hub and the peer"); |
|
| 199 | + | let first = s.streams[0].2; |
|
| 200 | + | let before = s.field.dots[first].x; |
|
| 201 | + | s.step(0.1); |
|
| 202 | + | assert_ne!(before, s.field.dots[first].x); |
|
| 203 | + | s.point(Some(s.pos[1])); |
|
| 204 | + | for _ in 0..30 { |
|
| 205 | + | s.step(0.02); |
|
| 206 | + | } |
|
| 207 | + | assert_eq!(s.label().map(|l| l.0), Some("earth-1")); |
|
| 208 | + | } |
|
| 209 | + | } |
modified CHANGELOG.md +1 −0
| … | 6 unchanged lines |
||
| 7 | 7 | - **Diffusion (#148, docs/diffusion.md).** When a repository that others pin as a subrepo moves on, each consumer that follows it gets a proposal moving the pin, signed by the hub, tested by the consumer's own pipeline, with the upstream's changes listed. One proposal per consumer and subrepo, kept up to date; never landed by itself. Turned on per repository (Settings › Diffusion, `PUT /repos/{owner}/{name}/diffusion`, or `follow` in `.oxymodules`), off by default; `green` waits for the upstream's run to pass. A repository's page shows who uses it and how far behind each is (Diffusion › Diffuses to, `GET /repos/{owner}/{name}/consumers`). Consumers are bumped on the bookmark their work lands on, learned from where their proposals land. |
|
| 8 | 8 | - **Diffusion in the API (#153):** `GET /repos/{owner}/{name}/diffusion` gives what a repository takes from and what it diffuses to, each with its pill, for the VS Code extension. |
|
| 9 | 9 | - **The card layout everywhere (#159–#164):** the About card's counts in its heading and its buttons centred; wider repository cards with each Diffusion row on one line; the Code button and + New project in the accent blue; proposal and issue sidebars, profile and organisation pages, search results and the wiki's sidebar as cards; every panel's and form card's heading a shaded bar. |
|
| 10 | + | - **Every backdrop scene (#167, docs/backgrounds.md):** the Oxygen mark's two rings turning behind sign in, register and device sign-in; ripples on a repository's Diffusion tab, its upstreams flowing in and changes flowing out to its consumers in their pills' tones; the swarm on Admin › Runners › Images, chunks streaming to a fetching runner from every holder; and one mark adrift on a page not found or a server error. |
|
| 10 | 11 | - **The hub's sky (#165):** Explore and the signed-out home draw the same sky from the public repositories listed (a filter narrows it), never a private one, whoever looks. |
|
| 11 | 12 | - **Your sky (#157, docs/backgrounds.md):** a quiet, moving backdrop behind the dashboard, drawn in Rust compiled to WebAssembly: your repositories as clusters and their last 30 days (every change pushed, proposal and issue events, releases, Flow runs) as marks around them; the cursor lights and links them, and names the cluster it rests by. Settings › Appearance: Moving, Still or Off; still under reduced motion. |
|
| 12 | 13 | - **Signed-in pages are private (#158):** HTML for a signed-in viewer says `Cache-Control: private, no-store`, so no proxy or CDN serves one viewer's page to another. |
|
| … | 167 unchanged lines |
modified assets/backdrop/backdrop.js +2 −1
| 1 | 1 | /** |
|
| 2 | - | * Start the dashboard's sky on `canvas` with the page's `data`. `still` |
|
| 2 | + | * Start a backdrop on `canvas` with the page's `data`: the scene it names |
|
| 3 | + | * (docs/backgrounds.md §2), the dashboard's sky by default. `still` |
|
| 3 | 4 | * is the viewer's choice of a still backdrop; reduced motion asks for |
|
| 4 | 5 | * one too. Without WebGL2 there is no backdrop, and nothing breaks. |
|
| 5 | 6 | * @param {HTMLCanvasElement} canvas |
|
| … | 641 unchanged lines |
replaced assets/backdrop/backdrop_bg.wasm +0 −0
No line changes.
modified crates/backdrop/src/lib.rs +73 −18
| … | 3 unchanged lines |
||
| 4 | 4 | //! `gl` draws it; this module is the browser around them: the frame loop, |
|
| 5 | 5 | //! the cursor, the window's size, the page's colours and when to stop. |
|
| 6 | 6 | ||
| 7 | + | pub mod adrift; |
|
| 8 | + | pub mod field; |
|
| 7 | 9 | pub mod gl; |
|
| 10 | + | pub mod mark; |
|
| 11 | + | pub mod ripples; |
|
| 12 | + | pub mod scene; |
|
| 8 | 13 | pub mod sky; |
|
| 14 | + | pub mod swarm; |
|
| 9 | 15 | ||
| 16 | + | use scene::Scene; |
|
| 10 | 17 | use sky::{Colours, Event, Kind, Repo, Sky}; |
|
| 11 | 18 | use std::cell::RefCell; |
|
| 12 | 19 | use std::rc::Rc; |
|
| … | 13 unchanged lines |
||
| 26 | 33 | window: Window, |
|
| 27 | 34 | canvas: HtmlCanvasElement, |
|
| 28 | 35 | renderer: gl::Renderer, |
|
| 29 | - | sky: Sky, |
|
| 36 | + | scene: Box<dyn Scene>, |
|
| 30 | 37 | colours: Colours, |
|
| 31 | 38 | colours_at: f64, |
|
| 32 | 39 | probe: HtmlElement, |
|
| … | 32 unchanged lines |
||
| 65 | 72 | .and_then(|d| d.query_selector("[data-backdrop-avoid]").ok().flatten()) |
|
| 66 | 73 | .map(|el| el.get_bounding_client_rect()) |
|
| 67 | 74 | .map(|r| (r.left() as f32, r.right() as f32)); |
|
| 68 | - | self.sky.resize(w as f32, h as f32, avoid); |
|
| 75 | + | self.scene.resize(w as f32, h as f32, avoid); |
|
| 69 | 76 | } |
|
| 70 | 77 | ||
| 71 | 78 | /// The page's backdrop tokens, as the browser computes them, so a |
|
| … | 13 unchanged lines |
||
| 85 | 92 | mark: read("--bd-mark", [0.5, 0.5, 0.55, 0.16]), |
|
| 86 | 93 | lit: read("--bd-lit", [0.04, 0.41, 0.85, 0.7]), |
|
| 87 | 94 | link: read("--bd-link", [0.04, 0.41, 0.85, 0.22]), |
|
| 95 | + | ok: read("--ok", [0.1, 0.5, 0.22, 1.0]), |
|
| 96 | + | warn: read("--warn", [0.6, 0.4, 0.0, 1.0]), |
|
| 97 | + | bad: read("--bad", [0.82, 0.14, 0.18, 1.0]), |
|
| 88 | 98 | }; |
|
| 89 | 99 | } |
|
| 90 | 100 | ||
| … | 5 unchanged lines |
||
| 96 | 106 | } |
|
| 97 | 107 | let idle = (t - self.input_at) / 1000.0; |
|
| 98 | 108 | self.frame += 1; |
|
| 99 | - | if !self.sky.still && idle > HALF_RATE_AFTER_S && self.frame % 2 == 1 { |
|
| 109 | + | if !self.scene.still() && idle > HALF_RATE_AFTER_S && self.frame % 2 == 1 { |
|
| 100 | 110 | return true; |
|
| 101 | 111 | } |
|
| 102 | 112 | let dt = self.last.map(|l| ((t - l) / 1000.0) as f32).unwrap_or(0.0); |
|
| … | 2 unchanged lines |
||
| 105 | 115 | self.read_colours(); |
|
| 106 | 116 | self.colours_at = t; |
|
| 107 | 117 | } |
|
| 108 | - | self.sky.step(dt); |
|
| 109 | - | self.sky.instances(&self.colours, &mut self.inst); |
|
| 110 | - | self.sky.links(&self.colours, &mut self.lines); |
|
| 111 | - | self.renderer.draw(self.sky.w, self.sky.h, self.pw, self.ph, &self.inst, &self.lines); |
|
| 118 | + | self.scene.step(dt); |
|
| 119 | + | self.scene.instances(&self.colours, &mut self.inst); |
|
| 120 | + | self.scene.links(&self.colours, &mut self.lines); |
|
| 121 | + | let (w, h) = self.scene.size(); |
|
| 122 | + | self.renderer.draw(w, h, self.pw, self.ph, &self.inst, &self.lines); |
|
| 112 | 123 | self.show_label(); |
|
| 113 | - | if self.sky.still { |
|
| 114 | - | self.sky.busy() |
|
| 124 | + | if self.scene.still() { |
|
| 125 | + | self.scene.busy() |
|
| 115 | 126 | } else { |
|
| 116 | - | idle < STOP_AFTER_S || self.sky.busy() |
|
| 127 | + | idle < STOP_AFTER_S || self.scene.busy() |
|
| 117 | 128 | } |
|
| 118 | 129 | } |
|
| 119 | 130 | ||
| 120 | 131 | /// The name above the cluster a cursor rests by (§4): faded in and out |
|
| 121 | 132 | /// by CSS, which centres it on `left` and sets its foot on `top`. |
|
| 122 | 133 | fn show_label(&mut self) { |
|
| 123 | - | let next = self.sky.label().map(|(name, x, y)| (name.to_string(), x, y)); |
|
| 134 | + | let next = self.scene.label().map(|(name, x, y)| (name.to_string(), x, y)); |
|
| 124 | 135 | let style = self.label.style(); |
|
| 125 | 136 | match next { |
|
| 126 | 137 | Some((name, x, y)) => { |
|
| … | 47 unchanged lines |
||
| 174 | 185 | (repos, events) |
|
| 175 | 186 | } |
|
| 176 | 187 | ||
| 177 | - | /// Start the dashboard's sky on `canvas` with the page's `data`. `still` |
|
| 188 | + | fn strings(v: &JsValue) -> Vec<String> { |
|
| 189 | + | js_sys::Array::from(v).iter().filter_map(|x| x.as_string()).collect() |
|
| 190 | + | } |
|
| 191 | + | ||
| 192 | + | /// A Diffusion node, `[name, tone]`. |
|
| 193 | + | fn ripple_nodes(v: &JsValue) -> Vec<ripples::Node> { |
|
| 194 | + | js_sys::Array::from(v) |
|
| 195 | + | .iter() |
|
| 196 | + | .filter_map(|n| { |
|
| 197 | + | let n = js_sys::Array::from(&n); |
|
| 198 | + | Some(ripples::Node { name: n.get(0).as_string()?, tone: field::Tone::from_name(&n.get(1).as_string().unwrap_or_default()) }) |
|
| 199 | + | }) |
|
| 200 | + | .collect() |
|
| 201 | + | } |
|
| 202 | + | ||
| 203 | + | /// The scene the page's data names (`scene`), the sky when it names none. |
|
| 204 | + | fn read_scene(data: &JsValue, still: bool) -> Box<dyn Scene> { |
|
| 205 | + | match get(data, "scene").as_string().as_deref() { |
|
| 206 | + | Some("mark") => Box::new(mark::Mark::new(still)), |
|
| 207 | + | Some("adrift") => Box::new(adrift::Adrift::new(still)), |
|
| 208 | + | Some("ripples") => Box::new(ripples::Ripples::new( |
|
| 209 | + | get(data, "repo").as_string().unwrap_or_default(), |
|
| 210 | + | ripple_nodes(&get(data, "upstreams")), |
|
| 211 | + | ripple_nodes(&get(data, "consumers")), |
|
| 212 | + | still, |
|
| 213 | + | )), |
|
| 214 | + | Some("swarm") => { |
|
| 215 | + | let images = js_sys::Array::from(&get(data, "images")) |
|
| 216 | + | .iter() |
|
| 217 | + | .map(|img| { |
|
| 218 | + | let idx = |
|
| 219 | + | |key: &str| js_sys::Array::from(&get(&img, key)).iter().filter_map(|v| v.as_f64()).map(|v| v as usize).collect(); |
|
| 220 | + | swarm::Image { held: idx("held"), fetching: idx("fetching") } |
|
| 221 | + | }) |
|
| 222 | + | .collect(); |
|
| 223 | + | Box::new(swarm::Swarm::new(strings(&get(data, "nodes")), images, still)) |
|
| 224 | + | } |
|
| 225 | + | _ => { |
|
| 226 | + | let (repos, events) = read_data(data); |
|
| 227 | + | Box::new(Sky::new(&repos, &events, 0.0, 0.0, still)) |
|
| 228 | + | } |
|
| 229 | + | } |
|
| 230 | + | } |
|
| 231 | + | ||
| 232 | + | /// Start a backdrop on `canvas` with the page's `data`: the scene it names |
|
| 233 | + | /// (docs/backgrounds.md §2), the dashboard's sky by default. `still` |
|
| 178 | 234 | /// is the viewer's choice of a still backdrop; reduced motion asks for |
|
| 179 | 235 | /// one too. Without WebGL2 there is no backdrop, and nothing breaks. |
|
| 180 | 236 | #[wasm_bindgen] |
|
| … | 21 unchanged lines |
||
| 202 | 258 | label.set_attribute("aria-hidden", "true")?; |
|
| 203 | 259 | body.append_child(&label)?; |
|
| 204 | 260 | ||
| 205 | - | let (repos, events) = read_data(&data); |
|
| 206 | - | let sky = Sky::new(&repos, &events, 0.0, 0.0, still); |
|
| 207 | - | let colours = Colours { mark: [0.0; 4], lit: [0.0; 4], link: [0.0; 4] }; |
|
| 261 | + | let scene = read_scene(&data, still); |
|
| 262 | + | let colours = Colours::default(); |
|
| 208 | 263 | let engine = Rc::new(RefCell::new(Engine { |
|
| 209 | 264 | window: window.clone(), |
|
| 210 | 265 | canvas, |
|
| 211 | 266 | renderer, |
|
| 212 | - | sky, |
|
| 267 | + | scene, |
|
| 213 | 268 | colours, |
|
| 214 | 269 | colours_at: f64::NEG_INFINITY, |
|
| 215 | 270 | probe, |
|
| … | 37 unchanged lines |
||
| 253 | 308 | let moved = Closure::<dyn FnMut(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| { |
|
| 254 | 309 | { |
|
| 255 | 310 | let mut e = engine.borrow_mut(); |
|
| 256 | - | e.sky.point(Some((ev.client_x() as f32, ev.client_y() as f32))); |
|
| 311 | + | e.scene.point(Some((ev.client_x() as f32, ev.client_y() as f32))); |
|
| 257 | 312 | e.input_at = e.now(); |
|
| 258 | 313 | } |
|
| 259 | 314 | wake(&engine, &frame); |
|
| … | 5 unchanged lines |
||
| 265 | 320 | let (engine, frame) = (engine.clone(), frame.clone()); |
|
| 266 | 321 | let left = Closure::<dyn FnMut(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| { |
|
| 267 | 322 | if ev.related_target().is_none() { |
|
| 268 | - | engine.borrow_mut().sky.point(None); |
|
| 323 | + | engine.borrow_mut().scene.point(None); |
|
| 269 | 324 | wake(&engine, &frame); |
|
| 270 | 325 | } |
|
| 271 | 326 | }); |
|
| … | 28 unchanged lines |
modified crates/backdrop/src/sky.rs +8 −4
| … | 139 unchanged lines |
||
| 140 | 140 | /// Straight RGBA, 0 to 1, as the page's tokens give them. |
|
| 141 | 141 | pub type Rgba = [f32; 4]; |
|
| 142 | 142 | ||
| 143 | - | /// The three backdrop tokens (§3). |
|
| 144 | - | #[derive(Clone, Copy, Debug, PartialEq)] |
|
| 143 | + | /// The three backdrop tokens (§3), and the page's status colours for the |
|
| 144 | + | /// scenes whose data has a status (Diffusion's pills). |
|
| 145 | + | #[derive(Clone, Copy, Debug, Default, PartialEq)] |
|
| 145 | 146 | pub struct Colours { |
|
| 146 | 147 | pub mark: Rgba, |
|
| 147 | 148 | pub lit: Rgba, |
|
| 148 | 149 | pub link: Rgba, |
|
| 150 | + | pub ok: Rgba, |
|
| 151 | + | pub warn: Rgba, |
|
| 152 | + | pub bad: Rgba, |
|
| 149 | 153 | } |
|
| 150 | 154 | ||
| 151 | 155 | /// The scene. |
|
| … | 370 unchanged lines |
||
| 522 | 526 | assert!(s.marks[2].lit > 0.99, "lit near the cursor"); |
|
| 523 | 527 | assert_eq!(radii, s.marks.iter().map(|m| m.radius).collect::<Vec<_>>(), "nothing is pulled"); |
|
| 524 | 528 | let mut links = Vec::new(); |
|
| 525 | - | 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] }; |
|
| 529 | + | 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() }; |
|
| 526 | 530 | s.links(&colours, &mut links); |
|
| 527 | 531 | assert!(!links.is_empty() && links.len() % 12 == 0, "lit marks of one repository are joined"); |
|
| 528 | 532 | // Resting fades the links; leaving fades the light. |
|
| … | 24 unchanged lines |
||
| 553 | 557 | #[test] |
|
| 554 | 558 | fn instances_are_premultiplied_and_a_resting_cursor_names_a_cluster() { |
|
| 555 | 559 | let mut s = sky(false); |
|
| 556 | - | 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] }; |
|
| 560 | + | 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() }; |
|
| 557 | 561 | let mut inst = Vec::new(); |
|
| 558 | 562 | s.instances(&colours, &mut inst); |
|
| 559 | 563 | assert_eq!(inst.len(), s.marks.len() * 8); |
|
| … | 53 unchanged lines |
modified crates/hub/src/web/diffusion.rs +19 −1
| … | 19 unchanged lines |
||
| 20 | 20 | follows: Vec<(RepoRow, Consumer)>, |
|
| 21 | 21 | /// This repository has no checks of its own: its bumps are amber. |
|
| 22 | 22 | unverified: bool, |
|
| 23 | + | /// The ripples backdrop's data (docs/backgrounds.md §2): this page's |
|
| 24 | + | /// own repositories and pill tones, JSON safe in a script. |
|
| 25 | + | backdrop: String, |
|
| 23 | 26 | } |
|
| 24 | 27 | ||
| 28 | + | /// The ripples' data: the repository, the upstreams it takes from (each in |
|
| 29 | + | /// its upstream half's tone) and the consumers it diffuses to (each in its |
|
| 30 | + | /// own half's tone), as `{scene, repo, upstreams: [[name, tone]], |
|
| 31 | + | /// consumers: [[name, tone]]}`. |
|
| 32 | + | fn ripples_json(slug: &str, used_by: &[Consumer], follows: &[(RepoRow, Consumer)]) -> String { |
|
| 33 | + | let tone = |class: &str| class.trim_start_matches("df-").to_string(); |
|
| 34 | + | let upstreams: Vec<(String, String)> = |
|
| 35 | + | follows.iter().map(|(up, c)| (format!("{}/{}", up.owner, up.name), tone(c.upstream.class))).collect(); |
|
| 36 | + | let consumers: Vec<(String, String)> = used_by.iter().map(|c| (format!("{}/{}", c.owner, c.name), tone(c.downstream.class))).collect(); |
|
| 37 | + | serde_json::json!({ "scene": "ripples", "repo": slug, "upstreams": upstreams, "consumers": consumers }) |
|
| 38 | + | .to_string() |
|
| 39 | + | .replace('<', "\\u003c") |
|
| 40 | + | } |
|
| 41 | + | ||
| 25 | 42 | /// `GET /{owner}/{name}/diffusion`. |
|
| 26 | 43 | pub async fn page( |
|
| 27 | 44 | State(app): State<Shared>, |
|
| … | 5 unchanged lines |
||
| 33 | 50 | let used_by = crate::diffusion::consumers(&app, &row, viewer.account()).await.unwrap_or_default(); |
|
| 34 | 51 | let follows = crate::diffusion::follows(&app, &row, viewer.account()).await; |
|
| 35 | 52 | let unverified = crate::diffusion::unverified(&app, &row).await; |
|
| 36 | - | Ok(Page(DiffusionPage { shell, repo: RepoShell::new(&app, &row, "diffusion").with_role(role), used_by, follows, unverified })) |
|
| 53 | + | let backdrop = ripples_json(&format!("{owner}/{name}"), &used_by, &follows); |
|
| 54 | + | Ok(Page(DiffusionPage { shell, repo: RepoShell::new(&app, &row, "diffusion").with_role(role), used_by, follows, unverified, backdrop })) |
|
| 37 | 55 | } |
|
| 38 | 56 | ||
| 39 | 57 | #[derive(Template)] |
|
| … | 15 unchanged lines |
modified crates/hub/src/web/runners.rs +34 −1
| … | 451 unchanged lines |
||
| 452 | 452 | on_hub: usize, |
|
| 453 | 453 | /// Runners in the image swarm, as "name (site)". |
|
| 454 | 454 | swarm: Vec<String>, |
|
| 455 | + | /// The swarm backdrop's data (docs/backgrounds.md §2): the hub and the |
|
| 456 | + | /// runners, and for each image who holds it and who fetches it now. |
|
| 457 | + | backdrop: String, |
|
| 455 | 458 | } |
|
| 456 | 459 | ||
| 460 | + | /// The swarm's data, `{scene, nodes: [name], images: [{held, fetching}]}`, |
|
| 461 | + | /// the hub first, from the page's own rows. |
|
| 462 | + | fn swarm_json(images: &[ImageRow]) -> String { |
|
| 463 | + | let mut nodes: Vec<String> = vec!["hub".into()]; |
|
| 464 | + | let mut list = Vec::new(); |
|
| 465 | + | for img in images { |
|
| 466 | + | let (mut held, mut fetching) = (Vec::new(), Vec::new()); |
|
| 467 | + | for p in &img.places { |
|
| 468 | + | let i = match p.kind { |
|
| 469 | + | "hub" => 0, |
|
| 470 | + | _ => match nodes.iter().position(|n| *n == p.name) { |
|
| 471 | + | Some(i) => i, |
|
| 472 | + | None => { |
|
| 473 | + | nodes.push(p.name.clone()); |
|
| 474 | + | nodes.len() - 1 |
|
| 475 | + | } |
|
| 476 | + | }, |
|
| 477 | + | }; |
|
| 478 | + | if p.kind == "fetching" { |
|
| 479 | + | fetching.push(i); |
|
| 480 | + | } else { |
|
| 481 | + | held.push(i); |
|
| 482 | + | } |
|
| 483 | + | } |
|
| 484 | + | list.push(serde_json::json!({ "held": held, "fetching": fetching })); |
|
| 485 | + | } |
|
| 486 | + | serde_json::json!({ "scene": "swarm", "nodes": nodes, "images": list }).to_string().replace('<', "\\u003c") |
|
| 487 | + | } |
|
| 488 | + | ||
| 457 | 489 | /// `GET /admin/runners/images` (C0, docs/cylinder.md): every runner image, |
|
| 458 | 490 | /// where it is and what uses it. |
|
| 459 | 491 | pub async fn images(State(app): State<Shared>, viewer: Viewer) -> Result<Page<ImagesPage>, Error> { |
|
| … | 65 unchanged lines |
||
| 525 | 557 | let mut images: Vec<ImageRow> = rows.into_values().collect(); |
|
| 526 | 558 | images.sort_by(|a, b| b.last_used.cmp(&a.last_used).then_with(|| a.hash.cmp(&b.hash))); |
|
| 527 | 559 | let on_hub = kept.len(); |
|
| 528 | - | Ok(Page(ImagesPage { shell, images, on_hub, swarm })) |
|
| 560 | + | let backdrop = swarm_json(&images); |
|
| 561 | + | Ok(Page(ImagesPage { shell, images, on_hub, swarm, backdrop })) |
|
| 529 | 562 | } |
modified crates/hub/tests/flow.rs +7 −0
| … | 3104 unchanged lines |
||
| 3105 | 3105 | let on_hub = row(&kept); |
|
| 3106 | 3106 | assert!(on_hub.contains("img-hub") && on_hub.contains("1.0 MiB") && on_hub.contains("x86_64"), "{on_hub}"); |
|
| 3107 | 3107 | assert!(page.contains("earth-1 (home)"), "the swarm is named"); |
|
| 3108 | + | // The swarm backdrop (docs/backgrounds.md §2): the hub and the runners. |
|
| 3109 | + | assert!(page.contains(r#"{"images":"#) && page.contains(r#""scene":"swarm""#) && page.contains(r#""earth-1""#), "the swarm's data"); |
|
| 3108 | 3110 | assert_eq!(call(&h.app, "GET", "/admin/runners/images", None, None).await.0, StatusCode::NOT_FOUND, "administrators only"); |
|
| 3109 | 3111 | } |
|
| 3110 | 3112 | ||
| … | 276 unchanged lines |
||
| 3387 | 3389 | assert_eq!(st, StatusCode::OK); |
|
| 3388 | 3390 | assert!(page.contains("Diffuses to") && page.contains("andy/game") && page.contains(&format!("#{}", p2.number)), "the bump is listed"); |
|
| 3389 | 3391 | assert!(page.contains("df-green"), "a passing run of a checked upstream is green"); |
|
| 3392 | + | // The ripples backdrop: the page's own repositories and tones. |
|
| 3393 | + | assert!( |
|
| 3394 | + | page.contains(r#""scene":"ripples""#) && page.contains(r#"["andy/game","#) && page.contains(r#""repo":"andy/api""#), |
|
| 3395 | + | "the ripples' data" |
|
| 3396 | + | ); |
|
| 3390 | 3397 | let (_, page) = call(&h.app, "GET", "/andy/game/diffusion", None, None).await; |
|
| 3391 | 3398 | assert!(page.contains("Takes from") && page.contains("andy/api")); |
|
| 3392 | 3399 | let (_, overview) = call(&h.app, "GET", "/andy/game", None, None).await; |
|
| … | 164 unchanged lines |
modified crates/hub/tests/issues.rs +8 −0
| … | 388 unchanged lines |
||
| 389 | 389 | .await |
|
| 390 | 390 | .unwrap(); |
|
| 391 | 391 | assert_eq!(res.headers()[header::CACHE_CONTROL], "private, no-store"); |
|
| 392 | + | // The mark on sign in; adrift on a page not found, not on a refusal. |
|
| 393 | + | assert!(get(app, "/login", None) |
|
| 394 | + | .await |
|
| 395 | + | .body |
|
| 396 | + | .contains(r#"<script type="application/json" id="backdrop-data">{"scene":"mark"}</script>"#)); |
|
| 397 | + | let lost = get(app, "/no/such/page/here", None).await; |
|
| 398 | + | assert_eq!(lost.status, StatusCode::NOT_FOUND); |
|
| 399 | + | assert!(lost.body.contains(r#"{"scene":"adrift"}"#)); |
|
| 392 | 400 | // Signed out, `/` has the hub's sky (#165): public repositories only. |
|
| 393 | 401 | let sky_of = |body: &str| { |
|
| 394 | 402 | let start = body.find(r#"id="backdrop-data">"#).unwrap() + r#"id="backdrop-data">"#.len(); |
|
| … | 583 unchanged lines |
modified docs/backgrounds.md +39 −4
| 1 | 1 | # Backdrops: living backgrounds for OxygenHub |
|
| 2 | 2 | ||
| 3 | - | Status: the engine, the dashboard's Your sky (oxygenhub#157) and the hub's |
|
| 4 | - | sky on Explore and the signed-out home (oxygenhub#165) are built |
|
| 5 | - | (2026-10-13); the other scenes are proposed (oxygenhub#156). |
|
| 3 | + | Status: every scene in §2 is built (2026-10-13): Your sky (oxygenhub#157), |
|
| 4 | + | the hub's sky (#165), and the mark, ripples, the swarm and adrift (#167). |
|
| 6 | 5 | ||
| 7 | 6 | ## 1. What a backdrop is |
|
| 8 | 7 | ||
| … | 82 unchanged lines |
||
| 91 | 90 | - **Parallax.** Three depth layers shift by at most 6 px, 3 px and 1 px |
|
| 92 | 91 | against the cursor's offset from the page's centre. That gives a little |
|
| 93 | 92 | depth with no feeling of motion sickness. |
|
| 93 | + | - **Streams.** Where a scene shows something travelling (Diffusion's |
|
| 94 | + | changes, the swarm's chunks), dots move along a gentle arc at 40 px a |
|
| 95 | + | second, faster than drift, fading in and out at the ends. |
|
| 94 | 96 | - **Arrivals.** When the page learns of new activity, its mark appears |
|
| 95 | 97 | with one soft ring in the brand gradient that widens and fades over |
|
| 96 | 98 | 1.2 s. This is the one moment a backdrop may draw the eye, and only |
|
| … | 45 unchanged lines |
||
| 142 | 144 | notification count), the event joins its cluster with an arrival ring |
|
| 143 | 145 | (§4). Nothing reloads. |
|
| 144 | 146 | ||
| 147 | + | ## 5.1 The other scenes |
|
| 148 | + | ||
| 149 | + | - **The mark** (sign in, register, device sign-in): 560 dots on the |
|
| 150 | + | Oxygen mark's two rings, centred on the page, turning opposite ways at |
|
| 151 | + | 9 px a second at the rim, so the stream runs through the middle like |
|
| 152 | + | the mark's infinity; each ring has its core. Each ring's dots are |
|
| 153 | + | linked in the order round it. Nothing is named. |
|
| 154 | + | - **Ripples** (a repository's Diffusion tab): the repository in the left |
|
| 155 | + | margin with its upstreams beside it, its consumers in the right margin, |
|
| 156 | + | each a ring of 18 dots about a thin-ring core in the tone of its |
|
| 157 | + | Diffusion pill (the upstream's half for an upstream, the consumer's own |
|
| 158 | + | half for a consumer). Ten dots a stream flow from each upstream to the |
|
| 159 | + | repository and from it to each consumer, in the stream's tone, and a |
|
| 160 | + | ring of 40 dots widens from the repository every 5 s over 3.2 s (never |
|
| 161 | + | when still). Names: every repository. |
|
| 162 | + | - **The swarm** (Admin › Runners › Images, now in the standard width so it |
|
| 163 | + | has margins): the hub in the left margin, the runners in the right, |
|
| 164 | + | each ringed by a dot for every image it holds; while a runner fetches |
|
| 165 | + | an image, 8 small squares a stream (chunks) flow to it from every |
|
| 166 | + | holder, the hub's streams bowed one way and peers' the other, up to 40 |
|
| 167 | + | streams. Names: the hub and each runner. |
|
| 168 | + | - **Adrift** (not found, and server errors; never a refusal or a bad |
|
| 169 | + | request): a cluster of 70 dots in the left margin and one larger mark |
|
| 170 | + | leaving it up and to the right at 7 px a second, fading after 70 s and |
|
| 171 | + | setting out again. |
|
| 172 | + | ||
| 173 | + | Every scene is a `Scene` (`crates/backdrop/src/scene.rs`); all but the |
|
| 174 | + | sky share `field.rs`: dots placed by their scene each frame, lit by the |
|
| 175 | + | cursor, joined within a group, named on rest, coloured from the backdrop |
|
| 176 | + | tokens or, where the data has a status, the page's `--ok`, `--warn` and |
|
| 177 | + | `--bad`. The page names its scene in `#backdrop-data` |
|
| 178 | + | (`{"scene": "mark"}`); none means Your sky. |
|
| 179 | + | ||
| 145 | 180 | ## 6. Engine |
|
| 146 | 181 | ||
| 147 | 182 | The engine is Rust compiled to WebAssembly (Andy's choice, 2026-10-13): |
|
| … | 1 unchanged lines |
||
| 149 | 184 | the scene with no browser in it, tested natively; `gl.rs` draws it; |
|
| 150 | 185 | `lib.rs` is the browser glue. `make backdrop` builds it for |
|
| 151 | 186 | `wasm32-unknown-unknown` and runs `wasm-bindgen --target web` into |
|
| 152 | - | `assets/backdrop/` (about 186 KB of wasm, 60 KB gzipped, and the glue), |
|
| 187 | + | `assets/backdrop/` (about 288 KB of wasm, 84 KB gzipped, with every scene, and the glue), |
|
| 153 | 188 | which is committed and embedded in the hub, as `make ts` commits the API |
|
| 154 | 189 | types, so neither the hub build nor its offline CI needs the wasm |
|
| 155 | 190 | toolchain. `make backdrop-check` runs its fmt, clippy (wasm32) and tests. |
|
| … | 51 unchanged lines |
modified templates/admin_images.html +6 −1
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% block title %}Images · Administration{% endblock %} |
|
| 3 | - | {% block mainclass %} class="wide"{% endblock %} |
|
| 4 | 3 | {% block content %} |
|
| 4 | + | {# The swarm (docs/backgrounds.md §2): the hub and the runners, and the |
|
| 5 | + | images moving between them. #} |
|
| 6 | + | <script type="application/json" id="backdrop-data">{{ backdrop|safe }}</script> |
|
| 7 | + | {% include "backdrop.html" %} |
|
| 8 | + | <div data-backdrop-avoid> |
|
| 5 | 9 | <div class="head"><h1>Runners <small>{{ images.len()|plural("image", "images") }} · {{ on_hub }} on the hub · <a href="/admin">Administration</a></small></h1></div> |
|
| 6 | 10 | <div class="bar"><span class="modes"><a href="/admin/runners">runners</a><a href="/admin/runners/images" class="on">images</a><a href="/admin/runners/settings">settings</a></span></div> |
|
| 7 | 11 | <p class="small mute">Every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days, newest use first. {% if swarm.is_empty() %}No runner shares images peer to peer yet: give one a <code>[runner.swarm]</code> section.{% else %}Sharing images peer to peer: {% for s in swarm %}{{ s }}{% if !loop.last %}, {% endif %}{% endfor %}.{% endif %} Send an image to the hub with <code>flow rootfs push HASH</code>.</p> |
|
| … | 13 unchanged lines |
||
| 21 | 25 | {% endfor %} |
|
| 22 | 26 | </table></div> |
|
| 23 | 27 | {% endif %} |
|
| 28 | + | </div> |
|
| 24 | 29 | {% endblock %} |
modified templates/backdrop.html +3 −3
| 1 | - | {# A backdrop (docs/backgrounds.md, #157): the page's `backdrop` data drawn |
|
| 2 | - | by the engine, Rust compiled to WebAssembly. The viewer's choice (Settings › Appearance) is a cookie; |
|
| 1 | + | {# A backdrop (docs/backgrounds.md): the scene the page's |
|
| 2 | + | `#backdrop-data` names (written just before this include) drawn by the |
|
| 3 | + | engine, Rust compiled to WebAssembly. The viewer's choice (Settings › Appearance) is a cookie; |
|
| 3 | 4 | unset, a touch-only device or Save-Data gets a still sky. Any failure |
|
| 4 | 5 | leaves no backdrop and nothing else changed. #} |
|
| 5 | 6 | <canvas id="backdrop" aria-hidden="true"></canvas> |
|
| 6 | - | <script type="application/json" id="backdrop-data">{{ backdrop|safe }}</script> |
|
| 7 | 7 | <script type="module"> |
|
| 8 | 8 | const canvas = document.getElementById("backdrop"); |
|
| 9 | 9 | const chosen = document.cookie.match(/(?:^|; )backdrop=(moving|still|off)/); |
|
| … | 14 unchanged lines |
modified templates/dashboard.html +1 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% block title %}Dashboard{% endblock %} |
|
| 3 | 3 | {% block content %} |
|
| 4 | + | <script type="application/json" id="backdrop-data">{{ backdrop|safe }}</script> |
|
| 4 | 5 | {% include "backdrop.html" %} |
|
| 5 | 6 | <div class="head"> |
|
| 6 | 7 | <h1>Dashboard <small>signed in as {% if let Some(u) = shell.user %}{{ u }}{% endif %}</small></h1> |
|
| … | 91 unchanged lines |
modified templates/device.html +3 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% block title %}Sign in a device{% endblock %} |
|
| 3 | 3 | {% block content %} |
|
| 4 | + | {# The mark (docs/backgrounds.md §2): brand only, no data. #} |
|
| 5 | + | <script type="application/json" id="backdrop-data">{"scene":"mark"}</script> |
|
| 6 | + | {% include "backdrop.html" %} |
|
| 4 | 7 | <div class="head"><h1>Sign in a device <small>an app asked to use your account</small></h1></div> |
|
| 5 | 8 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 6 | 9 | {% if let Some(d) = done %} |
|
| … | 35 unchanged lines |
modified templates/diffusion.html +4 −0
| … | 4 unchanged lines |
||
| 5 | 5 | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 6 | 6 | {% block tabs %}{% call m::tabs(repo, "diffusion") %}{% endblock %} |
|
| 7 | 7 | {% block content %} |
|
| 8 | + | <script type="application/json" id="backdrop-data">{{ backdrop|safe }}</script> |
|
| 9 | + | {% include "backdrop.html" %} |
|
| 10 | + | <div data-backdrop-avoid> |
|
| 8 | 11 | <div class="head"><h1>Diffusion <small>how changes move between this repository and the ones that pin it</small></h1></div> |
|
| 9 | 12 | <p class="small mute df-key">Each pill has two halves. Left, the upstream: <span class="df-pill"><span class="df-half df-green">checked</span></span> its own run passed, <span class="df-pill"><span class="df-half df-amber">untested</span></span> it has no checks, <span class="df-pill"><span class="df-half df-red">failing</span></span> its run failed. Right, the repository that pins it: <span class="df-pill"><span class="df-half df-green">passes</span></span> its run on the bump passed, <span class="df-pill"><span class="df-half df-red">fails</span></span> it failed, <span class="df-pill"><span class="df-half df-grey">testing</span></span> still running, <span class="df-pill"><span class="df-half df-green">up to date</span></span> nothing to bump.</p> |
|
| 10 | 13 | {% if !used_by.is_empty() %} |
|
| … | 19 unchanged lines |
||
| 30 | 33 | {% endif %} |
|
| 31 | 34 | {% if used_by.is_empty() && follows.is_empty() %}<p class="empty">No repository on this hub pins this one, and it pins none that this hub holds.</p>{% endif %} |
|
| 32 | 35 | <p class="small mute">Diffusion opens a proposal in a consumer when its upstream moves, and never lands one itself. How each repository follows is under its Settings › Diffusion. <a href="/help#diffusion">How it works</a></p> |
|
| 36 | + | </div> |
|
| 33 | 37 | {% endblock %} |
modified templates/error.html +5 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% block title %}{{ title }}{% endblock %} |
|
| 3 | 3 | {% block content %} |
|
| 4 | + | {% if status == 404 || status >= 500 %}{# Adrift (docs/backgrounds.md §2): no data. #} |
|
| 5 | + | <script type="application/json" id="backdrop-data">{"scene":"adrift"}</script> |
|
| 6 | + | {% include "backdrop.html" %}{% endif %} |
|
| 7 | + | <div data-backdrop-avoid> |
|
| 4 | 8 | <div class="head"><h1>{{ status }} <small>{{ title }}</small></h1></div> |
|
| 5 | 9 | <p class="lead">{{ detail }}</p> |
|
| 6 | 10 | <p><a class="go" href="/">Back to the front page</a></p> |
|
| 11 | + | </div> |
|
| 7 | 12 | {% endblock %} |
modified templates/home.html +2 −1
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% block title %}{% if let Some(o) = owner %}{{ o }}{% else %}Repositories{% endif %}{% endblock %} |
|
| 3 | 3 | {% block content %} |
|
| 4 | - | {% if !backdrop.is_empty() %}{% include "backdrop.html" %}{% endif %} |
|
| 4 | + | {% if !backdrop.is_empty() %}<script type="application/json" id="backdrop-data">{{ backdrop|safe }}</script> |
|
| 5 | + | {% include "backdrop.html" %}{% endif %} |
|
| 5 | 6 | <div class="head"> |
|
| 6 | 7 | {% if let Some(o) = owner %}<h1>{{ o }} <small>{{ repos.len()|plural("repository", "repositories") }}</small></h1> |
|
| 7 | 8 | {% else %}<h1>Repositories <small>{{ description }}</small></h1>{% endif %} |
|
| … | 31 unchanged lines |
modified templates/login.html +3 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% block title %}Sign in{% endblock %} |
|
| 3 | 3 | {% block content %} |
|
| 4 | + | {# The mark (docs/backgrounds.md §2): brand only, no data. #} |
|
| 5 | + | <script type="application/json" id="backdrop-data">{"scene":"mark"}</script> |
|
| 6 | + | {% include "backdrop.html" %} |
|
| 4 | 7 | <div class="head"><h1>Sign in <small>{{ shell.site }}</small></h1></div> |
|
| 5 | 8 | {% if reset %}<p class="note">Your password is set. Sign in with it; every other session was signed out.</p>{% endif %} |
|
| 6 | 9 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| … | 17 unchanged lines |
modified templates/register.html +3 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% block title %}Register{% endblock %} |
|
| 3 | 3 | {% block content %} |
|
| 4 | + | {# The mark (docs/backgrounds.md §2): brand only, no data. #} |
|
| 5 | + | <script type="application/json" id="backdrop-data">{"scene":"mark"}</script> |
|
| 6 | + | {% include "backdrop.html" %} |
|
| 4 | 7 | <div class="head"><h1>Register <small>{% if first %}the first account becomes the administrator{% else %}registration is {{ mode }}{% endif %}</small></h1></div> |
|
| 5 | 8 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 6 | 9 | {% if !first && mode == "closed" %} |
|
| … | 11 unchanged lines |