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:
sergio
2026-05-19 00:45:48 +00:00
parent 9084cf4b79
commit 06a1ca11ce
34 changed files with 325 additions and 315 deletions
@@ -0,0 +1,15 @@
[package]
name = "cosmobiologia-canvas"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Tahuantinsuyu — widget GPUI del canvas astrológico. Capas modulares, jog-dial perimetral, estado unificado."
[dependencies]
cosmobiologia-engine = { path = "../cosmobiologia-engine" }
cosmobiologia-model = { path = "../cosmobiologia-model" }
cosmobiologia-modules = { path = "../cosmobiologia-modules" }
cosmobiologia-render = { path = "../cosmobiologia-render" }
cosmobiologia-theme = { path = "../cosmobiologia-theme" }
yahweh-theme = { workspace = true }
gpui = { workspace = true }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
[package]
name = "cosmobiologia-card"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Tahuantinsuyu — Tarjeta de Presentación brahman + spawn del sidecar + protocolo del service socket."
[dependencies]
brahman-card = { path = "../../../core/brahman-card" }
brahman-sidecar = { path = "../../../shared/brahman-sidecar" }
cosmobiologia-engine = { path = "../cosmobiologia-engine" }
cosmobiologia-model = { path = "../cosmobiologia-model" }
ulid = { workspace = true }
serde = { workspace = true }
postcard = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
directories = { workspace = true }
thiserror = { workspace = true }
@@ -0,0 +1,95 @@
//! `cosmobiologia-card` — Tarjeta de Presentación + sidecar de la app.
//!
//! Cualquier binario que levante Tahuantinsuyu llama [`spawn_sidecar`]
//! antes de abrir la ventana GPUI. La lógica de thread / tokio /
//! ping-loop vive en `brahman-sidecar`; aquí solo declaramos quién es
//! Tahuantinsuyu como módulo Brahman.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
pub mod service;
use std::collections::BTreeSet;
use brahman_card::{
Card, Flow, Flows, FsPolicy, IpcPolicy, Lifecycle, Payload, Permissions, Priority, Supervision,
TypeRef, CARD_SCHEMA_VERSION,
};
use ulid::Ulid;
/// Label canónico — coincide con el binario y aparece en `ListEntes`.
pub const LABEL: &str = "brahman.cosmobiologia";
/// Spawn fire-and-forget. Si el Init no está corriendo, el sidecar
/// loggea y termina; la app sigue ejecutándose standalone.
pub fn spawn_sidecar() {
brahman_sidecar::spawn(build_card());
}
/// Construye la Card. Expuesto público para tests + para shells que
/// quieran inspeccionar el manifiesto antes de spawnear. Anuncia el
/// path del service socket en `Card.service_socket` para que otros
/// módulos brahman, después de matchear via el broker, puedan conectar
/// directo al data plane.
pub fn build_card() -> Card {
Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
lineage: None,
label: LABEL.into(),
service_socket: Some(service::default_service_socket()),
provides: BTreeSet::new(),
requires: BTreeSet::new(),
payload: Payload::Virtual,
supervision: Supervision::Delegate,
lifecycle: Lifecycle::Widget,
priority: Priority::Normal,
permissions: Permissions {
// La app guarda su DB SQLite en disco; necesita RW filesystem.
filesystem: FsPolicy::ReadWrite,
ipc: IpcPolicy {
allow: vec!["wit-v1".into()],
},
..Default::default()
},
flow: Flows {
// Recibe peticiones de cómputo (carta natal, transit, etc.)
// serializadas como JSON. La forma exacta la define
// `cosmobiologia-engine`.
input: vec![Flow {
name: "chart-request".into(),
ty: TypeRef::Primitive {
name: "json".into(),
},
pin_to: None,
}],
// Publica el resultado de un cómputo (placements, aspectos,
// casas) también como JSON. Otras apps brahman pueden
// consumirlo para visualizar o derivar.
output: vec![Flow {
name: "chart-result".into(),
ty: TypeRef::Primitive {
name: "json".into(),
},
pin_to: None,
}],
},
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn card_label_and_flow() {
let c = build_card();
assert_eq!(c.label, LABEL);
assert_eq!(c.flow.input.len(), 1);
assert_eq!(c.flow.output.len(), 1);
assert_eq!(c.flow.input[0].name, "chart-request");
assert_eq!(c.flow.output[0].name, "chart-result");
}
}
@@ -0,0 +1,244 @@
//! Service socket de Tahuantinsuyu — protocolo y server.
//!
//! La Card de Tahuantinsuyu declara desde fase 1 los flows
//! `chart-request` (input) y `chart-result` (output). Acá vive el
//! **data plane** real que los implementa: un Unix socket sobre el que
//! cualquier módulo brahman puede pedir un cómputo de carta y recibir
//! el RenderModel ya armado.
//!
//! ## Protocolo
//!
//! Frame: `u32 length` little-endian + `postcard`-serialized payload.
//! Misma forma que `brahman-handshake` para reducir sorpresas.
//!
//! ## Endpoints
//!
//! - `ComputeRequest::Natal { birth, config, offset_minutes }` →
//! `ComputeResponse::Render { render }` o `Error { message }`.
//! - `ComputeRequest::Ping` → `ComputeResponse::Pong`.
//!
//! El service no expone los overlays (transit / synastry / etc) por
//! ahora — son una pasada futura. Cubre el caso 80%: "necesito la
//! carta natal de estos datos".
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use cosmobiologia_engine::{compose_with_options, NatalOptions, RenderModel};
use cosmobiologia_model::{Chart, ChartId, ChartKind, ContactId, StoredBirthData, StoredChartConfig};
use thiserror::Error;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{UnixListener, UnixStream};
use tracing::{debug, error, info, warn};
/// Path canónico del service socket. Usa `XDG_RUNTIME_DIR` si está
/// (por usuario, no persistente), sino cae a `/tmp/cosmobiologia.sock`.
pub fn default_service_socket() -> PathBuf {
if let Some(rt) = directories::ProjectDirs::from("net", "gioser", "cosmobiologia") {
// ProjectDirs no expone runtime_dir directo en todas las
// plataformas — usamos cache_dir como fallback estable.
let mut p = rt.cache_dir().to_path_buf();
std::fs::create_dir_all(&p).ok();
p.push("service.sock");
return p;
}
PathBuf::from("/tmp/cosmobiologia.sock")
}
// =====================================================================
// Tipos del protocolo
// =====================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ComputeRequest {
/// Salud del server. Usá para verificar que el sidecar está vivo.
Ping,
/// Pide el cómputo de una carta natal pura (sin overlays).
Natal {
birth: StoredBirthData,
config: StoredChartConfig,
/// Offset en minutos sobre el instante natal — útil para
/// rectificación rápida sin guardar variantes.
#[serde(default)]
offset_minutes: i64,
/// Label opcional para que el render lo lleve en su title.
#[serde(default)]
label: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ComputeResponse {
Pong,
Render { render: RenderModel },
Error { message: String },
}
// =====================================================================
// Errores
// =====================================================================
#[derive(Debug, Error)]
pub enum ServiceError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("postcard: {0}")]
Postcard(#[from] postcard::Error),
#[error("frame demasiado grande: {0} bytes")]
FrameTooLarge(u32),
#[error("connect a {path}: {source}")]
Connect {
path: PathBuf,
source: std::io::Error,
},
}
/// Cap de tamaño de frame — defensivo contra peers malformados.
const MAX_FRAME_BYTES: u32 = 1024 * 1024; // 1 MiB
// =====================================================================
// Server
// =====================================================================
/// Arranca el server async sobre `socket_path`. Cada conexión nueva
/// procesa una secuencia de Request/Response hasta que el peer cierra.
pub async fn serve(socket_path: PathBuf) -> Result<(), ServiceError> {
// Si quedó un socket viejo del run anterior, lo borramos.
let _ = std::fs::remove_file(&socket_path);
let listener = UnixListener::bind(&socket_path)?;
info!(socket = %socket_path.display(), "cosmobiologia service socket arriba");
loop {
let (stream, _addr) = listener.accept().await?;
tokio::spawn(async move {
if let Err(e) = serve_connection(stream).await {
warn!(?e, "connection terminó con error");
}
});
}
}
async fn serve_connection(mut stream: UnixStream) -> Result<(), ServiceError> {
loop {
let request: ComputeRequest = match read_frame(&mut stream).await {
Ok(r) => r,
Err(ServiceError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
debug!("peer cerró");
return Ok(());
}
Err(e) => return Err(e),
};
let response = handle(request);
write_frame(&mut stream, &response).await?;
}
}
fn handle(req: ComputeRequest) -> ComputeResponse {
match req {
ComputeRequest::Ping => ComputeResponse::Pong,
ComputeRequest::Natal {
birth,
config,
offset_minutes,
label,
} => {
let chart = Chart {
id: ChartId::new(),
contact_id: ContactId::new(),
kind: ChartKind::Natal,
label: label.unwrap_or_else(|| "Service request".into()),
birth_data: birth,
config,
related_chart_id: None,
created_at_ms: 0,
};
match compose_with_options(&chart, offset_minutes, &[], &NatalOptions::default()) {
Ok(render) => ComputeResponse::Render { render },
Err(e) => ComputeResponse::Error {
message: format!("{}", e),
},
}
}
}
}
// =====================================================================
// Client helper
// =====================================================================
/// Cliente async: abre el socket, envía un request, espera la response.
/// Cierra la conexión al volver (no reusable; útil para CLI/tests).
pub async fn request(
socket: &Path,
req: &ComputeRequest,
) -> Result<ComputeResponse, ServiceError> {
let mut stream = UnixStream::connect(socket).await.map_err(|source| {
ServiceError::Connect {
path: socket.to_path_buf(),
source,
}
})?;
write_frame(&mut stream, req).await?;
read_frame(&mut stream).await
}
// =====================================================================
// Framing
// =====================================================================
async fn write_frame<T: Serialize>(stream: &mut UnixStream, value: &T) -> Result<(), ServiceError> {
let bytes = postcard::to_allocvec(value)?;
let len = u32::try_from(bytes.len()).map_err(|_| ServiceError::FrameTooLarge(u32::MAX))?;
if len > MAX_FRAME_BYTES {
return Err(ServiceError::FrameTooLarge(len));
}
stream.write_u32_le(len).await?;
stream.write_all(&bytes).await?;
stream.flush().await?;
Ok(())
}
async fn read_frame<T: for<'de> Deserialize<'de>>(
stream: &mut UnixStream,
) -> Result<T, ServiceError> {
let len = stream.read_u32_le().await?;
if len > MAX_FRAME_BYTES {
return Err(ServiceError::FrameTooLarge(len));
}
let mut buf = vec![0u8; len as usize];
stream.read_exact(&mut buf).await?;
let value = postcard::from_bytes(&buf)?;
Ok(value)
}
// =====================================================================
// Spawn helper para uso desde el binario GUI
// =====================================================================
/// Spawn fire-and-forget: thread aparte con tokio runtime current_thread
/// corriendo el server. Si la initialización falla, loggea warn y el
/// thread termina. El binario GUI sigue funcionando standalone.
pub fn spawn_service_thread(socket_path: PathBuf) {
std::thread::Builder::new()
.name("cosmobiologia-service".into())
.spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_io()
.build()
{
Ok(rt) => rt,
Err(e) => {
error!(?e, "no pude crear runtime para service thread");
return;
}
};
if let Err(e) = rt.block_on(serve(socket_path)) {
error!(?e, "service server terminó con error");
}
})
.map(|_| ())
.unwrap_or_else(|e| {
error!(?e, "no pude spawnear thread del service socket");
});
}
@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[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 "));
}
}
@@ -0,0 +1,12 @@
[package]
name = "cosmobiologia-model"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Tahuantinsuyu — tipos agnósticos del modelo astrológico (Group, Contact, Chart, StoredBirthData, StoredChartConfig)."
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
ulid = { workspace = true }
@@ -0,0 +1,394 @@
//! `cosmobiologia-model` — tipos agnósticos del estudio astrológico.
//!
//! Esta es la capa de **datos puros**: no conoce GPUI, ni rusqlite, ni
//! `eternal-astrology`. Solo tipos `serde`-able que viajan entre la
//! store, la engine, los widgets, y eventualmente la Card de Brahman.
//!
//! ## Jerarquía
//!
//! ```text
//! Group (puede anidar otros Groups vía parent_id)
//! ├── Group (sub-agrupación)
//! └── Contact (persona / evento / lugar)
//! └── Chart (carta astrológica)
//! ```
//!
//! Las `Chart` son las hojas — cada una guarda su `StoredBirthData` y su
//! `StoredChartConfig`. La engine las traduce a tipos de `eternal-astrology`
//! cuando hay que computar.
//!
//! ## Por qué tipos "Stored" propios y no reusar `eternal-astrology`
//!
//! Forward-compat: si mañana cambia el shape de `BirthData` upstream, o
//! queremos persistir en otro backend astronómico, el modelo + la base
//! sobreviven. La engine es el único puente que conoce ambas formas.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use serde::{Deserialize, Serialize};
use thiserror::Error;
use ulid::Ulid;
pub use ::ulid;
// =====================================================================
// Identidades
// =====================================================================
macro_rules! ulid_newtype {
($name:ident, $doc:expr) => {
#[doc = $doc]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct $name(pub Ulid);
impl $name {
pub fn new() -> Self {
Self(Ulid::new())
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl std::str::FromStr for $name {
type Err = ulid::DecodeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ulid::from_string(s).map(Self)
}
}
};
}
ulid_newtype!(GroupId, "Identificador estable de un Group.");
ulid_newtype!(ContactId, "Identificador estable de un Contact.");
ulid_newtype!(ChartId, "Identificador estable de un Chart.");
// =====================================================================
// Group / Contact
// =====================================================================
/// Agrupación jerárquica de contactos. Puede anidar otros groups vía
/// `parent_id` (un Group raíz tiene `parent_id = None`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Group {
pub id: GroupId,
pub parent_id: Option<GroupId>,
pub name: String,
#[serde(default)]
pub description: Option<String>,
/// Epoch millis. Decisión: `i64` para tolerar valores pre-1970 en
/// imports históricos sin overflow.
pub created_at_ms: i64,
/// Orden manual dentro del padre. Más bajo = primero. Empate → por nombre.
#[serde(default)]
pub sort_order: i32,
}
/// Persona o evento del que se calcula una o más cartas. Puede vivir
/// directamente en la raíz (`group_id = None`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Contact {
pub id: ContactId,
pub group_id: Option<GroupId>,
pub name: String,
#[serde(default)]
pub notes: Option<String>,
pub created_at_ms: i64,
}
// =====================================================================
// Datos de nacimiento (espejo agnóstico de eternal_astrology::BirthData)
// =====================================================================
/// Datos crudos de nacimiento. La engine los traduce a
/// `eternal_astrology::BirthData` cuando hay que computar.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredBirthData {
/// Calendario civil local.
pub year: i32,
pub month: u32,
pub day: u32,
pub hour: u32,
pub minute: u32,
/// Segundos fraccionarios (0.0..60.0).
pub second: f64,
/// Offset desde UTC, en minutos. Ej: -240 = UTC-04:00.
pub tz_offset_minutes: i32,
/// Coordenadas geográficas en grados decimales.
pub latitude_deg: f64,
pub longitude_deg: f64,
/// Altura en metros sobre el geoide WGS-84.
#[serde(default)]
pub altitude_m: f64,
#[serde(default)]
pub time_certainty: TimeCertainty,
#[serde(default)]
pub subject_name: Option<String>,
#[serde(default)]
pub birthplace_label: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TimeCertainty {
#[default]
Exact,
RoundedHour,
RoundedDay,
Estimated,
}
// =====================================================================
// Configuración de carta (espejo agnóstico de eternal_astrology::ChartConfig)
// =====================================================================
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Zodiac {
#[default]
Tropical,
Sidereal,
Draconic,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HouseSystem {
#[default]
Placidus,
Koch,
Regiomontanus,
Campanus,
Porphyry,
Equal,
WholeSign,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredChartConfig {
#[serde(default)]
pub zodiac: Zodiac,
#[serde(default)]
pub house_system: HouseSystem,
/// Nombre del ayanamsha cuando `zodiac == Sidereal`. Ej: "lahiri",
/// "fagan_bradley". Ignorado para Tropical/Draconic.
#[serde(default)]
pub ayanamsha: Option<String>,
/// Cuerpos a incluir. Strings opacos para que el modelo no se ate
/// al enum `Body` de eternal. Ej: ["sun","moon","mercury",…].
#[serde(default = "default_bodies")]
pub bodies: Vec<String>,
#[serde(default = "default_true")]
pub include_south_node: bool,
#[serde(default)]
pub include_lilith: bool,
#[serde(default)]
pub include_main_belt_asteroids: bool,
#[serde(default)]
pub include_fixed_stars: bool,
/// Tabla de orbes a usar (nombre simbólico). `None` → orbes defaults
/// de la engine.
#[serde(default)]
pub orb_table: Option<String>,
}
impl Default for StoredChartConfig {
fn default() -> Self {
Self {
zodiac: Zodiac::default(),
house_system: HouseSystem::default(),
ayanamsha: None,
bodies: default_bodies(),
include_south_node: true,
include_lilith: false,
include_main_belt_asteroids: false,
include_fixed_stars: false,
orb_table: None,
}
}
}
fn default_bodies() -> Vec<String> {
vec![
"sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune",
"pluto", "mean_node",
]
.into_iter()
.map(String::from)
.collect()
}
fn default_true() -> bool {
true
}
// =====================================================================
// Chart
// =====================================================================
/// Tipo de carta astrológica. Determina qué rutina de la engine corre
/// y qué `Layer`s aporta al canvas.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChartKind {
Natal,
Transit,
SecondaryProgression,
TertiaryProgression,
MinorProgression,
SolarArc,
SolarReturn,
LunarReturn,
Synastry,
Composite,
Davison,
Profection,
PrimaryDirection,
/// Carta "mundial" para un instante + lugar sin sujeto natal.
Mundane,
}
impl ChartKind {
/// `true` si la carta necesita una segunda carta natal como referencia
/// (synastry/composite/davison). Útil para validar al persistir.
pub fn requires_related_chart(self) -> bool {
matches!(
self,
ChartKind::Synastry | ChartKind::Composite | ChartKind::Davison
)
}
}
/// Una carta concreta dentro de un contacto. Las cartas de tipo
/// derivado (transit, progression, synastry, …) referencian la carta
/// natal de la que parten vía `related_chart_id`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Chart {
pub id: ChartId,
pub contact_id: ContactId,
pub kind: ChartKind,
pub label: String,
pub birth_data: StoredBirthData,
pub config: StoredChartConfig,
/// Para cartas derivadas: la carta de referencia. Para transit/
/// progression apunta a la natal del mismo contacto. Para synastry
/// apunta a la carta del otro sujeto.
#[serde(default)]
pub related_chart_id: Option<ChartId>,
pub created_at_ms: i64,
}
// =====================================================================
// Estado de módulos por carta (qué capas están activas + su config)
// =====================================================================
/// Cada `ChartKind` puede activar uno o más `module_id` (ej. una carta
/// natal puede tener `natal`, `dignities`, `fixed_stars`, `uranian`).
/// El estado por-carta se persiste en la store; el canvas lo consulta
/// para decidir qué capas pintar y qué controles mostrar en el panel.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModuleState {
pub chart_id: ChartId,
pub module_id: String,
pub enabled: bool,
/// JSON libre — cada módulo define su schema.
#[serde(default)]
pub config: serde_json::Value,
}
// =====================================================================
// Selección activa (qué muestra el canvas)
// =====================================================================
/// Identificador de una carta "libre" — efímera, no persistida en la
/// store. Llave de un `HashMap` en el shell. El valor `SKY_NOW_ID`
/// está reservado para la carta del instante actual; otros se
/// generan al vuelo como UUIDs string-encoded.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FreeChartId(pub String);
impl FreeChartId {
pub fn sky_now() -> Self {
Self(SKY_NOW_ID.into())
}
pub fn is_sky_now(&self) -> bool {
self.0 == SKY_NOW_ID
}
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Sentinela del id de la carta "Cielo ahora" — siempre presente
/// como primer elemento de la sección "Cartas libres" del tree.
pub const SKY_NOW_ID: &str = "sky-now";
/// Item activo del tree. El canvas reacciona a este tipo:
/// - `Chart` → abre la carta puntual.
/// - `Contact` / `Group` → muestra thumbnails de las cartas descendientes.
/// - `FreeChart` → carta libre (no anclada a contacto). Incluye la
/// especial "Cielo ahora" + cualquier creada por el usuario.
/// - `FreeChartsRoot` → branch virtual de la sección "Cartas libres".
/// - `GeneralRoot` → nodo branch virtual que agrupa los contactos
/// sin grupo padre (contacts con parent=None).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum TreeSelection {
Group(GroupId),
Contact(ContactId),
Chart(ChartId),
FreeChart(FreeChartId),
FreeChartsRoot,
GeneralRoot,
}
// =====================================================================
// Errores
// =====================================================================
#[derive(Debug, Error)]
pub enum ModelError {
#[error("chart {kind:?} requiere related_chart_id pero recibió None")]
MissingRelatedChart { kind: ChartKind },
#[error("group {0} no puede ser su propio ancestro")]
GroupCycle(GroupId),
#[error("invalid field {field}: {reason}")]
InvalidField {
field: &'static str,
reason: String,
},
}
impl Chart {
/// Validación liviana: ataja errores que la base no captura
/// (ej. synastry sin `related_chart_id`).
pub fn validate(&self) -> Result<(), ModelError> {
if self.kind.requires_related_chart() && self.related_chart_id.is_none() {
return Err(ModelError::MissingRelatedChart { kind: self.kind });
}
if !(-90.0..=90.0).contains(&self.birth_data.latitude_deg) {
return Err(ModelError::InvalidField {
field: "latitude_deg",
reason: format!("{} fuera de [-90, 90]", self.birth_data.latitude_deg),
});
}
if !(-180.0..=180.0).contains(&self.birth_data.longitude_deg) {
return Err(ModelError::InvalidField {
field: "longitude_deg",
reason: format!("{} fuera de [-180, 180]", self.birth_data.longitude_deg),
});
}
Ok(())
}
}
@@ -0,0 +1,12 @@
[package]
name = "cosmobiologia-modules"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Tahuantinsuyu — registry de módulos astrológicos (Natal, Transit, Synastry, Uranian, …)."
[dependencies]
cosmobiologia-model = { path = "../cosmobiologia-model" }
cosmobiologia-engine = { path = "../cosmobiologia-engine" }
serde = { workspace = true }
serde_json = { workspace = true }
@@ -0,0 +1,955 @@
//! `cosmobiologia-modules` — registry de módulos astrológicos.
//!
//! Cada tipo de astrología (natal, tránsito, progresión, sinastría,
//! Uraniano, …) es un **módulo** que declara:
//!
//! - Qué `Layer`s aporta al `RenderModel`.
//! - Qué `Control`s expone al panel inferior (toggles, sliders, selects).
//! - Hotkeys opcionales.
//! - Si su cómputo es lazy (sólo cuando se activa) o eager.
//!
//! El registry es un `Vec<&dyn Module>` estático: el canvas consulta
//! "para esta `ChartKind`, ¿qué módulos están disponibles?" y el panel
//! pinta sus controles. Activar / desactivar persiste en
//! `ModuleState` (en la store).
//!
//! Esta fase 1 trae el trait + un módulo `NatalModule` de placeholder.
//! En fases posteriores agregamos Transit, Progression, Synastry,
//! Composite, SolarArc, Uranian, FixedStars, Dignities, Lots…
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use serde::{Deserialize, Serialize};
use cosmobiologia_engine::Layer;
use cosmobiologia_model::{Chart, ChartKind};
// =====================================================================
// Trait Module
// =====================================================================
/// Una capa de astrología enchufable.
///
/// `Send + Sync` para que el registry sea estático y se pueda consultar
/// desde cualquier thread (el cómputo pesado va a un background executor).
pub trait Module: Send + Sync {
/// Identidad estable del módulo. Coincide con `ModuleState.module_id`
/// en la store.
fn id(&self) -> &'static str;
/// Etiqueta amigable para el panel.
fn label(&self) -> &'static str;
/// Breve descripción para tooltip.
fn description(&self) -> &'static str;
/// Para qué tipos de carta tiene sentido este módulo. El panel filtra
/// con esto al armar la lista de toggles disponibles.
fn applies_to(&self, kind: ChartKind) -> bool;
/// Si el módulo está activado por default al crear una carta.
fn enabled_by_default(&self) -> bool {
false
}
/// Controles que aporta al panel inferior.
fn controls(&self) -> Vec<Control> {
Vec::new()
}
/// Computa las capas que este módulo aporta al RenderModel de
/// `chart`. La engine la llama solo si el módulo está activado
/// para esa carta.
///
/// Devuelve `Vec` (no Option) — un módulo puede no aportar capas
/// si su config interna lo apaga (ej. "Uranian: mostrar simetría
/// = false"); en ese caso retorna `Vec::new()`.
fn compute_layers(&self, chart: &Chart, config: &serde_json::Value) -> Vec<Layer>;
}
// =====================================================================
// Controls expuestos al panel
// =====================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Control {
Toggle {
key: String,
label: String,
default: bool,
hotkey: Option<String>,
},
Slider {
key: String,
label: String,
min: f64,
max: f64,
step: f64,
default: f64,
},
Select {
key: String,
label: String,
options: Vec<SelectOption>,
default: String,
},
/// Texto libre — útil para etiquetas, comentarios.
TextInput {
key: String,
label: String,
default: String,
},
/// Picker dinámico de una carta de la DB. Las opciones las inyecta
/// el host (Shell) en el panel — el módulo solo declara la
/// existencia del control. Valor emitido en `ControlChanged` =
/// `Value::String(chart_id)` cuando se selecciona, `Value::Null`
/// cuando se vuelve a "automático".
ChartPicker {
key: String,
label: String,
},
/// Botón sin estado — el click dispara un `PanelEvent::Action`
/// con `key`. El panel lo pinta como pill clickeable. Útil para
/// "Guardar como carta libre" en los módulos overlay con
/// transformación (RS, progresión, solar arc, GR).
Action {
key: String,
label: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SelectOption {
pub value: String,
pub label: String,
}
// =====================================================================
// Registry
// =====================================================================
/// Lista estática de módulos disponibles. La app los registra al boot.
pub struct Registry {
modules: Vec<Box<dyn Module>>,
}
impl Registry {
/// Registry con todos los módulos built-in. La app llama esto al
/// boot y luego usa `find()` / `for_kind()` para consultar.
pub fn with_builtins() -> Self {
let mut r = Self { modules: Vec::new() };
r.register(Box::new(natal::NatalModule));
r.register(Box::new(transit::TransitModule));
r.register(Box::new(progression::ProgressionModule));
r.register(Box::new(solar_arc::SolarArcModule));
r.register(Box::new(synastry::SynastryModule));
r.register(Box::new(planetary_return::PlanetaryReturnModule));
r.register(Box::new(midpoints::MidpointsModule));
r.register(Box::new(composite::CompositeModule));
r.register(Box::new(uranian::UranianModule));
r.register(Box::new(lots::LotsModule));
r.register(Box::new(fixed_stars::FixedStarsModule));
r.register(Box::new(topocentric::TopocentricModule));
r.register(Box::new(primary_directions::PrimaryDirectionsModule));
r
}
pub fn register(&mut self, m: Box<dyn Module>) {
self.modules.push(m);
}
pub fn all(&self) -> &[Box<dyn Module>] {
&self.modules
}
pub fn find(&self, id: &str) -> Option<&dyn Module> {
self.modules
.iter()
.find(|m| m.id() == id)
.map(|m| m.as_ref())
}
pub fn for_kind(&self, kind: ChartKind) -> Vec<&dyn Module> {
self.modules
.iter()
.filter(|m| m.applies_to(kind))
.map(|m| m.as_ref())
.collect()
}
}
// =====================================================================
// NatalModule — placeholder fase 1
// =====================================================================
pub mod natal {
use super::*;
use cosmobiologia_engine::compute_mock;
pub struct NatalModule;
impl Module for NatalModule {
fn id(&self) -> &'static str {
"natal"
}
fn label(&self) -> &'static str {
"Carta natal"
}
fn description(&self) -> &'static str {
"Posiciones natales, casas y aspectos."
}
fn applies_to(&self, kind: ChartKind) -> bool {
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
true
}
fn controls(&self) -> Vec<Control> {
vec![
Control::Toggle {
key: "show_sign_dial".into(),
label: "Dial zodiacal".into(),
default: true,
hotkey: Some("D".into()),
},
Control::Toggle {
key: "show_houses".into(),
label: "Casas".into(),
default: true,
hotkey: Some("H".into()),
},
Control::Toggle {
key: "show_aspects".into(),
label: "Aspectos".into(),
default: true,
hotkey: Some("X".into()),
},
Control::Toggle {
key: "show_bodies".into(),
label: "Cuerpos".into(),
default: true,
hotkey: Some("P".into()),
},
Control::Toggle {
key: "show_coords".into(),
label: "Coordenadas (grado°min')".into(),
default: true,
hotkey: Some("C".into()),
},
// Filtros de aspectos: cambian QUÉ se computa, no QUÉ
// se pinta del render. Recompose al togglear.
Control::Toggle {
key: "aspect_majors".into(),
label: "Mayores (☌ ☍ △ □ ⚹)".into(),
default: true,
hotkey: None,
},
Control::Toggle {
key: "aspect_minors".into(),
label: "Menores (quincunx, semi-…)".into(),
default: false,
hotkey: None,
},
Control::Slider {
key: "orb_multiplier".into(),
label: "Multiplicador de orbe".into(),
min: 0.25,
max: 2.5,
step: 0.25,
default: 1.0,
},
Control::Toggle {
key: "show_dignities".into(),
label: "Dignidades esenciales (+ · *)".into(),
default: false,
hotkey: None,
},
Control::Slider {
key: "harmonic".into(),
label: "Armónico".into(),
min: 1.0,
max: 20.0,
step: 1.0,
default: 1.0,
},
]
}
fn compute_layers(&self, chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
// Fase 1: delega al mock de la engine para que la UI tenga
// algo que pintar. Fase 3 reemplaza con `engine::compute`
// contra `eternal-astrology`.
compute_mock(chart).layers
}
}
}
// =====================================================================
// TransitModule — overlay del cielo del momento sobre la carta natal
// =====================================================================
pub mod transit {
use super::*;
/// Anillo externo con las posiciones planetarias del **instante
/// actual** (reloj de pared) sobre el sujeto natal, más las
/// cross-aspects natal × transit. La engine despacha al pipeline
/// `PipelineRequest::Transit` cuando este módulo está activo en el
/// `module_configs` del shell.
pub struct TransitModule;
impl Module for TransitModule {
fn id(&self) -> &'static str {
"transit"
}
fn label(&self) -> &'static str {
"Tránsitos"
}
fn description(&self) -> &'static str {
"Cielo del momento sobre la natal + cross aspects."
}
fn applies_to(&self, kind: ChartKind) -> bool {
// Por ahora solo overlay sobre cartas natales — más adelante
// podríamos overlayar tránsitos sobre Progresiones, etc.
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
false
}
fn controls(&self) -> Vec<Control> {
vec![
Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: Some("T".into()),
},
Control::Action {
key: "save_as_free".into(),
label: "💾 Guardar tránsito como carta libre".into(),
},
]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
// Las capas de tránsito se construyen en la engine vía
// `PipelineRequest::Transit` porque necesitan acceso a la
// NatalChart cruda + EphemerisSession. Este método queda
// como no-op — el módulo es puramente declarativo.
Vec::new()
}
}
}
// =====================================================================
// ProgressionModule — progresión secundaria (día por año)
// =====================================================================
pub mod progression {
use super::*;
/// Anillo interno con la carta progresada (método secundario,
/// "un día de efemérides = un año de vida") + cross aspects natal ×
/// progresada. La engine lo despacha vía
/// `PipelineRequest::SecondaryProgression { target_age_years }`.
pub struct ProgressionModule;
impl Module for ProgressionModule {
fn id(&self) -> &'static str {
"progression"
}
fn label(&self) -> &'static str {
"Progresión secundaria"
}
fn description(&self) -> &'static str {
"Día-por-año: avanza la carta a la edad actual."
}
fn applies_to(&self, kind: ChartKind) -> bool {
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
false
}
fn controls(&self) -> Vec<Control> {
vec![
Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: None,
},
// El default (30.0) es un placeholder — el shell empuja
// la edad actual del sujeto al cargar una carta vía
// panel.set_slider("progression", "target_age_years",
// current_age).
Control::Slider {
key: "target_age_years".into(),
label: "Edad objetivo (años)".into(),
min: 0.0,
max: 120.0,
step: 0.25,
default: 30.0,
},
Control::Action {
key: "save_as_free".into(),
label: "💾 Guardar progresada como carta libre".into(),
},
]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
}
}
}
// =====================================================================
// SynastryModule — bi-wheel con otra carta hermana del contacto actual
// =====================================================================
pub mod synastry {
use super::*;
/// Pone la carta del partner en el anillo externo (compartido con
/// Transit — mutuamente excluyentes) y dibuja las cross aspects
/// natal × partner. El shell elige el partner: la primera carta
/// hermana del mismo contacto. Si no hay hermana, el request se
/// salta silenciosamente.
pub struct SynastryModule;
impl Module for SynastryModule {
fn id(&self) -> &'static str {
"synastry"
}
fn label(&self) -> &'static str {
"Sinastría"
}
fn description(&self) -> &'static str {
"Bi-wheel con la primera carta hermana del contacto."
}
fn applies_to(&self, kind: ChartKind) -> bool {
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
false
}
fn controls(&self) -> Vec<Control> {
vec![
Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: None,
},
Control::ChartPicker {
key: "partner_chart_id".into(),
label: "Partner".into(),
},
]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
}
}
}
// =====================================================================
// PlanetaryReturnModule — retornos de cualquier cuerpo a su pos natal
// =====================================================================
pub mod planetary_return {
use super::*;
/// Computa la carta natal completa al instante del próximo retorno
/// del cuerpo elegido. Sun = anual (cumpleaños), Moon = mensual,
/// Júpiter/Saturno = generacionales. Comparte el outer ring con
/// Transit y Synastry — mutuamente excluyentes a nivel de Shell.
pub struct PlanetaryReturnModule;
impl Module for PlanetaryReturnModule {
fn id(&self) -> &'static str {
"planetary_return"
}
fn label(&self) -> &'static str {
"Retornos planetarios"
}
fn description(&self) -> &'static str {
"Carta del próximo retorno (Sol, Luna, Júpiter, Saturno…)."
}
fn applies_to(&self, kind: ChartKind) -> bool {
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
false
}
fn controls(&self) -> Vec<Control> {
vec![
Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: None,
},
Control::Select {
key: "body".into(),
label: "Cuerpo".into(),
default: "sun".into(),
options: vec![
SelectOption { value: "sun".into(), label: "Sol".into() },
SelectOption { value: "moon".into(), label: "Luna".into() },
SelectOption { value: "mercury".into(), label: "Mercurio".into() },
SelectOption { value: "venus".into(), label: "Venus".into() },
SelectOption { value: "mars".into(), label: "Marte".into() },
SelectOption { value: "jupiter".into(), label: "Júpiter".into() },
SelectOption { value: "saturn".into(), label: "Saturno".into() },
SelectOption { value: "uranus".into(), label: "Urano".into() },
SelectOption { value: "neptune".into(), label: "Neptuno".into() },
SelectOption { value: "pluto".into(), label: "Plutón".into() },
],
},
Control::Slider {
key: "target_age_years".into(),
label: "Edad del retorno".into(),
min: 0.0,
max: 120.0,
step: 1.0,
default: 30.0,
},
// Offset adicional para Moon return (saltar ~28d entre
// retornos lunares) o ajuste fino del Solar return.
Control::Slider {
key: "shift_days".into(),
label: "Shift días (lunar nav)".into(),
min: -180.0,
max: 180.0,
step: 1.0,
default: 0.0,
},
// Botón: captura la carta del retorno actual (cuerpo +
// edad) como FreeChart con label `{contacto} rs-{N}`
// (o `lunar-{N}` etc. según el cuerpo). El usuario
// luego decide si guardarla en un contacto.
Control::Action {
key: "save_as_free".into(),
label: "💾 Guardar retorno como carta libre".into(),
},
]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
}
}
}
// =====================================================================
// CompositeModule — carta compuesta (midpoint Davison) con un partner
// =====================================================================
pub mod composite {
use super::*;
/// Carta compuesta entre la natal y otra carta — cada placement es
/// el midpoint angular del par. Mismo ChartPicker que sinastría
/// para elegir el partner.
pub struct CompositeModule;
impl Module for CompositeModule {
fn id(&self) -> &'static str {
"composite"
}
fn label(&self) -> &'static str {
"Composite"
}
fn description(&self) -> &'static str {
"Carta compuesta con otro sujeto (midpoint Davison)."
}
fn applies_to(&self, kind: ChartKind) -> bool {
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
false
}
fn controls(&self) -> Vec<Control> {
vec![
Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: None,
},
Control::ChartPicker {
key: "partner_chart_id".into(),
label: "Partner".into(),
},
]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
}
}
}
// =====================================================================
// SolarArcModule — Solar Arc dirigido (true progressed Sun)
// =====================================================================
pub mod solar_arc {
use super::*;
/// Cada planeta y cusp natal se desplaza por el mismo arco
/// (≈ 1° por año de vida, calculado como el delta del Sol
/// progresado secundario). Anillo interno bien adentro + cross
/// aspects natal × dirigida.
pub struct SolarArcModule;
impl Module for SolarArcModule {
fn id(&self) -> &'static str {
"solar_arc"
}
fn label(&self) -> &'static str {
"Solar Arc"
}
fn description(&self) -> &'static str {
"Dirección por arco solar — uniforme, ≈1°/año."
}
fn applies_to(&self, kind: ChartKind) -> bool {
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
false
}
fn controls(&self) -> Vec<Control> {
vec![
Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: None,
},
Control::Slider {
key: "target_age_years".into(),
label: "Edad objetivo (años)".into(),
min: 0.0,
max: 120.0,
step: 0.25,
default: 30.0,
},
]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
}
}
}
// =====================================================================
// MidpointsModule — puntos medios entre cuerpos natales (Sol/Luna)
// =====================================================================
pub mod midpoints {
use super::*;
/// Computa midpoints entre los cuerpos natales (filtrado a los que
/// involucran Sol o Luna, ~10 puntos) y los renderea como pequeños
/// puntos en un anillo interior. Hovering muestra los dos cuerpos
/// que originan el midpoint.
pub struct MidpointsModule;
impl Module for MidpointsModule {
fn id(&self) -> &'static str {
"midpoints"
}
fn label(&self) -> &'static str {
"Midpoints"
}
fn description(&self) -> &'static str {
"Puntos medios que involucran al Sol o a la Luna."
}
fn applies_to(&self, kind: ChartKind) -> bool {
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
false
}
fn controls(&self) -> Vec<Control> {
vec![Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: None,
}]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_finds_builtins() {
let r = Registry::with_builtins();
assert!(r.find("natal").is_some());
assert!(r.find("transit").is_some());
assert!(r.find("progression").is_some());
assert!(r.find("solar_arc").is_some());
assert!(r.find("synastry").is_some());
assert!(r.find("planetary_return").is_some());
assert!(r.find("midpoints").is_some());
assert!(r.find("composite").is_some());
assert!(r.find("uranian").is_some());
assert!(r.find("lots").is_some());
assert!(r.find("fixed_stars").is_some());
// Natal kind tiene 11 módulos aplicables.
assert_eq!(r.for_kind(ChartKind::Natal).len(), 11);
assert!(r.for_kind(ChartKind::Synastry).is_empty());
}
}
// =====================================================================
// LotsModule — Lots helenísticos (Fortune, Spirit, Eros, …)
// =====================================================================
pub mod lots {
use super::*;
/// Calcula los 7 Lots arábigos clásicos via eternal-astrology y
/// los renderea como pequeños labels en un ring justo debajo de
/// los cuerpos natales. Hover muestra el nombre completo.
pub struct LotsModule;
impl Module for LotsModule {
fn id(&self) -> &'static str {
"lots"
}
fn label(&self) -> &'static str {
"Lots (helenísticos)"
}
fn description(&self) -> &'static str {
"Fortune, Spirit, Eros, Necessity, Courage, Victory, Nemesis."
}
fn applies_to(&self, kind: ChartKind) -> bool {
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
false
}
fn controls(&self) -> Vec<Control> {
vec![Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: None,
}]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
}
}
}
// =====================================================================
// FixedStarsModule — 9 estrellas astrológicamente notables
// =====================================================================
pub mod fixed_stars {
use super::*;
/// 9 estrellas fijas (Aldebaran, Regulus, Antares, Fomalhaut,
/// Spica, Sirius, Algol, Vega, Pollux) con posición tropical
/// aproximada (J2000 + precesión simple). Marcadores chicos en el
/// margen exterior del sign dial.
pub struct FixedStarsModule;
impl Module for FixedStarsModule {
fn id(&self) -> &'static str {
"fixed_stars"
}
fn label(&self) -> &'static str {
"Estrellas fijas"
}
fn description(&self) -> &'static str {
"9 estrellas notables — conjunciones con planetas natales."
}
fn applies_to(&self, kind: ChartKind) -> bool {
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
false
}
fn controls(&self) -> Vec<Control> {
vec![Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: None,
}]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
}
}
}
// =====================================================================
// UranianModule — ejes del dial uraniano de 90° (versión textual)
// =====================================================================
pub mod uranian {
use super::*;
/// Detecta "ejes" del dial uraniano: grupos de cuerpos natales cuya
/// longitud módulo 90 cae dentro de una tolerancia. Los grupos
/// resultantes se listan en el footer del canvas. La visualización
/// geométrica del dial completo de 90° queda para una fase futura.
pub struct UranianModule;
impl Module for UranianModule {
fn id(&self) -> &'static str {
"uranian"
}
fn label(&self) -> &'static str {
"Uraniano (90°)"
}
fn description(&self) -> &'static str {
"Ejes del dial uraniano — cuerpos en la misma posición mod 90."
}
fn applies_to(&self, kind: ChartKind) -> bool {
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
false
}
fn controls(&self) -> Vec<Control> {
vec![Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: None,
}]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
}
}
}
// =====================================================================
// TopocentricModule — capa "ascensional" (paralaje + Polich-Page)
// =====================================================================
pub mod topocentric {
use super::*;
/// Capa topocéntrica que convive con la natal geocéntrica: cada
/// planeta se re-proyecta a longitud eclíptica topocéntrica (con
/// paralaje horizontal por cuerpo) y las casas se calculan con el
/// sistema Polich-Page. El shift es visible en la Luna (~1°),
/// modesto en interiores cerca de oposición, e imperceptible en
/// exteriores. La engine despacha al pipeline
/// `PipelineRequest::Topocentric` cuando este módulo está activo.
pub struct TopocentricModule;
impl Module for TopocentricModule {
fn id(&self) -> &'static str {
"topocentric"
}
fn label(&self) -> &'static str {
"Topocéntrico (ascensional)"
}
fn description(&self) -> &'static str {
"Paralaje horizontal por cuerpo + casas Polich-Page."
}
fn applies_to(&self, kind: ChartKind) -> bool {
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
true
}
fn controls(&self) -> Vec<Control> {
vec![Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: true,
hotkey: None,
}]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
}
}
}
// =====================================================================
// PrimaryDirectionsModule — GR dual-ring (Direct + Converse)
// =====================================================================
pub mod primary_directions {
use super::*;
/// Direcciones Primarias del Sistema GR (García Rosas): cada
/// cuerpo natal se proyecta en dos rings — directa (rotación
/// diurna forward) y conversa (rotación inversa). El usuario
/// scrubea `target_age_years` para ver el movimiento en vivo.
/// Útil para rectificación: un evento real debe coincidir con
/// arcos directos y conversos consistentes si la hora natal es
/// correcta.
pub struct PrimaryDirectionsModule;
impl Module for PrimaryDirectionsModule {
fn id(&self) -> &'static str {
"primary_directions"
}
fn label(&self) -> &'static str {
"Direcciones primarias (GR)"
}
fn description(&self) -> &'static str {
"Dual-ring directas + conversas para rectificación en vivo."
}
fn applies_to(&self, kind: ChartKind) -> bool {
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
false
}
fn controls(&self) -> Vec<Control> {
vec![
Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: None,
},
Control::Slider {
key: "target_age_years".into(),
label: "Edad (años)".into(),
min: 0.0,
max: 120.0,
step: 0.05,
default: 30.0,
},
Control::Select {
key: "key".into(),
label: "Clave (arco/año)".into(),
default: "naibod".into(),
options: vec![
SelectOption {
value: "naibod".into(),
label: "Naibod (0°59'08\"/año)".into(),
},
SelectOption {
value: "ptolemy".into(),
label: "Ptolomeo (1°/año)".into(),
},
],
},
]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
}
}
}
@@ -0,0 +1,14 @@
[package]
name = "cosmobiologia-panel"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Tahuantinsuyu — panel de control inferior. Toggles, sliders y selectores por módulo de astrología."
[dependencies]
cosmobiologia-model = { path = "../cosmobiologia-model" }
cosmobiologia-modules = { path = "../cosmobiologia-modules" }
cosmobiologia-theme = { path = "../cosmobiologia-theme" }
yahweh-theme = { workspace = true }
gpui = { workspace = true }
serde_json = { workspace = true }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
[package]
name = "cosmobiologia-render"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Tahuantinsuyu — modelo y matemática de render agnósticos de surface. Compila a WASM y a nativo; el canvas gpui y el cliente web lo consumen para emitir las primitivas comunes de la rueda."
[dependencies]
cosmobiologia-model = { path = "../cosmobiologia-model" }
serde = { workspace = true }
[lib]
crate-type = ["rlib"]
@@ -0,0 +1,220 @@
//! `cosmobiologia-render` — modelo y matemática de render
//! **agnósticos de surface**. Lo consumen tanto el canvas gpui
//! (nativo, render Vulkan/Metal) como el cliente web (WASM, render
//! SVG / Canvas2D). Cualquier mejora del layout / spread / cluster /
//! coords vive acá una sola vez y aparece en ambos clientes.
//!
//! ## Por qué un crate aparte
//!
//! `cosmobiologia-engine` arrastra `eternal-sky` (VSOP2013 + I/O de
//! tablas) que **no compila a WASM** sin empaquetar 30+ MB de
//! efemérides. Los tipos del `RenderModel` en sí son serde puro y
//! sí compilan a WASM — extraerlos a este crate libera al cliente
//! web de la dependencia transitiva.
//!
//! ## Capas
//!
//! 1. **Modelo de render** — `RenderModel`, `Layer`, `Glyph`,
//! `LineSeg`, `Geometry`, `LayerKind`. Estructuras serde-friendly
//! que el engine emite y los clients consumen.
//! 2. **Matemática agnóstica** *(módulos siguientes, no en esta primera
//! versión)* — `polar_to_screen`, `spread_angles`, `find_clusters`,
//! `format_coord_compact`, `Radii`. Migran desde el canvas gpui.
//! 3. **`DrawCommand`** *(módulo siguiente)* — primitivas de pintura
//! (line, circle, glyph, pill) que cada surface traduce a su API.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use serde::{Deserialize, Serialize};
pub use cosmobiologia_model::{Chart, ChartId, ChartKind};
pub mod math;
pub use math::{
find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii,
};
// =====================================================================
// RenderModel — lo que el client renderea
// =====================================================================
/// Resultado agnóstico de un cómputo astrológico, listo para renderizar.
/// El canvas gpui y el cliente web lo consumen idénticamente: el engine
/// computa (en nativo, con eternal) y publica este struct.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenderModel {
pub chart_id: ChartId,
pub chart_kind: ChartKind,
pub title: String,
#[serde(default)]
pub subtitle: Option<String>,
pub compute_ms: u64,
// ─── Ángulos del chart (grados eclípticos, 0..360) ───────────────
/// Ascendente — punto fijo de rotación del lienzo. La rueda se gira
/// de modo que el Asc cae a las 9 (lado izquierdo).
pub ascendant_deg: f32,
pub midheaven_deg: f32,
pub descendant_deg: f32,
pub imum_coeli_deg: f32,
/// Capas a pintar. Orden = z-order ascendente.
pub layers: Vec<Layer>,
/// Metadata humana por overlay activo (transit, progresión,
/// sinastría, retorno...). Vacío para una carta natal pura. La UI
/// la pinta como badges en el footer.
#[serde(default)]
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>,
/// Grupos uranianos detectados (cuerpos en la misma posición mod 90).
/// Vacío sino se activó el módulo Uranian.
#[serde(default)]
pub uranian_groups: Vec<UranianGroup>,
}
/// Etiqueta legible de un overlay para el footer del canvas. La engine
/// la pushea desde cada `build_*_overlay`; el canvas solo lee y pinta.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OverlayMeta {
pub module_id: String,
/// Etiqueta corta — ej. "Tránsito ahora", "Progresión 38.2a",
/// "Sinastría · Ana", "Saturn return 29a".
pub label: String,
}
/// Grupo de cuerpos natales que caen en la misma posición del
/// dial uraniano de 90° (su longitud zodiacal módulo 90 es igual o
/// muy cercana). En la astrología uraniana esto es una "fórmula" o
/// "axis" — los cuerpos están en correspondencia simbólica directa
/// porque comparten un cuadrante simétrico.
///
/// Solo se emiten grupos con 2+ miembros (los singletons no son
/// fórmulas). La engine los ordena por proximidad al ε de tolerancia.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UranianGroup {
/// Identificadores agnósticos de los cuerpos en el grupo
/// (ej. `["sun", "jupiter", "saturn"]`).
pub bodies: Vec<String>,
/// Posición en el dial de 90° (la longitud módulo 90).
pub mod90_deg: f64,
}
/// 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)]
pub struct Layer {
pub module_id: String,
pub kind: LayerKind,
/// Radio normalizado [0, 1] sobre el lienzo — el canvas lo convierte
/// a píxeles. Permite stack de anillos.
pub ring: f32,
#[serde(default)]
pub z: i32,
pub geometry: Geometry,
#[serde(default)]
pub glyphs: Vec<Glyph>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LayerKind {
SignDial,
Houses,
Bodies,
Aspects,
Lots,
FixedStars,
Midpoints,
Outer,
Custom,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Geometry {
GlyphsOnly,
/// Anillo dividido en sectores. `cusps_deg` son los grados
/// zodiacales donde van las divisiones radiales.
Ring { cusps_deg: Vec<f32> },
Lines(Vec<LineSeg>),
Points(Vec<PointMark>),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LineSeg {
/// Grados zodiacales del extremo "a".
pub from_deg: f32,
/// Grados zodiacales del extremo "b".
pub to_deg: f32,
/// Categoría simbólica (`"conjunction"`, `"trine"`, …) — el theme la
/// resuelve a color.
pub kind: String,
pub opacity: f32,
/// Cuerpo en el extremo "a" — populado para LineSegs de aspectos
/// (natal × natal, cross con overlays). Vacío en `Default::default`
/// para serde back-compat.
#[serde(default)]
pub from_body: String,
/// Cuerpo en el extremo "b".
#[serde(default)]
pub to_body: String,
/// Orb absoluto en grados (para tooltips).
#[serde(default)]
pub orb_deg: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PointMark {
pub deg: f32,
pub label: String,
pub tag: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Glyph {
/// Grado eclíptico [0, 360).
pub deg: f32,
/// Glyph simbólico — el theme/canvas lo mapea a unicode o imagen.
/// Ej: `"sun"`, `"moon"`, `"aries"`, `"asc"`, `"mc"`.
pub symbol: String,
#[serde(default)]
pub annotation: Option<String>,
#[serde(default)]
pub retrograde: bool,
#[serde(default)]
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>,
}
/// Módulos overlay que pintan en el mismo slot (outer ring del wheel)
/// y por lo tanto son **mutuamente excluyentes** a nivel de UI: al
/// prender uno, el shell debe apagar los otros. Single source of truth
/// — el shell y el canvas leen de acá en vez de hardcodear listas.
pub const OUTER_RING_MODULES: &[&str] = &["transit", "synastry", "planetary_return"];
@@ -0,0 +1,396 @@
//! Matemática agnóstica de surface — radios canónicos del wheel,
//! conversión polar → pantalla, spread anti-solapamiento, detección
//! de clusters, formato de coordenadas.
//!
//! Vive aquí (no en el canvas gpui) porque exactamente la misma
//! lógica corre en el cliente web (WASM) y en la app desktop. Cualquier
//! ajuste de geometría aparece en ambos a la vez.
use core::f32::consts::PI;
use crate::OUTER_RING_MODULES;
// =====================================================================
// Radii — geometría radial canónica de la rueda
// =====================================================================
/// Geometría radial canónica del wheel. Aros nombrados según convención
/// de Sergio, de afuera hacia adentro:
///
/// * **Aro A** (`sign_outer`) — externo del zodiaco.
/// * **Zona AB** — sign dial: glyphs de signos zodiacales.
/// * **Aro B** (`sign_inner` = `topo_houses_outer`) — interno del
/// zodiaco / externo del bloque ascensional.
/// * **Zona BC** — casas topocéntricas (cusps b→c) + planetas
/// topocéntricos, ambos con sus coordenadas.
/// * **Aro C** (`topo_houses_inner` = `houses_outer`) — separador
/// ascensional / casas geo.
/// * **Zona CD** — casas geocéntricas (cusps c→d) + sus coordenadas.
/// * **Aro D** (`houses_inner`) — externo de los planetas natales.
/// Junto a D, hacia adentro, se posan los planetas natales y sus
/// coordenadas.
/// * **Aro E** (`aspects`) — el más interno. Desde aquí nacen las
/// líneas de aspecto / relaciones / overlays opcionales.
///
/// Los overlays adicionales (transits, midpoints, progression, solar
/// arc, composite) viven INTERIORES al aro E — solo se pintan
/// cuando el módulo correspondiente está activo, así no compiten
/// con el layout base.
#[derive(Clone, Copy, Debug)]
pub struct Radii {
pub sign_outer: f32, // Aro A
pub sign_inner: f32, // Aro B
pub topo_houses_outer: f32, // = Aro B
pub topocentric: f32, // Zona BC: planetas topo
pub topo_houses_inner: f32, // Aro C
pub houses_outer: f32, // = Aro C
pub houses_inner: f32, // Aro D
pub bodies: f32, // Zona D-E: planetas natales (junto a D)
pub pd_direct: f32, // GR (cuando activo): exterior al cinturón natal
pub pd_converse: f32, // GR (cuando activo): interior al cinturón natal
pub aspects: f32, // Aro E (invisible, ancla de líneas)
// Overlays adicionales — todos interiores a E.
pub transits: f32,
pub midpoints: f32,
pub progression: f32,
pub solar_arc: f32,
pub composite: f32,
}
impl Radii {
pub fn from_outer(r: f32) -> Self {
Self {
sign_outer: r,
sign_inner: r * 0.92,
topo_houses_outer: r * 0.92,
topocentric: r * 0.85,
topo_houses_inner: r * 0.78,
houses_outer: r * 0.78,
houses_inner: r * 0.62,
bodies: r * 0.57,
pd_direct: r * 0.545,
pd_converse: r * 0.515,
aspects: r * 0.49,
transits: r * 0.43,
midpoints: r * 0.39,
progression: r * 0.33,
solar_arc: r * 0.27,
composite: r * 0.21,
}
}
/// Radio del ring de cuerpos según el `module_id` del Layer.
pub fn body_ring(&self, module_id: &str) -> f32 {
match module_id {
"progression" => self.progression,
"solar_arc" => self.solar_arc,
"composite" => self.composite,
"midpoints" => self.midpoints,
"topocentric" => self.topocentric,
"pd_direct" => self.pd_direct,
"pd_converse" => self.pd_converse,
_ => self.bodies,
}
}
/// Resuelve qué radios corresponden a una capa de aspectos según el
/// `module_id`: natal-natal en `aspects`, cross con cada overlay
/// desde `bodies` (extremo natal) al ring del módulo. Los módulos
/// del outer ring (OUTER_RING_MODULES) comparten el slot de
/// tránsito (son mutuamente excluyentes a nivel de Shell).
pub fn aspect_endpoints(&self, module_id: &str) -> (f32, f32) {
if OUTER_RING_MODULES.contains(&module_id) {
return (self.bodies, self.transits);
}
match module_id {
"progression" => (self.bodies, self.progression),
"solar_arc" => (self.bodies, self.solar_arc),
"composite" => (self.bodies, self.composite),
_ => (self.aspects, self.aspects),
}
}
}
// =====================================================================
// polar_to_screen — convención de rotación del wheel
// =====================================================================
/// Convierte una longitud eclíptica a coords cartesianas relativas al
/// centro del wheel. Convención: el Ascendente cae a las 9 (lado
/// izquierdo). `rot_offset_deg` permite rotar la vista (jog-dial).
pub fn polar_to_screen(
longitude_deg: f32,
ascendant_deg: f32,
rot_offset_deg: f32,
radius: f32,
) -> (f32, f32) {
let deg = 180.0 - (longitude_deg - ascendant_deg + rot_offset_deg);
let rad = deg * PI / 180.0;
(radius * rad.cos(), radius * rad.sin())
}
// =====================================================================
// Spread anti-solapamiento de glyphs
// =====================================================================
/// Reposiciona angularmente un conjunto de longitudes para que pares
/// adyacentes mantengan al menos `min_sep_deg` de separación, **sin
/// que ningún glyph se aleje más de `max_shift_deg` de su posición
/// real**. La acotación es clave para evitar que un cluster denso
/// "empuje" a planetas que estaban lejos.
///
/// Algoritmo: iteramos hasta 80 veces; en cada pasada re-ordenamos
/// los displays para mantener el orden circular, y en cada par
/// adyacente que esté muy cerca acumulamos fuerzas en sentidos
/// opuestos. Aplicamos las fuerzas con `damping = 0.6` y clampeamos
/// cada display al rango `[raw[i] - max_shift, raw[i] + max_shift]`.
/// Si el cluster es tan denso que el clamp impide alcanzar el
/// `min_sep`, el residual queda alto y el caller encoge los discos.
///
/// Devuelve `(displays, residual)` con `residual ∈ [0, 1]` =
/// fracción de presión no resuelta tras el clamp.
pub fn spread_angles(
angles_deg: &[f32],
min_sep_deg: f32,
max_shift_deg: f32,
) -> (Vec<f32>, f32) {
let n = angles_deg.len();
if n <= 1 {
return (angles_deg.to_vec(), 0.0);
}
if (n as f32) * min_sep_deg >= 360.0 {
return (angles_deg.to_vec(), 1.0);
}
let raw: Vec<f32> = angles_deg.iter().map(|a| a.rem_euclid(360.0)).collect();
let mut displays: Vec<f32> = raw.clone();
let mut last_residual = 0.0_f32;
let clamp_to_raw = |display: f32, raw: f32, max_shift: f32| -> f32 {
let mut delta = display - raw;
if delta > 180.0 {
delta -= 360.0;
}
if delta < -180.0 {
delta += 360.0;
}
let clamped = delta.clamp(-max_shift, max_shift);
(raw + clamped).rem_euclid(360.0)
};
let damping: f32 = 0.6;
for _ in 0..80 {
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| {
displays[a]
.partial_cmp(&displays[b])
.unwrap_or(core::cmp::Ordering::Equal)
});
let mut forces = vec![0.0_f32; n];
let mut max_residual: f32 = 0.0;
for k in 0..n {
let i = order[k];
let j = order[(k + 1) % n];
let diff = (displays[j] - displays[i]).rem_euclid(360.0);
if diff < min_sep_deg {
let push = (min_sep_deg - diff) / 2.0;
forces[i] -= push;
forces[j] += push;
let r = (min_sep_deg - diff) / min_sep_deg;
if r > max_residual {
max_residual = r;
}
}
}
for i in 0..n {
let stepped = (displays[i] + forces[i] * damping).rem_euclid(360.0);
displays[i] = clamp_to_raw(stepped, raw[i], max_shift_deg);
}
last_residual = max_residual;
if max_residual < 0.001 {
break;
}
}
(displays, last_residual)
}
/// Detecta clusters de longitudes angularmente cercanas. Dos
/// elementos están en el mismo cluster si su separación circular es
/// menor a `threshold_deg`. Devuelve los índices originales
/// agrupados; cada Vec interno representa un cluster (incluso si
/// es de tamaño 1). Cluster con wrap-around (último→primero) se
/// fusionan correctamente.
pub fn find_clusters(angles_deg: &[f32], threshold_deg: f32) -> Vec<Vec<usize>> {
let n = angles_deg.len();
if n == 0 {
return Vec::new();
}
let mut idxed: Vec<(usize, f32)> = angles_deg
.iter()
.copied()
.map(|a| a.rem_euclid(360.0))
.enumerate()
.collect();
idxed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal));
let mut clusters: Vec<Vec<usize>> = Vec::new();
let mut cur: Vec<usize> = vec![idxed[0].0];
let mut last = idxed[0].1;
for (idx, a) in idxed.iter().skip(1).copied() {
if (a - last) < threshold_deg {
cur.push(idx);
} else {
clusters.push(core::mem::take(&mut cur));
cur.push(idx);
}
last = a;
}
clusters.push(cur);
if clusters.len() >= 2 {
let first_a = angles_deg[clusters[0][0]].rem_euclid(360.0);
let last_a = angles_deg[*clusters.last().unwrap().last().unwrap()].rem_euclid(360.0);
let wrap_diff = 360.0 - last_a + first_a;
if wrap_diff < threshold_deg {
let mut tail = clusters.pop().unwrap();
tail.extend(clusters[0].iter().copied());
clusters[0] = tail;
}
}
clusters
}
// =====================================================================
// Coord formatter
// =====================================================================
/// Formato compacto con precisión de minutos: "DD°MM'{signo}" donde
/// el signo es el glyph zodiacal (♈♉♊…). Ej: 14.93° → "14°56'♈".
/// Los minutos se redondean al entero más cercano; carry-overs entre
/// signos están cubiertos por trabajar en minutos enteros absolutos.
pub fn format_coord_compact(deg: f32) -> String {
let normalized = deg.rem_euclid(360.0);
let total_minutes = (normalized * 60.0).round() as i64;
let total_minutes = total_minutes.rem_euclid(360 * 60);
let sign_idx = (total_minutes / (30 * 60)) as usize % 12;
let within_sign = total_minutes - (sign_idx as i64) * 30 * 60;
let deg_int = (within_sign / 60) as i32;
let minutes = (within_sign % 60) as i32;
let sign_glyph = match sign_idx {
0 => "",
1 => "",
2 => "",
3 => "",
4 => "",
5 => "",
6 => "",
7 => "",
8 => "",
9 => "",
10 => "",
_ => "",
};
format!("{}°{:02}'{}", deg_int, minutes, sign_glyph)
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_min_sep(displays: &[f32], min_sep: f32) {
let n = displays.len();
let mut sorted = displays.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let tol = min_sep * 0.02;
for i in 0..n {
let nxt = (i + 1) % n;
let diff = (sorted[nxt] - sorted[i]).rem_euclid(360.0);
assert!(
diff + tol >= min_sep,
"vecinos {} y {} a {}° (mínimo {})",
sorted[i],
sorted[nxt],
diff,
min_sep
);
}
}
#[test]
fn spread_empty_and_single_unchanged() {
let (r, residual) = spread_angles(&[], 10.0, 30.0);
assert!(r.is_empty());
assert_eq!(residual, 0.0);
let (r, residual) = spread_angles(&[42.0], 10.0, 30.0);
assert_eq!(r, vec![42.0]);
assert_eq!(residual, 0.0);
}
#[test]
fn spread_spaced_input_left_alone() {
let input = vec![0.0, 30.0, 90.0, 200.0];
let (out, residual) = spread_angles(&input, 10.0, 30.0);
assert!(residual < 0.001);
for (a, b) in input.iter().zip(out.iter()) {
assert!((a - b).abs() < 1e-3, "{} vs {}", a, b);
}
}
#[test]
fn spread_tight_cluster_gets_spread() {
let input = vec![100.0, 101.0, 102.0];
let (out, residual) = spread_angles(&input, 10.0, 30.0);
assert!(residual < 0.05, "residual {}", residual);
assert_min_sep(&out, 10.0);
}
#[test]
fn spread_shift_is_bounded() {
let input = vec![100.0, 101.0];
let (out, _) = spread_angles(&input, 10.0, 2.0);
for (raw, disp) in input.iter().zip(out.iter()) {
let mut delta = (disp - raw).abs();
if delta > 180.0 {
delta = 360.0 - delta;
}
assert!(delta <= 2.0 + 0.01, "shift {} > 2°", delta);
}
}
#[test]
fn spread_distant_planet_unaffected_by_dense_cluster() {
let input = vec![100.0, 100.5, 101.0, 200.0];
let (out, _) = spread_angles(&input, 10.0, 10.0);
let mut delta = (out[3] - 200.0).abs();
if delta > 180.0 {
delta = 360.0 - delta;
}
assert!(delta < 5.0, "planeta lejano se movió {}°", delta);
}
#[test]
fn coord_zero_aries() {
assert_eq!(format_coord_compact(0.0), "0°00'♈");
}
#[test]
fn coord_fourteen_fiftysix_aries() {
assert_eq!(format_coord_compact(14.933_3), "14°56'♈");
}
#[test]
fn coord_rollover_to_taurus() {
assert_eq!(format_coord_compact(29.9995), "0°00'♉");
}
#[test]
fn coord_negative_wraps() {
assert_eq!(format_coord_compact(-10.0), "20°00'♓");
}
#[test]
fn polar_to_screen_asc_on_left() {
// Si la longitud = asc, el punto cae a las 9 (x = -radius, y = 0).
let (x, y) = polar_to_screen(120.0, 120.0, 0.0, 100.0);
assert!((x - (-100.0)).abs() < 1e-3, "x={}", x);
assert!(y.abs() < 1e-3, "y={}", y);
}
}
@@ -0,0 +1,14 @@
[package]
name = "cosmobiologia-store"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Tahuantinsuyu — persistencia SQLite de groups / contacts / charts / module_state."
[dependencies]
cosmobiologia-model = { path = "../cosmobiologia-model" }
rusqlite = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
ulid = { workspace = true }
@@ -0,0 +1,760 @@
//! `cosmobiologia-store` — persistencia SQLite del estudio astrológico.
//!
//! Una sola conexión `rusqlite` envuelta en `Arc<Mutex>` para que la app
//! GPUI la comparta entre threads sin pelearse con el ownership. La
//! migración inicial corre la primera vez que se abre un archivo nuevo
//! (idempotente vía `CREATE TABLE IF NOT EXISTS`).
//!
//! Patrón inspirado en `yahweh_provider_sqlite::SqliteDataProvider` pero
//! con dominio propio (no extiende el `DataProvider` agnóstico — esa
//! integración viene en `cosmobiologia-tree` que envuelve este store
//! detrás del trait de yahweh).
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use rusqlite::{Connection, OptionalExtension, params};
use thiserror::Error;
use cosmobiologia_model::{
Chart, ChartId, ChartKind, Contact, ContactId, Group, GroupId, ModuleState, StoredBirthData,
StoredChartConfig,
};
const SCHEMA_VERSION: i32 = 1;
#[derive(Debug, Error)]
pub enum StoreError {
#[error("sqlite: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("schema downgrade: db is at v{found}, code expects v{expected}")]
SchemaDowngrade { found: i32, expected: i32 },
#[error("ulid decode: {0}")]
UlidDecode(#[from] ulid::DecodeError),
#[error("model invariant: {0}")]
Model(#[from] cosmobiologia_model::ModelError),
#[error("not found: {0}")]
NotFound(String),
}
pub type StoreResult<T> = Result<T, StoreError>;
/// Store backed by a single SQLite file.
///
/// Clone-able: comparte la misma conexión bajo el mutex. Útil para que
/// distintos widgets (tree, panel, canvas) compartan una vista
/// consistente sin pasar `&mut` por todos lados.
#[derive(Clone)]
pub struct Store {
conn: Arc<Mutex<Connection>>,
}
impl Store {
/// Abre (o crea) un archivo SQLite y corre las migraciones.
pub fn open(path: impl AsRef<Path>) -> StoreResult<Self> {
let conn = Connection::open(path)?;
let store = Self {
conn: Arc::new(Mutex::new(conn)),
};
store.migrate()?;
Ok(store)
}
/// Variante in-memory para tests.
pub fn in_memory() -> StoreResult<Self> {
let conn = Connection::open_in_memory()?;
let store = Self {
conn: Arc::new(Mutex::new(conn)),
};
store.migrate()?;
Ok(store)
}
fn migrate(&self) -> StoreResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute_batch(MIGRATION_V1)?;
let found: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
if found > SCHEMA_VERSION {
return Err(StoreError::SchemaDowngrade {
found,
expected: SCHEMA_VERSION,
});
}
if found < SCHEMA_VERSION {
conn.execute(&format!("PRAGMA user_version = {}", SCHEMA_VERSION), [])?;
}
Ok(())
}
// -----------------------------------------------------------------
// Groups
// -----------------------------------------------------------------
pub fn create_group(
&self,
parent_id: Option<GroupId>,
name: &str,
description: Option<&str>,
) -> StoreResult<Group> {
let group = Group {
id: GroupId::new(),
parent_id,
name: name.into(),
description: description.map(String::from),
created_at_ms: now_ms(),
sort_order: 0,
};
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO groups (id, parent_id, name, description, created_at_ms, sort_order) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
group.id.to_string(),
group.parent_id.map(|g| g.to_string()),
group.name,
group.description,
group.created_at_ms,
group.sort_order,
],
)?;
Ok(group)
}
pub fn list_groups(&self, parent_id: Option<GroupId>) -> StoreResult<Vec<Group>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, parent_id, name, description, created_at_ms, sort_order \
FROM groups WHERE parent_id IS ?1 \
ORDER BY sort_order ASC, name COLLATE NOCASE ASC",
)?;
let parent_str = parent_id.map(|g| g.to_string());
let rows = stmt.query_map(params![parent_str], row_to_group)?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}
pub fn delete_group(&self, id: GroupId) -> StoreResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM groups WHERE id = ?1", params![id.to_string()])?;
Ok(())
}
pub fn rename_group(&self, id: GroupId, name: &str) -> StoreResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE groups SET name = ?2 WHERE id = ?1",
params![id.to_string(), name],
)?;
Ok(())
}
/// Cambia el `parent_id` de un Group. Pasar `None` para mover a raíz.
/// **No** valida ciclos — el caller debe garantizar que el nuevo
/// padre no sea descendiente del que mueve (sino la DB queda con un
/// ciclo que el list_groups no rompe pero hace al CTE infinito).
pub fn move_group(&self, id: GroupId, new_parent: Option<GroupId>) -> StoreResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE groups SET parent_id = ?2 WHERE id = ?1",
params![id.to_string(), new_parent.map(|g| g.to_string())],
)?;
Ok(())
}
// -----------------------------------------------------------------
// Contacts
// -----------------------------------------------------------------
pub fn create_contact(
&self,
group_id: Option<GroupId>,
name: &str,
notes: Option<&str>,
) -> StoreResult<Contact> {
let c = Contact {
id: ContactId::new(),
group_id,
name: name.into(),
notes: notes.map(String::from),
created_at_ms: now_ms(),
};
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO contacts (id, group_id, name, notes, created_at_ms) \
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
c.id.to_string(),
c.group_id.map(|g| g.to_string()),
c.name,
c.notes,
c.created_at_ms,
],
)?;
Ok(c)
}
pub fn list_contacts(&self, group_id: Option<GroupId>) -> StoreResult<Vec<Contact>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, group_id, name, notes, created_at_ms \
FROM contacts WHERE group_id IS ?1 \
ORDER BY name COLLATE NOCASE ASC",
)?;
let g = group_id.map(|g| g.to_string());
let rows = stmt.query_map(params![g], row_to_contact)?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}
pub fn delete_contact(&self, id: ContactId) -> StoreResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM contacts WHERE id = ?1", params![id.to_string()])?;
Ok(())
}
pub fn rename_contact(&self, id: ContactId, name: &str) -> StoreResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE contacts SET name = ?2 WHERE id = ?1",
params![id.to_string(), name],
)?;
Ok(())
}
pub fn move_contact(&self, id: ContactId, new_group: Option<GroupId>) -> StoreResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE contacts SET group_id = ?2 WHERE id = ?1",
params![id.to_string(), new_group.map(|g| g.to_string())],
)?;
Ok(())
}
// -----------------------------------------------------------------
// Charts
// -----------------------------------------------------------------
pub fn create_chart(
&self,
contact_id: ContactId,
kind: ChartKind,
label: &str,
birth: &StoredBirthData,
config: &StoredChartConfig,
related_chart_id: Option<ChartId>,
) -> StoreResult<Chart> {
let chart = Chart {
id: ChartId::new(),
contact_id,
kind,
label: label.into(),
birth_data: birth.clone(),
config: config.clone(),
related_chart_id,
created_at_ms: now_ms(),
};
chart.validate()?;
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO charts \
(id, contact_id, kind, label, birth_data_json, config_json, \
related_chart_id, created_at_ms) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
chart.id.to_string(),
chart.contact_id.to_string(),
serde_json::to_string(&chart.kind)?,
chart.label,
serde_json::to_string(&chart.birth_data)?,
serde_json::to_string(&chart.config)?,
chart.related_chart_id.map(|c| c.to_string()),
chart.created_at_ms,
],
)?;
Ok(chart)
}
pub fn list_charts(&self, contact_id: ContactId) -> StoreResult<Vec<Chart>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, contact_id, kind, label, birth_data_json, config_json, \
related_chart_id, created_at_ms \
FROM charts WHERE contact_id = ?1 \
ORDER BY created_at_ms ASC",
)?;
let rows = stmt.query_map(params![contact_id.to_string()], row_to_chart)?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
.and_then(|v| v.into_iter().collect::<StoreResult<Vec<_>>>())
}
/// Lista todas las cartas del DB ordenadas por label (case-insensitive).
/// Pensado para pickers / selectores cross-contact (ej. elegir un
/// partner de sinastría desde cualquier contacto).
pub fn list_all_charts(&self) -> StoreResult<Vec<Chart>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, contact_id, kind, label, birth_data_json, config_json, \
related_chart_id, created_at_ms \
FROM charts ORDER BY label COLLATE NOCASE ASC",
)?;
let rows = stmt.query_map([], row_to_chart)?;
let mut out = Vec::new();
for r in rows {
out.push(r??);
}
Ok(out)
}
pub fn get_chart(&self, id: ChartId) -> StoreResult<Chart> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, contact_id, kind, label, birth_data_json, config_json, \
related_chart_id, created_at_ms \
FROM charts WHERE id = ?1",
)?;
let chart = stmt
.query_row(params![id.to_string()], row_to_chart)
.optional()?;
match chart {
Some(Ok(c)) => Ok(c),
Some(Err(e)) => Err(e),
None => Err(StoreError::NotFound(format!("chart {}", id))),
}
}
pub fn delete_chart(&self, id: ChartId) -> StoreResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM charts WHERE id = ?1", params![id.to_string()])?;
Ok(())
}
pub fn rename_chart(&self, id: ChartId, label: &str) -> StoreResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE charts SET label = ?2 WHERE id = ?1",
params![id.to_string(), label],
)?;
Ok(())
}
/// Reemplaza label + birth_data + config de una carta existente,
/// preservando id / contact_id / related_chart_id / created_at_ms y
/// el `module_state` asociado (no se borra). Usado por el editor de
/// rectificación natal.
pub fn update_chart(
&self,
id: ChartId,
label: &str,
birth: &StoredBirthData,
config: &StoredChartConfig,
) -> StoreResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE charts SET label = ?2, birth_data_json = ?3, config_json = ?4 \
WHERE id = ?1",
params![
id.to_string(),
label,
serde_json::to_string(birth)?,
serde_json::to_string(config)?,
],
)?;
Ok(())
}
// -----------------------------------------------------------------
// Module state
// -----------------------------------------------------------------
pub fn upsert_module_state(&self, state: &ModuleState) -> StoreResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO module_state (chart_id, module_id, enabled, config_json) \
VALUES (?1, ?2, ?3, ?4) \
ON CONFLICT(chart_id, module_id) DO UPDATE SET \
enabled = excluded.enabled, \
config_json = excluded.config_json",
params![
state.chart_id.to_string(),
state.module_id,
state.enabled as i32,
state.config.to_string(),
],
)?;
Ok(())
}
pub fn list_module_states(&self, chart_id: ChartId) -> StoreResult<Vec<ModuleState>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT chart_id, module_id, enabled, config_json \
FROM module_state WHERE chart_id = ?1",
)?;
let rows = stmt.query_map(params![chart_id.to_string()], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i32>(2)?,
row.get::<_, String>(3)?,
))
})?;
let mut out = Vec::new();
for r in rows {
let (chart_str, module_id, enabled, config_str) = r?;
out.push(ModuleState {
chart_id: chart_str
.parse()
.map_err(|e: ulid::DecodeError| StoreError::UlidDecode(e))?,
module_id,
enabled: enabled != 0,
config: serde_json::from_str(&config_str).unwrap_or(serde_json::Value::Null),
});
}
Ok(out)
}
// -----------------------------------------------------------------
// Settings (key/value libre — layout, last-opened chart, etc.)
// -----------------------------------------------------------------
/// Lee un valor de la tabla `settings`. `None` si no existe.
pub fn get_setting(&self, key: &str) -> StoreResult<Option<String>> {
let conn = self.conn.lock().unwrap();
let val = conn
.query_row(
"SELECT value FROM settings WHERE key = ?1",
params![key],
|row| row.get::<_, String>(0),
)
.optional()?;
Ok(val)
}
/// Upsert un setting. El valor es texto libre — para JSON, el caller
/// serializa antes de llamar.
pub fn set_setting(&self, key: &str, value: &str) -> StoreResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO settings (key, value) VALUES (?1, ?2) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value],
)?;
Ok(())
}
// -----------------------------------------------------------------
// Recursive descent: charts under a group/contact (para thumbnails)
// -----------------------------------------------------------------
/// Devuelve todas las cartas que descienden de un Group (incluyendo
/// los Contacts de sub-groups recursivamente).
pub fn charts_under_group(&self, root: GroupId) -> StoreResult<Vec<Chart>> {
let conn = self.conn.lock().unwrap();
// CTE recursivo para listar todos los descendientes del group.
let mut stmt = conn.prepare(
"WITH RECURSIVE descendants(id) AS ( \
SELECT ?1 \
UNION ALL \
SELECT g.id FROM groups g JOIN descendants d ON g.parent_id = d.id \
) \
SELECT c.id, c.contact_id, c.kind, c.label, c.birth_data_json, c.config_json, \
c.related_chart_id, c.created_at_ms \
FROM charts c \
JOIN contacts ct ON ct.id = c.contact_id \
WHERE ct.group_id IN descendants \
ORDER BY c.created_at_ms ASC",
)?;
let rows = stmt.query_map(params![root.to_string()], row_to_chart)?;
let mut out = Vec::new();
for r in rows {
out.push(r??);
}
Ok(out)
}
}
// =====================================================================
// SQL schema
// =====================================================================
const MIGRATION_V1: &str = r#"
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY,
parent_id TEXT,
name TEXT NOT NULL,
description TEXT,
created_at_ms INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(parent_id) REFERENCES groups(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_groups_parent ON groups(parent_id);
CREATE TABLE IF NOT EXISTS contacts (
id TEXT PRIMARY KEY,
group_id TEXT,
name TEXT NOT NULL,
notes TEXT,
created_at_ms INTEGER NOT NULL,
FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_contacts_group ON contacts(group_id);
CREATE TABLE IF NOT EXISTS charts (
id TEXT PRIMARY KEY,
contact_id TEXT NOT NULL,
kind TEXT NOT NULL,
label TEXT NOT NULL,
birth_data_json TEXT NOT NULL,
config_json TEXT NOT NULL,
related_chart_id TEXT,
created_at_ms INTEGER NOT NULL,
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE,
FOREIGN KEY(related_chart_id) REFERENCES charts(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_charts_contact ON charts(contact_id);
CREATE TABLE IF NOT EXISTS module_state (
chart_id TEXT NOT NULL,
module_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
config_json TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY(chart_id, module_id),
FOREIGN KEY(chart_id) REFERENCES charts(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"#;
// =====================================================================
// Row decoders
// =====================================================================
fn row_to_group(row: &rusqlite::Row<'_>) -> rusqlite::Result<Group> {
let id_str: String = row.get(0)?;
let parent_id_str: Option<String> = row.get(1)?;
Ok(Group {
id: id_str
.parse()
.map_err(|e: ulid::DecodeError| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
parent_id: match parent_id_str {
Some(s) => Some(s.parse().map_err(|e: ulid::DecodeError| {
rusqlite::Error::ToSqlConversionFailure(Box::new(e))
})?),
None => None,
},
name: row.get(2)?,
description: row.get(3)?,
created_at_ms: row.get(4)?,
sort_order: row.get(5)?,
})
}
fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
let id_str: String = row.get(0)?;
let group_str: Option<String> = row.get(1)?;
Ok(Contact {
id: id_str
.parse()
.map_err(|e: ulid::DecodeError| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
group_id: match group_str {
Some(s) => Some(s.parse().map_err(|e: ulid::DecodeError| {
rusqlite::Error::ToSqlConversionFailure(Box::new(e))
})?),
None => None,
},
name: row.get(2)?,
notes: row.get(3)?,
created_at_ms: row.get(4)?,
})
}
fn row_to_chart(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreResult<Chart>> {
// Doble-Result porque hay deserialización JSON adentro que rusqlite no
// sabe modelar. El caller la aplana.
let id_str: String = row.get(0)?;
let contact_str: String = row.get(1)?;
let kind_json: String = row.get(2)?;
let label: String = row.get(3)?;
let bd_json: String = row.get(4)?;
let cfg_json: String = row.get(5)?;
let related_str: Option<String> = row.get(6)?;
let created_at_ms: i64 = row.get(7)?;
Ok((|| -> StoreResult<Chart> {
Ok(Chart {
id: id_str.parse()?,
contact_id: contact_str.parse()?,
kind: serde_json::from_str(&kind_json)?,
label,
birth_data: serde_json::from_str(&bd_json)?,
config: serde_json::from_str(&cfg_json)?,
related_chart_id: match related_str {
Some(s) => Some(s.parse()?),
None => None,
},
created_at_ms,
})
})())
}
fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
// =====================================================================
// Tests
// =====================================================================
#[cfg(test)]
mod tests {
use super::*;
use cosmobiologia_model::{ModuleState, StoredBirthData, StoredChartConfig};
#[test]
fn open_and_migrate() {
let s = Store::in_memory().unwrap();
let groups = s.list_groups(None).unwrap();
assert!(groups.is_empty());
}
#[test]
fn module_state_roundtrip() {
let s = Store::in_memory().unwrap();
let g = s.create_group(None, "Familia", None).unwrap();
let c = s.create_contact(Some(g.id), "Sergio", None).unwrap();
let chart = s
.create_chart(
c.id,
ChartKind::Natal,
"Natal",
&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,
},
&StoredChartConfig::default(),
None,
)
.unwrap();
// Persistir dos módulos con configs distintos.
let state1 = ModuleState {
chart_id: chart.id,
module_id: "transit".into(),
enabled: true,
config: serde_json::json!({}),
};
let state2 = ModuleState {
chart_id: chart.id,
module_id: "progression".into(),
enabled: false,
config: serde_json::json!({ "target_age_years": 42.5 }),
};
s.upsert_module_state(&state1).unwrap();
s.upsert_module_state(&state2).unwrap();
let loaded = s.list_module_states(chart.id).unwrap();
assert_eq!(loaded.len(), 2);
let by_id: std::collections::HashMap<_, _> =
loaded.into_iter().map(|m| (m.module_id.clone(), m)).collect();
assert_eq!(by_id["transit"].enabled, true);
assert_eq!(by_id["progression"].enabled, false);
assert_eq!(
by_id["progression"]
.config
.get("target_age_years")
.and_then(|v| v.as_f64()),
Some(42.5)
);
// Upsert: cambiar enabled de transit a false.
let state1_off = ModuleState {
chart_id: chart.id,
module_id: "transit".into(),
enabled: false,
config: serde_json::json!({}),
};
s.upsert_module_state(&state1_off).unwrap();
let loaded = s.list_module_states(chart.id).unwrap();
let by_id: std::collections::HashMap<_, _> =
loaded.into_iter().map(|m| (m.module_id.clone(), m)).collect();
assert_eq!(by_id["transit"].enabled, false);
}
#[test]
fn settings_upsert_and_read() {
let s = Store::in_memory().unwrap();
assert_eq!(s.get_setting("layout.outer").unwrap(), None);
s.set_setting("layout.outer", "4.0,1.0").unwrap();
assert_eq!(
s.get_setting("layout.outer").unwrap().as_deref(),
Some("4.0,1.0")
);
// Upsert — el segundo set sobreescribe.
s.set_setting("layout.outer", "3.5,1.5").unwrap();
assert_eq!(
s.get_setting("layout.outer").unwrap().as_deref(),
Some("3.5,1.5")
);
}
#[test]
fn full_hierarchy_roundtrip() {
let s = Store::in_memory().unwrap();
let g = s.create_group(None, "Familia", None).unwrap();
let c = s.create_contact(Some(g.id), "Sergio", None).unwrap();
let chart = s
.create_chart(
c.id,
ChartKind::Natal,
"Natal",
&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: Some("Sergio".into()),
birthplace_label: Some("Caracas".into()),
},
&StoredChartConfig::default(),
None,
)
.unwrap();
assert_eq!(s.get_chart(chart.id).unwrap().label, "Natal");
let under = s.charts_under_group(g.id).unwrap();
assert_eq!(under.len(), 1);
assert_eq!(under[0].id, chart.id);
}
}
@@ -0,0 +1,10 @@
[package]
name = "cosmobiologia-theme"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Tahuantinsuyu — paleta astrológica (elementos, planetas, signos) + presets dark/light místicos sobre yahweh-theme."
[dependencies]
gpui = { workspace = true }
yahweh-theme = { workspace = true }
@@ -0,0 +1,421 @@
//! `cosmobiologia-theme` — paleta simbólica + presets místicos.
//!
//! Una capa fina sobre [`yahweh_theme::Theme`]: el theme base aporta los
//! slots de panel/foreground/accent; nosotros agregamos paletas
//! semánticas para los elementos (fuego/tierra/aire/agua), los modos
//! (cardinal/fijo/mutable), los planetas y los aspectos.
//!
//! El canvas pide colores por símbolo (`palette.element(Element::Fire)`),
//! nunca hex directos. Así una sola tabla controla tanto el dark como el
//! light, y cambiar la paleta no requiere tocar el render.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use gpui::{Hsla, hsla};
// =====================================================================
// Símbolos
// =====================================================================
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Element {
Fire,
Earth,
Air,
Water,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Modality {
Cardinal,
Fixed,
Mutable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Planet {
Sun,
Moon,
Mercury,
Venus,
Mars,
Jupiter,
Saturn,
Uranus,
Neptune,
Pluto,
Chiron,
NorthNode,
SouthNode,
Lilith,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AspectKind {
Conjunction,
Sextile,
Square,
Trine,
Opposition,
Quincunx,
Semisextile,
Semisquare,
Sesquisquare,
Quintile,
Biquintile,
}
// =====================================================================
// Paleta
// =====================================================================
/// Paleta completa de símbolos astrológicos resuelta a colores HSLA. Las
/// dos variantes (`dark` / `light`) comparten estructura — el canvas
/// elige según `yahweh_theme::Theme::is_dark`.
#[derive(Debug, Clone)]
pub struct AstroPalette {
pub is_dark: bool,
pub fire: Hsla,
pub earth: Hsla,
pub air: Hsla,
pub water: Hsla,
pub cardinal: Hsla,
pub fixed: Hsla,
pub mutable: Hsla,
pub sun: Hsla,
pub moon: Hsla,
pub mercury: Hsla,
pub venus: Hsla,
pub mars: Hsla,
pub jupiter: Hsla,
pub saturn: Hsla,
pub uranus: Hsla,
pub neptune: Hsla,
pub pluto: Hsla,
pub chiron: Hsla,
pub north_node: Hsla,
pub south_node: Hsla,
pub lilith: Hsla,
pub conjunction: Hsla,
pub sextile: Hsla,
pub square: Hsla,
pub trine: Hsla,
pub opposition: Hsla,
pub minor_aspect: Hsla,
/// Color del dial zodiacal (anillo exterior).
pub dial_ring: Hsla,
/// Cusps de casas.
pub house_cusp: Hsla,
/// Resaltado del ascendente / MC.
pub angle_highlight: Hsla,
}
impl AstroPalette {
/// Variante oscura — calibrada para sentirse cálida y mística sin
/// caer en saturación de carnaval. Las cusps quedan apenas más
/// claras que el fondo, los planetas tienen luminancia media-alta
/// para destacar sin glow falso.
pub fn dark() -> Self {
Self {
is_dark: true,
// Elementos — saturación alta + luminancia media. Familiares
// al símbolo pero suaves para coexistir.
fire: hsla(11.0 / 360.0, 0.78, 0.58, 1.0),
earth: hsla(95.0 / 360.0, 0.40, 0.48, 1.0),
air: hsla(48.0 / 360.0, 0.72, 0.66, 1.0),
water: hsla(210.0 / 360.0, 0.68, 0.58, 1.0),
cardinal: hsla(340.0 / 360.0, 0.55, 0.62, 1.0),
fixed: hsla(258.0 / 360.0, 0.48, 0.58, 1.0),
mutable: hsla(170.0 / 360.0, 0.42, 0.55, 1.0),
sun: hsla(45.0 / 360.0, 0.92, 0.62, 1.0),
moon: hsla(220.0 / 360.0, 0.25, 0.85, 1.0),
mercury: hsla(140.0 / 360.0, 0.40, 0.62, 1.0),
venus: hsla(330.0 / 360.0, 0.55, 0.70, 1.0),
mars: hsla(8.0 / 360.0, 0.78, 0.55, 1.0),
jupiter: hsla(38.0 / 360.0, 0.72, 0.62, 1.0),
saturn: hsla(28.0 / 360.0, 0.20, 0.50, 1.0),
uranus: hsla(195.0 / 360.0, 0.65, 0.62, 1.0),
neptune: hsla(225.0 / 360.0, 0.55, 0.66, 1.0),
pluto: hsla(280.0 / 360.0, 0.40, 0.45, 1.0),
chiron: hsla(75.0 / 360.0, 0.30, 0.55, 1.0),
north_node: hsla(35.0 / 360.0, 0.35, 0.70, 1.0),
south_node: hsla(35.0 / 360.0, 0.20, 0.45, 1.0),
lilith: hsla(310.0 / 360.0, 0.45, 0.40, 1.0),
conjunction: hsla(50.0 / 360.0, 0.65, 0.70, 0.85),
sextile: hsla(195.0 / 360.0, 0.60, 0.62, 0.75),
square: hsla(8.0 / 360.0, 0.75, 0.58, 0.85),
trine: hsla(140.0 / 360.0, 0.55, 0.55, 0.80),
opposition: hsla(280.0 / 360.0, 0.55, 0.62, 0.85),
minor_aspect: hsla(220.0 / 360.0, 0.20, 0.55, 0.55),
dial_ring: hsla(40.0 / 360.0, 0.18, 0.78, 0.85),
house_cusp: hsla(40.0 / 360.0, 0.12, 0.55, 0.60),
angle_highlight: hsla(50.0 / 360.0, 0.95, 0.65, 1.0),
}
}
/// Variante clara — desaturada y con luminancias bajas para que los
/// símbolos no compitan con el fondo blanco. Pensada para imprimir.
pub fn light() -> Self {
Self {
is_dark: false,
fire: hsla(11.0 / 360.0, 0.65, 0.42, 1.0),
earth: hsla(95.0 / 360.0, 0.45, 0.30, 1.0),
air: hsla(48.0 / 360.0, 0.55, 0.42, 1.0),
water: hsla(210.0 / 360.0, 0.60, 0.38, 1.0),
cardinal: hsla(340.0 / 360.0, 0.55, 0.42, 1.0),
fixed: hsla(258.0 / 360.0, 0.45, 0.40, 1.0),
mutable: hsla(170.0 / 360.0, 0.42, 0.35, 1.0),
sun: hsla(38.0 / 360.0, 0.85, 0.45, 1.0),
moon: hsla(220.0 / 360.0, 0.22, 0.45, 1.0),
mercury: hsla(140.0 / 360.0, 0.45, 0.36, 1.0),
venus: hsla(330.0 / 360.0, 0.55, 0.45, 1.0),
mars: hsla(8.0 / 360.0, 0.75, 0.40, 1.0),
jupiter: hsla(38.0 / 360.0, 0.72, 0.42, 1.0),
saturn: hsla(28.0 / 360.0, 0.25, 0.30, 1.0),
uranus: hsla(195.0 / 360.0, 0.65, 0.40, 1.0),
neptune: hsla(225.0 / 360.0, 0.55, 0.42, 1.0),
pluto: hsla(280.0 / 360.0, 0.45, 0.30, 1.0),
chiron: hsla(75.0 / 360.0, 0.32, 0.35, 1.0),
north_node: hsla(35.0 / 360.0, 0.45, 0.45, 1.0),
south_node: hsla(35.0 / 360.0, 0.20, 0.30, 1.0),
lilith: hsla(310.0 / 360.0, 0.50, 0.30, 1.0),
// Aspectos en light: alpha alta y luminancia media-baja para
// que las líneas tengan presencia contra fondo claro. En dark
// las alphas pueden ser más bajas porque el contraste contra
// el fondo oscuro ya las hace destacar.
conjunction: hsla(45.0 / 360.0, 0.70, 0.38, 0.95),
sextile: hsla(195.0 / 360.0, 0.65, 0.36, 0.90),
square: hsla(8.0 / 360.0, 0.80, 0.38, 0.95),
trine: hsla(140.0 / 360.0, 0.60, 0.32, 0.92),
opposition: hsla(280.0 / 360.0, 0.60, 0.40, 0.95),
minor_aspect: hsla(220.0 / 360.0, 0.30, 0.38, 0.75),
// dial_ring: luminancia baja (oscuro sobre blanco) para que
// el anillo de signos tenga peso. house_cusp: subimos alpha
// y bajamos luminancia para que las cúspides no se laven en
// un beige translúcido.
dial_ring: hsla(40.0 / 360.0, 0.20, 0.28, 0.95),
house_cusp: hsla(40.0 / 360.0, 0.15, 0.32, 0.80),
angle_highlight: hsla(38.0 / 360.0, 0.90, 0.38, 1.0),
}
}
/// Variante "papel coloreado" — para preview de impresión. Hue de
/// cada slot mantenido; luminancia 0.26-0.34 y saturación alta
/// para que sobreviva el ink-bleed sin perder identidad. Sin glow.
pub fn print_color() -> Self {
Self {
is_dark: false,
fire: hsla(11.0 / 360.0, 0.78, 0.34, 1.0),
earth: hsla(95.0 / 360.0, 0.55, 0.26, 1.0),
air: hsla(48.0 / 360.0, 0.78, 0.34, 1.0),
water: hsla(210.0 / 360.0, 0.72, 0.32, 1.0),
cardinal: hsla(340.0 / 360.0, 0.65, 0.34, 1.0),
fixed: hsla(258.0 / 360.0, 0.55, 0.32, 1.0),
mutable: hsla(170.0 / 360.0, 0.55, 0.28, 1.0),
sun: hsla(35.0 / 360.0, 0.95, 0.34, 1.0),
moon: hsla(220.0 / 360.0, 0.35, 0.34, 1.0),
mercury: hsla(140.0 / 360.0, 0.55, 0.28, 1.0),
venus: hsla(330.0 / 360.0, 0.65, 0.36, 1.0),
mars: hsla(8.0 / 360.0, 0.85, 0.34, 1.0),
jupiter: hsla(38.0 / 360.0, 0.85, 0.34, 1.0),
saturn: hsla(28.0 / 360.0, 0.30, 0.26, 1.0),
uranus: hsla(195.0 / 360.0, 0.75, 0.34, 1.0),
neptune: hsla(225.0 / 360.0, 0.65, 0.34, 1.0),
pluto: hsla(280.0 / 360.0, 0.55, 0.28, 1.0),
chiron: hsla(75.0 / 360.0, 0.42, 0.30, 1.0),
north_node: hsla(35.0 / 360.0, 0.55, 0.36, 1.0),
south_node: hsla(35.0 / 360.0, 0.30, 0.28, 1.0),
lilith: hsla(310.0 / 360.0, 0.60, 0.26, 1.0),
conjunction: hsla(45.0 / 360.0, 0.75, 0.32, 1.0),
sextile: hsla(195.0 / 360.0, 0.70, 0.32, 1.0),
square: hsla(8.0 / 360.0, 0.85, 0.34, 1.0),
trine: hsla(140.0 / 360.0, 0.65, 0.28, 1.0),
opposition: hsla(280.0 / 360.0, 0.65, 0.36, 1.0),
minor_aspect: hsla(220.0 / 360.0, 0.40, 0.40, 0.85),
dial_ring: hsla(40.0 / 360.0, 0.30, 0.22, 1.0),
house_cusp: hsla(40.0 / 360.0, 0.20, 0.28, 0.90),
angle_highlight: hsla(15.0 / 360.0, 0.85, 0.36, 1.0),
}
}
/// Variante "papel B&N" — preview de impresión monocromática.
/// TODO los slots de planeta y aspecto se reducen a niveles de
/// gris. El canvas se encarga de diferenciar aspectos por dash
/// pattern y planetas por glyph (el unicode astronómico es
/// distintivo aunque pierda color).
pub fn print_bw() -> Self {
// Tres niveles funcionales: muy oscuro (texto, glyphs
// principales), medio (cusps, líneas), claro (fondos, minors).
let ink_strong = hsla(0.0, 0.0, 0.10, 1.0);
let ink_mid = hsla(0.0, 0.0, 0.30, 1.0);
let ink_soft = hsla(0.0, 0.0, 0.50, 0.90);
let ink_faint = hsla(0.0, 0.0, 0.55, 0.75);
Self {
is_dark: false,
fire: ink_strong,
earth: ink_strong,
air: ink_strong,
water: ink_strong,
cardinal: ink_mid,
fixed: ink_mid,
mutable: ink_mid,
// Planetas: todos en ink_strong para que los glyphs se
// lean fuerte. El usuario distingue por el unicode
// astronómico, no por hue.
sun: ink_strong,
moon: ink_strong,
mercury: ink_strong,
venus: ink_strong,
mars: ink_strong,
jupiter: ink_strong,
saturn: ink_strong,
uranus: ink_strong,
neptune: ink_strong,
pluto: ink_strong,
chiron: ink_mid,
north_node: ink_mid,
south_node: ink_mid,
lilith: ink_mid,
// Aspectos: el color es uniforme; la diferenciación es por
// dash pattern en el painter (square=dashed, trine=solid,
// sextile=dotted, etc.). Acá solo damos el "intensity"
// base que el painter modula.
conjunction: ink_strong,
sextile: ink_mid,
square: ink_strong,
trine: ink_mid,
opposition: ink_strong,
minor_aspect: ink_faint,
dial_ring: ink_mid,
house_cusp: ink_soft,
angle_highlight: ink_strong,
}
}
pub fn for_theme(theme: &yahweh_theme::Theme) -> Self {
// Dispatcher por nombre para los themes "papel"; el resto cae
// al binary dark/light según `is_dark`. Mantenemos el match
// case-insensitive por defensa contra cambios de naming.
match theme.name {
"Print Color" => Self::print_color(),
"Print B&W" => Self::print_bw(),
_ if theme.is_dark => Self::dark(),
_ => Self::light(),
}
}
/// Devuelve `true` si la paleta es monocromática — los painters
/// la usan para activar dash patterns en lugar de diferenciar
/// aspectos por color.
pub fn is_monochrome(&self) -> bool {
// Heurística simple: si conjunction y square (que en color
// siempre tienen hues distintos) tienen el mismo hue,
// estamos en BW.
(self.conjunction.h - self.square.h).abs() < 1e-3
}
pub fn element(&self, e: Element) -> Hsla {
match e {
Element::Fire => self.fire,
Element::Earth => self.earth,
Element::Air => self.air,
Element::Water => self.water,
}
}
pub fn modality(&self, m: Modality) -> Hsla {
match m {
Modality::Cardinal => self.cardinal,
Modality::Fixed => self.fixed,
Modality::Mutable => self.mutable,
}
}
pub fn planet(&self, p: Planet) -> Hsla {
match p {
Planet::Sun => self.sun,
Planet::Moon => self.moon,
Planet::Mercury => self.mercury,
Planet::Venus => self.venus,
Planet::Mars => self.mars,
Planet::Jupiter => self.jupiter,
Planet::Saturn => self.saturn,
Planet::Uranus => self.uranus,
Planet::Neptune => self.neptune,
Planet::Pluto => self.pluto,
Planet::Chiron => self.chiron,
Planet::NorthNode => self.north_node,
Planet::SouthNode => self.south_node,
Planet::Lilith => self.lilith,
}
}
pub fn aspect(&self, a: AspectKind) -> Hsla {
match a {
AspectKind::Conjunction => self.conjunction,
AspectKind::Sextile => self.sextile,
AspectKind::Square => self.square,
AspectKind::Trine => self.trine,
AspectKind::Opposition => self.opposition,
_ => self.minor_aspect,
}
}
}
/// Resuelve un símbolo zodiacal (string) a su elemento.
/// Ej. `"aries" → Fire`, `"taurus" → Earth`, …
pub fn element_for_sign(sign: &str) -> Option<Element> {
Some(match sign.to_ascii_lowercase().as_str() {
"aries" | "leo" | "sagittarius" => Element::Fire,
"taurus" | "virgo" | "capricorn" => Element::Earth,
"gemini" | "libra" | "aquarius" => Element::Air,
"cancer" | "scorpio" | "pisces" => Element::Water,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn element_lookup() {
assert_eq!(element_for_sign("aries"), Some(Element::Fire));
assert_eq!(element_for_sign("CAPRICORN"), Some(Element::Earth));
assert_eq!(element_for_sign("zod"), None);
}
#[test]
fn palette_indexes() {
let p = AstroPalette::dark();
assert_eq!(p.planet(Planet::Sun), p.sun);
assert_eq!(p.element(Element::Water), p.water);
}
}
@@ -0,0 +1,14 @@
[package]
name = "cosmobiologia-tree"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Tahuantinsuyu — explorador izquierdo (Groups/Contacts/Charts) sobre yahweh-widget-tree."
[dependencies]
cosmobiologia-model = { path = "../cosmobiologia-model" }
cosmobiologia-store = { path = "../cosmobiologia-store" }
yahweh-theme = { workspace = true }
yahweh-widget-tree = { workspace = true }
yahweh-widget-text-input = { path = "../../ui_engine/widgets/text_input" }
gpui = { workspace = true }
File diff suppressed because it is too large Load Diff