// Copied from OxygenHub crates/backdrop/src/field.rs (6dc080f6) by // scripts/sync-backdrop.sh: change it there, never here. //! What every scene but the sky shares (docs/backgrounds.md ยง4): a field //! of dots that its scene places each frame, lit by the cursor without //! being moved, joined within a group in order, and named things that a //! resting cursor names. Dots of a scene with a status (Diffusion's pills) //! take the page's status colours; every other dot the backdrop tokens. use crate::backdrop::sky::{Colours, Rgba}; /// How far the cursor lights dots, in CSS pixels, as in the sky. pub const LIGHT_RADIUS: f32 = 170.0; const LIT_IN: f32 = 0.15; const LIT_OUT: f32 = 0.6; /// The longest link drawn between two dots of a group. const LINK_MAX: f32 = 85.0; const LINK_REST: f32 = 2.0; const LABEL_NEAR: f32 = 110.0; const LABEL_REST: f32 = 0.4; /// A dot's colour: the backdrop's own, or a status where the data has one. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Tone { Mark, Ok, Warn, Bad, } impl Tone { /// A Diffusion pill half's tone as the page names it. pub fn from_name(s: &str) -> Tone { match s { "green" => Tone::Ok, "amber" => Tone::Warn, "red" => Tone::Bad, _ => Tone::Mark, } } } /// One drawn dot. #[derive(Clone, Copy, Debug)] pub struct Dot { pub x: f32, pub y: f32, /// Radius in CSS pixels. pub size: f32, /// The shader's shape (sky::Kind::shape). pub shape: f32, pub tone: Tone, /// 0 to 1: how strongly it shows at rest. pub bright: f32, pub lit: f32, /// Dots of one group are joined in `order` when lit; `NONE` never. pub group: u32, pub order: u32, } impl Dot { pub const NONE: u32 = u32::MAX; pub fn new(x: f32, y: f32, size: f32, shape: f32) -> Dot { Dot { x, y, size, shape, tone: Tone::Mark, bright: 1.0, lit: 0.0, group: Dot::NONE, order: 0 } } } /// Something with a name the cursor can rest by. #[derive(Clone, Debug)] pub struct Named { pub name: String, pub x: f32, pub y: f32, /// How far its dots reach: the label sits just above. pub reach: f32, } pub struct Field { pub dots: Vec, pub named: Vec, pub cursor: Option<(f32, f32)>, pub rested: f32, pub still: bool, link_fade: f32, } impl Field { pub fn new(still: bool) -> Field { Field { dots: Vec::new(), named: Vec::new(), cursor: None, rested: 0.0, still, link_fade: 0.0 } } pub fn point(&mut self, at: Option<(f32, f32)>) { self.cursor = at; self.rested = 0.0; } /// Light the dots near the cursor, after the scene has placed them. pub fn light(&mut self, dt: f32) { let dt = dt.clamp(0.0, 0.1); self.rested += dt; let cursor = self.cursor; let still = self.still; for d in &mut self.dots { let target = match cursor { Some((x, y)) if (d.x - x).hypot(d.y - y) <= LIGHT_RADIUS => 1.0, _ => 0.0, }; d.lit = if still { target } else if target > d.lit { (d.lit + dt / LIT_IN).min(target) } else { (d.lit - dt / LIT_OUT).max(target) }; } let fade_to = if cursor.is_some() && self.rested < LINK_REST { 1.0 } else { 0.0 }; self.link_fade = if 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 lighting or links are still easing. pub fn easing(&self) -> bool { self.dots.iter().any(|d| d.lit > 0.0 && d.lit < 1.0) || (self.link_fade > 0.0 && self.link_fade < 1.0) } fn colour(c: &Colours, tone: Tone, bright: f32, lit: f32) -> Rgba { let (rest, hot) = match tone { Tone::Mark => (c.mark, c.lit), Tone::Ok => (with_alpha(c.ok, 0.45), with_alpha(c.ok, 0.95)), Tone::Warn => (with_alpha(c.warn, 0.45), with_alpha(c.warn, 0.95)), Tone::Bad => (with_alpha(c.bad, 0.45), with_alpha(c.bad, 0.95)), }; let rest = [rest[0], rest[1], rest[2], rest[3] * bright]; let t = lit.clamp(0.0, 1.0); [ rest[0] + (hot[0] - rest[0]) * t, rest[1] + (hot[1] - rest[1]) * t, rest[2] + (hot[2] - rest[2]) * t, rest[3] + (hot[3] - rest[3]) * t, ] } /// 8 floats a dot, as sky::Sky::instances lays them out. pub fn instances(&self, c: &Colours, out: &mut Vec) { out.clear(); for d in &self.dots { let col = Field::colour(c, d.tone, d.bright, d.lit); let size = d.size * (1.0 + 0.35 * d.lit); 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]]); } } /// Lit dots of a group joined in order, where near each other. pub fn links(&self, c: &Colours, out: &mut Vec) { out.clear(); if self.link_fade <= 0.0 { return; } let mut lit: Vec<&Dot> = self.dots.iter().filter(|d| d.group != Dot::NONE && d.lit > 0.05).collect(); lit.sort_by_key(|d| (d.group, d.order)); for pair in lit.windows(2) { let (a, b) = (pair[0], pair[1]); if a.group != b.group || (a.x - b.x).hypot(a.y - b.y) > LINK_MAX { continue; } let alpha = c.link[3] * a.lit.min(b.lit) * self.link_fade; let col = [c.link[0] * alpha, c.link[1] * alpha, c.link[2] * alpha, alpha]; 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]]); } } /// The name above the named thing a resting cursor is by. pub fn label(&self) -> Option<(&str, f32, f32)> { let (x, y) = self.cursor?; if self.rested < LABEL_REST { return None; } self.named .iter() .map(|n| (n, (n.x - x).hypot(n.y - y))) .filter(|(n, d)| *d <= LABEL_NEAR.max(n.reach)) .min_by(|a, b| a.1.total_cmp(&b.1)) .map(|(n, _)| (n.name.as_str(), n.x, n.y - n.reach - 10.0)) } } fn with_alpha(c: Rgba, a: f32) -> Rgba { [c[0], c[1], c[2], a] } /// A number from 0 to 1 from bits of a hash (sky::hash). pub fn unit(h: u64, shift: u32) -> f32 { ((h >> shift) & 0xffff) as f32 / 65536.0 } /// One dot of a knot: its orbit about the knot's centre. #[derive(Clone, Copy, Debug)] pub struct Orbit { pub radius: f32, pub angle: f32, /// Radians a second, signed. pub speed: f32, } impl Orbit { pub fn at(&self, centre: (f32, f32), t: f32) -> (f32, f32) { let a = self.angle + self.speed * t; (centre.0 + a.cos() * self.radius, centre.1 + a.sin() * self.radius) } } /// `n` dots as a knot like a sky's cluster (sky::Sky, Andy's 2026-10-13 /// tuning): close lanes, more of them the busier, drawn 1.4 times larger, /// turning one way at the sky's slow speed. `seed` makes it stable. pub fn knot(seed: &str, n: usize) -> Vec { let lanes = (6 + n / 30).min(14) as u32; let dir = if crate::backdrop::sky::hash(seed) & 1 == 0 { 1.0 } else { -1.0 }; (0..n as u32) .map(|k| { let h = crate::backdrop::sky::hash(&format!("{seed}#{k}")); let radius = (16.0 + (k % lanes) as f32 * 6.5 + unit(h, 0) * 4.0) * 1.4; let speed = dir * (crate::backdrop::sky::MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 16)); Orbit { radius, angle: unit(h, 32) * std::f32::consts::TAU, speed } }) .collect() } /// A dot's size drawn as the sky draws its marks (1.4 times larger). pub const ZOOM: f32 = 1.4; /// The page's margins beside its content column, when both are wide /// enough to hold something: (left width, right start). pub fn margins(w: f32, avoid: Option<(f32, f32)>) -> Option<(f32, f32)> { let (x0, x1) = avoid?; (x0 >= 180.0 && w - x1 >= 180.0).then_some((x0, x1)) } #[cfg(test)] mod tests { use super::*; #[test] fn the_cursor_lights_and_joins_a_group_and_names_what_it_rests_by() { let mut f = Field::new(false); for i in 0..4 { let mut d = Dot::new(100.0 + i as f32 * 20.0, 100.0, 3.0, 0.0); d.group = 0; d.order = i; f.dots.push(d); } f.dots.push(Dot::new(900.0, 900.0, 3.0, 0.0)); f.named.push(Named { name: "earth-1".into(), x: 130.0, y: 100.0, reach: 40.0 }); f.point(Some((130.0, 100.0))); for _ in 0..30 { f.light(1.0 / 60.0); } assert!(f.dots[..4].iter().all(|d| d.lit == 1.0) && f.dots[4].lit == 0.0); let c = Colours::default(); let mut links = Vec::new(); f.links(&c, &mut links); assert_eq!(links.len(), 3 * 12, "three links in order, none to the dot far away"); assert_eq!(f.label().map(|l| l.0), Some("earth-1")); let mut inst = Vec::new(); f.instances(&c, &mut inst); assert_eq!(inst.len(), 5 * 8); } #[test] fn status_tones_come_from_the_pill_names() { assert_eq!(Tone::from_name("green"), Tone::Ok); assert_eq!(Tone::from_name("amber"), Tone::Warn); assert_eq!(Tone::from_name("red"), Tone::Bad); assert_eq!(Tone::from_name("grey"), Tone::Mark); assert_eq!(margins(2560.0, Some((707.0, 1838.0))), Some((707.0, 1838.0))); assert_eq!(margins(1200.0, Some((100.0, 1100.0))), None); } }