⎇ fce630c8d0a50843b6d447531efb106a627fd1c7
crates/apps/gioser-web/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(gioser): sol detrás, título central, drawers MD + pluma agnóstico
1//! Entrypoint WASM de la landing GioSer.
2//!
3//! Responsabilidades:
4//! - Montar canvas WebGL2 + listeners de mouse/resize/RAF loop.
5//! - Reposicionar los 4 tips DOM (botones cardinales) sobre las posiciones
6//! proyectadas del aro de la chacana cada frame.
7//! - Inclinar el título "GioSer" central inyectando CSS vars de tilt.
8//! - Manejar click sobre cada tip → animar drawer expandiéndose desde la
9//! posición del botón hasta fullscreen, cargar el .md asociado vía
10//! `pluma-reader-web` y renderearlo themed por elemento.
11//! - Cerrar drawer con close button, Escape o backdrop click.
12
13use std::cell::RefCell;
14use std::rc::Rc;
15
16use gioser_canvas_web::{tips, Renderer};
17use pluma_reader_web::Reader;
18use wasm_bindgen::prelude::*;
19use wasm_bindgen::JsCast;
20use web_sys::{
21 Document, Event, HtmlCanvasElement, HtmlElement, KeyboardEvent, MouseEvent, Window,
22};
23
24/// Factor radial sobre `arm_extent` donde se anclan los botones DOM.
25/// Queda entre la punta de la chacana y el aro grueso.
26const BUTTON_RADIUS_FACTOR: f32 = 1.32;
27
28#[wasm_bindgen(start)]
29pub fn boot() -> Result<(), JsValue> {
30 install_panic_hook();
31 let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
32 let document = window
33 .document()
34 .ok_or_else(|| JsValue::from_str("no document"))?;
35
36 let canvas: HtmlCanvasElement = document
37 .get_element_by_id("gioser-canvas")
38 .ok_or_else(|| JsValue::from_str("no canvas#gioser-canvas"))?
39 .dyn_into()?;
40
41 fit_canvas(&canvas, &window);
42 let renderer = Rc::new(RefCell::new(Renderer::new(&canvas)?));
43 renderer
44 .borrow_mut()
45 .resize(canvas.width(), canvas.height());
46
47 install_resize(&window, &canvas, &renderer)?;
48 install_mouse(&document, &canvas, &renderer)?;
49 install_drawer_handlers(&document)?;
50 install_raf(&window, &document, &canvas, &renderer);
51
52 Ok(())
53}
54
55fn install_resize(
56 window: &Window,
57 canvas: &HtmlCanvasElement,
58 renderer: &Rc<RefCell<Renderer>>,
59) -> Result<(), JsValue> {
60 let canvas = canvas.clone();
61 let win2 = window.clone();
62 let r = renderer.clone();
63 let cb = Closure::<dyn FnMut()>::new(move || {
64 fit_canvas(&canvas, &win2);
65 r.borrow_mut().resize(canvas.width(), canvas.height());
66 });
67 window.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?;
68 cb.forget();
69 Ok(())
70}
71
72fn install_mouse(
73 document: &Document,
74 canvas: &HtmlCanvasElement,
75 renderer: &Rc<RefCell<Renderer>>,
76) -> Result<(), JsValue> {
77 let canvas = canvas.clone();
78 let r = renderer.clone();
79 let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
80 let w = canvas.client_width().max(1) as f32;
81 let h = canvas.client_height().max(1) as f32;
82 let x = e.client_x() as f32 - w * 0.5;
83 let y = h * 0.5 - e.client_y() as f32;
84 r.borrow_mut().set_mouse_px(x, y);
85 });
86 document.add_event_listener_with_callback("mousemove", cb.as_ref().unchecked_ref())?;
87 cb.forget();
88 Ok(())
89}
90
91fn install_drawer_handlers(document: &Document) -> Result<(), JsValue> {
92 let doc = document.clone();
93
94 // Clicks en los 4 tips → open drawer.
95 let tips = document.query_selector_all(".tip[data-md]")?;
96 for i in 0..tips.length() {
97 let Some(node) = tips.item(i) else { continue };
98 let Ok(el) = node.dyn_into::<HtmlElement>() else { continue };
99 let id = el.id();
100 let element = id.strip_prefix("tip-").unwrap_or("").to_string();
101 let md_url = el.get_attribute("data-md").unwrap_or_default();
102 let d = doc.clone();
103 let el_for_rect = el.clone();
104 let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
105 e.prevent_default();
106 open_drawer(&d, &element, &el_for_rect, &md_url);
107 });
108 el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
109 cb.forget();
110 }
111
112 // Cualquier elemento marcado con `data-close-drawer` cierra.
113 let closes = document.query_selector_all("[data-close-drawer]")?;
114 for i in 0..closes.length() {
115 let Some(node) = closes.item(i) else { continue };
116 let Ok(el) = node.dyn_into::<HtmlElement>() else { continue };
117 let d = doc.clone();
118 let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
119 e.stop_propagation();
120 close_drawers(&d);
121 });
122 el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
123 cb.forget();
124 }
125
126 // Escape cierra.
127 let d = doc.clone();
128 let kcb = Closure::<dyn FnMut(KeyboardEvent)>::new(move |e: KeyboardEvent| {
129 if e.key() == "Escape" {
130 close_drawers(&d);
131 }
132 });
133 document.add_event_listener_with_callback("keydown", kcb.as_ref().unchecked_ref())?;
134 kcb.forget();
135
136 Ok(())
137}
138
139fn open_drawer(doc: &Document, element: &str, button: &HtmlElement, md_url: &str) {
140 let rect = button.get_bounding_client_rect();
141 let cx = rect.left() + rect.width() / 2.0;
142 let cy = rect.top() + rect.height() / 2.0;
143 let drawer_id = format!("drawer-{}", element);
144 let Some(drawer_el) = doc.get_element_by_id(&drawer_id) else {
145 return;
146 };
147 let drawer: HtmlElement = drawer_el.unchecked_into();
148 let _ = drawer
149 .style()
150 .set_property("--origin-x", &format!("{:.1}px", cx));
151 let _ = drawer
152 .style()
153 .set_property("--origin-y", &format!("{:.1}px", cy));
154 let _ = drawer.class_list().add_1("open");
155 drawer.set_attribute("aria-hidden", "false").ok();
156
157 if let Some(body) = doc.body() {
158 let _ = body.class_list().add_1("drawer-active");
159 let _ = body
160 .class_list()
161 .add_1(&format!("drawer-active-{}", element));
162 }
163
164 // Carga del .md en background.
165 let content_id = format!("drawer-{}-content", element);
166 if let Some(content_el) = doc.get_element_by_id(&content_id) {
167 let content: HtmlElement = content_el.unchecked_into();
168 let reader = Reader::new(content);
169 let element_owned = element.to_string();
170 let md_url_owned = md_url.to_string();
171 wasm_bindgen_futures::spawn_local(async move {
172 if let Err(e) = reader.open_url(&md_url_owned, &element_owned).await {
173 web_sys::console::warn_1(&e);
174 }
175 });
176 }
177}
178
179fn close_drawers(doc: &Document) {
180 let Ok(drawers) = doc.query_selector_all(".drawer.open") else {
181 return;
182 };
183 for i in 0..drawers.length() {
184 let Some(node) = drawers.item(i) else { continue };
185 let Ok(el) = node.dyn_into::<HtmlElement>() else {
186 continue;
187 };
188 let _ = el.class_list().remove_1("open");
189 let _ = el.set_attribute("aria-hidden", "true");
190 }
191 if let Some(body) = doc.body() {
192 let _ = body.class_list().remove_1("drawer-active");
193 for e in ["aire", "fuego", "tierra", "agua"] {
194 let _ = body
195 .class_list()
196 .remove_1(&format!("drawer-active-{}", e));
197 }
198 }
199}
200
201fn install_raf(
202 window: &Window,
203 document: &Document,
204 canvas: &HtmlCanvasElement,
205 renderer: &Rc<RefCell<Renderer>>,
206) {
207 let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut(f64)>>));
208 let g = f.clone();
209 let renderer = renderer.clone();
210 let canvas = canvas.clone();
211 let document = document.clone();
212 let window2 = window.clone();
213 *g.borrow_mut() = Some(Closure::<dyn FnMut(f64)>::new(move |time_ms: f64| {
214 let r = renderer.borrow_mut();
215 // r es Mut, ojo: el render necesita mut, lo hacemos antes de paint.
216 drop(r);
217 renderer.borrow_mut().render(time_ms);
218 let r = renderer.borrow();
219 position_tips(&document, &canvas, &r);
220 update_tilt_css(&document, &r);
221 drop(r);
222 if let Some(cb) = f.borrow().as_ref() {
223 let _ = window2.request_animation_frame(cb.as_ref().unchecked_ref());
224 }
225 }));
226 let _ = window.request_animation_frame(g.borrow().as_ref().unwrap().as_ref().unchecked_ref());
227}
228
229fn fit_canvas(canvas: &HtmlCanvasElement, window: &Window) {
230 let dpr = window.device_pixel_ratio() as f32;
231 let w = window
232 .inner_width()
233 .ok()
234 .and_then(|v| v.as_f64())
235 .unwrap_or(1280.0) as f32;
236 let h = window
237 .inner_height()
238 .ok()
239 .and_then(|v| v.as_f64())
240 .unwrap_or(720.0) as f32;
241 canvas.set_width((w * dpr) as u32);
242 canvas.set_height((h * dpr) as u32);
243 let el: &HtmlElement = canvas.unchecked_ref();
244 let style = el.style();
245 let _ = style.set_property("width", &format!("{}px", w));
246 let _ = style.set_property("height", &format!("{}px", h));
247}
248
249fn position_tips(document: &Document, canvas: &HtmlCanvasElement, renderer: &Renderer) {
250 let clips = renderer.cardinal_positions_ndc(BUTTON_RADIUS_FACTOR);
251 let cw = canvas.client_width().max(1) as f32;
252 let ch = canvas.client_height().max(1) as f32;
253 for (i, (id, _color, _label)) in tips::ORDER.iter().enumerate() {
254 let (nx, ny) = clips[i];
255 let px = (nx + 1.0) * 0.5 * cw;
256 let py = (1.0 - (ny + 1.0) * 0.5) * ch;
257 let sel = format!("tip-{}", id);
258 if let Some(el) = document.get_element_by_id(&sel) {
259 if let Ok(el) = el.dyn_into::<HtmlElement>() {
260 let _ = el.style().set_property(
261 "transform",
262 &format!("translate({:.2}px, {:.2}px) translate(-50%, -50%)", px, py),
263 );
264 }
265 }
266 }
267}
268
269fn update_tilt_css(document: &Document, renderer: &Renderer) {
270 let (pitch, yaw) = renderer.tilt_degrees();
271 if let Some(brand) = document.get_element_by_id("brand") {
272 if let Ok(brand) = brand.dyn_into::<HtmlElement>() {
273 // CSS rotateX usa el mismo signo que nuestra pitch (mouse up tilts top toward viewer).
274 let _ = brand
275 .style()
276 .set_property("--tilt-x", &format!("{:.2}deg", pitch));
277 let _ = brand
278 .style()
279 .set_property("--tilt-y", &format!("{:.2}deg", yaw));
280 }
281 }
282}
283
284fn install_panic_hook() {
285 static SET: std::sync::Once = std::sync::Once::new();
286 SET.call_once(|| {
287 std::panic::set_hook(Box::new(|info| {
288 let msg = format!("{}", info);
289 web_sys::console::error_1(&JsValue::from_str(&msg));
290 }));
291 });
292}