tawasuyu

tawasuyu/brahman

Rust 572TOML 217Markdown 68JSON 198326 Definitions

⎇ 794d3e932e3dbb37742ff2d5454c9efcd360b1d8 crates/apps/gioser-web/src/lib.rs Rust · 838 lines · 30.7 KB History Who touched what raw

expandir a 9 páginas: +cuerpo, sombra, cosmos, practica, olvido 794d3e932 · Sergio ·

1//! Entrypoint WASM de la landing GioSer.
2//!
3//! Capas:
4//! - **Canvas WebGL**: chacana animada (gioser-canvas-web).
5//! - **Tips**: 4 botones DOM en los cardinales, posicionados cada frame.
6//! - **Deck** (vista-web): contenedor único con páginas swipeable estilo
7//!   Flutter `PageView`. Cada elemento del logo es una página dinámica.
8//! - **Taskbar**: barra abajo con home + brand "GioSer" + tabs activas +
9//!   copyleft/email a la derecha. Sincronizada por WASM.
10//!
11//! Acciones (todas pasan por `AppState`):
12//! - `open_or_switch`   — click en tip o abrir nueva pestaña.
13//! - `restore_from_tab` — click en cajita de la taskbar.
14//! - `minimize`         — botón ─ de la página o Escape.
15//! - `close`            — botón × de la página, remueve del taskbar.
16//! - `home`             — botón casa o brand, minimiza todo (mantiene tabs).
17//! - `on_swipe`         — callback de vista-web cuando el snap cambia.
18
19use std::cell::RefCell;
20use std::rc::Rc;
21
22use barra_web::{Task, TaskList};
23use gioser_canvas_web::{tips, Renderer};
24use gioser_graph_web::GraphWidget;
25use fana_md_reader_web::Reader;
26use revista_web::Deck;
27use wasm_bindgen::prelude::*;
28use wasm_bindgen::JsCast;
29use web_sys::{
30    Document, Element, Event, HtmlCanvasElement, HtmlElement, KeyboardEvent, MouseEvent,
31    PointerEvent, Window,
32};
33
34const BUTTON_RADIUS_FACTOR: f32 = 1.32;
35const TASKBAR_HEIGHT_PX: f32 = 52.0;
36const BUTTON_HALF_W_PX: f32 = 90.0;
37const BUTTON_HALF_H_PX: f32 = 64.0;
38const VIEWPORT_MARGIN_PX: f32 = 14.0;
39const ELEMENTS: [&str; 9] = ["aire", "fuego", "tierra", "agua", "cuerpo", "sombra", "cosmos", "practica", "olvido"];
40
41#[derive(Default)]
42struct DeckState {
43    /// Pestañas abiertas en orden de apertura. Coincide con páginas en el strip.
44    pages: Vec<String>,
45    /// Cuál es la página visible. `None` = deck minimizado (pestañas siguen
46    /// en la taskbar) o sin pestañas.
47    active: Option<String>,
48}
49
50struct AppState {
51    document: Document,
52    deck: Deck,
53    taskbar: TaskList,
54    state: RefCell<DeckState>,
55}
56
57impl AppState {
58    fn open_or_switch(&self, element: &str, origin_x: f64, origin_y: f64, md_url: &str) {
59        let was_visible = self.state.borrow().active.is_some();
60        let was_in_pages = self.state.borrow().pages.iter().any(|e| e == element);
61        if !was_in_pages {
62            self.ensure_page_dom(element);
63            self.state.borrow_mut().pages.push(element.to_string());
64        }
65        self.state.borrow_mut().active = Some(element.to_string());
66        let idx = self
67            .state
68            .borrow()
69            .pages
70            .iter()
71            .position(|e| e == element)
72            .unwrap_or(0);
73        if was_visible {
74            self.deck.goto(idx, true);
75        } else {
76            self.deck.goto(idx, false);
77            self.show_deck(origin_x, origin_y);
78        }
79        self.sync_active_class();
80        self.sync_taskbar();
81        self.load_md_if_empty(element, md_url);
82        // Actualizar URL con history.pushState (sin #)
83        if let Some(win) = web_sys::window() {
84            if let Ok(hist) = win.history() {
85                let path = format!("/estudio/{}", element);
86                let _ = hist.push_state_with_url(
87                    &wasm_bindgen::JsValue::NULL,
88                    "",
89                    Some(&path),
90                );
91            }
92        }
93    }
94
95    fn restore_from_tab(&self, element: &str, origin_x: f64, origin_y: f64) {
96        let idx_opt = self
97            .state
98            .borrow()
99            .pages
100            .iter()
101            .position(|e| e == element);
102        let Some(idx) = idx_opt else { return };
103        let was_visible = self.state.borrow().active.is_some();
104        self.state.borrow_mut().active = Some(element.to_string());
105        if was_visible {
106            self.deck.goto(idx, true);
107        } else {
108            self.deck.goto(idx, false);
109            self.show_deck(origin_x, origin_y);
110        }
111        self.sync_active_class();
112        self.sync_taskbar();
113    }
114
115    fn minimize(&self, origin_x: f64, origin_y: f64) {
116        self.state.borrow_mut().active = None;
117        self.sync_active_class();
118        self.sync_taskbar();
119        self.hide_deck(origin_x, origin_y);
120        // Restaurar URL
121        if let Some(win) = web_sys::window() {
122            if let Ok(hist) = win.history() {
123                let _ = hist.push_state_with_url(
124                    &wasm_bindgen::JsValue::NULL,
125                    "",
126                    Some("/"),
127                );
128            }
129        }
130    }
131
132    fn close(&self, element: &str, origin_x: f64, origin_y: f64) {
133        let was_active = self.state.borrow().active.as_deref() == Some(element);
134        self.state.borrow_mut().pages.retain(|e| e != element);
135        self.remove_page_dom(element);
136        if was_active {
137            let pages_now: Vec<String> = self.state.borrow().pages.clone();
138            let new_active = pages_now.last().cloned();
139            self.state.borrow_mut().active = new_active.clone();
140            if let Some(new_active) = new_active {
141                let idx = pages_now
142                    .iter()
143                    .position(|e| e == &new_active)
144                    .unwrap_or(0);
145                self.deck.goto(idx, true);
146            } else {
147                self.hide_deck(origin_x, origin_y);
148            }
149        }
150        self.sync_active_class();
151        self.sync_taskbar();
152    }
153
154    fn home(&self) {
155        // Minimiza el deck sin cerrar las pestañas (estilo "show desktop").
156        let (ox, oy) = self
157            .element_center(".taskbar-home")
158            .unwrap_or((24.0, self.viewport_height() - 26.0));
159        self.minimize(ox, oy);
160    }
161
162    fn on_swipe(&self, new_index: usize) {
163        let element = self.state.borrow().pages.get(new_index).cloned();
164        if let Some(element) = element {
165            self.state.borrow_mut().active = Some(element);
166            self.sync_active_class();
167            self.sync_taskbar();
168        }
169    }
170
171    fn show_deck(&self, x: f64, y: f64) {
172        self.set_deck_origin(x, y);
173        if let Some(deck) = self.deck_el() {
174            let _ = deck.class_list().add_1("open");
175            let _ = deck.set_attribute("aria-hidden", "false");
176        }
177        if let Some(body) = self.document.body() {
178            let _ = body.class_list().add_1("deck-visible");
179        }
180        self.sync_page_controls();
181    }
182
183    fn hide_deck(&self, x: f64, y: f64) {
184        self.set_deck_origin(x, y);
185        if let Some(deck) = self.deck_el() {
186            let _ = deck.class_list().remove_1("open");
187            let _ = deck.set_attribute("aria-hidden", "true");
188        }
189        if let Some(body) = self.document.body() {
190            let _ = body.class_list().remove_1("deck-visible");
191        }
192        self.sync_page_controls();
193    }
194
195    fn sync_page_controls(&self) {
196        if let Some(ctl) = self.document.get_element_by_id("global-page-controls") {
197            let is_visible = self.state.borrow().active.is_some();
198            ctl.set_attribute("style", if is_visible {
199                "opacity:1;pointer-events:auto;"
200            } else {
201                "opacity:0;pointer-events:none;"
202            }).ok();
203        }
204    }
205
206    fn deck_el(&self) -> Option<HtmlElement> {
207        self.document
208            .get_element_by_id("deck")
209            .and_then(|e| e.dyn_into::<HtmlElement>().ok())
210    }
211
212    fn set_deck_origin(&self, x: f64, y: f64) {
213        if let Some(deck) = self.deck_el() {
214            let _ = deck.style().set_property("--origin-x", &format!("{:.1}px", x));
215            let _ = deck.style().set_property("--origin-y", &format!("{:.1}px", y));
216        }
217    }
218
219    fn sync_active_class(&self) {
220        if let Some(body) = self.document.body() {
221            let active = self.state.borrow().active.clone();
222            for &e in &ELEMENTS {
223                let cls = format!("deck-active-{}", e);
224                if active.as_deref() == Some(e) {
225                    let _ = body.class_list().add_1(&cls);
226                } else {
227                    let _ = body.class_list().remove_1(&cls);
228                }
229            }
230        }
231    }
232
233    fn sync_taskbar(&self) {
234        let s = self.state.borrow();
235        let tasks: Vec<Task> = s
236            .pages
237            .iter()
238            .map(|e| {
239                let mut t = Task::new(e.clone(), e.to_uppercase());
240                if s.active.as_deref() == Some(e.as_str()) {
241                    t = t.active();
242                }
243                t
244            })
245            .collect();
246        self.taskbar.set_tasks(&tasks);
247    }
248
249    fn ensure_page_dom(&self, element: &str) {
250        let sel = format!(".deck-page[data-element=\"{}\"]", element);
251        if self
252            .document
253            .query_selector(&sel)
254            .ok()
255            .flatten()
256            .is_some()
257        {
258            return;
259        }
260        let Some(strip) = self.document.get_element_by_id("deck-strip") else {
261            return;
262        };
263        let (title, tag) = match element {
264            "aire" => ("Software", "Tecnología · Open Source · IA"),
265            "fuego" => ("Quién Soy", "Bitácora · Crónica"),
266            "tierra" => ("Manifiesto", "Invariantes · Piedra de toque"),
267            "agua" => ("Mística", "Espiritualidad aplicada"),
268            "cuerpo" => ("El Cuerpo", "Somática · Respiración · Portal"),
269            "sombra" => ("La Sombra", "Integración · Patrones · Acecho"),
270            "cosmos" => ("Cosmovisión", "4 Elementos · Arquetipos"),
271            "practica" => ("Prácticas", "Ejercicios · Transformación"),
272            "olvido" => ("El Olvido", "Desaprender · Soltar · Vaciar"),
273            _ => return,
274        };
275        let html = format!(
276            "<article class=\"deck-page\" data-element=\"{el}\" id=\"deck-page-{el}\">\
277                <div class=\"page-ambience\" aria-hidden=\"true\"></div>\
278                <header class=\"page-head\">\
279                    <span class=\"page-mark\">{el}</span>\
280                    <h2 class=\"page-title\">{title}</h2>\
281                    <span class=\"page-tag\">{tag}</span>\
282                </header>\
283                <section class=\"page-content\" id=\"page-{el}-content\"></section>\
284            </article>",
285            el = element, title = title, tag = tag
286        );
287        let _ = strip.insert_adjacent_html("beforeend", &html);
288    }
289
290    fn remove_page_dom(&self, element: &str) {
291        let id = format!("deck-page-{}", element);
292        if let Some(el) = self.document.get_element_by_id(&id) {
293            el.remove();
294        }
295    }
296
297    fn load_md_if_empty(&self, element: &str, md_url: &str) {
298        let content_id = format!("page-{}-content", element);
299        let Some(content_el) = self.document.get_element_by_id(&content_id) else {
300            return;
301        };
302        let Ok(content): Result<HtmlElement, _> = content_el.dyn_into() else {
303            return;
304        };
305        let inner = content.inner_html();
306        if inner.contains("pluma-doc") {
307            return; // ya hidratado
308        }
309        let document_clone = self.document.clone();
310        let element_owned = element.to_string();
311        let url_owned = md_url.to_string();
312        let reader = fana_md_reader_web::Reader::new(content.clone());
313        wasm_bindgen_futures::spawn_local(async move {
314            let content_clone = content.clone();
315            if let Err(e) = reader.open_url(&url_owned, &element_owned).await {
316                web_sys::console::warn_1(&e);
317            }
318            // Después de cargar el md, montar el grafo debajo
319            let graph_container_id = format!("graph-{}-container", element_owned);
320            // Si ya existe, no lo duplicamos
321            if document_clone.get_element_by_id(&graph_container_id).is_some() {
322                return;
323            }
324            // Crear contenedor debajo del content
325            let wrapper: HtmlElement = document_clone
326                .create_element("div")
327                .ok()
328                .and_then(|e| e.dyn_into::<HtmlElement>().ok())
329                .unwrap();
330            wrapper.set_id(&graph_container_id);
331            wrapper.style().set_property("margin-top", "1rem").ok();
332            wrapper.style().set_property("padding-top", "1rem").ok();
333            wrapper.style().set_property("border-top", "1px solid rgba(255,255,255,0.08)").ok();
334            // Label
335            let label: HtmlElement = document_clone
336                .create_element("div")
337                .ok()
338                .and_then(|e| e.dyn_into::<HtmlElement>().ok())
339                .unwrap();
340            label.set_inner_html(
341                "<span style=\"font-family: Inter, sans-serif; font-size: 0.75rem; \
342                 letter-spacing: 0.3em; text-transform: uppercase; color: rgba(232,234,245,0.45);\">
343                 · grafo semántico ·
344                 </span>"
345            );
346            wrapper.append_child(&label).ok();
347            content_clone.append_child(&wrapper).ok();
348            // Callback: recibe 'camino' del nodo clickeado y navega
349            let cb: Box<dyn FnMut(String)> = Box::new(move |target| {
350                web_sys::console::log_1(&format!("DEBUG grafo: click target={}", target).into());
351                // Mapa: camino → id del tip en HTML
352                let el = match target.as_str() {
353                    "logos" | "aire" => "aire",
354                    "nomos" | "fuego" => "fuego",
355                    "kay" | "tierra" => "tierra",
356                    "uku" | "agua" => "agua",
357                    "cuerpo" => "cuerpo",
358                    "sombra" => "sombra",
359                    "cosmos" => "cosmos",
360                    "practica" => "practica",
361                    "olvido" => "olvido",
362                    _ => "aire",
363                };
364                web_sys::console::log_1(&format!("DEBUG grafo: el={}", el).into());
365                let sel = format!(".tip[data-md][id='tip-{}']", el);
366                web_sys::console::log_1(&format!("DEBUG grafo: selector={}", sel).into());
367                match document_clone.query_selector(&sel).ok().flatten() {
368                    Some(tip) => {
369                        web_sys::console::log_1(&"DEBUG grafo: tip encontrado, llamando click()".into());
370                        let tip_html: HtmlElement = tip.clone().dyn_into().unwrap();
371                        tip_html.click();
372                        web_sys::console::log_1(&"DEBUG grafo: click() ejecutado".into());
373                    }
374                    None => {
375                        web_sys::console::log_1(&"DEBUG grafo: tip NO encontrado".into());
376                    }
377                }
378            });
379            let mut graph = GraphWidget::new(
380                wrapper,
381                "https://api.gioser.net",
382                Some(cb),
383            );
384            if let Err(e) = graph.load().await {
385                web_sys::console::warn_1(&format!("grafo: error al cargar: {:?}", e).into());
386                return;
387            }
388        });
389    }
390
391    fn element_center(&self, selector: &str) -> Option<(f64, f64)> {
392        let el = self.document.query_selector(selector).ok().flatten()?;
393        let rect = el.get_bounding_client_rect();
394        Some((
395            rect.left() + rect.width() / 2.0,
396            rect.top() + rect.height() / 2.0,
397        ))
398    }
399
400    fn taskbar_item_center(&self, element: &str) -> Option<(f64, f64)> {
401        self.taskbar.task_center(element)
402    }
403
404    fn viewport_height(&self) -> f64 {
405        web_sys::window()
406            .and_then(|w| w.inner_height().ok())
407            .and_then(|v| v.as_f64())
408            .unwrap_or(720.0)
409    }
410}
411
412#[wasm_bindgen(start)]
413pub fn boot() -> Result<(), JsValue> {
414    install_panic_hook();
415    let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
416    let document = window
417        .document()
418        .ok_or_else(|| JsValue::from_str("no document"))?;
419
420    let canvas: HtmlCanvasElement = document
421        .get_element_by_id("gioser-canvas")
422        .ok_or_else(|| JsValue::from_str("no canvas#gioser-canvas"))?
423        .dyn_into()?;
424
425    fit_canvas(&canvas, &window);
426    let renderer = Rc::new(RefCell::new(Renderer::new(&canvas)?));
427    {
428        let mut r = renderer.borrow_mut();
429        r.resize(canvas.width(), canvas.height());
430        r.set_client_size(
431            canvas.client_width() as f32,
432            canvas.client_height() as f32,
433        );
434    }
435
436    // Mount vista deck
437    let strip_el: HtmlElement = document
438        .get_element_by_id("deck-strip")
439        .ok_or_else(|| JsValue::from_str("no #deck-strip"))?
440        .dyn_into()?;
441    let deck = Deck::mount(strip_el)?;
442
443    // Mount barra-web taskbar (manages the dynamic task list).
444    let list_el: HtmlElement = document
445        .get_element_by_id("taskbar-list")
446        .ok_or_else(|| JsValue::from_str("no #taskbar-list"))?
447        .dyn_into()?;
448    let taskbar = TaskList::mount(list_el)?;
449
450    let app = Rc::new(AppState {
451        document: document.clone(),
452        deck: deck.clone(),
453        taskbar: taskbar.clone(),
454        state: RefCell::default(),
455    });
456
457    // vista on_change → on_swipe del app
458    {
459        let app2 = app.clone();
460        deck.on_change(move |idx| {
461            app2.on_swipe(idx);
462        });
463    }
464
465    // barra on_click → restore / toggle minimize del app
466    {
467        let app2 = app.clone();
468        taskbar.on_click(move |id, cx, cy| {
469            let is_active = app2.state.borrow().active.as_deref() == Some(id);
470            if is_active {
471                app2.minimize(cx, cy);
472            } else {
473                app2.restore_from_tab(id, cx, cy);
474            }
475        });
476    }
477
478    install_resize(&window, &canvas, &renderer)?;
479    install_mouse(&document, &canvas, &renderer)?;
480    install_canvas_pointer(&canvas, &renderer)?;
481    install_canvas_leave(&canvas, &renderer)?;
482    install_tip_clicks(&document, &app)?;
483    install_controls_delegation(&document, &app)?;
484    install_taskbar(&document, &app)?;
485    install_keyboard(&document, &app)?;
486    install_popstate_listener(&window, &app)?;
487    install_raf(&window, &document, &canvas, &renderer);
488
489    // Leer ruta inicial para abrir página directa
490    if let Ok(pathname) = window.location().pathname() {
491        let clean = pathname.trim_start_matches('/').trim_start_matches("estudio/");
492        if !clean.is_empty() {
493            if let Some(el) = document.query_selector(&format!(".tip[data-md][id='tip-{}']", clean)).ok().flatten() {
494                let rect = el.get_bounding_client_rect();
495                let cx = rect.left() + rect.width() / 2.0;
496                let cy = rect.top() + rect.height() / 2.0;
497                if let Some(md_url) = el.get_attribute("data-md") {
498                    app.open_or_switch(clean, cx, cy, &md_url);
499                }
500            }
501        }
502    }
503
504    Ok(())
505}
506
507fn install_popstate_listener(window: &Window, app: &Rc<AppState>) -> Result<(), JsValue> {
508    let app2 = app.clone();
509    let doc = app.document.clone();
510    let win2 = window.clone();
511    let cb = Closure::<dyn FnMut(Event)>::new(move |_e: Event| {
512        if let Ok(pathname) = win2.location().pathname() {
513            let clean = pathname.trim_start_matches('/').trim_start_matches("estudio/");
514            if clean.is_empty() || clean == "/" {
515                app2.home();
516            } else if let Some(el) = doc.query_selector(&format!(".tip[data-md][id='tip-{}']", clean)).ok().flatten() {
517                let rect = el.get_bounding_client_rect();
518                let cx = rect.left() + rect.width() / 2.0;
519                let cy = rect.top() + rect.height() / 2.0;
520                if let Some(md_url) = el.get_attribute("data-md") {
521                    app2.open_or_switch(clean, cx, cy, &md_url);
522                }
523            }
524        }
525    });
526    window.add_event_listener_with_callback("popstate", cb.as_ref().unchecked_ref())?;
527    cb.forget();
528    Ok(())
529}
530
531fn install_resize(
532    window: &Window,
533    canvas: &HtmlCanvasElement,
534    renderer: &Rc<RefCell<Renderer>>,
535) -> Result<(), JsValue> {
536    let canvas = canvas.clone();
537    let win2 = window.clone();
538    let r = renderer.clone();
539    let cb = Closure::<dyn FnMut()>::new(move || {
540        fit_canvas(&canvas, &win2);
541        let mut rr = r.borrow_mut();
542        rr.resize(canvas.width(), canvas.height());
543        rr.set_client_size(
544            canvas.client_width() as f32,
545            canvas.client_height() as f32,
546        );
547    });
548    window.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?;
549    cb.forget();
550    Ok(())
551}
552
553fn install_mouse(
554    document: &Document,
555    canvas: &HtmlCanvasElement,
556    renderer: &Rc<RefCell<Renderer>>,
557) -> Result<(), JsValue> {
558    let canvas = canvas.clone();
559    let r = renderer.clone();
560    let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
561        let w = canvas.client_width().max(1) as f32;
562        let h = canvas.client_height().max(1) as f32;
563        let x = e.client_x() as f32 - w * 0.5;
564        let y = h * 0.5 - e.client_y() as f32;
565        r.borrow_mut().set_mouse_px(x, y);
566    });
567    document.add_event_listener_with_callback("mousemove", cb.as_ref().unchecked_ref())?;
568    cb.forget();
569    Ok(())
570}
571
572fn install_canvas_pointer(
573    canvas: &HtmlCanvasElement,
574    renderer: &Rc<RefCell<Renderer>>,
575) -> Result<(), JsValue> {
576    let canvas2 = canvas.clone();
577    let r = renderer.clone();
578    let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
579        let rect = canvas2.get_bounding_client_rect();
580        let dx = e.client_x() as f64 - (rect.left() + rect.width() / 2.0);
581        let dy = e.client_y() as f64 - (rect.top() + rect.height() / 2.0);
582        let dist2 = dx * dx + dy * dy;
583        let ring = {
584            let rb = r.borrow();
585            rb.click_radius_css_px() as f64
586        };
587        if dist2 <= ring * ring {
588            r.borrow_mut().impulse_click();
589        }
590    });
591    canvas.add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?;
592    cb.forget();
593    Ok(())
594}
595
596fn install_canvas_leave(
597    canvas: &HtmlCanvasElement,
598    renderer: &Rc<RefCell<Renderer>>,
599) -> Result<(), JsValue> {
600    let r = renderer.clone();
601    let cb = Closure::<dyn FnMut(Event)>::new(move |_e: Event| {
602        r.borrow_mut().release_tilt();
603    });
604    canvas.add_event_listener_with_callback("mouseleave", cb.as_ref().unchecked_ref())?;
605    cb.forget();
606    Ok(())
607}
608
609fn install_tip_clicks(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
610    let tips_nodes = document.query_selector_all(".tip[data-md]")?;
611    for i in 0..tips_nodes.length() {
612        let Some(node) = tips_nodes.item(i) else {
613            continue;
614        };
615        let Ok(el) = node.dyn_into::<HtmlElement>() else {
616            continue;
617        };
618        let id = el.id();
619        let element = id.strip_prefix("tip-").unwrap_or("").to_string();
620        let md_url = el.get_attribute("data-md").unwrap_or_default();
621        let app2 = app.clone();
622        let el_for_rect = el.clone();
623        let el_name = element.clone();
624        let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
625            web_sys::console::log_1(&format!("DEBUG tip: click en {} isTrusted={}", el_name, e.is_trusted()).into());
626            e.prevent_default();
627            let rect = el_for_rect.get_bounding_client_rect();
628            let cx = rect.left() + rect.width() / 2.0;
629            let cy = rect.top() + rect.height() / 2.0;
630            web_sys::console::log_1(&format!("DEBUG tip: llamando open_or_switch({}, {}, {})", el_name, cx, cy).into());
631            app2.open_or_switch(&element, cx, cy, &md_url);
632        });
633        el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
634        cb.forget();
635    }
636    Ok(())
637}
638
639/// Un listener en el deck delega clicks de minimize y close en cada página.
640/// Las páginas se crean dinámicamente, así que no podemos adjuntar listeners
641/// por botón en boot.
642fn install_controls_delegation(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
643    let app2 = app.clone();
644    let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
645        let Some(target) = e.target() else { return };
646        let Ok(target_el): Result<Element, _> = target.dyn_into() else {
647            return;
648        };
649        // Minimize
650        if let Ok(Some(btn)) = target_el.closest("[data-minimize]") {
651            e.stop_propagation();
652            let element = btn.get_attribute("data-minimize").unwrap_or_default();
653            // Si el data-minimize está vacío, usar el elemento activo
654            let el = if element.is_empty() {
655                app2.state.borrow().active.clone().unwrap_or_default()
656            } else {
657                element
658            };
659            if !el.is_empty() {
660                let origin = app2
661                    .taskbar_item_center(&el)
662                    .unwrap_or_else(|| center_of_element(&btn));
663                app2.minimize(origin.0, origin.1);
664            }
665            return;
666        }
667        // Close
668        if let Ok(Some(btn)) = target_el.closest("[data-close-page]") {
669            e.stop_propagation();
670            let element = btn.get_attribute("data-close-page").unwrap_or_default();
671            let el = if element.is_empty() {
672                app2.state.borrow().active.clone().unwrap_or_default()
673            } else {
674                element
675            };
676            if !el.is_empty() {
677                let origin = app2
678                    .taskbar_item_center(&el)
679                    .unwrap_or_else(|| center_of_element(&btn));
680                app2.close(&el, origin.0, origin.1);
681            }
682        }
683    });
684    document.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
685    cb.forget();
686    Ok(())
687}
688
689fn center_of_element(el: &Element) -> (f64, f64) {
690    let rect = el.get_bounding_client_rect();
691    (
692        rect.left() + rect.width() / 2.0,
693        rect.top() + rect.height() / 2.0,
694    )
695}
696
697/// Home button + brand link (data-home) — toda la lógica de tabs vive en
698/// barra-web::TaskList. Acá sólo se instalan los handlers para [data-home].
699fn install_taskbar(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
700    let homes = document.query_selector_all("[data-home]")?;
701    for i in 0..homes.length() {
702        let Some(node) = homes.item(i) else { continue };
703        let Ok(el) = node.dyn_into::<HtmlElement>() else {
704            continue;
705        };
706        let app2 = app.clone();
707        let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
708            e.prevent_default();
709            app2.home();
710        });
711        el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
712        cb.forget();
713    }
714    Ok(())
715}
716
717fn install_keyboard(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
718    let app2 = app.clone();
719    let cb = Closure::<dyn FnMut(KeyboardEvent)>::new(move |e: KeyboardEvent| {
720        if e.key() == "Escape" {
721            if app2.state.borrow().active.is_some() {
722                app2.home();
723            }
724        }
725    });
726    document.add_event_listener_with_callback("keydown", cb.as_ref().unchecked_ref())?;
727    cb.forget();
728    Ok(())
729}
730
731fn install_raf(
732    window: &Window,
733    document: &Document,
734    canvas: &HtmlCanvasElement,
735    renderer: &Rc<RefCell<Renderer>>,
736) {
737    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut(f64)>>));
738    let g = f.clone();
739    let renderer = renderer.clone();
740    let canvas = canvas.clone();
741    let document = document.clone();
742    let window2 = window.clone();
743    *g.borrow_mut() = Some(Closure::<dyn FnMut(f64)>::new(move |time_ms: f64| {
744        renderer.borrow_mut().render(time_ms);
745        let r = renderer.borrow();
746        position_tips(&document, &canvas, &r);
747        drop(r);
748        if let Some(cb) = f.borrow().as_ref() {
749            let _ = window2.request_animation_frame(cb.as_ref().unchecked_ref());
750        }
751    }));
752    let _ = window.request_animation_frame(g.borrow().as_ref().unwrap().as_ref().unchecked_ref());
753}
754
755fn fit_canvas(canvas: &HtmlCanvasElement, window: &Window) {
756    let dpr = window.device_pixel_ratio() as f32;
757    let w = window
758        .inner_width()
759        .ok()
760        .and_then(|v| v.as_f64())
761        .unwrap_or(1280.0) as f32;
762    let h = window
763        .inner_height()
764        .ok()
765        .and_then(|v| v.as_f64())
766        .unwrap_or(720.0) as f32;
767    canvas.set_width((w * dpr) as u32);
768    canvas.set_height((h * dpr) as u32);
769    let el: &HtmlElement = canvas.unchecked_ref();
770    let style = el.style();
771    let _ = style.set_property("width", &format!("{}px", w));
772    let _ = style.set_property("height", &format!("{}px", h));
773}
774
775fn position_tips(document: &Document, canvas: &HtmlCanvasElement, renderer: &Renderer) {
776    let clips = renderer.cardinal_positions_ndc(BUTTON_RADIUS_FACTOR);
777    let cw = canvas.client_width().max(1) as f32;
778    let ch = canvas.client_height().max(1) as f32;
779    let min_x = VIEWPORT_MARGIN_PX + BUTTON_HALF_W_PX;
780    let max_x = (cw - VIEWPORT_MARGIN_PX - BUTTON_HALF_W_PX).max(min_x);
781    let min_y = VIEWPORT_MARGIN_PX + BUTTON_HALF_H_PX;
782    let max_y = (ch - TASKBAR_HEIGHT_PX - VIEWPORT_MARGIN_PX - BUTTON_HALF_H_PX).max(min_y);
783    for (i, (id, _color, _label)) in tips::ORDER.iter().enumerate() {
784        let (nx, ny) = clips[i];
785        let raw_x = (nx + 1.0) * 0.5 * cw;
786        let raw_y = (1.0 - (ny + 1.0) * 0.5) * ch;
787        let px = raw_x.clamp(min_x, max_x);
788        let py = raw_y.clamp(min_y, max_y);
789        let sel = format!("tip-{}", id);
790        if let Some(el) = document.get_element_by_id(&sel) {
791            if let Ok(el) = el.dyn_into::<HtmlElement>() {
792                let _ = el.style().set_property(
793                    "transform",
794                    &format!("translate({:.2}px, {:.2}px) translate(-50%, -50%)", px, py),
795                );
796            }
797        }
798    }
799}
800
801/// Mapea un doc_id de Qdrant al nombre del elemento (aire/fuego/tierra/agua)
802/// y su ruta md. Los doc_ids se generan con uuid5 en el indexador, pero
803/// podemos inferir por el nombre del camino o del elemento.
804fn map_doc_id_to_element(doc_id: &str) -> (String, String) {
805    // Inferir del doc_id: contiene el nombre del elemento
806    let el = if doc_id.contains("aire") || doc_id.contains("logos") {
807        "aire"
808    } else if doc_id.contains("fuego") || doc_id.contains("nomos") {
809        "fuego"
810    } else if doc_id.contains("tierra") || doc_id.contains("kay") {
811        "tierra"
812    } else if doc_id.contains("agua") || doc_id.contains("uku") {
813        "agua"
814    } else if doc_id.contains("cuerpo") {
815        "cuerpo"
816    } else if doc_id.contains("sombra") {
817        "sombra"
818    } else if doc_id.contains("cosmos") {
819        "cosmos"
820    } else if doc_id.contains("practica") {
821        "practica"
822    } else if doc_id.contains("olvido") {
823        "olvido"
824    } else {
825        "aire"
826    };
827    (el.to_string(), format!("./md/{}.md", el))
828}
829
830fn install_panic_hook() {
831    static SET: std::sync::Once = std::sync::Once::new();
832    SET.call_once(|| {
833        std::panic::set_hook(Box::new(|info| {
834            let msg = format!("{}", info);
835            web_sys::console::error_1(&JsValue::from_str(&msg));
836        }));
837    });
838}