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>
This commit is contained in:
@@ -1176,9 +1176,30 @@ impl Shell {
|
|||||||
CanvasEvent::GrAgeDelta(delta) => {
|
CanvasEvent::GrAgeDelta(delta) => {
|
||||||
self.scrub_gr_age(*delta, cx);
|
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`
|
/// Scrubbing en vivo de la edad GR vía jog-dial. Acumula `delta`
|
||||||
/// sobre `target_age_years` del módulo `primary_directions`,
|
/// sobre `target_age_years` del módulo `primary_directions`,
|
||||||
/// clampa a [0,120], sincroniza el slider del panel y recompone.
|
/// clampa a [0,120], sincroniza el slider del panel y recompone.
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ pub enum CanvasEvent {
|
|||||||
/// la edad en vez del tiempo. Lleva el delta de edad en años; el
|
/// 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.
|
/// host lo acumula sobre `target_age_years` y recompone en vivo.
|
||||||
GrAgeDelta(f64),
|
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
|
// Lista textual de aspectos (top 12 por orb). Compacta, en grid
|
||||||
// de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos
|
// de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos
|
||||||
// computados.
|
// computados.
|
||||||
@@ -1903,6 +1919,89 @@ fn render_gr_hud(theme: &Theme, triggers: &[GrTrigger]) -> gpui::Div {
|
|||||||
col
|
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,
|
/// Color de un trigger GR según su orbe: rojo intenso (orbe cerrado,
|
||||||
/// contacto fuerte) que se desatura hacia gris al ensancharse. El
|
/// contacto fuerte) que se desatura hacia gris al ensancharse. El
|
||||||
/// orbe de referencia (gris pleno) es el orbe del HUD, 2°.
|
/// orbe de referencia (gris pleno) es el orbe del HUD, 2°.
|
||||||
|
|||||||
@@ -1497,6 +1497,8 @@ fn build_render_model(
|
|||||||
aspect_summary: Vec::new(),
|
aspect_summary: Vec::new(),
|
||||||
uranian_groups: Vec::new(),
|
uranian_groups: Vec::new(),
|
||||||
gr_triggers: Vec::new(),
|
gr_triggers: Vec::new(),
|
||||||
|
harmonic: 1,
|
||||||
|
harmonic_spectrum: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -343,6 +343,8 @@ pub fn compute_mock(chart: &Chart) -> RenderModel {
|
|||||||
aspect_summary: Vec::new(),
|
aspect_summary: Vec::new(),
|
||||||
uranian_groups: Vec::new(),
|
uranian_groups: Vec::new(),
|
||||||
gr_triggers: Vec::new(),
|
gr_triggers: Vec::new(),
|
||||||
|
harmonic: 1,
|
||||||
|
harmonic_spectrum: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -270,7 +270,8 @@ pub mod natal {
|
|||||||
key: "harmonic".into(),
|
key: "harmonic".into(),
|
||||||
label: "Armónico".into(),
|
label: "Armónico".into(),
|
||||||
min: 1.0,
|
min: 1.0,
|
||||||
max: 20.0,
|
// 1-32: el rango del espectro de fuerza armónica.
|
||||||
|
max: 32.0,
|
||||||
step: 1.0,
|
step: 1.0,
|
||||||
default: 1.0,
|
default: 1.0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
|
|
||||||
use crate::{AspectSummary, Geometry, LayerKind, LineSeg, RenderModel};
|
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)`.
|
/// 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.
|
/// Conjunción y oposición llevan orbe más amplio, como es convención.
|
||||||
const HARMONIC_ASPECTS: &[(&str, f32, f32)] = &[
|
const HARMONIC_ASPECTS: &[(&str, f32, f32)] = &[
|
||||||
@@ -38,6 +41,14 @@ pub fn apply_harmonic(model: &mut RenderModel, n: u32) {
|
|||||||
}
|
}
|
||||||
let nf = n as f32;
|
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)`.
|
// 1. Transformar los cuerpos natales; recolectar `(símbolo, lon)`.
|
||||||
let mut bodies: Vec<(String, f32)> = Vec::new();
|
let mut bodies: Vec<(String, f32)> = Vec::new();
|
||||||
for layer in &mut model.layers {
|
for layer in &mut model.layers {
|
||||||
@@ -79,10 +90,43 @@ pub fn apply_harmonic(model: &mut RenderModel, n: u32) {
|
|||||||
})
|
})
|
||||||
.collect();
|
.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);
|
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`).
|
/// Separación circular mínima entre dos longitudes (rango `0..=180`).
|
||||||
fn circular_sep(a: f32, b: f32) -> f32 {
|
fn circular_sep(a: f32, b: f32) -> f32 {
|
||||||
let d = (a - b).rem_euclid(360.0);
|
let d = (a - b).rem_euclid(360.0);
|
||||||
@@ -188,6 +232,8 @@ mod tests {
|
|||||||
aspect_summary: Vec::new(),
|
aspect_summary: Vec::new(),
|
||||||
uranian_groups: Vec::new(),
|
uranian_groups: Vec::new(),
|
||||||
gr_triggers: 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");
|
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]
|
#[test]
|
||||||
fn houses_layer_is_preserved() {
|
fn houses_layer_is_preserved() {
|
||||||
let mut model = natal_model(&[("sun", 10.0)]);
|
let mut model = natal_model(&[("sun", 10.0)]);
|
||||||
|
|||||||
@@ -92,6 +92,19 @@ pub struct RenderModel {
|
|||||||
/// y resalta los `event = true` (convergencias directo+converso).
|
/// y resalta los `event = true` (convergencias directo+converso).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gr_triggers: Vec<GrTrigger>,
|
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
|
/// Etiqueta legible de un overlay para el footer del canvas. La engine
|
||||||
|
|||||||
Reference in New Issue
Block a user