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>
11d8bcb4aff4064af7fca37c25381197551c5e67 · por sergio · 2026-05-22T14:03:14Z (hace 5 meses) · padre 823eff034 · ver el árbol en este commit
Delta semántico · 7
crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
diff --git a/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
index aa6711a..1aba2c5 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
@@ -41,7 +41,7 @@ use gpui::{
};
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_theme::{AspectKind as TAspectKind, AstroPalette, Element, Planet};
@@ -1704,49 +1704,61 @@ fn render_wheel(
footer = footer.child(b);
}
- // Ejes uranianos detectados (cuerpos en la misma posición mod 90).
- // Aparece sólo cuando el módulo Uranian está activo y hay
- // grupos. Cada grupo se muestra como pill con los unicode de los
- // cuerpos + el grado dial-90.
- if !render.uranian_groups.is_empty() {
- let mut row = div().flex().flex_row().flex_wrap().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(
+ // Dial uraniano de 90°. Aparece cuando el módulo Uranian está
+ // activo: una proyección geométrica de los cuerpos sobre el eje
+ // 0-90° + la lista de fórmulas (cuerpos en el mismo grado dial).
+ if render.overlays.iter().any(|o| o.module_id == "uranian") {
+ let mut section = div()
+ .flex()
+ .flex_col()
+ .items_center()
+ .gap(px(4.0))
+ .child(
div()
- .px(px(8.0))
- .py(px(2.0))
- .rounded(px(10.0))
- .bg(theme.bg_panel_alt.clone())
- .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
- ))),
- );
- }
- footer = footer.child(
- div()
+ .text_size(px(10.0))
+ .text_color(theme.fg_muted)
+ .child("Dial 90° (uraniano)"),
+ )
+ .child(render_uranian_dial(
+ theme,
+ palette,
+ &render.layers,
+ &render.uranian_groups,
+ ));
+ // Pills de fórmulas, sólo si se detectó algún eje.
+ if !render.uranian_groups.is_empty() {
+ let mut row = div()
.flex()
- .flex_col()
- .items_center()
- .gap(px(3.0))
- .child(
+ .flex_row()
+ .flex_wrap()
+ .justify_center()
+ .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()
- .text_size(px(10.0))
- .text_color(theme.fg_muted)
- .child("Ejes uranianos (90°)"),
- )
- .child(row),
- );
+ .px(px(8.0))
+ .py(px(2.0))
+ .rounded(px(10.0))
+ .bg(theme.bg_panel_alt.clone())
+ .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
@@ -2002,6 +2014,122 @@ fn render_harmonic_spectrum(
.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, "0°"),
+ (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,
/// contacto fuerte) que se desatura hacia gris al ensancharse. El
/// orbe de referencia (gris pleno) es el orbe del HUD, 2°.
diff --git a/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs
index d3053c5..0a8fb1e 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs
@@ -141,9 +141,9 @@ pub enum PipelineRequest {
/// `module_id = "uranian"` — calcula los "ejes" del dial uraniano
/// de 90°: agrupa los cuerpos natales cuya longitud módulo 90 cae
/// dentro de una tolerancia (~2°). El resultado se publica en
- /// `RenderModel.uranian_groups` para que la UI lo liste como
- /// fórmulas analíticas. La visualización geométrica completa del
- /// dial de 90° queda pendiente para una fase posterior.
+ /// `RenderModel.uranian_groups`; la UI lo pinta como un dial
+ /// geométrico de 90° (proyección sobre el eje 0-90°) más la lista
+ /// de fórmulas.
Uranian,
/// `module_id = "lots"` — Lots arábigos (helenísticos) calculados
/// via `eternal_astrology::compute_lot`: Fortune, Spirit, Eros,