Files
sergio 4619ba3a2b feat(cosmobiologia): crate WASM + fallback inteligente + DEPLOY.md (fase 3b)
Cierra el requerimiento del módulo web. El cliente puede correr en
modo WASM (render local, scrubbing instantáneo, sin round-trip) o
caer al SSR (server compone el SVG) si el bundle WASM no está
desplegado. Switch automático sin configuración.

cosmobiologia-web (crate nuevo, cdylib + rlib):
- `lib.rs` con un único export wasm-bindgen
  `render_model_to_svg(json, size, rot_offset_deg) -> String` que
  deserializa un `RenderModel`, llama `compose_wheel` +
  `draw_commands_to_svg` de cosmobiologia-render, y devuelve el
  SVG inline listo para `wheel.innerHTML = svg`.
- Cargo.toml con `wasm-bindgen` + `getrandom` con feature
  `wasm_js` solo bajo `target_arch = "wasm32"` (en nativo no se
  arrastran).
- `.cargo/config.toml` con `--cfg getrandom_backend="wasm_js"`
  para que la transitividad
  `uuid → cosmobiologia-model → cosmobiologia-render` compile a
  wasm32-unknown-unknown.
- `cargo check -p cosmobiologia-web` pasa en nativo (valida la
  signature). Build WASM real lo dispara el usuario con
  `wasm-pack build --target web --out-dir ../../../apps/
  cosmobiologia-server/static/wasm` — comando documentado en
  DEPLOY.md y en doc del crate.

cosmobiologia-server — soporte cliente WASM:
- Nuevo flag `--static-wasm <dir>` (default = static/wasm relativo
  al cwd). Si el directorio existe, los archivos WASM se sirven
  en `/static/wasm/*`. Si no existe, devuelve 404 y el cliente
  cae al SSR.
- ServeDir de `tower-http` para fileserver simple.

index.html:
- Nueva función `tryLoadWasm()` que hace `import dinámico` del
  módulo WASM al boot. Si carga OK, `wasm` global queda set; si
  falla (archivo no existe o error de WASM), se loguea info y
  sigue.
- `refreshSelected()` ahora hace fetch del RenderModel JSON
  (`/api/sky` o `/api/charts/:id/render`); si hay WASM, llama
  `wasm.render_model_to_svg(json)` localmente; si no hay WASM o
  el render WASM falla, hace fetch del SVG SSR como fallback.
- Info row muestra "WASM" o "SSR" según el modo activo —
  visualmente claro qué pipeline está corriendo.

cosmobiologia-server/DEPLOY.md (nuevo):
- Build del binario + build del WASM (con wasm-pack).
- systemd service template (sandboxing básico: ProtectSystem
  strict, ProtectHome, PrivateTmp, NoNewPrivileges).
- Caddyfile y nginx para reverse proxy con TLS.
- DNS: A records para cosmobiologia.gioser.net + api.*.
- CORS: warnings sobre permissive vs producción multi-usuario.
- Separación demo público (DB vacía en VPS) vs desktop personal
  (DB compartida en `~/.local/share/cosmobiologia/`).
- Backup con SQLite `.backup`.
- Smoke test post-deploy con curl.
- Tabla de referencia de TODOS los endpoints.

Tests: 10 verdes (cosmobiologia-render::math). El cliente WASM
no agrega tests propios — la lógica testeable vive en render.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:25:48 +00:00

277 lines
8.3 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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();
}
// Cliente WASM opcional. Si `/static/wasm/cosmobiologia_web.js`
// existe (= el usuario corrió `wasm-pack build` después de
// `cargo build`), el rendering se hace localmente sin pedirle
// el SVG al server por cada interacción. Si NO existe (deploy
// mínimo), caemos al SSR de `/api/*.svg`. Toggle automático,
// sin configuración.
let wasm = null;
async function tryLoadWasm() {
try {
const mod = await import('/static/wasm/cosmobiologia_web.js');
await mod.default(); // wasm-pack: inicializa
wasm = mod;
document.getElementById('info').textContent = 'WASM cargado — render local';
} catch (e) {
console.info('WASM no disponible, usando SSR:', e.message);
}
}
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 });
const jsonUrl = mode === 'sky'
? `/api/sky`
: `/api/charts/${selectedChartId}/render?${params}`;
const ssrUrl = mode === 'sky'
? `/api/sky.svg`
: `/api/charts/${selectedChartId}/wheel.svg?${params}`;
const render = await fetch(jsonUrl).then(r => r.json()).catch(() => null);
let svg;
if (wasm && render) {
// Render local — WASM compose_wheel + draw_commands_to_svg.
try {
svg = wasm.render_model_to_svg(JSON.stringify(render), 600, 0);
} catch (e) {
console.warn('WASM render falló, fallback SSR:', e);
svg = await fetch(ssrUrl).then(r => r.text());
}
} else {
// Fallback: SSR — el server devuelve el SVG ya compuesto.
svg = await fetch(ssrUrl).then(r => r.text());
}
document.getElementById('wheel-container').innerHTML = svg;
const info = document.getElementById('info');
if (render) {
const mode_label = wasm ? 'WASM' : 'SSR';
info.innerHTML =
`<b>${escapeHtml(render.title)}</b> · ` +
`Asc ${render.ascendant_deg.toFixed(2)}° · ` +
`MC ${render.midheaven_deg.toFixed(2)}° · ` +
`${render.compute_ms} ms · ${mode_label}`;
} 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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
(async () => {
await tryLoadWasm();
await loadTree();
await loadSky();
})();
</script>
</body>
</html>