Files
brahman/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
T
sergio 1a3bc55016 feat(tahuantinsuyu): fase 9 — Solar Arc como segundo overlay
Confirma que la arquitectura de fase 6 escala: tres overlays simultáneos
(transit + progression + solar_arc) sin acoplamiento entre módulos, y
sin tocar el flujo del Shell salvo registrar el nuevo branch.

Tres puntos de extensión por overlay nuevo (exactamente los predichos):
1. variante en PipelineRequest
2. helper build_*_overlay en bridge + match arm en compose
3. módulo declarativo en modules/ + registro

- engine: PipelineRequest::SolarArc { target_age_years: f64 } +
  build_solar_arc_overlay que llama solar_arc_true(natal, session, age)
  → desplaza uniformemente cada placement y cusp por el arco solar
  (default ≈1°/año, vía true progressed Sun). Cross aspects natal ×
  dirigida vía find_synastry_aspects(majors). Layers con
  module_id="solar_arc" y z=8/9 (sobre todos los demás).
- modules: solar_arc::SolarArcModule con id="solar_arc", toggle
  "Activar" + slider target_age_years 0..120. Mismo shape que
  ProgressionModule. Registry.with_builtins lo registra. Test pasó a
  4 módulos aplicables a ChartKind::Natal.
- canvas: Radii.solar_arc = 0.40 (entre progression 0.48 y aspects),
  aspects shrunk a 0.32 para hacer lugar. Helpers Radii::body_ring()
  y Radii::aspect_endpoints() ahora reconocen "solar_arc". paint_wheel
  itera ambos overlays (progression + solar_arc) para dibujar dots,
  glyph overlays y anillos guía sutiles. Loop común `for (id, ring) in
  [..]` evita duplicación de código.
- shell: build_requests detecta solar_arc.enabled, agrega request con
  edad. apply_selection inicializa target_age_years para ambos
  overlays (progression + solar_arc) en current_age + sincroniza los
  sliders del panel. Helper module_age_or_current(id) factoriza la
  lectura de edad con fallback.

Activando los tres overlays al mismo tiempo el canvas se convierte en
una rueda de cinco anillos: zodíaco (1.00), tránsito (0.82), natal
(0.66-0.78), bodies natal (0.58), progression (0.48), solar arc (0.40),
con líneas de aspectos cross convergiendo desde el ring natal hacia
cada overlay simultáneamente.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 10:59:01 +00:00

1276 lines
41 KiB
Rust

