⎇ a908d8420cc8f4e2b5c4386f1f784f0977de1747
crates/apps/gioser-web/src/lib.rs
Historia
Quién tocó qué
crudo
gioser-web: fix graph not appearing — use MutationObserver
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 // El script cytoscape-graph.js usa MutationObserver
310 // para detectar <gioser-graph> dinámicos. No hace falta
311 // disparar nada acá.
312 });
313 }
314
315 fn element_center(&self, selector: &str) -> Option<(f64, f64)> {
316 let el = self.document.query_selector(selector).ok().flatten()?;
317 let rect = el.get_bounding_client_rect();
318 Some((
319 rect.left() + rect.width() / 2.0,
320 rect.top() + rect.height() / 2.0,
321 ))
322 }
323
324 fn taskbar_item_center(&self, element: &str) -> Option<(f64, f64)> {
325 self.taskbar.task_center(element)
326 }
327
328 fn viewport_height(&self) -> f64 {
329 web_sys::window()
330 .and_then(|w| w.inner_height().ok())
331 .and_then(|v| v.as_f64())
332 .unwrap_or(720.0)
333 }
334}
335
336#[wasm_bindgen(start)]
337pub fn boot() -> Result<(), JsValue> {
338 install_panic_hook();
339 let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
340 let document = window
341 .document()
342 .ok_or_else(|| JsValue::from_str("no document"))?;
343
344 let canvas: HtmlCanvasElement = document
345 .get_element_by_id("gioser-canvas")
346 .ok_or_else(|| JsValue::from_str("no canvas#gioser-canvas"))?
347 .dyn_into()?;
348
349 fit_canvas(&canvas, &window);
350 let renderer = Rc::new(RefCell::new(Renderer::new(&canvas)?));
351 {
352 let mut r = renderer.borrow_mut();
353 r.resize(canvas.width(), canvas.height());
354 r.set_client_size(
355 canvas.client_width() as f32,
356 canvas.client_height() as f32,
357 );
358 }
359
360 // Mount vista deck
361 let strip_el: HtmlElement = document
362 .get_element_by_id("deck-strip")
363 .ok_or_else(|| JsValue::from_str("no #deck-strip"))?
364 .dyn_into()?;
365 let deck = Deck::mount(strip_el)?;
366
367 // Mount barra-web taskbar (manages the dynamic task list).
368 let list_el: HtmlElement = document
369 .get_element_by_id("taskbar-list")
370 .ok_or_else(|| JsValue::from_str("no #taskbar-list"))?
371 .dyn_into()?;
372 let taskbar = TaskList::mount(list_el)?;
373
374 let app = Rc::new(AppState {
375 document: document.clone(),
376 deck: deck.clone(),
377 taskbar: taskbar.clone(),
378 state: RefCell::default(),
379 });
380
381 // vista on_change → on_swipe del app
382 {
383 let app2 = app.clone();
384 deck.on_change(move |idx| {
385 app2.on_swipe(idx);
386 });
387 }
388
389 // barra on_click → restore / toggle minimize del app
390 {
391 let app2 = app.clone();
392 taskbar.on_click(move |id, cx, cy| {
393 let is_active = app2.state.borrow().active.as_deref() == Some(id);
394 if is_active {
395 app2.minimize(cx, cy);
396 } else {
397 app2.restore_from_tab(id, cx, cy);
398 }
399 });
400 }
401
402 install_resize(&window, &canvas, &renderer)?;
403 install_mouse(&document, &canvas, &renderer)?;
404 install_canvas_pointer(&canvas, &renderer)?;
405 install_canvas_leave(&canvas, &renderer)?;
406 install_tip_clicks(&document, &app)?;
407 install_deck_delegation(&document, &app)?;
408 install_taskbar(&document, &app)?;
409 install_keyboard(&document, &app)?;
410 install_raf(&window, &document, &canvas, &renderer);
411
412 Ok(())
413}
414
415fn install_resize(
416 window: &Window,
417 canvas: &HtmlCanvasElement,
418 renderer: &Rc<RefCell<Renderer>>,
419) -> Result<(), JsValue> {
420 let canvas = canvas.clone();
421 let win2 = window.clone();
422 let r = renderer.clone();
423 let cb = Closure::<dyn FnMut()>::new(move || {
424 fit_canvas(&canvas, &win2);
425 let mut rr = r.borrow_mut();
426 rr.resize(canvas.width(), canvas.height());
427 rr.set_client_size(
428 canvas.client_width() as f32,
429 canvas.client_height() as f32,
430 );
431 });
432 window.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?;
433 cb.forget();
434 Ok(())
435}
436
437fn install_mouse(
438 document: &Document,
439 canvas: &HtmlCanvasElement,
440 renderer: &Rc<RefCell<Renderer>>,
441) -> Result<(), JsValue> {
442 let canvas = canvas.clone();
443 let r = renderer.clone();
444 let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
445 let w = canvas.client_width().max(1) as f32;
446 let h = canvas.client_height().max(1) as f32;
447 let x = e.client_x() as f32 - w * 0.5;
448 let y = h * 0.5 - e.client_y() as f32;
449 r.borrow_mut().set_mouse_px(x, y);
450 });
451 document.add_event_listener_with_callback("mousemove", cb.as_ref().unchecked_ref())?;
452 cb.forget();
453 Ok(())
454}
455
456fn install_canvas_pointer(
457 canvas: &HtmlCanvasElement,
458 renderer: &Rc<RefCell<Renderer>>,
459) -> Result<(), JsValue> {
460 let canvas2 = canvas.clone();
461 let r = renderer.clone();
462 let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
463 let rect = canvas2.get_bounding_client_rect();
464 let dx = e.client_x() as f64 - (rect.left() + rect.width() / 2.0);
465 let dy = e.client_y() as f64 - (rect.top() + rect.height() / 2.0);
466 let dist2 = dx * dx + dy * dy;
467 let ring = {
468 let rb = r.borrow();
469 rb.click_radius_css_px() as f64
470 };
471 if dist2 <= ring * ring {
472 r.borrow_mut().impulse_click();
473 }
474 });
475 canvas.add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?;
476 cb.forget();
477 Ok(())
478}
479
480fn install_canvas_leave(
481 canvas: &HtmlCanvasElement,
482 renderer: &Rc<RefCell<Renderer>>,
483) -> Result<(), JsValue> {
484 let r = renderer.clone();
485 let cb = Closure::<dyn FnMut(Event)>::new(move |_e: Event| {
486 r.borrow_mut().release_tilt();
487 });
488 canvas.add_event_listener_with_callback("mouseleave", cb.as_ref().unchecked_ref())?;
489 cb.forget();
490 Ok(())
491}
492
493fn install_tip_clicks(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
494 let tips_nodes = document.query_selector_all(".tip[data-md]")?;
495 for i in 0..tips_nodes.length() {
496 let Some(node) = tips_nodes.item(i) else {
497 continue;
498 };
499 let Ok(el) = node.dyn_into::<HtmlElement>() else {
500 continue;
501 };
502 let id = el.id();
503 let element = id.strip_prefix("tip-").unwrap_or("").to_string();
504 let md_url = el.get_attribute("data-md").unwrap_or_default();
505 let app2 = app.clone();
506 let el_for_rect = el.clone();
507 let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
508 e.prevent_default();
509 let rect = el_for_rect.get_bounding_client_rect();
510 let cx = rect.left() + rect.width() / 2.0;
511 let cy = rect.top() + rect.height() / 2.0;
512 app2.open_or_switch(&element, cx, cy, &md_url);
513 });
514 el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
515 cb.forget();
516 }
517 Ok(())
518}
519
520/// Un listener en el deck delega clicks de minimize y close en cada página.
521/// Las páginas se crean dinámicamente, así que no podemos adjuntar listeners
522/// por botón en boot.
523fn install_deck_delegation(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
524 let Some(deck_el) = document.get_element_by_id("deck") else {
525 return Ok(());
526 };
527 let app2 = app.clone();
528 let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
529 let Some(target) = e.target() else { return };
530 let Ok(target_el): Result<Element, _> = target.dyn_into() else {
531 return;
532 };
533 // Minimize
534 if let Ok(Some(btn)) = target_el.closest("[data-minimize]") {
535 e.stop_propagation();
536 let element = btn.get_attribute("data-minimize").unwrap_or_default();
537 // Origin = la cajita correspondiente en la taskbar (efecto
538 // visual: la página se "encoge" hacia su entrada del taskbar).
539 let origin = app2
540 .taskbar_item_center(&element)
541 .unwrap_or_else(|| center_of_element(&btn));
542 app2.minimize(origin.0, origin.1);
543 return;
544 }
545 // Close
546 if let Ok(Some(btn)) = target_el.closest("[data-close-page]") {
547 e.stop_propagation();
548 let element = btn.get_attribute("data-close-page").unwrap_or_default();
549 let origin = app2
550 .taskbar_item_center(&element)
551 .unwrap_or_else(|| center_of_element(&btn));
552 app2.close(&element, origin.0, origin.1);
553 }
554 });
555 deck_el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
556 cb.forget();
557 Ok(())
558}
559
560fn center_of_element(el: &Element) -> (f64, f64) {
561 let rect = el.get_bounding_client_rect();
562 (
563 rect.left() + rect.width() / 2.0,
564 rect.top() + rect.height() / 2.0,
565 )
566}
567
568/// Home button + brand link (data-home) — toda la lógica de tabs vive en
569/// barra-web::TaskList. Acá sólo se instalan los handlers para [data-home].
570fn install_taskbar(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
571 let homes = document.query_selector_all("[data-home]")?;
572 for i in 0..homes.length() {
573 let Some(node) = homes.item(i) else { continue };
574 let Ok(el) = node.dyn_into::<HtmlElement>() else {
575 continue;
576 };
577 let app2 = app.clone();
578 let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
579 e.prevent_default();
580 app2.home();
581 });
582 el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
583 cb.forget();
584 }
585 Ok(())
586}
587
588fn install_keyboard(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
589 let app2 = app.clone();
590 let cb = Closure::<dyn FnMut(KeyboardEvent)>::new(move |e: KeyboardEvent| {
591 if e.key() == "Escape" {
592 if app2.state.borrow().active.is_some() {
593 app2.home();
594 }
595 }
596 });
597 document.add_event_listener_with_callback("keydown", cb.as_ref().unchecked_ref())?;
598 cb.forget();
599 Ok(())
600}
601
602fn install_raf(
603 window: &Window,
604 document: &Document,
605 canvas: &HtmlCanvasElement,
606 renderer: &Rc<RefCell<Renderer>>,
607) {
608 let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut(f64)>>));
609 let g = f.clone();
610 let renderer = renderer.clone();
611 let canvas = canvas.clone();
612 let document = document.clone();
613 let window2 = window.clone();
614 *g.borrow_mut() = Some(Closure::<dyn FnMut(f64)>::new(move |time_ms: f64| {
615 renderer.borrow_mut().render(time_ms);
616 let r = renderer.borrow();
617 position_tips(&document, &canvas, &r);
618 drop(r);
619 if let Some(cb) = f.borrow().as_ref() {
620 let _ = window2.request_animation_frame(cb.as_ref().unchecked_ref());
621 }
622 }));
623 let _ = window.request_animation_frame(g.borrow().as_ref().unwrap().as_ref().unchecked_ref());
624}
625
626fn fit_canvas(canvas: &HtmlCanvasElement, window: &Window) {
627 let dpr = window.device_pixel_ratio() as f32;
628 let w = window
629 .inner_width()
630 .ok()
631 .and_then(|v| v.as_f64())
632 .unwrap_or(1280.0) as f32;
633 let h = window
634 .inner_height()
635 .ok()
636 .and_then(|v| v.as_f64())
637 .unwrap_or(720.0) as f32;
638 canvas.set_width((w * dpr) as u32);
639 canvas.set_height((h * dpr) as u32);
640 let el: &HtmlElement = canvas.unchecked_ref();
641 let style = el.style();
642 let _ = style.set_property("width", &format!("{}px", w));
643 let _ = style.set_property("height", &format!("{}px", h));
644}
645
646fn position_tips(document: &Document, canvas: &HtmlCanvasElement, renderer: &Renderer) {
647 let clips = renderer.cardinal_positions_ndc(BUTTON_RADIUS_FACTOR);
648 let cw = canvas.client_width().max(1) as f32;
649 let ch = canvas.client_height().max(1) as f32;
650 let min_x = VIEWPORT_MARGIN_PX + BUTTON_HALF_W_PX;
651 let max_x = (cw - VIEWPORT_MARGIN_PX - BUTTON_HALF_W_PX).max(min_x);
652 let min_y = VIEWPORT_MARGIN_PX + BUTTON_HALF_H_PX;
653 let max_y = (ch - TASKBAR_HEIGHT_PX - VIEWPORT_MARGIN_PX - BUTTON_HALF_H_PX).max(min_y);
654 for (i, (id, _color, _label)) in tips::ORDER.iter().enumerate() {
655 let (nx, ny) = clips[i];
656 let raw_x = (nx + 1.0) * 0.5 * cw;
657 let raw_y = (1.0 - (ny + 1.0) * 0.5) * ch;
658 let px = raw_x.clamp(min_x, max_x);
659 let py = raw_y.clamp(min_y, max_y);
660 let sel = format!("tip-{}", id);
661 if let Some(el) = document.get_element_by_id(&sel) {
662 if let Ok(el) = el.dyn_into::<HtmlElement>() {
663 let _ = el.style().set_property(
664 "transform",
665 &format!("translate({:.2}px, {:.2}px) translate(-50%, -50%)", px, py),
666 );
667 }
668 }
669 }
670}
671
672/// Mapea un doc_id de Qdrant al nombre del elemento (aire/fuego/tierra/agua)
673/// y su ruta md. Los doc_ids se generan con uuid5 en el indexador, pero
674/// podemos inferir por el nombre del camino o del elemento.
675fn map_doc_id_to_element(doc_id: &str) -> (String, String) {
676 // Inferir del doc_id: contiene el nombre del elemento
677 let el = if doc_id.contains("aire") || doc_id.contains("logos") {
678 "aire"
679 } else if doc_id.contains("fuego") || doc_id.contains("nomos") {
680 "fuego"
681 } else if doc_id.contains("tierra") || doc_id.contains("kay") {
682 "tierra"
683 } else if doc_id.contains("agua") || doc_id.contains("uku") {
684 "agua"
685 } else {
686 "aire"
687 };
688 (el.to_string(), format!("./md/{}.md", el))
689}
690
691fn install_panic_hook() {
692 static SET: std::sync::Once = std::sync::Once::new();
693 SET.call_once(|| {
694 std::panic::set_hook(Box::new(|info| {
695 let msg = format!("{}", info);
696 web_sys::console::error_1(&JsValue::from_str(&msg));
697 }));
698 });
699}