diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs index 631f9ab..c050a89 100644 --- a/crates/apps/tahuantinsuyu/src/shell.rs +++ b/crates/apps/tahuantinsuyu/src/shell.rs @@ -22,7 +22,7 @@ use gpui::{ use tahuantinsuyu_canvas::{ AstrologyCanvas, CanvasEvent, CanvasMode, ThumbnailItem, ThumbnailScope, }; -use tahuantinsuyu_engine::{LayerKind, compute_at_offset}; +use tahuantinsuyu_engine::{LayerKind, compute_at_offset, compute_with_transits_at_now}; use tahuantinsuyu_model::{Chart, TreeSelection}; use tahuantinsuyu_panel::{ControlPanel, PanelEvent}; use tahuantinsuyu_store::Store; @@ -44,6 +44,9 @@ pub struct Shell { /// recomputarla con time-offsets sin re-leer la DB cada vez. current_chart: Option, current_offset_minutes: i64, + /// Overlay de tránsitos al instante actual sobre la natal. Disparado + /// por el toggle `show_transits` del panel o la hotkey `[T]`. + show_transits: bool, } impl Shell { @@ -78,6 +81,7 @@ impl Shell { panel, current_chart: None, current_offset_minutes: 0, + show_transits: false, } } @@ -164,12 +168,20 @@ impl Shell { let Some(chart) = self.current_chart.as_ref() else { return; }; - let render = match compute_at_offset(chart, self.current_offset_minutes) { + let result = if self.show_transits { + compute_with_transits_at_now(chart, self.current_offset_minutes) + } else { + compute_at_offset(chart, self.current_offset_minutes) + }; + let render = match result { Ok(r) => r, Err(e) => { eprintln!( - "[shell] compute_at_offset {} (+{}min): {}", - chart.id, self.current_offset_minutes, e + "[shell] compute {}{} (+{}min): {}", + chart.id, + if self.show_transits { " +transits" } else { "" }, + self.current_offset_minutes, + e ); return; } @@ -193,8 +205,17 @@ impl Shell { } } CanvasEvent::LayerVisibilityChanged { kind, visible } => { - // Sync el panel para que el toggle visual coincida con - // lo que disparó el hotkey en el canvas. + // El toggle de Outer (hotkey [T]) significa "transit + // overlay" — no es solo un layer hide, dispara un + // recompute distinto. El resto son visibility puros. + if matches!(kind, LayerKind::Outer) { + self.show_transits = *visible; + self.panel.update(cx, |p, cx| { + p.set_toggle("natal", "show_transits", *visible, cx) + }); + self.render_current(cx); + return; + } let key = match kind { LayerKind::SignDial => "show_sign_dial", LayerKind::Houses => "show_houses", @@ -213,9 +234,19 @@ impl Shell { fn on_panel_event(&mut self, ev: &PanelEvent, cx: &mut Context) { match ev { - PanelEvent::ControlChanged { module_id, key, value } => { + PanelEvent::ControlChanged { + module_id, key, value, + } => { let visible = value.as_bool().unwrap_or(true); if module_id == "natal" { + if key == "show_transits" { + self.show_transits = visible; + self.canvas.update(cx, |c, cx| { + c.set_layer_visible(LayerKind::Outer, visible, cx) + }); + self.render_current(cx); + return; + } let kind = match key.as_str() { "show_sign_dial" => Some(LayerKind::SignDial), "show_houses" => Some(LayerKind::Houses), @@ -230,7 +261,7 @@ impl Shell { } } PanelEvent::ModuleToggled { .. } => { - // Fase 5: encender/apagar módulos enteros (Transit, + // Fase 6: encender/apagar módulos enteros (Progression, // Synastry, Uranian). } } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs index 95b787a..43be368 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs @@ -279,6 +279,7 @@ impl AstrologyCanvas { "h" | "H" => LayerKind::Houses, "x" | "X" => LayerKind::Aspects, "p" | "P" => LayerKind::Bodies, + "t" | "T" => LayerKind::Outer, "r" | "R" => { self.reset_time_offset(cx); return; @@ -534,7 +535,7 @@ fn render_wheel( } } - // Planet glyphs. + // Planet glyphs (natal). if visible.get(&LayerKind::Bodies).copied().unwrap_or(true) { for layer in &render.layers { if matches!(layer.kind, LayerKind::Bodies) { @@ -559,6 +560,31 @@ fn render_wheel( } } + // 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() @@ -611,7 +637,7 @@ fn render_wheel( div() .text_size(px(10.0)) .text_color(theme.fg_disabled) - .child("[D]ial [H]ouses as[X]pects [P]lanets [R]eset"), + .child("[D]ial [H]ouses as[X]pects [P]lanets [T]ransits [R]eset"), ); div() @@ -650,6 +676,10 @@ fn format_offset(minutes: i64) -> String { struct Radii { sign_outer: f32, sign_inner: f32, + /// Anillo de glifos de tránsito (cuando el overlay está activo). + /// Vive entre `sign_inner` y `houses_outer`; queda vacío cuando no + /// hay capa Outer. + transits: f32, houses_outer: f32, houses_inner: f32, bodies: f32, @@ -661,10 +691,11 @@ impl Radii { Self { sign_outer: r, sign_inner: r * 0.88, - houses_outer: r * 0.86, - houses_inner: r * 0.72, - bodies: r * 0.65, - aspects: r * 0.58, + transits: r * 0.82, + houses_outer: r * 0.78, + houses_inner: r * 0.66, + bodies: r * 0.58, + aspects: r * 0.50, } } } @@ -779,32 +810,49 @@ fn paint_wheel( ); } - // 3. Aspectos. + // 3. Aspectos. Distinguir natal (line entre dos puntos en r_aspects) + // del transit (line natal → transit, distintos radios). if show(LayerKind::Aspects) { for layer in layers { if matches!(layer.kind, LayerKind::Aspects) { if let Geometry::Lines(segs) = &layer.geometry { + let is_transit = layer.module_id == "transit"; for seg in segs { let color = aspect_color(palette, &seg.kind); let color = with_alpha(color, color.a * seg.opacity); - paint_aspect_line( - window, - cx, - cy, - seg.from_deg, - seg.to_deg, - ascendant_deg, - rot_offset_deg, - radii.aspects, - color, - ); + if is_transit { + paint_cross_aspect_line( + window, + cx, + cy, + seg.from_deg, + seg.to_deg, + ascendant_deg, + rot_offset_deg, + radii.bodies, + radii.transits, + color, + ); + } else { + paint_aspect_line( + window, + cx, + cy, + seg.from_deg, + seg.to_deg, + ascendant_deg, + rot_offset_deg, + radii.aspects, + color, + ); + } } } } } } - // 4. Dots de cuerpos. + // 4. Dots de cuerpos (natal). if show(LayerKind::Bodies) { let dot_r = (radii.sign_outer * 0.018).max(2.0); for layer in layers { @@ -818,6 +866,42 @@ fn paint_wheel( } } } + + // 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( @@ -939,6 +1023,33 @@ fn paint_aspect_line( } } +/// 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 // ===================================================================== diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs index 4929895..5511832 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs @@ -9,8 +9,8 @@ use std::sync::OnceLock; use std::time::Instant; use eternal_astrology::{ - find_aspects, Aspect, AspectKind as EAspectKind, BirthData, BodySet, ChartConfig, - HouseSystem as EHouseSystem, NatalChart, OrbTable, Zodiac as EZodiac, + find_aspects, find_synastry_aspects, Aspect, AspectKind as EAspectKind, BirthData, BodySet, + ChartConfig, HouseSystem as EHouseSystem, NatalChart, OrbTable, Zodiac as EZodiac, }; use eternal_sky::{Ayanamsha, Body, EphemerisSession, Instant as ESInstant, Observer, SessionConfig}; @@ -173,10 +173,15 @@ fn aspect_kind_id(k: EAspectKind) -> &'static str { // compute() // ===================================================================== -pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result { - let t0 = Instant::now(); +/// Construye los tipos eternales (`BirthData`, `ChartConfig`) desde el +/// `Chart` agnóstico, aplicando el offset temporal. Devuelve también el +/// `Observer` y la `ChartConfig` para reusar en pipelines extendidas +/// (transits, sinastría) sin re-traducir. +fn build_eternal_inputs( + chart: &Chart, + offset_minutes: i64, +) -> Result<(BirthData, ChartConfig, Observer), EngineError> { chart.validate()?; - let bd = &chart.birth_data; let base_instant = ESInstant::from_civil_local( bd.year, @@ -197,10 +202,6 @@ pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result Result Result<(NatalChart, ChartConfig, Observer), EngineError> { + let (birth_e, config_e, observer) = build_eternal_inputs(chart, offset_minutes)?; let session = session()?; let natal = NatalChart::compute(&birth_e, &config_e, session) .map_err(|e| EngineError::Eternal(format!("NatalChart::compute: {:?}", e)))?; + Ok((natal, config_e, observer)) +} +pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result { + let t0 = Instant::now(); + let (natal, _, _) = compute_natal_chart(chart, offset_minutes)?; let aspects = find_aspects(&natal, &OrbTable::modern_western()); + Ok(build_render_model(chart, &natal, &aspects, t0)) +} - let render = build_render_model(chart, &natal, &aspects, t0); +/// Pipeline natal + overlay de tránsitos. Computa la carta natal +/// (eventualmente con un `offset_minutes` aplicado) **y además** una +/// segunda `NatalChart` con el mismo observer pero al instante +/// `transit_at` (usualmente `Instant::now()`). Devuelve un `RenderModel` +/// con dos capas extra: +/// +/// - `LayerKind::Outer` con `module_id = "transit"` — glifos +/// planetarios del cielo actual, pintados en un anillo externo. +/// - `LayerKind::Aspects` con `module_id = "transit"` — aspectos cross +/// natal × transit (sólo mayores). Convención: `LineSeg.from_deg` = +/// longitud natal, `LineSeg.to_deg` = longitud transit. +pub fn compute_with_transits( + chart: &Chart, + offset_minutes: i64, + transit_at: ESInstant, +) -> Result { + let t0 = Instant::now(); + let (natal, config_e, observer) = compute_natal_chart(chart, offset_minutes)?; + let aspects = find_aspects(&natal, &OrbTable::modern_western()); + let mut render = build_render_model(chart, &natal, &aspects, t0); + + // Carta de tránsito: mismo observer, mismo config, instante "ahora". + let transit_birth = BirthData::new(transit_at, observer); + let session = session()?; + let transit = NatalChart::compute(&transit_birth, &config_e, session).map_err(|e| { + EngineError::Eternal(format!("NatalChart::compute (transit): {:?}", e)) + })?; + + // Outer ring de glifos: planetas del cielo actual. + let outer_glyphs: Vec = transit + .placements + .iter() + .map(|p| Glyph { + deg: p.longitude.longitude_deg() as f32, + symbol: body_symbol(p.body).into(), + annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())), + retrograde: p.longitude_rate_rad_per_day < 0.0, + house: None, + }) + .collect(); + render.layers.push(Layer { + module_id: "transit".into(), + kind: LayerKind::Outer, + ring: 0.82, + z: 4, + geometry: Geometry::GlyphsOnly, + glyphs: outer_glyphs, + }); + + // Cross aspects natal × transit. find_synastry_aspects toma una lista + // de `AspectKind`s — usamos solo mayores para no saturar. + let cross = find_synastry_aspects( + &natal, + &transit, + &OrbTable::modern_western(), + EAspectKind::MAJORS, + ); + let cross_lines: Vec = cross + .iter() + .filter_map(|a| { + let natal_p = natal.placement(a.person_a_body)?; + let transit_p = transit.placement(a.person_b_body)?; + let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind); + Some(LineSeg { + from_deg: natal_p.longitude.longitude_deg() as f32, + to_deg: transit_p.longitude.longitude_deg() as f32, + kind: aspect_kind_id(a.kind).into(), + // Apagamos un poco más los cross para distinguirlos del + // tejido natal-natal. + opacity: opacity * 0.75, + }) + }) + .collect(); + render.layers.push(Layer { + module_id: "transit".into(), + kind: LayerKind::Aspects, + ring: 0.0, + z: 5, + geometry: Geometry::Lines(cross_lines), + glyphs: Vec::new(), + }); + + render.compute_ms = t0.elapsed().as_millis() as u64; Ok(render) } +/// Atajo: tránsitos al instante actual del reloj. +pub fn compute_with_transits_at_now( + chart: &Chart, + offset_minutes: i64, +) -> Result { + compute_with_transits(chart, offset_minutes, ESInstant::now()) +} + // ===================================================================== // NatalChart → RenderModel // ===================================================================== diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs index 51118d6..f0e0912 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs @@ -173,6 +173,33 @@ pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result Result { + #[cfg(feature = "eternal-bridge")] + { + bridge::compute_with_transits_at_now(chart, offset_minutes) + } + #[cfg(not(feature = "eternal-bridge"))] + { + let _ = offset_minutes; + Ok(compute_mock(chart)) + } +} + /// Stub determinista — útil para tests + para la UI sin eternal. pub fn compute_mock(chart: &Chart) -> RenderModel { use std::time::Instant; diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs index 228503b..2c7eaaa 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs @@ -203,6 +203,12 @@ pub mod natal { default: true, hotkey: Some("P".into()), }, + Control::Toggle { + key: "show_transits".into(), + label: "Tránsitos (ahora)".into(), + default: false, + hotkey: Some("T".into()), + }, Control::Slider { key: "harmonic".into(), label: "Armónico".into(),