OXYGENHUB
# 1dfa631436b6 oxygenhub-android / rust-core / src / backdrop / field.rs
9.5 KiB · Rust · last changed The hub's living backgrounds in the app: the mark at sign-in, Your sky on the dashboard, live runs pulse (refs #23) 8 days ago source blame history raw
1dfa6314 Andy McCall ↶ 1
// Copied from OxygenHub crates/backdrop/src/field.rs (6dc080f6) by
2
// scripts/sync-backdrop.sh: change it there, never here.
3
//! What every scene but the sky shares (docs/backgrounds.md §4): a field
4
//! of dots that its scene places each frame, lit by the cursor without
5
//! being moved, joined within a group in order, and named things that a
6
//! resting cursor names. Dots of a scene with a status (Diffusion's pills)
7
//! take the page's status colours; every other dot the backdrop tokens.
8
9
use crate::backdrop::sky::{Colours, Rgba};
10
11
/// How far the cursor lights dots, in CSS pixels, as in the sky.
12
pub const LIGHT_RADIUS: f32 = 170.0;
13
const LIT_IN: f32 = 0.15;
14
const LIT_OUT: f32 = 0.6;
15
/// The longest link drawn between two dots of a group.
16
const LINK_MAX: f32 = 85.0;
17
const LINK_REST: f32 = 2.0;
18
const LABEL_NEAR: f32 = 110.0;
19
const LABEL_REST: f32 = 0.4;
20
21
/// A dot's colour: the backdrop's own, or a status where the data has one.
22
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23
pub enum Tone {
24
    Mark,
25
    Ok,
26
    Warn,
27
    Bad,
28
}
29
30
impl Tone {
31
    /// A Diffusion pill half's tone as the page names it.
32
    pub fn from_name(s: &str) -> Tone {
33
        match s {
34
            "green" => Tone::Ok,
35
            "amber" => Tone::Warn,
36
            "red" => Tone::Bad,
37
            _ => Tone::Mark,
38
        }
39
    }
40
}
41
42
/// One drawn dot.
43
#[derive(Clone, Copy, Debug)]
44
pub struct Dot {
45
    pub x: f32,
46
    pub y: f32,
47
    /// Radius in CSS pixels.
48
    pub size: f32,
49
    /// The shader's shape (sky::Kind::shape).
50
    pub shape: f32,
51
    pub tone: Tone,
52
    /// 0 to 1: how strongly it shows at rest.
53
    pub bright: f32,
54
    pub lit: f32,
55
    /// Dots of one group are joined in `order` when lit; `NONE` never.
56
    pub group: u32,
57
    pub order: u32,
58
}
59
60
impl Dot {
61
    pub const NONE: u32 = u32::MAX;
62
63
    pub fn new(x: f32, y: f32, size: f32, shape: f32) -> Dot {
64
        Dot { x, y, size, shape, tone: Tone::Mark, bright: 1.0, lit: 0.0, group: Dot::NONE, order: 0 }
65
    }
66
}
67
68
/// Something with a name the cursor can rest by.
69
#[derive(Clone, Debug)]
70
pub struct Named {
71
    pub name: String,
72
    pub x: f32,
73
    pub y: f32,
74
    /// How far its dots reach: the label sits just above.
75
    pub reach: f32,
76
}
77
78
pub struct Field {
79
    pub dots: Vec<Dot>,
80
    pub named: Vec<Named>,
81
    pub cursor: Option<(f32, f32)>,
82
    pub rested: f32,
83
    pub still: bool,
84
    link_fade: f32,
85
}
86
87
impl Field {
88
    pub fn new(still: bool) -> Field {
89
        Field { dots: Vec::new(), named: Vec::new(), cursor: None, rested: 0.0, still, link_fade: 0.0 }
90
    }
91
92
    pub fn point(&mut self, at: Option<(f32, f32)>) {
93
        self.cursor = at;
94
        self.rested = 0.0;
95
    }
96
97
    /// Light the dots near the cursor, after the scene has placed them.
98
    pub fn light(&mut self, dt: f32) {
99
        let dt = dt.clamp(0.0, 0.1);
100
        self.rested += dt;
101
        let cursor = self.cursor;
102
        let still = self.still;
103
        for d in &mut self.dots {
104
            let target = match cursor {
105
                Some((x, y)) if (d.x - x).hypot(d.y - y) <= LIGHT_RADIUS => 1.0,
106
                _ => 0.0,
107
            };
108
            d.lit = if still {
109
                target
110
            } else if target > d.lit {
111
                (d.lit + dt / LIT_IN).min(target)
112
            } else {
113
                (d.lit - dt / LIT_OUT).max(target)
114
            };
115
        }
116
        let fade_to = if cursor.is_some() && self.rested < LINK_REST { 1.0 } else { 0.0 };
117
        self.link_fade = if still {
118
            fade_to
119
        } else if fade_to > self.link_fade {
120
            (self.link_fade + dt / LIT_IN).min(1.0)
121
        } else {
122
            (self.link_fade - dt / LIT_OUT).max(0.0)
123
        };
124
    }
125
126
    /// Whether lighting or links are still easing.
127
    pub fn easing(&self) -> bool {
128
        self.dots.iter().any(|d| d.lit > 0.0 && d.lit < 1.0) || (self.link_fade > 0.0 && self.link_fade < 1.0)
129
    }
130
131
    fn colour(c: &Colours, tone: Tone, bright: f32, lit: f32) -> Rgba {
132
        let (rest, hot) = match tone {
133
            Tone::Mark => (c.mark, c.lit),
134
            Tone::Ok => (with_alpha(c.ok, 0.45), with_alpha(c.ok, 0.95)),
135
            Tone::Warn => (with_alpha(c.warn, 0.45), with_alpha(c.warn, 0.95)),
136
            Tone::Bad => (with_alpha(c.bad, 0.45), with_alpha(c.bad, 0.95)),
137
        };
138
        let rest = [rest[0], rest[1], rest[2], rest[3] * bright];
139
        let t = lit.clamp(0.0, 1.0);
140
        [
141
            rest[0] + (hot[0] - rest[0]) * t,
142
            rest[1] + (hot[1] - rest[1]) * t,
143
            rest[2] + (hot[2] - rest[2]) * t,
144
            rest[3] + (hot[3] - rest[3]) * t,
145
        ]
146
    }
147
148
    /// 8 floats a dot, as sky::Sky::instances lays them out.
149
    pub fn instances(&self, c: &Colours, out: &mut Vec<f32>) {
150
        out.clear();
151
        for d in &self.dots {
152
            let col = Field::colour(c, d.tone, d.bright, d.lit);
153
            let size = d.size * (1.0 + 0.35 * d.lit);
154
            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]]);
