chore: rename tahuantinsuyu → cosmobiologia
Rename clean del proyecto astrológico antes de empezar el módulo
web (fase 2 = server axum, fase 3 = cliente WASM). Hacerlo ahora
ahorra refactor de URLs, package.json, paths de assets HTML y
deploy configs que aparecerían con el nombre en cuanto exista el
server.
Mecánica:
- `git mv` de los 10 crates de módulo + 2 apps:
* `crates/modules/tahuantinsuyu/` → `cosmobiologia/`
* `crates/modules/tahuantinsuyu/tahuantinsuyu-*` →
`cosmobiologia/cosmobiologia-*`
* `crates/apps/tahuantinsuyu` y `tahuantinsuyu-cli` análogos.
- Sed sobre todos los `.rs` y `.toml`: `tahuantinsuyu` →
`cosmobiologia` (cubre crate names, deps paths, use
statements, ProjectDirs literals, binary names).
- Workspace `Cargo.toml`: members con paths nuevos.
- Memoria del proyecto (`~/.claude/.../memory/project_*.md`)
actualizada.
Cero leftovers: `grep -rn tahuantinsuyu --include="*.rs"
--include="*.toml" crates/` devuelve vacío.
DB & XDG: clean slate. La nueva app arranca con DB vacía en
`$XDG_DATA_HOME/cosmobiologia/charts.db`. Si tenías cartas
guardadas, viven todavía en `~/.local/share/tahuantinsuyu/` —
las podés migrar manualmente con un `cp`.
IDs UI inalterados: el prefijo `tts-` de gpui ElementIds queda
igual (cosmético, no afecta funcionalidad). Cambiarlo a `cb-`
ahora sería 3-4 líneas más de sed pero ningún beneficio
operativo.
Tests: 20 verdes (10 shell + 10 render math). Compila full:
`cargo check -p cosmobiologia` OK.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "cosmobiologia-engine"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = "Tahuantinsuyu — bridge entre el modelo agnóstico y eternal-astrology. Produce RenderModel agnóstico para el canvas."
|
||||
|
||||
[dependencies]
|
||||
cosmobiologia-model = { path = "../cosmobiologia-model" }
|
||||
cosmobiologia-render = { path = "../cosmobiologia-render" }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# eternal-astrology vive en otro workspace (~/eternal). Lo enlazamos por
|
||||
# path para que el bridge use la misma lógica validada que el harness de
|
||||
# Sergio. Si el path no existe (CI sin eternal checked out), el feature
|
||||
# `eternal-bridge` se apaga.
|
||||
[dependencies.eternal-astrology]
|
||||
path = "../../../../../eternal/eternal-astrology"
|
||||
optional = true
|
||||
|
||||
[dependencies.eternal-sky]
|
||||
path = "../../../../../eternal/eternal-sky"
|
||||
optional = true
|
||||
|
||||
[features]
|
||||
# El bridge real contra eternal-astrology está prendido por default
|
||||
# porque la app sin eternal no muestra cartas reales. Si necesitás
|
||||
# compilar sin eternal checked out (CI, builds aisladas), `--no-default-features`
|
||||
# lo apaga y `compute()` cae a `compute_mock()`.
|
||||
default = ["eternal-bridge"]
|
||||
eternal-bridge = ["dep:eternal-astrology", "dep:eternal-sky"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
//! `cosmobiologia-engine` — bridge entre el modelo agnóstico y
|
||||
//! `eternal-astrology`.
|
||||
//!
|
||||
//! Recibe un `Chart` del modelo + un `ChartKind` y devuelve un
|
||||
//! [`RenderModel`] que describe la geometría a pintar **sin** acoplar
|
||||
//! el canvas a tipos de la librería astronómica. El canvas habla
|
||||
//! grados decimales, radios normalizados y kinds simbólicos.
|
||||
//!
|
||||
//! ## Por qué un RenderModel intermedio
|
||||
//!
|
||||
//! 1. El canvas no debería caer si cambia el shape de `NatalChart`
|
||||
//! upstream.
|
||||
//! 2. Tests del canvas: podemos generar `RenderModel`s sintéticos sin
|
||||
//! arrancar eternal.
|
||||
//! 3. Cada `ChartKind` produce el mismo shape genérico → el render
|
||||
//! coordina N módulos sin saber qué calcularon.
|
||||
//!
|
||||
//! ## Feature `eternal-bridge`
|
||||
//!
|
||||
//! - **on** (default): [`compute`] abre una `EphemerisSession` VSOP2013
|
||||
//! compartida y corre la pipeline real.
|
||||
//! - **off**: [`compute`] cae a [`compute_mock`] — útil para tests +
|
||||
//! builds sin eternal checked out.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(rust_2018_idioms)]
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
pub use cosmobiologia_model::{Chart, ChartId, ChartKind};
|
||||
|
||||
// Los tipos del RenderModel viven en `cosmobiologia-render` (crate
|
||||
// agnóstico de surface — compila a WASM, lo consumen tanto el canvas
|
||||
// gpui como el cliente web). El engine los reexporta para mantener
|
||||
// compatibilidad con todos los call sites históricos
|
||||
// (`cosmobiologia_engine::Layer`, etc.) sin tener que cambiar
|
||||
// imports en el shell, canvas, modules, tree, panel...
|
||||
pub use cosmobiologia_render::{
|
||||
AspectSummary, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta, PointMark,
|
||||
RenderModel, UranianGroup, OUTER_RING_MODULES,
|
||||
};
|
||||
|
||||
// `Chart` reexportado arriba es lo que `PipelineRequest::Synastry`
|
||||
// transporta — el caller (shell) lee del Store y pasa el Chart entero
|
||||
// para que el bridge construya su NatalChart en eternal.
|
||||
|
||||
#[cfg(feature = "eternal-bridge")]
|
||||
mod bridge;
|
||||
#[cfg(feature = "eternal-bridge")]
|
||||
mod dignity;
|
||||
#[cfg(feature = "eternal-bridge")]
|
||||
mod natal_cache;
|
||||
#[cfg(feature = "eternal-bridge")]
|
||||
pub mod svg_export;
|
||||
|
||||
// =====================================================================
|
||||
// Errores
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum EngineError {
|
||||
#[error("bridge a eternal-astrology no disponible (recompilá con feature `eternal-bridge`)")]
|
||||
BridgeDisabled,
|
||||
#[error("model: {0}")]
|
||||
Model(#[from] cosmobiologia_model::ModelError),
|
||||
#[error("eternal: {0}")]
|
||||
Eternal(String),
|
||||
#[error("kind {0:?} todavía no implementado")]
|
||||
UnsupportedKind(ChartKind),
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// API pública
|
||||
// =====================================================================
|
||||
|
||||
/// Pedidos que el host (Shell) eleva a la engine para componer un
|
||||
/// `RenderModel`. La capa natal **siempre** se computa; estos requests
|
||||
/// son **overlays adicionales**.
|
||||
///
|
||||
/// Cada variante mapea 1-a-1 con un Module declarado en
|
||||
/// `cosmobiologia-modules` por id string. Esto deja la engine como
|
||||
/// dueña única del cómputo (no depende del trait Module — los módulos
|
||||
/// son sólo metadata + UI controls).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PipelineRequest {
|
||||
/// `module_id = "transit"` — anillo externo con planetas al
|
||||
/// instante actual (reloj de pared) + cross aspects natal × transit.
|
||||
Transit,
|
||||
/// `module_id = "progression"` — anillo interno con los planetas
|
||||
/// progresados (método secundario "día por año") a la edad pedida
|
||||
/// + cross aspects natal × progresada.
|
||||
SecondaryProgression {
|
||||
/// Edad simbólica en años a la que avanzar la carta. Para "la
|
||||
/// edad de hoy", el shell la calcula a partir de `birth_data` +
|
||||
/// `SystemTime::now`.
|
||||
target_age_years: f64,
|
||||
},
|
||||
/// `module_id = "solar_arc"` — Solar Arc dirigido (default = "true
|
||||
/// progressed Sun"): cada cuerpo y cada cusp natal se desplazan por
|
||||
/// el mismo arco ≈ 1° por año de vida. Anillo interno bien adentro
|
||||
/// + cross aspects natal × dirigida.
|
||||
SolarArc {
|
||||
target_age_years: f64,
|
||||
},
|
||||
/// `module_id = "synastry"` — bi-wheel: la natal en el centro, la
|
||||
/// carta del partner en el anillo externo (compartido con Transit
|
||||
/// — mutuamente excluyentes), cross aspects natal × partner.
|
||||
/// El partner viene como `Chart` completo del shell.
|
||||
Synastry {
|
||||
partner_chart: Box<Chart>,
|
||||
},
|
||||
/// `module_id = "planetary_return"` — carta natal fresca al
|
||||
/// instante del próximo retorno del cuerpo elegido a su posición
|
||||
/// natal, para la edad pedida. Sun = retorno solar anual, Moon =
|
||||
/// mensual, Júpiter/Saturno = generacionales. Anillo externo
|
||||
/// compartido con Transit/Synastry — mutuamente excluyentes a
|
||||
/// nivel de Shell.
|
||||
PlanetaryReturn {
|
||||
/// Identificador agnóstico del cuerpo ("sun", "moon",
|
||||
/// "jupiter", …). El bridge lo mapea a `eternal_sky::Body`.
|
||||
body: String,
|
||||
target_age_years: f64,
|
||||
/// Días extra que se suman al anchor de búsqueda (birth +
|
||||
/// age*año). Para Solar return suele ser 0 (el return cae cerca
|
||||
/// del cumpleaños); para Lunar return permite saltar de un
|
||||
/// retorno mensual al siguiente (~28 días por click).
|
||||
shift_days: i64,
|
||||
},
|
||||
/// `module_id = "midpoints"` — anillo de puntos medios entre pares
|
||||
/// de cuerpos natales. Por simplicidad filtramos a los que
|
||||
/// involucran al Sol o a la Luna (~10 puntos).
|
||||
Midpoints,
|
||||
/// `module_id = "composite"` — carta compuesta (midpoint composite,
|
||||
/// método Davison) entre dos sujetos. Renderea los planetas
|
||||
/// compuestos en un anillo interno propio (radio 0.36, entre solar
|
||||
/// arc 0.40 y aspects). Útil para análisis de relaciones.
|
||||
Composite {
|
||||
partner_chart: Box<Chart>,
|
||||
},
|
||||
/// `module_id = "uranian"` — calcula los "ejes" del dial uraniano
|
||||
/// de 90°: agrupa los cuerpos natales cuya longitud módulo 90 cae
|
||||
/// dentro de una tolerancia (~2°). El resultado se publica en
|
||||
/// `RenderModel.uranian_groups` para que la UI lo liste como
|
||||
/// fórmulas analíticas. La visualización geométrica completa del
|
||||
/// dial de 90° queda pendiente para una fase posterior.
|
||||
Uranian,
|
||||
/// `module_id = "lots"` — Lots arábigos (helenísticos) calculados
|
||||
/// via `eternal_astrology::compute_lot`: Fortune, Spirit, Eros,
|
||||
/// Necessity, Courage, Victory, Nemesis. Renderea cada lot como
|
||||
/// un texto pequeño en el ring de bodies natales.
|
||||
Lots,
|
||||
/// `module_id = "fixed_stars"` — overlay con ~9 estrellas fijas
|
||||
/// notables (Aldebaran, Regulus, Antares, Fomalhaut, Spica,
|
||||
/// Sirius, Algol, Vega, Pollux). Posiciones tropicales J2000
|
||||
/// aproximadas + precesión simple (~50.29″/año). Renderea como
|
||||
/// marcadores chicos justo afuera del sign dial.
|
||||
FixedStars,
|
||||
/// `module_id = "topocentric"` — capa "ascensional": planetas
|
||||
/// re-proyectados a longitud eclíptica topocéntrica (con paralaje
|
||||
/// horizontal aplicada por cuerpo) + casas Polich-Page (sistema
|
||||
/// topocéntrico de domificación). Visible sobre todo en la Luna
|
||||
/// (~1° de shift); imperceptible en planetas exteriores. La capa
|
||||
/// convive con la natal geocéntrica como overlay comparativo.
|
||||
Topocentric,
|
||||
/// `module_id = "pd_direct"` + `"pd_converse"` — Direcciones
|
||||
/// Primarias del Sistema GR (García Rosas). Cada cuerpo natal se
|
||||
/// proyecta dos veces: hacia adelante en el tiempo diurno
|
||||
/// (direct) y hacia atrás (converse). Los dos resultados a la
|
||||
/// edad pedida pintan un dual-ring para rectificación en vivo.
|
||||
///
|
||||
/// `key` controla la conversión arco↔año: "naibod" (default
|
||||
/// moderno, 0°59'08.33″/año) o "ptolemy" (clásica, 1°/año).
|
||||
PrimaryDirections {
|
||||
target_age_years: f64,
|
||||
key: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Opciones que afectan la pasada natal (qué aspectos pintar, qué
|
||||
/// multiplicador de orbe usar). Es independiente de los overlays.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NatalOptions {
|
||||
/// Incluir aspectos mayores (conj/opp/trine/square/sextile).
|
||||
pub show_majors: bool,
|
||||
/// Incluir aspectos menores (quincunx/semi-sextile/etc).
|
||||
pub show_minors: bool,
|
||||
/// Multiplicador uniforme sobre los orbes default. `1.0` = orbes
|
||||
/// modern_western; `0.5` = tight; `2.0` = wide.
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_majors: true,
|
||||
show_minors: false,
|
||||
orb_multiplier: 1.0,
|
||||
show_dignities: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Composición canónica: carta natal + todos los overlays pedidos.
|
||||
/// Equivalente a `compose_with_options` con `NatalOptions::default()`.
|
||||
pub fn compose(
|
||||
chart: &Chart,
|
||||
offset_minutes: i64,
|
||||
requests: &[PipelineRequest],
|
||||
) -> Result<RenderModel, EngineError> {
|
||||
compose_with_options(chart, offset_minutes, requests, &NatalOptions::default())
|
||||
}
|
||||
|
||||
/// Variante que permite controlar qué aspectos natales se computan y
|
||||
/// con qué multiplicador de orbe.
|
||||
pub fn compose_with_options(
|
||||
chart: &Chart,
|
||||
offset_minutes: i64,
|
||||
requests: &[PipelineRequest],
|
||||
natal_options: &NatalOptions,
|
||||
) -> Result<RenderModel, EngineError> {
|
||||
#[cfg(feature = "eternal-bridge")]
|
||||
{
|
||||
bridge::compose(chart, offset_minutes, requests, natal_options)
|
||||
}
|
||||
#[cfg(not(feature = "eternal-bridge"))]
|
||||
{
|
||||
let _ = (offset_minutes, requests, natal_options);
|
||||
Ok(compute_mock(chart))
|
||||
}
|
||||
}
|
||||
|
||||
/// Atajo: natal sin overlays. Equivalente a `compose(chart, 0, &[])`.
|
||||
pub fn compute(chart: &Chart) -> Result<RenderModel, EngineError> {
|
||||
compose(chart, 0, &[])
|
||||
}
|
||||
|
||||
/// Atajo: natal con time-scrubbing pero sin overlays.
|
||||
pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result<RenderModel, EngineError> {
|
||||
compose(chart, offset_minutes, &[])
|
||||
}
|
||||
|
||||
/// Atajo: natal + overlay de tránsitos al instante actual.
|
||||
pub fn compute_with_transits_at_now(
|
||||
chart: &Chart,
|
||||
offset_minutes: i64,
|
||||
) -> Result<RenderModel, EngineError> {
|
||||
compose(chart, offset_minutes, &[PipelineRequest::Transit])
|
||||
}
|
||||
|
||||
/// Computa la carta del retorno planetario actual (cuerpo + edad)
|
||||
/// como `StoredBirthData` standalone — la app la usa para crear
|
||||
/// una `FreeChart` que el usuario puede después persistir en un
|
||||
/// contacto. Devuelve también un label-corto del instante para
|
||||
/// concatenar al nombre.
|
||||
#[cfg(feature = "eternal-bridge")]
|
||||
pub fn compute_planetary_return_chart(
|
||||
chart: &Chart,
|
||||
body: &str,
|
||||
target_age_years: f64,
|
||||
shift_days: i64,
|
||||
) -> Result<(cosmobiologia_model::StoredBirthData, String), EngineError> {
|
||||
bridge::compute_planetary_return_chart(chart, body, target_age_years, shift_days)
|
||||
}
|
||||
|
||||
/// Helper análogo para tránsito — birth_data = `ahora` UTC + lugar
|
||||
/// del natal. Útil para snapshotear el cielo en este instante anclado
|
||||
/// a las coordenadas del sujeto.
|
||||
#[cfg(feature = "eternal-bridge")]
|
||||
pub fn compute_transit_chart(
|
||||
chart: &Chart,
|
||||
) -> Result<(cosmobiologia_model::StoredBirthData, String), EngineError> {
|
||||
bridge::compute_transit_chart(chart)
|
||||
}
|
||||
|
||||
/// Helper análogo para progresión secundaria — birth_data = natal +
|
||||
/// target_age_years × 1 día simbólico.
|
||||
#[cfg(feature = "eternal-bridge")]
|
||||
pub fn compute_progression_chart(
|
||||
chart: &Chart,
|
||||
target_age_years: f64,
|
||||
) -> Result<(cosmobiologia_model::StoredBirthData, String), EngineError> {
|
||||
bridge::compute_progression_chart(chart, target_age_years)
|
||||
}
|
||||
|
||||
/// Helper retrocompatible: construye un `PlanetaryReturn` con
|
||||
/// `shift_days = 0`. Útil para llamadores que no necesitan ajuste
|
||||
/// fino (todos los Solar return y muchos casos básicos).
|
||||
pub fn planetary_return_request(body: String, target_age_years: f64) -> PipelineRequest {
|
||||
PipelineRequest::PlanetaryReturn {
|
||||
body,
|
||||
target_age_years,
|
||||
shift_days: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub determinista — útil para tests + para la UI sin eternal.
|
||||
pub fn compute_mock(chart: &Chart) -> RenderModel {
|
||||
use std::time::Instant;
|
||||
let t0 = Instant::now();
|
||||
|
||||
let sign_dial = Layer {
|
||||
module_id: "natal".into(),
|
||||
kind: LayerKind::SignDial,
|
||||
ring: 1.0,
|
||||
z: 0,
|
||||
geometry: Geometry::Ring {
|
||||
cusps_deg: (0..12).map(|i| (i as f32) * 30.0).collect(),
|
||||
},
|
||||
glyphs: (0..12)
|
||||
.map(|i| Glyph {
|
||||
deg: (i as f32) * 30.0 + 15.0,
|
||||
symbol: ZODIAC_GLYPHS[i].into(),
|
||||
annotation: None,
|
||||
retrograde: false,
|
||||
house: None,
|
||||
dignity_marker: None,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
RenderModel {
|
||||
chart_id: chart.id,
|
||||
chart_kind: chart.kind,
|
||||
title: chart.label.clone(),
|
||||
subtitle: chart.birth_data.birthplace_label.clone(),
|
||||
compute_ms: t0.elapsed().as_millis() as u64,
|
||||
ascendant_deg: 0.0,
|
||||
midheaven_deg: 270.0,
|
||||
descendant_deg: 180.0,
|
||||
imum_coeli_deg: 90.0,
|
||||
layers: vec![sign_dial],
|
||||
overlays: Vec::new(),
|
||||
aspect_summary: Vec::new(),
|
||||
uranian_groups: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
const ZODIAC_GLYPHS: [&str; 12] = [
|
||||
"aries",
|
||||
"taurus",
|
||||
"gemini",
|
||||
"cancer",
|
||||
"leo",
|
||||
"virgo",
|
||||
"libra",
|
||||
"scorpio",
|
||||
"sagittarius",
|
||||
"capricorn",
|
||||
"aquarius",
|
||||
"pisces",
|
||||
];
|
||||
|
||||
// =====================================================================
|
||||
// Tests
|
||||
// =====================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cosmobiologia_model::{
|
||||
Chart, ChartKind, ContactId, StoredBirthData, StoredChartConfig,
|
||||
};
|
||||
|
||||
fn sample_chart() -> Chart {
|
||||
Chart {
|
||||
id: 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.4806,
|
||||
longitude_deg: -66.9036,
|
||||
altitude_m: 900.0,
|
||||
time_certainty: Default::default(),
|
||||
subject_name: None,
|
||||
birthplace_label: None,
|
||||
},
|
||||
config: StoredChartConfig::default(),
|
||||
related_chart_id: None,
|
||||
created_at_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_emits_sign_dial() {
|
||||
let model = compute_mock(&sample_chart());
|
||||
assert_eq!(model.layers.len(), 1);
|
||||
assert!(matches!(model.layers[0].kind, LayerKind::SignDial));
|
||||
assert_eq!(model.layers[0].glyphs.len(), 12);
|
||||
}
|
||||
|
||||
#[cfg(feature = "eternal-bridge")]
|
||||
#[test]
|
||||
fn real_compute_natal_demo() {
|
||||
let model = compute(&sample_chart()).expect("compute con eternal");
|
||||
assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::SignDial)));
|
||||
assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::Houses)));
|
||||
assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::Bodies)));
|
||||
// El Asc debe ser un grado válido.
|
||||
assert!(model.ascendant_deg.is_finite());
|
||||
assert!((0.0..360.0).contains(&model.ascendant_deg));
|
||||
}
|
||||
|
||||
/// El cache de NatalChart debe hacer que la segunda llamada con
|
||||
/// inputs idénticos sea sustancialmente más rápida que la primera.
|
||||
/// Verificamos un piso del 4× — en práctica el ratio suele ser
|
||||
/// >10× porque la primera carga VSOP2013 también.
|
||||
#[cfg(feature = "eternal-bridge")]
|
||||
#[test]
|
||||
fn natal_cache_hits_are_faster() {
|
||||
let chart = sample_chart();
|
||||
// Warmup: abre la sesión de efemérides y puebla el cache.
|
||||
let _ = compute(&chart).expect("warmup");
|
||||
|
||||
// Reset implícito: insertar una clave distinta no botaría la
|
||||
// nuestra (cap=8) pero la marcaría como más vieja. Como solo
|
||||
// tenemos 1 entrada, sigue al frente.
|
||||
let t1 = std::time::Instant::now();
|
||||
let _ = compute(&chart).expect("primera medida");
|
||||
let cold_or_hot_1 = t1.elapsed();
|
||||
|
||||
let t2 = std::time::Instant::now();
|
||||
let _ = compute(&chart).expect("segunda medida");
|
||||
let hot = t2.elapsed();
|
||||
|
||||
// Después del warmup, las dos llamadas son hot. Para validar el
|
||||
// efecto del cache, modificamos el offset_minutes para forzar
|
||||
// un MISS y comparar contra un HIT.
|
||||
use crate::PipelineRequest;
|
||||
let t3 = std::time::Instant::now();
|
||||
let _ = compose(&chart, 17, &[] as &[PipelineRequest])
|
||||
.expect("miss con offset distinto");
|
||||
let miss = t3.elapsed();
|
||||
|
||||
let t4 = std::time::Instant::now();
|
||||
let _ = compose(&chart, 17, &[] as &[PipelineRequest])
|
||||
.expect("hit con mismo offset");
|
||||
let hit = t4.elapsed();
|
||||
|
||||
// Sanity: el hit debe ser estrictamente más rápido que el miss.
|
||||
assert!(
|
||||
hit < miss,
|
||||
"cache hit ({:?}) debería ser más rápido que miss ({:?}); \
|
||||
warmup={:?}, repeat={:?}",
|
||||
hit, miss, cold_or_hot_1, hot
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! LRU cache para `NatalChart` por contenido.
|
||||
//!
|
||||
//! `NatalChart::compute` cuesta varios ms (VSOP2013 + casas + aspectos
|
||||
//! base). En el shell, mover el slider de orbe o tocar un toggle
|
||||
//! dispara un `compose()` completo donde la **misma** carta natal del
|
||||
//! sujeto principal se recomputa idéntica. Lo mismo pasa con el partner
|
||||
//! de Synastry / Composite — cada drag de slider rearma `partner_natal`.
|
||||
//!
|
||||
//! Este cache de 8 entradas es suficiente: el usuario rara vez tiene
|
||||
//! más de 2 cartas activas a la vez (natal + partner) y el LRU bota la
|
||||
//! más vieja cuando se llena. La clave es el **contenido** de
|
||||
//! `StoredBirthData + StoredChartConfig + offset_minutes`, así que
|
||||
//! editar una carta invalida automáticamente su entrada.
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use eternal_astrology::NatalChart;
|
||||
use cosmobiologia_model::{StoredBirthData, StoredChartConfig};
|
||||
|
||||
const CAPACITY: usize = 8;
|
||||
|
||||
type Key = u64;
|
||||
|
||||
struct Cache {
|
||||
/// Front = más reciente, back = más viejo. `VecDeque` simple — con
|
||||
/// cap 8 el search lineal cuesta menos que un HashMap.
|
||||
entries: Vec<(Key, Arc<NatalChart>)>,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
entries: Vec::with_capacity(CAPACITY),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&mut self, k: Key) -> Option<Arc<NatalChart>> {
|
||||
let idx = self.entries.iter().position(|(kk, _)| *kk == k)?;
|
||||
// Move-to-front para mantener LRU.
|
||||
let hit = self.entries.remove(idx);
|
||||
let chart = hit.1.clone();
|
||||
self.entries.insert(0, hit);
|
||||
Some(chart)
|
||||
}
|
||||
|
||||
fn put(&mut self, k: Key, v: Arc<NatalChart>) {
|
||||
// Si ya existe la entrada (race: dos threads computaron lo mismo
|
||||
// antes de poblar), reemplaza in-place.
|
||||
if let Some(idx) = self.entries.iter().position(|(kk, _)| *kk == k) {
|
||||
self.entries.remove(idx);
|
||||
}
|
||||
self.entries.insert(0, (k, v));
|
||||
if self.entries.len() > CAPACITY {
|
||||
self.entries.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
|
||||
|
||||
fn cache() -> &'static Mutex<Cache> {
|
||||
CACHE.get_or_init(|| Mutex::new(Cache::new()))
|
||||
}
|
||||
|
||||
/// Hash de contenido: incluye todos los campos relevantes para el
|
||||
/// cómputo de la carta natal. `f64` se hashea via `to_bits` para evitar
|
||||
/// el `Hash` ausente de los flotantes.
|
||||
pub fn key_for(
|
||||
birth: &StoredBirthData,
|
||||
config: &StoredChartConfig,
|
||||
offset_minutes: i64,
|
||||
) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
// Birth data — fecha/hora/lugar.
|
||||
birth.year.hash(&mut h);
|
||||
birth.month.hash(&mut h);
|
||||
birth.day.hash(&mut h);
|
||||
birth.hour.hash(&mut h);
|
||||
birth.minute.hash(&mut h);
|
||||
birth.second.to_bits().hash(&mut h);
|
||||
birth.tz_offset_minutes.hash(&mut h);
|
||||
birth.latitude_deg.to_bits().hash(&mut h);
|
||||
birth.longitude_deg.to_bits().hash(&mut h);
|
||||
birth.altitude_m.to_bits().hash(&mut h);
|
||||
// Config — todos los toggles que afectan el cómputo de placements y
|
||||
// casas. Los enums derivan Debug; reusamos eso para hashear sin
|
||||
// forzarles `Hash` manualmente.
|
||||
format!("{:?}", config.house_system).hash(&mut h);
|
||||
format!("{:?}", config.zodiac).hash(&mut h);
|
||||
config.ayanamsha.hash(&mut h);
|
||||
config.bodies.hash(&mut h);
|
||||
config.include_south_node.hash(&mut h);
|
||||
config.include_lilith.hash(&mut h);
|
||||
config.include_main_belt_asteroids.hash(&mut h);
|
||||
config.include_fixed_stars.hash(&mut h);
|
||||
// Offset temporal (rectificación rápida).
|
||||
offset_minutes.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Consulta. Devuelve `None` en miss; el caller debe computar y llamar
|
||||
/// a `insert`.
|
||||
pub fn get(k: Key) -> Option<Arc<NatalChart>> {
|
||||
cache().lock().ok()?.get(k)
|
||||
}
|
||||
|
||||
/// Inserta una entrada. Idempotente: re-insertar la misma key la mueve
|
||||
/// al frente.
|
||||
pub fn insert(k: Key, v: Arc<NatalChart>) {
|
||||
if let Ok(mut guard) = cache().lock() {
|
||||
guard.put(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 `cosmobiologia-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 cosmobiologia_model::{Chart, ContactId, StoredBirthData, StoredChartConfig};
|
||||
|
||||
fn sample_chart() -> Chart {
|
||||
Chart {
|
||||
id: cosmobiologia_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 "));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user