tawasuyu

tawasuyu/brahman

Rust 478TOML 190Markdown 50JSON 176650 Definitions

⎇ d3649bfd1a80153652c2a028315369bb4b184024 crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs Rust · 1766 lines · 59.4 KB History Who touched what raw

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