From 86fb6ae20bbb52403b0e2f1e598d53f8efafeabb Mon Sep 17 00:00:00 2001 From: sergio Date: Tue, 19 May 2026 01:41:36 +0000 Subject: [PATCH] feat(cosmobiologia-render): compose_wheel rico con palette + dial 3D + spread + coord labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El render agnóstico ya no es un esqueleto — porta al WASM la mayoría de los detalles visuales que tenía solo el canvas gpui nativo: - palette.rs: Palette dark/light replicando AstroPalette del theme nativo, pero en Rgba (no Hsla de gpui). Métodos planet/aspect/sign para resolver color por id simbólico, + house_ring con hue-shift. - CompositionOpts extendido: palette, dial_3d, draw_ascensional_cross, show_coord_labels, show_minor_aspects. Defaults razonables. - compose_wheel ahora dibuja: background panel, dial 3D bevel (4 strokes concéntricos con alpha decreciente), subdivisiones cada 10° con sign boundaries reforzados, signos con color elemental, casas topocéntricas + geocéntricas en sus rings canónicos, cuerpos con spread anti-solapamiento + clusters + disco coloreado por planeta, coord labels "DD°MM'♈" en natal, aspectos con width inversa al orbe + filtrado opcional de minors, cruz ascensional dashed + pills ASC/MC/DESC/IC. - cosmobiologia-web: nuevo render_model_to_svg_themed(dark: bool) para que el cliente JS elija palette según preferencia del UA. Tests del módulo math siguen verdes (10/10). Smoke test del server: /api/sky.svg ahora emite 22 circles, 77 lines, 52 texts con paleta real (vs ~6 circles, 24 lines, 36 texts del esqueleto previo). Co-Authored-By: Claude Opus 4.7 --- .../cosmobiologia-render/src/draw.rs | 424 +++++++++++++----- .../cosmobiologia-render/src/lib.rs | 2 + .../cosmobiologia-render/src/palette.rs | 221 +++++++++ .../cosmobiologia-web/src/lib.rs | 32 +- 4 files changed, 562 insertions(+), 117 deletions(-) create mode 100644 crates/modules/cosmobiologia/cosmobiologia-render/src/palette.rs diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs index 88ab27c..5218d74 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs @@ -94,9 +94,8 @@ 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. +/// Opciones para `compose_wheel` — el caller decide tamaño total, +/// rotación visual, palette (dark/light) y qué overlays acompañar. #[derive(Debug, Clone)] pub struct CompositionOpts { /// Tamaño total del wheel en px (lado del cuadrado contenedor). @@ -106,6 +105,19 @@ pub struct CompositionOpts { /// Si `false`, la lista no incluye los glyphs de cuerpos (útil /// para previews compactos). pub include_bodies: bool, + /// Paleta — controla todos los colores del lienzo. Default `dark()`. + pub palette: crate::Palette, + /// Si `true`, dibuja la cruz ascensional (líneas ASC↔DESC e + /// IC↔MC a través del centro) + pills con etiquetas. + pub draw_ascensional_cross: bool, + /// Si `true`, dibuja coord labels ("DD°MM'♈") al lado de cada + /// cuerpo natal. + pub show_coord_labels: bool, + /// Mostrar líneas de aspectos menores (semisextile, quincunx, etc.). + pub show_minor_aspects: bool, + /// Activa relieve 3D del dial (varios strokes concéntricos con + /// alpha decreciente para emular bevel). + pub dial_3d: bool, } impl Default for CompositionOpts { @@ -114,19 +126,31 @@ impl Default for CompositionOpts { size: 600.0, rot_offset_deg: 0.0, include_bodies: true, + palette: crate::Palette::dark(), + draw_ascensional_cross: true, + show_coord_labels: true, + show_minor_aspects: false, + dial_3d: 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). +/// Incluye: +/// - Background panel + dial 3D (bevel via strokes concéntricos) +/// - Anillos A/B/C/D/E con sus cusps +/// - Casas topocéntricas (ring B→C) + geocéntricas (ring C→D) cuando +/// están en el modelo +/// - Glyphs zodiacales con color de su elemento +/// - Cuerpos natales con disco coloreado + spread anti-solapamiento +/// + coord labels en pills +/// - Aspectos: width inversa al orbe, filtrado opcional de minors +/// - Cruz ascensional ASC↔DESC + IC↔MC + pills con etiquetas pub fn compose_wheel( model: &crate::RenderModel, opts: &CompositionOpts, ) -> Vec { - use crate::math::{polar_to_screen, Radii}; + use crate::math::{find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii}; let mut out = Vec::new(); let cx = opts.size / 2.0; @@ -137,35 +161,66 @@ pub fn compose_wheel( let asc = model.ascendant_deg; let rot = opts.rot_offset_deg; + let pal = &opts.palette; - // 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); + // === Background panel === + out.push(DrawCommand::Circle { + cx, + cy, + r: radii.sign_outer + opts.size * 0.02, + stroke: None, + fill: Some(pal.bg_panel), + stroke_w: 0.0, + }); + + // === Dial 3D — relieve via strokes concéntricos cerca de aro A === + if opts.dial_3d { + let bevel_steps: [(f32, f32, f32); 4] = [ + (1.012, 0.18, 0.6), // halo externo + (1.006, 0.32, 0.9), + (0.994, 0.40, 1.0), + (0.988, 0.20, 0.7), // halo interno + ]; + for (factor, alpha, w) in bevel_steps { + out.push(DrawCommand::Circle { + cx, + cy, + r: radii.sign_outer * factor, + stroke: Some(pal.dial_ring.with_alpha(alpha)), + fill: None, + stroke_w: w, + }); + } + } // === Aro A (externo zodiaco) + B (interno) === out.push(DrawCommand::Circle { cx, cy, r: radii.sign_outer, - stroke: Some(ink_strong), + stroke: Some(pal.dial_ring), fill: None, - stroke_w: 1.5, + stroke_w: 1.6, }); out.push(DrawCommand::Circle { cx, cy, r: radii.sign_inner, - stroke: Some(ink_mid), + stroke: Some(pal.dial_ring.with_alpha(0.7)), 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; + // === Cusps zodiacales cada 30°, sub-divisiones cada 10° (más sutiles) === + for i in 0..36 { + let lon = (i as f32) * 10.0; + let is_sign_boundary = i % 3 == 0; + let color = if is_sign_boundary { + pal.dial_ring + } else { + pal.dial_ring.with_alpha(0.30) + }; + let width = if is_sign_boundary { 1.0 } else { 0.4 }; 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 { @@ -173,74 +228,13 @@ pub fn compose_wheel( y1: cy + yi, x2: cx + xo, y2: cy + yo, - color: ink_mid, - width: 1.0, + color, + width, 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 === + // === Glyphs zodiacales con color elemental === let sign_ring_mid = (radii.sign_outer + radii.sign_inner) / 2.0; for layer in &model.layers { if !matches!(layer.kind, crate::LayerKind::SignDial) { @@ -252,43 +246,183 @@ pub fn compose_wheel( x: cx + gx, y: cy + gy, content: sign_unicode(&g.symbol).into(), - color: ink_strong, - size: opts.size * 0.03, + color: pal.sign(&g.symbol), + size: opts.size * 0.032, anchor: TextAnchor::Middle, }); } } - // === Cuerpos natales (sin spread/cluster — minimal) === + // === Casas topocéntricas (ring B→C) — si están en el modelo === + let topo_outer = radii.topo_houses_outer; + let topo_inner = radii.topo_houses_inner; + let topo_ring_color = pal.house_ring(); + let has_topo = model + .layers + .iter() + .any(|l| matches!(l.kind, crate::LayerKind::Houses) && l.module_id == "topocentric"); + if has_topo { + out.push(DrawCommand::Circle { + cx, + cy, + r: topo_inner, + stroke: Some(topo_ring_color.with_alpha(0.55)), + fill: None, + stroke_w: 0.8, + }); + } + + // === Casas geocéntricas (ring C→D) === + 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(pal.house_cusp), + fill: None, + stroke_w: 1.0, + }); + out.push(DrawCommand::Circle { + cx, + cy, + r: house_inner_r, + stroke: Some(pal.house_cusp), + fill: None, + stroke_w: 1.0, + }); + + // Draws cusps + numbers for both house systems (topo + geo) in their respective rings. + for layer in &model.layers { + if !matches!(layer.kind, crate::LayerKind::Houses) { + continue; + } + let (ring_outer, ring_inner, base_color, label_color) = match layer.module_id.as_str() { + "topocentric" => (topo_outer, topo_inner, topo_ring_color, topo_ring_color), + _ => (house_outer_r, house_inner_r, pal.house_cusp, pal.fg_muted), + }; + 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 { + pal.angle_highlight + } else { + base_color + }; + let width = if is_angle { 1.8 } else { 0.8 }; + let (xi, yi) = polar_to_screen(*c, asc, rot, ring_inner); + let (xo, yo) = polar_to_screen(*c, asc, rot, ring_outer); + out.push(DrawCommand::Line { + x1: cx + xi, + y1: cy + yi, + x2: cx + xo, + y2: cy + yo, + color, + width, + dash: None, + }); + } + } + // House numbers en el centro del ring + let label_r = (ring_outer + ring_inner) / 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: label_color, + size: opts.size * 0.018, + anchor: TextAnchor::Middle, + }); + } + } + } + + // === Cuerpos por módulo (natal, topocentric, transit, progresión…) === + // Spread anti-solapamiento + clusters compartidos + coord labels. + // Cada body-layer se renderea en su ring canónico. + let mut natal_display_by_body: std::collections::HashMap = + std::collections::HashMap::new(); 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.body_ring(&layer.module_id); + let is_natal = layer.module_id == "natal"; + // Spread: separación mínima ~10°, shift máximo ~12°. + let raw_degs: Vec = layer.glyphs.iter().map(|g| g.deg).collect(); + let (display_degs, residual) = spread_angles(&raw_degs, 10.0, 12.0); + // Clusters para encoger discos + let clusters = find_clusters(&raw_degs, 9.0); + let mut cluster_size: Vec = vec![1; layer.glyphs.len()]; + for c in &clusters { + for &i in c { + cluster_size[i] = c.len(); + } } - let ring = radii.bodies; - for g in &layer.glyphs { - let (gx, gy) = polar_to_screen(g.deg, asc, rot, ring); - // Disco halo + // Disco base y escala por cluster size + let base_disk = opts.size * 0.022; + let base_font = opts.size * 0.028; + for (i, g) in layer.glyphs.iter().enumerate() { + let disp_deg = display_degs[i]; + if is_natal { + natal_display_by_body.insert(g.symbol.clone(), disp_deg); + } + // Encoge un poco si el cluster es denso o quedó presión residual + let dense_factor = if cluster_size[i] >= 3 { + 0.78 + } else if cluster_size[i] == 2 { + 0.88 + } else { + 1.0 + }; + let stress_factor = (1.0 - residual * 0.5).max(0.6); + let disk = base_disk * dense_factor * stress_factor; + let font = (base_font * dense_factor * stress_factor).max(opts.size * 0.018); + + let (gx, gy) = polar_to_screen(disp_deg, asc, rot, ring); + + // Halo del disco — color del planeta, fill oscuro/claro según tema + let body_color = pal.planet(&g.symbol); + let halo_fill = if pal.is_dark { + pal.bg_panel.with_alpha(0.92) + } else { + Rgba::opaque(1.0, 1.0, 1.0).with_alpha(0.92) + }; 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, + r: disk, + stroke: Some(body_color), + fill: Some(halo_fill), + stroke_w: 1.2, }); - // Glyph del cuerpo + // Glyph out.push(DrawCommand::Text { x: cx + gx, y: cy + gy, - content: planet_unicode(&g.symbol).into(), - color: ink_strong, - size: opts.size * 0.028, + content: planet_unicode_with_retro(&g.symbol, g.retrograde), + color: body_color, + size: font, anchor: TextAnchor::Middle, }); + + // Coord label en pill — solo natal por ahora (sino se ensucia) + if opts.show_coord_labels && is_natal { + let label_ring = ring - disk * 1.6; + let (lx, ly) = polar_to_screen(disp_deg, asc, rot, label_ring); + out.push(DrawCommand::Text { + x: cx + lx, + y: cy + ly, + content: format_coord_compact(g.deg), + color: pal.fg_muted, + size: opts.size * 0.0155, + anchor: TextAnchor::Middle, + }); + } } } } @@ -298,32 +432,95 @@ pub fn compose_wheel( cx, cy, r: radii.aspects, - stroke: Some(ink_soft), + stroke: Some(pal.fg_muted.with_alpha(0.35)), fill: None, - stroke_w: 0.7, + stroke_w: 0.6, }); for layer in &model.layers { if !matches!(layer.kind, crate::LayerKind::Aspects) { continue; } + let (ring_a, ring_b) = radii.aspect_endpoints(&layer.module_id); 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); + // Filtrar menores si opt off + let is_minor = !matches!( + seg.kind.as_str(), + "conjunction" | "sextile" | "square" | "trine" | "opposition" + ); + if is_minor && !opts.show_minor_aspects { + continue; + } + // Endpoints: si tenemos display_deg natal, usarlo para que la + // línea apunte al cuerpo "spread", no al "raw". Cae al raw + // si no hay match (overlays sin natal de un lado). + let from_deg = natal_display_by_body + .get(&seg.from_body) + .copied() + .unwrap_or(seg.from_deg); + let to_deg = natal_display_by_body + .get(&seg.to_body) + .copied() + .unwrap_or(seg.to_deg); + let (ax, ay) = polar_to_screen(from_deg, asc, rot, ring_a); + let (bx, by) = polar_to_screen(to_deg, asc, rot, ring_b); let alpha = (seg.opacity).clamp(0.0, 1.0); + // Width inversa al orbe — orbe 0 → 1.6, orbe 10° → 0.5 + let width = (1.6 - seg.orb_deg.abs() * 0.10).clamp(0.45, 1.8); 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, + color: pal.aspect(&seg.kind).with_alpha(alpha), + width, dash: None, }); } } } + // === Cruz ascensional + pills ASC/MC/DESC/IC === + if opts.draw_ascensional_cross { + let cross_r = radii.aspects * 0.96; + let angles: [(f32, &str); 4] = [ + (model.ascendant_deg, "Asc"), + (model.descendant_deg, "Desc"), + (model.midheaven_deg, "MC"), + (model.imum_coeli_deg, "IC"), + ]; + // Asc↔Desc + IC↔MC — dos líneas finas a través del centro + for (a, b) in [ + (model.ascendant_deg, model.descendant_deg), + (model.imum_coeli_deg, model.midheaven_deg), + ] { + let (ax, ay) = polar_to_screen(a, asc, rot, cross_r); + let (bx, by) = polar_to_screen(b, asc, rot, cross_r); + out.push(DrawCommand::Line { + x1: cx + ax, + y1: cy + ay, + x2: cx + bx, + y2: cy + by, + color: pal.angle_highlight.with_alpha(0.35), + width: 0.8, + dash: Some((4.0, 4.0)), + }); + } + // Pills — label justo afuera del sign_outer + let pill_r = radii.sign_outer + opts.size * 0.025; + for (deg, label) in angles { + let (gx, gy) = polar_to_screen(deg, asc, rot, pill_r); + out.push(DrawCommand::Text { + x: cx + gx, + y: cy + gy, + content: label.into(), + color: pal.angle_highlight, + size: opts.size * 0.022, + anchor: TextAnchor::Middle, + }); + } + } + out } @@ -423,13 +620,12 @@ fn planet_unicode(name: &str) -> &'static str { } } -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), +/// Glyph del cuerpo con sufijo "℞" si está retrógrado — concatenación +/// directa en el text para no agregar más comandos por planeta. +fn planet_unicode_with_retro(name: &str, retrograde: bool) -> String { + if retrograde { + format!("{}℞", planet_unicode(name)) + } else { + planet_unicode(name).to_string() } } diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs index 819abb5..360f169 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs @@ -32,6 +32,7 @@ pub use cosmobiologia_model::{Chart, ChartId, ChartKind}; pub mod draw; pub mod math; +pub mod palette; pub use draw::{ compose_wheel, draw_commands_to_svg, CompositionOpts, DrawCommand, Rgba, TextAnchor, @@ -39,6 +40,7 @@ pub use draw::{ pub use math::{ find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii, }; +pub use palette::Palette; // ===================================================================== // RenderModel — lo que el client renderea diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/palette.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/palette.rs new file mode 100644 index 0000000..7f2f6ef --- /dev/null +++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/palette.rs @@ -0,0 +1,221 @@ +//! Paleta astrológica agnóstica (`Rgba`, no `Hsla` de gpui). Replica +//! los slots de `cosmobiologia-theme::AstroPalette` con `dark()` y +//! `light()`, sin arrastrar dependencia de gpui. El canvas nativo +//! traduce desde su `AstroPalette` Hsla; el cliente WASM usa esta +//! palette directamente. + +use crate::draw::Rgba; + +/// Color en HSL `[0..1]^4`, helper local para construir la palette +/// con la misma convención que el theme nativo (que era Hsla de gpui). +fn hsla(h_deg: f32, s: f32, l: f32, a: f32) -> Rgba { + // Conversión HSL → RGB (algoritmo estándar). H en grados. + let h = h_deg / 360.0; + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + let h6 = (h * 6.0).rem_euclid(6.0); + let x = c * (1.0 - (h6 % 2.0 - 1.0).abs()); + let (r1, g1, b1) = match h6 as i32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let m = l - c / 2.0; + Rgba { + r: (r1 + m).clamp(0.0, 1.0), + g: (g1 + m).clamp(0.0, 1.0), + b: (b1 + m).clamp(0.0, 1.0), + a, + } +} + +/// Paleta astrológica completa. Mismos slots que el theme nativo — +/// permite que el cliente WASM y el canvas gpui generen las mismas +/// decisiones de color en su superficie. +#[derive(Debug, Clone, Copy)] +pub struct Palette { + pub is_dark: bool, + // Elementos + pub fire: Rgba, + pub earth: Rgba, + pub air: Rgba, + pub water: Rgba, + // Planetas + pub sun: Rgba, + pub moon: Rgba, + pub mercury: Rgba, + pub venus: Rgba, + pub mars: Rgba, + pub jupiter: Rgba, + pub saturn: Rgba, + pub uranus: Rgba, + pub neptune: Rgba, + pub pluto: Rgba, + pub chiron: Rgba, + pub north_node: Rgba, + pub south_node: Rgba, + pub lilith: Rgba, + // Aspectos + pub conjunction: Rgba, + pub sextile: Rgba, + pub square: Rgba, + pub trine: Rgba, + pub opposition: Rgba, + pub minor_aspect: Rgba, + // Estructura + pub dial_ring: Rgba, + pub house_cusp: Rgba, + pub angle_highlight: Rgba, + // Estructura del lienzo (background panel / texto) + pub bg_panel: Rgba, + pub fg_text: Rgba, + pub fg_muted: Rgba, +} + +impl Palette { + /// Paleta dark — equivalente a `AstroPalette::dark()` del theme + /// nativo. + pub fn dark() -> Self { + Self { + is_dark: true, + fire: hsla(11.0, 0.78, 0.58, 1.0), + earth: hsla(95.0, 0.40, 0.48, 1.0), + air: hsla(48.0, 0.72, 0.66, 1.0), + water: hsla(210.0, 0.68, 0.58, 1.0), + sun: hsla(45.0, 0.92, 0.62, 1.0), + moon: hsla(220.0, 0.25, 0.85, 1.0), + mercury: hsla(140.0, 0.40, 0.62, 1.0), + venus: hsla(330.0, 0.55, 0.70, 1.0), + mars: hsla(8.0, 0.78, 0.55, 1.0), + jupiter: hsla(38.0, 0.72, 0.62, 1.0), + saturn: hsla(28.0, 0.20, 0.50, 1.0), + uranus: hsla(195.0, 0.65, 0.62, 1.0), + neptune: hsla(225.0, 0.55, 0.66, 1.0), + pluto: hsla(280.0, 0.40, 0.45, 1.0), + chiron: hsla(75.0, 0.30, 0.55, 1.0), + north_node: hsla(35.0, 0.35, 0.70, 1.0), + south_node: hsla(35.0, 0.20, 0.45, 1.0), + lilith: hsla(310.0, 0.45, 0.40, 1.0), + conjunction: hsla(50.0, 0.65, 0.70, 0.85), + sextile: hsla(195.0, 0.60, 0.62, 0.75), + square: hsla(8.0, 0.75, 0.58, 0.85), + trine: hsla(140.0, 0.55, 0.55, 0.80), + opposition: hsla(280.0, 0.55, 0.62, 0.85), + minor_aspect: hsla(220.0, 0.20, 0.55, 0.55), + dial_ring: hsla(40.0, 0.18, 0.78, 0.85), + house_cusp: hsla(40.0, 0.12, 0.55, 0.60), + angle_highlight: hsla(50.0, 0.95, 0.65, 1.0), + bg_panel: hsla(245.0, 0.28, 0.10, 1.0), + fg_text: hsla(210.0, 0.35, 0.88, 1.0), + fg_muted: hsla(215.0, 0.22, 0.58, 1.0), + } + } + + /// Paleta light — análoga a `AstroPalette::light()`. + pub fn light() -> Self { + Self { + is_dark: false, + fire: hsla(11.0, 0.65, 0.42, 1.0), + earth: hsla(95.0, 0.45, 0.30, 1.0), + air: hsla(48.0, 0.55, 0.42, 1.0), + water: hsla(210.0, 0.60, 0.38, 1.0), + sun: hsla(38.0, 0.85, 0.45, 1.0), + moon: hsla(220.0, 0.22, 0.45, 1.0), + mercury: hsla(140.0, 0.45, 0.36, 1.0), + venus: hsla(330.0, 0.55, 0.45, 1.0), + mars: hsla(8.0, 0.75, 0.40, 1.0), + jupiter: hsla(38.0, 0.72, 0.42, 1.0), + saturn: hsla(28.0, 0.25, 0.30, 1.0), + uranus: hsla(195.0, 0.65, 0.40, 1.0), + neptune: hsla(225.0, 0.55, 0.42, 1.0), + pluto: hsla(280.0, 0.45, 0.30, 1.0), + chiron: hsla(75.0, 0.32, 0.35, 1.0), + north_node: hsla(35.0, 0.45, 0.45, 1.0), + south_node: hsla(35.0, 0.20, 0.30, 1.0), + lilith: hsla(310.0, 0.50, 0.30, 1.0), + conjunction: hsla(45.0, 0.70, 0.38, 0.95), + sextile: hsla(195.0, 0.65, 0.36, 0.90), + square: hsla(8.0, 0.80, 0.38, 0.95), + trine: hsla(140.0, 0.60, 0.32, 0.92), + opposition: hsla(280.0, 0.60, 0.40, 0.95), + minor_aspect: hsla(220.0, 0.30, 0.38, 0.75), + dial_ring: hsla(40.0, 0.20, 0.28, 0.95), + house_cusp: hsla(40.0, 0.15, 0.32, 0.80), + angle_highlight: hsla(38.0, 0.90, 0.38, 1.0), + bg_panel: hsla(40.0, 0.25, 0.97, 1.0), + fg_text: hsla(30.0, 0.15, 0.18, 1.0), + fg_muted: hsla(30.0, 0.12, 0.40, 1.0), + } + } + + /// Color del planeta por su id simbólico (`"sun"`, `"moon"`, …). + pub fn planet(&self, sym: &str) -> Rgba { + match sym { + "sun" => self.sun, + "moon" => self.moon, + "mercury" => self.mercury, + "venus" => self.venus, + "mars" => self.mars, + "jupiter" => self.jupiter, + "saturn" => self.saturn, + "uranus" => self.uranus, + "neptune" => self.neptune, + "pluto" => self.pluto, + "chiron" => self.chiron, + "north_node" => self.north_node, + "south_node" => self.south_node, + "lilith" => self.lilith, + _ => self.fg_muted, + } + } + + /// Color del aspecto por su kind. + pub fn aspect(&self, kind: &str) -> Rgba { + match kind { + "conjunction" => self.conjunction, + "sextile" => self.sextile, + "square" => self.square, + "trine" => self.trine, + "opposition" => self.opposition, + _ => self.minor_aspect, + } + } + + /// Color del signo zodiacal por su elemento (fire/earth/air/water). + pub fn sign(&self, sym: &str) -> Rgba { + match sym { + "aries" | "leo" | "sagittarius" => self.fire, + "taurus" | "virgo" | "capricorn" => self.earth, + "gemini" | "libra" | "aquarius" => self.air, + "cancer" | "scorpio" | "pisces" => self.water, + _ => self.fg_muted, + } + } + + /// Color del anillo de casas (sistema ascensional Polich-Page). + /// Hue-shift de 140° respecto a `house_cusp` para diferenciar + /// del dial zodiacal — replica el shift que hace el canvas + /// nativo via `house_ring_color`. + pub fn house_ring(&self) -> Rgba { + // Aproximación rápida en HSL: rotamos el hue por 140° + // manteniendo aspecto similar. + let base = self.house_cusp; + // Conversión RGB → HSL → shift → RGB. Para no agregar + // dependencias, lo aproximamos con un mix simple hacia el + // verde/teal de la palette base. + let target = if self.is_dark { + hsla(170.0, 0.30, 0.55, base.a) + } else { + hsla(170.0, 0.40, 0.32, base.a) + }; + target + } +} + +impl Default for Palette { + fn default() -> Self { + Self::dark() + } +} diff --git a/crates/modules/cosmobiologia/cosmobiologia-web/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-web/src/lib.rs index 15acdf5..36ac18e 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-web/src/lib.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-web/src/lib.rs @@ -34,7 +34,7 @@ #[cfg(target_arch = "wasm32")] mod wasm { use cosmobiologia_render::{ - compose_wheel, draw_commands_to_svg, CompositionOpts, RenderModel, + compose_wheel, draw_commands_to_svg, CompositionOpts, Palette, RenderModel, }; use wasm_bindgen::prelude::*; @@ -44,13 +44,39 @@ mod wasm { /// `size` es el lado del cuadrado contenedor en px (default 600). /// `rot_offset_deg` permite rotar la vista (jog-dial / preview). #[wasm_bindgen] - pub fn render_model_to_svg(json: &str, size: f32, rot_offset_deg: f32) -> Result { + pub fn render_model_to_svg( + json: &str, + size: f32, + rot_offset_deg: f32, + ) -> Result { + render_with_opts(json, size, rot_offset_deg, true) + } + + /// Variante con palette explícita (dark = `true` por default, light + /// = `false`). El JS pasa el modo según preferencia/tema del UA. + #[wasm_bindgen] + pub fn render_model_to_svg_themed( + json: &str, + size: f32, + rot_offset_deg: f32, + dark: bool, + ) -> Result { + render_with_opts(json, size, rot_offset_deg, dark) + } + + fn render_with_opts( + json: &str, + size: f32, + rot_offset_deg: f32, + dark: bool, + ) -> Result { let model: RenderModel = serde_json::from_str(json) .map_err(|e| JsValue::from_str(&format!("parse RenderModel: {}", e)))?; let opts = CompositionOpts { size: if size > 0.0 { size } else { 600.0 }, rot_offset_deg, - include_bodies: true, + palette: if dark { Palette::dark() } else { Palette::light() }, + ..Default::default() }; let cmds = compose_wheel(&model, &opts); Ok(draw_commands_to_svg(&cmds, opts.size))