gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198629 Definiciones

⎇ a0f67fd86f4375a1d4e1825c2c869805175c9a16 crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs Rust · 3146 líneas · 115.9 KB Historia Quién tocó qué crudo

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