feat(tahuantinsuyu): fase 5 — overlay de tránsitos (bi-wheel natal × ahora)

Activá el toggle "Tránsitos (ahora)" en el panel (o hotkey [T] sobre
el wheel): la engine computa una segunda NatalChart al instante
SystemTime::now() con el mismo observer y dibuja un anillo externo de
planet glyphs encima del natal, más las cross-aspects entre ambos
charts (sólo mayores). Las líneas cross van del ring de cuerpos
natales al ring externo de tránsitos, con stroke más fino y opacidad
más baja para no taparle el ojo a las aspectos natal-natal.

- engine/bridge.rs: extraídas build_eternal_inputs y
  compute_natal_chart como helpers reutilizables. Nueva
  compute_with_transits(chart, offset, transit_at) que llama
  find_synastry_aspects entre natal y transit (AspectKind::MAJORS).
  Atajo compute_with_transits_at_now usa ESInstant::now(). Las capas
  extra van con module_id = "transit" y LayerKind::Outer /
  LayerKind::Aspects para que el canvas las distinga.
- engine/lib.rs: re-export de compute_with_transits_at_now con el
  mismo fallback al mock cuando feature `eternal-bridge` está off.
- canvas: nueva Radii::transits = 0.82, layout del wheel re-balanceado
  (houses_outer 0.78, houses_inner 0.66, bodies 0.58, aspects 0.50)
  para hacer lugar al anillo externo sin colisiones. paint_wheel:
  detecta layers de transit por module_id, pinta dots + glifos en el
  anillo nuevo + anillos guía sutiles. paint_cross_aspect_line con
  stroke 0.7 entre los dos radios. Glyph overlay para Outer ring con
  alpha 0.9 y font_size más chico que el natal. Hotkey [T] en
  on_key_down toggle LayerKind::Outer.
- modules: NatalModule.controls() agrega toggle show_transits con
  hotkey [T] (default false — no recomputar transits si nadie pidió).
- shell: nuevo show_transits flag. render_current despacha entre
  compute_at_offset y compute_with_transits_at_now según el flag.
  on_panel_event traduce ControlChanged show_transits a flip + redraw.
  on_canvas_event: el toggle de LayerKind::Outer dispara show_transits
  flip + render (no es un visibility toggle puro).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-17 10:24:36 +00:00
