gioser

tawasuyu/brahman

Rust 496TOML 195Markdown 55JSON 197001 Definitions

feat(tahuantinsuyu): fase 19 — theme switcher + house tooltips + midpoints + city presets

Fase completa con 4 mejoras independientes que aprovechan toda la
infraestructura previa. La aplicación ahora cubre lecturas profundas
(midpoints uranianos), accesibilidad visual (tooltips de cusps),
personalización (6 themes vía yahweh-widget-theme-switcher) y
usabilidad pragmática (city presets en el form).

## C — Theme switcher en header

- apps/tahuantinsuyu: nueva dep yahweh-widget-theme-switcher.
- shell render(): theme_switcher(cx) en el extremo derecho del header
  (con flex_grow del divider del medio). Click cicla entre los 6
  presets de yahweh-theme (Nebula, Aurora, Sunset, FlatDark,
  SolarizedLight, HighContrast). AstroPalette::for_theme(theme) lee
  is_dark, así toda la rueda se re-tinta automáticamente.

## B — Tooltips sobre house cusps

- canvas: HoverInfo deja de ser struct para ser enum con variantes
  Body { ... } y HouseCusp { house_number, deg, local_x, local_y }.
  Helpers .local() y .key() unifican el acceso.
- on_hover_check: primero hit-test bodies (threshold 14px); si no hubo
  match Y el mouse está dentro del anillo de casas
  (houses_inner..houses_outer ± 6px), calcula la longitud zodiacal
  desde el ángulo de pantalla (inversa de polar_to_screen) y busca el
  cusp más cercano (proximidad angular < 2.5°). HoverInfo::HouseCusp.
- Tooltip render: "Cusp Casa N · Signo XX.X°".

## D — MidpointsModule (Uranian-lite)

- engine: PipelineRequest::Midpoints (sin parámetros, default empty).
- bridge: build_midpoints_overlay computa midpoints entre todos los
  pares de placements donde involucran Sol o Luna (~10 puntos según
  body set). Fórmula: si |a-b|>180, mid=((a+b)/2+180) mod 360, sino
  (a+b)/2 mod 360. Emite como Layer { kind: Midpoints, module_id:
  "midpoints", ring: 0.62 } con Glyph.symbol="sun/jupiter" y
  annotation="Sun/Jupiter".
- modules: midpoints::MidpointsModule con toggle "Activar". Registry
  pasa a 7 módulos. Test actualizado.
- shell: build_requests detecta midpoints.enabled, pushea
  PipelineRequest::Midpoints (no toma age ni body — es derivado puro).
- canvas: Radii agrega midpoints: r * 0.62 (entre houses_inner y
  bodies natales). body_ring("midpoints") y aspect_endpoints retornan
  ese radio. paint_wheel agrega un loop para LayerKind::Midpoints
  pintando dots pequeños (r=0.012, alpha 0.7 sobre house_cusp color)
  — los midpoints no llevan unicode symbol propio (no existe en
  Unicode astrológico estándar). El detalle del par viene en hover.
- Hover sobre un midpoint: tooltip muestra "☉/♄ Tauro 14.3° ·
  Sun/Jupiter" (display_symbol parsea "a/b" en dos unicodes;
  annotation incluye nombres completos eternal).

## A — City presets en el ChartForm

- tree: nueva const CITY_PRESETS con 25 ciudades (Latinoamérica
  capitales + 5 europeas + 5 anglosajonas + Tokyo/Sydney/Mumbai/Cairo)
  con (name, lat, lon, tz_offset_minutes) sin DST. CityPreset struct.
- tree: TahuantinsuyuTree gana city_picker_open: bool. close_modal
  lo resetea. toggle_city_picker + apply_city_preset(preset) helpers.
  apply_city_preset lee el Modal activo (CreateChart o EditChart),
  llama TextInput::set_text en place/lat/lon/tz del ChartForm,
  cierra el picker.
- render_chart_form: title_row ahora tiene "📍 Ciudad rápida ▾"
  button a la derecha del title. Click → toggle. Cuando picker_open,
  popup absoluto debajo con la lista de presets. Click en preset →
  autocompleta + cierra. El usuario sigue pudiendo editar manualmente
  cualquier campo después; el preset es solo un punto de partida
  rápido para evitar tipear coordenadas a mano.

