Files
brahman/crates/apps/cosmobiologia-server/static/index.html
T
sergio eac8c58974 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>
2026-05-19 01:08:44 +00:00

246 lines
7.1 KiB
HTML
Raw 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();
}
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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
loadTree();
loadSky();
</script>
</body>
</html>