//! `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::{
AppContext, Bounds, Context, EventEmitter, FocusHandle, Focusable, Hsla, IntoElement,
KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement,
PathBuilder, Pixels, Point, Render, SharedString, Styled, Window, canvas, div, hsla, point,
prelude::*, px,
};
use tahuantinsuyu_engine::{Geometry, Layer, LayerKind, 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 },
}
// =====================================================================
// 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,
}
#[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,
/// Por-LayerKind: `true` = visible. Default = todo visible.
pub layer_visibility: HashMap<LayerKind, bool>,
drag_jog: Option<JogDragState>,
}
impl Default for CanvasState {
fn default() -> Self {
Self {
mode: CanvasMode::default(),
view_rotation_deg: 0.0,
time_offset_minutes: 0,
layer_visibility: HashMap::new(),
drag_jog: 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();
}
// ----- Internos: handlers de jog-dial -----
fn on_jog_down(
&mut self,
position: Point<Pixels>,
bounds: Bounds<Pixels>,
cx: &mut Context<Self>,
) {
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 = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
let radii = Radii::from_outer(r_outer);
// Aro de captura un poco más generoso que el anillo del dial.
if dist < radii.sign_inner * 0.95 || dist > radii.sign_outer * 1.10 {
return;
}
let angle = dy.atan2(dx).to_degrees();
self.state.drag_jog = Some(JogDragState {
last_screen_angle_deg: angle,
accumulated_delta_deg: 0.0,
});
}
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();
}
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;
}
_ => return,
};
self.toggle_layer(kind, cx);
}
}
// =====================================================================
// Geometría de pantalla
// =====================================================================
const WHEEL_SIZE: f32 = 580.0;
const WHEEL_MARGIN: f32 = 28.0;
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)
}
// =====================================================================
// 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.layer_visibility,
entity,
),
CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items),
};
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);
}),
)
.size_full()
.bg(theme.bg_panel.clone())
.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,
layer_visibility: &HashMap<LayerKind, bool>,
entity: gpui::Entity<AstrologyCanvas>,
) -> gpui::Div {
let asc = render.ascendant_deg;
let rot_offset = view_rotation_deg;
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();
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,
);
// Handlers de mouse para el jog-dial — se registran cada
// frame contra el window; GPUI los reemplaza al re-renderear.
let entity_d = entity_for_canvas.clone();
window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| {
if ev.button != MouseButton::Left {
return;
}
if !bounds.contains(&ev.position) {
return;
}
entity_d.update(cx, |this, cx| this.on_jog_down(ev.position, bounds, cx));
});
let entity_m = entity_for_canvas.clone();
window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
if !ev.dragging() {
return;
}
entity_m.update(cx, |this, cx| this.on_jog_move(ev.position, bounds, cx));
});
let entity_u = entity_for_canvas.clone();
window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
entity_u.update(cx, |this, cx| this.on_jog_up(cx));
});
},
)
.absolute()
.w(px(WHEEL_SIZE))
.h(px(WHEEL_SIZE));
let mut wheel = div()
.relative()
.w(px(WHEEL_SIZE))
.h(px(WHEEL_SIZE))
.child(canvas_element);
// 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,
18.0,
sign_unicode(&g.symbol).into(),
color,
));
}
}
}
}
// House numbers.
if visible.get(&LayerKind::Houses).copied().unwrap_or(true) {
let house_label_r = (radii.houses_outer + radii.houses_inner) / 2.0;
for layer in &render.layers {
if matches!(layer.kind, LayerKind::Houses) {
for g in &layer.glyphs {
let (x, y) = polar_to_screen(g.deg, asc, rot_offset, house_label_r);
if let Some(h) = g.house {
wheel = wheel.child(centered_glyph(
cx_center + x,
cy_center + y,
16.0,
10.0,
format!("{}", h).into(),
palette.house_cusp,
));
}
}
}
}
}
// Planet glyphs: natal (en `bodies`) + overlays en sus rings
// (progression, solar_arc) con alpha + tamaño más chico para
// diferenciarse visualmente del natal.
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 ring = radii.body_ring(&layer.module_id);
let alpha = if is_natal { 1.0 } else { 0.85 };
let font_size = if is_natal { 18.0 } else { 14.0 };
let box_size = if is_natal { 24.0 } else { 20.0 };
for g in &layer.glyphs {
let (x, y) = polar_to_screen(g.deg, asc, rot_offset, ring);
let color = with_alpha(planet_color(palette, &g.symbol), alpha);
let glyph_text = if g.retrograde {
format!("{}ᴿ", planet_unicode(&g.symbol))
} else {
planet_unicode(&g.symbol).into()
};
wheel = wheel.child(centered_glyph(
cx_center + x,
cy_center + y,
box_size,
font_size,
glyph_text.into(),
color,
));
}
}
}
}
// Planet glyphs (transit ring) — solo si la capa Outer está activa.
if visible.get(&LayerKind::Outer).copied().unwrap_or(true) {
for layer in &render.layers {
if matches!(layer.kind, LayerKind::Outer) && layer.module_id == "transit" {
for g in &layer.glyphs {
let (x, y) = polar_to_screen(g.deg, asc, rot_offset, radii.transits);
let color = with_alpha(planet_color(palette, &g.symbol), 0.9);
let glyph_text = if g.retrograde {
format!("{}ᴿ", planet_unicode(&g.symbol))
} else {
planet_unicode(&g.symbol).into()
};
wheel = wheel.child(centered_glyph(
cx_center + x,
cy_center + y,
20.0,
14.0,
glyph_text.into(),
color,
));
}
}
}
}
// --- 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
};
let offset_label = format_offset(time_offset_minutes);
let offset_color = if time_offset_minutes == 0 {
theme.fg_disabled
} else {
palette.angle_highlight
};
let footer = 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 [R]eset"),
);
div()
.flex()
.flex_col()
.items_center()
.gap(px(8.0))
.child(header)
.child(wheel)
.child(footer)
}
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
// =====================================================================
#[derive(Clone, Copy)]
struct Radii {
sign_outer: f32,
sign_inner: f32,
/// Anillo de glifos de tránsito (cuando el overlay está activo).
transits: f32,
houses_outer: f32,
houses_inner: f32,
bodies: f32,
/// Anillo interno con cuerpos progresados (overlay opcional).
progression: f32,
/// Anillo más interno con cuerpos dirigidos por Solar Arc (overlay
/// opcional). Si tanto progression como solar_arc están activos,
/// progression va afuera (más cerca de bodies natales) y solar_arc
/// adentro (entre progression y aspects).
solar_arc: f32,
aspects: f32,
}
impl Radii {
fn from_outer(r: f32) -> Self {
Self {
sign_outer: r,
sign_inner: r * 0.88,
transits: r * 0.82,
houses_outer: r * 0.78,
houses_inner: r * 0.66,
bodies: r * 0.58,
progression: r * 0.48,
solar_arc: r * 0.40,
aspects: r * 0.32,
}
}
/// Radio del ring de cuerpos según el `module_id` del Layer.
fn body_ring(&self, module_id: &str) -> f32 {
match module_id {
"progression" => self.progression,
"solar_arc" => self.solar_arc,
_ => self.bodies,
}
}
/// Resuelve qué radios corresponden a una capa de aspectos según el
/// `module_id`: natal-natal en `aspects`, cross con cada overlay
/// desde `bodies` (extremo natal) al ring del módulo.
fn aspect_endpoints(&self, module_id: &str) -> (f32, f32) {
match module_id {
"transit" => (self.bodies, self.transits),
"progression" => (self.bodies, self.progression),
"solar_arc" => (self.bodies, self.solar_arc),
_ => (self.aspects, self.aspects),
}
}
}
#[allow(clippy::too_many_arguments)]
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>,
) {
let (cx, cy) = bounds_center(bounds);
let _ = theme;
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.
stroke_circle(window, cx, cy, radii.sign_outer, 1.5, palette.dial_ring);
stroke_circle(window, cx, cy, radii.sign_inner, 1.0, palette.dial_ring);
// 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 — cusps radiales + énfasis Asc/IC/Desc/MC.
if show(LayerKind::Houses) {
stroke_circle(
window,
cx,
cy,
radii.houses_inner,
0.8,
with_alpha(palette.house_cusp, 0.6),
);
for layer in layers {
if matches!(layer.kind, LayerKind::Houses) {
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_angle {
palette.angle_highlight
} else {
with_alpha(palette.house_cusp, 0.7)
};
let width = if is_angle { 2.0 } else { 0.8 };
paint_radial_line(
window,
cx,
cy,
*c,
ascendant_deg,
rot_offset_deg,
radii.houses_inner,
radii.houses_outer,
color,
width,
);
}
}
}
}
// Asc + MC extendidos hasta el centro con opacidad sutil.
paint_radial_line(
window,
cx,
cy,
ascendant_deg,
ascendant_deg,
rot_offset_deg,
0.0,
radii.houses_outer,
with_alpha(palette.angle_highlight, 0.35),
1.0,
);
paint_radial_line(
window,
cx,
cy,
midheaven_deg,
ascendant_deg,
rot_offset_deg,
0.0,
radii.houses_outer,
with_alpha(palette.angle_highlight, 0.35),
1.0,
);
}
// 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) {
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 {
let color = aspect_color(palette, &seg.kind);
let color = with_alpha(color, color.a * seg.opacity);
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,
);
} else {
paint_aspect_line(
window,
cx,
cy,
seg.from_deg,
seg.to_deg,
ascendant_deg,
rot_offset_deg,
r_from,
color,
);
}
}
}
}
}
}
// 4. Dots de cuerpos: natal en `bodies`, overlays en sus rings
// específicos (progression, solar_arc).
if show(LayerKind::Bodies) {
let dot_r = (radii.sign_outer * 0.018).max(2.0);
for layer in layers {
if matches!(layer.kind, LayerKind::Bodies) {
let ring = radii.body_ring(&layer.module_id);
let alpha = if layer.module_id == "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);
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 overlay): anillo guía + dots de transit.
let transit_active = layers
.iter()
.any(|l| matches!(l.kind, LayerKind::Outer) && l.module_id == "transit");
if transit_active && show(LayerKind::Outer) {
// Anillos guía para delimitar el slot.
stroke_circle(
window,
cx,
cy,
radii.transits + radii.sign_outer * 0.035,
0.6,
with_alpha(palette.dial_ring, 0.4),
);
stroke_circle(
window,
cx,
cy,
radii.transits - radii.sign_outer * 0.035,
0.6,
with_alpha(palette.dial_ring, 0.4),
);
let dot_r = (radii.sign_outer * 0.017).max(2.0);
for layer in layers {
if matches!(layer.kind, LayerKind::Outer) && layer.module_id == "transit" {
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);
}
}
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,
) {
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);
let mut builder = PathBuilder::stroke(px(1.0));
builder.move_to(point(px(cx + xa), px(cy + ya)));
builder.line_to(point(px(cx + xb), px(cy + yb)));
if let Ok(path) = builder.build() {
window.paint_path(path, color);
}
}
/// 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,
) {
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);
let mut builder = PathBuilder::stroke(px(0.7));
builder.move_to(point(px(cx + xa), px(cy + ya)));
builder.line_to(point(px(cx + xb), px(cy + yb)));
if let Ok(path) = builder.build() {
window.paint_path(path, color);
}
}
// =====================================================================
// Helpers
// =====================================================================
fn polar_to_screen(
longitude_deg: f32,
ascendant_deg: f32,
rot_offset_deg: f32,
radius: f32,
) -> (f32, f32) {
let deg = 180.0 - (longitude_deg - ascendant_deg + rot_offset_deg);
let rad = deg * PI / 180.0;
(radius * rad.cos(), radius * rad.sin())
}
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)
}
fn with_alpha(c: Hsla, a: f32) -> Hsla {
hsla(c.h, c.s, c.l, a.clamp(0.0, 1.0))
}
fn sign_unicode(name: &str) -> &'static str {
match name {
"aries" => "",
"taurus" => "",
"gemini" => "",
"cancer" => "",
"leo" => "",
"virgo" => "",
"libra" => "",
"scorpio" => "",
"sagittarius" => "",
"capricorn" => "",
"aquarius" => "",
"pisces" => "",
_ => "?",
}
}
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)
}
}
}