Files
brahman/crates/apps/gioser-web/src/lib.rs
T
sergio d94dcfb5af feat(gioser): ciclo sol↔luna↔tierra, auras anchas, nuevo set Software/Quién Soy/Manifiesto/Mística
Shader (gioser-shaders):
- 3 cuerpos centrales renderizados realísticamente con interpolación
  gradual entre ellos (cross-fade smoothstep):
  - render_sun: núcleo gauss + corona pulsante + textura de plasma FBM
    (boiling surface).
  - render_moon: disco con limb darkening, cráteres + mares (2 octavas
    de fbm), terminador móvil (fase lunar), halo azulado en el limb
    iluminado.
  - render_earth: disco con continentes fbm (rotación lenta), polos
    blancos, nubes en otra capa, día/noche en hemisferio iluminado,
    halo atmosférico azul (Rayleigh simplificado).
- Uniforms u_body_a, u_body_b (int 0/1/2), u_body_blend (float).
- Cuerpo central se calcula sólo si inside > 0.001 (perf — saltea pixels
  fuera de la superficie de la chacana).
- radial_mult atenúa los rayos cuando luna/tierra están activos — el sol
  es el único que irradia tan intensamente.

- element_cloud(): aura ancha por cardinal (sigma_along=0.42,
  sigma_perp=0.34) con textura fbm animada y modulación por elemento.
  - AIRE: corrientes suaves que ondulan horizontalmente.
  - FUEGO: lengüetazos rápidos con flicker.
  - TIERRA: densidad sólida con variación lenta.
  - AGUA: ondulaciones grandes que viajan hacia afuera.
  Las nubes cubren todo el cuadrante del cardinal, no solo la punta.

- Helper functions vnoise_c + fbm_c agregadas (necesarias para superficies
  realistas de luna/tierra y para nubes elementales).

Renderer (gioser-canvas-web):
- body_state(t) -> (body_a, body_b, blend) state machine:
  - BODY_PHASE_SECS = 45 (≈10 pulsos del sol antes de transicionar).
  - BODY_TRANSITION_SECS = 4 (cross-fade gradual).
  - Total cycle: 147s = sol 45s → trans 4s → luna 45s → trans 4s → tierra 45s → trans 4s.
  - Smoothstep cubic en el blend para curva natural (no linear).
- Sube u_body_a/b como int (uniform1i) y u_body_blend como float.

App + contenido:
- index.html: nuevos labels en los 4 tips
  - NORTE (aire):  SOFTWARE / Tecnología
  - ESTE (fuego):  QUIÉN SOY / Bitácora
  - SUR (tierra):  MANIFIESTO / Invariantes
  - OESTE (agua):  MÍSTICA / Espiritualidad
- Íconos SVG nuevos relacionados al tema:
  - aire: chip de circuito con nodos y conexiones
  - fuego: libro abierto con líneas
  - tierra: hexagrama dentro de círculo (sacred geometry / invariante)
  - agua: ojo en triángulo (mística)
