gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Definitions

feat(tahuantinsuyu): anti-solapamiento de glyphs + selector Naibod/Ptolomeo

Tres mejoras de UX para manejar conjunciones (stelium) y dar más
control sobre el sistema GR:

1. `spread_angles(angles, min_sep_deg)`: reposiciona angularmente
   los glyphs adyacentes para que ningún par caiga más cerca que
   el threshold visual (derivado del ancho del label pill al
   radio del ring). Iterativo (≤60 pasos), re-ordena cada
   iteración para preservar el orden circular, devuelve también
   `residual` ∈ [0,1] = fracción de presión no resuelta. Las
   posiciones REALES no se tocan — solo afecta la geometría
   visual del glyph. 5 tests cubren: empty, separados intactos,
   cluster cerrado, orden preservado, cluster infactible.

2. Aplicación al render de Bodies (natal/topo/pd/outer): cada
   layer pasa por spread_angles antes de iterar glyphs. Si
   residual queda alta, los discos y fonts se encogen
   proporcionalmente (0.55..1.0×) y los coord labels se omiten —
   evita pillas montadas sobre el bloque.

3. `find_clusters(angles, threshold_deg)`: detecta grupos
   angularmente cercanos (incluye wrap-around 359°→1°). Glyphs en
   cluster de ≥3 miembros NO llevan coord label individual;
   en su lugar, al final del loop se pinta UN solo label
   compartido con los símbolos concatenados (ej. "☉ ☿ ♀  14°56'♈")
   posicionado en el centroide angular del cluster. El usuario
   sigue viendo cada planeta con su disco, pero no se ahoga en
   pills superpuestas.

