feat(tahuantinsuyu): fase 12 — Control::ChartPicker para Synastry

Cualquier carta del DB se puede elegir como partner de sinastría
(no solo hermanas del contacto actual). El módulo SynastryModule
declara un Control::ChartPicker; el panel renderea un dropdown que
abre un popup con todas las cartas; el shell inyecta las opciones y
resuelve el partner desde la selección persistida.

- modules: nueva variante Control::ChartPicker { key, label } sin
  default — las opciones las inyecta el host. SynastryModule.controls()
  agrega un picker con key="partner_chart_id".
- store: list_all_charts() — query sin filtros, ordenada por label
  case-insensitive. Pensada para selectores cross-contact.
- panel:
  - ChartOption struct público (id + label).
  - chart_options Vec + chart_picker_value HashMap + chart_picker_open
    Option (solo uno abierto a la vez).
  - APIs públicas set_chart_options / set_chart_picker para sync.
  - set_active_kind inicializa picker_value a None ("automático").
  - render_chart_picker: botón "▾ label" en bg_button; al click toggle
    un popup absolute con (automático) + separador + cada chart_option
    clickable. select_picker emite ControlChanged con Value::String(id)
    o Value::Null.
- shell:
  - new() llama refresh_chart_options al final para popular el panel
    desde el boot.
  - refresh_chart_options() builds Vec<ChartOption> desde
    list_all_charts (label + birth_brief "YYYY-MM-DD · Lugar"). Lo
    llamamos también desde TreeEvent::HierarchyChanged para que el
    dropdown refleje altas/bajas.
  - resolve_synastry_partner reemplaza find_synastry_partner: 1) lee
    module_configs["synastry"]["partner_chart_id"] y resuelve el chart;
    2) fallback al automático (primera hermana). El fallback significa
    que el módulo sigue funcionando sin elegir manualmente.
  - sync_panel_from_configs ahora maneja Value::String / Value::Null
    → set_chart_picker, así el picker se restaura al cargar una carta.

Persistencia: el partner_chart_id va al config_json (fase 11), así
que cada carta recuerda con quién hizo sinastría la última vez.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-17 11:17:35 +00:00
parent 22e6ed6a71
commit d9e21fbedc
4 changed files with 338 additions and 16 deletions
+58 -10
View File
@@ -33,7 +33,7 @@ use tahuantinsuyu_canvas::{
};
use tahuantinsuyu_engine::{LayerKind, PipelineRequest, compose};
use tahuantinsuyu_model::{Chart, ChartId, ModuleState, TreeSelection};
use tahuantinsuyu_panel::{ControlPanel, PanelEvent};
use tahuantinsuyu_panel::{ChartOption, ControlPanel, PanelEvent};
use tahuantinsuyu_store::Store;
use tahuantinsuyu_tree::{TahuantinsuyuTree, TreeEvent};
use yahweh_bus::AppBus;
@@ -81,7 +81,7 @@ impl Shell {
})
.detach();
Self {
let mut shell = Self {
store,
bus,
tree,
@@ -90,7 +90,25 @@ impl Shell {
current_chart: None,
current_offset_minutes: 0,
module_configs: HashMap::new(),
}
};
shell.refresh_chart_options(cx);
shell
}
/// Recarga la lista de opciones para los `Control::ChartPicker` y
/// la pushea al panel. Llamado al boot + tras cada
/// `TreeEvent::HierarchyChanged`.
fn refresh_chart_options(&self, cx: &mut Context<Self>) {
let charts = self.store.list_all_charts().unwrap_or_default();
let options: Vec<ChartOption> = charts
.into_iter()
.map(|c| ChartOption {
id: c.id.to_string(),
label: format!("{} — {}", c.label, format_birth_brief(&c.birth_data)),
})
.collect();
self.panel
.update(cx, |p, cx| p.set_chart_options(options, cx));
}
fn on_tree_event(&mut self, ev: &TreeEvent, cx: &mut Context<Self>) {
@@ -98,6 +116,10 @@ impl Shell {
TreeEvent::Selected(s) => s,
TreeEvent::Opened(s) => s,
TreeEvent::HierarchyChanged => {
// La jerarquía cambió (alta/baja de cartas) — refrescar
// las opciones del picker para que aparezcan / desaparezcan
// en el dropdown.
self.refresh_chart_options(cx);
cx.notify();
return;
}
@@ -211,7 +233,7 @@ impl Shell {
});
}
if module_enabled(&self.module_configs, "synastry") {
if let Some(partner) = self.find_synastry_partner() {
if let Some(partner) = self.resolve_synastry_partner() {
requests.push(PipelineRequest::Synastry {
partner_chart: Box::new(partner),
});
@@ -220,10 +242,22 @@ impl Shell {
requests
}
/// Encuentra una carta hermana del contacto actual (cualquier otra
/// carta con el mismo `contact_id` ≠ self). `None` si no hay
/// hermana — el shell salta el request silenciosamente.
fn find_synastry_partner(&self) -> Option<Chart> {
/// Resuelve la carta partner para sinastría: 1) si el picker tiene
/// un `partner_chart_id` válido en `module_configs`, lo usa; 2)
/// si no, cae al automático (primera carta hermana del contacto
/// actual). `None` si nada matchea — el request se salta.
fn resolve_synastry_partner(&self) -> Option<Chart> {
let manual = self
.module_configs
.get("synastry")
.and_then(|c| c.get("partner_chart_id"))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<tahuantinsuyu_model::ChartId>().ok())
.and_then(|id| self.store.get_chart(id).ok());
manual.or_else(|| self.find_synastry_partner_auto())
}
fn find_synastry_partner_auto(&self) -> Option<Chart> {
let current = self.current_chart.as_ref()?;
let siblings = self.store.list_charts(current.contact_id).ok()?;
siblings.into_iter().find(|c| c.id != current.id)
@@ -272,8 +306,8 @@ impl Shell {
}
}
/// Pushea cada toggle/slider del `module_configs` al panel para que
/// la UI refleje el estado persistido al cargar una carta.
/// Pushea cada toggle/slider/picker del `module_configs` al panel
/// para que la UI refleje el estado persistido al cargar una carta.
fn sync_panel_from_configs(&mut self, cx: &mut Context<Self>) {
let snapshot: Vec<(String, serde_json::Value)> = self
.module_configs
@@ -288,6 +322,10 @@ impl Shell {
p.set_toggle(module_id, key, b, cx);
} else if let Some(f) = value.as_f64() {
p.set_slider(module_id, key, f, cx);
} else if let Some(s) = value.as_str() {
p.set_chart_picker(module_id, key, Some(s.to_string()), cx);
} else if value.is_null() {
p.set_chart_picker(module_id, key, None, cx);
}
}
}
@@ -482,6 +520,16 @@ impl Shell {
// Helpers de module_configs
// =====================================================================
/// Etiqueta breve para mostrar al elegir una carta en el picker:
/// `"YYYY-MM-DD · Lugar"` cuando hay lugar, sino solo la fecha.
fn format_birth_brief(birth: &tahuantinsuyu_model::StoredBirthData) -> String {
let date = format!("{:04}-{:02}-{:02}", birth.year, birth.month, birth.day);
match &birth.birthplace_label {
Some(p) if !p.is_empty() => format!("{} · {}", date, p),
_ => date,
}
}
/// 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.