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