feat(tahuantinsuyu): fase 18 — aspect list + hover tooltips + dignidades + export SVG
Fase grande con 4 features que aprovechan toda la infraestructura ya
construida. Engine ganó 2 módulos nuevos (dignity table data-only +
svg_export), el RenderModel se enriqueció con AspectSummary y los
glyphs con dignity_marker, y el canvas trae hit-test pasivo + lista
textual + botón de export.
## C — Lista textual de aspectos
- engine: nuevo `AspectSummary { module_id, from_body, to_body, kind,
orb_deg, applying }` + campo `aspect_summary: Vec<AspectSummary>`
en RenderModel. populate_natal_aspect_summary y
populate_cross_aspect_summary se llaman desde compose por cada
pasada (natal + 4 overlays). Ordenado por orb_deg asc (los más
exactos primero).
- canvas: nuevo aspect_unicode helper (☌ ☍ △ □ ⚹ ⚻ ⚺ ∠ ⚼ Q bQ).
Footer agrega un grid flex-wrap con las top 12 entries del summary,
cada una formateada como "[module_id] ☉ △ ☾ · 2.3° A" coloreado
por palette.aspect(kind).
## A — Tooltips al hover
- canvas: nuevo HoverInfo { module_id, symbol, deg, house, retrograde,
dignity_marker, annotation, local_x, local_y } + state.hover.
on_hover_check ejecuta hit-test sobre todos los glyphs Bodies +
Outer (threshold 14px); se llama desde el handler MouseMoveEvent
cuando NO está dragging (handler reescrito para soportar drag y
hover en mismo callback). Cuando mouse sale del wheel, hover=None.
- Tooltip absoluto: "☉ Tauro · 23.4° · Casa 5 · ℞" con border
angle_highlight. Posición offset arriba-derecha del planeta,
clampada al wheel para no salirse.
## B — Dignidades esenciales clásicas
- engine: nuevo mod `dignity` con `Dignity { Rulership/Exaltation/
Detriment/Fall }` + tabla rules_classical (7 planetas tradicionales)
+ exalts_at table. `essential_dignity(body, sign_index) -> Option`.
4 tests cubren rulership/detriment/exaltation/fall + edge case
modernos (Urano/Nept/Plutón sin dignidad clásica). 4 markers:
+ (domicilio), · (exaltación), − (exilio), * (caída).
- engine: Glyph gana campo `dignity_marker: Option<String>`. Default
derive en Glyph para no romper N construction sites. bridge::
annotate_dignities mutua RenderModel post-build agregando markers
a glyphs natales según el signo de cada placement.
- NatalOptions agrega show_dignities. NatalModule.controls() agrega
Toggle "Dignidades esenciales (+ · − *)" default false.
- canvas: glyph render append dignity_marker al texto después del ᴿ.
## D — Export SVG
- engine: nuevo `pub mod svg_export` con `render_to_svg(&RenderModel)
-> String`. Reproduce la geometría del canvas en un SVG standalone
800×800 escalable: anillos zodiacales, cusps, planetas con
retrograde+dignity markers, aspectos coloreados por kind (cross
conocen rings de origen/destino vía aspect_radii), labels ASC/MC/
DESC/IC. Sin dependencias nuevas — write! sobre String. Test
asserts well-formed XML.
- canvas: CanvasEvent::ExportSvgRequested + botón pequeño "⬇ SVG"
en el header del wheel (al lado del title).
- shell: on_canvas_event ExportSvgRequested → export_current_to_svg
recompose actual + svg_export::render_to_svg + write a
$XDG_DATA_HOME/tahuantinsuyu/exports/<label>_<short_id>.svg.
Ruta logueada a stderr para que el usuario encuentre el archivo.
`cargo check` y `cargo test` verdes con 8 tests en engine
(2 existentes + 4 dignity + 1 svg + 1 mock).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,9 @@ pub enum CanvasEvent {
|
||||
/// El usuario togggleó una capa via hotkey — el panel debería
|
||||
/// reflejarlo si quisiera mantenerse en sync.
|
||||
LayerVisibilityChanged { kind: LayerKind, visible: bool },
|
||||
/// El usuario pidió exportar el render actual como SVG. El shell
|
||||
/// se encarga de escribir el archivo (la engine genera el string).
|
||||
ExportSvgRequested,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -110,9 +113,28 @@ pub struct CanvasState {
|
||||
pub time_offset_minutes: i64,
|
||||
/// Por-LayerKind: `true` = visible. Default = todo visible.
|
||||
pub layer_visibility: HashMap<LayerKind, bool>,
|
||||
/// Planeta hovered actualmente (para tooltip). `None` cuando el
|
||||
/// mouse no está sobre ningún cuerpo.
|
||||
pub hover: Option<HoverInfo>,
|
||||
drag_jog: Option<JogDragState>,
|
||||
}
|
||||
|
||||
/// Info del cuerpo bajo el cursor — usado por el render para mostrar
|
||||
/// un tooltip flotante con detalles.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HoverInfo {
|
||||
pub module_id: String,
|
||||
pub symbol: String,
|
||||
pub deg: f32,
|
||||
pub house: Option<u8>,
|
||||
pub retrograde: bool,
|
||||
pub dignity_marker: Option<String>,
|
||||
pub annotation: Option<String>,
|
||||
/// Posición relativa al wheel (en píxeles desde su top-left).
|
||||
pub local_x: f32,
|
||||
pub local_y: f32,
|
||||
}
|
||||
|
||||
impl Default for CanvasState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -120,6 +142,7 @@ impl Default for CanvasState {
|
||||
view_rotation_deg: 0.0,
|
||||
time_offset_minutes: 0,
|
||||
layer_visibility: HashMap::new(),
|
||||
hover: None,
|
||||
drag_jog: None,
|
||||
}
|
||||
}
|
||||
@@ -250,6 +273,81 @@ impl AstrologyCanvas {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Hit-test sobre los body glyphs activos. Recibe la posición del
|
||||
/// mouse en window coords y los bounds del canvas (wheel). Para
|
||||
/// cada Glyph con LayerKind == Bodies u Outer en el RenderModel
|
||||
/// actual, calcula su posición pintada y mide distancia. Si está
|
||||
/// dentro de `threshold_px`, actualiza `state.hover`.
|
||||
fn on_hover_check(
|
||||
&mut self,
|
||||
position: Point<Pixels>,
|
||||
bounds: Bounds<Pixels>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let CanvasMode::Wheel { render } = &self.state.mode else {
|
||||
if self.state.hover.take().is_some() {
|
||||
cx.notify();
|
||||
}
|
||||
return;
|
||||
};
|
||||
let (cx_px, cy_px) = bounds_center(bounds);
|
||||
let mx: f32 = position.x.into();
|
||||
let my: f32 = position.y.into();
|
||||
let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
|
||||
let radii = Radii::from_outer(r_outer);
|
||||
let asc = render.ascendant_deg;
|
||||
let rot = self.state.view_rotation_deg;
|
||||
let threshold = 14.0_f32;
|
||||
|
||||
let mut best: Option<(f32, HoverInfo)> = None;
|
||||
for layer in &render.layers {
|
||||
let ring = match layer.kind {
|
||||
LayerKind::Bodies => radii.body_ring(&layer.module_id),
|
||||
LayerKind::Outer if OUTER_RING_MODULES.contains(&layer.module_id.as_str()) => {
|
||||
radii.transits
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
for g in &layer.glyphs {
|
||||
let (gx, gy) = polar_to_screen(g.deg, asc, rot, ring);
|
||||
let dx = mx - (cx_px + gx);
|
||||
let dy = my - (cy_px + gy);
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
if dist > threshold {
|
||||
continue;
|
||||
}
|
||||
if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
|
||||
let ox: f32 = bounds.origin.x.into();
|
||||
let oy: f32 = bounds.origin.y.into();
|
||||
best = Some((
|
||||
dist,
|
||||
HoverInfo {
|
||||
module_id: layer.module_id.clone(),
|
||||
symbol: g.symbol.clone(),
|
||||
deg: g.deg,
|
||||
house: g.house,
|
||||
retrograde: g.retrograde,
|
||||
dignity_marker: g.dignity_marker.clone(),
|
||||
annotation: g.annotation.clone(),
|
||||
local_x: cx_px + gx - ox,
|
||||
local_y: cy_px + gy - oy,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let new_hover = best.map(|(_, h)| h);
|
||||
let changed = match (&self.state.hover, &new_hover) {
|
||||
(Some(a), Some(b)) => a.symbol != b.symbol || a.module_id != b.module_id,
|
||||
(None, None) => false,
|
||||
_ => true,
|
||||
};
|
||||
if changed {
|
||||
self.state.hover = new_hover;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn on_jog_up(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(jog) = self.state.drag_jog.take() else {
|
||||
return;
|
||||
@@ -325,6 +423,7 @@ impl Render for AstrologyCanvas {
|
||||
self.state.view_rotation_deg,
|
||||
self.state.time_offset_minutes,
|
||||
&self.state.layer_visibility,
|
||||
self.state.hover.as_ref(),
|
||||
entity,
|
||||
),
|
||||
CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items),
|
||||
@@ -421,6 +520,7 @@ fn render_wheel(
|
||||
view_rotation_deg: f32,
|
||||
time_offset_minutes: i64,
|
||||
layer_visibility: &HashMap<LayerKind, bool>,
|
||||
hover: Option<&HoverInfo>,
|
||||
entity: gpui::Entity<AstrologyCanvas>,
|
||||
) -> gpui::Div {
|
||||
let asc = render.ascendant_deg;
|
||||
@@ -471,10 +571,19 @@ fn render_wheel(
|
||||
});
|
||||
let entity_m = entity_for_canvas.clone();
|
||||
window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
|
||||
if !ev.dragging() {
|
||||
return;
|
||||
if ev.dragging() {
|
||||
entity_m.update(cx, |this, cx| this.on_jog_move(ev.position, bounds, cx));
|
||||
} else if bounds.contains(&ev.position) {
|
||||
// Mouse hover sin drag: hit-test sobre los body
|
||||
// glyphs para el tooltip.
|
||||
entity_m.update(cx, |this, cx| this.on_hover_check(ev.position, bounds, cx));
|
||||
} else {
|
||||
entity_m.update(cx, |this, cx| {
|
||||
if this.state.hover.take().is_some() {
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
}
|
||||
entity_m.update(cx, |this, cx| this.on_jog_move(ev.position, bounds, cx));
|
||||
});
|
||||
let entity_u = entity_for_canvas.clone();
|
||||
window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
|
||||
@@ -561,11 +670,13 @@ fn render_wheel(
|
||||
for g in &layer.glyphs {
|
||||
let (x, y) = polar_to_screen(g.deg, asc, rot_offset, ring);
|
||||
let color = with_alpha(planet_color(palette, &g.symbol), alpha);
|
||||
let glyph_text = if g.retrograde {
|
||||
format!("{}ᴿ", planet_unicode(&g.symbol))
|
||||
} else {
|
||||
planet_unicode(&g.symbol).into()
|
||||
};
|
||||
let mut glyph_text = planet_unicode(&g.symbol).to_string();
|
||||
if g.retrograde {
|
||||
glyph_text.push('ᴿ');
|
||||
}
|
||||
if let Some(marker) = &g.dignity_marker {
|
||||
glyph_text.push_str(marker);
|
||||
}
|
||||
wheel = wheel.child(centered_glyph(
|
||||
cx_center + x,
|
||||
cy_center + y,
|
||||
@@ -607,6 +718,50 @@ fn render_wheel(
|
||||
}
|
||||
}
|
||||
|
||||
// Tooltip absoluto sobre el cuerpo hovered. Aparece arriba-derecha
|
||||
// del planeta, con offset pequeño para no taparlo. Texto:
|
||||
// "<unicode> <signo grado>° · Casa N · módulo · retrógrado?"
|
||||
if let Some(hov) = hover {
|
||||
let sign_idx = ((hov.deg / 30.0).floor() as usize) % 12;
|
||||
let sign_name = SIGN_NAMES_ES[sign_idx];
|
||||
let deg_in_sign = hov.deg - (sign_idx as f32) * 30.0;
|
||||
let mut text = format!(
|
||||
"{} {} · {:.1}°",
|
||||
planet_unicode(&hov.symbol),
|
||||
sign_name,
|
||||
deg_in_sign,
|
||||
);
|
||||
if let Some(h) = hov.house {
|
||||
text.push_str(&format!(" · Casa {}", h));
|
||||
}
|
||||
if hov.retrograde {
|
||||
text.push_str(" · ℞");
|
||||
}
|
||||
if let Some(m) = &hov.dignity_marker {
|
||||
text.push_str(&format!(" · {}", m));
|
||||
}
|
||||
if hov.module_id != "natal" {
|
||||
text.push_str(&format!(" · {}", hov.module_id));
|
||||
}
|
||||
let tip_x = (hov.local_x + 14.0).min(WHEEL_SIZE - 220.0).max(8.0);
|
||||
let tip_y = (hov.local_y - 28.0).max(8.0);
|
||||
wheel = wheel.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left(px(tip_x))
|
||||
.top(px(tip_y))
|
||||
.px(px(8.0))
|
||||
.py(px(4.0))
|
||||
.rounded(px(6.0))
|
||||
.bg(theme.bg_panel_alt.clone())
|
||||
.border_1()
|
||||
.border_color(palette.angle_highlight)
|
||||
.text_size(px(11.0))
|
||||
.text_color(theme.fg_text)
|
||||
.child(SharedString::from(text)),
|
||||
);
|
||||
}
|
||||
|
||||
// Labels ASC/MC/DESC/IC en el perímetro. Texto pequeño en el
|
||||
// margen exterior (radius * 1.05) para que no se monte con los
|
||||
// glifos de los signos. Color angle_highlight para que el ojo los
|
||||
@@ -652,6 +807,34 @@ fn render_wheel(
|
||||
} else {
|
||||
header
|
||||
};
|
||||
// Botón export SVG — pequeño, alineado a la derecha del title.
|
||||
let export_btn = div()
|
||||
.id("tts-canvas-export-svg")
|
||||
.px(px(10.0))
|
||||
.py(px(3.0))
|
||||
.rounded(px(4.0))
|
||||
.bg(theme.bg_button())
|
||||
.hover(|s| s.bg(theme.bg_button_hover()))
|
||||
.border_1()
|
||||
.border_color(theme.border)
|
||||
.text_size(px(10.0))
|
||||
.text_color(theme.fg_text)
|
||||
.child("⬇ SVG")
|
||||
.on_click({
|
||||
let entity_e = entity.clone();
|
||||
move |_: &gpui::ClickEvent, _w, cx: &mut gpui::App| {
|
||||
entity_e.update(cx, |_this, cx| {
|
||||
cx.emit(CanvasEvent::ExportSvgRequested);
|
||||
});
|
||||
}
|
||||
});
|
||||
let header = div()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.items_center()
|
||||
.gap(px(12.0))
|
||||
.child(header)
|
||||
.child(export_btn);
|
||||
|
||||
let offset_label = format_offset(time_offset_minutes);
|
||||
let offset_color = if time_offset_minutes == 0 {
|
||||
@@ -706,6 +889,48 @@ fn render_wheel(
|
||||
footer = footer.child(b);
|
||||
}
|
||||
|
||||
// Lista textual de aspectos (top 12 por orb). Compacta, en grid
|
||||
// de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos
|
||||
// computados.
|
||||
if !render.aspect_summary.is_empty() {
|
||||
let mut grid = div()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.flex_wrap()
|
||||
.gap(px(10.0))
|
||||
.max_w(px(WHEEL_SIZE + 80.0))
|
||||
.justify_center();
|
||||
for ap in render.aspect_summary.iter().take(12) {
|
||||
let kind_sym = aspect_unicode(&ap.kind);
|
||||
let line = format!(
|
||||
"{} {} {} · {:.1}°{}",
|
||||
planet_unicode(&ap.from_body),
|
||||
kind_sym,
|
||||
planet_unicode(&ap.to_body),
|
||||
ap.orb_deg,
|
||||
match ap.applying {
|
||||
Some(true) => " A",
|
||||
Some(false) => " S",
|
||||
None => "",
|
||||
}
|
||||
);
|
||||
let prefix = if ap.module_id == "natal" {
|
||||
String::new()
|
||||
} else {
|
||||
format!("[{}] ", ap.module_id)
|
||||
};
|
||||
grid = grid.child(
|
||||
div()
|
||||
.px(px(6.0))
|
||||
.py(px(2.0))
|
||||
.text_size(px(11.0))
|
||||
.text_color(aspect_color(palette, &ap.kind))
|
||||
.child(SharedString::from(format!("{}{}", prefix, line))),
|
||||
);
|
||||
}
|
||||
footer = footer.child(grid);
|
||||
}
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
@@ -1289,6 +1514,38 @@ fn sign_unicode(name: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
const SIGN_NAMES_ES: [&str; 12] = [
|
||||
"Aries",
|
||||
"Tauro",
|
||||
"Géminis",
|
||||
"Cáncer",
|
||||
"Leo",
|
||||
"Virgo",
|
||||
"Libra",
|
||||
"Escorpio",
|
||||
"Sagitario",
|
||||
"Capricornio",
|
||||
"Acuario",
|
||||
"Piscis",
|
||||
];
|
||||
|
||||
fn aspect_unicode(kind: &str) -> &'static str {
|
||||
match kind {
|
||||
"conjunction" => "☌",
|
||||
"opposition" => "☍",
|
||||
"trine" => "△",
|
||||
"square" => "□",
|
||||
"sextile" => "⚹",
|
||||
"quincunx" => "⚻",
|
||||
"semi_sextile" => "⚺",
|
||||
"semi_square" => "∠",
|
||||
"sesquiquadrate" => "⚼",
|
||||
"quintile" => "Q",
|
||||
"biquintile" => "bQ",
|
||||
_ => "·",
|
||||
}
|
||||
}
|
||||
|
||||
fn planet_unicode(name: &str) -> &'static str {
|
||||
match name {
|
||||
"sun" => "☉",
|
||||
|
||||
Reference in New Issue
Block a user