⎇ 6a0781c0a811c29aa592c34c97351dc166916cc6
crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
History
Who touched what
raw
feat(cosmobiologia): esfera celeste 3D — la carta como objeto rotable
1//! `cosmobiologia-canvas` — el widget GPUI del lienzo astrológico.
2//!
3//! Modela el cielo como un lienzo de **geometría reactiva**: un estado
4//! unificado [`CanvasState`] guarda offsets de rotación, flags de
5//! visibilidad y la lista de `Layer`s a pintar. Las interacciones
6//! (drag, hotkeys, toggles) mutan el estado; el render lee la última
7//! `RenderModel` y la deriva al frame.
8//!
9//! ## Convención de rotación
10//!
11//! El Ascendente cae a las 9 del reloj (lado izquierdo). Las casas
12//! crecen contrarreloj visualmente. Para una longitud eclíptica `L` y
13//! un ascendente `asc`:
14//!
15//! ```text
16//! screen_angle_rad = π - (L - asc + view_rotation) · π/180
17//! point = (cx + r·cos(θ), cy + r·sin(θ))
18//! ```
19//!
20//! ## Interacciones (fase 4)
21//!
22//! - **Drag en el aro exterior** (jog-dial perimetral): rota la rueda
23//! visualmente mientras dura el drag y, al soltar, traduce el delta
24//! angular a minutos (1° ≈ 4 min) y emite
25//! [`CanvasEvent::TimeOffsetChanged`]. El host (la app) recomputa la
26//! carta para el instante desplazado.
27//! - **Hotkeys**: `D`/`H`/`X`/`P` togglean SignDial/Houses/Aspects/
28//! Bodies. Click sobre el wheel le da focus al widget.
29
30#![forbid(unsafe_code)]
31#![warn(rust_2018_idioms)]
32
33use std::collections::HashMap;
34use std::f32::consts::PI;
35
36use gpui::{
37 Bounds, Context, EventEmitter, FocusHandle, Focusable, Hsla, IntoElement,
38 KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement,
39 PathBuilder, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, SharedString, Styled,
40 Window, canvas, div, hsla, point, prelude::*, px,
41};
42
43use cosmobiologia_engine::{
44 Geometry, GrTrigger, Layer, LayerKind, Rectificacion, RenderModel, UranianGroup,
45 OUTER_RING_MODULES,
46};
47use cosmobiologia_model::{ChartId, ContactId, GroupId};
48use cosmobiologia_render::{compose_sphere, DrawCommand, Palette, SphereOpts, SphereView};
49use cosmobiologia_theme::{AspectKind as TAspectKind, AstroPalette, Element, Planet};
50use nahual_theme::Theme;
51
52// =====================================================================
53// Eventos
54// =====================================================================
55
56#[derive(Clone, Debug)]
57pub enum CanvasEvent {
58 /// Doble click sobre un thumbnail.
59 ChartRequested(ChartId),
60 /// Drag terminado: el offset acumulado de tiempo (en minutos)
61 /// cambió. El host debe recomputar el chart con este offset.
62 TimeOffsetChanged(i64),
63 /// El usuario togggleó una capa via hotkey — el panel debería
64 /// reflejarlo si quisiera mantenerse en sync.
65 LayerVisibilityChanged { kind: LayerKind, visible: bool },
66 /// El usuario togggleó los coord labels via hotkey C. El panel
67 /// debe sincronizar el toggle "show_coords" del NatalModule.
68 ShowCoordsChanged(bool),
69 /// El usuario pidió exportar el render actual como SVG. El shell
70 /// se encarga de escribir el archivo (la engine genera el string).
71 ExportSvgRequested,
72 /// En modo GR (direcciones primarias activas) el jog-dial scrubea
73 /// la edad en vez del tiempo. Lleva el delta de edad en años; el
74 /// host lo acumula sobre `target_age_years` y recompone en vivo.
75 GrAgeDelta(f64),
76 /// El usuario hizo clic en una barra del espectro armónico. Lleva
77 /// el orden de armónica elegido; el host fija el slider `harmonic`
78 /// del módulo natal y recompone.
79 HarmonicSelected(u32),
80}
81
82// =====================================================================
83// Estado
84// =====================================================================
85
86#[derive(Clone, Debug, Default)]
87pub enum CanvasMode {
88 #[default]
89 Empty,
90 Wheel { render: Box<RenderModel> },
91 Thumbnails {
92 scope: ThumbnailScope,
93 items: Vec<ThumbnailItem>,
94 },
95}
96
97#[derive(Clone, Debug)]
98pub enum ThumbnailScope {
99 Group(GroupId),
100 Contact(ContactId),
101}
102
103#[derive(Clone, Debug)]
104pub struct ThumbnailItem {
105 pub chart_id: ChartId,
106 pub label: SharedString,
107 pub subtitle: Option<SharedString>,
108 pub preview: Option<RenderModel>,
109}
110
111/// Estado de un drag activo del jog-dial. `last_screen_angle_deg` se
112/// actualiza en cada `MouseMoveEvent`; `accumulated_delta_deg` lleva la
113/// rotación total desde que arrancó el drag (puede pasar de ±360°).
114#[derive(Clone, Debug)]
115struct JogDragState {
116 last_screen_angle_deg: f32,
117 accumulated_delta_deg: f32,
118}
119
120/// Drag activo de pan (MMB o LMB con Space). Captura el pan inicial al
121/// hacer mousedown; el move agrega delta_pos a esos valores.
122#[derive(Clone, Debug)]
123struct PanDragState {
124 start_pos: Point<Pixels>,
125 pan_x_start: f32,
126 pan_y_start: f32,
127}
128
129/// Drag activo de rotación de la esfera 3D. El delta del cursor desde
130/// `start` se suma a `yaw`/`pitch` de partida — arrastrar = orbitar.
131#[derive(Clone, Copy, Debug)]
132struct SphereDragState {
133 start: Point<Pixels>,
134 yaw_start: f32,
135 pitch_start: f32,
136}
137
138#[derive(Clone, Debug)]
139pub struct CanvasState {
140 pub mode: CanvasMode,
141 /// Rotación visual transitoria durante un drag. Se resetea a `0` al
142 /// soltar — el render nuevo trae el `ascendant_deg` ya rotado.
143 pub view_rotation_deg: f32,
144 /// Offset acumulado en minutos. Persiste entre drags hasta que el
145 /// host lo resetee.
146 pub time_offset_minutes: i64,
147 /// Factor de zoom multiplicativo aplicado al wheel. `1.0` = tamaño
148 /// nominal. Clampeado a [VIEW_SCALE_MIN, VIEW_SCALE_MAX].
149 pub view_scale: f32,
150 /// Pan horizontal en px (positivo = desplaza el wheel a la derecha
151 /// desde el centro). Se aplica como margin shift sobre el centrado
152 /// natural del flex parent.
153 pub view_pan_x: f32,
154 /// Pan vertical en px (positivo = abajo).
155 pub view_pan_y: f32,
156 /// Por-LayerKind: `true` = visible. Default = todo visible.
157 pub layer_visibility: HashMap<LayerKind, bool>,
158 /// Indicadores de grado al lado de cada planeta y cusp de casa.
159 /// Default `true` — el usuario los espera ver para leer la
160 /// carta. Se togglean con `C` (Coords) o desde el panel.
161 pub show_coords: bool,
162 /// Planeta hovered actualmente (para tooltip). `None` cuando el
163 /// mouse no está sobre ningún cuerpo.
164 pub hover: Option<HoverInfo>,
165 /// Último resultado del rectificador automático, si se corrió uno.
166 /// El canvas dibuja su perfil como una curva en el footer; el valle
167 /// marca la hora de nacimiento que mejor explica los eventos.
168 pub rectificacion: Option<Rectificacion>,
169 /// `true` cuando la carta se muestra como esfera celeste 3D en vez
170 /// de la rueda 2D. Solo aplica al modo `Wheel`.
171 pub sphere_3d: bool,
172 /// Orientación de la esfera 3D — la muta el drag.
173 pub sphere_view: SphereView,
174 drag_jog: Option<JogDragState>,
175 drag_pan: Option<PanDragState>,
176 drag_sphere: Option<SphereDragState>,
177}
178
179/// Límites del zoom — bajo 0.5 los glyphs se vuelven ilegibles; sobre
180/// 3.0 el wheel desborda incluso pantallas grandes.
181pub const VIEW_SCALE_MIN: f32 = 0.5;
182pub const VIEW_SCALE_MAX: f32 = 3.0;
183
184/// Info del elemento bajo el cursor — usado por el render para mostrar
185/// un tooltip flotante con detalles. Cubre body glyphs, cusps de casa,
186/// y líneas de aspectos.
187#[derive(Clone, Debug)]
188pub enum HoverInfo {
189 Body {
190 module_id: String,
191 symbol: String,
192 deg: f32,
193 house: Option<u8>,
194 retrograde: bool,
195 dignity_marker: Option<String>,
196 annotation: Option<String>,
197 local_x: f32,
198 local_y: f32,
199 },
200 HouseCusp {
201 house_number: u8,
202 deg: f32,
203 local_x: f32,
204 local_y: f32,
205 },
206 /// Hover sobre una línea de aspecto. `from_body`/`to_body` y `kind`
207 /// vienen de la LineSeg; `orb_deg` también. Los coords son el
208 /// punto medio del segmento donde se muestra el tooltip.
209 Aspect {
210 module_id: String,
211 from_body: String,
212 to_body: String,
213 kind: String,
214 orb_deg: f32,
215 local_x: f32,
216 local_y: f32,
217 },
218}
219
220impl HoverInfo {
221 fn local(&self) -> (f32, f32) {
222 match self {
223 HoverInfo::Body { local_x, local_y, .. } => (*local_x, *local_y),
224 HoverInfo::HouseCusp { local_x, local_y, .. } => (*local_x, *local_y),
225 HoverInfo::Aspect { local_x, local_y, .. } => (*local_x, *local_y),
226 }
227 }
228
229 fn key(&self) -> String {
230 match self {
231 HoverInfo::Body {
232 module_id, symbol, ..
233 } => format!("body:{}:{}", module_id, symbol),
234 HoverInfo::HouseCusp { house_number, .. } => format!("cusp:{}", house_number),
235 HoverInfo::Aspect {
236 module_id,
237 from_body,
238 to_body,
239 kind,
240 ..
241 } => format!("aspect:{}:{}-{}-{}", module_id, from_body, kind, to_body),
242 }
243 }
244}
245
246impl Default for CanvasState {
247 fn default() -> Self {
248 Self {
249 mode: CanvasMode::default(),
250 view_rotation_deg: 0.0,
251 time_offset_minutes: 0,
252 view_scale: 1.0,
253 view_pan_x: 0.0,
254 view_pan_y: 0.0,
255 layer_visibility: HashMap::new(),
256 show_coords: true,
257 hover: None,
258 rectificacion: None,
259 sphere_3d: false,
260 sphere_view: SphereView::default(),
261 drag_jog: None,
262 drag_pan: None,
263 drag_sphere: None,
264 }
265 }
266}
267
268impl CanvasState {
269 pub fn is_layer_visible(&self, kind: LayerKind) -> bool {
270 self.layer_visibility.get(&kind).copied().unwrap_or(true)
271 }
272
273 /// `true` cuando hay un overlay de direcciones primarias activo.
274 /// En ese modo el jog-dial scrubea la edad GR en vez del tiempo.
275 fn gr_active(&self) -> bool {
276 matches!(
277 &self.mode,
278 CanvasMode::Wheel { render }
279 if render.layers.iter().any(|l| l.module_id == "pd_direct")
280 )
281 }
282}
283
284/// Sensibilidad del scrubbing GR: años de edad por grado de jog. A
285/// 0.1, una vuelta completa del dial barre 36 años — fino para
286/// explorar contactos sin perder rango.
287const GR_AGE_PER_DEG: f32 = 0.1;
288
289// =====================================================================
290// Widget
291// =====================================================================
292
293pub struct AstrologyCanvas {
294 state: CanvasState,
295 focus_handle: FocusHandle,
296}
297
298impl EventEmitter<CanvasEvent> for AstrologyCanvas {}
299
300impl Focusable for AstrologyCanvas {
301 fn focus_handle(&self, _: &gpui::App) -> FocusHandle {
302 self.focus_handle.clone()
303 }
304}
305
306impl AstrologyCanvas {
307 pub fn new(cx: &mut Context<'_, Self>) -> Self {
308 cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
309 Self {
310 state: CanvasState::default(),
311 focus_handle: cx.focus_handle(),
312 }
313 }
314
315 pub fn state(&self) -> &CanvasState {
316 &self.state
317 }
318
319 pub fn set_mode(&mut self, mode: CanvasMode, cx: &mut Context<'_, Self>) {
320 self.state.mode = mode;
321 cx.notify();
322 }
323
324 pub fn set_layer_visible(&mut self, kind: LayerKind, visible: bool, cx: &mut Context<'_, Self>) {
325 self.state.layer_visibility.insert(kind, visible);
326 cx.notify();
327 }
328
329 pub fn toggle_layer(&mut self, kind: LayerKind, cx: &mut Context<'_, Self>) {
330 let current = self.state.is_layer_visible(kind);
331 self.set_layer_visible(kind, !current, cx);
332 cx.emit(CanvasEvent::LayerVisibilityChanged {
333 kind,
334 visible: !current,
335 });
336 }
337
338 pub fn reset_time_offset(&mut self, cx: &mut Context<'_, Self>) {
339 if self.state.time_offset_minutes != 0 || self.state.view_rotation_deg != 0.0 {
340 self.state.time_offset_minutes = 0;
341 self.state.view_rotation_deg = 0.0;
342 cx.emit(CanvasEvent::TimeOffsetChanged(0));
343 cx.notify();
344 }
345 }
346
347 pub fn set_view_rotation(&mut self, deg: f32, cx: &mut Context<'_, Self>) {
348 self.state.view_rotation_deg = deg.rem_euclid(360.0);
349 cx.notify();
350 }
351
352 pub fn toggle_coords(&mut self, cx: &mut Context<'_, Self>) {
353 let new_val = !self.state.show_coords;
354 self.set_show_coords(new_val, cx);
355 cx.emit(CanvasEvent::ShowCoordsChanged(new_val));
356 }
357
358 /// Setter idempotente — el shell lo usa para reflejar cambios del
359 /// panel sin disparar el `ShowCoordsChanged` (que iría en el otro
360 /// sentido y crearía un loop).
361 pub fn set_show_coords(&mut self, value: bool, cx: &mut Context<'_, Self>) {
362 if self.state.show_coords != value {
363 self.state.show_coords = value;
364 cx.notify();
365 }
366 }
367
368 /// Publica el resultado de un barrido de rectificación: el canvas
369 /// dibuja su perfil como una curva en el footer. `None` lo borra.
370 pub fn set_rectificacion(
371 &mut self,
372 rectificacion: Option<Rectificacion>,
373 cx: &mut Context<'_, Self>,
374 ) {
375 self.state.rectificacion = rectificacion;
376 cx.notify();
377 }
378
379 /// Alterna entre la rueda 2D y la esfera celeste 3D. Solo tiene
380 /// efecto visible cuando hay una carta cargada (`CanvasMode::Wheel`).
381 pub fn toggle_sphere(&mut self, cx: &mut Context<'_, Self>) {
382 self.state.sphere_3d = !self.state.sphere_3d;
383 cx.notify();
384 }
385
386 /// Resetea zoom y pan a sus defaults (1.0 y 0,0). No toca rotation
387 /// ni time offset — esos son ortogonales y tienen su propio reset.
388 pub fn reset_view(&mut self, cx: &mut Context<'_, Self>) {
389 if self.state.view_scale != 1.0
390 || self.state.view_pan_x != 0.0
391 || self.state.view_pan_y != 0.0
392 {
393 self.state.view_scale = 1.0;
394 self.state.view_pan_x = 0.0;
395 self.state.view_pan_y = 0.0;
396 cx.notify();
397 }
398 }
399
400 /// Zoom multiplicativo. El nuevo scale es `current * factor`, clamp
401 /// al rango permitido. El zoom es centrado (no rastrea el cursor) —
402 /// para mover el foco después del zoom, el usuario paneja con MMB.
403 fn zoom_by(&mut self, factor: f32, cx: &mut Context<'_, Self>) {
404 let new_scale =
405 (self.state.view_scale * factor).clamp(VIEW_SCALE_MIN, VIEW_SCALE_MAX);
406 if (new_scale - self.state.view_scale).abs() < 1e-4 {
407 return;
408 }
409 // Mantener el centro del wheel anclado al centro de pantalla:
410 // como el pan está en coords de la pantalla y el zoom es desde
411 // el centro del wheel, el pan se escala proporcional al ratio.
412 let ratio = new_scale / self.state.view_scale;
413 self.state.view_pan_x *= ratio;
414 self.state.view_pan_y *= ratio;
415 self.state.view_scale = new_scale;
416 cx.notify();
417 }
418
419 #[allow(dead_code)]
420 fn pan_by(&mut self, dx: f32, dy: f32, cx: &mut Context<'_, Self>) {
421 if dx == 0.0 && dy == 0.0 {
422 return;
423 }
424 self.state.view_pan_x += dx;
425 self.state.view_pan_y += dy;
426 cx.notify();
427 }
428
429 // ----- Internos: handlers de jog-dial -----
430
431 /// Despacha el LMB down entre jog-dial y pan. El jog-dial es un
432 /// control "fuerte" (mueve el tiempo de la carta), así que se
433 /// activa SOLO con modifier Ctrl/Cmd + click sobre el anillo de
434 /// signos — sin modifier es siempre pan, incluso sobre el anillo,
435 /// para que no haya rotaciones accidentales al manipular la
436 /// rueda.
437 fn on_primary_down(
438 &mut self,
439 position: Point<Pixels>,
440 modifiers: gpui::Modifiers,
441 bounds: Bounds<Pixels>,
442 cx: &mut Context<'_, Self>,
443 ) {
444 // Sin modifier: pan, sin importar dónde caiga el click.
445 if !(modifiers.control || modifiers.platform) {
446 self.on_pan_down(position, cx);
447 return;
448 }
449 let (cx_px, cy_px) = bounds_center(bounds);
450 let mx: f32 = position.x.into();
451 let my: f32 = position.y.into();
452 let dx = mx - cx_px;
453 let dy = my - cy_px;
454 let dist = (dx * dx + dy * dy).sqrt();
455 let r_outer = effective_r_outer(bounds);
456 let radii = Radii::from_outer(r_outer);
457 let on_dial = dist >= radii.sign_inner * 0.95 && dist <= radii.sign_outer * 1.10;
458 if on_dial {
459 let angle = dy.atan2(dx).to_degrees();
460 self.state.drag_jog = Some(JogDragState {
461 last_screen_angle_deg: angle,
462 accumulated_delta_deg: 0.0,
463 });
464 } else {
465 // Ctrl+click fuera del anillo: pan también — el modifier
466 // habilita el jog-dial pero no impide la navegación.
467 self.on_pan_down(position, cx);
468 }
469 }
470
471 fn on_jog_move(
472 &mut self,
473 position: Point<Pixels>,
474 bounds: Bounds<Pixels>,
475 cx: &mut Context<'_, Self>,
476 ) {
477 let gr = self.state.gr_active();
478 let Some(jog) = self.state.drag_jog.as_mut() else {
479 return;
480 };
481 let (cx_px, cy_px) = bounds_center(bounds);
482 let mx: f32 = position.x.into();
483 let my: f32 = position.y.into();
484 let dx = mx - cx_px;
485 let dy = my - cy_px;
486 let angle = dy.atan2(dx).to_degrees();
487 let mut delta = angle - jog.last_screen_angle_deg;
488 // Normalizar a (-180, 180] para cruzar el wrap sin saltar.
489 if delta > 180.0 {
490 delta -= 360.0;
491 } else if delta < -180.0 {
492 delta += 360.0;
493 }
494 jog.accumulated_delta_deg += delta;
495 jog.last_screen_angle_deg = angle;
496 let accumulated = jog.accumulated_delta_deg;
497 if gr {
498 // Modo GR: el jog scrubea la edad. No rota el wheel — el
499 // feedback es el movimiento de los glifos dirigidos cuando
500 // el shell recompone con la edad nueva.
501 cx.emit(CanvasEvent::GrAgeDelta((-delta * GR_AGE_PER_DEG) as f64));
502 } else {
503 // Reflejo visual durante el drag (sin recomputar).
504 self.state.view_rotation_deg = accumulated;
505 cx.notify();
506 }
507 }
508
509 /// Hit-test sobre body glyphs + house cusps. Para bodies: distancia
510 /// al centro del glyph dentro de threshold. Para cusps: el mouse
511 /// debe estar cerca del ring de casas Y angularmente cerca del
512 /// cusp (proximidad a la línea radial).
513 fn on_hover_check(
514 &mut self,
515 position: Point<Pixels>,
516 bounds: Bounds<Pixels>,
517 cx: &mut Context<'_, Self>,
518 ) {
519 let CanvasMode::Wheel { render } = &self.state.mode else {
520 if self.state.hover.take().is_some() {
521 cx.notify();
522 }
523 return;
524 };
525 let (cx_px, cy_px) = bounds_center(bounds);
526 let mx: f32 = position.x.into();
527 let my: f32 = position.y.into();
528 let ox: f32 = bounds.origin.x.into();
529 let oy: f32 = bounds.origin.y.into();
530 let r_outer = effective_r_outer(bounds);
531 let radii = Radii::from_outer(r_outer);
532 let asc = render.ascendant_deg;
533 let rot = self.state.view_rotation_deg;
534 let body_threshold = 14.0_f32;
535
536 let mut best: Option<(f32, HoverInfo)> = None;
537
538 // 1) Body glyphs (incluye natal, overlays, midpoints).
539 //
540 // Importante: el hit-test debe usar `display_deg` (post-spread)
541 // y no `g.deg` (raw) — el spread mueve los discos para evitar
542 // solapes y si el hover sigue al raw, el usuario tendría que
543 // apuntar a una zona vacía para activarlo. Calculamos los
544 // displays con la misma función que render_wheel.
545 let view_scale = self.state.view_scale;
546 for layer in &render.layers {
547 let ring = match layer.kind {
548 LayerKind::Bodies => radii.body_ring(&layer.module_id),
549 LayerKind::Midpoints => radii.midpoints,
550 LayerKind::Outer if OUTER_RING_MODULES.contains(&layer.module_id.as_str()) => {
551 radii.transits
552 }
553 _ => continue,
554 };
555 let disk_base = body_disk_base(&layer.module_id, layer.kind, view_scale);
556 let raw_degs: Vec<f32> = layer.glyphs.iter().map(|g| g.deg).collect();
557 let disk_angular = (disk_base / (std::f32::consts::TAU * ring)) * 360.0;
558 let (display_degs, _) =
559 spread_angles(&raw_degs, disk_angular, disk_angular);
560 for (i, g) in layer.glyphs.iter().enumerate() {
561 let (gx, gy) = polar_to_screen(display_degs[i], asc, rot, ring);
562 let dx = mx - (cx_px + gx);
563 let dy = my - (cy_px + gy);
564 let dist = (dx * dx + dy * dy).sqrt();
565 if dist > body_threshold {
566 continue;
567 }
568 if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
569 best = Some((
570 dist,
571 HoverInfo::Body {
572 module_id: layer.module_id.clone(),
573 symbol: g.symbol.clone(),
574 deg: g.deg,
575 house: g.house,
576 retrograde: g.retrograde,
577 dignity_marker: g.dignity_marker.clone(),
578 annotation: g.annotation.clone(),
579 local_x: cx_px + gx - ox,
580 local_y: cy_px + gy - oy,
581 },
582 ));
583 }
584 }
585 }
586
587 // 2) Aspect lines (segundo: las líneas son más "frágiles" que
588 // los planetas; si un body matcheó arriba ya tomó precedencia).
589 // Computa distancia punto-segmento del mouse al line.
590 if best.is_none() {
591 for layer in &render.layers {
592 if !matches!(layer.kind, LayerKind::Aspects) {
593 continue;
594 }
595 let (r_from, r_to) = radii.aspect_endpoints(&layer.module_id);
596 if let Geometry::Lines(segs) = &layer.geometry {
597 for seg in segs {
598 if seg.from_body.is_empty() || seg.to_body.is_empty() {
599 continue;
600 }
601 let (ax, ay) = polar_to_screen(seg.from_deg, asc, rot, r_from);
602 let (bx, by) = polar_to_screen(seg.to_deg, asc, rot, r_to);
603 let px_a = cx_px + ax;
604 let py_a = cy_px + ay;
605 let px_b = cx_px + bx;
606 let py_b = cy_px + by;
607 let dist = dist_point_segment(mx, my, px_a, py_a, px_b, py_b);
608 if dist > 4.0 {
609 continue;
610 }
611 if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
612 let mid_x = (px_a + px_b) / 2.0;
613 let mid_y = (py_a + py_b) / 2.0;
614 best = Some((
615 dist,
616 HoverInfo::Aspect {
617 module_id: layer.module_id.clone(),
618 from_body: seg.from_body.clone(),
619 to_body: seg.to_body.clone(),
620 kind: seg.kind.clone(),
621 orb_deg: seg.orb_deg,
622 local_x: mid_x - ox,
623 local_y: mid_y - oy,
624 },
625 ));
626 }
627 }
628 }
629 }
630 }
631
632 // 3) House cusps — solo si el mouse está cerca del anillo de
633 // casas (radio entre houses_inner y houses_outer + margen) y
634 // ningún body ganó. Las cusps son líneas radiales — la
635 // distancia angular al cusp más cercano determina el hit.
636 if best.is_none() {
637 let dx = mx - cx_px;
638 let dy = my - cy_px;
639 let mouse_r = (dx * dx + dy * dy).sqrt();
640 let r_in = radii.houses_inner - 6.0;
641 let r_out = radii.houses_outer + 6.0;
642 if mouse_r >= r_in && mouse_r <= r_out {
643 // Calcular la longitud zodiacal que corresponde a este
644 // ángulo de pantalla (inversa de polar_to_screen).
645 let screen_angle_deg = dy.atan2(dx).to_degrees(); // (-180, 180]
646 // polar_to_screen: deg = 180 - (lon - asc + rot)
647 // → lon = asc + 180 - screen_angle_deg - rot
648 let lon = ((asc + 180.0 - screen_angle_deg - rot) as f32).rem_euclid(360.0);
649 // Buscar cusp más cercano (con wraparound).
650 for layer in &render.layers {
651 if matches!(layer.kind, LayerKind::Houses) {
652 if let Geometry::Ring { cusps_deg } = &layer.geometry {
653 for (i, c) in cusps_deg.iter().enumerate() {
654 let mut diff = (lon - c).abs();
655 if diff > 180.0 {
656 diff = 360.0 - diff;
657 }
658 if diff < 2.5 {
659 // Mouse cerca de ESTE cusp.
660 let (gx, gy) = polar_to_screen(
661 *c,
662 asc,
663 rot,
664 (radii.houses_inner + radii.houses_outer) / 2.0,
665 );
666 best = Some((
667 diff,
668 HoverInfo::HouseCusp {
669 house_number: (i as u8) + 1,
670 deg: *c,
671 local_x: cx_px + gx - ox,
672 local_y: cy_px + gy - oy,
673 },
674 ));
675 break;
676 }
677 }
678 }
679 }
680 }
681 }
682 }
683
684 let new_hover = best.map(|(_, h)| h);
685 let changed = match (&self.state.hover, &new_hover) {
686 (Some(a), Some(b)) => a.key() != b.key(),
687 (None, None) => false,
688 _ => true,
689 };
690 if changed {
691 self.state.hover = new_hover;
692 cx.notify();
693 }
694 }
695
696 // ----- Internos: pan drag (MMB) -----
697
698 fn on_pan_down(&mut self, position: Point<Pixels>, _cx: &mut Context<'_, Self>) {
699 self.state.drag_pan = Some(PanDragState {
700 start_pos: position,
701 pan_x_start: self.state.view_pan_x,
702 pan_y_start: self.state.view_pan_y,
703 });
704 }
705
706 fn on_pan_move(&mut self, position: Point<Pixels>, cx: &mut Context<'_, Self>) {
707 let Some(pan) = self.state.drag_pan.as_ref() else {
708 return;
709 };
710 let dx: f32 = (position.x - pan.start_pos.x).into();
711 let dy: f32 = (position.y - pan.start_pos.y).into();
712 self.state.view_pan_x = pan.pan_x_start + dx;
713 self.state.view_pan_y = pan.pan_y_start + dy;
714 cx.notify();
715 }
716
717 fn on_pan_up(&mut self, cx: &mut Context<'_, Self>) {
718 if self.state.drag_pan.take().is_some() {
719 cx.notify();
720 }
721 }
722
723 // ----- Internos: rotación de la esfera 3D -----
724
725 fn on_sphere_down(&mut self, position: Point<Pixels>) {
726 self.state.drag_sphere = Some(SphereDragState {
727 start: position,
728 yaw_start: self.state.sphere_view.yaw_deg,
729 pitch_start: self.state.sphere_view.pitch_deg,
730 });
731 }
732
733 fn on_sphere_move(&mut self, position: Point<Pixels>, cx: &mut Context<'_, Self>) {
734 let Some(drag) = self.state.drag_sphere else {
735 return;
736 };
737 let dx: f32 = (position.x - drag.start.x).into();
738 let dy: f32 = (position.y - drag.start.y).into();
739 // 0.4°/px da una rotación cómoda. El pitch se clampea para no
740 // dar vuelta la esfera de adentro hacia afuera.
741 self.state.sphere_view.yaw_deg = (drag.yaw_start + dx * 0.4).rem_euclid(360.0);
742 self.state.sphere_view.pitch_deg = (drag.pitch_start + dy * 0.4).clamp(-89.0, 89.0);
743 cx.notify();
744 }
745
746 fn on_sphere_up(&mut self, cx: &mut Context<'_, Self>) {
747 if self.state.drag_sphere.take().is_some() {
748 cx.notify();
749 }
750 }
751
752 fn on_scroll(
753 &mut self,
754 event: &ScrollWheelEvent,
755 _w: &mut Window,
756 cx: &mut Context<'_, Self>,
757 ) {
758 let (_dx_px, dy_px) = match event.delta {
759 ScrollDelta::Pixels(p) => (f32::from(p.x), f32::from(p.y)),
760 ScrollDelta::Lines(p) => (p.x * 16.0, p.y * 16.0),
761 };
762 // Wheel = zoom puro, sin modifier. Pan se hace con drag (LMB
763 // fuera del anillo, o MMB). 100px de scroll ≈ ±20% zoom.
764 let factor = (dy_px * 0.002).exp();
765 self.zoom_by(factor, cx);
766 }
767
768 fn on_jog_up(&mut self, cx: &mut Context<'_, Self>) {
769 let gr = self.state.gr_active();
770 let Some(jog) = self.state.drag_jog.take() else {
771 return;
772 };
773 if gr {
774 // El scrub GR se aplicó en vivo durante el drag; al soltar
775 // no queda nada que confirmar.
776 return;
777 }
778 // 1° de arco ≈ 4 minutos de tiempo sideral (15°/hora).
779 // CW visual (delta negativa en nuestra convención) → tiempo
780 // hacia adelante.
781 let delta_minutes = (-jog.accumulated_delta_deg * 4.0) as i64;
782 if delta_minutes != 0 {
783 self.state.time_offset_minutes =
784 self.state.time_offset_minutes.saturating_add(delta_minutes);
785 // Snap visual: el shell recomputa con el nuevo offset y el
786 // render trae el ascendant rotado.
787 self.state.view_rotation_deg = 0.0;
788 cx.emit(CanvasEvent::TimeOffsetChanged(self.state.time_offset_minutes));
789 cx.notify();
790 } else {
791 self.state.view_rotation_deg = 0.0;
792 cx.notify();
793 }
794 }
795
796 fn on_key_down(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<'_, Self>) {
797 let key = event.keystroke.key.as_str();
798 let kind = match key {
799 "d" | "D" => LayerKind::SignDial,
800 "h" | "H" => LayerKind::Houses,
801 "x" | "X" => LayerKind::Aspects,
802 "p" | "P" => LayerKind::Bodies,
803 "t" | "T" => LayerKind::Outer,
804 "r" | "R" => {
805 self.reset_time_offset(cx);
806 return;
807 }
808 "0" => {
809 self.reset_view(cx);
810 return;
811 }
812 "c" | "C" => {
813 self.toggle_coords(cx);
814 return;
815 }
816 "s" | "S" => {
817 cx.emit(CanvasEvent::ExportSvgRequested);
818 return;
819 }
820 "v" | "V" => {
821 self.toggle_sphere(cx);
822 return;
823 }
824 _ => return,
825 };
826 self.toggle_layer(kind, cx);
827 }
828}
829
830// =====================================================================
831// Geometría de pantalla
832// =====================================================================
833
834const WHEEL_SIZE: f32 = 580.0;
835const WHEEL_MARGIN: f32 = 28.0;
836
837/// Pinta un gradiente radial de profundidad sobre el background del
838/// canvas — efecto vignette. Se aproxima al gradient radial (no
839/// soportado nativamente por gpui en `.bg()`) pintando ~28 anillos
840/// concéntricos del centro hacia afuera, con alpha creciente hacia el
841/// borde. El centro queda claro y los extremos se oscurecen, dando
842/// sensación de "el wheel emerge desde la profundidad".
843///
844/// Solo activo en themes dark — sobre papel (light / print) el panel
845/// queda plano: una viñeta sobre fondo claro tiñe el papel y rompe
846/// la metáfora "impresión".
847fn paint_depth_field(bounds: Bounds<Pixels>, window: &mut Window, theme: &Theme) {
848 if !theme.is_dark {
849 return;
850 }
851 let ox: f32 = bounds.origin.x.into();
852 let oy: f32 = bounds.origin.y.into();
853 let bw: f32 = bounds.size.width.into();
854 let bh: f32 = bounds.size.height.into();
855 if bw <= 0.0 || bh <= 0.0 {
856 return;
857 }
858 let cx = ox + bw / 2.0;
859 let cy = oy + bh / 2.0;
860 // El gradient se extiende hasta la diagonal del rectángulo para
861 // que las esquinas estén dentro del último anillo (sin "halo"
862 // visible donde se corta).
863 let r_max = ((bw * bw + bh * bh).sqrt()) / 2.0 * 1.05;
864 let steps = 28;
865 // Color: casi-negro con tinte ligero del panel (el panel es dark).
866 let deep = hsla(230.0 / 360.0, 0.30, 0.04, 1.0);
867 // Stroke de cada anillo: el ancho cubre 1/steps del radio para
868 // que no queden gaps entre anillos.
869 let stroke_w = (r_max / steps as f32) * 1.15;
870 for i in 0..steps {
871 let t = i as f32 / (steps - 1) as f32;
872 let r = r_max * t;
873 // Curva ease-in: alpha crece de 0 (centro) a ~0.55 (borde),
874 // con la mayor parte del cambio en la mitad exterior. t² da
875 // ese "fondo profundo en el perímetro sin opacar el centro".
876 let alpha = 0.55 * (t * t);
877 stroke_circle(window, cx, cy, r, stroke_w, with_alpha(deep, alpha));
878 }
879}
880
881fn bounds_center(bounds: Bounds<Pixels>) -> (f32, f32) {
882 let ox: f32 = bounds.origin.x.into();
883 let oy: f32 = bounds.origin.y.into();
884 let bw: f32 = bounds.size.width.into();
885 let bh: f32 = bounds.size.height.into();
886 (ox + bw / 2.0, oy + bh / 2.0)
887}
888
889/// Radio del anillo exterior derivado del width *actual* del canvas
890/// (que ya está escalado por view_scale). Mantiene la proporción del
891/// margen contra `WHEEL_SIZE` original, así el hit-test del jog-dial y
892/// las cusps se adapta automáticamente al zoom sin que cada caller
893/// recalcule `view_scale`.
894fn effective_r_outer(bounds: Bounds<Pixels>) -> f32 {
895 let bw: f32 = bounds.size.width.into();
896 let scale = if WHEEL_SIZE > 0.0 { bw / WHEEL_SIZE } else { 1.0 };
897 (bw - WHEEL_MARGIN * scale * 2.0) / 2.0
898}
899
900// =====================================================================
901// Render
902// =====================================================================
903
904impl Render for AstrologyCanvas {
905 fn render(&mut self, _w: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
906 let theme = Theme::global(cx).clone();
907 let palette = AstroPalette::for_theme(&theme);
908 let entity = cx.entity();
909 let focus = self.focus_handle.clone();
910
911 let body = match &self.state.mode {
912 CanvasMode::Empty => render_empty(&theme),
913 CanvasMode::Wheel { render } if self.state.sphere_3d => render_sphere(
914 &theme,
915 render,
916 self.state.sphere_view,
917 self.state.view_scale,
918 self.state.view_pan_x,
919 self.state.view_pan_y,
920 entity,
921 ),
922 CanvasMode::Wheel { render } => render_wheel(
923 &theme,
924 &palette,
925 render,
926 self.state.view_rotation_deg,
927 self.state.time_offset_minutes,
928 self.state.view_scale,
929 self.state.view_pan_x,
930 self.state.view_pan_y,
931 &self.state.layer_visibility,
932 self.state.show_coords,
933 self.state.hover.as_ref(),
934 self.state.rectificacion.as_ref(),
935 entity,
936 ),
937 CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items),
938 };
939
940 // Botón flotante 2D ⇄ 3D — visible solo con una carta cargada.
941 // Muestra el modo al que se cambiará, no el activo.
942 let sphere_toggle = matches!(self.state.mode, CanvasMode::Wheel { .. }).then(|| {
943 let label = if self.state.sphere_3d {
944 "Rueda 2D"
945 } else {
946 "Esfera 3D"
947 };
948 div()
949 .absolute()
950 .top(px(12.0))
951 .right(px(12.0))
952 .px(px(11.0))
953 .py(px(5.0))
954 .rounded(px(6.0))
955 .bg(theme.bg_panel_alt.clone())
956 .border_1()
957 .border_color(theme.border)
958 .text_size(px(11.0))
959 .text_color(theme.fg_text)
960 .cursor_pointer()
961 .child(label)
962 .on_mouse_down(
963 MouseButton::Left,
964 cx.listener(|this, _, _w, cx| this.toggle_sphere(cx)),
965 )
966 });
967
968 // Depth field: capa absoluta detrás del body, ocupa todo el
969 // canvas. Vignette radial — el centro queda claro y los
970 // bordes se oscurecen, dando profundidad sin "ruido" de
971 // puntos. Solo en themes dark (en papel rompería la
972 // metáfora).
973 let theme_for_depth = theme.clone();
974 let depth_field = canvas(
975 |_b, _w, _cx| (),
976 move |bounds, _, window, _| paint_depth_field(bounds, window, &theme_for_depth),
977 )
978 .absolute()
979 .size_full();
980
981 div()
982 .id("astrology-canvas-root")
983 .track_focus(&focus)
984 .key_context("AstrologyCanvas")
985 .on_key_down(cx.listener(Self::on_key_down))
986 .on_mouse_down(
987 MouseButton::Left,
988 cx.listener(|this, _, w, _cx| {
989 w.focus(&this.focus_handle);
990 }),
991 )
992 .on_scroll_wheel(cx.listener(Self::on_scroll))
993 .size_full()
994 .bg(theme.bg_panel.clone())
995 .relative()
996 .overflow_hidden()
997 .child(depth_field)
998 .child(
999 div()
1000 .size_full()
1001 .flex()
1002 .flex_col()
1003 .items_center()
1004 .justify_center()
1005 .child(body),
1006 )
1007 .children(sphere_toggle)
1008 }
1009}
1010
1011// =====================================================================
1012// Modos: empty / thumbnails / wheel
1013// =====================================================================
1014
1015fn render_empty(theme: &Theme) -> gpui::Div {
1016 div()
1017 .flex()
1018 .flex_col()
1019 .items_center()
1020 .justify_center()
1021 .gap(px(12.0))
1022 .child(
1023 div()
1024 .text_size(px(13.0))
1025 .text_color(theme.fg_muted)
1026 .child("Tahuantinsuyu"),
1027 )
1028 .child(
1029 div()
1030 .text_size(px(11.0))
1031 .text_color(theme.fg_disabled)
1032 .child("Seleccioná una carta en el árbol para empezar."),
1033 )
1034}
1035
1036fn render_thumbnails(theme: &Theme, items: &[ThumbnailItem]) -> gpui::Div {
1037 if items.is_empty() {
1038 return div()
1039 .text_size(px(12.0))
1040 .text_color(theme.fg_muted)
1041 .child("Sin cartas en este grupo todavía.");
1042 }
1043 let mut row = div().flex().flex_row().flex_wrap().gap(px(12.0));
1044 for it in items {
1045 row = row.child(
1046 div()
1047 .w(px(140.0))
1048 .h(px(160.0))
1049 .rounded(px(6.0))
1050 .border_1()
1051 .border_color(theme.border)
1052 .bg(theme.bg_panel_alt.clone())
1053 .flex()
1054 .flex_col()
1055 .items_center()
1056 .justify_end()
1057 .pb(px(8.0))
1058 .child(
1059 div()
1060 .text_size(px(11.0))
1061 .text_color(theme.fg_text)
1062 .child(it.label.clone()),
1063 ),
1064 );
1065 }
1066 row
1067}
1068
1069// =====================================================================
1070// Esfera celeste 3D
1071// =====================================================================
1072
1073/// Render del modo esfera 3D. Compone la escena agnóstica con
1074/// `compose_sphere` (en `cosmobiologia-render`) y traduce sus
1075/// `DrawCommand`s a primitivas GPUI: las líneas y discos se pintan en
1076/// el `canvas`; los glifos van como hijos DOM sobre las coordenadas ya
1077/// proyectadas. El drag rota la esfera.
1078#[allow(clippy::too_many_arguments)]
1079fn render_sphere(
1080 theme: &Theme,
1081 render: &RenderModel,
1082 view: SphereView,
1083 view_scale: f32,
1084 view_pan_x: f32,
1085 view_pan_y: f32,
1086 entity: gpui::Entity<AstrologyCanvas>,
1087) -> gpui::Div {
1088 let sphere_size = WHEEL_SIZE * view_scale;
1089 let opts = SphereOpts {
1090 size: sphere_size,
1091 palette: if theme.is_dark {
1092 Palette::dark()
1093 } else {
1094 Palette::light()
1095 },
1096 ..Default::default()
1097 };
1098 let commands = compose_sphere(render, &view, &opts);
1099 // Las líneas y círculos se pintan en el canvas; el texto va al DOM.
1100 let paint_cmds: Vec<DrawCommand> = commands
1101 .iter()
1102 .filter(|c| !matches!(c, DrawCommand::Text { .. }))
1103 .cloned()
1104 .collect();
1105
1106 let entity_for_canvas = entity.clone();
1107 let canvas_element = canvas(
1108 move |_b: Bounds<Pixels>, _w, _cx| (),
1109 move |bounds: Bounds<Pixels>, _, window, _| {
1110 let ox: f32 = bounds.origin.x.into();
1111 let oy: f32 = bounds.origin.y.into();
1112 for cmd in &paint_cmds {
1113 match cmd {
1114 DrawCommand::Line { x1, y1, x2, y2, color, width, dash } => {
1115 paint_segment(
1116 window,
1117 ox + *x1,
1118 oy + *y1,
1119 ox + *x2,
1120 oy + *y2,
1121 rgba_to_hsla(*color),
1122 *dash,
1123 *width,
1124 );
1125 }
1126 DrawCommand::Circle { cx, cy, r, stroke, fill, stroke_w } => {
1127 if let Some(f) = fill {
1128 fill_circle(window, ox + *cx, oy + *cy, *r, rgba_to_hsla(*f));
1129 }
1130 if let Some(s) = stroke {
1131 stroke_circle(
1132 window,
1133 ox + *cx,
1134 oy + *cy,
1135 *r,
1136 *stroke_w,
1137 rgba_to_hsla(*s),
1138 );
1139 }
1140 }
1141 DrawCommand::Text { .. } => {}
1142 }
1143 }
1144
1145 // Drag para orbitar la esfera.
1146 let ent = entity_for_canvas.clone();
1147 window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| {
1148 if ev.button == MouseButton::Left && bounds.contains(&ev.position) {
1149 ent.update(cx, |this, _cx| this.on_sphere_down(ev.position));
1150 }
1151 });
1152 let ent = entity_for_canvas.clone();
1153 window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
1154 if ev.dragging() {
1155 ent.update(cx, |this, cx| this.on_sphere_move(ev.position, cx));
1156 }
1157 });
1158 let ent = entity_for_canvas.clone();
1159 window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
1160 ent.update(cx, |this, cx| this.on_sphere_up(cx));
1161 });
1162 },
1163 )
1164 .absolute()
1165 .w(px(sphere_size))
1166 .h(px(sphere_size));
1167
1168 let mut sphere = div()
1169 .relative()
1170 .w(px(sphere_size))
1171 .h(px(sphere_size))
1172 .ml(px(view_pan_x))
1173 .mt(px(view_pan_y))
1174 .child(canvas_element);
1175
1176 // Glifos (signos, ángulos, cuerpos) como hijos DOM, ubicados sobre
1177 // las coordenadas que ya proyectó `compose_sphere`.
1178 for cmd in &commands {
1179 if let DrawCommand::Text { x, y, content, color, size, .. } = cmd {
1180 sphere = sphere.child(centered_glyph(
1181 *x,
1182 *y,
1183 size * 1.9,
1184 *size,
1185 content.clone().into(),
1186 rgba_to_hsla(*color),
1187 ));
1188 }
1189 }
1190
1191 // Pista de interacción al pie.
1192 sphere.child(
1193 div()
1194 .absolute()
1195 .bottom(px(6.0))
1196 .left(px(0.0))
1197 .w(px(sphere_size))
1198 .flex()
1199 .justify_center()
1200 .text_size(px(10.0))
1201 .text_color(theme.fg_disabled)
1202 .child("Arrastrá para rotar la esfera"),
1203 )
1204}
1205
1206/// Convierte un `Rgba` agnóstico (`[0,1]^4`) al `Hsla` de gpui.
1207fn rgba_to_hsla(c: cosmobiologia_render::Rgba) -> Hsla {
1208 let (r, g, b) = (c.r, c.g, c.b);
1209 let max = r.max(g).max(b);
1210 let min = r.min(g).min(b);
1211 let l = (max + min) / 2.0;
1212 let d = max - min;
1213 if d < 1e-6 {
1214 return hsla(0.0, 0.0, l.clamp(0.0, 1.0), c.a.clamp(0.0, 1.0));
1215 }
1216 let s = d / (1.0 - (2.0 * l - 1.0).abs());
1217 let h = (if max == r {
1218 ((g - b) / d).rem_euclid(6.0)
1219 } else if max == g {
1220 (b - r) / d + 2.0
1221 } else {
1222 (r - g) / d + 4.0
1223 }) * 60.0;
1224 hsla(
1225 h.rem_euclid(360.0) / 360.0,
1226 s.clamp(0.0, 1.0),
1227 l.clamp(0.0, 1.0),
1228 c.a.clamp(0.0, 1.0),
1229 )
1230}
1231
1232// =====================================================================
1233// Wheel
1234// =====================================================================
1235
1236#[allow(clippy::too_many_arguments)]
1237fn render_wheel(
1238 theme: &Theme,
1239 palette: &AstroPalette,
1240 render: &RenderModel,
1241 view_rotation_deg: f32,
1242 time_offset_minutes: i64,
1243 view_scale: f32,
1244 view_pan_x: f32,
1245 view_pan_y: f32,
1246 layer_visibility: &HashMap<LayerKind, bool>,
1247 show_coords: bool,
1248 hover: Option<&HoverInfo>,
1249 rectificacion: Option<&Rectificacion>,
1250 entity: gpui::Entity<AstrologyCanvas>,
1251) -> gpui::Div {
1252 let asc = render.ascendant_deg;
1253 let rot_offset = view_rotation_deg;
1254 // Todo el wheel escala uniforme: el cuadro contenedor y los anillos
1255 // crecen con view_scale, así que glifos, líneas y márgenes mantienen
1256 // sus proporciones. cx/cy_center vive en coords locales del wheel,
1257 // donde el wheel tiene tamaño `wheel_size` (no WHEEL_SIZE).
1258 let wheel_size = WHEEL_SIZE * view_scale;
1259 let wheel_margin = WHEEL_MARGIN * view_scale;
1260 let cx_center = wheel_size / 2.0;
1261 let cy_center = wheel_size / 2.0;
1262 let r_outer = (wheel_size - wheel_margin * 2.0) / 2.0;
1263 let radii = Radii::from_outer(r_outer);
1264
1265 let visible = layer_visibility.clone();
1266
1267 // --- Canvas element con todo el trazo + jog-dial drag ---
1268 let palette_paint = palette.clone();
1269 let theme_paint = theme.clone();
1270 let layers_paint: Vec<Layer> = render.layers.clone();
1271 let gr_triggers_paint: Vec<GrTrigger> = render.gr_triggers.clone();
1272 let asc_for_paint = asc;
1273 let mc_for_paint = render.midheaven_deg;
1274 let visibility_for_paint = visible.clone();
1275 let entity_for_canvas = entity.clone();
1276 // Hover focus para el highlight de aspectos — solo cuando el hover
1277 // es un Body (sobre un planeta), no sobre cusps ni aspectos.
1278 let hover_focus_paint: Option<String> = match hover {
1279 Some(HoverInfo::Body { symbol, .. }) => Some(symbol.clone()),
1280 _ => None,
1281 };
1282 let canvas_element = canvas(
1283 move |_b: Bounds<Pixels>, _w, _cx| (),
1284 move |bounds: Bounds<Pixels>, _, window, _| {
1285 // Painting de la rueda.
1286 paint_wheel(
1287 bounds,
1288 window,
1289 &theme_paint,
1290 &palette_paint,
1291 &layers_paint,
1292 asc_for_paint,
1293 mc_for_paint,
1294 rot_offset,
1295 radii,
1296 &visibility_for_paint,
1297 hover_focus_paint.as_deref(),
1298 &gr_triggers_paint,
1299 );
1300
1301 // Handlers de mouse — se registran cada frame contra el
1302 // window; GPUI los reemplaza al re-renderear. LMB despacha
1303 // entre jog-dial (sobre el anillo) y pan (afuera). MMB es
1304 // pan secundario para usuarios con scroll-mouse.
1305 let entity_d = entity_for_canvas.clone();
1306 window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| {
1307 if !bounds.contains(&ev.position) {
1308 return;
1309 }
1310 match ev.button {
1311 MouseButton::Left => {
1312 let mods = ev.modifiers;
1313 entity_d.update(cx, |this, cx| {
1314 this.on_primary_down(ev.position, mods, bounds, cx)
1315 });
1316 }
1317 MouseButton::Middle => {
1318 entity_d.update(cx, |this, cx| this.on_pan_down(ev.position, cx));
1319 }
1320 _ => {}
1321 }
1322 });
1323 let entity_m = entity_for_canvas.clone();
1324 window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
1325 if ev.dragging() {
1326 entity_m.update(cx, |this, cx| {
1327 if this.state.drag_pan.is_some() {
1328 this.on_pan_move(ev.position, cx);
1329 } else {
1330 this.on_jog_move(ev.position, bounds, cx);
1331 }
1332 });
1333 } else if bounds.contains(&ev.position) {
1334 // Mouse hover sin drag: hit-test sobre los body
1335 // glyphs para el tooltip.
1336 entity_m.update(cx, |this, cx| this.on_hover_check(ev.position, bounds, cx));
1337 } else {
1338 entity_m.update(cx, |this, cx| {
1339 if this.state.hover.take().is_some() {
1340 cx.notify();
1341 }
1342 });
1343 }
1344 });
1345 let entity_u = entity_for_canvas.clone();
1346 window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
1347 entity_u.update(cx, |this, cx| {
1348 this.on_pan_up(cx);
1349 this.on_jog_up(cx);
1350 });
1351 });
1352 },
1353 )
1354 .absolute()
1355 .w(px(wheel_size))
1356 .h(px(wheel_size));
1357
1358 // El wheel ya no tiene bg propio — antes era un cuadrado con
1359 // gradient que cortaba contra el fondo del panel; ahora el panel
1360 // (con su starfield encima en `render`) fluye continuo a través
1361 // del área del wheel, dando el efecto de "rueda flotando en el
1362 // universo" en lugar de "rueda sobre placa cuadrada".
1363 let mut wheel = div()
1364 .relative()
1365 .w(px(wheel_size))
1366 .h(px(wheel_size))
1367 .ml(px(view_pan_x))
1368 .mt(px(view_pan_y))
1369 .child(canvas_element);
1370
1371 // Factor de escala para los glyphs DOM. Los radii ya están
1372 // escalados (vienen de wheel_size = WHEEL_SIZE * view_scale), pero
1373 // los tamaños de fuente y disco están hardcoded — los multiplico
1374 // por view_scale para que el zoom afecte uniformemente todo el
1375 // contenido visual del wheel, no solo la geometría del canvas.
1376 let s = view_scale;
1377 // Color del halo para los discos detrás de glyphs y pills — se
1378 // calcula una sola vez, lo usan planetas, casas, ASC/MC y los
1379 // coord labels.
1380 let halo_bg = glyph_halo(theme);
1381 // Sign glyphs.
1382 if visible.get(&LayerKind::SignDial).copied().unwrap_or(true) {
1383 let sign_ring_mid = (radii.sign_outer + radii.sign_inner) / 2.0;
1384 for layer in &render.layers {
1385 if matches!(layer.kind, LayerKind::SignDial) {
1386 for g in &layer.glyphs {
1387 let (x, y) = polar_to_screen(g.deg, asc, rot_offset, sign_ring_mid);
1388 let color = element_color_for_sign(palette, &g.symbol);
1389 wheel = wheel.child(centered_glyph(
1390 cx_center + x,
1391 cy_center + y,
1392 20.0 * s,
1393 18.0 * s,
1394 sign_unicode(&g.symbol).into(),
1395 color,
1396 ));
1397 }
1398 }
1399 }
1400 }
1401
1402 // House numbers + (opcional) coord del cusp.
1403 //
1404 // El layer `natal` usa Zona CD (entre aros C y D); `topocentric`
1405 // usa Zona BC (entre aros B y C). Los house numbers se posan al
1406 // centro de la zona; las coord pills se posan adyacentes al aro
1407 // interior de la propia zona, así no se sale del bloque.
1408 if visible.get(&LayerKind::Houses).copied().unwrap_or(true) {
1409 let house_label_color = house_ring_color(palette);
1410 for layer in &render.layers {
1411 if matches!(layer.kind, LayerKind::Houses) {
1412 let is_topo = layer.module_id == "topocentric";
1413 let (r_out, r_in) = if is_topo {
1414 (radii.topo_houses_outer, radii.topo_houses_inner)
1415 } else {
1416 (radii.houses_outer, radii.houses_inner)
1417 };
1418 let label_r = (r_out + r_in) / 2.0;
1419 let coord_r = r_in + (r_out - r_in) * 0.18;
1420 for g in &layer.glyphs {
1421 let (x, y) = polar_to_screen(g.deg, asc, rot_offset, label_r);
1422 if let Some(h) = g.house {
1423 wheel = wheel.child(centered_glyph(
1424 cx_center + x,
1425 cy_center + y,
1426 16.0 * s,
1427 11.0 * s,
1428 format!("{}", h).into(),
1429 house_label_color,
1430 ));
1431 if show_coords {
1432 let coord = format_coord_compact(g.deg);
1433 let (lx, ly) =
1434 polar_to_screen(g.deg, asc, rot_offset, coord_r);
1435 wheel = wheel.child(coord_label(
1436 cx_center + lx,
1437 cy_center + ly,
1438 coord.into(),
1439 theme.fg_muted,
1440 halo_bg,
1441 8.5 * s,
1442 ));
1443 }
1444 }
1445 }
1446 }
1447 }
1448 }
1449
1450 // Planet glyphs: natal en `bodies` + overlays (progression,
1451 // solar_arc) en sus rings, ambos con disco-halo para legibilidad
1452 // contra cualquier fondo. El natal lleva un tamaño un poco mayor
1453 // que los overlays para que se lea como "el cuerpo principal".
1454 if visible.get(&LayerKind::Bodies).copied().unwrap_or(true) {
1455 for layer in &render.layers {
1456 if matches!(layer.kind, LayerKind::Bodies) {
1457 let is_natal = layer.module_id == "natal";
1458 let is_topo = layer.module_id == "topocentric";
1459 let is_pd_direct = layer.module_id == "pd_direct";
1460 let is_pd_converse = layer.module_id == "pd_converse";
1461 let is_pd = is_pd_direct || is_pd_converse;
1462 let ring = radii.body_ring(&layer.module_id);
1463 let alpha = if is_natal {
1464 1.0
1465 } else if is_topo {
1466 0.75
1467 } else if is_pd {
1468 0.80
1469 } else {
1470 0.88
1471 };
1472 let font_size = (if is_natal {
1473 18.0
1474 } else if is_topo {
1475 15.0
1476 } else if is_pd {
1477 13.0
1478 } else {
1479 14.0
1480 }) * s;
1481 let disk_size_base = (if is_natal {
1482 26.0
1483 } else if is_topo {
1484 22.0
1485 } else if is_pd {
1486 20.0
1487 } else {
1488 22.0
1489 }) * s;
1490
1491 // Anti-solapamiento: spread directo sobre TODOS los
1492 // glyphs con `min_sep = disk_angular` (tangencial: los
1493 // discos se rozan sin pisarse) y `max_shift = disk_angular`
1494 // (cap fuerte: ningún planeta puede alejarse más de
1495 // un diámetro de disco de su grado real). El cap evita
1496 // que un cluster denso "empuje" a planetas lejanos.
1497 //
1498 // En paralelo, `find_clusters` con threshold = ancho
1499 // del disco × 1.2 detecta pares/tríos cercanos para
1500 // que compartan label. Sin esto, dos planetas en
1501 // conjunción a 5° real se ven con sus discos
1502 // separados a 10° y CADA UNO con su pill — dos labels
1503 // que dicen casi lo mismo, exactamente lo que el
1504 // usuario reporta como "se repiten en vez de
1505 // reutilizarse".
1506 let raw_degs: Vec<f32> = layer.glyphs.iter().map(|g| g.deg).collect();
1507 let disk_angular_deg =
1508 (disk_size_base / (std::f32::consts::TAU * ring)) * 360.0;
1509 let max_shift = disk_angular_deg;
1510 let (display_degs, residual) =
1511 spread_angles(&raw_degs, disk_angular_deg, max_shift);
1512 let cluster_thresh = disk_angular_deg * 1.2;
1513 let clusters = find_clusters(&raw_degs, cluster_thresh);
1514
1515 let cluster_centroids: Vec<f32> = clusters
1516 .iter()
1517 .map(|c| {
1518 let mut sx = 0.0_f32;
1519 let mut sy = 0.0_f32;
1520 for &idx in c {
1521 let a = raw_degs[idx].to_radians();
1522 sx += a.cos();
1523 sy += a.sin();
1524 }
1525 sy.atan2(sx).to_degrees().rem_euclid(360.0)
1526 })
1527 .collect();
1528 let display_centroids: Vec<f32> = clusters
1529 .iter()
1530 .map(|c| {
1531 let mut sx = 0.0_f32;
1532 let mut sy = 0.0_f32;
1533 for &idx in c {
1534 let a = display_degs[idx].to_radians();
1535 sx += a.cos();
1536 sy += a.sin();
1537 }
1538 sy.atan2(sx).to_degrees().rem_euclid(360.0)
1539 })
1540 .collect();
1541 let mut cluster_of = vec![0usize; layer.glyphs.len()];
1542 for (ci, c) in clusters.iter().enumerate() {
1543 for &idx in c {
1544 cluster_of[idx] = ci;
1545 }
1546 }
1547
1548 let shrink_residual = (1.0 - residual * 0.30).clamp(0.60, 1.0);
1549
1550 // El hovered glyph y su cluster reciben tratamiento
1551 // especial: lo postponemos para pintarlo al FINAL del
1552 // árbol (queda por encima del resto = z-order), y le
1553 // damos un border más fuerte. Su label cluster también
1554 // se destaca (color fg_text en lugar de fg_muted, font
1555 // un punto más grande).
1556 let hovered_sym: Option<&str> = match hover {
1557 Some(HoverInfo::Body { symbol, .. }) => Some(symbol.as_str()),
1558 _ => None,
1559 };
1560 let hovered_idx: Option<usize> = hovered_sym.and_then(|sym| {
1561 layer.glyphs.iter().position(|g| g.symbol == sym)
1562 });
1563 let hovered_cluster: Option<usize> = hovered_idx.map(|i| cluster_of[i]);
1564
1565 for (i, g) in layer.glyphs.iter().enumerate() {
1566 if Some(i) == hovered_idx {
1567 continue; // se pinta al final
1568 }
1569 // Achicar discos cuando el glyph está en cluster
1570 // (≥2 miembros) — al estar pegados se ven mejor
1571 // un poco más pequeños.
1572 let cluster_size = clusters[cluster_of[i]].len();
1573 let in_cluster_shrink = if cluster_size >= 2 { 0.86 } else { 1.0 };
1574 let effective_shrink = shrink_residual * in_cluster_shrink;
1575 let disk_size = disk_size_base * effective_shrink;
1576 let font_size_eff = (font_size * effective_shrink).max(11.0);
1577
1578 let display_deg = display_degs[i];
1579 let (x, y) = polar_to_screen(display_deg, asc, rot_offset, ring);
1580 let color = with_alpha(planet_color(palette, &g.symbol), alpha);
1581 let mut glyph_text = planet_unicode(&g.symbol).to_string();
1582 if g.retrograde {
1583 glyph_text.push('ᴿ');
1584 }
1585 if let Some(marker) = &g.dignity_marker {
1586 glyph_text.push_str(marker);
1587 }
1588 wheel = wheel.child(planet_glyph(
1589 cx_center + x,
1590 cy_center + y,
1591 disk_size,
1592 font_size_eff,
1593 glyph_text.into(),
1594 color,
1595 halo_bg,
1596 with_alpha(color, 0.85),
1597 ));
1598
1599 // Coord label individual: solo cuando el glyph
1600 // está SOLO en su cluster (≥2 ⇒ label compartido).
1601 if show_coords && (is_natal || is_topo) && cluster_size == 1 {
1602 let coord = format_coord_compact(g.deg);
1603 let label_r = ring - disk_size * 1.3;
1604 let (lx, ly) =
1605 polar_to_screen(display_deg, asc, rot_offset, label_r);
1606 wheel = wheel.child(coord_label(
1607 cx_center + lx,
1608 cy_center + ly,
1609 coord.into(),
1610 theme.fg_muted,
1611 halo_bg,
1612 8.5 * s,
1613 ));
1614 }
1615 }
1616
1617 // Label compartido para CADA cluster con ≥2 miembros.
1618 // El del cluster hovereado se destaca: color fg_text
1619 // (vs fg_muted) y font un punto más grande.
1620 if show_coords && (is_natal || is_topo) {
1621 let disk_size_typical = disk_size_base * shrink_residual * 0.86;
1622 for (ci, c) in clusters.iter().enumerate() {
1623 if c.len() < 2 {
1624 continue;
1625 }
1626 let highlighted = Some(ci) == hovered_cluster;
1627 let center_display_deg = display_centroids[ci];
1628 let center_real_deg = cluster_centroids[ci];
1629 let symbols: String = c
1630 .iter()
1631 .map(|&idx| planet_unicode(&layer.glyphs[idx].symbol))
1632 .collect::<Vec<_>>()
1633 .join(" ");
1634 let coord = format_coord_compact(center_real_deg);
1635 let text = format!("{} {}", symbols, coord);
1636 let label_r = ring - disk_size_typical * 1.5;
1637 let (lx, ly) = polar_to_screen(
1638 center_display_deg,
1639 asc,
1640 rot_offset,
1641 label_r,
1642 );
1643 let (fg, font_sz) = if highlighted {
1644 (theme.fg_text, 10.0 * s)
1645 } else {
1646 (theme.fg_muted, 9.0 * s)
1647 };
1648 wheel = wheel.child(coord_label(
1649 cx_center + lx,
1650 cy_center + ly,
1651 text.into(),
1652 fg,
1653 halo_bg,
1654 font_sz,
1655 ));
1656 }
1657 }
1658
1659 // Render del glyph hovered al FINAL: queda encima del
1660 // resto en z-order. Disco un poco más grande y border
1661 // más prominente para destacar.
1662 if let Some(hi) = hovered_idx {
1663 let g = &layer.glyphs[hi];
1664 let display_deg = display_degs[hi];
1665 let (x, y) = polar_to_screen(display_deg, asc, rot_offset, ring);
1666 let color = with_alpha(planet_color(palette, &g.symbol), alpha);
1667 let mut glyph_text = planet_unicode(&g.symbol).to_string();
1668 if g.retrograde {
1669 glyph_text.push('ᴿ');
1670 }
1671 if let Some(marker) = &g.dignity_marker {
1672 glyph_text.push_str(marker);
1673 }
1674 let disk_size = disk_size_base * shrink_residual * 1.18;
1675 let font_size_eff = font_size * shrink_residual * 1.12;
1676 wheel = wheel.child(planet_glyph(
1677 cx_center + x,
1678 cy_center + y,
1679 disk_size,
1680 font_size_eff,
1681 glyph_text.into(),
1682 color,
1683 halo_bg,
1684 color, // border al color pleno (no .85) — destaca
1685 ));
1686 // Si el hovered no está en cluster compartido,
1687 // pintamos su coord individual destacada acá.
1688 let cluster_size = clusters[cluster_of[hi]].len();
1689 if show_coords && (is_natal || is_topo) && cluster_size == 1 {
1690 let coord = format_coord_compact(g.deg);
1691 let label_r = ring - disk_size * 1.3;
1692 let (lx, ly) =
1693 polar_to_screen(display_deg, asc, rot_offset, label_r);
1694 wheel = wheel.child(coord_label(
1695 cx_center + lx,
1696 cy_center + ly,
1697 coord.into(),
1698 theme.fg_text,
1699 halo_bg,
1700 10.0 * s,
1701 ));
1702 }
1703 }
1704 }
1705 }
1706 }
1707
1708 // Planet glyphs en el outer ring — transit o synastry (slot
1709 // compartido, mutuamente excluyentes a nivel de Shell). Disco un
1710 // poco más chico que el natal — el outer es "secundario".
1711 if visible.get(&LayerKind::Outer).copied().unwrap_or(true) {
1712 for layer in &render.layers {
1713 if matches!(layer.kind, LayerKind::Outer)
1714 && (OUTER_RING_MODULES.contains(&layer.module_id.as_str()))
1715 {
1716 let disk_base = 20.0 * s;
1717 let raw_degs: Vec<f32> = layer.glyphs.iter().map(|g| g.deg).collect();
1718 let disk_angular =
1719 (disk_base / (std::f32::consts::TAU * radii.transits)) * 360.0;
1720 let (display_degs, residual) =
1721 spread_angles(&raw_degs, disk_angular, disk_angular);
1722 let shrink = (1.0 - residual * 0.30).clamp(0.60, 1.0);
1723 for (i, g) in layer.glyphs.iter().enumerate() {
1724 let display_deg = display_degs[i];
1725 let (x, y) = polar_to_screen(display_deg, asc, rot_offset, radii.transits);
1726 let color = with_alpha(planet_color(palette, &g.symbol), 0.92);
1727 let glyph_text = if g.retrograde {
1728 format!("{}ᴿ", planet_unicode(&g.symbol))
1729 } else {
1730 planet_unicode(&g.symbol).into()
1731 };
1732 wheel = wheel.child(planet_glyph(
1733 cx_center + x,
1734 cy_center + y,
1735 20.0 * s * shrink,
1736 13.0 * s * shrink,
1737 glyph_text.into(),
1738 color,
1739 halo_bg,
1740 with_alpha(color, 0.75),
1741 ));
1742 }
1743 }
1744 }
1745 }
1746
1747 // Tooltip absoluto sobre el elemento hovered (cuerpo o cusp).
1748 if let Some(hov) = hover {
1749 let text = match hov {
1750 HoverInfo::Body {
1751 module_id,
1752 symbol,
1753 deg,
1754 house,
1755 retrograde,
1756 dignity_marker,
1757 annotation,
1758 ..
1759 } => {
1760 let sign_idx = ((deg / 30.0).floor() as usize) % 12;
1761 let sign_name = SIGN_NAMES_ES[sign_idx];
1762 let deg_in_sign = deg - (sign_idx as f32) * 30.0;
1763 let display_symbol = if module_id == "midpoints" {
1764 // El symbol del midpoint es "a/b" — para el header
1765 // del tooltip usamos los unicodes individuales.
1766 if let Some((a, b)) = symbol.split_once('/') {
1767 format!("{}/{}", planet_unicode(a), planet_unicode(b))
1768 } else {
1769 symbol.clone()
1770 }
1771 } else {
1772 planet_unicode(symbol).to_string()
1773 };
1774 let mut t = format!("{} {} · {:.1}°", display_symbol, sign_name, deg_in_sign);
1775 if let Some(h) = house {
1776 t.push_str(&format!(" · Casa {}", h));
1777 }
1778 if *retrograde {
1779 t.push_str(" · ℞");
1780 }
1781 if let Some(m) = dignity_marker {
1782 t.push_str(&format!(" · {}", m));
1783 }
1784 if module_id == "midpoints" {
1785 if let Some(a) = annotation {
1786 t.push_str(&format!(" · {}", a));
1787 }
1788 } else if module_id != "natal" {
1789 t.push_str(&format!(" · {}", module_id));
1790 }
1791 t
1792 }
1793 HoverInfo::HouseCusp {
1794 house_number, deg, ..
1795 } => {
1796 let sign_idx = ((deg / 30.0).floor() as usize) % 12;
1797 let sign_name = SIGN_NAMES_ES[sign_idx];
1798 let deg_in_sign = deg - (sign_idx as f32) * 30.0;
1799 format!(
1800 "Cusp Casa {} · {} {:.1}°",
1801 house_number, sign_name, deg_in_sign
1802 )
1803 }
1804 HoverInfo::Aspect {
1805 module_id,
1806 from_body,
1807 to_body,
1808 kind,
1809 orb_deg,
1810 ..
1811 } => {
1812 let mut t = format!(
1813 "{} {} {} · orb {:.1}°",
1814 planet_unicode(from_body),
1815 aspect_unicode(kind),
1816 planet_unicode(to_body),
1817 orb_deg
1818 );
1819 if module_id != "natal" {
1820 t.push_str(&format!(" · {}", module_id));
1821 }
1822 t
1823 }
1824 };
1825 let (lx, ly) = hov.local();
1826 let tip_x = (lx + 14.0).min(WHEEL_SIZE - 220.0).max(8.0);
1827 let tip_y = (ly - 28.0).max(8.0);
1828 wheel = wheel.child(
1829 div()
1830 .absolute()
1831 .left(px(tip_x))
1832 .top(px(tip_y))
1833 .px(px(8.0))
1834 .py(px(4.0))
1835 .rounded(px(6.0))
1836 .bg(theme.bg_panel_alt.clone())
1837 .border_1()
1838 .border_color(palette.angle_highlight)
1839 .text_size(px(11.0))
1840 .text_color(theme.fg_text)
1841 .child(SharedString::from(text)),
1842 );
1843 }
1844
1845 // Labels ASC/MC/DESC/IC como pills en el perímetro — bg del halo
1846 // + border y texto en `angle_highlight`. Más legibles que el
1847 // centered_glyph plano del fase anterior, en especial sobre
1848 // fondos claros donde el ámbar/oro de angle_highlight se diluye.
1849 let angle_labels = [
1850 (asc, "ASC"),
1851 (render.midheaven_deg, "MC"),
1852 (render.descendant_deg, "DESC"),
1853 (render.imum_coeli_deg, "IC"),
1854 ];
1855 let label_r = r_outer * 1.08;
1856 for (deg, label) in angle_labels {
1857 let (x, y) = polar_to_screen(deg, asc, rot_offset, label_r);
1858 let pill_w = (if label.len() > 2 { 38.0 } else { 30.0 }) * s;
1859 let pill_h = 18.0 * s;
1860 wheel = wheel.child(
1861 div()
1862 .absolute()
1863 .left(px(cx_center + x - pill_w / 2.0))
1864 .top(px(cy_center + y - pill_h / 2.0))
1865 .w(px(pill_w))
1866 .h(px(pill_h))
1867 .flex()
1868 .items_center()
1869 .justify_center()
1870 .rounded(px(9.0 * s))
1871 .bg(halo_bg)
1872 .border_1()
1873 .border_color(with_alpha(palette.angle_highlight, 0.85))
1874 .text_size(px(11.0 * s))
1875 .text_color(palette.angle_highlight)
1876 .child(SharedString::from(label)),
1877 );
1878 }
1879
1880 // --- Header + footer + indicador de tiempo ---
1881 let header = div()
1882 .flex()
1883 .flex_col()
1884 .items_center()
1885 .gap(px(2.0))
1886 .child(
1887 div()
1888 .text_size(px(16.0))
1889 .text_color(theme.fg_text)
1890 .child(SharedString::from(render.title.clone())),
1891 );
1892 let header = if let Some(sub) = &render.subtitle {
1893 header.child(
1894 div()
1895 .text_size(px(11.0))
1896 .text_color(theme.fg_muted)
1897 .child(SharedString::from(sub.clone())),
1898 )
1899 } else {
1900 header
1901 };
1902 // Botón export SVG — pequeño, alineado a la derecha del title.
1903 let export_btn = div()
1904 .id("tts-canvas-export-svg")
1905 .px(px(10.0))
1906 .py(px(3.0))
1907 .rounded(px(4.0))
1908 .bg(theme.bg_button())
1909 .hover(|s| s.bg(theme.bg_button_hover()))
1910 .border_1()
1911 .border_color(theme.border)
1912 .text_size(px(10.0))
1913 .text_color(theme.fg_text)
1914 .child("⬇ SVG")
1915 .on_click({
1916 let entity_e = entity.clone();
1917 move |_: &gpui::ClickEvent, _w, cx: &mut gpui::App| {
1918 entity_e.update(cx, |_this, cx| {
1919 cx.emit(CanvasEvent::ExportSvgRequested);
1920 });
1921 }
1922 });
1923 let header = div()
1924 .flex()
1925 .flex_row()
1926 .items_center()
1927 .gap(px(12.0))
1928 .child(header)
1929 .child(export_btn);
1930
1931 let offset_label = format_offset(time_offset_minutes);
1932 let offset_color = if time_offset_minutes == 0 {
1933 theme.fg_disabled
1934 } else {
1935 palette.angle_highlight
1936 };
1937 let info_row = div()
1938 .flex()
1939 .flex_row()
1940 .gap(px(10.0))
1941 .child(
1942 div()
1943 .text_size(px(10.0))
1944 .text_color(theme.fg_disabled)
1945 .child(SharedString::from(format!(
1946 "Asc {:.1}° · MC {:.1}° · {} ms",
1947 render.ascendant_deg, render.midheaven_deg, render.compute_ms,
1948 ))),
1949 )
1950 .child(
1951 div()
1952 .text_size(px(10.0))
1953 .text_color(offset_color)
1954 .child(SharedString::from(offset_label)),
1955 )
1956 .child(
1957 div()
1958 .text_size(px(10.0))
1959 .text_color(theme.fg_disabled)
1960 .child(
1961 "[D]ial [H]ouses as[X]pects [P]lanets [T]ransits [C]oords · Ctrl+drag = tiempo/edad GR · [0] reset zoom · [R] reset tiempo · [S]vg",
1962 ),
1963 );
1964
1965 // Badges de overlays activos. Cada uno se pinta como pill con
1966 // background sutil y border tenue. Solo aparecen cuando hay
1967 // overlays — la carta natal pura ve solo el info_row.
1968 let badges_row = if render.overlays.is_empty() {
1969 None
1970 } else {
1971 let mut row = div().flex().flex_row().flex_wrap().gap(px(6.0));
1972 // Badge "natal" base, siempre presente cuando hay overlays —
1973 // ayuda al usuario a leer la pila de izquierda a derecha.
1974 row = row.child(badge(theme, palette, "natal", "Natal", true));
1975 for ov in &render.overlays {
1976 row = row.child(badge(theme, palette, &ov.module_id, &ov.label, false));
1977 }
1978 Some(row)
1979 };
1980
1981 let mut footer = div().flex().flex_col().items_center().gap(px(4.0)).child(info_row);
1982 if let Some(b) = badges_row {
1983 footer = footer.child(b);
1984 }
1985
1986 // Dial uraniano de 90°. Aparece cuando el módulo Uranian está
1987 // activo: una proyección geométrica de los cuerpos sobre el eje
1988 // 0-90° + la lista de fórmulas (cuerpos en el mismo grado dial).
1989 if render.overlays.iter().any(|o| o.module_id == "uranian") {
1990 let mut section = div()
1991 .flex()
1992 .flex_col()
1993 .items_center()
1994 .gap(px(4.0))
1995 .child(
1996 div()
1997 .text_size(px(10.0))
1998 .text_color(theme.fg_muted)
1999 .child("Dial 90° (uraniano)"),
2000 )
2001 .child(render_uranian_dial(
2002 theme,
2003 palette,
2004 &render.layers,
2005 &render.uranian_groups,
2006 ));
2007 // Pills de fórmulas, sólo si se detectó algún eje.
2008 if !render.uranian_groups.is_empty() {
2009 let mut row = div()
2010 .flex()
2011 .flex_row()
2012 .flex_wrap()
2013 .justify_center()
2014 .gap(px(6.0));
2015 for group in &render.uranian_groups {
2016 let bodies_text: String = group
2017 .bodies
2018 .iter()
2019 .map(|b| planet_unicode(b))
2020 .collect::<Vec<_>>()
2021 .join(" ");
2022 row = row.child(
2023 div()
2024 .px(px(8.0))
2025 .py(px(2.0))
2026 .rounded(px(10.0))
2027 .bg(theme.bg_panel_alt.clone())
2028 .border_1()
2029 .border_color(with_alpha(palette.angle_highlight, 0.6))
2030 .text_size(px(11.0))
2031 .text_color(theme.fg_text)
2032 .child(SharedString::from(format!(
2033 "{} · {:.1}°",
2034 bodies_text, group.mod90_deg
2035 ))),
2036 );
2037 }
2038 section = section.child(row);
2039 }
2040 footer = footer.child(section);
2041 }
2042
2043 // Espectro de fuerza armónica — histograma clicable. Aparece sólo
2044 // en modo armónico (harmonic > 1) y guía qué armónico mirar.
2045 if !render.harmonic_spectrum.is_empty() {
2046 footer = footer.child(render_harmonic_spectrum(
2047 theme,
2048 palette,
2049 &render.harmonic_spectrum,
2050 render.harmonic,
2051 entity.clone(),
2052 ));
2053 }
2054
2055 // Perfil del rectificador automático — la curva del barrido de horas
2056 // candidatas. Aparece tras correr una rectificación; su valle marca
2057 // la hora de nacimiento que mejor explica los eventos conocidos.
2058 if let Some(r) = rectificacion {
2059 if !r.perfil.is_empty() {
2060 footer = footer.child(render_rectify_profile(theme, palette, r, entity.clone()));
2061 }
2062 }
2063
2064 // Lista textual de aspectos (top 12 por orb). Compacta, en grid
2065 // de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos
2066 // computados.
2067 if !render.aspect_summary.is_empty() {
2068 let mut grid = div()
2069 .flex()
2070 .flex_row()
2071 .flex_wrap()
2072 .gap(px(10.0))
2073 .max_w(px(WHEEL_SIZE + 80.0))
2074 .justify_center();
2075 for ap in render.aspect_summary.iter().take(12) {
2076 let kind_sym = aspect_unicode(&ap.kind);
2077 let line = format!(
2078 "{} {} {} · {:.1}°{}",
2079 planet_unicode(&ap.from_body),
2080 kind_sym,
2081 planet_unicode(&ap.to_body),
2082 ap.orb_deg,
2083 match ap.applying {
2084 Some(true) => " A",
2085 Some(false) => " S",
2086 None => "",
2087 }
2088 );
2089 let prefix = if ap.module_id == "natal" {
2090 String::new()
2091 } else {
2092 format!("[{}] ", ap.module_id)
2093 };
2094 grid = grid.child(
2095 div()
2096 .px(px(6.0))
2097 .py(px(2.0))
2098 .text_size(px(11.0))
2099 .text_color(aspect_color(palette, &ap.kind))
2100 .child(SharedString::from(format!("{}{}", prefix, line))),
2101 );
2102 }
2103 footer = footer.child(grid);
2104 }
2105
2106 // El wheel va solo, salvo en modo GR: ahí lo acompaña el HUD
2107 // lateral de triggers de rectificación, anclado a su derecha.
2108 let body = if render.gr_triggers.is_empty() {
2109 div().child(wheel)
2110 } else {
2111 div()
2112 .flex()
2113 .flex_row()
2114 .items_start()
2115 .gap(px(14.0))
2116 .child(wheel)
2117 .child(render_gr_hud(theme, &render.gr_triggers))
2118 };
2119
2120 div()
2121 .flex()
2122 .flex_col()
2123 .items_center()
2124 .gap(px(8.0))
2125 .child(header)
2126 .child(body)
2127 .child(footer)
2128}
2129
2130/// HUD lateral de rectificación GR: lista los triggers de direcciones
2131/// primarias ordenados por orbe (los más cerrados arriba). El color va
2132/// de rojo (orbe apretado) a gris (orbe ancho); las convergencias
2133/// directo+converso llevan un marcador ✦ y un fondo resaltado.
2134fn render_gr_hud(theme: &Theme, triggers: &[GrTrigger]) -> gpui::Div {
2135 const SHOWN: usize = 20;
2136 let event_count = triggers.iter().filter(|t| t.event).count();
2137
2138 let mut col = div()
2139 .flex()
2140 .flex_col()
2141 .gap(px(2.0))
2142 .w(px(238.0))
2143 .p(px(10.0))
2144 .rounded(px(8.0))
2145 .bg(theme.bg_panel_alt.clone())
2146 .border_1()
2147 .border_color(theme.border);
2148
2149 col = col.child(
2150 div()
2151 .flex()
2152 .flex_row()
2153 .justify_between()
2154 .items_center()
2155 .child(
2156 div()
2157 .text_size(px(12.0))
2158 .text_color(theme.fg_text)
2159 .child("Triggers GR"),
2160 )
2161 .child(
2162 div()
2163 .text_size(px(10.0))
2164 .text_color(theme.fg_muted)
2165 .child(SharedString::from(format!(
2166 "{} · {} conv.",
2167 triggers.len(),
2168 event_count
2169 ))),
2170 ),
2171 );
2172 col = col.child(
2173 div()
2174 .text_size(px(9.0))
2175 .text_color(theme.fg_disabled)
2176 .mb(px(4.0))
2177 .child("rectificación · orbe ascendente"),
2178 );
2179
2180 for t in triggers.iter().take(SHOWN) {
2181 let color = if t.event {
2182 hsla(0.0, 0.88, 0.64, 1.0)
2183 } else {
2184 gr_orb_color(t.orb_deg)
2185 };
2186 let marker = if t.event { "✦" } else { "·" };
2187 let line = format!(
2188 "{} {}{} → {} {}",
2189 marker,
2190 planet_unicode(&t.promissor),
2191 t.direction.short(),
2192 gr_target_glyph(&t.natal_target),
2193 format_orb(t.orb_deg),
2194 );
2195 let mut row = div()
2196 .px(px(5.0))
2197 .py(px(2.0))
2198 .rounded(px(3.0))
2199 .text_size(px(11.0))
2200 .text_color(color)
2201 .child(SharedString::from(line));
2202 if t.event {
2203 row = row.bg(with_alpha(hsla(0.0, 0.80, 0.50, 1.0), 0.16));
2204 }
2205 col = col.child(row);
2206 }
2207 if triggers.len() > SHOWN {
2208 col = col.child(
2209 div()
2210 .text_size(px(9.0))
2211 .text_color(theme.fg_disabled)
2212 .mt(px(3.0))
2213 .child(SharedString::from(format!(
2214 "+{} más",
2215 triggers.len() - SHOWN
2216 ))),
2217 );
2218 }
2219 col
2220}
2221
2222/// Histograma del espectro de fuerza armónica. Cada barra es clicable:
2223/// un clic salta el slider de armónico a esa armónica. La barra de la
2224/// armónica activa va resaltada.
2225fn render_harmonic_spectrum(
2226 theme: &Theme,
2227 palette: &AstroPalette,
2228 spectrum: &[f32],
2229 current: u32,
2230 entity: gpui::Entity<AstrologyCanvas>,
2231) -> gpui::Div {
2232 const BAR_AREA_H: f32 = 46.0;
2233 let max = spectrum.iter().copied().fold(0.0_f32, f32::max).max(1e-3);
2234
2235 let mut bars = div().flex().flex_row().items_end().gap(px(2.0));
2236 for (i, &strength) in spectrum.iter().enumerate() {
2237 let h = (i as u32) + 1;
2238 let norm = (strength / max).clamp(0.0, 1.0);
2239 let bar_h = (norm * BAR_AREA_H).max(2.0);
2240 let is_current = h == current;
2241 let color = if is_current {
2242 palette.angle_highlight
2243 } else {
2244 with_alpha(palette.angle_highlight, 0.28 + norm * 0.45)
2245 };
2246 // Etiqueta cada 4 armónicas (+ la primera y la activa) para no
2247 // saturar la tira.
2248 let label = if h == current || h == 1 || h % 4 == 0 {
2249 format!("{h}")
2250 } else {
2251 String::new()
2252 };
2253 let column = div()
2254 .id(SharedString::from(format!("tts-harmonic-bar-{h}")))
2255 .flex()
2256 .flex_col()
2257 .items_center()
2258 .gap(px(2.0))
2259 .cursor_pointer()
2260 .child(
2261 div()
2262 .h(px(BAR_AREA_H))
2263 .flex()
2264 .flex_col()
2265 .justify_end()
2266 .child(div().w(px(11.0)).h(px(bar_h)).rounded(px(1.5)).bg(color)),
2267 )
2268 .child(
2269 div()
2270 .text_size(px(7.0))
2271 .text_color(if is_current {
2272 palette.angle_highlight
2273 } else {
2274 theme.fg_disabled
2275 })
2276 .child(SharedString::from(label)),
2277 )
2278 .on_click({
2279 let entity = entity.clone();
2280 move |_: &gpui::ClickEvent, _w, cx: &mut gpui::App| {
2281 entity.update(cx, |_this, cx| {
2282 cx.emit(CanvasEvent::HarmonicSelected(h));
2283 });
2284 }
2285 });
2286 bars = bars.child(column);
2287 }
2288
2289 div()
2290 .flex()
2291 .flex_col()
2292 .items_center()
2293 .gap(px(3.0))
2294 .child(
2295 div()
2296 .text_size(px(10.0))
2297 .text_color(theme.fg_muted)
2298 .child(SharedString::from(format!(
2299 "Espectro armónico · H{current} activo · clic para saltar"
2300 ))),
2301 )
2302 .child(bars)
2303}
2304
2305/// Curva del barrido del rectificador automático. Cada barra es una hora
2306/// de nacimiento candidata; su altura crece cuanto MEJOR explica los
2307/// eventos conocidos (menor puntaje de convergencia). La barra más alta
2308/// —el valle del puntaje— es la hora rectificada, y va resaltada.
2309///
2310/// Cada barra es clicable: un clic lleva la carta a esa hora candidata
2311/// (emite `TimeOffsetChanged`), para inspeccionarla sobre el wheel.
2312fn render_rectify_profile(
2313 theme: &Theme,
2314 palette: &AstroPalette,
2315 r: &Rectificacion,
2316 entity: gpui::Entity<AstrologyCanvas>,
2317) -> gpui::Div {
2318 const BAR_AREA_H: f32 = 46.0;
2319
2320 let (min_p, max_p) = r.perfil.iter().fold(
2321 (f32::INFINITY, f32::NEG_INFINITY),
2322 |(lo, hi), &(_, p)| (lo.min(p), hi.max(p)),
2323 );
2324 let rango = (max_p - min_p).max(1e-3);
2325 let primero = r.perfil.first().map(|&(o, _)| o).unwrap_or(0);
2326 let ultimo = r.perfil.last().map(|&(o, _)| o).unwrap_or(0);
2327 // El perfil va en segundos a paso de minuto; el mejor offset es
2328 // fino (segundos). La barra resaltada es la del minuto más cercano.
2329 let mejor_barra = (r.mejor_offset_segundos as f64 / 60.0).round() as i64 * 60;
2330
2331 let mut bars = div().flex().flex_row().items_end().gap(px(2.0));
2332 for &(offset, puntaje) in &r.perfil {
2333 // Fitness: el mejor candidato (puntaje mínimo) → barra más alta.
2334 let fitness = ((max_p - puntaje) / rango).clamp(0.0, 1.0);
2335 let bar_h = (fitness * BAR_AREA_H).max(2.0);
2336 let es_mejor = offset == mejor_barra;
2337 let color = if es_mejor {
2338 palette.angle_highlight
2339 } else {
2340 with_alpha(palette.angle_highlight, 0.25 + fitness * 0.45)
2341 };
2342 // Etiquetar sólo los hitos: el mejor, el 0 y los dos extremos.
2343 // El offset va en segundos; la etiqueta lo muestra en minutos.
2344 let label = if es_mejor || offset == 0 || offset == primero || offset == ultimo {
2345 if offset == 0 {
2346 "0".to_string()
2347 } else {
2348 format!("{:+}", offset / 60)
2349 }
2350 } else {
2351 String::new()
2352 };
2353 let column = div()
2354 .id(SharedString::from(format!("tts-rectify-bar-{offset}")))
2355 .flex()
2356 .flex_col()
2357 .items_center()
2358 .gap(px(2.0))
2359 .cursor_pointer()
2360 .child(
2361 div()
2362 .h(px(BAR_AREA_H))
2363 .flex()
2364 .flex_col()
2365 .justify_end()
2366 .child(div().w(px(9.0)).h(px(bar_h)).rounded(px(1.5)).bg(color)),
2367 )
2368 .child(
2369 div()
2370 .text_size(px(7.0))
2371 .text_color(if es_mejor {
2372 palette.angle_highlight
2373 } else {
2374 theme.fg_disabled
2375 })
2376 .child(SharedString::from(label)),
2377 )
2378 .on_click({
2379 // Un clic lleva la carta a esta hora candidata reusando
2380 // el scrub de tiempo del jog-dial (`TimeOffsetChanged`,
2381 // en minutos — el offset del perfil va en segundos).
2382 let entity = entity.clone();
2383 move |_: &gpui::ClickEvent, _w, cx: &mut gpui::App| {
2384 entity.update(cx, |_this, cx| {
2385 cx.emit(CanvasEvent::TimeOffsetChanged(offset / 60));
2386 });
2387 }
2388 });
2389 bars = bars.child(column);
2390 }
2391
2392 // La hora rectificada, fina: «±Xm Ys».
2393 let seg = r.mejor_offset_segundos;
2394 let signo = if seg < 0 { "-" } else { "+" };
2395 let abs = seg.abs();
2396
2397 div()
2398 .flex()
2399 .flex_col()
2400 .items_center()
2401 .gap(px(3.0))
2402 .child(
2403 div()
2404 .text_size(px(10.0))
2405 .text_color(theme.fg_muted)
2406 .child(SharedString::from(format!(
2407 "Rectificación · hora {}{}m {:02}s · error {:.2}a · el valle es la hora",
2408 signo,
2409 abs / 60,
2410 abs % 60,
2411 r.mejor_puntaje
2412 ))),
2413 )
2414 .child(bars)
2415}
2416
2417/// Dial uraniano de 90°: proyección geométrica de los cuerpos natales
2418/// sobre un eje horizontal 0-90° (longitud mod 90). Los cuerpos que
2419/// forman una fórmula uraniana (mismo grado dial) caen agrupados y se
2420/// resaltan; clusters densos se escalonan en filas para legibilidad.
2421fn render_uranian_dial(
2422 theme: &Theme,
2423 palette: &AstroPalette,
2424 layers: &[Layer],
2425 groups: &[UranianGroup],
2426) -> gpui::Div {
2427 const DIAL_W: f32 = 560.0;
2428 const ROW_H: f32 = 18.0;
2429 const MAX_ROWS: usize = 4;
2430 const AXIS_Y: f32 = ROW_H * MAX_ROWS as f32;
2431 const MIN_GAP: f32 = 17.0;
2432
2433 let Some(layer) = layers
2434 .iter()
2435 .find(|l| l.module_id == "natal" && matches!(l.kind, LayerKind::Bodies))
2436 else {
2437 return div();
2438 };
2439
2440 // `(símbolo, x, agrupado)` ordenados por posición en el dial.
2441 let mut marks: Vec<(String, f32, bool)> = layer
2442 .glyphs
2443 .iter()
2444 .map(|g| {
2445 let x = g.deg.rem_euclid(90.0) / 90.0 * DIAL_W;
2446 let grouped = groups
2447 .iter()
2448 .any(|gr| gr.bodies.iter().any(|b| b == &g.symbol));
2449 (g.symbol.clone(), x, grouped)
2450 })
2451 .collect();
2452 marks.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
2453
2454 let mut track = div().relative().w(px(DIAL_W)).h(px(AXIS_Y + 22.0));
2455
2456 // Eje base.
2457 track = track.child(
2458 div()
2459 .absolute()
2460 .left(px(0.0))
2461 .top(px(AXIS_Y))
2462 .w(px(DIAL_W))
2463 .h(px(1.0))
2464 .bg(with_alpha(palette.dial_ring, 0.7)),
2465 );
2466 // Ticks 0 / 22½ / 45 / 67½ / 90 — las divisiones duras del dial.
2467 for (deg, label) in [
2468 (0.0_f32, "0°"),
2469 (22.5, "22½°"),
2470 (45.0, "45°"),
2471 (67.5, "67½°"),
2472 (90.0, "90°"),
2473 ] {
2474 let x = deg / 90.0 * DIAL_W;
2475 track = track
2476 .child(
2477 div()
2478 .absolute()
2479 .left(px(x))
2480 .top(px(AXIS_Y))
2481 .w(px(1.0))
2482 .h(px(6.0))
2483 .bg(with_alpha(palette.dial_ring, 0.85)),
2484 )
2485 .child(
2486 div()
2487 .absolute()
2488 .left(px(x - 14.0))
2489 .top(px(AXIS_Y + 8.0))
2490 .w(px(28.0))
2491 .flex()
2492 .justify_center()
2493 .text_size(px(8.0))
2494 .text_color(theme.fg_disabled)
2495 .child(SharedString::from(label)),
2496 );
2497 }
2498
2499 // Glyphs, con escalonado vertical para los clusters.
2500 let mut last_x = f32::NEG_INFINITY;
2501 let mut row = 0usize;
2502 for (symbol, x, grouped) in &marks {
2503 if x - last_x < MIN_GAP {
2504 row = (row + 1).min(MAX_ROWS - 1);
2505 } else {
2506 row = 0;
2507 }
2508 last_x = *x;
2509 let color = if *grouped {
2510 palette.angle_highlight
2511 } else {
2512 with_alpha(planet_color(palette, symbol), 0.55)
2513 };
2514 track = track.child(
2515 div()
2516 .absolute()
2517 .left(px(x - 8.0))
2518 .top(px(row as f32 * ROW_H))
2519 .w(px(16.0))
2520 .h(px(16.0))
2521 .flex()
2522 .items_center()
2523 .justify_center()
2524 .text_size(px(13.0))
2525 .text_color(color)
2526 .child(SharedString::from(planet_unicode(symbol).to_string())),
2527 );
2528 }
2529
2530 track
2531}
2532
2533/// Color de un trigger GR según su orbe: rojo intenso (orbe cerrado,
2534/// contacto fuerte) que se desatura hacia gris al ensancharse. El
2535/// orbe de referencia (gris pleno) es el orbe del HUD, 2°.
2536fn gr_orb_color(orb_deg: f32) -> Hsla {
2537 let t = (orb_deg / 2.0).clamp(0.0, 1.0);
2538 let s = 0.82 + (0.10 - 0.82) * t;
2539 let l = 0.62 + (0.52 - 0.62) * t;
2540 hsla(0.0, s, l, 1.0)
2541}
2542
2543/// Orbe en grados → texto compacto `D°MM'`.
2544fn format_orb(orb_deg: f32) -> String {
2545 let total_min = (orb_deg.abs() * 60.0).round() as i64;
2546 format!("{}°{:02}'", total_min / 60, total_min % 60)
2547}
2548
2549/// Glyph corto de un punto natal objetivo: ángulos como texto,
2550/// cuerpos vía [`planet_unicode`].
2551fn gr_target_glyph(name: &str) -> String {
2552 match name {
2553 "asc" => "Asc".to_string(),
2554 "mc" => "MC".to_string(),
2555 "desc" => "Dsc".to_string(),
2556 "ic" => "IC".to_string(),
2557 other => planet_unicode(other).to_string(),
2558 }
2559}
2560
2561/// Pequeña pill con la etiqueta de un overlay activo. El borde toma
2562/// color según el "tipo" del módulo para ayudar a mapear a su anillo
2563/// en el wheel: natal = neutro, outer ring share (transit/synastry/
2564/// planetary_return) = palette.angle_highlight, inner overlays
2565/// (progression/solar_arc) = palette.house_cusp.
2566fn badge(theme: &Theme, palette: &AstroPalette, module_id: &str, label: &str, is_natal: bool) -> gpui::Div {
2567 let border = if is_natal {
2568 theme.border
2569 } else {
2570 match module_id {
2571 "transit" | "synastry" | "planetary_return" => palette.angle_highlight,
2572 "progression" | "solar_arc" => palette.house_cusp,
2573 _ => theme.border,
2574 }
2575 };
2576 div()
2577 .px(px(8.0))
2578 .py(px(2.0))
2579 .rounded(px(10.0))
2580 .bg(theme.bg_panel_alt.clone())
2581 .border_1()
2582 .border_color(border)
2583 .text_size(px(10.0))
2584 .text_color(theme.fg_text)
2585 .child(SharedString::from(label.to_string()))
2586}
2587
2588fn format_offset(minutes: i64) -> String {
2589 if minutes == 0 {
2590 return "⏱ ahora".to_string();
2591 }
2592 let sign = if minutes > 0 { '+' } else { '-' };
2593 let m = minutes.unsigned_abs();
2594 let days = m / (60 * 24);
2595 let hours = (m / 60) % 24;
2596 let mins = m % 60;
2597 if days > 0 {
2598 format!("⏱ {}{}d {:02}h {:02}m", sign, days, hours, mins)
2599 } else if hours > 0 {
2600 format!("⏱ {}{:02}h {:02}m", sign, hours, mins)
2601 } else {
2602 format!("⏱ {}{:02}m", sign, mins)
2603 }
2604}
2605
2606// =====================================================================
2607// Painting
2608// =====================================================================
2609
2610// `Radii` + helpers migraron a `cosmobiologia-render` (crate
2611// agnóstico de surface, compila a WASM y nativo). Re-export para
2612// que el código del canvas siga refiriendo `Radii` sin cambiar
2613// imports en cada call site.
2614use cosmobiologia_render::Radii;
2615
2616#[allow(clippy::too_many_arguments)]
2617// `hover_focus`: symbol del planeta hovereado en este frame (si lo
2618// hay). Las líneas de aspecto que NO tocan a ese planeta se opacan
2619// para que el usuario lea claramente "qué afecta a qué". Si `None`,
2620// todas las líneas se pintan a alpha plena.
2621fn paint_wheel(
2622 bounds: Bounds<Pixels>,
2623 window: &mut Window,
2624 theme: &Theme,
2625 palette: &AstroPalette,
2626 layers: &[Layer],
2627 ascendant_deg: f32,
2628 midheaven_deg: f32,
2629 rot_offset_deg: f32,
2630 radii: Radii,
2631 visibility: &HashMap<LayerKind, bool>,
2632 hover_focus: Option<&str>,
2633 gr_triggers: &[GrTrigger],
2634) {
2635 let (cx, cy) = bounds_center(bounds);
2636 let show = |k: LayerKind| visibility.get(&k).copied().unwrap_or(true);
2637
2638 // 1. Sectores zodiacales (parte del SignDial layer).
2639 if show(LayerKind::SignDial) {
2640 paint_sign_sectors(window, cx, cy, &radii, palette, ascendant_deg, rot_offset_deg);
2641
2642 // Anillos del dial con efecto 3D: highlight interior + base +
2643 // shadow exterior. El highlight es 1 px hacia el centro con
2644 // luminancia +0.18; la shadow 1 px hacia afuera con -0.18.
2645 // El bevel central — varios strokes finos con alpha en bell
2646 // curve entre sign_inner y sign_outer — da volumen al dial.
2647 stroke_circle_3d(window, cx, cy, radii.sign_outer, 1.5, palette.dial_ring, theme);
2648 stroke_circle_3d(window, cx, cy, radii.sign_inner, 1.0, palette.dial_ring, theme);
2649 paint_dial_bevel(window, cx, cy, &radii, palette, theme);
2650
2651 // Cusps zodiacales cada 30°.
2652 for i in 0..12 {
2653 let lon = (i as f32) * 30.0;
2654 let color = palette.dial_ring;
2655 paint_radial_line(
2656 window,
2657 cx,
2658 cy,
2659 lon,
2660 ascendant_deg,
2661 rot_offset_deg,
2662 radii.sign_inner,
2663 radii.sign_outer,
2664 color,
2665 1.0,
2666 );
2667 }
2668 }
2669
2670 // 2. Casas — doble anillo (inner + outer) + cusps radiales +
2671 // énfasis Asc/IC/Desc/MC. La doble línea vuelve a la zona de
2672 // casas una "corona" claramente identificable. Color derivado
2673 // de `house_cusp` con un hue shift para que el sistema
2674 // ascensional (casas) se distinga visualmente del eclíptico
2675 // (dial zodiacal) que va en dorado.
2676 if show(LayerKind::Houses) {
2677 let house_base = house_ring_color(palette);
2678 let house_color = with_alpha(house_base, 0.85);
2679 stroke_circle_3d(window, cx, cy, radii.houses_outer, 1.1, house_color, theme);
2680 stroke_circle_3d(window, cx, cy, radii.houses_inner, 1.1, house_color, theme);
2681 // Si hay capa topocéntrica activa, pintar también sus dos
2682 // anillos (con stroke más sutil que el geocéntrico, para que
2683 // se lea como "sistema ascensional" sin competir).
2684 if layers
2685 .iter()
2686 .any(|l| matches!(l.kind, LayerKind::Houses) && l.module_id == "topocentric")
2687 {
2688 let topo_color = with_alpha(house_base, 0.55);
2689 stroke_circle(window, cx, cy, radii.topo_houses_outer, 0.8, topo_color);
2690 stroke_circle(window, cx, cy, radii.topo_houses_inner, 0.8, topo_color);
2691 }
2692
2693 for layer in layers {
2694 if matches!(layer.kind, LayerKind::Houses) {
2695 let is_topo = layer.module_id == "topocentric";
2696 let (r_in, r_out) = if is_topo {
2697 (radii.topo_houses_inner, radii.topo_houses_outer)
2698 } else {
2699 (radii.houses_inner, radii.houses_outer)
2700 };
2701 if let Geometry::Ring { cusps_deg } = &layer.geometry {
2702 for (i, c) in cusps_deg.iter().enumerate() {
2703 let is_angle = i == 0 || i == 3 || i == 6 || i == 9;
2704 let color = if is_topo {
2705 with_alpha(house_base, 0.60)
2706 } else if is_angle {
2707 palette.angle_highlight
2708 } else {
2709 with_alpha(house_base, 0.75)
2710 };
2711 let width = if is_angle && !is_topo { 2.0 } else { 0.8 };
2712 if is_topo {
2713 // Topocéntrico: cusp como línea punteada
2714 // en su propio anillo cercano al sign
2715 // dial — se distingue del Placidus
2716 // geocéntrico por el dash pattern y la
2717 // ubicación más exterior.
2718 paint_segment(
2719 window,
2720 cx
2721 + polar_to_screen(
2722 *c,
2723 ascendant_deg,
2724 rot_offset_deg,
2725 r_in,
2726 )
2727 .0,
2728 cy
2729 + polar_to_screen(
2730 *c,
2731 ascendant_deg,
2732 rot_offset_deg,
2733 r_in,
2734 )
2735 .1,
2736 cx
2737 + polar_to_screen(
2738 *c,
2739 ascendant_deg,
2740 rot_offset_deg,
2741 r_out,
2742 )
2743 .0,
2744 cy
2745 + polar_to_screen(
2746 *c,
2747 ascendant_deg,
2748 rot_offset_deg,
2749 r_out,
2750 )
2751 .1,
2752 color,
2753 Some((3.0, 2.5)),
2754 1.0,
2755 );
2756 } else {
2757 paint_radial_line(
2758 window,
2759 cx,
2760 cy,
2761 *c,
2762 ascendant_deg,
2763 rot_offset_deg,
2764 r_in,
2765 r_out,
2766 color,
2767 width,
2768 );
2769 }
2770 }
2771 }
2772 }
2773 }
2774
2775 // Cruz completa Asc-Desc + MC-IC, alpha bastante visible para
2776 // que orienten la lectura sin competir con cuerpos/aspectos.
2777 // 4 radios desde el centro: ASC, DESC (=asc+180), MC, IC
2778 // (=mc+180). `paint_radial_line` con r_inner=0 pinta un radio
2779 // del centro al borde — la cruz es la unión de los 4.
2780 let axis_color = with_alpha(palette.angle_highlight, 0.55);
2781 for axis_deg in [
2782 ascendant_deg,
2783 ascendant_deg + 180.0,
2784 midheaven_deg,
2785 midheaven_deg + 180.0,
2786 ] {
2787 paint_radial_line(
2788 window,
2789 cx,
2790 cy,
2791 axis_deg,
2792 ascendant_deg,
2793 rot_offset_deg,
2794 0.0,
2795 radii.houses_outer,
2796 axis_color,
2797 1.4,
2798 );
2799 }
2800 }
2801
2802 // Aro D — único anillo visible del bloque de planetas natales
2803 // (la idea del "carril doble" se descartó: confundía con el
2804 // sistema de casas). El aro E (`radii.aspects`) no se pinta por
2805 // diseño; solo es ancla invisible de las líneas.
2806 if show(LayerKind::Bodies) {
2807 let belt_color = with_alpha(palette.dial_ring, 0.55);
2808 stroke_circle_3d(window, cx, cy, radii.houses_inner, 0.9, belt_color, theme);
2809 // GR dual-ring: si las capas de direcciones primarias están
2810 // presentes, marcar sus anillos para que el visual lea como
2811 // "abrazo" del cinturón natal. La directa va punteada,
2812 // la conversa también — la diferencia entre las dos es la
2813 // ubicación radial (afuera vs adentro del cinturón natal).
2814 let has_pd = layers.iter().any(|l| {
2815 matches!(l.kind, LayerKind::Bodies)
2816 && (l.module_id == "pd_direct" || l.module_id == "pd_converse")
2817 });
2818 if has_pd {
2819 let pd_color = with_alpha(palette.angle_highlight, 0.50);
2820 for r in [radii.pd_direct, radii.pd_converse] {
2821 // Pintamos el anillo como tramo punteado fino: 24
2822 // segmentos cortos a lo largo del círculo.
2823 let steps = 96;
2824 for i in 0..steps {
2825 if i % 2 != 0 {
2826 continue;
2827 }
2828 let a0 = (i as f32) / (steps as f32) * std::f32::consts::TAU;
2829 let a1 = ((i + 1) as f32) / (steps as f32) * std::f32::consts::TAU;
2830 let x0 = cx + r * a0.cos();
2831 let y0 = cy + r * a0.sin();
2832 let x1 = cx + r * a1.cos();
2833 let y1 = cy + r * a1.sin();
2834 paint_segment(window, x0, y0, x1, y1, pd_color, None, 0.6);
2835 }
2836 }
2837 }
2838
2839 // Resaltado de convergencias GR: por cada punto natal donde un
2840 // trigger directo y otro converso coinciden dentro del
2841 // micro-orbe, un eje brillante atraviesa la zona del dual-ring
2842 // hasta el cinturón natal. Es la señal de rectificación — si la
2843 // hora natal es correcta, el evento real cae sobre este eje.
2844 let mut event_degs: Vec<f32> = gr_triggers
2845 .iter()
2846 .filter(|t| t.event)
2847 .map(|t| t.natal_deg)
2848 .collect();
2849 event_degs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2850 event_degs.dedup_by(|a, b| (*a - *b).abs() < 0.02);
2851 if !event_degs.is_empty() {
2852 let hot = hsla(0.0, 0.86, 0.60, 1.0);
2853 let marker_r = (radii.sign_outer * 0.014).max(2.5);
2854 for deg in event_degs {
2855 paint_radial_line(
2856 window,
2857 cx,
2858 cy,
2859 deg,
2860 ascendant_deg,
2861 rot_offset_deg,
2862 radii.pd_converse,
2863 radii.houses_inner,
2864 with_alpha(hot, 0.92),
2865 2.6,
2866 );
2867 let (mx, my) =
2868 polar_to_screen(deg, ascendant_deg, rot_offset_deg, radii.bodies);
2869 paint_glow(window, cx + mx, cy + my, marker_r * 2.0, hot);
2870 fill_circle(window, cx + mx, cy + my, marker_r, hot);
2871 }
2872 }
2873 }
2874
2875 // 3. Aspectos. Cada module_id usa su par de radios — natal-natal
2876 // ambos en `aspects`, cross con transit en `bodies → transits`,
2877 // cross con progression en `bodies → progression`.
2878 if show(LayerKind::Aspects) {
2879 let mono = palette.is_monochrome();
2880 for layer in layers {
2881 if matches!(layer.kind, LayerKind::Aspects) {
2882 if let Geometry::Lines(segs) = &layer.geometry {
2883 let (r_from, r_to) = radii.aspect_endpoints(&layer.module_id);
2884 let is_cross = r_from != r_to;
2885 for seg in segs {
2886 // Filtro minors con orbe ancho: los aspectos
2887 // menores (quincunx, semi-square, quintile…)
2888 // solo se trazan si están MUY apretados
2889 // (orbe ≤ 3°). Sobre 3° ensucian sin aportar.
2890 if !is_major_aspect(&seg.kind) && seg.orb_deg.abs() > 3.0 {
2891 continue;
2892 }
2893 let base = aspect_color(palette, &seg.kind);
2894 let base = with_alpha(base, base.a * seg.opacity);
2895 // Hover focus: si hay un planeta hovereado y
2896 // este segmento NO lo toca, lo atenuamos al
2897 // 18%; si lo toca o no hay hover, va pleno.
2898 let touches_hover = hover_focus
2899 .map(|sym| seg.from_body == sym || seg.to_body == sym)
2900 .unwrap_or(true);
2901 let factor = if touches_hover { 1.0 } else { 0.18 };
2902 let color = with_alpha(base, base.a * factor);
2903 let dash = if mono {
2904 dash_pattern_for_kind(&seg.kind)
2905 } else {
2906 None
2907 };
2908 // Width inverso al orbe: orbes cerrados se ven
2909 // gruesos (aspecto "fuerte"), orbes amplios
2910 // finos. Mayores van un escalón más gruesos
2911 // que menores en su mismo orbe.
2912 let width = aspect_width(&seg.kind, seg.orb_deg, mono);
2913 if is_cross {
2914 paint_cross_aspect_line(
2915 window,
2916 cx,
2917 cy,
2918 seg.from_deg,
2919 seg.to_deg,
2920 ascendant_deg,
2921 rot_offset_deg,
2922 r_from,
2923 r_to,
2924 color,
2925 dash,
2926 );
2927 } else {
2928 paint_aspect_line(
2929 window,
2930 cx,
2931 cy,
2932 seg.from_deg,
2933 seg.to_deg,
2934 ascendant_deg,
2935 rot_offset_deg,
2936 r_from,
2937 color,
2938 dash,
2939 width,
2940 );
2941 }
2942 }
2943 }
2944 }
2945 }
2946 }
2947
2948 // 4. Marcadores de posición exacta. Antes el dot era "el planeta";
2949 // ahora el glyph (con halo, en DOM) lo es. El círculo acá queda
2950 // como marker de precisión angular — chico, alpha alta, sobre el
2951 // anillo correspondiente. Glow se mantiene para Sol/Luna como
2952 // toque místico, pero también reducido.
2953 if show(LayerKind::Bodies) {
2954 let dot_r = (radii.sign_outer * 0.009).max(1.5);
2955 for layer in layers {
2956 if matches!(layer.kind, LayerKind::Bodies) {
2957 let ring = radii.body_ring(&layer.module_id);
2958 let is_natal = layer.module_id == "natal";
2959 let alpha = if is_natal { 1.0 } else { 0.85 };
2960 for g in &layer.glyphs {
2961 let color = with_alpha(planet_color(palette, &g.symbol), alpha);
2962 let (x, y) = polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, ring);
2963 if is_natal && (g.symbol == "sun" || g.symbol == "moon") {
2964 paint_glow(window, cx + x, cy + y, dot_r * 1.8, color);
2965 }
2966 fill_circle(window, cx + x, cy + y, dot_r, color);
2967 }
2968 }
2969 }
2970 }
2971
2972 // Anillos guía para los overlays internos (progression, solar_arc).
2973 let guide_inset = radii.sign_outer * 0.03;
2974 for (module_id, ring) in [
2975 ("progression", radii.progression),
2976 ("solar_arc", radii.solar_arc),
2977 ] {
2978 let active = layers
2979 .iter()
2980 .any(|l| matches!(l.kind, LayerKind::Bodies) && l.module_id == module_id);
2981 if active {
2982 stroke_circle(
2983 window,
2984 cx,
2985 cy,
2986 ring + guide_inset,
2987 0.5,
2988 with_alpha(palette.house_cusp, 0.35),
2989 );
2990 stroke_circle(
2991 window,
2992 cx,
2993 cy,
2994 ring - guide_inset,
2995 0.5,
2996 with_alpha(palette.house_cusp, 0.35),
2997 );
2998 }
2999 }
3000
3001 // 5. Outer ring (transit o synastry overlay): anillo guía + dots
3002 // de la capa activa. Son mutuamente excluyentes a nivel de Shell;
3003 // si alguno de los dos está prendido, pintamos el slot.
3004 let outer_active = layers.iter().any(|l| {
3005 matches!(l.kind, LayerKind::Outer)
3006 && OUTER_RING_MODULES.contains(&l.module_id.as_str())
3007 });
3008 if outer_active && show(LayerKind::Outer) {
3009 let band = radii.sign_outer * 0.035;
3010 stroke_circle_3d(
3011 window,
3012 cx,
3013 cy,
3014 radii.transits + band,
3015 0.7,
3016 with_alpha(palette.dial_ring, 0.55),
3017 theme,
3018 );
3019 stroke_circle_3d(
3020 window,
3021 cx,
3022 cy,
3023 radii.transits - band,
3024 0.7,
3025 with_alpha(palette.dial_ring, 0.55),
3026 theme,
3027 );
3028
3029 let dot_r = (radii.sign_outer * 0.008).max(1.5);
3030 for layer in layers {
3031 if matches!(layer.kind, LayerKind::Outer)
3032 && (OUTER_RING_MODULES.contains(&layer.module_id.as_str()))
3033 {
3034 for g in &layer.glyphs {
3035 let color = with_alpha(planet_color(palette, &g.symbol), 0.85);
3036 let (x, y) =
3037 polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, radii.transits);
3038 fill_circle(window, cx + x, cy + y, dot_r, color);
3039 }
3040 }
3041 }
3042 }
3043}
3044
3045fn paint_sign_sectors(
3046 window: &mut Window,
3047 cx: f32,
3048 cy: f32,
3049 radii: &Radii,
3050 palette: &AstroPalette,
3051 ascendant_deg: f32,
3052 rot_offset_deg: f32,
3053) {
3054 const SUBDIVISIONS: usize = 18;
3055 for i in 0..12 {
3056 let lon_start = (i as f32) * 30.0;
3057 let lon_end = lon_start + 30.0;
3058 let element = sign_element_by_index(i);
3059 let color = with_alpha(palette.element(element), 0.10);
3060
3061 let mut builder = PathBuilder::fill();
3062 let (x0, y0) = polar_to_screen(lon_start, ascendant_deg, rot_offset_deg, radii.sign_inner);
3063 builder.move_to(point(px(cx + x0), px(cy + y0)));
3064
3065 for k in 1..=SUBDIVISIONS {
3066 let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32);
3067 let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_inner);
3068 builder.line_to(point(px(cx + x), px(cy + y)));
3069 }
3070 let (xe, ye) = polar_to_screen(lon_end, ascendant_deg, rot_offset_deg, radii.sign_outer);
3071 builder.line_to(point(px(cx + xe), px(cy + ye)));
3072
3073 for k in (0..SUBDIVISIONS).rev() {
3074 let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32);
3075 let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_outer);
3076 builder.line_to(point(px(cx + x), px(cy + y)));
3077 }
3078 builder.close();
3079 if let Ok(path) = builder.build() {
3080 window.paint_path(path, color);
3081 }
3082 }
3083}
3084
3085fn stroke_circle(window: &mut Window, cx: f32, cy: f32, r: f32, width: f32, color: Hsla) {
3086 const SEGMENTS: usize = 96;
3087 let mut builder = PathBuilder::stroke(px(width));
3088 for i in 0..=SEGMENTS {
3089 let t = (i as f32) / (SEGMENTS as f32) * (2.0 * PI);
3090 let x = cx + r * t.cos();
3091 let y = cy + r * t.sin();
3092 if i == 0 {
3093 builder.move_to(point(px(x), px(y)));
3094 } else {
3095 builder.line_to(point(px(x), px(y)));
3096 }
3097 }
3098 if let Ok(path) = builder.build() {
3099 window.paint_path(path, color);
3100 }
3101}
3102
3103/// Pinta 3 halos concéntricos con alpha decreciente alrededor de un
3104/// punto — usado para Sol/Luna natales. El radio crece, la opacidad
3105/// cae: el ojo lo lee como "esto irradia". Sin glow real (GPUI 0.2 no
3106/// tiene radial gradient), pero el shading concéntrico convence.
3107fn paint_glow(window: &mut Window, cx: f32, cy: f32, base_r: f32, color: Hsla) {
3108 const HALOS: [(f32, f32); 3] = [(5.0, 0.05), (3.0, 0.12), (1.8, 0.22)];
3109 for (mult, alpha) in HALOS {
3110 let r = base_r * mult;
3111 let halo = hsla(color.h, color.s, color.l, alpha);
3112 fill_circle(window, cx, cy, r, halo);
3113 }
3114}
3115
3116fn fill_circle(window: &mut Window, cx: f32, cy: f32, r: f32, color: Hsla) {
3117 const SEGMENTS: usize = 32;
3118 let mut builder = PathBuilder::fill();
3119 builder.move_to(point(px(cx + r), px(cy)));
3120 for i in 1..=SEGMENTS {
3121 let t = (i as f32) / (SEGMENTS as f32) * (2.0 * PI);
3122 let x = cx + r * t.cos();
3123 let y = cy + r * t.sin();
3124 builder.line_to(point(px(x), px(y)));
3125 }
3126 builder.close();
3127 if let Ok(path) = builder.build() {
3128 window.paint_path(path, color);
3129 }
3130}
3131
3132#[allow(clippy::too_many_arguments)]
3133fn paint_radial_line(
3134 window: &mut Window,
3135 cx: f32,
3136 cy: f32,
3137 longitude_deg: f32,
3138 ascendant_deg: f32,
3139 rot_offset_deg: f32,
3140 r_inner: f32,
3141 r_outer: f32,
3142 color: Hsla,
3143 width: f32,
3144) {
3145 let (xi, yi) = polar_to_screen(longitude_deg, ascendant_deg, rot_offset_deg, r_inner);
3146 let (xo, yo) = polar_to_screen(longitude_deg, ascendant_deg, rot_offset_deg, r_outer);
3147 let mut builder = PathBuilder::stroke(px(width));
3148 builder.move_to(point(px(cx + xi), px(cy + yi)));
3149 builder.line_to(point(px(cx + xo), px(cy + yo)));
3150 if let Ok(path) = builder.build() {
3151 window.paint_path(path, color);
3152 }
3153}
3154
3155#[allow(clippy::too_many_arguments)]
3156fn paint_aspect_line(
3157 window: &mut Window,
3158 cx: f32,
3159 cy: f32,
3160 a_deg: f32,
3161 b_deg: f32,
3162 ascendant_deg: f32,
3163 rot_offset_deg: f32,
3164 r: f32,
3165 color: Hsla,
3166 dash: Option<(f32, f32)>,
3167 width: f32,
3168) {
3169 let (xa, ya) = polar_to_screen(a_deg, ascendant_deg, rot_offset_deg, r);
3170 let (xb, yb) = polar_to_screen(b_deg, ascendant_deg, rot_offset_deg, r);
3171 paint_segment(window, cx + xa, cy + ya, cx + xb, cy + yb, color, dash, width);
3172}
3173
3174/// Línea de aspecto natal ↔ tránsito: extremos en radios distintos.
3175/// El `from_deg` cae sobre el ring de cuerpos natales (`r_from`); el
3176/// `to_deg` sobre el ring de tránsito (`r_to`). Trazo más fino que el
3177/// natal-natal para no competir visualmente.
3178#[allow(clippy::too_many_arguments)]
3179fn paint_cross_aspect_line(
3180 window: &mut Window,
3181 cx: f32,
3182 cy: f32,
3183 natal_deg: f32,
3184 transit_deg: f32,
3185 ascendant_deg: f32,
3186 rot_offset_deg: f32,
3187 r_from: f32,
3188 r_to: f32,
3189 color: Hsla,
3190 dash: Option<(f32, f32)>,
3191) {
3192 let (xa, ya) = polar_to_screen(natal_deg, ascendant_deg, rot_offset_deg, r_from);
3193 let (xb, yb) = polar_to_screen(transit_deg, ascendant_deg, rot_offset_deg, r_to);
3194 paint_segment(window, cx + xa, cy + ya, cx + xb, cy + yb, color, dash, 0.7);
3195}
3196
3197/// Pinta un segmento entre dos puntos. Si `dash` es `Some((on, off))`,
3198/// itera el vector pintando trechos de `on` px con gaps de `off` px.
3199/// Si `None`, una sola línea continua. Usado por todos los aspect
3200/// painters — el dash pattern es la forma de distinguir kinds en
3201/// el theme BW (donde el color no sirve).
3202fn paint_segment(
3203 window: &mut Window,
3204 x0: f32,
3205 y0: f32,
3206 x1: f32,
3207 y1: f32,
3208 color: Hsla,
3209 dash: Option<(f32, f32)>,
3210 width: f32,
3211) {
3212 let Some((on, off)) = dash else {
3213 let mut b = PathBuilder::stroke(px(width));
3214 b.move_to(point(px(x0), px(y0)));
3215 b.line_to(point(px(x1), px(y1)));
3216 if let Ok(p) = b.build() {
3217 window.paint_path(p, color);
3218 }
3219 return;
3220 };
3221 let dx = x1 - x0;
3222 let dy = y1 - y0;
3223 let len = (dx * dx + dy * dy).sqrt();
3224 if len < 0.1 {
3225 return;
3226 }
3227 let ux = dx / len;
3228 let uy = dy / len;
3229 let step = on + off;
3230 if step < 0.1 {
3231 return;
3232 }
3233 let mut t = 0.0;
3234 while t < len {
3235 let t_end = (t + on).min(len);
3236 let sx = x0 + ux * t;
3237 let sy = y0 + uy * t;
3238 let ex = x0 + ux * t_end;
3239 let ey = y0 + uy * t_end;
3240 let mut b = PathBuilder::stroke(px(width));
3241 b.move_to(point(px(sx), px(sy)));
3242 b.line_to(point(px(ex), px(ey)));
3243 if let Ok(p) = b.build() {
3244 window.paint_path(p, color);
3245 }
3246 t += step;
3247 }
3248}
3249
3250/// `true` para los 5 aspectos Ptoloméicos (conjunction, sextile,
3251/// square, trine, opposition). Cualquier otro `kind` se considera
3252/// menor — quincunx, semi-square, quintile, sesquiquadrate, etc.
3253fn is_major_aspect(kind: &str) -> bool {
3254 matches!(
3255 kind,
3256 "conjunction" | "sextile" | "square" | "trine" | "opposition"
3257 )
3258}
3259
3260/// Grosor de línea de aspecto inverso al orbe. La idea: a orbe 0°
3261/// (aspecto exacto) la línea va gruesa porque "pesa" más; a orbe
3262/// amplio se afina. Los mayores arrancan en un techo más alto que
3263/// los menores. En BW se le suma un poquito a todos porque las
3264/// líneas competen con sus dash patterns.
3265fn aspect_width(kind: &str, orb_deg: f32, mono: bool) -> f32 {
3266 let orb = orb_deg.abs();
3267 let major = is_major_aspect(kind);
3268 // Orbe de referencia para normalizar: ~8° para mayores, ~3° para
3269 // menores. Más allá la línea ya está afinada al mínimo.
3270 let max_orb = if major { 8.0 } else { 3.0 };
3271 let t = (1.0 - (orb / max_orb)).clamp(0.0, 1.0);
3272 let (min_w, max_w) = if major { (0.7, 2.1) } else { (0.5, 1.2) };
3273 let w = min_w + (max_w - min_w) * t;
3274 if mono { w + 0.2 } else { w }
3275}
3276
3277/// Dash pattern por aspecto, para modo monocromático. En modo color
3278/// el caller pasa `None` y las líneas van sólidas. Patterns elegidos
3279/// para que cada kind sea distinguible a ojo:
3280/// - conjunction/opposition: sólido (más peso visual, son los
3281/// aspectos "fuertes")
3282/// - square: dash medio (4 on / 3 off)
3283/// - trine: dash largo (8 on / 2 off) — casi sólido pero distinguible
3284/// - sextile: dotted (1.5 on / 3 off)
3285/// - minor: dotted finísimo (1 on / 4 off)
3286fn dash_pattern_for_kind(kind: &str) -> Option<(f32, f32)> {
3287 match kind {
3288 "conjunction" | "opposition" => None,
3289 "square" => Some((4.0, 3.0)),
3290 "trine" => Some((8.0, 2.0)),
3291 "sextile" => Some((1.5, 3.0)),
3292 _ => Some((1.0, 4.0)),
3293 }
3294}
3295
3296// =====================================================================
3297// Helpers
3298// =====================================================================
3299
3300/// Distancia mínima entre un punto y un segmento de recta. Usado por
3301/// hover_check para detectar proximity a líneas de aspectos.
3302fn dist_point_segment(px: f32, py: f32, ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
3303 let dx = bx - ax;
3304 let dy = by - ay;
3305 let len_sq = dx * dx + dy * dy;
3306 if len_sq < f32::EPSILON {
3307 // Segmento degenerado → distancia al punto a.
3308 let pdx = px - ax;
3309 let pdy = py - ay;
3310 return (pdx * pdx + pdy * pdy).sqrt();
3311 }
3312 let t = (((px - ax) * dx + (py - ay) * dy) / len_sq).clamp(0.0, 1.0);
3313 let proj_x = ax + t * dx;
3314 let proj_y = ay + t * dy;
3315 let dx2 = px - proj_x;
3316 let dy2 = py - proj_y;
3317 (dx2 * dx2 + dy2 * dy2).sqrt()
3318}
3319
3320// `polar_to_screen` se importa desde `cosmobiologia-render`.
3321use cosmobiologia_render::polar_to_screen;
3322
3323fn centered_glyph(
3324 x: f32,
3325 y: f32,
3326 box_size: f32,
3327 font_size: f32,
3328 text: SharedString,
3329 color: Hsla,
3330) -> gpui::Div {
3331 div()
3332 .absolute()
3333 .left(px(x - box_size / 2.0))
3334 .top(px(y - box_size / 2.0))
3335 .w(px(box_size))
3336 .h(px(box_size))
3337 .flex()
3338 .items_center()
3339 .justify_center()
3340 .text_size(px(font_size))
3341 .text_color(color)
3342 .child(text)
3343}
3344
3345/// Glyph de planeta con disco-halo detrás del char. El disco viene en
3346/// `disk_bg` (semi-opaco para que se vea a través el fondo del wheel)
3347/// y `disk_border` (típicamente el color del planeta). El char por
3348/// dentro va en `text_color` — recomendado el color del planeta sobre
3349/// disco neutro, o color contrastante sobre disco coloreado.
3350fn planet_glyph(
3351 x: f32,
3352 y: f32,
3353 disk_size: f32,
3354 font_size: f32,
3355 text: SharedString,
3356 text_color: Hsla,
3357 disk_bg: Hsla,
3358 disk_border: Hsla,
3359) -> gpui::Div {
3360 div()
3361 .absolute()
3362 .left(px(x - disk_size / 2.0))
3363 .top(px(y - disk_size / 2.0))
3364 .w(px(disk_size))
3365 .h(px(disk_size))
3366 .rounded_full()
3367 .bg(disk_bg)
3368 .border_1()
3369 .border_color(disk_border)
3370 .flex()
3371 .items_center()
3372 .justify_center()
3373 .text_size(px(font_size))
3374 .text_color(text_color)
3375 .child(text)
3376}
3377
3378/// Disco base (px) de un body glyph según `module_id` y kind. Lo
3379/// usan render_wheel (para pintar) y on_hover_check (para
3380/// hit-testear) — ambos deben coincidir o el hover apunta a una
3381/// posición distinta a donde se pinta el disco.
3382fn body_disk_base(module_id: &str, kind: LayerKind, view_scale: f32) -> f32 {
3383 let base = match kind {
3384 LayerKind::Outer => 20.0,
3385 LayerKind::Midpoints => 16.0,
3386 _ => match module_id {
3387 "natal" => 26.0,
3388 "topocentric" => 22.0,
3389 "pd_direct" | "pd_converse" => 20.0,
3390 _ => 22.0,
3391 },
3392 };
3393 base * view_scale
3394}
3395
3396// `spread_angles` y `find_clusters` migraron a `cosmobiologia-render`.
3397use cosmobiologia_render::{find_clusters, spread_angles};
3398
3399// `format_coord_compact` migró a `cosmobiologia-render`.
3400use cosmobiologia_render::format_coord_compact;
3401
3402// Los tests de `spread_angles`, `find_clusters` y
3403// `format_coord_compact` viven ahora en `cosmobiologia-render::math`
3404// junto a sus implementaciones.
3405
3406/// Pill pequeña con un coord ("14°♈") junto al glyph de un planeta
3407/// o cusp. Fondo halo + texto fg_muted, padding mínimo para no
3408/// saturar la rueda con etiquetas grandes.
3409fn coord_label(
3410 x: f32,
3411 y: f32,
3412 text: SharedString,
3413 fg: Hsla,
3414 halo_bg: Hsla,
3415 font_size: f32,
3416) -> gpui::Div {
3417 // Estimación del ancho basada en `chars().count()` (NO `text.len()`
3418 // — los chars unicode astronómicos cuentan 3 bytes pero ocupan
3419 // ~1 columna de fuente). Padding lateral muy pequeño en lugar de
3420 // un mínimo grande: pills con 1-3 chars no llevan "espacios en
3421 // negro" que sobrescriben elementos vecinos.
3422 let char_count = text.chars().count() as f32;
3423 let w = (char_count * font_size * 0.62 + font_size * 0.5).max(font_size * 1.4);
3424 let h = font_size + 5.0;
3425 div()
3426 .absolute()
3427 .left(px(x - w / 2.0))
3428 .top(px(y - h / 2.0))
3429 .w(px(w))
3430 .h(px(h))
3431 .flex()
3432 .items_center()
3433 .justify_center()
3434 .rounded(px(h / 2.0))
3435 .bg(halo_bg)
3436 .text_size(px(font_size))
3437 .text_color(fg)
3438 .child(text)
3439}
3440
3441/// Color HSL semi-opaco para los halos de los glyphs — derivado del
3442/// theme. En dark va casi negro; en light casi blanco. Alpha alta para
3443/// que el char quede legible contra cualquier cosa que haya detrás
3444/// (anillo, líneas de aspecto, starfield).
3445fn glyph_halo(theme: &Theme) -> Hsla {
3446 if theme.is_dark {
3447 hsla(0.0, 0.0, 0.07, 0.92)
3448 } else {
3449 hsla(0.0, 0.0, 0.97, 0.92)
3450 }
3451}
3452
3453fn with_alpha(c: Hsla, a: f32) -> Hsla {
3454 hsla(c.h, c.s, c.l, a.clamp(0.0, 1.0))
3455}
3456
3457/// Devuelve `c` con la luminancia modificada por `delta` (clamp 0..1).
3458/// Útil para derivar highlight (+luma) y shadow (-luma) de un color
3459/// base manteniendo hue y saturación — efecto bevel/3D barato.
3460fn adjust_luma(c: Hsla, delta: f32) -> Hsla {
3461 hsla(c.h, c.s, (c.l + delta).clamp(0.0, 1.0), c.a)
3462}
3463
3464/// Devuelve `c` con el hue desplazado `delta_deg` grados sobre el
3465/// círculo cromático (wrap a [0,1] en la escala normalizada de gpui).
3466/// Usado para derivar el color del anillo de casas desde el del dial
3467/// zodiacal — los dos sistemas (eclíptica vs ascensional) deben
3468/// distinguirse a primera vista pero compartir "familia" cromática.
3469fn shift_hue(c: Hsla, delta_deg: f32) -> Hsla {
3470 let new_h = (c.h + delta_deg / 360.0).rem_euclid(1.0);
3471 hsla(new_h, c.s, c.l, c.a)
3472}
3473
3474/// Color para los anillos del sistema de casas (ascensional). En
3475/// paletas con color, lo derivamos de `house_cusp` con un hue shift
3476/// de ~140° para diferenciar de la eclíptica (que va con el dorado
3477/// de `dial_ring`). En BW devolvemos `house_cusp` tal cual — un
3478/// shift cromático en monocromo es ruido sin información.
3479fn house_ring_color(palette: &AstroPalette) -> Hsla {
3480 if palette.is_monochrome() {
3481 palette.house_cusp
3482 } else {
3483 shift_hue(palette.house_cusp, 140.0)
3484 }
3485}
3486
3487/// Stroke con efecto embossed: 3 trazos concéntricos. El highlight va
3488/// 0.7 px hacia el centro con luminancia subida; el principal en `r`;
3489/// el shadow 0.7 px hacia afuera con luminancia bajada. La dirección
3490/// del bevel depende del theme: en dark el highlight es exterior (luz
3491/// "desde arriba"), en light interior (sombra "desde arriba" hacia
3492/// el centro).
3493fn stroke_circle_3d(
3494 window: &mut Window,
3495 cx: f32,
3496 cy: f32,
3497 r: f32,
3498 width: f32,
3499 color: Hsla,
3500 theme: &Theme,
3501) {
3502 let (hl_offset, sh_offset) = if theme.is_dark {
3503 (-0.7, 0.7)
3504 } else {
3505 (0.7, -0.7)
3506 };
3507 let hl = with_alpha(adjust_luma(color, 0.20), color.a * 0.55);
3508 let sh = with_alpha(adjust_luma(color, -0.18), color.a * 0.55);
3509 stroke_circle(window, cx, cy, r + hl_offset, (width * 0.7).max(0.4), hl);
3510 stroke_circle(window, cx, cy, r, width, color);
3511 stroke_circle(window, cx, cy, r + sh_offset, (width * 0.7).max(0.4), sh);
3512}
3513
3514/// Bevel central del anillo de signos: ~10 strokes finos entre
3515/// sign_inner y sign_outer, con alpha en bell curve (máximo en el
3516/// medio, decae hacia los bordes). Genera la sensación de volumen
3517/// sin pintar gradient radial (no soportado en gpui canvas).
3518fn paint_dial_bevel(
3519 window: &mut Window,
3520 cx: f32,
3521 cy: f32,
3522 radii: &Radii,
3523 palette: &AstroPalette,
3524 theme: &Theme,
3525) {
3526 let steps = 10;
3527 let base = if theme.is_dark { 0.07 } else { 0.10 };
3528 let color = palette.dial_ring;
3529 for i in 0..steps {
3530 let t = (i as f32 + 0.5) / steps as f32;
3531 let r = radii.sign_inner + (radii.sign_outer - radii.sign_inner) * t;
3532 // Bell curve simétrica: |t-0.5|*2 da 0..1 desde el centro, lo
3533 // invertimos para que el centro tenga peso máximo.
3534 let bell = 1.0 - ((t - 0.5).abs() * 2.0);
3535 let a = base * bell;
3536 stroke_circle(window, cx, cy, r, 1.0, with_alpha(color, a));
3537 }
3538}
3539
3540fn sign_unicode(name: &str) -> &'static str {
3541 match name {
3542 "aries" => "♈",
3543 "taurus" => "♉",
3544 "gemini" => "♊",
3545 "cancer" => "♋",
3546 "leo" => "♌",
3547 "virgo" => "♍",
3548 "libra" => "♎",
3549 "scorpio" => "♏",
3550 "sagittarius" => "♐",
3551 "capricorn" => "♑",
3552 "aquarius" => "♒",
3553 "pisces" => "♓",
3554 _ => "?",
3555 }
3556}
3557
3558const SIGN_NAMES_ES: [&str; 12] = [
3559 "Aries",
3560 "Tauro",
3561 "Géminis",
3562 "Cáncer",
3563 "Leo",
3564 "Virgo",
3565 "Libra",
3566 "Escorpio",
3567 "Sagitario",
3568 "Capricornio",
3569 "Acuario",
3570 "Piscis",
3571];
3572
3573fn aspect_unicode(kind: &str) -> &'static str {
3574 match kind {
3575 "conjunction" => "☌",
3576 "opposition" => "☍",
3577 "trine" => "△",
3578 "square" => "□",
3579 "sextile" => "⚹",
3580 "quincunx" => "⚻",
3581 "semi_sextile" => "⚺",
3582 "semi_square" => "∠",
3583 "sesquiquadrate" => "⚼",
3584 "quintile" => "Q",
3585 "biquintile" => "bQ",
3586 _ => "·",
3587 }
3588}
3589
3590fn planet_unicode(name: &str) -> &'static str {
3591 match name {
3592 "sun" => "☉",
3593 "moon" => "☽",
3594 "mercury" => "☿",
3595 "venus" => "♀",
3596 "mars" => "♂",
3597 "jupiter" => "♃",
3598 "saturn" => "♄",
3599 "uranus" => "♅",
3600 "neptune" => "♆",
3601 "pluto" => "♇",
3602 "north_node" => "☊",
3603 "south_node" => "☋",
3604 "chiron" => "⚷",
3605 "lilith" => "⚸",
3606 "ceres" => "⚳",
3607 "pallas" => "⚴",
3608 "juno" => "⚵",
3609 "vesta" => "⚶",
3610 _ => "•",
3611 }
3612}
3613
3614fn planet_color(p: &AstroPalette, name: &str) -> Hsla {
3615 let planet = match name {
3616 "sun" => Planet::Sun,
3617 "moon" => Planet::Moon,
3618 "mercury" => Planet::Mercury,
3619 "venus" => Planet::Venus,
3620 "mars" => Planet::Mars,
3621 "jupiter" => Planet::Jupiter,
3622 "saturn" => Planet::Saturn,
3623 "uranus" => Planet::Uranus,
3624 "neptune" => Planet::Neptune,
3625 "pluto" => Planet::Pluto,
3626 "chiron" => Planet::Chiron,
3627 "north_node" => Planet::NorthNode,
3628 "south_node" => Planet::SouthNode,
3629 "lilith" => Planet::Lilith,
3630 _ => return p.fg_text_fallback(),
3631 };
3632 p.planet(planet)
3633}
3634
3635fn sign_element_by_index(i: usize) -> Element {
3636 match i % 4 {
3637 0 => Element::Fire,
3638 1 => Element::Earth,
3639 2 => Element::Air,
3640 _ => Element::Water,
3641 }
3642}
3643
3644fn element_color_for_sign(p: &AstroPalette, name: &str) -> Hsla {
3645 let elem = match name {
3646 "aries" | "leo" | "sagittarius" => Element::Fire,
3647 "taurus" | "virgo" | "capricorn" => Element::Earth,
3648 "gemini" | "libra" | "aquarius" => Element::Air,
3649 "cancer" | "scorpio" | "pisces" => Element::Water,
3650 _ => return p.fg_text_fallback(),
3651 };
3652 p.element(elem)
3653}
3654
3655fn aspect_color(p: &AstroPalette, kind: &str) -> Hsla {
3656 let k = match kind {
3657 "conjunction" => TAspectKind::Conjunction,
3658 "opposition" => TAspectKind::Opposition,
3659 "trine" => TAspectKind::Trine,
3660 "square" => TAspectKind::Square,
3661 "sextile" => TAspectKind::Sextile,
3662 "quincunx" => TAspectKind::Quincunx,
3663 "semi_sextile" => TAspectKind::Semisextile,
3664 "semi_square" => TAspectKind::Semisquare,
3665 "sesquiquadrate" => TAspectKind::Sesquisquare,
3666 "quintile" => TAspectKind::Quintile,
3667 "biquintile" => TAspectKind::Biquintile,
3668 _ => return p.minor_aspect,
3669 };
3670 p.aspect(k)
3671}
3672
3673trait AstroPaletteExt {
3674 fn fg_text_fallback(&self) -> Hsla;
3675}
3676
3677impl AstroPaletteExt for AstroPalette {
3678 fn fg_text_fallback(&self) -> Hsla {
3679 if self.is_dark {
3680 hsla(0.0, 0.0, 0.85, 1.0)
3681 } else {
3682 hsla(0.0, 0.0, 0.25, 1.0)
3683 }
3684 }
3685}