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