155
        }
156
    }
157
158
    /// Lit dots of a group joined in order, where near each other.
159
    pub fn links(&self, c: &Colours, out: &mut Vec<f32>) {
160
        out.clear();
161
        if self.link_fade <= 0.0 {
162
            return;
163
        }
164
        let mut lit: Vec<&Dot> = self.dots.iter().filter(|d| d.group != Dot::NONE && d.lit > 0.05).collect();
165
        lit.sort_by_key(|d| (d.group, d.order));
166
        for pair in lit.windows(2) {
167
            let (a, b) = (pair[0], pair[1]);
168
            if a.group != b.group || (a.x - b.x).hypot(a.y - b.y) > LINK_MAX {
169
                continue;
170
            }
171
            let alpha = c.link[3] * a.lit.min(b.lit) * self.link_fade;
172
            let col = [c.link[0] * alpha, c.link[1] * alpha, c.link[2] * alpha, alpha];
173
            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]]);
174
        }
175
    }
176
177
    /// The name above the named thing a resting cursor is by.
178
    pub fn label(&self) -> Option<(&str, f32, f32)> {
179
        let (x, y) = self.cursor?;
180
        if self.rested < LABEL_REST {
181
            return None;
182
        }
183
        self.named
184
            .iter()
185
            .map(|n| (n, (n.x - x).hypot(n.y - y)))
186
            .filter(|(n, d)| *d <= LABEL_NEAR.max(n.reach))
187
            .min_by(|a, b| a.1.total_cmp(&b.1))
188
            .map(|(n, _)| (n.name.as_str(), n.x, n.y - n.reach - 10.0))
189
    }
190
}
191
192
fn with_alpha(c: Rgba, a: f32) -> Rgba {
193
    [c[0], c[1], c[2], a]
194
}
195
196
/// A number from 0 to 1 from bits of a hash (sky::hash).
197
pub fn unit(h: u64, shift: u32) -> f32 {
198
    ((h >> shift) & 0xffff) as f32 / 65536.0
199
}
200
201
/// One dot of a knot: its orbit about the knot's centre.
202
#[derive(Clone, Copy, Debug)]
203
pub struct Orbit {
204
    pub radius: f32,
205
    pub angle: f32,
206
    /// Radians a second, signed.
207
    pub speed: f32,
208
}
209
210
impl Orbit {
211
    pub fn at(&self, centre: (f32, f32), t: f32) -> (f32, f32) {
212
        let a = self.angle + self.speed * t;
213
        (centre.0 + a.cos() * self.radius, centre.1 + a.sin() * self.radius)
214
    }
215
}
216
217
/// `n` dots as a knot like a sky's cluster (sky::Sky, Andy's 2026-10-13
218
/// tuning): close lanes, more of them the busier, drawn 1.4 times larger,
219
/// turning one way at the sky's slow speed. `seed` makes it stable.
220
pub fn knot(seed: &str, n: usize) -> Vec<Orbit> {
221
    let lanes = (6 + n / 30).min(14) as u32;
222
    let dir = if crate::backdrop::sky::hash(seed) & 1 == 0 { 1.0 } else { -1.0 };
223
    (0..n as u32)
224
        .map(|k| {
225
            let h = crate::backdrop::sky::hash(&format!("{seed}#{k}"));
226
            let radius = (16.0 + (k % lanes) as f32 * 6.5 + unit(h, 0) * 4.0) * 1.4;
227
            let speed = dir * (crate::backdrop::sky::MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 16));
228
            Orbit { radius, angle: unit(h, 32) * std::f32::consts::TAU, speed }
229
        })
230
        .collect()
231
}
232
233
/// A dot's size drawn as the sky draws its marks (1.4 times larger).
234
pub const ZOOM: f32 = 1.4;
235
236
/// The page's margins beside its content column, when both are wide
237
/// enough to hold something: (left width, right start).
238
pub fn margins(w: f32, avoid: Option<(f32, f32)>) -> Option<(f32, f32)> {
239
    let (x0, x1) = avoid?;
240
    (x0 >= 180.0 && w - x1 >= 180.0).then_some((x0, x1))
241
}
242
243
#[cfg(test)]
244
mod tests {
245
    use super::*;
246
247
    #[test]
248
    fn the_cursor_lights_and_joins_a_group_and_names_what_it_rests_by() {
249
        let mut f = Field::new(false);
250
        for i in 0..4 {
251
            let mut d = Dot::new(100.0 + i as f32 * 20.0, 100.0, 3.0, 0.0);
252
            d.group = 0;
253
            d.order = i;
254
            f.dots.push(d);
255
        }
256
        f.dots.push(Dot::new(900.0, 900.0, 3.0, 0.0));
257
        f.named.push(Named { name: "earth-1".into(), x: 130.0, y: 100.0, reach: 40.0 });
258
        f.point(Some((130.0, 100.0)));
259
        for _ in 0..30 {
260
            f.light(1.0 / 60.0);
261
        }
262
        assert!(f.dots[..4].iter().all(|d| d.lit == 1.0) && f.dots[4].lit == 0.0);
263
        let c = Colours::default();
264
        let mut links = Vec::new();
265
        f.links(&c, &mut links);
266
        assert_eq!(links.len(), 3 * 12, "three links in order, none to the dot far away");
267
        assert_eq!(f.label().map(|l| l.0), Some("earth-1"));
268
        let mut inst = Vec::new();
269
        f.instances(&c, &mut inst);
270
        assert_eq!(inst.len(), 5 * 8);
271
    }
272
273
    #[test]
274
    fn status_tones_come_from_the_pill_names() {
275
        assert_eq!(Tone::from_name("green"), Tone::Ok);
276
        assert_eq!(Tone::from_name("amber"), Tone::Warn);
277
        assert_eq!(Tone::from_name("red"), Tone::Bad);
278
        assert_eq!(Tone::from_name("grey"), Tone::Mark);
279
        assert_eq!(margins(2560.0, Some((707.0, 1838.0))), Some((707.0, 1838.0)));
280
        assert_eq!(margins(1200.0, Some((100.0, 1100.0))), None);
281
    }
282
}