- gioser-web src/lib.rs: ensure_page_dom usa nuevos title+tag por elemento.
- 4 md/*.md reescritos con contenido seed para los nuevos temas, con
  manifiesto explícito en tierra.md.

Workspace verde + 21 tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 04:31:02 +00:00

651 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 pluma_reader_web::Reader;
use vista_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; 4] = ["aire", "fuego", "tierra", "agua"];
#[derive(Default)]
struct DeckState {
/// Pestañas abiertas en orden de apertura. Coincide con páginas en el strip.
pages: Vec<String>,
/// Cuál es la página visible. `None` = deck minimizado (pestañas siguen
/// en la taskbar) o sin pestañas.
active: Option<String>,
}
struct AppState {
document: Document,
deck: Deck,
taskbar: TaskList,
state: RefCell<DeckState>,
}
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);
}
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);
}
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<String> = 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");
}
}
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");
}
}
fn deck_el(&self) -> Option<HtmlElement> {
self.document
.get_element_by_id("deck")
.and_then(|e| e.dyn_into::<HtmlElement>().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<Task> = 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"),
_ => return,
};
let html = format!(
"<article class=\"deck-page\" data-element=\"{el}\" id=\"deck-page-{el}\">\
<div class=\"page-controls\">\
<button class=\"page-control-btn page-minimize\" data-minimize=\"{el}\" type=\"button\" aria-label=\"Minimizar {title}\">\
<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>\
</button>\
<button class=\"page-control-btn page-close\" data-close-page=\"{el}\" type=\"button\" aria-label=\"Cerrar {title}\">×</button>\
</div>\
<div class=\"page-ambience\" aria-hidden=\"true\"></div>\
<header class=\"page-head\">\
<span class=\"page-mark\">{el}</span>\
<h2 class=\"page-title\">{title}</h2>\
<span class=\"page-tag\">{tag}</span>\
</header>\
<section class=\"page-content\" id=\"page-{el}-content\"></section>\
</article>",
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<HtmlElement, _> = content_el.dyn_into() else {
return;
};
let inner = content.inner_html();
if inner.contains("pluma-doc") {
return; // ya hidratado
}
let reader = Reader::new(content);
let element_owned = element.to_string();
let url_owned = md_url.to_string();
wasm_bindgen_futures::spawn_local(async move {
if let Err(e) = reader.open_url(&url_owned, &element_owned).await {
web_sys::console::warn_1(&e);
}
});
}
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_deck_delegation(&document, &app)?;
install_taskbar(&document, &app)?;
install_keyboard(&document, &app)?;
install_raf(&window, &document, &canvas, &renderer);
Ok(())
}
fn install_resize(
window: &Window,
canvas: &HtmlCanvasElement,
renderer: &Rc<RefCell<Renderer>>,
) -> Result<(), JsValue> {
let canvas = canvas.clone();
let win2 = window.clone();
let r = renderer.clone();
let cb = Closure::<dyn FnMut()>::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<RefCell<Renderer>>,
) -> Result<(), JsValue> {
let canvas = canvas.clone();
let r = renderer.clone();
let cb = Closure::<dyn FnMut(MouseEvent)>::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<RefCell<Renderer>>,
) -> Result<(), JsValue> {
let canvas2 = canvas.clone();
let r = renderer.clone();
let cb = Closure::<dyn FnMut(PointerEvent)>::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<RefCell<Renderer>>,
) -> Result<(), JsValue> {
let r = renderer.clone();
let cb = Closure::<dyn FnMut(Event)>::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<AppState>) -> 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::<HtmlElement>() 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 cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
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;
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_deck_delegation(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
let Some(deck_el) = document.get_element_by_id("deck") else {
return Ok(());
};
let app2 = app.clone();
let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
let Some(target) = e.target() else { return };
let Ok(target_el): Result<Element, _> = 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();
// Origin = la cajita correspondiente en la taskbar (efecto
// visual: la página se "encoge" hacia su entrada del taskbar).
let origin = app2
.taskbar_item_center(&element)
.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 origin = app2
.taskbar_item_center(&element)
.unwrap_or_else(|| center_of_element(&btn));
app2.close(&element, origin.0, origin.1);
}
});
deck_el.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<AppState>) -> 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::<HtmlElement>() else {
continue;
};
let app2 = app.clone();
let cb = Closure::<dyn FnMut(Event)>::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<AppState>) -> Result<(), JsValue> {
let app2 = app.clone();
let cb = Closure::<dyn FnMut(KeyboardEvent)>::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<RefCell<Renderer>>,
) {
let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut(f64)>>));
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::<dyn FnMut(f64)>::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::<HtmlElement>() {
let _ = el.style().set_property(
"transform",
&format!("translate({:.2}px, {:.2}px) translate(-50%, -50%)", px, py),
);
}
}
}
}
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));
}));
});
}