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:
sergio
2026-05-17 23:12:17 +00:00
parent 2cd34c82da
commit 32ab22f954
8 changed files with 436 additions and 63 deletions
@@ -119,20 +119,47 @@ pub struct CanvasState {
drag_jog: Option<JogDragState>,
}
/// Info del cuerpo bajo el cursor — usado por el render para mostrar
/// un tooltip flotante con detalles.
/// Info del elemento bajo el cursor — usado por el render para mostrar
/// un tooltip flotante con detalles. Cubre body glyphs (módulo +
/// símbolo + grado + casa + retro + dignidad) y cusps de casa (número
/// de la casa + grado del cusp + signo).
#[derive(Clone, Debug)]
pub struct HoverInfo {
pub module_id: String,
pub symbol: String,
pub deg: f32,
pub house: Option<u8>,
pub retrograde: bool,
pub dignity_marker: Option<String>,
pub annotation: Option<String>,
/// Posición relativa al wheel (en píxeles desde su top-left).
pub local_x: f32,
pub local_y: f32,
pub enum HoverInfo {
Body {
module_id: String,
symbol: String,
deg: f32,
house: Option<u8>,
retrograde: bool,
dignity_marker: Option<String>,
annotation: Option<String>,
local_x: f32,
local_y: f32,
},
HouseCusp {
house_number: u8,
deg: f32,
local_x: f32,
local_y: f32,
},
}
impl HoverInfo {
fn local(&self) -> (f32, f32) {
match self {
HoverInfo::Body { local_x, local_y, .. } => (*local_x, *local_y),
HoverInfo::HouseCusp { local_x, local_y, .. } => (*local_x, *local_y),
}
}
fn key(&self) -> String {
match self {
HoverInfo::Body {
module_id, symbol, ..
} => format!("body:{}:{}", module_id, symbol),
HoverInfo::HouseCusp { house_number, .. } => format!("cusp:{}", house_number),
}
}
}
impl Default for CanvasState {
@@ -273,11 +300,10 @@ impl AstrologyCanvas {
cx.notify();
}
/// Hit-test sobre los body glyphs activos. Recibe la posición del
/// mouse en window coords y los bounds del canvas (wheel). Para
/// cada Glyph con LayerKind == Bodies u Outer en el RenderModel
/// actual, calcula su posición pintada y mide distancia. Si está
/// dentro de `threshold_px`, actualiza `state.hover`.
/// Hit-test sobre body glyphs + house cusps. Para bodies: distancia
/// al centro del glyph dentro de threshold. Para cusps: el mouse
/// debe estar cerca del ring de casas Y angularmente cerca del
/// cusp (proximidad a la línea radial).
fn on_hover_check(
&mut self,
position: Point<Pixels>,
@@ -293,16 +319,21 @@ impl AstrologyCanvas {
let (cx_px, cy_px) = bounds_center(bounds);
let mx: f32 = position.x.into();
let my: f32 = position.y.into();
let ox: f32 = bounds.origin.x.into();
let oy: f32 = bounds.origin.y.into();
let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
let radii = Radii::from_outer(r_outer);
let asc = render.ascendant_deg;
let rot = self.state.view_rotation_deg;
let threshold = 14.0_f32;
let body_threshold = 14.0_f32;
let mut best: Option<(f32, HoverInfo)> = None;
// 1) Body glyphs (incluye natal, overlays, midpoints).
for layer in &render.layers {
let ring = match layer.kind {
LayerKind::Bodies => radii.body_ring(&layer.module_id),
LayerKind::Midpoints => radii.midpoints,
LayerKind::Outer if OUTER_RING_MODULES.contains(&layer.module_id.as_str()) => {
radii.transits
}
@@ -313,15 +344,13 @@ impl AstrologyCanvas {
let dx = mx - (cx_px + gx);
let dy = my - (cy_px + gy);
let dist = (dx * dx + dy * dy).sqrt();
if dist > threshold {
if dist > body_threshold {
continue;
}
if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
let ox: f32 = bounds.origin.x.into();
let oy: f32 = bounds.origin.y.into();
best = Some((
dist,
HoverInfo {
HoverInfo::Body {
module_id: layer.module_id.clone(),
symbol: g.symbol.clone(),
deg: g.deg,
@@ -336,9 +365,62 @@ impl AstrologyCanvas {
}
}
}
// 2) House cusps — solo si el mouse está cerca del anillo de
// casas (radio entre houses_inner y houses_outer + margen) y
// ningún body ganó. Las cusps son líneas radiales — la
// distancia angular al cusp más cercano determina el hit.
if best.is_none() {
let dx = mx - cx_px;
let dy = my - cy_px;
let mouse_r = (dx * dx + dy * dy).sqrt();
let r_in = radii.houses_inner - 6.0;
let r_out = radii.houses_outer + 6.0;
if mouse_r >= r_in && mouse_r <= r_out {
// Calcular la longitud zodiacal que corresponde a este
// ángulo de pantalla (inversa de polar_to_screen).
let screen_angle_deg = dy.atan2(dx).to_degrees(); // (-180, 180]
// polar_to_screen: deg = 180 - (lon - asc + rot)
// → lon = asc + 180 - screen_angle_deg - rot
let lon = ((asc + 180.0 - screen_angle_deg - rot) as f32).rem_euclid(360.0);
// Buscar cusp más cercano (con wraparound).
for layer in &render.layers {
if matches!(layer.kind, LayerKind::Houses) {
if let Geometry::Ring { cusps_deg } = &layer.geometry {
for (i, c) in cusps_deg.iter().enumerate() {
let mut diff = (lon - c).abs();
if diff > 180.0 {
diff = 360.0 - diff;
}
if diff < 2.5 {
// Mouse cerca de ESTE cusp.
let (gx, gy) = polar_to_screen(
*c,
asc,
rot,
(radii.houses_inner + radii.houses_outer) / 2.0,
);
best = Some((
diff,
HoverInfo::HouseCusp {
house_number: (i as u8) + 1,
deg: *c,
local_x: cx_px + gx - ox,
local_y: cy_px + gy - oy,
},
));
break;
}
}
}
}
}
}
}
let new_hover = best.map(|(_, h)| h);
let changed = match (&self.state.hover, &new_hover) {
(Some(a), Some(b)) => a.symbol != b.symbol || a.module_id != b.module_id,
(Some(a), Some(b)) => a.key() != b.key(),
(None, None) => false,
_ => true,
};
@@ -718,33 +800,67 @@ fn render_wheel(
}
}
// Tooltip absoluto sobre el cuerpo hovered. Aparece arriba-derecha
// del planeta, con offset pequeño para no taparlo. Texto:
// "<unicode> <signo grado>° · Casa N · módulo · retrógrado?"
// Tooltip absoluto sobre el elemento hovered (cuerpo o cusp).
if let Some(hov) = hover {
let sign_idx = ((hov.deg / 30.0).floor() as usize) % 12;
let sign_name = SIGN_NAMES_ES[sign_idx];
let deg_in_sign = hov.deg - (sign_idx as f32) * 30.0;
let mut text = format!(
"{} {} · {:.1}°",
planet_unicode(&hov.symbol),
sign_name,
deg_in_sign,
);
if let Some(h) = hov.house {
text.push_str(&format!(" · Casa {}", h));
}
if hov.retrograde {
text.push_str(" · ℞");
}
if let Some(m) = &hov.dignity_marker {
text.push_str(&format!(" · {}", m));
}
if hov.module_id != "natal" {
text.push_str(&format!(" · {}", hov.module_id));
}
let tip_x = (hov.local_x + 14.0).min(WHEEL_SIZE - 220.0).max(8.0);
let tip_y = (hov.local_y - 28.0).max(8.0);
let text = match hov {
HoverInfo::Body {
module_id,
symbol,
deg,
house,
retrograde,
dignity_marker,
annotation,
..
} => {
let sign_idx = ((deg / 30.0).floor() as usize) % 12;
let sign_name = SIGN_NAMES_ES[sign_idx];
let deg_in_sign = deg - (sign_idx as f32) * 30.0;
let display_symbol = if module_id == "midpoints" {
// El symbol del midpoint es "a/b" — para el header
// del tooltip usamos los unicodes individuales.
if let Some((a, b)) = symbol.split_once('/') {
format!("{}/{}", planet_unicode(a), planet_unicode(b))
} else {
symbol.clone()
}
} else {
planet_unicode(symbol).to_string()
};
let mut t = format!("{} {} · {:.1}°", display_symbol, sign_name, deg_in_sign);
if let Some(h) = house {
t.push_str(&format!(" · Casa {}", h));
}
if *retrograde {
t.push_str(" · ℞");
}
if let Some(m) = dignity_marker {
t.push_str(&format!(" · {}", m));
}
if module_id == "midpoints" {
if let Some(a) = annotation {
t.push_str(&format!(" · {}", a));
}
} else if module_id != "natal" {
t.push_str(&format!(" · {}", module_id));
}
t
}
HoverInfo::HouseCusp {
house_number, deg, ..
} => {
let sign_idx = ((deg / 30.0).floor() as usize) % 12;
let sign_name = SIGN_NAMES_ES[sign_idx];
let deg_in_sign = deg - (sign_idx as f32) * 30.0;
format!(
"Cusp Casa {} · {} {:.1}°",
house_number, sign_name, deg_in_sign
)
}
};
let (lx, ly) = hov.local();
let tip_x = (lx + 14.0).min(WHEEL_SIZE - 220.0).max(8.0);
let tip_y = (ly - 28.0).max(8.0);
wheel = wheel.child(
div()
.absolute()
@@ -998,13 +1114,12 @@ struct Radii {
transits: f32,
houses_outer: f32,
houses_inner: f32,
/// Anillo de midpoints — entre bodies natales y houses_inner.
midpoints: f32,
bodies: f32,
/// Anillo interno con cuerpos progresados (overlay opcional).
progression: f32,
/// Anillo más interno con cuerpos dirigidos por Solar Arc (overlay
/// opcional). Si tanto progression como solar_arc están activos,
/// progression va afuera (más cerca de bodies natales) y solar_arc
/// adentro (entre progression y aspects).
/// Anillo más interno con cuerpos dirigidos por Solar Arc.
solar_arc: f32,
aspects: f32,
}
@@ -1017,6 +1132,7 @@ impl Radii {
transits: r * 0.82,
houses_outer: r * 0.78,
houses_inner: r * 0.66,
midpoints: r * 0.62,
bodies: r * 0.58,
progression: r * 0.48,
solar_arc: r * 0.40,
@@ -1029,6 +1145,7 @@ impl Radii {
match module_id {
"progression" => self.progression,
"solar_arc" => self.solar_arc,
"midpoints" => self.midpoints,
_ => self.bodies,
}
}