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>
This commit is contained in:
sergio
2026-05-17 11:30:21 +00:00
parent 8d95833c20
commit 6d572c81ca
6 changed files with 273 additions and 126 deletions
@@ -264,11 +264,21 @@ pub fn compose(
crate::PipelineRequest::Synastry { partner_chart } => {
build_synastry_overlay(&natal, partner_chart, &mut render)?;
}
crate::PipelineRequest::SolarReturn { target_age_years } => {
build_solar_return_overlay(
crate::PipelineRequest::PlanetaryReturn {
body,
target_age_years,
} => {
let body_e = map_body(body).ok_or_else(|| {
EngineError::Eternal(format!(
"body desconocido para planetary return: {}",
body
))
})?;
build_planetary_return_overlay(
&natal,
&config_e,
observer,
body_e,
*target_age_years,
&mut render,
)?;
@@ -540,29 +550,34 @@ fn build_synastry_overlay(
Ok(())
}
/// Helper: agrega al `RenderModel` las capas del overlay de Solar
/// Return — la carta natal completa computada al instante en que el
/// Sol vuelve a su posición natal en el año pedido. Esa nueva carta
/// va en el anillo externo (compartido con Transit/Synastry —
/// mutuamente excluyentes a nivel de Shell). Cross aspects natal ×
/// return.
fn build_solar_return_overlay(
/// Helper: agrega al `RenderModel` las capas del overlay de retorno
/// planetario — la carta natal completa computada al instante en que
/// el `body` vuelve a su posición natal cerca de la edad pedida.
/// Sun = retorno solar anual, Moon = mensual, Júpiter/Saturno =
/// generacionales. Esa nueva carta va en el anillo externo (compartido
/// con Transit/Synastry, mutuamente excluyentes a nivel de Shell).
/// Cross aspects natal × return.
fn build_planetary_return_overlay(
natal: &NatalChart,
config_e: &ChartConfig,
observer: Observer,
body: Body,
target_age_years: f64,
render: &mut RenderModel,
) -> Result<(), EngineError> {
let session = session()?;
let natal_sun = natal.placement(Body::Sun).ok_or_else(|| {
EngineError::Eternal("natal chart sin Sol — Solar Return imposible".into())
let natal_p = natal.placement(body).ok_or_else(|| {
EngineError::Eternal(format!(
"natal chart sin {} — return imposible",
body.name()
))
})?;
let natal_sun_lon = natal_sun.longitude.longitude_rad();
let natal_lon = natal_p.longitude.longitude_rad();
// Buscar el próximo retorno desde un punto razonable antes del
// cumpleaños del año target. Restamos 30 días para garantizar que
// el retorno (que cae ~en la fecha de nacimiento) quede DENTRO de
// la ventana de búsqueda.
// El offset desde el cumpleaños depende del período sinódico del
// cuerpo: para Sun/planet lentos, ~30 días antes garantiza captar
// el return; para Moon, ~15 días. Tomamos un margen amplio que
// sirve para todos.
const TROPICAL_YEAR_SECS: f64 = 365.242190 * 86400.0;
let after_seconds = (target_age_years * 365.242190 - 30.0) * 86400.0;
let after_utc = natal
@@ -572,15 +587,20 @@ fn build_solar_return_overlay(
.add_seconds(after_seconds.max(-TROPICAL_YEAR_SECS * 2.0));
let after = ESInstant::from_utc(after_utc);
let return_instant = next_return(session, Body::Sun, natal_sun_lon, after, None)
.map_err(|e| EngineError::Eternal(format!("next_return Sun: {:?}", e)))?;
let return_instant = next_return(session, body, natal_lon, after, None).map_err(|e| {
EngineError::Eternal(format!("next_return {}: {:?}", body.name(), e))
})?;
// La carta del retorno se computa al return_instant con el mismo
// observer y config natales (convención clásica: solar return
// tropical en la ciudad de nacimiento).
// observer y config natales (convención clásica: return tropical
// en la ciudad de nacimiento).
let return_birth = BirthData::new(return_instant, observer);
let return_chart = NatalChart::compute(&return_birth, config_e, session).map_err(|e| {
EngineError::Eternal(format!("NatalChart::compute (solar return): {:?}", e))
EngineError::Eternal(format!(
"NatalChart::compute ({} return): {:?}",
body.name(),
e
))
})?;
let glyphs: Vec<Glyph> = return_chart
@@ -595,7 +615,7 @@ fn build_solar_return_overlay(
})
.collect();
render.layers.push(Layer {
module_id: "solar_return".into(),
module_id: "planetary_return".into(),
kind: LayerKind::Outer,
ring: 0.82,
z: 12,
@@ -624,7 +644,7 @@ fn build_solar_return_overlay(
})
.collect();
render.layers.push(Layer {
module_id: "solar_return".into(),
module_id: "planetary_return".into(),
kind: LayerKind::Aspects,
ring: 0.0,
z: 13,