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