tawasuyu

tawasuyu/brahman

Rust 201TOML 61JSON 7HTML 12439 Sutichaykuna

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>

d3649bfd1a80153652c2a028315369bb4b184024 · ruwaq sergio · (5 killa ñawpaq) · tayta 32ab22f95 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 30

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

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

7 qillqakuna · +348 −31

diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs
index 020b3ad..4238a46 100644
--- a/crates/apps/tahuantinsuyu/src/shell.rs
+++ b/crates/apps/tahuantinsuyu/src/shell.rs
@@ -256,6 +256,13 @@ impl Shell {
         if module_enabled(&self.module_configs, "midpoints") {
             requests.push(PipelineRequest::Midpoints);
         }
+        if module_enabled(&self.module_configs, "composite") {
+            if let Some(partner) = self.resolve_composite_partner() {
+                requests.push(PipelineRequest::Composite {
+                    partner_chart: Box::new(partner),
+                });
+            }
+        }
         if module_enabled(&self.module_configs, "planetary_return") {
             let age = self.module_age_or_current("planetary_return");
             let body = self
@@ -265,9 +272,17 @@ impl Shell {
                 .and_then(|v| v.as_str())
                 .unwrap_or("sun")
                 .to_string();
+            let shift_days = self
+                .module_configs
+                .get("planetary_return")
+                .and_then(|c| c.get("shift_days"))
+                .and_then(|v| v.as_f64())
+                .map(|v| v as i64)
+                .unwrap_or(0);
             requests.push(PipelineRequest::PlanetaryReturn {
                 body,
                 target_age_years: age,
+                shift_days,
             });
         }
         requests
@@ -294,6 +309,20 @@ impl Shell {
         siblings.into_iter().find(|c| c.id != current.id)
     }
 
+    /// Resuelve el partner para Composite — mismo patrón que Synastry:
+    /// 1) lee module_configs["composite"]["partner_chart_id"] y resuelve
+    /// el chart; 2) fallback al primer hermano del contacto actual.
+    fn resolve_composite_partner(&self) -> Option<Chart> {
+        let manual = self
+            .module_configs
+            .get("composite")
+            .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())
+    }
+
     /// Deriva las `NatalOptions` activas a partir del `module_configs["natal"]`.
     /// Si la entry no existe, devuelve defaults (majors=true, minors=false,
     /// multiplier=1.0).
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
index c2aac9b..7b22247 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
@@ -1121,6 +1121,8 @@ struct Radii {
     progression: f32,
     /// Anillo más interno con cuerpos dirigidos por Solar Arc.
     solar_arc: f32,
+    /// Anillo de carta compuesta (midpoint Davison) con un partner.
+    composite: f32,
     aspects: f32,
 }
 
@@ -1136,7 +1138,8 @@ impl Radii {
             bodies: r * 0.58,
             progression: r * 0.48,
             solar_arc: r * 0.40,
-            aspects: r * 0.32,
+            composite: r * 0.32,
+            aspects: r * 0.24,
         }
     }
 
