gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definiciones

feat(cosmobiologia): espectro de fuerza armónica — histograma clicable

Completa la feature de armónicos: además de la carta armónica, ahora
hay un espectro que guía qué armónico mirar.

- cosmobiologia-render: harmonic_spectrum computa la fuerza de cada
  armónica 1-32 (suma de cercanía a conjunción exacta de los pares de
  cuerpos en esa armónica). apply_harmonic lo puebla + expone el
  armónico activo. Campos RenderModel.harmonic / .harmonic_spectrum.
  2 tests nuevos (el pico cae en la armónica resonante).
- cosmobiologia-canvas: render_harmonic_spectrum pinta el histograma
  en el footer; cada barra es clicable y emite HarmonicSelected — un
  clic salta a esa armónica. La barra activa va resaltada.
- shell: select_harmonic fija el slider del módulo natal y recompone.
- modules: el slider de armónico pasa de 1-20 a 1-32 (rango del
  espectro).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

54de7849c5fe644e79d51603b6e03fed1741e59c · por sergio · (hace 5 meses) · padre 968255f4c · ver el árbol en este commit

Delta semántico · 19

crates/apps/cosmobiologia/src/shell.rs

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

crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs

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

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

crates/modules/cosmobiologia/cosmobiologia-render/src/harmonic.rs

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

7 archivos · +208 −2

diff --git a/crates/apps/cosmobiologia/src/shell.rs b/crates/apps/cosmobiologia/src/shell.rs
index ca2ec70..ddb78b1 100644
--- a/crates/apps/cosmobiologia/src/shell.rs
+++ b/crates/apps/cosmobiologia/src/shell.rs
@@ -1176,9 +1176,30 @@ impl Shell {
             CanvasEvent::GrAgeDelta(delta) => {
                 self.scrub_gr_age(*delta, cx);
             }
+            CanvasEvent::HarmonicSelected(n) => {
+                self.select_harmonic(*n, cx);
+            }
         }
     }
 
+    /// Fija el armónico de la carta natal (clic en una barra del
+    /// espectro): escribe `harmonic` en `module_configs["natal"]`,
+    /// sincroniza el slider del panel y recompone.
+    fn select_harmonic(&mut self, n: u32, cx: &mut Context<Self>) {
+        let entry = self
+            .module_configs
+            .entry("natal".into())
+            .or_insert_with(|| serde_json::json!({}));
+        if let serde_json::Value::Object(map) = entry {
+            map.insert("harmonic".into(), serde_json::json!(n));
+        }
+        self.panel.update(cx, |p, cx| {
+            p.set_slider("natal", "harmonic", n as f64, cx)
+        });
+        self.persist_module("natal");
+        self.render_current(cx);
+    }
+
     /// Scrubbing en vivo de la edad GR vía jog-dial. Acumula `delta`
     /// sobre `target_age_years` del módulo `primary_directions`,
     /// clampa a [0,120], sincroniza el slider del panel y recompone.
diff --git a/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
index e9d6f18..aa6711a 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
@@ -71,6 +71,10 @@ pub enum CanvasEvent {
     /// la edad en vez del tiempo. Lleva el delta de edad en años; el
     /// host lo acumula sobre `target_age_years` y recompone en vivo.
     GrAgeDelta(f64),
+    /// El usuario hizo clic en una barra del espectro armónico. Lleva
+    /// el orden de armónica elegido; el host fija el slider `harmonic`
+    /// del módulo natal y recompone.
+    HarmonicSelected(u32),
 }
 
 // =====================================================================
@@ -1745,6 +1749,18 @@ fn render_wheel(
         );
     }
 
+    // Espectro de fuerza armónica — histograma clicable. Aparece sólo
+    // en modo armónico (harmonic > 1) y guía qué armónico mirar.
+    if !render.harmonic_spectrum.is_empty() {
+        footer = footer.child(render_harmonic_spectrum(
+            theme,
+            palette,
+            &render.harmonic_spectrum,
+            render.harmonic,
+            entity.clone(),
+        ));
+    }
+
     // Lista textual de aspectos (top 12 por orb). Compacta, en grid
     // de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos
     // computados.
@@ -1903,6 +1919,89 @@ fn render_gr_hud(theme: &Theme, triggers: &[GrTrigger]) -> gpui::Div {
     col
 }
 
