⎇ 5e0fcae4b438491c340d858d9a907103d366593e
crates/apps/gioser-web/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(gioser): shake on click, mouseleave rebound, element particles, taskbar
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}