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>
This commit is contained in:
@@ -552,8 +552,16 @@ impl Shell {
|
|||||||
}
|
}
|
||||||
if module_enabled(&self.module_configs, "primary_directions") {
|
if module_enabled(&self.module_configs, "primary_directions") {
|
||||||
let age = self.module_age_or_current("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 {
|
requests.push(PipelineRequest::PrimaryDirections {
|
||||||
target_age_years: age,
|
target_age_years: age,
|
||||||
|
key,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if module_enabled(&self.module_configs, "composite") {
|
if module_enabled(&self.module_configs, "composite") {
|
||||||
|
|||||||
@@ -1145,7 +1145,7 @@ fn render_wheel(
|
|||||||
} else {
|
} else {
|
||||||
14.0
|
14.0
|
||||||
}) * s;
|
}) * s;
|
||||||
let disk_size = (if is_natal {
|
let disk_size_base = (if is_natal {
|
||||||
26.0
|
26.0
|
||||||
} else if is_topo {
|
} else if is_topo {
|
||||||
22.0
|
22.0
|
||||||
@@ -1154,8 +1154,37 @@ fn render_wheel(
|
|||||||
} else {
|
} else {
|
||||||
22.0
|
22.0
|
||||||
}) * s;
|
}) * 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 color = with_alpha(planet_color(palette, &g.symbol), alpha);
|
||||||
let mut glyph_text = planet_unicode(&g.symbol).to_string();
|
let mut glyph_text = planet_unicode(&g.symbol).to_string();
|
||||||
if g.retrograde {
|
if g.retrograde {
|
||||||
@@ -1168,29 +1197,82 @@ fn render_wheel(
|
|||||||
cx_center + x,
|
cx_center + x,
|
||||||
cy_center + y,
|
cy_center + y,
|
||||||
disk_size,
|
disk_size,
|
||||||
font_size,
|
font_size_eff,
|
||||||
glyph_text.into(),
|
glyph_text.into(),
|
||||||
color,
|
color,
|
||||||
halo_bg,
|
halo_bg,
|
||||||
with_alpha(color, 0.85),
|
with_alpha(color, 0.85),
|
||||||
));
|
));
|
||||||
|
|
||||||
// Coord label en pill — junto al planeta, dentro
|
// Coord label individual: solo cuando el cluster
|
||||||
// de su propia zona radial. Topo en zona BC
|
// del glyph es chico (<3 miembros). Para clusters
|
||||||
// (label hacia C, lado interior); natal en
|
// grandes el label se pinta una sola vez abajo,
|
||||||
// zona cerca de D (label hacia E, lado interior
|
// compartido por todos los miembros.
|
||||||
// del cinturón natal — del lado de los aspectos).
|
let cluster_size = clusters[cluster_of[i]].len();
|
||||||
if show_coords && (is_natal || is_topo) {
|
if show_coords && (is_natal || is_topo) && cluster_size < 3 {
|
||||||
let coord = format_coord_compact(g.deg);
|
let coord = format_coord_compact(g.deg);
|
||||||
let label_r = ring - disk_size * 0.7;
|
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(
|
wheel = wheel.child(coord_label(
|
||||||
cx_center + lx,
|
cx_center + lx,
|
||||||
cy_center + ly,
|
cy_center + ly,
|
||||||
coord.into(),
|
coord.into(),
|
||||||
theme.fg_muted,
|
theme.fg_muted,
|
||||||
halo_bg,
|
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)
|
if matches!(layer.kind, LayerKind::Outer)
|
||||||
&& (OUTER_RING_MODULES.contains(&layer.module_id.as_str()))
|
&& (OUTER_RING_MODULES.contains(&layer.module_id.as_str()))
|
||||||
{
|
{
|
||||||
for g in &layer.glyphs {
|
let disk_base = 20.0 * s;
|
||||||
let (x, y) = polar_to_screen(g.deg, asc, rot_offset, radii.transits);
|
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 color = with_alpha(planet_color(palette, &g.symbol), 0.92);
|
||||||
let glyph_text = if g.retrograde {
|
let glyph_text = if g.retrograde {
|
||||||
format!("{}ᴿ", planet_unicode(&g.symbol))
|
format!("{}ᴿ", planet_unicode(&g.symbol))
|
||||||
@@ -1217,8 +1306,8 @@ fn render_wheel(
|
|||||||
wheel = wheel.child(planet_glyph(
|
wheel = wheel.child(planet_glyph(
|
||||||
cx_center + x,
|
cx_center + x,
|
||||||
cy_center + y,
|
cy_center + y,
|
||||||
20.0 * s,
|
20.0 * s * shrink,
|
||||||
13.0 * s,
|
13.0 * s * shrink,
|
||||||
glyph_text.into(),
|
glyph_text.into(),
|
||||||
color,
|
color,
|
||||||
halo_bg,
|
halo_bg,
|
||||||
@@ -2459,6 +2548,120 @@ fn planet_glyph(
|
|||||||
.child(text)
|
.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
|
/// Formato compacto con precisión de minutos: "DD°MM'{signo}" donde
|
||||||
/// el signo es el glyph zodiacal (♈♉♊…). Ej: 14.93° → "14°56'♈".
|
/// el signo es el glyph zodiacal (♈♉♊…). Ej: 14.93° → "14°56'♈".
|
||||||
/// Los minutos se redondean al entero más cercano; si el redondeo
|
/// 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)
|
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)]
|
#[cfg(test)]
|
||||||
mod coord_tests {
|
mod coord_tests {
|
||||||
use super::format_coord_compact;
|
use super::format_coord_compact;
|
||||||
|
|||||||
@@ -390,12 +390,31 @@ pub fn compose(
|
|||||||
"Topocéntrico (Polich-Page)".into(),
|
"Topocéntrico (Polich-Page)".into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
crate::PipelineRequest::PrimaryDirections { target_age_years } => {
|
crate::PipelineRequest::PrimaryDirections {
|
||||||
build_primary_directions_overlay(&natal, *target_age_years, &mut render);
|
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(
|
push_overlay_meta(
|
||||||
&mut render,
|
&mut render,
|
||||||
"primary_directions",
|
"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(
|
fn build_primary_directions_overlay(
|
||||||
natal: &NatalChart,
|
natal: &NatalChart,
|
||||||
target_age_years: f64,
|
target_age_years: f64,
|
||||||
|
key: EDirectionKey,
|
||||||
render: &mut RenderModel,
|
render: &mut RenderModel,
|
||||||
) {
|
) {
|
||||||
let key = EDirectionKey::Naibod;
|
|
||||||
let eps = natal.obliquity_rad;
|
let eps = natal.obliquity_rad;
|
||||||
|
|
||||||
let project = |dir: PrimaryDirection| -> Vec<Glyph> {
|
let project = |dir: PrimaryDirection| -> Vec<Glyph> {
|
||||||
|
|||||||
@@ -338,8 +338,12 @@ pub enum PipelineRequest {
|
|||||||
/// proyecta dos veces: hacia adelante en el tiempo diurno
|
/// proyecta dos veces: hacia adelante en el tiempo diurno
|
||||||
/// (direct) y hacia atrás (converse). Los dos resultados a la
|
/// (direct) y hacia atrás (converse). Los dos resultados a la
|
||||||
/// edad pedida pintan un dual-ring para rectificación en vivo.
|
/// 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 {
|
PrimaryDirections {
|
||||||
target_age_years: f64,
|
target_age_years: f64,
|
||||||
|
key: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -905,6 +905,21 @@ pub mod primary_directions {
|
|||||||
step: 0.05,
|
step: 0.05,
|
||||||
default: 30.0,
|
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> {
|
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
|
||||||
|
|||||||
Reference in New Issue
Block a user