//! Your sky (docs/backgrounds.md §5): the dashboard's scene, its layout //! and its motion, with no browser in it so it is tested natively. //! //! Each repository is a cluster placed by a hash of its name, so a //! viewer's sky is the same on every visit; its events of the last 30 days //! orbit it as marks, brightest when newest, shaped by kind. The cursor //! lights marks near it without moving them, and joins the lit marks of //! one repository in the order they happened. use std::f32::consts::TAU; /// The scale of marks and orbits: the sky drawn 40% closer than first /// built (Andy, 2026-10-13). const ZOOM: f32 = 1.4; /// How far the cursor lights marks, in CSS pixels (§4). pub const LIGHT_RADIUS: f32 = 170.0; /// Seconds a mark takes to light, and to fade. const LIT_IN: f32 = 0.15; const LIT_OUT: f32 = 0.6; /// The fastest a mark drifts, in CSS pixels a second. pub const MAX_SPEED: f32 = 12.0; /// How far each depth layer shifts against the cursor, front to back. const PARALLAX: [f32; 3] = [6.0, 3.0, 1.0]; /// The longest link: only marks that happened one after another and sit /// near each other are joined, so a busy repository reads as a /// constellation, not a scribble. const LINK_MAX: f32 = 85.0; /// Links fade when the cursor has rested this long, in seconds. const LINK_REST: f32 = 2.0; /// A resting cursor this near a cluster's core names it, after this long. const LABEL_NEAR: f32 = 110.0; const LABEL_REST: f32 = 0.4; /// Events older than this many days are not drawn. pub const DAYS: f32 = 30.0; /// The most marks one push makes (one a change), and in all. const PER_PUSH: u32 = 40; const MAX_MARKS: usize = 3000; /// A side margin narrower than this holds no clusters: the sky falls back /// to a ring around the page. const MIN_MARGIN: f32 = 180.0; /// The width a cluster gets in a margin's grid. const CELL: f32 = 260.0; /// An arrival's ring (§4): seconds it lasts, and its radius from first to /// last, in CSS pixels before the zoom. const RING_S: f32 = 1.2; const RING_FROM: f32 = 6.0; const RING_TO: f32 = 46.0; /// What a mark stands for, which decides its shape. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Kind { Push, Proposal, Issue, Release, /// A Flow run. Run, /// The repository itself, its cluster's core. Repo, } impl Kind { /// The code the page's data uses: 0 a push's change, 1 a proposal /// event, 2 an issue event, 3 a release, 4 a Flow run. pub fn from_code(code: u32) -> Kind { match code { 1 => Kind::Proposal, 2 => Kind::Issue, 3 => Kind::Release, 4 => Kind::Run, _ => Kind::Push, } } /// The shape the fragment shader draws: 0 dot, 1 diamond, 2 ring, /// 3 four-point star, 4 thin ring (a repository), 5 square. pub fn shape(self) -> f32 { match self { Kind::Push => 0.0, Kind::Proposal => 1.0, Kind::Issue => 2.0, Kind::Release => 3.0, Kind::Repo => 4.0, Kind::Run => 5.0, } } /// Its radius in CSS pixels. fn size(self) -> f32 { match self { Kind::Push => 2.0, Kind::Run => 2.2, Kind::Proposal => 3.2, Kind::Issue => 3.0, Kind::Release => 4.2, Kind::Repo => 6.0, } } } /// One repository, as the page gives it. #[derive(Clone, Debug)] pub struct Repo { pub name: String, } /// One event, as the page gives it: its repository (an index into the /// repositories), its kind, how old it is, and how many marks it makes (a /// push's changes; one otherwise). #[derive(Clone, Copy, Debug)] pub struct Event { pub repo: usize, pub kind: Kind, pub age_days: f32, pub n: u32, } /// A repository's place: a fraction of the view, and a depth layer. #[derive(Clone, Debug)] pub struct Cluster { pub name: String, pub fx: f32, pub fy: f32, pub layer: usize, } /// One drawn mark, orbiting its cluster. #[derive(Clone, Debug)] pub struct Mark { pub cluster: usize, pub kind: Kind, /// Its place in its cluster's history, 0 the newest; the core is 0 /// too and has radius 0. pub order: u32, pub radius: f32, pub angle: f32, /// Radians a second, signed. pub speed: f32, /// 1 for today, fading to 0.3 for a month ago. pub bright: f32, /// How lit it is now, 0 to 1. pub lit: f32, } /// Straight RGBA, 0 to 1, as the page's tokens give them. pub type Rgba = [f32; 4]; /// The three backdrop tokens (§3), and the page's status colours for the /// scenes whose data has a status (Diffusion's pills). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Colours { pub mark: Rgba, pub lit: Rgba, pub link: Rgba, pub ok: Rgba, pub warn: Rgba, pub bad: Rgba, } /// A widening ring at a cluster's core: something just arrived there. #[derive(Clone, Copy, Debug)] pub struct Ring { pub cluster: usize, /// Seconds since it began. pub age: f32, } /// The scene. pub struct Sky { pub clusters: Vec, pub marks: Vec, /// The view, in CSS pixels. pub w: f32, pub h: f32, /// Where the cursor is, if it is over the page. pub cursor: Option<(f32, f32)>, /// Seconds since the cursor last moved. pub rested: f32, /// Still (reduced motion, or the viewer's choice): no drift, no /// parallax, and marks light at once. pub still: bool, /// The page's content column, left and right, in CSS pixels: clusters /// go in the margins beside it, where they can be seen. avoid: Option<(f32, f32)>, parallax: [(f32, f32); 3], link_fade: f32, /// Arrivals' rings, while they widen. pub rings: Vec, /// Marks added since the page loaded, for their places in the lanes. arrived: u32, } /// FNV-1a, then splitmix64's finaliser: a small, stable hash, so a /// repository's place never changes. The finaliser spreads every bit: /// FNV alone gives names that differ only at the end (`repo#1`, `repo#2`) /// alike high bits, which drew a cluster's marks as fans at a few angles. pub fn hash(s: &str) -> u64 { let mut h: u64 = 0xcbf2_9ce4_8422_2325; for b in s.as_bytes() { h ^= *b as u64; h = h.wrapping_mul(0x0100_0000_01b3); } h = (h ^ (h >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); h = (h ^ (h >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); h ^ (h >> 31) } /// A number from 0 to 1 from bits of a hash. fn unit(h: u64, shift: u32) -> f32 { ((h >> shift) & 0xffff) as f32 / 65536.0 } impl Sky { /// The sky of `repos` and their `events` (newest first, as the page /// lists them), in a view of `w` by `h` CSS pixels. pub fn new(repos: &[Repo], events: &[Event], w: f32, h: f32, still: bool) -> Sky { // The most active repositories in front, the quiet ones behind. let mut activity = vec![0u32; repos.len()]; for e in events { if e.repo < repos.len() && e.age_days <= DAYS { activity[e.repo] += 1; } } let mut rank: Vec = (0..repos.len()).collect(); rank.sort_by_key(|&i| std::cmp::Reverse(activity[i])); let mut layer = vec![2usize; repos.len()]; for (pos, &i) in rank.iter().enumerate() { layer[i] = (pos * 3 / repos.len().max(1)).min(2); } let clusters: Vec = repos.iter().enumerate().map(|(i, r)| Cluster { name: r.name.clone(), fx: 0.5, fy: 0.5, layer: layer[i] }).collect(); let mut marks: Vec = Vec::new(); for i in 0..clusters.len() { 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 }); } // Each event's marks: a push is a dot for each change it brought. let expanded: Vec = events .iter() .filter(|e| e.repo < repos.len() && e.age_days <= DAYS) .flat_map(|e| std::iter::repeat_n(*e, if e.kind == Kind::Push { e.n.clamp(1, PER_PUSH) as usize } else { 1 })) .take(MAX_MARKS) .collect(); // A busy repository gets more lanes, so its marks spread out. let mut count = vec![0u32; repos.len()]; for e in &expanded { count[e.repo] += 1; } let mut seen = vec![0u32; repos.len()]; for e in &expanded { let k = seen[e.repo]; seen[e.repo] += 1; let lanes = (6 + count[e.repo] / 30).min(14); let h = hash(&format!("{}#{k}", repos[e.repo].name)); // Each repository turns one way; its marks keep their lanes, so // two clusters' streams cross without touching. let dir = if hash(&repos[e.repo].name) & 1 == 0 { 1.0 } else { -1.0 }; // Lanes close together: each repository a tight knot. let radius = (16.0 + (k % lanes) as f32 * 6.5 + unit(h, 0) * 4.0) * ZOOM; let speed = dir * (MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 16)); marks.push(Mark { cluster: e.repo, kind: e.kind, order: k, radius, angle: unit(h, 32) * TAU, speed, bright: 1.0 - 0.7 * (e.age_days / DAYS).clamp(0.0, 1.0), lit: 0.0, }); } let mut sky = Sky { clusters, marks, w, h, cursor: None, rested: 0.0, still, avoid: None, parallax: [(0.0, 0.0); 3], link_fade: 0.0, rings: Vec::new(), arrived: 0, }; sky.layout(); sky } /// The view is now `w` by `h`, its content column between `avoid`'s /// left and right. pub fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>) { self.w = w; self.h = h; self.avoid = avoid; self.layout(); } /// Place the clusters. In the margins beside the content when both are /// wide enough: repositories in the order of their names' hashes, split /// between the sides by their widths, each side a staggered grid from /// top to bottom, so the sky fills the space the page leaves. Otherwise /// a ring around the middle. The same repositories in the same view /// always land in the same places. fn layout(&mut self) { let n = self.clusters.len(); let mut order: Vec = (0..n).collect(); order.sort_by_key(|&i| hash(&self.clusters[i].name)); let margins = self.avoid.and_then(|(x0, x1)| { let (l, r) = (x0.max(0.0), (self.w - x1).max(0.0)); (l >= MIN_MARGIN && r >= MIN_MARGIN).then_some((l, r, x1)) }); // One repository's own sky (oxygenhub#196): its cluster sits in the // wider margin, a little below the middle, even a narrow one, so // it never lands on the page's heading or tabs. if n == 1 { if let Some((x0, x1)) = self.avoid { let (l, r) = (x0.max(0.0), (self.w - x1).max(0.0)); if l.max(r) >= 40.0 && self.w > 0.0 { let cx = if r >= l { x1 + r / 2.0 } else { l / 2.0 }; self.clusters[0].fx = cx / self.w; self.clusters[0].fy = 0.6; return; } } } match margins { Some((l, r, x1)) => { let left = (((n as f32) * l / (l + r)).round() as usize).min(n); let (a, b) = order.split_at(left); self.grid(a, 0.0, l); self.grid(b, x1, self.w); } None => { for c in &mut self.clusters { let h = hash(&c.name); let a = unit(h, 0) * TAU; let d = 0.25 + 0.3 * unit(h, 16).sqrt(); c.fx = (0.5 + d * a.cos()).clamp(0.03, 0.97); c.fy = (0.5 + 0.9 * d * a.sin()).clamp(0.05, 0.95); } } } } /// Lay `ids` out in a staggered grid between `x0` and `x1`. fn grid(&mut self, ids: &[usize], x0: f32, x1: f32) { if ids.is_empty() || self.w <= 0.0 || self.h <= 0.0 { return; } let (top, bottom) = (self.h * 0.1, self.h * 0.97); let width = x1 - x0; let cols = ((width / CELL) as usize).clamp(1, 3); let rows = ids.len().div_ceil(cols); let (cw, ch) = (width / cols as f32, (bottom - top) / rows as f32); for (j, &i) in ids.iter().enumerate() { let (col, row) = (j % cols, j / cols); let h = hash(&self.clusters[i].name); let stagger = if cols > 1 && row % 2 == 1 { cw * 0.25 } else { 0.0 }; let cx = x0 + (col as f32 + 0.5) * cw + stagger + (unit(h, 0) - 0.5) * 0.3 * cw; let cy = top + (row as f32 + 0.5) * ch + (unit(h, 16) - 0.5) * 0.3 * ch; self.clusters[i].fx = cx.clamp(x0 + 24.0, x1 - 24.0) / self.w; self.clusters[i].fy = cy / self.h; } } /// The cursor moved to `(x, y)`, or left the page. pub fn point(&mut self, at: Option<(f32, f32)>) { self.cursor = at; self.rested = 0.0; } /// New activity in the repository `name` (§5, "Live"): its marks join /// its cluster as the newest, and one ring widens from its core. A /// repository the sky does not draw is ignored; a still sky adds the /// marks without the ring. pub fn arrive(&mut self, name: &str, kind: Kind, n: u32) { let Some(c) = self.clusters.iter().position(|cl| cl.name == name) else { return; }; let add = if kind == Kind::Push { n.clamp(1, PER_PUSH) } else { 1 }; for m in self.marks.iter_mut().filter(|m| m.cluster == c && m.kind != Kind::Repo) { m.order += add; } let have = self.marks.iter().filter(|m| m.cluster == c && m.kind != Kind::Repo).count() as u32; let lanes = (6 + (have + add) / 30).min(14); let dir = if hash(name) & 1 == 0 { 1.0 } else { -1.0 }; for i in 0..add { let h = hash(&format!("{name}+{}", self.arrived)); self.arrived += 1; let radius = (16.0 + ((have + i) % lanes) as f32 * 6.5 + unit(h, 0) * 4.0) * ZOOM; let speed = dir * (MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 16)); self.marks.push(Mark { cluster: c, kind, order: add - 1 - i, radius, angle: unit(h, 32) * TAU, speed, bright: 1.0, lit: 0.0 }); } // Over the limit, the cluster's oldest marks make room. while self.marks.len() > MAX_MARKS { let Some(oldest) = self .marks .iter() .enumerate() .filter(|(_, m)| m.cluster == c && m.kind != Kind::Repo) .max_by_key(|(_, m)| m.order) .map(|(i, _)| i) else { break; }; self.marks.swap_remove(oldest); } if !self.still { self.rings.retain(|r| r.cluster != c); self.rings.push(Ring { cluster: c, age: 0.0 }); } } /// Where cluster `c`'s core is now. pub fn core(&self, c: usize) -> (f32, f32) { let cl = &self.clusters[c]; let (px, py) = self.parallax[cl.layer]; (cl.fx * self.w + px, cl.fy * self.h + py) } /// Where mark `m` is now. pub fn position(&self, m: &Mark) -> (f32, f32) { let (cx, cy) = self.core(m.cluster); (cx + m.angle.cos() * m.radius, cy + m.angle.sin() * m.radius) } /// Move the scene on by `dt` seconds. pub fn step(&mut self, dt: f32) { let dt = dt.clamp(0.0, 0.1); self.rested += dt; for r in &mut self.rings { r.age += dt; } self.rings.retain(|r| r.age < RING_S); if !self.still { for m in &mut self.marks { m.angle = (m.angle + m.speed * dt) % TAU; } let ease = 1.0 - (-dt * 4.0).exp(); for (l, p) in self.parallax.iter_mut().enumerate() { let (tx, ty) = match self.cursor { Some((x, y)) if self.w > 0.0 && self.h > 0.0 => { (-(x - self.w / 2.0) / (self.w / 2.0) * PARALLAX[l], -(y - self.h / 2.0) / (self.h / 2.0) * PARALLAX[l]) } _ => (0.0, 0.0), }; p.0 += (tx - p.0) * ease; p.1 += (ty - p.1) * ease; } } let lit_targets: Vec = self .marks .iter() .map(|m| match self.cursor { Some((x, y)) => { let (mx, my) = self.position(m); if (mx - x).hypot(my - y) <= LIGHT_RADIUS { 1.0 } else { 0.0 } } None => 0.0, }) .collect(); for (m, target) in self.marks.iter_mut().zip(lit_targets) { m.lit = if self.still { target } else if target > m.lit { (m.lit + dt / LIT_IN).min(target) } else { (m.lit - dt / LIT_OUT).max(target) }; } let fade_to = if self.cursor.is_some() && self.rested < LINK_REST { 1.0 } else { 0.0 }; self.link_fade = if self.still { fade_to } else if fade_to > self.link_fade { (self.link_fade + dt / LIT_IN).min(1.0) } else { (self.link_fade - dt / LIT_OUT).max(0.0) }; } /// Whether anything besides the drift is still changing: lighting, /// links or a ring. The engine keeps drawing a moving sky for a while /// after the last input anyway (§6), then stops until something is. pub fn busy(&self) -> bool { !self.rings.is_empty() || self.marks.iter().any(|m| m.lit > 0.0 && m.lit < 1.0) || (self.link_fade > 0.0 && self.link_fade < 1.0) } /// One instance per mark: centre x, y and radius in CSS pixels, its /// shape, and its colour premultiplied, 8 floats each. pub fn instances(&self, c: &Colours, out: &mut Vec) { out.clear(); for m in &self.marks { let (x, y) = self.position(m); let rest = [c.mark[0], c.mark[1], c.mark[2], c.mark[3] * m.bright]; let col = mix(rest, c.lit, m.lit); let size = m.kind.size() * ZOOM * (1.0 + 0.35 * m.lit); out.extend_from_slice(&[x, y, size, m.kind.shape(), col[0] * col[3], col[1] * col[3], col[2] * col[3], col[3]]); } // Arrivals: a thin ring in the lit colour, widening as it fades. for r in &self.rings { let t = (r.age / RING_S).clamp(0.0, 1.0); let (x, y) = self.core(r.cluster); let size = (RING_FROM + (RING_TO - RING_FROM) * (1.0 - (1.0 - t) * (1.0 - t))) * ZOOM; let a = c.lit[3] * (1.0 - t) * 0.8; out.extend_from_slice(&[x, y, size, Kind::Repo.shape(), c.lit[0] * a, c.lit[1] * a, c.lit[2] * a, a]); } } /// The links among lit marks: each repository's lit marks in the order /// they happened, its core to its newest lit mark. Two vertices a /// link, x, y and a premultiplied colour each, 6 floats a vertex. pub fn links(&self, c: &Colours, out: &mut Vec) { out.clear(); if self.link_fade <= 0.0 { return; } for ci in 0..self.clusters.len() { let mut lit: Vec<&Mark> = self.marks.iter().filter(|m| m.cluster == ci && m.lit > 0.05).collect(); lit.sort_by_key(|m| (m.kind != Kind::Repo, m.order)); for pair in lit.windows(2) { let (a, b) = (self.position(pair[0]), self.position(pair[1])); if (a.0 - b.0).hypot(a.1 - b.1) > LINK_MAX { continue; } let alpha = c.link[3] * pair[0].lit.min(pair[1].lit) * self.link_fade; let col = [c.link[0] * alpha, c.link[1] * alpha, c.link[2] * alpha, alpha]; 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]]); } } } /// How far a cluster's marks reach from its core. fn reach(&self, c: usize) -> f32 { self.marks.iter().filter(|m| m.cluster == c).map(|m| m.radius).fold(0.0, f32::max) } /// The name to show for a resting cursor, and where: centred just /// above the nearest cluster within reach, clear of its marks. pub fn label(&self) -> Option<(&str, f32, f32)> { let (x, y) = self.cursor?; if self.rested < LABEL_REST { return None; } (0..self.clusters.len()) .map(|i| (i, self.core(i))) .map(|(i, (cx, cy))| (i, cx, cy, (cx - x).hypot(cy - y))) .filter(|t| t.3 <= LABEL_NEAR) .min_by(|a, b| a.3.total_cmp(&b.3)) .map(|(i, cx, cy, _)| (self.clusters[i].name.as_str(), cx, cy - self.reach(i) - 10.0)) } } fn mix(a: Rgba, b: Rgba, t: f32) -> Rgba { let t = t.clamp(0.0, 1.0); [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] } /// A computed CSS colour: `rgb(r, g, b)`, `rgba(r, g, b, a)` or /// `color(srgb r g b / a)`, as browsers report them. pub fn parse_colour(s: &str) -> Option { let s = s.trim(); if let Some(inner) = s.strip_prefix("color(srgb").and_then(|r| r.strip_suffix(')')) { let (rgb, a) = match inner.split_once('/') { Some((rgb, a)) => (rgb, a.trim().parse().ok()?), None => (inner, 1.0), }; let v: Vec = rgb.split_whitespace().map(|x| x.parse().ok()).collect::>()?; return (v.len() == 3).then(|| [v[0], v[1], v[2], a]); } let inner = s.strip_prefix("rgba(").or_else(|| s.strip_prefix("rgb("))?.strip_suffix(')')?; let v: Vec = inner.split([',', ' ', '/']).filter(|x| !x.is_empty()).map(|x| x.trim().parse().ok()).collect::>()?; match v.len() { 3 => Some([v[0] / 255.0, v[1] / 255.0, v[2] / 255.0, 1.0]), 4 => Some([v[0] / 255.0, v[1] / 255.0, v[2] / 255.0, v[3]]), _ => None, } } #[cfg(test)] mod tests { use super::*; fn sky(still: bool) -> Sky { let repos = vec![Repo { name: "andy/hub".into() }, Repo { name: "andy/oxygen".into() }]; let events = vec![ Event { repo: 0, kind: Kind::Push, age_days: 0.0, n: 3 }, Event { repo: 0, kind: Kind::Proposal, age_days: 2.0, n: 1 }, Event { repo: 1, kind: Kind::Release, age_days: 29.0, n: 1 }, Event { repo: 1, kind: Kind::Issue, age_days: 45.0, n: 1 }, ]; Sky::new(&repos, &events, 1600.0, 900.0, still) } #[test] fn a_sky_is_the_same_on_every_visit() { let (a, b) = (sky(false), sky(false)); assert_eq!(a.clusters[0].fx, b.clusters[0].fx); assert_eq!(a.clusters[1].fy, b.clusters[1].fy); // A core per repository, a dot per change pushed, and one mark per // other event of the last 30 days. assert_eq!(a.marks.len(), 2 + 3 + 1 + 1); assert!(a.marks.iter().all(|m| m.kind != Kind::Issue), "older than 30 days: not drawn"); let newest = a.marks.iter().find(|m| m.kind == Kind::Push).unwrap(); let old = a.marks.iter().find(|m| m.kind == Kind::Release).unwrap(); assert!(newest.bright > old.bright, "newer is brighter"); assert_eq!(a.clusters[0].layer, 0, "the busiest in front"); } #[test] fn marks_drift_slowly_and_the_cursor_lights_without_pulling() { let mut s = sky(false); for m in &s.marks { assert!(m.speed.abs() * m.radius <= MAX_SPEED + 0.01, "at most 12 px a second"); } let target = s.position(&s.marks[2]); let radii: Vec = s.marks.iter().map(|m| m.radius).collect(); s.point(Some(target)); for _ in 0..30 { s.step(1.0 / 60.0); } assert!(s.marks[2].lit > 0.99, "lit near the cursor"); assert_eq!(radii, s.marks.iter().map(|m| m.radius).collect::>(), "nothing is pulled"); let mut links = Vec::new(); 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() }; s.links(&colours, &mut links); assert!(!links.is_empty() && links.len() % 12 == 0, "lit marks of one repository are joined"); // Resting fades the links; leaving fades the light. for _ in 0..240 { s.step(1.0 / 60.0); } s.links(&colours, &mut links); assert!(links.is_empty(), "a resting cursor's links fade"); s.point(None); for _ in 0..60 { s.step(1.0 / 60.0); } assert!(s.marks.iter().all(|m| m.lit == 0.0)); } #[test] fn still_means_no_drift_and_instant_light() { let mut s = sky(true); let before: Vec = s.marks.iter().map(|m| m.angle).collect(); let at = s.position(&s.marks[2]); s.point(Some(at)); s.step(1.0 / 60.0); assert_eq!(before, s.marks.iter().map(|m| m.angle).collect::>()); assert_eq!(s.marks[2].lit, 1.0); assert!(!s.busy(), "nothing left to animate"); } #[test] fn instances_are_premultiplied_and_a_resting_cursor_names_a_cluster() { let mut s = sky(false); 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() }; let mut inst = Vec::new(); s.instances(&colours, &mut inst); assert_eq!(inst.len(), s.marks.len() * 8); let core = &inst[0..8]; assert!((core[4] - core[7]).abs() < 1e-6 && core[7] < 0.5, "premultiplied, dimmed by brightness"); let (cx, cy) = s.core(1); s.point(Some((cx + 10.0, cy))); assert!(s.label().is_none(), "not while moving"); for _ in 0..10 { s.step(0.05); } assert_eq!(s.label().map(|l| l.0), Some("andy/oxygen")); } #[test] fn clusters_fill_the_margins_beside_the_content() { let repos: Vec = (0..17).map(|i| Repo { name: format!("owner/repo{i}") }).collect(); let mut s = Sky::new(&repos, &[], 2560.0, 1270.0, false); s.resize(2560.0, 1270.0, Some((707.0, 1838.0))); let xs: Vec = (0..17).map(|i| s.core(i).0).collect(); assert!(xs.iter().all(|&x| x < 707.0 || x > 1838.0), "none behind the content: {xs:?}"); let left = xs.iter().filter(|&&x| x < 707.0).count(); assert!((7..=10).contains(&left), "both sides used: {left} on the left"); let ys: Vec = (0..17).map(|i| s.core(i).1).collect(); assert!(ys.iter().any(|&y| y < 400.0) && ys.iter().any(|&y| y > 900.0), "top to bottom: {ys:?}"); // The same view lays the same sky out. let mut again = Sky::new(&repos, &[], 2560.0, 1270.0, false); again.resize(2560.0, 1270.0, Some((707.0, 1838.0))); assert_eq!(xs, (0..17).map(|i| again.core(i).0).collect::>()); // Narrow margins: back to the ring. s.resize(1200.0, 800.0, Some((100.0, 1100.0))); assert!((0..17).any(|i| (s.core(i).0 - 600.0).abs() < 400.0)); } #[test] fn a_clusters_marks_spread_all_the_way_round() { let repos = vec![Repo { name: "doomsdayone/rstar-api".into() }]; let events: Vec = (0..200).map(|_| Event { repo: 0, kind: Kind::Issue, age_days: 1.0, n: 1 }).collect(); let s = Sky::new(&repos, &events, 1600.0, 900.0, true); // Every eighth of the circle holds a fair share of the marks. let mut octants = [0u32; 8]; for m in s.marks.iter().filter(|m| m.kind == Kind::Issue) { octants[((m.angle / TAU * 8.0) as usize).min(7)] += 1; } assert!(octants.iter().all(|&n| (12..=40).contains(&n)), "{octants:?}"); } #[test] fn one_repository_sits_in_the_wider_margin() { let repos = vec![Repo { name: "andy/demo".into() }]; let mut s = Sky::new(&repos, &[], 1400.0, 900.0, false); s.resize(1400.0, 900.0, Some((127.0, 1259.0))); let (x, y) = s.core(0); assert!((x - (1259.0 + 141.0 / 2.0)).abs() < 8.0 && (y - 540.0).abs() < 8.0, "{x} {y}"); } #[test] fn an_arrival_joins_its_cluster_with_one_ring() { let mut s = sky(false); let before = s.marks.iter().filter(|m| m.cluster == 1).count(); s.arrive("andy/oxygen", Kind::Push, 2); s.arrive("someone/else", Kind::Issue, 1); let marks: Vec<&Mark> = s.marks.iter().filter(|m| m.cluster == 1).collect(); assert_eq!(marks.len(), before + 2, "a dot for each change pushed; an unknown repository adds nothing"); let newest: Vec<&&Mark> = marks.iter().filter(|m| m.kind == Kind::Push).collect(); assert!(newest.iter().all(|m| m.order < 2 && m.bright == 1.0), "the newest, at full brightness"); assert!(marks.iter().any(|m| m.kind == Kind::Release && m.order == 2), "older marks move back"); assert_eq!(s.rings.len(), 1); assert!(s.busy(), "a ring is drawn while it widens"); let colours = Colours { lit: [0.0, 0.4, 0.9, 0.7], ..Default::default() }; let mut inst = Vec::new(); s.instances(&colours, &mut inst); assert_eq!(inst.len(), (s.marks.len() + 1) * 8, "the ring is one more instance"); for _ in 0..80 { s.step(1.0 / 60.0); } assert!(s.rings.is_empty() && !s.busy(), "gone after 1.2 s, and nothing left to draw but the drift"); // A still sky takes the marks without the ring. let mut still = sky(true); still.arrive("andy/hub", Kind::Release, 1); assert!(still.rings.is_empty()); assert_eq!(still.marks.iter().filter(|m| m.kind == Kind::Release).count(), 2); } #[test] fn computed_colours_parse() { assert_eq!(parse_colour("rgb(9, 105, 218)"), Some([9.0 / 255.0, 105.0 / 255.0, 218.0 / 255.0, 1.0])); assert_eq!(parse_colour("rgba(0, 240, 255, 0.75)").map(|c| c[3]), Some(0.75)); assert_eq!(parse_colour("color(srgb 0 0.5 1 / 0.2)"), Some([0.0, 0.5, 1.0, 0.2])); assert_eq!(parse_colour("rgb(1 2 3 / 0.5)").map(|c| c[3]), Some(0.5)); assert!(parse_colour("transparent").is_none()); } }