Files
brahman/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
T
sergio 9084cf4b79 refactor(tahuantinsuyu): extrae tahuantinsuyu-render — preparación para WASM
Fase 1 de "módulo web": extracción del modelo y la matemática
agnóstica de surface a un crate separado, sin dependencia de
gpui ni de eternal. Es la base sobre la que el cliente WASM y
el canvas nativo van a converger.

Crate nuevo `tahuantinsuyu-render`:
- Tipos del RenderModel migrados desde `tahuantinsuyu-engine`:
  `RenderModel`, `Layer`, `LayerKind`, `Geometry`, `LineSeg`,
  `PointMark`, `Glyph`, `OverlayMeta`, `UranianGroup`,
  `AspectSummary`, `OUTER_RING_MODULES`. El engine los
  reexporta — ningún call site del shell/canvas/modules/tree/
  panel cambia su `use`.
- Módulo `math` con la geometría canónica del wheel migrada
  desde `tahuantinsuyu-canvas`:
  * `Radii` con los aros A/B/C/D/E + helpers `body_ring` y
    `aspect_endpoints`
  * `polar_to_screen` (Asc a las 9 del reloj)
  * `spread_angles` (anti-solapamiento con damping + clamp por
    glyph)
  * `find_clusters` (con wrap-around)
  * `format_coord_compact` ("DD°MM'{signo}")
- 10 tests del math (5 spread + 4 coord + 1 polar) viajaron con
  las implementaciones. El canvas se queda solo con los tests
  de UI.

Por qué un crate aparte:
- `tahuantinsuyu-engine` arrastra `eternal-sky` (VSOP2013 +
  I/O de tablas) que NO compila a WASM sin empaquetar 30+ MB
  de efemérides. Los tipos del modelo son serde puro y sí
  compilan a WASM — extraerlos libera al cliente web futuro
  de la dependencia transitiva.
- Cuando llegue la fase 2 (`tahuantinsuyu-server` axum) y la
  fase 3 (`tahuantinsuyu-web` cdylib WASM), ambos consumen
  `tahuantinsuyu-render` con la misma fuente de verdad sobre
  el layout, evitando duplicar la lógica entre desktop y web.

Pendiente: `tahuantinsuyu-model` arrastra `uuid → getrandom`
que falla a WASM sin `wasm_js` feature flag. Lo resuelvo en la
fase del cliente WASM (necesita su propio Cargo.toml con la
config getrandom + .cargo/config con RUSTFLAGS).

Tests: 20 verdes (10 shell + 10 render math). Compilación
nativa OK; canvas sin cambios visuales (mismo código,
diferente origen).
2026-05-19 00:33:39 +00:00

