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>
This commit is contained in:
Generated
+1
@@ -10918,6 +10918,7 @@ dependencies = [
|
|||||||
"tahuantinsuyu-tree",
|
"tahuantinsuyu-tree",
|
||||||
"yahweh-bus",
|
"yahweh-bus",
|
||||||
"yahweh-theme",
|
"yahweh-theme",
|
||||||
|
"yahweh-widget-theme-switcher",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ tahuantinsuyu-tree = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-tree" }
|
|||||||
|
|
||||||
yahweh-bus = { workspace = true }
|
yahweh-bus = { workspace = true }
|
||||||
yahweh-theme = { workspace = true }
|
yahweh-theme = { workspace = true }
|
||||||
|
yahweh-widget-theme-switcher = { path = "../../modules/ui_engine/widgets/theme-switcher" }
|
||||||
gpui = { workspace = true }
|
gpui = { workspace = true }
|
||||||
directories = { workspace = true }
|
directories = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ use tahuantinsuyu_store::Store;
|
|||||||
use tahuantinsuyu_tree::{TahuantinsuyuTree, TreeEvent};
|
use tahuantinsuyu_tree::{TahuantinsuyuTree, TreeEvent};
|
||||||
use yahweh_bus::AppBus;
|
use yahweh_bus::AppBus;
|
||||||
use yahweh_theme::Theme;
|
use yahweh_theme::Theme;
|
||||||
|
use yahweh_widget_theme_switcher::theme_switcher;
|
||||||
|
|
||||||
const TREE_WIDTH: f32 = 280.0;
|
const TREE_WIDTH: f32 = 280.0;
|
||||||
const PANEL_HEIGHT: f32 = 180.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") {
|
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
|
||||||
@@ -705,7 +709,9 @@ impl Render for Shell {
|
|||||||
.text_size(px(10.0))
|
.text_size(px(10.0))
|
||||||
.text_color(theme.fg_muted)
|
.text_color(theme.fg_muted)
|
||||||
.child("estudio de astrología profesional"),
|
.child("estudio de astrología profesional"),
|
||||||
);
|
)
|
||||||
|
.child(div().flex_grow())
|
||||||
|
.child(theme_switcher(cx));
|
||||||
|
|
||||||
let tree_panel = div()
|
let tree_panel = div()
|
||||||
.w(px(TREE_WIDTH))
|
.w(px(TREE_WIDTH))
|
||||||
|
|||||||
@@ -119,20 +119,47 @@ pub struct CanvasState {
|
|||||||
drag_jog: Option<JogDragState>,
|
drag_jog: Option<JogDragState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Info del cuerpo bajo el cursor — usado por el render para mostrar
|
/// Info del elemento bajo el cursor — usado por el render para mostrar
|
||||||
/// un tooltip flotante con detalles.
|
/// 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)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct HoverInfo {
|
pub enum HoverInfo {
|
||||||
pub module_id: String,
|
Body {
|
||||||
pub symbol: String,
|
module_id: String,
|
||||||
pub deg: f32,
|
symbol: String,
|
||||||
pub house: Option<u8>,
|
deg: f32,
|
||||||
pub retrograde: bool,
|
house: Option<u8>,
|
||||||
pub dignity_marker: Option<String>,
|
retrograde: bool,
|
||||||
pub annotation: Option<String>,
|
dignity_marker: Option<String>,
|
||||||
/// Posición relativa al wheel (en píxeles desde su top-left).
|
annotation: Option<String>,
|
||||||
pub local_x: f32,
|
local_x: f32,
|
||||||
pub local_y: 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 {
|
impl Default for CanvasState {
|
||||||
@@ -273,11 +300,10 @@ impl AstrologyCanvas {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hit-test sobre los body glyphs activos. Recibe la posición del
|
/// Hit-test sobre body glyphs + house cusps. Para bodies: distancia
|
||||||
/// mouse en window coords y los bounds del canvas (wheel). Para
|
/// al centro del glyph dentro de threshold. Para cusps: el mouse
|
||||||
/// cada Glyph con LayerKind == Bodies u Outer en el RenderModel
|
/// debe estar cerca del ring de casas Y angularmente cerca del
|
||||||
/// actual, calcula su posición pintada y mide distancia. Si está
|
/// cusp (proximidad a la línea radial).
|
||||||
/// dentro de `threshold_px`, actualiza `state.hover`.
|
|
||||||
fn on_hover_check(
|
fn on_hover_check(
|
||||||
&mut self,
|
&mut self,
|
||||||
position: Point<Pixels>,
|
position: Point<Pixels>,
|
||||||
@@ -293,16 +319,21 @@ impl AstrologyCanvas {
|
|||||||
let (cx_px, cy_px) = bounds_center(bounds);
|
let (cx_px, cy_px) = bounds_center(bounds);
|
||||||
let mx: f32 = position.x.into();
|
let mx: f32 = position.x.into();
|
||||||
let my: f32 = position.y.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 r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
|
||||||
let radii = Radii::from_outer(r_outer);
|
let radii = Radii::from_outer(r_outer);
|
||||||
let asc = render.ascendant_deg;
|
let asc = render.ascendant_deg;
|
||||||
let rot = self.state.view_rotation_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;
|
let mut best: Option<(f32, HoverInfo)> = None;
|
||||||
|
|
||||||
|
// 1) Body glyphs (incluye natal, overlays, midpoints).
|
||||||
for layer in &render.layers {
|
for layer in &render.layers {
|
||||||
let ring = match layer.kind {
|
let ring = match layer.kind {
|
||||||
LayerKind::Bodies => radii.body_ring(&layer.module_id),
|
LayerKind::Bodies => radii.body_ring(&layer.module_id),
|
||||||
|
LayerKind::Midpoints => radii.midpoints,
|
||||||
LayerKind::Outer if OUTER_RING_MODULES.contains(&layer.module_id.as_str()) => {
|
LayerKind::Outer if OUTER_RING_MODULES.contains(&layer.module_id.as_str()) => {
|
||||||
radii.transits
|
radii.transits
|
||||||
}
|
}
|
||||||
@@ -313,15 +344,13 @@ impl AstrologyCanvas {
|
|||||||
let dx = mx - (cx_px + gx);
|
let dx = mx - (cx_px + gx);
|
||||||
let dy = my - (cy_px + gy);
|
let dy = my - (cy_px + gy);
|
||||||
let dist = (dx * dx + dy * dy).sqrt();
|
let dist = (dx * dx + dy * dy).sqrt();
|
||||||
if dist > threshold {
|
if dist > body_threshold {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
|
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((
|
best = Some((
|
||||||
dist,
|
dist,
|
||||||
HoverInfo {
|
HoverInfo::Body {
|
||||||
module_id: layer.module_id.clone(),
|
module_id: layer.module_id.clone(),
|
||||||
symbol: g.symbol.clone(),
|
symbol: g.symbol.clone(),
|
||||||
deg: g.deg,
|
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 new_hover = best.map(|(_, h)| h);
|
||||||
let changed = match (&self.state.hover, &new_hover) {
|
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,
|
(None, None) => false,
|
||||||
_ => true,
|
_ => true,
|
||||||
};
|
};
|
||||||
@@ -718,33 +800,67 @@ fn render_wheel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tooltip absoluto sobre el cuerpo hovered. Aparece arriba-derecha
|
// Tooltip absoluto sobre el elemento hovered (cuerpo o cusp).
|
||||||
// del planeta, con offset pequeño para no taparlo. Texto:
|
|
||||||
// "<unicode> <signo grado>° · Casa N · módulo · retrógrado?"
|
|
||||||
if let Some(hov) = hover {
|
if let Some(hov) = hover {
|
||||||
let sign_idx = ((hov.deg / 30.0).floor() as usize) % 12;
|
let text = match hov {
|
||||||
let sign_name = SIGN_NAMES_ES[sign_idx];
|
HoverInfo::Body {
|
||||||
let deg_in_sign = hov.deg - (sign_idx as f32) * 30.0;
|
module_id,
|
||||||
let mut text = format!(
|
symbol,
|
||||||
"{} {} · {:.1}°",
|
deg,
|
||||||
planet_unicode(&hov.symbol),
|
house,
|
||||||
sign_name,
|
retrograde,
|
||||||
deg_in_sign,
|
dignity_marker,
|
||||||
);
|
annotation,
|
||||||
if let Some(h) = hov.house {
|
..
|
||||||
text.push_str(&format!(" · Casa {}", h));
|
} => {
|
||||||
}
|
let sign_idx = ((deg / 30.0).floor() as usize) % 12;
|
||||||
if hov.retrograde {
|
let sign_name = SIGN_NAMES_ES[sign_idx];
|
||||||
text.push_str(" · ℞");
|
let deg_in_sign = deg - (sign_idx as f32) * 30.0;
|
||||||
}
|
let display_symbol = if module_id == "midpoints" {
|
||||||
if let Some(m) = &hov.dignity_marker {
|
// El symbol del midpoint es "a/b" — para el header
|
||||||
text.push_str(&format!(" · {}", m));
|
// del tooltip usamos los unicodes individuales.
|
||||||
}
|
if let Some((a, b)) = symbol.split_once('/') {
|
||||||
if hov.module_id != "natal" {
|
format!("{}/{}", planet_unicode(a), planet_unicode(b))
|
||||||
text.push_str(&format!(" · {}", hov.module_id));
|
} else {
|
||||||
}
|
symbol.clone()
|
||||||
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);
|
} 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(
|
wheel = wheel.child(
|
||||||
div()
|
div()
|
||||||
.absolute()
|
.absolute()
|
||||||
@@ -998,13 +1114,12 @@ struct Radii {
|
|||||||
transits: f32,
|
transits: f32,
|
||||||
houses_outer: f32,
|
houses_outer: f32,
|
||||||
houses_inner: f32,
|
houses_inner: f32,
|
||||||
|
/// Anillo de midpoints — entre bodies natales y houses_inner.
|
||||||
|
midpoints: f32,
|
||||||
bodies: f32,
|
bodies: f32,
|
||||||
/// Anillo interno con cuerpos progresados (overlay opcional).
|
/// Anillo interno con cuerpos progresados (overlay opcional).
|
||||||
progression: f32,
|
progression: f32,
|
||||||
/// Anillo más interno con cuerpos dirigidos por Solar Arc (overlay
|
/// Anillo más interno con cuerpos dirigidos por Solar Arc.
|
||||||
/// 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).
|
|
||||||
solar_arc: f32,
|
solar_arc: f32,
|
||||||
aspects: f32,
|
aspects: f32,
|
||||||
}
|
}
|
||||||
@@ -1017,6 +1132,7 @@ impl Radii {
|
|||||||
transits: r * 0.82,
|
transits: r * 0.82,
|
||||||
houses_outer: r * 0.78,
|
houses_outer: r * 0.78,
|
||||||
houses_inner: r * 0.66,
|
houses_inner: r * 0.66,
|
||||||
|
midpoints: r * 0.62,
|
||||||
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,
|
||||||
@@ -1029,6 +1145,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,
|
||||||
|
"midpoints" => self.midpoints,
|
||||||
_ => self.bodies,
|
_ => self.bodies,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,6 +299,10 @@ pub fn compose(
|
|||||||
format!("Sinastría · {}", partner_label),
|
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 {
|
crate::PipelineRequest::PlanetaryReturn {
|
||||||
body,
|
body,
|
||||||
target_age_years,
|
target_age_years,
|
||||||
@@ -467,6 +471,57 @@ fn build_progression_overlay(
|
|||||||
Ok(())
|
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
|
/// Helper: agrega al `RenderModel` las capas del overlay de Solar Arc
|
||||||
/// (método true-progressed-Sun por default). Cada cuerpo natal se
|
/// (método true-progressed-Sun por default). Cada cuerpo natal se
|
||||||
/// desplaza por el mismo arco — preserva las relaciones angulares y
|
/// desplaza por el mismo arco — preserva las relaciones angulares y
|
||||||
|
|||||||
@@ -258,6 +258,10 @@ pub enum PipelineRequest {
|
|||||||
body: String,
|
body: String,
|
||||||
target_age_years: f64,
|
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é
|
/// Opciones que afectan la pasada natal (qué aspectos pintar, qué
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ impl Registry {
|
|||||||
r.register(Box::new(solar_arc::SolarArcModule));
|
r.register(Box::new(solar_arc::SolarArcModule));
|
||||||
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
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -558,8 +602,9 @@ mod tests {
|
|||||||
assert!(r.find("solar_arc").is_some());
|
assert!(r.find("solar_arc").is_some());
|
||||||
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());
|
||||||
// Natal kind tiene 6 módulos aplicables.
|
assert!(r.find("midpoints").is_some());
|
||||||
assert_eq!(r.for_kind(ChartKind::Natal).len(), 6);
|
// Natal kind tiene 7 módulos aplicables.
|
||||||
|
assert_eq!(r.for_kind(ChartKind::Natal).len(), 7);
|
||||||
assert!(r.for_kind(ChartKind::Synastry).is_empty());
|
assert!(r.for_kind(ChartKind::Synastry).is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,8 +147,51 @@ pub struct TahuantinsuyuTree {
|
|||||||
expanded: HashSet<String>,
|
expanded: HashSet<String>,
|
||||||
menu: Option<MenuState>,
|
menu: Option<MenuState>,
|
||||||
modal: Option<Modal>,
|
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 EventEmitter<TreeEvent> for TahuantinsuyuTree {}
|
||||||
|
|
||||||
impl TahuantinsuyuTree {
|
impl TahuantinsuyuTree {
|
||||||
@@ -167,6 +210,7 @@ impl TahuantinsuyuTree {
|
|||||||
expanded: HashSet::new(),
|
expanded: HashSet::new(),
|
||||||
menu: None,
|
menu: None,
|
||||||
modal: None,
|
modal: None,
|
||||||
|
city_picker_open: false,
|
||||||
};
|
};
|
||||||
me.refresh(cx);
|
me.refresh(cx);
|
||||||
me
|
me
|
||||||
@@ -292,10 +336,43 @@ impl TahuantinsuyuTree {
|
|||||||
|
|
||||||
fn close_modal(&mut self, cx: &mut Context<Self>) {
|
fn close_modal(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.modal.take().is_some() {
|
if self.modal.take().is_some() {
|
||||||
|
self.city_picker_open = false;
|
||||||
cx.notify();
|
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(
|
fn open_create_group(
|
||||||
&mut self,
|
&mut self,
|
||||||
parent: Option<GroupId>,
|
parent: Option<GroupId>,
|
||||||
@@ -1139,6 +1216,78 @@ fn render_chart_form(
|
|||||||
this.close_modal(cx);
|
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()
|
let mut body = div()
|
||||||
.min_w(px(640.0))
|
.min_w(px(640.0))
|
||||||
.p(px(18.0))
|
.p(px(18.0))
|
||||||
@@ -1149,12 +1298,7 @@ fn render_chart_form(
|
|||||||
.border_1()
|
.border_1()
|
||||||
.border_color(theme.border_strong)
|
.border_color(theme.border_strong)
|
||||||
.rounded(px(8.0))
|
.rounded(px(8.0))
|
||||||
.child(
|
.child(title_row)
|
||||||
div()
|
|
||||||
.text_size(px(14.0))
|
|
||||||
.text_color(theme.fg_text)
|
|
||||||
.child(SharedString::from(title.to_string())),
|
|
||||||
)
|
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
|
|||||||
Reference in New Issue
Block a user