⎇ 72758e75ce515f355f27615671c95ecaf8c2a8bc
crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
fix(tahuantinsuyu): hit-test del hover usa display_deg post-spread
1//! `tahuantinsuyu-canvas` — el widget GPUI del lienzo astrológico.
2//!
3//! Modela el cielo como un lienzo de **geometría reactiva**: un estado
4//! unificado [`CanvasState`] guarda offsets de rotación, flags de
5//! visibilidad y la lista de `Layer`s a pintar. Las interacciones
6//! (drag, hotkeys, toggles) mutan el estado; el render lee la última
7//! `RenderModel` y la deriva al frame.
8//!
9//! ## Convención de rotación
10//!
11//! El Ascendente cae a las 9 del reloj (lado izquierdo). Las casas
12//! crecen contrarreloj visualmente. Para una longitud eclíptica `L` y
13//! un ascendente `asc`:
14//!
15//! ```text
16//! screen_angle_rad = π - (L - asc + view_rotation) · π/180
17//! point = (cx + r·cos(θ), cy + r·sin(θ))
18//! ```
19//!
20//! ## Interacciones (fase 4)
21//!
22//! - **Drag en el aro exterior** (jog-dial perimetral): rota la rueda
23//! visualmente mientras dura el drag y, al soltar, traduce el delta
24//! angular a minutos (1° ≈ 4 min) y emite
25//! [`CanvasEvent::TimeOffsetChanged`]. El host (la app) recomputa la
26//! carta para el instante desplazado.
27//! - **Hotkeys**: `D`/`H`/`X`/`P` togglean SignDial/Houses/Aspects/
28//! Bodies. Click sobre el wheel le da focus al widget.
29
30#![forbid(unsafe_code)]
31#![warn(rust_2018_idioms)]
32
33use std::collections::HashMap;
34use std::f32::consts::PI;
35
36use gpui::{
37 Bounds, Context, EventEmitter, FocusHandle, Focusable, Hsla, IntoElement,
38 KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement,
39 PathBuilder, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, SharedString, Styled,
40 Window, canvas, div, hsla, point, prelude::*, px,
41};
42
43use tahuantinsuyu_engine::{Geometry, Layer, LayerKind, OUTER_RING_MODULES, RenderModel};
44use tahuantinsuyu_model::{ChartId, ContactId, GroupId};
45use tahuantinsuyu_theme::{AspectKind as TAspectKind, AstroPalette, Element, Planet};
46use yahweh_theme::Theme;
47
48// =====================================================================
49// Eventos
50// =====================================================================
51
52#[derive(Clone, Debug)]
53pub enum CanvasEvent {
54 /// Doble click sobre un thumbnail.
55 ChartRequested(ChartId),
56 /// Drag terminado: el offset acumulado de tiempo (en minutos)
57 /// cambió. El host debe recomputar el chart con este offset.
58 TimeOffsetChanged(i64),
59 /// El usuario togggleó una capa via hotkey — el panel debería
60 /// reflejarlo si quisiera mantenerse en sync.
61 LayerVisibilityChanged { kind: LayerKind, visible: bool },
62 /// El usuario togggleó los coord labels via hotkey C. El panel
63 /// debe sincronizar el toggle "show_coords" del NatalModule.
64 ShowCoordsChanged(bool),
65 /// El usuario pidió exportar el render actual como SVG. El shell
66 /// se encarga de escribir el archivo (la engine genera el string).
67 ExportSvgRequested,
68}
69
70// =====================================================================
71// Estado
72// =====================================================================
73
74#[derive(Clone, Debug, Default)]
75pub enum CanvasMode {
76 #[default]
77 Empty,
78 Wheel { render: Box<RenderModel> },
79 Thumbnails {
80 scope: ThumbnailScope,
81 items: Vec<ThumbnailItem>,
82 },
83}
84
85#[derive(Clone, Debug)]
86pub enum ThumbnailScope {
87 Group(GroupId),
88 Contact(ContactId),
89}
90
91#[derive(Clone, Debug)]
92pub struct ThumbnailItem {
93 pub chart_id: ChartId,
94 pub label: SharedString,
95 pub subtitle: Option<SharedString>,
96 pub preview: Option<RenderModel>,
97}
98
99/// Estado de un drag activo del jog-dial. `last_screen_angle_deg` se
100/// actualiza en cada `MouseMoveEvent`; `accumulated_delta_deg` lleva la
101/// rotación total desde que arrancó el drag (puede pasar de ±360°).
102#[derive(Clone, Debug)]
103struct JogDragState {
104 last_screen_angle_deg: f32,
105 accumulated_delta_deg: f32,
106}
107
108/// Drag activo de pan (MMB o LMB con Space). Captura el pan inicial al
109/// hacer mousedown; el move agrega delta_pos a esos valores.
110#[derive(Clone, Debug)]
111struct PanDragState {
112 start_pos: Point<Pixels>,
113 pan_x_start: f32,
114 pan_y_start: f32,
115}
116
117#[derive(Clone, Debug)]
118pub struct CanvasState {
119 pub mode: CanvasMode,
120 /// Rotación visual transitoria durante un drag. Se resetea a `0` al
121 /// soltar — el render nuevo trae el `ascendant_deg` ya rotado.
122 pub view_rotation_deg: f32,
123 /// Offset acumulado en minutos. Persiste entre drags hasta que el
124 /// host lo resetee.
125 pub time_offset_minutes: i64,
126 /// Factor de zoom multiplicativo aplicado al wheel. `1.0` = tamaño
127 /// nominal. Clampeado a [VIEW_SCALE_MIN, VIEW_SCALE_MAX].
128 pub view_scale: f32,
129 /// Pan horizontal en px (positivo = desplaza el wheel a la derecha
130 /// desde el centro). Se aplica como margin shift sobre el centrado
131 /// natural del flex parent.
132 pub view_pan_x: f32,
133 /// Pan vertical en px (positivo = abajo).
134 pub view_pan_y: f32,
135 /// Por-LayerKind: `true` = visible. Default = todo visible.
136 pub layer_visibility: HashMap<LayerKind, bool>,
137 /// Indicadores de grado al lado de cada planeta y cusp de casa.
138 /// Default `true` — el usuario los espera ver para leer la
139 /// carta. Se togglean con `C` (Coords) o desde el panel.
140 pub show_coords: bool,
141 /// Planeta hovered actualmente (para tooltip). `None` cuando el
142 /// mouse no está sobre ningún cuerpo.
143 pub hover: Option<HoverInfo>,
144 drag_jog: Option<JogDragState>,
145 drag_pan: Option<PanDragState>,
146}
147
148/// Límites del zoom — bajo 0.5 los glyphs se vuelven ilegibles; sobre
149/// 3.0 el wheel desborda incluso pantallas grandes.
150pub const VIEW_SCALE_MIN: f32 = 0.5;
151pub const VIEW_SCALE_MAX: f32 = 3.0;
152
153/// Info del elemento bajo el cursor — usado por el render para mostrar
154/// un tooltip flotante con detalles. Cubre body glyphs, cusps de casa,
155/// y líneas de aspectos.
156#[derive(Clone, Debug)]
157pub enum HoverInfo {
158 Body {
159 module_id: String,
160 symbol: String,
161 deg: f32,
162 house: Option<u8>,
163 retrograde: bool,
164 dignity_marker: Option<String>,
165 annotation: Option<String>,
166 local_x: f32,
167 local_y: f32,
168 },
169 HouseCusp {
170 house_number: u8,
171 deg: f32,
172 local_x: f32,
173 local_y: f32,
174 },
175 /// Hover sobre una línea de aspecto. `from_body`/`to_body` y `kind`
176 /// vienen de la LineSeg; `orb_deg` también. Los coords son el
177 /// punto medio del segmento donde se muestra el tooltip.
178 Aspect {
179 module_id: String,
180 from_body: String,
181 to_body: String,
182 kind: String,
183 orb_deg: f32,
184 local_x: f32,
185 local_y: f32,
186 },
187}
188
189impl HoverInfo {
190 fn local(&self) -> (f32, f32) {
191 match self {
192 HoverInfo::Body { local_x, local_y, .. } => (*local_x, *local_y),
193 HoverInfo::HouseCusp { local_x, local_y, .. } => (*local_x, *local_y),
194 HoverInfo::Aspect { local_x, local_y, .. } => (*local_x, *local_y),
195 }
196 }
197
198 fn key(&self) -> String {
199 match self {
200 HoverInfo::Body {
201 module_id, symbol, ..
202 } => format!("body:{}:{}", module_id, symbol),
203 HoverInfo::HouseCusp { house_number, .. } => format!("cusp:{}", house_number),
204 HoverInfo::Aspect {
205 module_id,
206 from_body,
207 to_body,
208 kind,
209 ..
210 } => format!("aspect:{}:{}-{}-{}", module_id, from_body, kind, to_body),
211 }
212 }
213}
214
215impl Default for CanvasState {
216 fn default() -> Self {
217 Self {
218 mode: CanvasMode::default(),
219 view_rotation_deg: 0.0,
220 time_offset_minutes: 0,
221 view_scale: 1.0,
222 view_pan_x: 0.0,
223 view_pan_y: 0.0,
224 layer_visibility: HashMap::new(),
225 show_coords: true,
226 hover: None,
227 drag_jog: None,
228 drag_pan: None,
229 }
230 }
231}
232
233impl CanvasState {
234 pub fn is_layer_visible(&self, kind: LayerKind) -> bool {
235 self.layer_visibility.get(&kind).copied().unwrap_or(true)
236 }
237}
238
239// =====================================================================
240// Widget
241// =====================================================================
242
243pub struct AstrologyCanvas {
244 state: CanvasState,
245 focus_handle: FocusHandle,
246}
247
248impl EventEmitter<CanvasEvent> for AstrologyCanvas {}
249
250impl Focusable for AstrologyCanvas {
251 fn focus_handle(&self, _: &gpui::App) -> FocusHandle {
252 self.focus_handle.clone()
253 }
254}
255
256impl AstrologyCanvas {
257 pub fn new(cx: &mut Context<'_, Self>) -> Self {
258 cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
259 Self {
260 state: CanvasState::default(),
261 focus_handle: cx.focus_handle(),
262 }
263 }
264
265 pub fn state(&self) -> &CanvasState {
266 &self.state
267 }
268
269 pub fn set_mode(&mut self, mode: CanvasMode, cx: &mut Context<'_, Self>) {
270 self.state.mode = mode;
271 cx.notify();
272 }
273
274 pub fn set_layer_visible(&mut self, kind: LayerKind, visible: bool, cx: &mut Context<'_, Self>) {
275 self.state.layer_visibility.insert(kind, visible);
276 cx.notify();
277 }
278
279 pub fn toggle_layer(&mut self, kind: LayerKind, cx: &mut Context<'_, Self>) {
280 let current = self.state.is_layer_visible(kind);
281 self.set_layer_visible(kind, !current, cx);
282 cx.emit(CanvasEvent::LayerVisibilityChanged {
283 kind,
284 visible: !current,
285 });
286 }
287
288 pub fn reset_time_offset(&mut self, cx: &mut Context<'_, Self>) {
289 if self.state.time_offset_minutes != 0 || self.state.view_rotation_deg != 0.0 {
290 self.state.time_offset_minutes = 0;
291 self.state.view_rotation_deg = 0.0;
292 cx.emit(CanvasEvent::TimeOffsetChanged(0));
293 cx.notify();
294 }
295 }
296
297 pub fn set_view_rotation(&mut self, deg: f32, cx: &mut Context<'_, Self>) {
298 self.state.view_rotation_deg = deg.rem_euclid(360.0);
299 cx.notify();
300 }
301
302 pub fn toggle_coords(&mut self, cx: &mut Context<'_, Self>) {
303 let new_val = !self.state.show_coords;
304 self.set_show_coords(new_val, cx);
305 cx.emit(CanvasEvent::ShowCoordsChanged(new_val));
306 }
307
308 /// Setter idempotente — el shell lo usa para reflejar cambios del
309 /// panel sin disparar el `ShowCoordsChanged` (que iría en el otro
310 /// sentido y crearía un loop).
311 pub fn set_show_coords(&mut self, value: bool, cx: &mut Context<'_, Self>) {
312 if self.state.show_coords != value {
313 self.state.show_coords = value;
314 cx.notify();
315 }
316 }
317
318 /// Resetea zoom y pan a sus defaults (1.0 y 0,0). No toca rotation
319 /// ni time offset — esos son ortogonales y tienen su propio reset.
320 pub fn reset_view(&mut self, cx: &mut Context<'_, Self>) {
321 if self.state.view_scale != 1.0
322 || self.state.view_pan_x != 0.0
323 || self.state.view_pan_y != 0.0
324 {
325 self.state.view_scale = 1.0;
326 self.state.view_pan_x = 0.0;
327 self.state.view_pan_y = 0.0;
328 cx.notify();
329 }
330 }
331
332 /// Zoom multiplicativo. El nuevo scale es `current * factor`, clamp
333 /// al rango permitido. El zoom es centrado (no rastrea el cursor) —
334 /// para mover el foco después del zoom, el usuario paneja con MMB.
335 fn zoom_by(&mut self, factor: f32, cx: &mut Context<'_, Self>) {
336 let new_scale =
337 (self.state.view_scale * factor).clamp(VIEW_SCALE_MIN, VIEW_SCALE_MAX);
338 if (new_scale - self.state.view_scale).abs() < 1e-4 {
339 return;
340 }
341 // Mantener el centro del wheel anclado al centro de pantalla:
342 // como el pan está en coords de la pantalla y el zoom es desde
343 // el centro del wheel, el pan se escala proporcional al ratio.
344 let ratio = new_scale / self.state.view_scale;
345 self.state.view_pan_x *= ratio;
346 self.state.view_pan_y *= ratio;
347 self.state.view_scale = new_scale;
348 cx.notify();
349 }
350
351 #[allow(dead_code)]
352 fn pan_by(&mut self, dx: f32, dy: f32, cx: &mut Context<'_, Self>) {
353 if dx == 0.0 && dy == 0.0 {
354 return;
355 }
356 self.state.view_pan_x += dx;
357 self.state.view_pan_y += dy;
358 cx.notify();
359 }
360
361 // ----- Internos: handlers de jog-dial -----
362
363 /// Despacha el LMB down entre jog-dial y pan. El jog-dial es un
364 /// control "fuerte" (mueve el tiempo de la carta), así que se
365 /// activa SOLO con modifier Ctrl/Cmd + click sobre el anillo de
366 /// signos — sin modifier es siempre pan, incluso sobre el anillo,
367 /// para que no haya rotaciones accidentales al manipular la
368 /// rueda.
369 fn on_primary_down(
370 &mut self,
371 position: Point<Pixels>,
372 modifiers: gpui::Modifiers,
373 bounds: Bounds<Pixels>,
374 cx: &mut Context<'_, Self>,
375 ) {
376 // Sin modifier: pan, sin importar dónde caiga el click.
377 if !(modifiers.control || modifiers.platform) {
378 self.on_pan_down(position, cx);
379 return;
380 }
381 let (cx_px, cy_px) = bounds_center(bounds);
382 let mx: f32 = position.x.into();
383 let my: f32 = position.y.into();
384 let dx = mx - cx_px;
385 let dy = my - cy_px;
386 let dist = (dx * dx + dy * dy).sqrt();
387 let r_outer = effective_r_outer(bounds);
388 let radii = Radii::from_outer(r_outer);
389 let on_dial = dist >= radii.sign_inner * 0.95 && dist <= radii.sign_outer * 1.10;
390 if on_dial {
391 let angle = dy.atan2(dx).to_degrees();
392 self.state.drag_jog = Some(JogDragState {
393 last_screen_angle_deg: angle,
394 accumulated_delta_deg: 0.0,
395 });
396 } else {
397 // Ctrl+click fuera del anillo: pan también — el modifier
398 // habilita el jog-dial pero no impide la navegación.
399 self.on_pan_down(position, cx);
400 }
401 }
402
403 fn on_jog_move(
404 &mut self,
405 position: Point<Pixels>,
406 bounds: Bounds<Pixels>,
407 cx: &mut Context<'_, Self>,
408 ) {
409 let Some(jog) = self.state.drag_jog.as_mut() else {
410 return;
411 };
412 let (cx_px, cy_px) = bounds_center(bounds);
413 let mx: f32 = position.x.into();
414 let my: f32 = position.y.into();
415 let dx = mx - cx_px;
416 let dy = my - cy_px;
417 let angle = dy.atan2(dx).to_degrees();
418 let mut delta = angle - jog.last_screen_angle_deg;
419 // Normalizar a (-180, 180] para cruzar el wrap sin saltar.
420 if delta > 180.0 {
421 delta -= 360.0;
422 } else if delta < -180.0 {
423 delta += 360.0;
424 }
425 jog.accumulated_delta_deg += delta;
426 jog.last_screen_angle_deg = angle;
427 // Reflejo visual durante el drag (sin recomputar).
428 self.state.view_rotation_deg = jog.accumulated_delta_deg;
429 cx.notify();
430 }
431
432 /// Hit-test sobre body glyphs + house cusps. Para bodies: distancia
433 /// al centro del glyph dentro de threshold. Para cusps: el mouse
434 /// debe estar cerca del ring de casas Y angularmente cerca del
435 /// cusp (proximidad a la línea radial).
436 fn on_hover_check(
437 &mut self,
438 position: Point<Pixels>,
439 bounds: Bounds<Pixels>,
440 cx: &mut Context<'_, Self>,
441 ) {
442 let CanvasMode::Wheel { render } = &self.state.mode else {
443 if self.state.hover.take().is_some() {
444 cx.notify();
445 }
446 return;
447 };
448 let (cx_px, cy_px) = bounds_center(bounds);
449 let mx: f32 = position.x.into();
450 let my: f32 = position.y.into();
451 let ox: f32 = bounds.origin.x.into();
452 let oy: f32 = bounds.origin.y.into();
453 let r_outer = effective_r_outer(bounds);
454 let radii = Radii::from_outer(r_outer);
455 let asc = render.ascendant_deg;
456 let rot = self.state.view_rotation_deg;
457 let body_threshold = 14.0_f32;
458
459 let mut best: Option<(f32, HoverInfo)> = None;
460
461 // 1) Body glyphs (incluye natal, overlays, midpoints).
462 //
463 // Importante: el hit-test debe usar `display_deg` (post-spread)
464 // y no `g.deg` (raw) — el spread mueve los discos para evitar
465 // solapes y si el hover sigue al raw, el usuario tendría que
466 // apuntar a una zona vacía para activarlo. Calculamos los
467 // displays con la misma función que render_wheel.
468 let view_scale = self.state.view_scale;
469 for layer in &render.layers {
470 let ring = match layer.kind {
471 LayerKind::Bodies => radii.body_ring(&layer.module_id),
472 LayerKind::Midpoints => radii.midpoints,
473 LayerKind::Outer if OUTER_RING_MODULES.contains(&layer.module_id.as_str()) => {
474 radii.transits
475 }
476 _ => continue,
477 };
478 let disk_base = body_disk_base(&layer.module_id, layer.kind, view_scale);
479 let raw_degs: Vec<f32> = layer.glyphs.iter().map(|g| g.deg).collect();
480 let disk_angular = (disk_base / (std::f32::consts::TAU * ring)) * 360.0;
481 let (display_degs, _) =
482 spread_angles(&raw_degs, disk_angular, disk_angular);
483 for (i, g) in layer.glyphs.iter().enumerate() {
484 let (gx, gy) = polar_to_screen(display_degs[i], asc, rot, ring);
485 let dx = mx - (cx_px + gx);
486 let dy = my - (cy_px + gy);
487 let dist = (dx * dx + dy * dy).sqrt();
488 if dist > body_threshold {
489 continue;
490 }
491 if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
492 best = Some((
493 dist,
494 HoverInfo::Body {
495 module_id: layer.module_id.clone(),
496 symbol: g.symbol.clone(),
497 deg: g.deg,
498 house: g.house,
499 retrograde: g.retrograde,
500 dignity_marker: g.dignity_marker.clone(),
501 annotation: g.annotation.clone(),
502 local_x: cx_px + gx - ox,
503 local_y: cy_px + gy - oy,
504 },
505 ));
506 }
507 }
508 }
509
510 // 2) Aspect lines (segundo: las líneas son más "frágiles" que
511 // los planetas; si un body matcheó arriba ya tomó precedencia).
512 // Computa distancia punto-segmento del mouse al line.
513 if best.is_none() {
514 for layer in &render.layers {
515 if !matches!(layer.kind, LayerKind::Aspects) {
516 continue;
517 }
518 let (r_from, r_to) = radii.aspect_endpoints(&layer.module_id);
519 if let Geometry::Lines(segs) = &layer.geometry {
520 for seg in segs {
521 if seg.from_body.is_empty() || seg.to_body.is_empty() {
522 continue;
523 }
524 let (ax, ay) = polar_to_screen(seg.from_deg, asc, rot, r_from);
525 let (bx, by) = polar_to_screen(seg.to_deg, asc, rot, r_to);
526 let px_a = cx_px + ax;
527 let py_a = cy_px + ay;
528 let px_b = cx_px + bx;
529 let py_b = cy_px + by;
530 let dist = dist_point_segment(mx, my, px_a, py_a, px_b, py_b);
531 if dist > 4.0 {
532 continue;
533 }
534 if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
535 let mid_x = (px_a + px_b) / 2.0;
536 let mid_y = (py_a + py_b) / 2.0;
537 best = Some((
538 dist,
539 HoverInfo::Aspect {
540 module_id: layer.module_id.clone(),
541 from_body: seg.from_body.clone(),
542 to_body: seg.to_body.clone(),
543 kind: seg.kind.clone(),
544 orb_deg: seg.orb_deg,
545 local_x: mid_x - ox,
546 local_y: mid_y - oy,
547 },
548 ));
549 }
550 }
551 }
552 }
553 }
554
555 // 3) House cusps — solo si el mouse está cerca del anillo de
556 // casas (radio entre houses_inner y houses_outer + margen) y
557 // ningún body ganó. Las cusps son líneas radiales — la
558 // distancia angular al cusp más cercano determina el hit.
559 if best.is_none() {
560 let dx = mx - cx_px;
561 let dy = my - cy_px;
562 let mouse_r = (dx * dx + dy * dy).sqrt();
563 let r_in = radii.houses_inner - 6.0;
564 let r_out = radii.houses_outer + 6.0;
565 if mouse_r >= r_in && mouse_r <= r_out {
566 // Calcular la longitud zodiacal que corresponde a este
567 // ángulo de pantalla (inversa de polar_to_screen).
568 let screen_angle_deg = dy.atan2(dx).to_degrees(); // (-180, 180]
569 // polar_to_screen: deg = 180 - (lon - asc + rot)
570 // → lon = asc + 180 - screen_angle_deg - rot
571 let lon = ((asc + 180.0 - screen_angle_deg - rot) as f32).rem_euclid(360.0);
572 // Buscar cusp más cercano (con wraparound).
573 for layer in &render.layers {
574 if matches!(layer.kind, LayerKind::Houses) {
575 if let Geometry::Ring { cusps_deg } = &layer.geometry {
576 for (i, c) in cusps_deg.iter().enumerate() {
577 let mut diff = (lon - c).abs();
578 if diff > 180.0 {
579 diff = 360.0 - diff;
580 }
581 if diff < 2.5 {
582 // Mouse cerca de ESTE cusp.
583 let (gx, gy) = polar_to_screen(
584 *c,
585 asc,
586 rot,
587 (radii.houses_inner + radii.houses_outer) / 2.0,
588 );
589 best = Some((
590 diff,
591 HoverInfo::HouseCusp {
592 house_number: (i as u8) + 1,
593 deg: *c,
594 local_x: cx_px + gx - ox,
595 local_y: cy_px + gy - oy,
596 },
597 ));
598 break;
599 }
600 }
601 }
602 }
603 }
604 }
605 }
606
607 let new_hover = best.map(|(_, h)| h);
608 let changed = match (&self.state.hover, &new_hover) {
609 (Some(a), Some(b)) => a.key() != b.key(),
610 (None, None) => false,
611 _ => true,
612 };
613 if changed {
614 self.state.hover = new_hover;
615 cx.notify();
616 }
617 }
618
619 // ----- Internos: pan drag (MMB) -----
620
621 fn on_pan_down(&mut self, position: Point<Pixels>, _cx: &mut Context<'_, Self>) {
622 self.state.drag_pan = Some(PanDragState {
623 start_pos: position,
624 pan_x_start: self.state.view_pan_x,
625 pan_y_start: self.state.view_pan_y,
626 });
627 }
628
629 fn on_pan_move(&mut self, position: Point<Pixels>, cx: &mut Context<'_, Self>) {
630 let Some(pan) = self.state.drag_pan.as_ref() else {
631 return;
632 };
633 let dx: f32 = (position.x - pan.start_pos.x).into();
634 let dy: f32 = (position.y - pan.start_pos.y).into();
635 self.state.view_pan_x = pan.pan_x_start + dx;
636 self.state.view_pan_y = pan.pan_y_start + dy;
637 cx.notify();
638 }
639
640 fn on_pan_up(&mut self, cx: &mut Context<'_, Self>) {
641 if self.state.drag_pan.take().is_some() {
642 cx.notify();
643 }
644 }
645
646 fn on_scroll(
647 &mut self,
648 event: &ScrollWheelEvent,
649 _w: &mut Window,
650 cx: &mut Context<'_, Self>,
651 ) {
652 let (_dx_px, dy_px) = match event.delta {
653 ScrollDelta::Pixels(p) => (f32::from(p.x), f32::from(p.y)),
654 ScrollDelta::Lines(p) => (p.x * 16.0, p.y * 16.0),
655 };
656 // Wheel = zoom puro, sin modifier. Pan se hace con drag (LMB
657 // fuera del anillo, o MMB). 100px de scroll ≈ ±20% zoom.
658 let factor = (dy_px * 0.002).exp();
659 self.zoom_by(factor, cx);
660 }
661
662 fn on_jog_up(&mut self, cx: &mut Context<'_, Self>) {
663 let Some(jog) = self.state.drag_jog.take() else {
664 return;
665 };
666 // 1° de arco ≈ 4 minutos de tiempo sideral (15°/hora).
667 // CW visual (delta negativa en nuestra convención) → tiempo
668 // hacia adelante.
669 let delta_minutes = (-jog.accumulated_delta_deg * 4.0) as i64;
670 if delta_minutes != 0 {
671 self.state.time_offset_minutes =
672 self.state.time_offset_minutes.saturating_add(delta_minutes);
673 // Snap visual: el shell recomputa con el nuevo offset y el
674 // render trae el ascendant rotado.
675 self.state.view_rotation_deg = 0.0;
676 cx.emit(CanvasEvent::TimeOffsetChanged(self.state.time_offset_minutes));
677 cx.notify();
678 } else {
679 self.state.view_rotation_deg = 0.0;
680 cx.notify();
681 }
682 }
683
684 fn on_key_down(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<'_, Self>) {
685 let key = event.keystroke.key.as_str();
686 let kind = match key {
687 "d" | "D" => LayerKind::SignDial,
688 "h" | "H" => LayerKind::Houses,
689 "x" | "X" => LayerKind::Aspects,
690 "p" | "P" => LayerKind::Bodies,
691 "t" | "T" => LayerKind::Outer,
692 "r" | "R" => {
693 self.reset_time_offset(cx);
694 return;
695 }
696 "0" => {
697 self.reset_view(cx);
698 return;
699 }
700 "c" | "C" => {
701 self.toggle_coords(cx);
702 return;
703 }
704 "s" | "S" => {
705 cx.emit(CanvasEvent::ExportSvgRequested);
706 return;
707 }
708 _ => return,
709 };
710 self.toggle_layer(kind, cx);
711 }
712}
713
714// =====================================================================
715// Geometría de pantalla
716// =====================================================================
717
718const WHEEL_SIZE: f32 = 580.0;
719const WHEEL_MARGIN: f32 = 28.0;
720
721/// Pinta un gradiente radial de profundidad sobre el background del
722/// canvas — efecto vignette. Se aproxima al gradient radial (no
723/// soportado nativamente por gpui en `.bg()`) pintando ~28 anillos
724/// concéntricos del centro hacia afuera, con alpha creciente hacia el
725/// borde. El centro queda claro y los extremos se oscurecen, dando
726/// sensación de "el wheel emerge desde la profundidad".
727///
728/// Solo activo en themes dark — sobre papel (light / print) el panel
729/// queda plano: una viñeta sobre fondo claro tiñe el papel y rompe
730/// la metáfora "impresión".
731fn paint_depth_field(bounds: Bounds<Pixels>, window: &mut Window, theme: &Theme) {
732 if !theme.is_dark {
733 return;
734 }
735 let ox: f32 = bounds.origin.x.into();
736 let oy: f32 = bounds.origin.y.into();
737 let bw: f32 = bounds.size.width.into();
738 let bh: f32 = bounds.size.height.into();
739 if bw <= 0.0 || bh <= 0.0 {
740 return;
741 }
742 let cx = ox + bw / 2.0;
743 let cy = oy + bh / 2.0;
744 // El gradient se extiende hasta la diagonal del rectángulo para
745 // que las esquinas estén dentro del último anillo (sin "halo"
746 // visible donde se corta).
747 let r_max = ((bw * bw + bh * bh).sqrt()) / 2.0 * 1.05;
748 let steps = 28;
749 // Color: casi-negro con tinte ligero del panel (el panel es dark).
750 let deep = hsla(230.0 / 360.0, 0.30, 0.04, 1.0);
751 // Stroke de cada anillo: el ancho cubre 1/steps del radio para
752 // que no queden gaps entre anillos.
753 let stroke_w = (r_max / steps as f32) * 1.15;
754 for i in 0..steps {
755 let t = i as f32 / (steps - 1) as f32;
756 let r = r_max * t;
757 // Curva ease-in: alpha crece de 0 (centro) a ~0.55 (borde),
758 // con la mayor parte del cambio en la mitad exterior. t² da
759 // ese "fondo profundo en el perímetro sin opacar el centro".
760 let alpha = 0.55 * (t * t);
761 stroke_circle(window, cx, cy, r, stroke_w, with_alpha(deep, alpha));
762 }
763}
764
765fn bounds_center(bounds: Bounds<Pixels>) -> (f32, f32) {
766 let ox: f32 = bounds.origin.x.into();
767 let oy: f32 = bounds.origin.y.into();
768 let bw: f32 = bounds.size.width.into();
769 let bh: f32 = bounds.size.height.into();
770 (ox + bw / 2.0, oy + bh / 2.0)
771}
772
773/// Radio del anillo exterior derivado del width *actual* del canvas
774/// (que ya está escalado por view_scale). Mantiene la proporción del
775/// margen contra `WHEEL_SIZE` original, así el hit-test del jog-dial y
776/// las cusps se adapta automáticamente al zoom sin que cada caller
777/// recalcule `view_scale`.
778fn effective_r_outer(bounds: Bounds<Pixels>) -> f32 {
779 let bw: f32 = bounds.size.width.into();
780 let scale = if WHEEL_SIZE > 0.0 { bw / WHEEL_SIZE } else { 1.0 };
781 (bw - WHEEL_MARGIN * scale * 2.0) / 2.0
782}
783
784// =====================================================================
785// Render
786// =====================================================================
787
788impl Render for AstrologyCanvas {
789 fn render(&mut self, _w: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
790 let theme = Theme::global(cx).clone();
791 let palette = AstroPalette::for_theme(&theme);
792 let entity = cx.entity();
793 let focus = self.focus_handle.clone();
794
795 let body = match &self.state.mode {
796 CanvasMode::Empty => render_empty(&theme),
797 CanvasMode::Wheel { render } => render_wheel(
798 &theme,
799 &palette,
800 render,
801 self.state.view_rotation_deg,
802 self.state.time_offset_minutes,
803 self.state.view_scale,
804 self.state.view_pan_x,
805 self.state.view_pan_y,
806 &self.state.layer_visibility,
807 self.state.show_coords,
808 self.state.hover.as_ref(),
809 entity,
810 ),
811 CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items),
812 };
813
814 // Depth field: capa absoluta detrás del body, ocupa todo el
815 // canvas. Vignette radial — el centro queda claro y los
816 // bordes se oscurecen, dando profundidad sin "ruido" de
817 // puntos. Solo en themes dark (en papel rompería la
818 // metáfora).
819 let theme_for_depth = theme.clone();
820 let depth_field = canvas(
821 |_b, _w, _cx| (),
822 move |bounds, _, window, _| paint_depth_field(bounds, window, &theme_for_depth),
823 )
824 .absolute()
825 .size_full();
826
827 div()
828 .id("astrology-canvas-root")
829 .track_focus(&focus)
830 .key_context("AstrologyCanvas")
831 .on_key_down(cx.listener(Self::on_key_down))
832 .on_mouse_down(
833 MouseButton::Left,
834 cx.listener(|this, _, w, _cx| {
835 w.focus(&this.focus_handle);
836 }),
837 )
838 .on_scroll_wheel(cx.listener(Self::on_scroll))
839 .size_full()
840 .bg(theme.bg_panel.clone())
841 .relative()
842 .overflow_hidden()
843 .child(depth_field)
844 .child(
845 div()
846 .size_full()
847 .flex()
848 .flex_col()
849 .items_center()
850 .justify_center()
851 .child(body),
852 )
853 }
854}
855
856// =====================================================================
857// Modos: empty / thumbnails / wheel
858// =====================================================================
859
860fn render_empty(theme: &Theme) -> gpui::Div {
861 div()
862 .flex()
863 .flex_col()
864 .items_center()
865 .justify_center()
866 .gap(px(12.0))
867 .child(
868 div()
869 .text_size(px(13.0))
870 .text_color(theme.fg_muted)
871 .child("Tahuantinsuyu"),
872 )
873 .child(
874 div()
875 .text_size(px(11.0))
876 .text_color(theme.fg_disabled)
877 .child("Seleccioná una carta en el árbol para empezar."),
878 )
879}
880
881fn render_thumbnails(theme: &Theme, items: &[ThumbnailItem]) -> gpui::Div {
882 if items.is_empty() {
883 return div()
884 .text_size(px(12.0))
885 .text_color(theme.fg_muted)
886 .child("Sin cartas en este grupo todavía.");
887 }
888 let mut row = div().flex().flex_row().flex_wrap().gap(px(12.0));
889 for it in items {
890 row = row.child(
891 div()
892 .w(px(140.0))
893 .h(px(160.0))
894 .rounded(px(6.0))
895 .border_1()
896 .border_color(theme.border)
897 .bg(theme.bg_panel_alt.clone())
898 .flex()
899 .flex_col()
900 .items_center()
901 .justify_end()
902 .pb(px(8.0))
903 .child(
904 div()
905 .text_size(px(11.0))
906 .text_color(theme.fg_text)
907 .child(it.label.clone()),
908 ),
909 );
910 }
911 row
912}
913
914// =====================================================================
915// Wheel
916// =====================================================================
917
918#[allow(clippy::too_many_arguments)]
919fn render_wheel(
920 theme: &Theme,
921 palette: &AstroPalette,
922 render: &RenderModel,
923 view_rotation_deg: f32,
924 time_offset_minutes: i64,
925 view_scale: f32,
926 view_pan_x: f32,
927 view_pan_y: f32,
928 layer_visibility: &HashMap<LayerKind, bool>,
929 show_coords: bool,
930 hover: Option<&HoverInfo>,
931 entity: gpui::Entity<AstrologyCanvas>,
932) -> gpui::Div {
933 let asc = render.ascendant_deg;
934 let rot_offset = view_rotation_deg;
935 // Todo el wheel escala uniforme: el cuadro contenedor y los anillos
936 // crecen con view_scale, así que glifos, líneas y márgenes mantienen
937 // sus proporciones. cx/cy_center vive en coords locales del wheel,
938 // donde el wheel tiene tamaño `wheel_size` (no WHEEL_SIZE).
939 let wheel_size = WHEEL_SIZE * view_scale;
940 let wheel_margin = WHEEL_MARGIN * view_scale;
941 let cx_center = wheel_size / 2.0;
942 let cy_center = wheel_size / 2.0;
943 let r_outer = (wheel_size - wheel_margin * 2.0) / 2.0;
944 let radii = Radii::from_outer(r_outer);
945
946 let visible = layer_visibility.clone();
947
948 // --- Canvas element con todo el trazo + jog-dial drag ---
949 let palette_paint = palette.clone();
950 let theme_paint = theme.clone();
951 let layers_paint: Vec<Layer> = render.layers.clone();
952 let asc_for_paint = asc;
953 let mc_for_paint = render.midheaven_deg;
954 let visibility_for_paint = visible.clone();
955 let entity_for_canvas = entity.clone();
956 // Hover focus para el highlight de aspectos — solo cuando el hover
957 // es un Body (sobre un planeta), no sobre cusps ni aspectos.
958 let hover_focus_paint: Option<String> = match hover {
959 Some(HoverInfo::Body { symbol, .. }) => Some(symbol.clone()),
960 _ => None,
961 };
962 let canvas_element = canvas(
963 move |_b: Bounds<Pixels>, _w, _cx| (),
964 move |bounds: Bounds<Pixels>, _, window, _| {
965 // Painting de la rueda.
966 paint_wheel(
967 bounds,
968 window,
969 &theme_paint,
970 &palette_paint,
971 &layers_paint,
972 asc_for_paint,
973 mc_for_paint,
974 rot_offset,
975 radii,
976 &visibility_for_paint,
977 hover_focus_paint.as_deref(),
978 );
979
980 // Handlers de mouse — se registran cada frame contra el
981 // window; GPUI los reemplaza al re-renderear. LMB despacha
982 // entre jog-dial (sobre el anillo) y pan (afuera). MMB es
983 // pan secundario para usuarios con scroll-mouse.
984 let entity_d = entity_for_canvas.clone();
985 window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| {
986 if !bounds.contains(&ev.position) {
987 return;
988 }
989 match ev.button {
990 MouseButton::Left => {
991 let mods = ev.modifiers;
992 entity_d.update(cx, |this, cx| {
993 this.on_primary_down(ev.position, mods, bounds, cx)
994 });
995 }
996 MouseButton::Middle => {
997 entity_d.update(cx, |this, cx| this.on_pan_down(ev.position, cx));
998 }
999 _ => {}
1000 }
1001 });
1002 let entity_m = entity_for_canvas.clone();
1003 window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
1004 if ev.dragging() {
1005 entity_m.update(cx, |this, cx| {
1006 if this.state.drag_pan.is_some() {
1007 this.on_pan_move(ev.position, cx);
1008 } else {
1009 this.on_jog_move(ev.position, bounds, cx);
1010 }
1011 });
1012 } else if bounds.contains(&ev.position) {
1013 // Mouse hover sin drag: hit-test sobre los body
1014 // glyphs para el tooltip.
1015 entity_m.update(cx, |this, cx| this.on_hover_check(ev.position, bounds, cx));
1016 } else {
1017 entity_m.update(cx, |this, cx| {
1018 if this.state.hover.take().is_some() {
1019 cx.notify();
1020 }
1021 });
1022 }
1023 });
1024 let entity_u = entity_for_canvas.clone();
1025 window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
1026 entity_u.update(cx, |this, cx| {
1027 this.on_pan_up(cx);
1028 this.on_jog_up(cx);
1029 });
1030 });
1031 },
1032 )
1033 .absolute()
1034 .w(px(wheel_size))
1035 .h(px(wheel_size));
1036
1037 // El wheel ya no tiene bg propio — antes era un cuadrado con
1038 // gradient que cortaba contra el fondo del panel; ahora el panel
1039 // (con su starfield encima en `render`) fluye continuo a través
1040 // del área del wheel, dando el efecto de "rueda flotando en el
1041 // universo" en lugar de "rueda sobre placa cuadrada".
1042 let mut wheel = div()
1043 .relative()
1044 .w(px(wheel_size))
1045 .h(px(wheel_size))
1046 .ml(px(view_pan_x))
1047 .mt(px(view_pan_y))
1048 .child(canvas_element);
1049
1050 // Factor de escala para los glyphs DOM. Los radii ya están
1051 // escalados (vienen de wheel_size = WHEEL_SIZE * view_scale), pero
1052 // los tamaños de fuente y disco están hardcoded — los multiplico
1053 // por view_scale para que el zoom afecte uniformemente todo el
1054 // contenido visual del wheel, no solo la geometría del canvas.
1055 let s = view_scale;
1056 // Color del halo para los discos detrás de glyphs y pills — se
1057 // calcula una sola vez, lo usan planetas, casas, ASC/MC y los
1058 // coord labels.
1059 let halo_bg = glyph_halo(theme);
1060 // Sign glyphs.
1061 if visible.get(&LayerKind::SignDial).copied().unwrap_or(true) {
1062 let sign_ring_mid = (radii.sign_outer + radii.sign_inner) / 2.0;
1063 for layer in &render.layers {
1064 if matches!(layer.kind, LayerKind::SignDial) {
1065 for g in &layer.glyphs {
1066 let (x, y) = polar_to_screen(g.deg, asc, rot_offset, sign_ring_mid);
1067 let color = element_color_for_sign(palette, &g.symbol);
1068 wheel = wheel.child(centered_glyph(
1069 cx_center + x,
1070 cy_center + y,
1071 20.0 * s,
1072 18.0 * s,
1073 sign_unicode(&g.symbol).into(),
1074 color,
1075 ));
1076 }
1077 }
1078 }
1079 }
1080
1081 // House numbers + (opcional) coord del cusp.
1082 //
1083 // El layer `natal` usa Zona CD (entre aros C y D); `topocentric`
1084 // usa Zona BC (entre aros B y C). Los house numbers se posan al
1085 // centro de la zona; las coord pills se posan adyacentes al aro
1086 // interior de la propia zona, así no se sale del bloque.
1087 if visible.get(&LayerKind::Houses).copied().unwrap_or(true) {
1088 let house_label_color = house_ring_color(palette);
1089 for layer in &render.layers {
1090 if matches!(layer.kind, LayerKind::Houses) {
1091 let is_topo = layer.module_id == "topocentric";
1092 let (r_out, r_in) = if is_topo {
1093 (radii.topo_houses_outer, radii.topo_houses_inner)
1094 } else {
1095 (radii.houses_outer, radii.houses_inner)
1096 };
1097 let label_r = (r_out + r_in) / 2.0;
1098 let coord_r = r_in + (r_out - r_in) * 0.18;
1099 for g in &layer.glyphs {
1100 let (x, y) = polar_to_screen(g.deg, asc, rot_offset, label_r);
1101 if let Some(h) = g.house {
1102 wheel = wheel.child(centered_glyph(
1103 cx_center + x,
1104 cy_center + y,
1105 16.0 * s,
1106 11.0 * s,
1107 format!("{}", h).into(),
1108 house_label_color,
1109 ));
1110 if show_coords {
1111 let coord = format_coord_compact(g.deg);
1112 let (lx, ly) =
1113 polar_to_screen(g.deg, asc, rot_offset, coord_r);
1114 wheel = wheel.child(coord_label(
1115 cx_center + lx,
1116 cy_center + ly,
1117 coord.into(),
1118 theme.fg_muted,
1119 halo_bg,
1120 8.5 * s,
1121 ));
1122 }
1123 }
1124 }
1125 }
1126 }
1127 }
1128
1129 // Planet glyphs: natal en `bodies` + overlays (progression,
1130 // solar_arc) en sus rings, ambos con disco-halo para legibilidad
1131 // contra cualquier fondo. El natal lleva un tamaño un poco mayor
1132 // que los overlays para que se lea como "el cuerpo principal".
1133 if visible.get(&LayerKind::Bodies).copied().unwrap_or(true) {
1134 for layer in &render.layers {
1135 if matches!(layer.kind, LayerKind::Bodies) {
1136 let is_natal = layer.module_id == "natal";
1137 let is_topo = layer.module_id == "topocentric";
1138 let is_pd_direct = layer.module_id == "pd_direct";
1139 let is_pd_converse = layer.module_id == "pd_converse";
1140 let is_pd = is_pd_direct || is_pd_converse;
1141 let ring = radii.body_ring(&layer.module_id);
1142 let alpha = if is_natal {
1143 1.0
1144 } else if is_topo {
1145 0.75
1146 } else if is_pd {
1147 0.80
1148 } else {
1149 0.88
1150 };
1151 let font_size = (if is_natal {
1152 18.0
1153 } else if is_topo {
1154 15.0
1155 } else if is_pd {
1156 13.0
1157 } else {
1158 14.0
1159 }) * s;
1160 let disk_size_base = (if is_natal {
1161 26.0
1162 } else if is_topo {
1163 22.0
1164 } else if is_pd {
1165 20.0
1166 } else {
1167 22.0
1168 }) * s;
1169
1170 // Anti-solapamiento: spread directo sobre TODOS los
1171 // glyphs con `min_sep = disk_angular` (tangencial: los
1172 // discos se rozan sin pisarse) y `max_shift = disk_angular`
1173 // (cap fuerte: ningún planeta puede alejarse más de
1174 // un diámetro de disco de su grado real). El cap evita
1175 // que un cluster denso "empuje" a planetas lejanos.
1176 //
1177 // En paralelo, `find_clusters` con threshold = ancho
1178 // del disco × 1.2 detecta pares/tríos cercanos para
1179 // que compartan label. Sin esto, dos planetas en
1180 // conjunción a 5° real se ven con sus discos
1181 // separados a 10° y CADA UNO con su pill — dos labels
1182 // que dicen casi lo mismo, exactamente lo que el
1183 // usuario reporta como "se repiten en vez de
1184 // reutilizarse".
1185 let raw_degs: Vec<f32> = layer.glyphs.iter().map(|g| g.deg).collect();
1186 let disk_angular_deg =
1187 (disk_size_base / (std::f32::consts::TAU * ring)) * 360.0;
1188 let max_shift = disk_angular_deg;
1189 let (display_degs, residual) =
1190 spread_angles(&raw_degs, disk_angular_deg, max_shift);
1191 let cluster_thresh = disk_angular_deg * 1.2;
1192 let clusters = find_clusters(&raw_degs, cluster_thresh);
1193
1194 let cluster_centroids: Vec<f32> = clusters
1195 .iter()
1196 .map(|c| {
1197 let mut sx = 0.0_f32;
1198 let mut sy = 0.0_f32;
1199 for &idx in c {
1200 let a = raw_degs[idx].to_radians();
1201 sx += a.cos();
1202 sy += a.sin();
1203 }
1204 sy.atan2(sx).to_degrees().rem_euclid(360.0)
1205 })
1206 .collect();
1207 let display_centroids: Vec<f32> = clusters
1208 .iter()
1209 .map(|c| {
1210 let mut sx = 0.0_f32;
1211 let mut sy = 0.0_f32;
1212 for &idx in c {
1213 let a = display_degs[idx].to_radians();
1214 sx += a.cos();
1215 sy += a.sin();
1216 }
1217 sy.atan2(sx).to_degrees().rem_euclid(360.0)
1218 })
1219 .collect();
1220 let mut cluster_of = vec![0usize; layer.glyphs.len()];
1221 for (ci, c) in clusters.iter().enumerate() {
1222 for &idx in c {
1223 cluster_of[idx] = ci;
1224 }
1225 }
1226
1227 let shrink_residual = (1.0 - residual * 0.30).clamp(0.60, 1.0);
1228
1229 // El hovered glyph y su cluster reciben tratamiento
1230 // especial: lo postponemos para pintarlo al FINAL del
1231 // árbol (queda por encima del resto = z-order), y le
1232 // damos un border más fuerte. Su label cluster también
1233 // se destaca (color fg_text en lugar de fg_muted, font
1234 // un punto más grande).
1235 let hovered_sym: Option<&str> = match hover {
1236 Some(HoverInfo::Body { symbol, .. }) => Some(symbol.as_str()),
1237 _ => None,
1238 };
1239 let hovered_idx: Option<usize> = hovered_sym.and_then(|sym| {
1240 layer.glyphs.iter().position(|g| g.symbol == sym)
1241 });
1242 let hovered_cluster: Option<usize> = hovered_idx.map(|i| cluster_of[i]);
1243
1244 for (i, g) in layer.glyphs.iter().enumerate() {
1245 if Some(i) == hovered_idx {
1246 continue; // se pinta al final
1247 }
1248 // Achicar discos cuando el glyph está en cluster
1249 // (≥2 miembros) — al estar pegados se ven mejor
1250 // un poco más pequeños.
1251 let cluster_size = clusters[cluster_of[i]].len();
1252 let in_cluster_shrink = if cluster_size >= 2 { 0.86 } else { 1.0 };
1253 let effective_shrink = shrink_residual * in_cluster_shrink;
1254 let disk_size = disk_size_base * effective_shrink;
1255 let font_size_eff = (font_size * effective_shrink).max(11.0);
1256
1257 let display_deg = display_degs[i];
1258 let (x, y) = polar_to_screen(display_deg, asc, rot_offset, ring);
1259 let color = with_alpha(planet_color(palette, &g.symbol), alpha);
1260 let mut glyph_text = planet_unicode(&g.symbol).to_string();
1261 if g.retrograde {
1262 glyph_text.push('ᴿ');
1263 }
1264 if let Some(marker) = &g.dignity_marker {
1265 glyph_text.push_str(marker);
1266 }
1267 wheel = wheel.child(planet_glyph(
1268 cx_center + x,
1269 cy_center + y,
1270 disk_size,
1271 font_size_eff,
1272 glyph_text.into(),
1273 color,
1274 halo_bg,
1275 with_alpha(color, 0.85),
1276 ));
1277
1278 // Coord label individual: solo cuando el glyph
1279 // está SOLO en su cluster (≥2 ⇒ label compartido).
1280 if show_coords && (is_natal || is_topo) && cluster_size == 1 {
1281 let coord = format_coord_compact(g.deg);
1282 let label_r = ring - disk_size * 1.3;
1283 let (lx, ly) =
1284 polar_to_screen(display_deg, asc, rot_offset, label_r);
1285 wheel = wheel.child(coord_label(
1286 cx_center + lx,
1287 cy_center + ly,
1288 coord.into(),
1289 theme.fg_muted,
1290 halo_bg,
1291 8.5 * s,
1292 ));
1293 }
1294 }
1295
1296 // Label compartido para CADA cluster con ≥2 miembros.
1297 // El del cluster hovereado se destaca: color fg_text
1298 // (vs fg_muted) y font un punto más grande.
1299 if show_coords && (is_natal || is_topo) {
1300 let disk_size_typical = disk_size_base * shrink_residual * 0.86;
1301 for (ci, c) in clusters.iter().enumerate() {
1302 if c.len() < 2 {
1303 continue;
1304 }
1305 let highlighted = Some(ci) == hovered_cluster;
1306 let center_display_deg = display_centroids[ci];
1307 let center_real_deg = cluster_centroids[ci];
1308 let symbols: String = c
1309 .iter()
1310 .map(|&idx| planet_unicode(&layer.glyphs[idx].symbol))
1311 .collect::<Vec<_>>()
1312 .join(" ");
1313 let coord = format_coord_compact(center_real_deg);
1314 let text = format!("{} {}", symbols, coord);
1315 let label_r = ring - disk_size_typical * 1.5;
1316 let (lx, ly) = polar_to_screen(
1317 center_display_deg,
1318 asc,
1319 rot_offset,
1320 label_r,
1321 );
1322 let (fg, font_sz) = if highlighted {
1323 (theme.fg_text, 10.0 * s)
1324 } else {
1325 (theme.fg_muted, 9.0 * s)
1326 };
1327 wheel = wheel.child(coord_label(
1328 cx_center + lx,
1329 cy_center + ly,
1330 text.into(),
1331 fg,
1332 halo_bg,
1333 font_sz,
1334 ));
1335 }
1336 }
1337
1338 // Render del glyph hovered al FINAL: queda encima del
1339 // resto en z-order. Disco un poco más grande y border
1340 // más prominente para destacar.
1341 if let Some(hi) = hovered_idx {
1342 let g = &layer.glyphs[hi];
1343 let display_deg = display_degs[hi];
1344 let (x, y) = polar_to_screen(display_deg, asc, rot_offset, ring);
1345 let color = with_alpha(planet_color(palette, &g.symbol), alpha);
1346 let mut glyph_text = planet_unicode(&g.symbol).to_string();
1347 if g.retrograde {
1348 glyph_text.push('ᴿ');
1349 }
1350 if let Some(marker) = &g.dignity_marker {
1351 glyph_text.push_str(marker);
1352 }
1353 let disk_size = disk_size_base * shrink_residual * 1.18;
1354 let font_size_eff = font_size * shrink_residual * 1.12;
1355 wheel = wheel.child(planet_glyph(
1356 cx_center + x,
1357 cy_center + y,
1358 disk_size,
1359 font_size_eff,
1360 glyph_text.into(),
1361 color,
1362 halo_bg,
1363 color, // border al color pleno (no .85) — destaca
1364 ));
1365 // Si el hovered no está en cluster compartido,
1366 // pintamos su coord individual destacada acá.
1367 let cluster_size = clusters[cluster_of[hi]].len();
1368 if show_coords && (is_natal || is_topo) && cluster_size == 1 {
1369 let coord = format_coord_compact(g.deg);
1370 let label_r = ring - disk_size * 1.3;
1371 let (lx, ly) =
1372 polar_to_screen(display_deg, asc, rot_offset, label_r);
1373 wheel = wheel.child(coord_label(
1374 cx_center + lx,
1375 cy_center + ly,
1376 coord.into(),
1377 theme.fg_text,
1378 halo_bg,
1379 10.0 * s,
1380 ));
1381 }
1382 }
1383 }
1384 }
1385 }
1386
1387 // Planet glyphs en el outer ring — transit o synastry (slot
1388 // compartido, mutuamente excluyentes a nivel de Shell). Disco un
1389 // poco más chico que el natal — el outer es "secundario".
1390 if visible.get(&LayerKind::Outer).copied().unwrap_or(true) {
1391 for layer in &render.layers {
1392 if matches!(layer.kind, LayerKind::Outer)
1393 && (OUTER_RING_MODULES.contains(&layer.module_id.as_str()))
1394 {
1395 let disk_base = 20.0 * s;
1396 let raw_degs: Vec<f32> = layer.glyphs.iter().map(|g| g.deg).collect();
1397 let disk_angular =
1398 (disk_base / (std::f32::consts::TAU * radii.transits)) * 360.0;
1399 let (display_degs, residual) =
1400 spread_angles(&raw_degs, disk_angular, disk_angular);
1401 let shrink = (1.0 - residual * 0.30).clamp(0.60, 1.0);
1402 for (i, g) in layer.glyphs.iter().enumerate() {
1403 let display_deg = display_degs[i];
1404 let (x, y) = polar_to_screen(display_deg, asc, rot_offset, radii.transits);
1405 let color = with_alpha(planet_color(palette, &g.symbol), 0.92);
1406 let glyph_text = if g.retrograde {
1407 format!("{}ᴿ", planet_unicode(&g.symbol))
1408 } else {
1409 planet_unicode(&g.symbol).into()
1410 };
1411 wheel = wheel.child(planet_glyph(
1412 cx_center + x,
1413 cy_center + y,
1414 20.0 * s * shrink,
1415 13.0 * s * shrink,
1416 glyph_text.into(),
1417 color,
1418 halo_bg,
1419 with_alpha(color, 0.75),
1420 ));
1421 }
1422 }
1423 }
1424 }
1425
1426 // Tooltip absoluto sobre el elemento hovered (cuerpo o cusp).
1427 if let Some(hov) = hover {
1428 let text = match hov {
1429 HoverInfo::Body {
1430 module_id,
1431 symbol,
1432 deg,
1433 house,
1434 retrograde,
1435 dignity_marker,
1436 annotation,
1437 ..
1438 } => {
1439 let sign_idx = ((deg / 30.0).floor() as usize) % 12;
1440 let sign_name = SIGN_NAMES_ES[sign_idx];
1441 let deg_in_sign = deg - (sign_idx as f32) * 30.0;
1442 let display_symbol = if module_id == "midpoints" {
1443 // El symbol del midpoint es "a/b" — para el header
1444 // del tooltip usamos los unicodes individuales.
1445 if let Some((a, b)) = symbol.split_once('/') {
1446 format!("{}/{}", planet_unicode(a), planet_unicode(b))
1447 } else {
1448 symbol.clone()
1449 }
1450 } else {
1451 planet_unicode(symbol).to_string()
1452 };
1453 let mut t = format!("{} {} · {:.1}°", display_symbol, sign_name, deg_in_sign);
1454 if let Some(h) = house {
1455 t.push_str(&format!(" · Casa {}", h));
1456 }
1457 if *retrograde {
1458 t.push_str(" · ℞");
1459 }
1460 if let Some(m) = dignity_marker {
1461 t.push_str(&format!(" · {}", m));
1462 }
1463 if module_id == "midpoints" {
1464 if let Some(a) = annotation {
1465 t.push_str(&format!(" · {}", a));
1466 }
1467 } else if module_id != "natal" {
1468 t.push_str(&format!(" · {}", module_id));
1469 }
1470 t
1471 }
1472 HoverInfo::HouseCusp {
1473 house_number, deg, ..
1474 } => {
1475 let sign_idx = ((deg / 30.0).floor() as usize) % 12;
1476 let sign_name = SIGN_NAMES_ES[sign_idx];
1477 let deg_in_sign = deg - (sign_idx as f32) * 30.0;
1478 format!(
1479 "Cusp Casa {} · {} {:.1}°",
1480 house_number, sign_name, deg_in_sign
1481 )
1482 }
1483 HoverInfo::Aspect {
1484 module_id,
1485 from_body,
1486 to_body,
1487 kind,
1488 orb_deg,
1489 ..
1490 } => {
1491 let mut t = format!(
1492 "{} {} {} · orb {:.1}°",
1493 planet_unicode(from_body),
1494 aspect_unicode(kind),
1495 planet_unicode(to_body),
1496 orb_deg
1497 );
1498 if module_id != "natal" {
1499 t.push_str(&format!(" · {}", module_id));
1500 }
1501 t
1502 }
1503 };
1504 let (lx, ly) = hov.local();
1505 let tip_x = (lx + 14.0).min(WHEEL_SIZE - 220.0).max(8.0);
1506 let tip_y = (ly - 28.0).max(8.0);
1507 wheel = wheel.child(
1508 div()
1509 .absolute()
1510 .left(px(tip_x))
1511 .top(px(tip_y))
1512 .px(px(8.0))
1513 .py(px(4.0))
1514 .rounded(px(6.0))
1515 .bg(theme.bg_panel_alt.clone())
1516 .border_1()
1517 .border_color(palette.angle_highlight)
1518 .text_size(px(11.0))
1519 .text_color(theme.fg_text)
1520 .child(SharedString::from(text)),
1521 );
1522 }
1523
1524 // Labels ASC/MC/DESC/IC como pills en el perímetro — bg del halo
1525 // + border y texto en `angle_highlight`. Más legibles que el
1526 // centered_glyph plano del fase anterior, en especial sobre
1527 // fondos claros donde el ámbar/oro de angle_highlight se diluye.
1528 let angle_labels = [
1529 (asc, "ASC"),
1530 (render.midheaven_deg, "MC"),
1531 (render.descendant_deg, "DESC"),
1532 (render.imum_coeli_deg, "IC"),
1533 ];
1534 let label_r = r_outer * 1.08;
1535 for (deg, label) in angle_labels {
1536 let (x, y) = polar_to_screen(deg, asc, rot_offset, label_r);
1537 let pill_w = (if label.len() > 2 { 38.0 } else { 30.0 }) * s;
1538 let pill_h = 18.0 * s;
1539 wheel = wheel.child(
1540 div()
1541 .absolute()
1542 .left(px(cx_center + x - pill_w / 2.0))
1543 .top(px(cy_center + y - pill_h / 2.0))
1544 .w(px(pill_w))
1545 .h(px(pill_h))
1546 .flex()
1547 .items_center()
1548 .justify_center()
1549 .rounded(px(9.0 * s))
1550 .bg(halo_bg)
1551 .border_1()
1552 .border_color(with_alpha(palette.angle_highlight, 0.85))
1553 .text_size(px(11.0 * s))
1554 .text_color(palette.angle_highlight)
1555 .child(SharedString::from(label)),
1556 );
1557 }
1558
1559 // --- Header + footer + indicador de tiempo ---
1560 let header = div()
1561 .flex()
1562 .flex_col()
1563 .items_center()
1564 .gap(px(2.0))
1565 .child(
1566 div()
1567 .text_size(px(16.0))
1568 .text_color(theme.fg_text)
1569 .child(SharedString::from(render.title.clone())),
1570 );
1571 let header = if let Some(sub) = &render.subtitle {
1572 header.child(
1573 div()
1574 .text_size(px(11.0))
1575 .text_color(theme.fg_muted)
1576 .child(SharedString::from(sub.clone())),
1577 )
1578 } else {
1579 header
1580 };
1581 // Botón export SVG — pequeño, alineado a la derecha del title.
1582 let export_btn = div()
1583 .id("tts-canvas-export-svg")
1584 .px(px(10.0))
1585 .py(px(3.0))
1586 .rounded(px(4.0))
1587 .bg(theme.bg_button())
1588 .hover(|s| s.bg(theme.bg_button_hover()))
1589 .border_1()
1590 .border_color(theme.border)
1591 .text_size(px(10.0))
1592 .text_color(theme.fg_text)
1593 .child("⬇ SVG")
1594 .on_click({
1595 let entity_e = entity.clone();
1596 move |_: &gpui::ClickEvent, _w, cx: &mut gpui::App| {
1597 entity_e.update(cx, |_this, cx| {
1598 cx.emit(CanvasEvent::ExportSvgRequested);
1599 });
1600 }
1601 });
1602 let header = div()
1603 .flex()
1604 .flex_row()
1605 .items_center()
1606 .gap(px(12.0))
1607 .child(header)
1608 .child(export_btn);
1609
1610 let offset_label = format_offset(time_offset_minutes);
1611 let offset_color = if time_offset_minutes == 0 {
1612 theme.fg_disabled
1613 } else {
1614 palette.angle_highlight
1615 };
1616 let info_row = div()
1617 .flex()
1618 .flex_row()
1619 .gap(px(10.0))
1620 .child(
1621 div()
1622 .text_size(px(10.0))
1623 .text_color(theme.fg_disabled)
1624 .child(SharedString::from(format!(
1625 "Asc {:.1}° · MC {:.1}° · {} ms",
1626 render.ascendant_deg, render.midheaven_deg, render.compute_ms,
1627 ))),
1628 )
1629 .child(
1630 div()
1631 .text_size(px(10.0))
1632 .text_color(offset_color)
1633 .child(SharedString::from(offset_label)),
1634 )
1635 .child(
1636 div()
1637 .text_size(px(10.0))
1638 .text_color(theme.fg_disabled)
1639 .child(
1640 "[D]ial [H]ouses as[X]pects [P]lanets [T]ransits [C]oords · Ctrl+drag = tiempo · [0] reset zoom · [R] reset tiempo · [S]vg",
1641 ),
1642 );
1643
1644 // Badges de overlays activos. Cada uno se pinta como pill con
1645 // background sutil y border tenue. Solo aparecen cuando hay
1646 // overlays — la carta natal pura ve solo el info_row.
1647 let badges_row = if render.overlays.is_empty() {
1648 None
1649 } else {
1650 let mut row = div().flex().flex_row().flex_wrap().gap(px(6.0));
1651 // Badge "natal" base, siempre presente cuando hay overlays —
1652 // ayuda al usuario a leer la pila de izquierda a derecha.
1653 row = row.child(badge(theme, palette, "natal", "Natal", true));
1654 for ov in &render.overlays {
1655 row = row.child(badge(theme, palette, &ov.module_id, &ov.label, false));
1656 }
1657 Some(row)
1658 };
1659
1660 let mut footer = div().flex().flex_col().items_center().gap(px(4.0)).child(info_row);
1661 if let Some(b) = badges_row {
1662 footer = footer.child(b);
1663 }
1664
1665 // Ejes uranianos detectados (cuerpos en la misma posición mod 90).
1666 // Aparece sólo cuando el módulo Uranian está activo y hay
1667 // grupos. Cada grupo se muestra como pill con los unicode de los
1668 // cuerpos + el grado dial-90.
1669 if !render.uranian_groups.is_empty() {
1670 let mut row = div().flex().flex_row().flex_wrap().gap(px(6.0));
1671 for group in &render.uranian_groups {
1672 let bodies_text: String = group
1673 .bodies
1674 .iter()
1675 .map(|b| planet_unicode(b))
1676 .collect::<Vec<_>>()
1677 .join(" ");
1678 row = row.child(
1679 div()
1680 .px(px(8.0))
1681 .py(px(2.0))
1682 .rounded(px(10.0))
1683 .bg(theme.bg_panel_alt.clone())
1684 .border_1()
1685 .border_color(with_alpha(palette.angle_highlight, 0.6))
1686 .text_size(px(11.0))
1687 .text_color(theme.fg_text)
1688 .child(SharedString::from(format!(
1689 "{} · {:.1}°",
1690 bodies_text, group.mod90_deg
1691 ))),
1692 );
1693 }
1694 footer = footer.child(
1695 div()
1696 .flex()
1697 .flex_col()
1698 .items_center()
1699 .gap(px(3.0))
1700 .child(
1701 div()
1702 .text_size(px(10.0))
1703 .text_color(theme.fg_muted)
1704 .child("Ejes uranianos (90°)"),
1705 )
1706 .child(row),
1707 );
1708 }
1709
1710 // Lista textual de aspectos (top 12 por orb). Compacta, en grid
1711 // de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos
1712 // computados.
1713 if !render.aspect_summary.is_empty() {
1714 let mut grid = div()
1715 .flex()
1716 .flex_row()
1717 .flex_wrap()
1718 .gap(px(10.0))
1719 .max_w(px(WHEEL_SIZE + 80.0))
1720 .justify_center();
1721 for ap in render.aspect_summary.iter().take(12) {
1722 let kind_sym = aspect_unicode(&ap.kind);
1723 let line = format!(
1724 "{} {} {} · {:.1}°{}",
1725 planet_unicode(&ap.from_body),
1726 kind_sym,
1727 planet_unicode(&ap.to_body),
1728 ap.orb_deg,
1729 match ap.applying {
1730 Some(true) => " A",
1731 Some(false) => " S",
1732 None => "",
1733 }
1734 );
1735 let prefix = if ap.module_id == "natal" {
1736 String::new()
1737 } else {
1738 format!("[{}] ", ap.module_id)
1739 };
1740 grid = grid.child(
1741 div()
1742 .px(px(6.0))
1743 .py(px(2.0))
1744 .text_size(px(11.0))
1745 .text_color(aspect_color(palette, &ap.kind))
1746 .child(SharedString::from(format!("{}{}", prefix, line))),
1747 );
1748 }
1749 footer = footer.child(grid);
1750 }
1751
1752 div()
1753 .flex()
1754 .flex_col()
1755 .items_center()
1756 .gap(px(8.0))
1757 .child(header)
1758 .child(wheel)
1759 .child(footer)
1760}
1761
1762/// Pequeña pill con la etiqueta de un overlay activo. El borde toma
1763/// color según el "tipo" del módulo para ayudar a mapear a su anillo
1764/// en el wheel: natal = neutro, outer ring share (transit/synastry/
1765/// planetary_return) = palette.angle_highlight, inner overlays
1766/// (progression/solar_arc) = palette.house_cusp.
1767fn badge(theme: &Theme, palette: &AstroPalette, module_id: &str, label: &str, is_natal: bool) -> gpui::Div {
1768 let border = if is_natal {
1769 theme.border
1770 } else {
1771 match module_id {
1772 "transit" | "synastry" | "planetary_return" => palette.angle_highlight,
1773 "progression" | "solar_arc" => palette.house_cusp,
1774 _ => theme.border,
1775 }
1776 };
1777 div()
1778 .px(px(8.0))
1779 .py(px(2.0))
1780 .rounded(px(10.0))
1781 .bg(theme.bg_panel_alt.clone())
1782 .border_1()
1783 .border_color(border)
1784 .text_size(px(10.0))
1785 .text_color(theme.fg_text)
1786 .child(SharedString::from(label.to_string()))
1787}
1788
1789fn format_offset(minutes: i64) -> String {
1790 if minutes == 0 {
1791 return "⏱ ahora".to_string();
1792 }
1793 let sign = if minutes > 0 { '+' } else { '-' };
1794 let m = minutes.unsigned_abs();
1795 let days = m / (60 * 24);
1796 let hours = (m / 60) % 24;
1797 let mins = m % 60;
1798 if days > 0 {
1799 format!("⏱ {}{}d {:02}h {:02}m", sign, days, hours, mins)
1800 } else if hours > 0 {
1801 format!("⏱ {}{:02}h {:02}m", sign, hours, mins)
1802 } else {
1803 format!("⏱ {}{:02}m", sign, mins)
1804 }
1805}
1806
1807// =====================================================================
1808// Painting
1809// =====================================================================
1810
1811/// Geometría radial canónica de la rueda. Aros nombrados según
1812/// convención del usuario, de afuera hacia adentro:
1813///
1814/// * **Aro A** (`sign_outer`) — externo del zodiaco.
1815/// * **Zona AB** — sign dial: glyphs de signos zodiacales.
1816/// * **Aro B** (`sign_inner` = `topo_houses_outer`) — interno del
1817/// zodiaco / externo del bloque ascensional.
1818/// * **Zona BC** — casas topocéntricas (cusps b→c) + planetas
1819/// topocéntricos, ambos con sus coordenadas.
1820/// * **Aro C** (`topo_houses_inner` = `houses_outer`) — separador
1821/// ascensional / casas geo.
1822/// * **Zona CD** — casas geocéntricas (cusps c→d) + sus coordenadas.
1823/// * **Aro D** (`houses_inner`) — externo de los planetas natales.
1824/// Junto a D, hacia adentro, se posan los planetas natales y sus
1825/// coordenadas.
1826/// * **Aro E** (`aspects`) — el más interno. Desde aquí nacen las
1827/// líneas de aspecto / relaciones / overlays opcionales.
1828///
1829/// Los overlays adicionales (transits, midpoints, progression, solar
1830/// arc, composite) viven INTERIORES al aro E — solo se pintan
1831/// cuando el módulo correspondiente está activo, así no compiten
1832/// con el layout base.
1833#[derive(Clone, Copy)]
1834struct Radii {
1835 sign_outer: f32, // Aro A
1836 sign_inner: f32, // Aro B
1837 topo_houses_outer: f32, // = Aro B
1838 topocentric: f32, // Zona BC: planetas topo
1839 topo_houses_inner: f32, // Aro C
1840 houses_outer: f32, // = Aro C
1841 houses_inner: f32, // Aro D
1842 bodies: f32, // Zona D-E: planetas natales (junto a D)
1843 pd_direct: f32, // GR (cuando activo): exterior al cinturón natal
1844 pd_converse: f32, // GR (cuando activo): interior al cinturón natal
1845 aspects: f32, // Aro E (invisible, ancla de líneas)
1846 // Overlays adicionales — todos interiores a E.
1847 transits: f32,
1848 midpoints: f32,
1849 progression: f32,
1850 solar_arc: f32,
1851 composite: f32,
1852}
1853
1854impl Radii {
1855 fn from_outer(r: f32) -> Self {
1856 Self {
1857 // Aro A — externo zodiaco.
1858 sign_outer: r,
1859 // Aro B — interno zodiaco / externo bloque ascensional.
1860 sign_inner: r * 0.92,
1861 topo_houses_outer: r * 0.92,
1862 // Zona BC: planetas topocéntricos centrados.
1863 topocentric: r * 0.85,
1864 // Aro C — separador ascensional / casas geo.
1865 topo_houses_inner: r * 0.78,
1866 houses_outer: r * 0.78,
1867 // Aro D — externo planetas natales.
1868 houses_inner: r * 0.62,
1869 // Planetas natales justo dentro de D.
1870 bodies: r * 0.57,
1871 // GR dual-ring (cuando se activa): abraza el cinturón
1872 // natal por afuera (`pd_direct`) y por adentro
1873 // (`pd_converse`). Si GR está OFF, ninguno de los dos se
1874 // pinta — no compite con el layout base.
1875 pd_direct: r * 0.545,
1876 pd_converse: r * 0.515,
1877 // Aro E — anclaje invisible de las líneas de aspecto.
1878 aspects: r * 0.49,
1879 // Overlays adicionales — todos INTERIORES al aro E. Se
1880 // pintan solo cuando el módulo correspondiente está
1881 // activo, así no compiten con el layout base.
1882 transits: r * 0.43,
1883 midpoints: r * 0.39,
1884 progression: r * 0.33,
1885 solar_arc: r * 0.27,
1886 composite: r * 0.21,
1887 }
1888 }
1889
1890 /// Radio del ring de cuerpos según el `module_id` del Layer.
1891 fn body_ring(&self, module_id: &str) -> f32 {
1892 match module_id {
1893 "progression" => self.progression,
1894 "solar_arc" => self.solar_arc,
1895 "composite" => self.composite,
1896 "midpoints" => self.midpoints,
1897 "topocentric" => self.topocentric,
1898 "pd_direct" => self.pd_direct,
1899 "pd_converse" => self.pd_converse,
1900 _ => self.bodies,
1901 }
1902 }
1903
1904 /// Resuelve qué radios corresponden a una capa de aspectos según el
1905 /// `module_id`: natal-natal en `aspects`, cross con cada overlay
1906 /// desde `bodies` (extremo natal) al ring del módulo. Los módulos
1907 /// del outer ring (OUTER_RING_MODULES) comparten el slot de
1908 /// tránsito (son mutuamente excluyentes a nivel de Shell).
1909 fn aspect_endpoints(&self, module_id: &str) -> (f32, f32) {
1910 if OUTER_RING_MODULES.contains(&module_id) {
1911 return (self.bodies, self.transits);
1912 }
1913 match module_id {
1914 "progression" => (self.bodies, self.progression),
1915 "solar_arc" => (self.bodies, self.solar_arc),
1916 "composite" => (self.bodies, self.composite),
1917 _ => (self.aspects, self.aspects),
1918 }
1919 }
1920}
1921
1922#[allow(clippy::too_many_arguments)]
1923// `hover_focus`: symbol del planeta hovereado en este frame (si lo
1924// hay). Las líneas de aspecto que NO tocan a ese planeta se opacan
1925// para que el usuario lea claramente "qué afecta a qué". Si `None`,
1926// todas las líneas se pintan a alpha plena.
1927fn paint_wheel(
1928 bounds: Bounds<Pixels>,
1929 window: &mut Window,
1930 theme: &Theme,
1931 palette: &AstroPalette,
1932 layers: &[Layer],
1933 ascendant_deg: f32,
1934 midheaven_deg: f32,
1935 rot_offset_deg: f32,
1936 radii: Radii,
1937 visibility: &HashMap<LayerKind, bool>,
1938 hover_focus: Option<&str>,
1939) {
1940 let (cx, cy) = bounds_center(bounds);
1941 let show = |k: LayerKind| visibility.get(&k).copied().unwrap_or(true);
1942
1943 // 1. Sectores zodiacales (parte del SignDial layer).
1944 if show(LayerKind::SignDial) {
1945 paint_sign_sectors(window, cx, cy, &radii, palette, ascendant_deg, rot_offset_deg);
1946
1947 // Anillos del dial con efecto 3D: highlight interior + base +
1948 // shadow exterior. El highlight es 1 px hacia el centro con
1949 // luminancia +0.18; la shadow 1 px hacia afuera con -0.18.
1950 // El bevel central — varios strokes finos con alpha en bell
1951 // curve entre sign_inner y sign_outer — da volumen al dial.
1952 stroke_circle_3d(window, cx, cy, radii.sign_outer, 1.5, palette.dial_ring, theme);
1953 stroke_circle_3d(window, cx, cy, radii.sign_inner, 1.0, palette.dial_ring, theme);
1954 paint_dial_bevel(window, cx, cy, &radii, palette, theme);
1955
1956 // Cusps zodiacales cada 30°.
1957 for i in 0..12 {
1958 let lon = (i as f32) * 30.0;
1959 let color = palette.dial_ring;
1960 paint_radial_line(
1961 window,
1962 cx,
1963 cy,
1964 lon,
1965 ascendant_deg,
1966 rot_offset_deg,
1967 radii.sign_inner,
1968 radii.sign_outer,
1969 color,
1970 1.0,
1971 );
1972 }
1973 }
1974
1975 // 2. Casas — doble anillo (inner + outer) + cusps radiales +
1976 // énfasis Asc/IC/Desc/MC. La doble línea vuelve a la zona de
1977 // casas una "corona" claramente identificable. Color derivado
1978 // de `house_cusp` con un hue shift para que el sistema
1979 // ascensional (casas) se distinga visualmente del eclíptico
1980 // (dial zodiacal) que va en dorado.
1981 if show(LayerKind::Houses) {
1982 let house_base = house_ring_color(palette);
1983 let house_color = with_alpha(house_base, 0.85);
1984 stroke_circle_3d(window, cx, cy, radii.houses_outer, 1.1, house_color, theme);
1985 stroke_circle_3d(window, cx, cy, radii.houses_inner, 1.1, house_color, theme);
1986 // Si hay capa topocéntrica activa, pintar también sus dos
1987 // anillos (con stroke más sutil que el geocéntrico, para que
1988 // se lea como "sistema ascensional" sin competir).
1989 if layers
1990 .iter()
1991 .any(|l| matches!(l.kind, LayerKind::Houses) && l.module_id == "topocentric")
1992 {
1993 let topo_color = with_alpha(house_base, 0.55);
1994 stroke_circle(window, cx, cy, radii.topo_houses_outer, 0.8, topo_color);
1995 stroke_circle(window, cx, cy, radii.topo_houses_inner, 0.8, topo_color);
1996 }
1997
1998 for layer in layers {
1999 if matches!(layer.kind, LayerKind::Houses) {
2000 let is_topo = layer.module_id == "topocentric";
2001 let (r_in, r_out) = if is_topo {
2002 (radii.topo_houses_inner, radii.topo_houses_outer)
2003 } else {
2004 (radii.houses_inner, radii.houses_outer)
2005 };
2006 if let Geometry::Ring { cusps_deg } = &layer.geometry {
2007 for (i, c) in cusps_deg.iter().enumerate() {
2008 let is_angle = i == 0 || i == 3 || i == 6 || i == 9;
2009 let color = if is_topo {
2010 with_alpha(house_base, 0.60)
2011 } else if is_angle {
2012 palette.angle_highlight
2013 } else {
2014 with_alpha(house_base, 0.75)
2015 };
2016 let width = if is_angle && !is_topo { 2.0 } else { 0.8 };
2017 if is_topo {
2018 // Topocéntrico: cusp como línea punteada
2019 // en su propio anillo cercano al sign
2020 // dial — se distingue del Placidus
2021 // geocéntrico por el dash pattern y la
2022 // ubicación más exterior.
2023 paint_segment(
2024 window,
2025 cx
2026 + polar_to_screen(
2027 *c,
2028 ascendant_deg,
2029 rot_offset_deg,
2030 r_in,
2031 )
2032 .0,
2033 cy
2034 + polar_to_screen(
2035 *c,
2036 ascendant_deg,
2037 rot_offset_deg,
2038 r_in,
2039 )
2040 .1,
2041 cx
2042 + polar_to_screen(
2043 *c,
2044 ascendant_deg,
2045 rot_offset_deg,
2046 r_out,
2047 )
2048 .0,
2049 cy
2050 + polar_to_screen(
2051 *c,
2052 ascendant_deg,
2053 rot_offset_deg,
2054 r_out,
2055 )
2056 .1,
2057 color,
2058 Some((3.0, 2.5)),
2059 1.0,
2060 );
2061 } else {
2062 paint_radial_line(
2063 window,
2064 cx,
2065 cy,
2066 *c,
2067 ascendant_deg,
2068 rot_offset_deg,
2069 r_in,
2070 r_out,
2071 color,
2072 width,
2073 );
2074 }
2075 }
2076 }
2077 }
2078 }
2079
2080 // Cruz completa Asc-Desc + MC-IC, alpha bastante visible para
2081 // que orienten la lectura sin competir con cuerpos/aspectos.
2082 // 4 radios desde el centro: ASC, DESC (=asc+180), MC, IC
2083 // (=mc+180). `paint_radial_line` con r_inner=0 pinta un radio
2084 // del centro al borde — la cruz es la unión de los 4.
2085 let axis_color = with_alpha(palette.angle_highlight, 0.55);
2086 for axis_deg in [
2087 ascendant_deg,
2088 ascendant_deg + 180.0,
2089 midheaven_deg,
2090 midheaven_deg + 180.0,
2091 ] {
2092 paint_radial_line(
2093 window,
2094 cx,
2095 cy,
2096 axis_deg,
2097 ascendant_deg,
2098 rot_offset_deg,
2099 0.0,
2100 radii.houses_outer,
2101 axis_color,
2102 1.4,
2103 );
2104 }
2105 }
2106
2107 // Aro D — único anillo visible del bloque de planetas natales
2108 // (la idea del "carril doble" se descartó: confundía con el
2109 // sistema de casas). El aro E (`radii.aspects`) no se pinta por
2110 // diseño; solo es ancla invisible de las líneas.
2111 if show(LayerKind::Bodies) {
2112 let belt_color = with_alpha(palette.dial_ring, 0.55);
2113 stroke_circle_3d(window, cx, cy, radii.houses_inner, 0.9, belt_color, theme);
2114 // GR dual-ring: si las capas de direcciones primarias están
2115 // presentes, marcar sus anillos para que el visual lea como
2116 // "abrazo" del cinturón natal. La directa va punteada,
2117 // la conversa también — la diferencia entre las dos es la
2118 // ubicación radial (afuera vs adentro del cinturón natal).
2119 let has_pd = layers.iter().any(|l| {
2120 matches!(l.kind, LayerKind::Bodies)
2121 && (l.module_id == "pd_direct" || l.module_id == "pd_converse")
2122 });
2123 if has_pd {
2124 let pd_color = with_alpha(palette.angle_highlight, 0.50);
2125 for r in [radii.pd_direct, radii.pd_converse] {
2126 // Pintamos el anillo como tramo punteado fino: 24
2127 // segmentos cortos a lo largo del círculo.
2128 let steps = 96;
2129 for i in 0..steps {
2130 if i % 2 != 0 {
2131 continue;
2132 }
2133 let a0 = (i as f32) / (steps as f32) * std::f32::consts::TAU;
2134 let a1 = ((i + 1) as f32) / (steps as f32) * std::f32::consts::TAU;
2135 let x0 = cx + r * a0.cos();
2136 let y0 = cy + r * a0.sin();
2137 let x1 = cx + r * a1.cos();
2138 let y1 = cy + r * a1.sin();
2139 paint_segment(window, x0, y0, x1, y1, pd_color, None, 0.6);
2140 }
2141 }
2142 }
2143 }
2144
2145 // 3. Aspectos. Cada module_id usa su par de radios — natal-natal
2146 // ambos en `aspects`, cross con transit en `bodies → transits`,
2147 // cross con progression en `bodies → progression`.
2148 if show(LayerKind::Aspects) {
2149 let mono = palette.is_monochrome();
2150 for layer in layers {
2151 if matches!(layer.kind, LayerKind::Aspects) {
2152 if let Geometry::Lines(segs) = &layer.geometry {
2153 let (r_from, r_to) = radii.aspect_endpoints(&layer.module_id);
2154 let is_cross = r_from != r_to;
2155 for seg in segs {
2156 // Filtro minors con orbe ancho: los aspectos
2157 // menores (quincunx, semi-square, quintile…)
2158 // solo se trazan si están MUY apretados
2159 // (orbe ≤ 3°). Sobre 3° ensucian sin aportar.
2160 if !is_major_aspect(&seg.kind) && seg.orb_deg.abs() > 3.0 {
2161 continue;
2162 }
2163 let base = aspect_color(palette, &seg.kind);
2164 let base = with_alpha(base, base.a * seg.opacity);
2165 // Hover focus: si hay un planeta hovereado y
2166 // este segmento NO lo toca, lo atenuamos al
2167 // 18%; si lo toca o no hay hover, va pleno.
2168 let touches_hover = hover_focus
2169 .map(|sym| seg.from_body == sym || seg.to_body == sym)
2170 .unwrap_or(true);
2171 let factor = if touches_hover { 1.0 } else { 0.18 };
2172 let color = with_alpha(base, base.a * factor);
2173 let dash = if mono {
2174 dash_pattern_for_kind(&seg.kind)
2175 } else {
2176 None
2177 };
2178 // Width inverso al orbe: orbes cerrados se ven
2179 // gruesos (aspecto "fuerte"), orbes amplios
2180 // finos. Mayores van un escalón más gruesos
2181 // que menores en su mismo orbe.
2182 let width = aspect_width(&seg.kind, seg.orb_deg, mono);
2183 if is_cross {
2184 paint_cross_aspect_line(
2185 window,
2186 cx,
2187 cy,
2188 seg.from_deg,
2189 seg.to_deg,
2190 ascendant_deg,
2191 rot_offset_deg,
2192 r_from,
2193 r_to,
2194 color,
2195 dash,
2196 );
2197 } else {
2198 paint_aspect_line(
2199 window,
2200 cx,
2201 cy,
2202 seg.from_deg,
2203 seg.to_deg,
2204 ascendant_deg,
2205 rot_offset_deg,
2206 r_from,
2207 color,
2208 dash,
2209 width,
2210 );
2211 }
2212 }
2213 }
2214 }
2215 }
2216 }
2217
2218 // 4. Marcadores de posición exacta. Antes el dot era "el planeta";
2219 // ahora el glyph (con halo, en DOM) lo es. El círculo acá queda
2220 // como marker de precisión angular — chico, alpha alta, sobre el
2221 // anillo correspondiente. Glow se mantiene para Sol/Luna como
2222 // toque místico, pero también reducido.
2223 if show(LayerKind::Bodies) {
2224 let dot_r = (radii.sign_outer * 0.009).max(1.5);
2225 for layer in layers {
2226 if matches!(layer.kind, LayerKind::Bodies) {
2227 let ring = radii.body_ring(&layer.module_id);
2228 let is_natal = layer.module_id == "natal";
2229 let alpha = if is_natal { 1.0 } else { 0.85 };
2230 for g in &layer.glyphs {
2231 let color = with_alpha(planet_color(palette, &g.symbol), alpha);
2232 let (x, y) = polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, ring);
2233 if is_natal && (g.symbol == "sun" || g.symbol == "moon") {
2234 paint_glow(window, cx + x, cy + y, dot_r * 1.8, color);
2235 }
2236 fill_circle(window, cx + x, cy + y, dot_r, color);
2237 }
2238 }
2239 }
2240 }
2241
2242 // Anillos guía para los overlays internos (progression, solar_arc).
2243 let guide_inset = radii.sign_outer * 0.03;
2244 for (module_id, ring) in [
2245 ("progression", radii.progression),
2246 ("solar_arc", radii.solar_arc),
2247 ] {
2248 let active = layers
2249 .iter()
2250 .any(|l| matches!(l.kind, LayerKind::Bodies) && l.module_id == module_id);
2251 if active {
2252 stroke_circle(
2253 window,
2254 cx,
2255 cy,
2256 ring + guide_inset,
2257 0.5,
2258 with_alpha(palette.house_cusp, 0.35),
2259 );
2260 stroke_circle(
2261 window,
2262 cx,
2263 cy,
2264 ring - guide_inset,
2265 0.5,
2266 with_alpha(palette.house_cusp, 0.35),
2267 );
2268 }
2269 }
2270
2271 // 5. Outer ring (transit o synastry overlay): anillo guía + dots
2272 // de la capa activa. Son mutuamente excluyentes a nivel de Shell;
2273 // si alguno de los dos está prendido, pintamos el slot.
2274 let outer_active = layers.iter().any(|l| {
2275 matches!(l.kind, LayerKind::Outer)
2276 && OUTER_RING_MODULES.contains(&l.module_id.as_str())
2277 });
2278 if outer_active && show(LayerKind::Outer) {
2279 let band = radii.sign_outer * 0.035;
2280 stroke_circle_3d(
2281 window,
2282 cx,
2283 cy,
2284 radii.transits + band,
2285 0.7,
2286 with_alpha(palette.dial_ring, 0.55),
2287 theme,
2288 );
2289 stroke_circle_3d(
2290 window,
2291 cx,
2292 cy,
2293 radii.transits - band,
2294 0.7,
2295 with_alpha(palette.dial_ring, 0.55),
2296 theme,
2297 );
2298
2299 let dot_r = (radii.sign_outer * 0.008).max(1.5);
2300 for layer in layers {
2301 if matches!(layer.kind, LayerKind::Outer)
2302 && (OUTER_RING_MODULES.contains(&layer.module_id.as_str()))
2303 {
2304 for g in &layer.glyphs {
2305 let color = with_alpha(planet_color(palette, &g.symbol), 0.85);
2306 let (x, y) =
2307 polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, radii.transits);
2308 fill_circle(window, cx + x, cy + y, dot_r, color);
2309 }
2310 }
2311 }
2312 }
2313}
2314
2315fn paint_sign_sectors(
2316 window: &mut Window,
2317 cx: f32,
2318 cy: f32,
2319 radii: &Radii,
2320 palette: &AstroPalette,
2321 ascendant_deg: f32,
2322 rot_offset_deg: f32,
2323) {
2324 const SUBDIVISIONS: usize = 18;
2325 for i in 0..12 {
2326 let lon_start = (i as f32) * 30.0;
2327 let lon_end = lon_start + 30.0;
2328 let element = sign_element_by_index(i);
2329 let color = with_alpha(palette.element(element), 0.10);
2330
2331 let mut builder = PathBuilder::fill();
2332 let (x0, y0) = polar_to_screen(lon_start, ascendant_deg, rot_offset_deg, radii.sign_inner);
2333 builder.move_to(point(px(cx + x0), px(cy + y0)));
2334
2335 for k in 1..=SUBDIVISIONS {
2336 let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32);
2337 let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_inner);
2338 builder.line_to(point(px(cx + x), px(cy + y)));
2339 }
2340 let (xe, ye) = polar_to_screen(lon_end, ascendant_deg, rot_offset_deg, radii.sign_outer);
2341 builder.line_to(point(px(cx + xe), px(cy + ye)));
2342
2343 for k in (0..SUBDIVISIONS).rev() {
2344 let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32);
2345 let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_outer);
2346 builder.line_to(point(px(cx + x), px(cy + y)));
2347 }
2348 builder.close();
2349 if let Ok(path) = builder.build() {
2350 window.paint_path(path, color);
2351 }
2352 }
2353}
2354
2355fn stroke_circle(window: &mut Window, cx: f32, cy: f32, r: f32, width: f32, color: Hsla) {
2356 const SEGMENTS: usize = 96;
2357 let mut builder = PathBuilder::stroke(px(width));
2358 for i in 0..=SEGMENTS {
2359 let t = (i as f32) / (SEGMENTS as f32) * (2.0 * PI);
2360 let x = cx + r * t.cos();
2361 let y = cy + r * t.sin();
2362 if i == 0 {
2363 builder.move_to(point(px(x), px(y)));
2364 } else {
2365 builder.line_to(point(px(x), px(y)));
2366 }
2367 }
2368 if let Ok(path) = builder.build() {
2369 window.paint_path(path, color);
2370 }
2371}
2372
2373/// Pinta 3 halos concéntricos con alpha decreciente alrededor de un
2374/// punto — usado para Sol/Luna natales. El radio crece, la opacidad
2375/// cae: el ojo lo lee como "esto irradia". Sin glow real (GPUI 0.2 no
2376/// tiene radial gradient), pero el shading concéntrico convence.
2377fn paint_glow(window: &mut Window, cx: f32, cy: f32, base_r: f32, color: Hsla) {
2378 const HALOS: [(f32, f32); 3] = [(5.0, 0.05), (3.0, 0.12), (1.8, 0.22)];
2379 for (mult, alpha) in HALOS {
2380 let r = base_r * mult;
2381 let halo = hsla(color.h, color.s, color.l, alpha);
2382 fill_circle(window, cx, cy, r, halo);
2383 }
2384}
2385
2386fn fill_circle(window: &mut Window, cx: f32, cy: f32, r: f32, color: Hsla) {
2387 const SEGMENTS: usize = 32;
2388 let mut builder = PathBuilder::fill();
2389 builder.move_to(point(px(cx + r), px(cy)));
2390 for i in 1..=SEGMENTS {
2391 let t = (i as f32) / (SEGMENTS as f32) * (2.0 * PI);
2392 let x = cx + r * t.cos();
2393 let y = cy + r * t.sin();
2394 builder.line_to(point(px(x), px(y)));
2395 }
2396 builder.close();
2397 if let Ok(path) = builder.build() {
2398 window.paint_path(path, color);
2399 }
2400}
2401
2402#[allow(clippy::too_many_arguments)]
2403fn paint_radial_line(
2404 window: &mut Window,
2405 cx: f32,
2406 cy: f32,
2407 longitude_deg: f32,
2408 ascendant_deg: f32,
2409 rot_offset_deg: f32,
2410 r_inner: f32,
2411 r_outer: f32,
2412 color: Hsla,
2413 width: f32,
2414) {
2415 let (xi, yi) = polar_to_screen(longitude_deg, ascendant_deg, rot_offset_deg, r_inner);
2416 let (xo, yo) = polar_to_screen(longitude_deg, ascendant_deg, rot_offset_deg, r_outer);
2417 let mut builder = PathBuilder::stroke(px(width));
2418 builder.move_to(point(px(cx + xi), px(cy + yi)));
2419 builder.line_to(point(px(cx + xo), px(cy + yo)));
2420 if let Ok(path) = builder.build() {
2421 window.paint_path(path, color);
2422 }
2423}
2424
2425#[allow(clippy::too_many_arguments)]
2426fn paint_aspect_line(
2427 window: &mut Window,
2428 cx: f32,
2429 cy: f32,
2430 a_deg: f32,
2431 b_deg: f32,
2432 ascendant_deg: f32,
2433 rot_offset_deg: f32,
2434 r: f32,
2435 color: Hsla,
2436 dash: Option<(f32, f32)>,
2437 width: f32,
2438) {
2439 let (xa, ya) = polar_to_screen(a_deg, ascendant_deg, rot_offset_deg, r);
2440 let (xb, yb) = polar_to_screen(b_deg, ascendant_deg, rot_offset_deg, r);
2441 paint_segment(window, cx + xa, cy + ya, cx + xb, cy + yb, color, dash, width);
2442}
2443
2444/// Línea de aspecto natal ↔ tránsito: extremos en radios distintos.
2445/// El `from_deg` cae sobre el ring de cuerpos natales (`r_from`); el
2446/// `to_deg` sobre el ring de tránsito (`r_to`). Trazo más fino que el
2447/// natal-natal para no competir visualmente.
2448#[allow(clippy::too_many_arguments)]
2449fn paint_cross_aspect_line(
2450 window: &mut Window,
2451 cx: f32,
2452 cy: f32,
2453 natal_deg: f32,
2454 transit_deg: f32,
2455 ascendant_deg: f32,
2456 rot_offset_deg: f32,
2457 r_from: f32,
2458 r_to: f32,
2459 color: Hsla,
2460 dash: Option<(f32, f32)>,
2461) {
2462 let (xa, ya) = polar_to_screen(natal_deg, ascendant_deg, rot_offset_deg, r_from);
2463 let (xb, yb) = polar_to_screen(transit_deg, ascendant_deg, rot_offset_deg, r_to);
2464 paint_segment(window, cx + xa, cy + ya, cx + xb, cy + yb, color, dash, 0.7);
2465}
2466
2467/// Pinta un segmento entre dos puntos. Si `dash` es `Some((on, off))`,
2468/// itera el vector pintando trechos de `on` px con gaps de `off` px.
2469/// Si `None`, una sola línea continua. Usado por todos los aspect
2470/// painters — el dash pattern es la forma de distinguir kinds en
2471/// el theme BW (donde el color no sirve).
2472fn paint_segment(
2473 window: &mut Window,
2474 x0: f32,
2475 y0: f32,
2476 x1: f32,
2477 y1: f32,
2478 color: Hsla,
2479 dash: Option<(f32, f32)>,
2480 width: f32,
2481) {
2482 let Some((on, off)) = dash else {
2483 let mut b = PathBuilder::stroke(px(width));
2484 b.move_to(point(px(x0), px(y0)));
2485 b.line_to(point(px(x1), px(y1)));
2486 if let Ok(p) = b.build() {
2487 window.paint_path(p, color);
2488 }
2489 return;
2490 };
2491 let dx = x1 - x0;
2492 let dy = y1 - y0;
2493 let len = (dx * dx + dy * dy).sqrt();
2494 if len < 0.1 {
2495 return;
2496 }
2497 let ux = dx / len;
2498 let uy = dy / len;
2499 let step = on + off;
2500 if step < 0.1 {
2501 return;
2502 }
2503 let mut t = 0.0;
2504 while t < len {
2505 let t_end = (t + on).min(len);
2506 let sx = x0 + ux * t;
2507 let sy = y0 + uy * t;
2508 let ex = x0 + ux * t_end;
2509 let ey = y0 + uy * t_end;
2510 let mut b = PathBuilder::stroke(px(width));
2511 b.move_to(point(px(sx), px(sy)));
2512 b.line_to(point(px(ex), px(ey)));
2513 if let Ok(p) = b.build() {
2514 window.paint_path(p, color);
2515 }
2516 t += step;
2517 }
2518}
2519
2520/// `true` para los 5 aspectos Ptoloméicos (conjunction, sextile,
2521/// square, trine, opposition). Cualquier otro `kind` se considera
2522/// menor — quincunx, semi-square, quintile, sesquiquadrate, etc.
2523fn is_major_aspect(kind: &str) -> bool {
2524 matches!(
2525 kind,
2526 "conjunction" | "sextile" | "square" | "trine" | "opposition"
2527 )
2528}
2529
2530/// Grosor de línea de aspecto inverso al orbe. La idea: a orbe 0°
2531/// (aspecto exacto) la línea va gruesa porque "pesa" más; a orbe
2532/// amplio se afina. Los mayores arrancan en un techo más alto que
2533/// los menores. En BW se le suma un poquito a todos porque las
2534/// líneas competen con sus dash patterns.
2535fn aspect_width(kind: &str, orb_deg: f32, mono: bool) -> f32 {
2536 let orb = orb_deg.abs();
2537 let major = is_major_aspect(kind);
2538 // Orbe de referencia para normalizar: ~8° para mayores, ~3° para
2539 // menores. Más allá la línea ya está afinada al mínimo.
2540 let max_orb = if major { 8.0 } else { 3.0 };
2541 let t = (1.0 - (orb / max_orb)).clamp(0.0, 1.0);
2542 let (min_w, max_w) = if major { (0.7, 2.1) } else { (0.5, 1.2) };
2543 let w = min_w + (max_w - min_w) * t;
2544 if mono { w + 0.2 } else { w }
2545}
2546
2547/// Dash pattern por aspecto, para modo monocromático. En modo color
2548/// el caller pasa `None` y las líneas van sólidas. Patterns elegidos
2549/// para que cada kind sea distinguible a ojo:
2550/// - conjunction/opposition: sólido (más peso visual, son los
2551/// aspectos "fuertes")
2552/// - square: dash medio (4 on / 3 off)
2553/// - trine: dash largo (8 on / 2 off) — casi sólido pero distinguible
2554/// - sextile: dotted (1.5 on / 3 off)
2555/// - minor: dotted finísimo (1 on / 4 off)
2556fn dash_pattern_for_kind(kind: &str) -> Option<(f32, f32)> {
2557 match kind {
2558 "conjunction" | "opposition" => None,
2559 "square" => Some((4.0, 3.0)),
2560 "trine" => Some((8.0, 2.0)),
2561 "sextile" => Some((1.5, 3.0)),
2562 _ => Some((1.0, 4.0)),
2563 }
2564}
2565
2566// =====================================================================
2567// Helpers
2568// =====================================================================
2569
2570/// Distancia mínima entre un punto y un segmento de recta. Usado por
2571/// hover_check para detectar proximity a líneas de aspectos.
2572fn dist_point_segment(px: f32, py: f32, ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
2573 let dx = bx - ax;
2574 let dy = by - ay;
2575 let len_sq = dx * dx + dy * dy;
2576 if len_sq < f32::EPSILON {
2577 // Segmento degenerado → distancia al punto a.
2578 let pdx = px - ax;
2579 let pdy = py - ay;
2580 return (pdx * pdx + pdy * pdy).sqrt();
2581 }
2582 let t = (((px - ax) * dx + (py - ay) * dy) / len_sq).clamp(0.0, 1.0);
2583 let proj_x = ax + t * dx;
2584 let proj_y = ay + t * dy;
2585 let dx2 = px - proj_x;
2586 let dy2 = py - proj_y;
2587 (dx2 * dx2 + dy2 * dy2).sqrt()
2588}
2589
2590fn polar_to_screen(
2591 longitude_deg: f32,
2592 ascendant_deg: f32,
2593 rot_offset_deg: f32,
2594 radius: f32,
2595) -> (f32, f32) {
2596 let deg = 180.0 - (longitude_deg - ascendant_deg + rot_offset_deg);
2597 let rad = deg * PI / 180.0;
2598 (radius * rad.cos(), radius * rad.sin())
2599}
2600
2601fn centered_glyph(
2602 x: f32,
2603 y: f32,
2604 box_size: f32,
2605 font_size: f32,
2606 text: SharedString,
2607 color: Hsla,
2608) -> gpui::Div {
2609 div()
2610 .absolute()
2611 .left(px(x - box_size / 2.0))
2612 .top(px(y - box_size / 2.0))
2613 .w(px(box_size))
2614 .h(px(box_size))
2615 .flex()
2616 .items_center()
2617 .justify_center()
2618 .text_size(px(font_size))
2619 .text_color(color)
2620 .child(text)
2621}
2622
2623/// Glyph de planeta con disco-halo detrás del char. El disco viene en
2624/// `disk_bg` (semi-opaco para que se vea a través el fondo del wheel)
2625/// y `disk_border` (típicamente el color del planeta). El char por
2626/// dentro va en `text_color` — recomendado el color del planeta sobre
2627/// disco neutro, o color contrastante sobre disco coloreado.
2628fn planet_glyph(
2629 x: f32,
2630 y: f32,
2631 disk_size: f32,
2632 font_size: f32,
2633 text: SharedString,
2634 text_color: Hsla,
2635 disk_bg: Hsla,
2636 disk_border: Hsla,
2637) -> gpui::Div {
2638 div()
2639 .absolute()
2640 .left(px(x - disk_size / 2.0))
2641 .top(px(y - disk_size / 2.0))
2642 .w(px(disk_size))
2643 .h(px(disk_size))
2644 .rounded_full()
2645 .bg(disk_bg)
2646 .border_1()
2647 .border_color(disk_border)
2648 .flex()
2649 .items_center()
2650 .justify_center()
2651 .text_size(px(font_size))
2652 .text_color(text_color)
2653 .child(text)
2654}
2655
2656/// Disco base (px) de un body glyph según `module_id` y kind. Lo
2657/// usan render_wheel (para pintar) y on_hover_check (para
2658/// hit-testear) — ambos deben coincidir o el hover apunta a una
2659/// posición distinta a donde se pinta el disco.
2660fn body_disk_base(module_id: &str, kind: LayerKind, view_scale: f32) -> f32 {
2661 let base = match kind {
2662 LayerKind::Outer => 20.0,
2663 LayerKind::Midpoints => 16.0,
2664 _ => match module_id {
2665 "natal" => 26.0,
2666 "topocentric" => 22.0,
2667 "pd_direct" | "pd_converse" => 20.0,
2668 _ => 22.0,
2669 },
2670 };
2671 base * view_scale
2672}
2673
2674/// Reposiciona angularmente un conjunto de longitudes para que pares
2675/// adyacentes mantengan al menos `min_sep_deg` de separación, **sin
2676/// que ningún glyph se aleje más de `max_shift_deg` de su posición
2677/// real**. La acotación es clave para evitar que un cluster denso
2678/// "empuje" a planetas que estaban lejos.
2679///
2680/// Algoritmo: iteramos hasta 60 veces; en cada pasada re-ordenamos
2681/// los displays para mantener el orden circular, y en cada par
2682/// adyacente que esté muy cerca empujamos los dos extremos en
2683/// sentidos opuestos. Tras cada empuje clampeamos `displays[i]` al
2684/// rango `[raw[i] - max_shift, raw[i] + max_shift]` (circular).
2685/// Si el cluster es tan denso que el clamp impide alcanzar el
2686/// `min_sep`, el residual queda alto y el caller encoge los discos.
2687///
2688/// Devuelve `(displays, residual)` con `residual ∈ [0, 1]` =
2689/// fracción de presión no resuelta tras el clamp.
2690fn spread_angles(angles_deg: &[f32], min_sep_deg: f32, max_shift_deg: f32) -> (Vec<f32>, f32) {
2691 let n = angles_deg.len();
2692 if n <= 1 {
2693 return (angles_deg.to_vec(), 0.0);
2694 }
2695 if (n as f32) * min_sep_deg >= 360.0 {
2696 return (angles_deg.to_vec(), 1.0);
2697 }
2698 let raw: Vec<f32> = angles_deg.iter().map(|a| a.rem_euclid(360.0)).collect();
2699 let mut displays: Vec<f32> = raw.clone();
2700 let mut last_residual = 0.0_f32;
2701
2702 // Clamp circular: ajusta `display` a estar dentro de
2703 // `raw ± max_shift_deg`, midiendo distancia mínima circular.
2704 let clamp_to_raw = |display: f32, raw: f32, max_shift: f32| -> f32 {
2705 let mut delta = display - raw;
2706 if delta > 180.0 {
2707 delta -= 360.0;
2708 }
2709 if delta < -180.0 {
2710 delta += 360.0;
2711 }
2712 let clamped = delta.clamp(-max_shift, max_shift);
2713 (raw + clamped).rem_euclid(360.0)
2714 };
2715
2716 // Loop tipo "physics": acumulamos fuerzas sobre TODOS los pares
2717 // adyacentes en una pasada, luego aplicamos un step con damping.
2718 // Esto evita las oscilaciones del empuje inmediato (que reordenaba
2719 // los displays a mitad de la pasada y nunca convergía).
2720 let damping: f32 = 0.6;
2721 for _ in 0..80 {
2722 let mut order: Vec<usize> = (0..n).collect();
2723 order.sort_by(|&a, &b| {
2724 displays[a]
2725 .partial_cmp(&displays[b])
2726 .unwrap_or(std::cmp::Ordering::Equal)
2727 });
2728 let mut forces = vec![0.0_f32; n];
2729 let mut max_residual: f32 = 0.0;
2730 for k in 0..n {
2731 let i = order[k];
2732 let j = order[(k + 1) % n];
2733 let diff = (displays[j] - displays[i]).rem_euclid(360.0);
2734 if diff < min_sep_deg {
2735 let push = (min_sep_deg - diff) / 2.0;
2736 forces[i] -= push;
2737 forces[j] += push;
2738 let r = (min_sep_deg - diff) / min_sep_deg;
2739 if r > max_residual {
2740 max_residual = r;
2741 }
2742 }
2743 }
2744 // Aplicar fuerzas con damping + clamp al rango ±max_shift.
2745 for i in 0..n {
2746 let stepped = (displays[i] + forces[i] * damping).rem_euclid(360.0);
2747 displays[i] = clamp_to_raw(stepped, raw[i], max_shift_deg);
2748 }
2749 last_residual = max_residual;
2750 if max_residual < 0.001 {
2751 break;
2752 }
2753 }
2754
2755 (displays, last_residual)
2756}
2757
2758/// Detecta clusters de longitudes angularmente cercanas. Dos
2759/// elementos están en el mismo cluster si su separación circular es
2760/// menor a `threshold_deg`. Devuelve los índices originales
2761/// agrupados; cada Vec interno representa un cluster (incluso si
2762/// es de tamaño 1). Cluster con wrap-around (último→primero) se
2763/// fusionan correctamente.
2764fn find_clusters(angles_deg: &[f32], threshold_deg: f32) -> Vec<Vec<usize>> {
2765 let n = angles_deg.len();
2766 if n == 0 {
2767 return Vec::new();
2768 }
2769 let mut idxed: Vec<(usize, f32)> = angles_deg
2770 .iter()
2771 .copied()
2772 .map(|a| a.rem_euclid(360.0))
2773 .enumerate()
2774 .collect();
2775 idxed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
2776 let mut clusters: Vec<Vec<usize>> = Vec::new();
2777 let mut cur: Vec<usize> = vec![idxed[0].0];
2778 let mut last = idxed[0].1;
2779 for (idx, a) in idxed.iter().skip(1).copied() {
2780 if (a - last) < threshold_deg {
2781 cur.push(idx);
2782 } else {
2783 clusters.push(std::mem::take(&mut cur));
2784 cur.push(idx);
2785 }
2786 last = a;
2787 }
2788 clusters.push(cur);
2789 // Wrap-around: si el último cluster y el primero "tocan" a través
2790 // de 0° (la diferencia circular < threshold), mergearlos.
2791 if clusters.len() >= 2 {
2792 let first_a = angles_deg[clusters[0][0]].rem_euclid(360.0);
2793 let last_a = angles_deg[*clusters.last().unwrap().last().unwrap()].rem_euclid(360.0);
2794 let wrap_diff = 360.0 - last_a + first_a;
2795 if wrap_diff < threshold_deg {
2796 let mut tail = clusters.pop().unwrap();
2797 tail.extend(clusters[0].iter().copied());
2798 clusters[0] = tail;
2799 }
2800 }
2801 clusters
2802}
2803
2804/// Formato compacto con precisión de minutos: "DD°MM'{signo}" donde
2805/// el signo es el glyph zodiacal (♈♉♊…). Ej: 14.93° → "14°56'♈".
2806/// Los minutos se redondean al entero más cercano; si el redondeo
2807/// excede 60, bumpea el grado y mantiene la representación canónica
2808/// (29°60' → 30°00', que a su vez = 0° del signo siguiente — lo
2809/// recalculamos para evitar mostrar "30°00'♈" en vez de "0°00'♉").
2810fn format_coord_compact(deg: f32) -> String {
2811 let normalized = deg.rem_euclid(360.0);
2812 let total_minutes = (normalized * 60.0).round() as i64;
2813 // Carry-overs: 60' → siguiente grado; 30° → siguiente signo (eso
2814 // ya está cubierto porque el total_minutes refleja la posición
2815 // ABSOLUTA y volvemos a derivar sign + minutos del entero limpio).
2816 let total_minutes = total_minutes.rem_euclid(360 * 60);
2817 let sign_idx = (total_minutes / (30 * 60)) as usize % 12;
2818 let within_sign = total_minutes - (sign_idx as i64) * 30 * 60;
2819 let deg_int = (within_sign / 60) as i32;
2820 let minutes = (within_sign % 60) as i32;
2821 let sign_glyph = match sign_idx {
2822 0 => "♈",
2823 1 => "♉",
2824 2 => "♊",
2825 3 => "♋",
2826 4 => "♌",
2827 5 => "♍",
2828 6 => "♎",
2829 7 => "♏",
2830 8 => "♐",
2831 9 => "♑",
2832 10 => "♒",
2833 _ => "♓",
2834 };
2835 format!("{}°{:02}'{}", deg_int, minutes, sign_glyph)
2836}
2837
2838#[cfg(test)]
2839mod spread_tests {
2840 use super::spread_angles;
2841
2842 fn assert_min_sep(displays: &[f32], min_sep: f32) {
2843 let n = displays.len();
2844 let mut sorted = displays.to_vec();
2845 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
2846 // Tolerancia: el algoritmo converge dentro del 1% del min_sep.
2847 let tol = min_sep * 0.02;
2848 for i in 0..n {
2849 let nxt = (i + 1) % n;
2850 let diff = (sorted[nxt] - sorted[i]).rem_euclid(360.0);
2851 assert!(
2852 diff + tol >= min_sep,
2853 "vecinos {} y {} a {}° (mínimo {})",
2854 sorted[i],
2855 sorted[nxt],
2856 diff,
2857 min_sep
2858 );
2859 }
2860 }
2861
2862 #[test]
2863 fn empty_and_single_unchanged() {
2864 let (r, residual) = spread_angles(&[], 10.0, 30.0);
2865 assert!(r.is_empty());
2866 assert_eq!(residual, 0.0);
2867 let (r, residual) = spread_angles(&[42.0], 10.0, 30.0);
2868 assert_eq!(r, vec![42.0]);
2869 assert_eq!(residual, 0.0);
2870 }
2871
2872 #[test]
2873 fn spaced_input_left_alone() {
2874 let input = vec![0.0, 30.0, 90.0, 200.0];
2875 let (out, residual) = spread_angles(&input, 10.0, 30.0);
2876 assert!(residual < 0.001);
2877 for (a, b) in input.iter().zip(out.iter()) {
2878 assert!((a - b).abs() < 1e-3, "{} vs {}", a, b);
2879 }
2880 }
2881
2882 #[test]
2883 fn tight_cluster_gets_spread() {
2884 // 3 planetas a 1° con min_sep=10, max_shift=30 — caben.
2885 let input = vec![100.0, 101.0, 102.0];
2886 let (out, residual) = spread_angles(&input, 10.0, 30.0);
2887 assert!(residual < 0.05, "residual {}", residual);
2888 assert_min_sep(&out, 10.0);
2889 }
2890
2891 #[test]
2892 fn shift_is_bounded() {
2893 // Si max_shift=2°, los planetas no pueden alejarse más allá.
2894 let input = vec![100.0, 101.0];
2895 let (out, _) = spread_angles(&input, 10.0, 2.0);
2896 for (raw, disp) in input.iter().zip(out.iter()) {
2897 let mut delta = (disp - raw).abs();
2898 if delta > 180.0 {
2899 delta = 360.0 - delta;
2900 }
2901 assert!(delta <= 2.0 + 0.01, "shift {} > 2°", delta);
2902 }
2903 }
2904
2905 #[test]
2906 fn distant_planet_unaffected_by_dense_cluster() {
2907 // Cluster denso en 100-101° + planeta solo en 200°. El de 200°
2908 // debe quedarse cerca de 200° con max_shift=10°.
2909 let input = vec![100.0, 100.5, 101.0, 200.0];
2910 let (out, _) = spread_angles(&input, 10.0, 10.0);
2911 let mut delta = (out[3] - 200.0).abs();
2912 if delta > 180.0 {
2913 delta = 360.0 - delta;
2914 }
2915 assert!(delta < 5.0, "planeta lejano se movió {}°", delta);
2916 }
2917
2918 #[test]
2919 fn unfeasible_cluster_reports_max_residual() {
2920 let input: Vec<f32> = (0..40).map(|_| 0.0).collect();
2921 let (_out, residual) = spread_angles(&input, 10.0, 30.0);
2922 assert!((residual - 1.0).abs() < 0.01);
2923 }
2924}
2925
2926#[cfg(test)]
2927mod coord_tests {
2928 use super::format_coord_compact;
2929
2930 #[test]
2931 fn zero_aries() {
2932 assert_eq!(format_coord_compact(0.0), "0°00'♈");
2933 }
2934
2935 #[test]
2936 fn fourteen_fiftysix_aries() {
2937 // 14.9333° = 14° 56'
2938 assert_eq!(format_coord_compact(14.933_3), "14°56'♈");
2939 }
2940
2941 #[test]
2942 fn rollover_to_taurus() {
2943 // 29.9995° debería redondear a 30° y caer en 0°00'♉.
2944 assert_eq!(format_coord_compact(29.9995), "0°00'♉");
2945 }
2946
2947 #[test]
2948 fn capricorn_anchor() {
2949 // 270° = inicio de Capricornio.
2950 assert_eq!(format_coord_compact(270.0), "0°00'♑");
2951 }
2952
2953 #[test]
2954 fn negative_wraps() {
2955 // -10° = 350° = 20°00'♓.
2956 assert_eq!(format_coord_compact(-10.0), "20°00'♓");
2957 }
2958}
2959
2960/// Pill pequeña con un coord ("14°♈") junto al glyph de un planeta
2961/// o cusp. Fondo halo + texto fg_muted, padding mínimo para no
2962/// saturar la rueda con etiquetas grandes.
2963fn coord_label(
2964 x: f32,
2965 y: f32,
2966 text: SharedString,
2967 fg: Hsla,
2968 halo_bg: Hsla,
2969 font_size: f32,
2970) -> gpui::Div {
2971 // Estimación del ancho basada en `chars().count()` (NO `text.len()`
2972 // — los chars unicode astronómicos cuentan 3 bytes pero ocupan
2973 // ~1 columna de fuente). Padding lateral muy pequeño en lugar de
2974 // un mínimo grande: pills con 1-3 chars no llevan "espacios en
2975 // negro" que sobrescriben elementos vecinos.
2976 let char_count = text.chars().count() as f32;
2977 let w = (char_count * font_size * 0.62 + font_size * 0.5).max(font_size * 1.4);
2978 let h = font_size + 5.0;
2979 div()
2980 .absolute()
2981 .left(px(x - w / 2.0))
2982 .top(px(y - h / 2.0))
2983 .w(px(w))
2984 .h(px(h))
2985 .flex()
2986 .items_center()
2987 .justify_center()
2988 .rounded(px(h / 2.0))
2989 .bg(halo_bg)
2990 .text_size(px(font_size))
2991 .text_color(fg)
2992 .child(text)
2993}
2994
2995/// Color HSL semi-opaco para los halos de los glyphs — derivado del
2996/// theme. En dark va casi negro; en light casi blanco. Alpha alta para
2997/// que el char quede legible contra cualquier cosa que haya detrás
2998/// (anillo, líneas de aspecto, starfield).
2999fn glyph_halo(theme: &Theme) -> Hsla {
3000 if theme.is_dark {
3001 hsla(0.0, 0.0, 0.07, 0.92)
3002 } else {
3003 hsla(0.0, 0.0, 0.97, 0.92)
3004 }
3005}
3006
3007fn with_alpha(c: Hsla, a: f32) -> Hsla {
3008 hsla(c.h, c.s, c.l, a.clamp(0.0, 1.0))
3009}
3010
3011/// Devuelve `c` con la luminancia modificada por `delta` (clamp 0..1).
3012/// Útil para derivar highlight (+luma) y shadow (-luma) de un color
3013/// base manteniendo hue y saturación — efecto bevel/3D barato.
3014fn adjust_luma(c: Hsla, delta: f32) -> Hsla {
3015 hsla(c.h, c.s, (c.l + delta).clamp(0.0, 1.0), c.a)
3016}
3017
3018/// Devuelve `c` con el hue desplazado `delta_deg` grados sobre el
3019/// círculo cromático (wrap a [0,1] en la escala normalizada de gpui).
3020/// Usado para derivar el color del anillo de casas desde el del dial
3021/// zodiacal — los dos sistemas (eclíptica vs ascensional) deben
3022/// distinguirse a primera vista pero compartir "familia" cromática.
3023fn shift_hue(c: Hsla, delta_deg: f32) -> Hsla {
3024 let new_h = (c.h + delta_deg / 360.0).rem_euclid(1.0);
3025 hsla(new_h, c.s, c.l, c.a)
3026}
3027
3028/// Color para los anillos del sistema de casas (ascensional). En
3029/// paletas con color, lo derivamos de `house_cusp` con un hue shift
3030/// de ~140° para diferenciar de la eclíptica (que va con el dorado
3031/// de `dial_ring`). En BW devolvemos `house_cusp` tal cual — un
3032/// shift cromático en monocromo es ruido sin información.
3033fn house_ring_color(palette: &AstroPalette) -> Hsla {
3034 if palette.is_monochrome() {
3035 palette.house_cusp
3036 } else {
3037 shift_hue(palette.house_cusp, 140.0)
3038 }
3039}
3040
3041/// Stroke con efecto embossed: 3 trazos concéntricos. El highlight va
3042/// 0.7 px hacia el centro con luminancia subida; el principal en `r`;
3043/// el shadow 0.7 px hacia afuera con luminancia bajada. La dirección
3044/// del bevel depende del theme: en dark el highlight es exterior (luz
3045/// "desde arriba"), en light interior (sombra "desde arriba" hacia
3046/// el centro).
3047fn stroke_circle_3d(
3048 window: &mut Window,
3049 cx: f32,
3050 cy: f32,
3051 r: f32,
3052 width: f32,
3053 color: Hsla,
3054 theme: &Theme,
3055) {
3056 let (hl_offset, sh_offset) = if theme.is_dark {
3057 (-0.7, 0.7)
3058 } else {
3059 (0.7, -0.7)
3060 };
3061 let hl = with_alpha(adjust_luma(color, 0.20), color.a * 0.55);
3062 let sh = with_alpha(adjust_luma(color, -0.18), color.a * 0.55);
3063 stroke_circle(window, cx, cy, r + hl_offset, (width * 0.7).max(0.4), hl);
3064 stroke_circle(window, cx, cy, r, width, color);
3065 stroke_circle(window, cx, cy, r + sh_offset, (width * 0.7).max(0.4), sh);
3066}
3067
3068/// Bevel central del anillo de signos: ~10 strokes finos entre
3069/// sign_inner y sign_outer, con alpha en bell curve (máximo en el
3070/// medio, decae hacia los bordes). Genera la sensación de volumen
3071/// sin pintar gradient radial (no soportado en gpui canvas).
3072fn paint_dial_bevel(
3073 window: &mut Window,
3074 cx: f32,
3075 cy: f32,
3076 radii: &Radii,
3077 palette: &AstroPalette,
3078 theme: &Theme,
3079) {
3080 let steps = 10;
3081 let base = if theme.is_dark { 0.07 } else { 0.10 };
3082 let color = palette.dial_ring;
3083 for i in 0..steps {
3084 let t = (i as f32 + 0.5) / steps as f32;
3085 let r = radii.sign_inner + (radii.sign_outer - radii.sign_inner) * t;
3086 // Bell curve simétrica: |t-0.5|*2 da 0..1 desde el centro, lo
3087 // invertimos para que el centro tenga peso máximo.
3088 let bell = 1.0 - ((t - 0.5).abs() * 2.0);
3089 let a = base * bell;
3090 stroke_circle(window, cx, cy, r, 1.0, with_alpha(color, a));
3091 }
3092}
3093
3094fn sign_unicode(name: &str) -> &'static str {
3095 match name {
3096 "aries" => "♈",
3097 "taurus" => "♉",
3098 "gemini" => "♊",
3099 "cancer" => "♋",
3100 "leo" => "♌",
3101 "virgo" => "♍",
3102 "libra" => "♎",
3103 "scorpio" => "♏",
3104 "sagittarius" => "♐",
3105 "capricorn" => "♑",
3106 "aquarius" => "♒",
3107 "pisces" => "♓",
3108 _ => "?",
3109 }
3110}
3111
3112const SIGN_NAMES_ES: [&str; 12] = [
3113 "Aries",
3114 "Tauro",
3115 "Géminis",
3116 "Cáncer",
3117 "Leo",
3118 "Virgo",
3119 "Libra",
3120 "Escorpio",
3121 "Sagitario",
3122 "Capricornio",
3123 "Acuario",
3124 "Piscis",
3125];
3126
3127fn aspect_unicode(kind: &str) -> &'static str {
3128 match kind {
3129 "conjunction" => "☌",
3130 "opposition" => "☍",
3131 "trine" => "△",
3132 "square" => "□",
3133 "sextile" => "⚹",
3134 "quincunx" => "⚻",
3135 "semi_sextile" => "⚺",
3136 "semi_square" => "∠",
3137 "sesquiquadrate" => "⚼",
3138 "quintile" => "Q",
3139 "biquintile" => "bQ",
3140 _ => "·",
3141 }
3142}
3143
3144fn planet_unicode(name: &str) -> &'static str {
3145 match name {
3146 "sun" => "☉",
3147 "moon" => "☽",
3148 "mercury" => "☿",
3149 "venus" => "♀",
3150 "mars" => "♂",
3151 "jupiter" => "♃",
3152 "saturn" => "♄",
3153 "uranus" => "♅",
3154 "neptune" => "♆",
3155 "pluto" => "♇",
3156 "north_node" => "☊",
3157 "south_node" => "☋",
3158 "chiron" => "⚷",
3159 "lilith" => "⚸",
3160 "ceres" => "⚳",
3161 "pallas" => "⚴",
3162 "juno" => "⚵",
3163 "vesta" => "⚶",
3164 _ => "•",
3165 }
3166}
3167
3168fn planet_color(p: &AstroPalette, name: &str) -> Hsla {
3169 let planet = match name {
3170 "sun" => Planet::Sun,
3171 "moon" => Planet::Moon,
3172 "mercury" => Planet::Mercury,
3173 "venus" => Planet::Venus,
3174 "mars" => Planet::Mars,
3175 "jupiter" => Planet::Jupiter,
3176 "saturn" => Planet::Saturn,
3177 "uranus" => Planet::Uranus,
3178 "neptune" => Planet::Neptune,
3179 "pluto" => Planet::Pluto,
3180 "chiron" => Planet::Chiron,
3181 "north_node" => Planet::NorthNode,
3182 "south_node" => Planet::SouthNode,
3183 "lilith" => Planet::Lilith,
3184 _ => return p.fg_text_fallback(),
3185 };
3186 p.planet(planet)
3187}
3188
3189fn sign_element_by_index(i: usize) -> Element {
3190 match i % 4 {
3191 0 => Element::Fire,
3192 1 => Element::Earth,
3193 2 => Element::Air,
3194 _ => Element::Water,
3195 }
3196}
3197
3198fn element_color_for_sign(p: &AstroPalette, name: &str) -> Hsla {
3199 let elem = match name {
3200 "aries" | "leo" | "sagittarius" => Element::Fire,
3201 "taurus" | "virgo" | "capricorn" => Element::Earth,
3202 "gemini" | "libra" | "aquarius" => Element::Air,
3203 "cancer" | "scorpio" | "pisces" => Element::Water,
3204 _ => return p.fg_text_fallback(),
3205 };
3206 p.element(elem)
3207}
3208
3209fn aspect_color(p: &AstroPalette, kind: &str) -> Hsla {
3210 let k = match kind {
3211 "conjunction" => TAspectKind::Conjunction,
3212 "opposition" => TAspectKind::Opposition,
3213 "trine" => TAspectKind::Trine,
3214 "square" => TAspectKind::Square,
3215 "sextile" => TAspectKind::Sextile,
3216 "quincunx" => TAspectKind::Quincunx,
3217 "semi_sextile" => TAspectKind::Semisextile,
3218 "semi_square" => TAspectKind::Semisquare,
3219 "sesquiquadrate" => TAspectKind::Sesquisquare,
3220 "quintile" => TAspectKind::Quintile,
3221 "biquintile" => TAspectKind::Biquintile,
3222 _ => return p.minor_aspect,
3223 };
3224 p.aspect(k)
3225}
3226
3227trait AstroPaletteExt {
3228 fn fg_text_fallback(&self) -> Hsla;
3229}
3230
3231impl AstroPaletteExt for AstroPalette {
3232 fn fg_text_fallback(&self) -> Hsla {
3233 if self.is_dark {
3234 hsla(0.0, 0.0, 0.85, 1.0)
3235 } else {
3236 hsla(0.0, 0.0, 0.25, 1.0)
3237 }
3238 }
3239}