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