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 · 2026-05-22T13:57:54Z (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
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