Files
brahman/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
T
sergio 6d572c81ca feat(tahuantinsuyu): fase 14 — Return abstracto + Control::Select interactivo
El módulo SolarReturn se generaliza a PlanetaryReturn parametrizable
por cuerpo (Sun/Moon/Mercury/Venus/Mars/Jupiter/Saturn/Uranus/Neptune/
Pluto). Validado contra `Control::Select`, ahora interactivo como
tercer tipo de control draggable (después de Toggle/Slider/ChartPicker).

Refactor estructural: el dropdown del ChartPicker pasa a ser
infraestructura compartida — chart_picker_value/chart_picker_open
desaparecen, reemplazados por string_state/dropdown_open que sirven
a CUALQUIER control basado en string (picker + select).
render_chart_picker y render_select ahora son thin wrappers sobre
render_dropdown(options, include_auto).

- engine:
  - PipelineRequest::SolarReturn → PipelineRequest::PlanetaryReturn
    { body: String, target_age_years }. Body como string agnóstico
    (sun/moon/jupiter/...) que el bridge mapea a eternal_sky::Body
    vía map_body — el mismo helper que ya usa StoredChartConfig.
  - build_solar_return_overlay → build_planetary_return_overlay con
    parameter `body: Body`. next_return acepta cualquier body, así que
    Moon return (mensual) y Saturn return (29 años) funcionan igual.
    Mensajes de error incluyen body.name() para diagnóstico.
- modules:
  - SolarReturnModule → PlanetaryReturnModule (mod planetary_return).
    id "planetary_return". Controles: toggle "enabled" + Select "body"
    con 10 opciones de cuerpo (Sol → Plutón) + Slider edad. label
    "Retornos planetarios".
- panel:
  - Refactor: chart_picker_value/chart_picker_open → string_state/
    dropdown_open (compartido entre ChartPicker y Select).
  - set_string(module_id, key, value, cx) — API unificada. set_chart_picker
    queda como alias retrocompatible.
  - render_dropdown(options, include_auto, …) — helper común. picker
    pasa include_auto=true (muestra "(automático)" + separador);
    select pasa include_auto=false (las options son la única opción).
  - render_select implementado — el botón muestra la option's label
    (no value); click abre dropdown; click en opción emite ControlChanged
    con Value::String(option.value).
- shell:
  - OUTER_RING_MODULES const: "solar_return" → "planetary_return".
  - build_requests para planetary_return: lee body string del
    module_configs (default "sun"), arma PipelineRequest::PlanetaryReturn.
  - apply_selection inicializa target_age + body=sun default para
    planetary_return.
  - sync_panel_from_configs strings → set_string (era set_chart_picker).

Probarlo: en el panel del módulo "Retornos planetarios", click en el
dropdown "Cuerpo" abre el popup; click en "Saturno" + slider en 29
años + toggle "Activar" = ves la carta del primer retorno de Saturno
(cuando recién terminaba la primera vuelta) en el outer ring con
cross aspects al natal.

NOTE: La persistencia con id "solar_return" de fase 13 queda huérfana
en la DB de los users que ya hayan probado. No es destructivo —
simplemente esas rows quedan sin módulo que las lea. Pre-1.0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 11:30:21 +00:00

