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>
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(¢roids, 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);