@@ -1145,6 +1148,7 @@ impl Radii {
         match module_id {
             "progression" => self.progression,
             "solar_arc" => self.solar_arc,
+            "composite" => self.composite,
             "midpoints" => self.midpoints,
             _ => self.bodies,
         }
@@ -1162,6 +1166,7 @@ impl Radii {
         match module_id {
             "progression" => (self.bodies, self.progression),
             "solar_arc" => (self.bodies, self.solar_arc),
+            "composite" => (self.bodies, self.composite),
             _ => (self.aspects, self.aspects),
         }
     }
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
index 17657fe..cab0f07 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
@@ -9,9 +9,9 @@ use std::sync::OnceLock;
 use std::time::Instant;
 
 use eternal_astrology::{
-    find_aspects, find_synastry_aspects, next_return, secondary_progression, solar_arc_true,
-    Aspect, AspectKind as EAspectKind, BirthData, BodySet, ChartConfig, HouseSystem as EHouseSystem,
-    NatalChart, OrbTable, Zodiac as EZodiac,
+    composite, find_aspects, find_synastry_aspects, next_return, secondary_progression,
+    solar_arc_true, Aspect, AspectKind as EAspectKind, BirthData, BodySet, ChartConfig,
+    HouseSystem as EHouseSystem, NatalChart, OrbTable, Zodiac as EZodiac,
 };
 use eternal_sky::{Ayanamsha, Body, EphemerisSession, Instant as ESInstant, Observer, SessionConfig};
 
@@ -306,6 +306,7 @@ pub fn compose(
             crate::PipelineRequest::PlanetaryReturn {
                 body,
                 target_age_years,
+                shift_days,
             } => {
                 let body_e = map_body(body).ok_or_else(|| {
                     EngineError::Eternal(format!(
@@ -319,12 +320,27 @@ pub fn compose(
                     observer,
                     body_e,
                     *target_age_years,
+                    *shift_days,
                     &mut render,
                 )?;
+                let shift_label = if *shift_days == 0 {
+                    String::new()
+                } else {
+                    format!(" {:+}d", shift_days)
+                };
                 push_overlay_meta(
                     &mut render,
                     "planetary_return",
-                    format!("{} return {:.0}a", body_e.name(), target_age_years),
+                    format!("{} return {:.0}a{}", body_e.name(), target_age_years, shift_label),
+                );
+            }
+            crate::PipelineRequest::Composite { partner_chart } => {
+                let partner_label = partner_chart.label.clone();
+                build_composite_overlay(&natal, partner_chart, &mut render)?;
+                push_overlay_meta(
+                    &mut render,
+                    "composite",
+                    format!("Composite · {}", partner_label),
                 );
             }
         }
@@ -471,6 +487,43 @@ fn build_progression_overlay(
     Ok(())
 }
 
+/// Helper: agrega al `RenderModel` la carta compuesta (midpoint
+/// composite, Davison 1958) entre la natal del sujeto y la carta del
+/// partner. Cada planeta compuesto es el angular midpoint entre los
+/// dos correspondientes. Se renderea en `radii.composite` (ring 0.36).
+fn build_composite_overlay(
+    natal: &NatalChart,
+    partner_chart: &Chart,
+    render: &mut RenderModel,
+) -> Result<(), EngineError> {
+    let (partner_natal, _config, _observer) = compute_natal_chart(partner_chart, 0)?;
+    let comp = composite(natal, &partner_natal).map_err(|e| {
+        EngineError::Eternal(format!("composite: {:?}", e))
+    })?;
+
+    let glyphs: Vec<Glyph> = comp
+        .placements
+        .iter()
+        .map(|p| Glyph {
+            deg: p.longitude.longitude_deg() as f32,
+            symbol: body_symbol(p.body).into(),
+            annotation: Some(format!("composite {}", p.body.name())),
+            retrograde: false,
+            house: Some(p.house_number),
+            dignity_marker: None,
+        })
+        .collect();
+    render.layers.push(Layer {
+        module_id: "composite".into(),
+        kind: LayerKind::Bodies,
+        ring: 0.36,
+        z: 15,
+        geometry: Geometry::GlyphsOnly,
+        glyphs,
+    });
+    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
@@ -666,6 +719,7 @@ fn build_planetary_return_overlay(
     observer: Observer,
     body: Body,
     target_age_years: f64,
+    shift_days: i64,
     render: &mut RenderModel,
 ) -> Result<(), EngineError> {
     let session = session()?;
@@ -682,7 +736,10 @@ fn build_planetary_return_overlay(
     // 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;
+    // shift_days permite saltar de un retorno mensual al siguiente
+    // cuando body=Moon, o ajustar finamente el año en Solar return.
+    let after_seconds =
+        (target_age_years * 365.242190 - 30.0 + shift_days as f64) * 86400.0;
     let after_utc = natal
         .birth
         .instant
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
index 39c75f2..fdd5e88 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
@@ -257,11 +257,23 @@ pub enum PipelineRequest {
         /// "jupiter", …). El bridge lo mapea a `eternal_sky::Body`.
         body: String,
         target_age_years: f64,
+        /// Días extra que se suman al anchor de búsqueda (birth +
+        /// age*año). Para Solar return suele ser 0 (el return cae cerca
+        /// del cumpleaños); para Lunar return permite saltar de un
+        /// retorno mensual al siguiente (~28 días por click).
+        shift_days: i64,
     },
     /// `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,
+    /// `module_id = "composite"` — carta compuesta (midpoint composite,
+    /// método Davison) entre dos sujetos. Renderea los planetas
+    /// compuestos en un anillo interno propio (radio 0.36, entre solar
+    /// arc 0.40 y aspects). Útil para análisis de relaciones.
+    Composite {
+        partner_chart: Box<Chart>,
+    },
 }
 
 /// Opciones que afectan la pasada natal (qué aspectos pintar, qué
@@ -339,6 +351,17 @@ pub fn compute_with_transits_at_now(
     compose(chart, offset_minutes, &[PipelineRequest::Transit])
 }
 
+/// Helper retrocompatible: construye un `PlanetaryReturn` con
+/// `shift_days = 0`. Útil para llamadores que no necesitan ajuste
+/// fino (todos los Solar return y muchos casos básicos).
+pub fn planetary_return_request(body: String, target_age_years: f64) -> PipelineRequest {
+    PipelineRequest::PlanetaryReturn {
+        body,
+        target_age_years,
+        shift_days: 0,
+    }
+}
+
 /// Stub determinista — útil para tests + para la UI sin eternal.
 pub fn compute_mock(chart: &Chart) -> RenderModel {
     use std::time::Instant;
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
index ff3a811..c689170 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
@@ -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());
     }
 }
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
index 6a54457..5396da6 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs
@@ -90,6 +90,11 @@ pub struct ControlPanel {
     /// Si hay un dropdown abierto, su (module_id, key). Mutuamente
     /// excluyente: solo uno abierto a la vez en todo el panel.
     dropdown_open: Option<(String, String)>,
+    /// Overrides explícitos del estado expanded/collapsed por módulo.
+    /// La semántica del default (sin override) está en
+    /// [`Self::is_collapsed`]: natal y módulos enabled = expanded;
+    /// el resto collapsed.
+    collapse_overrides: HashMap<String, bool>,
     registry: Registry,
 }
 
@@ -106,10 +111,35 @@ impl ControlPanel {
             chart_options: Vec::new(),
             string_state: HashMap::new(),
             dropdown_open: None,
+            collapse_overrides: HashMap::new(),
             registry: Registry::with_builtins(),
         }
     }
 
+    /// Decide si el card de un módulo debe pintarse collapsed (solo
+    /// header) o expanded (header + controles). La regla: si el usuario
+    /// puso un override explícito lo respetamos; sino, natal va
+    /// expanded siempre y el resto solo si su toggle "enabled" es true.
+    fn is_collapsed(&self, module_id: &str) -> bool {
+        if let Some(v) = self.collapse_overrides.get(module_id) {
+            return *v;
+        }
+        if module_id == "natal" {
+            return false;
+        }
+        !self
+            .toggle_state
+            .get(&(module_id.to_string(), "enabled".to_string()))
+            .copied()
+            .unwrap_or(false)
+    }
+
+    fn toggle_collapsed(&mut self, module_id: String, cx: &mut Context<Self>) {
+        let current = self.is_collapsed(&module_id);
+        self.collapse_overrides.insert(module_id, !current);
+        cx.notify();
+    }
+
     pub fn set_active_kind(&mut self, kind: Option<ChartKind>, cx: &mut Context<Self>) {
         if self.active_kind != kind {
             if let Some(k) = kind {
@@ -410,29 +440,51 @@ impl ControlPanel {
         controls: &[Control],
         cx: &mut Context<Self>,
     ) -> gpui::Div {
+        let collapsed = self.is_collapsed(module_id);
+        let chevron = if collapsed { "▸" } else { "▾" };
+        let header_id: SharedString =
+            SharedString::from(format!("tts-module-header-{}", module_id));
+        let module_id_for_listener = module_id.to_string();
         let header = div()
+            .id(gpui::ElementId::from(header_id))
             .flex()
-            .flex_col()
-            .gap(px(2.0))
+            .flex_row()
+            .items_center()
+            .gap(px(8.0))
+            .hover(|s| s.bg(theme.bg_row_hover))
+            .rounded(px(4.0))
+            .px(px(4.0))
+            .py(px(2.0))
             .child(
                 div()
-                    .text_size(px(12.0))
-                    .text_color(theme.fg_text)
-                    .child(SharedString::from(label.to_string())),
+                    .text_size(px(11.0))
+                    .text_color(theme.fg_muted)
+                    .child(chevron),
             )
             .child(
                 div()
-                    .text_size(px(10.0))
-                    .text_color(theme.fg_muted)
-                    .child(SharedString::from(description.to_string())),
-            );
-
-        let mut body = div().flex().flex_col().gap(px(4.0));
-        for c in controls {
-            body = body.child(self.render_control(theme, module_id, c, cx));
-        }
+                    .flex()
+                    .flex_col()
+                    .flex_grow()
+                    .gap(px(2.0))
+                    .child(
+                        div()
+                            .text_size(px(12.0))
+                            .text_color(theme.fg_text)
+                            .child(SharedString::from(label.to_string())),
+                    )
+                    .child(
+                        div()
+                            .text_size(px(10.0))
+                            .text_color(theme.fg_muted)
+                            .child(SharedString::from(description.to_string())),
+                    ),
+            )
+            .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
+                this.toggle_collapsed(module_id_for_listener.clone(), cx);
+            }));
 
-        div()
+        let mut card = div()
             .min_w(px(260.0))
             .p(px(8.0))
             .rounded(px(6.0))
@@ -442,8 +494,15 @@ impl ControlPanel {
             .flex()
             .flex_col()
             .gap(px(6.0))
-            .child(header)
-            .child(body)
+            .child(header);
+        if !collapsed {
+            let mut body = div().flex().flex_col().gap(px(4.0));
+            for c in controls {
+                body = body.child(self.render_control(theme, module_id, c, cx));
+            }
+            card = card.child(body);
+        }
+        card
     }
 
     fn render_control(
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
index 2e07a6c..c706732 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
@@ -165,31 +165,112 @@ struct CityPreset {
 }
 
 const CITY_PRESETS: &[CityPreset] = &[
+    // Latinoamérica
     CityPreset { name: "Buenos Aires, AR", lat: -34.6037, lon: -58.3816, tz_offset_minutes: -180 },
+    CityPreset { name: "Córdoba, AR",      lat: -31.4201, lon: -64.1888, tz_offset_minutes: -180 },
+    CityPreset { name: "Rosario, AR",      lat: -32.9587, lon: -60.6930, tz_offset_minutes: -180 },
+    CityPreset { name: "Mendoza, AR",      lat: -32.8908, lon: -68.8272, tz_offset_minutes: -180 },
     CityPreset { name: "Caracas, VE",      lat: 10.4806,  lon: -66.9036, tz_offset_minutes: -240 },
+    CityPreset { name: "Maracaibo, VE",    lat: 10.6427,  lon: -71.6125, tz_offset_minutes: -240 },
+    CityPreset { name: "Valencia, VE",     lat: 10.1620,  lon: -68.0078, tz_offset_minutes: -240 },
     CityPreset { name: "Bogotá, CO",       lat: 4.7110,   lon: -74.0721, tz_offset_minutes: -300 },
+    CityPreset { name: "Medellín, CO",     lat: 6.2442,   lon: -75.5812, tz_offset_minutes: -300 },
+    CityPreset { name: "Cali, CO",         lat: 3.4516,   lon: -76.5320, tz_offset_minutes: -300 },
     CityPreset { name: "Lima, PE",         lat: -12.0464, lon: -77.0428, tz_offset_minutes: -300 },
+    CityPreset { name: "Cusco, PE",        lat: -13.5319, lon: -71.9675, tz_offset_minutes: -300 },
     CityPreset { name: "Santiago, CL",     lat: -33.4489, lon: -70.6693, tz_offset_minutes: -240 },
+    CityPreset { name: "Valparaíso, CL",   lat: -33.0472, lon: -71.6127, tz_offset_minutes: -240 },
     CityPreset { name: "Quito, EC",        lat: -0.1807,  lon: -78.4678, tz_offset_minutes: -300 },
+    CityPreset { name: "Guayaquil, EC",    lat: -2.1709,  lon: -79.9224, 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: "Guadalajara, MX",  lat: 20.6597,  lon: -103.3496, tz_offset_minutes: -360 },
+    CityPreset { name: "Monterrey, MX",    lat: 25.6866,  lon: -100.3161, tz_offset_minutes: -360 },
+    CityPreset { name: "Habana, CU",       lat: 23.1136,  lon: -82.3666, tz_offset_minutes: -300 },
+    CityPreset { name: "San Juan, PR",     lat: 18.4655,  lon: -66.1057, tz_offset_minutes: -240 },
+    CityPreset { name: "San José, CR",     lat: 9.9281,   lon: -84.0907, tz_offset_minutes: -360 },
+    CityPreset { name: "Panamá, PA",       lat: 8.9824,   lon: -79.5199, tz_offset_minutes: -300 },
+    CityPreset { name: "San Salvador, SV", lat: 13.6929,  lon: -89.2182, tz_offset_minutes: -360 },
+    CityPreset { name: "Guatemala, GT",    lat: 14.6349,  lon: -90.5069, tz_offset_minutes: -360 },
+    CityPreset { name: "Tegucigalpa, HN",  lat: 14.0723,  lon: -87.1921, tz_offset_minutes: -360 },
+    CityPreset { name: "Managua, NI",      lat: 12.1149,  lon: -86.2362, tz_offset_minutes: -360 },
+    CityPreset { name: "Santo Domingo, DO", lat: 18.4861, lon: -69.9312, tz_offset_minutes: -240 },
+    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: "Brasília, BR",     lat: -15.8267, lon: -47.9218, tz_offset_minutes: -180 },
+    CityPreset { name: "Salvador, BR",     lat: -12.9777, lon: -38.5016, tz_offset_minutes: -180 },
+    // España
     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: "Sevilla, ES",      lat: 37.3891,  lon: -5.9845,  tz_offset_minutes: 60 },
+    CityPreset { name: "Valencia, ES",     lat: 39.4699,  lon: -0.3763,  tz_offset_minutes: 60 },
+    CityPreset { name: "Bilbao, ES",       lat: 43.2630,  lon: -2.9350,  tz_offset_minutes: 60 },
+    // Europa
     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: "München, DE",      lat: 48.1351,  lon: 11.5820,  tz_offset_minutes: 60 },
     CityPreset { name: "Roma, IT",         lat: 41.9028,  lon: 12.4964,  tz_offset_minutes: 60 },
+    CityPreset { name: "Milano, IT",       lat: 45.4642,  lon: 9.1900,   tz_offset_minutes: 60 },
+    CityPreset { name: "Amsterdam, NL",    lat: 52.3676,  lon: 4.9041,   tz_offset_minutes: 60 },
+    CityPreset { name: "Bruxelles, BE",    lat: 50.8503,  lon: 4.3517,   tz_offset_minutes: 60 },
+    CityPreset { name: "Wien, AT",         lat: 48.2082,  lon: 16.3738,  tz_offset_minutes: 60 },
+    CityPreset { name: "Zürich, CH",       lat: 47.3769,  lon: 8.5417,   tz_offset_minutes: 60 },
+    CityPreset { name: "Lisboa, PT",       lat: 38.7223,  lon: -9.1393,  tz_offset_minutes: 0 },
+    CityPreset { name: "Dublin, IE",       lat: 53.3498,  lon: -6.2603,  tz_offset_minutes: 0 },
+    CityPreset { name: "Stockholm, SE",    lat: 59.3293,  lon: 18.0686,  tz_offset_minutes: 60 },
+    CityPreset { name: "Oslo, NO",         lat: 59.9139,  lon: 10.7522,  tz_offset_minutes: 60 },
+    CityPreset { name: "København, DK",    lat: 55.6761,  lon: 12.5683,  tz_offset_minutes: 60 },
+    CityPreset { name: "Helsinki, FI",     lat: 60.1699,  lon: 24.9384,  tz_offset_minutes: 120 },
+    CityPreset { name: "Warszawa, PL",     lat: 52.2297,  lon: 21.0122,  tz_offset_minutes: 60 },
+    CityPreset { name: "Praha, CZ",        lat: 50.0755,  lon: 14.4378,  tz_offset_minutes: 60 },
+    CityPreset { name: "Budapest, HU",     lat: 47.4979,  lon: 19.0402,  tz_offset_minutes: 60 },
+    CityPreset { name: "Athina, GR",       lat: 37.9838,  lon: 23.7275,  tz_offset_minutes: 120 },
+    CityPreset { name: "İstanbul, TR",     lat: 41.0082,  lon: 28.9784,  tz_offset_minutes: 180 },
+    CityPreset { name: "Moskva, RU",       lat: 55.7558,  lon: 37.6173,  tz_offset_minutes: 180 },
+    // USA + Canada
     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: "Miami, US",        lat: 25.7617,  lon: -80.1918, tz_offset_minutes: -300 },
+    CityPreset { name: "Houston, US",      lat: 29.7604,  lon: -95.3698, tz_offset_minutes: -360 },
+    CityPreset { name: "San Francisco, US", lat: 37.7749, lon: -122.4194, tz_offset_minutes: -480 },
+    CityPreset { name: "Seattle, US",      lat: 47.6062,  lon: -122.3321, tz_offset_minutes: -480 },
+    CityPreset { name: "Boston, US",       lat: 42.3601,  lon: -71.0589, tz_offset_minutes: -300 },
+    CityPreset { name: "Washington DC",    lat: 38.9072,  lon: -77.0369, tz_offset_minutes: -300 },
+    CityPreset { name: "Toronto, CA",      lat: 43.6532,  lon: -79.3832, tz_offset_minutes: -300 },
+    CityPreset { name: "Montreal, CA",     lat: 45.5017,  lon: -73.5673, tz_offset_minutes: -300 },
+    CityPreset { name: "Vancouver, CA",    lat: 49.2827,  lon: -123.1207, tz_offset_minutes: -480 },
+    // Asia
     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: "Beijing, CN",      lat: 39.9042,  lon: 116.4074, tz_offset_minutes: 480 },
+    CityPreset { name: "Shanghai, CN",     lat: 31.2304,  lon: 121.4737, tz_offset_minutes: 480 },
+    CityPreset { name: "Hong Kong",        lat: 22.3193,  lon: 114.1694, tz_offset_minutes: 480 },
+    CityPreset { name: "Singapore",        lat: 1.3521,   lon: 103.8198, tz_offset_minutes: 480 },
+    CityPreset { name: "Seoul, KR",        lat: 37.5665,  lon: 126.9780, tz_offset_minutes: 540 },
+    CityPreset { name: "Bangkok, TH",      lat: 13.7563,  lon: 100.5018, tz_offset_minutes: 420 },
+    CityPreset { name: "Jakarta, ID",      lat: -6.2088,  lon: 106.8456, tz_offset_minutes: 420 },
+    CityPreset { name: "Manila, PH",       lat: 14.5995,  lon: 120.9842, tz_offset_minutes: 480 },
     CityPreset { name: "Mumbai, IN",       lat: 19.0760,  lon: 72.8777,  tz_offset_minutes: 330 },
+    CityPreset { name: "Delhi, IN",        lat: 28.7041,  lon: 77.1025,  tz_offset_minutes: 330 },
+    CityPreset { name: "Bangalore, IN",    lat: 12.9716,  lon: 77.5946,  tz_offset_minutes: 330 },
+    CityPreset { name: "Karachi, PK",      lat: 24.8607,  lon: 67.0011,  tz_offset_minutes: 300 },
+    CityPreset { name: "Tehran, IR",       lat: 35.6892,  lon: 51.3890,  tz_offset_minutes: 210 },
+    CityPreset { name: "Dubai, AE",        lat: 25.2048,  lon: 55.2708,  tz_offset_minutes: 240 },
+    CityPreset { name: "Tel Aviv, IL",     lat: 32.0853,  lon: 34.7818,  tz_offset_minutes: 120 },
+    // África
     CityPreset { name: "Cairo, EG",        lat: 30.0444,  lon: 31.2357,  tz_offset_minutes: 120 },
+    CityPreset { name: "Lagos, NG",        lat: 6.5244,   lon: 3.3792,   tz_offset_minutes: 60 },
+    CityPreset { name: "Nairobi, KE",      lat: -1.2921,  lon: 36.8219,  tz_offset_minutes: 180 },
+    CityPreset { name: "Johannesburg, ZA", lat: -26.2041, lon: 28.0473,  tz_offset_minutes: 120 },
+    CityPreset { name: "Cape Town, ZA",    lat: -33.9249, lon: 18.4241,  tz_offset_minutes: 120 },
+    CityPreset { name: "Casablanca, MA",   lat: 33.5731,  lon: -7.5898,  tz_offset_minutes: 60 },
+    // Oceanía
+    CityPreset { name: "Sydney, AU",       lat: -33.8688, lon: 151.2093, tz_offset_minutes: 600 },
+    CityPreset { name: "Melbourne, AU",    lat: -37.8136, lon: 144.9631, tz_offset_minutes: 600 },
+    CityPreset { name: "Auckland, NZ",     lat: -36.8485, lon: 174.7633, tz_offset_minutes: 720 },
 ];
 
 impl EventEmitter<TreeEvent> for TahuantinsuyuTree {}
@@ -1253,19 +1334,22 @@ fn render_chart_form(
         .child(div().flex_grow())
         .child(city_btn);
     let title_row = if picker_open {
+        let popup_id: SharedString = SharedString::from("tts-form-city-popup");
         let mut popup = div()
+            .id(gpui::ElementId::from(popup_id))
             .absolute()
             .top(px(36.0))
             .right(px(0.0))
-            .min_w(px(220.0))
-            .max_h(px(320.0))
+            .min_w(px(240.0))
+            .h(px(360.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();
+            .flex_col()
+            .overflow_y_scroll();
         for preset in CITY_PRESETS.iter().copied() {
             let row_id: SharedString =
                 SharedString::from(format!("tts-city-{}", preset.name));