gioser

tawasuyu/brahman

Rust 483TOML 191Markdown 50JSON 176777 Definiciones

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 · (hace 5 meses) · padre 823eff034 · ver el árbol en este commit

Delta semántico · 7

crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs

2 archivos · +172 −44

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,