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:
@@ -33,6 +33,7 @@ use tahuantinsuyu_canvas::{
|
|||||||
};
|
};
|
||||||
use tahuantinsuyu_engine::{
|
use tahuantinsuyu_engine::{
|
||||||
LayerKind, NatalOptions, OUTER_RING_MODULES, PipelineRequest, compose_with_options,
|
LayerKind, NatalOptions, OUTER_RING_MODULES, PipelineRequest, compose_with_options,
|
||||||
|
svg_export,
|
||||||
};
|
};
|
||||||
use tahuantinsuyu_model::{Chart, ChartId, ModuleState, TreeSelection};
|
use tahuantinsuyu_model::{Chart, ChartId, ModuleState, TreeSelection};
|
||||||
use tahuantinsuyu_panel::{ChartOption, ControlPanel, PanelEvent};
|
use tahuantinsuyu_panel::{ChartOption, ControlPanel, PanelEvent};
|
||||||
@@ -308,6 +309,7 @@ impl Shell {
|
|||||||
show_majors: read_bool("aspect_majors", true),
|
show_majors: read_bool("aspect_majors", true),
|
||||||
show_minors: read_bool("aspect_minors", false),
|
show_minors: read_bool("aspect_minors", false),
|
||||||
orb_multiplier: read_f64("orb_multiplier", 1.0),
|
orb_multiplier: read_f64("orb_multiplier", 1.0),
|
||||||
|
show_dignities: read_bool("show_dignities", false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +497,53 @@ impl Shell {
|
|||||||
CanvasEvent::ChartRequested(_) => {
|
CanvasEvent::ChartRequested(_) => {
|
||||||
// Fase 7: doble click sobre un thumbnail abre la carta.
|
// Fase 7: doble click sobre un thumbnail abre la carta.
|
||||||
}
|
}
|
||||||
|
CanvasEvent::ExportSvgRequested => {
|
||||||
|
self.export_current_to_svg();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recompone la carta actual + escribe el SVG a un archivo en
|
||||||
|
/// `$XDG_DATA_HOME/tahuantinsuyu/exports/<label>_<short_id>.svg`.
|
||||||
|
/// Logea la ruta a stderr — futuro: file save dialog GPUI.
|
||||||
|
fn export_current_to_svg(&self) {
|
||||||
|
let Some(chart) = self.current_chart.as_ref() else {
|
||||||
|
eprintln!("[shell] export svg: sin carta activa");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let requests = self.build_requests();
|
||||||
|
let natal_options = self.build_natal_options();
|
||||||
|
let render = match compose_with_options(
|
||||||
|
chart,
|
||||||
|
self.current_offset_minutes,
|
||||||
|
&requests,
|
||||||
|
&natal_options,
|
||||||
|
) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[shell] export svg compose: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let svg = svg_export::render_to_svg(&render);
|
||||||
|
let dir = directories::ProjectDirs::from("net", "gioser", "tahuantinsuyu")
|
||||||
|
.map(|d| d.data_dir().join("exports"))
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||||
|
eprintln!("[shell] mkdir {:?}: {}", dir, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let safe_label: String = chart
|
||||||
|
.label
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
||||||
|
.collect();
|
||||||
|
let short = format!("{}", chart.id).chars().take(8).collect::<String>();
|
||||||
|
let path = dir.join(format!("{}_{}.svg", safe_label, short));
|
||||||
|
if let Err(e) = std::fs::write(&path, svg) {
|
||||||
|
eprintln!("[shell] write {:?}: {}", path, e);
|
||||||
|
} else {
|
||||||
|
eprintln!("[shell] SVG exportado → {}", path.display());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ pub enum CanvasEvent {
|
|||||||
/// El usuario togggleó una capa via hotkey — el panel debería
|
/// El usuario togggleó una capa via hotkey — el panel debería
|
||||||
/// reflejarlo si quisiera mantenerse en sync.
|
/// reflejarlo si quisiera mantenerse en sync.
|
||||||
LayerVisibilityChanged { kind: LayerKind, visible: bool },
|
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,
|
pub time_offset_minutes: i64,
|
||||||
/// 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>,
|
||||||
|
/// Planeta hovered actualmente (para tooltip). `None` cuando el
|
||||||
|
/// mouse no está sobre ningún cuerpo.
|
||||||
|
pub hover: Option<HoverInfo>,
|
||||||
drag_jog: Option<JogDragState>,
|
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 {
|
impl Default for CanvasState {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -120,6 +142,7 @@ impl Default for CanvasState {
|
|||||||
view_rotation_deg: 0.0,
|
view_rotation_deg: 0.0,
|
||||||
time_offset_minutes: 0,
|
time_offset_minutes: 0,
|
||||||
layer_visibility: HashMap::new(),
|
layer_visibility: HashMap::new(),
|
||||||
|
hover: None,
|
||||||
drag_jog: None,
|
drag_jog: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,6 +273,81 @@ impl AstrologyCanvas {
|
|||||||
cx.notify();
|
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>) {
|
fn on_jog_up(&mut self, cx: &mut Context<Self>) {
|
||||||
let Some(jog) = self.state.drag_jog.take() else {
|
let Some(jog) = self.state.drag_jog.take() else {
|
||||||
return;
|
return;
|
||||||
@@ -325,6 +423,7 @@ impl Render for AstrologyCanvas {
|
|||||||
self.state.view_rotation_deg,
|
self.state.view_rotation_deg,
|
||||||
self.state.time_offset_minutes,
|
self.state.time_offset_minutes,
|
||||||
&self.state.layer_visibility,
|
&self.state.layer_visibility,
|
||||||
|
self.state.hover.as_ref(),
|
||||||
entity,
|
entity,
|
||||||
),
|
),
|
||||||
CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items),
|
CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items),
|
||||||
@@ -421,6 +520,7 @@ fn render_wheel(
|
|||||||
view_rotation_deg: f32,
|
view_rotation_deg: f32,
|
||||||
time_offset_minutes: i64,
|
time_offset_minutes: i64,
|
||||||
layer_visibility: &HashMap<LayerKind, bool>,
|
layer_visibility: &HashMap<LayerKind, bool>,
|
||||||
|
hover: Option<&HoverInfo>,
|
||||||
entity: gpui::Entity<AstrologyCanvas>,
|
entity: gpui::Entity<AstrologyCanvas>,
|
||||||
) -> gpui::Div {
|
) -> gpui::Div {
|
||||||
let asc = render.ascendant_deg;
|
let asc = render.ascendant_deg;
|
||||||
@@ -471,10 +571,19 @@ fn render_wheel(
|
|||||||
});
|
});
|
||||||
let entity_m = entity_for_canvas.clone();
|
let entity_m = entity_for_canvas.clone();
|
||||||
window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
|
window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
|
||||||
if !ev.dragging() {
|
if ev.dragging() {
|
||||||
return;
|
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();
|
let entity_u = entity_for_canvas.clone();
|
||||||
window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
|
window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
|
||||||
@@ -561,11 +670,13 @@ fn render_wheel(
|
|||||||
for g in &layer.glyphs {
|
for g in &layer.glyphs {
|
||||||
let (x, y) = polar_to_screen(g.deg, asc, rot_offset, ring);
|
let (x, y) = polar_to_screen(g.deg, asc, rot_offset, ring);
|
||||||
let color = with_alpha(planet_color(palette, &g.symbol), alpha);
|
let color = with_alpha(planet_color(palette, &g.symbol), alpha);
|
||||||
let glyph_text = if g.retrograde {
|
let mut glyph_text = planet_unicode(&g.symbol).to_string();
|
||||||
format!("{}ᴿ", planet_unicode(&g.symbol))
|
if g.retrograde {
|
||||||
} else {
|
glyph_text.push('ᴿ');
|
||||||
planet_unicode(&g.symbol).into()
|
}
|
||||||
};
|
if let Some(marker) = &g.dignity_marker {
|
||||||
|
glyph_text.push_str(marker);
|
||||||
|
}
|
||||||
wheel = wheel.child(centered_glyph(
|
wheel = wheel.child(centered_glyph(
|
||||||
cx_center + x,
|
cx_center + x,
|
||||||
cy_center + y,
|
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
|
// 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
|
// margen exterior (radius * 1.05) para que no se monte con los
|
||||||
// glifos de los signos. Color angle_highlight para que el ojo los
|
// glifos de los signos. Color angle_highlight para que el ojo los
|
||||||
@@ -652,6 +807,34 @@ fn render_wheel(
|
|||||||
} else {
|
} else {
|
||||||
header
|
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_label = format_offset(time_offset_minutes);
|
||||||
let offset_color = if time_offset_minutes == 0 {
|
let offset_color = if time_offset_minutes == 0 {
|
||||||
@@ -706,6 +889,48 @@ fn render_wheel(
|
|||||||
footer = footer.child(b);
|
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()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.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 {
|
fn planet_unicode(name: &str) -> &'static str {
|
||||||
match name {
|
match name {
|
||||||
"sun" => "☉",
|
"sun" => "☉",
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ use eternal_sky::{Ayanamsha, Body, EphemerisSession, Instant as ESInstant, Obser
|
|||||||
|
|
||||||
use tahuantinsuyu_model::{Chart, HouseSystem, StoredChartConfig, Zodiac};
|
use tahuantinsuyu_model::{Chart, HouseSystem, StoredChartConfig, Zodiac};
|
||||||
|
|
||||||
use crate::{EngineError, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta, RenderModel};
|
use crate::dignity::essential_dignity;
|
||||||
|
use crate::{
|
||||||
|
AspectSummary, EngineError, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta,
|
||||||
|
RenderModel,
|
||||||
|
};
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Sesión global cacheada
|
// Sesión global cacheada
|
||||||
@@ -259,6 +263,10 @@ pub fn compose(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut render = build_render_model(chart, &natal, &aspects, t0);
|
let mut render = build_render_model(chart, &natal, &aspects, t0);
|
||||||
|
if natal_options.show_dignities {
|
||||||
|
annotate_dignities(&natal, &mut render);
|
||||||
|
}
|
||||||
|
populate_natal_aspect_summary(&aspects, &mut render);
|
||||||
|
|
||||||
for req in requests {
|
for req in requests {
|
||||||
match req {
|
match req {
|
||||||
@@ -346,6 +354,7 @@ fn build_transit_overlay(
|
|||||||
annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
|
annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
|
||||||
retrograde: p.longitude_rate_rad_per_day < 0.0,
|
retrograde: p.longitude_rate_rad_per_day < 0.0,
|
||||||
house: None,
|
house: None,
|
||||||
|
dignity_marker: None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
render.layers.push(Layer {
|
render.layers.push(Layer {
|
||||||
@@ -385,6 +394,7 @@ fn build_transit_overlay(
|
|||||||
geometry: Geometry::Lines(cross_lines),
|
geometry: Geometry::Lines(cross_lines),
|
||||||
glyphs: Vec::new(),
|
glyphs: Vec::new(),
|
||||||
});
|
});
|
||||||
|
populate_cross_aspect_summary(&cross, "transit", render);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +422,7 @@ fn build_progression_overlay(
|
|||||||
annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
|
annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
|
||||||
retrograde: p.longitude_rate_rad_per_day < 0.0,
|
retrograde: p.longitude_rate_rad_per_day < 0.0,
|
||||||
house: Some(p.house_number),
|
house: Some(p.house_number),
|
||||||
|
dignity_marker: None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
render.layers.push(Layer {
|
render.layers.push(Layer {
|
||||||
@@ -452,6 +463,7 @@ fn build_progression_overlay(
|
|||||||
geometry: Geometry::Lines(cross_lines),
|
geometry: Geometry::Lines(cross_lines),
|
||||||
glyphs: Vec::new(),
|
glyphs: Vec::new(),
|
||||||
});
|
});
|
||||||
|
populate_cross_aspect_summary(&cross, "progression", render);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,6 +490,7 @@ fn build_solar_arc_overlay(
|
|||||||
annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
|
annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
|
||||||
retrograde: p.longitude_rate_rad_per_day < 0.0,
|
retrograde: p.longitude_rate_rad_per_day < 0.0,
|
||||||
house: Some(p.house_number),
|
house: Some(p.house_number),
|
||||||
|
dignity_marker: None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
render.layers.push(Layer {
|
render.layers.push(Layer {
|
||||||
@@ -517,6 +530,7 @@ fn build_solar_arc_overlay(
|
|||||||
geometry: Geometry::Lines(cross_lines),
|
geometry: Geometry::Lines(cross_lines),
|
||||||
glyphs: Vec::new(),
|
glyphs: Vec::new(),
|
||||||
});
|
});
|
||||||
|
populate_cross_aspect_summary(&cross, "solar_arc", render);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,6 +554,7 @@ fn build_synastry_overlay(
|
|||||||
annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
|
annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
|
||||||
retrograde: p.longitude_rate_rad_per_day < 0.0,
|
retrograde: p.longitude_rate_rad_per_day < 0.0,
|
||||||
house: Some(p.house_number),
|
house: Some(p.house_number),
|
||||||
|
dignity_marker: None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
render.layers.push(Layer {
|
render.layers.push(Layer {
|
||||||
@@ -579,6 +594,7 @@ fn build_synastry_overlay(
|
|||||||
geometry: Geometry::Lines(cross_lines),
|
geometry: Geometry::Lines(cross_lines),
|
||||||
glyphs: Vec::new(),
|
glyphs: Vec::new(),
|
||||||
});
|
});
|
||||||
|
populate_cross_aspect_summary(&cross, "synastry", render);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,6 +660,7 @@ fn build_planetary_return_overlay(
|
|||||||
annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
|
annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
|
||||||
retrograde: p.longitude_rate_rad_per_day < 0.0,
|
retrograde: p.longitude_rate_rad_per_day < 0.0,
|
||||||
house: Some(p.house_number),
|
house: Some(p.house_number),
|
||||||
|
dignity_marker: None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
render.layers.push(Layer {
|
render.layers.push(Layer {
|
||||||
@@ -683,6 +700,7 @@ fn build_planetary_return_overlay(
|
|||||||
geometry: Geometry::Lines(cross_lines),
|
geometry: Geometry::Lines(cross_lines),
|
||||||
glyphs: Vec::new(),
|
glyphs: Vec::new(),
|
||||||
});
|
});
|
||||||
|
populate_cross_aspect_summary(&cross, "planetary_return", render);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,6 +735,7 @@ fn build_render_model(
|
|||||||
annotation: None,
|
annotation: None,
|
||||||
retrograde: false,
|
retrograde: false,
|
||||||
house: None,
|
house: None,
|
||||||
|
dignity_marker: None,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
};
|
};
|
||||||
@@ -745,6 +764,7 @@ fn build_render_model(
|
|||||||
annotation: None,
|
annotation: None,
|
||||||
retrograde: false,
|
retrograde: false,
|
||||||
house: Some((i as u8) + 1),
|
house: Some((i as u8) + 1),
|
||||||
|
dignity_marker: None,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
};
|
};
|
||||||
@@ -764,6 +784,7 @@ fn build_render_model(
|
|||||||
// depender del wrapper.
|
// depender del wrapper.
|
||||||
retrograde: p.longitude_rate_rad_per_day < 0.0,
|
retrograde: p.longitude_rate_rad_per_day < 0.0,
|
||||||
house: Some(p.house_number),
|
house: Some(p.house_number),
|
||||||
|
dignity_marker: None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let bodies = Layer {
|
let bodies = Layer {
|
||||||
@@ -838,6 +859,7 @@ fn build_render_model(
|
|||||||
imum_coeli_deg,
|
imum_coeli_deg,
|
||||||
layers: vec![sign_dial, houses, bodies, aspects_layer],
|
layers: vec![sign_dial, houses, bodies, aspects_layer],
|
||||||
overlays: Vec::new(),
|
overlays: Vec::new(),
|
||||||
|
aspect_summary: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,6 +891,67 @@ fn push_overlay_meta(render: &mut RenderModel, module_id: &str, label: String) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decora cada Glyph de Bodies (module_id="natal") con su dignity
|
||||||
|
/// marker en `glyph.dignity_marker`. Usa `essential_dignity(body, sign)`
|
||||||
|
/// — los cuerpos modernos quedan sin marker.
|
||||||
|
fn annotate_dignities(natal: &NatalChart, render: &mut RenderModel) {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
let mut by_symbol: HashMap<&'static str, &'static str> = HashMap::new();
|
||||||
|
for p in &natal.placements {
|
||||||
|
let sign_idx = (p.longitude.longitude_deg() / 30.0).floor() as u8 % 12;
|
||||||
|
if let Some(d) = essential_dignity(p.body, sign_idx) {
|
||||||
|
by_symbol.insert(body_symbol(p.body), d.marker());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for layer in render.layers.iter_mut() {
|
||||||
|
if matches!(layer.kind, LayerKind::Bodies) && layer.module_id == "natal" {
|
||||||
|
for g in layer.glyphs.iter_mut() {
|
||||||
|
if let Some(marker) = by_symbol.get(g.symbol.as_str()) {
|
||||||
|
g.dignity_marker = Some((*marker).to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn populate_natal_aspect_summary(aspects: &[Aspect], render: &mut RenderModel) {
|
||||||
|
for a in aspects {
|
||||||
|
render.aspect_summary.push(AspectSummary {
|
||||||
|
module_id: "natal".into(),
|
||||||
|
from_body: body_symbol(a.a).into(),
|
||||||
|
to_body: body_symbol(a.b).into(),
|
||||||
|
kind: aspect_kind_id(a.kind).into(),
|
||||||
|
orb_deg: a.orb_abs_deg(),
|
||||||
|
applying: Some(a.applying),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sort_aspect_summary(render);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn populate_cross_aspect_summary(
|
||||||
|
cross: &[eternal_astrology::SynastryAspect],
|
||||||
|
module_id: &str,
|
||||||
|
render: &mut RenderModel,
|
||||||
|
) {
|
||||||
|
for a in cross {
|
||||||
|
render.aspect_summary.push(AspectSummary {
|
||||||
|
module_id: module_id.to_string(),
|
||||||
|
from_body: body_symbol(a.person_a_body).into(),
|
||||||
|
to_body: body_symbol(a.person_b_body).into(),
|
||||||
|
kind: aspect_kind_id(a.kind).into(),
|
||||||
|
orb_deg: a.orb_abs_deg(),
|
||||||
|
applying: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sort_aspect_summary(render);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sort_aspect_summary(render: &mut RenderModel) {
|
||||||
|
render
|
||||||
|
.aspect_summary
|
||||||
|
.sort_by(|x, y| x.orb_deg.partial_cmp(&y.orb_deg).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
}
|
||||||
|
|
||||||
/// Mapea el orb absoluto a una opacidad — los aspectos más exactos se
|
/// Mapea el orb absoluto a una opacidad — los aspectos más exactos se
|
||||||
/// pintan más fuerte, los flojos casi se desvanecen.
|
/// pintan más fuerte, los flojos casi se desvanecen.
|
||||||
fn orb_to_opacity(orb_deg: f64, kind: EAspectKind) -> f32 {
|
fn orb_to_opacity(orb_deg: f64, kind: EAspectKind) -> f32 {
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//! Dignidades esenciales clásicas — tabla data-only.
|
||||||
|
//!
|
||||||
|
//! Cada planeta tradicional tiene cuatro estatus posibles según el
|
||||||
|
//! signo en el que cae:
|
||||||
|
//!
|
||||||
|
//! - **Domicilio** (rulership) — el signo del que es regente.
|
||||||
|
//! - **Exaltación** — un signo "huésped" que le da fuerza extra.
|
||||||
|
//! - **Exilio** (detriment) — opuesto al domicilio, debilita.
|
||||||
|
//! - **Caída** (fall) — opuesto a la exaltación, debilita.
|
||||||
|
//!
|
||||||
|
//! Esta tabla usa las regencias **clásicas** (Aries=Marte, Escorpio=
|
||||||
|
//! Marte, Acuario=Saturno, Piscis=Júpiter) — los planetas modernos
|
||||||
|
//! (Urano/Neptuno/Plutón) no tienen regencia clásica por convención.
|
||||||
|
//! En una fase futura podemos exponer un toggle "regencias modernas"
|
||||||
|
//! que mapee Escorpio→Plutón, Acuario→Urano, Piscis→Neptuno.
|
||||||
|
|
||||||
|
use eternal_sky::Body;
|
||||||
|
|
||||||
|
/// Status de dignidad esencial de un cuerpo en un signo dado.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Dignity {
|
||||||
|
/// Domicilio. Marker `"+"`.
|
||||||
|
Rulership,
|
||||||
|
/// Exaltación. Marker `"·"`.
|
||||||
|
Exaltation,
|
||||||
|
/// Exilio. Marker `"−"`.
|
||||||
|
Detriment,
|
||||||
|
/// Caída. Marker `"*"`.
|
||||||
|
Fall,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dignity {
|
||||||
|
pub fn marker(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Dignity::Rulership => "+",
|
||||||
|
Dignity::Exaltation => "·",
|
||||||
|
Dignity::Detriment => "−",
|
||||||
|
Dignity::Fall => "*",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Devuelve el status de dignidad de `body` en `sign_index` (0..12,
|
||||||
|
/// Aries=0) o `None` si no aplica (sin dignidad / cuerpo moderno sin
|
||||||
|
/// regencia clásica).
|
||||||
|
pub fn essential_dignity(body: Body, sign_index: u8) -> Option<Dignity> {
|
||||||
|
let sign = sign_index % 12;
|
||||||
|
let opposite = (sign + 6) % 12;
|
||||||
|
|
||||||
|
// Rulership clásico — el "regente" del signo.
|
||||||
|
if rules_classical(body, sign) {
|
||||||
|
return Some(Dignity::Rulership);
|
||||||
|
}
|
||||||
|
// Detriment = el cuerpo gobierna el signo opuesto.
|
||||||
|
if rules_classical(body, opposite) {
|
||||||
|
return Some(Dignity::Detriment);
|
||||||
|
}
|
||||||
|
// Exaltación tabular.
|
||||||
|
if exalts_at(body) == Some(sign) {
|
||||||
|
return Some(Dignity::Exaltation);
|
||||||
|
}
|
||||||
|
// Caída = opuesto a la exaltación.
|
||||||
|
if exalts_at(body) == Some(opposite) {
|
||||||
|
return Some(Dignity::Fall);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Devuelve true si `body` gobierna `sign` (0=Aries..11=Pisces) en el
|
||||||
|
/// esquema clásico de 7 planetas.
|
||||||
|
fn rules_classical(body: Body, sign: u8) -> bool {
|
||||||
|
match (body, sign) {
|
||||||
|
// Sol: Leo (4)
|
||||||
|
(Body::Sun, 4) => true,
|
||||||
|
// Luna: Cancer (3)
|
||||||
|
(Body::Moon, 3) => true,
|
||||||
|
// Mercurio: Gemini (2), Virgo (5)
|
||||||
|
(Body::Mercury, 2) | (Body::Mercury, 5) => true,
|
||||||
|
// Venus: Taurus (1), Libra (6)
|
||||||
|
(Body::Venus, 1) | (Body::Venus, 6) => true,
|
||||||
|
// Marte: Aries (0), Scorpio (7)
|
||||||
|
(Body::Mars, 0) | (Body::Mars, 7) => true,
|
||||||
|
// Júpiter: Sagittarius (8), Pisces (11)
|
||||||
|
(Body::Jupiter, 8) | (Body::Jupiter, 11) => true,
|
||||||
|
// Saturno: Capricorn (9), Aquarius (10)
|
||||||
|
(Body::Saturn, 9) | (Body::Saturn, 10) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Devuelve el signo (0..12) donde el cuerpo exalta, o `None` si no
|
||||||
|
/// tiene exaltación clásica documentada.
|
||||||
|
fn exalts_at(body: Body) -> Option<u8> {
|
||||||
|
Some(match body {
|
||||||
|
Body::Sun => 0, // Aries
|
||||||
|
Body::Moon => 1, // Taurus
|
||||||
|
Body::Mercury => 5, // Virgo (algunas tradiciones la ponen acá)
|
||||||
|
Body::Venus => 11, // Pisces
|
||||||
|
Body::Mars => 9, // Capricorn
|
||||||
|
Body::Jupiter => 3, // Cancer
|
||||||
|
Body::Saturn => 6, // Libra
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rulership_examples() {
|
||||||
|
assert_eq!(essential_dignity(Body::Sun, 4), Some(Dignity::Rulership)); // Sol en Leo
|
||||||
|
assert_eq!(essential_dignity(Body::Moon, 3), Some(Dignity::Rulership)); // Luna en Cancer
|
||||||
|
assert_eq!(essential_dignity(Body::Mars, 7), Some(Dignity::Rulership)); // Marte en Scorpio
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detriment_examples() {
|
||||||
|
assert_eq!(essential_dignity(Body::Sun, 10), Some(Dignity::Detriment)); // Sol en Acuario
|
||||||
|
assert_eq!(essential_dignity(Body::Moon, 9), Some(Dignity::Detriment)); // Luna en Capricornio
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exaltation_examples() {
|
||||||
|
assert_eq!(essential_dignity(Body::Sun, 0), Some(Dignity::Exaltation)); // Sol en Aries
|
||||||
|
assert_eq!(essential_dignity(Body::Saturn, 6), Some(Dignity::Exaltation)); // Saturno en Libra
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fall_examples() {
|
||||||
|
assert_eq!(essential_dignity(Body::Sun, 6), Some(Dignity::Fall)); // Sol en Libra
|
||||||
|
assert_eq!(essential_dignity(Body::Saturn, 0), Some(Dignity::Fall)); // Saturno en Aries
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modern_planets_no_classical_dignity() {
|
||||||
|
assert_eq!(essential_dignity(Body::Uranus, 10), None);
|
||||||
|
assert_eq!(essential_dignity(Body::Neptune, 11), None);
|
||||||
|
assert_eq!(essential_dignity(Body::Pluto, 7), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,10 @@ pub use tahuantinsuyu_model::{Chart, ChartId, ChartKind};
|
|||||||
|
|
||||||
#[cfg(feature = "eternal-bridge")]
|
#[cfg(feature = "eternal-bridge")]
|
||||||
mod bridge;
|
mod bridge;
|
||||||
|
#[cfg(feature = "eternal-bridge")]
|
||||||
|
mod dignity;
|
||||||
|
#[cfg(feature = "eternal-bridge")]
|
||||||
|
pub mod svg_export;
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// RenderModel — lo que el canvas necesita pintar
|
// RenderModel — lo que el canvas necesita pintar
|
||||||
@@ -66,6 +70,11 @@ pub struct RenderModel {
|
|||||||
/// la pinta como badges en el footer.
|
/// la pinta como badges en el footer.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub overlays: Vec<OverlayMeta>,
|
pub overlays: Vec<OverlayMeta>,
|
||||||
|
/// Lista paralela a las LineSeg de aspectos — uno por aspecto
|
||||||
|
/// natal o cross. Ordenado por `orb_deg` ascendente (los más
|
||||||
|
/// cerrados primero). La UI lo usa para la lista textual.
|
||||||
|
#[serde(default)]
|
||||||
|
pub aspect_summary: Vec<AspectSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Etiqueta legible de un overlay para el footer del canvas. La engine
|
/// Etiqueta legible de un overlay para el footer del canvas. La engine
|
||||||
@@ -78,6 +87,26 @@ pub struct OverlayMeta {
|
|||||||
pub label: String,
|
pub label: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resumen textual de un aspecto para listas legibles. La engine lo
|
||||||
|
/// emite en paralelo con las `LineSeg` de la capa de aspectos, así
|
||||||
|
/// el canvas no tiene que re-derivar nombres de cuerpos desde grados.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AspectSummary {
|
||||||
|
/// Module al que pertenece — "natal", "transit", "synastry",
|
||||||
|
/// "progression", "solar_arc", "planetary_return".
|
||||||
|
pub module_id: String,
|
||||||
|
/// Identificador agnóstico del cuerpo "a" — "sun", "moon", etc.
|
||||||
|
pub from_body: String,
|
||||||
|
pub to_body: String,
|
||||||
|
/// Identificador del aspecto — "conjunction", "trine", etc.
|
||||||
|
pub kind: String,
|
||||||
|
pub orb_deg: f64,
|
||||||
|
/// `Some(true)` = applying, `Some(false)` = separating. `None` para
|
||||||
|
/// cross-aspects (sinastría/return) donde no se computa.
|
||||||
|
#[serde(default)]
|
||||||
|
pub applying: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Layer {
|
pub struct Layer {
|
||||||
pub module_id: String,
|
pub module_id: String,
|
||||||
@@ -135,7 +164,7 @@ pub struct PointMark {
|
|||||||
pub tag: String,
|
pub tag: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct Glyph {
|
pub struct Glyph {
|
||||||
/// Grado eclíptico [0, 360).
|
/// Grado eclíptico [0, 360).
|
||||||
pub deg: f32,
|
pub deg: f32,
|
||||||
@@ -148,6 +177,11 @@ pub struct Glyph {
|
|||||||
pub retrograde: bool,
|
pub retrograde: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub house: Option<u8>,
|
pub house: Option<u8>,
|
||||||
|
/// Marker de dignidad esencial, set solo cuando
|
||||||
|
/// `NatalOptions::show_dignities` está activo: `"+"` (domicilio),
|
||||||
|
/// `"·"` (exaltación), `"−"` (exilio), `"*"` (caída).
|
||||||
|
#[serde(default)]
|
||||||
|
pub dignity_marker: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -237,6 +271,10 @@ pub struct NatalOptions {
|
|||||||
/// Multiplicador uniforme sobre los orbes default. `1.0` = orbes
|
/// Multiplicador uniforme sobre los orbes default. `1.0` = orbes
|
||||||
/// modern_western; `0.5` = tight; `2.0` = wide.
|
/// modern_western; `0.5` = tight; `2.0` = wide.
|
||||||
pub orb_multiplier: f64,
|
pub orb_multiplier: f64,
|
||||||
|
/// Si `true`, anota cada cuerpo natal con su dignidad esencial
|
||||||
|
/// (domicilio +, exaltación ·, exilio −, caída *). El canvas lo
|
||||||
|
/// renderea como sufijo del glifo.
|
||||||
|
pub show_dignities: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for NatalOptions {
|
impl Default for NatalOptions {
|
||||||
@@ -245,6 +283,7 @@ impl Default for NatalOptions {
|
|||||||
show_majors: true,
|
show_majors: true,
|
||||||
show_minors: false,
|
show_minors: false,
|
||||||
orb_multiplier: 1.0,
|
orb_multiplier: 1.0,
|
||||||
|
show_dignities: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,6 +355,7 @@ pub fn compute_mock(chart: &Chart) -> RenderModel {
|
|||||||
annotation: None,
|
annotation: None,
|
||||||
retrograde: false,
|
retrograde: false,
|
||||||
house: None,
|
house: None,
|
||||||
|
dignity_marker: None,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
};
|
};
|
||||||
@@ -332,6 +372,7 @@ pub fn compute_mock(chart: &Chart) -> RenderModel {
|
|||||||
imum_coeli_deg: 90.0,
|
imum_coeli_deg: 90.0,
|
||||||
layers: vec![sign_dial],
|
layers: vec![sign_dial],
|
||||||
overlays: Vec::new(),
|
overlays: Vec::new(),
|
||||||
|
aspect_summary: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
//! Export del `RenderModel` a SVG.
|
||||||
|
//!
|
||||||
|
//! Genera un documento SVG standalone con la misma geometría que pinta
|
||||||
|
//! el canvas: anillos zodiacales, cusps, planetas, aspectos. El
|
||||||
|
//! resultado es escalable (imprimible a cualquier tamaño) y no requiere
|
||||||
|
//! la app GPUI para verse — cualquier visor de SVG sirve.
|
||||||
|
//!
|
||||||
|
//! Convención de coordenadas idéntica al canvas:
|
||||||
|
//! `screen_angle_deg = 180 - (longitude - ascendant)` con +y para abajo.
|
||||||
|
|
||||||
|
use std::f64::consts::PI;
|
||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
use crate::{Geometry, LayerKind, RenderModel};
|
||||||
|
|
||||||
|
/// Dimensiones default del viewport. Aspect ratio cuadrada.
|
||||||
|
const VIEWBOX: f64 = 800.0;
|
||||||
|
const MARGIN: f64 = 40.0;
|
||||||
|
|
||||||
|
/// Radios normalizados — espejan los de `tahuantinsuyu-canvas`.
|
||||||
|
const R_SIGN_OUTER: f64 = 1.00;
|
||||||
|
const R_SIGN_INNER: f64 = 0.88;
|
||||||
|
const R_TRANSITS: f64 = 0.82;
|
||||||
|
const R_HOUSES_OUTER: f64 = 0.78;
|
||||||
|
const R_HOUSES_INNER: f64 = 0.66;
|
||||||
|
const R_BODIES: f64 = 0.58;
|
||||||
|
const R_PROGRESSION: f64 = 0.48;
|
||||||
|
const R_SOLAR_ARC: f64 = 0.40;
|
||||||
|
const R_ASPECTS: f64 = 0.32;
|
||||||
|
|
||||||
|
/// Convierte el `RenderModel` a un documento SVG completo.
|
||||||
|
pub fn render_to_svg(render: &RenderModel) -> String {
|
||||||
|
let mut out = String::with_capacity(8192);
|
||||||
|
let r_outer = (VIEWBOX - MARGIN * 2.0) / 2.0;
|
||||||
|
let cx = VIEWBOX / 2.0;
|
||||||
|
let cy = VIEWBOX / 2.0;
|
||||||
|
let asc = render.ascendant_deg as f64;
|
||||||
|
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {0} {1}" width="{0}" height="{1}" font-family="serif" text-anchor="middle" dominant-baseline="central">"#,
|
||||||
|
VIEWBOX, VIEWBOX
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Fondo + título.
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
r##" <rect x="0" y="0" width="{0}" height="{0}" fill="#fdfaf3"/>
|
||||||
|
<text x="{cx}" y="20" font-size="14" fill="#2a2620">{title}</text>"##,
|
||||||
|
VIEWBOX,
|
||||||
|
cx = cx,
|
||||||
|
title = escape_xml(&render.title)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Anillos base.
|
||||||
|
for r in [R_SIGN_OUTER, R_SIGN_INNER, R_HOUSES_OUTER, R_HOUSES_INNER] {
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
r##" <circle cx="{cx}" cy="{cy}" r="{r}" fill="none" stroke="#a89572" stroke-width="0.6"/>"##,
|
||||||
|
r = r * r_outer
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cusps del zodíaco cada 30°.
|
||||||
|
for i in 0..12 {
|
||||||
|
let lon = (i as f64) * 30.0;
|
||||||
|
let (x1, y1) = polar(lon, asc, R_SIGN_INNER * r_outer, cx, cy);
|
||||||
|
let (x2, y2) = polar(lon, asc, R_SIGN_OUTER * r_outer, cx, cy);
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
r##" <line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="#a89572" stroke-width="0.5"/>"##,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glifos de signos a media-altura del dial.
|
||||||
|
let sign_mid = (R_SIGN_OUTER + R_SIGN_INNER) / 2.0;
|
||||||
|
for layer in &render.layers {
|
||||||
|
if matches!(layer.kind, LayerKind::SignDial) {
|
||||||
|
for g in &layer.glyphs {
|
||||||
|
let (x, y) = polar(g.deg as f64, asc, sign_mid * r_outer, cx, cy);
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
r##" <text x="{x:.2}" y="{y:.2}" font-size="16" fill="#5a4830">{}</text>"##,
|
||||||
|
sign_unicode(&g.symbol)
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cusps de casas + énfasis Asc/IC/Desc/MC.
|
||||||
|
for layer in &render.layers {
|
||||||
|
if matches!(layer.kind, LayerKind::Houses) {
|
||||||
|
if let Geometry::Ring { cusps_deg } = &layer.geometry {
|
||||||
|
for (i, c) in cusps_deg.iter().enumerate() {
|
||||||
|
let is_angle = i == 0 || i == 3 || i == 6 || i == 9;
|
||||||
|
let (color, w) = if is_angle {
|
||||||
|
("#b8862e", 1.6)
|
||||||
|
} else {
|
||||||
|
("#9b8460", 0.5)
|
||||||
|
};
|
||||||
|
let (x1, y1) =
|
||||||
|
polar(*c as f64, asc, R_HOUSES_INNER * r_outer, cx, cy);
|
||||||
|
let (x2, y2) =
|
||||||
|
polar(*c as f64, asc, R_HOUSES_OUTER * r_outer, cx, cy);
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
r##" <line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="{color}" stroke-width="{w}"/>"##,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Líneas de aspectos. Para natal usamos un solo ring; para
|
||||||
|
// cross-aspects (transit/synastry/progression/solar_arc/...) los
|
||||||
|
// extremos van en rings distintos según el `module_id`.
|
||||||
|
for layer in &render.layers {
|
||||||
|
if !matches!(layer.kind, LayerKind::Aspects) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Geometry::Lines(segs) = &layer.geometry {
|
||||||
|
let (r_from, r_to) = aspect_radii(&layer.module_id);
|
||||||
|
for seg in segs {
|
||||||
|
let color = aspect_color_hex(&seg.kind);
|
||||||
|
let (x1, y1) = polar(seg.from_deg as f64, asc, r_from * r_outer, cx, cy);
|
||||||
|
let (x2, y2) = polar(seg.to_deg as f64, asc, r_to * r_outer, cx, cy);
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
r##" <line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="{color}" stroke-width="0.6" stroke-opacity="{op:.2}"/>"##,
|
||||||
|
op = seg.opacity
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glifos planetarios (natal + overlays). Cada uno en su ring.
|
||||||
|
for layer in &render.layers {
|
||||||
|
if !matches!(layer.kind, LayerKind::Bodies | LayerKind::Outer) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ring = body_ring_radius(&layer.module_id);
|
||||||
|
let size = if layer.module_id == "natal" { 18 } else { 14 };
|
||||||
|
for g in &layer.glyphs {
|
||||||
|
let (x, y) = polar(g.deg as f64, asc, ring * r_outer, cx, cy);
|
||||||
|
let glyph = planet_unicode(&g.symbol);
|
||||||
|
let suffix = match (g.retrograde, g.dignity_marker.as_deref()) {
|
||||||
|
(true, Some(m)) => format!("ᴿ{}", m),
|
||||||
|
(true, None) => "ᴿ".into(),
|
||||||
|
(false, Some(m)) => m.to_string(),
|
||||||
|
(false, None) => String::new(),
|
||||||
|
};
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
r##" <text x="{x:.2}" y="{y:.2}" font-size="{size}" fill="#1f1812">{glyph}{suffix}</text>"##
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Etiquetas ASC / MC / DESC / IC en el perímetro.
|
||||||
|
for (deg, label) in [
|
||||||
|
(asc, "ASC"),
|
||||||
|
(render.midheaven_deg as f64, "MC"),
|
||||||
|
(render.descendant_deg as f64, "DESC"),
|
||||||
|
(render.imum_coeli_deg as f64, "IC"),
|
||||||
|
] {
|
||||||
|
let (x, y) = polar(deg, asc, 1.06 * r_outer, cx, cy);
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
r##" <text x="{x:.2}" y="{y:.2}" font-size="10" fill="#b8862e">{label}</text>"##
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
writeln!(out, "</svg>").unwrap();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn polar(longitude_deg: f64, ascendant_deg: f64, radius: f64, cx: f64, cy: f64) -> (f64, f64) {
|
||||||
|
let deg = 180.0 - (longitude_deg - ascendant_deg);
|
||||||
|
let rad = deg * PI / 180.0;
|
||||||
|
(cx + radius * rad.cos(), cy + radius * rad.sin())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aspect_radii(module_id: &str) -> (f64, f64) {
|
||||||
|
if crate::OUTER_RING_MODULES.contains(&module_id) {
|
||||||
|
return (R_BODIES, R_TRANSITS);
|
||||||
|
}
|
||||||
|
match module_id {
|
||||||
|
"progression" => (R_BODIES, R_PROGRESSION),
|
||||||
|
"solar_arc" => (R_BODIES, R_SOLAR_ARC),
|
||||||
|
_ => (R_ASPECTS, R_ASPECTS),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn body_ring_radius(module_id: &str) -> f64 {
|
||||||
|
if crate::OUTER_RING_MODULES.contains(&module_id) {
|
||||||
|
return R_TRANSITS;
|
||||||
|
}
|
||||||
|
match module_id {
|
||||||
|
"progression" => R_PROGRESSION,
|
||||||
|
"solar_arc" => R_SOLAR_ARC,
|
||||||
|
_ => R_BODIES,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_unicode(name: &str) -> &'static str {
|
||||||
|
match name {
|
||||||
|
"aries" => "♈",
|
||||||
|
"taurus" => "♉",
|
||||||
|
"gemini" => "♊",
|
||||||
|
"cancer" => "♋",
|
||||||
|
"leo" => "♌",
|
||||||
|
"virgo" => "♍",
|
||||||
|
"libra" => "♎",
|
||||||
|
"scorpio" => "♏",
|
||||||
|
"sagittarius" => "♐",
|
||||||
|
"capricorn" => "♑",
|
||||||
|
"aquarius" => "♒",
|
||||||
|
"pisces" => "♓",
|
||||||
|
_ => "?",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn planet_unicode(name: &str) -> &'static str {
|
||||||
|
match name {
|
||||||
|
"sun" => "☉",
|
||||||
|
"moon" => "☽",
|
||||||
|
"mercury" => "☿",
|
||||||
|
"venus" => "♀",
|
||||||
|
"mars" => "♂",
|
||||||
|
"jupiter" => "♃",
|
||||||
|
"saturn" => "♄",
|
||||||
|
"uranus" => "♅",
|
||||||
|
"neptune" => "♆",
|
||||||
|
"pluto" => "♇",
|
||||||
|
"north_node" => "☊",
|
||||||
|
"south_node" => "☋",
|
||||||
|
"chiron" => "⚷",
|
||||||
|
"lilith" => "⚸",
|
||||||
|
"ceres" => "⚳",
|
||||||
|
"pallas" => "⚴",
|
||||||
|
"juno" => "⚵",
|
||||||
|
"vesta" => "⚶",
|
||||||
|
_ => "•",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aspect_color_hex(kind: &str) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
"conjunction" => "#b8862e",
|
||||||
|
"opposition" => "#a64a8a",
|
||||||
|
"trine" => "#3f7d57",
|
||||||
|
"square" => "#c64b2a",
|
||||||
|
"sextile" => "#3a6db5",
|
||||||
|
_ => "#8a7660",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_xml(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{compute_mock, ChartKind};
|
||||||
|
use tahuantinsuyu_model::{Chart, ContactId, StoredBirthData, StoredChartConfig};
|
||||||
|
|
||||||
|
fn sample_chart() -> Chart {
|
||||||
|
Chart {
|
||||||
|
id: tahuantinsuyu_model::ChartId::new(),
|
||||||
|
contact_id: ContactId::new(),
|
||||||
|
kind: ChartKind::Natal,
|
||||||
|
label: "Test".into(),
|
||||||
|
birth_data: StoredBirthData {
|
||||||
|
year: 1987,
|
||||||
|
month: 3,
|
||||||
|
day: 14,
|
||||||
|
hour: 5,
|
||||||
|
minute: 22,
|
||||||
|
second: 0.0,
|
||||||
|
tz_offset_minutes: -240,
|
||||||
|
latitude_deg: 10.0,
|
||||||
|
longitude_deg: -66.0,
|
||||||
|
altitude_m: 0.0,
|
||||||
|
time_certainty: Default::default(),
|
||||||
|
subject_name: None,
|
||||||
|
birthplace_label: None,
|
||||||
|
},
|
||||||
|
config: StoredChartConfig::default(),
|
||||||
|
related_chart_id: None,
|
||||||
|
created_at_ms: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn svg_well_formed_minimal() {
|
||||||
|
let render = compute_mock(&sample_chart());
|
||||||
|
let svg = render_to_svg(&render);
|
||||||
|
assert!(svg.starts_with("<?xml"));
|
||||||
|
assert!(svg.contains("<svg"));
|
||||||
|
assert!(svg.ends_with("</svg>\n"));
|
||||||
|
// Debe traer al menos un círculo de los rings base.
|
||||||
|
assert!(svg.contains("<circle "));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -239,6 +239,12 @@ pub mod natal {
|
|||||||
step: 0.25,
|
step: 0.25,
|
||||||
default: 1.0,
|
default: 1.0,
|
||||||
},
|
},
|
||||||
|
Control::Toggle {
|
||||||
|
key: "show_dignities".into(),
|
||||||
|
label: "Dignidades esenciales (+ · − *)".into(),
|
||||||
|
default: false,
|
||||||
|
hotkey: None,
|
||||||
|
},
|
||||||
Control::Slider {
|
Control::Slider {
|
||||||
key: "harmonic".into(),
|
key: "harmonic".into(),
|
||||||
label: "Armónico".into(),
|
label: "Armónico".into(),
|
||||||
|
|||||||
Reference in New Issue
Block a user