parent 360797132e
commit 4d14a4495f
5 changed files with 320 additions and 39 deletions
@@ -279,6 +279,7 @@ impl AstrologyCanvas {
"h" | "H" => LayerKind::Houses,
"x" | "X" => LayerKind::Aspects,
"p" | "P" => LayerKind::Bodies,
"t" | "T" => LayerKind::Outer,
"r" | "R" => {
self.reset_time_offset(cx);
return;
@@ -534,7 +535,7 @@ fn render_wheel(
}
}
// Planet glyphs.
// Planet glyphs (natal).
if visible.get(&LayerKind::Bodies).copied().unwrap_or(true) {
for layer in &render.layers {
if matches!(layer.kind, LayerKind::Bodies) {
@@ -559,6 +560,31 @@ fn render_wheel(
}
}
// Planet glyphs (transit ring) — solo si la capa Outer está activa.
if visible.get(&LayerKind::Outer).copied().unwrap_or(true) {
for layer in &render.layers {
if matches!(layer.kind, LayerKind::Outer) && layer.module_id == "transit" {
for g in &layer.glyphs {
let (x, y) = polar_to_screen(g.deg, asc, rot_offset, radii.transits);
let color = with_alpha(planet_color(palette, &g.symbol), 0.9);
let glyph_text = if g.retrograde {
format!("{}ᴿ", planet_unicode(&g.symbol))
} else {
planet_unicode(&g.symbol).into()
};
wheel = wheel.child(centered_glyph(
cx_center + x,
cy_center + y,
20.0,
14.0,
glyph_text.into(),
color,
));
}
}
}
}
// --- Header + footer + indicador de tiempo ---
let header = div()
.flex()
@@ -611,7 +637,7 @@ fn render_wheel(
div()
.text_size(px(10.0))
.text_color(theme.fg_disabled)
.child("[D]ial [H]ouses as[X]pects [P]lanets [R]eset"),
.child("[D]ial [H]ouses as[X]pects [P]lanets [T]ransits [R]eset"),
);
div()
@@ -650,6 +676,10 @@ fn format_offset(minutes: i64) -> String {
struct Radii {
sign_outer: f32,
sign_inner: f32,
/// Anillo de glifos de tránsito (cuando el overlay está activo).
/// Vive entre `sign_inner` y `houses_outer`; queda vacío cuando no
/// hay capa Outer.
transits: f32,
houses_outer: f32,
houses_inner: f32,
bodies: f32,
@@ -661,10 +691,11 @@ impl Radii {
Self {
sign_outer: r,
sign_inner: r * 0.88,
houses_outer: r * 0.86,
houses_inner: r * 0.72,
bodies: r * 0.65,
aspects: r * 0.58,
transits: r * 0.82,
houses_outer: r * 0.78,
houses_inner: r * 0.66,
bodies: r * 0.58,
aspects: r * 0.50,
}
}
}
@@ -779,32 +810,49 @@ fn paint_wheel(
);
}
// 3. Aspectos.
// 3. Aspectos. Distinguir natal (line entre dos puntos en r_aspects)
// del transit (line natal → transit, distintos radios).
if show(LayerKind::Aspects) {
for layer in layers {
if matches!(layer.kind, LayerKind::Aspects) {
if let Geometry::Lines(segs) = &layer.geometry {
let is_transit = layer.module_id == "transit";
for seg in segs {
let color = aspect_color(palette, &seg.kind);
let color = with_alpha(color, color.a * seg.opacity);
paint_aspect_line(
window,
cx,
cy,
seg.from_deg,
seg.to_deg,
ascendant_deg,
rot_offset_deg,
radii.aspects,
color,
);
if is_transit {
paint_cross_aspect_line(
window,
cx,
cy,
seg.from_deg,
seg.to_deg,
ascendant_deg,
rot_offset_deg,
radii.bodies,
radii.transits,
color,
);
} else {
paint_aspect_line(
window,
cx,
cy,
seg.from_deg,
seg.to_deg,
ascendant_deg,
rot_offset_deg,
radii.aspects,
color,
);
}
}
}
}
}
}
// 4. Dots de cuerpos.
// 4. Dots de cuerpos (natal).
if show(LayerKind::Bodies) {
let dot_r = (radii.sign_outer * 0.018).max(2.0);
for layer in layers {
@@ -818,6 +866,42 @@ fn paint_wheel(
}
}
}
// 5. Outer ring (transit overlay): anillo guía + dots de transit.
let transit_active = layers
.iter()
.any(|l| matches!(l.kind, LayerKind::Outer) && l.module_id == "transit");
if transit_active && show(LayerKind::Outer) {
// Anillos guía para delimitar el slot.
stroke_circle(
window,
cx,
cy,
radii.transits + radii.sign_outer * 0.035,
0.6,
with_alpha(palette.dial_ring, 0.4),
);
stroke_circle(
window,
cx,
cy,
radii.transits - radii.sign_outer * 0.035,
0.6,
with_alpha(palette.dial_ring, 0.4),
);
let dot_r = (radii.sign_outer * 0.017).max(2.0);
for layer in layers {
if matches!(layer.kind, LayerKind::Outer) && layer.module_id == "transit" {
for g in &layer.glyphs {
let color = with_alpha(planet_color(palette, &g.symbol), 0.85);
let (x, y) =
polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, radii.transits);
fill_circle(window, cx + x, cy + y, dot_r, color);
}
}
}
}
}
fn paint_sign_sectors(
@@ -939,6 +1023,33 @@ fn paint_aspect_line(
}
}
/// Línea de aspecto natal ↔ tránsito: extremos en radios distintos.
/// El `from_deg` cae sobre el ring de cuerpos natales (`r_from`); el
/// `to_deg` sobre el ring de tránsito (`r_to`). Trazo más fino que el
/// natal-natal para no competir visualmente.
#[allow(clippy::too_many_arguments)]
fn paint_cross_aspect_line(
window: &mut Window,
cx: f32,
cy: f32,
natal_deg: f32,
transit_deg: f32,
ascendant_deg: f32,
rot_offset_deg: f32,
r_from: f32,
r_to: f32,
color: Hsla,
) {
let (xa, ya) = polar_to_screen(natal_deg, ascendant_deg, rot_offset_deg, r_from);
let (xb, yb) = polar_to_screen(transit_deg, ascendant_deg, rot_offset_deg, r_to);
let mut builder = PathBuilder::stroke(px(0.7));
builder.move_to(point(px(cx + xa), px(cy + ya)));
builder.line_to(point(px(cx + xb), px(cy + yb)));
if let Ok(path) = builder.build() {
window.paint_path(path, color);
}
}
// =====================================================================
// Helpers
// =====================================================================