From eac8c58974d78d4fab51ce62f6bb1a12da0b7426 Mon Sep 17 00:00:00 2001 From: sergio Date: Tue, 19 May 2026 01:08:44 +0000 Subject: [PATCH] =?UTF-8?q?feat(cosmobiologia):=20cliente=20web=20demo=20S?= =?UTF-8?q?SR=20+=20DrawCommand=20agn=C3=B3stico=20(fase=203a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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 --- crates/apps/cosmobiologia-server/src/main.rs | 58 +++ .../cosmobiologia-server/static/index.html | 245 ++++++++++ .../cosmobiologia-render/src/draw.rs | 435 ++++++++++++++++++ .../cosmobiologia-render/src/lib.rs | 4 + 4 files changed, 742 insertions(+) create mode 100644 crates/apps/cosmobiologia-server/static/index.html create mode 100644 crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs diff --git a/crates/apps/cosmobiologia-server/src/main.rs b/crates/apps/cosmobiologia-server/src/main.rs index 1c4933c..844b3cf 100644 --- a/crates/apps/cosmobiologia-server/src/main.rs +++ b/crates/apps/cosmobiologia-server/src/main.rs @@ -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> { fn router() -> Router { 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 { .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 { + 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, + Path(id): Path, + Query(q): Query, +) -> Result { + 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 // ===================================================================== diff --git a/crates/apps/cosmobiologia-server/static/index.html b/crates/apps/cosmobiologia-server/static/index.html new file mode 100644 index 0000000..5e355d5 --- /dev/null +++ b/crates/apps/cosmobiologia-server/static/index.html @@ -0,0 +1,245 @@ + + + + + + Cosmobiología + + + + + +
+
+ + + + +
+
+
Seleccioná una carta o "Cielo ahora"
+
+
+
+ + + + diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs new file mode 100644 index 0000000..88ab27c --- /dev/null +++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs @@ -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, + #[serde(default)] + fill: Option, + #[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 { + 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!( + "", + 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!( + "", + 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!( + "", + 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!( + "{}", + x, y, sz, color.to_css(), anchor_attr, escaped + )); + } + } + } + s.push_str(""); + 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), + } +} diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs index e37f7fb..819abb5 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs @@ -30,8 +30,12 @@ use serde::{Deserialize, Serialize}; pub use cosmobiologia_model::{Chart, ChartId, ChartKind}; +pub mod draw; pub mod math; +pub use draw::{ + compose_wheel, draw_commands_to_svg, CompositionOpts, DrawCommand, Rgba, TextAnchor, +}; pub use math::{ find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii, };