tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definitions

⎇ d4c31d70b77d750a02162f12c014a69b12fca4fa crates/apps/gioser-web/src/lib.rs Rust · 702 lines · 25.2 KB History Who touched what raw

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