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