OXYGENHUB
# a3ba7cc10673 oxygenhub / crates / backdrop / src / lib.rs
14.2 KiB · Rust · last changed Live arrivals on the sky 22 days ago source blame history raw
17ca6cdd Andy McCall ↶ 1
//! Backdrops (docs/backgrounds.md): the quiet, moving pictures behind
2
//! some of OxygenHub's pages, in Rust compiled to WebAssembly and drawn
3
//! with WebGL2. `sky` is the scene and its motion, with no browser in it;
4
//! `gl` draws it; this module is the browser around them: the frame loop,
5
//! the cursor, the window's size, the page's colours and when to stop.
6
6246d1e5 Andy McCall ↶ 7
pub mod adrift;
8
pub mod field;
17ca6cdd Andy McCall ↶ 9
pub mod gl;
6246d1e5 Andy McCall ↶ 10
pub mod mark;
11
pub mod ripples;
12
pub mod scene;
17ca6cdd Andy McCall ↶ 13
pub mod sky;
6246d1e5 Andy McCall ↶ 14
pub mod swarm;
17ca6cdd Andy McCall ↶ 15
6246d1e5 Andy McCall ↶ 16
use scene::Scene;
17ca6cdd Andy McCall ↶ 17
use sky::{Colours, Event, Kind, Repo, Sky};
18
use std::cell::RefCell;
19
use std::rc::Rc;
20
use wasm_bindgen::prelude::*;
21
use wasm_bindgen::JsCast;
22
use web_sys::{HtmlCanvasElement, HtmlElement, WebGl2RenderingContext, Window};
23
24
/// The highest device pixel ratio drawn at: sharp enough, and cheap.
25
const MAX_DPR: f64 = 1.5;
26
/// After this long without input a moving backdrop draws its last frame
27
/// and stops until the next movement (§6).
28
const STOP_AFTER_S: f64 = 120.0;
29
/// A still cursor drops the frame rate to half after this long.
30
const HALF_RATE_AFTER_S: f64 = 3.0;
31
32
struct Engine {
33
    window: Window,
34
    canvas: HtmlCanvasElement,
35
    renderer: gl::Renderer,
6246d1e5 Andy McCall ↶ 36
    scene: Box<dyn Scene>,
17ca6cdd Andy McCall ↶ 37
    colours: Colours,
38
    colours_at: f64,
39
    probe: HtmlElement,
40
    label: HtmlElement,
41
    label_text: String,
42
    inst: Vec<f32>,
43
    lines: Vec<f32>,
44
    last: Option<f64>,
45
    frame: u64,
46
    running: bool,
47
    input_at: f64,
48
    pw: i32,
49
    ph: i32,
50
}
51
52
type Frame = Rc<RefCell<Option<Closure<dyn FnMut(f64)>>>>;
53
da8f7826 Andy McCall ↶ 54
thread_local! {
55
    /// The running backdrop, for `arrive`.
56
    static RUNNING: RefCell<Option<(Rc<RefCell<Engine>>, Frame)>> = const { RefCell::new(None) };
57
}
58
17ca6cdd Andy McCall ↶ 59
impl Engine {
60
    fn now(&self) -> f64 {
61
        self.window.performance().map(|p| p.now()).unwrap_or(0.0)
62
    }
63
64
    /// Fit the canvas to the window.
65
    fn fit(&mut self) {
66
        let w = self.window.inner_width().ok().and_then(|v| v.as_f64()).unwrap_or(0.0);
67
        let h = self.window.inner_height().ok().and_then(|v| v.as_f64()).unwrap_or(0.0);
68
        let dpr = self.window.device_pixel_ratio().clamp(1.0, MAX_DPR);
69
        self.pw = (w * dpr).round() as i32;
70
        self.ph = (h * dpr).round() as i32;
71
        self.canvas.set_width(self.pw.max(1) as u32);
72
        self.canvas.set_height(self.ph.max(1) as u32);
6907a989 Andy McCall ↶ 73
        // The content column, which the page marks: clusters go beside it.
74
        let avoid = self
75
            .window
76
            .document()
77
            .and_then(|d| d.query_selector("[data-backdrop-avoid]").ok().flatten())
78
            .map(|el| el.get_bounding_client_rect())
79
            .map(|r| (r.left() as f32, r.right() as f32));
6246d1e5 Andy McCall ↶ 80
        self.scene.resize(w as f32, h as f32, avoid);
17ca6cdd Andy McCall ↶ 81
    }
82
83
    /// The page's backdrop tokens, as the browser computes them, so a
84
    /// theme switch shows within a second.
85
    fn read_colours(&mut self) {
86
        let read = |token: &str, fallback: sky::Rgba| -> sky::Rgba {
87
            let _ = self.probe.style().set_property("color", &format!("var({token})"));
88
            self.window
89
                .get_computed_style(&self.probe)
90
                .ok()
91
                .flatten()
92
                .and_then(|s| s.get_property_value("color").ok())
93
                .and_then(|c| sky::parse_colour(&c))
94
                .unwrap_or(fallback)
95
        };
96
        self.colours = Colours {
97
            mark: read("--bd-mark", [0.5, 0.5, 0.55, 0.16]),
98
            lit: read("--bd-lit", [0.04, 0.41, 0.85, 0.7]),
99
            link: read("--bd-link", [0.04, 0.41, 0.85, 0.22]),
6246d1e5 Andy McCall ↶ 100
            ok: read("--ok", [0.1, 0.5, 0.22, 1.0]),
101
            warn: read("--warn", [0.6, 0.4, 0.0, 1.0]),
102
            bad: read("--bad", [0.82, 0.14, 0.18, 1.0]),
17ca6cdd Andy McCall ↶ 103
        };
104
    }
105
106
    /// One animation frame at time `t`; whether another is wanted.
107
    fn tick(&mut self, t: f64) -> bool {
108
        if self.window.document().is_some_and(|d| d.hidden()) {
109
            self.last = None;
110
            return false;
111
        }
112
        let idle = (t - self.input_at) / 1000.0;
113
        self.frame += 1;
6246d1e5 Andy McCall ↶ 114
        if !self.scene.still() && idle > HALF_RATE_AFTER_S && self.frame % 2 == 1 {
17ca6cdd Andy McCall ↶ 115
            return true;
116
        }
117
        let dt = self.last.map(|l| ((t - l) / 1000.0) as f32).unwrap_or(0.0);
118
        self.last = Some(t);
119
        if t - self.colours_at > 1000.0 {
120
            self.read_colours();
121
            self.colours_at = t;
122
        }
6246d1e5 Andy McCall ↶ 123
        self.scene.step(dt);
124
        self.scene.instances(&self.colours, &mut self.inst);
125
        self.scene.links(&self.colours, &mut self.lines);
126
        let (w, h) = self.scene.size();
127
        self.renderer.draw(w, h, self.pw, self.ph, &self.inst, &self.lines);
17ca6cdd Andy McCall ↶ 128
        self.show_label();
6246d1e5 Andy McCall ↶ 129
        if self.scene.still() {
130
            self.scene.busy()
17ca6cdd Andy McCall ↶ 131
        } else {
6246d1e5 Andy McCall ↶ 132
            idle < STOP_AFTER_S || self.scene.busy()
17ca6cdd Andy McCall ↶ 133
        }
134
    }
135
df5c69cb Andy McCall ↶ 136
    /// The name above the cluster a cursor rests by (§4): faded in and out
137
    /// by CSS, which centres it on `left` and sets its foot on `top`.
17ca6cdd Andy McCall ↶ 138
    fn show_label(&mut self) {
6246d1e5 Andy McCall ↶ 139
        let next = self.scene.label().map(|(name, x, y)| (name.to_string(), x, y));
17ca6cdd Andy McCall ↶ 140
        let style = self.label.style();
141
        match next {
142
            Some((name, x, y)) => {
143
                if name != self.label_text {
144
                    self.label.set_text_content(Some(&name));
145
                    self.label_text = name;
146
                }
df5c69cb Andy McCall ↶ 147
                let _ = style.set_property("left", &format!("{}px", x as i32));
148
                let _ = style.set_property("top", &format!("{}px", y as i32));
17ca6cdd Andy McCall ↶ 149
                let _ = style.set_property("opacity", "1");
150
            }
151
            None => {
152
                let _ = style.set_property("opacity", "0");
153
            }
154
        }
155
    }
156
}
157
158
/// Ask for a frame unless the loop is already running.
159
fn wake(engine: &Rc<RefCell<Engine>>, frame: &Frame) {
160
    let mut e = engine.borrow_mut();
161
    if e.running {
162
        return;
163
    }
164
    e.running = true;
165
    if let Some(f) = frame.borrow().as_ref() {
166
        let _ = e.window.request_animation_frame(f.as_ref().unchecked_ref());
167
    }
168
}
169
170
fn get(v: &JsValue, key: &str) -> JsValue {
171
    js_sys::Reflect::get(v, &JsValue::from_str(key)).unwrap_or(JsValue::UNDEFINED)
172
}
173
df5c69cb Andy McCall ↶ 174
/// The page's data (§5): `{now, repos: [name], events: [[repo, kind, at_ms, n]]}`.
17ca6cdd Andy McCall ↶ 175
fn read_data(data: &JsValue) -> (Vec<Repo>, Vec<Event>) {
176
    let now = get(data, "now").as_f64().unwrap_or(0.0);
177
    let repos: Vec<Repo> =
178
        js_sys::Array::from(&get(data, "repos")).iter().filter_map(|v| v.as_string()).map(|name| Repo { name }).collect();
179
    let events: Vec<Event> = js_sys::Array::from(&get(data, "events"))
180
        .iter()
181
        .filter_map(|v| {
182
            let e = js_sys::Array::from(&v);
183
            let repo = e.get(0).as_f64()? as usize;
184
            let kind = Kind::from_code(e.get(1).as_f64()? as u32);
185
            let at = e.get(2).as_f64()?;
df5c69cb Andy McCall ↶ 186
            let n = e.get(3).as_f64().unwrap_or(1.0).max(1.0) as u32;
187
            Some(Event { repo, kind, age_days: ((now - at) / 86_400_000.0).max(0.0) as f32, n })
17ca6cdd Andy McCall ↶ 188
        })
189
        .collect();
190
    (repos, events)
191
}
192
6246d1e5 Andy McCall ↶ 193
fn strings(v: &JsValue) -> Vec<String> {
194
    js_sys::Array::from(v).iter().filter_map(|x| x.as_string()).collect()
195
}
196
197
/// A Diffusion node, `[name, tone]`.
198
fn ripple_nodes(v: &JsValue) -> Vec<ripples::Node> {
199
    js_sys::Array::from(v)
200
        .iter()
201
        .filter_map(|n| {
202
            let n = js_sys::Array::from(&n);
203
            Some(ripples::Node { name: n.get(0).as_string()?, tone: field::Tone::from_name(&n.get(1).as_string().unwrap_or_default()) })
204
        })
205
        .collect()
da8f7826 Andy McCall ↶ 206
}
207
208
/// New activity, as the page polled it (§5, "Live"): `{events: [[repo
209
/// name, kind, at_ms, n]]}`. Each joins its cluster with an arrival ring;
210
/// a scene other than the sky ignores it.
211
#[wasm_bindgen]
212
pub fn arrive(data: JsValue) {
213
    let Some((engine, frame)) = RUNNING.with(|r| r.borrow().clone()) else {
214
        return;
215
    };
216
    let mut any = false;
217
    // Oldest first, so the newest ends up newest.
218
    for v in js_sys::Array::from(&get(&data, "events")).iter().rev() {
219
        let e = js_sys::Array::from(&v);
220
        let (Some(repo), Some(kind)) = (e.get(0).as_string(), e.get(1).as_f64()) else {
221
            continue;
222
        };
223
        let n = e.get(3).as_f64().unwrap_or(1.0).max(1.0) as u32;
224
        engine.borrow_mut().scene.arrive(&repo, Kind::from_code(kind as u32), n);
225
        any = true;
226
    }
227
    if any {
228
        wake(&engine, &frame);
229
    }
6246d1e5 Andy McCall ↶ 230
}
231
232
/// The scene the page's data names (`scene`), the sky when it names none.
233
fn read_scene(data: &JsValue, still: bool) -> Box<dyn Scene> {
234
    match get(data, "scene").as_string().as_deref() {
235
        Some("mark") => Box::new(mark::Mark::new(still)),
236
        Some("adrift") => Box::new(adrift::Adrift::new(still)),
237
        Some("ripples") => Box::new(ripples::Ripples::new(
238
            get(data, "repo").as_string().unwrap_or_default(),
239
            ripple_nodes(&get(data, "upstreams")),
240
            ripple_nodes(&get(data, "consumers")),
241
            still,
242
        )),
243
        Some("swarm") => {
244
            let images = js_sys::Array::from(&get(data, "images"))
245
                .iter()
246
                .map(|img| {
247
                    let idx =
248
                        |key: &str| js_sys::Array::from(&get(&img, key)).iter().filter_map(|v| v.as_f64()).map(|v| v as usize).collect();
249
                    swarm::Image { held: idx("held"), fetching: idx("fetching") }
250
                })
251
                .collect();
252
            Box::new(swarm::Swarm::new(strings(&get(data, "nodes")), images, still))
253
        }
254
        _ => {
255
            let (repos, events) = read_data(data);
256
            Box::new(Sky::new(&repos, &events, 0.0, 0.0, still))
257
        }
258
    }
259
}
260
261
/// Start a backdrop on `canvas` with the page's `data`: the scene it names
262
/// (docs/backgrounds.md §2), the dashboard's sky by default. `still`
17ca6cdd Andy McCall ↶ 263
/// is the viewer's choice of a still backdrop; reduced motion asks for
264
/// one too. Without WebGL2 there is no backdrop, and nothing breaks.
265
#[wasm_bindgen]
266
pub fn start(canvas: HtmlCanvasElement, data: JsValue, still: bool) -> Result<(), JsValue> {
267
    let window = web_sys::window().ok_or("no window")?;
268
    let document = window.document().ok_or("no document")?;
269
    let reduced = window.match_media("(prefers-reduced-motion: reduce)").ok().flatten().is_some_and(|m| m.matches());
270
    let still = still || reduced;
271
272
    let options = js_sys::Object::new();
273
    js_sys::Reflect::set(&options, &"alpha".into(), &true.into())?;
274
    js_sys::Reflect::set(&options, &"premultipliedAlpha".into(), &true.into())?;
275
    js_sys::Reflect::set(&options, &"antialias".into(), &false.into())?;
276
    js_sys::Reflect::set(&options, &"powerPreference".into(), &"low-power".into())?;
277
    let gl: WebGl2RenderingContext = canvas.get_context_with_context_options("webgl2", &options)?.ok_or("no WebGL2")?.dyn_into()?;
278
    let renderer = gl::Renderer::new(gl)?;
279
280
    let body = document.body().ok_or("no body")?;
281
    let probe: HtmlElement = document.create_element("span")?.dyn_into()?;
282
    probe.set_attribute("style", "position:fixed;visibility:hidden;pointer-events:none")?;
283
    probe.set_attribute("aria-hidden", "true")?;
284
    body.append_child(&probe)?;
285
    let label: HtmlElement = document.create_element("div")?.dyn_into()?;
286
    label.set_class_name("bd-label");
287
    label.set_attribute("aria-hidden", "true")?;
288
    body.append_child(&label)?;
289
6246d1e5 Andy McCall ↶ 290
    let scene = read_scene(&data, still);
291
    let colours = Colours::default();
17ca6cdd Andy McCall ↶ 292
    let engine = Rc::new(RefCell::new(Engine {
293
        window: window.clone(),
294
        canvas,
295
        renderer,
6246d1e5 Andy McCall ↶ 296
        scene,
17ca6cdd Andy McCall ↶ 297
        colours,
298
        colours_at: f64::NEG_INFINITY,
299
        probe,
300
        label,
301
        label_text: String::new(),
302
        inst: Vec::new(),
303
        lines: Vec::new(),
304
        last: None,
305
        frame: 0,
306
        running: false,
307
        input_at: 0.0,
308
        pw: 0,
309
        ph: 0,
310
    }));
311
    {
312
        let mut e = engine.borrow_mut();
313
        e.fit();
314
        e.input_at = e.now();
315
    }
316
317
    // The frame loop: each frame asks for the next while it is wanted.
318
    let frame: Frame = Rc::new(RefCell::new(None));
319
    {
320
        let (engine, again) = (engine.clone(), frame.clone());
321
        *frame.borrow_mut() = Some(Closure::new(move |t: f64| {
322
            let more = engine.borrow_mut().tick(t);
323
            let mut e = engine.borrow_mut();
324
            if more {
325
                if let Some(f) = again.borrow().as_ref() {
326
                    let _ = e.window.request_animation_frame(f.as_ref().unchecked_ref());
327
                }
328
            } else {
329
                e.running = false;
330
            }
331
        }));
332
    }
333
334
    // The cursor lights marks; it never moves them.
335
    {
336
        let (engine, frame) = (engine.clone(), frame.clone());
337
        let moved = Closure::<dyn FnMut(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
338
            {
339
                let mut e = engine.borrow_mut();
6246d1e5 Andy McCall ↶ 340
                e.scene.point(Some((ev.client_x() as f32, ev.client_y() as f32)));
17ca6cdd Andy McCall ↶ 341
                e.input_at = e.now();
342
            }
343
            wake(&engine, &frame);
344
        });
345
        window.add_event_listener_with_callback("mousemove", moved.as_ref().unchecked_ref())?;
346
        moved.forget();
347
    }
