//! 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, }, /// Polígono cerrado — lista de vértices, con relleno y/o trazo. Polygon { points: Vec<(f32, f32)>, #[serde(default)] fill: Option, #[serde(default)] stroke: Option, #[serde(default = "default_stroke_width")] stroke_w: f32, }, } fn default_stroke_width() -> f32 { 1.0 } fn default_anchor() -> TextAnchor { TextAnchor::Middle } /// 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). 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, /// 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 { fn default() -> Self { Self { 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`. /// 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::{find_clusters, format_coord_compact, polar_to_screen, spread_angles, 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; let pal = &opts.palette; // === 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(pal.dial_ring), fill: None, stroke_w: 1.6, }); out.push(DrawCommand::Circle { cx, cy, r: radii.sign_inner, stroke: Some(pal.dial_ring.with_alpha(0.7)), fill: None, stroke_w: 1.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 { x1: cx + xi, y1: cy + yi, x2: cx + xo, y2: cy + yo, color, width, dash: None, }); } // === 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) { 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: pal.sign(&g.symbol), size: opts.size * 0.032, anchor: TextAnchor::Middle, }); } } // === 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; } 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(); } } // 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: disk, stroke: Some(body_color), fill: Some(halo_fill), stroke_w: 1.2, }); // Glyph out.push(DrawCommand::Text { x: cx + gx, y: cy + gy, 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, }); } } } } // === Anillo de aspectos + líneas === out.push(DrawCommand::Circle { cx, cy, r: radii.aspects, stroke: Some(pal.fg_muted.with_alpha(0.35)), fill: None, 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 { // 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: 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 } /// 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::Polygon { points, fill, stroke, stroke_w } => { let pts: String = points .iter() .map(|(x, y)| format!("{:.2},{:.2} ", x, y)) .collect(); let fill_attr = match fill { Some(c) => format!(" fill=\"{}\"", c.to_css()), None => " fill=\"none\"".into(), }; let stroke_attr = stroke .map(|c| format!(" stroke=\"{}\" stroke-width=\"{}\"", c.to_css(), stroke_w)) .unwrap_or_default(); s.push_str(&format!( "", pts.trim_end(), fill_attr, stroke_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('"', """) } pub(crate) 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" => "⚸", _ => "•", } } /// Glyph del cuerpo con sufijo "℞" si está retrógrado — concatenación /// directa en el text para no agregar más comandos por planeta. pub(crate) fn planet_unicode_with_retro(name: &str, retrograde: bool) -> String { if retrograde { format!("{}℞", planet_unicode(name)) } else { planet_unicode(name).to_string() } }