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