feat(cosmobiologia): GR — scrubbing live de la edad con el jog-dial
Tercer y último incremento del Sistema GR: en modo GR (direcciones
primarias activas) el jog-dial deja de rotar el wheel y pasa a
scrubear la edad en vivo.
- canvas: CanvasState::gr_active() detecta el modo; on_jog_move emite
CanvasEvent::GrAgeDelta (años por grado de jog, sensibilidad 0.1)
en vez de rotar; on_jog_up no aplica snap de tiempo.
- shell: scrub_gr_age acumula el delta sobre target_age_years del
módulo primary_directions, clampa a [0,120], sincroniza el slider
del panel y recompone — los glifos dirigidos y el HUD se mueven en
vivo bajo el cursor.
Con esto el Sistema GR queda completo: cómputo de triggers, resaltado
de convergencias, HUD de rectificación y scrubbing live.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
diff --git a/crates/apps/cosmobiologia/src/shell.rs b/crates/apps/cosmobiologia/src/shell.rs
index 96f31ec..52ce17f 100644
--- a/crates/apps/cosmobiologia/src/shell.rs
+++ b/crates/apps/cosmobiologia/src/shell.rs
@@ -1172,7 +1172,36 @@ impl Shell {
CanvasEvent::ExportSvgRequested => {
self.export_current_to_svg();
}
+ CanvasEvent::GrAgeDelta(delta) => {
+ self.scrub_gr_age(*delta, 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.
+ fn scrub_gr_age(&mut self, delta_years: f64, cx: &mut Context<Self>) {
+ if !module_enabled(&self.module_configs, "primary_directions") {
+ return;
}
+ let current = self.module_age_or_current("primary_directions");
+ let next = (current + delta_years).clamp(0.0, 120.0);
+ if (next - current).abs() < 1e-6 {
+ return;
+ }
+ let entry = self
+ .module_configs
+ .entry("primary_directions".into())
+ .or_insert_with(|| serde_json::json!({}));
+ if let serde_json::Value::Object(map) = entry {
+ map.insert("target_age_years".into(), serde_json::json!(next));
+ }
+ self.panel.update(cx, |p, cx| {
+ p.set_slider("primary_directions", "target_age_years", next, cx)
+ });
+ self.persist_module("primary_directions");
+ self.render_current(cx);
}
/// Recompone la carta actual + escribe el SVG a un archivo en
diff --git a/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
index 11a7d72..e9d6f18 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
@@ -67,6 +67,10 @@ pub enum CanvasEvent {
/// El usuario pidió exportar el render actual como SVG. El shell
/// se encarga de escribir el archivo (la engine genera el string).
ExportSvgRequested,
+ /// En modo GR (direcciones primarias activas) el jog-dial scrubea
+ /// 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),
}
// =====================================================================
@@ -236,8 +240,23 @@ impl CanvasState {
pub fn is_layer_visible(&self, kind: LayerKind) -> bool {
self.layer_visibility.get(&kind).copied().unwrap_or(true)
}
+
+ /// `true` cuando hay un overlay de direcciones primarias activo.
+ /// En ese modo el jog-dial scrubea la edad GR en vez del tiempo.
+ fn gr_active(&self) -> bool {
+ matches!(
+ &self.mode,
+ CanvasMode::Wheel { render }
+ if render.layers.iter().any(|l| l.module_id == "pd_direct")
+ )
+ }
}
+/// Sensibilidad del scrubbing GR: años de edad por grado de jog. A
+/// 0.1, una vuelta completa del dial barre 36 años — fino para
+/// explorar contactos sin perder rango.
+const GR_AGE_PER_DEG: f32 = 0.1;
+
// =====================================================================
// Widget
// =====================================================================
@@ -408,6 +427,7 @@ impl AstrologyCanvas {
bounds: Bounds<Pixels>,
cx: &mut Context<'_, Self>,
) {
+ let gr = self.state.gr_active();
let Some(jog) = self.state.drag_jog.as_mut() else {
return;
};
@@ -426,9 +446,17 @@ impl AstrologyCanvas {
}
jog.accumulated_delta_deg += delta;
jog.last_screen_angle_deg = angle;
- // Reflejo visual durante el drag (sin recomputar).
- self.state.view_rotation_deg = jog.accumulated_delta_deg;
- cx.notify();
+ let accumulated = jog.accumulated_delta_deg;
+ if gr {
+ // Modo GR: el jog scrubea la edad. No rota el wheel — el
+ // feedback es el movimiento de los glifos dirigidos cuando
+ // el shell recompone con la edad nueva.
+ cx.emit(CanvasEvent::GrAgeDelta((-delta * GR_AGE_PER_DEG) as f64));
+ } else {
+ // Reflejo visual durante el drag (sin recomputar).
+ self.state.view_rotation_deg = accumulated;
+ cx.notify();
+ }
}
/// Hit-test sobre body glyphs + house cusps. Para bodies: distancia
@@ -662,9 +690,15 @@ impl AstrologyCanvas {
}
fn on_jog_up(&mut self, cx: &mut Context<'_, Self>) {
+ let gr = self.state.gr_active();
let Some(jog) = self.state.drag_jog.take() else {
return;
};
+ if gr {
+ // El scrub GR se aplicó en vivo durante el drag; al soltar
+ // no queda nada que confirmar.
+ return;
+ }
// 1° de arco ≈ 4 minutos de tiempo sideral (15°/hora).
// CW visual (delta negativa en nuestra convención) → tiempo
// hacia adelante.
@@ -1641,7 +1675,7 @@ fn render_wheel(
.text_size(px(10.0))
.text_color(theme.fg_disabled)
.child(
- "[D]ial [H]ouses as[X]pects [P]lanets [T]ransits [C]oords · Ctrl+drag = tiempo · [0] reset zoom · [R] reset tiempo · [S]vg",
+ "[D]ial [H]ouses as[X]pects [P]lanets [T]ransits [C]oords · Ctrl+drag = tiempo/edad GR · [0] reset zoom · [R] reset tiempo · [S]vg",
),
);