tawasuyu

tawasuyu/brahman

Rust 506TOML 198Markdown 56JSON 197212 Definitions

⎇ 8235391add876267347c7585bd13a6938ce80e8d crates/apps/gioser-web/src/lib.rs Rust · 712 lines · 25.5 KB History Who touched what raw

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