gioser

tawasuyu/brahman

Rust 231TOML 70JSON 14HTML 12905 Definitions

⎇ 6a0781c0a811c29aa592c34c97351dc166916cc6 crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs Rust · 3685 lines · 134.8 KB History Who touched what raw

feat(cosmobiologia): esfera celeste 3D — la carta como objeto rotable 6a0781c0a · sergio ·

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}