tawasuyu

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Definiciones

⎇ 6191ce7dee1e6336b0dfd8becaf47ca8e32a96a6 crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs Rust · 1919 líneas · 65.2 KB Historia Quién tocó qué crudo

feat(tahuantinsuyu): fase 25 — production polish (tree search + hotkey SVG + aspect tooltips) 6191ce7de · 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    AppContext, Bounds, Context, EventEmitter, FocusHandle, Focusable, Hsla, IntoElement,
38    KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement,
39    PathBuilder, Pixels, Point, Render, SharedString, Styled, Window, canvas, div, hsla,
40    linear_color_stop, linear_gradient, 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 pidió exportar el render actual como SVG. El shell
63    /// se encarga de escribir el archivo (la engine genera el string).
64    ExportSvgRequested,
65}
66
67// =====================================================================
68// Estado
69// =====================================================================
70
71#[derive(Clone, Debug, Default)]
72pub enum CanvasMode {
73    #[default]
74    Empty,
75    Wheel { render: Box<RenderModel> },
76    Thumbnails {
77        scope: ThumbnailScope,
78        items: Vec<ThumbnailItem>,
79    },
80}
81
82#[derive(Clone, Debug)]
83pub enum ThumbnailScope {
84    Group(GroupId),
85    Contact(ContactId),
86}
87
88#[derive(Clone, Debug)]
89pub struct ThumbnailItem {
90    pub chart_id: ChartId,
91    pub label: SharedString,
92    pub subtitle: Option<SharedString>,
93    pub preview: Option<RenderModel>,
94}
95
96/// Estado de un drag activo del jog-dial. `last_screen_angle_deg` se
97/// actualiza en cada `MouseMoveEvent`; `accumulated_delta_deg` lleva la
98/// rotación total desde que arrancó el drag (puede pasar de ±360°).
99#[derive(Clone, Debug)]
100struct JogDragState {
101    last_screen_angle_deg: f32,
102    accumulated_delta_deg: f32,
103}
104
105#[derive(Clone, Debug)]
106pub struct CanvasState {
107    pub mode: CanvasMode,
108    /// Rotación visual transitoria durante un drag. Se resetea a `0` al
109    /// soltar — el render nuevo trae el `ascendant_deg` ya rotado.
110    pub view_rotation_deg: f32,
111    /// Offset acumulado en minutos. Persiste entre drags hasta que el
112    /// host lo resetee.
113    pub time_offset_minutes: i64,
114    /// Por-LayerKind: `true` = visible. Default = todo visible.
115    pub layer_visibility: HashMap<LayerKind, bool>,
116    /// Planeta hovered actualmente (para tooltip). `None` cuando el
117    /// mouse no está sobre ningún cuerpo.
118    pub hover: Option<HoverInfo>,
119    drag_jog: Option<JogDragState>,
120}
121
122/// Info del elemento bajo el cursor — usado por el render para mostrar
123/// un tooltip flotante con detalles. Cubre body glyphs, cusps de casa,
124/// y líneas de aspectos.
125#[derive(Clone, Debug)]
126pub enum HoverInfo {
127    Body {
128        module_id: String,
129        symbol: String,
130        deg: f32,
131        house: Option<u8>,
132        retrograde: bool,
133        dignity_marker: Option<String>,
134        annotation: Option<String>,
135        local_x: f32,
136        local_y: f32,
137    },
138    HouseCusp {
139        house_number: u8,
140        deg: f32,
141        local_x: f32,
142        local_y: f32,
143    },
144    /// Hover sobre una línea de aspecto. `from_body`/`to_body` y `kind`
145    /// vienen de la LineSeg; `orb_deg` también. Los coords son el
146    /// punto medio del segmento donde se muestra el tooltip.
147    Aspect {
148        module_id: String,
149        from_body: String,
150        to_body: String,
151        kind: String,
152        orb_deg: f32,
153        local_x: f32,
154        local_y: f32,
155    },
156}
157
158impl HoverInfo {
159    fn local(&self) -> (f32, f32) {
160        match self {
161            HoverInfo::Body { local_x, local_y, .. } => (*local_x, *local_y),
162            HoverInfo::HouseCusp { local_x, local_y, .. } => (*local_x, *local_y),
163            HoverInfo::Aspect { local_x, local_y, .. } => (*local_x, *local_y),
164        }
165    }
166
167    fn key(&self) -> String {
168        match self {
169            HoverInfo::Body {
170                module_id, symbol, ..
171            } => format!("body:{}:{}", module_id, symbol),
172            HoverInfo::HouseCusp { house_number, .. } => format!("cusp:{}", house_number),
173            HoverInfo::Aspect {
174                module_id,
175                from_body,
176                to_body,
177                kind,
178                ..
179            } => format!("aspect:{}:{}-{}-{}", module_id, from_body, kind, to_body),
180        }
181    }
182}
183
184impl Default for CanvasState {
185    fn default() -> Self {
186        Self {
187            mode: CanvasMode::default(),
188            view_rotation_deg: 0.0,
189            time_offset_minutes: 0,
190            layer_visibility: HashMap::new(),
191            hover: None,
192            drag_jog: None,
193        }
194    }
195}
196
197impl CanvasState {
198    pub fn is_layer_visible(&self, kind: LayerKind) -> bool {
199        self.layer_visibility.get(&kind).copied().unwrap_or(true)
200    }
201}
202
203// =====================================================================
204// Widget
205// =====================================================================
206
207pub struct AstrologyCanvas {
208    state: CanvasState,
209    focus_handle: FocusHandle,
210}
211
212impl EventEmitter<CanvasEvent> for AstrologyCanvas {}
213
214impl Focusable for AstrologyCanvas {
215    fn focus_handle(&self, _: &gpui::App) -> FocusHandle {
216        self.focus_handle.clone()
217    }
218}
219
220impl AstrologyCanvas {
221    pub fn new(cx: &mut Context<Self>) -> Self {
222        cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
223        Self {
224            state: CanvasState::default(),
225            focus_handle: cx.focus_handle(),
226        }
227    }
228
229    pub fn state(&self) -> &CanvasState {
230        &self.state
231    }
232
233    pub fn set_mode(&mut self, mode: CanvasMode, cx: &mut Context<Self>) {
234        self.state.mode = mode;
235        cx.notify();
236    }
237
238    pub fn set_layer_visible(&mut self, kind: LayerKind, visible: bool, cx: &mut Context<Self>) {
239        self.state.layer_visibility.insert(kind, visible);
240        cx.notify();
241    }
242
243    pub fn toggle_layer(&mut self, kind: LayerKind, cx: &mut Context<Self>) {
244        let current = self.state.is_layer_visible(kind);
245        self.set_layer_visible(kind, !current, cx);
246        cx.emit(CanvasEvent::LayerVisibilityChanged {
247            kind,
248            visible: !current,
249        });
250    }
251
252    pub fn reset_time_offset(&mut self, cx: &mut Context<Self>) {
253        if self.state.time_offset_minutes != 0 || self.state.view_rotation_deg != 0.0 {
254            self.state.time_offset_minutes = 0;
255            self.state.view_rotation_deg = 0.0;
256            cx.emit(CanvasEvent::TimeOffsetChanged(0));
257            cx.notify();
258        }
259    }
260
261    pub fn set_view_rotation(&mut self, deg: f32, cx: &mut Context<Self>) {
262        self.state.view_rotation_deg = deg.rem_euclid(360.0);
263        cx.notify();
264    }
265
266    // ----- Internos: handlers de jog-dial -----
267
268    fn on_jog_down(
269        &mut self,
270        position: Point<Pixels>,
271        bounds: Bounds<Pixels>,
272        cx: &mut Context<Self>,
273    ) {
274        let (cx_px, cy_px) = bounds_center(bounds);
275        let mx: f32 = position.x.into();
276        let my: f32 = position.y.into();
277        let dx = mx - cx_px;
278        let dy = my - cy_px;
279        let dist = (dx * dx + dy * dy).sqrt();
280        let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
281        let radii = Radii::from_outer(r_outer);
282        // Aro de captura un poco más generoso que el anillo del dial.
283        if dist < radii.sign_inner * 0.95 || dist > radii.sign_outer * 1.10 {
284            return;
285        }
286        let angle = dy.atan2(dx).to_degrees();
287        self.state.drag_jog = Some(JogDragState {
288            last_screen_angle_deg: angle,
289            accumulated_delta_deg: 0.0,
290        });
291    }
292
293    fn on_jog_move(
294        &mut self,
295        position: Point<Pixels>,
296        bounds: Bounds<Pixels>,
297        cx: &mut Context<Self>,
298    ) {
299        let Some(jog) = self.state.drag_jog.as_mut() else {
300            return;
301        };
302        let (cx_px, cy_px) = bounds_center(bounds);
303        let mx: f32 = position.x.into();
304        let my: f32 = position.y.into();
305        let dx = mx - cx_px;
306        let dy = my - cy_px;
307        let angle = dy.atan2(dx).to_degrees();
308        let mut delta = angle - jog.last_screen_angle_deg;
309        // Normalizar a (-180, 180] para cruzar el wrap sin saltar.
310        if delta > 180.0 {
311            delta -= 360.0;
312        } else if delta < -180.0 {
313            delta += 360.0;
314        }
315        jog.accumulated_delta_deg += delta;
316        jog.last_screen_angle_deg = angle;
317        // Reflejo visual durante el drag (sin recomputar).
318        self.state.view_rotation_deg = jog.accumulated_delta_deg;
319        cx.notify();
320    }
321
322    /// Hit-test sobre body glyphs + house cusps. Para bodies: distancia
323    /// al centro del glyph dentro de threshold. Para cusps: el mouse
324    /// debe estar cerca del ring de casas Y angularmente cerca del
325    /// cusp (proximidad a la línea radial).
326    fn on_hover_check(
327        &mut self,
328        position: Point<Pixels>,
329        bounds: Bounds<Pixels>,
330        cx: &mut Context<Self>,
331    ) {
332        let CanvasMode::Wheel { render } = &self.state.mode else {
333            if self.state.hover.take().is_some() {
334                cx.notify();
335            }
336            return;
337        };
338        let (cx_px, cy_px) = bounds_center(bounds);
339        let mx: f32 = position.x.into();
340        let my: f32 = position.y.into();
341        let ox: f32 = bounds.origin.x.into();
342        let oy: f32 = bounds.origin.y.into();
343        let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
344        let radii = Radii::from_outer(r_outer);
345        let asc = render.ascendant_deg;
346        let rot = self.state.view_rotation_deg;
347        let body_threshold = 14.0_f32;
348
349        let mut best: Option<(f32, HoverInfo)> = None;
350
351        // 1) Body glyphs (incluye natal, overlays, midpoints).
352        for layer in &render.layers {
353            let ring = match layer.kind {
354                LayerKind::Bodies => radii.body_ring(&layer.module_id),
355                LayerKind::Midpoints => radii.midpoints,
356                LayerKind::Outer if OUTER_RING_MODULES.contains(&layer.module_id.as_str()) => {
357                    radii.transits
358                }
359                _ => continue,
360            };
361            for g in &layer.glyphs {
362                let (gx, gy) = polar_to_screen(g.deg, asc, rot, ring);
363                let dx = mx - (cx_px + gx);
364                let dy = my - (cy_px + gy);
365                let dist = (dx * dx + dy * dy).sqrt();
366                if dist > body_threshold {
367                    continue;
368                }
369                if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
370                    best = Some((
371                        dist,
372                        HoverInfo::Body {
373                            module_id: layer.module_id.clone(),
374                            symbol: g.symbol.clone(),
375                            deg: g.deg,
376                            house: g.house,
377                            retrograde: g.retrograde,
378                            dignity_marker: g.dignity_marker.clone(),
379                            annotation: g.annotation.clone(),
380                            local_x: cx_px + gx - ox,
381                            local_y: cy_px + gy - oy,
382                        },
383                    ));
384                }
385            }
386        }
387
388        // 2) Aspect lines (segundo: las líneas son más "frágiles" que
389        // los planetas; si un body matcheó arriba ya tomó precedencia).
390        // Computa distancia punto-segmento del mouse al line.
391        if best.is_none() {
392            for layer in &render.layers {
393                if !matches!(layer.kind, LayerKind::Aspects) {
394                    continue;
395                }
396                let (r_from, r_to) = radii.aspect_endpoints(&layer.module_id);
397                if let Geometry::Lines(segs) = &layer.geometry {
398                    for seg in segs {
399                        if seg.from_body.is_empty() || seg.to_body.is_empty() {
400                            continue;
401                        }
402                        let (ax, ay) = polar_to_screen(seg.from_deg, asc, rot, r_from);
403                        let (bx, by) = polar_to_screen(seg.to_deg, asc, rot, r_to);
404                        let px_a = cx_px + ax;
405                        let py_a = cy_px + ay;
406                        let px_b = cx_px + bx;
407                        let py_b = cy_px + by;
408                        let dist = dist_point_segment(mx, my, px_a, py_a, px_b, py_b);
409                        if dist > 4.0 {
410                            continue;
411                        }
412                        if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
413                            let mid_x = (px_a + px_b) / 2.0;
414                            let mid_y = (py_a + py_b) / 2.0;
415                            best = Some((
416                                dist,
417                                HoverInfo::Aspect {
418                                    module_id: layer.module_id.clone(),
419                                    from_body: seg.from_body.clone(),
420                                    to_body: seg.to_body.clone(),
421                                    kind: seg.kind.clone(),
422                                    orb_deg: seg.orb_deg,
423                                    local_x: mid_x - ox,
424                                    local_y: mid_y - oy,
425                                },
426                            ));
427                        }
428                    }
429                }
430            }
431        }
432
433        // 3) House cusps — solo si el mouse está cerca del anillo de
434        // casas (radio entre houses_inner y houses_outer + margen) y
435        // ningún body ganó. Las cusps son líneas radiales — la
436        // distancia angular al cusp más cercano determina el hit.
437        if best.is_none() {
438            let dx = mx - cx_px;
439            let dy = my - cy_px;
440            let mouse_r = (dx * dx + dy * dy).sqrt();
441            let r_in = radii.houses_inner - 6.0;
442            let r_out = radii.houses_outer + 6.0;
443            if mouse_r >= r_in && mouse_r <= r_out {
444                // Calcular la longitud zodiacal que corresponde a este
445                // ángulo de pantalla (inversa de polar_to_screen).
446                let screen_angle_deg = dy.atan2(dx).to_degrees(); // (-180, 180]
447                // polar_to_screen: deg = 180 - (lon - asc + rot)
448                // → lon = asc + 180 - screen_angle_deg - rot
449                let lon = ((asc + 180.0 - screen_angle_deg - rot) as f32).rem_euclid(360.0);
450                // Buscar cusp más cercano (con wraparound).
451                for layer in &render.layers {
452                    if matches!(layer.kind, LayerKind::Houses) {
453                        if let Geometry::Ring { cusps_deg } = &layer.geometry {
454                            for (i, c) in cusps_deg.iter().enumerate() {
455                                let mut diff = (lon - c).abs();
456                                if diff > 180.0 {
457                                    diff = 360.0 - diff;
458                                }
459                                if diff < 2.5 {
460                                    // Mouse cerca de ESTE cusp.
461                                    let (gx, gy) = polar_to_screen(
462                                        *c,
463                                        asc,
464                                        rot,
465                                        (radii.houses_inner + radii.houses_outer) / 2.0,
466                                    );
467                                    best = Some((
468                                        diff,
469                                        HoverInfo::HouseCusp {
470                                            house_number: (i as u8) + 1,
471                                            deg: *c,
472                                            local_x: cx_px + gx - ox,
473                                            local_y: cy_px + gy - oy,
474                                        },
475                                    ));
476                                    break;
477                                }
478                            }
479                        }
480                    }
481                }
482            }
483        }
484
485        let new_hover = best.map(|(_, h)| h);
486        let changed = match (&self.state.hover, &new_hover) {
487            (Some(a), Some(b)) => a.key() != b.key(),
488            (None, None) => false,
489            _ => true,
490        };
491        if changed {
492            self.state.hover = new_hover;
493            cx.notify();
494        }
495    }
496
497    fn on_jog_up(&mut self, cx: &mut Context<Self>) {
498        let Some(jog) = self.state.drag_jog.take() else {
499            return;
500        };
501        // 1° de arco ≈ 4 minutos de tiempo sideral (15°/hora).
502        // CW visual (delta negativa en nuestra convención) → tiempo
503        // hacia adelante.
504        let delta_minutes = (-jog.accumulated_delta_deg * 4.0) as i64;
505        if delta_minutes != 0 {
506            self.state.time_offset_minutes =
507                self.state.time_offset_minutes.saturating_add(delta_minutes);
508            // Snap visual: el shell recomputa con el nuevo offset y el
509            // render trae el ascendant rotado.
510            self.state.view_rotation_deg = 0.0;
511            cx.emit(CanvasEvent::TimeOffsetChanged(self.state.time_offset_minutes));
512            cx.notify();
513        } else {
514            self.state.view_rotation_deg = 0.0;
515            cx.notify();
516        }
517    }
518
519    fn on_key_down(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
520        let key = event.keystroke.key.as_str();
521        let kind = match key {
522            "d" | "D" => LayerKind::SignDial,
523            "h" | "H" => LayerKind::Houses,
524            "x" | "X" => LayerKind::Aspects,
525            "p" | "P" => LayerKind::Bodies,
526            "t" | "T" => LayerKind::Outer,
527            "r" | "R" => {
528                self.reset_time_offset(cx);
529                return;
530            }
531            "s" | "S" => {
532                cx.emit(CanvasEvent::ExportSvgRequested);
533                return;
534            }
535            _ => return,
536        };
537        self.toggle_layer(kind, cx);
538    }
539}
540
541// =====================================================================
542// Geometría de pantalla
543// =====================================================================
544
545const WHEEL_SIZE: f32 = 580.0;
546const WHEEL_MARGIN: f32 = 28.0;
547
548fn bounds_center(bounds: Bounds<Pixels>) -> (f32, f32) {
549    let ox: f32 = bounds.origin.x.into();
550    let oy: f32 = bounds.origin.y.into();
551    let bw: f32 = bounds.size.width.into();
552    let bh: f32 = bounds.size.height.into();
553    (ox + bw / 2.0, oy + bh / 2.0)
554}
555
556// =====================================================================
557// Render
558// =====================================================================
559
560impl Render for AstrologyCanvas {
561    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
562        let theme = Theme::global(cx).clone();
563        let palette = AstroPalette::for_theme(&theme);
564        let entity = cx.entity();
565        let focus = self.focus_handle.clone();
566
567        let body = match &self.state.mode {
568            CanvasMode::Empty => render_empty(&theme),
569            CanvasMode::Wheel { render } => render_wheel(
570                &theme,
571                &palette,
572                render,
573                self.state.view_rotation_deg,
574                self.state.time_offset_minutes,
575                &self.state.layer_visibility,
576                self.state.hover.as_ref(),
577                entity,
578            ),
579            CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items),
580        };
581
582        div()
583            .id("astrology-canvas-root")
584            .track_focus(&focus)
585            .key_context("AstrologyCanvas")
586            .on_key_down(cx.listener(Self::on_key_down))
587            .on_mouse_down(
588                MouseButton::Left,
589                cx.listener(|this, _, w, _cx| {
590                    w.focus(&this.focus_handle);
591                }),
592            )
593            .size_full()
594            .bg(theme.bg_panel.clone())
595            .flex()
596            .flex_col()
597            .items_center()
598            .justify_center()
599            .child(body)
600    }
601}
602
603// =====================================================================
604// Modos: empty / thumbnails / wheel
605// =====================================================================
606
607fn render_empty(theme: &Theme) -> gpui::Div {
608    div()
609        .flex()
610        .flex_col()
611        .items_center()
612        .justify_center()
613        .gap(px(12.0))
614        .child(
615            div()
616                .text_size(px(13.0))
617                .text_color(theme.fg_muted)
618                .child("Tahuantinsuyu"),
619        )
620        .child(
621            div()
622                .text_size(px(11.0))
623                .text_color(theme.fg_disabled)
624                .child("Seleccioná una carta en el árbol para empezar."),
625        )
626}
627
628fn render_thumbnails(theme: &Theme, items: &[ThumbnailItem]) -> gpui::Div {
629    if items.is_empty() {
630        return div()
631            .text_size(px(12.0))
632            .text_color(theme.fg_muted)
633            .child("Sin cartas en este grupo todavía.");
634    }
635    let mut row = div().flex().flex_row().flex_wrap().gap(px(12.0));
636    for it in items {
637        row = row.child(
638            div()
639                .w(px(140.0))
640                .h(px(160.0))
641                .rounded(px(6.0))
642                .border_1()
643                .border_color(theme.border)
644                .bg(theme.bg_panel_alt.clone())
645                .flex()
646                .flex_col()
647                .items_center()
648                .justify_end()
649                .pb(px(8.0))
650                .child(
651                    div()
652                        .text_size(px(11.0))
653                        .text_color(theme.fg_text)
654                        .child(it.label.clone()),
655                ),
656        );
657    }
658    row
659}
660
661// =====================================================================
662// Wheel
663// =====================================================================
664
665#[allow(clippy::too_many_arguments)]
666fn render_wheel(
667    theme: &Theme,
668    palette: &AstroPalette,
669    render: &RenderModel,
670    view_rotation_deg: f32,
671    time_offset_minutes: i64,
672    layer_visibility: &HashMap<LayerKind, bool>,
673    hover: Option<&HoverInfo>,
674    entity: gpui::Entity<AstrologyCanvas>,
675) -> gpui::Div {
676    let asc = render.ascendant_deg;
677    let rot_offset = view_rotation_deg;
678    let cx_center = WHEEL_SIZE / 2.0;
679    let cy_center = WHEEL_SIZE / 2.0;
680    let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
681    let radii = Radii::from_outer(r_outer);
682
683    let visible = layer_visibility.clone();
684
685    // --- Canvas element con todo el trazo + jog-dial drag ---
686    let palette_paint = palette.clone();
687    let theme_paint = theme.clone();
688    let layers_paint: Vec<Layer> = render.layers.clone();
689    let asc_for_paint = asc;
690    let mc_for_paint = render.midheaven_deg;
691    let visibility_for_paint = visible.clone();
692    let entity_for_canvas = entity.clone();
693    let canvas_element = canvas(
694        move |_b: Bounds<Pixels>, _w, _cx| (),
695        move |bounds: Bounds<Pixels>, _, window, _| {
696            // Painting de la rueda.
697            paint_wheel(
698                bounds,
699                window,
700                &theme_paint,
701                &palette_paint,
702                &layers_paint,
703                asc_for_paint,
704                mc_for_paint,
705                rot_offset,
706                radii,
707                &visibility_for_paint,
708            );
709
710            // Handlers de mouse para el jog-dial — se registran cada
711            // frame contra el window; GPUI los reemplaza al re-renderear.
712            let entity_d = entity_for_canvas.clone();
713            window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| {
714                if ev.button != MouseButton::Left {
715                    return;
716                }
717                if !bounds.contains(&ev.position) {
718                    return;
719                }
720                entity_d.update(cx, |this, cx| this.on_jog_down(ev.position, bounds, cx));
721            });
722            let entity_m = entity_for_canvas.clone();
723            window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
724                if ev.dragging() {
725                    entity_m.update(cx, |this, cx| this.on_jog_move(ev.position, bounds, cx));
726                } else if bounds.contains(&ev.position) {
727                    // Mouse hover sin drag: hit-test sobre los body
728                    // glyphs para el tooltip.
729                    entity_m.update(cx, |this, cx| this.on_hover_check(ev.position, bounds, cx));
730                } else {
731                    entity_m.update(cx, |this, cx| {
732                        if this.state.hover.take().is_some() {
733                            cx.notify();
734                        }
735                    });
736                }
737            });
738            let entity_u = entity_for_canvas.clone();
739            window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
740                entity_u.update(cx, |this, cx| this.on_jog_up(cx));
741            });
742        },
743    )
744    .absolute()
745    .w(px(WHEEL_SIZE))
746    .h(px(WHEEL_SIZE));
747
748    // Gradient sutil diagonal en el fondo del wheel — toque "místico
749    // velado": alpha muy baja, así no compite con la geometría pintada
750    // encima pero llena las zonas vacías (esquinas del cuadrado, gaps
751    // entre anillos) con un shimmer mineral.
752    let wheel_bg = linear_gradient(
753        155.0,
754        linear_color_stop(with_alpha(palette.dial_ring, 0.06), 0.0),
755        linear_color_stop(with_alpha(palette.angle_highlight, 0.03), 1.0),
756    );
757
758    let mut wheel = div()
759        .relative()
760        .w(px(WHEEL_SIZE))
761        .h(px(WHEEL_SIZE))
762        .bg(wheel_bg)
763        .rounded(px(12.0))
764        .child(canvas_element);
765
766    // Sign glyphs.
767    if visible.get(&LayerKind::SignDial).copied().unwrap_or(true) {
768        let sign_ring_mid = (radii.sign_outer + radii.sign_inner) / 2.0;
769        for layer in &render.layers {
770            if matches!(layer.kind, LayerKind::SignDial) {
771                for g in &layer.glyphs {
772                    let (x, y) = polar_to_screen(g.deg, asc, rot_offset, sign_ring_mid);
773                    let color = element_color_for_sign(palette, &g.symbol);
774                    wheel = wheel.child(centered_glyph(
775                        cx_center + x,
776                        cy_center + y,
777                        20.0,
778                        18.0,
779                        sign_unicode(&g.symbol).into(),
780                        color,
781                    ));
782                }
783            }
784        }
785    }
786
787    // House numbers.
788    if visible.get(&LayerKind::Houses).copied().unwrap_or(true) {
789        let house_label_r = (radii.houses_outer + radii.houses_inner) / 2.0;
790        for layer in &render.layers {
791            if matches!(layer.kind, LayerKind::Houses) {
792                for g in &layer.glyphs {
793                    let (x, y) = polar_to_screen(g.deg, asc, rot_offset, house_label_r);
794                    if let Some(h) = g.house {
795                        wheel = wheel.child(centered_glyph(
796                            cx_center + x,
797                            cy_center + y,
798                            16.0,
799                            10.0,
800                            format!("{}", h).into(),
801                            palette.house_cusp,
802                        ));
803                    }
804                }
805            }
806        }
807    }
808
809    // Planet glyphs: natal (en `bodies`) + overlays en sus rings
810    // (progression, solar_arc) con alpha + tamaño más chico para
811    // diferenciarse visualmente del natal.
812    if visible.get(&LayerKind::Bodies).copied().unwrap_or(true) {
813        for layer in &render.layers {
814            if matches!(layer.kind, LayerKind::Bodies) {
815                let is_natal = layer.module_id == "natal";
816                let ring = radii.body_ring(&layer.module_id);
817                let alpha = if is_natal { 1.0 } else { 0.85 };
818                let font_size = if is_natal { 18.0 } else { 14.0 };
819                let box_size = if is_natal { 24.0 } else { 20.0 };
820                for g in &layer.glyphs {
821                    let (x, y) = polar_to_screen(g.deg, asc, rot_offset, ring);
822                    let color = with_alpha(planet_color(palette, &g.symbol), alpha);
823                    let mut glyph_text = planet_unicode(&g.symbol).to_string();
824                    if g.retrograde {
825                        glyph_text.push('ᴿ');
826                    }
827                    if let Some(marker) = &g.dignity_marker {
828                        glyph_text.push_str(marker);
829                    }
830                    wheel = wheel.child(centered_glyph(
831                        cx_center + x,
832                        cy_center + y,
833                        box_size,
834                        font_size,
835                        glyph_text.into(),
836                        color,
837                    ));
838                }
839            }
840        }
841    }
842
843    // Planet glyphs en el outer ring — transit o synastry, los dos
844    // comparten ese slot (mutuamente excluyentes a nivel de Shell).
845    if visible.get(&LayerKind::Outer).copied().unwrap_or(true) {
846        for layer in &render.layers {
847            if matches!(layer.kind, LayerKind::Outer)
848                && (OUTER_RING_MODULES.contains(&layer.module_id.as_str()))
849            {
850                for g in &layer.glyphs {
851                    let (x, y) = polar_to_screen(g.deg, asc, rot_offset, radii.transits);
852                    let color = with_alpha(planet_color(palette, &g.symbol), 0.9);
853                    let glyph_text = if g.retrograde {
854                        format!("{}ᴿ", planet_unicode(&g.symbol))
855                    } else {
856                        planet_unicode(&g.symbol).into()
857                    };
858                    wheel = wheel.child(centered_glyph(
859                        cx_center + x,
860                        cy_center + y,
861                        20.0,
862                        14.0,
863                        glyph_text.into(),
864                        color,
865                    ));
866                }
867            }
868        }
869    }
870
871    // Tooltip absoluto sobre el elemento hovered (cuerpo o cusp).
872    if let Some(hov) = hover {
873        let text = match hov {
874            HoverInfo::Body {
875                module_id,
876                symbol,
877                deg,
878                house,
879                retrograde,
880                dignity_marker,
881                annotation,
882                ..
883            } => {
884                let sign_idx = ((deg / 30.0).floor() as usize) % 12;
885                let sign_name = SIGN_NAMES_ES[sign_idx];
886                let deg_in_sign = deg - (sign_idx as f32) * 30.0;
887                let display_symbol = if module_id == "midpoints" {
888                    // El symbol del midpoint es "a/b" — para el header
889                    // del tooltip usamos los unicodes individuales.
890                    if let Some((a, b)) = symbol.split_once('/') {
891                        format!("{}/{}", planet_unicode(a), planet_unicode(b))
892                    } else {
893                        symbol.clone()
894                    }
895                } else {
896                    planet_unicode(symbol).to_string()
897                };
898                let mut t = format!("{} {} · {:.1}°", display_symbol, sign_name, deg_in_sign);
899                if let Some(h) = house {
900                    t.push_str(&format!(" · Casa {}", h));
901                }
902                if *retrograde {
903                    t.push_str(" · ℞");
904                }
905                if let Some(m) = dignity_marker {
906                    t.push_str(&format!(" · {}", m));
907                }
908                if module_id == "midpoints" {
909                    if let Some(a) = annotation {
910                        t.push_str(&format!(" · {}", a));
911                    }
912                } else if module_id != "natal" {
913                    t.push_str(&format!(" · {}", module_id));
914                }
915                t
916            }
917            HoverInfo::HouseCusp {
918                house_number, deg, ..
919            } => {
920                let sign_idx = ((deg / 30.0).floor() as usize) % 12;
921                let sign_name = SIGN_NAMES_ES[sign_idx];
922                let deg_in_sign = deg - (sign_idx as f32) * 30.0;
923                format!(
924                    "Cusp Casa {} · {} {:.1}°",
925                    house_number, sign_name, deg_in_sign
926                )
927            }
928            HoverInfo::Aspect {
929                module_id,
930                from_body,
931                to_body,
932                kind,
933                orb_deg,
934                ..
935            } => {
936                let mut t = format!(
937                    "{} {} {}  ·  orb {:.1}°",
938                    planet_unicode(from_body),
939                    aspect_unicode(kind),
940                    planet_unicode(to_body),
941                    orb_deg
942                );
943                if module_id != "natal" {
944                    t.push_str(&format!(" · {}", module_id));
945                }
946                t
947            }
948        };
949        let (lx, ly) = hov.local();
950        let tip_x = (lx + 14.0).min(WHEEL_SIZE - 220.0).max(8.0);
951        let tip_y = (ly - 28.0).max(8.0);
952        wheel = wheel.child(
953            div()
954                .absolute()
955                .left(px(tip_x))
956                .top(px(tip_y))
957                .px(px(8.0))
958                .py(px(4.0))
959                .rounded(px(6.0))
960                .bg(theme.bg_panel_alt.clone())
961                .border_1()
962                .border_color(palette.angle_highlight)
963                .text_size(px(11.0))
964                .text_color(theme.fg_text)
965                .child(SharedString::from(text)),
966        );
967    }
968
969    // Labels ASC/MC/DESC/IC en el perímetro. Texto pequeño en el
970    // margen exterior (radius * 1.05) para que no se monte con los
971    // glifos de los signos. Color angle_highlight para que el ojo los
972    // reconozca como los cuatro ángulos cardinales.
973    let angle_labels = [
974        (asc, "ASC"),
975        (render.midheaven_deg, "MC"),
976        (render.descendant_deg, "DESC"),
977        (render.imum_coeli_deg, "IC"),
978    ];
979    let label_r = r_outer * 1.06;
980    for (deg, label) in angle_labels {
981        let (x, y) = polar_to_screen(deg, asc, rot_offset, label_r);
982        wheel = wheel.child(centered_glyph(
983            cx_center + x,
984            cy_center + y,
985            32.0,
986            10.0,
987            label.into(),
988            palette.angle_highlight,
989        ));
990    }
991
992    // --- Header + footer + indicador de tiempo ---
993    let header = div()
994        .flex()
995        .flex_col()
996        .items_center()
997        .gap(px(2.0))
998        .child(
999            div()
1000                .text_size(px(16.0))
1001                .text_color(theme.fg_text)
1002                .child(SharedString::from(render.title.clone())),
1003        );
1004    let header = if let Some(sub) = &render.subtitle {
1005        header.child(
1006            div()
1007                .text_size(px(11.0))
1008                .text_color(theme.fg_muted)
1009                .child(SharedString::from(sub.clone())),
1010        )
1011    } else {
1012        header
1013    };
1014    // Botón export SVG — pequeño, alineado a la derecha del title.
1015    let export_btn = div()
1016        .id("tts-canvas-export-svg")
1017        .px(px(10.0))
1018        .py(px(3.0))
1019        .rounded(px(4.0))
1020        .bg(theme.bg_button())
1021        .hover(|s| s.bg(theme.bg_button_hover()))
1022        .border_1()
1023        .border_color(theme.border)
1024        .text_size(px(10.0))
1025        .text_color(theme.fg_text)
1026        .child("⬇ SVG")
1027        .on_click({
1028            let entity_e = entity.clone();
1029            move |_: &gpui::ClickEvent, _w, cx: &mut gpui::App| {
1030                entity_e.update(cx, |_this, cx| {
1031                    cx.emit(CanvasEvent::ExportSvgRequested);
1032                });
1033            }
1034        });
1035    let header = div()
1036        .flex()
1037        .flex_row()
1038        .items_center()
1039        .gap(px(12.0))
1040        .child(header)
1041        .child(export_btn);
1042
1043    let offset_label = format_offset(time_offset_minutes);
1044    let offset_color = if time_offset_minutes == 0 {
1045        theme.fg_disabled
1046    } else {
1047        palette.angle_highlight
1048    };
1049    let info_row = div()
1050        .flex()
1051        .flex_row()
1052        .gap(px(10.0))
1053        .child(
1054            div()
1055                .text_size(px(10.0))
1056                .text_color(theme.fg_disabled)
1057                .child(SharedString::from(format!(
1058                    "Asc {:.1}°  ·  MC {:.1}°  ·  {} ms",
1059                    render.ascendant_deg, render.midheaven_deg, render.compute_ms,
1060                ))),
1061        )
1062        .child(
1063            div()
1064                .text_size(px(10.0))
1065                .text_color(offset_color)
1066                .child(SharedString::from(offset_label)),
1067        )
1068        .child(
1069            div()
1070                .text_size(px(10.0))
1071                .text_color(theme.fg_disabled)
1072                .child("[D]ial [H]ouses as[X]pects [P]lanets [T]ransits [S]vg [R]eset"),
1073        );
1074
1075    // Badges de overlays activos. Cada uno se pinta como pill con
1076    // background sutil y border tenue. Solo aparecen cuando hay
1077    // overlays — la carta natal pura ve solo el info_row.
1078    let badges_row = if render.overlays.is_empty() {
1079        None
1080    } else {
1081        let mut row = div().flex().flex_row().flex_wrap().gap(px(6.0));
1082        // Badge "natal" base, siempre presente cuando hay overlays —
1083        // ayuda al usuario a leer la pila de izquierda a derecha.
1084        row = row.child(badge(theme, palette, "natal", "Natal", true));
1085        for ov in &render.overlays {
1086            row = row.child(badge(theme, palette, &ov.module_id, &ov.label, false));
1087        }
1088        Some(row)
1089    };
1090
1091    let mut footer = div().flex().flex_col().items_center().gap(px(4.0)).child(info_row);
1092    if let Some(b) = badges_row {
1093        footer = footer.child(b);
1094    }
1095
1096    // Ejes uranianos detectados (cuerpos en la misma posición mod 90).
1097    // Aparece sólo cuando el módulo Uranian está activo y hay
1098    // grupos. Cada grupo se muestra como pill con los unicode de los
1099    // cuerpos + el grado dial-90.
1100    if !render.uranian_groups.is_empty() {
1101        let mut row = div().flex().flex_row().flex_wrap().gap(px(6.0));
1102        for group in &render.uranian_groups {
1103            let bodies_text: String = group
1104                .bodies
1105                .iter()
1106                .map(|b| planet_unicode(b))
1107                .collect::<Vec<_>>()
1108                .join(" ");
1109            row = row.child(
1110                div()
1111                    .px(px(8.0))
1112                    .py(px(2.0))
1113                    .rounded(px(10.0))
1114                    .bg(theme.bg_panel_alt.clone())
1115                    .border_1()
1116                    .border_color(with_alpha(palette.angle_highlight, 0.6))
1117                    .text_size(px(11.0))
1118                    .text_color(theme.fg_text)
1119                    .child(SharedString::from(format!(
1120                        "{}  ·  {:.1}°",
1121                        bodies_text, group.mod90_deg
1122                    ))),
1123            );
1124        }
1125        footer = footer.child(
1126            div()
1127                .flex()
1128                .flex_col()
1129                .items_center()
1130                .gap(px(3.0))
1131                .child(
1132                    div()
1133                        .text_size(px(10.0))
1134                        .text_color(theme.fg_muted)
1135                        .child("Ejes uranianos (90°)"),
1136                )
1137                .child(row),
1138        );
1139    }
1140
1141    // Lista textual de aspectos (top 12 por orb). Compacta, en grid
1142    // de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos
1143    // computados.
1144    if !render.aspect_summary.is_empty() {
1145        let mut grid = div()
1146            .flex()
1147            .flex_row()
1148            .flex_wrap()
1149            .gap(px(10.0))
1150            .max_w(px(WHEEL_SIZE + 80.0))
1151            .justify_center();
1152        for ap in render.aspect_summary.iter().take(12) {
1153            let kind_sym = aspect_unicode(&ap.kind);
1154            let line = format!(
1155                "{} {} {}  ·  {:.1}°{}",
1156                planet_unicode(&ap.from_body),
1157                kind_sym,
1158                planet_unicode(&ap.to_body),
1159                ap.orb_deg,
1160                match ap.applying {
1161                    Some(true) => " A",
1162                    Some(false) => " S",
1163                    None => "",
1164                }
1165            );
1166            let prefix = if ap.module_id == "natal" {
1167                String::new()
1168            } else {
1169                format!("[{}] ", ap.module_id)
1170            };
1171            grid = grid.child(
1172                div()
1173                    .px(px(6.0))
1174                    .py(px(2.0))
1175                    .text_size(px(11.0))
1176                    .text_color(aspect_color(palette, &ap.kind))
1177                    .child(SharedString::from(format!("{}{}", prefix, line))),
1178            );
1179        }
1180        footer = footer.child(grid);
1181    }
1182
1183    div()
1184        .flex()
1185        .flex_col()
1186        .items_center()
1187        .gap(px(8.0))
1188        .child(header)
1189        .child(wheel)
1190        .child(footer)
1191}
1192
1193/// Pequeña pill con la etiqueta de un overlay activo. El borde toma
1194/// color según el "tipo" del módulo para ayudar a mapear a su anillo
1195/// en el wheel: natal = neutro, outer ring share (transit/synastry/
1196/// planetary_return) = palette.angle_highlight, inner overlays
1197/// (progression/solar_arc) = palette.house_cusp.
1198fn badge(theme: &Theme, palette: &AstroPalette, module_id: &str, label: &str, is_natal: bool) -> gpui::Div {
1199    let border = if is_natal {
1200        theme.border
1201    } else {
1202        match module_id {
1203            "transit" | "synastry" | "planetary_return" => palette.angle_highlight,
1204            "progression" | "solar_arc" => palette.house_cusp,
1205            _ => theme.border,
1206        }
1207    };
1208    div()
1209        .px(px(8.0))
1210        .py(px(2.0))
1211        .rounded(px(10.0))
1212        .bg(theme.bg_panel_alt.clone())
1213        .border_1()
1214        .border_color(border)
1215        .text_size(px(10.0))
1216        .text_color(theme.fg_text)
1217        .child(SharedString::from(label.to_string()))
1218}
1219
1220fn format_offset(minutes: i64) -> String {
1221    if minutes == 0 {
1222        return "⏱ ahora".to_string();
1223    }
1224    let sign = if minutes > 0 { '+' } else { '-' };
1225    let m = minutes.unsigned_abs();
1226    let days = m / (60 * 24);
1227    let hours = (m / 60) % 24;
1228    let mins = m % 60;
1229    if days > 0 {
1230        format!("⏱ {}{}d {:02}h {:02}m", sign, days, hours, mins)
1231    } else if hours > 0 {
1232        format!("⏱ {}{:02}h {:02}m", sign, hours, mins)
1233    } else {
1234        format!("⏱ {}{:02}m", sign, mins)
1235    }
1236}
1237
1238// =====================================================================
1239// Painting
1240// =====================================================================
1241
1242#[derive(Clone, Copy)]
1243struct Radii {
1244    sign_outer: f32,
1245    sign_inner: f32,
1246    /// Anillo de glifos de tránsito (cuando el overlay está activo).
1247    transits: f32,
1248    houses_outer: f32,
1249    houses_inner: f32,
1250    /// Anillo de midpoints — entre bodies natales y houses_inner.
1251    midpoints: f32,
1252    bodies: f32,
1253    /// Anillo interno con cuerpos progresados (overlay opcional).
1254    progression: f32,
1255    /// Anillo más interno con cuerpos dirigidos por Solar Arc.
1256    solar_arc: f32,
1257    /// Anillo de carta compuesta (midpoint Davison) con un partner.
1258    composite: f32,
1259    aspects: f32,
1260}
1261
1262impl Radii {
1263    fn from_outer(r: f32) -> Self {
1264        Self {
1265            sign_outer: r,
1266            sign_inner: r * 0.88,
1267            transits: r * 0.82,
1268            houses_outer: r * 0.78,
1269            houses_inner: r * 0.66,
1270            midpoints: r * 0.62,
1271            bodies: r * 0.58,
1272            progression: r * 0.48,
1273            solar_arc: r * 0.40,
1274            composite: r * 0.32,
1275            aspects: r * 0.24,
1276        }
1277    }
1278
1279    /// Radio del ring de cuerpos según el `module_id` del Layer.
1280    fn body_ring(&self, module_id: &str) -> f32 {
1281        match module_id {
1282            "progression" => self.progression,
1283            "solar_arc" => self.solar_arc,
1284            "composite" => self.composite,
1285            "midpoints" => self.midpoints,
1286            _ => self.bodies,
1287        }
1288    }
1289
1290    /// Resuelve qué radios corresponden a una capa de aspectos según el
1291    /// `module_id`: natal-natal en `aspects`, cross con cada overlay
1292    /// desde `bodies` (extremo natal) al ring del módulo. Los módulos
1293    /// del outer ring (OUTER_RING_MODULES) comparten el slot de
1294    /// tránsito (son mutuamente excluyentes a nivel de Shell).
1295    fn aspect_endpoints(&self, module_id: &str) -> (f32, f32) {
1296        if OUTER_RING_MODULES.contains(&module_id) {
1297            return (self.bodies, self.transits);
1298        }
1299        match module_id {
1300            "progression" => (self.bodies, self.progression),
1301            "solar_arc" => (self.bodies, self.solar_arc),
1302            "composite" => (self.bodies, self.composite),
1303            _ => (self.aspects, self.aspects),
1304        }
1305    }
1306}
1307
1308#[allow(clippy::too_many_arguments)]
1309fn paint_wheel(
1310    bounds: Bounds<Pixels>,
1311    window: &mut Window,
1312    theme: &Theme,
1313    palette: &AstroPalette,
1314    layers: &[Layer],
1315    ascendant_deg: f32,
1316    midheaven_deg: f32,
1317    rot_offset_deg: f32,
1318    radii: Radii,
1319    visibility: &HashMap<LayerKind, bool>,
1320) {
1321    let (cx, cy) = bounds_center(bounds);
1322    let _ = theme;
1323    let show = |k: LayerKind| visibility.get(&k).copied().unwrap_or(true);
1324
1325    // 1. Sectores zodiacales (parte del SignDial layer).
1326    if show(LayerKind::SignDial) {
1327        paint_sign_sectors(window, cx, cy, &radii, palette, ascendant_deg, rot_offset_deg);
1328
1329        // Anillos del dial.
1330        stroke_circle(window, cx, cy, radii.sign_outer, 1.5, palette.dial_ring);
1331        stroke_circle(window, cx, cy, radii.sign_inner, 1.0, palette.dial_ring);
1332
1333        // Cusps zodiacales cada 30°.
1334        for i in 0..12 {
1335            let lon = (i as f32) * 30.0;
1336            let color = palette.dial_ring;
1337            paint_radial_line(
1338                window,
1339                cx,
1340                cy,
1341                lon,
1342                ascendant_deg,
1343                rot_offset_deg,
1344                radii.sign_inner,
1345                radii.sign_outer,
1346                color,
1347                1.0,
1348            );
1349        }
1350    }
1351
1352    // 2. Casas — cusps radiales + énfasis Asc/IC/Desc/MC.
1353    if show(LayerKind::Houses) {
1354        stroke_circle(
1355            window,
1356            cx,
1357            cy,
1358            radii.houses_inner,
1359            0.8,
1360            with_alpha(palette.house_cusp, 0.6),
1361        );
1362
1363        for layer in layers {
1364            if matches!(layer.kind, LayerKind::Houses) {
1365                if let Geometry::Ring { cusps_deg } = &layer.geometry {
1366                    for (i, c) in cusps_deg.iter().enumerate() {
1367                        let is_angle = i == 0 || i == 3 || i == 6 || i == 9;
1368                        let color = if is_angle {
1369                            palette.angle_highlight
1370                        } else {
1371                            with_alpha(palette.house_cusp, 0.7)
1372                        };
1373                        let width = if is_angle { 2.0 } else { 0.8 };
1374                        paint_radial_line(
1375                            window,
1376                            cx,
1377                            cy,
1378                            *c,
1379                            ascendant_deg,
1380                            rot_offset_deg,
1381                            radii.houses_inner,
1382                            radii.houses_outer,
1383                            color,
1384                            width,
1385                        );
1386                    }
1387                }
1388            }
1389        }
1390
1391        // Asc + MC extendidos hasta el centro con opacidad sutil.
1392        paint_radial_line(
1393            window,
1394            cx,
1395            cy,
1396            ascendant_deg,
1397            ascendant_deg,
1398            rot_offset_deg,
1399            0.0,
1400            radii.houses_outer,
1401            with_alpha(palette.angle_highlight, 0.35),
1402            1.0,
1403        );
1404        paint_radial_line(
1405            window,
1406            cx,
1407            cy,
1408            midheaven_deg,
1409            ascendant_deg,
1410            rot_offset_deg,
1411            0.0,
1412            radii.houses_outer,
1413            with_alpha(palette.angle_highlight, 0.35),
1414            1.0,
1415        );
1416    }
1417
1418    // 3. Aspectos. Cada module_id usa su par de radios — natal-natal
1419    // ambos en `aspects`, cross con transit en `bodies → transits`,
1420    // cross con progression en `bodies → progression`.
1421    if show(LayerKind::Aspects) {
1422        for layer in layers {
1423            if matches!(layer.kind, LayerKind::Aspects) {
1424                if let Geometry::Lines(segs) = &layer.geometry {
1425                    let (r_from, r_to) = radii.aspect_endpoints(&layer.module_id);
1426                    let is_cross = r_from != r_to;
1427                    for seg in segs {
1428                        let color = aspect_color(palette, &seg.kind);
1429                        let color = with_alpha(color, color.a * seg.opacity);
1430                        if is_cross {
1431                            paint_cross_aspect_line(
1432                                window,
1433                                cx,
1434                                cy,
1435                                seg.from_deg,
1436                                seg.to_deg,
1437                                ascendant_deg,
1438                                rot_offset_deg,
1439                                r_from,
1440                                r_to,
1441                                color,
1442                            );
1443                        } else {
1444                            paint_aspect_line(
1445                                window,
1446                                cx,
1447                                cy,
1448                                seg.from_deg,
1449                                seg.to_deg,
1450                                ascendant_deg,
1451                                rot_offset_deg,
1452                                r_from,
1453                                color,
1454                            );
1455                        }
1456                    }
1457                }
1458            }
1459        }
1460    }
1461
1462    // 4. Dots de cuerpos: natal en `bodies`, overlays en sus rings
1463    // específicos (progression, solar_arc). Las luminarias natales
1464    // (Sol/Luna) llevan glow halo — invita la mística sin saturar.
1465    if show(LayerKind::Bodies) {
1466        let dot_r = (radii.sign_outer * 0.018).max(2.0);
1467        for layer in layers {
1468            if matches!(layer.kind, LayerKind::Bodies) {
1469                let ring = radii.body_ring(&layer.module_id);
1470                let is_natal = layer.module_id == "natal";
1471                let alpha = if is_natal { 1.0 } else { 0.85 };
1472                for g in &layer.glyphs {
1473                    let color = with_alpha(planet_color(palette, &g.symbol), alpha);
1474                    let (x, y) = polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, ring);
1475                    if is_natal && (g.symbol == "sun" || g.symbol == "moon") {
1476                        paint_glow(window, cx + x, cy + y, dot_r, color);
1477                    }
1478                    fill_circle(window, cx + x, cy + y, dot_r, color);
1479                }
1480            }
1481        }
1482    }
1483
1484    // Anillos guía para los overlays internos (progression, solar_arc).
1485    let guide_inset = radii.sign_outer * 0.03;
1486    for (module_id, ring) in [
1487        ("progression", radii.progression),
1488        ("solar_arc", radii.solar_arc),
1489    ] {
1490        let active = layers
1491            .iter()
1492            .any(|l| matches!(l.kind, LayerKind::Bodies) && l.module_id == module_id);
1493        if active {
1494            stroke_circle(
1495                window,
1496                cx,
1497                cy,
1498                ring + guide_inset,
1499                0.5,
1500                with_alpha(palette.house_cusp, 0.35),
1501            );
1502            stroke_circle(
1503                window,
1504                cx,
1505                cy,
1506                ring - guide_inset,
1507                0.5,
1508                with_alpha(palette.house_cusp, 0.35),
1509            );
1510        }
1511    }
1512
1513    // 5. Outer ring (transit o synastry overlay): anillo guía + dots
1514    // de la capa activa. Son mutuamente excluyentes a nivel de Shell;
1515    // si alguno de los dos está prendido, pintamos el slot.
1516    let outer_active = layers.iter().any(|l| {
1517        matches!(l.kind, LayerKind::Outer)
1518            && OUTER_RING_MODULES.contains(&l.module_id.as_str())
1519    });
1520    if outer_active && show(LayerKind::Outer) {
1521        stroke_circle(
1522            window,
1523            cx,
1524            cy,
1525            radii.transits + radii.sign_outer * 0.035,
1526            0.6,
1527            with_alpha(palette.dial_ring, 0.4),
1528        );
1529        stroke_circle(
1530            window,
1531            cx,
1532            cy,
1533            radii.transits - radii.sign_outer * 0.035,
1534            0.6,
1535            with_alpha(palette.dial_ring, 0.4),
1536        );
1537
1538        let dot_r = (radii.sign_outer * 0.017).max(2.0);
1539        for layer in layers {
1540            if matches!(layer.kind, LayerKind::Outer)
1541                && (OUTER_RING_MODULES.contains(&layer.module_id.as_str()))
1542            {
1543                for g in &layer.glyphs {
1544                    let color = with_alpha(planet_color(palette, &g.symbol), 0.85);
1545                    let (x, y) =
1546                        polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, radii.transits);
1547                    fill_circle(window, cx + x, cy + y, dot_r, color);
1548                }
1549            }
1550        }
1551    }
1552}
1553
1554fn paint_sign_sectors(
1555    window: &mut Window,
1556    cx: f32,
1557    cy: f32,
1558    radii: &Radii,
1559    palette: &AstroPalette,
1560    ascendant_deg: f32,
1561    rot_offset_deg: f32,
1562) {
1563    const SUBDIVISIONS: usize = 18;
1564    for i in 0..12 {
1565        let lon_start = (i as f32) * 30.0;
1566        let lon_end = lon_start + 30.0;
1567        let element = sign_element_by_index(i);
1568        let color = with_alpha(palette.element(element), 0.10);
1569
1570        let mut builder = PathBuilder::fill();
1571        let (x0, y0) = polar_to_screen(lon_start, ascendant_deg, rot_offset_deg, radii.sign_inner);
1572        builder.move_to(point(px(cx + x0), px(cy + y0)));
1573
1574        for k in 1..=SUBDIVISIONS {
1575            let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32);
1576            let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_inner);
1577            builder.line_to(point(px(cx + x), px(cy + y)));
1578        }
1579        let (xe, ye) = polar_to_screen(lon_end, ascendant_deg, rot_offset_deg, radii.sign_outer);
1580        builder.line_to(point(px(cx + xe), px(cy + ye)));
1581
1582        for k in (0..SUBDIVISIONS).rev() {
1583            let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32);
1584            let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_outer);
1585            builder.line_to(point(px(cx + x), px(cy + y)));
1586        }
1587        builder.close();
1588        if let Ok(path) = builder.build() {
1589            window.paint_path(path, color);
1590        }
1591    }
1592}
1593
1594fn stroke_circle(window: &mut Window, cx: f32, cy: f32, r: f32, width: f32, color: Hsla) {
1595    const SEGMENTS: usize = 96;
1596    let mut builder = PathBuilder::stroke(px(width));
1597    for i in 0..=SEGMENTS {
1598        let t = (i as f32) / (SEGMENTS as f32) * (2.0 * PI);
1599        let x = cx + r * t.cos();
1600        let y = cy + r * t.sin();
1601        if i == 0 {
1602            builder.move_to(point(px(x), px(y)));
1603        } else {
1604            builder.line_to(point(px(x), px(y)));
1605        }
1606    }
1607    if let Ok(path) = builder.build() {
1608        window.paint_path(path, color);
1609    }
1610}
1611
1612/// Pinta 3 halos concéntricos con alpha decreciente alrededor de un
1613/// punto — usado para Sol/Luna natales. El radio crece, la opacidad
1614/// cae: el ojo lo lee como "esto irradia". Sin glow real (GPUI 0.2 no
1615/// tiene radial gradient), pero el shading concéntrico convence.
1616fn paint_glow(window: &mut Window, cx: f32, cy: f32, base_r: f32, color: Hsla) {
1617    const HALOS: [(f32, f32); 3] = [(5.0, 0.05), (3.0, 0.12), (1.8, 0.22)];
1618    for (mult, alpha) in HALOS {
1619        let r = base_r * mult;
1620        let halo = hsla(color.h, color.s, color.l, alpha);
1621        fill_circle(window, cx, cy, r, halo);
1622    }
1623}
1624
1625fn fill_circle(window: &mut Window, cx: f32, cy: f32, r: f32, color: Hsla) {
1626    const SEGMENTS: usize = 32;
1627    let mut builder = PathBuilder::fill();
1628    builder.move_to(point(px(cx + r), px(cy)));
1629    for i in 1..=SEGMENTS {
1630        let t = (i as f32) / (SEGMENTS as f32) * (2.0 * PI);
1631        let x = cx + r * t.cos();
1632        let y = cy + r * t.sin();
1633        builder.line_to(point(px(x), px(y)));
1634    }
1635    builder.close();
1636    if let Ok(path) = builder.build() {
1637        window.paint_path(path, color);
1638    }
1639}
1640
1641#[allow(clippy::too_many_arguments)]
1642fn paint_radial_line(
1643    window: &mut Window,
1644    cx: f32,
1645    cy: f32,
1646    longitude_deg: f32,
1647    ascendant_deg: f32,
1648    rot_offset_deg: f32,
1649    r_inner: f32,
1650    r_outer: f32,
1651    color: Hsla,
1652    width: f32,
1653) {
1654    let (xi, yi) = polar_to_screen(longitude_deg, ascendant_deg, rot_offset_deg, r_inner);
1655    let (xo, yo) = polar_to_screen(longitude_deg, ascendant_deg, rot_offset_deg, r_outer);
1656    let mut builder = PathBuilder::stroke(px(width));
1657    builder.move_to(point(px(cx + xi), px(cy + yi)));
1658    builder.line_to(point(px(cx + xo), px(cy + yo)));
1659    if let Ok(path) = builder.build() {
1660        window.paint_path(path, color);
1661    }
1662}
1663
1664#[allow(clippy::too_many_arguments)]
1665fn paint_aspect_line(
1666    window: &mut Window,
1667    cx: f32,
1668    cy: f32,
1669    a_deg: f32,
1670    b_deg: f32,
1671    ascendant_deg: f32,
1672    rot_offset_deg: f32,
1673    r: f32,
1674    color: Hsla,
1675) {
1676    let (xa, ya) = polar_to_screen(a_deg, ascendant_deg, rot_offset_deg, r);
1677    let (xb, yb) = polar_to_screen(b_deg, ascendant_deg, rot_offset_deg, r);
1678    let mut builder = PathBuilder::stroke(px(1.0));
1679    builder.move_to(point(px(cx + xa), px(cy + ya)));
1680    builder.line_to(point(px(cx + xb), px(cy + yb)));
1681    if let Ok(path) = builder.build() {
1682        window.paint_path(path, color);
1683    }
1684}
1685
1686/// Línea de aspecto natal ↔ tránsito: extremos en radios distintos.
1687/// El `from_deg` cae sobre el ring de cuerpos natales (`r_from`); el
1688/// `to_deg` sobre el ring de tránsito (`r_to`). Trazo más fino que el
1689/// natal-natal para no competir visualmente.
1690#[allow(clippy::too_many_arguments)]
1691fn paint_cross_aspect_line(
1692    window: &mut Window,
1693    cx: f32,
1694    cy: f32,
1695    natal_deg: f32,
1696    transit_deg: f32,
1697    ascendant_deg: f32,
1698    rot_offset_deg: f32,
1699    r_from: f32,
1700    r_to: f32,
1701    color: Hsla,
1702) {
1703    let (xa, ya) = polar_to_screen(natal_deg, ascendant_deg, rot_offset_deg, r_from);
1704    let (xb, yb) = polar_to_screen(transit_deg, ascendant_deg, rot_offset_deg, r_to);
1705    let mut builder = PathBuilder::stroke(px(0.7));
1706    builder.move_to(point(px(cx + xa), px(cy + ya)));
1707    builder.line_to(point(px(cx + xb), px(cy + yb)));
1708    if let Ok(path) = builder.build() {
1709        window.paint_path(path, color);
1710    }
1711}
1712
1713// =====================================================================
1714// Helpers
1715// =====================================================================
1716
1717/// Distancia mínima entre un punto y un segmento de recta. Usado por
1718/// hover_check para detectar proximity a líneas de aspectos.
1719fn dist_point_segment(px: f32, py: f32, ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
1720    let dx = bx - ax;
1721    let dy = by - ay;
1722    let len_sq = dx * dx + dy * dy;
1723    if len_sq < f32::EPSILON {
1724        // Segmento degenerado → distancia al punto a.
1725        let pdx = px - ax;
1726        let pdy = py - ay;
1727        return (pdx * pdx + pdy * pdy).sqrt();
1728    }
1729    let t = (((px - ax) * dx + (py - ay) * dy) / len_sq).clamp(0.0, 1.0);
1730    let proj_x = ax + t * dx;
1731    let proj_y = ay + t * dy;
1732    let dx2 = px - proj_x;
1733    let dy2 = py - proj_y;
1734    (dx2 * dx2 + dy2 * dy2).sqrt()
1735}
1736
1737fn polar_to_screen(
1738    longitude_deg: f32,
1739    ascendant_deg: f32,
1740    rot_offset_deg: f32,
1741    radius: f32,
1742) -> (f32, f32) {
1743    let deg = 180.0 - (longitude_deg - ascendant_deg + rot_offset_deg);
1744    let rad = deg * PI / 180.0;
1745    (radius * rad.cos(), radius * rad.sin())
1746}
1747
1748fn centered_glyph(
1749    x: f32,
1750    y: f32,
1751    box_size: f32,
1752    font_size: f32,
1753    text: SharedString,
1754    color: Hsla,
1755) -> gpui::Div {
1756    div()
1757        .absolute()
1758        .left(px(x - box_size / 2.0))
1759        .top(px(y - box_size / 2.0))
1760        .w(px(box_size))
1761        .h(px(box_size))
1762        .flex()
1763        .items_center()
1764        .justify_center()
1765        .text_size(px(font_size))
1766        .text_color(color)
1767        .child(text)
1768}
1769
1770fn with_alpha(c: Hsla, a: f32) -> Hsla {
1771    hsla(c.h, c.s, c.l, a.clamp(0.0, 1.0))
1772}
1773
1774fn sign_unicode(name: &str) -> &'static str {
1775    match name {
1776        "aries" => "♈",
1777        "taurus" => "♉",
1778        "gemini" => "♊",
1779        "cancer" => "♋",
1780        "leo" => "♌",
1781        "virgo" => "♍",
1782        "libra" => "♎",
1783        "scorpio" => "♏",
1784        "sagittarius" => "♐",
1785        "capricorn" => "♑",
1786        "aquarius" => "♒",
1787        "pisces" => "♓",
1788        _ => "?",
1789    }
1790}
1791
1792const SIGN_NAMES_ES: [&str; 12] = [
1793    "Aries",
1794    "Tauro",
1795    "Géminis",
1796    "Cáncer",
1797    "Leo",
1798    "Virgo",
1799    "Libra",
1800    "Escorpio",
1801    "Sagitario",
1802    "Capricornio",
1803    "Acuario",
1804    "Piscis",
1805];
1806
1807fn aspect_unicode(kind: &str) -> &'static str {
1808    match kind {
1809        "conjunction" => "☌",
1810        "opposition" => "☍",
1811        "trine" => "△",
1812        "square" => "□",
1813        "sextile" => "⚹",
1814        "quincunx" => "⚻",
1815        "semi_sextile" => "⚺",
1816        "semi_square" => "∠",
1817        "sesquiquadrate" => "⚼",
1818        "quintile" => "Q",
1819        "biquintile" => "bQ",
1820        _ => "·",
1821    }
1822}
1823
1824fn planet_unicode(name: &str) -> &'static str {
1825    match name {
1826        "sun" => "☉",
1827        "moon" => "☽",
1828        "mercury" => "☿",
1829        "venus" => "♀",
1830        "mars" => "♂",
1831        "jupiter" => "♃",
1832        "saturn" => "♄",
1833        "uranus" => "♅",
1834        "neptune" => "♆",
1835        "pluto" => "♇",
1836        "north_node" => "☊",
1837        "south_node" => "☋",
1838        "chiron" => "⚷",
1839        "lilith" => "⚸",
1840        "ceres" => "⚳",
1841        "pallas" => "⚴",
1842        "juno" => "⚵",
1843        "vesta" => "⚶",
1844        _ => "•",
1845    }
1846}
1847
1848fn planet_color(p: &AstroPalette, name: &str) -> Hsla {
1849    let planet = match name {
1850        "sun" => Planet::Sun,
1851        "moon" => Planet::Moon,
1852        "mercury" => Planet::Mercury,
1853        "venus" => Planet::Venus,
1854        "mars" => Planet::Mars,
1855        "jupiter" => Planet::Jupiter,
1856        "saturn" => Planet::Saturn,
1857        "uranus" => Planet::Uranus,
1858        "neptune" => Planet::Neptune,
1859        "pluto" => Planet::Pluto,
1860        "chiron" => Planet::Chiron,
1861        "north_node" => Planet::NorthNode,
1862        "south_node" => Planet::SouthNode,
1863        "lilith" => Planet::Lilith,
1864        _ => return p.fg_text_fallback(),
1865    };
1866    p.planet(planet)
1867}
1868
1869fn sign_element_by_index(i: usize) -> Element {
1870    match i % 4 {
1871        0 => Element::Fire,
1872        1 => Element::Earth,
1873        2 => Element::Air,
1874        _ => Element::Water,
1875    }
1876}
1877
1878fn element_color_for_sign(p: &AstroPalette, name: &str) -> Hsla {
1879    let elem = match name {
1880        "aries" | "leo" | "sagittarius" => Element::Fire,
1881        "taurus" | "virgo" | "capricorn" => Element::Earth,
1882        "gemini" | "libra" | "aquarius" => Element::Air,
1883        "cancer" | "scorpio" | "pisces" => Element::Water,
1884        _ => return p.fg_text_fallback(),
1885    };
1886    p.element(elem)
1887}
1888
1889fn aspect_color(p: &AstroPalette, kind: &str) -> Hsla {
1890    let k = match kind {
1891        "conjunction" => TAspectKind::Conjunction,
1892        "opposition" => TAspectKind::Opposition,
1893        "trine" => TAspectKind::Trine,
1894        "square" => TAspectKind::Square,
1895        "sextile" => TAspectKind::Sextile,
1896        "quincunx" => TAspectKind::Quincunx,
1897        "semi_sextile" => TAspectKind::Semisextile,
1898        "semi_square" => TAspectKind::Semisquare,
1899        "sesquiquadrate" => TAspectKind::Sesquisquare,
1900        "quintile" => TAspectKind::Quintile,
1901        "biquintile" => TAspectKind::Biquintile,
1902        _ => return p.minor_aspect,
1903    };
1904    p.aspect(k)
1905}
1906
1907trait AstroPaletteExt {
1908    fn fg_text_fallback(&self) -> Hsla;
1909}
1910
1911impl AstroPaletteExt for AstroPalette {
1912    fn fg_text_fallback(&self) -> Hsla {
1913        if self.is_dark {
1914            hsla(0.0, 0.0, 0.85, 1.0)
1915        } else {
1916            hsla(0.0, 0.0, 0.25, 1.0)
1917        }
1918    }
1919}