+/// Histograma del espectro de fuerza armónica. Cada barra es clicable:
+/// un clic salta el slider de armónico a esa armónica. La barra de la
+/// armónica activa va resaltada.
+fn render_harmonic_spectrum(
+    theme: &Theme,
+    palette: &AstroPalette,
+    spectrum: &[f32],
+    current: u32,
+    entity: gpui::Entity<AstrologyCanvas>,
+) -> gpui::Div {
+    const BAR_AREA_H: f32 = 46.0;
+    let max = spectrum.iter().copied().fold(0.0_f32, f32::max).max(1e-3);
+
+    let mut bars = div().flex().flex_row().items_end().gap(px(2.0));
+    for (i, &strength) in spectrum.iter().enumerate() {
+        let h = (i as u32) + 1;
+        let norm = (strength / max).clamp(0.0, 1.0);
+        let bar_h = (norm * BAR_AREA_H).max(2.0);
+        let is_current = h == current;
+        let color = if is_current {
+            palette.angle_highlight
+        } else {
+            with_alpha(palette.angle_highlight, 0.28 + norm * 0.45)
+        };
+        // Etiqueta cada 4 armónicas (+ la primera y la activa) para no
+        // saturar la tira.
+        let label = if h == current || h == 1 || h % 4 == 0 {
+            format!("{h}")
+        } else {
+            String::new()
+        };
+        let column = div()
+            .id(SharedString::from(format!("tts-harmonic-bar-{h}")))
+            .flex()
+            .flex_col()
+            .items_center()
+            .gap(px(2.0))
+            .cursor_pointer()
+            .child(
+                div()
+                    .h(px(BAR_AREA_H))
+                    .flex()
+                    .flex_col()
+                    .justify_end()
+                    .child(div().w(px(11.0)).h(px(bar_h)).rounded(px(1.5)).bg(color)),
+            )
+            .child(
+                div()
+                    .text_size(px(7.0))
+                    .text_color(if is_current {
+                        palette.angle_highlight
+                    } else {
+                        theme.fg_disabled
+                    })
+                    .child(SharedString::from(label)),
+            )
+            .on_click({
+                let entity = entity.clone();
+                move |_: &gpui::ClickEvent, _w, cx: &mut gpui::App| {
+                    entity.update(cx, |_this, cx| {
+                        cx.emit(CanvasEvent::HarmonicSelected(h));
+                    });
+                }
+            });
+        bars = bars.child(column);
+    }
+
+    div()
+        .flex()
+        .flex_col()
+        .items_center()
+        .gap(px(3.0))
+        .child(
+            div()
+                .text_size(px(10.0))
+                .text_color(theme.fg_muted)
+                .child(SharedString::from(format!(
+                    "Espectro armónico · H{current} activo · clic para saltar"
+                ))),
+        )
+        .child(bars)
+}
+
 /// 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/bridge.rs b/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs
index 637aae4..7a367ed 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs
@@ -1497,6 +1497,8 @@ fn build_render_model(
         aspect_summary: Vec::new(),
         uranian_groups: Vec::new(),
         gr_triggers: Vec::new(),
+        harmonic: 1,
+        harmonic_spectrum: Vec::new(),
     }
 }
 
diff --git a/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs
index 9e8c14e..d3053c5 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs
@@ -343,6 +343,8 @@ pub fn compute_mock(chart: &Chart) -> RenderModel {
         aspect_summary: Vec::new(),
         uranian_groups: Vec::new(),
         gr_triggers: Vec::new(),
+        harmonic: 1,
+        harmonic_spectrum: Vec::new(),
     }
 }
 
diff --git a/crates/modules/cosmobiologia/cosmobiologia-modules/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-modules/src/lib.rs
index e42f013..5fe6348 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-modules/src/lib.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-modules/src/lib.rs
@@ -270,7 +270,8 @@ pub mod natal {
                     key: "harmonic".into(),
                     label: "Armónico".into(),
                     min: 1.0,
-                    max: 20.0,
+                    // 1-32: el rango del espectro de fuerza armónica.
+                    max: 32.0,
                     step: 1.0,
                     default: 1.0,
                 },
diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/harmonic.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/harmonic.rs
index 119d7a0..f0987b0 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-render/src/harmonic.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/harmonic.rs
@@ -14,6 +14,9 @@
 
 use crate::{AspectSummary, Geometry, LayerKind, LineSeg, RenderModel};
 
+/// Máxima armónica que cubre el espectro de fuerza.
+pub const HARMONIC_SPECTRUM_MAX: u32 = 32;
+
 /// Aspectos que se buscan en la carta armónica: `(id, ángulo, orbe)`.
 /// Conjunción y oposición llevan orbe más amplio, como es convención.
 const HARMONIC_ASPECTS: &[(&str, f32, f32)] = &[
@@ -38,6 +41,14 @@ pub fn apply_harmonic(model: &mut RenderModel, n: u32) {
     }
     let nf = n as f32;
 
+    // 0. Longitudes natales (pre-transformación) para el espectro.
+    let natal_longitudes: Vec<f32> = model
+        .layers
+        .iter()
+        .filter(|l| l.module_id == "natal" && l.kind == LayerKind::Bodies)
+        .flat_map(|l| l.glyphs.iter().map(|g| g.deg))
+        .collect();
+
     // 1. Transformar los cuerpos natales; recolectar `(símbolo, lon)`.
     let mut bodies: Vec<(String, f32)> = Vec::new();
     for layer in &mut model.layers {
@@ -79,10 +90,43 @@ pub fn apply_harmonic(model: &mut RenderModel, n: u32) {
         })
         .collect();
 
-    // 4. Anotar el armónico en el título.
+    // 4. Espectro de fuerza armónica + armónico activo + título.
+    model.harmonic = n;
+    model.harmonic_spectrum = harmonic_spectrum(&natal_longitudes, HARMONIC_SPECTRUM_MAX);
     model.title = format!("{} · H{}", model.title, n);
 }
 
