gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Definitions

⎇ 4c7d716c0cf5f5eb175007ca809a5c5c39fef937 crates/apps/gioser-web/src/lib.rs Rust · 780 lines · 28.5 KB History Who touched what raw

gioser-web: hash routing, clickable graph nodes, hover bounce, line normalization 4c7d716c0 · 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; 4] = ["aire", "fuego", "tierra", "agua"];
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 hash sin disparar evento (evitar loop)
83        if let Some(win) = web_sys::window() {
84            let _ = win.location().set_hash(&format!("/{}", element));
85        }
86    }
87
88    fn restore_from_tab(&self, element: &str, origin_x: f64, origin_y: f64) {
89        let idx_opt = self
90            .state
91            .borrow()
92            .pages
93            .iter()
94            .position(|e| e == element);
95        let Some(idx) = idx_opt else { return };
96        let was_visible = self.state.borrow().active.is_some();
97        self.state.borrow_mut().active = Some(element.to_string());
98        if was_visible {
99            self.deck.goto(idx, true);
100        } else {
101            self.deck.goto(idx, false);
102            self.show_deck(origin_x, origin_y);
103        }
104        self.sync_active_class();
105        self.sync_taskbar();
106    }
107
108    fn minimize(&self, origin_x: f64, origin_y: f64) {
109        self.state.borrow_mut().active = None;
110        self.sync_active_class();
111        self.sync_taskbar();
112        self.hide_deck(origin_x, origin_y);
113        // Limpiar hash
114        if let Some(win) = web_sys::window() {
115            let _ = win.location().set_hash("");
116        }
117    }
118
119    fn close(&self, element: &str, origin_x: f64, origin_y: f64) {
120        let was_active = self.state.borrow().active.as_deref() == Some(element);
121        self.state.borrow_mut().pages.retain(|e| e != element);
122        self.remove_page_dom(element);
123        if was_active {
124            let pages_now: Vec<String> = self.state.borrow().pages.clone();
125            let new_active = pages_now.last().cloned();
126            self.state.borrow_mut().active = new_active.clone();
127            if let Some(new_active) = new_active {
128                let idx = pages_now
129                    .iter()
130                    .position(|e| e == &new_active)
131                    .unwrap_or(0);
132                self.deck.goto(idx, true);
133            } else {
134                self.hide_deck(origin_x, origin_y);
135            }
136        }
137        self.sync_active_class();
138        self.sync_taskbar();
139    }
140
141    fn home(&self) {
142        // Minimiza el deck sin cerrar las pestañas (estilo "show desktop").
143        let (ox, oy) = self
144            .element_center(".taskbar-home")
145            .unwrap_or((24.0, self.viewport_height() - 26.0));
146        self.minimize(ox, oy);
147    }
148
149    fn on_swipe(&self, new_index: usize) {
150        let element = self.state.borrow().pages.get(new_index).cloned();
151        if let Some(element) = element {
152            self.state.borrow_mut().active = Some(element);
153            self.sync_active_class();
154            self.sync_taskbar();
155        }
156    }
157
158    fn show_deck(&self, x: f64, y: f64) {
159        self.set_deck_origin(x, y);
160        if let Some(deck) = self.deck_el() {
161            let _ = deck.class_list().add_1("open");
162            let _ = deck.set_attribute("aria-hidden", "false");
163        }
164        if let Some(body) = self.document.body() {
165            let _ = body.class_list().add_1("deck-visible");
166        }
167    }
168
169    fn hide_deck(&self, x: f64, y: f64) {
170        self.set_deck_origin(x, y);
171        if let Some(deck) = self.deck_el() {
172            let _ = deck.class_list().remove_1("open");
173            let _ = deck.set_attribute("aria-hidden", "true");
174        }
175        if let Some(body) = self.document.body() {
176            let _ = body.class_list().remove_1("deck-visible");
177        }
178    }
179
180    fn deck_el(&self) -> Option<HtmlElement> {
181        self.document
182            .get_element_by_id("deck")
183            .and_then(|e| e.dyn_into::<HtmlElement>().ok())
184    }
185
186    fn set_deck_origin(&self, x: f64, y: f64) {
187        if let Some(deck) = self.deck_el() {
188            let _ = deck.style().set_property("--origin-x", &format!("{:.1}px", x));
189            let _ = deck.style().set_property("--origin-y", &format!("{:.1}px", y));
190        }
191    }
192
193    fn sync_active_class(&self) {
194        if let Some(body) = self.document.body() {
195            let active = self.state.borrow().active.clone();
196            for &e in &ELEMENTS {
197                let cls = format!("deck-active-{}", e);
198                if active.as_deref() == Some(e) {
199                    let _ = body.class_list().add_1(&cls);
200                } else {
201                    let _ = body.class_list().remove_1(&cls);
202                }
203            }
204        }
205    }
206
207    fn sync_taskbar(&self) {
208        let s = self.state.borrow();
209        let tasks: Vec<Task> = s
210            .pages
211            .iter()
212            .map(|e| {
213                let mut t = Task::new(e.clone(), e.to_uppercase());
214                if s.active.as_deref() == Some(e.as_str()) {
215                    t = t.active();
216                }
217                t
218            })
219            .collect();
220        self.taskbar.set_tasks(&tasks);
221    }
222
223    fn ensure_page_dom(&self, element: &str) {
224        let sel = format!(".deck-page[data-element=\"{}\"]", element);
225        if self
226            .document
227            .query_selector(&sel)
228            .ok()
229            .flatten()
230            .is_some()
231        {
232            return;
233        }
234        let Some(strip) = self.document.get_element_by_id("deck-strip") else {
235            return;
236        };
237        let (title, tag) = match element {
238            "aire" => ("Software", "Tecnología · Open Source · IA"),
239            "fuego" => ("Quién Soy", "Bitácora · Crónica"),
240            "tierra" => ("Manifiesto", "Invariantes · Piedra de toque"),
241            "agua" => ("Mística", "Espiritualidad aplicada"),
242            _ => return,
243        };
244        let html = format!(
245            "<article class=\"deck-page\" data-element=\"{el}\" id=\"deck-page-{el}\">\
246                <div class=\"page-controls\">\
247                    <button class=\"page-control-btn page-minimize\" data-minimize=\"{el}\" type=\"button\" aria-label=\"Minimizar {title}\">\
248                        <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M5 19 H19\" stroke=\"currentColor\" stroke-width=\"2\" fill=\"none\" stroke-linecap=\"round\"/></svg>\
249                    </button>\
250                    <button class=\"page-control-btn page-close\" data-close-page=\"{el}\" type=\"button\" aria-label=\"Cerrar {title}\">×</button>\
251                </div>\
252                <div class=\"page-ambience\" aria-hidden=\"true\"></div>\
253                <header class=\"page-head\">\
254                    <span class=\"page-mark\">{el}</span>\
255                    <h2 class=\"page-title\">{title}</h2>\
256                    <span class=\"page-tag\">{tag}</span>\
257                </header>\
258                <section class=\"page-content\" id=\"page-{el}-content\"></section>\
259            </article>",
260            el = element, title = title, tag = tag
261        );
262        let _ = strip.insert_adjacent_html("beforeend", &html);
263    }
264
265    fn remove_page_dom(&self, element: &str) {
266        let id = format!("deck-page-{}", element);
267        if let Some(el) = self.document.get_element_by_id(&id) {
268            el.remove();
269        }
270    }
271
272    fn load_md_if_empty(&self, element: &str, md_url: &str) {
273        let content_id = format!("page-{}-content", element);
274        let Some(content_el) = self.document.get_element_by_id(&content_id) else {
275            return;
276        };
277        let Ok(content): Result<HtmlElement, _> = content_el.dyn_into() else {
278            return;
279        };
280        let inner = content.inner_html();
281        if inner.contains("pluma-doc") {
282            return; // ya hidratado
283        }
284        let document_clone = self.document.clone();
285        let element_owned = element.to_string();
286        let url_owned = md_url.to_string();
287        let reader = fana_md_reader_web::Reader::new(content.clone());
288        wasm_bindgen_futures::spawn_local(async move {
289            let content_clone = content.clone();
290            if let Err(e) = reader.open_url(&url_owned, &element_owned).await {
291                web_sys::console::warn_1(&e);
292            }
293            // Después de cargar el md, montar el grafo debajo
294            let graph_container_id = format!("graph-{}-container", element_owned);
295            // Si ya existe, no lo duplicamos
296            if document_clone.get_element_by_id(&graph_container_id).is_some() {
297                return;
298            }
299            // Crear contenedor debajo del content
300            let wrapper: HtmlElement = document_clone
301                .create_element("div")
302                .ok()
303                .and_then(|e| e.dyn_into::<HtmlElement>().ok())
304                .unwrap();
305            wrapper.set_id(&graph_container_id);
306            wrapper.style().set_property("margin-top", "1rem").ok();
307            wrapper.style().set_property("padding-top", "1rem").ok();
308            wrapper.style().set_property("border-top", "1px solid rgba(255,255,255,0.08)").ok();
309            // Label
310            let label: HtmlElement = document_clone
311                .create_element("div")
312                .ok()
313                .and_then(|e| e.dyn_into::<HtmlElement>().ok())
314                .unwrap();
315            label.set_inner_html(
316                "<span style=\"font-family: Inter, sans-serif; font-size: 0.75rem; \
317                 letter-spacing: 0.3em; text-transform: uppercase; color: rgba(232,234,245,0.45);\">
318                 · grafo semántico ·
319                 </span>"
320            );
321            wrapper.append_child(&label).ok();
322            content_clone.append_child(&wrapper).ok();
323            // Callback: recibe 'camino' del nodo clickeado y dispara click
324            // en el tip correspondiente (simula apertura de página)
325            let cb: Box<dyn FnMut(String)> = Box::new(move |target| {
326                // Mapa camino → elemento HTML
327                let el = match target.as_str() {
328                    "logos" | "aire" => "logos",
329                    "nomos" | "fuego" => "nomos",
330                    "kay" | "tierra" => "kay",
331                    "uku" | "agua" => "uku",
332                    _ => "logos",
333                };
334                if let Some(tip) = document_clone.query_selector(
335                    &format!(".tip[data-md][id='tip-{}']", el)
336                ).ok().flatten() {
337                    // Simular click real en el tip — la animación de
338                    // expansión la maneja AppState::open_or_switch
339                    let _ = tip.dispatch_event(
340                        &web_sys::MouseEvent::new("click").unwrap()
341                    );
342                }
343            });
344            let mut graph = GraphWidget::new(
345                wrapper,
346                "https://api.gioser.net",
347                Some(cb),
348            );
349            if let Err(e) = graph.load().await {
350                web_sys::console::warn_1(&format!("grafo: error al cargar: {:?}", e).into());
351                return;
352            }
353        });
354    }
355
356    fn element_center(&self, selector: &str) -> Option<(f64, f64)> {
357        let el = self.document.query_selector(selector).ok().flatten()?;
358        let rect = el.get_bounding_client_rect();
359        Some((
360            rect.left() + rect.width() / 2.0,
361            rect.top() + rect.height() / 2.0,
362        ))
363    }
364
365    fn taskbar_item_center(&self, element: &str) -> Option<(f64, f64)> {
366        self.taskbar.task_center(element)
367    }
368
369    fn viewport_height(&self) -> f64 {
370        web_sys::window()
371            .and_then(|w| w.inner_height().ok())
372            .and_then(|v| v.as_f64())
373            .unwrap_or(720.0)
374    }
375}
376
377#[wasm_bindgen(start)]
378pub fn boot() -> Result<(), JsValue> {
379    install_panic_hook();
380    let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
381    let document = window
382        .document()
383        .ok_or_else(|| JsValue::from_str("no document"))?;
384
385    let canvas: HtmlCanvasElement = document
386        .get_element_by_id("gioser-canvas")
387        .ok_or_else(|| JsValue::from_str("no canvas#gioser-canvas"))?
388        .dyn_into()?;
389
390    fit_canvas(&canvas, &window);
391    let renderer = Rc::new(RefCell::new(Renderer::new(&canvas)?));
392    {
393        let mut r = renderer.borrow_mut();
394        r.resize(canvas.width(), canvas.height());
395        r.set_client_size(
396            canvas.client_width() as f32,
397            canvas.client_height() as f32,
398        );
399    }
400
401    // Mount vista deck
402    let strip_el: HtmlElement = document
403        .get_element_by_id("deck-strip")
404        .ok_or_else(|| JsValue::from_str("no #deck-strip"))?
405        .dyn_into()?;
406    let deck = Deck::mount(strip_el)?;
407
408    // Mount barra-web taskbar (manages the dynamic task list).
409    let list_el: HtmlElement = document
410        .get_element_by_id("taskbar-list")
411        .ok_or_else(|| JsValue::from_str("no #taskbar-list"))?
412        .dyn_into()?;
413    let taskbar = TaskList::mount(list_el)?;
414
415    let app = Rc::new(AppState {
416        document: document.clone(),
417        deck: deck.clone(),
418        taskbar: taskbar.clone(),
419        state: RefCell::default(),
420    });
421
422    // vista on_change → on_swipe del app
423    {
424        let app2 = app.clone();
425        deck.on_change(move |idx| {
426            app2.on_swipe(idx);
427        });
428    }
429
430    // barra on_click → restore / toggle minimize del app
431    {
432        let app2 = app.clone();
433        taskbar.on_click(move |id, cx, cy| {
434            let is_active = app2.state.borrow().active.as_deref() == Some(id);
435            if is_active {
436                app2.minimize(cx, cy);
437            } else {
438                app2.restore_from_tab(id, cx, cy);
439            }
440        });
441    }
442
443    install_resize(&window, &canvas, &renderer)?;
444    install_mouse(&document, &canvas, &renderer)?;
445    install_canvas_pointer(&canvas, &renderer)?;
446    install_canvas_leave(&canvas, &renderer)?;
447    install_tip_clicks(&document, &app)?;
448    install_deck_delegation(&document, &app)?;
449    install_taskbar(&document, &app)?;
450    install_keyboard(&document, &app)?;
451    install_hash_listener(&window, &app)?;
452    install_raf(&window, &document, &canvas, &renderer);
453
454    // Leer hash inicial para abrir página directa
455    if let Ok(hash) = window.location().hash() {
456        let clean = hash.trim_start_matches('#').trim_start_matches('/');
457        if !clean.is_empty() && clean != "" {
458            if let Some(el) = document.query_selector(&format!(".tip[data-md][id='tip-{}']", clean)).ok().flatten() {
459                let rect = el.get_bounding_client_rect();
460                let cx = rect.left() + rect.width() / 2.0;
461                let cy = rect.top() + rect.height() / 2.0;
462                if let Some(md_url) = el.get_attribute("data-md") {
463                    app.open_or_switch(clean, cx, cy, &md_url);
464                }
465            }
466        }
467    }
468
469    Ok(())
470}
471
472fn install_hash_listener(window: &Window, app: &Rc<AppState>) -> Result<(), JsValue> {
473    let app2 = app.clone();
474    let doc = app.document.clone();
475    let win2 = window.clone();
476    let cb = Closure::<dyn FnMut(Event)>::new(move |_e: Event| {
477        if let Ok(hash) = win2.location().hash() {
478            let clean = hash.trim_start_matches('#').trim_start_matches('/');
479            if clean.is_empty() {
480                app2.home();
481            } else if let Some(el) = doc.query_selector(&format!(".tip[data-md][id='tip-{}']", clean)).ok().flatten() {
482                let rect = el.get_bounding_client_rect();
483                let cx = rect.left() + rect.width() / 2.0;
484                let cy = rect.top() + rect.height() / 2.0;
485                if let Some(md_url) = el.get_attribute("data-md") {
486                    app2.open_or_switch(clean, cx, cy, &md_url);
487                }
488            }
489        }
490    });
491    window.add_event_listener_with_callback("hashchange", cb.as_ref().unchecked_ref())?;
492    cb.forget();
493    Ok(())
494}
495
496fn install_resize(
497    window: &Window,
498    canvas: &HtmlCanvasElement,
499    renderer: &Rc<RefCell<Renderer>>,
500) -> Result<(), JsValue> {
501    let canvas = canvas.clone();
502    let win2 = window.clone();
503    let r = renderer.clone();
504    let cb = Closure::<dyn FnMut()>::new(move || {
505        fit_canvas(&canvas, &win2);
506        let mut rr = r.borrow_mut();
507        rr.resize(canvas.width(), canvas.height());
508        rr.set_client_size(
509            canvas.client_width() as f32,
510            canvas.client_height() as f32,
511        );
512    });
513    window.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?;
514    cb.forget();
515    Ok(())
516}
517
518fn install_mouse(
519    document: &Document,
520    canvas: &HtmlCanvasElement,
521    renderer: &Rc<RefCell<Renderer>>,
522) -> Result<(), JsValue> {
523    let canvas = canvas.clone();
524    let r = renderer.clone();
525    let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
526        let w = canvas.client_width().max(1) as f32;
527        let h = canvas.client_height().max(1) as f32;
528        let x = e.client_x() as f32 - w * 0.5;
529        let y = h * 0.5 - e.client_y() as f32;
530        r.borrow_mut().set_mouse_px(x, y);
531    });
532    document.add_event_listener_with_callback("mousemove", cb.as_ref().unchecked_ref())?;
533    cb.forget();
534    Ok(())
535}
536
537fn install_canvas_pointer(
538    canvas: &HtmlCanvasElement,
539    renderer: &Rc<RefCell<Renderer>>,
540) -> Result<(), JsValue> {
541    let canvas2 = canvas.clone();
542    let r = renderer.clone();
543    let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
544        let rect = canvas2.get_bounding_client_rect();
545        let dx = e.client_x() as f64 - (rect.left() + rect.width() / 2.0);
546        let dy = e.client_y() as f64 - (rect.top() + rect.height() / 2.0);
547        let dist2 = dx * dx + dy * dy;
548        let ring = {
549            let rb = r.borrow();
550            rb.click_radius_css_px() as f64
551        };
552        if dist2 <= ring * ring {
553            r.borrow_mut().impulse_click();
554        }
555    });
556    canvas.add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?;
557    cb.forget();
558    Ok(())
559}
560
561fn install_canvas_leave(
562    canvas: &HtmlCanvasElement,
563    renderer: &Rc<RefCell<Renderer>>,
564) -> Result<(), JsValue> {
565    let r = renderer.clone();
566    let cb = Closure::<dyn FnMut(Event)>::new(move |_e: Event| {
567        r.borrow_mut().release_tilt();
568    });
569    canvas.add_event_listener_with_callback("mouseleave", cb.as_ref().unchecked_ref())?;
570    cb.forget();
571    Ok(())
572}
573
574fn install_tip_clicks(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
575    let tips_nodes = document.query_selector_all(".tip[data-md]")?;
576    for i in 0..tips_nodes.length() {
577        let Some(node) = tips_nodes.item(i) else {
578            continue;
579        };
580        let Ok(el) = node.dyn_into::<HtmlElement>() else {
581            continue;
582        };
583        let id = el.id();
584        let element = id.strip_prefix("tip-").unwrap_or("").to_string();
585        let md_url = el.get_attribute("data-md").unwrap_or_default();
586        let app2 = app.clone();
587        let el_for_rect = el.clone();
588        let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
589            e.prevent_default();
590            let rect = el_for_rect.get_bounding_client_rect();
591            let cx = rect.left() + rect.width() / 2.0;
592            let cy = rect.top() + rect.height() / 2.0;
593            app2.open_or_switch(&element, cx, cy, &md_url);
594        });
595        el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
596        cb.forget();
597    }
598    Ok(())
599}
600
601/// Un listener en el deck delega clicks de minimize y close en cada página.
602/// Las páginas se crean dinámicamente, así que no podemos adjuntar listeners
603/// por botón en boot.
604fn install_deck_delegation(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
605    let Some(deck_el) = document.get_element_by_id("deck") else {
606        return Ok(());
607    };
608    let app2 = app.clone();
609    let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
610        let Some(target) = e.target() else { return };
611        let Ok(target_el): Result<Element, _> = target.dyn_into() else {
612            return;
613        };
614        // Minimize
615        if let Ok(Some(btn)) = target_el.closest("[data-minimize]") {
616            e.stop_propagation();
617            let element = btn.get_attribute("data-minimize").unwrap_or_default();
618            // Origin = la cajita correspondiente en la taskbar (efecto
619            // visual: la página se "encoge" hacia su entrada del taskbar).
620            let origin = app2
621                .taskbar_item_center(&element)
622                .unwrap_or_else(|| center_of_element(&btn));
623            app2.minimize(origin.0, origin.1);
624            return;
625        }
626        // Close
627        if let Ok(Some(btn)) = target_el.closest("[data-close-page]") {
628            e.stop_propagation();
629            let element = btn.get_attribute("data-close-page").unwrap_or_default();
630            let origin = app2
631                .taskbar_item_center(&element)
632                .unwrap_or_else(|| center_of_element(&btn));
633            app2.close(&element, origin.0, origin.1);
634        }
635    });
636    deck_el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
637    cb.forget();
638    Ok(())
639}
640
641fn center_of_element(el: &Element) -> (f64, f64) {
642    let rect = el.get_bounding_client_rect();
643    (
644        rect.left() + rect.width() / 2.0,
645        rect.top() + rect.height() / 2.0,
646    )
647}
648
649/// Home button + brand link (data-home) — toda la lógica de tabs vive en
650/// barra-web::TaskList. Acá sólo se instalan los handlers para [data-home].
651fn install_taskbar(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
652    let homes = document.query_selector_all("[data-home]")?;
653    for i in 0..homes.length() {
654        let Some(node) = homes.item(i) else { continue };
655        let Ok(el) = node.dyn_into::<HtmlElement>() else {
656            continue;
657        };
658        let app2 = app.clone();
659        let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
660            e.prevent_default();
661            app2.home();
662        });
663        el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
664        cb.forget();
665    }
666    Ok(())
667}
668
669fn install_keyboard(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
670    let app2 = app.clone();
671    let cb = Closure::<dyn FnMut(KeyboardEvent)>::new(move |e: KeyboardEvent| {
672        if e.key() == "Escape" {
673            if app2.state.borrow().active.is_some() {
674                app2.home();
675            }
676        }
677    });
678    document.add_event_listener_with_callback("keydown", cb.as_ref().unchecked_ref())?;
679    cb.forget();
680    Ok(())
681}
682
683fn install_raf(
684    window: &Window,
685    document: &Document,
686    canvas: &HtmlCanvasElement,
687    renderer: &Rc<RefCell<Renderer>>,
688) {
689    let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut(f64)>>));
690    let g = f.clone();
691    let renderer = renderer.clone();
692    let canvas = canvas.clone();
693    let document = document.clone();
694    let window2 = window.clone();
695    *g.borrow_mut() = Some(Closure::<dyn FnMut(f64)>::new(move |time_ms: f64| {
696        renderer.borrow_mut().render(time_ms);
697        let r = renderer.borrow();
698        position_tips(&document, &canvas, &r);
699        drop(r);
700        if let Some(cb) = f.borrow().as_ref() {
701            let _ = window2.request_animation_frame(cb.as_ref().unchecked_ref());
702        }
703    }));
704    let _ = window.request_animation_frame(g.borrow().as_ref().unwrap().as_ref().unchecked_ref());
705}
706
707fn fit_canvas(canvas: &HtmlCanvasElement, window: &Window) {
708    let dpr = window.device_pixel_ratio() as f32;
709    let w = window
710        .inner_width()
711        .ok()
712        .and_then(|v| v.as_f64())
713        .unwrap_or(1280.0) as f32;
714    let h = window
715        .inner_height()
716        .ok()
717        .and_then(|v| v.as_f64())
718        .unwrap_or(720.0) as f32;
719    canvas.set_width((w * dpr) as u32);
720    canvas.set_height((h * dpr) as u32);
721    let el: &HtmlElement = canvas.unchecked_ref();
722    let style = el.style();
723    let _ = style.set_property("width", &format!("{}px", w));
724    let _ = style.set_property("height", &format!("{}px", h));
725}
726
727fn position_tips(document: &Document, canvas: &HtmlCanvasElement, renderer: &Renderer) {
728    let clips = renderer.cardinal_positions_ndc(BUTTON_RADIUS_FACTOR);
729    let cw = canvas.client_width().max(1) as f32;
730    let ch = canvas.client_height().max(1) as f32;
731    let min_x = VIEWPORT_MARGIN_PX + BUTTON_HALF_W_PX;
732    let max_x = (cw - VIEWPORT_MARGIN_PX - BUTTON_HALF_W_PX).max(min_x);
733    let min_y = VIEWPORT_MARGIN_PX + BUTTON_HALF_H_PX;
734    let max_y = (ch - TASKBAR_HEIGHT_PX - VIEWPORT_MARGIN_PX - BUTTON_HALF_H_PX).max(min_y);
735    for (i, (id, _color, _label)) in tips::ORDER.iter().enumerate() {
736        let (nx, ny) = clips[i];
737        let raw_x = (nx + 1.0) * 0.5 * cw;
738        let raw_y = (1.0 - (ny + 1.0) * 0.5) * ch;
739        let px = raw_x.clamp(min_x, max_x);
740        let py = raw_y.clamp(min_y, max_y);
741        let sel = format!("tip-{}", id);
742        if let Some(el) = document.get_element_by_id(&sel) {
743            if let Ok(el) = el.dyn_into::<HtmlElement>() {
744                let _ = el.style().set_property(
745                    "transform",
746                    &format!("translate({:.2}px, {:.2}px) translate(-50%, -50%)", px, py),
747                );
748            }
749        }
750    }
751}
752
753/// Mapea un doc_id de Qdrant al nombre del elemento (aire/fuego/tierra/agua)
754/// y su ruta md. Los doc_ids se generan con uuid5 en el indexador, pero
755/// podemos inferir por el nombre del camino o del elemento.
756fn map_doc_id_to_element(doc_id: &str) -> (String, String) {
757    // Inferir del doc_id: contiene el nombre del elemento
758    let el = if doc_id.contains("aire") || doc_id.contains("logos") {
759        "aire"
760    } else if doc_id.contains("fuego") || doc_id.contains("nomos") {
761        "fuego"
762    } else if doc_id.contains("tierra") || doc_id.contains("kay") {
763        "tierra"
764    } else if doc_id.contains("agua") || doc_id.contains("uku") {
765        "agua"
766    } else {
767        "aire"
768    };
769    (el.to_string(), format!("./md/{}.md", el))
770}
771
772fn install_panic_hook() {
773    static SET: std::sync::Once = std::sync::Once::new();
774    SET.call_once(|| {
775        std::panic::set_hook(Box::new(|info| {
776            let msg = format!("{}", info);
777            web_sys::console::error_1(&JsValue::from_str(&msg));
778        }));
779    });
780}