tawasuyu

tawasuyu/brahman

Rust 326TOML 123JSON 17Markdown 144606 Sutichaykuna

⎇ 11d8bcb4aff4064af7fca37c25381197551c5e67 crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs Rust · 3285 siq'ikuna · 120.5 KB Ñawpa willay Pi imata llamkarqan ch'uya

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