feat(tahuantinsuyu): distinción eclíptica/casas, coord labels, jog-dial con Ctrl

- Anillo de casas claramente distinto del dial zodiacal: nuevo
  `house_ring_color(palette)` que toma `house_cusp` y le aplica
  un hue shift de 140° en paletas con color (en BW devuelve el
  color original — un shift cromático en monocromo es ruido sin
  información). El sistema ascensional (casas) ya no se confunde
  con el eclíptico (signos): dorado vs verde/teal.

- Coordenadas permanentes en planetas y cusps de casa: por
  default visibles, togglean con hotkey `C` o desde el panel
  vía `state.show_coords`. Cada planeta natal lleva una pill
  pequeña afuera del disco con "DD°{signo}" (ej. "14°"); cada
  cusp de casa lleva la misma pill por dentro del anillo
  interior. Helpers nuevos: `format_coord_compact`,
  `coord_label`.

- Jog-dial requiere Ctrl/Cmd para activar. Sin modifier, LMB
  drag es siempre pan — sobre o fuera del anillo. Con modifier
  + LMB sobre el anillo se activa la rotación de tiempo (el
  control de rectificación). Evita rotaciones accidentales al
  navegar la rueda.

- Hint del info_row actualizado: incluye [C]oords y la
  convención "Ctrl+drag = tiempo".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-18 16:40:19 +00:00
