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:
sergio
2026-05-19 01:08:44 +00:00
parent d341004f59
commit eac8c58974
4 changed files with 742 additions and 0 deletions
@@ -45,6 +45,7 @@ use clap::Parser;
use cosmobiologia_engine::{
compose_with_options, svg_export, EngineError, NatalOptions, PipelineRequest, RenderModel,
};
use cosmobiologia_render::{compose_wheel, draw_commands_to_svg, CompositionOpts};
use cosmobiologia_model::{
Chart, ChartId, ChartKind, Contact, ContactId, Group, GroupId, StoredBirthData,
StoredChartConfig,
@@ -118,9 +119,17 @@ fn default_db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
fn router() -> Router<AppState> {
Router::new()
.route("/", get(get_index))
.route("/api/health", get(health))
.route("/api/tree", get(get_tree))
.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/:id", patch(patch_group).delete(delete_group))
.route("/api/contacts", post(post_contact))
@@ -139,6 +148,55 @@ fn router() -> Router<AppState> {
.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
// =====================================================================