//! Entrypoint WASM de la landing GioSer. //! //! Capas: //! - **Canvas WebGL**: chacana animada (gioser-canvas-web). //! - **Tips**: 4 botones DOM en los cardinales, posicionados cada frame. //! - **Deck** (vista-web): contenedor único con páginas swipeable estilo //! Flutter `PageView`. Cada elemento del logo es una página dinámica. //! - **Taskbar**: barra abajo con home + brand "GioSer" + tabs activas + //! copyleft/email a la derecha. Sincronizada por WASM. //! //! Acciones (todas pasan por `AppState`): //! - `open_or_switch` — click en tip o abrir nueva pestaña. //! - `restore_from_tab` — click en cajita de la taskbar. //! - `minimize` — botón ─ de la página o Escape. //! - `close` — botón × de la página, remueve del taskbar. //! - `home` — botón casa o brand, minimiza todo (mantiene tabs). //! - `on_swipe` — callback de vista-web cuando el snap cambia. use std::cell::RefCell; use std::rc::Rc; use barra_web::{Task, TaskList}; use gioser_canvas_web::{tips, Renderer}; use gioser_graph_web::GraphWidget; use fana_md_reader_web::Reader; use revista_web::Deck; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use web_sys::{ Document, Element, Event, HtmlCanvasElement, HtmlElement, KeyboardEvent, MouseEvent, PointerEvent, Window, }; const BUTTON_RADIUS_FACTOR: f32 = 1.32; const TASKBAR_HEIGHT_PX: f32 = 52.0; const BUTTON_HALF_W_PX: f32 = 90.0; const BUTTON_HALF_H_PX: f32 = 64.0; const VIEWPORT_MARGIN_PX: f32 = 14.0; const ELEMENTS: [&str; 9] = ["aire", "fuego", "tierra", "agua", "cuerpo", "sombra", "cosmos", "practica", "olvido"]; #[derive(Default)] struct DeckState { /// Pestañas abiertas en orden de apertura. Coincide con páginas en el strip. pages: Vec, /// Cuál es la página visible. `None` = deck minimizado (pestañas siguen /// en la taskbar) o sin pestañas. active: Option, } struct AppState { document: Document, deck: Deck, taskbar: TaskList, state: RefCell, } impl AppState { fn open_or_switch(&self, element: &str, origin_x: f64, origin_y: f64, md_url: &str) { let was_visible = self.state.borrow().active.is_some(); let was_in_pages = self.state.borrow().pages.iter().any(|e| e == element); if !was_in_pages { self.ensure_page_dom(element); self.state.borrow_mut().pages.push(element.to_string()); } self.state.borrow_mut().active = Some(element.to_string()); let idx = self .state .borrow() .pages .iter() .position(|e| e == element) .unwrap_or(0); if was_visible { self.deck.goto(idx, true); } else { self.deck.goto(idx, false); self.show_deck(origin_x, origin_y); } self.sync_active_class(); self.sync_taskbar(); self.load_md_if_empty(element, md_url); // Actualizar URL con history.pushState (sin #) if let Some(win) = web_sys::window() { if let Ok(hist) = win.history() { let path = format!("/estudio/{}", element); let _ = hist.push_state_with_url( &wasm_bindgen::JsValue::NULL, "", Some(&path), ); } } } fn restore_from_tab(&self, element: &str, origin_x: f64, origin_y: f64) { let idx_opt = self .state .borrow() .pages .iter() .position(|e| e == element); let Some(idx) = idx_opt else { return }; let was_visible = self.state.borrow().active.is_some(); self.state.borrow_mut().active = Some(element.to_string()); if was_visible { self.deck.goto(idx, true); } else { self.deck.goto(idx, false); self.show_deck(origin_x, origin_y); } self.sync_active_class(); self.sync_taskbar(); } fn minimize(&self, origin_x: f64, origin_y: f64) { self.state.borrow_mut().active = None; self.sync_active_class(); self.sync_taskbar(); self.hide_deck(origin_x, origin_y); // Restaurar URL if let Some(win) = web_sys::window() { if let Ok(hist) = win.history() { let _ = hist.push_state_with_url( &wasm_bindgen::JsValue::NULL, "", Some("/"), ); } } } fn close(&self, element: &str, origin_x: f64, origin_y: f64) { let was_active = self.state.borrow().active.as_deref() == Some(element); self.state.borrow_mut().pages.retain(|e| e != element); self.remove_page_dom(element); if was_active { let pages_now: Vec = self.state.borrow().pages.clone(); let new_active = pages_now.last().cloned(); self.state.borrow_mut().active = new_active.clone(); if let Some(new_active) = new_active { let idx = pages_now .iter() .position(|e| e == &new_active) .unwrap_or(0); self.deck.goto(idx, true); } else { self.hide_deck(origin_x, origin_y); } } self.sync_active_class(); self.sync_taskbar(); } fn home(&self) { // Minimiza el deck sin cerrar las pestañas (estilo "show desktop"). let (ox, oy) = self .element_center(".taskbar-home") .unwrap_or((24.0, self.viewport_height() - 26.0)); self.minimize(ox, oy); } fn on_swipe(&self, new_index: usize) { let element = self.state.borrow().pages.get(new_index).cloned(); if let Some(element) = element { self.state.borrow_mut().active = Some(element); self.sync_active_class(); self.sync_taskbar(); } } fn show_deck(&self, x: f64, y: f64) { self.set_deck_origin(x, y); if let Some(deck) = self.deck_el() { let _ = deck.class_list().add_1("open"); let _ = deck.set_attribute("aria-hidden", "false"); } if let Some(body) = self.document.body() { let _ = body.class_list().add_1("deck-visible"); } self.sync_page_controls(); } fn hide_deck(&self, x: f64, y: f64) { self.set_deck_origin(x, y); if let Some(deck) = self.deck_el() { let _ = deck.class_list().remove_1("open"); let _ = deck.set_attribute("aria-hidden", "true"); } if let Some(body) = self.document.body() { let _ = body.class_list().remove_1("deck-visible"); } self.sync_page_controls(); } fn sync_page_controls(&self) { if let Some(ctl) = self.document.get_element_by_id("global-page-controls") { let is_visible = self.state.borrow().active.is_some(); ctl.set_attribute("style", if is_visible { "opacity:1;pointer-events:auto;" } else { "opacity:0;pointer-events:none;" }).ok(); } } fn deck_el(&self) -> Option { self.document .get_element_by_id("deck") .and_then(|e| e.dyn_into::().ok()) } fn set_deck_origin(&self, x: f64, y: f64) { if let Some(deck) = self.deck_el() { let _ = deck.style().set_property("--origin-x", &format!("{:.1}px", x)); let _ = deck.style().set_property("--origin-y", &format!("{:.1}px", y)); } } fn sync_active_class(&self) { if let Some(body) = self.document.body() { let active = self.state.borrow().active.clone(); for &e in &ELEMENTS { let cls = format!("deck-active-{}", e); if active.as_deref() == Some(e) { let _ = body.class_list().add_1(&cls); } else { let _ = body.class_list().remove_1(&cls); } } } } fn sync_taskbar(&self) { let s = self.state.borrow(); let tasks: Vec = s .pages .iter() .map(|e| { let mut t = Task::new(e.clone(), e.to_uppercase()); if s.active.as_deref() == Some(e.as_str()) { t = t.active(); } t }) .collect(); self.taskbar.set_tasks(&tasks); } fn ensure_page_dom(&self, element: &str) { let sel = format!(".deck-page[data-element=\"{}\"]", element); if self .document .query_selector(&sel) .ok() .flatten() .is_some() { return; } let Some(strip) = self.document.get_element_by_id("deck-strip") else { return; }; let (title, tag) = match element { "aire" => ("Software", "Tecnología · Open Source · IA"), "fuego" => ("Quién Soy", "Bitácora · Crónica"), "tierra" => ("Manifiesto", "Invariantes · Piedra de toque"), "agua" => ("Mística", "Espiritualidad aplicada"), "cuerpo" => ("El Cuerpo", "Somática · Respiración · Portal"), "sombra" => ("La Sombra", "Integración · Patrones · Acecho"), "cosmos" => ("Cosmovisión", "4 Elementos · Arquetipos"), "practica" => ("Prácticas", "Ejercicios · Transformación"), "olvido" => ("El Olvido", "Desaprender · Soltar · Vaciar"), _ => return, }; let html = format!( "
\
\
\ {el}\

