//! Backdrops (docs/backgrounds.md): the quiet, moving pictures behind //! some of OxygenHub's pages, in Rust compiled to WebAssembly and drawn //! with WebGL2. `sky` is the scene and its motion, with no browser in it; //! `gl` draws it; this module is the browser around them: the frame loop, //! the cursor, the window's size, the page's colours and when to stop. pub mod adrift; pub mod field; pub mod gl; pub mod mark; pub mod ripples; pub mod scene; pub mod sky; pub mod swarm; use scene::Scene; use sky::{Colours, Event, Kind, Repo, Sky}; use std::cell::RefCell; use std::rc::Rc; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use web_sys::{HtmlCanvasElement, HtmlElement, WebGl2RenderingContext, Window}; /// The highest device pixel ratio drawn at: sharp enough, and cheap. const MAX_DPR: f64 = 1.5; /// After this long without input a moving backdrop draws its last frame /// and stops until the next movement (§6). const STOP_AFTER_S: f64 = 120.0; /// A still cursor drops the frame rate to half after this long. const HALF_RATE_AFTER_S: f64 = 3.0; struct Engine { window: Window, canvas: HtmlCanvasElement, renderer: gl::Renderer, scene: Box, colours: Colours, colours_at: f64, probe: HtmlElement, label: HtmlElement, label_text: String, inst: Vec, lines: Vec, last: Option, frame: u64, running: bool, input_at: f64, pw: i32, ph: i32, } type Frame = Rc>>>; thread_local! { /// The running backdrop, for `arrive`. static RUNNING: RefCell>, Frame)>> = const { RefCell::new(None) }; } impl Engine { fn now(&self) -> f64 { self.window.performance().map(|p| p.now()).unwrap_or(0.0) } /// Fit the canvas to the window. fn fit(&mut self) { let w = self.window.inner_width().ok().and_then(|v| v.as_f64()).unwrap_or(0.0); let h = self.window.inner_height().ok().and_then(|v| v.as_f64()).unwrap_or(0.0); let dpr = self.window.device_pixel_ratio().clamp(1.0, MAX_DPR); self.pw = (w * dpr).round() as i32; self.ph = (h * dpr).round() as i32; self.canvas.set_width(self.pw.max(1) as u32); self.canvas.set_height(self.ph.max(1) as u32); // The content column, which the page marks: clusters go beside it. let avoid = self .window .document() .and_then(|d| d.query_selector("[data-backdrop-avoid]").ok().flatten()) .map(|el| el.get_bounding_client_rect()) .map(|r| (r.left() as f32, r.right() as f32)); self.scene.resize(w as f32, h as f32, avoid); } /// The page's backdrop tokens, as the browser computes them, so a /// theme switch shows within a second. fn read_colours(&mut self) { let read = |token: &str, fallback: sky::Rgba| -> sky::Rgba { let _ = self.probe.style().set_property("color", &format!("var({token})")); self.window .get_computed_style(&self.probe) .ok() .flatten() .and_then(|s| s.get_property_value("color").ok()) .and_then(|c| sky::parse_colour(&c)) .unwrap_or(fallback) }; self.colours = Colours { mark: read("--bd-mark", [0.5, 0.5, 0.55, 0.16]), lit: read("--bd-lit", [0.04, 0.41, 0.85, 0.7]), link: read("--bd-link", [0.04, 0.41, 0.85, 0.22]), ok: read("--ok", [0.1, 0.5, 0.22, 1.0]), warn: read("--warn", [0.6, 0.4, 0.0, 1.0]), bad: read("--bad", [0.82, 0.14, 0.18, 1.0]), }; } /// One animation frame at time `t`; whether another is wanted. fn tick(&mut self, t: f64) -> bool { if self.window.document().is_some_and(|d| d.hidden()) { self.last = None; return false; } let idle = (t - self.input_at) / 1000.0; self.frame += 1; if !self.scene.still() && idle > HALF_RATE_AFTER_S && self.frame % 2 == 1 { return true; } let dt = self.last.map(|l| ((t - l) / 1000.0) as f32).unwrap_or(0.0); self.last = Some(t); if t - self.colours_at > 1000.0 { self.read_colours(); self.colours_at = t; } self.scene.step(dt); self.scene.instances(&self.colours, &mut self.inst); self.scene.links(&self.colours, &mut self.lines); let (w, h) = self.scene.size(); self.renderer.draw(w, h, self.pw, self.ph, &self.inst, &self.lines); self.show_label(); if self.scene.still() { self.scene.busy() } else { idle < STOP_AFTER_S || self.scene.busy() } } /// The name above the cluster a cursor rests by (§4): faded in and out /// by CSS, which centres it on `left` and sets its foot on `top`. fn show_label(&mut self) { let next = self.scene.label().map(|(name, x, y)| (name.to_string(), x, y)); let style = self.label.style(); match next { Some((name, x, y)) => { if name != self.label_text { self.label.set_text_content(Some(&name)); self.label_text = name; } let _ = style.set_property("left", &format!("{}px", x as i32)); let _ = style.set_property("top", &format!("{}px", y as i32)); let _ = style.set_property("opacity", "1"); } None => { let _ = style.set_property("opacity", "0"); } } } } /// Ask for a frame unless the loop is already running. fn wake(engine: &Rc>, frame: &Frame) { let mut e = engine.borrow_mut(); if e.running { return; } e.running = true; if let Some(f) = frame.borrow().as_ref() { let _ = e.window.request_animation_frame(f.as_ref().unchecked_ref()); } } fn get(v: &JsValue, key: &str) -> JsValue { js_sys::Reflect::get(v, &JsValue::from_str(key)).unwrap_or(JsValue::UNDEFINED) } /// The page's data (§5): `{now, repos: [name], events: [[repo, kind, at_ms, n]]}`. fn read_data(data: &JsValue) -> (Vec, Vec) { let now = get(data, "now").as_f64().unwrap_or(0.0); let repos: Vec = js_sys::Array::from(&get(data, "repos")).iter().filter_map(|v| v.as_string()).map(|name| Repo { name }).collect(); let events: Vec = js_sys::Array::from(&get(data, "events")) .iter() .filter_map(|v| { let e = js_sys::Array::from(&v); let repo = e.get(0).as_f64()? as usize; let kind = Kind::from_code(e.get(1).as_f64()? as u32); let at = e.get(2).as_f64()?; let n = e.get(3).as_f64().unwrap_or(1.0).max(1.0) as u32; Some(Event { repo, kind, age_days: ((now - at) / 86_400_000.0).max(0.0) as f32, n }) }) .collect(); (repos, events) } fn strings(v: &JsValue) -> Vec { js_sys::Array::from(v).iter().filter_map(|x| x.as_string()).collect() } /// A Diffusion node, `[name, tone]`. fn ripple_nodes(v: &JsValue) -> Vec { js_sys::Array::from(v) .iter() .filter_map(|n| { let n = js_sys::Array::from(&n); Some(ripples::Node { name: n.get(0).as_string()?, tone: field::Tone::from_name(&n.get(1).as_string().unwrap_or_default()) }) }) .collect() } /// New activity, as the page polled it (§5, "Live"): `{events: [[repo /// name, kind, at_ms, n]]}`. Each joins its cluster with an arrival ring; /// a scene other than the sky ignores it. #[wasm_bindgen] pub fn arrive(data: JsValue) { let Some((engine, frame)) = RUNNING.with(|r| r.borrow().clone()) else { return; }; let mut any = false; // Oldest first, so the newest ends up newest. for v in js_sys::Array::from(&get(&data, "events")).iter().rev() { let e = js_sys::Array::from(&v); let (Some(repo), Some(kind)) = (e.get(0).as_string(), e.get(1).as_f64()) else { continue; }; let n = e.get(3).as_f64().unwrap_or(1.0).max(1.0) as u32; engine.borrow_mut().scene.arrive(&repo, Kind::from_code(kind as u32), n); any = true; } if any { wake(&engine, &frame); } } /// The scene the page's data names (`scene`), the sky when it names none. fn read_scene(data: &JsValue, still: bool) -> Box { match get(data, "scene").as_string().as_deref() { Some("mark") => Box::new(mark::Mark::new(still)), Some("adrift") => Box::new(adrift::Adrift::new(still)), Some("ripples") => Box::new(ripples::Ripples::new( get(data, "repo").as_string().unwrap_or_default(), ripple_nodes(&get(data, "upstreams")), ripple_nodes(&get(data, "consumers")), still, )), Some("swarm") => { let images = js_sys::Array::from(&get(data, "images")) .iter() .map(|img| { let idx = |key: &str| js_sys::Array::from(&get(&img, key)).iter().filter_map(|v| v.as_f64()).map(|v| v as usize).collect(); swarm::Image { held: idx("held"), fetching: idx("fetching") } }) .collect(); Box::new(swarm::Swarm::new(strings(&get(data, "nodes")), images, still)) } _ => { let (repos, events) = read_data(data); Box::new(Sky::new(&repos, &events, 0.0, 0.0, still)) } } } /// Start a backdrop on `canvas` with the page's `data`: the scene it names /// (docs/backgrounds.md §2), the dashboard's sky by default. `still` /// is the viewer's choice of a still backdrop; reduced motion asks for /// one too. Without WebGL2 there is no backdrop, and nothing breaks. #[wasm_bindgen] pub fn start(canvas: HtmlCanvasElement, data: JsValue, still: bool) -> Result<(), JsValue> { let window = web_sys::window().ok_or("no window")?; let document = window.document().ok_or("no document")?; let reduced = window.match_media("(prefers-reduced-motion: reduce)").ok().flatten().is_some_and(|m| m.matches()); let still = still || reduced; let options = js_sys::Object::new(); js_sys::Reflect::set(&options, &"alpha".into(), &true.into())?; js_sys::Reflect::set(&options, &"premultipliedAlpha".into(), &true.into())?; js_sys::Reflect::set(&options, &"antialias".into(), &false.into())?; js_sys::Reflect::set(&options, &"powerPreference".into(), &"low-power".into())?; let gl: WebGl2RenderingContext = canvas.get_context_with_context_options("webgl2", &options)?.ok_or("no WebGL2")?.dyn_into()?; let renderer = gl::Renderer::new(gl)?; let body = document.body().ok_or("no body")?; let probe: HtmlElement = document.create_element("span")?.dyn_into()?; probe.set_attribute("style", "position:fixed;visibility:hidden;pointer-events:none")?; probe.set_attribute("aria-hidden", "true")?; body.append_child(&probe)?; let label: HtmlElement = document.create_element("div")?.dyn_into()?; label.set_class_name("bd-label"); label.set_attribute("aria-hidden", "true")?; body.append_child(&label)?; let scene = read_scene(&data, still); let colours = Colours::default(); let engine = Rc::new(RefCell::new(Engine { window: window.clone(), canvas, renderer, scene, colours, colours_at: f64::NEG_INFINITY, probe, label, label_text: String::new(), inst: Vec::new(), lines: Vec::new(), last: None, frame: 0, running: false, input_at: 0.0, pw: 0, ph: 0, })); { let mut e = engine.borrow_mut(); e.fit(); e.input_at = e.now(); } // The frame loop: each frame asks for the next while it is wanted. let frame: Frame = Rc::new(RefCell::new(None)); { let (engine, again) = (engine.clone(), frame.clone()); *frame.borrow_mut() = Some(Closure::new(move |t: f64| { let more = engine.borrow_mut().tick(t); let mut e = engine.borrow_mut(); if more { if let Some(f) = again.borrow().as_ref() { let _ = e.window.request_animation_frame(f.as_ref().unchecked_ref()); } } else { e.running = false; } })); } // The cursor lights marks; it never moves them. { let (engine, frame) = (engine.clone(), frame.clone()); let moved = Closure::::new(move |ev: web_sys::MouseEvent| { { let mut e = engine.borrow_mut(); e.scene.point(Some((ev.client_x() as f32, ev.client_y() as f32))); e.input_at = e.now(); } wake(&engine, &frame); }); window.add_event_listener_with_callback("mousemove", moved.as_ref().unchecked_ref())?; moved.forget(); } { let (engine, frame) = (engine.clone(), frame.clone()); let left = Closure::::new(move |ev: web_sys::MouseEvent| { if ev.related_target().is_none() { engine.borrow_mut().scene.point(None); wake(&engine, &frame); } }); document.add_event_listener_with_callback("mouseout", left.as_ref().unchecked_ref())?; left.forget(); } { let (engine, frame) = (engine.clone(), frame.clone()); let resized = Closure::::new(move || { engine.borrow_mut().fit(); wake(&engine, &frame); }); window.add_event_listener_with_callback("resize", resized.as_ref().unchecked_ref())?; resized.forget(); } { let (engine, frame) = (engine.clone(), frame.clone()); let shown = Closure::::new(move || { let visible = engine.borrow().window.document().is_some_and(|d| !d.hidden()); if visible { engine.borrow_mut().last = None; wake(&engine, &frame); } }); document.add_event_listener_with_callback("visibilitychange", shown.as_ref().unchecked_ref())?; shown.forget(); } RUNNING.with(|r| *r.borrow_mut() = Some((engine.clone(), frame.clone()))); wake(&engine, &frame); Ok(()) }