//! `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 }, 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, } /// 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, 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, /// 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, drag_jog: Option, drag_pan: Option, } /// 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, retrograde: bool, dignity_marker: Option, annotation: Option, 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 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::(|_, 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, modifiers: gpui::Modifiers, bounds: Bounds, 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, bounds: Bounds, 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, bounds: Bounds, 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 = 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, _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, 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, 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) -> (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) -> 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, show_coords: bool, hover: Option<&HoverInfo>, entity: gpui::Entity, ) -> 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 = 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 = match hover { Some(HoverInfo::Body { symbol, .. }) => Some(symbol.clone()), _ => None, }; 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, 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 = 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 = 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 = 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 = hovered_sym.and_then(|sym| { layer.glyphs.iter().position(|g| g.symbol == sym) }); let hovered_cluster: Option = 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::>() .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 = 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::>() .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, window: &mut Window, theme: &Theme, palette: &AstroPalette, layers: &[Layer], ascendant_deg: f32, midheaven_deg: f32, rot_offset_deg: f32, radii: Radii, visibility: &HashMap, 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) } } }