tawasuyu

tawasuyu/brahman

Rust 570TOML 215Markdown 68JSON 198261 Definiciones

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

feat(tahuantinsuyu): fase 21 — background compute + UranianModule d890bd4b3 · 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    // Ejes uranianos detectados (cuerpos en la misma posición mod 90).
1009    // Aparece sólo cuando el módulo Uranian está activo y hay
1010    // grupos. Cada grupo se muestra como pill con los unicode de los
1011    // cuerpos + el grado dial-90.
1012    if !render.uranian_groups.is_empty() {
1013        let mut row = div().flex().flex_row().flex_wrap().gap(px(6.0));
1014        for group in &render.uranian_groups {
1015            let bodies_text: String = group
1016                .bodies
1017                .iter()
1018                .map(|b| planet_unicode(b))
1019                .collect::<Vec<_>>()
1020                .join(" ");
1021            row = row.child(
1022                div()
1023                    .px(px(8.0))
1024                    .py(px(2.0))
1025                    .rounded(px(10.0))
1026                    .bg(theme.bg_panel_alt.clone())
1027                    .border_1()
1028                    .border_color(with_alpha(palette.angle_highlight, 0.6))
1029                    .text_size(px(11.0))
1030                    .text_color(theme.fg_text)
1031                    .child(SharedString::from(format!(
1032                        "{}  ·  {:.1}°",
1033                        bodies_text, group.mod90_deg
1034                    ))),
1035            );
1036        }
1037        footer = footer.child(
1038            div()
1039                .flex()
1040                .flex_col()
1041                .items_center()
1042                .gap(px(3.0))
1043                .child(
1044                    div()
1045                        .text_size(px(10.0))
1046                        .text_color(theme.fg_muted)
1047                        .child("Ejes uranianos (90°)"),
1048                )
1049                .child(row),
1050        );
1051    }
1052
1053    // Lista textual de aspectos (top 12 por orb). Compacta, en grid
1054    // de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos
1055    // computados.
1056    if !render.aspect_summary.is_empty() {
1057        let mut grid = div()
1058            .flex()
1059            .flex_row()
1060            .flex_wrap()
1061            .gap(px(10.0))
1062            .max_w(px(WHEEL_SIZE + 80.0))
1063            .justify_center();
1064        for ap in render.aspect_summary.iter().take(12) {
1065            let kind_sym = aspect_unicode(&ap.kind);
1066            let line = format!(
1067                "{} {} {}  ·  {:.1}°{}",
1068                planet_unicode(&ap.from_body),
1069                kind_sym,
1070                planet_unicode(&ap.to_body),
1071                ap.orb_deg,
1072                match ap.applying {
1073                    Some(true) => " A",
1074                    Some(false) => " S",
1075                    None => "",
1076                }
1077            );
1078            let prefix = if ap.module_id == "natal" {
1079                String::new()
1080            } else {
1081                format!("[{}] ", ap.module_id)
1082            };
1083            grid = grid.child(
1084                div()
1085                    .px(px(6.0))
1086                    .py(px(2.0))
1087                    .text_size(px(11.0))
1088                    .text_color(aspect_color(palette, &ap.kind))
1089                    .child(SharedString::from(format!("{}{}", prefix, line))),
1090            );
1091        }
1092        footer = footer.child(grid);
1093    }
1094
1095    div()
1096        .flex()
1097        .flex_col()
1098        .items_center()
1099        .gap(px(8.0))
1100        .child(header)
1101        .child(wheel)
1102        .child(footer)
1103}
1104
1105/// Pequeña pill con la etiqueta de un overlay activo. El borde toma
1106/// color según el "tipo" del módulo para ayudar a mapear a su anillo
1107/// en el wheel: natal = neutro, outer ring share (transit/synastry/
1108/// planetary_return) = palette.angle_highlight, inner overlays
1109/// (progression/solar_arc) = palette.house_cusp.
1110fn badge(theme: &Theme, palette: &AstroPalette, module_id: &str, label: &str, is_natal: bool) -> gpui::Div {
1111    let border = if is_natal {
1112        theme.border
1113    } else {
1114        match module_id {
1115            "transit" | "synastry" | "planetary_return" => palette.angle_highlight,
1116            "progression" | "solar_arc" => palette.house_cusp,
1117            _ => theme.border,
1118        }
1119    };
1120    div()
1121        .px(px(8.0))
1122        .py(px(2.0))
1123        .rounded(px(10.0))
1124        .bg(theme.bg_panel_alt.clone())
1125        .border_1()
1126        .border_color(border)
1127        .text_size(px(10.0))
1128        .text_color(theme.fg_text)
1129        .child(SharedString::from(label.to_string()))
1130}
1131
1132fn format_offset(minutes: i64) -> String {
1133    if minutes == 0 {
1134        return "⏱ ahora".to_string();
1135    }
1136    let sign = if minutes > 0 { '+' } else { '-' };
1137    let m = minutes.unsigned_abs();
1138    let days = m / (60 * 24);
1139    let hours = (m / 60) % 24;
1140    let mins = m % 60;
1141    if days > 0 {
1142        format!("⏱ {}{}d {:02}h {:02}m", sign, days, hours, mins)
1143    } else if hours > 0 {
1144        format!("⏱ {}{:02}h {:02}m", sign, hours, mins)
1145    } else {
1146        format!("⏱ {}{:02}m", sign, mins)
1147    }
1148}
1149
1150// =====================================================================
1151// Painting
1152// =====================================================================
1153
1154#[derive(Clone, Copy)]
1155struct Radii {
1156    sign_outer: f32,
1157    sign_inner: f32,
1158    /// Anillo de glifos de tránsito (cuando el overlay está activo).
1159    transits: f32,
1160    houses_outer: f32,
1161    houses_inner: f32,
1162    /// Anillo de midpoints — entre bodies natales y houses_inner.
1163    midpoints: f32,
1164    bodies: f32,
1165    /// Anillo interno con cuerpos progresados (overlay opcional).
1166    progression: f32,
1167    /// Anillo más interno con cuerpos dirigidos por Solar Arc.
1168    solar_arc: f32,
1169    /// Anillo de carta compuesta (midpoint Davison) con un partner.
1170    composite: f32,
1171    aspects: f32,
1172}
1173
1174impl Radii {
1175    fn from_outer(r: f32) -> Self {
1176        Self {
1177            sign_outer: r,
1178            sign_inner: r * 0.88,
1179            transits: r * 0.82,
1180            houses_outer: r * 0.78,
1181            houses_inner: r * 0.66,
1182            midpoints: r * 0.62,
1183            bodies: r * 0.58,
1184            progression: r * 0.48,
1185            solar_arc: r * 0.40,
1186            composite: r * 0.32,
1187            aspects: r * 0.24,
1188        }
1189    }
1190
1191    /// Radio del ring de cuerpos según el `module_id` del Layer.
1192    fn body_ring(&self, module_id: &str) -> f32 {
1193        match module_id {
1194            "progression" => self.progression,
1195            "solar_arc" => self.solar_arc,
1196            "composite" => self.composite,
1197            "midpoints" => self.midpoints,
1198            _ => self.bodies,
1199        }
1200    }
1201
1202    /// Resuelve qué radios corresponden a una capa de aspectos según el
1203    /// `module_id`: natal-natal en `aspects`, cross con cada overlay
1204    /// desde `bodies` (extremo natal) al ring del módulo. Los módulos
1205    /// del outer ring (OUTER_RING_MODULES) comparten el slot de
1206    /// tránsito (son mutuamente excluyentes a nivel de Shell).
1207    fn aspect_endpoints(&self, module_id: &str) -> (f32, f32) {
1208        if OUTER_RING_MODULES.contains(&module_id) {
1209            return (self.bodies, self.transits);
1210        }
1211        match module_id {
1212            "progression" => (self.bodies, self.progression),
1213            "solar_arc" => (self.bodies, self.solar_arc),
1214            "composite" => (self.bodies, self.composite),
1215            _ => (self.aspects, self.aspects),
1216        }
1217    }
1218}
1219
1220#[allow(clippy::too_many_arguments)]
1221fn paint_wheel(
1222    bounds: Bounds<Pixels>,
1223    window: &mut Window,
1224    theme: &Theme,
1225    palette: &AstroPalette,
1226    layers: &[Layer],
1227    ascendant_deg: f32,
1228    midheaven_deg: f32,
1229    rot_offset_deg: f32,
1230    radii: Radii,
1231    visibility: &HashMap<LayerKind, bool>,
1232) {
1233    let (cx, cy) = bounds_center(bounds);
1234    let _ = theme;
1235    let show = |k: LayerKind| visibility.get(&k).copied().unwrap_or(true);
1236
1237    // 1. Sectores zodiacales (parte del SignDial layer).
1238    if show(LayerKind::SignDial) {
1239        paint_sign_sectors(window, cx, cy, &radii, palette, ascendant_deg, rot_offset_deg);
1240
1241        // Anillos del dial.
1242        stroke_circle(window, cx, cy, radii.sign_outer, 1.5, palette.dial_ring);
1243        stroke_circle(window, cx, cy, radii.sign_inner, 1.0, palette.dial_ring);
1244
1245        // Cusps zodiacales cada 30°.
1246        for i in 0..12 {
1247            let lon = (i as f32) * 30.0;
1248            let color = palette.dial_ring;
1249            paint_radial_line(
1250                window,
1251                cx,
1252                cy,
1253                lon,
1254                ascendant_deg,
1255                rot_offset_deg,
1256                radii.sign_inner,
1257                radii.sign_outer,
1258                color,
1259                1.0,
1260            );
1261        }
1262    }
1263
1264    // 2. Casas — cusps radiales + énfasis Asc/IC/Desc/MC.
1265    if show(LayerKind::Houses) {
1266        stroke_circle(
1267            window,
1268            cx,
1269            cy,
1270            radii.houses_inner,
1271            0.8,
1272            with_alpha(palette.house_cusp, 0.6),
1273        );
1274
1275        for layer in layers {
1276            if matches!(layer.kind, LayerKind::Houses) {
1277                if let Geometry::Ring { cusps_deg } = &layer.geometry {
1278                    for (i, c) in cusps_deg.iter().enumerate() {
1279                        let is_angle = i == 0 || i == 3 || i == 6 || i == 9;
1280                        let color = if is_angle {
1281                            palette.angle_highlight
1282                        } else {
1283                            with_alpha(palette.house_cusp, 0.7)
1284                        };
1285                        let width = if is_angle { 2.0 } else { 0.8 };
1286                        paint_radial_line(
1287                            window,
1288                            cx,
1289                            cy,
1290                            *c,
1291                            ascendant_deg,
1292                            rot_offset_deg,
1293                            radii.houses_inner,
1294                            radii.houses_outer,
1295                            color,
1296                            width,
1297                        );
1298                    }
1299                }
1300            }
1301        }
1302
1303        // Asc + MC extendidos hasta el centro con opacidad sutil.
1304        paint_radial_line(
1305            window,
1306            cx,
1307            cy,
1308            ascendant_deg,
1309            ascendant_deg,
1310            rot_offset_deg,
1311            0.0,
1312            radii.houses_outer,
1313            with_alpha(palette.angle_highlight, 0.35),
1314            1.0,
1315        );
1316        paint_radial_line(
1317            window,
1318            cx,
1319            cy,
1320            midheaven_deg,
1321            ascendant_deg,
1322            rot_offset_deg,
1323            0.0,
1324            radii.houses_outer,
1325            with_alpha(palette.angle_highlight, 0.35),
1326            1.0,
1327        );
1328    }
1329
1330    // 3. Aspectos. Cada module_id usa su par de radios — natal-natal
1331    // ambos en `aspects`, cross con transit en `bodies → transits`,
1332    // cross con progression en `bodies → progression`.
1333    if show(LayerKind::Aspects) {
1334        for layer in layers {
1335            if matches!(layer.kind, LayerKind::Aspects) {
1336                if let Geometry::Lines(segs) = &layer.geometry {
1337                    let (r_from, r_to) = radii.aspect_endpoints(&layer.module_id);
1338                    let is_cross = r_from != r_to;
1339                    for seg in segs {
1340                        let color = aspect_color(palette, &seg.kind);
1341                        let color = with_alpha(color, color.a * seg.opacity);
1342                        if is_cross {
1343                            paint_cross_aspect_line(
1344                                window,
1345                                cx,
1346                                cy,
1347                                seg.from_deg,
1348                                seg.to_deg,
1349                                ascendant_deg,
1350                                rot_offset_deg,
1351                                r_from,
1352                                r_to,
1353                                color,
1354                            );
1355                        } else {
1356                            paint_aspect_line(
1357                                window,
1358                                cx,
1359                                cy,
1360                                seg.from_deg,
1361                                seg.to_deg,
1362                                ascendant_deg,
1363                                rot_offset_deg,
1364                                r_from,
1365                                color,
1366                            );
1367                        }
1368                    }
1369                }
1370            }
1371        }
1372    }
1373
1374    // 4. Dots de cuerpos: natal en `bodies`, overlays en sus rings
1375    // específicos (progression, solar_arc). Las luminarias natales
1376    // (Sol/Luna) llevan glow halo — invita la mística sin saturar.
1377    if show(LayerKind::Bodies) {
1378        let dot_r = (radii.sign_outer * 0.018).max(2.0);
1379        for layer in layers {
1380            if matches!(layer.kind, LayerKind::Bodies) {
1381                let ring = radii.body_ring(&layer.module_id);
1382                let is_natal = layer.module_id == "natal";
1383                let alpha = if is_natal { 1.0 } else { 0.85 };
1384                for g in &layer.glyphs {
1385                    let color = with_alpha(planet_color(palette, &g.symbol), alpha);
1386                    let (x, y) = polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, ring);
1387                    if is_natal && (g.symbol == "sun" || g.symbol == "moon") {
1388                        paint_glow(window, cx + x, cy + y, dot_r, color);
1389                    }
1390                    fill_circle(window, cx + x, cy + y, dot_r, color);
1391                }
1392            }
1393        }
1394    }
1395
1396    // Anillos guía para los overlays internos (progression, solar_arc).
1397    let guide_inset = radii.sign_outer * 0.03;
1398    for (module_id, ring) in [
1399        ("progression", radii.progression),
1400        ("solar_arc", radii.solar_arc),
1401    ] {
1402        let active = layers
1403            .iter()
1404            .any(|l| matches!(l.kind, LayerKind::Bodies) && l.module_id == module_id);
1405        if active {
1406            stroke_circle(
1407                window,
1408                cx,
1409                cy,
1410                ring + guide_inset,
1411                0.5,
1412                with_alpha(palette.house_cusp, 0.35),
1413            );
1414            stroke_circle(
1415                window,
1416                cx,
1417                cy,
1418                ring - guide_inset,
1419                0.5,
1420                with_alpha(palette.house_cusp, 0.35),
1421            );
1422        }
1423    }
1424
1425    // 5. Outer ring (transit o synastry overlay): anillo guía + dots
1426    // de la capa activa. Son mutuamente excluyentes a nivel de Shell;
1427    // si alguno de los dos está prendido, pintamos el slot.
1428    let outer_active = layers.iter().any(|l| {
1429        matches!(l.kind, LayerKind::Outer)
1430            && OUTER_RING_MODULES.contains(&l.module_id.as_str())
1431    });
1432    if outer_active && show(LayerKind::Outer) {
1433        stroke_circle(
1434            window,
1435            cx,
1436            cy,
1437            radii.transits + radii.sign_outer * 0.035,
1438            0.6,
1439            with_alpha(palette.dial_ring, 0.4),
1440        );
1441        stroke_circle(
1442            window,
1443            cx,
1444            cy,
1445            radii.transits - radii.sign_outer * 0.035,
1446            0.6,
1447            with_alpha(palette.dial_ring, 0.4),
1448        );
1449
1450        let dot_r = (radii.sign_outer * 0.017).max(2.0);
1451        for layer in layers {
1452            if matches!(layer.kind, LayerKind::Outer)
1453                && (OUTER_RING_MODULES.contains(&layer.module_id.as_str()))
1454            {
1455                for g in &layer.glyphs {
1456                    let color = with_alpha(planet_color(palette, &g.symbol), 0.85);
1457                    let (x, y) =
1458                        polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, radii.transits);
1459                    fill_circle(window, cx + x, cy + y, dot_r, color);
1460                }
1461            }
1462        }
1463    }
1464}
1465
1466fn paint_sign_sectors(
1467    window: &mut Window,
1468    cx: f32,
1469    cy: f32,
1470    radii: &Radii,
1471    palette: &AstroPalette,
1472    ascendant_deg: f32,
1473    rot_offset_deg: f32,
1474) {
1475    const SUBDIVISIONS: usize = 18;
1476    for i in 0..12 {
1477        let lon_start = (i as f32) * 30.0;
1478        let lon_end = lon_start + 30.0;
1479        let element = sign_element_by_index(i);
1480        let color = with_alpha(palette.element(element), 0.10);
1481
1482        let mut builder = PathBuilder::fill();
1483        let (x0, y0) = polar_to_screen(lon_start, ascendant_deg, rot_offset_deg, radii.sign_inner);
1484        builder.move_to(point(px(cx + x0), px(cy + y0)));
1485
1486        for k in 1..=SUBDIVISIONS {
1487            let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32);
1488            let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_inner);
1489            builder.line_to(point(px(cx + x), px(cy + y)));
1490        }
1491        let (xe, ye) = polar_to_screen(lon_end, ascendant_deg, rot_offset_deg, radii.sign_outer);
1492        builder.line_to(point(px(cx + xe), px(cy + ye)));
1493
1494        for k in (0..SUBDIVISIONS).rev() {
1495            let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32);
1496            let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_outer);
1497            builder.line_to(point(px(cx + x), px(cy + y)));
1498        }
1499        builder.close();
1500        if let Ok(path) = builder.build() {
1501            window.paint_path(path, color);
1502        }
1503    }
1504}
1505
1506fn stroke_circle(window: &mut Window, cx: f32, cy: f32, r: f32, width: f32, color: Hsla) {
1507    const SEGMENTS: usize = 96;
1508    let mut builder = PathBuilder::stroke(px(width));
1509    for i in 0..=SEGMENTS {
1510        let t = (i as f32) / (SEGMENTS as f32) * (2.0 * PI);
1511        let x = cx + r * t.cos();
1512        let y = cy + r * t.sin();
1513        if i == 0 {
1514            builder.move_to(point(px(x), px(y)));
1515        } else {
1516            builder.line_to(point(px(x), px(y)));
1517        }
1518    }
1519    if let Ok(path) = builder.build() {
1520        window.paint_path(path, color);
1521    }
1522}
1523
1524/// Pinta 3 halos concéntricos con alpha decreciente alrededor de un
1525/// punto — usado para Sol/Luna natales. El radio crece, la opacidad
1526/// cae: el ojo lo lee como "esto irradia". Sin glow real (GPUI 0.2 no
1527/// tiene radial gradient), pero el shading concéntrico convence.
1528fn paint_glow(window: &mut Window, cx: f32, cy: f32, base_r: f32, color: Hsla) {
1529    const HALOS: [(f32, f32); 3] = [(5.0, 0.05), (3.0, 0.12), (1.8, 0.22)];
1530    for (mult, alpha) in HALOS {
1531        let r = base_r * mult;
1532        let halo = hsla(color.h, color.s, color.l, alpha);
1533        fill_circle(window, cx, cy, r, halo);
1534    }
1535}
1536
1537fn fill_circle(window: &mut Window, cx: f32, cy: f32, r: f32, color: Hsla) {
1538    const SEGMENTS: usize = 32;
1539    let mut builder = PathBuilder::fill();
1540    builder.move_to(point(px(cx + r), px(cy)));
1541    for i in 1..=SEGMENTS {
1542        let t = (i as f32) / (SEGMENTS as f32) * (2.0 * PI);
1543        let x = cx + r * t.cos();
1544        let y = cy + r * t.sin();
1545        builder.line_to(point(px(x), px(y)));
1546    }
1547    builder.close();
1548    if let Ok(path) = builder.build() {
1549        window.paint_path(path, color);
1550    }
1551}
1552
1553#[allow(clippy::too_many_arguments)]
1554fn paint_radial_line(
1555    window: &mut Window,
1556    cx: f32,
1557    cy: f32,
1558    longitude_deg: f32,
1559    ascendant_deg: f32,
1560    rot_offset_deg: f32,
1561    r_inner: f32,
1562    r_outer: f32,
1563    color: Hsla,
1564    width: f32,
1565) {
1566    let (xi, yi) = polar_to_screen(longitude_deg, ascendant_deg, rot_offset_deg, r_inner);
1567    let (xo, yo) = polar_to_screen(longitude_deg, ascendant_deg, rot_offset_deg, r_outer);
1568    let mut builder = PathBuilder::stroke(px(width));
1569    builder.move_to(point(px(cx + xi), px(cy + yi)));
1570    builder.line_to(point(px(cx + xo), px(cy + yo)));
1571    if let Ok(path) = builder.build() {
1572        window.paint_path(path, color);
1573    }
1574}
1575
1576#[allow(clippy::too_many_arguments)]
1577fn paint_aspect_line(
1578    window: &mut Window,
1579    cx: f32,
1580    cy: f32,
1581    a_deg: f32,
1582    b_deg: f32,
1583    ascendant_deg: f32,
1584    rot_offset_deg: f32,
1585    r: f32,
1586    color: Hsla,
1587) {
1588    let (xa, ya) = polar_to_screen(a_deg, ascendant_deg, rot_offset_deg, r);
1589    let (xb, yb) = polar_to_screen(b_deg, ascendant_deg, rot_offset_deg, r);
1590    let mut builder = PathBuilder::stroke(px(1.0));
1591    builder.move_to(point(px(cx + xa), px(cy + ya)));
1592    builder.line_to(point(px(cx + xb), px(cy + yb)));
1593    if let Ok(path) = builder.build() {
1594        window.paint_path(path, color);
1595    }
1596}
1597
1598/// Línea de aspecto natal ↔ tránsito: extremos en radios distintos.
1599/// El `from_deg` cae sobre el ring de cuerpos natales (`r_from`); el
1600/// `to_deg` sobre el ring de tránsito (`r_to`). Trazo más fino que el
1601/// natal-natal para no competir visualmente.
1602#[allow(clippy::too_many_arguments)]
1603fn paint_cross_aspect_line(
1604    window: &mut Window,
1605    cx: f32,
1606    cy: f32,
1607    natal_deg: f32,
1608    transit_deg: f32,
1609    ascendant_deg: f32,
1610    rot_offset_deg: f32,
1611    r_from: f32,
1612    r_to: f32,
1613    color: Hsla,
1614) {
1615    let (xa, ya) = polar_to_screen(natal_deg, ascendant_deg, rot_offset_deg, r_from);
1616    let (xb, yb) = polar_to_screen(transit_deg, ascendant_deg, rot_offset_deg, r_to);
1617    let mut builder = PathBuilder::stroke(px(0.7));
1618    builder.move_to(point(px(cx + xa), px(cy + ya)));
1619    builder.line_to(point(px(cx + xb), px(cy + yb)));
1620    if let Ok(path) = builder.build() {
1621        window.paint_path(path, color);
1622    }
1623}
1624
1625// =====================================================================
1626// Helpers
1627// =====================================================================
1628
1629fn polar_to_screen(
1630    longitude_deg: f32,
1631    ascendant_deg: f32,
1632    rot_offset_deg: f32,
1633    radius: f32,
1634) -> (f32, f32) {
1635    let deg = 180.0 - (longitude_deg - ascendant_deg + rot_offset_deg);
1636    let rad = deg * PI / 180.0;
1637    (radius * rad.cos(), radius * rad.sin())
1638}
1639
1640fn centered_glyph(
1641    x: f32,
1642    y: f32,
1643    box_size: f32,
1644    font_size: f32,
1645    text: SharedString,
1646    color: Hsla,
1647) -> gpui::Div {
1648    div()
1649        .absolute()
1650        .left(px(x - box_size / 2.0))
1651        .top(px(y - box_size / 2.0))
1652        .w(px(box_size))
1653        .h(px(box_size))
1654        .flex()
1655        .items_center()
1656        .justify_center()
1657        .text_size(px(font_size))
1658        .text_color(color)
1659        .child(text)
1660}
1661
1662fn with_alpha(c: Hsla, a: f32) -> Hsla {
1663    hsla(c.h, c.s, c.l, a.clamp(0.0, 1.0))
1664}
1665
1666fn sign_unicode(name: &str) -> &'static str {
1667    match name {
1668        "aries" => "♈",
1669        "taurus" => "♉",
1670        "gemini" => "♊",
1671        "cancer" => "♋",
1672        "leo" => "♌",
1673        "virgo" => "♍",
1674        "libra" => "♎",
1675        "scorpio" => "♏",
1676        "sagittarius" => "♐",
1677        "capricorn" => "♑",
1678        "aquarius" => "♒",
1679        "pisces" => "♓",
1680        _ => "?",
1681    }
1682}
1683
1684const SIGN_NAMES_ES: [&str; 12] = [
1685    "Aries",
1686    "Tauro",
1687    "Géminis",
1688    "Cáncer",
1689    "Leo",
1690    "Virgo",
1691    "Libra",
1692    "Escorpio",
1693    "Sagitario",
1694    "Capricornio",
1695    "Acuario",
1696    "Piscis",
1697];
1698
1699fn aspect_unicode(kind: &str) -> &'static str {
1700    match kind {
1701        "conjunction" => "☌",
1702        "opposition" => "☍",
1703        "trine" => "△",
1704        "square" => "□",
1705        "sextile" => "⚹",
1706        "quincunx" => "⚻",
1707        "semi_sextile" => "⚺",
1708        "semi_square" => "∠",
1709        "sesquiquadrate" => "⚼",
1710        "quintile" => "Q",
1711        "biquintile" => "bQ",
1712        _ => "·",
1713    }
1714}
1715
1716fn planet_unicode(name: &str) -> &'static str {
1717    match name {
1718        "sun" => "☉",
1719        "moon" => "☽",
1720        "mercury" => "☿",
1721        "venus" => "♀",
1722        "mars" => "♂",
1723        "jupiter" => "♃",
1724        "saturn" => "♄",
1725        "uranus" => "♅",
1726        "neptune" => "♆",
1727        "pluto" => "♇",
1728        "north_node" => "☊",
1729        "south_node" => "☋",
1730        "chiron" => "⚷",
1731        "lilith" => "⚸",
1732        "ceres" => "⚳",
1733        "pallas" => "⚴",
1734        "juno" => "⚵",
1735        "vesta" => "⚶",
1736        _ => "•",
1737    }
1738}
1739
1740fn planet_color(p: &AstroPalette, name: &str) -> Hsla {
1741    let planet = match name {
1742        "sun" => Planet::Sun,
1743        "moon" => Planet::Moon,
1744        "mercury" => Planet::Mercury,
1745        "venus" => Planet::Venus,
1746        "mars" => Planet::Mars,
1747        "jupiter" => Planet::Jupiter,
1748        "saturn" => Planet::Saturn,
1749        "uranus" => Planet::Uranus,
1750        "neptune" => Planet::Neptune,
1751        "pluto" => Planet::Pluto,
1752        "chiron" => Planet::Chiron,
1753        "north_node" => Planet::NorthNode,
1754        "south_node" => Planet::SouthNode,
1755        "lilith" => Planet::Lilith,
1756        _ => return p.fg_text_fallback(),
1757    };
1758    p.planet(planet)
1759}
1760
1761fn sign_element_by_index(i: usize) -> Element {
1762    match i % 4 {
1763        0 => Element::Fire,
1764        1 => Element::Earth,
1765        2 => Element::Air,
1766        _ => Element::Water,
1767    }
1768}
1769
1770fn element_color_for_sign(p: &AstroPalette, name: &str) -> Hsla {
1771    let elem = match name {
1772        "aries" | "leo" | "sagittarius" => Element::Fire,
1773        "taurus" | "virgo" | "capricorn" => Element::Earth,
1774        "gemini" | "libra" | "aquarius" => Element::Air,
1775        "cancer" | "scorpio" | "pisces" => Element::Water,
1776        _ => return p.fg_text_fallback(),
1777    };
1778    p.element(elem)
1779}
1780
1781fn aspect_color(p: &AstroPalette, kind: &str) -> Hsla {
1782    let k = match kind {
1783        "conjunction" => TAspectKind::Conjunction,
1784        "opposition" => TAspectKind::Opposition,
1785        "trine" => TAspectKind::Trine,
1786        "square" => TAspectKind::Square,
1787        "sextile" => TAspectKind::Sextile,
1788        "quincunx" => TAspectKind::Quincunx,
1789        "semi_sextile" => TAspectKind::Semisextile,
1790        "semi_square" => TAspectKind::Semisquare,
1791        "sesquiquadrate" => TAspectKind::Sesquisquare,
1792        "quintile" => TAspectKind::Quintile,
1793        "biquintile" => TAspectKind::Biquintile,
1794        _ => return p.minor_aspect,
1795    };
1796    p.aspect(k)
1797}
1798
1799trait AstroPaletteExt {
1800    fn fg_text_fallback(&self) -> Hsla;
1801}
1802
1803impl AstroPaletteExt for AstroPalette {
1804    fn fg_text_fallback(&self) -> Hsla {
1805        if self.is_dark {
1806            hsla(0.0, 0.0, 0.85, 1.0)
1807        } else {
1808            hsla(0.0, 0.0, 0.25, 1.0)
1809        }
1810    }
1811}