parent e9369371db
commit 8ede06f8c4
@@ -131,6 +131,10 @@ pub struct CanvasState {
pub view_pan_y: f32, pub view_pan_y: f32,
/// Por-LayerKind: `true` = visible. Default = todo visible. /// Por-LayerKind: `true` = visible. Default = todo visible.
pub layer_visibility: HashMap<LayerKind, bool>, pub layer_visibility: HashMap<LayerKind, bool>,
/// Indicadores de grado al lado de cada planeta y cusp de casa.
/// Default `true` — el usuario los espera ver para leer la
/// carta. Se togglean con `C` (Coords) o desde el panel.
pub show_coords: bool,
/// Planeta hovered actualmente (para tooltip). `None` cuando el /// Planeta hovered actualmente (para tooltip). `None` cuando el
/// mouse no está sobre ningún cuerpo. /// mouse no está sobre ningún cuerpo.
pub hover: Option<HoverInfo>, pub hover: Option<HoverInfo>,
@@ -215,6 +219,7 @@ impl Default for CanvasState {
view_pan_x: 0.0, view_pan_x: 0.0,
view_pan_y: 0.0, view_pan_y: 0.0,
layer_visibility: HashMap::new(), layer_visibility: HashMap::new(),
show_coords: true,
hover: None, hover: None,
drag_jog: None, drag_jog: None,
drag_pan: None, drag_pan: None,
@@ -291,6 +296,11 @@ impl AstrologyCanvas {
cx.notify(); cx.notify();
} }
pub fn toggle_coords(&mut self, cx: &mut Context<'_, Self>) {
self.state.show_coords = !self.state.show_coords;
cx.notify();
}
/// Resetea zoom y pan a sus defaults (1.0 y 0,0). No toca rotation /// Resetea zoom y pan a sus defaults (1.0 y 0,0). No toca rotation
/// ni time offset — esos son ortogonales y tienen su propio reset. /// ni time offset — esos son ortogonales y tienen su propio reset.
pub fn reset_view(&mut self, cx: &mut Context<'_, Self>) { pub fn reset_view(&mut self, cx: &mut Context<'_, Self>) {
@@ -336,15 +346,24 @@ impl AstrologyCanvas {
// ----- Internos: handlers de jog-dial ----- // ----- Internos: handlers de jog-dial -----
/// Despacha el LMB down entre jog-dial (sobre el anillo de signos) /// Despacha el LMB down entre jog-dial y pan. El jog-dial es un
/// y pan (cualquier otra parte del canvas). El jog-dial es el /// control "fuerte" (mueve el tiempo de la carta), así que se
/// control de rectificación de hora; el pan es navegación libre. /// activa SOLO con modifier Ctrl/Cmd + click sobre el anillo de
/// signos — sin modifier es siempre pan, incluso sobre el anillo,
/// para que no haya rotaciones accidentales al manipular la
/// rueda.
fn on_primary_down( fn on_primary_down(
&mut self, &mut self,
position: Point<Pixels>, position: Point<Pixels>,
modifiers: gpui::Modifiers,
bounds: Bounds<Pixels>, bounds: Bounds<Pixels>,
cx: &mut Context<'_, Self>, cx: &mut Context<'_, Self>,
) { ) {
// Sin modifier: pan, sin importar dónde caiga el click.
if !(modifiers.control || modifiers.platform) {
self.on_pan_down(position, cx);
return;
}
let (cx_px, cy_px) = bounds_center(bounds); let (cx_px, cy_px) = bounds_center(bounds);
let mx: f32 = position.x.into(); let mx: f32 = position.x.into();
let my: f32 = position.y.into(); let my: f32 = position.y.into();
@@ -361,6 +380,8 @@ impl AstrologyCanvas {
accumulated_delta_deg: 0.0, accumulated_delta_deg: 0.0,
}); });
} else { } else {
// Ctrl+click fuera del anillo: pan también — el modifier
// habilita el jog-dial pero no impide la navegación.
self.on_pan_down(position, cx); self.on_pan_down(position, cx);
} }
} }
@@ -650,6 +671,10 @@ impl AstrologyCanvas {
self.reset_view(cx); self.reset_view(cx);
return; return;
} }
"c" | "C" => {
self.toggle_coords(cx);
return;
}
"s" | "S" => { "s" | "S" => {
cx.emit(CanvasEvent::ExportSvgRequested); cx.emit(CanvasEvent::ExportSvgRequested);
return; return;
@@ -753,6 +778,7 @@ impl Render for AstrologyCanvas {
self.state.view_pan_x, self.state.view_pan_x,
self.state.view_pan_y, self.state.view_pan_y,
&self.state.layer_visibility, &self.state.layer_visibility,
self.state.show_coords,
self.state.hover.as_ref(), self.state.hover.as_ref(),
entity, entity,
), ),
@@ -874,6 +900,7 @@ fn render_wheel(
view_pan_x: f32, view_pan_x: f32,
view_pan_y: f32, view_pan_y: f32,
layer_visibility: &HashMap<LayerKind, bool>, layer_visibility: &HashMap<LayerKind, bool>,
show_coords: bool,
hover: Option<&HoverInfo>, hover: Option<&HoverInfo>,
entity: gpui::Entity<AstrologyCanvas>, entity: gpui::Entity<AstrologyCanvas>,
) -> gpui::Div { ) -> gpui::Div {
@@ -935,8 +962,9 @@ fn render_wheel(
} }
match ev.button { match ev.button {
MouseButton::Left => { MouseButton::Left => {
let mods = ev.modifiers;
entity_d.update(cx, |this, cx| { entity_d.update(cx, |this, cx| {
this.on_primary_down(ev.position, bounds, cx) this.on_primary_down(ev.position, mods, bounds, cx)
}); });
} }
MouseButton::Middle => { MouseButton::Middle => {
@@ -999,6 +1027,10 @@ fn render_wheel(
// por view_scale para que el zoom afecte uniformemente todo el // por view_scale para que el zoom afecte uniformemente todo el
// contenido visual del wheel, no solo la geometría del canvas. // contenido visual del wheel, no solo la geometría del canvas.
let s = view_scale; let s = view_scale;
// Color del halo para los discos detrás de glyphs y pills — se
// calcula una sola vez, lo usan planetas, casas, ASC/MC y los
// coord labels.
let halo_bg = glyph_halo(theme);
// Sign glyphs. // Sign glyphs.
if visible.get(&LayerKind::SignDial).copied().unwrap_or(true) { if visible.get(&LayerKind::SignDial).copied().unwrap_or(true) {
let sign_ring_mid = (radii.sign_outer + radii.sign_inner) / 2.0; let sign_ring_mid = (radii.sign_outer + radii.sign_inner) / 2.0;
@@ -1020,9 +1052,10 @@ fn render_wheel(
} }
} }
// House numbers. // House numbers + (opcional) coord del cusp.
if visible.get(&LayerKind::Houses).copied().unwrap_or(true) { if visible.get(&LayerKind::Houses).copied().unwrap_or(true) {
let house_label_r = (radii.houses_outer + radii.houses_inner) / 2.0; let house_label_r = (radii.houses_outer + radii.houses_inner) / 2.0;
let house_label_color = house_ring_color(palette);
for layer in &render.layers { for layer in &render.layers {
if matches!(layer.kind, LayerKind::Houses) { if matches!(layer.kind, LayerKind::Houses) {
for g in &layer.glyphs { for g in &layer.glyphs {
@@ -1034,8 +1067,25 @@ fn render_wheel(
16.0 * s, 16.0 * s,
11.0 * s, 11.0 * s,
format!("{}", h).into(), format!("{}", h).into(),
palette.house_cusp, house_label_color,
)); ));
// Coord del cusp justo dentro del anillo de
// casas — los grados se imprimen en una pill
// pequeña pegada al radio del cusp.
if show_coords {
let coord = format_coord_compact(g.deg);
let label_r = radii.houses_inner - 8.0 * s;
let (lx, ly) =
polar_to_screen(g.deg, asc, rot_offset, label_r);
wheel = wheel.child(coord_label(
cx_center + lx,
cy_center + ly,
coord.into(),
theme.fg_muted,
halo_bg,
8.5 * s,
));
}
} }
} }
} }
@@ -1046,7 +1096,6 @@ fn render_wheel(
// solar_arc) en sus rings, ambos con disco-halo para legibilidad // solar_arc) en sus rings, ambos con disco-halo para legibilidad
// contra cualquier fondo. El natal lleva un tamaño un poco mayor // contra cualquier fondo. El natal lleva un tamaño un poco mayor
// que los overlays para que se lea como "el cuerpo principal". // que los overlays para que se lea como "el cuerpo principal".
let halo_bg = glyph_halo(theme);
if visible.get(&LayerKind::Bodies).copied().unwrap_or(true) { if visible.get(&LayerKind::Bodies).copied().unwrap_or(true) {
for layer in &render.layers { for layer in &render.layers {
if matches!(layer.kind, LayerKind::Bodies) { if matches!(layer.kind, LayerKind::Bodies) {
@@ -1075,6 +1124,26 @@ fn render_wheel(
halo_bg, halo_bg,
with_alpha(color, 0.85), with_alpha(color, 0.85),
)); ));
// Coord label: grado dentro del signo + glyph del
// signo, pintado justo afuera del disco del
// planeta (radialmente). Sólo en natal (los
// overlays ya cargan info en su badge / tooltip).
if show_coords && is_natal {
let coord = format_coord_compact(g.deg);
let label_r = ring + disk_size * 0.7;
let (lx, ly) = polar_to_screen(g.deg, asc, rot_offset, label_r);
wheel = wheel.child(
coord_label(
cx_center + lx,
cy_center + ly,
coord.into(),
theme.fg_muted,
halo_bg,
9.5 * s,
),
);
}
} }
} }
} }
@@ -1324,7 +1393,9 @@ fn render_wheel(
div() div()
.text_size(px(10.0)) .text_size(px(10.0))
.text_color(theme.fg_disabled) .text_color(theme.fg_disabled)
.child("[D]ial [H]ouses as[X]pects [P]lanets [T]ransits [S]vg [R]eset"), .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",
),
); );
// Badges de overlays activos. Cada uno se pinta como pill con // Badges de overlays activos. Cada uno se pinta como pill con
@@ -1633,9 +1704,13 @@ fn paint_wheel(
// 2. Casas — doble anillo (inner + outer) + cusps radiales + // 2. Casas — doble anillo (inner + outer) + cusps radiales +
// énfasis Asc/IC/Desc/MC. La doble línea vuelve a la zona de // énfasis Asc/IC/Desc/MC. La doble línea vuelve a la zona de
// casas una "corona" claramente identificable contra el resto. // casas una "corona" claramente identificable. Color derivado
// de `house_cusp` con un hue shift para que el sistema
// ascensional (casas) se distinga visualmente del eclíptico
// (dial zodiacal) que va en dorado.
if show(LayerKind::Houses) { if show(LayerKind::Houses) {
let house_color = with_alpha(palette.house_cusp, 0.85); let house_base = house_ring_color(palette);
let house_color = with_alpha(house_base, 0.85);
stroke_circle_3d(window, cx, cy, radii.houses_outer, 1.1, house_color, theme); stroke_circle_3d(window, cx, cy, radii.houses_outer, 1.1, house_color, theme);
stroke_circle_3d(window, cx, cy, radii.houses_inner, 1.1, house_color, theme); stroke_circle_3d(window, cx, cy, radii.houses_inner, 1.1, house_color, theme);
@@ -1647,7 +1722,7 @@ fn paint_wheel(
let color = if is_angle { let color = if is_angle {
palette.angle_highlight palette.angle_highlight
} else { } else {
with_alpha(palette.house_cusp, 0.7) with_alpha(house_base, 0.75)
}; };
let width = if is_angle { 2.0 } else { 0.8 }; let width = if is_angle { 2.0 } else { 0.8 };
paint_radial_line( paint_radial_line(
@@ -2223,6 +2298,62 @@ fn planet_glyph(
.child(text) .child(text)
} }
/// Formato compacto de un grado eclíptico: "DD°SS" donde SS es el
/// glyph del signo zodiacal (♈♉♊…). Ej: 14.93° → "14°♈". Los
/// minutos se omiten — la pill es pequeña y los grados enteros
/// alcanzan para orientación visual. El tooltip muestra el detalle.
fn format_coord_compact(deg: f32) -> String {
let normalized = deg.rem_euclid(360.0);
let sign_idx = ((normalized / 30.0).floor() as usize) % 12;
let deg_in_sign = (normalized - (sign_idx as f32) * 30.0).floor() as i32;
let sign_glyph = match sign_idx {
0 => "",
1 => "",
2 => "",
3 => "",
4 => "",
5 => "",
6 => "",
7 => "",
8 => "",
9 => "",
10 => "",
_ => "",
};
format!("{}°{}", deg_in_sign, sign_glyph)
}
/// Pill pequeña con un coord ("14°♈") junto al glyph de un planeta
/// o cusp. Fondo halo + texto fg_muted, padding mínimo para no
/// saturar la rueda con etiquetas grandes.
fn coord_label(
x: f32,
y: f32,
text: SharedString,
fg: Hsla,
halo_bg: Hsla,
font_size: f32,
) -> gpui::Div {
// Estimación gruesa del ancho (caracteres × ~5.5 px a font 9.5).
// Suficiente para no recortar; el flex centra dentro.
let w = (text.len() as f32 * (font_size * 0.58)).max(font_size * 2.0);
let h = font_size + 6.0;
div()
.absolute()
.left(px(x - w / 2.0))
.top(px(y - h / 2.0))
.w(px(w))
.h(px(h))
.flex()
.items_center()
.justify_center()
.rounded(px(h / 2.0))
.bg(halo_bg)
.text_size(px(font_size))
.text_color(fg)
.child(text)
}
/// Color HSL semi-opaco para los halos de los glyphs — derivado del /// Color HSL semi-opaco para los halos de los glyphs — derivado del
/// theme. En dark va casi negro; en light casi blanco. Alpha alta para /// theme. En dark va casi negro; en light casi blanco. Alpha alta para
/// que el char quede legible contra cualquier cosa que haya detrás /// que el char quede legible contra cualquier cosa que haya detrás
@@ -2246,6 +2377,29 @@ fn adjust_luma(c: Hsla, delta: f32) -> Hsla {
hsla(c.h, c.s, (c.l + delta).clamp(0.0, 1.0), c.a) hsla(c.h, c.s, (c.l + delta).clamp(0.0, 1.0), c.a)
} }
/// Devuelve `c` con el hue desplazado `delta_deg` grados sobre el
/// círculo cromático (wrap a [0,1] en la escala normalizada de gpui).
/// Usado para derivar el color del anillo de casas desde el del dial
/// zodiacal — los dos sistemas (eclíptica vs ascensional) deben
/// distinguirse a primera vista pero compartir "familia" cromática.
fn shift_hue(c: Hsla, delta_deg: f32) -> Hsla {
let new_h = (c.h + delta_deg / 360.0).rem_euclid(1.0);
hsla(new_h, c.s, c.l, c.a)
}
/// Color para los anillos del sistema de casas (ascensional). En
/// paletas con color, lo derivamos de `house_cusp` con un hue shift
/// de ~140° para diferenciar de la eclíptica (que va con el dorado
/// de `dial_ring`). En BW devolvemos `house_cusp` tal cual — un
/// shift cromático en monocromo es ruido sin información.
fn house_ring_color(palette: &AstroPalette) -> Hsla {
if palette.is_monochrome() {
palette.house_cusp
} else {
shift_hue(palette.house_cusp, 140.0)
}
}
/// Stroke con efecto embossed: 3 trazos concéntricos. El highlight va /// Stroke con efecto embossed: 3 trazos concéntricos. El highlight va
/// 0.7 px hacia el centro con luminancia subida; el principal en `r`; /// 0.7 px hacia el centro con luminancia subida; el principal en `r`;
/// el shadow 0.7 px hacia afuera con luminancia bajada. La dirección /// el shadow 0.7 px hacia afuera con luminancia bajada. La dirección