tawasuyu

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Sutichaykuna

⎇ e09207b152419ca29accb3d3eb721990e49290ab crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs Rust · 2100 siq'ikuna · 72.2 KB Ñawpa willay Pi imata llamkarqan ch'uya

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