feat(tahuantinsuyu): fase 20 — accordion + lunar shift + CompositeModule + 90 ciudades

Cuatro features que cierran el set inicial de funcionalidades de
fase 1:

## D — Acordeón colapsable en el panel

Cuando hay 8 módulos en el panel se llenaba de cards. Ahora cada card
es expandible/colapsable por click en el header. Defaults:
- Natal siempre expanded
- Módulos con toggle "enabled" = true → expanded
- Resto → collapsed

El usuario puede forzar cualquiera vía override (collapse_overrides
HashMap). Chevron ▾/▸ a la izquierda del header. Hover sobre el
header lo resalta para invitar al click.

## B — Lunar return shift (navegación mensual)

PipelineRequest::PlanetaryReturn gana campo `shift_days: i64` (range
±180). El bridge lo suma a after_seconds del search anchor antes de
next_return. Para Solar return típicamente 0 (mantiene comportamiento).
Para Moon return, mover el slider ±28 días salta al retorno lunar
anterior o siguiente, permitiendo navegar mes a mes la lunación que
le toca al sujeto cumplido N años. PlanetaryReturnModule.controls()
agrega un slider "Shift días (lunar nav)". El badge del overlay
muestra "Moon return 38a +14d" cuando shift_days != 0. Helper
`planetary_return_request(body, age)` para callers que no necesitan
shift (zero default).

## C — CompositeModule

Carta compuesta (midpoint Davison) entre la natal del sujeto y otra
carta partner. Cada placement compuesto es el angular midpoint entre
los dos correspondientes. Engine: `PipelineRequest::Composite {
partner_chart: Box<Chart> }` + build_composite_overlay que llama
`eternal_astrology::composite()`. Renderiza placements en
`radii.composite = r * 0.32` (entre solar_arc 0.40 y aspects 0.24,
re-balanced). Módulo `composite::CompositeModule` con toggle +
ChartPicker (mismo patrón que synastry).

Shell: resolve_composite_partner reusa el fallback al primer hermano
del contacto, igual que synastry.

## A — 90 ciudades expandidas + dropdown scrollable

CITY_PRESETS pasa de 25 a 90 ciudades cubriendo:
- Latinoamérica (35): todas las capitales + grandes ciudades de AR/
  VE/CO/PE/CL/EC/UY/PY/BO/MX/CU/PR/CR/PA/SV/GT/HN/NI/DO/BR
- España (5) + Europa (20): Madrid/Barcelona/Sevilla/Valencia/Bilbao
  + London/Paris/Berlin/München/Roma/Milano/Amsterdam/Bruxelles/Wien/
  Zürich/Lisboa/Dublin/Stockholm/Oslo/København/Helsinki/Warszawa/
  Praha/Budapest/Athina/İstanbul/Moskva
- USA + Canadá (12): NY/LA/Chicago/Miami/Houston/SF/Seattle/Boston/
  DC + Toronto/Montreal/Vancouver
- Asia (16): Tokyo/Beijing/Shanghai/HK/Singapore/Seoul/Bangkok/
  Jakarta/Manila/Mumbai/Delhi/Bangalore/Karachi/Tehran/Dubai/Tel Aviv
- África (6): Cairo/Lagos/Nairobi/Johannesburg/Cape Town/Casablanca
- Oceanía (3): Sydney/Melbourne/Auckland

El popup del dropdown ahora es scrollable (h=360px, overflow_y_scroll)
con id estable para no perder scroll position entre re-renders.

cargo check verde, 8 tests engine + 1 test modules (8 módulos
aplicables a ChartKind::Natal) verdes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-17 23:24:11 +00:00
parent 32ab22f954
commit d3649bfd1a
7 changed files with 348 additions and 31 deletions
@@ -138,6 +138,7 @@ impl Registry {
r.register(Box::new(synastry::SynastryModule));
r.register(Box::new(planetary_return::PlanetaryReturnModule));
r.register(Box::new(midpoints::MidpointsModule));
r.register(Box::new(composite::CompositeModule));
r
}
@@ -485,6 +486,64 @@ pub mod planetary_return {
step: 1.0,
default: 30.0,
},
// Offset adicional para Moon return (saltar ~28d entre
// retornos lunares) o ajuste fino del Solar return.
Control::Slider {
key: "shift_days".into(),
label: "Shift días (lunar nav)".into(),
min: -180.0,
max: 180.0,
step: 1.0,
default: 0.0,
},
]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
}
}
}
// =====================================================================
// CompositeModule — carta compuesta (midpoint Davison) con un partner
// =====================================================================
pub mod composite {
use super::*;
/// Carta compuesta entre la natal y otra carta — cada placement es
/// el midpoint angular del par. Mismo ChartPicker que sinastría
/// para elegir el partner.
pub struct CompositeModule;
impl Module for CompositeModule {
fn id(&self) -> &'static str {
"composite"
}
fn label(&self) -> &'static str {
"Composite"
}
fn description(&self) -> &'static str {
"Carta compuesta con otro sujeto (midpoint Davison)."
}
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> {
@@ -603,8 +662,9 @@ mod tests {
assert!(r.find("synastry").is_some());
assert!(r.find("planetary_return").is_some());
assert!(r.find("midpoints").is_some());
// Natal kind tiene 7 módulos aplicables.
assert_eq!(r.for_kind(ChartKind::Natal).len(), 7);
assert!(r.find("composite").is_some());
// Natal kind tiene 8 módulos aplicables.
assert_eq!(r.for_kind(ChartKind::Natal).len(), 8);
assert!(r.for_kind(ChartKind::Synastry).is_empty());
}
}