348
    {
349
        let (engine, frame) = (engine.clone(), frame.clone());
350
        let left = Closure::<dyn FnMut(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
351
            if ev.related_target().is_none() {
6246d1e5 Andy McCall ↶ 352
                engine.borrow_mut().scene.point(None);
17ca6cdd Andy McCall ↶ 353
                wake(&engine, &frame);
354
            }
355
        });
356
        document.add_event_listener_with_callback("mouseout", left.as_ref().unchecked_ref())?;
357
        left.forget();
358
    }
359
    {
360
        let (engine, frame) = (engine.clone(), frame.clone());
361
        let resized = Closure::<dyn FnMut()>::new(move || {
362
            engine.borrow_mut().fit();
363
            wake(&engine, &frame);
364
        });
365
        window.add_event_listener_with_callback("resize", resized.as_ref().unchecked_ref())?;
366
        resized.forget();
367
    }
368
    {
369
        let (engine, frame) = (engine.clone(), frame.clone());
370
        let shown = Closure::<dyn FnMut()>::new(move || {
371
            let visible = engine.borrow().window.document().is_some_and(|d| !d.hidden());
372
            if visible {
373
                engine.borrow_mut().last = None;
374
                wake(&engine, &frame);
375
            }
376
        });
377
        document.add_event_listener_with_callback("visibilitychange", shown.as_ref().unchecked_ref())?;
378
        shown.forget();
379
    }
380
da8f7826 Andy McCall ↶ 381
    RUNNING.with(|r| *r.borrow_mut() = Some((engine.clone(), frame.clone())));
17ca6cdd Andy McCall ↶ 382
    wake(&engine, &frame);
383
    Ok(())
384
}