feat(cosmobiologia): GR — resaltado de eventos + HUD lateral de triggers

Segundo incremento del Sistema GR: el canvas ahora hace visible la
rectificación.

- Resaltado de convergencias: por cada punto natal donde un directo y
  un converso coinciden dentro del micro-orbe, un eje brillante cruza
  la zona del dual-ring hasta el cinturón natal, con marcador glow.
- HUD lateral: columna a la derecha del wheel cuando GR está activo,
  con los triggers ordenados por orbe. Color rojo→gris según orbe; las
  convergencias llevan ✦ y fondo resaltado.

paint_wheel recibe los gr_triggers; render_wheel monta el body como
fila wheel+HUD sólo en modo GR.

Falta: scrubbing live del jog-dial mapeado a la edad.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-22 13:44:14 +00:00
parent 15e45ace9b
commit 363f401b75
@@ -40,7 +40,9 @@ use gpui::{
Window, canvas, div, hsla, point, prelude::*, px, Window, canvas, div, hsla, point, prelude::*, px,
}; };
use cosmobiologia_engine::{Geometry, Layer, LayerKind, OUTER_RING_MODULES, RenderModel}; use cosmobiologia_engine::{
Geometry, GrTrigger, Layer, LayerKind, OUTER_RING_MODULES, RenderModel,
};
use cosmobiologia_model::{ChartId, ContactId, GroupId}; use cosmobiologia_model::{ChartId, ContactId, GroupId};
use cosmobiologia_theme::{AspectKind as TAspectKind, AstroPalette, Element, Planet}; use cosmobiologia_theme::{AspectKind as TAspectKind, AstroPalette, Element, Planet};
use nahual_theme::Theme; use nahual_theme::Theme;
@@ -949,6 +951,7 @@ fn render_wheel(
let palette_paint = palette.clone(); let palette_paint = palette.clone();
let theme_paint = theme.clone(); let theme_paint = theme.clone();
let layers_paint: Vec<Layer> = render.layers.clone(); let layers_paint: Vec<Layer> = render.layers.clone();
let gr_triggers_paint: Vec<GrTrigger> = render.gr_triggers.clone();
let asc_for_paint = asc; let asc_for_paint = asc;
let mc_for_paint = render.midheaven_deg; let mc_for_paint = render.midheaven_deg;
let visibility_for_paint = visible.clone(); let visibility_for_paint = visible.clone();
@@ -975,6 +978,7 @@ fn render_wheel(
radii, radii,
&visibility_for_paint, &visibility_for_paint,
hover_focus_paint.as_deref(), hover_focus_paint.as_deref(),
&gr_triggers_paint,
); );
// Handlers de mouse — se registran cada frame contra el // Handlers de mouse — se registran cada frame contra el
@@ -1749,16 +1753,150 @@ fn render_wheel(
footer = footer.child(grid); footer = footer.child(grid);
} }
// El wheel va solo, salvo en modo GR: ahí lo acompaña el HUD
// lateral de triggers de rectificación, anclado a su derecha.
let body = if render.gr_triggers.is_empty() {
div().child(wheel)
} else {
div()
.flex()
.flex_row()
.items_start()
.gap(px(14.0))
.child(wheel)
.child(render_gr_hud(theme, &render.gr_triggers))
};
div() div()
.flex() .flex()
.flex_col() .flex_col()
.items_center() .items_center()
.gap(px(8.0)) .gap(px(8.0))
.child(header) .child(header)
.child(wheel) .child(body)
.child(footer) .child(footer)
} }
/// HUD lateral de rectificación GR: lista los triggers de direcciones
/// primarias ordenados por orbe (los más cerrados arriba). El color va
/// de rojo (orbe apretado) a gris (orbe ancho); las convergencias
/// directo+converso llevan un marcador ✦ y un fondo resaltado.
fn render_gr_hud(theme: &Theme, triggers: &[GrTrigger]) -> gpui::Div {
const SHOWN: usize = 20;
let event_count = triggers.iter().filter(|t| t.event).count();
let mut col = div()
.flex()
.flex_col()
.gap(px(2.0))
.w(px(238.0))
.p(px(10.0))
.rounded(px(8.0))
.bg(theme.bg_panel_alt.clone())
.border_1()
.border_color(theme.border);
col = col.child(
div()
.flex()
.flex_row()
.justify_between()
.items_center()
.child(
div()
.text_size(px(12.0))
.text_color(theme.fg_text)
.child("Triggers GR"),
)
.child(
div()
.text_size(px(10.0))
.text_color(theme.fg_muted)
.child(SharedString::from(format!(
"{} · {} conv.",
triggers.len(),
event_count
))),
),
);
col = col.child(
div()
.text_size(px(9.0))
.text_color(theme.fg_disabled)
.mb(px(4.0))
.child("rectificación · orbe ascendente"),
);
for t in triggers.iter().take(SHOWN) {
let color = if t.event {
hsla(0.0, 0.88, 0.64, 1.0)
} else {
gr_orb_color(t.orb_deg)
};
let marker = if t.event { "" } else { "·" };
let line = format!(
"{} {}{}{} {}",
marker,
planet_unicode(&t.promissor),
t.direction.short(),
gr_target_glyph(&t.natal_target),
format_orb(t.orb_deg),
);
let mut row = div()
.px(px(5.0))
.py(px(2.0))
.rounded(px(3.0))
.text_size(px(11.0))
.text_color(color)
.child(SharedString::from(line));
if t.event {
row = row.bg(with_alpha(hsla(0.0, 0.80, 0.50, 1.0), 0.16));
}
col = col.child(row);
}
if triggers.len() > SHOWN {
col = col.child(
div()
.text_size(px(9.0))
.text_color(theme.fg_disabled)
.mt(px(3.0))
.child(SharedString::from(format!(
"+{} más",
triggers.len() - SHOWN
))),
);
}
col
}
/// 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°.
fn gr_orb_color(orb_deg: f32) -> Hsla {
let t = (orb_deg / 2.0).clamp(0.0, 1.0);
let s = 0.82 + (0.10 - 0.82) * t;
let l = 0.62 + (0.52 - 0.62) * t;
hsla(0.0, s, l, 1.0)
}
/// Orbe en grados → texto compacto `D°MM'`.
fn format_orb(orb_deg: f32) -> String {
let total_min = (orb_deg.abs() * 60.0).round() as i64;
format!("{}°{:02}'", total_min / 60, total_min % 60)
}
/// Glyph corto de un punto natal objetivo: ángulos como texto,
/// cuerpos vía [`planet_unicode`].
fn gr_target_glyph(name: &str) -> String {
match name {
"asc" => "Asc".to_string(),
"mc" => "MC".to_string(),
"desc" => "Dsc".to_string(),
"ic" => "IC".to_string(),
other => planet_unicode(other).to_string(),
}
}
/// Pequeña pill con la etiqueta de un overlay activo. El borde toma /// Pequeña pill con la etiqueta de un overlay activo. El borde toma
/// color según el "tipo" del módulo para ayudar a mapear a su anillo /// color según el "tipo" del módulo para ayudar a mapear a su anillo
/// en el wheel: natal = neutro, outer ring share (transit/synastry/ /// en el wheel: natal = neutro, outer ring share (transit/synastry/
@@ -1831,6 +1969,7 @@ fn paint_wheel(
radii: Radii, radii: Radii,
visibility: &HashMap<LayerKind, bool>, visibility: &HashMap<LayerKind, bool>,
hover_focus: Option<&str>, hover_focus: Option<&str>,
gr_triggers: &[GrTrigger],
) { ) {
let (cx, cy) = bounds_center(bounds); let (cx, cy) = bounds_center(bounds);
let show = |k: LayerKind| visibility.get(&k).copied().unwrap_or(true); let show = |k: LayerKind| visibility.get(&k).copied().unwrap_or(true);
@@ -2035,6 +2174,41 @@ fn paint_wheel(
} }
} }
} }
// Resaltado de convergencias GR: por cada punto natal donde un
// trigger directo y otro converso coinciden dentro del
// micro-orbe, un eje brillante atraviesa la zona del dual-ring
// hasta el cinturón natal. Es la señal de rectificación — si la
// hora natal es correcta, el evento real cae sobre este eje.
let mut event_degs: Vec<f32> = gr_triggers
.iter()
.filter(|t| t.event)
.map(|t| t.natal_deg)
.collect();
event_degs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
event_degs.dedup_by(|a, b| (*a - *b).abs() < 0.02);
if !event_degs.is_empty() {
let hot = hsla(0.0, 0.86, 0.60, 1.0);
let marker_r = (radii.sign_outer * 0.014).max(2.5);
for deg in event_degs {
paint_radial_line(
window,
cx,
cy,
deg,
ascendant_deg,
rot_offset_deg,
radii.pd_converse,
radii.houses_inner,
with_alpha(hot, 0.92),
2.6,
);
let (mx, my) =
polar_to_screen(deg, ascendant_deg, rot_offset_deg, radii.bodies);
paint_glow(window, cx + mx, cy + my, marker_r * 2.0, hot);
fill_circle(window, cx + mx, cy + my, marker_r, hot);
}
}
} }
// 3. Aspectos. Cada module_id usa su par de radios — natal-natal // 3. Aspectos. Cada module_id usa su par de radios — natal-natal