tawasuyu

tawasuyu/brahman

Rust 202TOML 61JSON 7HTML 12471 Sutichaykuna

fix(tahuantinsuyu): spread no propaga + label lejos del disco + glyph legible

Tres bugs en la pasada anterior de anti-solapamiento:

1. **Empuje propagado en cadena**: el spread greedy aplicado a
   cada glyph movía planetas lejanos cuando un cluster denso los
   empujaba. Ejemplo reportado: planetas a 9° y 10° (conjunción
   real) terminaban moviéndose hacia el 26° por la propagación
   simétrica del empuje.

   Solución: spread en dos pasos.
   * `find_clusters` con threshold `min(4°, disk_angular*0.5)`
     agrupa solo los que realmente están en conjunción cerrada.
     Dentro del cluster los glyphs SE QUEDAN en sus pos reales —
     dos planetas a 1° se ven a 1° (sus discos se rozan, refleja
     la geometría astrológica).
   * `spread_angles` se aplica SOLO a los **centroides** de los
     clusters, con threshold = ancho angular del disco. El
     empuje queda contenido a la vecindad inmediata; planetas
     lejos del cluster no se mueven.
   * Cada glyph hereda el shift de su cluster (centroide
     displayed − centroide real, wrap a ±180°).

2. **Label pisaba al planeta**: `label_r = ring - disk*0.7`
   dejaba solo ~2 px entre el borde del disco y la pill. Movido
   a `ring - disk*1.3` para individuales y `ring - disk*1.5`
   para clusters compartidos. Gap visual ~12 px.

3. **Símbolo se perdía en clusters densos**: shrink agresivo
   (0.45 sobre residual) achicaba el font por debajo del
   umbral legible del unicode astronómico. Bajado a 0.30, piso
   del shrink subido a 0.60×, y piso absoluto del font a 11 px.

4. Threshold de label compartido bajado a ≥2 miembros (era ≥3).
   En astrología, dos planetas en conjunción ya cuentan como un
   stellium funcional y se beneficiarían del label combinado.

Tests: 10 verdes (5 spread + 5 coord).

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

a0f67fd86f4375a1d4e1825c2c869805175c9a16 · ruwaq sergio · (5 killa ñawpaq) · tayta 8592bab19 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 1

crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs

1 qillqakuna · +121 −57

diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
index cf6b685..e7a4905 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
@@ -1155,33 +1155,79 @@ fn render_wheel(
                     22.0
                 }) * s;
 
-                // 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;
+                // Anti-solapamiento en dos pasos para evitar que el
+                // empuje se propague en cadena (un cluster denso no
+                // debe mover planetas que estaban lejos):
+                //
+                //   1) `find_clusters` agrupa los que están dentro de
+                //      `cluster_thresh` (~4° real); dentro del cluster
+                //      los glyphs se mantienen en sus posiciones REALES
+                //      — los discos pueden tocarse, eso refleja la
+                //      verdadera geometría astrológica.
+                //   2) `spread_angles` se aplica SOLO a los centroides
+                //      de los clusters, con threshold = ancho angular
+                //      del disco. El empuje queda contenido a la
+                //      vecindad inmediata; planetas lejos no se mueven.
+                let raw_degs: Vec<f32> = layer.glyphs.iter().map(|g| g.deg).collect();
+                let disk_angular_deg =
+                    (disk_size_base / (std::f32::consts::TAU * ring)) * 360.0;
+                let cluster_thresh = 4.0_f32.min(disk_angular_deg * 0.5);
+                let clusters = find_clusters(&raw_degs, cluster_thresh);
+
+                // Centroide circular de cada cluster (en grados reales).
+                let cluster_centroids: Vec<f32> = clusters
+                    .iter()
+                    .map(|c| {
+                        let mut sx = 0.0_f32;
+                        let mut sy = 0.0_f32;
+                        for &idx in c {
+                            let a = raw_degs[idx].to_radians();
+                            sx += a.cos();
+                            sy += a.sin();
+                        }
+                        sy.atan2(sx).to_degrees().rem_euclid(360.0)
+                    })
+                    .collect();
+
+                // Spread entre clusters: separación mínima ≈ ancho
+                // angular del disco (sin holgura, los discos se rozan).
+                let between_min_sep = disk_angular_deg;
+                let (display_centroids, residual) =
+                    spread_angles(&cluster_centroids, between_min_sep);
+
+                // Shift por cluster = displayed - original (wrap a ±180°).
+                let cluster_shifts: Vec<f32> = clusters
+                    .iter()
+                    .enumerate()
+                    .map(|(ci, _)| {
+                        let mut d = display_centroids[ci] - cluster_centroids[ci];
+                        if d > 180.0 {
+                            d -= 360.0;
+                        }
+                        if d < -180.0 {
+                            d += 360.0;
+                        }
+                        d
+                    })
+                    .collect();
 
