tawasuyu

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Definiciones

feat(tahuantinsuyu): fase 17 — filtros de aspectos + editor + cleanup + labels

Fase completa con 4 mejoras independientes que reusan toda la
infraestructura previa:

## A — Filtros de aspectos en NatalModule

NatalModule gana 3 controles nuevos que SÍ recomponen (a diferencia
de los show_* que solo togglean visibilidad):
- Toggle "Mayores (☌ ☍ △ □ ⚹)" default true
- Toggle "Menores (quincunx, semi-…)" default false
- Slider "Multiplicador de orbe" range 0.25..2.5 step 0.25 default 1.0

Engine API extendida sin romper la existente:
- pub struct NatalOptions { show_majors, show_minors, orb_multiplier }
- pub fn compose_with_options(chart, offset, requests, &NatalOptions)
- compose() queda como wrapper con NatalOptions::default()
- bridge::compose acepta el natal_options, construye OrbTable escalada
  (build_orb_table multiplier) y filtra aspects antes de pasarlos a
  build_render_model. Build_render_model dejó de filtrar majors
  internamente — ahora respeta lo que recibe.

Shell wire:
- build_natal_options() lee aspect_majors/aspect_minors/orb_multiplier
  desde module_configs["natal"] con defaults seguros.
- on_panel_event para natal: si key empieza con "show_" → canvas
  visibility (sin recompose); otherwise → update module_configs +
  persist + render_current.
- render_current pasa natal_options a compose_with_options.

## B — Editor de carta natal existente

- Store::update_chart(id, label, &birth, &config) — actualiza tres
  columnas preservando id/contact_id/related/created_at_ms y todo el
  module_state asociado (la FK CASCADE no se dispara por UPDATE).
- Tree: Modal::EditChart { id, form, error } reusa ChartForm que ya
  manejaba el create. open_edit_chart(id, w, cx) lee la carta con
  store.get_chart, pre-carga cada TextInput con el valor existente
  (label, birthplace, año, mes, día, hora, min, tz, lat, lon, alt).
  submit_modal::EditChart lee form, llama update_chart, preserva el
  config existente (zodiac/house_system/bodies no se editan acá).
  Menú contextual del chart agrega "Editar…" entre "Abrir" y
  "Renombrar".
- render_chart_form ahora toma `title: &str` parameter para que el
  modal muestre "Editar carta natal" vs "Nueva carta natal". El
  botón cambia "Crear carta" → "Guardar cambios" según el title.

## C — Single source of truth para OUTER_RING_MODULES

- engine exporta `pub const OUTER_RING_MODULES: &[&str] = &["transit",
  "synastry", "planetary_return"]`
- shell elimina su const local, importa del engine
- canvas elimina 4 listas hardcodeadas (paint_wheel outer ring active
  check + glyphs overlay + aspect_endpoints match) y usa contains() o
  early-return sobre el slice. Próximo módulo outer-ring solo necesita
  agregarse al const, no buscar copias.

## D — Labels ASC/MC/DESC/IC en el perímetro

Cuatro centered_glyphs en radii.sign_outer * 1.06 (justo afuera del
dial zodiacal, dentro del WHEEL_MARGIN) con color angle_highlight y
font 10px. El ojo identifica los 4 ángulos inmediatamente sin tener
que mapear la línea radial gruesa al ángulo correspondiente.
Las posiciones rotan con la rueda (drag del jog-dial los lleva).

`cargo check` y `cargo test` verdes. La fase agregó 6 controles
visibles al panel del NatalModule (4 view + 2 aspect filter + 1
slider) sin tocar la arquitectura de fases 6-15.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

0ae622550d62cf0f7dda779729f6a55d312c85f1 · por sergio · (hace 5 meses) · padre cabdb2927 · ver el árbol en este commit

Delta semántico · 23

crates/apps/tahuantinsuyu/src/shell.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs

7 archivos · +335 −35

diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs
index 43d6960..a85f42d 100644
--- a/crates/apps/tahuantinsuyu/src/shell.rs
+++ b/crates/apps/tahuantinsuyu/src/shell.rs
@@ -31,7 +31,9 @@ use gpui::{
 use tahuantinsuyu_canvas::{
     AstrologyCanvas, CanvasEvent, CanvasMode, ThumbnailItem, ThumbnailScope,
 };
-use tahuantinsuyu_engine::{LayerKind, PipelineRequest, compose};
+use tahuantinsuyu_engine::{
+    LayerKind, NatalOptions, OUTER_RING_MODULES, PipelineRequest, compose_with_options,
+};
 use tahuantinsuyu_model::{Chart, ChartId, ModuleState, TreeSelection};
 use tahuantinsuyu_panel::{ChartOption, ControlPanel, PanelEvent};
 use tahuantinsuyu_store::Store;
@@ -287,6 +289,28 @@ impl Shell {
         siblings.into_iter().find(|c| c.id != current.id)
     }
 
+    /// Deriva las `NatalOptions` activas a partir del `module_configs["natal"]`.
+    /// Si la entry no existe, devuelve defaults (majors=true, minors=false,
+    /// multiplier=1.0).
+    fn build_natal_options(&self) -> NatalOptions {
+        let cfg = self.module_configs.get("natal");
+        let read_bool = |key: &str, default: bool| -> bool {
+            cfg.and_then(|c| c.get(key))
+                .and_then(|v| v.as_bool())
+                .unwrap_or(default)
+        };
+        let read_f64 = |key: &str, default: f64| -> f64 {
+            cfg.and_then(|c| c.get(key))
+                .and_then(|v| v.as_f64())
+                .unwrap_or(default)
+        };
+        NatalOptions {
+            show_majors: read_bool("aspect_majors", true),
+            show_minors: read_bool("aspect_minors", false),
+            orb_multiplier: read_f64("orb_multiplier", 1.0),
+        }
+    }
+
     /// Lee `module_state` desde SQLite para la carta dada y los mergea
     /// con los defaults ya cargados en `module_configs`. Los valores
     /// persistidos ganan sobre los defaults.
@@ -406,7 +430,13 @@ impl Shell {
             return;
         };
         let requests = self.build_requests();
-        let render = match compose(chart, self.current_offset_minutes, &requests) {
+        let natal_options = self.build_natal_options();
+        let render = match compose_with_options(
+            chart,
+            self.current_offset_minutes,
+            &requests,
+            &natal_options,
+        ) {
             Ok(r) => r,
             Err(e) => {
                 eprintln!(
@@ -475,8 +505,9 @@ impl Shell {
             } => {
                 let bool_val = value.as_bool().unwrap_or(true);
                 if module_id == "natal" {
-                    // Toggles puramente visuales — solo afectan visibility
-                    // del render actual, sin recomponer.
+                    // Distinguimos: show_* = visibility (no recompose),
+                    // aspect_*/orb_* = filtros de engine (recompose +
+                    // persist).
                     let kind = match key.as_str() {
                         "show_sign_dial" => Some(LayerKind::SignDial),
                         "show_houses" => Some(LayerKind::Houses),
@@ -487,6 +518,17 @@ impl Shell {
                     if let Some(k) = kind {
                         self.canvas
                             .update(cx, |c, cx| c.set_layer_visible(k, bool_val, cx));
+                    } else {
+                        // Filtros: actualizar module_configs + recompose.
+                        let entry = self
+                            .module_configs
+                            .entry("natal".into())
+                            .or_insert_with(|| serde_json::json!({}));
+                        if let serde_json::Value::Object(map) = entry {
+                            map.insert(key.clone(), value.clone());
+                        }
+                        self.persist_module("natal");
+                        self.render_current(cx);
                     }
                 } else {
                     // Cualquier otro módulo: actualizamos su config y
@@ -540,9 +582,8 @@ impl Shell {
 // Helpers de module_configs
 // =====================================================================
 
-/// Módulos que pintan en el outer ring del canvas — mutuamente
-/// excluyentes a nivel de UI. Al prender uno, los otros se apagan.
-const OUTER_RING_MODULES: &[&str] = &["transit", "synastry", "planetary_return"];
+// OUTER_RING_MODULES viene de tahuantinsuyu_engine — single source of
+// truth. Shell y canvas leen del mismo slice.
 
 
 /// Etiqueta breve para mostrar al elegir una carta en el picker:
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
index 6d1f23c..83341ff 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
@@ -40,7 +40,7 @@ use gpui::{
     linear_color_stop, linear_gradient, point, prelude::*, px,
 };
 
-use tahuantinsuyu_engine::{Geometry, Layer, LayerKind, RenderModel};
+use tahuantinsuyu_engine::{Geometry, Layer, LayerKind, OUTER_RING_MODULES, RenderModel};
 use tahuantinsuyu_model::{ChartId, ContactId, GroupId};
 use tahuantinsuyu_theme::{AspectKind as TAspectKind, AstroPalette, Element, Planet};
 use yahweh_theme::Theme;
@@ -584,9 +584,7 @@ fn render_wheel(
     if visible.get(&LayerKind::Outer).copied().unwrap_or(true) {
         for layer in &render.layers {
             if matches!(layer.kind, LayerKind::Outer)
-                && (layer.module_id == "transit"
-                || layer.module_id == "synastry"
-                || layer.module_id == "planetary_return")
+                && (OUTER_RING_MODULES.contains(&layer.module_id.as_str()))
             {
                 for g in &layer.glyphs {
                     let (x, y) = polar_to_screen(g.deg, asc, rot_offset, radii.transits);
@@ -609,6 +607,29 @@ fn render_wheel(
         }
     }
 
+    // Labels ASC/MC/DESC/IC en el perímetro. Texto pequeño en el
+    // margen exterior (radius * 1.05) para que no se monte con los
+    // glifos de los signos. Color angle_highlight para que el ojo los
+    // reconozca como los cuatro ángulos cardinales.
+    let angle_labels = [
+        (asc, "ASC"),
+        (render.midheaven_deg, "MC"),
+        (render.descendant_deg, "DESC"),
+        (render.imum_coeli_deg, "IC"),
+    ];
+    let label_r = r_outer * 1.06;
+    for (deg, label) in angle_labels {
+        let (x, y) = polar_to_screen(deg, asc, rot_offset, label_r);
+        wheel = wheel.child(centered_glyph(
+            cx_center + x,
+            cy_center + y,
+            32.0,
+            10.0,
+            label.into(),
+            palette.angle_highlight,
+        ));
+    }
+
     // --- Header + footer + indicador de tiempo ---
     let header = div()
         .flex()
@@ -789,12 +810,14 @@ impl Radii {
 
     /// Resuelve qué radios corresponden a una capa de aspectos según el
     /// `module_id`: natal-natal en `aspects`, cross con cada overlay
-    /// desde `bodies` (extremo natal) al ring del módulo. Synastry y
-    /// Planetary Return comparten el outer ring de tránsito (los tres
-    /// son mutuamente excluyentes a nivel de Shell).
+    /// desde `bodies` (extremo natal) al ring del módulo. Los módulos
+    /// del outer ring (OUTER_RING_MODULES) comparten el slot de
+    /// tránsito (son mutuamente excluyentes a nivel de Shell).
     fn aspect_endpoints(&self, module_id: &str) -> (f32, f32) {
+        if OUTER_RING_MODULES.contains(&module_id) {
+            return (self.bodies, self.transits);
+        }
         match module_id {
-            "transit" | "synastry" | "planetary_return" => (self.bodies, self.transits),
             "progression" => (self.bodies, self.progression),
             "solar_arc" => (self.bodies, self.solar_arc),
             _ => (self.aspects, self.aspects),
@@ -1012,9 +1035,7 @@ fn paint_wheel(
     // si alguno de los dos está prendido, pintamos el slot.
     let outer_active = layers.iter().any(|l| {
         matches!(l.kind, LayerKind::Outer)
-            && (l.module_id == "transit"
-                || l.module_id == "synastry"
-                || l.module_id == "planetary_return")
+            && OUTER_RING_MODULES.contains(&l.module_id.as_str())
     });
     if outer_active && show(LayerKind::Outer) {
         stroke_circle(
@@ -1037,9 +1058,7 @@ fn paint_wheel(
         let dot_r = (radii.sign_outer * 0.017).max(2.0);
         for layer in layers {
             if matches!(layer.kind, LayerKind::Outer)
-                && (layer.module_id == "transit"
-                || layer.module_id == "synastry"
-                || layer.module_id == "planetary_return")
+                && (OUTER_RING_MODULES.contains(&layer.module_id.as_str()))
             {
                 for g in &layer.glyphs {
                     let color = with_alpha(planet_color(palette, &g.symbol), 0.85);
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
index 29870e5..ca29fe2 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
@@ -244,10 +244,20 @@ pub fn compose(
     chart: &Chart,
     offset_minutes: i64,
     requests: &[crate::PipelineRequest],
+    natal_options: &crate::NatalOptions,
 ) -> Result<RenderModel, EngineError> {
     let t0 = Instant::now();
     let (natal, config_e, observer) = compute_natal_chart(chart, offset_minutes)?;
-    let aspects = find_aspects(&natal, &OrbTable::modern_western());
+    let orb_table = build_orb_table(natal_options.orb_multiplier);
+    let all_aspects = find_aspects(&natal, &orb_table);
+    let aspects: Vec<Aspect> = all_aspects
+        .into_iter()
+        .filter(|a| {
+            let is_major = EAspectKind::MAJORS.contains(&a.kind);
+            (is_major && natal_options.show_majors)
+                || (!is_major && natal_options.show_minors)
+        })
+        .collect();
     let mut render = build_render_model(chart, &natal, &aspects, t0);
 
     for req in requests {
@@ -776,13 +786,10 @@ fn build_render_model(
     };
 
     // ─── Capa 3: Aspects ──────────────────────────────────────────────
+    // Los aspects ya vienen filtrados por NatalOptions (majors / minors)
+    // desde compose(). Acá solo mapeamos a LineSeg.
     let mut aspect_lines: Vec<LineSeg> = Vec::with_capacity(aspects.len());
     for a in aspects {
-        // Solo los aspectos mayores se pintan en este pase — los menores
-        // saturan visualmente. Fase 4 pondrá un toggle para mostrarlos.
-        if !EAspectKind::MAJORS.contains(&a.kind) {
-            continue;
-        }
         let pa = natal.placement(a.a);
         let pb = natal.placement(a.b);
         if let (Some(pa), Some(pb)) = (pa, pb) {
@@ -834,6 +841,27 @@ fn build_render_model(
     }
 }
 
+/// Construye una `OrbTable` con los orbes default de `modern_western`
+/// escalados por `multiplier`. Necesario porque eternal expone
+/// `set_orb` pero no permite iterar los base orbs internos.
+fn build_orb_table(multiplier: f64) -> OrbTable {
+    let mut t = OrbTable::modern_western();
+    let m = multiplier.max(0.0);
+    t.set_orb(EAspectKind::Conjunction, 8.0 * m);
+    t.set_orb(EAspectKind::Opposition, 8.0 * m);
+    t.set_orb(EAspectKind::Trine, 7.0 * m);
+    t.set_orb(EAspectKind::Square, 7.0 * m);
+    t.set_orb(EAspectKind::Sextile, 5.0 * m);
+    t.set_orb(EAspectKind::Quincunx, 2.5 * m);
+    t.set_orb(EAspectKind::SemiSextile, 2.0 * m);
+    t.set_orb(EAspectKind::SemiSquare, 2.0 * m);
+    t.set_orb(EAspectKind::Sesquiquadrate, 2.0 * m);
+    t.set_orb(EAspectKind::Quintile, 1.5 * m);
+    t.set_orb(EAspectKind::BiQuintile, 1.5 * m);
+    t.set_orb(EAspectKind::Septile, 1.5 * m);
+    t
+}
+
 fn push_overlay_meta(render: &mut RenderModel, module_id: &str, label: String) {
     render.overlays.push(OverlayMeta {
         module_id: module_id.to_string(),
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
index 533cfca..e47fdc0 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
@@ -178,6 +178,12 @@ pub enum EngineError {
 /// `tahuantinsuyu-modules` por id string. Esto deja la engine como
 /// dueña única del cómputo (no depende del trait Module — los módulos
 /// son sólo metadata + UI controls).
+/// Módulos overlay que pintan en el mismo slot (outer ring del wheel)
+/// y por lo tanto son **mutuamente excluyentes** a nivel de UI: al
+/// prender uno, el shell debe apagar los otros. Single source of truth
+/// — el shell y el canvas leen de acá en vez de hardcodear listas.
+pub const OUTER_RING_MODULES: &[&str] = &["transit", "synastry", "planetary_return"];
+
 #[derive(Debug, Clone)]
 pub enum PipelineRequest {
     /// `module_id = "transit"` — anillo externo con planetas al
@@ -220,21 +226,54 @@ pub enum PipelineRequest {
     },
 }
 
+/// Opciones que afectan la pasada natal (qué aspectos pintar, qué
+/// multiplicador de orbe usar). Es independiente de los overlays.
+#[derive(Debug, Clone)]
+pub struct NatalOptions {
+    /// Incluir aspectos mayores (conj/opp/trine/square/sextile).
+    pub show_majors: bool,
+    /// Incluir aspectos menores (quincunx/semi-sextile/etc).
+    pub show_minors: bool,
+    /// Multiplicador uniforme sobre los orbes default. `1.0` = orbes
+    /// modern_western; `0.5` = tight; `2.0` = wide.
+    pub orb_multiplier: f64,
+}
+
+impl Default for NatalOptions {
+    fn default() -> Self {
+        Self {
+            show_majors: true,
+            show_minors: false,
+            orb_multiplier: 1.0,
+        }
+    }
+}
+
 /// Composición canónica: carta natal + todos los overlays pedidos.
-/// Es la única función que el Shell necesita llamar — `compute_at_offset`
-/// y `compute_with_transits_at_now` quedan como atajos retrocompatibles.
+/// Equivalente a `compose_with_options` con `NatalOptions::default()`.
 pub fn compose(
     chart: &Chart,
     offset_minutes: i64,
     requests: &[PipelineRequest],
+) -> Result<RenderModel, EngineError> {
+    compose_with_options(chart, offset_minutes, requests, &NatalOptions::default())
+}
+
+/// Variante que permite controlar qué aspectos natales se computan y
+/// con qué multiplicador de orbe.
+pub fn compose_with_options(
+    chart: &Chart,
+    offset_minutes: i64,
+    requests: &[PipelineRequest],
+    natal_options: &NatalOptions,
 ) -> Result<RenderModel, EngineError> {
     #[cfg(feature = "eternal-bridge")]
     {
-        bridge::compose(chart, offset_minutes, requests)
+        bridge::compose(chart, offset_minutes, requests, natal_options)
     }
     #[cfg(not(feature = "eternal-bridge"))]
     {
-        let _ = (offset_minutes, requests);
+        let _ = (offset_minutes, requests, natal_options);
         Ok(compute_mock(chart))
     }
 }
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
index 4e67357..9a8320f 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
@@ -217,6 +217,28 @@ pub mod natal {
                     default: true,
                     hotkey: Some("P".into()),
                 },
+                // Filtros de aspectos: cambian QUÉ se computa, no QUÉ
+                // se pinta del render. Recompose al togglear.
+                Control::Toggle {
+                    key: "aspect_majors".into(),
+                    label: "Mayores (☌ ☍ △ □ ⚹)".into(),
+                    default: true,
+                    hotkey: None,
+                },
+                Control::Toggle {
+                    key: "aspect_minors".into(),
+                    label: "Menores (quincunx, semi-…)".into(),
+                    default: false,
+                    hotkey: None,
+                },
+                Control::Slider {
+                    key: "orb_multiplier".into(),
+                    label: "Multiplicador de orbe".into(),
+                    min: 0.25,
+                    max: 2.5,
+                    step: 0.25,
+                    default: 1.0,
+                },
                 Control::Slider {
                     key: "harmonic".into(),
                     label: "Armónico".into(),
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs
index 557c638..6742cd9 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs
@@ -343,6 +343,31 @@ impl Store {
         Ok(())
     }
 
+    /// Reemplaza label + birth_data + config de una carta existente,
+    /// preservando id / contact_id / related_chart_id / created_at_ms y
+    /// el `module_state` asociado (no se borra). Usado por el editor de
+    /// rectificación natal.
+    pub fn update_chart(
+        &self,
+        id: ChartId,
+        label: &str,
+        birth: &StoredBirthData,
+        config: &StoredChartConfig,
+    ) -> StoreResult<()> {
+        let conn = self.conn.lock().unwrap();
+        conn.execute(
+            "UPDATE charts SET label = ?2, birth_data_json = ?3, config_json = ?4 \
+             WHERE id = ?1",
+            params![
+                id.to_string(),
+                label,
+                serde_json::to_string(birth)?,
+                serde_json::to_string(config)?,
+            ],
+        )?;
+        Ok(())
+    }
+
     // -----------------------------------------------------------------
     // Module state
     // -----------------------------------------------------------------
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
index 410436e..d22ccea 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
@@ -112,6 +112,15 @@ enum Modal {
         form: ChartForm,
         error: Option<SharedString>,
     },
+    /// Editar una carta existente — reusa `ChartForm` pre-cargada.
+    /// El submit llama `store.update_chart(id, ...)` preservando
+    /// `chart.contact_id`, `related_chart_id`, `module_state` y el
+    /// historial.
+    EditChart {
+        id: ChartId,
+        form: ChartForm,
+        error: Option<SharedString>,
+    },
 }
 
 struct ChartForm {
@@ -309,6 +318,58 @@ impl TahuantinsuyuTree {
         self.close_menu(cx);
     }
 
+    fn open_edit_chart(
+        &mut self,
+        id: ChartId,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        // Cargar la carta existente; si no se puede, fallamos en silencio.
+        let chart = match self.store.get_chart(id) {
+            Ok(c) => c,
+            Err(e) => {
+                eprintln!("[tree] open_edit_chart {}: {}", id, e);
+                return;
+            }
+        };
+        let bd = &chart.birth_data;
+        let form = ChartForm {
+            name: self.make_input("Etiqueta de la carta", &chart.label, window, cx),
+            place: self.make_input(
+                "Lugar (ciudad, país)",
+                bd.birthplace_label.as_deref().unwrap_or(""),
+                window,
+                cx,
+            ),
+            year: self.make_input("Año", &bd.year.to_string(), window, cx),
+            month: self.make_input("Mes", &bd.month.to_string(), window, cx),
+            day: self.make_input("Día", &bd.day.to_string(), window, cx),
+            hour: self.make_input("Hora (0-23)", &bd.hour.to_string(), window, cx),
+            minute: self.make_input("Minuto", &bd.minute.to_string(), window, cx),
+            tz_offset_min: self.make_input(
+                "TZ offset (min)",
+                &bd.tz_offset_minutes.to_string(),
+                window,
+                cx,
+            ),
+            lat: self.make_input("Latitud (°)", &format!("{}", bd.latitude_deg), window, cx),
+            lon: self.make_input(
+                "Longitud (°)",
+                &format!("{}", bd.longitude_deg),
+                window,
+                cx,
+            ),
+            alt: self.make_input("Altitud (m)", &format!("{}", bd.altitude_m), window, cx),
+        };
+        form.name.update(cx, |i, _| i.request_focus(window));
+        self.modal = Some(Modal::EditChart {
+            id,
+            form,
+            error: None,
+        });
+        self.close_menu(cx);
+    }
+
     fn open_create_chart(
         &mut self,
         contact: ContactId,
@@ -521,6 +582,56 @@ impl TahuantinsuyuTree {
                     }
                 }
             }
+            Modal::EditChart {
+                id,
+                form,
+                error: _,
+            } => {
+                let _ = value;
+                // Para preservar el ChartConfig original (zodiac, house
+                // system, bodies, etc.) leemos la carta actual y solo
+                // sobrescribimos label + birth_data. El editor no toca
+                // config — eso se haría en un futuro panel de "Config
+                // de carta".
+                let existing = match self.store.get_chart(id) {
+                    Ok(c) => c,
+                    Err(e) => {
+                        self.modal = Some(Modal::EditChart {
+                            id,
+                            form,
+                            error: Some(SharedString::from(format!("Store: {}", e))),
+                        });
+                        cx.notify();
+                        return;
+                    }
+                };
+                match build_chart_from_form(&form, cx) {
+                    Ok((birth, label)) => {
+                        match self.store.update_chart(id, &label, &birth, &existing.config) {
+                            Ok(_) => {
+                                drop(form);
+                                self.after_mutation(cx);
+                            }
+                            Err(e) => {
+                                self.modal = Some(Modal::EditChart {
+                                    id,
+                                    form,
+                                    error: Some(SharedString::from(format!("Store: {}", e))),
+                                });
+                                cx.notify();
+                            }
+                        }
+                    }
+                    Err(msg) => {
+                        self.modal = Some(Modal::EditChart {
+                            id,
+                            form,
+                            error: Some(SharedString::from(msg)),
+                        });
+                        cx.notify();
+                    }
+                }
+            }
         }
     }
 
@@ -829,6 +940,11 @@ impl TahuantinsuyuTree {
                         this.close_menu(cx);
                     }),
                 ));
+                items = items.child(menu_item("tts-menu-edit-h", "Editar…", theme).on_click(
+                    cx.listener(move |this, _: &ClickEvent, w, cx| {
+                        this.open_edit_chart(id, w, cx);
+                    }),
+                ));
                 items = items.child(separator(theme));
                 let t = menu.target.clone();
                 items = items.child(menu_item("tts-menu-rename-h", "Renombrar…", theme).on_click(
@@ -869,7 +985,12 @@ impl TahuantinsuyuTree {
                 input.clone(),
                 "Enter = crear — Escape = cancelar",
             ),
-            Modal::CreateChart { form, error, .. } => render_chart_form(theme, form, error.clone(), cx),
+            Modal::CreateChart { form, error, .. } => {
+                render_chart_form(theme, "Nueva carta natal", form, error.clone(), cx)
+            }
+            Modal::EditChart { form, error, .. } => {
+                render_chart_form(theme, "Editar carta natal", form, error.clone(), cx)
+            }
         };
 
         div()
@@ -945,6 +1066,7 @@ fn modal_box(
 
 fn render_chart_form(
     theme: &Theme,
+    title: &str,
     form: &ChartForm,
     error: Option<SharedString>,
     cx: &mut Context<TahuantinsuyuTree>,
@@ -991,11 +1113,15 @@ fn render_chart_form(
         .hover(|s| s.bg(theme.bg_button_hover()))
         .text_size(px(12.0))
         .text_color(theme.fg_text)
-        .child("Crear carta")
+        .child(SharedString::from(if title.starts_with("Editar") {
+            "Guardar cambios"
+        } else {
+            "Crear carta"
+        }))
         .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
             // Disparamos un submit "vacío" — el handler de submit
             // re-lee todos los campos del form. El value que pasamos
-            // se ignora dentro del branch CreateChart.
+            // se ignora dentro del branch CreateChart/EditChart.
             this.submit_modal(String::new(), cx);
         }));
 
@@ -1027,7 +1153,7 @@ fn render_chart_form(
             div()
                 .text_size(px(14.0))
                 .text_color(theme.fg_text)
-                .child("Nueva carta natal"),
+                .child(SharedString::from(title.to_string())),
         )
         .child(
             div()