d3649bfd1a
Cuatro features que cierran el set inicial de funcionalidades de
fase 1:
## D — Acordeón colapsable en el panel
Cuando hay 8 módulos en el panel se llenaba de cards. Ahora cada card
es expandible/colapsable por click en el header. Defaults:
- Natal siempre expanded
- Módulos con toggle "enabled" = true → expanded
- Resto → collapsed
El usuario puede forzar cualquiera vía override (collapse_overrides
HashMap). Chevron ▾/▸ a la izquierda del header. Hover sobre el
header lo resalta para invitar al click.
## B — Lunar return shift (navegación mensual)
PipelineRequest::PlanetaryReturn gana campo `shift_days: i64` (range
±180). El bridge lo suma a after_seconds del search anchor antes de
next_return. Para Solar return típicamente 0 (mantiene comportamiento).
Para Moon return, mover el slider ±28 días salta al retorno lunar
anterior o siguiente, permitiendo navegar mes a mes la lunación que
le toca al sujeto cumplido N años. PlanetaryReturnModule.controls()
agrega un slider "Shift días (lunar nav)". El badge del overlay
muestra "Moon return 38a +14d" cuando shift_days != 0. Helper
`planetary_return_request(body, age)` para callers que no necesitan
shift (zero default).
## C — CompositeModule
Carta compuesta (midpoint Davison) entre la natal del sujeto y otra
carta partner. Cada placement compuesto es el angular midpoint entre
los dos correspondientes. Engine: `PipelineRequest::Composite {
partner_chart: Box<Chart> }` + build_composite_overlay que llama
`eternal_astrology::composite()`. Renderiza placements en
`radii.composite = r * 0.32` (entre solar_arc 0.40 y aspects 0.24,
re-balanced). Módulo `composite::CompositeModule` con toggle +
ChartPicker (mismo patrón que synastry).
Shell: resolve_composite_partner reusa el fallback al primer hermano
del contacto, igual que synastry.
## A — 90 ciudades expandidas + dropdown scrollable
CITY_PRESETS pasa de 25 a 90 ciudades cubriendo:
- Latinoamérica (35): todas las capitales + grandes ciudades de AR/
VE/CO/PE/CL/EC/UY/PY/BO/MX/CU/PR/CR/PA/SV/GT/HN/NI/DO/BR
- España (5) + Europa (20): Madrid/Barcelona/Sevilla/Valencia/Bilbao
+ London/Paris/Berlin/München/Roma/Milano/Amsterdam/Bruxelles/Wien/
Zürich/Lisboa/Dublin/Stockholm/Oslo/København/Helsinki/Warszawa/
Praha/Budapest/Athina/İstanbul/Moskva
- USA + Canadá (12): NY/LA/Chicago/Miami/Houston/SF/Seattle/Boston/
DC + Toronto/Montreal/Vancouver
- Asia (16): Tokyo/Beijing/Shanghai/HK/Singapore/Seoul/Bangkok/
Jakarta/Manila/Mumbai/Delhi/Bangalore/Karachi/Tehran/Dubai/Tel Aviv
- África (6): Cairo/Lagos/Nairobi/Johannesburg/Cape Town/Casablanca
- Oceanía (3): Sydney/Melbourne/Auckland
El popup del dropdown ahora es scrollable (h=360px, overflow_y_scroll)
con id estable para no perder scroll position entre re-renders.
cargo check verde, 8 tests engine + 1 test modules (8 módulos
aplicables a ChartKind::Natal) verdes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1767 lines
59 KiB
Rust
1767 lines
59 KiB
Rust
//! `tahuantinsuyu-canvas` — el widget GPUI del lienzo astrológico.
|
|
//!
|
|
//! Modela el cielo como un lienzo de **geometría reactiva**: un estado
|
|
//! unificado [`CanvasState`] guarda offsets de rotación, flags de
|
|
//! visibilidad y la lista de `Layer`s a pintar. Las interacciones
|
|
//! (drag, hotkeys, toggles) mutan el estado; el render lee la última
|
|
//! `RenderModel` y la deriva al frame.
|
|
//!
|
|
//! ## Convención de rotación
|
|
//!
|
|
//! El Ascendente cae a las 9 del reloj (lado izquierdo). Las casas
|
|
//! crecen contrarreloj visualmente. Para una longitud eclíptica `L` y
|
|
//! un ascendente `asc`:
|
|
//!
|
|
//! ```text
|
|
//! screen_angle_rad = π - (L - asc + view_rotation) · π/180
|
|
//! point = (cx + r·cos(θ), cy + r·sin(θ))
|
|
//! ```
|
|
//!
|
|
//! ## Interacciones (fase 4)
|
|
//!
|
|
//! - **Drag en el aro exterior** (jog-dial perimetral): rota la rueda
|
|
//! visualmente mientras dura el drag y, al soltar, traduce el delta
|
|
//! angular a minutos (1° ≈ 4 min) y emite
|
|
//! [`CanvasEvent::TimeOffsetChanged`]. El host (la app) recomputa la
|
|
//! carta para el instante desplazado.
|
|
//! - **Hotkeys**: `D`/`H`/`X`/`P` togglean SignDial/Houses/Aspects/
|
|
//! Bodies. Click sobre el wheel le da focus al widget.
|
|
|
|
#![forbid(unsafe_code)]
|
|
#![warn(rust_2018_idioms)]
|
|
|
|
use std::collections::HashMap;
|
|
use std::f32::consts::PI;
|
|
|
|
use gpui::{
|
|
AppContext, Bounds, Context, EventEmitter, FocusHandle, Focusable, Hsla, IntoElement,
|
|
KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement,
|
|
PathBuilder, Pixels, Point, Render, SharedString, Styled, Window, canvas, div, hsla,
|
|
linear_color_stop, linear_gradient, point, prelude::*, px,
|
|
};
|
|
|
|
use tahuantinsuyu_engine::{Geometry, Layer, LayerKind, OUTER_RING_MODULES, RenderModel};
|
|
use tahuantinsuyu_model::{ChartId, ContactId, GroupId};
|
|
use tahuantinsuyu_theme::{AspectKind as TAspectKind, AstroPalette, Element, Planet};
|
|
use yahweh_theme::Theme;
|
|
|
|
// =====================================================================
|
|
// Eventos
|
|
// =====================================================================
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum CanvasEvent {
|
|
/// Doble click sobre un thumbnail.
|
|
ChartRequested(ChartId),
|
|
/// Drag terminado: el offset acumulado de tiempo (en minutos)
|
|
/// cambió. El host debe recomputar el chart con este offset.
|
|
TimeOffsetChanged(i64),
|
|
/// El usuario togggleó una capa via hotkey — el panel debería
|
|
/// reflejarlo si quisiera mantenerse en sync.
|
|
LayerVisibilityChanged { kind: LayerKind, visible: bool },
|
|
/// El usuario pidió exportar el render actual como SVG. El shell
|
|
/// se encarga de escribir el archivo (la engine genera el string).
|
|
ExportSvgRequested,
|
|
}
|
|
|
|
// =====================================================================
|
|
// Estado
|
|
// =====================================================================
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub enum CanvasMode {
|
|
#[default]
|
|
Empty,
|
|
Wheel { render: Box<RenderModel> },
|
|
Thumbnails {
|
|
scope: ThumbnailScope,
|
|
items: Vec<ThumbnailItem>,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum ThumbnailScope {
|
|
Group(GroupId),
|
|
Contact(ContactId),
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct ThumbnailItem {
|
|
pub chart_id: ChartId,
|
|
pub label: SharedString,
|
|
pub subtitle: Option<SharedString>,
|
|
pub preview: Option<RenderModel>,
|
|
}
|
|
|
|
/// Estado de un drag activo del jog-dial. `last_screen_angle_deg` se
|
|
/// actualiza en cada `MouseMoveEvent`; `accumulated_delta_deg` lleva la
|
|
/// rotación total desde que arrancó el drag (puede pasar de ±360°).
|
|
#[derive(Clone, Debug)]
|
|
struct JogDragState {
|
|
last_screen_angle_deg: f32,
|
|
accumulated_delta_deg: f32,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct CanvasState {
|
|
pub mode: CanvasMode,
|
|
/// Rotación visual transitoria durante un drag. Se resetea a `0` al
|
|
/// soltar — el render nuevo trae el `ascendant_deg` ya rotado.
|
|
pub view_rotation_deg: f32,
|
|
/// Offset acumulado en minutos. Persiste entre drags hasta que el
|
|
/// host lo resetee.
|
|
pub time_offset_minutes: i64,
|
|
/// Por-LayerKind: `true` = visible. Default = todo visible.
|
|
pub layer_visibility: HashMap<LayerKind, bool>,
|
|
/// Planeta hovered actualmente (para tooltip). `None` cuando el
|
|
/// mouse no está sobre ningún cuerpo.
|
|
pub hover: Option<HoverInfo>,
|
|
drag_jog: Option<JogDragState>,
|
|
}
|
|
|
|
/// Info del elemento bajo el cursor — usado por el render para mostrar
|
|
/// un tooltip flotante con detalles. Cubre body glyphs (módulo +
|
|
/// símbolo + grado + casa + retro + dignidad) y cusps de casa (número
|
|
/// de la casa + grado del cusp + signo).
|
|
#[derive(Clone, Debug)]
|
|
pub enum HoverInfo {
|
|
Body {
|
|
module_id: String,
|
|
symbol: String,
|
|
deg: f32,
|
|
house: Option<u8>,
|
|
retrograde: bool,
|
|
dignity_marker: Option<String>,
|
|
annotation: Option<String>,
|
|
local_x: f32,
|
|
local_y: f32,
|
|
},
|
|
HouseCusp {
|
|
house_number: u8,
|
|
deg: f32,
|
|
local_x: f32,
|
|
local_y: f32,
|
|
},
|
|
}
|
|
|
|
impl HoverInfo {
|
|
fn local(&self) -> (f32, f32) {
|
|
match self {
|
|
HoverInfo::Body { local_x, local_y, .. } => (*local_x, *local_y),
|
|
HoverInfo::HouseCusp { local_x, local_y, .. } => (*local_x, *local_y),
|
|
}
|
|
}
|
|
|
|
fn key(&self) -> String {
|
|
match self {
|
|
HoverInfo::Body {
|
|
module_id, symbol, ..
|
|
} => format!("body:{}:{}", module_id, symbol),
|
|
HoverInfo::HouseCusp { house_number, .. } => format!("cusp:{}", house_number),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for CanvasState {
|
|
fn default() -> Self {
|
|
Self {
|
|
mode: CanvasMode::default(),
|
|
view_rotation_deg: 0.0,
|
|
time_offset_minutes: 0,
|
|
layer_visibility: HashMap::new(),
|
|
hover: None,
|
|
drag_jog: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CanvasState {
|
|
pub fn is_layer_visible(&self, kind: LayerKind) -> bool {
|
|
self.layer_visibility.get(&kind).copied().unwrap_or(true)
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Widget
|
|
// =====================================================================
|
|
|
|
pub struct AstrologyCanvas {
|
|
state: CanvasState,
|
|
focus_handle: FocusHandle,
|
|
}
|
|
|
|
impl EventEmitter<CanvasEvent> for AstrologyCanvas {}
|
|
|
|
impl Focusable for AstrologyCanvas {
|
|
fn focus_handle(&self, _: &gpui::App) -> FocusHandle {
|
|
self.focus_handle.clone()
|
|
}
|
|
}
|
|
|
|
impl AstrologyCanvas {
|
|
pub fn new(cx: &mut Context<Self>) -> Self {
|
|
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
|
|
Self {
|
|
state: CanvasState::default(),
|
|
focus_handle: cx.focus_handle(),
|
|
}
|
|
}
|
|
|
|
pub fn state(&self) -> &CanvasState {
|
|
&self.state
|
|
}
|
|
|
|
pub fn set_mode(&mut self, mode: CanvasMode, cx: &mut Context<Self>) {
|
|
self.state.mode = mode;
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn set_layer_visible(&mut self, kind: LayerKind, visible: bool, cx: &mut Context<Self>) {
|
|
self.state.layer_visibility.insert(kind, visible);
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn toggle_layer(&mut self, kind: LayerKind, cx: &mut Context<Self>) {
|
|
let current = self.state.is_layer_visible(kind);
|
|
self.set_layer_visible(kind, !current, cx);
|
|
cx.emit(CanvasEvent::LayerVisibilityChanged {
|
|
kind,
|
|
visible: !current,
|
|
});
|
|
}
|
|
|
|
pub fn reset_time_offset(&mut self, cx: &mut Context<Self>) {
|
|
if self.state.time_offset_minutes != 0 || self.state.view_rotation_deg != 0.0 {
|
|
self.state.time_offset_minutes = 0;
|
|
self.state.view_rotation_deg = 0.0;
|
|
cx.emit(CanvasEvent::TimeOffsetChanged(0));
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
pub fn set_view_rotation(&mut self, deg: f32, cx: &mut Context<Self>) {
|
|
self.state.view_rotation_deg = deg.rem_euclid(360.0);
|
|
cx.notify();
|
|
}
|
|
|
|
// ----- Internos: handlers de jog-dial -----
|
|
|
|
fn on_jog_down(
|
|
&mut self,
|
|
position: Point<Pixels>,
|
|
bounds: Bounds<Pixels>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let (cx_px, cy_px) = bounds_center(bounds);
|
|
let mx: f32 = position.x.into();
|
|
let my: f32 = position.y.into();
|
|
let dx = mx - cx_px;
|
|
let dy = my - cy_px;
|
|
let dist = (dx * dx + dy * dy).sqrt();
|
|
let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
|
|
let radii = Radii::from_outer(r_outer);
|
|
// Aro de captura un poco más generoso que el anillo del dial.
|
|
if dist < radii.sign_inner * 0.95 || dist > radii.sign_outer * 1.10 {
|
|
return;
|
|
}
|
|
let angle = dy.atan2(dx).to_degrees();
|
|
self.state.drag_jog = Some(JogDragState {
|
|
last_screen_angle_deg: angle,
|
|
accumulated_delta_deg: 0.0,
|
|
});
|
|
}
|
|
|
|
fn on_jog_move(
|
|
&mut self,
|
|
position: Point<Pixels>,
|
|
bounds: Bounds<Pixels>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(jog) = self.state.drag_jog.as_mut() else {
|
|
return;
|
|
};
|
|
let (cx_px, cy_px) = bounds_center(bounds);
|
|
let mx: f32 = position.x.into();
|
|
let my: f32 = position.y.into();
|
|
let dx = mx - cx_px;
|
|
let dy = my - cy_px;
|
|
let angle = dy.atan2(dx).to_degrees();
|
|
let mut delta = angle - jog.last_screen_angle_deg;
|
|
// Normalizar a (-180, 180] para cruzar el wrap sin saltar.
|
|
if delta > 180.0 {
|
|
delta -= 360.0;
|
|
} else if delta < -180.0 {
|
|
delta += 360.0;
|
|
}
|
|
jog.accumulated_delta_deg += delta;
|
|
jog.last_screen_angle_deg = angle;
|
|
// Reflejo visual durante el drag (sin recomputar).
|
|
self.state.view_rotation_deg = jog.accumulated_delta_deg;
|
|
cx.notify();
|
|
}
|
|
|
|
/// Hit-test sobre body glyphs + house cusps. Para bodies: distancia
|
|
/// al centro del glyph dentro de threshold. Para cusps: el mouse
|
|
/// debe estar cerca del ring de casas Y angularmente cerca del
|
|
/// cusp (proximidad a la línea radial).
|
|
fn on_hover_check(
|
|
&mut self,
|
|
position: Point<Pixels>,
|
|
bounds: Bounds<Pixels>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let CanvasMode::Wheel { render } = &self.state.mode else {
|
|
if self.state.hover.take().is_some() {
|
|
cx.notify();
|
|
}
|
|
return;
|
|
};
|
|
let (cx_px, cy_px) = bounds_center(bounds);
|
|
let mx: f32 = position.x.into();
|
|
let my: f32 = position.y.into();
|
|
let ox: f32 = bounds.origin.x.into();
|
|
let oy: f32 = bounds.origin.y.into();
|
|
let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
|
|
let radii = Radii::from_outer(r_outer);
|
|
let asc = render.ascendant_deg;
|
|
let rot = self.state.view_rotation_deg;
|
|
let body_threshold = 14.0_f32;
|
|
|
|
let mut best: Option<(f32, HoverInfo)> = None;
|
|
|
|
// 1) Body glyphs (incluye natal, overlays, midpoints).
|
|
for layer in &render.layers {
|
|
let ring = match layer.kind {
|
|
LayerKind::Bodies => radii.body_ring(&layer.module_id),
|
|
LayerKind::Midpoints => radii.midpoints,
|
|
LayerKind::Outer if OUTER_RING_MODULES.contains(&layer.module_id.as_str()) => {
|
|
radii.transits
|
|
}
|
|
_ => continue,
|
|
};
|
|
for g in &layer.glyphs {
|
|
let (gx, gy) = polar_to_screen(g.deg, asc, rot, ring);
|
|
let dx = mx - (cx_px + gx);
|
|
let dy = my - (cy_px + gy);
|
|
let dist = (dx * dx + dy * dy).sqrt();
|
|
if dist > body_threshold {
|
|
continue;
|
|
}
|
|
if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
|
|
best = Some((
|
|
dist,
|
|
HoverInfo::Body {
|
|
module_id: layer.module_id.clone(),
|
|
symbol: g.symbol.clone(),
|
|
deg: g.deg,
|
|
house: g.house,
|
|
retrograde: g.retrograde,
|
|
dignity_marker: g.dignity_marker.clone(),
|
|
annotation: g.annotation.clone(),
|
|
local_x: cx_px + gx - ox,
|
|
local_y: cy_px + gy - oy,
|
|
},
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2) House cusps — solo si el mouse está cerca del anillo de
|
|
// casas (radio entre houses_inner y houses_outer + margen) y
|
|
// ningún body ganó. Las cusps son líneas radiales — la
|
|
// distancia angular al cusp más cercano determina el hit.
|
|
if best.is_none() {
|
|
let dx = mx - cx_px;
|
|
let dy = my - cy_px;
|
|
let mouse_r = (dx * dx + dy * dy).sqrt();
|
|
let r_in = radii.houses_inner - 6.0;
|
|
let r_out = radii.houses_outer + 6.0;
|
|
if mouse_r >= r_in && mouse_r <= r_out {
|
|
// Calcular la longitud zodiacal que corresponde a este
|
|
// ángulo de pantalla (inversa de polar_to_screen).
|
|
let screen_angle_deg = dy.atan2(dx).to_degrees(); // (-180, 180]
|
|
// polar_to_screen: deg = 180 - (lon - asc + rot)
|
|
// → lon = asc + 180 - screen_angle_deg - rot
|
|
let lon = ((asc + 180.0 - screen_angle_deg - rot) as f32).rem_euclid(360.0);
|
|
// Buscar cusp más cercano (con wraparound).
|
|
for layer in &render.layers {
|
|
if matches!(layer.kind, LayerKind::Houses) {
|
|
if let Geometry::Ring { cusps_deg } = &layer.geometry {
|
|
for (i, c) in cusps_deg.iter().enumerate() {
|
|
let mut diff = (lon - c).abs();
|
|
if diff > 180.0 {
|
|
diff = 360.0 - diff;
|
|
}
|
|
if diff < 2.5 {
|
|
// Mouse cerca de ESTE cusp.
|
|
let (gx, gy) = polar_to_screen(
|
|
*c,
|
|
asc,
|
|
rot,
|
|
(radii.houses_inner + radii.houses_outer) / 2.0,
|
|
);
|
|
best = Some((
|
|
diff,
|
|
HoverInfo::HouseCusp {
|
|
house_number: (i as u8) + 1,
|
|
deg: *c,
|
|
local_x: cx_px + gx - ox,
|
|
local_y: cy_px + gy - oy,
|
|
},
|
|
));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let new_hover = best.map(|(_, h)| h);
|
|
let changed = match (&self.state.hover, &new_hover) {
|
|
(Some(a), Some(b)) => a.key() != b.key(),
|
|
(None, None) => false,
|
|
_ => true,
|
|
};
|
|
if changed {
|
|
self.state.hover = new_hover;
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
fn on_jog_up(&mut self, cx: &mut Context<Self>) {
|
|
let Some(jog) = self.state.drag_jog.take() else {
|
|
return;
|
|
};
|
|
// 1° de arco ≈ 4 minutos de tiempo sideral (15°/hora).
|
|
// CW visual (delta negativa en nuestra convención) → tiempo
|
|
// hacia adelante.
|
|
let delta_minutes = (-jog.accumulated_delta_deg * 4.0) as i64;
|
|
if delta_minutes != 0 {
|
|
self.state.time_offset_minutes =
|
|
self.state.time_offset_minutes.saturating_add(delta_minutes);
|
|
// Snap visual: el shell recomputa con el nuevo offset y el
|
|
// render trae el ascendant rotado.
|
|
self.state.view_rotation_deg = 0.0;
|
|
cx.emit(CanvasEvent::TimeOffsetChanged(self.state.time_offset_minutes));
|
|
cx.notify();
|
|
} else {
|
|
self.state.view_rotation_deg = 0.0;
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
fn on_key_down(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
|
|
let key = event.keystroke.key.as_str();
|
|
let kind = match key {
|
|
"d" | "D" => LayerKind::SignDial,
|
|
"h" | "H" => LayerKind::Houses,
|
|
"x" | "X" => LayerKind::Aspects,
|
|
"p" | "P" => LayerKind::Bodies,
|
|
"t" | "T" => LayerKind::Outer,
|
|
"r" | "R" => {
|
|
self.reset_time_offset(cx);
|
|
return;
|
|
}
|
|
_ => return,
|
|
};
|
|
self.toggle_layer(kind, cx);
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Geometría de pantalla
|
|
// =====================================================================
|
|
|
|
const WHEEL_SIZE: f32 = 580.0;
|
|
const WHEEL_MARGIN: f32 = 28.0;
|
|
|
|
fn bounds_center(bounds: Bounds<Pixels>) -> (f32, f32) {
|
|
let ox: f32 = bounds.origin.x.into();
|
|
let oy: f32 = bounds.origin.y.into();
|
|
let bw: f32 = bounds.size.width.into();
|
|
let bh: f32 = bounds.size.height.into();
|
|
(ox + bw / 2.0, oy + bh / 2.0)
|
|
}
|
|
|
|
// =====================================================================
|
|
// Render
|
|
// =====================================================================
|
|
|
|
impl Render for AstrologyCanvas {
|
|
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
let theme = Theme::global(cx).clone();
|
|
let palette = AstroPalette::for_theme(&theme);
|
|
let entity = cx.entity();
|
|
let focus = self.focus_handle.clone();
|
|
|
|
let body = match &self.state.mode {
|
|
CanvasMode::Empty => render_empty(&theme),
|
|
CanvasMode::Wheel { render } => render_wheel(
|
|
&theme,
|
|
&palette,
|
|
render,
|
|
self.state.view_rotation_deg,
|
|
self.state.time_offset_minutes,
|
|
&self.state.layer_visibility,
|
|
self.state.hover.as_ref(),
|
|
entity,
|
|
),
|
|
CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items),
|
|
};
|
|
|
|
div()
|
|
.id("astrology-canvas-root")
|
|
.track_focus(&focus)
|
|
.key_context("AstrologyCanvas")
|
|
.on_key_down(cx.listener(Self::on_key_down))
|
|
.on_mouse_down(
|
|
MouseButton::Left,
|
|
cx.listener(|this, _, w, _cx| {
|
|
w.focus(&this.focus_handle);
|
|
}),
|
|
)
|
|
.size_full()
|
|
.bg(theme.bg_panel.clone())
|
|
.flex()
|
|
.flex_col()
|
|
.items_center()
|
|
.justify_center()
|
|
.child(body)
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Modos: empty / thumbnails / wheel
|
|
// =====================================================================
|
|
|
|
fn render_empty(theme: &Theme) -> gpui::Div {
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.items_center()
|
|
.justify_center()
|
|
.gap(px(12.0))
|
|
.child(
|
|
div()
|
|
.text_size(px(13.0))
|
|
.text_color(theme.fg_muted)
|
|
.child("Tahuantinsuyu"),
|
|
)
|
|
.child(
|
|
div()
|
|
.text_size(px(11.0))
|
|
.text_color(theme.fg_disabled)
|
|
.child("Seleccioná una carta en el árbol para empezar."),
|
|
)
|
|
}
|
|
|
|
fn render_thumbnails(theme: &Theme, items: &[ThumbnailItem]) -> gpui::Div {
|
|
if items.is_empty() {
|
|
return div()
|
|
.text_size(px(12.0))
|
|
.text_color(theme.fg_muted)
|
|
.child("Sin cartas en este grupo todavía.");
|
|
}
|
|
let mut row = div().flex().flex_row().flex_wrap().gap(px(12.0));
|
|
for it in items {
|
|
row = row.child(
|
|
div()
|
|
.w(px(140.0))
|
|
.h(px(160.0))
|
|
.rounded(px(6.0))
|
|
.border_1()
|
|
.border_color(theme.border)
|
|
.bg(theme.bg_panel_alt.clone())
|
|
.flex()
|
|
.flex_col()
|
|
.items_center()
|
|
.justify_end()
|
|
.pb(px(8.0))
|
|
.child(
|
|
div()
|
|
.text_size(px(11.0))
|
|
.text_color(theme.fg_text)
|
|
.child(it.label.clone()),
|
|
),
|
|
);
|
|
}
|
|
row
|
|
}
|
|
|
|
// =====================================================================
|
|
// Wheel
|
|
// =====================================================================
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn render_wheel(
|
|
theme: &Theme,
|
|
palette: &AstroPalette,
|
|
render: &RenderModel,
|
|
view_rotation_deg: f32,
|
|
time_offset_minutes: i64,
|
|
layer_visibility: &HashMap<LayerKind, bool>,
|
|
hover: Option<&HoverInfo>,
|
|
entity: gpui::Entity<AstrologyCanvas>,
|
|
) -> gpui::Div {
|
|
let asc = render.ascendant_deg;
|
|
let rot_offset = view_rotation_deg;
|
|
let cx_center = WHEEL_SIZE / 2.0;
|
|
let cy_center = WHEEL_SIZE / 2.0;
|
|
let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
|
|
let radii = Radii::from_outer(r_outer);
|
|
|
|
let visible = layer_visibility.clone();
|
|
|
|
// --- Canvas element con todo el trazo + jog-dial drag ---
|
|
let palette_paint = palette.clone();
|
|
let theme_paint = theme.clone();
|
|
let layers_paint: Vec<Layer> = render.layers.clone();
|
|
let asc_for_paint = asc;
|
|
let mc_for_paint = render.midheaven_deg;
|
|
let visibility_for_paint = visible.clone();
|
|
let entity_for_canvas = entity.clone();
|
|
let canvas_element = canvas(
|
|
move |_b: Bounds<Pixels>, _w, _cx| (),
|
|
move |bounds: Bounds<Pixels>, _, window, _| {
|
|
// Painting de la rueda.
|
|
paint_wheel(
|
|
bounds,
|
|
window,
|
|
&theme_paint,
|
|
&palette_paint,
|
|
&layers_paint,
|
|
asc_for_paint,
|
|
mc_for_paint,
|
|
rot_offset,
|
|
radii,
|
|
&visibility_for_paint,
|
|
);
|
|
|
|
// Handlers de mouse para el jog-dial — se registran cada
|
|
// frame contra el window; GPUI los reemplaza al re-renderear.
|
|
let entity_d = entity_for_canvas.clone();
|
|
window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| {
|
|
if ev.button != MouseButton::Left {
|
|
return;
|
|
}
|
|
if !bounds.contains(&ev.position) {
|
|
return;
|
|
}
|
|
entity_d.update(cx, |this, cx| this.on_jog_down(ev.position, bounds, cx));
|
|
});
|
|
let entity_m = entity_for_canvas.clone();
|
|
window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
|
|
if ev.dragging() {
|
|
entity_m.update(cx, |this, cx| this.on_jog_move(ev.position, bounds, cx));
|
|
} else if bounds.contains(&ev.position) {
|
|
// Mouse hover sin drag: hit-test sobre los body
|
|
// glyphs para el tooltip.
|
|
entity_m.update(cx, |this, cx| this.on_hover_check(ev.position, bounds, cx));
|
|
} else {
|
|
entity_m.update(cx, |this, cx| {
|
|
if this.state.hover.take().is_some() {
|
|
cx.notify();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
let entity_u = entity_for_canvas.clone();
|
|
window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
|
|
entity_u.update(cx, |this, cx| this.on_jog_up(cx));
|
|
});
|
|
},
|
|
)
|
|
.absolute()
|
|
.w(px(WHEEL_SIZE))
|
|
.h(px(WHEEL_SIZE));
|
|
|
|
// Gradient sutil diagonal en el fondo del wheel — toque "místico
|
|
// velado": alpha muy baja, así no compite con la geometría pintada
|
|
// encima pero llena las zonas vacías (esquinas del cuadrado, gaps
|
|
// entre anillos) con un shimmer mineral.
|
|
let wheel_bg = linear_gradient(
|
|
155.0,
|
|
linear_color_stop(with_alpha(palette.dial_ring, 0.06), 0.0),
|
|
linear_color_stop(with_alpha(palette.angle_highlight, 0.03), 1.0),
|
|
);
|
|
|
|
let mut wheel = div()
|
|
.relative()
|
|
.w(px(WHEEL_SIZE))
|
|
.h(px(WHEEL_SIZE))
|
|
.bg(wheel_bg)
|
|
.rounded(px(12.0))
|
|
.child(canvas_element);
|
|
|
|
// Sign glyphs.
|
|
if visible.get(&LayerKind::SignDial).copied().unwrap_or(true) {
|
|
let sign_ring_mid = (radii.sign_outer + radii.sign_inner) / 2.0;
|
|
for layer in &render.layers {
|
|
if matches!(layer.kind, LayerKind::SignDial) {
|
|
for g in &layer.glyphs {
|
|
let (x, y) = polar_to_screen(g.deg, asc, rot_offset, sign_ring_mid);
|
|
let color = element_color_for_sign(palette, &g.symbol);
|
|
wheel = wheel.child(centered_glyph(
|
|
cx_center + x,
|
|
cy_center + y,
|
|
20.0,
|
|
18.0,
|
|
sign_unicode(&g.symbol).into(),
|
|
color,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// House numbers.
|
|
if visible.get(&LayerKind::Houses).copied().unwrap_or(true) {
|
|
let house_label_r = (radii.houses_outer + radii.houses_inner) / 2.0;
|
|
for layer in &render.layers {
|
|
if matches!(layer.kind, LayerKind::Houses) {
|
|
for g in &layer.glyphs {
|
|
let (x, y) = polar_to_screen(g.deg, asc, rot_offset, house_label_r);
|
|
if let Some(h) = g.house {
|
|
wheel = wheel.child(centered_glyph(
|
|
cx_center + x,
|
|
cy_center + y,
|
|
16.0,
|
|
10.0,
|
|
format!("{}", h).into(),
|
|
palette.house_cusp,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Planet glyphs: natal (en `bodies`) + overlays en sus rings
|
|
// (progression, solar_arc) con alpha + tamaño más chico para
|
|
// diferenciarse visualmente del natal.
|
|
if visible.get(&LayerKind::Bodies).copied().unwrap_or(true) {
|
|
for layer in &render.layers {
|
|
if matches!(layer.kind, LayerKind::Bodies) {
|
|
let is_natal = layer.module_id == "natal";
|
|
let ring = radii.body_ring(&layer.module_id);
|
|
let alpha = if is_natal { 1.0 } else { 0.85 };
|
|
let font_size = if is_natal { 18.0 } else { 14.0 };
|
|
let box_size = if is_natal { 24.0 } else { 20.0 };
|
|
for g in &layer.glyphs {
|
|
let (x, y) = polar_to_screen(g.deg, asc, rot_offset, ring);
|
|
let color = with_alpha(planet_color(palette, &g.symbol), alpha);
|
|
let mut glyph_text = planet_unicode(&g.symbol).to_string();
|
|
if g.retrograde {
|
|
glyph_text.push('ᴿ');
|
|
}
|
|
if let Some(marker) = &g.dignity_marker {
|
|
glyph_text.push_str(marker);
|
|
}
|
|
wheel = wheel.child(centered_glyph(
|
|
cx_center + x,
|
|
cy_center + y,
|
|
box_size,
|
|
font_size,
|
|
glyph_text.into(),
|
|
color,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Planet glyphs en el outer ring — transit o synastry, los dos
|
|
// comparten ese slot (mutuamente excluyentes a nivel de Shell).
|
|
if visible.get(&LayerKind::Outer).copied().unwrap_or(true) {
|
|
for layer in &render.layers {
|
|
if matches!(layer.kind, LayerKind::Outer)
|
|
&& (OUTER_RING_MODULES.contains(&layer.module_id.as_str()))
|
|
{
|
|
for g in &layer.glyphs {
|
|
let (x, y) = polar_to_screen(g.deg, asc, rot_offset, radii.transits);
|
|
let color = with_alpha(planet_color(palette, &g.symbol), 0.9);
|
|
let glyph_text = if g.retrograde {
|
|
format!("{}ᴿ", planet_unicode(&g.symbol))
|
|
} else {
|
|
planet_unicode(&g.symbol).into()
|
|
};
|
|
wheel = wheel.child(centered_glyph(
|
|
cx_center + x,
|
|
cy_center + y,
|
|
20.0,
|
|
14.0,
|
|
glyph_text.into(),
|
|
color,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tooltip absoluto sobre el elemento hovered (cuerpo o cusp).
|
|
if let Some(hov) = hover {
|
|
let text = match hov {
|
|
HoverInfo::Body {
|
|
module_id,
|
|
symbol,
|
|
deg,
|
|
house,
|
|
retrograde,
|
|
dignity_marker,
|
|
annotation,
|
|
..
|
|
} => {
|
|
let sign_idx = ((deg / 30.0).floor() as usize) % 12;
|
|
let sign_name = SIGN_NAMES_ES[sign_idx];
|
|
let deg_in_sign = deg - (sign_idx as f32) * 30.0;
|
|
let display_symbol = if module_id == "midpoints" {
|
|
// El symbol del midpoint es "a/b" — para el header
|
|
// del tooltip usamos los unicodes individuales.
|
|
if let Some((a, b)) = symbol.split_once('/') {
|
|
format!("{}/{}", planet_unicode(a), planet_unicode(b))
|
|
} else {
|
|
symbol.clone()
|
|
}
|
|
} else {
|
|
planet_unicode(symbol).to_string()
|
|
};
|
|
let mut t = format!("{} {} · {:.1}°", display_symbol, sign_name, deg_in_sign);
|
|
if let Some(h) = house {
|
|
t.push_str(&format!(" · Casa {}", h));
|
|
}
|
|
if *retrograde {
|
|
t.push_str(" · ℞");
|
|
}
|
|
if let Some(m) = dignity_marker {
|
|
t.push_str(&format!(" · {}", m));
|
|
}
|
|
if module_id == "midpoints" {
|
|
if let Some(a) = annotation {
|
|
t.push_str(&format!(" · {}", a));
|
|
}
|
|
} else if module_id != "natal" {
|
|
t.push_str(&format!(" · {}", module_id));
|
|
}
|
|
t
|
|
}
|
|
HoverInfo::HouseCusp {
|
|
house_number, deg, ..
|
|
} => {
|
|
let sign_idx = ((deg / 30.0).floor() as usize) % 12;
|
|
let sign_name = SIGN_NAMES_ES[sign_idx];
|
|
let deg_in_sign = deg - (sign_idx as f32) * 30.0;
|
|
format!(
|
|
"Cusp Casa {} · {} {:.1}°",
|
|
house_number, sign_name, deg_in_sign
|
|
)
|
|
}
|
|
};
|
|
let (lx, ly) = hov.local();
|
|
let tip_x = (lx + 14.0).min(WHEEL_SIZE - 220.0).max(8.0);
|
|
let tip_y = (ly - 28.0).max(8.0);
|
|
wheel = wheel.child(
|
|
div()
|
|
.absolute()
|
|
.left(px(tip_x))
|
|
.top(px(tip_y))
|
|
.px(px(8.0))
|
|
.py(px(4.0))
|
|
.rounded(px(6.0))
|
|
.bg(theme.bg_panel_alt.clone())
|
|
.border_1()
|
|
.border_color(palette.angle_highlight)
|
|
.text_size(px(11.0))
|
|
.text_color(theme.fg_text)
|
|
.child(SharedString::from(text)),
|
|
);
|
|
}
|
|
|
|
// Labels ASC/MC/DESC/IC en el perímetro. Texto pequeño en el
|
|
// margen exterior (radius * 1.05) para que no se monte con los
|
|
// glifos de los signos. Color angle_highlight para que el ojo los
|
|
// reconozca como los cuatro ángulos cardinales.
|
|
let angle_labels = [
|
|
(asc, "ASC"),
|
|
(render.midheaven_deg, "MC"),
|
|
(render.descendant_deg, "DESC"),
|
|
(render.imum_coeli_deg, "IC"),
|
|
];
|
|
let label_r = r_outer * 1.06;
|
|
for (deg, label) in angle_labels {
|
|
let (x, y) = polar_to_screen(deg, asc, rot_offset, label_r);
|
|
wheel = wheel.child(centered_glyph(
|
|
cx_center + x,
|
|
cy_center + y,
|
|
32.0,
|
|
10.0,
|
|
label.into(),
|
|
palette.angle_highlight,
|
|
));
|
|
}
|
|
|
|
// --- Header + footer + indicador de tiempo ---
|
|
let header = div()
|
|
.flex()
|
|
.flex_col()
|
|
.items_center()
|
|
.gap(px(2.0))
|
|
.child(
|
|
div()
|
|
.text_size(px(16.0))
|
|
.text_color(theme.fg_text)
|
|
.child(SharedString::from(render.title.clone())),
|
|
);
|
|
let header = if let Some(sub) = &render.subtitle {
|
|
header.child(
|
|
div()
|
|
.text_size(px(11.0))
|
|
.text_color(theme.fg_muted)
|
|
.child(SharedString::from(sub.clone())),
|
|
)
|
|
} else {
|
|
header
|
|
};
|
|
// Botón export SVG — pequeño, alineado a la derecha del title.
|
|
let export_btn = div()
|
|
.id("tts-canvas-export-svg")
|
|
.px(px(10.0))
|
|
.py(px(3.0))
|
|
.rounded(px(4.0))
|
|
.bg(theme.bg_button())
|
|
.hover(|s| s.bg(theme.bg_button_hover()))
|
|
.border_1()
|
|
.border_color(theme.border)
|
|
.text_size(px(10.0))
|
|
.text_color(theme.fg_text)
|
|
.child("⬇ SVG")
|
|
.on_click({
|
|
let entity_e = entity.clone();
|
|
move |_: &gpui::ClickEvent, _w, cx: &mut gpui::App| {
|
|
entity_e.update(cx, |_this, cx| {
|
|
cx.emit(CanvasEvent::ExportSvgRequested);
|
|
});
|
|
}
|
|
});
|
|
let header = div()
|
|
.flex()
|
|
.flex_row()
|
|
.items_center()
|
|
.gap(px(12.0))
|
|
.child(header)
|
|
.child(export_btn);
|
|
|
|
let offset_label = format_offset(time_offset_minutes);
|
|
let offset_color = if time_offset_minutes == 0 {
|
|
theme.fg_disabled
|
|
} else {
|
|
palette.angle_highlight
|
|
};
|
|
let info_row = div()
|
|
.flex()
|
|
.flex_row()
|
|
.gap(px(10.0))
|
|
.child(
|
|
div()
|
|
.text_size(px(10.0))
|
|
.text_color(theme.fg_disabled)
|
|
.child(SharedString::from(format!(
|
|
"Asc {:.1}° · MC {:.1}° · {} ms",
|
|
render.ascendant_deg, render.midheaven_deg, render.compute_ms,
|
|
))),
|
|
)
|
|
.child(
|
|
div()
|
|
.text_size(px(10.0))
|
|
.text_color(offset_color)
|
|
.child(SharedString::from(offset_label)),
|
|
)
|
|
.child(
|
|
div()
|
|
.text_size(px(10.0))
|
|
.text_color(theme.fg_disabled)
|
|
.child("[D]ial [H]ouses as[X]pects [P]lanets [T]ransits [R]eset"),
|
|
);
|
|
|
|
// Badges de overlays activos. Cada uno se pinta como pill con
|
|
// background sutil y border tenue. Solo aparecen cuando hay
|
|
// overlays — la carta natal pura ve solo el info_row.
|
|
let badges_row = if render.overlays.is_empty() {
|
|
None
|
|
} else {
|
|
let mut row = div().flex().flex_row().flex_wrap().gap(px(6.0));
|
|
// Badge "natal" base, siempre presente cuando hay overlays —
|
|
// ayuda al usuario a leer la pila de izquierda a derecha.
|
|
row = row.child(badge(theme, palette, "natal", "Natal", true));
|
|
for ov in &render.overlays {
|
|
row = row.child(badge(theme, palette, &ov.module_id, &ov.label, false));
|
|
}
|
|
Some(row)
|
|
};
|
|
|
|
let mut footer = div().flex().flex_col().items_center().gap(px(4.0)).child(info_row);
|
|
if let Some(b) = badges_row {
|
|
footer = footer.child(b);
|
|
}
|
|
|
|
// Lista textual de aspectos (top 12 por orb). Compacta, en grid
|
|
// de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos
|
|
// computados.
|
|
if !render.aspect_summary.is_empty() {
|
|
let mut grid = div()
|
|
.flex()
|
|
.flex_row()
|
|
.flex_wrap()
|
|
.gap(px(10.0))
|
|
.max_w(px(WHEEL_SIZE + 80.0))
|
|
.justify_center();
|
|
for ap in render.aspect_summary.iter().take(12) {
|
|
let kind_sym = aspect_unicode(&ap.kind);
|
|
let line = format!(
|
|
"{} {} {} · {:.1}°{}",
|
|
planet_unicode(&ap.from_body),
|
|
kind_sym,
|
|
planet_unicode(&ap.to_body),
|
|
ap.orb_deg,
|
|
match ap.applying {
|
|
Some(true) => " A",
|
|
Some(false) => " S",
|
|
None => "",
|
|
}
|
|
);
|
|
let prefix = if ap.module_id == "natal" {
|
|
String::new()
|
|
} else {
|
|
format!("[{}] ", ap.module_id)
|
|
};
|
|
grid = grid.child(
|
|
div()
|
|
.px(px(6.0))
|
|
.py(px(2.0))
|
|
.text_size(px(11.0))
|
|
.text_color(aspect_color(palette, &ap.kind))
|
|
.child(SharedString::from(format!("{}{}", prefix, line))),
|
|
);
|
|
}
|
|
footer = footer.child(grid);
|
|
}
|
|
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.items_center()
|
|
.gap(px(8.0))
|
|
.child(header)
|
|
.child(wheel)
|
|
.child(footer)
|
|
}
|
|
|
|
/// Pequeña pill con la etiqueta de un overlay activo. El borde toma
|
|
/// color según el "tipo" del módulo para ayudar a mapear a su anillo
|
|
/// en el wheel: natal = neutro, outer ring share (transit/synastry/
|
|
/// planetary_return) = palette.angle_highlight, inner overlays
|
|
/// (progression/solar_arc) = palette.house_cusp.
|
|
fn badge(theme: &Theme, palette: &AstroPalette, module_id: &str, label: &str, is_natal: bool) -> gpui::Div {
|
|
let border = if is_natal {
|
|
theme.border
|
|
} else {
|
|
match module_id {
|
|
"transit" | "synastry" | "planetary_return" => palette.angle_highlight,
|
|
"progression" | "solar_arc" => palette.house_cusp,
|
|
_ => theme.border,
|
|
}
|
|
};
|
|
div()
|
|
.px(px(8.0))
|
|
.py(px(2.0))
|
|
.rounded(px(10.0))
|
|
.bg(theme.bg_panel_alt.clone())
|
|
.border_1()
|
|
.border_color(border)
|
|
.text_size(px(10.0))
|
|
.text_color(theme.fg_text)
|
|
.child(SharedString::from(label.to_string()))
|
|
}
|
|
|
|
fn format_offset(minutes: i64) -> String {
|
|
if minutes == 0 {
|
|
return "⏱ ahora".to_string();
|
|
}
|
|
let sign = if minutes > 0 { '+' } else { '-' };
|
|
let m = minutes.unsigned_abs();
|
|
let days = m / (60 * 24);
|
|
let hours = (m / 60) % 24;
|
|
let mins = m % 60;
|
|
if days > 0 {
|
|
format!("⏱ {}{}d {:02}h {:02}m", sign, days, hours, mins)
|
|
} else if hours > 0 {
|
|
format!("⏱ {}{:02}h {:02}m", sign, hours, mins)
|
|
} else {
|
|
format!("⏱ {}{:02}m", sign, mins)
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Painting
|
|
// =====================================================================
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct Radii {
|
|
sign_outer: f32,
|
|
sign_inner: f32,
|
|
/// Anillo de glifos de tránsito (cuando el overlay está activo).
|
|
transits: f32,
|
|
houses_outer: f32,
|
|
houses_inner: f32,
|
|
/// Anillo de midpoints — entre bodies natales y houses_inner.
|
|
midpoints: f32,
|
|
bodies: f32,
|
|
/// Anillo interno con cuerpos progresados (overlay opcional).
|
|
progression: f32,
|
|
/// Anillo más interno con cuerpos dirigidos por Solar Arc.
|
|
solar_arc: f32,
|
|
/// Anillo de carta compuesta (midpoint Davison) con un partner.
|
|
composite: f32,
|
|
aspects: f32,
|
|
}
|
|
|
|
impl Radii {
|
|
fn from_outer(r: f32) -> Self {
|
|
Self {
|
|
sign_outer: r,
|
|
sign_inner: r * 0.88,
|
|
transits: r * 0.82,
|
|
houses_outer: r * 0.78,
|
|
houses_inner: r * 0.66,
|
|
midpoints: r * 0.62,
|
|
bodies: r * 0.58,
|
|
progression: r * 0.48,
|
|
solar_arc: r * 0.40,
|
|
composite: r * 0.32,
|
|
aspects: r * 0.24,
|
|
}
|
|
}
|
|
|
|
/// Radio del ring de cuerpos según el `module_id` del Layer.
|
|
fn body_ring(&self, module_id: &str) -> f32 {
|
|
match module_id {
|
|
"progression" => self.progression,
|
|
"solar_arc" => self.solar_arc,
|
|
"composite" => self.composite,
|
|
"midpoints" => self.midpoints,
|
|
_ => self.bodies,
|
|
}
|
|
}
|
|
|
|
/// Resuelve qué radios corresponden a una capa de aspectos según el
|
|
/// `module_id`: natal-natal en `aspects`, cross con cada overlay
|
|
/// desde `bodies` (extremo natal) al ring del módulo. Los módulos
|
|
/// del outer ring (OUTER_RING_MODULES) comparten el slot de
|
|
/// tránsito (son mutuamente excluyentes a nivel de Shell).
|
|
fn aspect_endpoints(&self, module_id: &str) -> (f32, f32) {
|
|
if OUTER_RING_MODULES.contains(&module_id) {
|
|
return (self.bodies, self.transits);
|
|
}
|
|
match module_id {
|
|
"progression" => (self.bodies, self.progression),
|
|
"solar_arc" => (self.bodies, self.solar_arc),
|
|
"composite" => (self.bodies, self.composite),
|
|
_ => (self.aspects, self.aspects),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn paint_wheel(
|
|
bounds: Bounds<Pixels>,
|
|
window: &mut Window,
|
|
theme: &Theme,
|
|
palette: &AstroPalette,
|
|
layers: &[Layer],
|
|
ascendant_deg: f32,
|
|
midheaven_deg: f32,
|
|
rot_offset_deg: f32,
|
|
radii: Radii,
|
|
visibility: &HashMap<LayerKind, bool>,
|
|
) {
|
|
let (cx, cy) = bounds_center(bounds);
|
|
let _ = theme;
|
|
let show = |k: LayerKind| visibility.get(&k).copied().unwrap_or(true);
|
|
|
|
// 1. Sectores zodiacales (parte del SignDial layer).
|
|
if show(LayerKind::SignDial) {
|
|
paint_sign_sectors(window, cx, cy, &radii, palette, ascendant_deg, rot_offset_deg);
|
|
|
|
// Anillos del dial.
|
|
stroke_circle(window, cx, cy, radii.sign_outer, 1.5, palette.dial_ring);
|
|
stroke_circle(window, cx, cy, radii.sign_inner, 1.0, palette.dial_ring);
|
|
|
|
// Cusps zodiacales cada 30°.
|
|
for i in 0..12 {
|
|
let lon = (i as f32) * 30.0;
|
|
let color = palette.dial_ring;
|
|
paint_radial_line(
|
|
window,
|
|
cx,
|
|
cy,
|
|
lon,
|
|
ascendant_deg,
|
|
rot_offset_deg,
|
|
radii.sign_inner,
|
|
radii.sign_outer,
|
|
color,
|
|
1.0,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 2. Casas — cusps radiales + énfasis Asc/IC/Desc/MC.
|
|
if show(LayerKind::Houses) {
|
|
stroke_circle(
|
|
window,
|
|
cx,
|
|
cy,
|
|
radii.houses_inner,
|
|
0.8,
|
|
with_alpha(palette.house_cusp, 0.6),
|
|
);
|
|
|
|
for layer in layers {
|
|
if matches!(layer.kind, LayerKind::Houses) {
|
|
if let 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 {
|
|
palette.angle_highlight
|
|
} else {
|
|
with_alpha(palette.house_cusp, 0.7)
|
|
};
|
|
let width = if is_angle { 2.0 } else { 0.8 };
|
|
paint_radial_line(
|
|
window,
|
|
cx,
|
|
cy,
|
|
*c,
|
|
ascendant_deg,
|
|
rot_offset_deg,
|
|
radii.houses_inner,
|
|
radii.houses_outer,
|
|
color,
|
|
width,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Asc + MC extendidos hasta el centro con opacidad sutil.
|
|
paint_radial_line(
|
|
window,
|
|
cx,
|
|
cy,
|
|
ascendant_deg,
|
|
ascendant_deg,
|
|
rot_offset_deg,
|
|
0.0,
|
|
radii.houses_outer,
|
|
with_alpha(palette.angle_highlight, 0.35),
|
|
1.0,
|
|
);
|
|
paint_radial_line(
|
|
window,
|
|
cx,
|
|
cy,
|
|
midheaven_deg,
|
|
ascendant_deg,
|
|
rot_offset_deg,
|
|
0.0,
|
|
radii.houses_outer,
|
|
with_alpha(palette.angle_highlight, 0.35),
|
|
1.0,
|
|
);
|
|
}
|
|
|
|
// 3. Aspectos. Cada module_id usa su par de radios — natal-natal
|
|
// ambos en `aspects`, cross con transit en `bodies → transits`,
|
|
// cross con progression en `bodies → progression`.
|
|
if show(LayerKind::Aspects) {
|
|
for layer in layers {
|
|
if matches!(layer.kind, LayerKind::Aspects) {
|
|
if let Geometry::Lines(segs) = &layer.geometry {
|
|
let (r_from, r_to) = radii.aspect_endpoints(&layer.module_id);
|
|
let is_cross = r_from != r_to;
|
|
for seg in segs {
|
|
let color = aspect_color(palette, &seg.kind);
|
|
let color = with_alpha(color, color.a * seg.opacity);
|
|
if is_cross {
|
|
paint_cross_aspect_line(
|
|
window,
|
|
cx,
|
|
cy,
|
|
seg.from_deg,
|
|
seg.to_deg,
|
|
ascendant_deg,
|
|
rot_offset_deg,
|
|
r_from,
|
|
r_to,
|
|
color,
|
|
);
|
|
} else {
|
|
paint_aspect_line(
|
|
window,
|
|
cx,
|
|
cy,
|
|
seg.from_deg,
|
|
seg.to_deg,
|
|
ascendant_deg,
|
|
rot_offset_deg,
|
|
r_from,
|
|
color,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Dots de cuerpos: natal en `bodies`, overlays en sus rings
|
|
// específicos (progression, solar_arc). Las luminarias natales
|
|
// (Sol/Luna) llevan glow halo — invita la mística sin saturar.
|
|
if show(LayerKind::Bodies) {
|
|
let dot_r = (radii.sign_outer * 0.018).max(2.0);
|
|
for layer in layers {
|
|
if matches!(layer.kind, LayerKind::Bodies) {
|
|
let ring = radii.body_ring(&layer.module_id);
|
|
let is_natal = layer.module_id == "natal";
|
|
let alpha = if is_natal { 1.0 } else { 0.85 };
|
|
for g in &layer.glyphs {
|
|
let color = with_alpha(planet_color(palette, &g.symbol), alpha);
|
|
let (x, y) = polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, ring);
|
|
if is_natal && (g.symbol == "sun" || g.symbol == "moon") {
|
|
paint_glow(window, cx + x, cy + y, dot_r, color);
|
|
}
|
|
fill_circle(window, cx + x, cy + y, dot_r, color);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Anillos guía para los overlays internos (progression, solar_arc).
|
|
let guide_inset = radii.sign_outer * 0.03;
|
|
for (module_id, ring) in [
|
|
("progression", radii.progression),
|
|
("solar_arc", radii.solar_arc),
|
|
] {
|
|
let active = layers
|
|
.iter()
|
|
.any(|l| matches!(l.kind, LayerKind::Bodies) && l.module_id == module_id);
|
|
if active {
|
|
stroke_circle(
|
|
window,
|
|
cx,
|
|
cy,
|
|
ring + guide_inset,
|
|
0.5,
|
|
with_alpha(palette.house_cusp, 0.35),
|
|
);
|
|
stroke_circle(
|
|
window,
|
|
cx,
|
|
cy,
|
|
ring - guide_inset,
|
|
0.5,
|
|
with_alpha(palette.house_cusp, 0.35),
|
|
);
|
|
}
|
|
}
|
|
|
|
// 5. Outer ring (transit o synastry overlay): anillo guía + dots
|
|
// de la capa activa. Son mutuamente excluyentes a nivel de Shell;
|
|
// si alguno de los dos está prendido, pintamos el slot.
|
|
let outer_active = layers.iter().any(|l| {
|
|
matches!(l.kind, LayerKind::Outer)
|
|
&& OUTER_RING_MODULES.contains(&l.module_id.as_str())
|
|
});
|
|
if outer_active && show(LayerKind::Outer) {
|
|
stroke_circle(
|
|
window,
|
|
cx,
|
|
cy,
|
|
radii.transits + radii.sign_outer * 0.035,
|
|
0.6,
|
|
with_alpha(palette.dial_ring, 0.4),
|
|
);
|
|
stroke_circle(
|
|
window,
|
|
cx,
|
|
cy,
|
|
radii.transits - radii.sign_outer * 0.035,
|
|
0.6,
|
|
with_alpha(palette.dial_ring, 0.4),
|
|
);
|
|
|
|
let dot_r = (radii.sign_outer * 0.017).max(2.0);
|
|
for layer in layers {
|
|
if matches!(layer.kind, LayerKind::Outer)
|
|
&& (OUTER_RING_MODULES.contains(&layer.module_id.as_str()))
|
|
{
|
|
for g in &layer.glyphs {
|
|
let color = with_alpha(planet_color(palette, &g.symbol), 0.85);
|
|
let (x, y) =
|
|
polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, radii.transits);
|
|
fill_circle(window, cx + x, cy + y, dot_r, color);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn paint_sign_sectors(
|
|
window: &mut Window,
|
|
cx: f32,
|
|
cy: f32,
|
|
radii: &Radii,
|
|
palette: &AstroPalette,
|
|
ascendant_deg: f32,
|
|
rot_offset_deg: f32,
|
|
) {
|
|
const SUBDIVISIONS: usize = 18;
|
|
for i in 0..12 {
|
|
let lon_start = (i as f32) * 30.0;
|
|
let lon_end = lon_start + 30.0;
|
|
let element = sign_element_by_index(i);
|
|
let color = with_alpha(palette.element(element), 0.10);
|
|
|
|
let mut builder = PathBuilder::fill();
|
|
let (x0, y0) = polar_to_screen(lon_start, ascendant_deg, rot_offset_deg, radii.sign_inner);
|
|
builder.move_to(point(px(cx + x0), px(cy + y0)));
|
|
|
|
for k in 1..=SUBDIVISIONS {
|
|
let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32);
|
|
let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_inner);
|
|
builder.line_to(point(px(cx + x), px(cy + y)));
|
|
}
|
|
let (xe, ye) = polar_to_screen(lon_end, ascendant_deg, rot_offset_deg, radii.sign_outer);
|
|
builder.line_to(point(px(cx + xe), px(cy + ye)));
|
|
|
|
for k in (0..SUBDIVISIONS).rev() {
|
|
let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32);
|
|
let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_outer);
|
|
builder.line_to(point(px(cx + x), px(cy + y)));
|
|
}
|
|
builder.close();
|
|
if let Ok(path) = builder.build() {
|
|
window.paint_path(path, color);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn stroke_circle(window: &mut Window, cx: f32, cy: f32, r: f32, width: f32, color: Hsla) {
|
|
const SEGMENTS: usize = 96;
|
|
let mut builder = PathBuilder::stroke(px(width));
|
|
for i in 0..=SEGMENTS {
|
|
let t = (i as f32) / (SEGMENTS as f32) * (2.0 * PI);
|
|
let x = cx + r * t.cos();
|
|
let y = cy + r * t.sin();
|
|
if i == 0 {
|
|
builder.move_to(point(px(x), px(y)));
|
|
} else {
|
|
builder.line_to(point(px(x), px(y)));
|
|
}
|
|
}
|
|
if let Ok(path) = builder.build() {
|
|
window.paint_path(path, color);
|
|
}
|
|
}
|
|
|
|
/// Pinta 3 halos concéntricos con alpha decreciente alrededor de un
|
|
/// punto — usado para Sol/Luna natales. El radio crece, la opacidad
|
|
/// cae: el ojo lo lee como "esto irradia". Sin glow real (GPUI 0.2 no
|
|
/// tiene radial gradient), pero el shading concéntrico convence.
|
|
fn paint_glow(window: &mut Window, cx: f32, cy: f32, base_r: f32, color: Hsla) {
|
|
const HALOS: [(f32, f32); 3] = [(5.0, 0.05), (3.0, 0.12), (1.8, 0.22)];
|
|
for (mult, alpha) in HALOS {
|
|
let r = base_r * mult;
|
|
let halo = hsla(color.h, color.s, color.l, alpha);
|
|
fill_circle(window, cx, cy, r, halo);
|
|
}
|
|
}
|
|
|
|
fn fill_circle(window: &mut Window, cx: f32, cy: f32, r: f32, color: Hsla) {
|
|
const SEGMENTS: usize = 32;
|
|
let mut builder = PathBuilder::fill();
|
|
builder.move_to(point(px(cx + r), px(cy)));
|
|
for i in 1..=SEGMENTS {
|
|
let t = (i as f32) / (SEGMENTS as f32) * (2.0 * PI);
|
|
let x = cx + r * t.cos();
|
|
let y = cy + r * t.sin();
|
|
builder.line_to(point(px(x), px(y)));
|
|
}
|
|
builder.close();
|
|
if let Ok(path) = builder.build() {
|
|
window.paint_path(path, color);
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn paint_radial_line(
|
|
window: &mut Window,
|
|
cx: f32,
|
|
cy: f32,
|
|
longitude_deg: f32,
|
|
ascendant_deg: f32,
|
|
rot_offset_deg: f32,
|
|
r_inner: f32,
|
|
r_outer: f32,
|
|
color: Hsla,
|
|
width: f32,
|
|
) {
|
|
let (xi, yi) = polar_to_screen(longitude_deg, ascendant_deg, rot_offset_deg, r_inner);
|
|
let (xo, yo) = polar_to_screen(longitude_deg, ascendant_deg, rot_offset_deg, r_outer);
|
|
let mut builder = PathBuilder::stroke(px(width));
|
|
builder.move_to(point(px(cx + xi), px(cy + yi)));
|
|
builder.line_to(point(px(cx + xo), px(cy + yo)));
|
|
if let Ok(path) = builder.build() {
|
|
window.paint_path(path, color);
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn paint_aspect_line(
|
|
window: &mut Window,
|
|
cx: f32,
|
|
cy: f32,
|
|
a_deg: f32,
|
|
b_deg: f32,
|
|
ascendant_deg: f32,
|
|
rot_offset_deg: f32,
|
|
r: f32,
|
|
color: Hsla,
|
|
) {
|
|
let (xa, ya) = polar_to_screen(a_deg, ascendant_deg, rot_offset_deg, r);
|
|
let (xb, yb) = polar_to_screen(b_deg, ascendant_deg, rot_offset_deg, r);
|
|
let mut builder = PathBuilder::stroke(px(1.0));
|
|
builder.move_to(point(px(cx + xa), px(cy + ya)));
|
|
builder.line_to(point(px(cx + xb), px(cy + yb)));
|
|
if let Ok(path) = builder.build() {
|
|
window.paint_path(path, color);
|
|
}
|
|
}
|
|
|
|
/// Línea de aspecto natal ↔ tránsito: extremos en radios distintos.
|
|
/// El `from_deg` cae sobre el ring de cuerpos natales (`r_from`); el
|
|
/// `to_deg` sobre el ring de tránsito (`r_to`). Trazo más fino que el
|
|
/// natal-natal para no competir visualmente.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn paint_cross_aspect_line(
|
|
window: &mut Window,
|
|
cx: f32,
|
|
cy: f32,
|
|
natal_deg: f32,
|
|
transit_deg: f32,
|
|
ascendant_deg: f32,
|
|
rot_offset_deg: f32,
|
|
r_from: f32,
|
|
r_to: f32,
|
|
color: Hsla,
|
|
) {
|
|
let (xa, ya) = polar_to_screen(natal_deg, ascendant_deg, rot_offset_deg, r_from);
|
|
let (xb, yb) = polar_to_screen(transit_deg, ascendant_deg, rot_offset_deg, r_to);
|
|
let mut builder = PathBuilder::stroke(px(0.7));
|
|
builder.move_to(point(px(cx + xa), px(cy + ya)));
|
|
builder.line_to(point(px(cx + xb), px(cy + yb)));
|
|
if let Ok(path) = builder.build() {
|
|
window.paint_path(path, color);
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Helpers
|
|
// =====================================================================
|
|
|
|
fn polar_to_screen(
|
|
longitude_deg: f32,
|
|
ascendant_deg: f32,
|
|
rot_offset_deg: f32,
|
|
radius: f32,
|
|
) -> (f32, f32) {
|
|
let deg = 180.0 - (longitude_deg - ascendant_deg + rot_offset_deg);
|
|
let rad = deg * PI / 180.0;
|
|
(radius * rad.cos(), radius * rad.sin())
|
|
}
|
|
|
|
fn centered_glyph(
|
|
x: f32,
|
|
y: f32,
|
|
box_size: f32,
|
|
font_size: f32,
|
|
text: SharedString,
|
|
color: Hsla,
|
|
) -> gpui::Div {
|
|
div()
|
|
.absolute()
|
|
.left(px(x - box_size / 2.0))
|
|
.top(px(y - box_size / 2.0))
|
|
.w(px(box_size))
|
|
.h(px(box_size))
|
|
.flex()
|
|
.items_center()
|
|
.justify_center()
|
|
.text_size(px(font_size))
|
|
.text_color(color)
|
|
.child(text)
|
|
}
|
|
|
|
fn with_alpha(c: Hsla, a: f32) -> Hsla {
|
|
hsla(c.h, c.s, c.l, a.clamp(0.0, 1.0))
|
|
}
|
|
|
|
fn sign_unicode(name: &str) -> &'static str {
|
|
match name {
|
|
"aries" => "♈",
|
|
"taurus" => "♉",
|
|
"gemini" => "♊",
|
|
"cancer" => "♋",
|
|
"leo" => "♌",
|
|
"virgo" => "♍",
|
|
"libra" => "♎",
|
|
"scorpio" => "♏",
|
|
"sagittarius" => "♐",
|
|
"capricorn" => "♑",
|
|
"aquarius" => "♒",
|
|
"pisces" => "♓",
|
|
_ => "?",
|
|
}
|
|
}
|
|
|
|
const SIGN_NAMES_ES: [&str; 12] = [
|
|
"Aries",
|
|
"Tauro",
|
|
"Géminis",
|
|
"Cáncer",
|
|
"Leo",
|
|
"Virgo",
|
|
"Libra",
|
|
"Escorpio",
|
|
"Sagitario",
|
|
"Capricornio",
|
|
"Acuario",
|
|
"Piscis",
|
|
];
|
|
|
|
fn aspect_unicode(kind: &str) -> &'static str {
|
|
match kind {
|
|
"conjunction" => "☌",
|
|
"opposition" => "☍",
|
|
"trine" => "△",
|
|
"square" => "□",
|
|
"sextile" => "⚹",
|
|
"quincunx" => "⚻",
|
|
"semi_sextile" => "⚺",
|
|
"semi_square" => "∠",
|
|
"sesquiquadrate" => "⚼",
|
|
"quintile" => "Q",
|
|
"biquintile" => "bQ",
|
|
_ => "·",
|
|
}
|
|
}
|
|
|
|
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" => "⚸",
|
|
"ceres" => "⚳",
|
|
"pallas" => "⚴",
|
|
"juno" => "⚵",
|
|
"vesta" => "⚶",
|
|
_ => "•",
|
|
}
|
|
}
|
|
|
|
fn planet_color(p: &AstroPalette, name: &str) -> Hsla {
|
|
let planet = match name {
|
|
"sun" => Planet::Sun,
|
|
"moon" => Planet::Moon,
|
|
"mercury" => Planet::Mercury,
|
|
"venus" => Planet::Venus,
|
|
"mars" => Planet::Mars,
|
|
"jupiter" => Planet::Jupiter,
|
|
"saturn" => Planet::Saturn,
|
|
"uranus" => Planet::Uranus,
|
|
"neptune" => Planet::Neptune,
|
|
"pluto" => Planet::Pluto,
|
|
"chiron" => Planet::Chiron,
|
|
"north_node" => Planet::NorthNode,
|
|
"south_node" => Planet::SouthNode,
|
|
"lilith" => Planet::Lilith,
|
|
_ => return p.fg_text_fallback(),
|
|
};
|
|
p.planet(planet)
|
|
}
|
|
|
|
fn sign_element_by_index(i: usize) -> Element {
|
|
match i % 4 {
|
|
0 => Element::Fire,
|
|
1 => Element::Earth,
|
|
2 => Element::Air,
|
|
_ => Element::Water,
|
|
}
|
|
}
|
|
|
|
fn element_color_for_sign(p: &AstroPalette, name: &str) -> Hsla {
|
|
let elem = match name {
|
|
"aries" | "leo" | "sagittarius" => Element::Fire,
|
|
"taurus" | "virgo" | "capricorn" => Element::Earth,
|
|
"gemini" | "libra" | "aquarius" => Element::Air,
|
|
"cancer" | "scorpio" | "pisces" => Element::Water,
|
|
_ => return p.fg_text_fallback(),
|
|
};
|
|
p.element(elem)
|
|
}
|
|
|
|
fn aspect_color(p: &AstroPalette, kind: &str) -> Hsla {
|
|
let k = match kind {
|
|
"conjunction" => TAspectKind::Conjunction,
|
|
"opposition" => TAspectKind::Opposition,
|
|
"trine" => TAspectKind::Trine,
|
|
"square" => TAspectKind::Square,
|
|
"sextile" => TAspectKind::Sextile,
|
|
"quincunx" => TAspectKind::Quincunx,
|
|
"semi_sextile" => TAspectKind::Semisextile,
|
|
"semi_square" => TAspectKind::Semisquare,
|
|
"sesquiquadrate" => TAspectKind::Sesquisquare,
|
|
"quintile" => TAspectKind::Quintile,
|
|
"biquintile" => TAspectKind::Biquintile,
|
|
_ => return p.minor_aspect,
|
|
};
|
|
p.aspect(k)
|
|
}
|
|
|
|
trait AstroPaletteExt {
|
|
fn fg_text_fallback(&self) -> Hsla;
|
|
}
|
|
|
|
impl AstroPaletteExt for AstroPalette {
|
|
fn fg_text_fallback(&self) -> Hsla {
|
|
if self.is_dark {
|
|
hsla(0.0, 0.0, 0.85, 1.0)
|
|
} else {
|
|
hsla(0.0, 0.0, 0.25, 1.0)
|
|
}
|
|
}
|
|
}
|