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) { + 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, +) -> 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 = 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 { + (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, + /// 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, +} + +/// 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