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

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

## D — Acordeón colapsable en el panel

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

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

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

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

## C — CompositeModule

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

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

## A — 90 ciudades expandidas + dropdown scrollable

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-17 23:24:11 +00:00
parent 32ab22f954
commit d3649bfd1a
7 changed files with 348 additions and 31 deletions
+29
View File
@@ -256,6 +256,13 @@ impl Shell {
if module_enabled(&self.module_configs, "midpoints") { if module_enabled(&self.module_configs, "midpoints") {
requests.push(PipelineRequest::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") { if module_enabled(&self.module_configs, "planetary_return") {
let age = self.module_age_or_current("planetary_return"); let age = self.module_age_or_current("planetary_return");
let body = self let body = self
@@ -265,9 +272,17 @@ impl Shell {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("sun") .unwrap_or("sun")
.to_string(); .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 { requests.push(PipelineRequest::PlanetaryReturn {
body, body,
target_age_years: age, target_age_years: age,
shift_days,
}); });
} }
requests requests
@@ -294,6 +309,20 @@ impl Shell {
siblings.into_iter().find(|c| c.id != current.id) 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"]`. /// Deriva las `NatalOptions` activas a partir del `module_configs["natal"]`.
/// Si la entry no existe, devuelve defaults (majors=true, minors=false, /// Si la entry no existe, devuelve defaults (majors=true, minors=false,
/// multiplier=1.0). /// multiplier=1.0).
@@ -1121,6 +1121,8 @@ struct Radii {
progression: f32, progression: f32,
/// Anillo más interno con cuerpos dirigidos por Solar Arc. /// Anillo más interno con cuerpos dirigidos por Solar Arc.
solar_arc: f32, solar_arc: f32,
/// Anillo de carta compuesta (midpoint Davison) con un partner.
composite: f32,
aspects: f32, aspects: f32,
} }
@@ -1136,7 +1138,8 @@ impl Radii {
bodies: r * 0.58, bodies: r * 0.58,
progression: r * 0.48, progression: r * 0.48,
solar_arc: r * 0.40, 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 { match module_id {
"progression" => self.progression, "progression" => self.progression,
"solar_arc" => self.solar_arc, "solar_arc" => self.solar_arc,
"composite" => self.composite,
"midpoints" => self.midpoints, "midpoints" => self.midpoints,
_ => self.bodies, _ => self.bodies,
} }
@@ -1162,6 +1166,7 @@ impl Radii {
match module_id { match module_id {
"progression" => (self.bodies, self.progression), "progression" => (self.bodies, self.progression),
"solar_arc" => (self.bodies, self.solar_arc), "solar_arc" => (self.bodies, self.solar_arc),
"composite" => (self.bodies, self.composite),
_ => (self.aspects, self.aspects), _ => (self.aspects, self.aspects),
} }
} }
@@ -9,9 +9,9 @@ use std::sync::OnceLock;
use std::time::Instant; use std::time::Instant;
use eternal_astrology::{ use eternal_astrology::{
find_aspects, find_synastry_aspects, next_return, secondary_progression, solar_arc_true, composite, find_aspects, find_synastry_aspects, next_return, secondary_progression,
Aspect, AspectKind as EAspectKind, BirthData, BodySet, ChartConfig, HouseSystem as EHouseSystem, solar_arc_true, Aspect, AspectKind as EAspectKind, BirthData, BodySet, ChartConfig,
NatalChart, OrbTable, Zodiac as EZodiac, HouseSystem as EHouseSystem, NatalChart, OrbTable, Zodiac as EZodiac,
}; };
use eternal_sky::{Ayanamsha, Body, EphemerisSession, Instant as ESInstant, Observer, SessionConfig}; use eternal_sky::{Ayanamsha, Body, EphemerisSession, Instant as ESInstant, Observer, SessionConfig};
@@ -306,6 +306,7 @@ pub fn compose(
crate::PipelineRequest::PlanetaryReturn { crate::PipelineRequest::PlanetaryReturn {
body, body,
target_age_years, target_age_years,
shift_days,
} => { } => {
let body_e = map_body(body).ok_or_else(|| { let body_e = map_body(body).ok_or_else(|| {
EngineError::Eternal(format!( EngineError::Eternal(format!(
@@ -319,12 +320,27 @@ pub fn compose(
observer, observer,
body_e, body_e,
*target_age_years, *target_age_years,
*shift_days,
&mut render, &mut render,
)?; )?;
let shift_label = if *shift_days == 0 {
String::new()
} else {
format!(" {:+}d", shift_days)
};
push_overlay_meta( push_overlay_meta(
&mut render, &mut render,
"planetary_return", "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(()) 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 /// Helper: agrega al `RenderModel` los midpoints entre pares de
/// cuerpos natales. Filtra para mostrar solo los que involucran al /// cuerpos natales. Filtra para mostrar solo los que involucran al
/// Sol o a la Luna (~10 puntos) — son los más significativos /// Sol o a la Luna (~10 puntos) — son los más significativos
@@ -666,6 +719,7 @@ fn build_planetary_return_overlay(
observer: Observer, observer: Observer,
body: Body, body: Body,
target_age_years: f64, target_age_years: f64,
shift_days: i64,
render: &mut RenderModel, render: &mut RenderModel,
) -> Result<(), EngineError> { ) -> Result<(), EngineError> {
let session = session()?; let session = session()?;
@@ -682,7 +736,10 @@ fn build_planetary_return_overlay(
// el return; para Moon, ~15 días. Tomamos un margen amplio que // el return; para Moon, ~15 días. Tomamos un margen amplio que
// sirve para todos. // sirve para todos.
const TROPICAL_YEAR_SECS: f64 = 365.242190 * 86400.0; 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 let after_utc = natal
.birth .birth
.instant .instant
@@ -257,11 +257,23 @@ pub enum PipelineRequest {
/// "jupiter", …). El bridge lo mapea a `eternal_sky::Body`. /// "jupiter", …). El bridge lo mapea a `eternal_sky::Body`.
body: String, body: String,
target_age_years: f64, 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 /// `module_id = "midpoints"` — anillo de puntos medios entre pares
/// de cuerpos natales. Por simplicidad filtramos a los que /// de cuerpos natales. Por simplicidad filtramos a los que
/// involucran al Sol o a la Luna (~10 puntos). /// involucran al Sol o a la Luna (~10 puntos).
Midpoints, 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é /// 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]) 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. /// Stub determinista — útil para tests + para la UI sin eternal.
pub fn compute_mock(chart: &Chart) -> RenderModel { pub fn compute_mock(chart: &Chart) -> RenderModel {
use std::time::Instant; use std::time::Instant;
@@ -138,6 +138,7 @@ impl Registry {
r.register(Box::new(synastry::SynastryModule)); r.register(Box::new(synastry::SynastryModule));
r.register(Box::new(planetary_return::PlanetaryReturnModule)); r.register(Box::new(planetary_return::PlanetaryReturnModule));
r.register(Box::new(midpoints::MidpointsModule)); r.register(Box::new(midpoints::MidpointsModule));
r.register(Box::new(composite::CompositeModule));
r r
} }
@@ -485,6 +486,64 @@ pub mod planetary_return {
step: 1.0, step: 1.0,
default: 30.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> { 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("synastry").is_some());
assert!(r.find("planetary_return").is_some()); assert!(r.find("planetary_return").is_some());
assert!(r.find("midpoints").is_some()); assert!(r.find("midpoints").is_some());
// Natal kind tiene 7 módulos aplicables. assert!(r.find("composite").is_some());
assert_eq!(r.for_kind(ChartKind::Natal).len(), 7); // Natal kind tiene 8 módulos aplicables.
assert_eq!(r.for_kind(ChartKind::Natal).len(), 8);
assert!(r.for_kind(ChartKind::Synastry).is_empty()); assert!(r.for_kind(ChartKind::Synastry).is_empty());
} }
} }
@@ -90,6 +90,11 @@ pub struct ControlPanel {
/// Si hay un dropdown abierto, su (module_id, key). Mutuamente /// Si hay un dropdown abierto, su (module_id, key). Mutuamente
/// excluyente: solo uno abierto a la vez en todo el panel. /// excluyente: solo uno abierto a la vez en todo el panel.
dropdown_open: Option<(String, String)>, 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, registry: Registry,
} }
@@ -106,10 +111,35 @@ impl ControlPanel {
chart_options: Vec::new(), chart_options: Vec::new(),
string_state: HashMap::new(), string_state: HashMap::new(),
dropdown_open: None, dropdown_open: None,
collapse_overrides: HashMap::new(),
registry: Registry::with_builtins(), 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>) { pub fn set_active_kind(&mut self, kind: Option<ChartKind>, cx: &mut Context<Self>) {
if self.active_kind != kind { if self.active_kind != kind {
if let Some(k) = kind { if let Some(k) = kind {
@@ -410,29 +440,51 @@ impl ControlPanel {
controls: &[Control], controls: &[Control],
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> gpui::Div { ) -> 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() let header = div()
.id(gpui::ElementId::from(header_id))
.flex() .flex()
.flex_col() .flex_row()
.gap(px(2.0)) .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( .child(
div() div()
.text_size(px(12.0)) .text_size(px(11.0))
.text_color(theme.fg_text) .text_color(theme.fg_muted)
.child(SharedString::from(label.to_string())), .child(chevron),
) )
.child( .child(
div() div()
.text_size(px(10.0)) .flex()
.text_color(theme.fg_muted) .flex_col()
.child(SharedString::from(description.to_string())), .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);
}));
let mut body = div().flex().flex_col().gap(px(4.0)); let mut card = div()
for c in controls {
body = body.child(self.render_control(theme, module_id, c, cx));
}
div()
.min_w(px(260.0)) .min_w(px(260.0))
.p(px(8.0)) .p(px(8.0))
.rounded(px(6.0)) .rounded(px(6.0))
@@ -442,8 +494,15 @@ impl ControlPanel {
.flex() .flex()
.flex_col() .flex_col()
.gap(px(6.0)) .gap(px(6.0))
.child(header) .child(header);
.child(body) 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( fn render_control(
@@ -165,31 +165,112 @@ struct CityPreset {
} }
const CITY_PRESETS: &[CityPreset] = &[ const CITY_PRESETS: &[CityPreset] = &[
// Latinoamérica
CityPreset { name: "Buenos Aires, AR", lat: -34.6037, lon: -58.3816, tz_offset_minutes: -180 }, 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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "Miami, US", lat: 25.7617, lon: -80.1918, tz_offset_minutes: -300 },
CityPreset { name: "Rio de Janeiro, BR", lat: -22.9068, lon: -43.1729, tz_offset_minutes: -180 }, 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: "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: "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: "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 {} impl EventEmitter<TreeEvent> for TahuantinsuyuTree {}
@@ -1253,19 +1334,22 @@ fn render_chart_form(
.child(div().flex_grow()) .child(div().flex_grow())
.child(city_btn); .child(city_btn);
let title_row = if picker_open { let title_row = if picker_open {
let popup_id: SharedString = SharedString::from("tts-form-city-popup");
let mut popup = div() let mut popup = div()
.id(gpui::ElementId::from(popup_id))
.absolute() .absolute()
.top(px(36.0)) .top(px(36.0))
.right(px(0.0)) .right(px(0.0))
.min_w(px(220.0)) .min_w(px(240.0))
.max_h(px(320.0)) .h(px(360.0))
.py(px(4.0)) .py(px(4.0))
.bg(theme.bg_panel_alt.clone()) .bg(theme.bg_panel_alt.clone())
.border_1() .border_1()
.border_color(theme.border_strong) .border_color(theme.border_strong)
.rounded(px(6.0)) .rounded(px(6.0))
.flex() .flex()
.flex_col(); .flex_col()
.overflow_y_scroll();
for preset in CITY_PRESETS.iter().copied() { for preset in CITY_PRESETS.iter().copied() {
let row_id: SharedString = let row_id: SharedString =
SharedString::from(format!("tts-city-{}", preset.name)); SharedString::from(format!("tts-city-{}", preset.name));