feat(tahuantinsuyu): fase 4 — jog-dial perimetral, hotkeys y panel interactivo

Time scrubbing por drag en el aro exterior del wheel: rota visualmente
mientras dura el drag, al soltar traduce el delta angular a minutos
(1° = 4 min sideral, CW = forward) y emite CanvasEvent::TimeOffsetChanged.
La Shell recomputa con engine::compute_at_offset y el ascendant rotado
queda en la nueva posición. Snap visual a 0° tras commit.

- engine: nueva variante compute_at_offset(chart, minutes) que suma
  segundos al UTC base via add_seconds + Instant::from_utc y corre la
  pipeline normal. compute() es ahora wrapper con offset=0.
- canvas: estado nuevo layer_visibility + drag_jog. Mouse handlers
  registrados desde el paint callback (mismo patrón que splitter/tiled).
  Hotkeys D/H/X/P toggle SignDial/Houses/Aspects/Bodies, R resetea
  offset. FocusHandle + click-to-focus para recibir teclas. Indicador
  ⏱ ±Xd HH:MM en el footer con color highlight cuando el offset != 0.
  paint_wheel + glyph overlays respetan layer_visibility (skip capas
  ocultas).
- modules: NatalModule.controls() ahora expone show_sign_dial /
  show_houses / show_aspects / show_bodies con hotkeys [D/H/X/P], más
  el slider de armónico.
- panel: ControlPanel mantiene toggle_state cache (module_id, key) →
  bool, inicializa desde defaults al cambiar de ChartKind. Click
  invierte el toggle visualmente y emite ControlChanged. Nuevo
  set_toggle(module, key, value) para que la Shell mantenga sync
  cuando el canvas se autotogglea por hotkey.
- shell: nuevo current_chart + current_offset_minutes. render_current()
  delega a compute_at_offset. Suscripción a CanvasEvent traduce
  TimeOffsetChanged → re-render, LayerVisibilityChanged → panel sync.
  Suscripción a PanelEvent::ControlChanged traduce show_* keys a
  set_layer_visible sobre el canvas.

Todos los tests verdes. La fase 5 sumará módulos extra (transit,
progression, synastry, uranian) + extracción de eternal de lo que falte.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-17 10:15:09 +00:00
parent f4944218e2
commit 360797132e
6 changed files with 862 additions and 429 deletions
@@ -173,12 +173,12 @@ fn aspect_kind_id(k: EAspectKind) -> &'static str {
// compute()
// =====================================================================
pub fn compute(chart: &Chart) -> Result<RenderModel, EngineError> {
pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result<RenderModel, EngineError> {
let t0 = Instant::now();
chart.validate()?;
let bd = &chart.birth_data;
let instant = ESInstant::from_civil_local(
let base_instant = ESInstant::from_civil_local(
bd.year,
u8::try_from(bd.month).map_err(|_| {
EngineError::Eternal(format!("mes fuera de u8: {}", bd.month))
@@ -197,6 +197,17 @@ pub fn compute(chart: &Chart) -> Result<RenderModel, EngineError> {
)
.map_err(|e| EngineError::Eternal(format!("Instant::from_civil_local: {:?}", e)))?;
// Aplicar el offset (en minutos) sumando segundos al UTC y
// reconstruyendo el `Instant`. `UTC::add_seconds` está disponible
// pero opera sobre la representación interna; el `Instant` la
// re-envuelve vía `from_utc`.
let instant = if offset_minutes == 0 {
base_instant
} else {
let shifted_utc = base_instant.utc().add_seconds((offset_minutes as f64) * 60.0);
ESInstant::from_utc(shifted_utc)
};
let observer = Observer::from_degrees(bd.latitude_deg, bd.longitude_deg, bd.altitude_m);
let mut birth_e = BirthData::new(instant, observer);