-                // 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);
+                // Asignar el shift a cada glyph según su cluster.
                 let mut cluster_of = vec![0usize; layer.glyphs.len()];
+                let mut display_degs = vec![0.0_f32; layer.glyphs.len()];
                 for (ci, c) in clusters.iter().enumerate() {
-                    for &gi in c {
-                        cluster_of[gi] = ci;
+                    for &idx in c {
+                        cluster_of[idx] = ci;
+                        display_degs[idx] =
+                            (raw_degs[idx] + cluster_shifts[ci]).rem_euclid(360.0);
                     }
                 }
 
+                // Shrink suave + piso de font para mantener legibilidad
+                // del unicode dentro del disco.
+                let shrink = (1.0 - residual * 0.30).clamp(0.60, 1.0);
+                let disk_size = disk_size_base * shrink;
+                let font_size_eff = (font_size * shrink).max(11.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, ring);
@@ -1204,14 +1250,12 @@ fn render_wheel(
                         with_alpha(color, 0.85),
                     ));
 
-                    // 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.
+                    // Coord label individual: solo cuando el glyph
+                    // está SOLO en su cluster (≥2 ⇒ label compartido).
                     let cluster_size = clusters[cluster_of[i]].len();
-                    if show_coords && (is_natal || is_topo) && cluster_size < 3 {
+                    if show_coords && (is_natal || is_topo) && cluster_size == 1 {
                         let coord = format_coord_compact(g.deg);
-                        let label_r = ring - disk_size * 0.7;
+                        let label_r = ring - disk_size * 1.3;
                         let (lx, ly) =
                             polar_to_screen(display_deg, asc, rot_offset, label_r);
                         wheel = wheel.child(coord_label(
@@ -1220,38 +1264,22 @@ fn render_wheel(
                             coord.into(),
                             theme.fg_muted,
                             halo_bg,
-                            9.5 * s * shrink,
+                            9.5 * s,
                         ));
                     }
                 }
 
-                // 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.
+                // Label compartido para CADA cluster con ≥2 miembros.
+                // Texto = símbolos concatenados + coord del centroide
+                // real. Posicionado sobre el centroide DISPLAY (donde
+                // se ven los discos tras el shift).
                 if show_coords && (is_natal || is_topo) {
-                    for c in &clusters {
-                        if c.len() < 3 {
+                    for (ci, c) in clusters.iter().enumerate() {
+                        if c.len() < 2 {
                             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 center_display_deg = display_centroids[ci];
+                        let center_real_deg = cluster_centroids[ci];
                         let symbols: String = c
                             .iter()
                             .map(|&idx| planet_unicode(&layer.glyphs[idx].symbol))
@@ -1259,7 +1287,7 @@ fn render_wheel(
                             .join(" ");
                         let coord = format_coord_compact(center_real_deg);
                         let text = format!("{}  {}", symbols, coord);
-                        let label_r = ring - disk_size * 1.3;
+                        let label_r = ring - disk_size * 1.5;
                         let (lx, ly) = polar_to_screen(
                             center_display_deg,
                             asc,
@@ -1289,11 +1317,47 @@ fn render_wheel(
                 && (OUTER_RING_MODULES.contains(&layer.module_id.as_str()))
             {
                 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);
+                let disk_angular =
+                    (disk_base / (std::f32::consts::TAU * radii.transits)) * 360.0;
+                let cluster_thresh = 4.0_f32.min(disk_angular * 0.5);
+                let clusters = find_clusters(&raw_degs, cluster_thresh);
+                let centroids: Vec<f32> = clusters
+                    .iter()
+                    .map(|c| {
+                        let mut sx = 0.0_f32;
+                        let mut sy = 0.0_f32;
+                        for &idx in c {
+                            let a = raw_degs[idx].to_radians();
+                            sx += a.cos();
+                            sy += a.sin();
+                        }
+                        sy.atan2(sx).to_degrees().rem_euclid(360.0)
+                    })
+                    .collect();
+                let (disp_centroids, residual) = spread_angles(&centroids, disk_angular);
+                let shifts: Vec<f32> = clusters
+                    .iter()
+                    .enumerate()
+                    .map(|(ci, _)| {
+                        let mut d = disp_centroids[ci] - centroids[ci];
+                        if d > 180.0 {
+                            d -= 360.0;
+                        }
+                        if d < -180.0 {
+                            d += 360.0;
+                        }
+                        d
+                    })
+                    .collect();
+                let mut display_degs = vec![0.0_f32; layer.glyphs.len()];
+                for (ci, c) in clusters.iter().enumerate() {
+                    for &idx in c {
+                        display_degs[idx] =
+                            (raw_degs[idx] + shifts[ci]).rem_euclid(360.0);
+                    }
+                }
+                let shrink = (1.0 - residual * 0.30).clamp(0.60, 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);