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:
@@ -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>
|
||||
Reference in New Issue
Block a user