2851 lines
106 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `tahuantinsuyu-canvas` — el widget GPUI del lienzo astrológico.
//!
//! Modela el cielo como un lienzo de **geometría reactiva**: un estado
//! unificado [`CanvasState`] guarda offsets de rotación, flags de
//! visibilidad y la lista de `Layer`s a pintar. Las interacciones
//! (drag, hotkeys, toggles) mutan el estado; el render lee la última
//! `RenderModel` y la deriva al frame.
//!
//! ## Convención de rotación
//!
//! El Ascendente cae a las 9 del reloj (lado izquierdo). Las casas
//! crecen contrarreloj visualmente. Para una longitud eclíptica `L` y
//! un ascendente `asc`:
//!
//! ```text
//! screen_angle_rad = π - (L - asc + view_rotation) · π/180
//! point = (cx + r·cos(θ), cy + r·sin(θ))
//! ```
//!
//! ## Interacciones (fase 4)
//!
//! - **Drag en el aro exterior** (jog-dial perimetral): rota la rueda
//! visualmente mientras dura el drag y, al soltar, traduce el delta
//! angular a minutos (1° ≈ 4 min) y emite
//! [`CanvasEvent::TimeOffsetChanged`]. El host (la app) recomputa la
//! carta para el instante desplazado.
//! - **Hotkeys**: `D`/`H`/`X`/`P` togglean SignDial/Houses/Aspects/
//! Bodies. Click sobre el wheel le da focus al widget.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use std::collections::HashMap;
use std::f32::consts::PI;
use gpui::{
Bounds, Context, EventEmitter, FocusHandle, Focusable, Hsla, IntoElement,
KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement,
PathBuilder, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, SharedString, Styled,
Window, canvas, div, hsla, point, prelude::*, px,
};
use tahuantinsuyu_engine::{Geometry, Layer, LayerKind, OUTER_RING_MODULES, RenderModel};
use tahuantinsuyu_model::{ChartId, ContactId, GroupId};
use tahuantinsuyu_theme::{AspectKind as TAspectKind, AstroPalette, Element, Planet};
use yahweh_theme::Theme;
// =====================================================================
// Eventos
// =====================================================================
#[derive(Clone, Debug)]
pub enum CanvasEvent {
/// Doble click sobre un thumbnail.
ChartRequested(ChartId),
/// Drag terminado: el offset acumulado de tiempo (en minutos)
/// cambió. El host debe recomputar el chart con este offset.
TimeOffsetChanged(i64),
/// El usuario togggleó una capa via hotkey — el panel debería
/// reflejarlo si quisiera mantenerse en sync.
LayerVisibilityChanged { kind: LayerKind, visible: bool },
/// El usuario togggleó los coord labels via hotkey C. El panel
/// debe sincronizar el toggle "show_coords" del NatalModule.
ShowCoordsChanged(bool),
/// El usuario pidió exportar el render actual como SVG. El shell
/// se encarga de escribir el archivo (la engine genera el string).
ExportSvgRequested,
}
// =====================================================================
// Estado
// =====================================================================
#[derive(Clone, Debug, Default)]
pub enum CanvasMode {
#[default]
Empty,
Wheel { render: Box<RenderModel> },
Thumbnails {
scope: ThumbnailScope,
items: Vec<ThumbnailItem>,
},
}
#[derive(Clone, Debug)]
pub enum ThumbnailScope {
Group(GroupId),
Contact(ContactId),
}
#[derive(Clone, Debug)]
pub struct ThumbnailItem {
pub chart_id: ChartId,
pub label: SharedString,
pub subtitle: Option<SharedString>,
pub preview: Option<RenderModel>,
}
/// Estado de un drag activo del jog-dial. `last_screen_angle_deg` se
/// actualiza en cada `MouseMoveEvent`; `accumulated_delta_deg` lleva la
/// rotación total desde que arrancó el drag (puede pasar de ±360°).
#[derive(Clone, Debug)]
struct JogDragState {
last_screen_angle_deg: f32,
accumulated_delta_deg: f32,
}
/// Drag activo de pan (MMB o LMB con Space). Captura el pan inicial al
/// hacer mousedown; el move agrega delta_pos a esos valores.
#[derive(Clone, Debug)]
struct PanDragState {
start_pos: Point<Pixels>,
pan_x_start: f32,
pan_y_start: f32,
}
#[derive(Clone, Debug)]
pub struct CanvasState {
pub mode: CanvasMode,
/// Rotación visual transitoria durante un drag. Se resetea a `0` al
/// soltar — el render nuevo trae el `ascendant_deg` ya rotado.
pub view_rotation_deg: f32,
/// Offset acumulado en minutos. Persiste entre drags hasta que el
/// host lo resetee.
pub time_offset_minutes: i64,
/// Factor de zoom multiplicativo aplicado al wheel. `1.0` = tamaño
/// nominal. Clampeado a [VIEW_SCALE_MIN, VIEW_SCALE_MAX].
pub view_scale: f32,
/// Pan horizontal en px (positivo = desplaza el wheel a la derecha
/// desde el centro). Se aplica como margin shift sobre el centrado
/// natural del flex parent.
pub view_pan_x: f32,
/// Pan vertical en px (positivo = abajo).
pub view_pan_y: f32,
/// Por-LayerKind: `true` = visible. Default = todo visible.
pub layer_visibility: HashMap<LayerKind, bool>,
/// Indicadores de grado al lado de cada planeta y cusp de casa.
/// Default `true` — el usuario los espera ver para leer la
/// carta. Se togglean con `C` (Coords) o desde el panel.
pub show_coords: bool,
/// Planeta hovered actualmente (para tooltip). `None` cuando el
/// mouse no está sobre ningún cuerpo.
pub hover: Option<HoverInfo>,
drag_jog: Option<JogDragState>,
drag_pan: Option<PanDragState>,
}
/// Límites del zoom — bajo 0.5 los glyphs se vuelven ilegibles; sobre
/// 3.0 el wheel desborda incluso pantallas grandes.
pub const VIEW_SCALE_MIN: f32 = 0.5;
pub const VIEW_SCALE_MAX: f32 = 3.0;
/// Info del elemento bajo el cursor — usado por el render para mostrar
/// un tooltip flotante con detalles. Cubre body glyphs, cusps de casa,
/// y líneas de aspectos.
#[derive(Clone, Debug)]
pub enum HoverInfo {
Body {
module_id: String,
symbol: String,
deg: f32,
house: Option<u8>,
retrograde: bool,
dignity_marker: Option<String>,
annotation: Option<String>,
local_x: f32,
local_y: f32,
},
HouseCusp {
house_number: u8,
deg: f32,
local_x: f32,
local_y: f32,
},
/// Hover sobre una línea de aspecto. `from_body`/`to_body` y `kind`
/// vienen de la LineSeg; `orb_deg` también. Los coords son el
/// punto medio del segmento donde se muestra el tooltip.
Aspect {
module_id: String,
from_body: String,
to_body: String,
kind: String,
orb_deg: f32,
local_x: f32,
local_y: f32,
},
}
impl HoverInfo {
fn local(&self) -> (f32, f32) {
match self {
HoverInfo::Body { local_x, local_y, .. } => (*local_x, *local_y),
HoverInfo::HouseCusp { local_x, local_y, .. } => (*local_x, *local_y),
HoverInfo::Aspect { local_x, local_y, .. } => (*local_x, *local_y),
}
}
fn key(&self) -> String {
match self {
HoverInfo::Body {
module_id, symbol, ..
} => format!("body:{}:{}", module_id, symbol),
HoverInfo::HouseCusp { house_number, .. } => format!("cusp:{}", house_number),
HoverInfo::Aspect {
module_id,
from_body,
to_body,
kind,
..
} => format!("aspect:{}:{}-{}-{}", module_id, from_body, kind, to_body),
}
}
}
impl Default for CanvasState {
fn default() -> Self {
Self {
mode: CanvasMode::default(),
view_rotation_deg: 0.0,
time_offset_minutes: 0,
view_scale: 1.0,
view_pan_x: 0.0,
view_pan_y: 0.0,
layer_visibility: HashMap::new(),
show_coords: true,
hover: None,
drag_jog: None,
drag_pan: None,
}
}
}
impl CanvasState {
pub fn is_layer_visible(&self, kind: LayerKind) -> bool {
self.layer_visibility.get(&kind).copied().unwrap_or(true)
}
}
// =====================================================================
// Widget
// =====================================================================
pub struct AstrologyCanvas {
state: CanvasState,
focus_handle: FocusHandle,
}
impl EventEmitter<CanvasEvent> for AstrologyCanvas {}
impl Focusable for AstrologyCanvas {
fn focus_handle(&self, _: &gpui::App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl AstrologyCanvas {
pub fn new(cx: &mut Context<'_, Self>) -> Self {
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
Self {
state: CanvasState::default(),
focus_handle: cx.focus_handle(),
}
}
pub fn state(&self) -> &CanvasState {
&self.state
}
pub fn set_mode(&mut self, mode: CanvasMode, cx: &mut Context<'_, Self>) {
self.state.mode = mode;
cx.notify();
}
pub fn set_layer_visible(&mut self, kind: LayerKind, visible: bool, cx: &mut Context<'_, Self>) {
self.state.layer_visibility.insert(kind, visible);
cx.notify();
}
pub fn toggle_layer(&mut self, kind: LayerKind, cx: &mut Context<'_, Self>) {
let current = self.state.is_layer_visible(kind);
self.set_layer_visible(kind, !current, cx);
cx.emit(CanvasEvent::LayerVisibilityChanged {
kind,
visible: !current,
});
}
pub fn reset_time_offset(&mut self, cx: &mut Context<'_, Self>) {
if self.state.time_offset_minutes != 0 || self.state.view_rotation_deg != 0.0 {
self.state.time_offset_minutes = 0;
self.state.view_rotation_deg = 0.0;
cx.emit(CanvasEvent::TimeOffsetChanged(0));
cx.notify();
}
}
pub fn set_view_rotation(&mut self, deg: f32, cx: &mut Context<'_, Self>) {
self.state.view_rotation_deg = deg.rem_euclid(360.0);
cx.notify();
}
pub fn toggle_coords(&mut self, cx: &mut Context<'_, Self>) {
let new_val = !self.state.show_coords;
self.set_show_coords(new_val, cx);
cx.emit(CanvasEvent::ShowCoordsChanged(new_val));
}
/// Setter idempotente — el shell lo usa para reflejar cambios del
/// panel sin disparar el `ShowCoordsChanged` (que iría en el otro
/// sentido y crearía un loop).
pub fn set_show_coords(&mut self, value: bool, cx: &mut Context<'_, Self>) {
if self.state.show_coords != value {
self.state.show_coords = value;
cx.notify();
}
}
/// Resetea zoom y pan a sus defaults (1.0 y 0,0). No toca rotation
/// ni time offset — esos son ortogonales y tienen su propio reset.
pub fn reset_view(&mut self, cx: &mut Context<'_, Self>) {
if self.state.view_scale != 1.0
|| self.state.view_pan_x != 0.0
|| self.state.view_pan_y != 0.0
{
self.state.view_scale = 1.0;
self.state.view_pan_x = 0.0;
self.state.view_pan_y = 0.0;
cx.notify();
}
}
/// Zoom multiplicativo. El nuevo scale es `current * factor`, clamp
/// al rango permitido. El zoom es centrado (no rastrea el cursor) —
/// para mover el foco después del zoom, el usuario paneja con MMB.
fn zoom_by(&mut self, factor: f32, cx: &mut Context<'_, Self>) {
let new_scale =
(self.state.view_scale * factor).clamp(VIEW_SCALE_MIN, VIEW_SCALE_MAX);
if (new_scale - self.state.view_scale).abs() < 1e-4 {
return;
}
// Mantener el centro del wheel anclado al centro de pantalla:
// como el pan está en coords de la pantalla y el zoom es desde
// el centro del wheel, el pan se escala proporcional al ratio.
let ratio = new_scale / self.state.view_scale;
self.state.view_pan_x *= ratio;
self.state.view_pan_y *= ratio;
self.state.view_scale = new_scale;
cx.notify();
}
#[allow(dead_code)]
fn pan_by(&mut self, dx: f32, dy: f32, cx: &mut Context<'_, Self>) {
if dx == 0.0 && dy == 0.0 {
return;
}
self.state.view_pan_x += dx;
self.state.view_pan_y += dy;
cx.notify();
}
// ----- Internos: handlers de jog-dial -----
/// Despacha el LMB down entre jog-dial y pan. El jog-dial es un
/// control "fuerte" (mueve el tiempo de la carta), así que se
/// activa SOLO con modifier Ctrl/Cmd + click sobre el anillo de
/// signos — sin modifier es siempre pan, incluso sobre el anillo,
/// para que no haya rotaciones accidentales al manipular la
/// rueda.
fn on_primary_down(
&mut self,
position: Point<Pixels>,
modifiers: gpui::Modifiers,
bounds: Bounds<Pixels>,
cx: &mut Context<'_, Self>,
) {
// Sin modifier: pan, sin importar dónde caiga el click.
if !(modifiers.control || modifiers.platform) {
self.on_pan_down(position, cx);
return;
}
let (cx_px, cy_px) = bounds_center(bounds);
let mx: f32 = position.x.into();
let my: f32 = position.y.into();
let dx = mx - cx_px;
let dy = my - cy_px;
let dist = (dx * dx + dy * dy).sqrt();
let r_outer = effective_r_outer(bounds);
let radii = Radii::from_outer(r_outer);
let on_dial = dist >= radii.sign_inner * 0.95 && dist <= radii.sign_outer * 1.10;
if on_dial {
let angle = dy.atan2(dx).to_degrees();
self.state.drag_jog = Some(JogDragState {
last_screen_angle_deg: angle,
accumulated_delta_deg: 0.0,
});
} else {
// Ctrl+click fuera del anillo: pan también — el modifier
// habilita el jog-dial pero no impide la navegación.
self.on_pan_down(position, cx);
}
}
fn on_jog_move(
&mut self,
position: Point<Pixels>,
bounds: Bounds<Pixels>,
cx: &mut Context<'_, Self>,
) {
let Some(jog) = self.state.drag_jog.as_mut() else {
return;
};
let (cx_px, cy_px) = bounds_center(bounds);
let mx: f32 = position.x.into();
let my: f32 = position.y.into();
let dx = mx - cx_px;
let dy = my - cy_px;
let angle = dy.atan2(dx).to_degrees();
let mut delta = angle - jog.last_screen_angle_deg;
// Normalizar a (-180, 180] para cruzar el wrap sin saltar.
if delta > 180.0 {
delta -= 360.0;
} else if delta < -180.0 {
delta += 360.0;
}
jog.accumulated_delta_deg += delta;
jog.last_screen_angle_deg = angle;
// Reflejo visual durante el drag (sin recomputar).
self.state.view_rotation_deg = jog.accumulated_delta_deg;
cx.notify();
}
/// Hit-test sobre body glyphs + house cusps. Para bodies: distancia
/// al centro del glyph dentro de threshold. Para cusps: el mouse
/// debe estar cerca del ring de casas Y angularmente cerca del
/// cusp (proximidad a la línea radial).
fn on_hover_check(
&mut self,
position: Point<Pixels>,
bounds: Bounds<Pixels>,
cx: &mut Context<'_, Self>,
) {
let CanvasMode::Wheel { render } = &self.state.mode else {
if self.state.hover.take().is_some() {
cx.notify();
}
return;
};
let (cx_px, cy_px) = bounds_center(bounds);
let mx: f32 = position.x.into();
let my: f32 = position.y.into();
let ox: f32 = bounds.origin.x.into();
let oy: f32 = bounds.origin.y.into();
let r_outer = effective_r_outer(bounds);
let radii = Radii::from_outer(r_outer);
let asc = render.ascendant_deg;
let rot = self.state.view_rotation_deg;
let body_threshold = 14.0_f32;
let mut best: Option<(f32, HoverInfo)> = None;
// 1) Body glyphs (incluye natal, overlays, midpoints).
//
// Importante: el hit-test debe usar `display_deg` (post-spread)
// y no `g.deg` (raw) — el spread mueve los discos para evitar
// solapes y si el hover sigue al raw, el usuario tendría que
// apuntar a una zona vacía para activarlo. Calculamos los
// displays con la misma función que render_wheel.
let view_scale = self.state.view_scale;
for layer in &render.layers {
let ring = match layer.kind {
LayerKind::Bodies => radii.body_ring(&layer.module_id),
LayerKind::Midpoints => radii.midpoints,
LayerKind::Outer if OUTER_RING_MODULES.contains(&layer.module_id.as_str()) => {
radii.transits
}
_ => continue,
};
let disk_base = body_disk_base(&layer.module_id, layer.kind, view_scale);
let raw_degs: Vec<f32> = layer.glyphs.iter().map(|g| g.deg).collect();
let disk_angular = (disk_base / (std::f32::consts::TAU * ring)) * 360.0;
let (display_degs, _) =
spread_angles(&raw_degs, disk_angular, disk_angular);
for (i, g) in layer.glyphs.iter().enumerate() {
let (gx, gy) = polar_to_screen(display_degs[i], asc, rot, ring);
let dx = mx - (cx_px + gx);
let dy = my - (cy_px + gy);
let dist = (dx * dx + dy * dy).sqrt();
if dist > body_threshold {
continue;
}
if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
best = Some((
dist,
HoverInfo::Body {
module_id: layer.module_id.clone(),
symbol: g.symbol.clone(),
deg: g.deg,
house: g.house,
retrograde: g.retrograde,
dignity_marker: g.dignity_marker.clone(),
annotation: g.annotation.clone(),
local_x: cx_px + gx - ox,
local_y: cy_px + gy - oy,
},
));
}
}
}
// 2) Aspect lines (segundo: las líneas son más "frágiles" que
// los planetas; si un body matcheó arriba ya tomó precedencia).
// Computa distancia punto-segmento del mouse al line.
if best.is_none() {
for layer in &render.layers {
if !matches!(layer.kind, LayerKind::Aspects) {
continue;
}
let (r_from, r_to) = radii.aspect_endpoints(&layer.module_id);
if let Geometry::Lines(segs) = &layer.geometry {
for seg in segs {
if seg.from_body.is_empty() || seg.to_body.is_empty() {
continue;
}
let (ax, ay) = polar_to_screen(seg.from_deg, asc, rot, r_from);
let (bx, by) = polar_to_screen(seg.to_deg, asc, rot, r_to);
let px_a = cx_px + ax;
let py_a = cy_px + ay;
let px_b = cx_px + bx;
let py_b = cy_px + by;
let dist = dist_point_segment(mx, my, px_a, py_a, px_b, py_b);
if dist > 4.0 {
continue;
}
if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
let mid_x = (px_a + px_b) / 2.0;
let mid_y = (py_a + py_b) / 2.0;
best = Some((
dist,
HoverInfo::Aspect {
module_id: layer.module_id.clone(),
from_body: seg.from_body.clone(),
to_body: seg.to_body.clone(),
kind: seg.kind.clone(),
orb_deg: seg.orb_deg,
local_x: mid_x - ox,
local_y: mid_y - oy,
},
));
}
}
}
}
}
// 3) House cusps — solo si el mouse está cerca del anillo de
// casas (radio entre houses_inner y houses_outer + margen) y
// ningún body ganó. Las cusps son líneas radiales — la
// distancia angular al cusp más cercano determina el hit.
if best.is_none() {
let dx = mx - cx_px;
let dy = my - cy_px;
let mouse_r = (dx * dx + dy * dy).sqrt();
let r_in = radii.houses_inner - 6.0;
let r_out = radii.houses_outer + 6.0;
if mouse_r >= r_in && mouse_r <= r_out {
// Calcular la longitud zodiacal que corresponde a este
// ángulo de pantalla (inversa de polar_to_screen).
let screen_angle_deg = dy.atan2(dx).to_degrees(); // (-180, 180]
// polar_to_screen: deg = 180 - (lon - asc + rot)
// → lon = asc + 180 - screen_angle_deg - rot
let lon = ((asc + 180.0 - screen_angle_deg - rot) as f32).rem_euclid(360.0);
// Buscar cusp más cercano (con wraparound).
for layer in &render.layers {
if matches!(layer.kind, LayerKind::Houses) {
if let Geometry::Ring { cusps_deg } = &layer.geometry {
for (i, c) in cusps_deg.iter().enumerate() {
let mut diff = (lon - c).abs();
if diff > 180.0 {
diff = 360.0 - diff;
}
if diff < 2.5 {
// Mouse cerca de ESTE cusp.
let (gx, gy) = polar_to_screen(
*c,
asc,
rot,
(radii.houses_inner + radii.houses_outer) / 2.0,
);
best = Some((
diff,
HoverInfo::HouseCusp {
house_number: (i as u8) + 1,
deg: *c,
local_x: cx_px + gx - ox,
local_y: cy_px + gy - oy,
},
));
break;
}
}
}
}
}
}
}
let new_hover = best.map(|(_, h)| h);
let changed = match (&self.state.hover, &new_hover) {
(Some(a), Some(b)) => a.key() != b.key(),
(None, None) => false,
_ => true,
};
if changed {
self.state.hover = new_hover;
cx.notify();
}
}
// ----- Internos: pan drag (MMB) -----
fn on_pan_down(&mut self, position: Point<Pixels>, _cx: &mut Context<'_, Self>) {
self.state.drag_pan = Some(PanDragState {
start_pos: position,
pan_x_start: self.state.view_pan_x,
pan_y_start: self.state.view_pan_y,
});
}
fn on_pan_move(&mut self, position: Point<Pixels>, cx: &mut Context<'_, Self>) {
let Some(pan) = self.state.drag_pan.as_ref() else {
return;
};
let dx: f32 = (position.x - pan.start_pos.x).into();
let dy: f32 = (position.y - pan.start_pos.y).into();
self.state.view_pan_x = pan.pan_x_start + dx;
self.state.view_pan_y = pan.pan_y_start + dy;
cx.notify();
}
fn on_pan_up(&mut self, cx: &mut Context<'_, Self>) {
if self.state.drag_pan.take().is_some() {
cx.notify();
}
}
fn on_scroll(
&mut self,
event: &ScrollWheelEvent,
_w: &mut Window,
cx: &mut Context<'_, Self>,
) {
let (_dx_px, dy_px) = match event.delta {
ScrollDelta::Pixels(p) => (f32::from(p.x), f32::from(p.y)),
ScrollDelta::Lines(p) => (p.x * 16.0, p.y * 16.0),
};
// Wheel = zoom puro, sin modifier. Pan se hace con drag (LMB
// fuera del anillo, o MMB). 100px de scroll ≈ ±20% zoom.
let factor = (dy_px * 0.002).exp();
self.zoom_by(factor, cx);
}
fn on_jog_up(&mut self, cx: &mut Context<'_, Self>) {
let Some(jog) = self.state.drag_jog.take() else {
return;
};
// 1° de arco ≈ 4 minutos de tiempo sideral (15°/hora).
// CW visual (delta negativa en nuestra convención) → tiempo
// hacia adelante.
let delta_minutes = (-jog.accumulated_delta_deg * 4.0) as i64;
if delta_minutes != 0 {
self.state.time_offset_minutes =
self.state.time_offset_minutes.saturating_add(delta_minutes);
// Snap visual: el shell recomputa con el nuevo offset y el
// render trae el ascendant rotado.
self.state.view_rotation_deg = 0.0;
cx.emit(CanvasEvent::TimeOffsetChanged(self.state.time_offset_minutes));
cx.notify();
} else {
self.state.view_rotation_deg = 0.0;
cx.notify();
}
}
fn on_key_down(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<'_, Self>) {
let key = event.keystroke.key.as_str();
let kind = match key {
"d" | "D" => LayerKind::SignDial,
"h" | "H" => LayerKind::Houses,
"x" | "X" => LayerKind::Aspects,
"p" | "P" => LayerKind::Bodies,
"t" | "T" => LayerKind::Outer,
"r" | "R" => {
self.reset_time_offset(cx);
return;
}
"0" => {
self.reset_view(cx);
return;
}
"c" | "C" => {
self.toggle_coords(cx);
return;
}
"s" | "S" => {
cx.emit(CanvasEvent::ExportSvgRequested);
return;
}
_ => return,
};
self.toggle_layer(kind, cx);
}
}
// =====================================================================
// Geometría de pantalla
// =====================================================================
const WHEEL_SIZE: f32 = 580.0;
const WHEEL_MARGIN: f32 = 28.0;
/// Pinta un gradiente radial de profundidad sobre el background del
/// canvas — efecto vignette. Se aproxima al gradient radial (no
/// soportado nativamente por gpui en `.bg()`) pintando ~28 anillos
/// concéntricos del centro hacia afuera, con alpha creciente hacia el
/// borde. El centro queda claro y los extremos se oscurecen, dando
/// sensación de "el wheel emerge desde la profundidad".
///
/// Solo activo en themes dark — sobre papel (light / print) el panel
/// queda plano: una viñeta sobre fondo claro tiñe el papel y rompe
/// la metáfora "impresión".
fn paint_depth_field(bounds: Bounds<Pixels>, window: &mut Window, theme: &Theme) {
if !theme.is_dark {
return;
}
let ox: f32 = bounds.origin.x.into();
let oy: f32 = bounds.origin.y.into();
let bw: f32 = bounds.size.width.into();
let bh: f32 = bounds.size.height.into();
if bw <= 0.0 || bh <= 0.0 {
return;
}
let cx = ox + bw / 2.0;
let cy = oy + bh / 2.0;
// El gradient se extiende hasta la diagonal del rectángulo para
// que las esquinas estén dentro del último anillo (sin "halo"
// visible donde se corta).
let r_max = ((bw * bw + bh * bh).sqrt()) / 2.0 * 1.05;
let steps = 28;
// Color: casi-negro con tinte ligero del panel (el panel es dark).
let deep = hsla(230.0 / 360.0, 0.30, 0.04, 1.0);
// Stroke de cada anillo: el ancho cubre 1/steps del radio para
// que no queden gaps entre anillos.
let stroke_w = (r_max / steps as f32) * 1.15;
for i in 0..steps {
let t = i as f32 / (steps - 1) as f32;
let r = r_max * t;
// Curva ease-in: alpha crece de 0 (centro) a ~0.55 (borde),
// con la mayor parte del cambio en la mitad exterior. t² da
// ese "fondo profundo en el perímetro sin opacar el centro".
let alpha = 0.55 * (t * t);
stroke_circle(window, cx, cy, r, stroke_w, with_alpha(deep, alpha));
}
}
fn bounds_center(bounds: Bounds<Pixels>) -> (f32, f32) {
let ox: f32 = bounds.origin.x.into();
let oy: f32 = bounds.origin.y.into();
let bw: f32 = bounds.size.width.into();
let bh: f32 = bounds.size.height.into();
(ox + bw / 2.0, oy + bh / 2.0)
}
/// Radio del anillo exterior derivado del width *actual* del canvas
/// (que ya está escalado por view_scale). Mantiene la proporción del
/// margen contra `WHEEL_SIZE` original, así el hit-test del jog-dial y
/// las cusps se adapta automáticamente al zoom sin que cada caller
/// recalcule `view_scale`.
fn effective_r_outer(bounds: Bounds<Pixels>) -> f32 {
let bw: f32 = bounds.size.width.into();
let scale = if WHEEL_SIZE > 0.0 { bw / WHEEL_SIZE } else { 1.0 };
(bw - WHEEL_MARGIN * scale * 2.0) / 2.0
}
// =====================================================================
// Render
// =====================================================================
impl Render for AstrologyCanvas {
fn render(&mut self, _w: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
let palette = AstroPalette::for_theme(&theme);
let entity = cx.entity();
let focus = self.focus_handle.clone();
let body = match &self.state.mode {
CanvasMode::Empty => render_empty(&theme),
CanvasMode::Wheel { render } => render_wheel(
&theme,
&palette,
render,
self.state.view_rotation_deg,
self.state.time_offset_minutes,
self.state.view_scale,
self.state.view_pan_x,
self.state.view_pan_y,
&self.state.layer_visibility,
self.state.show_coords,
self.state.hover.as_ref(),
entity,
),
CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items),
};
// Depth field: capa absoluta detrás del body, ocupa todo el
// canvas. Vignette radial — el centro queda claro y los
// bordes se oscurecen, dando profundidad sin "ruido" de
// puntos. Solo en themes dark (en papel rompería la
// metáfora).
let theme_for_depth = theme.clone();
let depth_field = canvas(
|_b, _w, _cx| (),
move |bounds, _, window, _| paint_depth_field(bounds, window, &theme_for_depth),
)
.absolute()
.size_full();
div()
.id("astrology-canvas-root")
.track_focus(&focus)
.key_context("AstrologyCanvas")
.on_key_down(cx.listener(Self::on_key_down))
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _, w, _cx| {
w.focus(&this.focus_handle);
}),
)
.on_scroll_wheel(cx.listener(Self::on_scroll))
.size_full()
.bg(theme.bg_panel.clone())
.relative()
.overflow_hidden()
.child(depth_field)
.child(
div()
.size_full()
.flex()
.flex_col()
.items_center()
.justify_center()
.child(body),
)
}
}
// =====================================================================
// Modos: empty / thumbnails / wheel
// =====================================================================
fn render_empty(theme: &Theme) -> gpui::Div {
div()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap(px(12.0))
.child(
div()
.text_size(px(13.0))
.text_color(theme.fg_muted)
.child("Tahuantinsuyu"),
)
.child(
div()
.text_size(px(11.0))
.text_color(theme.fg_disabled)
.child("Seleccioná una carta en el árbol para empezar."),
)
}
fn render_thumbnails(theme: &Theme, items: &[ThumbnailItem]) -> gpui::Div {
if items.is_empty() {
return div()
.text_size(px(12.0))
.text_color(theme.fg_muted)
.child("Sin cartas en este grupo todavía.");
}
let mut row = div().flex().flex_row().flex_wrap().gap(px(12.0));
for it in items {
row = row.child(
div()
.w(px(140.0))
.h(px(160.0))
.rounded(px(6.0))
.border_1()
.border_color(theme.border)
.bg(theme.bg_panel_alt.clone())
.flex()
.flex_col()
.items_center()
.justify_end()
.pb(px(8.0))
.child(
div()
.text_size(px(11.0))
.text_color(theme.fg_text)
.child(it.label.clone()),
),
);
}
row
}
// =====================================================================
// Wheel
// =====================================================================
#[allow(clippy::too_many_arguments)]
fn render_wheel(
theme: &Theme,
palette: &AstroPalette,
render: &RenderModel,
view_rotation_deg: f32,
time_offset_minutes: i64,
view_scale: f32,
view_pan_x: f32,
view_pan_y: f32,
layer_visibility: &HashMap<LayerKind, bool>,
show_coords: bool,
hover: Option<&HoverInfo>,
entity: gpui::Entity<AstrologyCanvas>,
) -> gpui::Div {
let asc = render.ascendant_deg;
let rot_offset = view_rotation_deg;
// Todo el wheel escala uniforme: el cuadro contenedor y los anillos
// crecen con view_scale, así que glifos, líneas y márgenes mantienen
// sus proporciones. cx/cy_center vive en coords locales del wheel,
// donde el wheel tiene tamaño `wheel_size` (no WHEEL_SIZE).
let wheel_size = WHEEL_SIZE * view_scale;
let wheel_margin = WHEEL_MARGIN * view_scale;
let cx_center = wheel_size / 2.0;
let cy_center = wheel_size / 2.0;
let r_outer = (wheel_size - wheel_margin * 2.0) / 2.0;
let radii = Radii::from_outer(r_outer);
let visible = layer_visibility.clone();
// --- Canvas element con todo el trazo + jog-dial drag ---
let palette_paint = palette.clone();
let theme_paint = theme.clone();
let layers_paint: Vec<Layer> = render.layers.clone();
let asc_for_paint = asc;
let mc_for_paint = render.midheaven_deg;
let visibility_for_paint = visible.clone();
let entity_for_canvas = entity.clone();
// Hover focus para el highlight de aspectos — solo cuando el hover
// es un Body (sobre un planeta), no sobre cusps ni aspectos.
let hover_focus_paint: Option<String> = match hover {
Some(HoverInfo::Body { symbol, .. }) => Some(symbol.clone()),
_ => None,
};
let canvas_element = canvas(
move |_b: Bounds<Pixels>, _w, _cx| (),
move |bounds: Bounds<Pixels>, _, window, _| {
// Painting de la rueda.
paint_wheel(
bounds,
window,
&theme_paint,
&palette_paint,
&layers_paint,
asc_for_paint,
mc_for_paint,
rot_offset,
radii,
&visibility_for_paint,
hover_focus_paint.as_deref(),
);
// Handlers de mouse — se registran cada frame contra el
// window; GPUI los reemplaza al re-renderear. LMB despacha
// entre jog-dial (sobre el anillo) y pan (afuera). MMB es
// pan secundario para usuarios con scroll-mouse.
let entity_d = entity_for_canvas.clone();
window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| {
if !bounds.contains(&ev.position) {
return;
}
match ev.button {
MouseButton::Left => {
let mods = ev.modifiers;
entity_d.update(cx, |this, cx| {
this.on_primary_down(ev.position, mods, bounds, cx)
});
}
MouseButton::Middle => {
entity_d.update(cx, |this, cx| this.on_pan_down(ev.position, cx));
}
_ => {}
}
});
let entity_m = entity_for_canvas.clone();
window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
if ev.dragging() {
entity_m.update(cx, |this, cx| {
if this.state.drag_pan.is_some() {
this.on_pan_move(ev.position, cx);
} else {
this.on_jog_move(ev.position, bounds, cx);
}
});
} else if bounds.contains(&ev.position) {
// Mouse hover sin drag: hit-test sobre los body
// glyphs para el tooltip.
entity_m.update(cx, |this, cx| this.on_hover_check(ev.position, bounds, cx));
} else {
entity_m.update(cx, |this, cx| {
if this.state.hover.take().is_some() {
cx.notify();
}
});
}
});
let entity_u = entity_for_canvas.clone();
window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
entity_u.update(cx, |this, cx| {
this.on_pan_up(cx);
this.on_jog_up(cx);
});
});
},
)
.absolute()
.w(px(wheel_size))
.h(px(wheel_size));
// El wheel ya no tiene bg propio — antes era un cuadrado con
// gradient que cortaba contra el fondo del panel; ahora el panel
// (con su starfield encima en `render`) fluye continuo a través
// del área del wheel, dando el efecto de "rueda flotando en el
// universo" en lugar de "rueda sobre placa cuadrada".
let mut wheel = div()
.relative()
.w(px(wheel_size))
.h(px(wheel_size))
.ml(px(view_pan_x))
.mt(px(view_pan_y))
.child(canvas_element);
// Factor de escala para los glyphs DOM. Los radii ya están
// escalados (vienen de wheel_size = WHEEL_SIZE * view_scale), pero
// los tamaños de fuente y disco están hardcoded — los multiplico
// por view_scale para que el zoom afecte uniformemente todo el
// contenido visual del wheel, no solo la geometría del canvas.
let s = view_scale;
// Color del halo para los discos detrás de glyphs y pills — se
// calcula una sola vez, lo usan planetas, casas, ASC/MC y los
// coord labels.
let halo_bg = glyph_halo(theme);
// Sign glyphs.
if visible.get(&LayerKind::SignDial).copied().unwrap_or(true) {
let sign_ring_mid = (radii.sign_outer + radii.sign_inner) / 2.0;
for layer in &render.layers {
if matches!(layer.kind, LayerKind::SignDial) {
for g in &layer.glyphs {
let (x, y) = polar_to_screen(g.deg, asc, rot_offset, sign_ring_mid);
let color = element_color_for_sign(palette, &g.symbol);
wheel = wheel.child(centered_glyph(
cx_center + x,
cy_center + y,
20.0 * s,
18.0 * s,
sign_unicode(&g.symbol).into(),
color,
));
}
}
}
}
// House numbers + (opcional) coord del cusp.
//
// El layer `natal` usa Zona CD (entre aros C y D); `topocentric`
// usa Zona BC (entre aros B y C). Los house numbers se posan al
// centro de la zona; las coord pills se posan adyacentes al aro
// interior de la propia zona, así no se sale del bloque.
if visible.get(&LayerKind::Houses).copied().unwrap_or(true) {
let house_label_color = house_ring_color(palette);
for layer in &render.layers {
if matches!(layer.kind, LayerKind::Houses) {
let is_topo = layer.module_id == "topocentric";
let (r_out, r_in) = if is_topo {
(radii.topo_houses_outer, radii.topo_houses_inner)
} else {
(radii.houses_outer, radii.houses_inner)
};
let label_r = (r_out + r_in) / 2.0;
let coord_r = r_in + (r_out - r_in) * 0.18;
for g in &layer.glyphs {
let (x, y) = polar_to_screen(g.deg, asc, rot_offset, label_r);
if let Some(h) = g.house {
wheel = wheel.child(centered_glyph(
cx_center + x,
cy_center + y,
16.0 * s,
11.0 * s,
format!("{}", h).into(),
house_label_color,
));
if show_coords {
let coord = format_coord_compact(g.deg);
let (lx, ly) =
polar_to_screen(g.deg, asc, rot_offset, coord_r);
wheel = wheel.child(coord_label(
cx_center + lx,
cy_center + ly,
coord.into(),
theme.fg_muted,
halo_bg,
8.5 * s,
));
}
}
}
}
}
}
// Planet glyphs: natal en `bodies` + overlays (progression,
// solar_arc) en sus rings, ambos con disco-halo para legibilidad
// contra cualquier fondo. El natal lleva un tamaño un poco mayor
// que los overlays para que se lea como "el cuerpo principal".
if visible.get(&LayerKind::Bodies).copied().unwrap_or(true) {
for layer in &render.layers {
if matches!(layer.kind, LayerKind::Bodies) {
let is_natal = layer.module_id == "natal";
let is_topo = layer.module_id == "topocentric";
let is_pd_direct = layer.module_id == "pd_direct";
let is_pd_converse = layer.module_id == "pd_converse";
let is_pd = is_pd_direct || is_pd_converse;
let ring = radii.body_ring(&layer.module_id);
let alpha = if is_natal {
1.0
} else if is_topo {
0.75
} else if is_pd {
0.80
} else {
0.88
};
let font_size = (if is_natal {
18.0
} else if is_topo {
15.0
} else if is_pd {
13.0
} else {
14.0
}) * s;
let disk_size_base = (if is_natal {
26.0
} else if is_topo {
22.0
} else if is_pd {
20.0
} else {
22.0
}) * s;
// Anti-solapamiento: spread directo sobre TODOS los
// glyphs con `min_sep = disk_angular` (tangencial: los
// discos se rozan sin pisarse) y `max_shift = disk_angular`
// (cap fuerte: ningún planeta puede alejarse más de
// un diámetro de disco de su grado real). El cap evita
// que un cluster denso "empuje" a planetas lejanos.
//
// En paralelo, `find_clusters` con threshold = ancho
// del disco × 1.2 detecta pares/tríos cercanos para
// que compartan label. Sin esto, dos planetas en
// conjunción a 5° real se ven con sus discos
// separados a 10° y CADA UNO con su pill — dos labels
// que dicen casi lo mismo, exactamente lo que el
// usuario reporta como "se repiten en vez de
// reutilizarse".
let raw_degs: Vec<f32> = layer.glyphs.iter().map(|g| g.deg).collect();
let disk_angular_deg =
(disk_size_base / (std::f32::consts::TAU * ring)) * 360.0;
let max_shift = disk_angular_deg;
let (display_degs, residual) =
spread_angles(&raw_degs, disk_angular_deg, max_shift);
let cluster_thresh = disk_angular_deg * 1.2;
let clusters = find_clusters(&raw_degs, cluster_thresh);
let cluster_centroids: Vec<f32> = clusters
.iter()
.map(|c| {
let mut sx = 0.0_f32;
let mut sy = 0.0_f32;
for &idx in c {
let a = raw_degs[idx].to_radians();
sx += a.cos();
sy += a.sin();
}
sy.atan2(sx).to_degrees().rem_euclid(360.0)
})
.collect();
let display_centroids: Vec<f32> = clusters
.iter()
.map(|c| {
let mut sx = 0.0_f32;
let mut sy = 0.0_f32;
for &idx in c {
let a = display_degs[idx].to_radians();
sx += a.cos();
sy += a.sin();
}
sy.atan2(sx).to_degrees().rem_euclid(360.0)
})
.collect();
let mut cluster_of = vec![0usize; layer.glyphs.len()];
for (ci, c) in clusters.iter().enumerate() {
for &idx in c {
cluster_of[idx] = ci;
}
}
let shrink_residual = (1.0 - residual * 0.30).clamp(0.60, 1.0);
// El hovered glyph y su cluster reciben tratamiento
// especial: lo postponemos para pintarlo al FINAL del
// árbol (queda por encima del resto = z-order), y le
// damos un border más fuerte. Su label cluster también
// se destaca (color fg_text en lugar de fg_muted, font
// un punto más grande).
let hovered_sym: Option<&str> = match hover {
Some(HoverInfo::Body { symbol, .. }) => Some(symbol.as_str()),
_ => None,
};
let hovered_idx: Option<usize> = hovered_sym.and_then(|sym| {
layer.glyphs.iter().position(|g| g.symbol == sym)
});
let hovered_cluster: Option<usize> = hovered_idx.map(|i| cluster_of[i]);
for (i, g) in layer.glyphs.iter().enumerate() {
if Some(i) == hovered_idx {
continue; // se pinta al final
}
// Achicar discos cuando el glyph está en cluster
// (≥2 miembros) — al estar pegados se ven mejor
// un poco más pequeños.
let cluster_size = clusters[cluster_of[i]].len();
let in_cluster_shrink = if cluster_size >= 2 { 0.86 } else { 1.0 };
let effective_shrink = shrink_residual * in_cluster_shrink;
let disk_size = disk_size_base * effective_shrink;
let font_size_eff = (font_size * effective_shrink).max(11.0);
let display_deg = display_degs[i];
let (x, y) = polar_to_screen(display_deg, asc, rot_offset, ring);
let color = with_alpha(planet_color(palette, &g.symbol), alpha);
let mut glyph_text = planet_unicode(&g.symbol).to_string();
if g.retrograde {
glyph_text.push('ᴿ');
}
if let Some(marker) = &g.dignity_marker {
glyph_text.push_str(marker);
}
wheel = wheel.child(planet_glyph(
cx_center + x,
cy_center + y,
disk_size,
font_size_eff,
glyph_text.into(),
color,
halo_bg,
with_alpha(color, 0.85),
));
// Coord label individual: solo cuando el glyph
// está SOLO en su cluster (≥2 ⇒ label compartido).
if show_coords && (is_natal || is_topo) && cluster_size == 1 {
let coord = format_coord_compact(g.deg);
let label_r = ring - disk_size * 1.3;
let (lx, ly) =
polar_to_screen(display_deg, asc, rot_offset, label_r);
wheel = wheel.child(coord_label(
cx_center + lx,
cy_center + ly,
coord.into(),
theme.fg_muted,
halo_bg,
8.5 * s,
));
}
}
// Label compartido para CADA cluster con ≥2 miembros.
// El del cluster hovereado se destaca: color fg_text
// (vs fg_muted) y font un punto más grande.
if show_coords && (is_natal || is_topo) {
let disk_size_typical = disk_size_base * shrink_residual * 0.86;
for (ci, c) in clusters.iter().enumerate() {
if c.len() < 2 {
continue;
}
let highlighted = Some(ci) == hovered_cluster;
let center_display_deg = display_centroids[ci];
let center_real_deg = cluster_centroids[ci];
let symbols: String = c
.iter()
.map(|&idx| planet_unicode(&layer.glyphs[idx].symbol))
.collect::<Vec<_>>()
.join(" ");
let coord = format_coord_compact(center_real_deg);
let text = format!("{} {}", symbols, coord);
let label_r = ring - disk_size_typical * 1.5;
let (lx, ly) = polar_to_screen(
center_display_deg,
asc,
rot_offset,
label_r,
);
let (fg, font_sz) = if highlighted {
(theme.fg_text, 10.0 * s)
} else {
(theme.fg_muted, 9.0 * s)
};
wheel = wheel.child(coord_label(
cx_center + lx,
cy_center + ly,
text.into(),
fg,
halo_bg,
font_sz,
));
}
}
// Render del glyph hovered al FINAL: queda encima del
// resto en z-order. Disco un poco más grande y border
// más prominente para destacar.
if let Some(hi) = hovered_idx {
let g = &layer.glyphs[hi];
let display_deg = display_degs[hi];
let (x, y) = polar_to_screen(display_deg, asc, rot_offset, ring);
let color = with_alpha(planet_color(palette, &g.symbol), alpha);
let mut glyph_text = planet_unicode(&g.symbol).to_string();
if g.retrograde {
glyph_text.push('ᴿ');
}
if let Some(marker) = &g.dignity_marker {
glyph_text.push_str(marker);
}
let disk_size = disk_size_base * shrink_residual * 1.18;
let font_size_eff = font_size * shrink_residual * 1.12;
wheel = wheel.child(planet_glyph(
cx_center + x,
cy_center + y,
disk_size,
font_size_eff,
glyph_text.into(),
color,
halo_bg,
color, // border al color pleno (no .85) — destaca
));
// Si el hovered no está en cluster compartido,
// pintamos su coord individual destacada acá.
let cluster_size = clusters[cluster_of[hi]].len();
if show_coords && (is_natal || is_topo) && cluster_size == 1 {
let coord = format_coord_compact(g.deg);
let label_r = ring - disk_size * 1.3;
let (lx, ly) =
polar_to_screen(display_deg, asc, rot_offset, label_r);
wheel = wheel.child(coord_label(
cx_center + lx,
cy_center + ly,
coord.into(),
theme.fg_text,
halo_bg,
10.0 * s,
));
}
}
}
}
}
// Planet glyphs en el outer ring — transit o synastry (slot
// compartido, mutuamente excluyentes a nivel de Shell). Disco un
// poco más chico que el natal — el outer es "secundario".
if visible.get(&LayerKind::Outer).copied().unwrap_or(true) {
for layer in &render.layers {
if matches!(layer.kind, LayerKind::Outer)
&& (OUTER_RING_MODULES.contains(&layer.module_id.as_str()))
{
let disk_base = 20.0 * s;
let raw_degs: Vec<f32> = layer.glyphs.iter().map(|g| g.deg).collect();
let disk_angular =
(disk_base / (std::f32::consts::TAU * radii.transits)) * 360.0;
let (display_degs, residual) =
spread_angles(&raw_degs, disk_angular, disk_angular);
let shrink = (1.0 - residual * 0.30).clamp(0.60, 1.0);
for (i, g) in layer.glyphs.iter().enumerate() {
let display_deg = display_degs[i];
let (x, y) = polar_to_screen(display_deg, asc, rot_offset, radii.transits);
let color = with_alpha(planet_color(palette, &g.symbol), 0.92);
let glyph_text = if g.retrograde {
format!("{}ᴿ", planet_unicode(&g.symbol))
} else {
planet_unicode(&g.symbol).into()
};
wheel = wheel.child(planet_glyph(
cx_center + x,
cy_center + y,
20.0 * s * shrink,
13.0 * s * shrink,
glyph_text.into(),
color,
halo_bg,
with_alpha(color, 0.75),
));
}
}
}
}
// Tooltip absoluto sobre el elemento hovered (cuerpo o cusp).
if let Some(hov) = hover {
let text = match hov {
HoverInfo::Body {
module_id,
symbol,
deg,
house,
retrograde,
dignity_marker,
annotation,
..
} => {
let sign_idx = ((deg / 30.0).floor() as usize) % 12;
let sign_name = SIGN_NAMES_ES[sign_idx];
let deg_in_sign = deg - (sign_idx as f32) * 30.0;
let display_symbol = if module_id == "midpoints" {
// El symbol del midpoint es "a/b" — para el header
// del tooltip usamos los unicodes individuales.
if let Some((a, b)) = symbol.split_once('/') {
format!("{}/{}", planet_unicode(a), planet_unicode(b))
} else {
symbol.clone()
}
} else {
planet_unicode(symbol).to_string()
};
let mut t = format!("{} {} · {:.1}°", display_symbol, sign_name, deg_in_sign);
if let Some(h) = house {
t.push_str(&format!(" · Casa {}", h));
}
if *retrograde {
t.push_str(" · ℞");
}
if let Some(m) = dignity_marker {
t.push_str(&format!(" · {}", m));
}
if module_id == "midpoints" {
if let Some(a) = annotation {
t.push_str(&format!(" · {}", a));
}
} else if module_id != "natal" {
t.push_str(&format!(" · {}", module_id));
}
t
}
HoverInfo::HouseCusp {
house_number, deg, ..
} => {
let sign_idx = ((deg / 30.0).floor() as usize) % 12;
let sign_name = SIGN_NAMES_ES[sign_idx];
let deg_in_sign = deg - (sign_idx as f32) * 30.0;
format!(
"Cusp Casa {} · {} {:.1}°",
house_number, sign_name, deg_in_sign
)
}
HoverInfo::Aspect {
module_id,
from_body,
to_body,
kind,
orb_deg,
..
} => {
let mut t = format!(
"{} {} {} · orb {:.1}°",
planet_unicode(from_body),
aspect_unicode(kind),
planet_unicode(to_body),
orb_deg
);
if module_id != "natal" {
t.push_str(&format!(" · {}", module_id));
}
t
}
};
let (lx, ly) = hov.local();
let tip_x = (lx + 14.0).min(WHEEL_SIZE - 220.0).max(8.0);
let tip_y = (ly - 28.0).max(8.0);
wheel = wheel.child(
div()
.absolute()
.left(px(tip_x))
.top(px(tip_y))
.px(px(8.0))
.py(px(4.0))
.rounded(px(6.0))
.bg(theme.bg_panel_alt.clone())
.border_1()
.border_color(palette.angle_highlight)
.text_size(px(11.0))
.text_color(theme.fg_text)
.child(SharedString::from(text)),
);
}
// Labels ASC/MC/DESC/IC como pills en el perímetro — bg del halo
// + border y texto en `angle_highlight`. Más legibles que el
// centered_glyph plano del fase anterior, en especial sobre
// fondos claros donde el ámbar/oro de angle_highlight se diluye.
let angle_labels = [
(asc, "ASC"),
(render.midheaven_deg, "MC"),
(render.descendant_deg, "DESC"),
(render.imum_coeli_deg, "IC"),
];
let label_r = r_outer * 1.08;
for (deg, label) in angle_labels {
let (x, y) = polar_to_screen(deg, asc, rot_offset, label_r);
let pill_w = (if label.len() > 2 { 38.0 } else { 30.0 }) * s;
let pill_h = 18.0 * s;
wheel = wheel.child(
div()
.absolute()
.left(px(cx_center + x - pill_w / 2.0))
.top(px(cy_center + y - pill_h / 2.0))
.w(px(pill_w))
.h(px(pill_h))
.flex()
.items_center()
.justify_center()
.rounded(px(9.0 * s))
.bg(halo_bg)
.border_1()
.border_color(with_alpha(palette.angle_highlight, 0.85))
.text_size(px(11.0 * s))
.text_color(palette.angle_highlight)
.child(SharedString::from(label)),
);
}
// --- Header + footer + indicador de tiempo ---
let header = div()
.flex()
.flex_col()
.items_center()
.gap(px(2.0))
.child(
div()
.text_size(px(16.0))
.text_color(theme.fg_text)
.child(SharedString::from(render.title.clone())),
);
let header = if let Some(sub) = &render.subtitle {
header.child(
div()
.text_size(px(11.0))
.text_color(theme.fg_muted)
.child(SharedString::from(sub.clone())),
)
} else {
header
};
// Botón export SVG — pequeño, alineado a la derecha del title.
let export_btn = div()
.id("tts-canvas-export-svg")
.px(px(10.0))
.py(px(3.0))
.rounded(px(4.0))
.bg(theme.bg_button())
.hover(|s| s.bg(theme.bg_button_hover()))
.border_1()
.border_color(theme.border)
.text_size(px(10.0))
.text_color(theme.fg_text)
.child("⬇ SVG")
.on_click({
let entity_e = entity.clone();
move |_: &gpui::ClickEvent, _w, cx: &mut gpui::App| {
entity_e.update(cx, |_this, cx| {
cx.emit(CanvasEvent::ExportSvgRequested);
});
}
});
let header = div()
.flex()
.flex_row()
.items_center()
.gap(px(12.0))
.child(header)
.child(export_btn);
let offset_label = format_offset(time_offset_minutes);
let offset_color = if time_offset_minutes == 0 {
theme.fg_disabled
} else {
palette.angle_highlight
};
let info_row = div()
.flex()
.flex_row()
.gap(px(10.0))
.child(
div()
.text_size(px(10.0))
.text_color(theme.fg_disabled)
.child(SharedString::from(format!(
"Asc {:.1}° · MC {:.1}° · {} ms",
render.ascendant_deg, render.midheaven_deg, render.compute_ms,
))),
)
.child(
div()
.text_size(px(10.0))
.text_color(offset_color)
.child(SharedString::from(offset_label)),
)
.child(
div()
.text_size(px(10.0))
.text_color(theme.fg_disabled)
.child(
"[D]ial [H]ouses as[X]pects [P]lanets [T]ransits [C]oords · Ctrl+drag = tiempo · [0] reset zoom · [R] reset tiempo · [S]vg",
),
);
// Badges de overlays activos. Cada uno se pinta como pill con
// background sutil y border tenue. Solo aparecen cuando hay
// overlays — la carta natal pura ve solo el info_row.
let badges_row = if render.overlays.is_empty() {
None
} else {
let mut row = div().flex().flex_row().flex_wrap().gap(px(6.0));
// Badge "natal" base, siempre presente cuando hay overlays —
// ayuda al usuario a leer la pila de izquierda a derecha.
row = row.child(badge(theme, palette, "natal", "Natal", true));
for ov in &render.overlays {
row = row.child(badge(theme, palette, &ov.module_id, &ov.label, false));
}
Some(row)
};
let mut footer = div().flex().flex_col().items_center().gap(px(4.0)).child(info_row);
if let Some(b) = badges_row {
footer = footer.child(b);
}
// Ejes uranianos detectados (cuerpos en la misma posición mod 90).
// Aparece sólo cuando el módulo Uranian está activo y hay
// grupos. Cada grupo se muestra como pill con los unicode de los
// cuerpos + el grado dial-90.
if !render.uranian_groups.is_empty() {
let mut row = div().flex().flex_row().flex_wrap().gap(px(6.0));
for group in &render.uranian_groups {
let bodies_text: String = group
.bodies
.iter()
.map(|b| planet_unicode(b))
.collect::<Vec<_>>()
.join(" ");
row = row.child(
div()
.px(px(8.0))
.py(px(2.0))
.rounded(px(10.0))
.bg(theme.bg_panel_alt.clone())
.border_1()
.border_color(with_alpha(palette.angle_highlight, 0.6))
.text_size(px(11.0))
.text_color(theme.fg_text)
.child(SharedString::from(format!(
"{} · {:.1}°",
bodies_text, group.mod90_deg
))),
);
}
footer = footer.child(
div()
.flex()
.flex_col()
.items_center()
.gap(px(3.0))
.child(
div()
.text_size(px(10.0))
.text_color(theme.fg_muted)
.child("Ejes uranianos (90°)"),
)
.child(row),
);
}
// Lista textual de aspectos (top 12 por orb). Compacta, en grid
// de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos
// computados.
if !render.aspect_summary.is_empty() {
let mut grid = div()
.flex()
.flex_row()
.flex_wrap()
.gap(px(10.0))
.max_w(px(WHEEL_SIZE + 80.0))
.justify_center();
for ap in render.aspect_summary.iter().take(12) {
let kind_sym = aspect_unicode(&ap.kind);
let line = format!(
"{} {} {} · {:.1}°{}",
planet_unicode(&ap.from_body),
kind_sym,
planet_unicode(&ap.to_body),
ap.orb_deg,
match ap.applying {
Some(true) => " A",
Some(false) => " S",
None => "",
}
);
let prefix = if ap.module_id == "natal" {
String::new()
} else {
format!("[{}] ", ap.module_id)
};
grid = grid.child(
div()
.px(px(6.0))
.py(px(2.0))
.text_size(px(11.0))
.text_color(aspect_color(palette, &ap.kind))
.child(SharedString::from(format!("{}{}", prefix, line))),
);
}
footer = footer.child(grid);
}
div()
.flex()
.flex_col()
.items_center()
.gap(px(8.0))
.child(header)
.child(wheel)
.child(footer)
}
/// Pequeña pill con la etiqueta de un overlay activo. El borde toma
/// color según el "tipo" del módulo para ayudar a mapear a su anillo
/// en el wheel: natal = neutro, outer ring share (transit/synastry/
/// planetary_return) = palette.angle_highlight, inner overlays
/// (progression/solar_arc) = palette.house_cusp.
fn badge(theme: &Theme, palette: &AstroPalette, module_id: &str, label: &str, is_natal: bool) -> gpui::Div {
let border = if is_natal {
theme.border
} else {
match module_id {
"transit" | "synastry" | "planetary_return" => palette.angle_highlight,
"progression" | "solar_arc" => palette.house_cusp,
_ => theme.border,
}
};
div()
.px(px(8.0))
.py(px(2.0))
.rounded(px(10.0))
.bg(theme.bg_panel_alt.clone())
.border_1()
.border_color(border)
.text_size(px(10.0))
.text_color(theme.fg_text)
.child(SharedString::from(label.to_string()))
}
fn format_offset(minutes: i64) -> String {
if minutes == 0 {
return "⏱ ahora".to_string();
}
let sign = if minutes > 0 { '+' } else { '-' };
let m = minutes.unsigned_abs();
let days = m / (60 * 24);
let hours = (m / 60) % 24;
let mins = m % 60;
if days > 0 {
format!("{}{}d {:02}h {:02}m", sign, days, hours, mins)
} else if hours > 0 {
format!("{}{:02}h {:02}m", sign, hours, mins)
} else {
format!("{}{:02}m", sign, mins)
}
}
// =====================================================================
// Painting
// =====================================================================
// `Radii` + helpers migraron a `tahuantinsuyu-render` (crate
// agnóstico de surface, compila a WASM y nativo). Re-export para
// que el código del canvas siga refiriendo `Radii` sin cambiar
// imports en cada call site.
use tahuantinsuyu_render::Radii;
#[allow(clippy::too_many_arguments)]
// `hover_focus`: symbol del planeta hovereado en este frame (si lo
// hay). Las líneas de aspecto que NO tocan a ese planeta se opacan
// para que el usuario lea claramente "qué afecta a qué". Si `None`,
// todas las líneas se pintan a alpha plena.
fn paint_wheel(
bounds: Bounds<Pixels>,
window: &mut Window,
theme: &Theme,
palette: &AstroPalette,
layers: &[Layer],
ascendant_deg: f32,
midheaven_deg: f32,
rot_offset_deg: f32,
radii: Radii,
visibility: &HashMap<LayerKind, bool>,
hover_focus: Option<&str>,
) {
let (cx, cy) = bounds_center(bounds);
let show = |k: LayerKind| visibility.get(&k).copied().unwrap_or(true);
// 1. Sectores zodiacales (parte del SignDial layer).
if show(LayerKind::SignDial) {
paint_sign_sectors(window, cx, cy, &radii, palette, ascendant_deg, rot_offset_deg);
// Anillos del dial con efecto 3D: highlight interior + base +
// shadow exterior. El highlight es 1 px hacia el centro con
// luminancia +0.18; la shadow 1 px hacia afuera con -0.18.
// El bevel central — varios strokes finos con alpha en bell
// curve entre sign_inner y sign_outer — da volumen al dial.
stroke_circle_3d(window, cx, cy, radii.sign_outer, 1.5, palette.dial_ring, theme);
stroke_circle_3d(window, cx, cy, radii.sign_inner, 1.0, palette.dial_ring, theme);
paint_dial_bevel(window, cx, cy, &radii, palette, theme);
// Cusps zodiacales cada 30°.
for i in 0..12 {
let lon = (i as f32) * 30.0;
let color = palette.dial_ring;
paint_radial_line(
window,
cx,
cy,
lon,
ascendant_deg,
rot_offset_deg,
radii.sign_inner,
radii.sign_outer,
color,
1.0,
);
}
}
// 2. Casas — doble anillo (inner + outer) + cusps radiales +
// énfasis Asc/IC/Desc/MC. La doble línea vuelve a la zona de
// casas una "corona" claramente identificable. Color derivado
// de `house_cusp` con un hue shift para que el sistema
// ascensional (casas) se distinga visualmente del eclíptico
// (dial zodiacal) que va en dorado.
if show(LayerKind::Houses) {
let house_base = house_ring_color(palette);
let house_color = with_alpha(house_base, 0.85);
stroke_circle_3d(window, cx, cy, radii.houses_outer, 1.1, house_color, theme);
stroke_circle_3d(window, cx, cy, radii.houses_inner, 1.1, house_color, theme);
// Si hay capa topocéntrica activa, pintar también sus dos
// anillos (con stroke más sutil que el geocéntrico, para que
// se lea como "sistema ascensional" sin competir).
if layers
.iter()
.any(|l| matches!(l.kind, LayerKind::Houses) && l.module_id == "topocentric")
{
let topo_color = with_alpha(house_base, 0.55);
stroke_circle(window, cx, cy, radii.topo_houses_outer, 0.8, topo_color);
stroke_circle(window, cx, cy, radii.topo_houses_inner, 0.8, topo_color);
}
for layer in layers {
if matches!(layer.kind, LayerKind::Houses) {
let is_topo = layer.module_id == "topocentric";
let (r_in, r_out) = if is_topo {
(radii.topo_houses_inner, radii.topo_houses_outer)
} else {
(radii.houses_inner, radii.houses_outer)
};
if let Geometry::Ring { cusps_deg } = &layer.geometry {
for (i, c) in cusps_deg.iter().enumerate() {
let is_angle = i == 0 || i == 3 || i == 6 || i == 9;
let color = if is_topo {
with_alpha(house_base, 0.60)
} else if is_angle {
palette.angle_highlight
} else {
with_alpha(house_base, 0.75)
};
let width = if is_angle && !is_topo { 2.0 } else { 0.8 };
if is_topo {
// Topocéntrico: cusp como línea punteada
// en su propio anillo cercano al sign
// dial — se distingue del Placidus
// geocéntrico por el dash pattern y la
// ubicación más exterior.
paint_segment(
window,
cx
+ polar_to_screen(
*c,
ascendant_deg,
rot_offset_deg,
r_in,
)
.0,
cy
+ polar_to_screen(
*c,
ascendant_deg,
rot_offset_deg,
r_in,
)
.1,
cx
+ polar_to_screen(
*c,
ascendant_deg,
rot_offset_deg,
r_out,
)
.0,
cy
+ polar_to_screen(
*c,
ascendant_deg,
rot_offset_deg,
r_out,
)
.1,
color,
Some((3.0, 2.5)),
1.0,
);
} else {
paint_radial_line(
window,
cx,
cy,
*c,
ascendant_deg,
rot_offset_deg,
r_in,
r_out,
color,
width,
);
}
}
}
}
}
// Cruz completa Asc-Desc + MC-IC, alpha bastante visible para
// que orienten la lectura sin competir con cuerpos/aspectos.
// 4 radios desde el centro: ASC, DESC (=asc+180), MC, IC
// (=mc+180). `paint_radial_line` con r_inner=0 pinta un radio
// del centro al borde — la cruz es la unión de los 4.
let axis_color = with_alpha(palette.angle_highlight, 0.55);
for axis_deg in [
ascendant_deg,
ascendant_deg + 180.0,
midheaven_deg,
midheaven_deg + 180.0,
] {
paint_radial_line(
window,
cx,
cy,
axis_deg,
ascendant_deg,
rot_offset_deg,
0.0,
radii.houses_outer,
axis_color,
1.4,
);
}
}
// Aro D — único anillo visible del bloque de planetas natales
// (la idea del "carril doble" se descartó: confundía con el
// sistema de casas). El aro E (`radii.aspects`) no se pinta por
// diseño; solo es ancla invisible de las líneas.
if show(LayerKind::Bodies) {
let belt_color = with_alpha(palette.dial_ring, 0.55);
stroke_circle_3d(window, cx, cy, radii.houses_inner, 0.9, belt_color, theme);
// GR dual-ring: si las capas de direcciones primarias están
// presentes, marcar sus anillos para que el visual lea como
// "abrazo" del cinturón natal. La directa va punteada,
// la conversa también — la diferencia entre las dos es la
// ubicación radial (afuera vs adentro del cinturón natal).
let has_pd = layers.iter().any(|l| {
matches!(l.kind, LayerKind::Bodies)
&& (l.module_id == "pd_direct" || l.module_id == "pd_converse")
});
if has_pd {
let pd_color = with_alpha(palette.angle_highlight, 0.50);
for r in [radii.pd_direct, radii.pd_converse] {
// Pintamos el anillo como tramo punteado fino: 24
// segmentos cortos a lo largo del círculo.
let steps = 96;
for i in 0..steps {
if i % 2 != 0 {
continue;
}
let a0 = (i as f32) / (steps as f32) * std::f32::consts::TAU;
let a1 = ((i + 1) as f32) / (steps as f32) * std::f32::consts::TAU;
let x0 = cx + r * a0.cos();
let y0 = cy + r * a0.sin();
let x1 = cx + r * a1.cos();
let y1 = cy + r * a1.sin();
paint_segment(window, x0, y0, x1, y1, pd_color, None, 0.6);
}
}
}
}
// 3. Aspectos. Cada module_id usa su par de radios — natal-natal
// ambos en `aspects`, cross con transit en `bodies → transits`,
// cross con progression en `bodies → progression`.
if show(LayerKind::Aspects) {
let mono = palette.is_monochrome();
for layer in layers {
if matches!(layer.kind, LayerKind::Aspects) {
if let Geometry::Lines(segs) = &layer.geometry {
let (r_from, r_to) = radii.aspect_endpoints(&layer.module_id);
let is_cross = r_from != r_to;
for seg in segs {
// Filtro minors con orbe ancho: los aspectos
// menores (quincunx, semi-square, quintile…)
// solo se trazan si están MUY apretados
// (orbe ≤ 3°). Sobre 3° ensucian sin aportar.
if !is_major_aspect(&seg.kind) && seg.orb_deg.abs() > 3.0 {
continue;
}
let base = aspect_color(palette, &seg.kind);
let base = with_alpha(base, base.a * seg.opacity);
// Hover focus: si hay un planeta hovereado y
// este segmento NO lo toca, lo atenuamos al
// 18%; si lo toca o no hay hover, va pleno.
let touches_hover = hover_focus
.map(|sym| seg.from_body == sym || seg.to_body == sym)
.unwrap_or(true);
let factor = if touches_hover { 1.0 } else { 0.18 };
let color = with_alpha(base, base.a * factor);
let dash = if mono {
dash_pattern_for_kind(&seg.kind)
} else {
None
};
// Width inverso al orbe: orbes cerrados se ven
// gruesos (aspecto "fuerte"), orbes amplios
// finos. Mayores van un escalón más gruesos
// que menores en su mismo orbe.
let width = aspect_width(&seg.kind, seg.orb_deg, mono);
if is_cross {
paint_cross_aspect_line(
window,
cx,
cy,
seg.from_deg,
seg.to_deg,
ascendant_deg,
rot_offset_deg,
r_from,
r_to,
color,
dash,
);
} else {
paint_aspect_line(
window,
cx,
cy,
seg.from_deg,
seg.to_deg,
ascendant_deg,
rot_offset_deg,
r_from,
color,
dash,
width,
);
}
}
}
}
}
}
// 4. Marcadores de posición exacta. Antes el dot era "el planeta";
// ahora el glyph (con halo, en DOM) lo es. El círculo acá queda
// como marker de precisión angular — chico, alpha alta, sobre el
// anillo correspondiente. Glow se mantiene para Sol/Luna como
// toque místico, pero también reducido.
if show(LayerKind::Bodies) {
let dot_r = (radii.sign_outer * 0.009).max(1.5);
for layer in layers {
if matches!(layer.kind, LayerKind::Bodies) {
let ring = radii.body_ring(&layer.module_id);
let is_natal = layer.module_id == "natal";
let alpha = if is_natal { 1.0 } else { 0.85 };
for g in &layer.glyphs {
let color = with_alpha(planet_color(palette, &g.symbol), alpha);
let (x, y) = polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, ring);
if is_natal && (g.symbol == "sun" || g.symbol == "moon") {
paint_glow(window, cx + x, cy + y, dot_r * 1.8, color);
}
fill_circle(window, cx + x, cy + y, dot_r, color);
}
}
}
}
// Anillos guía para los overlays internos (progression, solar_arc).
let guide_inset = radii.sign_outer * 0.03;
for (module_id, ring) in [
("progression", radii.progression),
("solar_arc", radii.solar_arc),
] {
let active = layers
.iter()
.any(|l| matches!(l.kind, LayerKind::Bodies) && l.module_id == module_id);
if active {
stroke_circle(
window,
cx,
cy,
ring + guide_inset,
0.5,
with_alpha(palette.house_cusp, 0.35),
);
stroke_circle(
window,
cx,
cy,
ring - guide_inset,
0.5,
with_alpha(palette.house_cusp, 0.35),
);
}
}
// 5. Outer ring (transit o synastry overlay): anillo guía + dots
// de la capa activa. Son mutuamente excluyentes a nivel de Shell;
// si alguno de los dos está prendido, pintamos el slot.
let outer_active = layers.iter().any(|l| {
matches!(l.kind, LayerKind::Outer)
&& OUTER_RING_MODULES.contains(&l.module_id.as_str())
});
if outer_active && show(LayerKind::Outer) {
let band = radii.sign_outer * 0.035;
stroke_circle_3d(
window,
cx,
cy,
radii.transits + band,
0.7,
with_alpha(palette.dial_ring, 0.55),
theme,
);
stroke_circle_3d(
window,
cx,
cy,
radii.transits - band,
0.7,
with_alpha(palette.dial_ring, 0.55),
theme,
);
let dot_r = (radii.sign_outer * 0.008).max(1.5);
for layer in layers {
if matches!(layer.kind, LayerKind::Outer)
&& (OUTER_RING_MODULES.contains(&layer.module_id.as_str()))
{
for g in &layer.glyphs {
let color = with_alpha(planet_color(palette, &g.symbol), 0.85);
let (x, y) =
polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, radii.transits);
fill_circle(window, cx + x, cy + y, dot_r, color);
}
}
}
}
}
fn paint_sign_sectors(
window: &mut Window,
cx: f32,
cy: f32,
radii: &Radii,
palette: &AstroPalette,
ascendant_deg: f32,
rot_offset_deg: f32,
) {
const SUBDIVISIONS: usize = 18;
for i in 0..12 {
let lon_start = (i as f32) * 30.0;
let lon_end = lon_start + 30.0;
let element = sign_element_by_index(i);
let color = with_alpha(palette.element(element), 0.10);
let mut builder = PathBuilder::fill();
let (x0, y0) = polar_to_screen(lon_start, ascendant_deg, rot_offset_deg, radii.sign_inner);
builder.move_to(point(px(cx + x0), px(cy + y0)));
for k in 1..=SUBDIVISIONS {
let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32);
let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_inner);
builder.line_to(point(px(cx + x), px(cy + y)));
}
let (xe, ye) = polar_to_screen(lon_end, ascendant_deg, rot_offset_deg, radii.sign_outer);
builder.line_to(point(px(cx + xe), px(cy + ye)));
for k in (0..SUBDIVISIONS).rev() {
let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32);
let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_outer);
builder.line_to(point(px(cx + x), px(cy + y)));
}
builder.close();
if let Ok(path) = builder.build() {
window.paint_path(path, color);
}
}
}
fn stroke_circle(window: &mut Window, cx: f32, cy: f32, r: f32, width: f32, color: Hsla) {
const SEGMENTS: usize = 96;
let mut builder = PathBuilder::stroke(px(width));
for i in 0..=SEGMENTS {
let t = (i as f32) / (SEGMENTS as f32) * (2.0 * PI);
let x = cx + r * t.cos();
let y = cy + r * t.sin();
if i == 0 {
builder.move_to(point(px(x), px(y)));
} else {
builder.line_to(point(px(x), px(y)));
}
}
if let Ok(path) = builder.build() {
window.paint_path(path, color);
}
}
/// Pinta 3 halos concéntricos con alpha decreciente alrededor de un
/// punto — usado para Sol/Luna natales. El radio crece, la opacidad
/// cae: el ojo lo lee como "esto irradia". Sin glow real (GPUI 0.2 no
/// tiene radial gradient), pero el shading concéntrico convence.
fn paint_glow(window: &mut Window, cx: f32, cy: f32, base_r: f32, color: Hsla) {
const HALOS: [(f32, f32); 3] = [(5.0, 0.05), (3.0, 0.12), (1.8, 0.22)];
for (mult, alpha) in HALOS {
let r = base_r * mult;
let halo = hsla(color.h, color.s, color.l, alpha);
fill_circle(window, cx, cy, r, halo);
}
}
fn fill_circle(window: &mut Window, cx: f32, cy: f32, r: f32, color: Hsla) {
const SEGMENTS: usize = 32;
let mut builder = PathBuilder::fill();
builder.move_to(point(px(cx + r), px(cy)));
for i in 1..=SEGMENTS {
let t = (i as f32) / (SEGMENTS as f32) * (2.0 * PI);
let x = cx + r * t.cos();
let y = cy + r * t.sin();
builder.line_to(point(px(x), px(y)));
}
builder.close();
if let Ok(path) = builder.build() {
window.paint_path(path, color);
}
}
#[allow(clippy::too_many_arguments)]
fn paint_radial_line(
window: &mut Window,
cx: f32,
cy: f32,
longitude_deg: f32,
ascendant_deg: f32,
rot_offset_deg: f32,
r_inner: f32,
r_outer: f32,
color: Hsla,
width: f32,
) {
let (xi, yi) = polar_to_screen(longitude_deg, ascendant_deg, rot_offset_deg, r_inner);
let (xo, yo) = polar_to_screen(longitude_deg, ascendant_deg, rot_offset_deg, r_outer);
let mut builder = PathBuilder::stroke(px(width));
builder.move_to(point(px(cx + xi), px(cy + yi)));
builder.line_to(point(px(cx + xo), px(cy + yo)));
if let Ok(path) = builder.build() {
window.paint_path(path, color);
}
}
#[allow(clippy::too_many_arguments)]
fn paint_aspect_line(
window: &mut Window,
cx: f32,
cy: f32,
a_deg: f32,
b_deg: f32,
ascendant_deg: f32,
rot_offset_deg: f32,
r: f32,
color: Hsla,
dash: Option<(f32, f32)>,
width: f32,
) {
let (xa, ya) = polar_to_screen(a_deg, ascendant_deg, rot_offset_deg, r);
let (xb, yb) = polar_to_screen(b_deg, ascendant_deg, rot_offset_deg, r);
paint_segment(window, cx + xa, cy + ya, cx + xb, cy + yb, color, dash, width);
}
/// Línea de aspecto natal ↔ tránsito: extremos en radios distintos.
/// El `from_deg` cae sobre el ring de cuerpos natales (`r_from`); el
/// `to_deg` sobre el ring de tránsito (`r_to`). Trazo más fino que el
/// natal-natal para no competir visualmente.
#[allow(clippy::too_many_arguments)]
fn paint_cross_aspect_line(
window: &mut Window,
cx: f32,
cy: f32,
natal_deg: f32,
transit_deg: f32,
ascendant_deg: f32,
rot_offset_deg: f32,
r_from: f32,
r_to: f32,
color: Hsla,
dash: Option<(f32, f32)>,
) {
let (xa, ya) = polar_to_screen(natal_deg, ascendant_deg, rot_offset_deg, r_from);
let (xb, yb) = polar_to_screen(transit_deg, ascendant_deg, rot_offset_deg, r_to);
paint_segment(window, cx + xa, cy + ya, cx + xb, cy + yb, color, dash, 0.7);
}
/// Pinta un segmento entre dos puntos. Si `dash` es `Some((on, off))`,
/// itera el vector pintando trechos de `on` px con gaps de `off` px.
/// Si `None`, una sola línea continua. Usado por todos los aspect
/// painters — el dash pattern es la forma de distinguir kinds en
/// el theme BW (donde el color no sirve).
fn paint_segment(
window: &mut Window,
x0: f32,
y0: f32,
x1: f32,
y1: f32,
color: Hsla,
dash: Option<(f32, f32)>,
width: f32,
) {
let Some((on, off)) = dash else {
let mut b = PathBuilder::stroke(px(width));
b.move_to(point(px(x0), px(y0)));
b.line_to(point(px(x1), px(y1)));
if let Ok(p) = b.build() {
window.paint_path(p, color);
}
return;
};
let dx = x1 - x0;
let dy = y1 - y0;
let len = (dx * dx + dy * dy).sqrt();
if len < 0.1 {
return;
}
let ux = dx / len;
let uy = dy / len;
let step = on + off;
if step < 0.1 {
return;
}
let mut t = 0.0;
while t < len {
let t_end = (t + on).min(len);
let sx = x0 + ux * t;
let sy = y0 + uy * t;
let ex = x0 + ux * t_end;
let ey = y0 + uy * t_end;
let mut b = PathBuilder::stroke(px(width));
b.move_to(point(px(sx), px(sy)));
b.line_to(point(px(ex), px(ey)));
if let Ok(p) = b.build() {
window.paint_path(p, color);
}
t += step;
}
}
/// `true` para los 5 aspectos Ptoloméicos (conjunction, sextile,
/// square, trine, opposition). Cualquier otro `kind` se considera
/// menor — quincunx, semi-square, quintile, sesquiquadrate, etc.
fn is_major_aspect(kind: &str) -> bool {
matches!(
kind,
"conjunction" | "sextile" | "square" | "trine" | "opposition"
)
}
/// Grosor de línea de aspecto inverso al orbe. La idea: a orbe 0°
/// (aspecto exacto) la línea va gruesa porque "pesa" más; a orbe
/// amplio se afina. Los mayores arrancan en un techo más alto que
/// los menores. En BW se le suma un poquito a todos porque las
/// líneas competen con sus dash patterns.
fn aspect_width(kind: &str, orb_deg: f32, mono: bool) -> f32 {
let orb = orb_deg.abs();
let major = is_major_aspect(kind);
// Orbe de referencia para normalizar: ~8° para mayores, ~3° para
// menores. Más allá la línea ya está afinada al mínimo.
let max_orb = if major { 8.0 } else { 3.0 };
let t = (1.0 - (orb / max_orb)).clamp(0.0, 1.0);
let (min_w, max_w) = if major { (0.7, 2.1) } else { (0.5, 1.2) };
let w = min_w + (max_w - min_w) * t;
if mono { w + 0.2 } else { w }
}
/// Dash pattern por aspecto, para modo monocromático. En modo color
/// el caller pasa `None` y las líneas van sólidas. Patterns elegidos
/// para que cada kind sea distinguible a ojo:
/// - conjunction/opposition: sólido (más peso visual, son los
/// aspectos "fuertes")
/// - square: dash medio (4 on / 3 off)
/// - trine: dash largo (8 on / 2 off) — casi sólido pero distinguible
/// - sextile: dotted (1.5 on / 3 off)
/// - minor: dotted finísimo (1 on / 4 off)
fn dash_pattern_for_kind(kind: &str) -> Option<(f32, f32)> {
match kind {
"conjunction" | "opposition" => None,
"square" => Some((4.0, 3.0)),
"trine" => Some((8.0, 2.0)),
"sextile" => Some((1.5, 3.0)),
_ => Some((1.0, 4.0)),
}
}
// =====================================================================
// Helpers
// =====================================================================
/// Distancia mínima entre un punto y un segmento de recta. Usado por
/// hover_check para detectar proximity a líneas de aspectos.
fn dist_point_segment(px: f32, py: f32, ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
let dx = bx - ax;
let dy = by - ay;
let len_sq = dx * dx + dy * dy;
if len_sq < f32::EPSILON {
// Segmento degenerado → distancia al punto a.
let pdx = px - ax;
let pdy = py - ay;
return (pdx * pdx + pdy * pdy).sqrt();
}
let t = (((px - ax) * dx + (py - ay) * dy) / len_sq).clamp(0.0, 1.0);
let proj_x = ax + t * dx;
let proj_y = ay + t * dy;
let dx2 = px - proj_x;
let dy2 = py - proj_y;
(dx2 * dx2 + dy2 * dy2).sqrt()
}
// `polar_to_screen` se importa desde `tahuantinsuyu-render`.
use tahuantinsuyu_render::polar_to_screen;
fn centered_glyph(
x: f32,
y: f32,
box_size: f32,
font_size: f32,
text: SharedString,
color: Hsla,
) -> gpui::Div {
div()
.absolute()
.left(px(x - box_size / 2.0))
.top(px(y - box_size / 2.0))
.w(px(box_size))
.h(px(box_size))
.flex()
.items_center()
.justify_center()
.text_size(px(font_size))
.text_color(color)
.child(text)
}
/// Glyph de planeta con disco-halo detrás del char. El disco viene en
/// `disk_bg` (semi-opaco para que se vea a través el fondo del wheel)
/// y `disk_border` (típicamente el color del planeta). El char por
/// dentro va en `text_color` — recomendado el color del planeta sobre
/// disco neutro, o color contrastante sobre disco coloreado.
fn planet_glyph(
x: f32,
y: f32,
disk_size: f32,
font_size: f32,
text: SharedString,
text_color: Hsla,
disk_bg: Hsla,
disk_border: Hsla,
) -> gpui::Div {
div()
.absolute()
.left(px(x - disk_size / 2.0))
.top(px(y - disk_size / 2.0))
.w(px(disk_size))
.h(px(disk_size))
.rounded_full()
.bg(disk_bg)
.border_1()
.border_color(disk_border)
.flex()
.items_center()
.justify_center()
.text_size(px(font_size))
.text_color(text_color)
.child(text)
}
/// Disco base (px) de un body glyph según `module_id` y kind. Lo
/// usan render_wheel (para pintar) y on_hover_check (para
/// hit-testear) — ambos deben coincidir o el hover apunta a una
/// posición distinta a donde se pinta el disco.
fn body_disk_base(module_id: &str, kind: LayerKind, view_scale: f32) -> f32 {
let base = match kind {
LayerKind::Outer => 20.0,
LayerKind::Midpoints => 16.0,
_ => match module_id {
"natal" => 26.0,
"topocentric" => 22.0,
"pd_direct" | "pd_converse" => 20.0,
_ => 22.0,
},
};
base * view_scale
}
// `spread_angles` y `find_clusters` migraron a `tahuantinsuyu-render`.
use tahuantinsuyu_render::{find_clusters, spread_angles};
// `format_coord_compact` migró a `tahuantinsuyu-render`.
use tahuantinsuyu_render::format_coord_compact;
// Los tests de `spread_angles`, `find_clusters` y
// `format_coord_compact` viven ahora en `tahuantinsuyu-render::math`
// junto a sus implementaciones.
/// Pill pequeña con un coord ("14°♈") junto al glyph de un planeta
/// o cusp. Fondo halo + texto fg_muted, padding mínimo para no
/// saturar la rueda con etiquetas grandes.
fn coord_label(
x: f32,
y: f32,
text: SharedString,
fg: Hsla,
halo_bg: Hsla,
font_size: f32,
) -> gpui::Div {
// Estimación del ancho basada en `chars().count()` (NO `text.len()`
// — los chars unicode astronómicos cuentan 3 bytes pero ocupan
// ~1 columna de fuente). Padding lateral muy pequeño en lugar de
// un mínimo grande: pills con 1-3 chars no llevan "espacios en
// negro" que sobrescriben elementos vecinos.
let char_count = text.chars().count() as f32;
let w = (char_count * font_size * 0.62 + font_size * 0.5).max(font_size * 1.4);
let h = font_size + 5.0;
div()
.absolute()
.left(px(x - w / 2.0))
.top(px(y - h / 2.0))
.w(px(w))
.h(px(h))
.flex()
.items_center()
.justify_center()
.rounded(px(h / 2.0))
.bg(halo_bg)
.text_size(px(font_size))
.text_color(fg)
.child(text)
}
/// Color HSL semi-opaco para los halos de los glyphs — derivado del
/// theme. En dark va casi negro; en light casi blanco. Alpha alta para
/// que el char quede legible contra cualquier cosa que haya detrás
/// (anillo, líneas de aspecto, starfield).
fn glyph_halo(theme: &Theme) -> Hsla {
if theme.is_dark {
hsla(0.0, 0.0, 0.07, 0.92)
} else {
hsla(0.0, 0.0, 0.97, 0.92)
}
}
fn with_alpha(c: Hsla, a: f32) -> Hsla {
hsla(c.h, c.s, c.l, a.clamp(0.0, 1.0))
}
/// Devuelve `c` con la luminancia modificada por `delta` (clamp 0..1).
/// Útil para derivar highlight (+luma) y shadow (-luma) de un color
/// base manteniendo hue y saturación — efecto bevel/3D barato.
fn adjust_luma(c: Hsla, delta: f32) -> Hsla {
hsla(c.h, c.s, (c.l + delta).clamp(0.0, 1.0), c.a)
}
/// Devuelve `c` con el hue desplazado `delta_deg` grados sobre el
/// círculo cromático (wrap a [0,1] en la escala normalizada de gpui).
/// Usado para derivar el color del anillo de casas desde el del dial
/// zodiacal — los dos sistemas (eclíptica vs ascensional) deben
/// distinguirse a primera vista pero compartir "familia" cromática.
fn shift_hue(c: Hsla, delta_deg: f32) -> Hsla {
let new_h = (c.h + delta_deg / 360.0).rem_euclid(1.0);
hsla(new_h, c.s, c.l, c.a)
}
/// Color para los anillos del sistema de casas (ascensional). En
/// paletas con color, lo derivamos de `house_cusp` con un hue shift
/// de ~140° para diferenciar de la eclíptica (que va con el dorado
/// de `dial_ring`). En BW devolvemos `house_cusp` tal cual — un
/// shift cromático en monocromo es ruido sin información.
fn house_ring_color(palette: &AstroPalette) -> Hsla {
if palette.is_monochrome() {
palette.house_cusp
} else {
shift_hue(palette.house_cusp, 140.0)
}
}
/// Stroke con efecto embossed: 3 trazos concéntricos. El highlight va
/// 0.7 px hacia el centro con luminancia subida; el principal en `r`;
/// el shadow 0.7 px hacia afuera con luminancia bajada. La dirección
/// del bevel depende del theme: en dark el highlight es exterior (luz
/// "desde arriba"), en light interior (sombra "desde arriba" hacia
/// el centro).
fn stroke_circle_3d(
window: &mut Window,
cx: f32,
cy: f32,
r: f32,
width: f32,
color: Hsla,
theme: &Theme,
) {
let (hl_offset, sh_offset) = if theme.is_dark {
(-0.7, 0.7)
} else {
(0.7, -0.7)
};
let hl = with_alpha(adjust_luma(color, 0.20), color.a * 0.55);
let sh = with_alpha(adjust_luma(color, -0.18), color.a * 0.55);
stroke_circle(window, cx, cy, r + hl_offset, (width * 0.7).max(0.4), hl);
stroke_circle(window, cx, cy, r, width, color);
stroke_circle(window, cx, cy, r + sh_offset, (width * 0.7).max(0.4), sh);
}
/// Bevel central del anillo de signos: ~10 strokes finos entre
/// sign_inner y sign_outer, con alpha en bell curve (máximo en el
/// medio, decae hacia los bordes). Genera la sensación de volumen
/// sin pintar gradient radial (no soportado en gpui canvas).
fn paint_dial_bevel(
window: &mut Window,
cx: f32,
cy: f32,
radii: &Radii,
palette: &AstroPalette,
theme: &Theme,
) {
let steps = 10;
let base = if theme.is_dark { 0.07 } else { 0.10 };
let color = palette.dial_ring;
for i in 0..steps {
let t = (i as f32 + 0.5) / steps as f32;
let r = radii.sign_inner + (radii.sign_outer - radii.sign_inner) * t;
// Bell curve simétrica: |t-0.5|*2 da 0..1 desde el centro, lo
// invertimos para que el centro tenga peso máximo.
let bell = 1.0 - ((t - 0.5).abs() * 2.0);
let a = base * bell;
stroke_circle(window, cx, cy, r, 1.0, with_alpha(color, a));
}
}
fn sign_unicode(name: &str) -> &'static str {
match name {
"aries" => "",
"taurus" => "",
"gemini" => "",
"cancer" => "",
"leo" => "",
"virgo" => "",
"libra" => "",
"scorpio" => "",
"sagittarius" => "",
"capricorn" => "",
"aquarius" => "",
"pisces" => "",
_ => "?",
}
}
const SIGN_NAMES_ES: [&str; 12] = [
"Aries",
"Tauro",
"Géminis",
"Cáncer",
"Leo",
"Virgo",
"Libra",
"Escorpio",
"Sagitario",
"Capricornio",
"Acuario",
"Piscis",
];
fn aspect_unicode(kind: &str) -> &'static str {
match kind {
"conjunction" => "",
"opposition" => "",
"trine" => "",
"square" => "",
"sextile" => "",
"quincunx" => "",
"semi_sextile" => "",
"semi_square" => "",
"sesquiquadrate" => "",
"quintile" => "Q",
"biquintile" => "bQ",
_ => "·",
}
}
fn planet_unicode(name: &str) -> &'static str {
match name {
"sun" => "",
"moon" => "",
"mercury" => "",
"venus" => "",
"mars" => "",
"jupiter" => "",
"saturn" => "",
"uranus" => "",
"neptune" => "",
"pluto" => "",
"north_node" => "",
"south_node" => "",
"chiron" => "",
"lilith" => "",
"ceres" => "",
"pallas" => "",
"juno" => "",
"vesta" => "",
_ => "",
}
}
fn planet_color(p: &AstroPalette, name: &str) -> Hsla {
let planet = match name {
"sun" => Planet::Sun,
"moon" => Planet::Moon,
"mercury" => Planet::Mercury,
"venus" => Planet::Venus,
"mars" => Planet::Mars,
"jupiter" => Planet::Jupiter,
"saturn" => Planet::Saturn,
"uranus" => Planet::Uranus,
"neptune" => Planet::Neptune,
"pluto" => Planet::Pluto,
"chiron" => Planet::Chiron,
"north_node" => Planet::NorthNode,
"south_node" => Planet::SouthNode,
"lilith" => Planet::Lilith,
_ => return p.fg_text_fallback(),
};
p.planet(planet)
}
fn sign_element_by_index(i: usize) -> Element {
match i % 4 {
0 => Element::Fire,
1 => Element::Earth,
2 => Element::Air,
_ => Element::Water,
}
}
fn element_color_for_sign(p: &AstroPalette, name: &str) -> Hsla {
let elem = match name {
"aries" | "leo" | "sagittarius" => Element::Fire,
"taurus" | "virgo" | "capricorn" => Element::Earth,
"gemini" | "libra" | "aquarius" => Element::Air,
"cancer" | "scorpio" | "pisces" => Element::Water,
_ => return p.fg_text_fallback(),
};
p.element(elem)
}
fn aspect_color(p: &AstroPalette, kind: &str) -> Hsla {
let k = match kind {
"conjunction" => TAspectKind::Conjunction,
"opposition" => TAspectKind::Opposition,
"trine" => TAspectKind::Trine,
"square" => TAspectKind::Square,
"sextile" => TAspectKind::Sextile,
"quincunx" => TAspectKind::Quincunx,
"semi_sextile" => TAspectKind::Semisextile,
"semi_square" => TAspectKind::Semisquare,
"sesquiquadrate" => TAspectKind::Sesquisquare,
"quintile" => TAspectKind::Quintile,
"biquintile" => TAspectKind::Biquintile,
_ => return p.minor_aspect,
};
p.aspect(k)
}
trait AstroPaletteExt {
fn fg_text_fallback(&self) -> Hsla;
}
impl AstroPaletteExt for AstroPalette {
fn fg_text_fallback(&self) -> Hsla {
if self.is_dark {
hsla(0.0, 0.0, 0.85, 1.0)
} else {
hsla(0.0, 0.0, 0.25, 1.0)
}
}
}