cargo check verde, 8 tests engine + 1 test modules (7 módulos)
verdes.

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

32ab22f9542192dcf4d8f5b6fce4cefb7b1d0f7d · by sergio · (5 months ago) · parent 2cd34c82d · browse the tree at this commit

Semantic delta · 32

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-tree/src/lib.rs

8 files · +436 −63

diff --git a/Cargo.lock b/Cargo.lock
index 010c816..fff98f7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -10918,6 +10918,7 @@ dependencies = [
  "tahuantinsuyu-tree",
  "yahweh-bus",
  "yahweh-theme",
+ "yahweh-widget-theme-switcher",
 ]
 
 [[package]]
diff --git a/crates/apps/tahuantinsuyu/Cargo.toml b/crates/apps/tahuantinsuyu/Cargo.toml
index 82a8ad5..0cd08cb 100644
--- a/crates/apps/tahuantinsuyu/Cargo.toml
+++ b/crates/apps/tahuantinsuyu/Cargo.toml
@@ -18,6 +18,7 @@ tahuantinsuyu-tree = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-tree" }
 
 yahweh-bus = { workspace = true }
 yahweh-theme = { workspace = true }
+yahweh-widget-theme-switcher = { path = "../../modules/ui_engine/widgets/theme-switcher" }
 gpui = { workspace = true }
 directories = { workspace = true }
 serde_json = { workspace = true }
diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs
index a0fc6b2..020b3ad 100644
--- a/crates/apps/tahuantinsuyu/src/shell.rs
+++ b/crates/apps/tahuantinsuyu/src/shell.rs
@@ -41,6 +41,7 @@ use tahuantinsuyu_store::Store;
 use tahuantinsuyu_tree::{TahuantinsuyuTree, TreeEvent};
 use yahweh_bus::AppBus;
 use yahweh_theme::Theme;
+use yahweh_widget_theme_switcher::theme_switcher;
 
 const TREE_WIDTH: f32 = 280.0;
 const PANEL_HEIGHT: f32 = 180.0;
@@ -252,6 +253,9 @@ impl Shell {
                 });
             }
         }
+        if module_enabled(&self.module_configs, "midpoints") {
+            requests.push(PipelineRequest::Midpoints);
+        }
         if module_enabled(&self.module_configs, "planetary_return") {
             let age = self.module_age_or_current("planetary_return");
             let body = self
@@ -705,7 +709,9 @@ impl Render for Shell {
                     .text_size(px(10.0))
                     .text_color(theme.fg_muted)
                     .child("estudio de astrología profesional"),
-            );
+            )
+            .child(div().flex_grow())
+            .child(theme_switcher(cx));
 
         let tree_panel = div()
             .w(px(TREE_WIDTH))
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
index 21e0de5..c2aac9b 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
@@ -119,20 +119,47 @@ pub struct CanvasState {
     drag_jog: Option<JogDragState>,
 }
 
-/// Info del cuerpo bajo el cursor — usado por el render para mostrar
-/// un tooltip flotante con detalles.
+/// Info del elemento bajo el cursor — usado por el render para mostrar
+/// un tooltip flotante con detalles. Cubre body glyphs (módulo +
+/// símbolo + grado + casa + retro + dignidad) y cusps de casa (número
+/// de la casa + grado del cusp + signo).
 #[derive(Clone, Debug)]