538 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `tahuantinsuyu-modules` — registry de módulos astrológicos.
//!
//! Cada tipo de astrología (natal, tránsito, progresión, sinastría,
//! Uraniano, …) es un **módulo** que declara:
//!
//! - Qué `Layer`s aporta al `RenderModel`.
//! - Qué `Control`s expone al panel inferior (toggles, sliders, selects).
//! - Hotkeys opcionales.
//! - Si su cómputo es lazy (sólo cuando se activa) o eager.
//!
//! El registry es un `Vec<&dyn Module>` estático: el canvas consulta
//! "para esta `ChartKind`, ¿qué módulos están disponibles?" y el panel
//! pinta sus controles. Activar / desactivar persiste en
//! `ModuleState` (en la store).
//!
//! Esta fase 1 trae el trait + un módulo `NatalModule` de placeholder.
//! En fases posteriores agregamos Transit, Progression, Synastry,
//! Composite, SolarArc, Uranian, FixedStars, Dignities, Lots…
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use serde::{Deserialize, Serialize};
use tahuantinsuyu_engine::Layer;
use tahuantinsuyu_model::{Chart, ChartKind};
// =====================================================================
// Trait Module
// =====================================================================
/// Una capa de astrología enchufable.
///
/// `Send + Sync` para que el registry sea estático y se pueda consultar
/// desde cualquier thread (el cómputo pesado va a un background executor).
pub trait Module: Send + Sync {
/// Identidad estable del módulo. Coincide con `ModuleState.module_id`
/// en la store.
fn id(&self) -> &'static str;
/// Etiqueta amigable para el panel.
fn label(&self) -> &'static str;
/// Breve descripción para tooltip.
fn description(&self) -> &'static str;
/// Para qué tipos de carta tiene sentido este módulo. El panel filtra
/// con esto al armar la lista de toggles disponibles.
fn applies_to(&self, kind: ChartKind) -> bool;
/// Si el módulo está activado por default al crear una carta.
fn enabled_by_default(&self) -> bool {
false
}
/// Controles que aporta al panel inferior.
fn controls(&self) -> Vec<Control> {
Vec::new()
}
/// Computa las capas que este módulo aporta al RenderModel de
/// `chart`. La engine la llama solo si el módulo está activado
/// para esa carta.
///
/// Devuelve `Vec` (no Option) — un módulo puede no aportar capas
/// si su config interna lo apaga (ej. "Uranian: mostrar simetría
/// = false"); en ese caso retorna `Vec::new()`.
fn compute_layers(&self, chart: &Chart, config: &serde_json::Value) -> Vec<Layer>;
}
// =====================================================================
// Controls expuestos al panel
// =====================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Control {
Toggle {
key: String,
label: String,
default: bool,
hotkey: Option<String>,
},
Slider {
key: String,
label: String,
min: f64,
max: f64,
step: f64,
default: f64,
},
Select {
key: String,
label: String,
options: Vec<SelectOption>,
default: String,
},
/// Texto libre — útil para etiquetas, comentarios.
TextInput {
key: String,
label: String,
default: String,
},
/// Picker dinámico de una carta de la DB. Las opciones las inyecta
/// el host (Shell) en el panel — el módulo solo declara la
/// existencia del control. Valor emitido en `ControlChanged` =
/// `Value::String(chart_id)` cuando se selecciona, `Value::Null`
/// cuando se vuelve a "automático".
ChartPicker {
key: String,
label: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SelectOption {
pub value: String,
pub label: String,
}
// =====================================================================
// Registry
// =====================================================================
/// Lista estática de módulos disponibles. La app los registra al boot.
pub struct Registry {
modules: Vec<Box<dyn Module>>,
}
impl Registry {
/// Registry con todos los módulos built-in. La app llama esto al
/// boot y luego usa `find()` / `for_kind()` para consultar.
pub fn with_builtins() -> Self {
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.register(Box::new(solar_arc::SolarArcModule));
r.register(Box::new(synastry::SynastryModule));
r.register(Box::new(planetary_return::PlanetaryReturnModule));
r
}
pub fn register(&mut self, m: Box<dyn Module>) {
self.modules.push(m);
}
pub fn all(&self) -> &[Box<dyn Module>] {
&self.modules
}
pub fn find(&self, id: &str) -> Option<&dyn Module> {
self.modules
.iter()
.find(|m| m.id() == id)
.map(|m| m.as_ref())
}
pub fn for_kind(&self, kind: ChartKind) -> Vec<&dyn Module> {
self.modules
.iter()
.filter(|m| m.applies_to(kind))
.map(|m| m.as_ref())
.collect()
}
}
// =====================================================================
// NatalModule — placeholder fase 1
// =====================================================================
pub mod natal {
use super::*;
use tahuantinsuyu_engine::compute_mock;
pub struct NatalModule;
impl Module for NatalModule {
fn id(&self) -> &'static str {
"natal"
}
fn label(&self) -> &'static str {
"Carta natal"
}
fn description(&self) -> &'static str {
"Posiciones natales, casas y aspectos."
}
fn applies_to(&self, kind: ChartKind) -> bool {
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
true
}
fn controls(&self) -> Vec<Control> {
vec![
Control::Toggle {
key: "show_sign_dial".into(),
label: "Dial zodiacal".into(),
default: true,
hotkey: Some("D".into()),
},
Control::Toggle {
key: "show_houses".into(),
label: "Casas".into(),
default: true,
hotkey: Some("H".into()),
},
Control::Toggle {
key: "show_aspects".into(),
label: "Aspectos".into(),
default: true,
hotkey: Some("X".into()),
},
Control::Toggle {
key: "show_bodies".into(),
label: "Cuerpos".into(),
default: true,
hotkey: Some("P".into()),
},
Control::Slider {
key: "harmonic".into(),
label: "Armónico".into(),
min: 1.0,
max: 20.0,
step: 1.0,
default: 1.0,
},
]
}
fn compute_layers(&self, chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
// Fase 1: delega al mock de la engine para que la UI tenga
// algo que pintar. Fase 3 reemplaza con `engine::compute`
// contra `eternal-astrology`.
compute_mock(chart).layers
}
}
}
// =====================================================================
// TransitModule — overlay del cielo del momento sobre la carta natal
// =====================================================================
pub mod transit {
use super::*;
/// Anillo externo con las posiciones planetarias del **instante
/// actual** (reloj de pared) sobre el sujeto natal, más las
/// cross-aspects natal × transit. La engine despacha al pipeline
/// `PipelineRequest::Transit` cuando este módulo está activo en el
/// `module_configs` del shell.
pub struct TransitModule;
impl Module for TransitModule {
fn id(&self) -> &'static str {
"transit"
}
fn label(&self) -> &'static str {
"Tránsitos"
}
fn description(&self) -> &'static str {
"Cielo del momento sobre la natal + cross aspects."
}
fn applies_to(&self, kind: ChartKind) -> bool {
// Por ahora solo overlay sobre cartas natales — más adelante
// podríamos overlayar tránsitos sobre Progresiones, etc.
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
false
}
fn controls(&self) -> Vec<Control> {
vec![Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: Some("T".into()),
}]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
// Las capas de tránsito se construyen en la engine vía
// `PipelineRequest::Transit` porque necesitan acceso a la
// NatalChart cruda + EphemerisSession. Este método queda
// como no-op — el módulo es puramente declarativo.
Vec::new()
}
}
}
// =====================================================================
// 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<Control> {
vec![
Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: None,
},
// El default (30.0) es un placeholder — el shell empuja
// la edad actual del sujeto al cargar una carta vía
// panel.set_slider("progression", "target_age_years",
// current_age).
Control::Slider {
key: "target_age_years".into(),
label: "Edad objetivo (años)".into(),
min: 0.0,
max: 120.0,
step: 0.25,
default: 30.0,
},
]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
}
}
}
// =====================================================================
// SynastryModule — bi-wheel con otra carta hermana del contacto actual
// =====================================================================
pub mod synastry {
use super::*;
/// Pone la carta del partner en el anillo externo (compartido con
/// Transit — mutuamente excluyentes) y dibuja las cross aspects
/// natal × partner. El shell elige el partner: la primera carta
/// hermana del mismo contacto. Si no hay hermana, el request se
/// salta silenciosamente.
pub struct SynastryModule;
impl Module for SynastryModule {
fn id(&self) -> &'static str {
"synastry"
}
fn label(&self) -> &'static str {
"Sinastría"
}
fn description(&self) -> &'static str {
"Bi-wheel con la primera carta hermana del contacto."
}
fn applies_to(&self, kind: ChartKind) -> bool {
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
false
}
fn controls(&self) -> Vec<Control> {
vec![
Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: None,
},
Control::ChartPicker {
key: "partner_chart_id".into(),
label: "Partner".into(),
},
]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
}
}
}
// =====================================================================
// PlanetaryReturnModule — retornos de cualquier cuerpo a su pos natal
// =====================================================================
pub mod planetary_return {
use super::*;
/// Computa la carta natal completa al instante del próximo retorno
/// del cuerpo elegido. Sun = anual (cumpleaños), Moon = mensual,
/// Júpiter/Saturno = generacionales. Comparte el outer ring con
/// Transit y Synastry — mutuamente excluyentes a nivel de Shell.
pub struct PlanetaryReturnModule;
impl Module for PlanetaryReturnModule {
fn id(&self) -> &'static str {
"planetary_return"
}
fn label(&self) -> &'static str {
"Retornos planetarios"
}
fn description(&self) -> &'static str {
"Carta del próximo retorno (Sol, Luna, Júpiter, Saturno…)."
}
fn applies_to(&self, kind: ChartKind) -> bool {
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
false
}
fn controls(&self) -> Vec<Control> {
vec![
Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: None,
},
Control::Select {
key: "body".into(),
label: "Cuerpo".into(),
default: "sun".into(),
options: vec![
SelectOption { value: "sun".into(), label: "Sol".into() },
SelectOption { value: "moon".into(), label: "Luna".into() },
SelectOption { value: "mercury".into(), label: "Mercurio".into() },
SelectOption { value: "venus".into(), label: "Venus".into() },
SelectOption { value: "mars".into(), label: "Marte".into() },
SelectOption { value: "jupiter".into(), label: "Júpiter".into() },
SelectOption { value: "saturn".into(), label: "Saturno".into() },
SelectOption { value: "uranus".into(), label: "Urano".into() },
SelectOption { value: "neptune".into(), label: "Neptuno".into() },
SelectOption { value: "pluto".into(), label: "Plutón".into() },
],
},
Control::Slider {
key: "target_age_years".into(),
label: "Edad del retorno".into(),
min: 0.0,
max: 120.0,
step: 1.0,
default: 30.0,
},
]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
}
}
}
// =====================================================================
// SolarArcModule — Solar Arc dirigido (true progressed Sun)
// =====================================================================
pub mod solar_arc {
use super::*;
/// Cada planeta y cusp natal se desplaza por el mismo arco
/// (≈ 1° por año de vida, calculado como el delta del Sol
/// progresado secundario). Anillo interno bien adentro + cross
/// aspects natal × dirigida.
pub struct SolarArcModule;
impl Module for SolarArcModule {
fn id(&self) -> &'static str {
"solar_arc"
}
fn label(&self) -> &'static str {
"Solar Arc"
}
fn description(&self) -> &'static str {
"Dirección por arco solar — uniforme, ≈1°/año."
}
fn applies_to(&self, kind: ChartKind) -> bool {
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
false
}
fn controls(&self) -> Vec<Control> {
vec![
Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: None,
},
Control::Slider {
key: "target_age_years".into(),
label: "Edad objetivo (años)".into(),
min: 0.0,
max: 120.0,
step: 0.25,
default: 30.0,
},
]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_finds_builtins() {
let r = Registry::with_builtins();
assert!(r.find("natal").is_some());
assert!(r.find("transit").is_some());
assert!(r.find("progression").is_some());
assert!(r.find("solar_arc").is_some());
assert!(r.find("synastry").is_some());
assert!(r.find("planetary_return").is_some());
// Natal kind tiene 6 módulos aplicables.
assert_eq!(r.for_kind(ChartKind::Natal).len(), 6);
assert!(r.for_kind(ChartKind::Synastry).is_empty());
}
}