gioser

tawasuyu/brahman

Rust 307TOML 110JSON 14Markdown 113941 Sutichaykuna

⎇ a4d1e0dc17b6fba7522adc49e007557321778fa0 crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs Rust · 804 siq'ikuna · 27.3 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(tahuantinsuyu): fase 27 — Lots helenísticos + 9 fixed stars a4d1e0dc1 · sergio ·

1//! `tahuantinsuyu-modules` — registry de módulos astrológicos.
2//!
3//! Cada tipo de astrología (natal, tránsito, progresión, sinastría,
4//! Uraniano, …) es un **módulo** que declara:
5//!
6//! - Qué `Layer`s aporta al `RenderModel`.
7//! - Qué `Control`s expone al panel inferior (toggles, sliders, selects).
8//! - Hotkeys opcionales.
9//! - Si su cómputo es lazy (sólo cuando se activa) o eager.
10//!
11//! El registry es un `Vec<&dyn Module>` estático: el canvas consulta
12//! "para esta `ChartKind`, ¿qué módulos están disponibles?" y el panel
13//! pinta sus controles. Activar / desactivar persiste en
14//! `ModuleState` (en la store).
15//!
16//! Esta fase 1 trae el trait + un módulo `NatalModule` de placeholder.
17//! En fases posteriores agregamos Transit, Progression, Synastry,
18//! Composite, SolarArc, Uranian, FixedStars, Dignities, Lots…
19
20#![forbid(unsafe_code)]
21#![warn(rust_2018_idioms)]
22
23use serde::{Deserialize, Serialize};
24
25use tahuantinsuyu_engine::Layer;
26use tahuantinsuyu_model::{Chart, ChartKind};
27
28// =====================================================================
29// Trait Module
30// =====================================================================
31
32/// Una capa de astrología enchufable.
33///
34/// `Send + Sync` para que el registry sea estático y se pueda consultar
35/// desde cualquier thread (el cómputo pesado va a un background executor).
36pub trait Module: Send + Sync {
37    /// Identidad estable del módulo. Coincide con `ModuleState.module_id`
38    /// en la store.
39    fn id(&self) -> &'static str;
40
41    /// Etiqueta amigable para el panel.
42    fn label(&self) -> &'static str;
43
44    /// Breve descripción para tooltip.
45    fn description(&self) -> &'static str;
46
47    /// Para qué tipos de carta tiene sentido este módulo. El panel filtra
48    /// con esto al armar la lista de toggles disponibles.
49    fn applies_to(&self, kind: ChartKind) -> bool;
50
51    /// Si el módulo está activado por default al crear una carta.
52    fn enabled_by_default(&self) -> bool {
53        false
54    }
55
56    /// Controles que aporta al panel inferior.
57    fn controls(&self) -> Vec<Control> {
58        Vec::new()
59    }
60
61    /// Computa las capas que este módulo aporta al RenderModel de
62    /// `chart`. La engine la llama solo si el módulo está activado
63    /// para esa carta.
64    ///
65    /// Devuelve `Vec` (no Option) — un módulo puede no aportar capas
66    /// si su config interna lo apaga (ej. "Uranian: mostrar simetría
67    /// = false"); en ese caso retorna `Vec::new()`.
68    fn compute_layers(&self, chart: &Chart, config: &serde_json::Value) -> Vec<Layer>;
69}
70
71// =====================================================================
72// Controls expuestos al panel
73// =====================================================================
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub enum Control {
77    Toggle {
78        key: String,
79        label: String,
80        default: bool,
81        hotkey: Option<String>,
82    },
83    Slider {
84        key: String,
85        label: String,
86        min: f64,
87        max: f64,
88        step: f64,
89        default: f64,
90    },
91    Select {
92        key: String,
93        label: String,
94        options: Vec<SelectOption>,
95        default: String,
96    },
97    /// Texto libre — útil para etiquetas, comentarios.
98    TextInput {
99        key: String,
100        label: String,
101        default: String,
102    },
103    /// Picker dinámico de una carta de la DB. Las opciones las inyecta
104    /// el host (Shell) en el panel — el módulo solo declara la
105    /// existencia del control. Valor emitido en `ControlChanged` =
106    /// `Value::String(chart_id)` cuando se selecciona, `Value::Null`
107    /// cuando se vuelve a "automático".
108    ChartPicker {
109        key: String,
110        label: String,
111    },
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct SelectOption {
116    pub value: String,
117    pub label: String,
118}
119
120// =====================================================================
121// Registry
122// =====================================================================
123
124/// Lista estática de módulos disponibles. La app los registra al boot.
125pub struct Registry {
126    modules: Vec<Box<dyn Module>>,
127}
128
129impl Registry {
130    /// Registry con todos los módulos built-in. La app llama esto al
131    /// boot y luego usa `find()` / `for_kind()` para consultar.
132    pub fn with_builtins() -> Self {
133        let mut r = Self { modules: Vec::new() };
134        r.register(Box::new(natal::NatalModule));
135        r.register(Box::new(transit::TransitModule));
136        r.register(Box::new(progression::ProgressionModule));
137        r.register(Box::new(solar_arc::SolarArcModule));
138        r.register(Box::new(synastry::SynastryModule));
139        r.register(Box::new(planetary_return::PlanetaryReturnModule));
140        r.register(Box::new(midpoints::MidpointsModule));
141        r.register(Box::new(composite::CompositeModule));
142        r.register(Box::new(uranian::UranianModule));
143        r.register(Box::new(lots::LotsModule));
144        r.register(Box::new(fixed_stars::FixedStarsModule));
145        r
146    }
147
148    pub fn register(&mut self, m: Box<dyn Module>) {
149        self.modules.push(m);
150    }
151
152    pub fn all(&self) -> &[Box<dyn Module>] {
153        &self.modules
154    }
155
156    pub fn find(&self, id: &str) -> Option<&dyn Module> {
157        self.modules
158            .iter()
159            .find(|m| m.id() == id)
160            .map(|m| m.as_ref())
161    }
162
163    pub fn for_kind(&self, kind: ChartKind) -> Vec<&dyn Module> {
164        self.modules
165            .iter()
166            .filter(|m| m.applies_to(kind))
167            .map(|m| m.as_ref())
168            .collect()
169    }
170}
171
172// =====================================================================
173// NatalModule — placeholder fase 1
174// =====================================================================
175
176pub mod natal {
177    use super::*;
178    use tahuantinsuyu_engine::compute_mock;
179
180    pub struct NatalModule;
181
182    impl Module for NatalModule {
183        fn id(&self) -> &'static str {
184            "natal"
185        }
186        fn label(&self) -> &'static str {
187            "Carta natal"
188        }
189        fn description(&self) -> &'static str {
190            "Posiciones natales, casas y aspectos."
191        }
192        fn applies_to(&self, kind: ChartKind) -> bool {
193            matches!(kind, ChartKind::Natal)
194        }
195        fn enabled_by_default(&self) -> bool {
196            true
197        }
198
199        fn controls(&self) -> Vec<Control> {
200            vec![
201                Control::Toggle {
202                    key: "show_sign_dial".into(),
203                    label: "Dial zodiacal".into(),
204                    default: true,
205                    hotkey: Some("D".into()),
206                },
207                Control::Toggle {
208                    key: "show_houses".into(),
209                    label: "Casas".into(),
210                    default: true,
211                    hotkey: Some("H".into()),
212                },
213                Control::Toggle {
214                    key: "show_aspects".into(),
215                    label: "Aspectos".into(),
216                    default: true,
217                    hotkey: Some("X".into()),
218                },
219                Control::Toggle {
220                    key: "show_bodies".into(),
221                    label: "Cuerpos".into(),
222                    default: true,
223                    hotkey: Some("P".into()),
224                },
225                // Filtros de aspectos: cambian QUÉ se computa, no QUÉ
226                // se pinta del render. Recompose al togglear.
227                Control::Toggle {
228                    key: "aspect_majors".into(),
229                    label: "Mayores (☌ ☍ △ □ ⚹)".into(),
230                    default: true,
231                    hotkey: None,
232                },
233                Control::Toggle {
234                    key: "aspect_minors".into(),
235                    label: "Menores (quincunx, semi-…)".into(),
236                    default: false,
237                    hotkey: None,
238                },
239                Control::Slider {
240                    key: "orb_multiplier".into(),
241                    label: "Multiplicador de orbe".into(),
242                    min: 0.25,
243                    max: 2.5,
244                    step: 0.25,
245                    default: 1.0,
246                },
247                Control::Toggle {
248                    key: "show_dignities".into(),
249                    label: "Dignidades esenciales (+ · − *)".into(),
250                    default: false,
251                    hotkey: None,
252                },
253                Control::Slider {
254                    key: "harmonic".into(),
255                    label: "Armónico".into(),
256                    min: 1.0,
257                    max: 20.0,
258                    step: 1.0,
259                    default: 1.0,
260                },
261            ]
262        }
263
264        fn compute_layers(&self, chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
265            // Fase 1: delega al mock de la engine para que la UI tenga
266            // algo que pintar. Fase 3 reemplaza con `engine::compute`
267            // contra `eternal-astrology`.
268            compute_mock(chart).layers
269        }
270    }
271}
272
273// =====================================================================
274// TransitModule — overlay del cielo del momento sobre la carta natal
275// =====================================================================
276
277pub mod transit {
278    use super::*;
279
280    /// Anillo externo con las posiciones planetarias del **instante
281    /// actual** (reloj de pared) sobre el sujeto natal, más las
282    /// cross-aspects natal × transit. La engine despacha al pipeline
283    /// `PipelineRequest::Transit` cuando este módulo está activo en el
284    /// `module_configs` del shell.
285    pub struct TransitModule;
286
287    impl Module for TransitModule {
288        fn id(&self) -> &'static str {
289            "transit"
290        }
291        fn label(&self) -> &'static str {
292            "Tránsitos"
293        }
294        fn description(&self) -> &'static str {
295            "Cielo del momento sobre la natal + cross aspects."
296        }
297        fn applies_to(&self, kind: ChartKind) -> bool {
298            // Por ahora solo overlay sobre cartas natales — más adelante
299            // podríamos overlayar tránsitos sobre Progresiones, etc.
300            matches!(kind, ChartKind::Natal)
301        }
302        fn enabled_by_default(&self) -> bool {
303            false
304        }
305        fn controls(&self) -> Vec<Control> {
306            vec![Control::Toggle {
307                key: "enabled".into(),
308                label: "Activar".into(),
309                default: false,
310                hotkey: Some("T".into()),
311            }]
312        }
313        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
314            // Las capas de tránsito se construyen en la engine vía
315            // `PipelineRequest::Transit` porque necesitan acceso a la
316            // NatalChart cruda + EphemerisSession. Este método queda
317            // como no-op — el módulo es puramente declarativo.
318            Vec::new()
319        }
320    }
321}
322
323// =====================================================================
324// ProgressionModule — progresión secundaria (día por año)
325// =====================================================================
326
327pub mod progression {
328    use super::*;
329
330    /// Anillo interno con la carta progresada (método secundario,
331    /// "un día de efemérides = un año de vida") + cross aspects natal ×
332    /// progresada. La engine lo despacha vía
333    /// `PipelineRequest::SecondaryProgression { target_age_years }`.
334    pub struct ProgressionModule;
335
336    impl Module for ProgressionModule {
337        fn id(&self) -> &'static str {
338            "progression"
339        }
340        fn label(&self) -> &'static str {
341            "Progresión secundaria"
342        }
343        fn description(&self) -> &'static str {
344            "Día-por-año: avanza la carta a la edad actual."
345        }
346        fn applies_to(&self, kind: ChartKind) -> bool {
347            matches!(kind, ChartKind::Natal)
348        }
349        fn enabled_by_default(&self) -> bool {
350            false
351        }
352        fn controls(&self) -> Vec<Control> {
353            vec![
354                Control::Toggle {
355                    key: "enabled".into(),
356                    label: "Activar".into(),
357                    default: false,
358                    hotkey: None,
359                },
360                // El default (30.0) es un placeholder — el shell empuja
361                // la edad actual del sujeto al cargar una carta vía
362                // panel.set_slider("progression", "target_age_years",
363                // current_age).
364                Control::Slider {
365                    key: "target_age_years".into(),
366                    label: "Edad objetivo (años)".into(),
367                    min: 0.0,
368                    max: 120.0,
369                    step: 0.25,
370                    default: 30.0,
371                },
372            ]
373        }
374        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
375            Vec::new()
376        }
377    }
378}
379
380// =====================================================================
381// SynastryModule — bi-wheel con otra carta hermana del contacto actual
382// =====================================================================
383
384pub mod synastry {
385    use super::*;
386
387    /// Pone la carta del partner en el anillo externo (compartido con
388    /// Transit — mutuamente excluyentes) y dibuja las cross aspects
389    /// natal × partner. El shell elige el partner: la primera carta
390    /// hermana del mismo contacto. Si no hay hermana, el request se
391    /// salta silenciosamente.
392    pub struct SynastryModule;
393
394    impl Module for SynastryModule {
395        fn id(&self) -> &'static str {
396            "synastry"
397        }
398        fn label(&self) -> &'static str {
399            "Sinastría"
400        }
401        fn description(&self) -> &'static str {
402            "Bi-wheel con la primera carta hermana del contacto."
403        }
404        fn applies_to(&self, kind: ChartKind) -> bool {
405            matches!(kind, ChartKind::Natal)
406        }
407        fn enabled_by_default(&self) -> bool {
408            false
409        }
410        fn controls(&self) -> Vec<Control> {
411            vec![
412                Control::Toggle {
413                    key: "enabled".into(),
414                    label: "Activar".into(),
415                    default: false,
416                    hotkey: None,
417                },
418                Control::ChartPicker {
419                    key: "partner_chart_id".into(),
420                    label: "Partner".into(),
421                },
422            ]
423        }
424        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
425            Vec::new()
426        }
427    }
428}
429
430// =====================================================================
431// PlanetaryReturnModule — retornos de cualquier cuerpo a su pos natal
432// =====================================================================
433
434pub mod planetary_return {
435    use super::*;
436
437    /// Computa la carta natal completa al instante del próximo retorno
438    /// del cuerpo elegido. Sun = anual (cumpleaños), Moon = mensual,
439    /// Júpiter/Saturno = generacionales. Comparte el outer ring con
440    /// Transit y Synastry — mutuamente excluyentes a nivel de Shell.
441    pub struct PlanetaryReturnModule;
442
443    impl Module for PlanetaryReturnModule {
444        fn id(&self) -> &'static str {
445            "planetary_return"
446        }
447        fn label(&self) -> &'static str {
448            "Retornos planetarios"
449        }
450        fn description(&self) -> &'static str {
451            "Carta del próximo retorno (Sol, Luna, Júpiter, Saturno…)."
452        }
453        fn applies_to(&self, kind: ChartKind) -> bool {
454            matches!(kind, ChartKind::Natal)
455        }
456        fn enabled_by_default(&self) -> bool {
457            false
458        }
459        fn controls(&self) -> Vec<Control> {
460            vec![
461                Control::Toggle {
462                    key: "enabled".into(),
463                    label: "Activar".into(),
464                    default: false,
465                    hotkey: None,
466                },
467                Control::Select {
468                    key: "body".into(),
469                    label: "Cuerpo".into(),
470                    default: "sun".into(),
471                    options: vec![
472                        SelectOption { value: "sun".into(), label: "Sol".into() },
473                        SelectOption { value: "moon".into(), label: "Luna".into() },
474                        SelectOption { value: "mercury".into(), label: "Mercurio".into() },
475                        SelectOption { value: "venus".into(), label: "Venus".into() },
476                        SelectOption { value: "mars".into(), label: "Marte".into() },
477                        SelectOption { value: "jupiter".into(), label: "Júpiter".into() },
478                        SelectOption { value: "saturn".into(), label: "Saturno".into() },
479                        SelectOption { value: "uranus".into(), label: "Urano".into() },
480                        SelectOption { value: "neptune".into(), label: "Neptuno".into() },
481                        SelectOption { value: "pluto".into(), label: "Plutón".into() },
482                    ],
483                },
484                Control::Slider {
485                    key: "target_age_years".into(),
486                    label: "Edad del retorno".into(),
487                    min: 0.0,
488                    max: 120.0,
489                    step: 1.0,
490                    default: 30.0,
491                },
492                // Offset adicional para Moon return (saltar ~28d entre
493                // retornos lunares) o ajuste fino del Solar return.
494                Control::Slider {
495                    key: "shift_days".into(),
496                    label: "Shift días (lunar nav)".into(),
497                    min: -180.0,
498                    max: 180.0,
499                    step: 1.0,
500                    default: 0.0,
501                },
502            ]
503        }
504        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
505            Vec::new()
506        }
507    }
508}
509
510// =====================================================================
511// CompositeModule — carta compuesta (midpoint Davison) con un partner
512// =====================================================================
513
514pub mod composite {
515    use super::*;
516
517    /// Carta compuesta entre la natal y otra carta — cada placement es
518    /// el midpoint angular del par. Mismo ChartPicker que sinastría
519    /// para elegir el partner.
520    pub struct CompositeModule;
521
522    impl Module for CompositeModule {
523        fn id(&self) -> &'static str {
524            "composite"
525        }
526        fn label(&self) -> &'static str {
527            "Composite"
528        }
529        fn description(&self) -> &'static str {
530            "Carta compuesta con otro sujeto (midpoint Davison)."
531        }
532        fn applies_to(&self, kind: ChartKind) -> bool {
533            matches!(kind, ChartKind::Natal)
534        }
535        fn enabled_by_default(&self) -> bool {
536            false
537        }
538        fn controls(&self) -> Vec<Control> {
539            vec![
540                Control::Toggle {
541                    key: "enabled".into(),
542                    label: "Activar".into(),
543                    default: false,
544                    hotkey: None,
545                },
546                Control::ChartPicker {
547                    key: "partner_chart_id".into(),
548                    label: "Partner".into(),
549                },
550            ]
551        }
552        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
553            Vec::new()
554        }
555    }
556}
557
558// =====================================================================
559// SolarArcModule — Solar Arc dirigido (true progressed Sun)
560// =====================================================================
561
562pub mod solar_arc {
563    use super::*;
564
565    /// Cada planeta y cusp natal se desplaza por el mismo arco
566    /// (≈ 1° por año de vida, calculado como el delta del Sol
567    /// progresado secundario). Anillo interno bien adentro + cross
568    /// aspects natal × dirigida.
569    pub struct SolarArcModule;
570
571    impl Module for SolarArcModule {
572        fn id(&self) -> &'static str {
573            "solar_arc"
574        }
575        fn label(&self) -> &'static str {
576            "Solar Arc"
577        }
578        fn description(&self) -> &'static str {
579            "Dirección por arco solar — uniforme, ≈1°/año."
580        }
581        fn applies_to(&self, kind: ChartKind) -> bool {
582            matches!(kind, ChartKind::Natal)
583        }
584        fn enabled_by_default(&self) -> bool {
585            false
586        }
587        fn controls(&self) -> Vec<Control> {
588            vec![
589                Control::Toggle {
590                    key: "enabled".into(),
591                    label: "Activar".into(),
592                    default: false,
593                    hotkey: None,
594                },
595                Control::Slider {
596                    key: "target_age_years".into(),
597                    label: "Edad objetivo (años)".into(),
598                    min: 0.0,
599                    max: 120.0,
600                    step: 0.25,
601                    default: 30.0,
602                },
603            ]
604        }
605        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
606            Vec::new()
607        }
608    }
609}
610
611// =====================================================================
612// MidpointsModule — puntos medios entre cuerpos natales (Sol/Luna)
613// =====================================================================
614
615pub mod midpoints {
616    use super::*;
617
618    /// Computa midpoints entre los cuerpos natales (filtrado a los que
619    /// involucran Sol o Luna, ~10 puntos) y los renderea como pequeños
620    /// puntos en un anillo interior. Hovering muestra los dos cuerpos
621    /// que originan el midpoint.
622    pub struct MidpointsModule;
623
624    impl Module for MidpointsModule {
625        fn id(&self) -> &'static str {
626            "midpoints"
627        }
628        fn label(&self) -> &'static str {
629            "Midpoints"
630        }
631        fn description(&self) -> &'static str {
632            "Puntos medios que involucran al Sol o a la Luna."
633        }
634        fn applies_to(&self, kind: ChartKind) -> bool {
635            matches!(kind, ChartKind::Natal)
636        }
637        fn enabled_by_default(&self) -> bool {
638            false
639        }
640        fn controls(&self) -> Vec<Control> {
641            vec![Control::Toggle {
642                key: "enabled".into(),
643                label: "Activar".into(),
644                default: false,
645                hotkey: None,
646            }]
647        }
648        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
649            Vec::new()
650        }
651    }
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657
658    #[test]
659    fn registry_finds_builtins() {
660        let r = Registry::with_builtins();
661        assert!(r.find("natal").is_some());
662        assert!(r.find("transit").is_some());
663        assert!(r.find("progression").is_some());
664        assert!(r.find("solar_arc").is_some());
665        assert!(r.find("synastry").is_some());
666        assert!(r.find("planetary_return").is_some());
667        assert!(r.find("midpoints").is_some());
668        assert!(r.find("composite").is_some());
669        assert!(r.find("uranian").is_some());
670        assert!(r.find("lots").is_some());
671        assert!(r.find("fixed_stars").is_some());
672        // Natal kind tiene 11 módulos aplicables.
673        assert_eq!(r.for_kind(ChartKind::Natal).len(), 11);
674        assert!(r.for_kind(ChartKind::Synastry).is_empty());
675    }
676}
677
678// =====================================================================
679// LotsModule — Lots helenísticos (Fortune, Spirit, Eros, …)
680// =====================================================================
681
682pub mod lots {
683    use super::*;
684
685    /// Calcula los 7 Lots arábigos clásicos via eternal-astrology y
686    /// los renderea como pequeños labels en un ring justo debajo de
687    /// los cuerpos natales. Hover muestra el nombre completo.
688    pub struct LotsModule;
689
690    impl Module for LotsModule {
691        fn id(&self) -> &'static str {
692            "lots"
693        }
694        fn label(&self) -> &'static str {
695            "Lots (helenísticos)"
696        }
697        fn description(&self) -> &'static str {
698            "Fortune, Spirit, Eros, Necessity, Courage, Victory, Nemesis."
699        }
700        fn applies_to(&self, kind: ChartKind) -> bool {
701            matches!(kind, ChartKind::Natal)
702        }
703        fn enabled_by_default(&self) -> bool {
704            false
705        }
706        fn controls(&self) -> Vec<Control> {
707            vec![Control::Toggle {
708                key: "enabled".into(),
709                label: "Activar".into(),
710                default: false,
711                hotkey: None,
712            }]
713        }
714        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
715            Vec::new()
716        }
717    }
718}
719
720// =====================================================================
721// FixedStarsModule — 9 estrellas astrológicamente notables
722// =====================================================================
723
724pub mod fixed_stars {
725    use super::*;
726
727    /// 9 estrellas fijas (Aldebaran, Regulus, Antares, Fomalhaut,
728    /// Spica, Sirius, Algol, Vega, Pollux) con posición tropical
729    /// aproximada (J2000 + precesión simple). Marcadores chicos en el
730    /// margen exterior del sign dial.
731    pub struct FixedStarsModule;
732
733    impl Module for FixedStarsModule {
734        fn id(&self) -> &'static str {
735            "fixed_stars"
736        }
737        fn label(&self) -> &'static str {
738            "Estrellas fijas"
739        }
740        fn description(&self) -> &'static str {
741            "9 estrellas notables — conjunciones con planetas natales."
742        }
743        fn applies_to(&self, kind: ChartKind) -> bool {
744            matches!(kind, ChartKind::Natal)
745        }
746        fn enabled_by_default(&self) -> bool {
747            false
748        }
749        fn controls(&self) -> Vec<Control> {
750            vec![Control::Toggle {
751                key: "enabled".into(),
752                label: "Activar".into(),
753                default: false,
754                hotkey: None,
755            }]
756        }
757        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
758            Vec::new()
759        }
760    }
761}
762
763// =====================================================================
764// UranianModule — ejes del dial uraniano de 90° (versión textual)
765// =====================================================================
766
767pub mod uranian {
768    use super::*;
769
770    /// Detecta "ejes" del dial uraniano: grupos de cuerpos natales cuya
771    /// longitud módulo 90 cae dentro de una tolerancia. Los grupos
772    /// resultantes se listan en el footer del canvas. La visualización
773    /// geométrica del dial completo de 90° queda para una fase futura.
774    pub struct UranianModule;
775
776    impl Module for UranianModule {
777        fn id(&self) -> &'static str {
778            "uranian"
779        }
780        fn label(&self) -> &'static str {
781            "Uraniano (90°)"
782        }
783        fn description(&self) -> &'static str {
784            "Ejes del dial uraniano — cuerpos en la misma posición mod 90."
785        }
786        fn applies_to(&self, kind: ChartKind) -> bool {
787            matches!(kind, ChartKind::Natal)
788        }
789        fn enabled_by_default(&self) -> bool {
790            false
791        }
792        fn controls(&self) -> Vec<Control> {
793            vec![Control::Toggle {
794                key: "enabled".into(),
795                label: "Activar".into(),
796                default: false,
797                hotkey: None,
798            }]
799        }
800        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
801            Vec::new()
802        }
803    }
804}