diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs index 0f5de20..e1f3342 100644 --- a/crates/apps/tahuantinsuyu/src/shell.rs +++ b/crates/apps/tahuantinsuyu/src/shell.rs @@ -178,6 +178,14 @@ impl Shell { if module_enabled(&self.module_configs, "transit") { requests.push(PipelineRequest::Transit); } + if module_enabled(&self.module_configs, "progression") { + if let Some(chart) = self.current_chart.as_ref() { + let age = current_age_years(&chart.birth_data); + requests.push(PipelineRequest::SecondaryProgression { + target_age_years: age, + }); + } + } requests } @@ -301,6 +309,22 @@ impl Shell { // Helpers de module_configs // ===================================================================== +/// Edad en años decimales desde el nacimiento hasta el reloj actual. +/// Aproximación: ignora la TZ de nacimiento (no afecta a resolución de +/// año) y usa una fracción de año tropical sobre los segundos Unix. +fn current_age_years(birth: &tahuantinsuyu_model::StoredBirthData) -> f64 { + use std::time::{SystemTime, UNIX_EPOCH}; + let now_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0); + let birth_year_frac = birth.year as f64 + + (birth.month.saturating_sub(1) as f64) / 12.0 + + (birth.day.saturating_sub(1) as f64) / 365.25; + let now_year_frac = 1970.0 + now_secs / (365.2422 * 86400.0); + (now_year_frac - birth_year_frac).max(0.0) +} + fn module_enabled(cfgs: &HashMap, id: &str) -> bool { cfgs.get(id) .and_then(|c| c.get("enabled")) diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs index 43be368..c265dbd 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs @@ -535,13 +535,19 @@ fn render_wheel( } } - // Planet glyphs (natal). + // Planet glyphs: natal (en `bodies`) y progresada (en `progression`, + // con alpha + tamaño un poco distintos para diferenciarlos). if visible.get(&LayerKind::Bodies).copied().unwrap_or(true) { for layer in &render.layers { if matches!(layer.kind, LayerKind::Bodies) { + let is_prog = layer.module_id == "progression"; + let ring = if is_prog { radii.progression } else { radii.bodies }; + let alpha = if is_prog { 0.85 } else { 1.0 }; + let font_size = if is_prog { 14.0 } else { 18.0 }; + let box_size = if is_prog { 20.0 } else { 24.0 }; for g in &layer.glyphs { - let (x, y) = polar_to_screen(g.deg, asc, rot_offset, radii.bodies); - let color = planet_color(palette, &g.symbol); + let (x, y) = polar_to_screen(g.deg, asc, rot_offset, ring); + let color = with_alpha(planet_color(palette, &g.symbol), alpha); let glyph_text = if g.retrograde { format!("{}ᴿ", planet_unicode(&g.symbol)) } else { @@ -550,8 +556,8 @@ fn render_wheel( wheel = wheel.child(centered_glyph( cx_center + x, cy_center + y, - 24.0, - 18.0, + box_size, + font_size, glyph_text.into(), color, )); @@ -678,11 +684,14 @@ struct Radii { 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. + /// hay capa Outer con module_id = "transit". transits: f32, houses_outer: f32, houses_inner: f32, bodies: f32, + /// Anillo interno con cuerpos progresados (cuando hay overlay de + /// progresión secundaria). Queda vacío sino. + progression: f32, aspects: f32, } @@ -695,7 +704,20 @@ impl Radii { houses_outer: r * 0.78, houses_inner: r * 0.66, bodies: r * 0.58, - aspects: r * 0.50, + progression: r * 0.48, + aspects: r * 0.38, + } + } + + /// Resuelve qué radios corresponden a una capa de aspectos según el + /// `module_id`: natal-natal en `aspects`, cross-aspects con + /// tránsito desde `bodies` a `transits`, cross con progresión desde + /// `bodies` a `progression`. + fn aspect_endpoints(&self, module_id: &str) -> (f32, f32) { + match module_id { + "transit" => (self.bodies, self.transits), + "progression" => (self.bodies, self.progression), + _ => (self.aspects, self.aspects), } } } @@ -810,17 +832,19 @@ fn paint_wheel( ); } - // 3. Aspectos. Distinguir natal (line entre dos puntos en r_aspects) - // del transit (line natal → transit, distintos radios). + // 3. Aspectos. Cada module_id usa su par de radios — natal-natal + // ambos en `aspects`, cross con transit en `bodies → transits`, + // cross con progression en `bodies → progression`. if show(LayerKind::Aspects) { for layer in layers { if matches!(layer.kind, LayerKind::Aspects) { if let Geometry::Lines(segs) = &layer.geometry { - let is_transit = layer.module_id == "transit"; + let (r_from, r_to) = radii.aspect_endpoints(&layer.module_id); + let is_cross = r_from != r_to; for seg in segs { let color = aspect_color(palette, &seg.kind); let color = with_alpha(color, color.a * seg.opacity); - if is_transit { + if is_cross { paint_cross_aspect_line( window, cx, @@ -829,8 +853,8 @@ fn paint_wheel( seg.to_deg, ascendant_deg, rot_offset_deg, - radii.bodies, - radii.transits, + r_from, + r_to, color, ); } else { @@ -842,7 +866,7 @@ fn paint_wheel( seg.to_deg, ascendant_deg, rot_offset_deg, - radii.aspects, + r_from, color, ); } @@ -852,21 +876,48 @@ fn paint_wheel( } } - // 4. Dots de cuerpos (natal). + // 4. Dots de cuerpos: natal en `bodies`, progresada en `progression`. if show(LayerKind::Bodies) { let dot_r = (radii.sign_outer * 0.018).max(2.0); for layer in layers { if matches!(layer.kind, LayerKind::Bodies) { + let ring = match layer.module_id.as_str() { + "progression" => radii.progression, + _ => radii.bodies, + }; + let alpha = if layer.module_id == "progression" { 0.85 } else { 1.0 }; for g in &layer.glyphs { - let color = planet_color(palette, &g.symbol); - let (x, y) = - polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, radii.bodies); + let color = with_alpha(planet_color(palette, &g.symbol), alpha); + let (x, y) = polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, ring); fill_circle(window, cx + x, cy + y, dot_r, color); } } } } + // Si hay progresión, dibujar anillo guía sutil delimitando su slot. + let prog_active = layers + .iter() + .any(|l| matches!(l.kind, LayerKind::Bodies) && l.module_id == "progression"); + if prog_active { + stroke_circle( + window, + cx, + cy, + radii.progression + radii.sign_outer * 0.03, + 0.5, + with_alpha(palette.house_cusp, 0.35), + ); + stroke_circle( + window, + cx, + cy, + radii.progression - radii.sign_outer * 0.03, + 0.5, + with_alpha(palette.house_cusp, 0.35), + ); + } + // 5. Outer ring (transit overlay): anillo guía + dots de transit. let transit_active = layers .iter() diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs index d4638d3..480f48c 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs @@ -9,8 +9,9 @@ use std::sync::OnceLock; use std::time::Instant; use eternal_astrology::{ - find_aspects, find_synastry_aspects, Aspect, AspectKind as EAspectKind, BirthData, BodySet, - ChartConfig, HouseSystem as EHouseSystem, NatalChart, OrbTable, Zodiac as EZodiac, + find_aspects, find_synastry_aspects, secondary_progression, 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}; @@ -254,6 +255,9 @@ pub fn compose( crate::PipelineRequest::Transit => { build_transit_overlay(&natal, &config_e, observer, ESInstant::now(), &mut render)?; } + crate::PipelineRequest::SecondaryProgression { target_age_years } => { + build_progression_overlay(&natal, *target_age_years, &mut render)?; + } } } @@ -327,6 +331,73 @@ fn build_transit_overlay( Ok(()) } +/// Helper: agrega al `RenderModel` las capas del overlay de progresión +/// secundaria. La carta progresada se computa con el mismo observer y +/// config que la natal pero al instante natal+(age_years/period_years) +/// días. +fn build_progression_overlay( + natal: &NatalChart, + target_age_years: f64, + render: &mut RenderModel, +) -> Result<(), EngineError> { + let session = session()?; + let prog = secondary_progression(natal, session, target_age_years) + .map_err(|e| EngineError::Eternal(format!("secondary_progression: {:?}", e)))?; + let progressed = &prog.progressed; + + // Glifos de los cuerpos progresados — anillo interno (radio 0.48). + let glyphs: Vec = progressed + .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: Some(p.house_number), + }) + .collect(); + render.layers.push(Layer { + module_id: "progression".into(), + kind: LayerKind::Bodies, + ring: 0.48, + z: 6, + geometry: Geometry::GlyphsOnly, + glyphs, + }); + + // Cross aspects natal × progresada (sólo mayores). + let cross = find_synastry_aspects( + natal, + progressed, + &OrbTable::modern_western(), + EAspectKind::MAJORS, + ); + let cross_lines: Vec = cross + .iter() + .filter_map(|a| { + let natal_p = natal.placement(a.person_a_body)?; + let prog_p = progressed.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: prog_p.longitude.longitude_deg() as f32, + kind: aspect_kind_id(a.kind).into(), + opacity: opacity * 0.7, + }) + }) + .collect(); + render.layers.push(Layer { + module_id: "progression".into(), + kind: LayerKind::Aspects, + ring: 0.0, + z: 7, + geometry: Geometry::Lines(cross_lines), + glyphs: Vec::new(), + }); + Ok(()) +} + // ===================================================================== // NatalChart → RenderModel // ===================================================================== diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs index 97b4e16..25d18c9 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs @@ -164,8 +164,16 @@ pub enum PipelineRequest { /// `module_id = "transit"` — anillo externo con planetas al /// instante actual (reloj de pared) + cross aspects natal × transit. Transit, - // ── Fase 7 ────────────────────────────────────────────────────── - // SecondaryProgression { target_year: i32 }, + /// `module_id = "progression"` — anillo interno con los planetas + /// progresados (método secundario "día por año") a la edad pedida + /// + cross aspects natal × progresada. + SecondaryProgression { + /// Edad simbólica en años a la que avanzar la carta. Para "la + /// edad de hoy", el shell la calcula a partir de `birth_data` + + /// `SystemTime::now`. + target_age_years: f64, + }, + // ── Fase 8 ────────────────────────────────────────────────────── // SolarArc { target_year: i32 }, // Synastry { partner: tahuantinsuyu_model::ChartId }, } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs index 6c2add1..8ae243d 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs @@ -124,6 +124,7 @@ impl Registry { let mut r = Self { modules: Vec::new() }; r.register(Box::new(natal::NatalModule)); r.register(Box::new(transit::TransitModule)); + r.register(Box::new(progression::ProgressionModule)); r } @@ -274,6 +275,52 @@ pub mod transit { } } +// ===================================================================== +// ProgressionModule — progresión secundaria (día por año) +// ===================================================================== + +pub mod progression { + use super::*; + + /// Anillo interno con la carta progresada (método secundario, + /// "un día de efemérides = un año de vida") + cross aspects natal × + /// progresada. La engine lo despacha vía + /// `PipelineRequest::SecondaryProgression { target_age_years }`. + pub struct ProgressionModule; + + impl Module for ProgressionModule { + fn id(&self) -> &'static str { + "progression" + } + fn label(&self) -> &'static str { + "Progresión secundaria" + } + fn description(&self) -> &'static str { + "Día-por-año: avanza la carta a la edad actual." + } + fn applies_to(&self, kind: ChartKind) -> bool { + matches!(kind, ChartKind::Natal) + } + fn enabled_by_default(&self) -> bool { + false + } + fn controls(&self) -> Vec { + vec![Control::Toggle { + key: "enabled".into(), + label: "Activar".into(), + default: false, + // Sin hotkey por ahora — el toggle vive en el panel. + // Fase 8 puede agregar [G] vía un canal genérico de + // ModuleToggleRequested. + hotkey: None, + }] + } + fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec { + Vec::new() + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -283,8 +330,9 @@ mod tests { let r = Registry::with_builtins(); assert!(r.find("natal").is_some()); assert!(r.find("transit").is_some()); - // Natal kind tiene 2 módulos aplicables: el propio + transit overlay. - assert_eq!(r.for_kind(ChartKind::Natal).len(), 2); + assert!(r.find("progression").is_some()); + // Natal kind tiene 3 módulos aplicables: natal + transit + progression. + assert_eq!(r.for_kind(ChartKind::Natal).len(), 3); // Synastry kind no tiene módulos hoy. assert!(r.for_kind(ChartKind::Synastry).is_empty()); }