tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Sutichaykuna

⎇ 72758e75ce515f355f27615671c95ecaf8c2a8bc crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs Rust · 3239 siq'ikuna · 120.4 KB Ñawpa willay Pi imata llamkarqan ch'uya

fix(tahuantinsuyu): hit-test del hover usa display_deg post-spread 0e66cda07 · sergio ·

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