feat(cosmobiologia): cliente web demo SSR + DrawCommand agnóstico (fase 3a)
Fase 3a — render web operativo sin WASM. Demo funcional inmediata
con server-side rendering del SVG; el cliente WASM puro se hace en
fase 3b cuando wasm-pack / wasm-bindgen-cli esté instalado.
cosmobiologia-render — nuevo módulo `draw`:
- `Rgba { r, g, b, a }` color agnóstico (no Hsla, no hex CSS).
- `DrawCommand` enum tagged-serde: `Circle`, `Line`, `Text`. Listo
para WASM o nativo — solo primitivas.
- `CompositionOpts { size, rot_offset_deg, include_bodies }`.
- `compose_wheel(model, opts) -> Vec<DrawCommand>` primera versión:
anillo zodiacal (A+B), 12 cusps cada 30°, glyphs de signos,
corona de casas (C+D), cusps de casas (Asc/IC/Desc/MC con peso
doble), house numbers, anillo de aspectos (E), líneas de
aspectos coloreadas por kind, glyphs de cuerpos natales con
disco halo.
- `draw_commands_to_svg(cmds, size) -> String` serializa la lista
a SVG inline. SVG-escape, `text-anchor` configurable, `dominant
-baseline=central` para centrar verticalmente.
Pendiente en `compose_wheel` (extender en commits siguientes,
copiando lo del canvas gpui): spread anti-solapamiento, clusters
compartidos, coord labels, dial 3D bevel, vignette, themes
PrintColor/PrintBW. Por ahora es un MVP suficiente para verificar
end-to-end y para que el usuario tenga algo visible YA.
cosmobiologia-server:
- Nuevos endpoints:
* `GET /` → HTML del cliente (single-page)
* `GET /api/sky.svg` → SVG agnóstico del "cielo ahora"
* `GET /api/charts/:id/wheel.svg` → SVG agnóstico de carta con
overlays via query (offset,
transit, prog, sa, pd)
- Página HTML embebida (`include_str!` de `static/index.html`):
* Sidebar con tree (groups → contacts → charts), click selecciona
* "⏱ Cielo ahora" siempre disponible como botón rápido
* Toolbar con input offset minutos + checkbox tránsito + botón
refresh + botón download SVG
* Botones "Nuevo grupo / Nuevo contacto" con prompt + POST
* Wheel renderizado en SVG inline, info row con título/asc/mc/ms
Smoke test:
cargo run -p cosmobiologia-server -- --port 18787
curl / → HTML (página completa)
curl /api/sky.svg → 12 KB SVG con 17 circles +
51 lines + 36 texts
curl /api/tree → árbol JSON
curl POST /api/groups → crea grupo
Browser http://127.0.0.1:8787 → wheel visible
Próximo (fase 3b): cliente cdylib WASM `cosmobiologia-web` que
reemplace el SSR — recibe RenderModel JSON, llama compose_wheel +
draw_commands_to_svg en WASM, monta SVG via DOM. Trade-off: el
SSR de hoy es 12 KB transferidos por click (sólido); WASM
descarga ~150 KB una sola vez y luego compone localmente
(scrubbing instantáneo, sin round-trip al server).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,7 @@ use clap::Parser;
|
|||||||
use cosmobiologia_engine::{
|
use cosmobiologia_engine::{
|
||||||
compose_with_options, svg_export, EngineError, NatalOptions, PipelineRequest, RenderModel,
|
compose_with_options, svg_export, EngineError, NatalOptions, PipelineRequest, RenderModel,
|
||||||
};
|
};
|
||||||
|
use cosmobiologia_render::{compose_wheel, draw_commands_to_svg, CompositionOpts};
|
||||||
use cosmobiologia_model::{
|
use cosmobiologia_model::{
|
||||||
Chart, ChartId, ChartKind, Contact, ContactId, Group, GroupId, StoredBirthData,
|
Chart, ChartId, ChartKind, Contact, ContactId, Group, GroupId, StoredBirthData,
|
||||||
StoredChartConfig,
|
StoredChartConfig,
|
||||||
@@ -118,9 +119,17 @@ fn default_db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
fn router() -> Router<AppState> {
|
fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.route("/", get(get_index))
|
||||||
.route("/api/health", get(health))
|
.route("/api/health", get(health))
|
||||||
.route("/api/tree", get(get_tree))
|
.route("/api/tree", get(get_tree))
|
||||||
.route("/api/sky", get(get_sky))
|
.route("/api/sky", get(get_sky))
|
||||||
|
// El render SVG agnóstico (via `cosmobiologia-render::compose_wheel`
|
||||||
|
// + `draw_commands_to_svg`) sirve a la fase 3 inicial: el
|
||||||
|
// cliente recibe SVG ya compuesto, sin necesidad de WASM.
|
||||||
|
// Cuando agreguemos el cliente WASM real, este endpoint se
|
||||||
|
// mantiene como fallback "ver SVG sin JS".
|
||||||
|
.route("/api/sky.svg", get(get_sky_svg))
|
||||||
|
.route("/api/charts/:id/wheel.svg", get(get_chart_wheel_svg))
|
||||||
.route("/api/groups", post(post_group))
|
.route("/api/groups", post(post_group))
|
||||||
.route("/api/groups/:id", patch(patch_group).delete(delete_group))
|
.route("/api/groups/:id", patch(patch_group).delete(delete_group))
|
||||||
.route("/api/contacts", post(post_contact))
|
.route("/api/contacts", post(post_contact))
|
||||||
@@ -139,6 +148,55 @@ fn router() -> Router<AppState> {
|
|||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Página HTML inicial
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
const INDEX_HTML: &str = include_str!("../static/index.html");
|
||||||
|
|
||||||
|
async fn get_index() -> Response {
|
||||||
|
(
|
||||||
|
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||||
|
INDEX_HTML.to_string(),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVG render agnóstico (no es el del engine — este viene de
|
||||||
|
// `cosmobiologia-render::compose_wheel` que es lo que mañana el
|
||||||
|
// cliente WASM también va a usar). Útil para demos sin WASM.
|
||||||
|
async fn get_sky_svg() -> Result<Response, ApiError> {
|
||||||
|
let chart = build_present_sky_chart();
|
||||||
|
let model = compose_with_options(&chart, 0, &[], &NatalOptions::default())?;
|
||||||
|
let cmds = compose_wheel(&model, &CompositionOpts::default());
|
||||||
|
let svg = draw_commands_to_svg(&cmds, 600.0);
|
||||||
|
Ok((
|
||||||
|
[(axum::http::header::CONTENT_TYPE, "image/svg+xml")],
|
||||||
|
svg,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_chart_wheel_svg(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(id): Path<ChartId>,
|
||||||
|
Query(q): Query<RenderQuery>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
let chart = s
|
||||||
|
.store
|
||||||
|
.get_chart(id)
|
||||||
|
.map_err(|_| ApiError::NotFound(format!("chart {}", id)))?;
|
||||||
|
let model =
|
||||||
|
compose_with_options(&chart, q.offset_min, &build_requests(&q), &NatalOptions::default())?;
|
||||||
|
let cmds = compose_wheel(&model, &CompositionOpts::default());
|
||||||
|
let svg = draw_commands_to_svg(&cmds, 600.0);
|
||||||
|
Ok((
|
||||||
|
[(axum::http::header::CONTENT_TYPE, "image/svg+xml")],
|
||||||
|
svg,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Error
|
// Error
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Cosmobiología</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--bg-panel: #171a21;
|
||||||
|
--fg: #e8e6df;
|
||||||
|
--fg-muted: #9aa0a8;
|
||||||
|
--accent: #c79a4d;
|
||||||
|
--border: #2a2e38;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: -apple-system, system-ui, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
aside {
|
||||||
|
width: 280px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 24px 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
}
|
||||||
|
.tree-node {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.tree-node:hover { background: rgba(255,255,255,.05); }
|
||||||
|
.tree-node.active { background: rgba(199,154,77,.15); color: var(--accent); }
|
||||||
|
.tree-node .icon { margin-right: 6px; opacity: .7; }
|
||||||
|
.indent-1 { padding-left: 22px; }
|
||||||
|
.indent-2 { padding-left: 38px; }
|
||||||
|
#wheel-container {
|
||||||
|
width: 600px;
|
||||||
|
height: 600px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
#info {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
}
|
||||||
|
#info b { color: var(--fg); font-weight: 500; }
|
||||||
|
.toolbar {
|
||||||
|
width: 600px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.toolbar button {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.toolbar button:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.toolbar label { font-size: 12px; color: var(--fg-muted); }
|
||||||
|
.toolbar input { width: 60px; padding: 4px; border: 1px solid var(--border);
|
||||||
|
background: var(--bg); color: var(--fg); border-radius: 4px; font-size: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<aside>
|
||||||
|
<h1>Cosmobiología</h1>
|
||||||
|
<div style="font-size:11px;color:var(--fg-muted);">cliente web demo</div>
|
||||||
|
|
||||||
|
<h2>Cartas</h2>
|
||||||
|
<div id="tree"></div>
|
||||||
|
|
||||||
|
<h2>Acciones rápidas</h2>
|
||||||
|
<div class="tree-node" onclick="loadSky()">⏱ Cielo ahora</div>
|
||||||
|
<div class="tree-node" onclick="newGroup()">+ Nuevo grupo</div>
|
||||||
|
<div class="tree-node" onclick="newContact()">+ Nuevo contacto</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button onclick="refreshSelected()">↻ Refrescar</button>
|
||||||
|
<label>Offset (min):
|
||||||
|
<input type="number" id="offset" value="0" step="60">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="transit"> Tránsito
|
||||||
|
</label>
|
||||||
|
<button onclick="downloadSvg()">⬇ SVG</button>
|
||||||
|
</div>
|
||||||
|
<div id="wheel-container">
|
||||||
|
<div style="color:var(--fg-muted)">Seleccioná una carta o "Cielo ahora"</div>
|
||||||
|
</div>
|
||||||
|
<div id="info"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let selectedChartId = null;
|
||||||
|
let mode = 'sky'; // 'sky' | 'chart'
|
||||||
|
|
||||||
|
async function loadTree() {
|
||||||
|
const tree = await fetch('/api/tree').then(r => r.json());
|
||||||
|
const el = document.getElementById('tree');
|
||||||
|
el.innerHTML = '';
|
||||||
|
renderNodes(tree, el, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNodes(nodes, container, depth) {
|
||||||
|
for (const n of nodes) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `tree-node indent-${depth}`;
|
||||||
|
const icon = n.kind === 'group' ? '◇' : n.kind === 'contact' ? '◯' : '✦';
|
||||||
|
div.innerHTML = `<span class="icon">${icon}</span>${escapeHtml(n.label)}`;
|
||||||
|
if (n.kind === 'chart') {
|
||||||
|
const id = n.id.replace(/^h:/, '');
|
||||||
|
div.onclick = () => selectChart(id);
|
||||||
|
}
|
||||||
|
container.appendChild(div);
|
||||||
|
if (n.children && n.children.length) {
|
||||||
|
renderNodes(n.children, container, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectChart(id) {
|
||||||
|
selectedChartId = id;
|
||||||
|
mode = 'chart';
|
||||||
|
document.querySelectorAll('.tree-node').forEach(el =>
|
||||||
|
el.classList.toggle('active', el.textContent.endsWith(' (active)'))
|
||||||
|
);
|
||||||
|
await refreshSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSky() {
|
||||||
|
mode = 'sky';
|
||||||
|
selectedChartId = null;
|
||||||
|
await refreshSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSelected() {
|
||||||
|
const offset = document.getElementById('offset').value || 0;
|
||||||
|
const transit = document.getElementById('transit').checked ? '1' : '0';
|
||||||
|
const params = new URLSearchParams({ offset_min: offset, transit });
|
||||||
|
let svgUrl, infoUrl;
|
||||||
|
if (mode === 'sky') {
|
||||||
|
svgUrl = `/api/sky.svg`;
|
||||||
|
infoUrl = `/api/sky`;
|
||||||
|
} else {
|
||||||
|
svgUrl = `/api/charts/${selectedChartId}/wheel.svg?${params}`;
|
||||||
|
infoUrl = `/api/charts/${selectedChartId}/render?${params}`;
|
||||||
|
}
|
||||||
|
const [svg, render] = await Promise.all([
|
||||||
|
fetch(svgUrl).then(r => r.text()),
|
||||||
|
fetch(infoUrl).then(r => r.json()).catch(() => null),
|
||||||
|
]);
|
||||||
|
document.getElementById('wheel-container').innerHTML = svg;
|
||||||
|
const info = document.getElementById('info');
|
||||||
|
if (render) {
|
||||||
|
info.innerHTML =
|
||||||
|
`<b>${escapeHtml(render.title)}</b> · ` +
|
||||||
|
`Asc ${render.ascendant_deg.toFixed(2)}° · ` +
|
||||||
|
`MC ${render.midheaven_deg.toFixed(2)}° · ` +
|
||||||
|
`${render.compute_ms} ms`;
|
||||||
|
} else {
|
||||||
|
info.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadSvg() {
|
||||||
|
const offset = document.getElementById('offset').value || 0;
|
||||||
|
const transit = document.getElementById('transit').checked ? '1' : '0';
|
||||||
|
const params = new URLSearchParams({ offset_min: offset, transit });
|
||||||
|
const url = mode === 'sky'
|
||||||
|
? `/api/sky.svg`
|
||||||
|
: `/api/charts/${selectedChartId}/wheel.svg?${params}`;
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newGroup() {
|
||||||
|
const name = prompt('Nombre del grupo:');
|
||||||
|
if (!name) return;
|
||||||
|
await fetch('/api/groups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
loadTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newContact() {
|
||||||
|
const name = prompt('Nombre del contacto:');
|
||||||
|
if (!name) return;
|
||||||
|
await fetch('/api/contacts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
loadTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return s.replace(/[&<>"']/g, c => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTree();
|
||||||
|
loadSky();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
//! Primitivas agnósticas de pintura — el `DrawCommand` que cada
|
||||||
|
//! surface (gpui canvas o SVG/Canvas2D del WASM) traduce a su API.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Color RGBA en `[0.0, 1.0]^4`. Independiente del color-space del
|
||||||
|
/// surface (no es Hsla de gpui ni hex de CSS). El traductor de surface
|
||||||
|
/// hace la conversión final.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||||
|
pub struct Rgba {
|
||||||
|
pub r: f32,
|
||||||
|
pub g: f32,
|
||||||
|
pub b: f32,
|
||||||
|
pub a: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rgba {
|
||||||
|
pub const TRANSPARENT: Rgba = Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.0 };
|
||||||
|
pub fn opaque(r: f32, g: f32, b: f32) -> Self {
|
||||||
|
Self { r, g, b, a: 1.0 }
|
||||||
|
}
|
||||||
|
pub fn with_alpha(mut self, a: f32) -> Self {
|
||||||
|
self.a = a;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
/// Helper para serializar como CSS rgba(...).
|
||||||
|
pub fn to_css(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"rgba({},{},{},{})",
|
||||||
|
(self.r * 255.0).round() as u8,
|
||||||
|
(self.g * 255.0).round() as u8,
|
||||||
|
(self.b * 255.0).round() as u8,
|
||||||
|
self.a
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anchor horizontal del texto. Vertical siempre es `middle` para
|
||||||
|
/// que el texto se centre verticalmente en `(x, y)`.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TextAnchor {
|
||||||
|
Start,
|
||||||
|
Middle,
|
||||||
|
End,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Primitiva de pintura agnóstica. La lista de comandos describe
|
||||||
|
/// **qué** dibujar, no **cómo** — cada surface traduce a su API.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum DrawCommand {
|
||||||
|
/// Círculo (stroke + fill opcional).
|
||||||
|
Circle {
|
||||||
|
cx: f32,
|
||||||
|
cy: f32,
|
||||||
|
r: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
stroke: Option<Rgba>,
|
||||||
|
#[serde(default)]
|
||||||
|
fill: Option<Rgba>,
|
||||||
|
#[serde(default = "default_stroke_width")]
|
||||||
|
stroke_w: f32,
|
||||||
|
},
|
||||||
|
/// Segmento de línea con dash opcional.
|
||||||
|
Line {
|
||||||
|
x1: f32,
|
||||||
|
y1: f32,
|
||||||
|
x2: f32,
|
||||||
|
y2: f32,
|
||||||
|
color: Rgba,
|
||||||
|
#[serde(default = "default_stroke_width")]
|
||||||
|
width: f32,
|
||||||
|
/// `Some((on, off))` para dash. None = sólido.
|
||||||
|
#[serde(default)]
|
||||||
|
dash: Option<(f32, f32)>,
|
||||||
|
},
|
||||||
|
/// Texto en `(x, y)`, anchor horizontal configurable.
|
||||||
|
Text {
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
content: String,
|
||||||
|
color: Rgba,
|
||||||
|
size: f32,
|
||||||
|
#[serde(default = "default_anchor")]
|
||||||
|
anchor: TextAnchor,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_stroke_width() -> f32 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
fn default_anchor() -> TextAnchor {
|
||||||
|
TextAnchor::Middle
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opciones para `compose_wheel` — el caller decide tamaño total del
|
||||||
|
/// wheel y rotación visual. Los colores son simples por ahora;
|
||||||
|
/// extender después con una palette completa.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CompositionOpts {
|
||||||
|
/// Tamaño total del wheel en px (lado del cuadrado contenedor).
|
||||||
|
pub size: f32,
|
||||||
|
/// Rotación adicional visual (para jog-dial / transformaciones).
|
||||||
|
pub rot_offset_deg: f32,
|
||||||
|
/// Si `false`, la lista no incluye los glyphs de cuerpos (útil
|
||||||
|
/// para previews compactos).
|
||||||
|
pub include_bodies: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CompositionOpts {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
size: 600.0,
|
||||||
|
rot_offset_deg: 0.0,
|
||||||
|
include_bodies: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compone una lista de `DrawCommand`s a partir de un `RenderModel`.
|
||||||
|
/// Versión inicial: anillo de signos + cusps cada 30° + house numbers
|
||||||
|
/// + cuerpos natales. Sin clusters/spread/aspectos (extiende en
|
||||||
|
/// commits siguientes).
|
||||||
|
pub fn compose_wheel(
|
||||||
|
model: &crate::RenderModel,
|
||||||
|
opts: &CompositionOpts,
|
||||||
|
) -> Vec<DrawCommand> {
|
||||||
|
use crate::math::{polar_to_screen, Radii};
|
||||||
|
let mut out = Vec::new();
|
||||||
|
|
||||||
|
let cx = opts.size / 2.0;
|
||||||
|
let cy = opts.size / 2.0;
|
||||||
|
let margin = opts.size * 0.05;
|
||||||
|
let r_outer = (opts.size / 2.0) - margin;
|
||||||
|
let radii = Radii::from_outer(r_outer);
|
||||||
|
|
||||||
|
let asc = model.ascendant_deg;
|
||||||
|
let rot = opts.rot_offset_deg;
|
||||||
|
|
||||||
|
// Colores neutros (en fase próxima los reemplazo por palette real)
|
||||||
|
let ink_strong = Rgba::opaque(0.15, 0.15, 0.20);
|
||||||
|
let ink_mid = Rgba::opaque(0.45, 0.45, 0.50).with_alpha(0.85);
|
||||||
|
let ink_soft = Rgba::opaque(0.55, 0.55, 0.60).with_alpha(0.55);
|
||||||
|
let house_color = Rgba::opaque(0.30, 0.55, 0.50).with_alpha(0.85);
|
||||||
|
let angle_color = Rgba::opaque(0.85, 0.55, 0.20);
|
||||||
|
|
||||||
|
// === Aro A (externo zodiaco) + B (interno) ===
|
||||||
|
out.push(DrawCommand::Circle {
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
r: radii.sign_outer,
|
||||||
|
stroke: Some(ink_strong),
|
||||||
|
fill: None,
|
||||||
|
stroke_w: 1.5,
|
||||||
|
});
|
||||||
|
out.push(DrawCommand::Circle {
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
r: radii.sign_inner,
|
||||||
|
stroke: Some(ink_mid),
|
||||||
|
fill: None,
|
||||||
|
stroke_w: 1.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Cusps zodiacales (12 radios entre sign_inner y sign_outer) ===
|
||||||
|
for i in 0..12 {
|
||||||
|
let lon = (i as f32) * 30.0;
|
||||||
|
let (xi, yi) = polar_to_screen(lon, asc, rot, radii.sign_inner);
|
||||||
|
let (xo, yo) = polar_to_screen(lon, asc, rot, radii.sign_outer);
|
||||||
|
out.push(DrawCommand::Line {
|
||||||
|
x1: cx + xi,
|
||||||
|
y1: cy + yi,
|
||||||
|
x2: cx + xo,
|
||||||
|
y2: cy + yo,
|
||||||
|
color: ink_mid,
|
||||||
|
width: 1.0,
|
||||||
|
dash: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Casas: aros + cusps + glyph número ===
|
||||||
|
let house_outer_r = radii.houses_outer;
|
||||||
|
let house_inner_r = radii.houses_inner;
|
||||||
|
out.push(DrawCommand::Circle {
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
r: house_outer_r,
|
||||||
|
stroke: Some(house_color),
|
||||||
|
fill: None,
|
||||||
|
stroke_w: 1.0,
|
||||||
|
});
|
||||||
|
out.push(DrawCommand::Circle {
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
r: house_inner_r,
|
||||||
|
stroke: Some(house_color),
|
||||||
|
fill: None,
|
||||||
|
stroke_w: 1.0,
|
||||||
|
});
|
||||||
|
for layer in &model.layers {
|
||||||
|
if !matches!(layer.kind, crate::LayerKind::Houses) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if layer.module_id != "natal" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let crate::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 = if is_angle { angle_color } else { house_color };
|
||||||
|
let width = if is_angle { 2.0 } else { 0.8 };
|
||||||
|
let (xi, yi) = polar_to_screen(*c, asc, rot, house_inner_r);
|
||||||
|
let (xo, yo) = polar_to_screen(*c, asc, rot, house_outer_r);
|
||||||
|
out.push(DrawCommand::Line {
|
||||||
|
x1: cx + xi,
|
||||||
|
y1: cy + yi,
|
||||||
|
x2: cx + xo,
|
||||||
|
y2: cy + yo,
|
||||||
|
color,
|
||||||
|
width,
|
||||||
|
dash: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// House numbers
|
||||||
|
let label_r = (house_outer_r + house_inner_r) / 2.0;
|
||||||
|
for g in &layer.glyphs {
|
||||||
|
if let Some(h) = g.house {
|
||||||
|
let (gx, gy) = polar_to_screen(g.deg, asc, rot, label_r);
|
||||||
|
out.push(DrawCommand::Text {
|
||||||
|
x: cx + gx,
|
||||||
|
y: cy + gy,
|
||||||
|
content: format!("{}", h),
|
||||||
|
color: ink_mid,
|
||||||
|
size: opts.size * 0.018,
|
||||||
|
anchor: TextAnchor::Middle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Glyphs zodiacales ===
|
||||||
|
let sign_ring_mid = (radii.sign_outer + radii.sign_inner) / 2.0;
|
||||||
|
for layer in &model.layers {
|
||||||
|
if !matches!(layer.kind, crate::LayerKind::SignDial) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for g in &layer.glyphs {
|
||||||
|
let (gx, gy) = polar_to_screen(g.deg, asc, rot, sign_ring_mid);
|
||||||
|
out.push(DrawCommand::Text {
|
||||||
|
x: cx + gx,
|
||||||
|
y: cy + gy,
|
||||||
|
content: sign_unicode(&g.symbol).into(),
|
||||||
|
color: ink_strong,
|
||||||
|
size: opts.size * 0.03,
|
||||||
|
anchor: TextAnchor::Middle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Cuerpos natales (sin spread/cluster — minimal) ===
|
||||||
|
if opts.include_bodies {
|
||||||
|
for layer in &model.layers {
|
||||||
|
if !matches!(layer.kind, crate::LayerKind::Bodies) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if layer.module_id != "natal" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ring = radii.bodies;
|
||||||
|
for g in &layer.glyphs {
|
||||||
|
let (gx, gy) = polar_to_screen(g.deg, asc, rot, ring);
|
||||||
|
// Disco halo
|
||||||
|
out.push(DrawCommand::Circle {
|
||||||
|
cx: cx + gx,
|
||||||
|
cy: cy + gy,
|
||||||
|
r: opts.size * 0.022,
|
||||||
|
stroke: Some(ink_strong),
|
||||||
|
fill: Some(Rgba::opaque(0.97, 0.97, 0.97).with_alpha(0.92)),
|
||||||
|
stroke_w: 1.0,
|
||||||
|
});
|
||||||
|
// Glyph del cuerpo
|
||||||
|
out.push(DrawCommand::Text {
|
||||||
|
x: cx + gx,
|
||||||
|
y: cy + gy,
|
||||||
|
content: planet_unicode(&g.symbol).into(),
|
||||||
|
color: ink_strong,
|
||||||
|
size: opts.size * 0.028,
|
||||||
|
anchor: TextAnchor::Middle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Anillo de aspectos + líneas ===
|
||||||
|
out.push(DrawCommand::Circle {
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
r: radii.aspects,
|
||||||
|
stroke: Some(ink_soft),
|
||||||
|
fill: None,
|
||||||
|
stroke_w: 0.7,
|
||||||
|
});
|
||||||
|
for layer in &model.layers {
|
||||||
|
if !matches!(layer.kind, crate::LayerKind::Aspects) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let crate::Geometry::Lines(segs) = &layer.geometry {
|
||||||
|
for seg in segs {
|
||||||
|
let (ax, ay) = polar_to_screen(seg.from_deg, asc, rot, radii.aspects);
|
||||||
|
let (bx, by) = polar_to_screen(seg.to_deg, asc, rot, radii.aspects);
|
||||||
|
let alpha = (seg.opacity).clamp(0.0, 1.0);
|
||||||
|
out.push(DrawCommand::Line {
|
||||||
|
x1: cx + ax,
|
||||||
|
y1: cy + ay,
|
||||||
|
x2: cx + bx,
|
||||||
|
y2: cy + by,
|
||||||
|
color: aspect_color(&seg.kind).with_alpha(alpha),
|
||||||
|
width: 0.9,
|
||||||
|
dash: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sirve los `DrawCommand`s como un documento SVG completo.
|
||||||
|
/// Devuelve un `String` listo para `innerHTML = ...` o file.
|
||||||
|
pub fn draw_commands_to_svg(commands: &[DrawCommand], size: f32) -> String {
|
||||||
|
let mut s = String::with_capacity(8192);
|
||||||
|
s.push_str(&format!(
|
||||||
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{0}\" height=\"{0}\" viewBox=\"0 0 {0} {0}\">",
|
||||||
|
size as i32
|
||||||
|
));
|
||||||
|
for cmd in commands {
|
||||||
|
match cmd {
|
||||||
|
DrawCommand::Circle { cx, cy, r, stroke, fill, stroke_w } => {
|
||||||
|
let stroke_attr = stroke
|
||||||
|
.map(|c| format!(" stroke=\"{}\" stroke-width=\"{}\"", c.to_css(), stroke_w))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let fill_attr = match fill {
|
||||||
|
Some(c) => format!(" fill=\"{}\"", c.to_css()),
|
||||||
|
None => " fill=\"none\"".into(),
|
||||||
|
};
|
||||||
|
s.push_str(&format!(
|
||||||
|
"<circle cx=\"{:.2}\" cy=\"{:.2}\" r=\"{:.2}\"{}{}/>",
|
||||||
|
cx, cy, r, stroke_attr, fill_attr
|
||||||
|
));
|
||||||
|
}
|
||||||
|
DrawCommand::Line { x1, y1, x2, y2, color, width, dash } => {
|
||||||
|
let dash_attr = match dash {
|
||||||
|
Some((on, off)) => format!(" stroke-dasharray=\"{},{}\"", on, off),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
s.push_str(&format!(
|
||||||
|
"<line x1=\"{:.2}\" y1=\"{:.2}\" x2=\"{:.2}\" y2=\"{:.2}\" stroke=\"{}\" stroke-width=\"{}\"{}/>",
|
||||||
|
x1, y1, x2, y2, color.to_css(), width, dash_attr
|
||||||
|
));
|
||||||
|
}
|
||||||
|
DrawCommand::Text { x, y, content, color, size: sz, anchor } => {
|
||||||
|
let anchor_attr = match anchor {
|
||||||
|
TextAnchor::Start => "start",
|
||||||
|
TextAnchor::Middle => "middle",
|
||||||
|
TextAnchor::End => "end",
|
||||||
|
};
|
||||||
|
let escaped = svg_escape(content);
|
||||||
|
s.push_str(&format!(
|
||||||
|
"<text x=\"{:.2}\" y=\"{:.2}\" font-size=\"{:.2}\" fill=\"{}\" text-anchor=\"{}\" dominant-baseline=\"central\">{}</text>",
|
||||||
|
x, y, sz, color.to_css(), anchor_attr, escaped
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.push_str("</svg>");
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn svg_escape(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
|
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" => "⚸",
|
||||||
|
_ => "•",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aspect_color(kind: &str) -> Rgba {
|
||||||
|
match kind {
|
||||||
|
"conjunction" => Rgba::opaque(0.85, 0.65, 0.20),
|
||||||
|
"sextile" => Rgba::opaque(0.20, 0.55, 0.80),
|
||||||
|
"square" => Rgba::opaque(0.90, 0.30, 0.30),
|
||||||
|
"trine" => Rgba::opaque(0.30, 0.70, 0.40),
|
||||||
|
"opposition" => Rgba::opaque(0.55, 0.30, 0.75),
|
||||||
|
_ => Rgba::opaque(0.55, 0.55, 0.60).with_alpha(0.55),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,8 +30,12 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
pub use cosmobiologia_model::{Chart, ChartId, ChartKind};
|
pub use cosmobiologia_model::{Chart, ChartId, ChartKind};
|
||||||
|
|
||||||
|
pub mod draw;
|
||||||
pub mod math;
|
pub mod math;
|
||||||
|
|
||||||
|
pub use draw::{
|
||||||
|
compose_wheel, draw_commands_to_svg, CompositionOpts, DrawCommand, Rgba, TextAnchor,
|
||||||
|
};
|
||||||
pub use math::{
|
pub use math::{
|
||||||
find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii,
|
find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user