feat(cosmobiologia): dial uraniano de 90° — proyección geométrica

El módulo Uranian sólo listaba las fórmulas como texto; ahora también
las muestra geométricamente.

- cosmobiologia-canvas: render_uranian_dial pinta un eje horizontal
  0-90° con cada cuerpo natal proyectado en su longitud mod 90. Ticks
  en las divisiones duras (0/22½/45/67½/90°); los cuerpos que forman
  una fórmula uraniana van resaltados, y los clusters densos se
  escalonan en filas para legibilidad. La sección del footer combina
  el dial geométrico con la lista de pills de fórmulas.
- El dial aparece siempre que el módulo Uranian está activo (antes la
  sección sólo salía si había grupos detectados).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-22 14:03:14 +00:00
parent 823eff0343
commit 11d8bcb4af
2 changed files with 172 additions and 44 deletions
@@ -41,7 +41,7 @@ use gpui::{
}; };
use cosmobiologia_engine::{ use cosmobiologia_engine::{
Geometry, GrTrigger, Layer, LayerKind, OUTER_RING_MODULES, RenderModel, Geometry, GrTrigger, Layer, LayerKind, RenderModel, UranianGroup, OUTER_RING_MODULES,
}; };
use cosmobiologia_model::{ChartId, ContactId, GroupId}; use cosmobiologia_model::{ChartId, ContactId, GroupId};
use cosmobiologia_theme::{AspectKind as TAspectKind, AstroPalette, Element, Planet}; use cosmobiologia_theme::{AspectKind as TAspectKind, AstroPalette, Element, Planet};
@@ -1704,49 +1704,61 @@ fn render_wheel(
footer = footer.child(b); footer = footer.child(b);
} }
// Ejes uranianos detectados (cuerpos en la misma posición mod 90). // Dial uraniano de 90°. Aparece cuando el módulo Uranian está
// Aparece sólo cuando el módulo Uranian está activo y hay // activo: una proyección geométrica de los cuerpos sobre el eje
// grupos. Cada grupo se muestra como pill con los unicode de los // 0-90° + la lista de fórmulas (cuerpos en el mismo grado dial).
// cuerpos + el grado dial-90. if render.overlays.iter().any(|o| o.module_id == "uranian") {
if !render.uranian_groups.is_empty() { let mut section = div()
let mut row = div().flex().flex_row().flex_wrap().gap(px(6.0)); .flex()
for group in &render.uranian_groups { .flex_col()
let bodies_text: String = group .items_center()
.bodies .gap(px(4.0))
.iter() .child(
.map(|b| planet_unicode(b))
.collect::<Vec<_>>()
.join(" ");
row = row.child(
div() div()
.px(px(8.0)) .text_size(px(10.0))
.py(px(2.0)) .text_color(theme.fg_muted)
.rounded(px(10.0)) .child("Dial 90° (uraniano)"),
.bg(theme.bg_panel_alt.clone()) )
.border_1() .child(render_uranian_dial(
.border_color(with_alpha(palette.angle_highlight, 0.6)) theme,
.text_size(px(11.0)) palette,
.text_color(theme.fg_text) &render.layers,
.child(SharedString::from(format!( &render.uranian_groups,
"{} · {:.1}°", ));
bodies_text, group.mod90_deg // Pills de fórmulas, sólo si se detectó algún eje.
))), if !render.uranian_groups.is_empty() {
); let mut row = div()
}
footer = footer.child(
div()
.flex() .flex()
.flex_col() .flex_row()
.items_center() .flex_wrap()
.gap(px(3.0)) .justify_center()
.child( .gap(px(6.0));
for group in &render.uranian_groups {
let bodies_text: String = group
.bodies
.iter()
.map(|b| planet_unicode(b))
.collect::<Vec<_>>()
.join(" ");
row = row.child(
div() div()
.text_size(px(10.0)) .px(px(8.0))
.text_color(theme.fg_muted) .py(px(2.0))
.child("Ejes uranianos (90°)"), .rounded(px(10.0))
) .bg(theme.bg_panel_alt.clone())
.child(row), .border_1()
); .border_color(with_alpha(palette.angle_highlight, 0.6))
.text_size(px(11.0))
.text_color(theme.fg_text)
.child(SharedString::from(format!(
"{} · {:.1}°",
bodies_text, group.mod90_deg
))),
);
}
section = section.child(row);
}
footer = footer.child(section);
} }
// Espectro de fuerza armónica — histograma clicable. Aparece sólo // Espectro de fuerza armónica — histograma clicable. Aparece sólo
@@ -2002,6 +2014,122 @@ fn render_harmonic_spectrum(
.child(bars) .child(bars)
} }
/// Dial uraniano de 90°: proyección geométrica de los cuerpos natales
/// sobre un eje horizontal 0-90° (longitud mod 90). Los cuerpos que
/// forman una fórmula uraniana (mismo grado dial) caen agrupados y se
/// resaltan; clusters densos se escalonan en filas para legibilidad.
fn render_uranian_dial(
theme: &Theme,
palette: &AstroPalette,
layers: &[Layer],
groups: &[UranianGroup],
) -> gpui::Div {
const DIAL_W: f32 = 560.0;
const ROW_H: f32 = 18.0;
const MAX_ROWS: usize = 4;
const AXIS_Y: f32 = ROW_H * MAX_ROWS as f32;
const MIN_GAP: f32 = 17.0;
let Some(layer) = layers
.iter()
.find(|l| l.module_id == "natal" && matches!(l.kind, LayerKind::Bodies))
else {
return div();
};
// `(símbolo, x, agrupado)` ordenados por posición en el dial.
let mut marks: Vec<(String, f32, bool)> = layer
.glyphs
.iter()
.map(|g| {
let x = g.deg.rem_euclid(90.0) / 90.0 * DIAL_W;
let grouped = groups
.iter()
.any(|gr| gr.bodies.iter().any(|b| b == &g.symbol));
(g.symbol.clone(), x, grouped)
})
.collect();
marks.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let mut track = div().relative().w(px(DIAL_W)).h(px(AXIS_Y + 22.0));
// Eje base.
track = track.child(
div()
.absolute()
.left(px(0.0))
.top(px(AXIS_Y))
.w(px(DIAL_W))
.h(px(1.0))
.bg(with_alpha(palette.dial_ring, 0.7)),
);
// Ticks 0 / 22½ / 45 / 67½ / 90 — las divisiones duras del dial.
for (deg, label) in [
(0.0_f32, ""),
(22.5, "22½°"),
(45.0, "45°"),
(67.5, "67½°"),
(90.0, "90°"),
] {
let x = deg / 90.0 * DIAL_W;
track = track
.child(
div()
.absolute()
.left(px(x))
.top(px(AXIS_Y))
.w(px(1.0))
.h(px(6.0))
.bg(with_alpha(palette.dial_ring, 0.85)),
)
.child(
div()
.absolute()
.left(px(x - 14.0))
.top(px(AXIS_Y + 8.0))
.w(px(28.0))
.flex()
.justify_center()
.text_size(px(8.0))
.text_color(theme.fg_disabled)
.child(SharedString::from(label)),
);
}
// Glyphs, con escalonado vertical para los clusters.
let mut last_x = f32::NEG_INFINITY;
let mut row = 0usize;
for (symbol, x, grouped) in &marks {
if x - last_x < MIN_GAP {
row = (row + 1).min(MAX_ROWS - 1);
} else {
row = 0;
}
last_x = *x;
let color = if *grouped {
palette.angle_highlight
} else {
with_alpha(planet_color(palette, symbol), 0.55)
};
track = track.child(
div()
.absolute()
.left(px(x - 8.0))
.top(px(row as f32 * ROW_H))
.w(px(16.0))
.h(px(16.0))
.flex()
.items_center()
.justify_center()
.text_size(px(13.0))
.text_color(color)
.child(SharedString::from(planet_unicode(symbol).to_string())),
);
}
track
}
/// Color de un trigger GR según su orbe: rojo intenso (orbe cerrado, /// Color de un trigger GR según su orbe: rojo intenso (orbe cerrado,
/// contacto fuerte) que se desatura hacia gris al ensancharse. El /// contacto fuerte) que se desatura hacia gris al ensancharse. El
/// orbe de referencia (gris pleno) es el orbe del HUD, 2°. /// orbe de referencia (gris pleno) es el orbe del HUD, 2°.
@@ -141,9 +141,9 @@ pub enum PipelineRequest {
/// `module_id = "uranian"` — calcula los "ejes" del dial uraniano /// `module_id = "uranian"` — calcula los "ejes" del dial uraniano
/// de 90°: agrupa los cuerpos natales cuya longitud módulo 90 cae /// de 90°: agrupa los cuerpos natales cuya longitud módulo 90 cae
/// dentro de una tolerancia (~2°). El resultado se publica en /// dentro de una tolerancia (~2°). El resultado se publica en
/// `RenderModel.uranian_groups` para que la UI lo liste como /// `RenderModel.uranian_groups`; la UI lo pinta como un dial
/// fórmulas analíticas. La visualización geométrica completa del /// geométrico de 90° (proyección sobre el eje 0-90°) más la lista
/// dial de 90° queda pendiente para una fase posterior. /// de fórmulas.
Uranian, Uranian,
/// `module_id = "lots"` — Lots arábigos (helenísticos) calculados /// `module_id = "lots"` — Lots arábigos (helenísticos) calculados
/// via `eternal_astrology::compute_lot`: Fortune, Spirit, Eros, /// via `eternal_astrology::compute_lot`: Fortune, Spirit, Eros,