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