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