chore: rename tahuantinsuyu → cosmobiologia
Rename clean del proyecto astrológico antes de empezar el módulo
web (fase 2 = server axum, fase 3 = cliente WASM). Hacerlo ahora
ahorra refactor de URLs, package.json, paths de assets HTML y
deploy configs que aparecerían con el nombre en cuanto exista el
server.
Mecánica:
- `git mv` de los 10 crates de módulo + 2 apps:
* `crates/modules/tahuantinsuyu/` → `cosmobiologia/`
* `crates/modules/tahuantinsuyu/tahuantinsuyu-*` →
`cosmobiologia/cosmobiologia-*`
* `crates/apps/tahuantinsuyu` y `tahuantinsuyu-cli` análogos.
- Sed sobre todos los `.rs` y `.toml`: `tahuantinsuyu` →
`cosmobiologia` (cubre crate names, deps paths, use
statements, ProjectDirs literals, binary names).
- Workspace `Cargo.toml`: members con paths nuevos.
- Memoria del proyecto (`~/.claude/.../memory/project_*.md`)
actualizada.
Cero leftovers: `grep -rn tahuantinsuyu --include="*.rs"
--include="*.toml" crates/` devuelve vacío.
DB & XDG: clean slate. La nueva app arranca con DB vacía en
`$XDG_DATA_HOME/cosmobiologia/charts.db`. Si tenías cartas
guardadas, viven todavía en `~/.local/share/tahuantinsuyu/` —
las podés migrar manualmente con un `cp`.
IDs UI inalterados: el prefijo `tts-` de gpui ElementIds queda
igual (cosmético, no afecta funcionalidad). Cambiarlo a `cb-`
ahora sería 3-4 líneas más de sed pero ningún beneficio
operativo.
Tests: 20 verdes (10 shell + 10 render math). Compila full:
`cargo check -p cosmobiologia` OK.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{compute_mock, ChartKind};
|
||||
use cosmobiologia_model::{Chart, ContactId, StoredBirthData, StoredChartConfig};
|
||||
|
||||
fn sample_chart() -> Chart {
|
||||
Chart {
|
||||
id: cosmobiologia_model::ChartId::new(),
|
||||
contact_id: ContactId::new(),
|
||||
kind: ChartKind::Natal,
|
||||
label: "Test".into(),
|
||||
birth_data: StoredBirthData {
|
||||
year: 1987,
|
||||
month: 3,
|
||||
day: 14,
|
||||
hour: 5,
|
||||
minute: 22,
|
||||
second: 0.0,
|
||||
tz_offset_minutes: -240,
|
||||
latitude_deg: 10.0,
|
||||
longitude_deg: -66.0,
|
||||
altitude_m: 0.0,
|
||||
time_certainty: Default::default(),
|
||||
subject_name: None,
|
||||
birthplace_label: None,
|
||||
},
|
||||
config: StoredChartConfig::default(),
|
||||
related_chart_id: None,
|
||||
created_at_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_well_formed_minimal() {
|
||||
let render = compute_mock(&sample_chart());
|
||||
let svg = render_to_svg(&render);
|
||||
assert!(svg.starts_with("<?xml"));
|
||||
assert!(svg.contains("<svg"));
|
||||
assert!(svg.ends_with("</svg>\n"));
|
||||
// Debe traer al menos un círculo de los rings base.
|
||||
assert!(svg.contains("<circle "));
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user