-pub struct HoverInfo {
-    pub module_id: String,
-    pub symbol: String,
-    pub deg: f32,
-    pub house: Option<u8>,
-    pub retrograde: bool,
-    pub dignity_marker: Option<String>,
-    pub annotation: Option<String>,
-    /// Posición relativa al wheel (en píxeles desde su top-left).
-    pub local_x: f32,
-    pub local_y: f32,
+pub enum HoverInfo {
+    Body {
+        module_id: String,
+        symbol: String,
+        deg: f32,
+        house: Option<u8>,
+        retrograde: bool,
+        dignity_marker: Option<String>,
+        annotation: Option<String>,
+        local_x: f32,
+        local_y: f32,
+    },
+    HouseCusp {
+        house_number: u8,
+        deg: f32,
+        local_x: f32,
+        local_y: f32,
+    },
+}
+
+impl HoverInfo {
+    fn local(&self) -> (f32, f32) {
+        match self {
+            HoverInfo::Body { local_x, local_y, .. } => (*local_x, *local_y),
+            HoverInfo::HouseCusp { local_x, local_y, .. } => (*local_x, *local_y),
+        }
+    }
+
+    fn key(&self) -> String {
+        match self {
+            HoverInfo::Body {
+                module_id, symbol, ..
+            } => format!("body:{}:{}", module_id, symbol),
+            HoverInfo::HouseCusp { house_number, .. } => format!("cusp:{}", house_number),
+        }
+    }
 }
 
 impl Default for CanvasState {
@@ -273,11 +300,10 @@ impl AstrologyCanvas {
         cx.notify();
     }
 
-    /// Hit-test sobre los body glyphs activos. Recibe la posición del
-    /// mouse en window coords y los bounds del canvas (wheel). Para
-    /// cada Glyph con LayerKind == Bodies u Outer en el RenderModel
-    /// actual, calcula su posición pintada y mide distancia. Si está
-    /// dentro de `threshold_px`, actualiza `state.hover`.
+    /// Hit-test sobre body glyphs + house cusps. Para bodies: distancia
+    /// al centro del glyph dentro de threshold. Para cusps: el mouse
+    /// debe estar cerca del ring de casas Y angularmente cerca del
+    /// cusp (proximidad a la línea radial).
     fn on_hover_check(
         &mut self,
         position: Point<Pixels>,
@@ -293,16 +319,21 @@ impl AstrologyCanvas {
         let (cx_px, cy_px) = bounds_center(bounds);
         let mx: f32 = position.x.into();
         let my: f32 = position.y.into();
+        let ox: f32 = bounds.origin.x.into();
+        let oy: f32 = bounds.origin.y.into();
         let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
         let radii = Radii::from_outer(r_outer);
         let asc = render.ascendant_deg;
         let rot = self.state.view_rotation_deg;
-        let threshold = 14.0_f32;
+        let body_threshold = 14.0_f32;
 
         let mut best: Option<(f32, HoverInfo)> = None;
+
+        // 1) Body glyphs (incluye natal, overlays, midpoints).
         for layer in &render.layers {
             let ring = match layer.kind {
                 LayerKind::Bodies => radii.body_ring(&layer.module_id),
+                LayerKind::Midpoints => radii.midpoints,
                 LayerKind::Outer if OUTER_RING_MODULES.contains(&layer.module_id.as_str()) => {
                     radii.transits
                 }
@@ -313,15 +344,13 @@ impl AstrologyCanvas {
                 let dx = mx - (cx_px + gx);
                 let dy = my - (cy_px + gy);
                 let dist = (dx * dx + dy * dy).sqrt();
-                if dist > threshold {
+                if dist > body_threshold {
                     continue;
                 }
                 if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
-                    let ox: f32 = bounds.origin.x.into();
-                    let oy: f32 = bounds.origin.y.into();
                     best = Some((
                         dist,
-                        HoverInfo {
+                        HoverInfo::Body {
                             module_id: layer.module_id.clone(),
                             symbol: g.symbol.clone(),
                             deg: g.deg,
@@ -336,9 +365,62 @@ impl AstrologyCanvas {
                 }
             }
         }
+
+        // 2) House cusps — solo si el mouse está cerca del anillo de
+        // casas (radio entre houses_inner y houses_outer + margen) y
+        // ningún body ganó. Las cusps son líneas radiales — la
+        // distancia angular al cusp más cercano determina el hit.
+        if best.is_none() {
+            let dx = mx - cx_px;
+            let dy = my - cy_px;
+            let mouse_r = (dx * dx + dy * dy).sqrt();
+            let r_in = radii.houses_inner - 6.0;
+            let r_out = radii.houses_outer + 6.0;
+            if mouse_r >= r_in && mouse_r <= r_out {
+                // Calcular la longitud zodiacal que corresponde a este
+                // ángulo de pantalla (inversa de polar_to_screen).
+                let screen_angle_deg = dy.atan2(dx).to_degrees(); // (-180, 180]
+                // polar_to_screen: deg = 180 - (lon - asc + rot)
+                // → lon = asc + 180 - screen_angle_deg - rot
+                let lon = ((asc + 180.0 - screen_angle_deg - rot) as f32).rem_euclid(360.0);
+                // Buscar cusp más cercano (con wraparound).
+                for layer in &render.layers {
+                    if matches!(layer.kind, LayerKind::Houses) {
+                        if let Geometry::Ring { cusps_deg } = &layer.geometry {
+                            for (i, c) in cusps_deg.iter().enumerate() {
+                                let mut diff = (lon - c).abs();
+                                if diff > 180.0 {
+                                    diff = 360.0 - diff;
+                                }
+                                if diff < 2.5 {
+                                    // Mouse cerca de ESTE cusp.
+                                    let (gx, gy) = polar_to_screen(
+                                        *c,
+                                        asc,
+                                        rot,
+                                        (radii.houses_inner + radii.houses_outer) / 2.0,
+                                    );
+                                    best = Some((
+                                        diff,
+                                        HoverInfo::HouseCusp {
+                                            house_number: (i as u8) + 1,
+                                            deg: *c,
+                                            local_x: cx_px + gx - ox,
+                                            local_y: cy_px + gy - oy,
+                                        },
+                                    ));
+                                    break;
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
         let new_hover = best.map(|(_, h)| h);
         let changed = match (&self.state.hover, &new_hover) {
-            (Some(a), Some(b)) => a.symbol != b.symbol || a.module_id != b.module_id,
+            (Some(a), Some(b)) => a.key() != b.key(),
             (None, None) => false,
             _ => true,
         };
@@ -718,33 +800,67 @@ fn render_wheel(
         }
     }
 
-    // Tooltip absoluto sobre el cuerpo hovered. Aparece arriba-derecha
-    // del planeta, con offset pequeño para no taparlo. Texto:
-    // "<unicode> <signo grado>° · Casa N · módulo · retrógrado?"
+    // Tooltip absoluto sobre el elemento hovered (cuerpo o cusp).
     if let Some(hov) = hover {
-        let sign_idx = ((hov.deg / 30.0).floor() as usize) % 12;
-        let sign_name = SIGN_NAMES_ES[sign_idx];
-        let deg_in_sign = hov.deg - (sign_idx as f32) * 30.0;
-        let mut text = format!(
-            "{} {} · {:.1}°",
-            planet_unicode(&hov.symbol),
-            sign_name,
-            deg_in_sign,
-        );
-        if let Some(h) = hov.house {
-            text.push_str(&format!(" · Casa {}", h));
-        }
-        if hov.retrograde {
-            text.push_str(" · ℞");
-        }
-        if let Some(m) = &hov.dignity_marker {
-            text.push_str(&format!(" · {}", m));
-        }
-        if hov.module_id != "natal" {
-            text.push_str(&format!(" · {}", hov.module_id));
-        }
-        let tip_x = (hov.local_x + 14.0).min(WHEEL_SIZE - 220.0).max(8.0);
-        let tip_y = (hov.local_y - 28.0).max(8.0);
+        let text = match hov {
+            HoverInfo::Body {
+                module_id,
+                symbol,
+                deg,
+                house,
+                retrograde,
+                dignity_marker,
+                annotation,
+                ..
+            } => {
+                let sign_idx = ((deg / 30.0).floor() as usize) % 12;
+                let sign_name = SIGN_NAMES_ES[sign_idx];
+                let deg_in_sign = deg - (sign_idx as f32) * 30.0;
+                let display_symbol = if module_id == "midpoints" {
+                    // El symbol del midpoint es "a/b" — para el header
+                    // del tooltip usamos los unicodes individuales.
+                    if let Some((a, b)) = symbol.split_once('/') {
+                        format!("{}/{}", planet_unicode(a), planet_unicode(b))
+                    } else {
+                        symbol.clone()
+                    }
+                } else {
+                    planet_unicode(symbol).to_string()
+                };
+                let mut t = format!("{} {} · {:.1}°", display_symbol, sign_name, deg_in_sign);
+                if let Some(h) = house {
+                    t.push_str(&format!(" · Casa {}", h));
+                }
+                if *retrograde {
+                    t.push_str(" · ℞");
+                }
+                if let Some(m) = dignity_marker {
+                    t.push_str(&format!(" · {}", m));
+                }
+                if module_id == "midpoints" {
+                    if let Some(a) = annotation {
+                        t.push_str(&format!(" · {}", a));
+                    }
+                } else if module_id != "natal" {
+                    t.push_str(&format!(" · {}", module_id));
+                }
+                t
+            }
+            HoverInfo::HouseCusp {
+                house_number, deg, ..
+            } => {
+                let sign_idx = ((deg / 30.0).floor() as usize) % 12;
+                let sign_name = SIGN_NAMES_ES[sign_idx];
+                let deg_in_sign = deg - (sign_idx as f32) * 30.0;
+                format!(
+                    "Cusp Casa {} · {} {:.1}°",
+                    house_number, sign_name, deg_in_sign
+                )
+            }
+        };
+        let (lx, ly) = hov.local();
+        let tip_x = (lx + 14.0).min(WHEEL_SIZE - 220.0).max(8.0);
+        let tip_y = (ly - 28.0).max(8.0);
         wheel = wheel.child(
             div()
                 .absolute()
@@ -998,13 +1114,12 @@ struct Radii {
     transits: f32,
     houses_outer: f32,
     houses_inner: f32,
+    /// Anillo de midpoints — entre bodies natales y houses_inner.
+    midpoints: f32,
     bodies: f32,
     /// Anillo interno con cuerpos progresados (overlay opcional).
     progression: f32,
-    /// Anillo más interno con cuerpos dirigidos por Solar Arc (overlay
-    /// opcional). Si tanto progression como solar_arc están activos,
-    /// progression va afuera (más cerca de bodies natales) y solar_arc
-    /// adentro (entre progression y aspects).
+    /// Anillo más interno con cuerpos dirigidos por Solar Arc.
     solar_arc: f32,
     aspects: f32,
 }
@@ -1017,6 +1132,7 @@ impl Radii {
             transits: r * 0.82,
             houses_outer: r * 0.78,
             houses_inner: r * 0.66,
+            midpoints: r * 0.62,
             bodies: r * 0.58,
             progression: r * 0.48,
             solar_arc: r * 0.40,
@@ -1029,6 +1145,7 @@ impl Radii {
         match module_id {
             "progression" => self.progression,
             "solar_arc" => self.solar_arc,
+            "midpoints" => self.midpoints,
             _ => self.bodies,
         }
     }
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
index 9e5a5a9..17657fe 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
@@ -299,6 +299,10 @@ pub fn compose(
                     format!("Sinastría · {}", partner_label),
                 );
             }
+            crate::PipelineRequest::Midpoints => {
+                build_midpoints_overlay(&natal, &mut render);
+                push_overlay_meta(&mut render, "midpoints", "Midpoints ☉/☽".into());
+            }
             crate::PipelineRequest::PlanetaryReturn {
                 body,
                 target_age_years,
@@ -467,6 +471,57 @@ fn build_progression_overlay(
     Ok(())
 }
 
+/// Helper: agrega al `RenderModel` los midpoints entre pares de
+/// cuerpos natales. Filtra para mostrar solo los que involucran al
+/// Sol o a la Luna (~10 puntos) — son los más significativos
+/// astrológicamente y mantiene la rueda legible.
+///
+/// El midpoint de dos longitudes es la menor distancia angular entre
+/// ellas. Si `|a - b| > 180`, hay que sumar 180 al promedio para
+/// obtener el midpoint "corto".
+fn build_midpoints_overlay(natal: &NatalChart, render: &mut RenderModel) {
+    let mut glyphs: Vec<Glyph> = Vec::new();
+    let placements = &natal.placements;
+
+    for i in 0..placements.len() {
+        for j in (i + 1)..placements.len() {
+            let pa = &placements[i];
+            let pb = &placements[j];
+            // Solo midpoints que involucren Sol o Luna.
+            let involves_luminary = matches!(pa.body, Body::Sun | Body::Moon)
+                || matches!(pb.body, Body::Sun | Body::Moon);
+            if !involves_luminary {
+                continue;
+            }
+            let a = pa.longitude.longitude_deg() as f32;
+            let b = pb.longitude.longitude_deg() as f32;
+            let diff = (a - b).abs();
+            let mid = if diff > 180.0 {
+                ((a + b) / 2.0 + 180.0).rem_euclid(360.0)
+            } else {
+                ((a + b) / 2.0).rem_euclid(360.0)
+            };
+            glyphs.push(Glyph {
+                deg: mid,
+                symbol: format!("{}/{}", body_symbol(pa.body), body_symbol(pb.body)),
+                annotation: Some(format!("{}/{}", pa.body.name(), pb.body.name())),
+                retrograde: false,
+                house: None,
+                dignity_marker: None,
+            });
+        }
+    }
+
+    render.layers.push(Layer {
+        module_id: "midpoints".into(),
+        kind: LayerKind::Midpoints,
+        ring: 0.62,
+        z: 14,
+        geometry: Geometry::GlyphsOnly,
+        glyphs,
+    });
+}
+
 /// Helper: agrega al `RenderModel` las capas del overlay de Solar Arc
 /// (método true-progressed-Sun por default). Cada cuerpo natal se
 /// desplaza por el mismo arco — preserva las relaciones angulares y
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
index 823127f..39c75f2 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
@@ -258,6 +258,10 @@ pub enum PipelineRequest {
         body: String,
         target_age_years: f64,
     },
+    /// `module_id = "midpoints"` — anillo de puntos medios entre pares
+    /// de cuerpos natales. Por simplicidad filtramos a los que
+    /// involucran al Sol o a la Luna (~10 puntos).
+    Midpoints,
 }
 
 /// Opciones que afectan la pasada natal (qué aspectos pintar, qué
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
index 04050b3..ff3a811 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
@@ -137,6 +137,7 @@ impl Registry {
         r.register(Box::new(solar_arc::SolarArcModule));
         r.register(Box::new(synastry::SynastryModule));
         r.register(Box::new(planetary_return::PlanetaryReturnModule));
+        r.register(Box::new(midpoints::MidpointsModule));
         r
     }
 
@@ -545,6 +546,49 @@ pub mod solar_arc {
     }
 }
 
+// =====================================================================
+// MidpointsModule — puntos medios entre cuerpos natales (Sol/Luna)
+// =====================================================================
+
+pub mod midpoints {
+    use super::*;
+
+    /// Computa midpoints entre los cuerpos natales (filtrado a los que
+    /// involucran Sol o Luna, ~10 puntos) y los renderea como pequeños
+    /// puntos en un anillo interior. Hovering muestra los dos cuerpos
+    /// que originan el midpoint.
+    pub struct MidpointsModule;
+
+    impl Module for MidpointsModule {
+        fn id(&self) -> &'static str {
+            "midpoints"
+        }
+        fn label(&self) -> &'static str {
+            "Midpoints"
+        }
+        fn description(&self) -> &'static str {
+            "Puntos medios que involucran al Sol o a la Luna."
+        }
+        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,
+            }]
+        }
+        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
+            Vec::new()
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -558,8 +602,9 @@ mod tests {
         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.find("midpoints").is_some());
+        // Natal kind tiene 7 módulos aplicables.
+        assert_eq!(r.for_kind(ChartKind::Natal).len(), 7);
         assert!(r.for_kind(ChartKind::Synastry).is_empty());
     }
 }
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
index d22ccea..2e07a6c 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
@@ -147,8 +147,51 @@ pub struct TahuantinsuyuTree {
     expanded: HashSet<String>,
     menu: Option<MenuState>,
     modal: Option<Modal>,
+    /// `true` cuando el dropdown de "ciudad rápida" en el ChartForm
+    /// está abierto. Vive en el tree (no en ChartForm) porque las
+    /// closures de los click handlers necesitan mutarlo via `cx.listener`.
+    city_picker_open: bool,
 }
 
+/// Preset de ciudad con datos canónicos para autocompletar lat/lon/tz
+/// al elegirlo en el form. TZ es la zona estándar **sin DST** — el
+/// usuario afina si necesita.
+#[derive(Clone, Copy)]
+struct CityPreset {
+    name: &'static str,
+    lat: f64,
+    lon: f64,
+    tz_offset_minutes: i32,
+}
+
+const CITY_PRESETS: &[CityPreset] = &[
+    CityPreset { name: "Buenos Aires, AR", lat: -34.6037, lon: -58.3816, tz_offset_minutes: -180 },
+    CityPreset { name: "Caracas, VE",      lat: 10.4806,  lon: -66.9036, tz_offset_minutes: -240 },
+    CityPreset { name: "Bogotá, CO",       lat: 4.7110,   lon: -74.0721, tz_offset_minutes: -300 },
+    CityPreset { name: "Lima, PE",         lat: -12.0464, lon: -77.0428, tz_offset_minutes: -300 },
+    CityPreset { name: "Santiago, CL",     lat: -33.4489, lon: -70.6693, tz_offset_minutes: -240 },
+    CityPreset { name: "Quito, EC",        lat: -0.1807,  lon: -78.4678, tz_offset_minutes: -300 },
+    CityPreset { name: "Montevideo, UY",   lat: -34.9011, lon: -56.1645, tz_offset_minutes: -180 },
+    CityPreset { name: "Asunción, PY",     lat: -25.2637, lon: -57.5759, tz_offset_minutes: -240 },
+    CityPreset { name: "La Paz, BO",       lat: -16.4897, lon: -68.1193, tz_offset_minutes: -240 },
+    CityPreset { name: "Ciudad de México", lat: 19.4326,  lon: -99.1332, tz_offset_minutes: -360 },
+    CityPreset { name: "Madrid, ES",       lat: 40.4168,  lon: -3.7038,  tz_offset_minutes: 60 },
+    CityPreset { name: "Barcelona, ES",    lat: 41.3851,  lon: 2.1734,   tz_offset_minutes: 60 },
+    CityPreset { name: "London, UK",       lat: 51.5074,  lon: -0.1278,  tz_offset_minutes: 0 },
+    CityPreset { name: "Paris, FR",        lat: 48.8566,  lon: 2.3522,   tz_offset_minutes: 60 },
+    CityPreset { name: "Berlin, DE",       lat: 52.5200,  lon: 13.4050,  tz_offset_minutes: 60 },
+    CityPreset { name: "Roma, IT",         lat: 41.9028,  lon: 12.4964,  tz_offset_minutes: 60 },
+    CityPreset { name: "New York, US",     lat: 40.7128,  lon: -74.0060, tz_offset_minutes: -300 },
+    CityPreset { name: "Los Angeles, US",  lat: 34.0522,  lon: -118.2437, tz_offset_minutes: -480 },
+    CityPreset { name: "Chicago, US",      lat: 41.8781,  lon: -87.6298, tz_offset_minutes: -360 },
+    CityPreset { name: "São Paulo, BR",    lat: -23.5505, lon: -46.6333, tz_offset_minutes: -180 },
+    CityPreset { name: "Rio de Janeiro, BR", lat: -22.9068, lon: -43.1729, tz_offset_minutes: -180 },
+    CityPreset { name: "Tokyo, JP",        lat: 35.6762,  lon: 139.6503, tz_offset_minutes: 540 },
+    CityPreset { name: "Sydney, AU",       lat: -33.8688, lon: 151.2093, tz_offset_minutes: 600 },
+    CityPreset { name: "Mumbai, IN",       lat: 19.0760,  lon: 72.8777,  tz_offset_minutes: 330 },
+    CityPreset { name: "Cairo, EG",        lat: 30.0444,  lon: 31.2357,  tz_offset_minutes: 120 },
+];
+
 impl EventEmitter<TreeEvent> for TahuantinsuyuTree {}
 
 impl TahuantinsuyuTree {
@@ -167,6 +210,7 @@ impl TahuantinsuyuTree {
             expanded: HashSet::new(),
             menu: None,
             modal: None,
+            city_picker_open: false,
         };
         me.refresh(cx);
         me
@@ -292,10 +336,43 @@ impl TahuantinsuyuTree {
 
     fn close_modal(&mut self, cx: &mut Context<Self>) {
         if self.modal.take().is_some() {
+            self.city_picker_open = false;
             cx.notify();
         }
     }
 
+    fn toggle_city_picker(&mut self, cx: &mut Context<Self>) {
+        self.city_picker_open = !self.city_picker_open;
+        cx.notify();
+    }
+
+    /// Aplica un city preset al ChartForm activo (CreateChart o
+    /// EditChart). Setea place, lat, lon, tz_offset_min vía
+    /// `TextInput::set_text` y cierra el picker.
+    fn apply_city_preset(&mut self, preset: CityPreset, cx: &mut Context<Self>) {
+        let form = match self.modal.as_mut() {
+            Some(Modal::CreateChart { form, .. }) => form,
+            Some(Modal::EditChart { form, .. }) => form,
+            _ => {
+                self.city_picker_open = false;
+                cx.notify();
+                return;
+            }
+        };
+        let place = form.place.clone();
+        let lat = form.lat.clone();
+        let lon = form.lon.clone();
+        let tz = form.tz_offset_min.clone();
+        place.update(cx, |i, cx| i.set_text(preset.name.to_string(), cx));
+        lat.update(cx, |i, cx| i.set_text(format!("{}", preset.lat), cx));
+        lon.update(cx, |i, cx| i.set_text(format!("{}", preset.lon), cx));
+        tz.update(cx, |i, cx| {
+            i.set_text(preset.tz_offset_minutes.to_string(), cx)
+        });
+        self.city_picker_open = false;
+        cx.notify();
+    }
+
     fn open_create_group(
         &mut self,
         parent: Option<GroupId>,
@@ -1139,6 +1216,78 @@ fn render_chart_form(
             this.close_modal(cx);
         }));
 
+    // Header del form: title + botón "Ciudad rápida" con dropdown
+    // que autocompleta place/lat/lon/tz al elegir un preset.
+    let picker_open = cx.entity().read(cx).city_picker_open;
+    let city_btn = div()
+        .id("tts-form-city-btn")
+        .px(px(10.0))
+        .py(px(4.0))
+        .rounded(px(4.0))
+        .bg(theme.bg_button())
+        .hover(|s| s.bg(theme.bg_button_hover()))
+        .border_1()
+        .border_color(if picker_open {
+            theme.accent_strong
+        } else {
+            theme.border
+        })
+        .text_size(px(11.0))
+        .text_color(theme.fg_text)
+        .child("📍 Ciudad rápida ▾")
+        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
+            this.toggle_city_picker(cx);
+        }));
+    let title_row = div()
+        .relative()
+        .flex()
+        .flex_row()
+        .items_center()
+        .gap(px(12.0))
+        .child(
+            div()
+                .text_size(px(14.0))
+                .text_color(theme.fg_text)
+                .child(SharedString::from(title.to_string())),
+        )
+        .child(div().flex_grow())
+        .child(city_btn);
+    let title_row = if picker_open {
+        let mut popup = div()
+            .absolute()
+            .top(px(36.0))
+            .right(px(0.0))
+            .min_w(px(220.0))
+            .max_h(px(320.0))
+            .py(px(4.0))
+            .bg(theme.bg_panel_alt.clone())
+            .border_1()
+            .border_color(theme.border_strong)
+            .rounded(px(6.0))
+            .flex()
+            .flex_col();
+        for preset in CITY_PRESETS.iter().copied() {
+            let row_id: SharedString =
+                SharedString::from(format!("tts-city-{}", preset.name));
+            popup = popup.child(
+                div()
+                    .id(gpui::ElementId::from(row_id))
+                    .px(px(10.0))
+                    .py(px(4.0))
+                    .text_size(px(11.0))
+                    .text_color(theme.fg_text)
+                    .hover(|s| s.bg(theme.bg_row_hover))
+                    .child(SharedString::from(preset.name.to_string()))
+                    .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
+                        this.apply_city_preset(preset, cx);
+                    })),
+            );
+        }
+        title_row.child(popup)
+    } else {
+        title_row
+    };
+
     let mut body = div()
         .min_w(px(640.0))
         .p(px(18.0))
@@ -1149,12 +1298,7 @@ fn render_chart_form(
         .border_1()
         .border_color(theme.border_strong)
         .rounded(px(8.0))
-        .child(
-            div()
-                .text_size(px(14.0))
-                .text_color(theme.fg_text)
-                .child(SharedString::from(title.to_string())),
-        )
+        .child(title_row)
         .child(
             div()
                 .flex()