//! `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 }, Thumbnails { scope: ThumbnailScope, items: Vec, }, } #[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, pub preview: Option, } /// 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, drag_jog: Option, } 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 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 { cx.observe_global::(|_, 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.state.mode = mode; cx.notify(); } pub fn set_layer_visible(&mut self, kind: LayerKind, visible: bool, cx: &mut Context) { self.state.layer_visibility.insert(kind, visible); cx.notify(); } pub fn toggle_layer(&mut self, kind: LayerKind, cx: &mut Context) { 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) { 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.state.view_rotation_deg = deg.rem_euclid(360.0); cx.notify(); } // ----- Internos: handlers de jog-dial ----- fn on_jog_down( &mut self, position: Point, bounds: Bounds, cx: &mut Context, ) { 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, bounds: Bounds, cx: &mut Context, ) { 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) { 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) { 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) -> (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) -> 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, entity: gpui::Entity, ) -> 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 = 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, _w, _cx| (), move |bounds: Bounds, _, 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, window: &mut Window, theme: &Theme, palette: &AstroPalette, layers: &[Layer], ascendant_deg: f32, midheaven_deg: f32, rot_offset_deg: f32, radii: Radii, visibility: &HashMap, ) { 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) } } }