OXYGENHUB
# 6907a989e82a oxygenhub / crates / backdrop / src / lib.rs
11.1 KiB · Rust · last changed feat(web): Your sky closer and fuller: 40% larger, tighter knots, the margins filled (refs #157) 22 days ago source blame history raw
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
7
pub mod gl;
8
pub mod sky;
9
10
use sky::{Colours, Event, Kind, Repo, Sky};
11
use std::cell::RefCell;
12
use std::rc::Rc;
13
use wasm_bindgen::prelude::*;
14
use wasm_bindgen::JsCast;
15
use web_sys::{HtmlCanvasElement, HtmlElement, WebGl2RenderingContext, Window};
16
17
/// The highest device pixel ratio drawn at: sharp enough, and cheap.
18
const MAX_DPR: f64 = 1.5;
19
/// After this long without input a moving backdrop draws its last frame
20
/// and stops until the next movement (§6).
21
const STOP_AFTER_S: f64 = 120.0;
22
/// A still cursor drops the frame rate to half after this long.
23
const HALF_RATE_AFTER_S: f64 = 3.0;
24
25
struct Engine {
26
    window: Window,
27
    canvas: HtmlCanvasElement,
28
    renderer: gl::Renderer,
29
    sky: Sky,
30
    colours: Colours,
31
    colours_at: f64,
32
    probe: HtmlElement,
33
    label: HtmlElement,
34
    label_text: String,
35
    inst: Vec<f32>,
36
    lines: Vec<f32>,
37
    last: Option<f64>,
38
    frame: u64,
39
    running: bool,
40
    input_at: f64,
41
    pw: i32,
42
    ph: i32,
43
}
44
45
type Frame = Rc<RefCell<Option<Closure<dyn FnMut(f64)>>>>;
46
47
impl Engine {
48
    fn now(&self) -> f64 {
49
        self.window.performance().map(|p| p.now()).unwrap_or(0.0)
50
    }
51
52
    /// Fit the canvas to the window.
53
    fn fit(&mut self) {
54
        let w = self.window.inner_width().ok().and_then(|v| v.as_f64()).unwrap_or(0.0);
55
        let h = self.window.inner_height().ok().and_then(|v| v.as_f64()).unwrap_or(0.0);
56
        let dpr = self.window.device_pixel_ratio().clamp(1.0, MAX_DPR);
57
        self.pw = (w * dpr).round() as i32;
58
        self.ph = (h * dpr).round() as i32;
59
        self.canvas.set_width(self.pw.max(1) as u32);
60
        self.canvas.set_height(self.ph.max(1) as u32);
61
        // The content column, which the page marks: clusters go beside it.
62
        let avoid = self
63
            .window
64
            .document()
65
            .and_then(|d| d.query_selector("[data-backdrop-avoid]").ok().flatten())
66
            .map(|el| el.get_bounding_client_rect())
67
            .map(|r| (r.left() as f32, r.right() as f32));
68
        self.sky.resize(w as f32, h as f32, avoid);
69
    }
70
71
    /// The page's backdrop tokens, as the browser computes them, so a
72
    /// theme switch shows within a second.
73
    fn read_colours(&mut self) {
74
        let read = |token: &str, fallback: sky::Rgba| -> sky::Rgba {
75
            let _ = self.probe.style().set_property("color", &format!("var({token})"));
76
            self.window
77
                .get_computed_style(&self.probe)
78
                .ok()
79
                .flatten()
80
                .and_then(|s| s.get_property_value("color").ok())
81
                .and_then(|c| sky::parse_colour(&c))
82
                .unwrap_or(fallback)
83
        };
84
        self.colours = Colours {
85
            mark: read("--bd-mark", [0.5, 0.5, 0.55, 0.16]),
86
            lit: read("--bd-lit", [0.04, 0.41, 0.85, 0.7]),
87
            link: read("--bd-link", [0.04, 0.41, 0.85, 0.22]),
88
        };
89
    }
90
91
    /// One animation frame at time `t`; whether another is wanted.
92
    fn tick(&mut self, t: f64) -> bool {
93
        if self.window.document().is_some_and(|d| d.hidden()) {
94
            self.last = None;
95
            return false;
96
        }
97
        let idle = (t - self.input_at) / 1000.0;
98
        self.frame += 1;
99
        if !self.sky.still && idle > HALF_RATE_AFTER_S && self.frame % 2 == 1 {
100
            return true;
101
        }
102
        let dt = self.last.map(|l| ((t - l) / 1000.0) as f32).unwrap_or(0.0);
103
        self.last = Some(t);
104
        if t - self.colours_at > 1000.0 {
105
            self.read_colours();
106
            self.colours_at = t;
107
        }
108
        self.sky.step(dt);
109
        self.sky.instances(&self.colours, &mut self.inst);
110
        self.sky.links(&self.colours, &mut self.lines);
111
        self.renderer.draw(self.sky.w, self.sky.h, self.pw, self.ph, &self.inst, &self.lines);
112
        self.show_label();
113
        if self.sky.still {
114
            self.sky.busy()
115
        } else {
116
            idle < STOP_AFTER_S || self.sky.busy()
117
        }
118
    }
119
120
    /// The name above the cluster a cursor rests by (§4): faded in and out
121
    /// by CSS, which centres it on `left` and sets its foot on `top`.
122
    fn show_label(&mut self) {
123
        let next = self.sky.label().map(|(name, x, y)| (name.to_string(), x, y));
124
        let style = self.label.style();
125
        match next {
126
            Some((name, x, y)) => {
127
                if name != self.label_text {
128
                    self.label.set_text_content(Some(&name));
129
                    self.label_text = name;
130
                }
131
                let _ = style.set_property("left", &format!("{}px", x as i32));
132
                let _ = style.set_property("top", &format!("{}px", y as i32));
133
                let _ = style.set_property("opacity", "1");
134
            }
135
            None => {
136
                let _ = style.set_property("opacity", "0");
137
            }
138
        }
139
    }
140
}
141
142
/// Ask for a frame unless the loop is already running.
143
fn wake(engine: &Rc<RefCell<Engine>>, frame: &Frame) {
144
    let mut e = engine.borrow_mut();
145
    if e.running {
146
        return;
147
    }
148
    e.running = true;
149
    if let Some(f) = frame.borrow().as_ref() {
150
        let _ = e.window.request_animation_frame(f.as_ref().unchecked_ref());
151
    }
152
}
153
154
fn get(v: &JsValue, key: &str) -> JsValue {
155
    js_sys::Reflect::get(v, &JsValue::from_str(key)).unwrap_or(JsValue::UNDEFINED)
156
}
157
158
/// The page's data (§5): `{now, repos: [name], events: [[repo, kind, at_ms, n]]}`.
159
fn read_data(data: &JsValue) -> (Vec<Repo>, Vec<Event>) {
160
    let now = get(data, "now").as_f64().unwrap_or(0.0);
161
    let repos: Vec<Repo> =
162
        js_sys::Array::from(&get(data, "repos")).iter().filter_map(|v| v.as_string()).map(|name| Repo { name }).collect();
163
    let events: Vec<Event> = js_sys::Array::from(&get(data, "events"))
164
        .iter()
165
        .filter_map(|v| {
166
            let e = js_sys::Array::from(&v);
167
            let repo = e.get(0).as_f64()? as usize;
168
            let kind = Kind::from_code(e.get(1).as_f64()? as u32);
169
            let at = e.get(2).as_f64()?;
170
            let n = e.get(3).as_f64().unwrap_or(1.0).max(1.0) as u32;
171
            Some(Event { repo, kind, age_days: ((now - at) / 86_400_000.0).max(0.0) as f32, n })
172
        })
173
        .collect();
174
    (repos, events)
175
}
176
177
/// Start the dashboard's sky on `canvas` with the page's `data`. `still`
178
/// is the viewer's choice of a still backdrop; reduced motion asks for
179
/// one too. Without WebGL2 there is no backdrop, and nothing breaks.
180
#[wasm_bindgen]
181
pub fn start(canvas: HtmlCanvasElement, data: JsValue, still: bool) -> Result<(), JsValue> {
182
    let window = web_sys::window().ok_or("no window")?;
183
    let document = window.document().ok_or("no document")?;
184
    let reduced = window.match_media("(prefers-reduced-motion: reduce)").ok().flatten().is_some_and(|m| m.matches());
185
    let still = still || reduced;
186
187
    let options = js_sys::Object::new();
188
    js_sys::Reflect::set(&options, &"alpha".into(), &true.into())?;
189
    js_sys::Reflect::set(&options, &"premultipliedAlpha".into(), &true.into())?;
190
    js_sys::Reflect::set(&options, &"antialias".into(), &false.into())?;
191
    js_sys::Reflect::set(&options, &"powerPreference".into(), &"low-power".into())?;
192
    let gl: WebGl2RenderingContext = canvas.get_context_with_context_options("webgl2", &options)?.ok_or("no WebGL2")?.dyn_into()?;
193
    let renderer = gl::Renderer::new(gl)?;
194
195
    let body = document.body().ok_or("no body")?;
196
    let probe: HtmlElement = document.create_element("span")?.dyn_into()?;
197
    probe.set_attribute("style", "position:fixed;visibility:hidden;pointer-events:none")?;
198
    probe.set_attribute("aria-hidden", "true")?;
199
    body.append_child(&probe)?;
200
    let label: HtmlElement = document.create_element("div")?.dyn_into()?;
201
    label.set_class_name("bd-label");
202
    label.set_attribute("aria-hidden", "true")?;
203
    body.append_child(&label)?;
204
205
    let (repos, events) = read_data(&data);
206
    let sky = Sky::new(&repos, &events, 0.0, 0.0, still);
207
    let colours = Colours { mark: [0.0; 4], lit: [0.0; 4], link: [0.0; 4] };
208
    let engine = Rc::new(RefCell::new(Engine {
209
        window: window.clone(),
210
        canvas,
211
        renderer,
212
        sky,
213
        colours,
214
        colours_at: f64::NEG_INFINITY,
215
        probe,
216
        label,
217
        label_text: String::new(),
218
        inst: Vec::new(),
219
        lines: Vec::new(),
220
        last: None,
221
        frame: 0,
222
        running: false,
223
        input_at: 0.0,
224
        pw: 0,
225
        ph: 0,
226
    }));
227
    {
228
        let mut e = engine.borrow_mut();
229
        e.fit();
230
        e.input_at = e.now();
231
    }
232
233
    // The frame loop: each frame asks for the next while it is wanted.
234
    let frame: Frame = Rc::new(RefCell::new(None));
235
    {
236
        let (engine, again) = (engine.clone(), frame.clone());
237
        *frame.borrow_mut() = Some(Closure::new(move |t: f64| {
238
            let more = engine.borrow_mut().tick(t);
239
            let mut e = engine.borrow_mut();
240
            if more {
241
                if let Some(f) = again.borrow().as_ref() {
242
                    let _ = e.window.request_animation_frame(f.as_ref().unchecked_ref());
243
                }
244
            } else {
245
                e.running = false;
246
            }
247
        }));
248
    }
249
250
    // The cursor lights marks; it never moves them.
251
    {
252
        let (engine, frame) = (engine.clone(), frame.clone());
253
        let moved = Closure::<dyn FnMut(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
254
            {
255
                let mut e = engine.borrow_mut();
256
                e.sky.point(Some((ev.client_x() as f32, ev.client_y() as f32)));
257
                e.input_at = e.now();
258
            }
259
            wake(&engine, &frame);
260
        });
261
        window.add_event_listener_with_callback("mousemove", moved.as_ref().unchecked_ref())?;
262
        moved.forget();
263
    }
264
    {
265
        let (engine, frame) = (engine.clone(), frame.clone());
266
        let left = Closure::<dyn FnMut(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
267
            if ev.related_target().is_none() {
268
                engine.borrow_mut().sky.point(None);
269
                wake(&engine, &frame);
270
            }
271
        });
272
        document.add_event_listener_with_callback("mouseout", left.as_ref().unchecked_ref())?;
273
        left.forget();
274
    }
275
    {
276
        let (engine, frame) = (engine.clone(), frame.clone());
277
        let resized = Closure::<dyn FnMut()>::new(move || {
278
            engine.borrow_mut().fit();
279
            wake(&engine, &frame);
280
        });
281
        window.add_event_listener_with_callback("resize", resized.as_ref().unchecked_ref())?;
282
        resized.forget();
283
    }
284
    {
285
        let (engine, frame) = (engine.clone(), frame.clone());
286
        let shown = Closure::<dyn FnMut()>::new(move || {
287
            let visible = engine.borrow().window.document().is_some_and(|d| !d.hidden());
288
            if visible {
289
                engine.borrow_mut().last = None;
290
                wake(&engine, &frame);
291
            }
292
        });
293
        document.add_event_listener_with_callback("visibilitychange", shown.as_ref().unchecked_ref())?;
294
        shown.forget();
295
    }
296
297
    wake(&engine, &frame);
298
    Ok(())
299
}