feat(cosmobiologia-render): compose_wheel rico con palette + dial 3D + spread + coord labels
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<DrawCommand> {
|
||||
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,81 +161,68 @@ 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 {
|
||||
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,
|
||||
@@ -222,25 +233,8 @@ pub fn compose_wheel(
|
||||
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<String, f32> =
|
||||
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<f32> = 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<usize> = 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<String, JsValue> {
|
||||
pub fn render_model_to_svg(
|
||||
json: &str,
|
||||
size: f32,
|
||||
rot_offset_deg: f32,
|
||||
) -> Result<String, JsValue> {
|
||||
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<String, JsValue> {
|
||||
render_with_opts(json, size, rot_offset_deg, dark)
|
||||
}
|
||||
|
||||
fn render_with_opts(
|
||||
json: &str,
|
||||
size: f32,
|
||||
rot_offset_deg: f32,
|
||||
dark: bool,
|
||||
) -> Result<String, JsValue> {
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user