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>
This commit is contained in:
sergio
2026-05-18 18:51:00 +00:00
parent 8592bab19e
commit a0f67fd86f
@@ -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);
// 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);
// 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();
// 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);