tawasuyu

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Sutichaykuna

⎇ 8ede06f8c4fb668a877e5864d655bf877cf00550 crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs Rust · 2601 siq'ikuna · 93.0 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(tahuantinsuyu): distinción eclíptica/casas, coord labels, jog-dial con Ctrl 8ede06f8c · sergio ·

1//! `tahuantinsuyu-canvas` — el widget GPUI del lienzo astrológico.
2//!
3//! Modela el cielo como un lienzo de **geometría reactiva**: un estado
4//! unificado [`CanvasState`] guarda offsets de rotación, flags de
5//! visibilidad y la lista de `Layer`s a pintar. Las interacciones
6//! (drag, hotkeys, toggles) mutan el estado; el render lee la última
7//! `RenderModel` y la deriva al frame.
8//!
9//! ## Convención de rotación
10//!
11//! El Ascendente cae a las 9 del reloj (lado izquierdo). Las casas
12//! crecen contrarreloj visualmente. Para una longitud eclíptica `L` y
13//! un ascendente `asc`:
14//!
15//! ```text
16//!   screen_angle_rad = π - (L - asc + view_rotation) · π/180
17//!   point = (cx + r·cos(θ), cy + r·sin(θ))
18//! ```
19//!
20//! ## Interacciones (fase 4)
21//!
22//! - **Drag en el aro exterior** (jog-dial perimetral): rota la rueda
23//!   visualmente mientras dura el drag y, al soltar, traduce el delta
24//!   angular a minutos (1° ≈ 4 min) y emite
25//!   [`CanvasEvent::TimeOffsetChanged`]. El host (la app) recomputa la
26//!   carta para el instante desplazado.
27//! - **Hotkeys**: `D`/`H`/`X`/`P` togglean SignDial/Houses/Aspects/
28//!   Bodies. Click sobre el wheel le da focus al widget.
29
30#![forbid(unsafe_code)]
31#![warn(rust_2018_idioms)]
32
33use std::collections::HashMap;
34use std::f32::consts::PI;
35
36use gpui::{
37    Bounds, Context, EventEmitter, FocusHandle, Focusable, Hsla, IntoElement,
38    KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement,
39    PathBuilder, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, SharedString, Styled,
40    Window, canvas, div, hsla, point, prelude::*, px,
41};
42
43use tahuantinsuyu_engine::{Geometry, Layer, LayerKind, OUTER_RING_MODULES, RenderModel};
44use tahuantinsuyu_model::{ChartId, ContactId, GroupId};
45use tahuantinsuyu_theme::{AspectKind as TAspectKind, AstroPalette, Element, Planet};
46use yahweh_theme::Theme;
47
48// =====================================================================
49// Eventos
50// =====================================================================
51
52#[derive(Clone, Debug)]
53pub enum CanvasEvent {
54    /// Doble click sobre un thumbnail.
55    ChartRequested(ChartId),
56    /// Drag terminado: el offset acumulado de tiempo (en minutos)
57    /// cambió. El host debe recomputar el chart con este offset.
58    TimeOffsetChanged(i64),
59    /// El usuario togggleó una capa via hotkey — el panel debería
60    /// reflejarlo si quisiera mantenerse en sync.
61    LayerVisibilityChanged { kind: LayerKind, visible: bool },
62    /// El usuario 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/// Drag activo de pan (MMB o LMB con Space). Captura el pan inicial al
106/// hacer mousedown; el move agrega delta_pos a esos valores.
107#[derive(Clone, Debug)]
108struct PanDragState {
109    start_pos: Point<Pixels>,
110    pan_x_start: f32,
111    pan_y_start: f32,
112}
113
114#[derive(Clone, Debug)]
115pub struct CanvasState {
116    pub mode: CanvasMode,
117    /// Rotación visual transitoria durante un drag. Se resetea a `0` al
118    /// soltar — el render nuevo trae el `ascendant_deg` ya rotado.
119    pub view_rotation_deg: f32,
120    /// Offset acumulado en minutos. Persiste entre drags hasta que el
121    /// host lo resetee.
122    pub time_offset_minutes: i64,
123    /// Factor de zoom multiplicativo aplicado al wheel. `1.0` = tamaño
124    /// nominal. Clampeado a [VIEW_SCALE_MIN, VIEW_SCALE_MAX].
125    pub view_scale: f32,
126    /// Pan horizontal en px (positivo = desplaza el wheel a la derecha
127    /// desde el centro). Se aplica como margin shift sobre el centrado
128    /// natural del flex parent.
129    pub view_pan_x: f32,
130    /// Pan vertical en px (positivo = abajo).
131    pub view_pan_y: f32,
132    /// Por-LayerKind: `true` = visible. Default = todo visible.
133    pub layer_visibility: HashMap<LayerKind, bool>,
134    /// Indicadores de grado al lado de cada planeta y cusp de casa.
135    /// Default `true` — el usuario los espera ver para leer la
136    /// carta. Se togglean con `C` (Coords) o desde el panel.
137    pub show_coords: bool,
138    /// Planeta hovered actualmente (para tooltip). `None` cuando el
139    /// mouse no está sobre ningún cuerpo.
140    pub hover: Option<HoverInfo>,
141    drag_jog: Option<JogDragState>,
142    drag_pan: Option<PanDragState>,
143}
144
145/// Límites del zoom — bajo 0.5 los glyphs se vuelven ilegibles; sobre
146/// 3.0 el wheel desborda incluso pantallas grandes.
147pub const VIEW_SCALE_MIN: f32 = 0.5;
148pub const VIEW_SCALE_MAX: f32 = 3.0;
149
150/// Info del elemento bajo el cursor — usado por el render para mostrar
151/// un tooltip flotante con detalles. Cubre body glyphs, cusps de casa,
152/// y líneas de aspectos.
153#[derive(Clone, Debug)]
154pub enum HoverInfo {
155    Body {
156        module_id: String,
157        symbol: String,
158        deg: f32,
159        house: Option<u8>,
160        retrograde: bool,
161        dignity_marker: Option<String>,
162        annotation: Option<String>,
163        local_x: f32,
164        local_y: f32,
165    },
166    HouseCusp {
167        house_number: u8,
168        deg: f32,
169        local_x: f32,
170        local_y: f32,
171    },
172    /// Hover sobre una línea de aspecto. `from_body`/`to_body` y `kind`
173    /// vienen de la LineSeg; `orb_deg` también. Los coords son el
174    /// punto medio del segmento donde se muestra el tooltip.
175    Aspect {
176        module_id: String,
177        from_body: String,
178        to_body: String,
179        kind: String,
180        orb_deg: f32,
181        local_x: f32,
182        local_y: f32,
183    },
184}
185
186impl HoverInfo {
187    fn local(&self) -> (f32, f32) {
188        match self {
189            HoverInfo::Body { local_x, local_y, .. } => (*local_x, *local_y),
190            HoverInfo::HouseCusp { local_x, local_y, .. } => (*local_x, *local_y),
191            HoverInfo::Aspect { local_x, local_y, .. } => (*local_x, *local_y),
192        }
193    }
194
195    fn key(&self) -> String {
196        match self {
197            HoverInfo::Body {
198                module_id, symbol, ..
199            } => format!("body:{}:{}", module_id, symbol),
200            HoverInfo::HouseCusp { house_number, .. } => format!("cusp:{}", house_number),
201            HoverInfo::Aspect {
202                module_id,
203                from_body,
204                to_body,
205                kind,
206                ..
207            } => format!("aspect:{}:{}-{}-{}", module_id, from_body, kind, to_body),
208        }
209    }
210}
211
212impl Default for CanvasState {
213    fn default() -> Self {
214        Self {
215            mode: CanvasMode::default(),
216            view_rotation_deg: 0.0,
217            time_offset_minutes: 0,
218            view_scale: 1.0,
219            view_pan_x: 0.0,
220            view_pan_y: 0.0,
221            layer_visibility: HashMap::new(),
222            show_coords: true,
223            hover: None,
224            drag_jog: None,
225            drag_pan: None,
226        }
227    }
228}
229
230impl CanvasState {
231    pub fn is_layer_visible(&self, kind: LayerKind) -> bool {
232        self.layer_visibility.get(&kind).copied().unwrap_or(true)
233    }
234}
235
236// =====================================================================
237// Widget
238// =====================================================================
239
240pub struct AstrologyCanvas {
241    state: CanvasState,
242    focus_handle: FocusHandle,
243}
244
245impl EventEmitter<CanvasEvent> for AstrologyCanvas {}
246
247impl Focusable for AstrologyCanvas {
248    fn focus_handle(&self, _: &gpui::App) -> FocusHandle {
249        self.focus_handle.clone()
250    }
251}
252
253impl AstrologyCanvas {
254    pub fn new(cx: &mut Context<'_, Self>) -> Self {
255        cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
256        Self {
257            state: CanvasState::default(),
258            focus_handle: cx.focus_handle(),
259        }
260    }
261
262    pub fn state(&self) -> &CanvasState {
263        &self.state
264    }
265
266    pub fn set_mode(&mut self, mode: CanvasMode, cx: &mut Context<'_, Self>) {
267        self.state.mode = mode;
268        cx.notify();
269    }
270
271    pub fn set_layer_visible(&mut self, kind: LayerKind, visible: bool, cx: &mut Context<'_, Self>) {
272        self.state.layer_visibility.insert(kind, visible);
273        cx.notify();
274    }
275
276    pub fn toggle_layer(&mut self, kind: LayerKind, cx: &mut Context<'_, Self>) {
277        let current = self.state.is_layer_visible(kind);
278        self.set_layer_visible(kind, !current, cx);
279        cx.emit(CanvasEvent::LayerVisibilityChanged {
280            kind,
281            visible: !current,
282        });
283    }
284
285    pub fn reset_time_offset(&mut self, cx: &mut Context<'_, Self>) {
286        if self.state.time_offset_minutes != 0 || self.state.view_rotation_deg != 0.0 {
287            self.state.time_offset_minutes = 0;
288            self.state.view_rotation_deg = 0.0;
289            cx.emit(CanvasEvent::TimeOffsetChanged(0));
290            cx.notify();
291        }
292    }
293
294    pub fn set_view_rotation(&mut self, deg: f32, cx: &mut Context<'_, Self>) {
295        self.state.view_rotation_deg = deg.rem_euclid(360.0);
296        cx.notify();
297    }
298
299    pub fn toggle_coords(&mut self, cx: &mut Context<'_, Self>) {
300        self.state.show_coords = !self.state.show_coords;
301        cx.notify();
302    }
303
304    /// Resetea zoom y pan a sus defaults (1.0 y 0,0). No toca rotation
305    /// ni time offset — esos son ortogonales y tienen su propio reset.
306    pub fn reset_view(&mut self, cx: &mut Context<'_, Self>) {
307        if self.state.view_scale != 1.0
308            || self.state.view_pan_x != 0.0
309            || self.state.view_pan_y != 0.0
310        {
311            self.state.view_scale = 1.0;
312            self.state.view_pan_x = 0.0;
313            self.state.view_pan_y = 0.0;
314            cx.notify();
315        }
316    }
317
318    /// Zoom multiplicativo. El nuevo scale es `current * factor`, clamp
319    /// al rango permitido. El zoom es centrado (no rastrea el cursor) —
320    /// para mover el foco después del zoom, el usuario paneja con MMB.
321    fn zoom_by(&mut self, factor: f32, cx: &mut Context<'_, Self>) {
322        let new_scale =
323            (self.state.view_scale * factor).clamp(VIEW_SCALE_MIN, VIEW_SCALE_MAX);
324        if (new_scale - self.state.view_scale).abs() < 1e-4 {
325            return;
326        }
327        // Mantener el centro del wheel anclado al centro de pantalla:
328        // como el pan está en coords de la pantalla y el zoom es desde
329        // el centro del wheel, el pan se escala proporcional al ratio.
330        let ratio = new_scale / self.state.view_scale;
331        self.state.view_pan_x *= ratio;
332        self.state.view_pan_y *= ratio;
333        self.state.view_scale = new_scale;
334        cx.notify();
335    }
336
337    #[allow(dead_code)]
338    fn pan_by(&mut self, dx: f32, dy: f32, cx: &mut Context<'_, Self>) {
339        if dx == 0.0 && dy == 0.0 {
340            return;
341        }
342        self.state.view_pan_x += dx;
343        self.state.view_pan_y += dy;
344        cx.notify();
345    }
346
347    // ----- Internos: handlers de jog-dial -----
348
349    /// Despacha el LMB down entre jog-dial y pan. El jog-dial es un
350    /// control "fuerte" (mueve el tiempo de la carta), así que se
351    /// activa SOLO con modifier Ctrl/Cmd + click sobre el anillo de
352    /// signos — sin modifier es siempre pan, incluso sobre el anillo,
353    /// para que no haya rotaciones accidentales al manipular la
354    /// rueda.
355    fn on_primary_down(
356        &mut self,
357        position: Point<Pixels>,
358        modifiers: gpui::Modifiers,
359        bounds: Bounds<Pixels>,
360        cx: &mut Context<'_, Self>,
361    ) {
362        // Sin modifier: pan, sin importar dónde caiga el click.
363        if !(modifiers.control || modifiers.platform) {
364            self.on_pan_down(position, cx);
365            return;
366        }
367        let (cx_px, cy_px) = bounds_center(bounds);
368        let mx: f32 = position.x.into();
369        let my: f32 = position.y.into();
370        let dx = mx - cx_px;
371        let dy = my - cy_px;
372        let dist = (dx * dx + dy * dy).sqrt();
373        let r_outer = effective_r_outer(bounds);
374        let radii = Radii::from_outer(r_outer);
375        let on_dial = dist >= radii.sign_inner * 0.95 && dist <= radii.sign_outer * 1.10;
376        if on_dial {
377            let angle = dy.atan2(dx).to_degrees();
378            self.state.drag_jog = Some(JogDragState {
379                last_screen_angle_deg: angle,
380                accumulated_delta_deg: 0.0,
381            });
382        } else {
383            // Ctrl+click fuera del anillo: pan también — el modifier
384            // habilita el jog-dial pero no impide la navegación.
385            self.on_pan_down(position, cx);
386        }
387    }
388
389    fn on_jog_move(
390        &mut self,
391        position: Point<Pixels>,
392        bounds: Bounds<Pixels>,
393        cx: &mut Context<'_, Self>,
394    ) {
395        let Some(jog) = self.state.drag_jog.as_mut() else {
396            return;
397        };
398        let (cx_px, cy_px) = bounds_center(bounds);
399        let mx: f32 = position.x.into();
400        let my: f32 = position.y.into();
401        let dx = mx - cx_px;
402        let dy = my - cy_px;
403        let angle = dy.atan2(dx).to_degrees();
404        let mut delta = angle - jog.last_screen_angle_deg;
405        // Normalizar a (-180, 180] para cruzar el wrap sin saltar.
406        if delta > 180.0 {
407            delta -= 360.0;
408        } else if delta < -180.0 {
409            delta += 360.0;
410        }
411        jog.accumulated_delta_deg += delta;
412        jog.last_screen_angle_deg = angle;
413        // Reflejo visual durante el drag (sin recomputar).
414        self.state.view_rotation_deg = jog.accumulated_delta_deg;
415        cx.notify();
416    }
417
418    /// Hit-test sobre body glyphs + house cusps. Para bodies: distancia
419    /// al centro del glyph dentro de threshold. Para cusps: el mouse
420    /// debe estar cerca del ring de casas Y angularmente cerca del
421    /// cusp (proximidad a la línea radial).
422    fn on_hover_check(
423        &mut self,
424        position: Point<Pixels>,
425        bounds: Bounds<Pixels>,
426        cx: &mut Context<'_, Self>,
427    ) {
428        let CanvasMode::Wheel { render } = &self.state.mode else {
429            if self.state.hover.take().is_some() {
430                cx.notify();
431            }
432            return;
433        };
434        let (cx_px, cy_px) = bounds_center(bounds);
435        let mx: f32 = position.x.into();
436        let my: f32 = position.y.into();
437        let ox: f32 = bounds.origin.x.into();
438        let oy: f32 = bounds.origin.y.into();
439        let r_outer = effective_r_outer(bounds);
440        let radii = Radii::from_outer(r_outer);
441        let asc = render.ascendant_deg;
442        let rot = self.state.view_rotation_deg;
443        let body_threshold = 14.0_f32;
444
445        let mut best: Option<(f32, HoverInfo)> = None;
446
447        // 1) Body glyphs (incluye natal, overlays, midpoints).
448        for layer in &render.layers {
449            let ring = match layer.kind {
450                LayerKind::Bodies => radii.body_ring(&layer.module_id),
451                LayerKind::Midpoints => radii.midpoints,
452                LayerKind::Outer if OUTER_RING_MODULES.contains(&layer.module_id.as_str()) => {
453                    radii.transits
454                }
455                _ => continue,
456            };
457            for g in &layer.glyphs {
458                let (gx, gy) = polar_to_screen(g.deg, asc, rot, ring);
459                let dx = mx - (cx_px + gx);
460                let dy = my - (cy_px + gy);
461                let dist = (dx * dx + dy * dy).sqrt();
462                if dist > body_threshold {
463                    continue;
464                }
465                if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
466                    best = Some((
467                        dist,
468                        HoverInfo::Body {
469                            module_id: layer.module_id.clone(),
470                            symbol: g.symbol.clone(),
471                            deg: g.deg,
472                            house: g.house,
473                            retrograde: g.retrograde,
474                            dignity_marker: g.dignity_marker.clone(),
475                            annotation: g.annotation.clone(),
476                            local_x: cx_px + gx - ox,
477                            local_y: cy_px + gy - oy,
478                        },
479                    ));
480                }
481            }
482        }
483
484        // 2) Aspect lines (segundo: las líneas son más "frágiles" que
485        // los planetas; si un body matcheó arriba ya tomó precedencia).
486        // Computa distancia punto-segmento del mouse al line.
487        if best.is_none() {
488            for layer in &render.layers {
489                if !matches!(layer.kind, LayerKind::Aspects) {
490                    continue;
491                }
492                let (r_from, r_to) = radii.aspect_endpoints(&layer.module_id);
493                if let Geometry::Lines(segs) = &layer.geometry {
494                    for seg in segs {
495                        if seg.from_body.is_empty() || seg.to_body.is_empty() {
496                            continue;
497                        }
498                        let (ax, ay) = polar_to_screen(seg.from_deg, asc, rot, r_from);
499                        let (bx, by) = polar_to_screen(seg.to_deg, asc, rot, r_to);
500                        let px_a = cx_px + ax;
501                        let py_a = cy_px + ay;
502                        let px_b = cx_px + bx;
503                        let py_b = cy_px + by;
504                        let dist = dist_point_segment(mx, my, px_a, py_a, px_b, py_b);
505                        if dist > 4.0 {
506                            continue;
507                        }
508                        if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
509                            let mid_x = (px_a + px_b) / 2.0;
510                            let mid_y = (py_a + py_b) / 2.0;
511                            best = Some((
512                                dist,
513                                HoverInfo::Aspect {
514                                    module_id: layer.module_id.clone(),
515                                    from_body: seg.from_body.clone(),
516                                    to_body: seg.to_body.clone(),
517                                    kind: seg.kind.clone(),
518                                    orb_deg: seg.orb_deg,
519                                    local_x: mid_x - ox,
520                                    local_y: mid_y - oy,
521                                },
522                            ));
523                        }
524                    }
525                }
526            }
527        }
528
529        // 3) House cusps — solo si el mouse está cerca del anillo de
530        // casas (radio entre houses_inner y houses_outer + margen) y
531        // ningún body ganó. Las cusps son líneas radiales — la
532        // distancia angular al cusp más cercano determina el hit.
533        if best.is_none() {
534            let dx = mx - cx_px;
535            let dy = my - cy_px;
536            let mouse_r = (dx * dx + dy * dy).sqrt();
537            let r_in = radii.houses_inner - 6.0;
538            let r_out = radii.houses_outer + 6.0;
539            if mouse_r >= r_in && mouse_r <= r_out {
540                // Calcular la longitud zodiacal que corresponde a este
541                // ángulo de pantalla (inversa de polar_to_screen).
542                let screen_angle_deg = dy.atan2(dx).to_degrees(); // (-180, 180]
543                // polar_to_screen: deg = 180 - (lon - asc + rot)
544                // → lon = asc + 180 - screen_angle_deg - rot
545                let lon = ((asc + 180.0 - screen_angle_deg - rot) as f32).rem_euclid(360.0);
546                // Buscar cusp más cercano (con wraparound).
547                for layer in &render.layers {
548                    if matches!(layer.kind, LayerKind::Houses) {
549                        if let Geometry::Ring { cusps_deg } = &layer.geometry {
550                            for (i, c) in cusps_deg.iter().enumerate() {
551                                let mut diff = (lon - c).abs();
552                                if diff > 180.0 {
553                                    diff = 360.0 - diff;
554                                }
555                                if diff < 2.5 {
556                                    // Mouse cerca de ESTE cusp.
557                                    let (gx, gy) = polar_to_screen(
558                                        *c,
559                                        asc,
560                                        rot,
561                                        (radii.houses_inner + radii.houses_outer) / 2.0,
562                                    );
563                                    best = Some((
564                                        diff,
565                                        HoverInfo::HouseCusp {
566                                            house_number: (i as u8) + 1,
567                                            deg: *c,
568                                            local_x: cx_px + gx - ox,
569                                            local_y: cy_px + gy - oy,
570                                        },
571                                    ));
572                                    break;
573                                }
574                            }
575                        }
576                    }
577                }
578            }
579        }
580
581        let new_hover = best.map(|(_, h)| h);
582        let changed = match (&self.state.hover, &new_hover) {
583            (Some(a), Some(b)) => a.key() != b.key(),
584            (None, None) => false,
585            _ => true,
586        };
587        if changed {
588            self.state.hover = new_hover;
589            cx.notify();
590        }
591    }
592
593    // ----- Internos: pan drag (MMB) -----
594
595    fn on_pan_down(&mut self, position: Point<Pixels>, _cx: &mut Context<'_, Self>) {
596        self.state.drag_pan = Some(PanDragState {
597            start_pos: position,
598            pan_x_start: self.state.view_pan_x,
599            pan_y_start: self.state.view_pan_y,
600        });
601    }
602
603    fn on_pan_move(&mut self, position: Point<Pixels>, cx: &mut Context<'_, Self>) {
604        let Some(pan) = self.state.drag_pan.as_ref() else {
605            return;
606        };
607        let dx: f32 = (position.x - pan.start_pos.x).into();
608        let dy: f32 = (position.y - pan.start_pos.y).into();
609        self.state.view_pan_x = pan.pan_x_start + dx;
610        self.state.view_pan_y = pan.pan_y_start + dy;
611        cx.notify();
612    }
613
614    fn on_pan_up(&mut self, cx: &mut Context<'_, Self>) {
615        if self.state.drag_pan.take().is_some() {
616            cx.notify();
617        }
618    }
619
620    fn on_scroll(
621        &mut self,
622        event: &ScrollWheelEvent,
623        _w: &mut Window,
624        cx: &mut Context<'_, Self>,
625    ) {
626        let (_dx_px, dy_px) = match event.delta {
627            ScrollDelta::Pixels(p) => (f32::from(p.x), f32::from(p.y)),
628            ScrollDelta::Lines(p) => (p.x * 16.0, p.y * 16.0),
629        };
630        // Wheel = zoom puro, sin modifier. Pan se hace con drag (LMB
631        // fuera del anillo, o MMB). 100px de scroll ≈ ±20% zoom.
632        let factor = (dy_px * 0.002).exp();
633        self.zoom_by(factor, cx);
634    }
635
636    fn on_jog_up(&mut self, cx: &mut Context<'_, Self>) {
637        let Some(jog) = self.state.drag_jog.take() else {
638            return;
639        };
640        // 1° de arco ≈ 4 minutos de tiempo sideral (15°/hora).
641        // CW visual (delta negativa en nuestra convención) → tiempo
642        // hacia adelante.
643        let delta_minutes = (-jog.accumulated_delta_deg * 4.0) as i64;
644        if delta_minutes != 0 {
645            self.state.time_offset_minutes =
646                self.state.time_offset_minutes.saturating_add(delta_minutes);
647            // Snap visual: el shell recomputa con el nuevo offset y el
648            // render trae el ascendant rotado.
649            self.state.view_rotation_deg = 0.0;
650            cx.emit(CanvasEvent::TimeOffsetChanged(self.state.time_offset_minutes));
651            cx.notify();
652        } else {
653            self.state.view_rotation_deg = 0.0;
654            cx.notify();
655        }
656    }
657
658    fn on_key_down(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<'_, Self>) {
659        let key = event.keystroke.key.as_str();
660        let kind = match key {
661            "d" | "D" => LayerKind::SignDial,
662            "h" | "H" => LayerKind::Houses,
663            "x" | "X" => LayerKind::Aspects,
664            "p" | "P" => LayerKind::Bodies,
665            "t" | "T" => LayerKind::Outer,
666            "r" | "R" => {
667                self.reset_time_offset(cx);
668                return;
669            }
670            "0" => {
671                self.reset_view(cx);
672                return;
673            }
674            "c" | "C" => {
675                self.toggle_coords(cx);
676                return;
677            }
678            "s" | "S" => {
679                cx.emit(CanvasEvent::ExportSvgRequested);
680                return;
681            }
682            _ => return,
683        };
684        self.toggle_layer(kind, cx);
685    }
686}
687
688// =====================================================================
689// Geometría de pantalla
690// =====================================================================
691
692const WHEEL_SIZE: f32 = 580.0;
693const WHEEL_MARGIN: f32 = 28.0;
694
695/// Pinta un gradiente radial de profundidad sobre el background del
696/// canvas — efecto vignette. Se aproxima al gradient radial (no
697/// soportado nativamente por gpui en `.bg()`) pintando ~28 anillos
698/// concéntricos del centro hacia afuera, con alpha creciente hacia el
699/// borde. El centro queda claro y los extremos se oscurecen, dando
700/// sensación de "el wheel emerge desde la profundidad".
701///
702/// Solo activo en themes dark — sobre papel (light / print) el panel
703/// queda plano: una viñeta sobre fondo claro tiñe el papel y rompe
704/// la metáfora "impresión".
705fn paint_depth_field(bounds: Bounds<Pixels>, window: &mut Window, theme: &Theme) {
706    if !theme.is_dark {
707        return;
708    }
709    let ox: f32 = bounds.origin.x.into();
710    let oy: f32 = bounds.origin.y.into();
711    let bw: f32 = bounds.size.width.into();
712    let bh: f32 = bounds.size.height.into();
713    if bw <= 0.0 || bh <= 0.0 {
714        return;
715    }
716    let cx = ox + bw / 2.0;
717    let cy = oy + bh / 2.0;
718    // El gradient se extiende hasta la diagonal del rectángulo para
719    // que las esquinas estén dentro del último anillo (sin "halo"
720    // visible donde se corta).
721    let r_max = ((bw * bw + bh * bh).sqrt()) / 2.0 * 1.05;
722    let steps = 28;
723    // Color: casi-negro con tinte ligero del panel (el panel es dark).
724    let deep = hsla(230.0 / 360.0, 0.30, 0.04, 1.0);
725    // Stroke de cada anillo: el ancho cubre 1/steps del radio para
726    // que no queden gaps entre anillos.
727    let stroke_w = (r_max / steps as f32) * 1.15;
728    for i in 0..steps {
729        let t = i as f32 / (steps - 1) as f32;
730        let r = r_max * t;
731        // Curva ease-in: alpha crece de 0 (centro) a ~0.55 (borde),
732        // con la mayor parte del cambio en la mitad exterior. t² da
733        // ese "fondo profundo en el perímetro sin opacar el centro".
734        let alpha = 0.55 * (t * t);
735        stroke_circle(window, cx, cy, r, stroke_w, with_alpha(deep, alpha));
736    }
737}
738
739fn bounds_center(bounds: Bounds<Pixels>) -> (f32, f32) {
740    let ox: f32 = bounds.origin.x.into();
741    let oy: f32 = bounds.origin.y.into();
742    let bw: f32 = bounds.size.width.into();
743    let bh: f32 = bounds.size.height.into();
744    (ox + bw / 2.0, oy + bh / 2.0)
745}
746
747/// Radio del anillo exterior derivado del width *actual* del canvas
748/// (que ya está escalado por view_scale). Mantiene la proporción del
749/// margen contra `WHEEL_SIZE` original, así el hit-test del jog-dial y
750/// las cusps se adapta automáticamente al zoom sin que cada caller
751/// recalcule `view_scale`.
752fn effective_r_outer(bounds: Bounds<Pixels>) -> f32 {
753    let bw: f32 = bounds.size.width.into();
754    let scale = if WHEEL_SIZE > 0.0 { bw / WHEEL_SIZE } else { 1.0 };
755    (bw - WHEEL_MARGIN * scale * 2.0) / 2.0
756}
757
758// =====================================================================
759// Render
760// =====================================================================
761
762impl Render for AstrologyCanvas {
763    fn render(&mut self, _w: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
764        let theme = Theme::global(cx).clone();
765        let palette = AstroPalette::for_theme(&theme);
766        let entity = cx.entity();
767        let focus = self.focus_handle.clone();
768
769        let body = match &self.state.mode {
770            CanvasMode::Empty => render_empty(&theme),
771            CanvasMode::Wheel { render } => render_wheel(
772                &theme,
773                &palette,
774                render,
775                self.state.view_rotation_deg,
776                self.state.time_offset_minutes,
777                self.state.view_scale,
778                self.state.view_pan_x,
779                self.state.view_pan_y,
780                &self.state.layer_visibility,
781                self.state.show_coords,
782                self.state.hover.as_ref(),
783                entity,
784            ),
785            CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items),
786        };
787
788        // Depth field: capa absoluta detrás del body, ocupa todo el
789        // canvas. Vignette radial — el centro queda claro y los
790        // bordes se oscurecen, dando profundidad sin "ruido" de
791        // puntos. Solo en themes dark (en papel rompería la
792        // metáfora).
793        let theme_for_depth = theme.clone();
794        let depth_field = canvas(
795            |_b, _w, _cx| (),
796            move |bounds, _, window, _| paint_depth_field(bounds, window, &theme_for_depth),
797        )
798        .absolute()
799        .size_full();
800
801        div()
802            .id("astrology-canvas-root")
803            .track_focus(&focus)
804            .key_context("AstrologyCanvas")
805            .on_key_down(cx.listener(Self::on_key_down))
806            .on_mouse_down(
807                MouseButton::Left,
808                cx.listener(|this, _, w, _cx| {
809                    w.focus(&this.focus_handle);
810                }),
811            )
812            .on_scroll_wheel(cx.listener(Self::on_scroll))
813            .size_full()
814            .bg(theme.bg_panel.clone())
815            .relative()
816            .overflow_hidden()
817            .child(depth_field)
818            .child(
819                div()
820                    .size_full()
821                    .flex()
822                    .flex_col()
823                    .items_center()
824                    .justify_center()
825                    .child(body),
826            )
827    }
828}
829
830// =====================================================================
831// Modos: empty / thumbnails / wheel
832// =====================================================================
833
834fn render_empty(theme: &Theme) -> gpui::Div {
835    div()
836        .flex()
837        .flex_col()
838        .items_center()
839        .justify_center()
840        .gap(px(12.0))
841        .child(
842            div()
843                .text_size(px(13.0))
844                .text_color(theme.fg_muted)
845                .child("Tahuantinsuyu"),
846        )
847        .child(
848            div()
849                .text_size(px(11.0))
850                .text_color(theme.fg_disabled)
851                .child("Seleccioná una carta en el árbol para empezar."),
852        )
853}
854
855fn render_thumbnails(theme: &Theme, items: &[ThumbnailItem]) -> gpui::Div {
856    if items.is_empty() {
857        return div()
858            .text_size(px(12.0))
859            .text_color(theme.fg_muted)
860            .child("Sin cartas en este grupo todavía.");
861    }
862    let mut row = div().flex().flex_row().flex_wrap().gap(px(12.0));
863    for it in items {
864        row = row.child(
865            div()
866                .w(px(140.0))
867                .h(px(160.0))
868                .rounded(px(6.0))
869                .border_1()
870                .border_color(theme.border)
871                .bg(theme.bg_panel_alt.clone())
872                .flex()
873                .flex_col()
874                .items_center()
875                .justify_end()
876                .pb(px(8.0))
877                .child(
878                    div()
879                        .text_size(px(11.0))
880                        .text_color(theme.fg_text)
881                        .child(it.label.clone()),
882                ),
883        );
884    }
885    row
886}
887
888// =====================================================================
889// Wheel
890// =====================================================================
891
892#[allow(clippy::too_many_arguments)]
893fn render_wheel(
894    theme: &Theme,
895    palette: &AstroPalette,
896    render: &RenderModel,
897    view_rotation_deg: f32,
898    time_offset_minutes: i64,
899    view_scale: f32,
900    view_pan_x: f32,
901    view_pan_y: f32,
902    layer_visibility: &HashMap<LayerKind, bool>,
903    show_coords: bool,
904    hover: Option<&HoverInfo>,
905    entity: gpui::Entity<AstrologyCanvas>,
906) -> gpui::Div {
907    let asc = render.ascendant_deg;
908    let rot_offset = view_rotation_deg;
909    // Todo el wheel escala uniforme: el cuadro contenedor y los anillos
910    // crecen con view_scale, así que glifos, líneas y márgenes mantienen
911    // sus proporciones. cx/cy_center vive en coords locales del wheel,
912    // donde el wheel tiene tamaño `wheel_size` (no WHEEL_SIZE).
913    let wheel_size = WHEEL_SIZE * view_scale;
914    let wheel_margin = WHEEL_MARGIN * view_scale;
915    let cx_center = wheel_size / 2.0;
916    let cy_center = wheel_size / 2.0;
917    let r_outer = (wheel_size - wheel_margin * 2.0) / 2.0;
918    let radii = Radii::from_outer(r_outer);
919
920    let visible = layer_visibility.clone();
921
922    // --- Canvas element con todo el trazo + jog-dial drag ---
923    let palette_paint = palette.clone();
924    let theme_paint = theme.clone();
925    let layers_paint: Vec<Layer> = render.layers.clone();
926    let asc_for_paint = asc;
927    let mc_for_paint = render.midheaven_deg;
928    let visibility_for_paint = visible.clone();
929    let entity_for_canvas = entity.clone();
930    // Hover focus para el highlight de aspectos — solo cuando el hover
931    // es un Body (sobre un planeta), no sobre cusps ni aspectos.
932    let hover_focus_paint: Option<String> = match hover {
933        Some(HoverInfo::Body { symbol, .. }) => Some(symbol.clone()),
934        _ => None,
935    };
936    let canvas_element = canvas(
937        move |_b: Bounds<Pixels>, _w, _cx| (),
938        move |bounds: Bounds<Pixels>, _, window, _| {
939            // Painting de la rueda.
940            paint_wheel(
941                bounds,
942                window,
943                &theme_paint,
944                &palette_paint,
945                &layers_paint,
946                asc_for_paint,
947                mc_for_paint,
948                rot_offset,
949                radii,
950                &visibility_for_paint,
951                hover_focus_paint.as_deref(),
952            );
953
954            // Handlers de mouse — se registran cada frame contra el
955            // window; GPUI los reemplaza al re-renderear. LMB despacha
956            // entre jog-dial (sobre el anillo) y pan (afuera). MMB es
957            // pan secundario para usuarios con scroll-mouse.
958            let entity_d = entity_for_canvas.clone();
959            window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| {
960                if !bounds.contains(&ev.position) {
961                    return;
962                }
963                match ev.button {
964                    MouseButton::Left => {
965                        let mods = ev.modifiers;
966                        entity_d.update(cx, |this, cx| {
967                            this.on_primary_down(ev.position, mods, bounds, cx)
968                        });
969                    }
970                    MouseButton::Middle => {
971                        entity_d.update(cx, |this, cx| this.on_pan_down(ev.position, cx));
972                    }
973                    _ => {}
974                }
975            });
976            let entity_m = entity_for_canvas.clone();
977            window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
978                if ev.dragging() {
979                    entity_m.update(cx, |this, cx| {
980                        if this.state.drag_pan.is_some() {
981                            this.on_pan_move(ev.position, cx);
982                        } else {
983                            this.on_jog_move(ev.position, bounds, cx);
984                        }
985                    });
986                } else if bounds.contains(&ev.position) {
987                    // Mouse hover sin drag: hit-test sobre los body
988                    // glyphs para el tooltip.
989                    entity_m.update(cx, |this, cx| this.on_hover_check(ev.position, bounds, cx));
990                } else {
991                    entity_m.update(cx, |this, cx| {
992                        if this.state.hover.take().is_some() {
993                            cx.notify();
994                        }
995                    });
996                }
997            });
998            let entity_u = entity_for_canvas.clone();
999            window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
1000                entity_u.update(cx, |this, cx| {
1001                    this.on_pan_up(cx);
1002                    this.on_jog_up(cx);
1003                });
1004            });
1005        },
1006    )
1007    .absolute()
1008    .w(px(wheel_size))
1009    .h(px(wheel_size));
1010
1011    // El wheel ya no tiene bg propio — antes era un cuadrado con
1012    // gradient que cortaba contra el fondo del panel; ahora el panel
1013    // (con su starfield encima en `render`) fluye continuo a través
1014    // del área del wheel, dando el efecto de "rueda flotando en el
1015    // universo" en lugar de "rueda sobre placa cuadrada".
1016    let mut wheel = div()
1017        .relative()
1018        .w(px(wheel_size))
1019        .h(px(wheel_size))
1020        .ml(px(view_pan_x))
1021        .mt(px(view_pan_y))
1022        .child(canvas_element);
1023
1024    // Factor de escala para los glyphs DOM. Los radii ya están
1025    // escalados (vienen de wheel_size = WHEEL_SIZE * view_scale), pero
1026    // los tamaños de fuente y disco están hardcoded — los multiplico
1027    // por view_scale para que el zoom afecte uniformemente todo el
1028    // contenido visual del wheel, no solo la geometría del canvas.
1029    let s = view_scale;
1030    // Color del halo para los discos detrás de glyphs y pills — se
1031    // calcula una sola vez, lo usan planetas, casas, ASC/MC y los
1032    // coord labels.
1033    let halo_bg = glyph_halo(theme);
1034    // Sign glyphs.
1035    if visible.get(&LayerKind::SignDial).copied().unwrap_or(true) {
1036        let sign_ring_mid = (radii.sign_outer + radii.sign_inner) / 2.0;
1037        for layer in &render.layers {
1038            if matches!(layer.kind, LayerKind::SignDial) {
1039                for g in &layer.glyphs {
1040                    let (x, y) = polar_to_screen(g.deg, asc, rot_offset, sign_ring_mid);
1041                    let color = element_color_for_sign(palette, &g.symbol);
1042                    wheel = wheel.child(centered_glyph(
1043                        cx_center + x,
1044                        cy_center + y,
1045                        20.0 * s,
1046                        18.0 * s,
1047                        sign_unicode(&g.symbol).into(),
1048                        color,
1049                    ));
1050                }
1051            }
1052        }
1053    }
1054
1055    // House numbers + (opcional) coord del cusp.
1056    if visible.get(&LayerKind::Houses).copied().unwrap_or(true) {
1057        let house_label_r = (radii.houses_outer + radii.houses_inner) / 2.0;
1058        let house_label_color = house_ring_color(palette);
1059        for layer in &render.layers {
1060            if matches!(layer.kind, LayerKind::Houses) {
1061                for g in &layer.glyphs {
1062                    let (x, y) = polar_to_screen(g.deg, asc, rot_offset, house_label_r);
1063                    if let Some(h) = g.house {
1064                        wheel = wheel.child(centered_glyph(
1065                            cx_center + x,
1066                            cy_center + y,
1067                            16.0 * s,
1068                            11.0 * s,
1069                            format!("{}", h).into(),
1070                            house_label_color,
1071                        ));
1072                        // Coord del cusp justo dentro del anillo de
1073                        // casas — los grados se imprimen en una pill
1074                        // pequeña pegada al radio del cusp.
1075                        if show_coords {
1076                            let coord = format_coord_compact(g.deg);
1077                            let label_r = radii.houses_inner - 8.0 * s;
1078                            let (lx, ly) =
1079                                polar_to_screen(g.deg, asc, rot_offset, label_r);
1080                            wheel = wheel.child(coord_label(
1081                                cx_center + lx,
1082                                cy_center + ly,
1083                                coord.into(),
1084                                theme.fg_muted,
1085                                halo_bg,
1086                                8.5 * s,
1087                            ));
1088                        }
1089                    }
1090                }
1091            }
1092        }
1093    }
1094
1095    // Planet glyphs: natal en `bodies` + overlays (progression,
1096    // solar_arc) en sus rings, ambos con disco-halo para legibilidad
1097    // contra cualquier fondo. El natal lleva un tamaño un poco mayor
1098    // que los overlays para que se lea como "el cuerpo principal".
1099    if visible.get(&LayerKind::Bodies).copied().unwrap_or(true) {
1100        for layer in &render.layers {
1101            if matches!(layer.kind, LayerKind::Bodies) {
1102                let is_natal = layer.module_id == "natal";
1103                let ring = radii.body_ring(&layer.module_id);
1104                let alpha = if is_natal { 1.0 } else { 0.88 };
1105                let font_size = (if is_natal { 18.0 } else { 14.0 }) * s;
1106                let disk_size = (if is_natal { 26.0 } else { 22.0 }) * s;
1107                for g in &layer.glyphs {
1108                    let (x, y) = polar_to_screen(g.deg, asc, rot_offset, ring);
1109                    let color = with_alpha(planet_color(palette, &g.symbol), alpha);
1110                    let mut glyph_text = planet_unicode(&g.symbol).to_string();
1111                    if g.retrograde {
1112                        glyph_text.push('ᴿ');
1113                    }
1114                    if let Some(marker) = &g.dignity_marker {
1115                        glyph_text.push_str(marker);
1116                    }
1117                    wheel = wheel.child(planet_glyph(
1118                        cx_center + x,
1119                        cy_center + y,
1120                        disk_size,
1121                        font_size,
1122                        glyph_text.into(),
1123                        color,
1124                        halo_bg,
1125                        with_alpha(color, 0.85),
1126                    ));
1127
1128                    // Coord label: grado dentro del signo + glyph del
1129                    // signo, pintado justo afuera del disco del
1130                    // planeta (radialmente). Sólo en natal (los
1131                    // overlays ya cargan info en su badge / tooltip).
1132                    if show_coords && is_natal {
1133                        let coord = format_coord_compact(g.deg);
1134                        let label_r = ring + disk_size * 0.7;
1135                        let (lx, ly) = polar_to_screen(g.deg, asc, rot_offset, label_r);
1136                        wheel = wheel.child(
1137                            coord_label(
1138                                cx_center + lx,
1139                                cy_center + ly,
1140                                coord.into(),
1141                                theme.fg_muted,
1142                                halo_bg,
1143                                9.5 * s,
1144                            ),
1145                        );
1146                    }
1147                }
1148            }
1149        }
1150    }
1151
1152    // Planet glyphs en el outer ring — transit o synastry (slot
1153    // compartido, mutuamente excluyentes a nivel de Shell). Disco un
1154    // poco más chico que el natal — el outer es "secundario".
1155    if visible.get(&LayerKind::Outer).copied().unwrap_or(true) {
1156        for layer in &render.layers {
1157            if matches!(layer.kind, LayerKind::Outer)
1158                && (OUTER_RING_MODULES.contains(&layer.module_id.as_str()))
1159            {
1160                for g in &layer.glyphs {
1161                    let (x, y) = polar_to_screen(g.deg, asc, rot_offset, radii.transits);
1162                    let color = with_alpha(planet_color(palette, &g.symbol), 0.92);
1163                    let glyph_text = if g.retrograde {
1164                        format!("{}ᴿ", planet_unicode(&g.symbol))
1165                    } else {
1166                        planet_unicode(&g.symbol).into()
1167                    };
1168                    wheel = wheel.child(planet_glyph(
1169                        cx_center + x,
1170                        cy_center + y,
1171                        20.0 * s,
1172                        13.0 * s,
1173                        glyph_text.into(),
1174                        color,
1175                        halo_bg,
1176                        with_alpha(color, 0.75),
1177                    ));
1178                }
1179            }
1180        }
1181    }
1182
1183    // Tooltip absoluto sobre el elemento hovered (cuerpo o cusp).
1184    if let Some(hov) = hover {
1185        let text = match hov {
1186            HoverInfo::Body {
1187                module_id,
1188                symbol,
1189                deg,
1190                house,
1191                retrograde,
1192                dignity_marker,
1193                annotation,
1194                ..
1195            } => {
1196                let sign_idx = ((deg / 30.0).floor() as usize) % 12;
1197                let sign_name = SIGN_NAMES_ES[sign_idx];
1198                let deg_in_sign = deg - (sign_idx as f32) * 30.0;
1199                let display_symbol = if module_id == "midpoints" {
1200                    // El symbol del midpoint es "a/b" — para el header
1201                    // del tooltip usamos los unicodes individuales.
1202                    if let Some((a, b)) = symbol.split_once('/') {
1203                        format!("{}/{}", planet_unicode(a), planet_unicode(b))
1204                    } else {
1205                        symbol.clone()
1206                    }
1207                } else {
1208                    planet_unicode(symbol).to_string()
1209                };
1210                let mut t = format!("{} {} · {:.1}°", display_symbol, sign_name, deg_in_sign);
1211                if let Some(h) = house {
1212                    t.push_str(&format!(" · Casa {}", h));
1213                }
1214                if *retrograde {
1215                    t.push_str(" · ℞");
1216                }
1217                if let Some(m) = dignity_marker {
1218                    t.push_str(&format!(" · {}", m));
1219                }
1220                if module_id == "midpoints" {
1221                    if let Some(a) = annotation {
1222                        t.push_str(&format!(" · {}", a));
1223                    }
1224                } else if module_id != "natal" {
1225                    t.push_str(&format!(" · {}", module_id));
1226                }
1227                t
1228            }
1229            HoverInfo::HouseCusp {
1230                house_number, deg, ..
1231            } => {
1232                let sign_idx = ((deg / 30.0).floor() as usize) % 12;
1233                let sign_name = SIGN_NAMES_ES[sign_idx];
1234                let deg_in_sign = deg - (sign_idx as f32) * 30.0;
1235                format!(
1236                    "Cusp Casa {} · {} {:.1}°",
1237                    house_number, sign_name, deg_in_sign
1238                )
1239            }
1240            HoverInfo::Aspect {
1241                module_id,
1242                from_body,
1243                to_body,
1244                kind,
1245                orb_deg,
1246                ..
1247            } => {
1248                let mut t = format!(
1249                    "{} {} {}  ·  orb {:.1}°",
1250                    planet_unicode(from_body),
1251                    aspect_unicode(kind),
1252                    planet_unicode(to_body),
1253                    orb_deg
1254                );
1255                if module_id != "natal" {
1256                    t.push_str(&format!(" · {}", module_id));
1257                }
1258                t
1259            }
1260        };
1261        let (lx, ly) = hov.local();
1262        let tip_x = (lx + 14.0).min(WHEEL_SIZE - 220.0).max(8.0);
1263        let tip_y = (ly - 28.0).max(8.0);
1264        wheel = wheel.child(
1265            div()
1266                .absolute()
1267                .left(px(tip_x))
1268                .top(px(tip_y))
1269                .px(px(8.0))
1270                .py(px(4.0))
1271                .rounded(px(6.0))
1272                .bg(theme.bg_panel_alt.clone())
1273                .border_1()
1274                .border_color(palette.angle_highlight)
1275                .text_size(px(11.0))
1276                .text_color(theme.fg_text)
1277                .child(SharedString::from(text)),
1278        );
1279    }
1280
1281    // Labels ASC/MC/DESC/IC como pills en el perímetro — bg del halo
1282    // + border y texto en `angle_highlight`. Más legibles que el
1283    // centered_glyph plano del fase anterior, en especial sobre
1284    // fondos claros donde el ámbar/oro de angle_highlight se diluye.
1285    let angle_labels = [
1286        (asc, "ASC"),
1287        (render.midheaven_deg, "MC"),
1288        (render.descendant_deg, "DESC"),
1289        (render.imum_coeli_deg, "IC"),
1290    ];
1291    let label_r = r_outer * 1.08;
1292    for (deg, label) in angle_labels {
1293        let (x, y) = polar_to_screen(deg, asc, rot_offset, label_r);
1294        let pill_w = (if label.len() > 2 { 38.0 } else { 30.0 }) * s;
1295        let pill_h = 18.0 * s;
1296        wheel = wheel.child(
1297            div()
1298                .absolute()
1299                .left(px(cx_center + x - pill_w / 2.0))
1300                .top(px(cy_center + y - pill_h / 2.0))
1301                .w(px(pill_w))
1302                .h(px(pill_h))
1303                .flex()
1304                .items_center()
1305                .justify_center()
1306                .rounded(px(9.0 * s))
1307                .bg(halo_bg)
1308                .border_1()
1309                .border_color(with_alpha(palette.angle_highlight, 0.85))
1310                .text_size(px(11.0 * s))
1311                .text_color(palette.angle_highlight)
1312                .child(SharedString::from(label)),
1313        );
1314    }
1315
1316    // --- Header + footer + indicador de tiempo ---
1317    let header = div()
1318        .flex()
1319        .flex_col()
1320        .items_center()
1321        .gap(px(2.0))
1322        .child(
1323            div()
1324                .text_size(px(16.0))
1325                .text_color(theme.fg_text)
1326                .child(SharedString::from(render.title.clone())),
1327        );
1328    let header = if let Some(sub) = &render.subtitle {
1329        header.child(
1330            div()
1331                .text_size(px(11.0))
1332                .text_color(theme.fg_muted)
1333                .child(SharedString::from(sub.clone())),
1334        )
1335    } else {
1336        header
1337    };
1338    // Botón export SVG — pequeño, alineado a la derecha del title.
1339    let export_btn = div()
1340        .id("tts-canvas-export-svg")
1341        .px(px(10.0))
1342        .py(px(3.0))
1343        .rounded(px(4.0))
1344        .bg(theme.bg_button())
1345        .hover(|s| s.bg(theme.bg_button_hover()))
1346        .border_1()
1347        .border_color(theme.border)
1348        .text_size(px(10.0))
1349        .text_color(theme.fg_text)
1350        .child("⬇ SVG")
1351        .on_click({
1352            let entity_e = entity.clone();
1353            move |_: &gpui::ClickEvent, _w, cx: &mut gpui::App| {
1354                entity_e.update(cx, |_this, cx| {
1355                    cx.emit(CanvasEvent::ExportSvgRequested);
1356                });
1357            }
1358        });
1359    let header = div()
1360        .flex()
1361        .flex_row()
1362        .items_center()
1363        .gap(px(12.0))
1364        .child(header)
1365        .child(export_btn);
1366
1367    let offset_label = format_offset(time_offset_minutes);
1368    let offset_color = if time_offset_minutes == 0 {
1369        theme.fg_disabled
1370    } else {
1371        palette.angle_highlight
1372    };
1373    let info_row = div()
1374        .flex()
1375        .flex_row()
1376        .gap(px(10.0))
1377        .child(
1378            div()
1379                .text_size(px(10.0))
1380                .text_color(theme.fg_disabled)
1381                .child(SharedString::from(format!(
1382                    "Asc {:.1}°  ·  MC {:.1}°  ·  {} ms",
1383                    render.ascendant_deg, render.midheaven_deg, render.compute_ms,
1384                ))),
1385        )
1386        .child(
1387            div()
1388                .text_size(px(10.0))
1389                .text_color(offset_color)
1390                .child(SharedString::from(offset_label)),
1391        )
1392        .child(
1393            div()
1394                .text_size(px(10.0))
1395                .text_color(theme.fg_disabled)
1396                .child(
1397                    "[D]ial [H]ouses as[X]pects [P]lanets [T]ransits [C]oords  ·  Ctrl+drag = tiempo  ·  [0] reset zoom  ·  [R] reset tiempo  ·  [S]vg",
1398                ),
1399        );
1400
1401    // Badges de overlays activos. Cada uno se pinta como pill con
1402    // background sutil y border tenue. Solo aparecen cuando hay
1403    // overlays — la carta natal pura ve solo el info_row.
1404    let badges_row = if render.overlays.is_empty() {
1405        None
1406    } else {
1407        let mut row = div().flex().flex_row().flex_wrap().gap(px(6.0));
1408        // Badge "natal" base, siempre presente cuando hay overlays —
1409        // ayuda al usuario a leer la pila de izquierda a derecha.
1410        row = row.child(badge(theme, palette, "natal", "Natal", true));
1411        for ov in &render.overlays {
1412            row = row.child(badge(theme, palette, &ov.module_id, &ov.label, false));
1413        }
1414        Some(row)
1415    };
1416
1417    let mut footer = div().flex().flex_col().items_center().gap(px(4.0)).child(info_row);
1418    if let Some(b) = badges_row {
1419        footer = footer.child(b);
1420    }
1421
1422    // Ejes uranianos detectados (cuerpos en la misma posición mod 90).
1423    // Aparece sólo cuando el módulo Uranian está activo y hay
1424    // grupos. Cada grupo se muestra como pill con los unicode de los
1425    // cuerpos + el grado dial-90.
1426    if !render.uranian_groups.is_empty() {
1427        let mut row = div().flex().flex_row().flex_wrap().gap(px(6.0));
1428        for group in &render.uranian_groups {
1429            let bodies_text: String = group
1430                .bodies
1431                .iter()
1432                .map(|b| planet_unicode(b))
1433                .collect::<Vec<_>>()
1434                .join(" ");
1435            row = row.child(
1436                div()
1437                    .px(px(8.0))
1438                    .py(px(2.0))
1439                    .rounded(px(10.0))
1440                    .bg(theme.bg_panel_alt.clone())
1441                    .border_1()
1442                    .border_color(with_alpha(palette.angle_highlight, 0.6))
1443                    .text_size(px(11.0))
1444                    .text_color(theme.fg_text)
1445                    .child(SharedString::from(format!(
1446                        "{}  ·  {:.1}°",
1447                        bodies_text, group.mod90_deg
1448                    ))),
1449            );
1450        }
1451        footer = footer.child(
1452            div()
1453                .flex()
1454                .flex_col()
1455                .items_center()
1456                .gap(px(3.0))
1457                .child(
1458                    div()
1459                        .text_size(px(10.0))
1460                        .text_color(theme.fg_muted)
1461                        .child("Ejes uranianos (90°)"),
1462                )
1463                .child(row),
1464        );
1465    }
1466
1467    // Lista textual de aspectos (top 12 por orb). Compacta, en grid
1468    // de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos
1469    // computados.
1470    if !render.aspect_summary.is_empty() {
1471        let mut grid = div()
1472            .flex()
1473            .flex_row()
1474            .flex_wrap()
1475            .gap(px(10.0))
1476            .max_w(px(WHEEL_SIZE + 80.0))
1477            .justify_center();
1478        for ap in render.aspect_summary.iter().take(12) {
1479            let kind_sym = aspect_unicode(&ap.kind);
1480            let line = format!(
1481                "{} {} {}  ·  {:.1}°{}",
1482                planet_unicode(&ap.from_body),
1483                kind_sym,
1484                planet_unicode(&ap.to_body),
1485                ap.orb_deg,
1486                match ap.applying {
1487                    Some(true) => " A",
1488                    Some(false) => " S",
1489                    None => "",
1490                }
1491            );
1492            let prefix = if ap.module_id == "natal" {
1493                String::new()
1494            } else {
1495                format!("[{}] ", ap.module_id)
1496            };
1497            grid = grid.child(
1498                div()
1499                    .px(px(6.0))
1500                    .py(px(2.0))
1501                    .text_size(px(11.0))
1502                    .text_color(aspect_color(palette, &ap.kind))
1503                    .child(SharedString::from(format!("{}{}", prefix, line))),
1504            );
1505        }
1506        footer = footer.child(grid);
1507    }
1508
1509    div()
1510        .flex()
1511        .flex_col()
1512        .items_center()
1513        .gap(px(8.0))
1514        .child(header)
1515        .child(wheel)
1516        .child(footer)
1517}
1518
1519/// Pequeña pill con la etiqueta de un overlay activo. El borde toma
1520/// color según el "tipo" del módulo para ayudar a mapear a su anillo
1521/// en el wheel: natal = neutro, outer ring share (transit/synastry/
1522/// planetary_return) = palette.angle_highlight, inner overlays
1523/// (progression/solar_arc) = palette.house_cusp.
1524fn badge(theme: &Theme, palette: &AstroPalette, module_id: &str, label: &str, is_natal: bool) -> gpui::Div {
1525    let border = if is_natal {
1526        theme.border
1527    } else {
1528        match module_id {
1529            "transit" | "synastry" | "planetary_return" => palette.angle_highlight,
1530            "progression" | "solar_arc" => palette.house_cusp,
1531            _ => theme.border,
1532        }
1533    };
1534    div()
1535        .px(px(8.0))
1536        .py(px(2.0))
1537        .rounded(px(10.0))
1538        .bg(theme.bg_panel_alt.clone())
1539        .border_1()
1540        .border_color(border)
1541        .text_size(px(10.0))
1542        .text_color(theme.fg_text)
1543        .child(SharedString::from(label.to_string()))
1544}
1545
1546fn format_offset(minutes: i64) -> String {
1547    if minutes == 0 {
1548        return "⏱ ahora".to_string();
1549    }
1550    let sign = if minutes > 0 { '+' } else { '-' };
1551    let m = minutes.unsigned_abs();
1552    let days = m / (60 * 24);
1553    let hours = (m / 60) % 24;
1554    let mins = m % 60;
1555    if days > 0 {
1556        format!("⏱ {}{}d {:02}h {:02}m", sign, days, hours, mins)
1557    } else if hours > 0 {
1558        format!("⏱ {}{:02}h {:02}m", sign, hours, mins)
1559    } else {
1560        format!("⏱ {}{:02}m", sign, mins)
1561    }
1562}
1563
1564// =====================================================================
1565// Painting
1566// =====================================================================
1567
1568#[derive(Clone, Copy)]
1569struct Radii {
1570    sign_outer: f32,
1571    sign_inner: f32,
1572    /// Anillo de glifos de tránsito (cuando el overlay está activo).
1573    transits: f32,
1574    houses_outer: f32,
1575    houses_inner: f32,
1576    /// Anillo de midpoints — entre bodies natales y houses_inner.
1577    midpoints: f32,
1578    /// Anillo principal de cuerpos natales — donde se posan los
1579    /// glyphs. Junto con `bodies_inner` forman el "cinturón" de los
1580    /// planetas (doble línea visual).
1581    bodies: f32,
1582    /// Borde interior del cinturón de planetas. Marca dónde "termina"
1583    /// la zona de cuerpos y empieza la zona de aspectos.
1584    bodies_inner: f32,
1585    /// Anillo interno con cuerpos progresados (overlay opcional).
1586    progression: f32,
1587    /// Anillo más interno con cuerpos dirigidos por Solar Arc.
1588    solar_arc: f32,
1589    /// Anillo de carta compuesta (midpoint Davison) con un partner.
1590    composite: f32,
1591    /// Círculo donde anclan las líneas de aspecto entre cuerpos
1592    /// natales. Justo dentro del cinturón de planetas, no en el
1593    /// centro — así las líneas conectan cuerpos cercanos al ring
1594    /// donde se ven, no atraviesan toda la rueda.
1595    aspects: f32,
1596}
1597
1598impl Radii {
1599    fn from_outer(r: f32) -> Self {
1600        Self {
1601            sign_outer: r,
1602            sign_inner: r * 0.88,
1603            transits: r * 0.82,
1604            houses_outer: r * 0.78,
1605            houses_inner: r * 0.66,
1606            midpoints: r * 0.62,
1607            bodies: r * 0.60,
1608            // bodies_inner cerca de bodies — los dos anillos juntos
1609            // forman un "carril" estrecho que delimita la franja de
1610            // planetas, no dos líneas separadas que confunden.
1611            bodies_inner: r * 0.57,
1612            // aspects justo bajo el carril de cuerpos. Las líneas
1613            // de aspecto entran a este radio, pero el círculo en sí
1614            // no se pinta — son las líneas las que importan, no
1615            // un anillo extra que sume ruido.
1616            aspects: r * 0.54,
1617            progression: r * 0.46,
1618            solar_arc: r * 0.38,
1619            composite: r * 0.30,
1620        }
1621    }
1622
1623    /// Radio del ring de cuerpos según el `module_id` del Layer.
1624    fn body_ring(&self, module_id: &str) -> f32 {
1625        match module_id {
1626            "progression" => self.progression,
1627            "solar_arc" => self.solar_arc,
1628            "composite" => self.composite,
1629            "midpoints" => self.midpoints,
1630            _ => self.bodies,
1631        }
1632    }
1633
1634    /// Resuelve qué radios corresponden a una capa de aspectos según el
1635    /// `module_id`: natal-natal en `aspects`, cross con cada overlay
1636    /// desde `bodies` (extremo natal) al ring del módulo. Los módulos
1637    /// del outer ring (OUTER_RING_MODULES) comparten el slot de
1638    /// tránsito (son mutuamente excluyentes a nivel de Shell).
1639    fn aspect_endpoints(&self, module_id: &str) -> (f32, f32) {
1640        if OUTER_RING_MODULES.contains(&module_id) {
1641            return (self.bodies, self.transits);
1642        }
1643        match module_id {
1644            "progression" => (self.bodies, self.progression),
1645            "solar_arc" => (self.bodies, self.solar_arc),
1646            "composite" => (self.bodies, self.composite),
1647            _ => (self.aspects, self.aspects),
1648        }
1649    }
1650}
1651
1652#[allow(clippy::too_many_arguments)]
1653// `hover_focus`: symbol del planeta hovereado en este frame (si lo
1654// hay). Las líneas de aspecto que NO tocan a ese planeta se opacan
1655// para que el usuario lea claramente "qué afecta a qué". Si `None`,
1656// todas las líneas se pintan a alpha plena.
1657fn paint_wheel(
1658    bounds: Bounds<Pixels>,
1659    window: &mut Window,
1660    theme: &Theme,
1661    palette: &AstroPalette,
1662    layers: &[Layer],
1663    ascendant_deg: f32,
1664    midheaven_deg: f32,
1665    rot_offset_deg: f32,
1666    radii: Radii,
1667    visibility: &HashMap<LayerKind, bool>,
1668    hover_focus: Option<&str>,
1669) {
1670    let (cx, cy) = bounds_center(bounds);
1671    let show = |k: LayerKind| visibility.get(&k).copied().unwrap_or(true);
1672
1673    // 1. Sectores zodiacales (parte del SignDial layer).
1674    if show(LayerKind::SignDial) {
1675        paint_sign_sectors(window, cx, cy, &radii, palette, ascendant_deg, rot_offset_deg);
1676
1677        // Anillos del dial con efecto 3D: highlight interior + base +
1678        // shadow exterior. El highlight es 1 px hacia el centro con
1679        // luminancia +0.18; la shadow 1 px hacia afuera con -0.18.
1680        // El bevel central — varios strokes finos con alpha en bell
1681        // curve entre sign_inner y sign_outer — da volumen al dial.
1682        stroke_circle_3d(window, cx, cy, radii.sign_outer, 1.5, palette.dial_ring, theme);
1683        stroke_circle_3d(window, cx, cy, radii.sign_inner, 1.0, palette.dial_ring, theme);
1684        paint_dial_bevel(window, cx, cy, &radii, palette, theme);
1685
1686        // Cusps zodiacales cada 30°.
1687        for i in 0..12 {
1688            let lon = (i as f32) * 30.0;
1689            let color = palette.dial_ring;
1690            paint_radial_line(
1691                window,
1692                cx,
1693                cy,
1694                lon,
1695                ascendant_deg,
1696                rot_offset_deg,
1697                radii.sign_inner,
1698                radii.sign_outer,
1699                color,
1700                1.0,
1701            );
1702        }
1703    }
1704
1705    // 2. Casas — doble anillo (inner + outer) + cusps radiales +
1706    // énfasis Asc/IC/Desc/MC. La doble línea vuelve a la zona de
1707    // casas una "corona" claramente identificable. Color derivado
1708    // de `house_cusp` con un hue shift para que el sistema
1709    // ascensional (casas) se distinga visualmente del eclíptico
1710    // (dial zodiacal) que va en dorado.
1711    if show(LayerKind::Houses) {
1712        let house_base = house_ring_color(palette);
1713        let house_color = with_alpha(house_base, 0.85);
1714        stroke_circle_3d(window, cx, cy, radii.houses_outer, 1.1, house_color, theme);
1715        stroke_circle_3d(window, cx, cy, radii.houses_inner, 1.1, house_color, theme);
1716
1717        for layer in layers {
1718            if matches!(layer.kind, LayerKind::Houses) {
1719                if let Geometry::Ring { cusps_deg } = &layer.geometry {
1720                    for (i, c) in cusps_deg.iter().enumerate() {
1721                        let is_angle = i == 0 || i == 3 || i == 6 || i == 9;
1722                        let color = if is_angle {
1723                            palette.angle_highlight
1724                        } else {
1725                            with_alpha(house_base, 0.75)
1726                        };
1727                        let width = if is_angle { 2.0 } else { 0.8 };
1728                        paint_radial_line(
1729                            window,
1730                            cx,
1731                            cy,
1732                            *c,
1733                            ascendant_deg,
1734                            rot_offset_deg,
1735                            radii.houses_inner,
1736                            radii.houses_outer,
1737                            color,
1738                            width,
1739                        );
1740                    }
1741                }
1742            }
1743        }
1744
1745        // Cruz completa Asc-Desc + MC-IC, alpha bastante visible para
1746        // que orienten la lectura sin competir con cuerpos/aspectos.
1747        // 4 radios desde el centro: ASC, DESC (=asc+180), MC, IC
1748        // (=mc+180). `paint_radial_line` con r_inner=0 pinta un radio
1749        // del centro al borde — la cruz es la unión de los 4.
1750        let axis_color = with_alpha(palette.angle_highlight, 0.55);
1751        for axis_deg in [
1752            ascendant_deg,
1753            ascendant_deg + 180.0,
1754            midheaven_deg,
1755            midheaven_deg + 180.0,
1756        ] {
1757            paint_radial_line(
1758                window,
1759                cx,
1760                cy,
1761                axis_deg,
1762                ascendant_deg,
1763                rot_offset_deg,
1764                0.0,
1765                radii.houses_outer,
1766                axis_color,
1767                1.4,
1768            );
1769        }
1770    }
1771
1772    // 2.5. Carril de planetas. `bodies` + `bodies_inner` muy juntos
1773    // delimitan la franja estrecha donde viven los glyphs natales.
1774    // El círculo de aspectos NO se pinta — `radii.aspects` solo
1775    // existe como punto donde se anclan las líneas; un stroke ahí
1776    // sería un anillo extra que confunde sin aportar.
1777    if show(LayerKind::Bodies) {
1778        let belt_color = with_alpha(palette.dial_ring, 0.55);
1779        stroke_circle_3d(window, cx, cy, radii.bodies, 0.9, belt_color, theme);
1780        stroke_circle(
1781            window,
1782            cx,
1783            cy,
1784            radii.bodies_inner,
1785            0.6,
1786            with_alpha(palette.dial_ring, 0.35),
1787        );
1788    }
1789
1790    // 3. Aspectos. Cada module_id usa su par de radios — natal-natal
1791    // ambos en `aspects`, cross con transit en `bodies → transits`,
1792    // cross con progression en `bodies → progression`.
1793    if show(LayerKind::Aspects) {
1794        let mono = palette.is_monochrome();
1795        for layer in layers {
1796            if matches!(layer.kind, LayerKind::Aspects) {
1797                if let Geometry::Lines(segs) = &layer.geometry {
1798                    let (r_from, r_to) = radii.aspect_endpoints(&layer.module_id);
1799                    let is_cross = r_from != r_to;
1800                    for seg in segs {
1801                        // Filtro minors con orbe ancho: los aspectos
1802                        // menores (quincunx, semi-square, quintile…)
1803                        // solo se trazan si están MUY apretados
1804                        // (orbe ≤ 3°). Sobre 3° ensucian sin aportar.
1805                        if !is_major_aspect(&seg.kind) && seg.orb_deg.abs() > 3.0 {
1806                            continue;
1807                        }
1808                        let base = aspect_color(palette, &seg.kind);
1809                        let base = with_alpha(base, base.a * seg.opacity);
1810                        // Hover focus: si hay un planeta hovereado y
1811                        // este segmento NO lo toca, lo atenuamos al
1812                        // 18%; si lo toca o no hay hover, va pleno.
1813                        let touches_hover = hover_focus
1814                            .map(|sym| seg.from_body == sym || seg.to_body == sym)
1815                            .unwrap_or(true);
1816                        let factor = if touches_hover { 1.0 } else { 0.18 };
1817                        let color = with_alpha(base, base.a * factor);
1818                        let dash = if mono {
1819                            dash_pattern_for_kind(&seg.kind)
1820                        } else {
1821                            None
1822                        };
1823                        // Width inverso al orbe: orbes cerrados se ven
1824                        // gruesos (aspecto "fuerte"), orbes amplios
1825                        // finos. Mayores van un escalón más gruesos
1826                        // que menores en su mismo orbe.
1827                        let width = aspect_width(&seg.kind, seg.orb_deg, mono);
1828                        if is_cross {
1829                            paint_cross_aspect_line(
1830                                window,
1831                                cx,
1832                                cy,
1833                                seg.from_deg,
1834                                seg.to_deg,
1835                                ascendant_deg,
1836                                rot_offset_deg,
1837                                r_from,
1838                                r_to,
1839                                color,
1840                                dash,
1841                            );
1842                        } else {
1843                            paint_aspect_line(
1844                                window,
1845                                cx,
1846                                cy,
1847                                seg.from_deg,
1848                                seg.to_deg,
1849                                ascendant_deg,
1850                                rot_offset_deg,
1851                                r_from,
1852                                color,
1853                                dash,
1854                                width,
1855                            );
1856                        }
1857                    }
1858                }
1859            }
1860        }
1861    }
1862
1863    // 4. Marcadores de posición exacta. Antes el dot era "el planeta";
1864    // ahora el glyph (con halo, en DOM) lo es. El círculo acá queda
1865    // como marker de precisión angular — chico, alpha alta, sobre el
1866    // anillo correspondiente. Glow se mantiene para Sol/Luna como
1867    // toque místico, pero también reducido.
1868    if show(LayerKind::Bodies) {
1869        let dot_r = (radii.sign_outer * 0.009).max(1.5);
1870        for layer in layers {
1871            if matches!(layer.kind, LayerKind::Bodies) {
1872                let ring = radii.body_ring(&layer.module_id);
1873                let is_natal = layer.module_id == "natal";
1874                let alpha = if is_natal { 1.0 } else { 0.85 };
1875                for g in &layer.glyphs {
1876                    let color = with_alpha(planet_color(palette, &g.symbol), alpha);
1877                    let (x, y) = polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, ring);
1878                    if is_natal && (g.symbol == "sun" || g.symbol == "moon") {
1879                        paint_glow(window, cx + x, cy + y, dot_r * 1.8, color);
1880                    }
1881                    fill_circle(window, cx + x, cy + y, dot_r, color);
1882                }
1883            }
1884        }
1885    }
1886
1887    // Anillos guía para los overlays internos (progression, solar_arc).
1888    let guide_inset = radii.sign_outer * 0.03;
1889    for (module_id, ring) in [
1890        ("progression", radii.progression),
1891        ("solar_arc", radii.solar_arc),
1892    ] {
1893        let active = layers
1894            .iter()
1895            .any(|l| matches!(l.kind, LayerKind::Bodies) && l.module_id == module_id);
1896        if active {
1897            stroke_circle(
1898                window,
1899                cx,
1900                cy,
1901                ring + guide_inset,
1902                0.5,
1903                with_alpha(palette.house_cusp, 0.35),
1904            );
1905            stroke_circle(
1906                window,
1907                cx,
1908                cy,
1909                ring - guide_inset,
1910                0.5,
1911                with_alpha(palette.house_cusp, 0.35),
1912            );
1913        }
1914    }
1915
1916    // 5. Outer ring (transit o synastry overlay): anillo guía + dots
1917    // de la capa activa. Son mutuamente excluyentes a nivel de Shell;
1918    // si alguno de los dos está prendido, pintamos el slot.
1919    let outer_active = layers.iter().any(|l| {
1920        matches!(l.kind, LayerKind::Outer)
1921            && OUTER_RING_MODULES.contains(&l.module_id.as_str())
1922    });
1923    if outer_active && show(LayerKind::Outer) {
1924        let band = radii.sign_outer * 0.035;
1925        stroke_circle_3d(
1926            window,
1927            cx,
1928            cy,
1929            radii.transits + band,
1930            0.7,
1931            with_alpha(palette.dial_ring, 0.55),
1932            theme,
1933        );
1934        stroke_circle_3d(
1935            window,
1936            cx,
1937            cy,
1938            radii.transits - band,
1939            0.7,
1940            with_alpha(palette.dial_ring, 0.55),
1941            theme,
1942        );
1943
1944        let dot_r = (radii.sign_outer * 0.008).max(1.5);
1945        for layer in layers {
1946            if matches!(layer.kind, LayerKind::Outer)
1947                && (OUTER_RING_MODULES.contains(&layer.module_id.as_str()))
1948            {
1949                for g in &layer.glyphs {
1950                    let color = with_alpha(planet_color(palette, &g.symbol), 0.85);
1951                    let (x, y) =
1952                        polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, radii.transits);
1953                    fill_circle(window, cx + x, cy + y, dot_r, color);
1954                }
1955            }
1956        }
1957    }
1958}
1959
1960fn paint_sign_sectors(
1961    window: &mut Window,
1962    cx: f32,
1963    cy: f32,
1964    radii: &Radii,
1965    palette: &AstroPalette,
1966    ascendant_deg: f32,
1967    rot_offset_deg: f32,
1968) {
1969    const SUBDIVISIONS: usize = 18;
1970    for i in 0..12 {
1971        let lon_start = (i as f32) * 30.0;
1972        let lon_end = lon_start + 30.0;
1973        let element = sign_element_by_index(i);
1974        let color = with_alpha(palette.element(element), 0.10);
1975
1976        let mut builder = PathBuilder::fill();
1977        let (x0, y0) = polar_to_screen(lon_start, ascendant_deg, rot_offset_deg, radii.sign_inner);
1978        builder.move_to(point(px(cx + x0), px(cy + y0)));
1979
1980        for k in 1..=SUBDIVISIONS {
1981            let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32);
1982            let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_inner);
1983            builder.line_to(point(px(cx + x), px(cy + y)));
1984        }
1985        let (xe, ye) = polar_to_screen(lon_end, ascendant_deg, rot_offset_deg, radii.sign_outer);
1986        builder.line_to(point(px(cx + xe), px(cy + ye)));
1987
1988        for k in (0..SUBDIVISIONS).rev() {
1989            let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32);
1990            let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_outer);
1991            builder.line_to(point(px(cx + x), px(cy + y)));
1992        }
1993        builder.close();
1994        if let Ok(path) = builder.build() {
1995            window.paint_path(path, color);
1996        }
1997    }
1998}
1999
2000fn stroke_circle(window: &mut Window, cx: f32, cy: f32, r: f32, width: f32, color: Hsla) {
2001    const SEGMENTS: usize = 96;
2002    let mut builder = PathBuilder::stroke(px(width));
2003    for i in 0..=SEGMENTS {
2004        let t = (i as f32) / (SEGMENTS as f32) * (2.0 * PI);
2005        let x = cx + r * t.cos();
2006        let y = cy + r * t.sin();
2007        if i == 0 {
2008            builder.move_to(point(px(x), px(y)));
2009        } else {
2010            builder.line_to(point(px(x), px(y)));
2011        }
2012    }
2013    if let Ok(path) = builder.build() {
2014        window.paint_path(path, color);
2015    }
2016}
2017
2018/// Pinta 3 halos concéntricos con alpha decreciente alrededor de un
2019/// punto — usado para Sol/Luna natales. El radio crece, la opacidad
2020/// cae: el ojo lo lee como "esto irradia". Sin glow real (GPUI 0.2 no
2021/// tiene radial gradient), pero el shading concéntrico convence.
2022fn paint_glow(window: &mut Window, cx: f32, cy: f32, base_r: f32, color: Hsla) {
2023    const HALOS: [(f32, f32); 3] = [(5.0, 0.05), (3.0, 0.12), (1.8, 0.22)];
2024    for (mult, alpha) in HALOS {
2025        let r = base_r * mult;
2026        let halo = hsla(color.h, color.s, color.l, alpha);
2027        fill_circle(window, cx, cy, r, halo);
2028    }
2029}
2030
2031fn fill_circle(window: &mut Window, cx: f32, cy: f32, r: f32, color: Hsla) {
2032    const SEGMENTS: usize = 32;
2033    let mut builder = PathBuilder::fill();
2034    builder.move_to(point(px(cx + r), px(cy)));
2035    for i in 1..=SEGMENTS {
2036        let t = (i as f32) / (SEGMENTS as f32) * (2.0 * PI);
2037        let x = cx + r * t.cos();
2038        let y = cy + r * t.sin();
2039        builder.line_to(point(px(x), px(y)));
2040    }
2041    builder.close();
2042    if let Ok(path) = builder.build() {
2043        window.paint_path(path, color);
2044    }
2045}
2046
2047#[allow(clippy::too_many_arguments)]
2048fn paint_radial_line(
2049    window: &mut Window,
2050    cx: f32,
2051    cy: f32,
2052    longitude_deg: f32,
2053    ascendant_deg: f32,
2054    rot_offset_deg: f32,
2055    r_inner: f32,
2056    r_outer: f32,
2057    color: Hsla,
2058    width: f32,
2059) {
2060    let (xi, yi) = polar_to_screen(longitude_deg, ascendant_deg, rot_offset_deg, r_inner);
2061    let (xo, yo) = polar_to_screen(longitude_deg, ascendant_deg, rot_offset_deg, r_outer);
2062    let mut builder = PathBuilder::stroke(px(width));
2063    builder.move_to(point(px(cx + xi), px(cy + yi)));
2064    builder.line_to(point(px(cx + xo), px(cy + yo)));
2065    if let Ok(path) = builder.build() {
2066        window.paint_path(path, color);
2067    }
2068}
2069
2070#[allow(clippy::too_many_arguments)]
2071fn paint_aspect_line(
2072    window: &mut Window,
2073    cx: f32,
2074    cy: f32,
2075    a_deg: f32,
2076    b_deg: f32,
2077    ascendant_deg: f32,
2078    rot_offset_deg: f32,
2079    r: f32,
2080    color: Hsla,
2081    dash: Option<(f32, f32)>,
2082    width: f32,
2083) {
2084    let (xa, ya) = polar_to_screen(a_deg, ascendant_deg, rot_offset_deg, r);
2085    let (xb, yb) = polar_to_screen(b_deg, ascendant_deg, rot_offset_deg, r);
2086    paint_segment(window, cx + xa, cy + ya, cx + xb, cy + yb, color, dash, width);
2087}
2088
2089/// Línea de aspecto natal ↔ tránsito: extremos en radios distintos.
2090/// El `from_deg` cae sobre el ring de cuerpos natales (`r_from`); el
2091/// `to_deg` sobre el ring de tránsito (`r_to`). Trazo más fino que el
2092/// natal-natal para no competir visualmente.
2093#[allow(clippy::too_many_arguments)]
2094fn paint_cross_aspect_line(
2095    window: &mut Window,
2096    cx: f32,
2097    cy: f32,
2098    natal_deg: f32,
2099    transit_deg: f32,
2100    ascendant_deg: f32,
2101    rot_offset_deg: f32,
2102    r_from: f32,
2103    r_to: f32,
2104    color: Hsla,
2105    dash: Option<(f32, f32)>,
2106) {
2107    let (xa, ya) = polar_to_screen(natal_deg, ascendant_deg, rot_offset_deg, r_from);
2108    let (xb, yb) = polar_to_screen(transit_deg, ascendant_deg, rot_offset_deg, r_to);
2109    paint_segment(window, cx + xa, cy + ya, cx + xb, cy + yb, color, dash, 0.7);
2110}
2111
2112/// Pinta un segmento entre dos puntos. Si `dash` es `Some((on, off))`,
2113/// itera el vector pintando trechos de `on` px con gaps de `off` px.
2114/// Si `None`, una sola línea continua. Usado por todos los aspect
2115/// painters — el dash pattern es la forma de distinguir kinds en
2116/// el theme BW (donde el color no sirve).
2117fn paint_segment(
2118    window: &mut Window,
2119    x0: f32,
2120    y0: f32,
2121    x1: f32,
2122    y1: f32,
2123    color: Hsla,
2124    dash: Option<(f32, f32)>,
2125    width: f32,
2126) {
2127    let Some((on, off)) = dash else {
2128        let mut b = PathBuilder::stroke(px(width));
2129        b.move_to(point(px(x0), px(y0)));
2130        b.line_to(point(px(x1), px(y1)));
2131        if let Ok(p) = b.build() {
2132            window.paint_path(p, color);
2133        }
2134        return;
2135    };
2136    let dx = x1 - x0;
2137    let dy = y1 - y0;
2138    let len = (dx * dx + dy * dy).sqrt();
2139    if len < 0.1 {
2140        return;
2141    }
2142    let ux = dx / len;
2143    let uy = dy / len;
2144    let step = on + off;
2145    if step < 0.1 {
2146        return;
2147    }
2148    let mut t = 0.0;
2149    while t < len {
2150        let t_end = (t + on).min(len);
2151        let sx = x0 + ux * t;
2152        let sy = y0 + uy * t;
2153        let ex = x0 + ux * t_end;
2154        let ey = y0 + uy * t_end;
2155        let mut b = PathBuilder::stroke(px(width));
2156        b.move_to(point(px(sx), px(sy)));
2157        b.line_to(point(px(ex), px(ey)));
2158        if let Ok(p) = b.build() {
2159            window.paint_path(p, color);
2160        }
2161        t += step;
2162    }
2163}
2164
2165/// `true` para los 5 aspectos Ptoloméicos (conjunction, sextile,
2166/// square, trine, opposition). Cualquier otro `kind` se considera
2167/// menor — quincunx, semi-square, quintile, sesquiquadrate, etc.
2168fn is_major_aspect(kind: &str) -> bool {
2169    matches!(
2170        kind,
2171        "conjunction" | "sextile" | "square" | "trine" | "opposition"
2172    )
2173}
2174
2175/// Grosor de línea de aspecto inverso al orbe. La idea: a orbe 0°
2176/// (aspecto exacto) la línea va gruesa porque "pesa" más; a orbe
2177/// amplio se afina. Los mayores arrancan en un techo más alto que
2178/// los menores. En BW se le suma un poquito a todos porque las
2179/// líneas competen con sus dash patterns.
2180fn aspect_width(kind: &str, orb_deg: f32, mono: bool) -> f32 {
2181    let orb = orb_deg.abs();
2182    let major = is_major_aspect(kind);
2183    // Orbe de referencia para normalizar: ~8° para mayores, ~3° para
2184    // menores. Más allá la línea ya está afinada al mínimo.
2185    let max_orb = if major { 8.0 } else { 3.0 };
2186    let t = (1.0 - (orb / max_orb)).clamp(0.0, 1.0);
2187    let (min_w, max_w) = if major { (0.7, 2.1) } else { (0.5, 1.2) };
2188    let w = min_w + (max_w - min_w) * t;
2189    if mono { w + 0.2 } else { w }
2190}
2191
2192/// Dash pattern por aspecto, para modo monocromático. En modo color
2193/// el caller pasa `None` y las líneas van sólidas. Patterns elegidos
2194/// para que cada kind sea distinguible a ojo:
2195/// - conjunction/opposition: sólido (más peso visual, son los
2196///   aspectos "fuertes")
2197/// - square: dash medio (4 on / 3 off)
2198/// - trine: dash largo (8 on / 2 off) — casi sólido pero distinguible
2199/// - sextile: dotted (1.5 on / 3 off)
2200/// - minor: dotted finísimo (1 on / 4 off)
2201fn dash_pattern_for_kind(kind: &str) -> Option<(f32, f32)> {
2202    match kind {
2203        "conjunction" | "opposition" => None,
2204        "square" => Some((4.0, 3.0)),
2205        "trine" => Some((8.0, 2.0)),
2206        "sextile" => Some((1.5, 3.0)),
2207        _ => Some((1.0, 4.0)),
2208    }
2209}
2210
2211// =====================================================================
2212// Helpers
2213// =====================================================================
2214
2215/// Distancia mínima entre un punto y un segmento de recta. Usado por
2216/// hover_check para detectar proximity a líneas de aspectos.
2217fn dist_point_segment(px: f32, py: f32, ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
2218    let dx = bx - ax;
2219    let dy = by - ay;
2220    let len_sq = dx * dx + dy * dy;
2221    if len_sq < f32::EPSILON {
2222        // Segmento degenerado → distancia al punto a.
2223        let pdx = px - ax;
2224        let pdy = py - ay;
2225        return (pdx * pdx + pdy * pdy).sqrt();
2226    }
2227    let t = (((px - ax) * dx + (py - ay) * dy) / len_sq).clamp(0.0, 1.0);
2228    let proj_x = ax + t * dx;
2229    let proj_y = ay + t * dy;
2230    let dx2 = px - proj_x;
2231    let dy2 = py - proj_y;
2232    (dx2 * dx2 + dy2 * dy2).sqrt()
2233}
2234
2235fn polar_to_screen(
2236    longitude_deg: f32,
2237    ascendant_deg: f32,
2238    rot_offset_deg: f32,
2239    radius: f32,
2240) -> (f32, f32) {
2241    let deg = 180.0 - (longitude_deg - ascendant_deg + rot_offset_deg);
2242    let rad = deg * PI / 180.0;
2243    (radius * rad.cos(), radius * rad.sin())
2244}
2245
2246fn centered_glyph(
2247    x: f32,
2248    y: f32,
2249    box_size: f32,
2250    font_size: f32,
2251    text: SharedString,
2252    color: Hsla,
2253) -> gpui::Div {
2254    div()
2255        .absolute()
2256        .left(px(x - box_size / 2.0))
2257        .top(px(y - box_size / 2.0))
2258        .w(px(box_size))
2259        .h(px(box_size))
2260        .flex()
2261        .items_center()
2262        .justify_center()
2263        .text_size(px(font_size))
2264        .text_color(color)
2265        .child(text)
2266}
2267
2268/// Glyph de planeta con disco-halo detrás del char. El disco viene en
2269/// `disk_bg` (semi-opaco para que se vea a través el fondo del wheel)
2270/// y `disk_border` (típicamente el color del planeta). El char por
2271/// dentro va en `text_color` — recomendado el color del planeta sobre
2272/// disco neutro, o color contrastante sobre disco coloreado.
2273fn planet_glyph(
2274    x: f32,
2275    y: f32,
2276    disk_size: f32,
2277    font_size: f32,
2278    text: SharedString,
2279    text_color: Hsla,
2280    disk_bg: Hsla,
2281    disk_border: Hsla,
2282) -> gpui::Div {
2283    div()
2284        .absolute()
2285        .left(px(x - disk_size / 2.0))
2286        .top(px(y - disk_size / 2.0))
2287        .w(px(disk_size))
2288        .h(px(disk_size))
2289        .rounded_full()
2290        .bg(disk_bg)
2291        .border_1()
2292        .border_color(disk_border)
2293        .flex()
2294        .items_center()
2295        .justify_center()
2296        .text_size(px(font_size))
2297        .text_color(text_color)
2298        .child(text)
2299}
2300
2301/// Formato compacto de un grado eclíptico: "DD°SS" donde SS es el
2302/// glyph del signo zodiacal (♈♉♊…). Ej: 14.93° → "14°♈". Los
2303/// minutos se omiten — la pill es pequeña y los grados enteros
2304/// alcanzan para orientación visual. El tooltip muestra el detalle.
2305fn format_coord_compact(deg: f32) -> String {
2306    let normalized = deg.rem_euclid(360.0);
2307    let sign_idx = ((normalized / 30.0).floor() as usize) % 12;
2308    let deg_in_sign = (normalized - (sign_idx as f32) * 30.0).floor() as i32;
2309    let sign_glyph = match sign_idx {
2310        0 => "♈",
2311        1 => "♉",
2312        2 => "♊",
2313        3 => "♋",
2314        4 => "♌",
2315        5 => "♍",
2316        6 => "♎",
2317        7 => "♏",
2318        8 => "♐",
2319        9 => "♑",
2320        10 => "♒",
2321        _ => "♓",
2322    };
2323    format!("{}°{}", deg_in_sign, sign_glyph)
2324}
2325
2326/// Pill pequeña con un coord ("14°♈") junto al glyph de un planeta
2327/// o cusp. Fondo halo + texto fg_muted, padding mínimo para no
2328/// saturar la rueda con etiquetas grandes.
2329fn coord_label(
2330    x: f32,
2331    y: f32,
2332    text: SharedString,
2333    fg: Hsla,
2334    halo_bg: Hsla,
2335    font_size: f32,
2336) -> gpui::Div {
2337    // Estimación gruesa del ancho (caracteres × ~5.5 px a font 9.5).
2338    // Suficiente para no recortar; el flex centra dentro.
2339    let w = (text.len() as f32 * (font_size * 0.58)).max(font_size * 2.0);
2340    let h = font_size + 6.0;
2341    div()
2342        .absolute()
2343        .left(px(x - w / 2.0))
2344        .top(px(y - h / 2.0))
2345        .w(px(w))
2346        .h(px(h))
2347        .flex()
2348        .items_center()
2349        .justify_center()
2350        .rounded(px(h / 2.0))
2351        .bg(halo_bg)
2352        .text_size(px(font_size))
2353        .text_color(fg)
2354        .child(text)
2355}
2356
2357/// Color HSL semi-opaco para los halos de los glyphs — derivado del
2358/// theme. En dark va casi negro; en light casi blanco. Alpha alta para
2359/// que el char quede legible contra cualquier cosa que haya detrás
2360/// (anillo, líneas de aspecto, starfield).
2361fn glyph_halo(theme: &Theme) -> Hsla {
2362    if theme.is_dark {
2363        hsla(0.0, 0.0, 0.07, 0.92)
2364    } else {
2365        hsla(0.0, 0.0, 0.97, 0.92)
2366    }
2367}
2368
2369fn with_alpha(c: Hsla, a: f32) -> Hsla {
2370    hsla(c.h, c.s, c.l, a.clamp(0.0, 1.0))
2371}
2372
2373/// Devuelve `c` con la luminancia modificada por `delta` (clamp 0..1).
2374/// Útil para derivar highlight (+luma) y shadow (-luma) de un color
2375/// base manteniendo hue y saturación — efecto bevel/3D barato.
2376fn adjust_luma(c: Hsla, delta: f32) -> Hsla {
2377    hsla(c.h, c.s, (c.l + delta).clamp(0.0, 1.0), c.a)
2378}
2379
2380/// Devuelve `c` con el hue desplazado `delta_deg` grados sobre el
2381/// círculo cromático (wrap a [0,1] en la escala normalizada de gpui).
2382/// Usado para derivar el color del anillo de casas desde el del dial
2383/// zodiacal — los dos sistemas (eclíptica vs ascensional) deben
2384/// distinguirse a primera vista pero compartir "familia" cromática.
2385fn shift_hue(c: Hsla, delta_deg: f32) -> Hsla {
2386    let new_h = (c.h + delta_deg / 360.0).rem_euclid(1.0);
2387    hsla(new_h, c.s, c.l, c.a)
2388}
2389
2390/// Color para los anillos del sistema de casas (ascensional). En
2391/// paletas con color, lo derivamos de `house_cusp` con un hue shift
2392/// de ~140° para diferenciar de la eclíptica (que va con el dorado
2393/// de `dial_ring`). En BW devolvemos `house_cusp` tal cual — un
2394/// shift cromático en monocromo es ruido sin información.
2395fn house_ring_color(palette: &AstroPalette) -> Hsla {
2396    if palette.is_monochrome() {
2397        palette.house_cusp
2398    } else {
2399        shift_hue(palette.house_cusp, 140.0)
2400    }
2401}
2402
2403/// Stroke con efecto embossed: 3 trazos concéntricos. El highlight va
2404/// 0.7 px hacia el centro con luminancia subida; el principal en `r`;
2405/// el shadow 0.7 px hacia afuera con luminancia bajada. La dirección
2406/// del bevel depende del theme: en dark el highlight es exterior (luz
2407/// "desde arriba"), en light interior (sombra "desde arriba" hacia
2408/// el centro).
2409fn stroke_circle_3d(
2410    window: &mut Window,
2411    cx: f32,
2412    cy: f32,
2413    r: f32,
2414    width: f32,
2415    color: Hsla,
2416    theme: &Theme,
2417) {
2418    let (hl_offset, sh_offset) = if theme.is_dark {
2419        (-0.7, 0.7)
2420    } else {
2421        (0.7, -0.7)
2422    };
2423    let hl = with_alpha(adjust_luma(color, 0.20), color.a * 0.55);
2424    let sh = with_alpha(adjust_luma(color, -0.18), color.a * 0.55);
2425    stroke_circle(window, cx, cy, r + hl_offset, (width * 0.7).max(0.4), hl);
2426    stroke_circle(window, cx, cy, r, width, color);
2427    stroke_circle(window, cx, cy, r + sh_offset, (width * 0.7).max(0.4), sh);
2428}
2429
2430/// Bevel central del anillo de signos: ~10 strokes finos entre
2431/// sign_inner y sign_outer, con alpha en bell curve (máximo en el
2432/// medio, decae hacia los bordes). Genera la sensación de volumen
2433/// sin pintar gradient radial (no soportado en gpui canvas).
2434fn paint_dial_bevel(
2435    window: &mut Window,
2436    cx: f32,
2437    cy: f32,
2438    radii: &Radii,
2439    palette: &AstroPalette,
2440    theme: &Theme,
2441) {
2442    let steps = 10;
2443    let base = if theme.is_dark { 0.07 } else { 0.10 };
2444    let color = palette.dial_ring;
2445    for i in 0..steps {
2446        let t = (i as f32 + 0.5) / steps as f32;
2447        let r = radii.sign_inner + (radii.sign_outer - radii.sign_inner) * t;
2448        // Bell curve simétrica: |t-0.5|*2 da 0..1 desde el centro, lo
2449        // invertimos para que el centro tenga peso máximo.
2450        let bell = 1.0 - ((t - 0.5).abs() * 2.0);
2451        let a = base * bell;
2452        stroke_circle(window, cx, cy, r, 1.0, with_alpha(color, a));
2453    }
2454}
2455
2456fn sign_unicode(name: &str) -> &'static str {
2457    match name {
2458        "aries" => "♈",
2459        "taurus" => "♉",
2460        "gemini" => "♊",
2461        "cancer" => "♋",
2462        "leo" => "♌",
2463        "virgo" => "♍",
2464        "libra" => "♎",
2465        "scorpio" => "♏",
2466        "sagittarius" => "♐",
2467        "capricorn" => "♑",
2468        "aquarius" => "♒",
2469        "pisces" => "♓",
2470        _ => "?",
2471    }
2472}
2473
2474const SIGN_NAMES_ES: [&str; 12] = [
2475    "Aries",
2476    "Tauro",
2477    "Géminis",
2478    "Cáncer",
2479    "Leo",
2480    "Virgo",
2481    "Libra",
2482    "Escorpio",
2483    "Sagitario",
2484    "Capricornio",
2485    "Acuario",
2486    "Piscis",
2487];
2488
2489fn aspect_unicode(kind: &str) -> &'static str {
2490    match kind {
2491        "conjunction" => "☌",
2492        "opposition" => "☍",
2493        "trine" => "△",
2494        "square" => "□",
2495        "sextile" => "⚹",
2496        "quincunx" => "⚻",
2497        "semi_sextile" => "⚺",
2498        "semi_square" => "∠",
2499        "sesquiquadrate" => "⚼",
2500        "quintile" => "Q",
2501        "biquintile" => "bQ",
2502        _ => "·",
2503    }
2504}
2505
2506fn planet_unicode(name: &str) -> &'static str {
2507    match name {
2508        "sun" => "☉",
2509        "moon" => "☽",
2510        "mercury" => "☿",
2511        "venus" => "♀",
2512        "mars" => "♂",
2513        "jupiter" => "♃",
2514        "saturn" => "♄",
2515        "uranus" => "♅",
2516        "neptune" => "♆",
2517        "pluto" => "♇",
2518        "north_node" => "☊",
2519        "south_node" => "☋",
2520        "chiron" => "⚷",
2521        "lilith" => "⚸",
2522        "ceres" => "⚳",
2523        "pallas" => "⚴",
2524        "juno" => "⚵",
2525        "vesta" => "⚶",
2526        _ => "•",
2527    }
2528}
2529
2530fn planet_color(p: &AstroPalette, name: &str) -> Hsla {
2531    let planet = match name {
2532        "sun" => Planet::Sun,
2533        "moon" => Planet::Moon,
2534        "mercury" => Planet::Mercury,
2535        "venus" => Planet::Venus,
2536        "mars" => Planet::Mars,
2537        "jupiter" => Planet::Jupiter,
2538        "saturn" => Planet::Saturn,
2539        "uranus" => Planet::Uranus,
2540        "neptune" => Planet::Neptune,
2541        "pluto" => Planet::Pluto,
2542        "chiron" => Planet::Chiron,
2543        "north_node" => Planet::NorthNode,
2544        "south_node" => Planet::SouthNode,
2545        "lilith" => Planet::Lilith,
2546        _ => return p.fg_text_fallback(),
2547    };
2548    p.planet(planet)
2549}
2550
2551fn sign_element_by_index(i: usize) -> Element {
2552    match i % 4 {
2553        0 => Element::Fire,
2554        1 => Element::Earth,
2555        2 => Element::Air,
2556        _ => Element::Water,
2557    }
2558}
2559
2560fn element_color_for_sign(p: &AstroPalette, name: &str) -> Hsla {
2561    let elem = match name {
2562        "aries" | "leo" | "sagittarius" => Element::Fire,
2563        "taurus" | "virgo" | "capricorn" => Element::Earth,
2564        "gemini" | "libra" | "aquarius" => Element::Air,
2565        "cancer" | "scorpio" | "pisces" => Element::Water,
2566        _ => return p.fg_text_fallback(),
2567    };
2568    p.element(elem)
2569}
2570
2571fn aspect_color(p: &AstroPalette, kind: &str) -> Hsla {
2572    let k = match kind {
2573        "conjunction" => TAspectKind::Conjunction,
2574        "opposition" => TAspectKind::Opposition,
2575        "trine" => TAspectKind::Trine,
2576        "square" => TAspectKind::Square,
2577        "sextile" => TAspectKind::Sextile,
2578        "quincunx" => TAspectKind::Quincunx,
2579        "semi_sextile" => TAspectKind::Semisextile,
2580        "semi_square" => TAspectKind::Semisquare,
2581        "sesquiquadrate" => TAspectKind::Sesquisquare,
2582        "quintile" => TAspectKind::Quintile,
2583        "biquintile" => TAspectKind::Biquintile,
2584        _ => return p.minor_aspect,
2585    };
2586    p.aspect(k)
2587}
2588
2589trait AstroPaletteExt {
2590    fn fg_text_fallback(&self) -> Hsla;
2591}
2592
2593impl AstroPaletteExt for AstroPalette {
2594    fn fg_text_fallback(&self) -> Hsla {
2595        if self.is_dark {
2596            hsla(0.0, 0.0, 0.85, 1.0)
2597        } else {
2598            hsla(0.0, 0.0, 0.25, 1.0)
2599        }
2600    }
2601}