tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198629 Sutichaykuna

⎇ 5e0fcae4b438491c340d858d9a907103d366593e crates/apps/gioser-web/src/lib.rs Rust · 508 siq'ikuna · 17.9 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(gioser): shake on click, mouseleave rebound, element particles, taskbar 5e0fcae4b · sergio ·

1//! Entrypoint WASM de la landing GioSer.
2//!
3//! Responsabilidades:
4//! - Montar canvas WebGL2 + listeners de mouse/pointer/resize/keyboard/RAF.
5//! - Reposicionar los 4 tips DOM cada frame siguiendo el aro de la chacana,
6//!   con clamp para que nunca salgan del viewport ni cubran la taskbar.
7//! - Inclinar el título "GioSer" central inyectando CSS vars de tilt+roll.
8//! - Manejar **click/tap dentro del aro** → vibración (impulso al shake spring).
9//! - Manejar **mouseleave del canvas** → tilt vuelve al frente con rebote.
10//! - Drawers MD por elemento que crecen desde la posición del botón
11//!   clickeado hasta fullscreen (excepto la taskbar).
12//! - Taskbar estilo Windows: home a la izquierda + cajitas dinámicas por
13//!   cada vista MD abierta, click cambia el activo.
14
15use std::cell::RefCell;
16use std::rc::Rc;
17
18use gioser_canvas_web::{tips, Renderer};
19use pluma_reader_web::Reader;
20use wasm_bindgen::prelude::*;
21use wasm_bindgen::JsCast;
22use web_sys::{
23    Document, Element, Event, HtmlCanvasElement, HtmlElement, KeyboardEvent, MouseEvent,
24    PointerEvent, Window,
25};
26
27/// Botones se anclan entre la punta de la chacana y el aro grueso.
28const BUTTON_RADIUS_FACTOR: f32 = 1.32;
29/// Altura reservada para la taskbar abajo. Sincronizar con CSS `.taskbar { height }`.
30const TASKBAR_HEIGHT_PX: f32 = 52.0;
31/// Padding de seguridad alrededor de los botones para que nunca toquen los bordes.
32const BUTTON_HALF_W_PX: f32 = 90.0;
33const BUTTON_HALF_H_PX: f32 = 64.0;
34const VIEWPORT_MARGIN_PX: f32 = 14.0;
35const ELEMENTS: [&str; 4] = ["aire", "fuego", "tierra", "agua"];
36
37#[derive(Default)]
38struct TaskbarState {
39    /// Elementos abiertos (en orden de apertura), aparecen como cajitas.
40    open: Vec<String>,
41    /// Cuál está visible. `None` = home (sin drawer activo).
42    active: Option<String>,
43}
44
45struct AppState {
46    document: Document,
47    state: RefCell<TaskbarState>,
48}
49
50impl AppState {
51    fn open_tab(&self, element: &str, origin_x: f64, origin_y: f64, md_url: &str) {
52        self.set_drawer_origin(element, origin_x, origin_y);
53        {
54            let mut s = self.state.borrow_mut();
55            if !s.open.iter().any(|e| e == element) {
56                s.open.push(element.to_string());
57            }
58            s.active = Some(element.to_string());
59        }
60        self.sync();
61        self.load_md_if_empty(element, md_url);
62    }
63
64    fn switch_tab(&self, element: &str, origin_x: f64, origin_y: f64) {
65        self.set_drawer_origin(element, origin_x, origin_y);
66        let mut s = self.state.borrow_mut();
67        if s.open.iter().any(|e| e == element) {
68            s.active = Some(element.to_string());
69        }
70        drop(s);
71        self.sync();
72    }
73
74    fn close_tab(&self, element: &str) {
75        let mut s = self.state.borrow_mut();
76        s.open.retain(|e| e != element);
77        if s.active.as_deref() == Some(element) {
78            s.active = s.open.last().cloned();
79        }
80        drop(s);
81        self.sync();
82    }
83
84    fn home(&self) {
85        let mut s = self.state.borrow_mut();
86        s.open.clear();
87        s.active = None;
88        drop(s);
89        self.sync();
90    }
91
92    fn active(&self) -> Option<String> {
93        self.state.borrow().active.clone()
94    }
95
96    fn set_drawer_origin(&self, element: &str, x: f64, y: f64) {
97        let id = format!("drawer-{}", element);
98        if let Some(el) = self.document.get_element_by_id(&id) {
99            if let Ok(el) = el.dyn_into::<HtmlElement>() {
100                let _ = el.style().set_property("--origin-x", &format!("{:.1}px", x));
101                let _ = el.style().set_property("--origin-y", &format!("{:.1}px", y));
102            }
103        }
104    }
105
106    fn sync(&self) {
107        let s = self.state.borrow();
108        let body = match self.document.body() {
109            Some(b) => b,
110            None => return,
111        };
112        if s.active.is_some() {
113            let _ = body.class_list().add_1("drawer-active");
114        } else {
115            let _ = body.class_list().remove_1("drawer-active");
116        }
117        for &e in &ELEMENTS {
118            let cls = format!("drawer-active-{}", e);
119            if s.active.as_deref() == Some(e) {
120                let _ = body.class_list().add_1(&cls);
121            } else {
122                let _ = body.class_list().remove_1(&cls);
123            }
124        }
125        for &e in &ELEMENTS {
126            let id = format!("drawer-{}", e);
127            if let Some(el) = self.document.get_element_by_id(&id) {
128                if let Ok(el) = el.dyn_into::<HtmlElement>() {
129                    if s.active.as_deref() == Some(e) {
130                        let _ = el.class_list().add_1("open");
131                        el.set_attribute("aria-hidden", "false").ok();
132                    } else {
133                        let _ = el.class_list().remove_1("open");
134                        el.set_attribute("aria-hidden", "true").ok();
135                    }
136                }
137            }
138        }
139        if let Some(list) = self.document.get_element_by_id("taskbar-list") {
140            let mut html = String::new();
141            for e in &s.open {
142                let label = e.to_uppercase();
143                let active = if s.active.as_deref() == Some(e.as_str()) {
144                    " active"
145                } else {
146                    ""
147                };
148                html.push_str(&format!(
149                    "<li><button class=\"taskbar-item{active}\" data-task=\"{e}\" type=\"button\">\
150                     <span class=\"taskbar-item-dot\" aria-hidden=\"true\"></span>{label}</button></li>"
151                ));
152            }
153            list.set_inner_html(&html);
154        }
155    }
156
157    fn load_md_if_empty(&self, element: &str, md_url: &str) {
158        let content_id = format!("drawer-{}-content", element);
159        let Some(content_el) = self.document.get_element_by_id(&content_id) else {
160            return;
161        };
162        let Ok(content): Result<HtmlElement, _> = content_el.dyn_into() else {
163            return;
164        };
165        let inner = content.inner_html();
166        // Si ya tiene contenido renderizado (pluma-doc) y no es loader/error, no re-fetch.
167        if inner.contains("pluma-doc") {
168            return;
169        }
170        let reader = Reader::new(content);
171        let element_owned = element.to_string();
172        let url_owned = md_url.to_string();
173        wasm_bindgen_futures::spawn_local(async move {
174            if let Err(e) = reader.open_url(&url_owned, &element_owned).await {
175                web_sys::console::warn_1(&e);
176            }
177        });
178    }
179}
180
181#[wasm_bindgen(start)]
182pub fn boot() -> Result<(), JsValue> {
183    install_panic_hook();
184    let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
185    let document = window
186        .document()
187        .ok_or_else(|| JsValue::from_str("no document"))?;
188
189    let canvas: HtmlCanvasElement = document
190        .get_element_by_id("gioser-canvas")
191        .ok_or_else(|| JsValue::from_str("no canvas#gioser-canvas"))?
192        .dyn_into()?;
193
194    fit_canvas(&canvas, &window);
195    let renderer = Rc::new(RefCell::new(Renderer::new(&canvas)?));
196    {
197        let mut r = renderer.borrow_mut();
198        r.resize(canvas.width(), canvas.height());
199        r.set_client_size(
200            canvas.client_width() as f32,
201            canvas.client_height() as f32,
202        );
203    }
204
205    let app = Rc::new(AppState {
206        document: document.clone(),
207        state: RefCell::default(),
208    });
209
210    install_resize(&window, &canvas, &renderer)?;
211    install_mouse(&document, &canvas, &renderer)?;
212    install_canvas_pointer(&canvas, &renderer)?;
213    install_canvas_leave(&canvas, &renderer)?;
214    install_tip_clicks(&document, &app)?;
215    install_drawer_close_buttons(&document, &app)?;
216    install_taskbar(&document, &app)?;
217    install_keyboard(&document, &app)?;
218    install_raf(&window, &document, &canvas, &renderer);
219
220    Ok(())
221}
222
223fn install_resize(
224    window: &Window,
225    canvas: &HtmlCanvasElement,
226    renderer: &Rc<RefCell<Renderer>>,
227) -> Result<(), JsValue> {
228    let canvas = canvas.clone();
229    let win2 = window.clone();
230    let r = renderer.clone();
231    let cb = Closure::<dyn FnMut()>::new(move || {
232        fit_canvas(&canvas, &win2);
233        let mut rr = r.borrow_mut();
234        rr.resize(canvas.width(), canvas.height());
235        rr.set_client_size(
236            canvas.client_width() as f32,
237            canvas.client_height() as f32,
238        );
239    });
240    window.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?;
241    cb.forget();
242    Ok(())
243}
244
245fn install_mouse(
246    document: &Document,
247    canvas: &HtmlCanvasElement,
248    renderer: &Rc<RefCell<Renderer>>,
249) -> Result<(), JsValue> {
250    let canvas = canvas.clone();
251    let r = renderer.clone();
252    let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
253        let w = canvas.client_width().max(1) as f32;
254        let h = canvas.client_height().max(1) as f32;
255        let x = e.client_x() as f32 - w * 0.5;
256        let y = h * 0.5 - e.client_y() as f32;
257        r.borrow_mut().set_mouse_px(x, y);
258    });
259    document.add_event_listener_with_callback("mousemove", cb.as_ref().unchecked_ref())?;
260    cb.forget();
261    Ok(())
262}
263
264/// Pointer down dentro del aro → impulso de vibración (click/tap shake).
265fn install_canvas_pointer(
266    canvas: &HtmlCanvasElement,
267    renderer: &Rc<RefCell<Renderer>>,
268) -> Result<(), JsValue> {
269    let canvas2 = canvas.clone();
270    let r = renderer.clone();
271    let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
272        let rect = canvas2.get_bounding_client_rect();
273        let dx = e.client_x() as f64 - (rect.left() + rect.width() / 2.0);
274        let dy = e.client_y() as f64 - (rect.top() + rect.height() / 2.0);
275        let dist2 = dx * dx + dy * dy;
276        let ring = {
277            let rb = r.borrow();
278            rb.click_radius_css_px() as f64
279        };
280        if dist2 <= ring * ring {
281            r.borrow_mut().impulse_click();
282        }
283    });
284    canvas.add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?;
285    cb.forget();
286    Ok(())
287}
288
289/// Mouse sale del canvas → tilt vuelve al frente con rebote del spring.
290fn install_canvas_leave(
291    canvas: &HtmlCanvasElement,
292    renderer: &Rc<RefCell<Renderer>>,
293) -> Result<(), JsValue> {
294    let r = renderer.clone();
295    let cb = Closure::<dyn FnMut(Event)>::new(move |_e: Event| {
296        r.borrow_mut().release_tilt();
297    });
298    canvas.add_event_listener_with_callback("mouseleave", cb.as_ref().unchecked_ref())?;
299    cb.forget();
300    Ok(())
301}
302
303fn install_tip_clicks(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
304    let tips_nodes = document.query_selector_all(".tip[data-md]")?;
305    for i in 0..tips_nodes.length() {
306        let Some(node) = tips_nodes.item(i) else {
307            continue;
308        };
309        let Ok(el) = node.dyn_into::<HtmlElement>() else {
310            continue;
311        };
312        let id = el.id();
313        let element = id.strip_prefix("tip-").unwrap_or("").to_string();
314        let md_url = el.get_attribute("data-md").unwrap_or_default();
315        let app2 = app.clone();
316        let el_for_rect = el.clone();
317        let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
318            e.prevent_default();
319            let rect = el_for_rect.get_bounding_client_rect();
320            let cx = rect.left() + rect.width() / 2.0;
321            let cy = rect.top() + rect.height() / 2.0;
322            app2.open_tab(&element, cx, cy, &md_url);
323        });
324        el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
325        cb.forget();
326    }
327    Ok(())
328}
329
330fn install_drawer_close_buttons(
331    document: &Document,
332    app: &Rc<AppState>,
333) -> Result<(), JsValue> {
334    let closes = document.query_selector_all("[data-close-drawer]")?;
335    for i in 0..closes.length() {
336        let Some(node) = closes.item(i) else { continue };
337        let Ok(el) = node.dyn_into::<HtmlElement>() else {
338            continue;
339        };
340        let el_ref: &Element = el.as_ref();
341        let element_attr = el_ref
342            .closest(".drawer")
343            .ok()
344            .flatten()
345            .and_then(|d| d.get_attribute("data-element"))
346            .unwrap_or_default();
347        let app2 = app.clone();
348        let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
349            e.stop_propagation();
350            app2.close_tab(&element_attr);
351        });
352        el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
353        cb.forget();
354    }
355    Ok(())
356}
357
358fn install_taskbar(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
359    // Botón home
360    let homes = document.query_selector_all("[data-home]")?;
361    for i in 0..homes.length() {
362        let Some(node) = homes.item(i) else { continue };
363        let Ok(el) = node.dyn_into::<HtmlElement>() else {
364            continue;
365        };
366        let app2 = app.clone();
367        let cb = Closure::<dyn FnMut(Event)>::new(move |_e: Event| {
368            app2.home();
369        });
370        el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
371        cb.forget();
372    }
373    // Delegación: 1 listener en la lista, dispatch por data-task del closest .taskbar-item.
374    if let Some(list) = document.get_element_by_id("taskbar-list") {
375        let app2 = app.clone();
376        let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
377            let Some(target) = e.target() else { return };
378            let Ok(target_el): Result<Element, _> = target.dyn_into() else {
379                return;
380            };
381            let Ok(Some(item)) = target_el.closest(".taskbar-item") else {
382                return;
383            };
384            if let Some(task) = item.get_attribute("data-task") {
385                let rect = item.get_bounding_client_rect();
386                let cx = rect.left() + rect.width() / 2.0;
387                let cy = rect.top() + rect.height() / 2.0;
388                app2.switch_tab(&task, cx, cy);
389            }
390        });
391        list.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
392        cb.forget();
393    }
394    Ok(())
395}
396
397fn install_keyboard(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
398    let app2 = app.clone();
399    let cb = Closure::<dyn FnMut(KeyboardEvent)>::new(move |e: KeyboardEvent| {
400        if e.key() == "Escape" {
401            if let Some(active) = app2.active() {
402                app2.close_tab(&active);
403            }
404        }
405    });
406    document.add_event_listener_with_callback("keydown", cb.as_ref().unchecked_ref())?;
407    cb.forget();
408    Ok(())
409}
410
411fn install_raf(
412    window: &Window,
413    document: &Document,
414    canvas: &HtmlCanvasElement,
415    renderer: &Rc<RefCell<Renderer>>,
416) {
417    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut(f64)>>));
418    let g = f.clone();
419    let renderer = renderer.clone();
420    let canvas = canvas.clone();
421    let document = document.clone();
422    let window2 = window.clone();
423    *g.borrow_mut() = Some(Closure::<dyn FnMut(f64)>::new(move |time_ms: f64| {
424        renderer.borrow_mut().render(time_ms);
425        let r = renderer.borrow();
426        position_tips(&document, &canvas, &r);
427        update_tilt_css(&document, &r);
428        drop(r);
429        if let Some(cb) = f.borrow().as_ref() {
430            let _ = window2.request_animation_frame(cb.as_ref().unchecked_ref());
431        }
432    }));
433    let _ = window.request_animation_frame(g.borrow().as_ref().unwrap().as_ref().unchecked_ref());
434}
435
436fn fit_canvas(canvas: &HtmlCanvasElement, window: &Window) {
437    let dpr = window.device_pixel_ratio() as f32;
438    let w = window
439        .inner_width()
440        .ok()
441        .and_then(|v| v.as_f64())
442        .unwrap_or(1280.0) as f32;
443    let h = window
444        .inner_height()
445        .ok()
446        .and_then(|v| v.as_f64())
447        .unwrap_or(720.0) as f32;
448    canvas.set_width((w * dpr) as u32);
449    canvas.set_height((h * dpr) as u32);
450    let el: &HtmlElement = canvas.unchecked_ref();
451    let style = el.style();
452    let _ = style.set_property("width", &format!("{}px", w));
453    let _ = style.set_property("height", &format!("{}px", h));
454}
455
456fn position_tips(document: &Document, canvas: &HtmlCanvasElement, renderer: &Renderer) {
457    let clips = renderer.cardinal_positions_ndc(BUTTON_RADIUS_FACTOR);
458    let cw = canvas.client_width().max(1) as f32;
459    let ch = canvas.client_height().max(1) as f32;
460    // Bounds en CSS pixels donde los botones pueden moverse libremente.
461    let min_x = VIEWPORT_MARGIN_PX + BUTTON_HALF_W_PX;
462    let max_x = (cw - VIEWPORT_MARGIN_PX - BUTTON_HALF_W_PX).max(min_x);
463    let min_y = VIEWPORT_MARGIN_PX + BUTTON_HALF_H_PX;
464    let max_y = (ch - TASKBAR_HEIGHT_PX - VIEWPORT_MARGIN_PX - BUTTON_HALF_H_PX).max(min_y);
465    for (i, (id, _color, _label)) in tips::ORDER.iter().enumerate() {
466        let (nx, ny) = clips[i];
467        let raw_x = (nx + 1.0) * 0.5 * cw;
468        let raw_y = (1.0 - (ny + 1.0) * 0.5) * ch;
469        let px = raw_x.clamp(min_x, max_x);
470        let py = raw_y.clamp(min_y, max_y);
471        let sel = format!("tip-{}", id);
472        if let Some(el) = document.get_element_by_id(&sel) {
473            if let Ok(el) = el.dyn_into::<HtmlElement>() {
474                let _ = el.style().set_property(
475                    "transform",
476                    &format!("translate({:.2}px, {:.2}px) translate(-50%, -50%)", px, py),
477                );
478            }
479        }
480    }
481}
482
483fn update_tilt_css(document: &Document, renderer: &Renderer) {
484    let (pitch, yaw, roll) = renderer.tilt_degrees();
485    if let Some(brand) = document.get_element_by_id("brand") {
486        if let Ok(brand) = brand.dyn_into::<HtmlElement>() {
487            let _ = brand
488                .style()
489                .set_property("--tilt-x", &format!("{:.2}deg", pitch));
490            let _ = brand
491                .style()
492                .set_property("--tilt-y", &format!("{:.2}deg", yaw));
493            let _ = brand
494                .style()
495                .set_property("--tilt-z", &format!("{:.2}deg", roll));
496        }
497    }
498}
499
500fn install_panic_hook() {
501    static SET: std::sync::Once = std::sync::Once::new();
502    SET.call_once(|| {
503        std::panic::set_hook(Box::new(|info| {
504            let msg = format!("{}", info);
505            web_sys::console::error_1(&JsValue::from_str(&msg));
506        }));
507    });
508}