{title}

\ {tag}\
\
\
", el = element, title = title, tag = tag ); let _ = strip.insert_adjacent_html("beforeend", &html); } fn remove_page_dom(&self, element: &str) { let id = format!("deck-page-{}", element); if let Some(el) = self.document.get_element_by_id(&id) { el.remove(); } } fn load_md_if_empty(&self, element: &str, md_url: &str) { let content_id = format!("page-{}-content", element); let Some(content_el) = self.document.get_element_by_id(&content_id) else { return; }; let Ok(content): Result = content_el.dyn_into() else { return; }; let inner = content.inner_html(); if inner.contains("pluma-doc") { return; // ya hidratado } let document_clone = self.document.clone(); let element_owned = element.to_string(); let url_owned = md_url.to_string(); let reader = fana_md_reader_web::Reader::new(content.clone()); wasm_bindgen_futures::spawn_local(async move { let content_clone = content.clone(); if let Err(e) = reader.open_url(&url_owned, &element_owned).await { web_sys::console::warn_1(&e); } // Después de cargar el md, montar el grafo debajo let graph_container_id = format!("graph-{}-container", element_owned); // Si ya existe, no lo duplicamos if document_clone.get_element_by_id(&graph_container_id).is_some() { return; } // Crear contenedor debajo del content let wrapper: HtmlElement = document_clone .create_element("div") .ok() .and_then(|e| e.dyn_into::().ok()) .unwrap(); wrapper.set_id(&graph_container_id); wrapper.style().set_property("margin-top", "1rem").ok(); wrapper.style().set_property("padding-top", "1rem").ok(); wrapper.style().set_property("border-top", "1px solid rgba(255,255,255,0.08)").ok(); // Label let label: HtmlElement = document_clone .create_element("div") .ok() .and_then(|e| e.dyn_into::().ok()) .unwrap(); label.set_inner_html( " · grafo semántico · " ); wrapper.append_child(&label).ok(); content_clone.append_child(&wrapper).ok(); // Callback: recibe 'camino' del nodo clickeado y navega let cb: Box = Box::new(move |target| { web_sys::console::log_1(&format!("DEBUG grafo: click target={}", target).into()); // Mapa: camino → id del tip en HTML let el = match target.as_str() { "logos" | "aire" => "aire", "nomos" | "fuego" => "fuego", "kay" | "tierra" => "tierra", "uku" | "agua" => "agua", "cuerpo" => "cuerpo", "sombra" => "sombra", "cosmos" => "cosmos", "practica" => "practica", "olvido" => "olvido", _ => "aire", }; web_sys::console::log_1(&format!("DEBUG grafo: el={}", el).into()); let sel = format!(".tip[data-md][id='tip-{}']", el); web_sys::console::log_1(&format!("DEBUG grafo: selector={}", sel).into()); match document_clone.query_selector(&sel).ok().flatten() { Some(tip) => { web_sys::console::log_1(&"DEBUG grafo: tip encontrado, llamando click()".into()); let tip_html: HtmlElement = tip.clone().dyn_into().unwrap(); tip_html.click(); web_sys::console::log_1(&"DEBUG grafo: click() ejecutado".into()); } None => { web_sys::console::log_1(&"DEBUG grafo: tip NO encontrado".into()); } } }); let mut graph = GraphWidget::new( wrapper, "https://api.gioser.net", Some(cb), ); if let Err(e) = graph.load().await { web_sys::console::warn_1(&format!("grafo: error al cargar: {:?}", e).into()); return; } }); } fn element_center(&self, selector: &str) -> Option<(f64, f64)> { let el = self.document.query_selector(selector).ok().flatten()?; let rect = el.get_bounding_client_rect(); Some(( rect.left() + rect.width() / 2.0, rect.top() + rect.height() / 2.0, )) } fn taskbar_item_center(&self, element: &str) -> Option<(f64, f64)> { self.taskbar.task_center(element) } fn viewport_height(&self) -> f64 { web_sys::window() .and_then(|w| w.inner_height().ok()) .and_then(|v| v.as_f64()) .unwrap_or(720.0) } } #[wasm_bindgen(start)] pub fn boot() -> Result<(), JsValue> { install_panic_hook(); let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; let document = window .document() .ok_or_else(|| JsValue::from_str("no document"))?; let canvas: HtmlCanvasElement = document .get_element_by_id("gioser-canvas") .ok_or_else(|| JsValue::from_str("no canvas#gioser-canvas"))? .dyn_into()?; fit_canvas(&canvas, &window); let renderer = Rc::new(RefCell::new(Renderer::new(&canvas)?)); { let mut r = renderer.borrow_mut(); r.resize(canvas.width(), canvas.height()); r.set_client_size( canvas.client_width() as f32, canvas.client_height() as f32, ); } // Mount vista deck let strip_el: HtmlElement = document .get_element_by_id("deck-strip") .ok_or_else(|| JsValue::from_str("no #deck-strip"))? .dyn_into()?; let deck = Deck::mount(strip_el)?; // Mount barra-web taskbar (manages the dynamic task list). let list_el: HtmlElement = document .get_element_by_id("taskbar-list") .ok_or_else(|| JsValue::from_str("no #taskbar-list"))? .dyn_into()?; let taskbar = TaskList::mount(list_el)?; let app = Rc::new(AppState { document: document.clone(), deck: deck.clone(), taskbar: taskbar.clone(), state: RefCell::default(), }); // vista on_change → on_swipe del app { let app2 = app.clone(); deck.on_change(move |idx| { app2.on_swipe(idx); }); } // barra on_click → restore / toggle minimize del app { let app2 = app.clone(); taskbar.on_click(move |id, cx, cy| { let is_active = app2.state.borrow().active.as_deref() == Some(id); if is_active { app2.minimize(cx, cy); } else { app2.restore_from_tab(id, cx, cy); } }); } install_resize(&window, &canvas, &renderer)?; install_mouse(&document, &canvas, &renderer)?; install_canvas_pointer(&canvas, &renderer)?; install_canvas_leave(&canvas, &renderer)?; install_tip_clicks(&document, &app)?; install_controls_delegation(&document, &app)?; install_taskbar(&document, &app)?; install_keyboard(&document, &app)?; install_popstate_listener(&window, &app)?; install_raf(&window, &document, &canvas, &renderer); // Leer ruta inicial para abrir página directa if let Ok(pathname) = window.location().pathname() { let clean = pathname.trim_start_matches('/').trim_start_matches("estudio/"); if !clean.is_empty() { if let Some(el) = document.query_selector(&format!(".tip[data-md][id='tip-{}']", clean)).ok().flatten() { let rect = el.get_bounding_client_rect(); let cx = rect.left() + rect.width() / 2.0; let cy = rect.top() + rect.height() / 2.0; if let Some(md_url) = el.get_attribute("data-md") { app.open_or_switch(clean, cx, cy, &md_url); } } } } Ok(()) } fn install_popstate_listener(window: &Window, app: &Rc) -> Result<(), JsValue> { let app2 = app.clone(); let doc = app.document.clone(); let win2 = window.clone(); let cb = Closure::::new(move |_e: Event| { if let Ok(pathname) = win2.location().pathname() { let clean = pathname.trim_start_matches('/').trim_start_matches("estudio/"); if clean.is_empty() || clean == "/" { app2.home(); } else if let Some(el) = doc.query_selector(&format!(".tip[data-md][id='tip-{}']", clean)).ok().flatten() { let rect = el.get_bounding_client_rect(); let cx = rect.left() + rect.width() / 2.0; let cy = rect.top() + rect.height() / 2.0; if let Some(md_url) = el.get_attribute("data-md") { app2.open_or_switch(clean, cx, cy, &md_url); } } } }); window.add_event_listener_with_callback("popstate", cb.as_ref().unchecked_ref())?; cb.forget(); Ok(()) } fn install_resize( window: &Window, canvas: &HtmlCanvasElement, renderer: &Rc>, ) -> Result<(), JsValue> { let canvas = canvas.clone(); let win2 = window.clone(); let r = renderer.clone(); let cb = Closure::::new(move || { fit_canvas(&canvas, &win2); let mut rr = r.borrow_mut(); rr.resize(canvas.width(), canvas.height()); rr.set_client_size( canvas.client_width() as f32, canvas.client_height() as f32, ); }); window.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?; cb.forget(); Ok(()) } fn install_mouse( document: &Document, canvas: &HtmlCanvasElement, renderer: &Rc>, ) -> Result<(), JsValue> { let canvas = canvas.clone(); let r = renderer.clone(); let cb = Closure::::new(move |e: MouseEvent| { let w = canvas.client_width().max(1) as f32; let h = canvas.client_height().max(1) as f32; let x = e.client_x() as f32 - w * 0.5; let y = h * 0.5 - e.client_y() as f32; r.borrow_mut().set_mouse_px(x, y); }); document.add_event_listener_with_callback("mousemove", cb.as_ref().unchecked_ref())?; cb.forget(); Ok(()) } fn install_canvas_pointer( canvas: &HtmlCanvasElement, renderer: &Rc>, ) -> Result<(), JsValue> { let canvas2 = canvas.clone(); let r = renderer.clone(); let cb = Closure::::new(move |e: PointerEvent| { let rect = canvas2.get_bounding_client_rect(); let dx = e.client_x() as f64 - (rect.left() + rect.width() / 2.0); let dy = e.client_y() as f64 - (rect.top() + rect.height() / 2.0); let dist2 = dx * dx + dy * dy; let ring = { let rb = r.borrow(); rb.click_radius_css_px() as f64 }; if dist2 <= ring * ring { r.borrow_mut().impulse_click(); } }); canvas.add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?; cb.forget(); Ok(()) } fn install_canvas_leave( canvas: &HtmlCanvasElement, renderer: &Rc>, ) -> Result<(), JsValue> { let r = renderer.clone(); let cb = Closure::::new(move |_e: Event| { r.borrow_mut().release_tilt(); }); canvas.add_event_listener_with_callback("mouseleave", cb.as_ref().unchecked_ref())?; cb.forget(); Ok(()) } fn install_tip_clicks(document: &Document, app: &Rc) -> Result<(), JsValue> { let tips_nodes = document.query_selector_all(".tip[data-md]")?; for i in 0..tips_nodes.length() { let Some(node) = tips_nodes.item(i) else { continue; }; let Ok(el) = node.dyn_into::() else { continue; }; let id = el.id(); let element = id.strip_prefix("tip-").unwrap_or("").to_string(); let md_url = el.get_attribute("data-md").unwrap_or_default(); let app2 = app.clone(); let el_for_rect = el.clone(); let el_name = element.clone(); let cb = Closure::::new(move |e: Event| { web_sys::console::log_1(&format!("DEBUG tip: click en {} isTrusted={}", el_name, e.is_trusted()).into()); e.prevent_default(); let rect = el_for_rect.get_bounding_client_rect(); let cx = rect.left() + rect.width() / 2.0; let cy = rect.top() + rect.height() / 2.0; web_sys::console::log_1(&format!("DEBUG tip: llamando open_or_switch({}, {}, {})", el_name, cx, cy).into()); app2.open_or_switch(&element, cx, cy, &md_url); }); el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?; cb.forget(); } Ok(()) } /// Un listener en el deck delega clicks de minimize y close en cada página. /// Las páginas se crean dinámicamente, así que no podemos adjuntar listeners /// por botón en boot. fn install_controls_delegation(document: &Document, app: &Rc) -> Result<(), JsValue> { let app2 = app.clone(); let cb = Closure::::new(move |e: MouseEvent| { let Some(target) = e.target() else { return }; let Ok(target_el): Result = target.dyn_into() else { return; }; // Minimize if let Ok(Some(btn)) = target_el.closest("[data-minimize]") { e.stop_propagation(); let element = btn.get_attribute("data-minimize").unwrap_or_default(); // Si el data-minimize está vacío, usar el elemento activo let el = if element.is_empty() { app2.state.borrow().active.clone().unwrap_or_default() } else { element }; if !el.is_empty() { let origin = app2 .taskbar_item_center(&el) .unwrap_or_else(|| center_of_element(&btn)); app2.minimize(origin.0, origin.1); } return; } // Close if let Ok(Some(btn)) = target_el.closest("[data-close-page]") { e.stop_propagation(); let element = btn.get_attribute("data-close-page").unwrap_or_default(); let el = if element.is_empty() { app2.state.borrow().active.clone().unwrap_or_default() } else { element }; if !el.is_empty() { let origin = app2 .taskbar_item_center(&el) .unwrap_or_else(|| center_of_element(&btn)); app2.close(&el, origin.0, origin.1); } } }); document.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?; cb.forget(); Ok(()) } fn center_of_element(el: &Element) -> (f64, f64) { let rect = el.get_bounding_client_rect(); ( rect.left() + rect.width() / 2.0, rect.top() + rect.height() / 2.0, ) } /// Home button + brand link (data-home) — toda la lógica de tabs vive en /// barra-web::TaskList. Acá sólo se instalan los handlers para [data-home]. fn install_taskbar(document: &Document, app: &Rc) -> Result<(), JsValue> { let homes = document.query_selector_all("[data-home]")?; for i in 0..homes.length() { let Some(node) = homes.item(i) else { continue }; let Ok(el) = node.dyn_into::() else { continue; }; let app2 = app.clone(); let cb = Closure::::new(move |e: Event| { e.prevent_default(); app2.home(); }); el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?; cb.forget(); } Ok(()) } fn install_keyboard(document: &Document, app: &Rc) -> Result<(), JsValue> { let app2 = app.clone(); let cb = Closure::::new(move |e: KeyboardEvent| { if e.key() == "Escape" { if app2.state.borrow().active.is_some() { app2.home(); } } }); document.add_event_listener_with_callback("keydown", cb.as_ref().unchecked_ref())?; cb.forget(); Ok(()) } fn install_raf( window: &Window, document: &Document, canvas: &HtmlCanvasElement, renderer: &Rc>, ) { let f = Rc::new(RefCell::new(None::>)); let g = f.clone(); let renderer = renderer.clone(); let canvas = canvas.clone(); let document = document.clone(); let window2 = window.clone(); *g.borrow_mut() = Some(Closure::::new(move |time_ms: f64| { renderer.borrow_mut().render(time_ms); let r = renderer.borrow(); position_tips(&document, &canvas, &r); drop(r); if let Some(cb) = f.borrow().as_ref() { let _ = window2.request_animation_frame(cb.as_ref().unchecked_ref()); } })); let _ = window.request_animation_frame(g.borrow().as_ref().unwrap().as_ref().unchecked_ref()); } fn fit_canvas(canvas: &HtmlCanvasElement, window: &Window) { let dpr = window.device_pixel_ratio() as f32; let w = window .inner_width() .ok() .and_then(|v| v.as_f64()) .unwrap_or(1280.0) as f32; let h = window .inner_height() .ok() .and_then(|v| v.as_f64()) .unwrap_or(720.0) as f32; canvas.set_width((w * dpr) as u32); canvas.set_height((h * dpr) as u32); let el: &HtmlElement = canvas.unchecked_ref(); let style = el.style(); let _ = style.set_property("width", &format!("{}px", w)); let _ = style.set_property("height", &format!("{}px", h)); } fn position_tips(document: &Document, canvas: &HtmlCanvasElement, renderer: &Renderer) { let clips = renderer.cardinal_positions_ndc(BUTTON_RADIUS_FACTOR); let cw = canvas.client_width().max(1) as f32; let ch = canvas.client_height().max(1) as f32; let min_x = VIEWPORT_MARGIN_PX + BUTTON_HALF_W_PX; let max_x = (cw - VIEWPORT_MARGIN_PX - BUTTON_HALF_W_PX).max(min_x); let min_y = VIEWPORT_MARGIN_PX + BUTTON_HALF_H_PX; let max_y = (ch - TASKBAR_HEIGHT_PX - VIEWPORT_MARGIN_PX - BUTTON_HALF_H_PX).max(min_y); for (i, (id, _color, _label)) in tips::ORDER.iter().enumerate() { let (nx, ny) = clips[i]; let raw_x = (nx + 1.0) * 0.5 * cw; let raw_y = (1.0 - (ny + 1.0) * 0.5) * ch; let px = raw_x.clamp(min_x, max_x); let py = raw_y.clamp(min_y, max_y); let sel = format!("tip-{}", id); if let Some(el) = document.get_element_by_id(&sel) { if let Ok(el) = el.dyn_into::() { let _ = el.style().set_property( "transform", &format!("translate({:.2}px, {:.2}px) translate(-50%, -50%)", px, py), ); } } } } /// Mapea un doc_id de Qdrant al nombre del elemento (aire/fuego/tierra/agua) /// y su ruta md. Los doc_ids se generan con uuid5 en el indexador, pero /// podemos inferir por el nombre del camino o del elemento. fn map_doc_id_to_element(doc_id: &str) -> (String, String) { // Inferir del doc_id: contiene el nombre del elemento let el = if doc_id.contains("aire") || doc_id.contains("logos") { "aire" } else if doc_id.contains("fuego") || doc_id.contains("nomos") { "fuego" } else if doc_id.contains("tierra") || doc_id.contains("kay") { "tierra" } else if doc_id.contains("agua") || doc_id.contains("uku") { "agua" } else if doc_id.contains("cuerpo") { "cuerpo" } else if doc_id.contains("sombra") { "sombra" } else if doc_id.contains("cosmos") { "cosmos" } else if doc_id.contains("practica") { "practica" } else if doc_id.contains("olvido") { "olvido" } else { "aire" }; (el.to_string(), format!("./md/{}.md", el)) } fn install_panic_hook() { static SET: std::sync::Once = std::sync::Once::new(); SET.call_once(|| { std::panic::set_hook(Box::new(|info| { let msg = format!("{}", info); web_sys::console::error_1(&JsValue::from_str(&msg)); })); }); }