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>
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
index a18161a..8d4d5eb 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
@@ -131,6 +131,10 @@ pub struct CanvasState {
pub view_pan_y: f32,
/// Por-LayerKind: `true` = visible. Default = todo visible.
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
/// mouse no está sobre ningún cuerpo.
pub hover: Option<HoverInfo>,
@@ -215,6 +219,7 @@ impl Default for CanvasState {
view_pan_x: 0.0,
view_pan_y: 0.0,
layer_visibility: HashMap::new(),
+ show_coords: true,
hover: None,
drag_jog: None,
drag_pan: None,
@@ -291,6 +296,11 @@ impl AstrologyCanvas {
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
/// ni time offset — esos son ortogonales y tienen su propio reset.
pub fn reset_view(&mut self, cx: &mut Context<'_, Self>) {
@@ -336,15 +346,24 @@ impl AstrologyCanvas {
// ----- Internos: handlers de jog-dial -----
- /// Despacha el LMB down entre jog-dial (sobre el anillo de signos)
- /// y pan (cualquier otra parte del canvas). El jog-dial es el
- /// control de rectificación de hora; el pan es navegación libre.
+ /// Despacha el LMB down entre jog-dial y pan. El jog-dial es un
+ /// control "fuerte" (mueve el tiempo de la carta), así que se
+ /// 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(
&mut self,
position: Point<Pixels>,
+ modifiers: gpui::Modifiers,
bounds: Bounds<Pixels>,
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 mx: f32 = position.x.into();
let my: f32 = position.y.into();
@@ -361,6 +380,8 @@ impl AstrologyCanvas {
accumulated_delta_deg: 0.0,
});
} 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);
}
}
@@ -650,6 +671,10 @@ impl AstrologyCanvas {
self.reset_view(cx);
return;
}
+ "c" | "C" => {
+ self.toggle_coords(cx);
+ return;
+ }
"s" | "S" => {
cx.emit(CanvasEvent::ExportSvgRequested);
return;
@@ -753,6 +778,7 @@ impl Render for AstrologyCanvas {
self.state.view_pan_x,
self.state.view_pan_y,
&self.state.layer_visibility,
+ self.state.show_coords,
self.state.hover.as_ref(),
entity,
),
@@ -874,6 +900,7 @@ fn render_wheel(
view_pan_x: f32,
view_pan_y: f32,
layer_visibility: &HashMap<LayerKind, bool>,
+ show_coords: bool,
hover: Option<&HoverInfo>,
entity: gpui::Entity<AstrologyCanvas>,
) -> gpui::Div {
@@ -935,8 +962,9 @@ fn render_wheel(
}
match ev.button {
MouseButton::Left => {
+ let mods = ev.modifiers;
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 => {
@@ -999,6 +1027,10 @@ fn render_wheel(
// por view_scale para que el zoom afecte uniformemente todo el
// contenido visual del wheel, no solo la geometría del canvas.
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.
if visible.get(&LayerKind::SignDial).copied().unwrap_or(true) {
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) {
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 {
if matches!(layer.kind, LayerKind::Houses) {
for g in &layer.glyphs {
@@ -1034,8 +1067,25 @@ fn render_wheel(
16.0 * s,
11.0 * s,
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
// contra cualquier fondo. El natal lleva un tamaño un poco mayor
// 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) {
for layer in &render.layers {
if matches!(layer.kind, LayerKind::Bodies) {
@@ -1075,6 +1124,26 @@ fn render_wheel(
halo_bg,
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()
.text_size(px(10.0))
.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
@@ -1633,9 +1704,13 @@ fn paint_wheel(
// 2. Casas — doble anillo (inner + outer) + cusps radiales +
// é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) {
- 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_inner, 1.1, house_color, theme);
@@ -1647,7 +1722,7 @@ fn paint_wheel(
let color = if is_angle {
palette.angle_highlight
} else {
- with_alpha(palette.house_cusp, 0.7)
+ with_alpha(house_base, 0.75)
};
let width = if is_angle { 2.0 } else { 0.8 };
paint_radial_line(
@@ -2223,6 +2298,62 @@ fn planet_glyph(
.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
/// 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
@@ -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)
}
+/// 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
/// 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