+/// Espectro de fuerza armónica: para cada armónica `1..=max`, cuánto
+/// resuena la carta — la suma de la cercanía a conjunción exacta de
+/// todos los pares de cuerpos en esa armónica. Un pico en H marca que
+/// la carta tiene un patrón fuerte de la N-ésima armónica; es la guía
+/// para elegir qué armónico mirar.
+pub fn harmonic_spectrum(natal_longitudes: &[f32], max: u32) -> Vec<f32> {
+    (1..=max)
+        .map(|h| harmonic_strength(natal_longitudes, h))
+        .collect()
+}
+
+/// Fuerza de una sola armónica: suma sobre pares de cuerpos de
+/// `1 - sep/orb` para los pares que caen a menos de `RESONANCE_ORB`
+/// de la conjunción en esa armónica.
+fn harmonic_strength(longitudes: &[f32], h: u32) -> f32 {
+    const RESONANCE_ORB: f32 = 10.0;
+    let hf = h as f32;
+    let mut score = 0.0;
+    for i in 0..longitudes.len() {
+        for j in (i + 1)..longitudes.len() {
+            let a = (longitudes[i] * hf).rem_euclid(360.0);
+            let b = (longitudes[j] * hf).rem_euclid(360.0);
+            let sep = circular_sep(a, b);
+            if sep < RESONANCE_ORB {
+                score += 1.0 - sep / RESONANCE_ORB;
+            }
+        }
+    }
+    score
+}
+
 /// Separación circular mínima entre dos longitudes (rango `0..=180`).
 fn circular_sep(a: f32, b: f32) -> f32 {
     let d = (a - b).rem_euclid(360.0);
@@ -188,6 +232,8 @@ mod tests {
             aspect_summary: Vec::new(),
             uranian_groups: Vec::new(),
             gr_triggers: Vec::new(),
+            harmonic: 1,
+            harmonic_spectrum: Vec::new(),
         }
     }
 
@@ -256,6 +302,28 @@ mod tests {
         assert_eq!(model.aspect_summary[0].kind, "conjunction");
     }
 
+    #[test]
+    fn spectrum_peaks_at_the_resonant_harmonic() {
+        // 0° y 72° son conjuntos en H5 (72·5 = 360 ≡ 0).
+        let spectrum = harmonic_spectrum(&[0.0, 72.0], HARMONIC_SPECTRUM_MAX);
+        assert_eq!(spectrum.len(), HARMONIC_SPECTRUM_MAX as usize);
+        let h5 = spectrum[4]; // índice 4 = H5
+        assert!(h5 > 0.99, "H5 resuena al máximo: {h5}");
+        let max = spectrum.iter().copied().fold(0.0_f32, f32::max);
+        assert!((h5 - max).abs() < 1e-4, "H5 es el pico del espectro");
+    }
+
+    #[test]
+    fn apply_harmonic_populates_spectrum_and_current_order() {
+        let mut model = natal_model(&[("sun", 0.0), ("venus", 72.0)]);
+        apply_harmonic(&mut model, 5);
+        assert_eq!(model.harmonic, 5);
+        assert_eq!(
+            model.harmonic_spectrum.len(),
+            HARMONIC_SPECTRUM_MAX as usize
+        );
+    }
+
     #[test]
     fn houses_layer_is_preserved() {
         let mut model = natal_model(&[("sun", 10.0)]);
diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs
index ab35fa0..5241581 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs
@@ -92,6 +92,19 @@ pub struct RenderModel {
     /// y resalta los `event = true` (convergencias directo+converso).
     #[serde(default)]
     pub gr_triggers: Vec<GrTrigger>,
+    /// Orden de la carta armónica activa. `1` = carta natal pura.
+    #[serde(default = "default_harmonic")]
+    pub harmonic: u32,
+    /// Espectro de fuerza armónica: índice `i` = fuerza de la armónica
+    /// `i + 1`. Vacío salvo en modo armónico (`harmonic > 1`). La UI
+    /// lo pinta como histograma para guiar qué armónico mirar.
+    #[serde(default)]
+    pub harmonic_spectrum: Vec<f32>,
+}
+
+/// Default serde del campo `harmonic`: 1 (carta natal sin transformar).
+fn default_harmonic() -> u32 {
+    1
 }
 
 /// Etiqueta legible de un overlay para el footer del canvas. La engine