From a92fa1577700a5cb28c6bb96872300ce99a7e5f8 Mon Sep 17 00:00:00 2001 From: sergio Date: Mon, 18 May 2026 18:38:51 +0000 Subject: [PATCH] feat(tahuantinsuyu): anti-solapamiento de glyphs + selector Naibod/Ptolomeo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/apps/tahuantinsuyu/src/shell.rs | 8 + .../tahuantinsuyu-canvas/src/lib.rs | 309 +++++++++++++++++- .../tahuantinsuyu-engine/src/bridge.rs | 27 +- .../tahuantinsuyu-engine/src/lib.rs | 4 + .../tahuantinsuyu-modules/src/lib.rs | 15 + 5 files changed, 343 insertions(+), 20 deletions(-) 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 = + 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::>() + .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 = 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) { + 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 = 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 = (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> { + 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::new(); + let mut cur: Vec = 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 = (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 { 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 {