4. Selector Naibod/Ptolomeo en PrimaryDirectionsModule via
   `Control::Select`. Default Naibod (0°59'08.33″/año, moderno).
   El shell extrae `module_configs["primary_directions"]["key"]`
   y lo pasa en `PipelineRequest::PrimaryDirections { key }`;
   el bridge mapea string → `DirectionKey` y pasa al cómputo.
   El overlay meta muestra qué clave se usó: "GR Direcciones ·
   30.5a · Naibod".

Tests: 16 verdes (6 shell + 5 spread + 5 coord).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

a92fa1577700a5cb28c6bb96872300ce99a7e5f8 · by sergio · (5 months ago) · parent a7214e049 · browse the tree at this commit

Semantic delta · 15

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

5 files · +343 −20

diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs
index 4f79ab1..5a5e8d3 100644
--- a/crates/apps/tahuantinsuyu/src/shell.rs
+++ b/crates/apps/tahuantinsuyu/src/shell.rs
@@ -552,8 +552,16 @@ impl Shell {
         }
         if module_enabled(&self.module_configs, "primary_directions") {
             let age = self.module_age_or_current("primary_directions");
+            let key = self
+                .module_configs
+                .get("primary_directions")
+                .and_then(|c| c.get("key"))
+                .and_then(|v| v.as_str())
+                .unwrap_or("naibod")
+                .to_string();
             requests.push(PipelineRequest::PrimaryDirections {
                 target_age_years: age,
+                key,
             });
         }
         if module_enabled(&self.module_configs, "composite") {
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
index 5bf314f..cf6b685 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
@@ -1145,7 +1145,7 @@ fn render_wheel(
                 } else {
                     14.0
                 }) * s;
-                let disk_size = (if is_natal {
+                let disk_size_base = (if is_natal {
                     26.0
                 } else if is_topo {
                     22.0
@@ -1154,8 +1154,37 @@ fn render_wheel(
                 } else {
                     22.0
                 }) * s;
-                for g in &layer.glyphs {
-                    let (x, y) = polar_to_screen(g.deg, asc, rot_offset, ring);
+
+                // Anti-solapamiento: aplicamos `spread_angles` con el
+                // threshold derivado del ancho del LABEL (pill más
+                // grande que el disco). Si el spread no puede
+                // converger, `residual` se acerca a 1 y achicamos
+                // los discos. Los planetas siguen en su grado real
+                // — solo el display angular se ajusta.
+                let label_w_est = 38.0 * s;
+                let min_sep_deg = (label_w_est / (std::f32::consts::TAU * ring)) * 360.0;
+                let raw_degs: Vec<f32> =
+                    layer.glyphs.iter().map(|g| g.deg).collect();
+                let (display_degs, residual) = spread_angles(&raw_degs, min_sep_deg);
+                let shrink = (1.0 - residual * 0.45).clamp(0.55, 1.0);
+                let disk_size = disk_size_base * shrink;
+                let font_size_eff = font_size * shrink;
+
+                // Cluster detection sobre grados REALES (no display).
+                // Clusters de ≥3 planetas en menos de 2.5° comparten
+                // un solo label en su centroide; los individuales y
+                // los duos llevan su label propio.
+                let clusters = find_clusters(&raw_degs, 2.5);
+                let mut cluster_of = vec![0usize; layer.glyphs.len()];
+                for (ci, c) in clusters.iter().enumerate() {
+                    for &gi in c {
+                        cluster_of[gi] = ci;
+                    }
+                }
+
+                for (i, g) in layer.glyphs.iter().enumerate() {
+                    let display_deg = display_degs[i];
+                    let (x, y) = polar_to_screen(display_deg, asc, rot_offset, ring);
                     let color = with_alpha(planet_color(palette, &g.symbol), alpha);
                     let mut glyph_text = planet_unicode(&g.symbol).to_string();
                     if g.retrograde {
@@ -1168,29 +1197,82 @@ fn render_wheel(
                         cx_center + x,
                         cy_center + y,
                         disk_size,
-                        font_size,
+                        font_size_eff,
                         glyph_text.into(),
                         color,
                         halo_bg,
                         with_alpha(color, 0.85),
                     ));
 
-                    // Coord label en pill — junto al planeta, dentro
-                    // de su propia zona radial. Topo en zona BC
-                    // (label hacia C, lado interior); natal en
-                    // zona cerca de D (label hacia E, lado interior
-                    // del cinturón natal — del lado de los aspectos).
-                    if show_coords && (is_natal || is_topo) {
+                    // Coord label individual: solo cuando el cluster
+                    // del glyph es chico (<3 miembros). Para clusters
+                    // grandes el label se pinta una sola vez abajo,
+                    // compartido por todos los miembros.
+                    let cluster_size = clusters[cluster_of[i]].len();
+                    if show_coords && (is_natal || is_topo) && cluster_size < 3 {
                         let coord = format_coord_compact(g.deg);
                         let label_r = ring - disk_size * 0.7;
-                        let (lx, ly) = polar_to_screen(g.deg, asc, rot_offset, label_r);
+                        let (lx, ly) =
+                            polar_to_screen(display_deg, asc, rot_offset, label_r);
                         wheel = wheel.child(coord_label(
                             cx_center + lx,
                             cy_center + ly,
                             coord.into(),
                             theme.fg_muted,
                             halo_bg,
-                            9.5 * s,
+                            9.5 * s * shrink,
+                        ));
+                    }
+                }
+
+                // Label compartido para clusters densos (≥3 glyphs en
+                // <2.5° reales). Texto = symbols concatenados +
+                // coord del centroide. Posicionado sobre el centroide
+                // de los display_degs.
+                if show_coords && (is_natal || is_topo) {
+                    for c in &clusters {
+                        if c.len() < 3 {
+                            continue;
+                        }
+                        // Centroide del display (promedio circular).
+                        let mut sx = 0.0_f32;
+                        let mut sy = 0.0_f32;
+                        for &idx in c {
+                            let a = display_degs[idx].to_radians();
+                            sx += a.cos();
+                            sy += a.sin();
+                        }
+                        let center_display_deg = sy.atan2(sx).to_degrees().rem_euclid(360.0);
+                        // Texto: ☉ ☿ ♀ + coord del centroide real
+                        let mut sx_r = 0.0_f32;
+                        let mut sy_r = 0.0_f32;
+                        for &idx in c {
+                            let a = layer.glyphs[idx].deg.to_radians();
+                            sx_r += a.cos();
+                            sy_r += a.sin();
+                        }
+                        let center_real_deg = sy_r.atan2(sx_r).to_degrees().rem_euclid(360.0);
+                        let symbols: String = c
+                            .iter()
+                            .map(|&idx| planet_unicode(&layer.glyphs[idx].symbol))
+                            .collect::<Vec<_>>()
+                            .join(" ");
+                        let coord = format_coord_compact(center_real_deg);
+                        let text = format!("{}  {}", symbols, coord);
+                        let label_r = ring - disk_size * 1.3;
+                        let (lx, ly) = polar_to_screen(
+                            center_display_deg,
+                            asc,
+                            rot_offset,
+                            label_r,
+                        );
+                        wheel = wheel.child(coord_label(
+                            cx_center + lx,
+                            cy_center + ly,
+                            text.into(),
+                            theme.fg_text,
+                            halo_bg,
+                            10.0 * s,
                         ));
                     }
                 }
@@ -1206,8 +1288,15 @@ fn render_wheel(
             if matches!(layer.kind, LayerKind::Outer)
                 && (OUTER_RING_MODULES.contains(&layer.module_id.as_str()))
             {
-                for g in &layer.glyphs {
-                    let (x, y) = polar_to_screen(g.deg, asc, rot_offset, radii.transits);
+                let disk_base = 20.0 * s;
+                let min_sep_deg =
+                    (disk_base / (std::f32::consts::TAU * radii.transits)) * 360.0;
+                let raw_degs: Vec<f32> = layer.glyphs.iter().map(|g| g.deg).collect();
+                let (display_degs, residual) = spread_angles(&raw_degs, min_sep_deg);
+                let shrink = (1.0 - residual * 0.45).clamp(0.55, 1.0);
+                for (i, g) in layer.glyphs.iter().enumerate() {
+                    let display_deg = display_degs[i];
+                    let (x, y) = polar_to_screen(display_deg, asc, rot_offset, radii.transits);
                     let color = with_alpha(planet_color(palette, &g.symbol), 0.92);
                     let glyph_text = if g.retrograde {
                         format!("{}ᴿ", planet_unicode(&g.symbol))
@@ -1217,8 +1306,8 @@ fn render_wheel(
                     wheel = wheel.child(planet_glyph(
                         cx_center + x,
                         cy_center + y,
-                        20.0 * s,
-                        13.0 * s,
+                        20.0 * s * shrink,
+                        13.0 * s * shrink,
                         glyph_text.into(),
                         color,
                         halo_bg,
@@ -2459,6 +2548,120 @@ fn planet_glyph(
         .child(text)
 }
 
+/// Reposiciona angularmente un conjunto de longitudes para que pares
+/// adyacentes mantengan al menos `min_sep_deg` de separación. Las
+/// posiciones reales NO se tocan — esto solo afecta la geometría
+/// visual del glyph, no su significado astrológico. Devuelve el
+/// vector de display angles en el mismo orden de entrada.
+///
+/// Algoritmo: ordenamos por longitud, iteramos N veces empujando
+/// pares adyacentes en sentidos opuestos cuando están muy cerca. El
+/// centro angular del cluster se preserva (los empujes son
+/// simétricos y suman cero). Convergencia típica: 5-15 iteraciones
+/// para clusters realistas; ponemos cap en 40.
+///
+/// Si la suma de separaciones mínimas excede 360° (cluster
+/// infactible), el spreading deja el último delta sin convergir —
+/// el caller debe pasarse a modo "compacto" (disco más chico) en
+/// ese caso. Devolvemos también la fracción de presión residual:
+/// `(displays, residual)` donde `residual ∈ [0, 1]`. 0 = todo
+/// converge, 1 = no se pudo abrir nada.
+fn spread_angles(angles_deg: &[f32], min_sep_deg: f32) -> (Vec<f32>, f32) {
+    let n = angles_deg.len();
+    if n <= 1 {
+        return (angles_deg.to_vec(), 0.0);
+    }
+    // Si el cluster total no cabe en 360°, no hay forma. Reportamos
+    // presión máxima y devolvemos las posiciones originales.
+    if (n as f32) * min_sep_deg >= 360.0 {
+        return (angles_deg.to_vec(), 1.0);
+    }
+    // `displays` mantiene la posición ajustada de cada glyph (indexado
+    // como el input). En cada iteración re-derivamos el orden circular
+    // — los empujes desordenan, así que sin re-sort el "vecino" se
+    // pierde tras la primera pasada y el cluster no converge.
+    let mut displays: Vec<f32> = angles_deg.iter().map(|a| a.rem_euclid(360.0)).collect();
+    let mut last_residual = 0.0_f32;
+
+    for _ in 0..60 {
+        let mut order: Vec<usize> = (0..n).collect();
+        order.sort_by(|&a, &b| {
+            displays[a]
+                .partial_cmp(&displays[b])
+                .unwrap_or(std::cmp::Ordering::Equal)
+        });
+        let mut max_residual: f32 = 0.0;
+        for k in 0..n {
+            let i = order[k];
+            let j = order[(k + 1) % n];
+            let cur = displays[i];
+            let nx = displays[j];
+            let diff = (nx - cur).rem_euclid(360.0);
+            if diff < min_sep_deg {
+                let push = (min_sep_deg - diff) / 2.0;
+                displays[i] = (cur - push).rem_euclid(360.0);
+                displays[j] = (nx + push).rem_euclid(360.0);
+                let r = (min_sep_deg - diff) / min_sep_deg;
+                if r > max_residual {
+                    max_residual = r;
+                }
+            }
+        }
+        last_residual = max_residual;
+        if max_residual < 0.001 {
+            break;
+        }
+    }
+
+    (displays, last_residual)
+}
+
+/// Detecta clusters de longitudes angularmente cercanas. Dos
+/// elementos están en el mismo cluster si su separación circular es
+/// menor a `threshold_deg`. Devuelve los índices originales
+/// agrupados; cada Vec interno representa un cluster (incluso si
+/// es de tamaño 1). Cluster con wrap-around (último→primero) se
+/// fusionan correctamente.
+fn find_clusters(angles_deg: &[f32], threshold_deg: f32) -> Vec<Vec<usize>> {
+    let n = angles_deg.len();
+    if n == 0 {
+        return Vec::new();
+    }
+    let mut idxed: Vec<(usize, f32)> = angles_deg
+        .iter()
+        .copied()
+        .map(|a| a.rem_euclid(360.0))
+        .enumerate()
+        .collect();
+    idxed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
+    let mut clusters: Vec<Vec<usize>> = Vec::new();
+    let mut cur: Vec<usize> = vec![idxed[0].0];
+    let mut last = idxed[0].1;
+    for (idx, a) in idxed.iter().skip(1).copied() {
+        if (a - last) < threshold_deg {
+            cur.push(idx);
+        } else {
+            clusters.push(std::mem::take(&mut cur));
+            cur.push(idx);
+        }
+        last = a;
+    }
+    clusters.push(cur);
+    // Wrap-around: si el último cluster y el primero "tocan" a través
+    // de 0° (la diferencia circular < threshold), mergearlos.
+    if clusters.len() >= 2 {
+        let first_a = angles_deg[clusters[0][0]].rem_euclid(360.0);
+        let last_a = angles_deg[*clusters.last().unwrap().last().unwrap()].rem_euclid(360.0);
+        let wrap_diff = 360.0 - last_a + first_a;
+        if wrap_diff < threshold_deg {
+            let mut tail = clusters.pop().unwrap();
+            tail.extend(clusters[0].iter().copied());
+            clusters[0] = tail;
+        }
+    }
+    clusters
+}
+
 /// Formato compacto con precisión de minutos: "DD°MM'{signo}" donde
 /// el signo es el glyph zodiacal (♈♉♊…). Ej: 14.93° → "14°56'♈".
 /// Los minutos se redondean al entero más cercano; si el redondeo
@@ -2493,6 +2696,80 @@ fn format_coord_compact(deg: f32) -> String {
     format!("{}°{:02}'{}", deg_int, minutes, sign_glyph)
 }
 
+#[cfg(test)]
+mod spread_tests {
+    use super::spread_angles;
+
+    fn assert_min_sep(displays: &[f32], min_sep: f32) {
+        let n = displays.len();
+        let mut sorted = displays.to_vec();
+        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
+        // Tolerancia: el algoritmo converge dentro del 1% del min_sep.
+        let tol = min_sep * 0.02;
+        for i in 0..n {
+            let nxt = (i + 1) % n;
+            let diff = (sorted[nxt] - sorted[i]).rem_euclid(360.0);
+            assert!(
+                diff + tol >= min_sep,
+                "vecinos {} y {} a {}° (mínimo {})",
+                sorted[i],
+                sorted[nxt],
+                diff,
+                min_sep
+            );
+        }
+    }
+
+    #[test]
+    fn empty_and_single_unchanged() {
+        let (r, residual) = spread_angles(&[], 10.0);
+        assert!(r.is_empty());
+        assert_eq!(residual, 0.0);
+        let (r, residual) = spread_angles(&[42.0], 10.0);
+        assert_eq!(r, vec![42.0]);
+        assert_eq!(residual, 0.0);
+    }
+
+    #[test]
+    fn spaced_input_left_alone() {
+        // Si todos están a >= min_sep, no se tocan.
+        let input = vec![0.0, 30.0, 90.0, 200.0];
+        let (out, residual) = spread_angles(&input, 10.0);
+        assert!(residual < 0.001);
+        for (a, b) in input.iter().zip(out.iter()) {
+            assert!((a - b).abs() < 1e-3, "{} vs {}", a, b);
+        }
+    }
+
+    #[test]
+    fn tight_cluster_gets_spread() {
+        // 3 planetas a 1° de distancia con min_sep=10 — deben separarse.
+        let input = vec![100.0, 101.0, 102.0];
+        let (out, residual) = spread_angles(&input, 10.0);
+        assert!(residual < 0.05, "residual {}", residual);
+        assert_min_sep(&out, 10.0);
+    }
+
+    #[test]
+    fn preserves_input_order_in_result() {
+        // El Vec de salida indexea igual que el de entrada.
+        let input = vec![350.0, 5.0, 100.0];
+        let (out, _) = spread_angles(&input, 8.0);
+        assert_eq!(out.len(), 3);
+        // El primer elemento de salida corresponde al primero de entrada
+        // (350.0 / cercano al wrap).
+        assert!(((out[0] - 350.0 + 360.0).rem_euclid(360.0)).abs() < 30.0);
+    }
+
+    #[test]
+    fn unfeasible_cluster_reports_max_residual() {
+        // 40 planetas con min_sep=10 = 400° > 360°: imposible.
+        let input: Vec<f32> = (0..40).map(|_| 0.0).collect();
+        let (_out, residual) = spread_angles(&input, 10.0);
+        assert!((residual - 1.0).abs() < 0.01);
+    }
+}
+
 #[cfg(test)]
 mod coord_tests {
     use super::format_coord_compact;
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
index acde26f..d77acaf 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
@@ -390,12 +390,31 @@ pub fn compose(
                     "Topocéntrico (Polich-Page)".into(),
                 );
             }
-            crate::PipelineRequest::PrimaryDirections { target_age_years } => {
-                build_primary_directions_overlay(&natal, *target_age_years, &mut render);
+            crate::PipelineRequest::PrimaryDirections {
+                target_age_years,
+                key,
+            } => {
+                let dkey = match key.as_str() {
+                    "ptolemy" => EDirectionKey::Ptolemy,
+                    _ => EDirectionKey::Naibod,
+                };
+                build_primary_directions_overlay(
+                    &natal,
+                    *target_age_years,
+                    dkey,
+                    &mut render,
+                );
                 push_overlay_meta(
                     &mut render,
                     "primary_directions",
-                    format!("GR Direcciones · {:.1}a", target_age_years),
+                    format!(
+                        "GR Direcciones · {:.1}a · {}",
+                        target_age_years,
+                        match dkey {
+                            EDirectionKey::Naibod => "Naibod",
+                            EDirectionKey::Ptolemy => "Ptolomeo",
+                        }
+                    ),
                 );
             }
         }
@@ -578,9 +597,9 @@ fn build_topocentric_overlay(
 fn build_primary_directions_overlay(
     natal: &NatalChart,
     target_age_years: f64,
+    key: EDirectionKey,
     render: &mut RenderModel,
 ) {
-    let key = EDirectionKey::Naibod;
     let eps = natal.obliquity_rad;
 
     let project = |dir: PrimaryDirection| -> Vec<Glyph> {
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
index 64f14e5..309a451 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
@@ -338,8 +338,12 @@ pub enum PipelineRequest {
     /// proyecta dos veces: hacia adelante en el tiempo diurno
     /// (direct) y hacia atrás (converse). Los dos resultados a la
     /// edad pedida pintan un dual-ring para rectificación en vivo.
+    ///
+    /// `key` controla la conversión arco↔año: "naibod" (default
+    /// moderno, 0°59'08.33″/año) o "ptolemy" (clásica, 1°/año).
     PrimaryDirections {
         target_age_years: f64,
+        key: String,
     },
 }
 
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
index 228d248..27ce5a6 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
@@ -905,6 +905,21 @@ pub mod primary_directions {
                     step: 0.05,
                     default: 30.0,
                 },
+                Control::Select {
+                    key: "key".into(),
+                    label: "Clave (arco/año)".into(),
+                    default: "naibod".into(),
+                    options: vec![
+                        SelectOption {
+                            value: "naibod".into(),
+                            label: "Naibod (0°59'08\"/año)".into(),
+                        },
+                        SelectOption {
+                            value: "ptolemy".into(),
+                            label: "Ptolomeo (1°/año)".into(),
+                        },
+                    ],
+                },
             ]
         }
         fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {