OXYGENHUB
# 91c306aa4706 oxygenhub / crates / backdrop / src / field.rs
9.3 KiB · Rust · last changed feat(web): every scene as dense as the sky: knots, fuller streams, 1.4x (refs #167) 22 days ago source blame history raw
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
/// One dot of a knot: its orbit about the knot's centre.
200
#[derive(Clone, Copy, Debug)]
201
pub struct Orbit {
202
    pub radius: f32,
203
    pub angle: f32,
204
    /// Radians a second, signed.
205
    pub speed: f32,
206
}
207
208
impl Orbit {
209
    pub fn at(&self, centre: (f32, f32), t: f32) -> (f32, f32) {
210
        let a = self.angle + self.speed * t;
211
        (centre.0 + a.cos() * self.radius, centre.1 + a.sin() * self.radius)
212
    }
213
}
214
215
/// `n` dots as a knot like a sky's cluster (sky::Sky, Andy's 2026-10-13
216
/// tuning): close lanes, more of them the busier, drawn 1.4 times larger,
217
/// turning one way at the sky's slow speed. `seed` makes it stable.
218
pub fn knot(seed: &str, n: usize) -> Vec<Orbit> {
219
    let lanes = (6 + n / 30).min(14) as u32;
220
    let dir = if crate::sky::hash(seed) & 1 == 0 { 1.0 } else { -1.0 };
221
    (0..n as u32)
222
        .map(|k| {
223
            let h = crate::sky::hash(&format!("{seed}#{k}"));
224
            let radius = (16.0 + (k % lanes) as f32 * 6.5 + unit(h, 0) * 4.0) * 1.4;
225
            let speed = dir * (crate::sky::MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 16));
226
            Orbit { radius, angle: unit(h, 32) * std::f32::consts::TAU, speed }
227
        })
228
        .collect()
229
}
230
231
/// A dot's size drawn as the sky draws its marks (1.4 times larger).
232
pub const ZOOM: f32 = 1.4;
233
234
/// The page's margins beside its content column, when both are wide
235
/// enough to hold something: (left width, right start).
236
pub fn margins(w: f32, avoid: Option<(f32, f32)>) -> Option<(f32, f32)> {
237
    let (x0, x1) = avoid?;
238
    (x0 >= 180.0 && w - x1 >= 180.0).then_some((x0, x1))
239
}
240
241
#[cfg(test)]
242
mod tests {
243
    use super::*;
244
245
    #[test]
246
    fn the_cursor_lights_and_joins_a_group_and_names_what_it_rests_by() {
247
        let mut f = Field::new(false);
248
        for i in 0..4 {
249
            let mut d = Dot::new(100.0 + i as f32 * 20.0, 100.0, 3.0, 0.0);
250
            d.group = 0;
251
            d.order = i;
252
            f.dots.push(d);
253
        }
254
        f.dots.push(Dot::new(900.0, 900.0, 3.0, 0.0));
255
        f.named.push(Named { name: "earth-1".into(), x: 130.0, y: 100.0, reach: 40.0 });
256
        f.point(Some((130.0, 100.0)));
257
        for _ in 0..30 {
258
            f.light(1.0 / 60.0);
259
        }
260
        assert!(f.dots[..4].iter().all(|d| d.lit == 1.0) && f.dots[4].lit == 0.0);
261
        let c = Colours::default();
262
        let mut links = Vec::new();
263
        f.links(&c, &mut links);
264
        assert_eq!(links.len(), 3 * 12, "three links in order, none to the dot far away");
265
        assert_eq!(f.label().map(|l| l.0), Some("earth-1"));
266
        let mut inst = Vec::new();
267
        f.instances(&c, &mut inst);
268
        assert_eq!(inst.len(), 5 * 8);
269
    }
270
271
    #[test]
272
    fn status_tones_come_from_the_pill_names() {
273
        assert_eq!(Tone::from_name("green"), Tone::Ok);
274
        assert_eq!(Tone::from_name("amber"), Tone::Warn);
275
        assert_eq!(Tone::from_name("red"), Tone::Bad);
276
        assert_eq!(Tone::from_name("grey"), Tone::Mark);
277
        assert_eq!(margins(2560.0, Some((707.0, 1838.0))), Some((707.0, 1838.0)));
278
        assert_eq!(margins(1200.0, Some((100.0, 1100.0))), None);
279
    }
280
}