Files
brahman/crates/apps/gioser-web/src/lib.rs
T
sergio 62058ab193 feat(gioser): deck swipeable + minimize, brand+copyleft en taskbar (vista-web)
Nuevo módulo agnóstico:
- `crates/modules/vista/vista-web` — Deck::mount(strip) instala swipe
  horizontal con snap a la página más cercana, estilo Flutter PageView.
  goto(idx, smooth) navega programáticamente. on_change(cb) fires tras
  snap. Drag decision: horizontal si dx > 8px y > 1.3*dy; sino cede al
  pan-y nativo (scroll vertical del contenido). resize listener ajusta
  --vista-offset sin animar usando .vista-instant un frame.

App rediseñada:
- Brand "GioSer" sacado del centro de la chacana → ahora en la taskbar
  junto al botón home (data-home). brand-dot dorado entre Gio·Ser. El
  centro de la chacana queda con sol limpio.
- Copyleft + sergio@gioser.net a la derecha de la taskbar, abre
  https://sergio.gioser.net en nueva pestaña (target=_blank rel=noopener).
- 4 drawers separados → reemplazados por un único `.deck` con `.deck-strip`
  vista-web manejado. Las páginas se crean dinámicamente al abrir un
  elemento por primera vez (`ensure_page_dom`).
- Cada página tiene controles minimizar (─) y cerrar (×) arriba a la
  derecha, con ambience animada por elemento.
- Click minimize → active=None, deck scale(0) hacia la cajita del
  taskbar (origin = bounding rect del taskbar-item). Página queda en
  memoria, tab sigue en la barra.
- Click cajita del taskbar:
  - Si está activa → minimize (toggle).
  - Si está minimizada → restore con scale-up desde la cajita.
- Click home / brand → minimize all (estilo Show Desktop, no destruye).
- Swipe horizontal o click cajita → deck.goto(idx, smooth=true) con snap
  animado por vista-web. on_swipe sync de taskbar active state.
- Cerrar página → remueve del strip + del Vec pages; si era activa,
  reemplaza por neighbor o hide deck si era la última.

CSS:
- Eliminado `.brand` fixed center y `.drawer` × 4 individuales.
- `.deck` único + `.deck-strip` con `transform: translate3d(--vista-offset)`
  y transition transform 360ms cubic-bezier(0.22, 0.61, 0.36, 1).
- `.deck-strip.vista-dragging` / `.vista-instant` → transition: none.
- `.deck-page[data-element]` cada una con su page-ambience animada
  (aire-drift, fuego-flicker, agua-tide, tierra static).
- `.taskbar-brand` Cinzel 1.3rem dorado + .brand-dot.
- `.taskbar-credit` con `.copyleft-mark` (© con scaleX(-1) = copyleft
  visual).
- `.taskbar-spacer { flex:1 }` empuja credit a la derecha.
- `.taskbar-item.active` glow del color del elemento + border-bottom.
- `body.deck-visible` baja opacity del canvas + esconde tips y brand.

Workspace verde + 18 tests (geom 6 + palette 4 + physics 3 + pluma-md 5).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 02:20:34 +00:00

660 lines
24 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 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,
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();
if let Some(list) = self.document.get_element_by_id("taskbar-list") {
let mut html = String::new();
for e in &s.pages {
let label = e.to_uppercase();
let active = if s.active.as_deref() == Some(e.as_str()) {
" active"
} else {
""
};
html.push_str(&format!(
"<li><button class=\"taskbar-item{active}\" data-task=\"{e}\" type=\"button\">\
<span class=\"taskbar-item-dot\" aria-hidden=\"true\"></span>{label}</button></li>"
));
}
list.set_inner_html(&html);
}
}
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" => ("Aire", "Software · IA · Aspiración"),
"fuego" => ("Fuego", "Inspiración"),
"tierra" => ("Tierra", "Cuerpo"),
"agua" => ("Agua", "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)> {
let sel = format!(".taskbar-item[data-task=\"{}\"]", element);
self.element_center(&sel)
}
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)?;
let app = Rc::new(AppState {
document: document.clone(),
deck: deck.clone(),
state: RefCell::default(),
});
// vista on_change → on_swipe del app
{
let app2 = app.clone();
deck.on_change(move |idx| {
app2.on_swipe(idx);
});
}
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,
)
}
fn install_taskbar(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
// Home button & brand link comparten data-home — minimizan todo.
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();
}
// Delegación en la lista de tabs.
if let Some(list) = document.get_element_by_id("taskbar-list") {
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;
};
let Ok(Some(item)) = target_el.closest(".taskbar-item") else {
return;
};
if let Some(task) = item.get_attribute("data-task") {
let rect = item.get_bounding_client_rect();
let cx = rect.left() + rect.width() / 2.0;
let cy = rect.top() + rect.height() / 2.0;
// Si la pestaña ya está activa, minimiza (toggle estilo Windows).
let is_active = app2.state.borrow().active.as_deref() == Some(&task);
if is_active {
app2.minimize(cx, cy);
} else {
app2.restore_from_tab(&task, cx, cy);
}
}
});
list.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));
}));
});
}